diff options
| -rw-r--r-- | lib/basic-contract/service.ts | 7368 | ||||
| -rw-r--r-- | lib/compliance/questions/compliance-question-edit-sheet.tsx | 232 | ||||
| -rw-r--r-- | lib/compliance/services.ts | 189 | ||||
| -rw-r--r-- | lib/compliance/table/compliance-survey-templates-toolbar.tsx | 7 | ||||
| -rw-r--r-- | lib/compliance/table/compliance-template-create-dialog.tsx | 103 | ||||
| -rw-r--r-- | lib/compliance/table/compliance-template-edit-sheet.tsx | 16 |
6 files changed, 4133 insertions, 3782 deletions
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index d7b3edc8..eb3d49f5 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -1,3685 +1,3685 @@ -"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,like } from "drizzle-orm";
-import { v4 as uuidv4 } from "uuid";
-import {
- basicContract,
- BasicContractTemplate,
- basicContractTemplates,
- basicContractView,
- complianceQuestionOptions,
- complianceQuestions,
- complianceResponseAnswers,
- complianceResponseFiles,
- complianceResponses,
- complianceSurveyTemplates,
- vendorAttachments, basicContractTemplateStatsView,
- type BasicContractTemplate as DBBasicContractTemplate,
- type NewComplianceResponse,
- type NewComplianceResponseAnswer,
- type NewComplianceResponseFile,
- gtcVendorDocuments,
- gtcVendorClauses,
- gtcClauses,
- gtcDocuments,
- vendors,
- vendorContacts,
- gtcNegotiationHistory,
- type GtcVendorClause,
- type GtcClause,
- projects,
- legalWorks,
- BasicContractView, users
-} from "@/db/schema";
-import path from "path";
-
-import {
- GetBasicContractTemplatesSchema,
- CreateBasicContractTemplateSchema,
- GetBasciContractsSchema,
- GetBasciContractsVendorSchema,
- GetBasciContractsByIdSchema,
- updateStatusSchema,
-} from "./validations";
-import { readFile } from "fs/promises"
-
-import {
- insertBasicContractTemplate,
- selectBasicContractTemplates,
- countBasicContractTemplates,
- deleteBasicContractTemplates,
- getBasicContractTemplateById,
- selectBasicContracts,
- countBasicContracts,
- findAllTemplates,
- countBasicContractsById,
- selectBasicContractsById,
- selectBasicContractsVendor,
- countBasicContractsVendor
-} 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";
-import { deleteFile, saveBuffer, saveFile, saveDRMFile } from "@/lib/file-stroage";
-import { decryptWithServerAction } from "@/components/drm/drmUtils";
-import { getServerSession } from "next-auth/next"
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-
-
-// 템플릿 추가
-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 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: "파일은 필수입니다." };
- }
-
- const saveResult = await saveFile({file, directory:"basicContract/template" });
-
- if (!saveResult.success) {
- return { success: false, error: saveResult.error };
- }
-
- // DB에 저장할 데이터 구성
- const formattedData = {
- templateName,
- status,
- fileName: file.name,
- filePath: saveResult.publicPath!
- };
-
- // DB에 저장
- const { data, error } = await createBasicContractTemplate(formattedData);
-
- if (error) {
- // DB 저장 실패 시 파일 삭제
- await deleteFile(saveResult.publicPath!);
- 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) => {
- const advancedWhere = filterColumns({
- table: basicContractTemplates,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
-
- let globalWhere = undefined;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- ilike(basicContractTemplates.templateName, s),
- ilike(basicContractTemplates.fileName, s),
- ilike(basicContractTemplates.status, s)
- );
- }
-
- const whereCondition = and(advancedWhere, globalWhere);
-
- 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 [row] = await insertBasicContractTemplate(tx, {
- templateName: input.templateName,
- revision: input.revision || 1,
- status: input.status || "ACTIVE",
-
- // 📝 null 처리 추가
- fileName: input.fileName || null,
- filePath: input.filePath || null,
- });
- return row;
- });
-
- return { data: newTemplate, error: null };
- } catch (error) {
- console.log(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}`;
-
- // ArrayBuffer를 File 객체로 변환
- const file = new File([fileBuffer], originalName);
-
- // ✅ 서명된 계약서 저장
- // 개발: /project/public/basicContract/signed/
- // 프로덕션: /nas_evcp/basicContract/signed/
- const saveResult = await saveFile({file,directory: "basicContract/signed" ,originalName:originalName});
-
- if (!saveResult.success) {
- return { result: false, error: saveResult.error! };
- }
-
- console.log(`✅ 서명된 계약서 저장됨: ${saveResult.filePath}`);
-
- await db.transaction(async (tx) => {
- await tx
- .update(basicContract)
- .set({
- status: "COMPLETED",
- fileName: originalName,
- filePath: saveResult.publicPath, // 웹 접근 경로 저장
- })
- .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) {
- const deleted = await deleteFile(template.filePath);
-
- if (deleted) {
- console.log(`✅ 파일 삭제됨: ${template.filePath}`);
- } else {
- console.log(`⚠️ 파일 삭제 실패: ${template.filePath}`);
- }
- }
-
-
- 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;
-}
-
-const SCOPE_KEYS = [
- "shipBuildingApplicable",
- "windApplicable",
- "pcApplicable",
- "nbApplicable",
- "rcApplicable",
- "gyApplicable",
- "sysApplicable",
- "infraApplicable",
-] as const;
-
-function getBool(fd: FormData, key: string, defaultValue = false) {
- const v = fd.get(key);
- if (v === null) return defaultValue;
- return v === "true";
-}
-
-export async function updateTemplate({
- id,
- formData
-}: UpdateTemplateParams): Promise<{ success?: boolean; error?: string }> {
- unstable_noStore();
-
- try {
- // 기존 템플릿 조회 (revision 유지 및 중복 체크를 위해)
- const existingTemplate = await db.query.basicContractTemplates.findFirst({
- where: eq(basicContractTemplates.id, id),
- });
-
- if (!existingTemplate) {
- return { error: "템플릿을 찾을 수 없습니다." };
- }
-
- // 필수값
- const templateName = formData.get("templateName") as string | null;
- if (!templateName) {
- return { error: "템플릿 이름은 필수입니다." };
- }
-
- // revision 처리: FormData에 있으면 사용, 없으면 기존 값 유지
- const revisionStr = formData.get("revision")?.toString();
- const revision = revisionStr ? Number(revisionStr) : existingTemplate.revision;
-
- // templateName과 revision 조합이 unique이므로, 다른 레코드와 중복되는지 확인
- if (templateName !== existingTemplate.templateName || revision !== existingTemplate.revision) {
- const duplicateCheck = await db.query.basicContractTemplates.findFirst({
- where: and(
- eq(basicContractTemplates.templateName, templateName),
- eq(basicContractTemplates.revision, revision),
- ne(basicContractTemplates.id, id) // 자기 자신은 제외
- ),
- });
-
- if (duplicateCheck) {
- return {
- error: `템플릿 이름 "${templateName}"과 리비전 ${revision} 조합이 이미 존재합니다. 다른 리비전을 사용하거나 템플릿 이름을 변경해주세요.`
- };
- }
- }
-
- const legalReviewRequired = getBool(formData, "legalReviewRequired", false);
-
- // status는 프런트에서 ACTIVE만 넣고 있으나, 없으면 기존값 유지 or 기본값 설정
- const status = (formData.get("status") as "ACTIVE" | "INACTIVE" | null) ?? "ACTIVE";
-
- // validityPeriod가 이제 필요없다면 제거하시고, 사용한다면 파싱 그대로
- const validityPeriodStr = formData.get("validityPeriod")?.toString();
- const validityPeriod = validityPeriodStr ? Number(validityPeriodStr) : undefined;
-
- // Scope booleans
- const scopeData: Record<string, boolean> = {};
- for (const key of SCOPE_KEYS) {
- scopeData[key] = getBool(formData, key, false);
- }
-
- // 파일 처리
- const file = formData.get("file") as File | null;
- let fileName: string | undefined = undefined;
- let filePath: string | undefined = undefined;
-
- if (file) {
- // 1) 새 파일 저장 (DRM 해제 로직 적용)
- const saveResult = await saveDRMFile(
- file,
- decryptWithServerAction,
- 'basicContract/template'
- );
-
- if (!saveResult.success) {
- return { success: false, error: saveResult.error };
- }
- fileName = file.name;
- filePath = saveResult.publicPath;
-
- // 2) 기존 파일 삭제 (existingTemplate은 이미 위에서 조회됨)
- if (existingTemplate?.filePath) {
- const deleted = await deleteFile(existingTemplate.filePath);
- if (deleted) {
- console.log(`✅ 기존 파일 삭제됨: ${existingTemplate.filePath}`);
- } else {
- console.log(`⚠️ 기존 파일 삭제 실패: ${existingTemplate.filePath}`);
- }
- }
- }
-
- // 업데이트할 데이터 구성
- const updateData: Record<string, any> = {
- templateName,
- revision,
- legalReviewRequired,
- status,
- updatedAt: new Date(),
- ...scopeData,
- };
-
- if (validityPeriod !== undefined) {
- updateData.validityPeriod = validityPeriod;
- }
- if (fileName && filePath) {
- updateData.fileName = fileName;
- updateData.filePath = filePath;
- }
-
- // 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 {
- const isComplianceTemplate = template.templateName?.includes('준법');
- let selectedTemplateId = template.id;
- let selectedTemplate = template;
-
- if (isComplianceTemplate) {
- const vendorUser = await db.query.users.findFirst({
- where: and(
- eq(users.email, vendor.email),
- eq(users.domain, 'partners')
- )
- });
-
- const userLanguage = vendorUser?.language || 'en'; // 기본값은 영어
-
- if (userLanguage === 'ko') {
- // 한글 준법서약 템플릿 찾기
- const koreanTemplate = await db.query.basicContractTemplates.findFirst({
- where: and(
- sql`${basicContractTemplates.templateName} LIKE '%준법%'`,
- sql`${basicContractTemplates.templateName} NOT LIKE '%영문%'`,
- eq(basicContractTemplates.status, 'ACTIVE')
- )
- });
-
- if (koreanTemplate) {
- selectedTemplateId = koreanTemplate.id;
- selectedTemplate = koreanTemplate;
- }
- } else {
- // 영문 준법서약 템플릿 찾기
- const englishTemplate = await db.query.basicContractTemplates.findFirst({
- where: and(
- sql`${basicContractTemplates.templateName} LIKE '%준법%'`,
- sql`${basicContractTemplates.templateName} LIKE '%영문%'`,
- eq(basicContractTemplates.status, 'ACTIVE')
- )
- });
-
- if (englishTemplate) {
- selectedTemplateId = englishTemplate.id;
- selectedTemplate = englishTemplate;
- console.log(`✅ 영어 사용자 ${vendor.vendorName}에게 영문 준법서약 템플릿 전송`);
- }
- }
- }
-
- // 3-1. basic_contract 테이블에 레코드 추가
- const [newContract] = await db
- .insert(basicContract)
- .values({
- templateId: selectedTemplateId, // 언어별로 선택된 템플릿 ID 사용
- vendorId: vendor.id,
- requestedBy: requestedBy,
- status: "PENDING",
- fileName: selectedTemplate.fileName, // 선택된 템플릿 파일 이름 사용
- filePath: selectedTemplate.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: basicContractTemplateStatsView,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
-
-
- let globalWhere
- if (input.search) {
- const s = `%${input.search}%`
- globalWhere = or(ilike(basicContractTemplateStatsView.templateName, 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(basicContractTemplateStatsView[item.id]) : asc(basicContractTemplateStatsView[item.id])
- )
- : [asc(basicContractTemplateStatsView.lastActivityDate)];
-
- // 트랜잭션 내부에서 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) {
- console.log(err)
- // 에러 발생 시 디폴트
- return { data: [], pageCount: 0 };
- }
- },
- [JSON.stringify(input)], // 캐싱 키
- {
- revalidate: 3600,
- tags: ["basicContractTemplateStatsView"], // revalidateTag("basicContractTemplateStatsView") 호출 시 무효화
- }
- )();
-}
-
-
-export async function getBasicContractsByVendorId(
- input: GetBasciContractsVendorSchema,
- 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 selectBasicContractsVendor(tx, {
- where,
- orderBy,
- offset,
- limit: input.perPage,
- });
-
- const total = await countBasicContractsVendor(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 getBasicContractsByTemplateId(
- input: GetBasciContractsByIdSchema,
- templateId: number
-) {
- // return unstable_cache(
- // async () => {
- try {
-
- console.log(input.sort)
- 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 templateCondition = eq(basicContractView.templateId, templateId);
-
- const finalWhere = and(
- // 항상 벤더 ID 조건을 포함
- templateCondition,
- // 기존 조건들
- 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 selectBasicContractsById(tx, {
- where,
- orderBy,
- offset,
- limit: input.perPage,
- });
-
- const total = await countBasicContractsById(tx, where);
- return { data, total };
- });
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data, pageCount };
- } catch (err) {
- // 에러 발생 시 디폴트\
- console.log(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 : "계약 상태 확인 중 오류가 발생했습니다."
- };
- }
-}
-
-
-/**
- * ID로 기본계약서 템플릿 조회
- */
-export async function getBasicContractTemplateByIdService(id: string) {
- try {
- const templateId = parseInt(id);
-
- if (isNaN(templateId)) {
- return null;
- }
-
- const templates = await db
- .select()
- .from(basicContractTemplates)
- .where(eq(basicContractTemplates.id, templateId))
- .limit(1);
-
- if (templates.length === 0) {
- return null;
- }
-
- return templates[0];
- } catch (error) {
- console.error("템플릿 조회 오류:", error);
- return null;
- }
-}
-
-/**
- * 템플릿 파일 저장 서버 액션
- */
-export async function saveTemplateFile(templateId: number, formData: FormData) {
- try {
- const file = formData.get("file") as File;
-
- if (!file) {
- return { error: "파일이 필요합니다." };
- }
-
- // 기존 템플릿 정보 조회
- const existingTemplate = await db
- .select()
- .from(basicContractTemplates)
- .where(eq(basicContractTemplates.id, templateId))
- .limit(1);
-
- if (existingTemplate.length === 0) {
- return { error: "템플릿을 찾을 수 없습니다." };
- }
-
- const template = existingTemplate[0];
-
- // 파일 저장 로직 (실제 파일 시스템에 저장)
- const { writeFile, mkdir } = await import("fs/promises");
- const { join } = await import("path");
-
- const bytes = await file.arrayBuffer();
- const buffer = Buffer.from(bytes);
-
- // 기존 파일 경로 사용 (덮어쓰기)
- const uploadPath = join(process.cwd(), "public", template.filePath.replace(/^\//, ""));
-
- // 디렉토리 확인 및 생성
- const dirPath = uploadPath.substring(0, uploadPath.lastIndexOf('/'));
- await mkdir(dirPath, { recursive: true });
-
- // 파일 저장
- await writeFile(uploadPath, buffer);
-
- // 데이터베이스 업데이트 (수정일시만 업데이트)
- await db
- .update(basicContractTemplates)
- .set({
- updatedAt: new Date(),
- })
- .where(eq(basicContractTemplates.id, templateId));
-
- // 캐시 무효화
- revalidatePath(`/evcp/basic-contract-template/${templateId}`);
- revalidateTag("basic-contract-templates");
-
- return { success: true, message: "템플릿이 성공적으로 저장되었습니다." };
-
- } catch (error) {
- console.error("템플릿 저장 오류:", error);
- return { error: "저장 중 오류가 발생했습니다." };
- }
-}
-
-/**
- * 템플릿 페이지 새로고침 서버 액션
- */
-export async function refreshTemplatePage(templateId: string) {
- revalidatePath(`/evcp/basic-contract-template/${templateId}`);
- revalidateTag("basic-contract-templates");
-}
-
-// 새 리비전 생성 함수
-export async function createBasicContractTemplateRevision(input: any) {
- unstable_noStore();
-
- try {
- // 기본 템플릿 존재 확인
- const baseTemplate = await db
- .select()
- .from(basicContractTemplates)
- .where(eq(basicContractTemplates.id, input.baseTemplateId))
- .limit(1);
-
- if (baseTemplate.length === 0) {
- return { data: null, error: "기본 템플릿을 찾을 수 없습니다." };
- }
-
- // 같은 템플릿 이름에 해당 리비전이 이미 존재하는지 확인
- const existingRevision = await db
- .select()
- .from(basicContractTemplates)
- .where(
- and(
- eq(basicContractTemplates.templateName, input.templateName),
- eq(basicContractTemplates.revision, input.revision)
- )
- )
- .limit(1);
-
- if (existingRevision.length > 0) {
- return {
- data: null,
- error: `${input.templateName} v${input.revision} 리비전이 이미 존재합니다.`
- };
- }
-
- // 새 리비전이 기존 리비전들보다 큰 번호인지 확인
- const maxRevision = await db
- .select({ maxRev: basicContractTemplates.revision })
- .from(basicContractTemplates)
- .where(eq(basicContractTemplates.templateName, input.templateName))
- .orderBy(desc(basicContractTemplates.revision))
- .limit(1);
-
- if (maxRevision.length > 0 && input.revision <= maxRevision[0].maxRev) {
- return {
- data: null,
- error: `새 리비전 번호는 현재 최대 리비전(v${maxRevision[0].maxRev})보다 커야 합니다.`
- };
- }
-
- const newRevision = await db.transaction(async (tx) => {
- const [row] = await insertBasicContractTemplate(tx, {
- templateName: input.templateName,
- revision: input.revision,
- legalReviewRequired: input.legalReviewRequired,
- status: "ACTIVE",
- fileName: input.fileName,
- filePath: input.filePath,
- validityPeriod: null,
- });
- return row;
- });
- //기존 템플릿의 이전 리비전은 비활성으로 변경
- await db.update(basicContractTemplates).set({
- status: "DISPOSED",
- }).where(and(eq(basicContractTemplates.templateName, input.templateName),ne(basicContractTemplates.revision, input.revision)));
- //캐시 무효화
- revalidateTag("basic-contract-templates");
-
- return { data: newRevision, error: null };
- } catch (error) {
- return { data: null, error: getErrorMessage(error) };
- }
-}
-
-
-
-
-// 1) 전체 basicContractTemplates 조회
-export async function getALLBasicContractTemplates() {
- return db
- .select()
- .from(basicContractTemplates)
- .where(eq(basicContractTemplates.status,"ACTIVE"))
- .orderBy(desc(basicContractTemplates.createdAt));
-}
-
-// 2) 등록된 templateName만 중복 없이 가져오기
-export async function getExistingTemplateNames(): Promise<string[]> {
- try {
- const templates = await db
- .select({
- templateName: basicContractTemplates.templateName
- })
- .from(basicContractTemplates)
- .where(
- and(
- eq(basicContractTemplates.status, 'ACTIVE'),
- // GTC가 아닌 것들만 중복 체크 (GTC는 프로젝트별로 여러 개 허용)
- not(like(basicContractTemplates.templateName, '% GTC'))
- )
- );
-
- return templates.map(t => t.templateName);
- } catch (error) {
- console.error('Failed to fetch existing template names:', error);
- throw new Error('기존 템플릿 이름을 가져오는데 실패했습니다.');
- }
-}
-
-export async function getExistingTemplateNamesById(id:number): Promise<string> {
- const rows = await db
- .select({
- templateName: basicContractTemplates.templateName,
- })
- .from(basicContractTemplates)
- .where(and(eq(basicContractTemplates.status,"ACTIVE"),eq(basicContractTemplates.id,id)))
- .limit(1)
-
- return rows[0].templateName;
-}
-
-export async function getVendorAttachments(vendorId: number) {
- try {
- const attachments = await db
- .select()
- .from(vendorAttachments)
- .where(
- and(
- eq(vendorAttachments.vendorId, vendorId),
- eq(vendorAttachments.attachmentType, "NDA_ATTACHMENT")
- )
- );
-
- console.log(attachments,"attachments")
-
- return {
- success: true,
- data: attachments
- };
- } catch (error) {
- console.error("Error fetching vendor attachments:", error);
- return {
- success: false,
- data: [],
- error: "Failed to fetch vendor attachments"
- };
- }
-}
-
-// 설문조사 템플릿 전체 데이터 타입
-export interface SurveyTemplateWithQuestions {
- id: number;
- name: string;
- description: string | null;
- version: string;
- questions: SurveyQuestion[];
-}
-
-export interface SurveyQuestion {
- id: number;
- questionNumber: string;
- questionText: string;
- questionType: string;
- isRequired: boolean;
- hasDetailText: boolean;
- hasFileUpload: boolean;
- parentQuestionId: number | null;
- conditionalValue: string | null;
- displayOrder: number;
- options: SurveyQuestionOption[];
-}
-
-export interface SurveyQuestionOption {
- id: number;
- optionValue: string;
- optionText: string;
- allowsOtherInput: boolean;
- displayOrder: number;
-}
-
-/**
- * 활성화된 첫 번째 설문조사 템플릿과 관련 데이터를 모두 가져오기
- */
-export async function getActiveSurveyTemplate(language: string = 'ko'): Promise<SurveyTemplateWithQuestions | null> {
- try {
- // 1. 활성화된 템플릿 가져오기 (언어에 따라 필터링)
- const templates = await db
- .select()
- .from(complianceSurveyTemplates)
- .where(eq(complianceSurveyTemplates.isActive, true))
- .orderBy(complianceSurveyTemplates.id);
-
- if (!templates || templates.length === 0) {
- console.log('활성화된 설문조사 템플릿이 없습니다.');
- return null;
- }
-
- // 언어에 따라 적절한 템플릿 선택
- let templateData;
- if (language === 'en') {
- // 영문 템플릿 찾기 (이름에 '영문' 또는 'English' 포함)
- templateData = templates.find(t =>
- t.name.includes('영문') ||
- t.name.toLowerCase().includes('english') ||
- t.name.toLowerCase().includes('en')
- );
- } else {
- // 한글 템플릿 찾기 (영문이 아닌 것)
- templateData = templates.find(t =>
- !t.name.includes('영문') &&
- !t.name.toLowerCase().includes('english') &&
- !t.name.toLowerCase().includes('en')
- );
- }
-
- // 적절한 템플릿을 찾지 못하면 첫 번째 템플릿 사용
- if (!templateData) {
- console.log(`언어 '${language}'에 맞는 템플릿을 찾지 못해 기본 템플릿을 사용합니다.`);
- templateData = templates[0];
- }
-
- console.log(`✅ 선택된 설문조사 템플릿: ${templateData.name} (언어: ${language})`);
-
- // 2. 해당 템플릿의 모든 질문 가져오기 (displayOrder 순)
- const questions = await db
- .select()
- .from(complianceQuestions)
- .where(eq(complianceQuestions.templateId, templateData.id))
- .orderBy(asc(complianceQuestions.displayOrder));
-
- // 3. 각 질문의 옵션들 가져오기
- const questionIds = questions.map(q => q.id);
- const allOptions = questionIds.length > 0
- ? await db
- .select()
- .from(complianceQuestionOptions)
- .where(inArray(complianceQuestionOptions.questionId, questionIds))
- .orderBy(
- complianceQuestionOptions.questionId,
- asc(complianceQuestionOptions.displayOrder)
- )
- : [];
-
-
- // 4. 질문별로 옵션들 그룹화
- const optionsByQuestionId = allOptions.reduce((acc, option) => {
- if (!acc[option.questionId]) {
- acc[option.questionId] = [];
- }
- acc[option.questionId].push({
- id: option.id,
- optionValue: option.optionValue,
- optionText: option.optionText,
- allowsOtherInput: option.allowsOtherInput,
- displayOrder: option.displayOrder,
- });
- return acc;
- }, {} as Record<number, SurveyQuestionOption[]>);
-
- // 5. 최종 데이터 구성
- const questionsWithOptions: SurveyQuestion[] = questions.map(question => ({
- id: question.id,
- questionNumber: question.questionNumber,
- questionText: question.questionText,
- questionType: question.questionType,
- isRequired: question.isRequired,
- hasDetailText: question.hasDetailText,
- hasFileUpload: question.hasFileUpload,
- parentQuestionId: question.parentQuestionId,
- conditionalValue: question.conditionalValue,
- displayOrder: question.displayOrder,
- options: optionsByQuestionId[question.id] || [],
- }));
-
- return {
- id: templateData.id,
- name: templateData.name,
- description: templateData.description,
- version: templateData.version,
- questions: questionsWithOptions,
- };
-
- } catch (error) {
- console.error('설문조사 템플릿 로드 실패:', error);
- return null;
- }
-}
-
-/**
- * 특정 템플릿 ID로 설문조사 템플릿 가져오기
- */
-export async function getSurveyTemplateById(templateId: number): Promise<SurveyTemplateWithQuestions | null> {
- try {
- const template = await db
- .select()
- .from(complianceSurveyTemplates)
- .where(eq(complianceSurveyTemplates.id, templateId))
- .limit(1);
-
- if (!template || template.length === 0) {
- return null;
- }
-
- const templateData = template[0];
-
- const questions = await db
- .select()
- .from(complianceQuestions)
- .where(eq(complianceQuestions.templateId, templateId))
- .orderBy(asc(complianceQuestions.displayOrder));
-
- const questionIds = questions.map(q => q.id);
- const allOptions = questionIds.length > 0
- ? await db
- .select()
- .from(complianceQuestionOptions)
- .where(
- complianceQuestionOptions.questionId.in ?
- complianceQuestionOptions.questionId.in(questionIds) :
- eq(complianceQuestionOptions.questionId, questionIds[0])
- )
- .orderBy(
- complianceQuestionOptions.questionId,
- asc(complianceQuestionOptions.displayOrder)
- )
- : [];
-
- const optionsByQuestionId = allOptions.reduce((acc, option) => {
- if (!acc[option.questionId]) {
- acc[option.questionId] = [];
- }
- acc[option.questionId].push({
- id: option.id,
- optionValue: option.optionValue,
- optionText: option.optionText,
- allowsOtherInput: option.allowsOtherInput,
- displayOrder: option.displayOrder,
- });
- return acc;
- }, {} as Record<number, SurveyQuestionOption[]>);
-
- const questionsWithOptions: SurveyQuestion[] = questions.map(question => ({
- id: question.id,
- questionNumber: question.questionNumber,
- questionText: question.questionText,
- questionType: question.questionType,
- isRequired: question.isRequired,
- hasDetailText: question.hasDetailText,
- hasFileUpload: question.hasFileUpload,
- parentQuestionId: question.parentQuestionId,
- conditionalValue: question.conditionalValue,
- displayOrder: question.displayOrder,
- options: optionsByQuestionId[question.id] || [],
- }));
-
- return {
- id: templateData.id,
- name: templateData.name,
- description: templateData.description,
- version: templateData.version,
- questions: questionsWithOptions,
- };
-
- } catch (error) {
- console.error('설문조사 템플릿 로드 실패:', error);
- return null;
- }
-}
-
-
-// 설문 답변 데이터 타입 정의
-export interface SurveyAnswerData {
- questionId: number;
- answerValue?: string;
- detailText?: string;
- otherText?: string;
- files?: File[];
-}
-
-// 설문조사 완료 요청 데이터 타입
-export interface CompleteSurveyRequest {
- contractId: number;
- templateId: number;
- answers: SurveyAnswerData[];
- progressStatus?: any; // 진행 상태 정보 (옵션)
-}
-
-// 서버 액션: 설문조사 완료 처리
-export async function completeSurvey(data: CompleteSurveyRequest) {
- try {
- console.log('🚀 설문조사 완료 처리 시작:', {
- contractId: data.contractId,
- templateId: data.templateId,
- answersCount: data.answers?.length || 0
- });
-
- // 입력 검증
- if (!data.contractId || !data.templateId || !data.answers?.length) {
- throw new Error('필수 데이터가 누락되었습니다.');
- }
-
- // 트랜잭션으로 처리
- const result = await db.transaction(async (tx) => {
- // 1. complianceResponses 테이블 upsert
- console.log('📋 complianceResponses 처리 중...');
-
- // 기존 응답 확인
- const existingResponse = await tx
- .select()
- .from(complianceResponses)
- .where(
- and(
- eq(complianceResponses.basicContractId, data.contractId),
- eq(complianceResponses.templateId, data.templateId)
- )
- )
- .limit(1);
-
- let responseId: number;
-
- if (existingResponse.length > 0) {
- // 기존 응답 업데이트
- const updateData = {
- status: 'COMPLETED' as const,
- completedAt: new Date(),
- updatedAt: new Date()
- };
-
- await tx
- .update(complianceResponses)
- .set(updateData)
- .where(eq(complianceResponses.id, existingResponse[0].id));
-
- responseId = existingResponse[0].id;
- console.log(`✅ 기존 응답 업데이트 완료: ID ${responseId}`);
- } else {
- // 새 응답 생성
- const newResponse: NewComplianceResponse = {
- basicContractId: data.contractId,
- templateId: data.templateId,
- status: 'COMPLETED',
- completedAt: new Date()
- };
-
- const insertResult = await tx
- .insert(complianceResponses)
- .values(newResponse)
- .returning({ id: complianceResponses.id });
-
- responseId = insertResult[0].id;
- console.log(`✅ 새 응답 생성 완료: ID ${responseId}`);
- }
-
- // 2. 기존 답변들 삭제 (파일도 함께 삭제됨 - CASCADE 설정 필요)
- console.log('🗑️ 기존 답변들 삭제 중...');
-
- // 먼저 기존 답변에 연결된 파일들 삭제
- const existingAnswers = await tx
- .select({ id: complianceResponseAnswers.id })
- .from(complianceResponseAnswers)
- .where(eq(complianceResponseAnswers.responseId, responseId));
-
- if (existingAnswers.length > 0) {
- const answerIds = existingAnswers.map(a => a.id);
-
- // 파일들 먼저 삭제
- for (const answerId of answerIds) {
- await tx
- .delete(complianceResponseFiles)
- .where(eq(complianceResponseFiles.answerId, answerId));
- }
-
- // 답변들 삭제
- await tx
- .delete(complianceResponseAnswers)
- .where(eq(complianceResponseAnswers.responseId, responseId));
- }
-
- // 3. 새로운 답변들 생성
- console.log('📝 새로운 답변들 생성 중...');
- const createdAnswers: { questionId: number; answerId: number; files?: File[] }[] = [];
-
- for (const answer of data.answers) {
- // 빈 답변은 스킵 (선택적 질문의 경우)
- if (!answer.answerValue && !answer.detailText && (!answer.files || answer.files.length === 0)) {
- continue;
- }
-
- const newAnswer: NewComplianceResponseAnswer = {
- responseId,
- questionId: answer.questionId,
- answerValue: answer.answerValue || null,
- detailText: answer.detailText || null,
- otherText: answer.otherText || null,
- // percentageValue는 필요시 추가 처리
- };
-
- const answerResult = await tx
- .insert(complianceResponseAnswers)
- .values(newAnswer)
- .returning({ id: complianceResponseAnswers.id });
-
- const answerId = answerResult[0].id;
-
- createdAnswers.push({
- questionId: answer.questionId,
- answerId,
- files: answer.files
- });
-
- console.log(`✅ 답변 생성: 질문 ${answer.questionId} -> 답변 ID ${answerId}`);
- }
-
- // 4. 파일 업로드 처리 (실제 파일 저장은 별도 로직 필요)
- console.log('📎 파일 업로드 처리 중...');
-
- for (const answerWithFiles of createdAnswers) {
- if (answerWithFiles.files && answerWithFiles.files.length > 0) {
- for (const file of answerWithFiles.files) {
- // TODO: 실제 파일 저장 로직 구현 필요
- // 현재는 파일 메타데이터만 저장
-
-
- // 파일 저장 경로 생성 (예시)
- const fileName = file.name;
- const filePath = `/uploads/compliance/${data.contractId}/${responseId}/${answerWithFiles.answerId}/${fileName}`;
-
- const fileUpload = await saveFile({file,filePath })
-
- const newFile: NewComplianceResponseFile = {
- answerId: answerWithFiles.answerId,
- fileName,
- filePath,
- fileSize: file.size,
- mimeType: file.type || 'application/octet-stream'
- };
-
- await tx
- .insert(complianceResponseFiles)
- .values(newFile);
-
- console.log(`📎 파일 메타데이터 저장: ${fileName}`);
- }
- }
- }
-
- return {
- responseId,
- answersCount: createdAnswers.length,
- success: true
- };
- });
-
- console.log('🎉 설문조사 완료 처리 성공:', result);
-
-
- return {
- success: true,
- message: '설문조사가 성공적으로 완료되었습니다.',
- data: result
- };
-
- } catch (error) {
- console.error('❌ 설문조사 완료 처리 실패:', error);
-
- return {
- success: false,
- message: error instanceof Error ? error.message : '설문조사 저장에 실패했습니다.',
- data: null
- };
- }
-}
-
-// 설문조사 응답 조회 서버 액션
-export async function getSurveyResponse(contractId: number, templateId: number) {
- try {
- const response = await db
- .select()
- .from(complianceResponses)
- .where(
- and(
- eq(complianceResponses.basicContractId, contractId),
- eq(complianceResponses.templateId, templateId)
- )
- )
- .limit(1);
-
- if (response.length === 0) {
- return { success: true, data: null };
- }
-
- // 답변들과 파일들도 함께 조회
- const answers = await db
- .select({
- id: complianceResponseAnswers.id,
- questionId: complianceResponseAnswers.questionId,
- answerValue: complianceResponseAnswers.answerValue,
- detailText: complianceResponseAnswers.detailText,
- otherText: complianceResponseAnswers.otherText,
- percentageValue: complianceResponseAnswers.percentageValue,
- })
- .from(complianceResponseAnswers)
- .where(eq(complianceResponseAnswers.responseId, response[0].id));
-
- // 각 답변의 파일들 조회
- const answersWithFiles = await Promise.all(
- answers.map(async (answer) => {
- const files = await db
- .select()
- .from(complianceResponseFiles)
- .where(eq(complianceResponseFiles.answerId, answer.id));
-
- return {
- ...answer,
- files
- };
- })
- );
-
- return {
- success: true,
- data: {
- response: response[0],
- answers: answersWithFiles
- }
- };
-
- } catch (error) {
- console.error('❌ 설문조사 응답 조회 실패:', error);
-
- return {
- success: false,
- message: error instanceof Error ? error.message : '설문조사 응답 조회에 실패했습니다.',
- data: null
- };
- }
-}
-
-// 파일 업로드를 위한 별도 서버 액션 (실제 파일 저장 로직)
-export async function uploadSurveyFile(file: File, contractId: number, answerId: number) {
- try {
- // TODO: 실제 파일 저장 구현
- // 예: AWS S3, 로컬 파일시스템, 등등
-
- // 현재는 예시 구현
- const fileName = `${Date.now()}-${file.name}`;
- const filePath = `/uploads/compliance/${contractId}/${answerId}/${fileName}`;
-
- // 실제로는 여기서 파일을 물리적으로 저장해야 함
- // const savedPath = await saveFileToStorage(file, filePath);
-
- return {
- success: true,
- filePath,
- fileName: file.name,
- fileSize: file.size,
- mimeType: file.type
- };
-
- } catch (error) {
- console.error('❌ 파일 업로드 실패:', error);
-
- return {
- success: false,
- message: error instanceof Error ? error.message : '파일 업로드에 실패했습니다.'
- };
- }
-}
-
-
-// 기존 응답 조회를 위한 타입
-export interface ExistingResponse {
- responseId: number;
- status: string;
- completedAt: string | null;
- answers: {
- questionId: number;
- answerValue: string | null;
- detailText: string | null;
- otherText: string | null;
- files: Array<{
- id: number;
- fileName: string;
- filePath: string;
- fileSize: number;
- }>;
- }[];
-}
-
-// 기존 응답 조회 서버 액션
-export async function getExistingSurveyResponse(
- contractId: number,
- templateId: number
-): Promise<{ success: boolean; data: ExistingResponse | null; message?: string }> {
- try {
- // 1. 해당 계약서의 응답 조회
- const response = await db
- .select()
- .from(complianceResponses)
- .where(
- and(
- eq(complianceResponses.basicContractId, contractId),
- eq(complianceResponses.templateId, templateId)
- )
- )
- .limit(1);
-
- if (!response || response.length === 0) {
- return { success: true, data: null };
- }
-
- const responseData = response[0];
-
- // 2. 해당 응답의 모든 답변 조회
- const answers = await db
- .select({
- questionId: complianceResponseAnswers.questionId,
- answerValue: complianceResponseAnswers.answerValue,
- detailText: complianceResponseAnswers.detailText,
- otherText: complianceResponseAnswers.otherText,
- answerId: complianceResponseAnswers.id,
- })
- .from(complianceResponseAnswers)
- .where(eq(complianceResponseAnswers.responseId, responseData.id));
-
- // 3. 각 답변의 파일들 조회
- const answerIds = answers.map(a => a.answerId);
- const files = answerIds.length > 0
- ? await db
- .select()
- .from(complianceResponseFiles)
- .where(inArray(complianceResponseFiles.answerId, answerIds))
- : [];
-
- // 4. 답변별 파일 그룹화
- const filesByAnswerId = files.reduce((acc, file) => {
- if (!acc[file.answerId]) {
- acc[file.answerId] = [];
- }
- acc[file.answerId].push({
- id: file.id,
- fileName: file.fileName,
- filePath: file.filePath,
- fileSize: file.fileSize || 0,
- });
- return acc;
- }, {} as Record<number, Array<{id: number; fileName: string; filePath: string; fileSize: number}>>);
-
- // 5. 최종 데이터 구성
- const answersWithFiles = answers.map(answer => ({
- questionId: answer.questionId,
- answerValue: answer.answerValue,
- detailText: answer.detailText,
- otherText: answer.otherText,
- files: filesByAnswerId[answer.answerId] || [],
- }));
-
- return {
- success: true,
- data: {
- responseId: responseData.id,
- status: responseData.status,
- completedAt: responseData.completedAt?.toISOString() || null,
- answers: answersWithFiles,
- },
- };
-
- } catch (error) {
- console.error('기존 설문 응답 조회 실패:', error);
- return {
- success: false,
- data: null,
- message: '기존 응답을 불러오는데 실패했습니다.'
- };
- }
-}
-
-export type GtcVendorData = {
- vendorDocument: {
- id: number;
- name: string;
- description: string | null;
- version: string;
- reviewStatus: string;
- vendorId: number;
- baseDocumentId: number;
- vendorName: string;
- vendorCode: string;
- };
- clauses: Array<{
- id: number;
- baseClauseId: number;
- vendorDocumentId: number;
- parentId: number | null;
- depth: number;
- sortOrder: string;
- fullPath: string | null;
- reviewStatus: string;
- negotiationNote: string | null;
- isExcluded: boolean;
-
- // 실제 표시될 값들 (수정된 값이 우선, 없으면 기본값)
- effectiveItemNumber: string;
- effectiveCategory: string | null;
- effectiveSubtitle: string;
- effectiveContent: string | null;
-
- // 기본 조항 정보
- baseItemNumber: string;
- baseCategory: string | null;
- baseSubtitle: string;
- baseContent: string | null;
-
- // 수정 여부
- hasModifications: boolean;
- isNumberModified: boolean;
- isCategoryModified: boolean;
- isSubtitleModified: boolean;
- isContentModified: boolean;
-
- // 코멘트 관련
- hasComment: boolean;
- pendingComment: string | null;
- }>;
-};
-
-/**
- * 현재 사용자(벤더)의 GTC 데이터를 가져옵니다.
- * @param contractId 기본 GTC 문서 ID (선택사항, 없으면 가장 최신 문서 사용)
- * @returns GTC 벤더 데이터 또는 null
- */
-export async function getVendorGtcData(contractId?: number): Promise<GtcVendorData | null> {
- try {
- const session = await getServerSession(authOptions);
- if (!session?.user?.companyId) {
- throw new Error("회사 정보가 없습니다.");
- }
-
- console.log(contractId, "contractId");
-
- const companyId = session.user.companyId;
- const vendorId = companyId; // companyId를 vendorId로 사용
-
- // 1. 계약 정보 가져오기
- const existingContract = await db.query.basicContract.findFirst({
- where: eq(basicContract.id, contractId),
- });
-
- if (!existingContract) {
- throw new Error("계약을 찾을 수 없습니다.");
- }
-
- // 2. 계약 템플릿 정보 가져오기
- const existingContractTemplate = await db.query.basicContractTemplates.findFirst({
- where: eq(basicContractTemplates.id, existingContract.templateId), // id가 아니라 templateId여야 할 것 같음
- });
-
- if (!existingContractTemplate) {
- throw new Error("계약 템플릿을 찾을 수 없습니다.");
- }
-
- // 3. General 타입인지 확인
- const isGeneral = existingContractTemplate.templateName.toLowerCase().includes('general');
-
- let targetBaseDocumentId: number;
-
- if (isGeneral) {
- // General인 경우: type이 'standard'인 활성 상태의 첫 번째 문서 사용
- const standardGtcDoc = await db.query.gtcDocuments.findFirst({
- where: and(
- eq(gtcDocuments.type, 'standard'),
- eq(gtcDocuments.isActive, true)
- ),
- orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)] // 최신 리비전 우선
- });
-
- if (!standardGtcDoc) {
- throw new Error("표준 GTC 문서를 찾을 수 없습니다.");
- }
-
- targetBaseDocumentId = standardGtcDoc.id;
- console.log(`표준 GTC 문서 사용: ${targetBaseDocumentId}`);
-
- } else {
- // General이 아닌 경우: 프로젝트별 GTC 문서 사용
- const projectCode = existingContractTemplate.templateName.split(" ")[0];
-
- if (!projectCode) {
- throw new Error("템플릿 이름에서 프로젝트 코드를 찾을 수 없습니다.");
- }
-
- // 프로젝트 찾기
- const existingProject = await db.query.projects.findFirst({
- where: eq(projects.code, projectCode),
- });
-
- if (!existingProject) {
- throw new Error(`프로젝트를 찾을 수 없습니다: ${projectCode}`);
- }
-
- // 해당 프로젝트의 GTC 문서 찾기
- const projectGtcDoc = await db.query.gtcDocuments.findFirst({
- where: and(
- eq(gtcDocuments.type, 'project'),
- eq(gtcDocuments.projectId, existingProject.id),
- eq(gtcDocuments.isActive, true)
- ),
- orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)] // 최신 리비전 우선
- });
-
- if (!projectGtcDoc) {
- console.warn(`프로젝트 ${projectCode}에 대한 GTC 문서가 없습니다. 표준 GTC 문서를 사용합니다.`);
-
- // 프로젝트별 GTC가 없으면 표준 GTC 사용
- const standardGtcDoc = await db.query.gtcDocuments.findFirst({
- where: and(
- eq(gtcDocuments.type, 'standard'),
- eq(gtcDocuments.isActive, true)
- ),
- orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)]
- });
-
- if (!standardGtcDoc) {
- throw new Error("표준 GTC 문서도 찾을 수 없습니다.");
- }
-
- targetBaseDocumentId = standardGtcDoc.id;
- } else {
- targetBaseDocumentId = projectGtcDoc.id;
- console.log(`프로젝트 GTC 문서 사용: ${targetBaseDocumentId} (프로젝트: ${projectCode})`);
- }
- }
-
- // 4. 벤더 문서 정보 가져오기 (없어도 기본 조항은 보여줌)
- const vendorDocResult = await db
- .select({
- id: gtcVendorDocuments.id,
- name: gtcVendorDocuments.name,
- description: gtcVendorDocuments.description,
- version: gtcVendorDocuments.version,
- reviewStatus: gtcVendorDocuments.reviewStatus,
- vendorId: gtcVendorDocuments.vendorId,
- baseDocumentId: gtcVendorDocuments.baseDocumentId,
- vendorName: vendors.vendorName,
- vendorCode: vendors.vendorCode,
- })
- .from(gtcVendorDocuments)
- .leftJoin(vendors, eq(gtcVendorDocuments.vendorId, vendors.id))
- .where(
- and(
- eq(gtcVendorDocuments.vendorId, vendorId),
- eq(gtcVendorDocuments.baseDocumentId, targetBaseDocumentId),
- eq(gtcVendorDocuments.isActive, true)
- )
- )
- .limit(1);
-
- // 벤더 문서가 없으면 기본 정보로 생성
- const vendorDocument = vendorDocResult.length > 0 ? vendorDocResult[0] : {
- id: null, // 벤더 문서가 아직 생성되지 않음
- name: `GTC 검토 (벤더 ID: ${vendorId})`,
- description: "기본 GTC 협의",
- version: "1.0",
- reviewStatus: "pending",
- vendorId: vendorId,
- baseDocumentId: targetBaseDocumentId,
- vendorName: "Unknown Vendor",
- vendorCode: "UNKNOWN"
- };
-
- if (vendorDocResult.length === 0) {
- console.info(`벤더 ID ${vendorId}에 대한 GTC 벤더 문서가 없습니다. 기본 조항만 표시합니다. (baseDocumentId: ${targetBaseDocumentId})`);
- }
-
- // 5. 기본 조항들 가져오기 (벤더별 수정 사항과 함께)
- const clausesResult = await db
- .select({
- // 기본 조항 정보 (메인)
- baseClauseId: gtcClauses.id,
- baseItemNumber: gtcClauses.itemNumber,
- baseCategory: gtcClauses.category,
- baseSubtitle: gtcClauses.subtitle,
- baseContent: gtcClauses.content,
- baseParentId: gtcClauses.parentId,
- baseDepth: gtcClauses.depth,
- baseSortOrder: gtcClauses.sortOrder,
- baseFullPath: gtcClauses.fullPath,
-
- // 벤더 조항 정보 (있는 경우만)
- vendorClauseId: gtcVendorClauses.id,
- vendorDocumentId: gtcVendorClauses.vendorDocumentId,
- reviewStatus: gtcVendorClauses.reviewStatus,
- negotiationNote: gtcVendorClauses.negotiationNote,
- isExcluded: gtcVendorClauses.isExcluded,
-
- // 수정된 값들 (있는 경우만)
- modifiedItemNumber: gtcVendorClauses.modifiedItemNumber,
- modifiedCategory: gtcVendorClauses.modifiedCategory,
- modifiedSubtitle: gtcVendorClauses.modifiedSubtitle,
- modifiedContent: gtcVendorClauses.modifiedContent,
-
- // 수정 여부
- isNumberModified: gtcVendorClauses.isNumberModified,
- isCategoryModified: gtcVendorClauses.isCategoryModified,
- isSubtitleModified: gtcVendorClauses.isSubtitleModified,
- isContentModified: gtcVendorClauses.isContentModified,
- })
- .from(gtcClauses)
- .leftJoin(gtcVendorClauses, and(
- eq(gtcVendorClauses.baseClauseId, gtcClauses.id),
- vendorDocument.id ? eq(gtcVendorClauses.vendorDocumentId, vendorDocument.id) : sql`false`,
- eq(gtcVendorClauses.isActive, true)
- ))
- .where(
- and(
- eq(gtcClauses.documentId, targetBaseDocumentId),
- eq(gtcClauses.isActive, true)
- )
- )
- .orderBy(gtcClauses.sortOrder);
-
- let negotiationHistoryMap = new Map();
-
- if (vendorDocument.id) {
- const vendorClauseIds = clausesResult
- .filter(c => c.vendorClauseId)
- .map(c => c.vendorClauseId);
-
- if (vendorClauseIds.length > 0) {
- const histories = await db
- .select({
- vendorClauseId: gtcNegotiationHistory.vendorClauseId,
- action: gtcNegotiationHistory.action,
- previousStatus: gtcNegotiationHistory.previousStatus,
- newStatus: gtcNegotiationHistory.newStatus,
- comment: gtcNegotiationHistory.comment,
- actorType: gtcNegotiationHistory.actorType,
- actorId: gtcNegotiationHistory.actorId,
- actorName: gtcNegotiationHistory.actorName,
- actorEmail: gtcNegotiationHistory.actorEmail,
- createdAt: gtcNegotiationHistory.createdAt,
- changedFields: gtcNegotiationHistory.changedFields,
- })
- .from(gtcNegotiationHistory)
- .leftJoin(users, eq(gtcNegotiationHistory.actorId, users.id))
- .where(inArray(gtcNegotiationHistory.vendorClauseId, vendorClauseIds))
- .orderBy(desc(gtcNegotiationHistory.createdAt));
-
- // 벤더 조항별로 이력 그룹화
- histories.forEach(history => {
- if (!negotiationHistoryMap.has(history.vendorClauseId)) {
- negotiationHistoryMap.set(history.vendorClauseId, []);
- }
- negotiationHistoryMap.get(history.vendorClauseId).push(history);
- });
- }
- }
-
-
-
- // 6. 데이터 변환 및 추가 정보 계산
- const clauses = clausesResult.map(clause => {
- const hasVendorData = !!clause.vendorClauseId;
- const negotiationHistory = hasVendorData ?
- (negotiationHistoryMap.get(clause.vendorClauseId) || []) : [];
-
- // 코멘트가 있는 이력들만 필터링
- const commentHistory = negotiationHistory.filter(h => h.comment);
- const latestComment = commentHistory[0]?.comment || null;
- const hasComment = commentHistory.length > 0;
-
- return {
- id: clause.baseClauseId,
- vendorClauseId: clause.vendorClauseId,
- vendorDocumentId: hasVendorData ? clause.vendorDocumentId : null,
-
- // 기본 조항의 계층 구조 정보 사용
- parentId: clause.baseParentId,
- depth: clause.baseDepth,
- sortOrder: clause.baseSortOrder,
- fullPath: clause.baseFullPath,
-
- // 상태 정보 (벤더 데이터가 있는 경우만)
- reviewStatus: clause.reviewStatus || 'pending',
- negotiationNote: clause.negotiationNote,
- isExcluded: clause.isExcluded || false,
-
- // 실제 표시될 값들 (수정된 값이 있으면 그것을, 없으면 기본값)
- effectiveItemNumber: clause.modifiedItemNumber || clause.baseItemNumber,
- effectiveCategory: clause.modifiedCategory || clause.baseCategory,
- effectiveSubtitle: clause.modifiedSubtitle || clause.baseSubtitle,
- effectiveContent: clause.modifiedContent || clause.baseContent,
-
- // 기본 조항 정보
- baseItemNumber: clause.baseItemNumber,
- baseCategory: clause.baseCategory,
- baseSubtitle: clause.baseSubtitle,
- baseContent: clause.baseContent,
-
- // 수정 여부
- // hasModifications,
- isNumberModified: clause.isNumberModified || false,
- isCategoryModified: clause.isCategoryModified || false,
- isSubtitleModified: clause.isSubtitleModified || false,
- isContentModified: clause.isContentModified || false,
-
- hasComment,
- latestComment,
- commentHistory, // 전체 코멘트 이력
- negotiationHistory, // 전체 협의 이력
- };
- });
-
- return {
- vendorDocument,
- clauses,
- };
-
- } catch (error) {
- console.error('GTC 벤더 데이터 가져오기 실패:', error);
- throw error;
- }
-}
-
-
-interface ClauseUpdateData {
- itemNumber: string;
- category: string | null;
- subtitle: string;
- content: string | null;
- comment: string;
-}
-
-interface VendorDocument {
- id: number | null;
- vendorId: number;
- baseDocumentId: number;
- name: string;
- description: string;
- version: string;
-}
-
-export async function updateVendorClause(
- baseClauseId: number,
- vendorClauseId: number | null,
- clauseData: any,
- vendorDocument: any
-): Promise<{ success: boolean; error?: string; vendorClauseId?: number; vendorDocumentId?: number }> {
- try {
- const session = await getServerSession(authOptions);
- if (!session?.user?.companyId) {
- return { success: false, error: "회사 정보가 없습니다." };
- }
-
- const companyId = session.user.companyId;
- const vendorId = companyId;
- const userId = Number(session.user.id);
-
- // 1. 기본 조항 정보 가져오기
- const baseClause = await db.query.gtcClauses.findFirst({
- where: eq(gtcClauses.id, baseClauseId),
- });
-
- if (!baseClause) {
- return { success: false, error: "기본 조항을 찾을 수 없습니다." };
- }
-
- // 2. 이전 코멘트 가져오기 (vendorClauseId가 있는 경우)
- let previousComment = null;
- if (vendorClauseId) {
- const previousData = await db
- .select({ comment: gtcVendorClauses.negotiationNote })
- .from(gtcVendorClauses)
- .where(eq(gtcVendorClauses.id, vendorClauseId))
- .limit(1);
-
- previousComment = previousData?.[0]?.comment || null;
- }
-
- // 3. 벤더 문서 ID 확보 (없으면 생성)
- let finalVendorDocumentId = vendorDocument?.id;
-
- if (!finalVendorDocumentId && vendorDocument) {
- const newVendorDoc = await db.insert(gtcVendorDocuments).values({
- vendorId: vendorId,
- baseDocumentId: vendorDocument.baseDocumentId,
- name: vendorDocument.name,
- description: vendorDocument.description,
- version: vendorDocument.version,
- reviewStatus: 'reviewing',
- createdById: userId,
- updatedById: userId,
- }).returning({ id: gtcVendorDocuments.id });
-
- if (newVendorDoc.length === 0) {
- return { success: false, error: "벤더 문서 생성에 실패했습니다." };
- }
-
- finalVendorDocumentId = newVendorDoc[0].id;
- console.log(`새 벤더 문서 생성: ${finalVendorDocumentId}`);
- }
-
- if (!finalVendorDocumentId) {
- return { success: false, error: "벤더 문서 ID를 확보할 수 없습니다." };
- }
-
- // 4. 수정 여부 확인
- const isNumberModified = clauseData.itemNumber !== baseClause.itemNumber;
- const isCategoryModified = clauseData.category !== baseClause.category;
- const isSubtitleModified = clauseData.subtitle !== baseClause.subtitle;
- const isContentModified = clauseData.content !== baseClause.content;
-
- const hasAnyModifications = isNumberModified || isCategoryModified || isSubtitleModified || isContentModified;
- const hasComment = !!(clauseData.comment?.trim());
-
- // 5. 벤더 조항 데이터 준비
- const vendorClauseData = {
- vendorDocumentId: finalVendorDocumentId,
- baseClauseId: baseClauseId,
- parentId: baseClause.parentId,
- depth: baseClause.depth,
- sortOrder: baseClause.sortOrder,
- fullPath: baseClause.fullPath,
-
- modifiedItemNumber: isNumberModified ? clauseData.itemNumber : null,
- modifiedCategory: isCategoryModified ? clauseData.category : null,
- modifiedSubtitle: isSubtitleModified ? clauseData.subtitle : null,
- modifiedContent: isContentModified ? clauseData.content : null,
-
- isNumberModified,
- isCategoryModified,
- isSubtitleModified,
- isContentModified,
-
- reviewStatus: (hasAnyModifications || hasComment) ? 'reviewing' : 'draft',
- negotiationNote: clauseData.comment?.trim() || null,
- editReason: clauseData.comment?.trim() || null,
-
- updatedAt: new Date(),
- updatedById: userId,
- };
-
- let finalVendorClauseId = vendorClauseId;
-
- // 6. 벤더 조항 생성 또는 업데이트
- if (vendorClauseId) {
- await db
- .update(gtcVendorClauses)
- .set(vendorClauseData)
- .where(eq(gtcVendorClauses.id, vendorClauseId));
-
- console.log(`벤더 조항 업데이트: ${vendorClauseId}`);
- } else {
- const newVendorClause = await db.insert(gtcVendorClauses).values({
- ...vendorClauseData,
- createdById: userId,
- }).returning({ id: gtcVendorClauses.id });
-
- if (newVendorClause.length === 0) {
- return { success: false, error: "벤더 조항 생성에 실패했습니다." };
- }
-
- finalVendorClauseId = newVendorClause[0].id;
- console.log(`새 벤더 조항 생성: ${finalVendorClauseId}`);
- }
-
- // 7. 협의 이력에 기록 (코멘트가 변경된 경우만)
- if (clauseData.comment !== previousComment) {
- await db.insert(gtcNegotiationHistory).values({
- vendorClauseId: finalVendorClauseId,
- action: previousComment ? "modified" : "commented",
- comment: clauseData.comment || null,
- previousStatus: null,
- newStatus: 'reviewing',
- actorType: "vendor",
- actorId: userId,
- actorName: session.user.name,
- actorEmail: session.user.email,
- changedFields: {
- comment: {
- from: previousComment,
- to: clauseData.comment || null
- }
- }
- });
- }
-
- return {
- success: true,
- vendorClauseId: finalVendorClauseId,
- vendorDocumentId: finalVendorDocumentId
- };
-
- } catch (error) {
- console.error('벤더 조항 업데이트 실패:', error);
- return {
- success: false,
- error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
- };
- }
-}
-// 기존 함수는 호환성을 위해 유지하되, 새 함수를 호출하도록 변경
-export async function updateVendorClauseComment(
- clauseId: number,
- comment: string
-): Promise<{ success: boolean; error?: string }> {
- console.warn('updateVendorClauseComment is deprecated. Use updateVendorClause instead.');
-
- // 기본 조항 정보 가져오기
- const baseClause = await db.query.gtcClauses.findFirst({
- where: eq(gtcClauses.id, clauseId),
- });
-
- if (!baseClause) {
- return { success: false, error: "기본 조항을 찾을 수 없습니다." };
- }
-
- // 기존 벤더 조항 찾기
- const session = await getServerSession(authOptions);
- const vendorId = session?.user?.companyId;
-
- const existingVendorClause = await db.query.gtcVendorClauses.findFirst({
- where: and(
- eq(gtcVendorClauses.baseClauseId, clauseId),
- eq(gtcVendorClauses.isActive, true)
- ),
- with: {
- vendorDocument: true
- }
- });
-
- const clauseData: ClauseUpdateData = {
- itemNumber: baseClause.itemNumber,
- category: baseClause.category,
- subtitle: baseClause.subtitle,
- content: baseClause.content,
- comment: comment,
- };
-
- const result = await updateVendorClause(
- clauseId,
- existingVendorClause?.id || null,
- clauseData,
- existingVendorClause?.vendorDocument || undefined
- );
-
- return {
- success: result.success,
- error: result.error
- };
-}
-
-
-/**
- * 벤더 조항 코멘트들의 상태 체크
- */
-export async function checkVendorClausesCommentStatus(
- vendorDocumentId: number
-): Promise<{ hasComments: boolean; commentCount: number }> {
- try {
- const clausesWithComments = await db
- .select({
- id: gtcVendorClauses.id,
- negotiationNote: gtcVendorClauses.negotiationNote
- })
- .from(gtcVendorClauses)
- .where(
- and(
- eq(gtcVendorClauses.vendorDocumentId, vendorDocumentId),
- eq(gtcVendorClauses.isActive, true)
- )
- );
-
- const commentCount = clausesWithComments.filter(
- clause => clause.negotiationNote && clause.negotiationNote.trim().length > 0
- ).length;
-
- return {
- hasComments: commentCount > 0,
- commentCount,
- };
-
- } catch (error) {
- console.error('벤더 조항 코멘트 상태 체크 실패:', error);
- return { hasComments: false, commentCount: 0 };
- }
-}
-
-/**
- * 특정 템플릿의 기본 정보를 조회하는 서버 액션
- * @param templateId - 조회할 템플릿의 ID
- * @returns 템플릿 기본 정보 또는 null
- */
-export async function getBasicContractTemplateInfo(templateId: number) {
- try {
- const templateInfo = await db
- .select({
- templateId: basicContractTemplates.id,
- templateName: basicContractTemplates.templateName,
- revision: basicContractTemplates.revision,
- status: basicContractTemplates.status,
- legalReviewRequired: basicContractTemplates.legalReviewRequired,
- validityPeriod: basicContractTemplates.validityPeriod,
- fileName: basicContractTemplates.fileName,
- filePath: basicContractTemplates.filePath,
- createdAt: basicContractTemplates.createdAt,
- updatedAt: basicContractTemplates.updatedAt,
- createdBy: basicContractTemplates.createdBy,
- updatedBy: basicContractTemplates.updatedBy,
- disposedAt: basicContractTemplates.disposedAt,
- restoredAt: basicContractTemplates.restoredAt,
- })
- .from(basicContractTemplates)
- .where(eq(basicContractTemplates.id, templateId))
- .then((res) => res[0] || null)
-
- return templateInfo
- } catch (error) {
- console.error("Error fetching template info:", error)
- return null
- }
-}
-
-
-
-/**
- * 카테고리 자동 분류 함수
- */
-function getCategoryFromTemplateName(templateName: string | null): string {
- if (!templateName) return "기타"
-
- const templateNameLower = templateName.toLowerCase()
-
- if (templateNameLower.includes("준법")) {
- return "CP"
- } else if (templateNameLower.includes("gtc")) {
- return "GTC"
- }
-
- return "기타"
-}
-
-/**
- * 법무검토 요청 서버 액션
- */
-export async function requestLegalReviewAction(
- contractIds: number[],
- reviewNote?: string
-): Promise<{ success: boolean; message: string; data?: any }> {
- try {
- // 세션 확인
- const session = await getServerSession(authOptions)
-
- if (!session?.user) {
- return {
- success: false,
- message: "로그인이 필요합니다."
- }
- }
-
- // 계약서 정보 조회
- const contracts = await db
- .select({
- id: basicContractView.id,
- vendorId: basicContractView.vendorId,
- vendorCode: basicContractView.vendorCode,
- vendorName: basicContractView.vendorName,
- templateName: basicContractView.templateName,
- legalReviewRequired: basicContractView.legalReviewRequired,
- legalReviewRequestedAt: basicContractView.legalReviewRequestedAt,
- })
- .from(basicContractView)
- .where(inArray(basicContractView.id, contractIds))
-
- if (contracts.length === 0) {
- return {
- success: false,
- message: "선택된 계약서를 찾을 수 없습니다."
- }
- }
-
- // 법무검토 요청 가능한 계약서 필터링
- const eligibleContracts = contracts.filter(contract =>
- contract.legalReviewRequired && !contract.legalReviewRequestedAt
- )
-
- if (eligibleContracts.length === 0) {
- return {
- success: false,
- message: "법무검토 요청 가능한 계약서가 없습니다."
- }
- }
-
- const currentDate = new Date()
- const reviewer = session.user.name || session.user.email || "알 수 없음"
-
- // 트랜잭션으로 처리
- const results = await db.transaction(async (tx) => {
- const legalWorkResults = []
- const contractUpdateResults = []
-
- // 각 계약서에 대해 legalWorks 레코드 생성
- for (const contract of eligibleContracts) {
- const category = getCategoryFromTemplateName(contract.templateName)
-
- // legalWorks에 레코드 삽입
- const legalWorkResult = await tx.insert(legalWorks).values({
- basicContractId: contract.id, // 레퍼런스 ID
- category: category,
- status: "신규등록",
- vendorId: contract.vendorId,
- vendorCode: contract.vendorCode,
- vendorName: contract.vendorName || "업체명 없음",
- isUrgent: false,
- consultationDate: currentDate.toISOString().split('T')[0], // YYYY-MM-DD 형식
- reviewer: reviewer,
- hasAttachment: false, // 기본값, 나중에 첨부파일 로직 추가 시 수정
- createdAt: currentDate,
- updatedAt: currentDate,
- }).returning({ id: legalWorks.id })
-
- legalWorkResults.push(legalWorkResult[0])
-
- // basicContract 테이블의 legalReviewRequestedAt 업데이트
- const contractUpdateResult = await tx
- .update(basicContract)
- .set({
- legalReviewRequestedAt: currentDate,
- updatedAt: currentDate,
- })
- .where(eq(basicContract.id, contract.id))
- .returning({ id: basicContract.id })
-
- contractUpdateResults.push(contractUpdateResult[0])
- }
-
- return {
- legalWorks: legalWorkResults,
- contractUpdates: contractUpdateResults
- }
- })
-
-
- console.log("법무검토 요청 완료:", {
- requestedBy: reviewer,
- contractIds: eligibleContracts.map(c => c.id),
- legalWorkIds: results.legalWorks.map(r => r.id),
- reviewNote,
- })
-
- return {
- success: true,
- message: `${eligibleContracts.length}건의 계약서에 대한 법무검토를 요청했습니다.`,
- data: {
- processedCount: eligibleContracts.length,
- totalRequested: contractIds.length,
- legalWorkIds: results.legalWorks.map(r => r.id),
- }
- }
-
- } catch (error) {
- console.error("법무검토 요청 중 오류:", error)
-
- return {
- success: false,
- message: "법무검토 요청 중 오류가 발생했습니다. 다시 시도해 주세요.",
- }
- }
-}
-
-export async function resendContractsAction(contractIds: number[]) {
- try {
- // 세션 확인
- const session = await getServerSession(authOptions)
- if (!session?.user?.id) {
- throw new Error('인증이 필요합니다.')
- }
-
- // 계약서 정보 조회
- const contracts = await db
- .select({
- id: basicContract.id,
- vendorId: basicContract.vendorId,
- fileName: basicContract.fileName,
- deadline: basicContract.deadline,
- status: basicContract.status,
- createdAt: basicContract.createdAt,
- })
- .from(basicContract)
- .where(inArray(basicContract.id, contractIds))
-
- if (contracts.length === 0) {
- throw new Error('발송할 계약서를 찾을 수 없습니다.')
- }
-
- // 각 계약서에 대해 이메일 발송
- const emailPromises = contracts.map(async (contract) => {
- // 벤더 정보 조회
- const vendor = await db
- .select({
- id: vendors.id,
- vendorName: vendors.vendorName,
- vendorCode: vendors.vendorCode,
- country: vendors.country,
- email: vendors.email,
- })
- .from(vendors)
- .where(eq(vendors.id, contract.vendorId!))
- .limit(1)
-
- if (!vendor[0]) {
- console.error(`벤더를 찾을 수 없습니다: vendorId ${contract.vendorId}`)
- return null
- }
-
- // 벤더 연락처 조회 (Primary 연락처 우선, 없으면 첫 번째 연락처)
- const contacts = await db
- .select({
- contactName: vendorContacts.contactName,
- contactEmail: vendorContacts.contactEmail,
- isPrimary: vendorContacts.isPrimary,
- })
- .from(vendorContacts)
- .where(eq(vendorContacts.vendorId, vendor[0].id))
- .orderBy(vendorContacts.isPrimary)
-
- // 이메일 수신자 결정 (Primary 연락처 > 첫 번째 연락처 > 벤더 기본 이메일)
- const primaryContact = contacts.find(c => c.isPrimary)
- const recipientEmail = primaryContact?.contactEmail || contacts[0]?.contactEmail || vendor[0].email
- const recipientName = primaryContact?.contactName || contacts[0]?.contactName || vendor[0].vendorName
-
- if (!recipientEmail) {
- console.error(`이메일 주소를 찾을 수 없습니다: vendorId ${vendor[0].id}`)
- return null
- }
-
- // 언어 결정 (한국 = 한글, 그 외 = 영어)
- const isKorean = vendor[0].country === 'KR'
- const template = isKorean ? 'contract-reminder-kr' : 'contract-reminder-en'
- const subject = isKorean
- ? '[eVCP] 계약서 서명 요청 리마인더'
- : '[eVCP] Contract Signature Reminder'
-
- // 마감일 포맷팅
- const deadlineDate = new Date(contract.deadline)
- const formattedDeadline = isKorean
- ? `${deadlineDate.getFullYear()}년 ${deadlineDate.getMonth() + 1}월 ${deadlineDate.getDate()}일`
- : deadlineDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
-
- // 남은 일수 계산
- const today = new Date()
- today.setHours(0, 0, 0, 0)
- const deadline = new Date(contract.deadline)
- deadline.setHours(0, 0, 0, 0)
- const daysRemaining = Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
-
- // 이메일 발송
- await sendEmail({
- from: session.user.email,
- to: recipientEmail,
- subject,
- template,
- context: {
- recipientName,
- vendorName: vendor[0].vendorName,
- vendorCode: vendor[0].vendorCode,
- contractFileName: contract.fileName,
- deadline: formattedDeadline,
- daysRemaining,
- senderName: session.user.name || session.user.email,
- senderEmail: session.user.email,
- // 계약서 링크 (실제 환경에 맞게 수정 필요)
- contractLink: `${process.env.NEXT_PUBLIC_APP_URL}/contracts/${contract.id}`,
- },
- })
-
- console.log(`리마인더 이메일 발송 완료: ${recipientEmail} (계약서 ID: ${contract.id})`)
- return { contractId: contract.id, email: recipientEmail }
- })
-
- const results = await Promise.allSettled(emailPromises)
-
- // 성공/실패 카운트
- const successful = results.filter(r => r.status === 'fulfilled' && r.value !== null).length
- const failed = results.filter(r => r.status === 'rejected' || (r.status === 'fulfilled' && r.value === null)).length
-
- if (failed > 0) {
- console.warn(`${failed}건의 이메일 발송 실패`)
- }
-
- return {
- success: true,
- message: `${successful}건의 리마인더 이메일을 발송했습니다.`,
- successful,
- failed,
- }
-
- } catch (error) {
- console.error('계약서 재발송 중 오류:', error)
- throw new Error('계약서 재발송 중 오류가 발생했습니다.')
- }
-}
-
-
-export async function processBuyerSignatureAction(
- contractId: number,
- signedFileData: ArrayBuffer,
- fileName: string
-): Promise<{ success: boolean; message: string; data?: any }> {
- try {
- // 세션 확인
- const session = await getServerSession(authOptions)
-
- if (!session?.user) {
- return {
- success: false,
- message: "로그인이 필요합니다."
- }
- }
-
- // 계약서 정보 조회 및 상태 확인
- const contract = await db
- .select()
- .from(basicContractView)
- .where(eq(basicContractView.id, contractId))
- .limit(1)
-
- if (contract.length === 0) {
- return {
- success: false,
- message: "계약서를 찾을 수 없습니다."
- }
- }
-
- const contractData = contract[0]
-
- // 최종승인 가능 상태 확인
- if (contractData.completedAt !== null) {
- return {
- success: false,
- message: "이미 완료된 계약서입니다."
- }
- }
-
- if (!contractData.signedFilePath) {
- return {
- success: false,
- message: "협력업체 서명이 완료되지 않았습니다."
- }
- }
-
- if (contractData.legalReviewRequestedAt && !contractData.legalReviewCompletedAt) {
- return {
- success: false,
- message: "법무검토가 완료되지 않았습니다."
- }
- }
-
- // 파일 저장 로직 (기존 파일 덮어쓰기)
- const saveResult = await saveBuffer({
- buffer: signedFileData,
- fileName: fileName,
- directory: "basicContract/signed"
- });
-
- if (!saveResult.success) {
- return {
- success: false,
- message: `파일 저장 중 오류가 발생했습니다: ${saveResult.error}`
- }
- }
-
- const currentDate = new Date()
-
- // 계약서 상태 업데이트
- const updatedContract = await db
- .update(basicContract)
- .set({
- buyerSignedAt: currentDate,
- completedAt: currentDate,
- status: "COMPLETED",
- updatedAt: currentDate,
- filePath: saveResult.publicPath, // 웹 접근 가능한 경로로 업데이트
- })
- .where(eq(basicContract.id, contractId))
- .returning()
-
- // 캐시 재검증
- revalidatePath("/contracts")
-
- console.log("구매자 서명 및 최종승인 완료:", {
- contractId,
- buyerSigner: session.user.name || session.user.email,
- completedAt: currentDate,
- })
-
- return {
- success: true,
- message: "계약서 최종승인이 완료되었습니다.",
- data: {
- contractId,
- completedAt: currentDate,
- }
- }
-
- } catch (error) {
- console.error("구매자 서명 처리 중 오류:", error)
-
- return {
- success: false,
- message: "최종승인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.",
- }
- }
-}
-
-/**
- * 일괄 최종승인 (서명 다이얼로그 호출용)
- */
-export async function prepareFinalApprovalAction(
- contractIds: number[]
-): Promise<{ success: boolean; message: string; contracts?: any[] }> {
- try {
- // 세션 확인
- const session = await getServerSession(authOptions)
-
- if (!session?.user) {
- return {
- success: false,
- message: "로그인이 필요합니다."
- }
- }
-
- // 계약서 정보 조회
- const contracts = await db
- .select()
- .from(basicContractView)
- .where(inArray(basicContractView.id, contractIds))
-
- if (contracts.length === 0) {
- return {
- success: false,
- message: "선택된 계약서를 찾을 수 없습니다."
- }
- }
-
- // 최종승인 가능한 계약서 필터링
- const eligibleContracts = contracts.filter(contract => {
- if (contract.completedAt !== null || !contract.signedFilePath) {
- return false
- }
- if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) {
- return false
- }
- return true
- })
-
- if (eligibleContracts.length === 0) {
- return {
- success: false,
- message: "최종승인 가능한 계약서가 없습니다."
- }
- }
-
- // 서명 다이얼로그에서 사용할 수 있는 형태로 변환
- const contractsForSigning = eligibleContracts.map(contract => ({
- id: contract.id,
- templateName: contract.templateName,
- signedFilePath: contract.signedFilePath,
- signedFileName: contract.signedFileName,
- vendorName: contract.vendorName,
- vendorCode: contract.vendorCode,
- requestedByName: "구매팀", // 최종승인자 표시
- createdAt: contract.createdAt,
- // 다른 필요한 필드들...
- }))
-
- return {
- success: true,
- message: `${eligibleContracts.length}건의 계약서를 최종승인할 준비가 되었습니다.`,
- contracts: contractsForSigning
- }
-
- } catch (error) {
- console.error("최종승인 준비 중 오류:", error)
-
- return {
- success: false,
- message: "최종승인 준비 중 오류가 발생했습니다.",
- }
- }
-}
-
-/**
- * 서명 없이 승인만 처리 (간단한 승인 방식)
- */
-export async function quickFinalApprovalAction(
- contractIds: number[]
-): Promise<{ success: boolean; message: string; data?: any }> {
- try {
- // 세션 확인
- const session = await getServerSession(authOptions)
-
- if (!session?.user) {
- return {
- success: false,
- message: "로그인이 필요합니다."
- }
- }
-
- // 계약서 정보 조회
- const contracts = await db
- .select()
- .from(basicContract)
- .where(inArray(basicContract.id, contractIds))
-
- // 승인 가능한 계약서 필터링
- const eligibleContracts = contracts.filter(contract => {
- if (contract.completedAt !== null || !contract.signedFilePath) {
- return false
- }
- if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) {
- return false
- }
- return true
- })
-
- if (eligibleContracts.length === 0) {
- return {
- success: false,
- message: "최종승인 가능한 계약서가 없습니다."
- }
- }
-
- const currentDate = new Date()
- const approver = session.user.name || session.user.email || "알 수 없음"
-
- // 일괄 업데이트
- const updatedContracts = await db
- .update(basicContract)
- .set({
- buyerSignedAt: currentDate,
- completedAt: currentDate,
- status: "COMPLETED",
- updatedAt: currentDate,
- })
- .where(inArray(basicContract.id, eligibleContracts.map(c => c.id)))
- .returning({ id: basicContract.id })
-
- // 캐시 재검증
- revalidatePath("/contracts")
-
- console.log("일괄 최종승인 완료:", {
- approver,
- contractIds: updatedContracts.map(c => c.id),
- completedAt: currentDate,
- })
-
- return {
- success: true,
- message: `${updatedContracts.length}건의 계약서 최종승인이 완료되었습니다.`,
- data: {
- processedCount: updatedContracts.length,
- contractIds: updatedContracts.map(c => c.id),
- }
- }
-
- } catch (error) {
- console.error("일괄 최종승인 중 오류:", error)
-
- return {
- success: false,
- message: "최종승인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.",
- }
- }
-}
-
-
-export async function getVendorSignatureFile() {
- try {
- // 세션에서 사용자 정보 가져오기
- const session = await getServerSession(authOptions)
-
- if (!session?.user?.companyId) {
- throw new Error("인증되지 않은 사용자이거나 회사 정보가 없습니다.")
- }
-
- // 조건에 맞는 vendor attachment 찾기
- const signatureAttachment = await db.query.vendorAttachments.findFirst({
- where: and(
- eq(vendorAttachments.vendorId, session.user.companyId),
- eq(vendorAttachments.attachmentType, "SIGNATURE")
- )
- })
-
- if (!signatureAttachment) {
- return {
- success: false,
- error: "서명 파일을 찾을 수 없습니다."
- }
- }
-
- // 파일 읽기
- let filePath: string;
- const nasPath = process.env.NAS_PATH || "/evcp_nas"
-
-
- if (process.env.NODE_ENV === 'production') {
- // ✅ 프로덕션: NAS 경로 사용
- filePath = path.join(nasPath, signatureAttachment.filePath);
-
- } else {
- // 개발: public 폴더
- filePath = path.join(process.cwd(), 'public', signatureAttachment.filePath);
- }
-
- const fileBuffer = await readFile(filePath)
-
- // Base64로 인코딩
- const base64File = fileBuffer.toString('base64')
-
- return {
- success: true,
- data: {
- id: signatureAttachment.id,
- fileName: signatureAttachment.fileName,
- fileType: signatureAttachment.fileType,
- base64: base64File,
- // 웹에서 사용할 수 있는 data URL 형식도 제공
- dataUrl: `data:${signatureAttachment.fileType || 'application/octet-stream'};base64,${base64File}`
- }
- }
-
- } catch (error) {
- console.error("서명 파일 조회 중 오류:", error)
- console.log("서명 파일 조회 중 오류:", error)
-
- return {
- success: false,
- error: error instanceof Error ? error.message : "파일을 읽는 중 오류가 발생했습니다."
- }
- }
-}
-
-
-
-
-// templateName에서 project code 추출
-function extractProjectCodeFromTemplateName(templateName: string): string | null {
- if (!templateName.includes('GTC')) return null;
- if (templateName.toLowerCase().includes('general')) return null;
-
- // GTC 앞의 문자열을 추출
- const gtcIndex = templateName.indexOf('GTC');
- if (gtcIndex > 0) {
- const beforeGTC = templateName.substring(0, gtcIndex).trim();
- // 마지막 단어를 project code로 간주
- const words = beforeGTC.split(/\s+/);
- return words[words.length - 1];
- }
-
- return null;
-}
-
-// 단일 contract에 대한 GTC 정보 확인
-async function checkGTCCommentsForContract(
- templateName: string,
- vendorId: number,
- basicContractId?: number
-): Promise<{ gtcDocumentId: number | null; hasComments: boolean }> {
- try {
- const projectCode = extractProjectCodeFromTemplateName(templateName);
- let gtcDocumentId: number | null = null;
-
- console.log(projectCode,"projectCode")
-
- // 1. GTC Document ID 찾기
- if (projectCode && projectCode.trim() !== '') {
- // Project GTC인 경우
- const project = await db
- .select({ id: projects.id })
- .from(projects)
- .where(eq(projects.code, projectCode.trim()))
- .limit(1)
-
- if (project.length > 0) {
- const projectGtcDoc = await db
- .select({ id: gtcDocuments.id })
- .from(gtcDocuments)
- .where(
- and(
- eq(gtcDocuments.projectId, project[0].id),
- eq(gtcDocuments.isActive, true)
- )
- )
- .orderBy(desc(gtcDocuments.revision))
- .limit(1)
-
- if (projectGtcDoc.length > 0) {
- gtcDocumentId = projectGtcDoc[0].id
- }
- }
- } else {
- // Standard GTC인 경우 (general 포함하거나 project code가 없는 경우)
- console.log(`🔍 [checkGTCCommentsForContract] Standard GTC 조회 중...`);
- const standardGtcDoc = await db
- .select({ id: gtcDocuments.id })
- .from(gtcDocuments)
- .where(
- and(
- eq(gtcDocuments.type, "standard"),
- eq(gtcDocuments.isActive, true),
- isNull(gtcDocuments.projectId)
- )
- )
- .orderBy(desc(gtcDocuments.revision))
- .limit(1)
-
- console.log(`📊 [checkGTCCommentsForContract] Standard GTC 조회 결과: ${standardGtcDoc.length}개`);
-
- if (standardGtcDoc.length > 0) {
- gtcDocumentId = standardGtcDoc[0].id
- console.log(`✅ [checkGTCCommentsForContract] Standard GTC 찾음: ${gtcDocumentId}`);
- } else {
- console.log(`❌ [checkGTCCommentsForContract] Standard GTC 없음 - gtc_documents 테이블에 standard 타입의 활성 문서가 없습니다`);
- }
- }
-
- console.log(`🎯 [checkGTCCommentsForContract] 최종 gtcDocumentId: ${gtcDocumentId}`)
-
- // GTC Document를 찾지 못한 경우
- if (basicContractId) {
- console.log(`🔍 [checkGTCCommentsForContract] basicContractId: ${basicContractId} 로 코멘트 조회`);
- const { agreementComments } = await import("@/db/schema");
- const newComments = await db
- .select({ id: agreementComments.id })
- .from(agreementComments)
- .where(
- and(
- eq(agreementComments.basicContractId, basicContractId),
- eq(agreementComments.isDeleted, false)
- )
- )
- .limit(1);
-
- console.log(`📊 [checkGTCCommentsForContract] basicContractId ${basicContractId}: 코멘트 ${newComments.length}개 발견`);
-
- if (newComments.length > 0) {
- console.log(`✅ [checkGTCCommentsForContract] basicContractId ${basicContractId}: hasComments = true 반환`);
- return {
- gtcDocumentId, // null일 수 있음
- hasComments: true
- };
- }
-
- console.log(`⚠️ [checkGTCCommentsForContract] basicContractId ${basicContractId}: agreementComments 없음`);
- }
-
- // GTC Document를 찾지 못한 경우 (기존 방식도 체크할 수 없음)
- if (!gtcDocumentId) {
- console.log(`⚠️ [checkGTCCommentsForContract] gtcDocumentId null - 기존 방식 체크 불가`);
- return { gtcDocumentId: null, hasComments: false };
- }
-
- // 2. 코멘트 존재 여부 확인
- // gtcDocumentId로 해당 벤더의 vendor documents 찾기
- const vendorDocuments = await db
- .select({ id: gtcVendorDocuments.id })
- .from(gtcVendorDocuments)
- .where(
- and(
- eq(gtcVendorDocuments.baseDocumentId, gtcDocumentId),
- eq(gtcVendorDocuments.vendorId, vendorId),
- eq(gtcVendorDocuments.isActive, true)
- )
- )
- .limit(1)
-
- if (vendorDocuments.length === 0) {
- return { gtcDocumentId, hasComments: false };
- }
-
- // vendor document에 연결된 clauses에서 negotiation history 확인
- const commentsExist = await db
- .select({ count: gtcNegotiationHistory.id })
- .from(gtcNegotiationHistory)
- .innerJoin(
- gtcVendorClauses,
- eq(gtcNegotiationHistory.vendorClauseId, gtcVendorClauses.id)
- )
- .where(
- and(
- eq(gtcVendorClauses.vendorDocumentId, vendorDocuments[0].id),
- eq(gtcVendorClauses.isActive, true),
- isNotNull(gtcNegotiationHistory.comment),
- ne(gtcNegotiationHistory.comment, '')
- )
- )
- .limit(1)
-
- return {
- gtcDocumentId,
- hasComments: commentsExist.length > 0
- };
-
- } catch (error) {
- console.error('Error checking GTC comments for contract:', error);
- return { gtcDocumentId: null, hasComments: false };
- }
-}
-
-// // 전체 contract 리스트에 대해 GTC document ID와 comment 정보 수집
-// export async function checkGTCCommentsForContracts(
-// contracts: BasicContractView[]
-// ): Promise<Record<number, { gtcDocumentId: number | null; hasComments: boolean }>> {
-// const gtcData: Record<number, { gtcDocumentId: number | null; hasComments: boolean }> = {};
-
-// // GTC가 포함된 contract만 필터링
-// const gtcContracts = contracts.filter(contract =>
-// contract.templateName?.includes('GTC')
-// );
-
-// if (gtcContracts.length === 0) {
-// return gtcData;
-// }
-
-// // Promise.all을 사용해서 병렬 처리
-// const checkPromises = gtcContracts.map(async (contract) => {
-// try {
-// const result = await checkGTCCommentsForContract(
-// contract.templateName!,
-// contract.vendorId!
-// );
-
-// return {
-// contractId: contract.id,
-// gtcDocumentId: result.gtcDocumentId,
-// hasComments: result.hasComments
-// };
-// } catch (error) {
-// console.error(`Error checking GTC for contract ${contract.id}:`, error);
-// return {
-// contractId: contract.id,
-// gtcDocumentId: null,
-// hasComments: false
-// };
-// }
-// });
-
-// const results = await Promise.all(checkPromises);
-
-// // 결과를 Record 형태로 변환
-// results.forEach(({ contractId, gtcDocumentId, hasComments }) => {
-// gtcData[contractId] = { gtcDocumentId, hasComments };
-// });
-
-// return gtcData;
-// }
-
-
-
-export async function updateVendorDocumentStatus(
- formData: FormData | {
- status: string;
- vendorDocumentId: number;
- documentId: number;
- vendorId: number;
- }
-) {
- try {
- // 세션 확인
- const session = await getServerSession(authOptions)
- if (!session?.user) {
- return { success: false, error: "인증되지 않은 사용자입니다." }
- }
-
- // 데이터 파싱
- const rawData = formData instanceof FormData
- ? {
- status: formData.get("status") as string,
- vendorDocumentId: Number(formData.get("vendorDocumentId")),
- documentId: Number(formData.get("documentId")),
- vendorId: Number(formData.get("vendorId")),
- }
- : formData
-
- // 유효성 검사
- const validatedData = updateStatusSchema.safeParse(rawData)
- if (!validatedData.success) {
- return { success: false, error: "유효하지 않은 데이터입니다." }
- }
-
- const { status, vendorDocumentId, documentId, vendorId } = validatedData.data
-
- // 완료 상태로 변경 시, 모든 조항이 approved 상태인지 확인
- if (status === "complete") {
- // 승인되지 않은 조항 확인
- const pendingClauses = await db
- .select({ id: gtcVendorClauses.id })
- .from(gtcVendorClauses)
- .where(
- and(
- eq(gtcVendorClauses.vendorDocumentId, vendorDocumentId),
- eq(gtcVendorClauses.isActive, true),
- not(eq(gtcVendorClauses.reviewStatus, "approved")),
- not(eq(gtcVendorClauses.isExcluded, true)) // 제외된 조항은 검사에서 제외
- )
- )
- .limit(1)
-
- if (pendingClauses.length > 0) {
- return {
- success: false,
- error: "모든 조항이 승인되어야 협의 완료 처리가 가능합니다."
- }
- }
- }
-
- // 업데이트 실행
- await db
- .update(gtcVendorDocuments)
- .set({
- reviewStatus: status,
- updatedAt: new Date(),
- updatedById: Number(session.user.id),
- // 완료 처리 시 협의 종료일 설정
- ...(status === "complete" ? {
- negotiationEndDate: new Date(),
- approvalDate: new Date()
- } : {})
- })
- .where(eq(gtcVendorDocuments.id, vendorDocumentId))
-
- // 캐시 무효화
- // revalidatePath(`/evcp/gtc/${documentId}?vendorId=${vendorId}`)
-
- return { success: true }
- } catch (error) {
- console.error("Error updating vendor document status:", error)
- return { success: false, error: "상태 업데이트 중 오류가 발생했습니다." }
- }
-}
-
-
-interface SaveDocumentParams {
- documentId: number
- pdfBuffer: Uint8Array
- originalFileName: string
- vendor: {
- vendorName: string
- }
- userId: number
-}
-
-export async function saveGtcDocumentAction({
- documentId,
- pdfBuffer,
- originalFileName,
- vendor
-}: SaveDocumentParams) {
- try {
- console.log("📄 GTC 문서 저장 시작:", {
- documentId,
- documentIdType: typeof documentId,
- vendorName: vendor.vendorName,
- originalFileName,
- bufferSize: pdfBuffer.length
- })
-
- // documentId 유효성 검사
- if (!documentId || isNaN(Number(documentId))) {
- throw new Error(`유효하지 않은 문서 ID: ${documentId}`)
- }
-
- // 기본계약 존재 여부 확인
- const existingContract = await db.query.basicContract.findFirst({
- where: eq(basicContract.id, documentId),
- })
-
- if (!existingContract) {
- throw new Error(`기본계약을 찾을 수 없습니다. ID: ${documentId}`)
- }
-
- console.log("📋 기존 계약 정보:", {
- contractId: existingContract.id,
- templateId: existingContract.templateId,
- vendorId: existingContract.vendorId,
- currentStatus: existingContract.status,
- currentFileName: existingContract.fileName,
- currentFilePath: existingContract.filePath
- })
-
- const session = await getServerSession(authOptions)
-
- if (!session?.user) {
- return { success: false, error: "인증되지 않은 사용자입니다." }
- }
- const userId = Number(session.user.id);
-
- // 1. PDF 파일명 생성
- const baseName = originalFileName.replace(/\.[^/.]+$/, "") // 확장자 제거
- const fileName = `GTC_${vendor.vendorName}_${baseName}_${new Date().toISOString().split('T')[0]}.pdf`
-
- // 2. 파일 저장 (공용 파일 저장 함수 사용)
- const saveResult = await saveBuffer({
- buffer: Buffer.from(pdfBuffer),
- fileName,
- directory: 'basicContract',
- originalName: fileName,
- userId: userId.toString()
- })
-
- if (!saveResult.success) {
- throw new Error(saveResult.error || '파일 저장 실패')
- }
-
- // 3. 데이터베이스 업데이트 - 트랜잭션으로 처리하고 결과 확인
- const updateResult = await db.update(basicContract)
- .set({
- fileName: saveResult.fileName!,
- filePath: saveResult.publicPath!,
- status: 'PENDING',
- // 기존 서명 관련 timestamp들 리셋
- vendorSignedAt: null,
- buyerSignedAt: null,
- legalReviewRequestedAt: null,
- legalReviewCompletedAt: null,
- updatedAt: new Date()
- })
- .where(eq(basicContract.id, documentId))
- .returning({
- id: basicContract.id,
- fileName: basicContract.fileName,
- filePath: basicContract.filePath
- })
-
- // DB 업데이트 성공 여부 확인
- if (!updateResult || updateResult.length === 0) {
- throw new Error(`기본계약 ID ${documentId}를 찾을 수 없거나 업데이트에 실패했습니다.`)
- }
-
- console.log("✅ GTC 문서 저장 완료:", {
- documentId,
- updatedRecord: updateResult[0],
- fileName: saveResult.fileName,
- filePath: saveResult.publicPath,
- fileSize: saveResult.fileSize
- })
-
- // 캐시 무효화
- revalidateTag("basic-contract-requests")
- revalidateTag("basic-contracts")
- revalidatePath("/partners/basic-contract")
-
- return {
- success: true,
- fileName: saveResult.fileName,
- filePath: saveResult.publicPath,
- fileSize: saveResult.fileSize,
- documentId: updateResult[0].id
- }
-
- } catch (error) {
- console.error("❌ GTC 문서 저장 실패:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '문서 저장 중 오류가 발생했습니다'
- }
- }
+"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,like } from "drizzle-orm"; +import { v4 as uuidv4 } from "uuid"; +import { + basicContract, + BasicContractTemplate, + basicContractTemplates, + basicContractView, + complianceQuestionOptions, + complianceQuestions, + complianceResponseAnswers, + complianceResponseFiles, + complianceResponses, + complianceSurveyTemplates, + vendorAttachments, basicContractTemplateStatsView, + type BasicContractTemplate as DBBasicContractTemplate, + type NewComplianceResponse, + type NewComplianceResponseAnswer, + type NewComplianceResponseFile, + gtcVendorDocuments, + gtcVendorClauses, + gtcClauses, + gtcDocuments, + vendors, + vendorContacts, + gtcNegotiationHistory, + type GtcVendorClause, + type GtcClause, + projects, + legalWorks, + BasicContractView, users +} from "@/db/schema"; +import path from "path"; + +import { + GetBasicContractTemplatesSchema, + CreateBasicContractTemplateSchema, + GetBasciContractsSchema, + GetBasciContractsVendorSchema, + GetBasciContractsByIdSchema, + updateStatusSchema, +} from "./validations"; +import { readFile } from "fs/promises" + +import { + insertBasicContractTemplate, + selectBasicContractTemplates, + countBasicContractTemplates, + deleteBasicContractTemplates, + getBasicContractTemplateById, + selectBasicContracts, + countBasicContracts, + findAllTemplates, + countBasicContractsById, + selectBasicContractsById, + selectBasicContractsVendor, + countBasicContractsVendor +} 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"; +import { deleteFile, saveBuffer, saveFile, saveDRMFile } from "@/lib/file-stroage"; +import { decryptWithServerAction } from "@/components/drm/drmUtils"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + + +// 템플릿 추가 +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 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: "파일은 필수입니다." }; + } + + const saveResult = await saveFile({file, directory:"basicContract/template" }); + + if (!saveResult.success) { + return { success: false, error: saveResult.error }; + } + + // DB에 저장할 데이터 구성 + const formattedData = { + templateName, + status, + fileName: file.name, + filePath: saveResult.publicPath! + }; + + // DB에 저장 + const { data, error } = await createBasicContractTemplate(formattedData); + + if (error) { + // DB 저장 실패 시 파일 삭제 + await deleteFile(saveResult.publicPath!); + 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) => { + const advancedWhere = filterColumns({ + table: basicContractTemplates, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + let globalWhere = undefined; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(basicContractTemplates.templateName, s), + ilike(basicContractTemplates.fileName, s), + ilike(basicContractTemplates.status, s) + ); + } + + const whereCondition = and(advancedWhere, globalWhere); + + 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 [row] = await insertBasicContractTemplate(tx, { + templateName: input.templateName, + revision: input.revision || 1, + status: input.status || "ACTIVE", + + // 📝 null 처리 추가 + fileName: input.fileName || null, + filePath: input.filePath || null, + }); + return row; + }); + + return { data: newTemplate, error: null }; + } catch (error) { + console.log(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}`; + + // ArrayBuffer를 File 객체로 변환 + const file = new File([fileBuffer], originalName); + + // ✅ 서명된 계약서 저장 + // 개발: /project/public/basicContract/signed/ + // 프로덕션: /nas_evcp/basicContract/signed/ + const saveResult = await saveFile({file,directory: "basicContract/signed" ,originalName:originalName}); + + if (!saveResult.success) { + return { result: false, error: saveResult.error! }; + } + + console.log(`✅ 서명된 계약서 저장됨: ${saveResult.filePath}`); + + await db.transaction(async (tx) => { + await tx + .update(basicContract) + .set({ + status: "COMPLETED", + fileName: originalName, + filePath: saveResult.publicPath, // 웹 접근 경로 저장 + }) + .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) { + const deleted = await deleteFile(template.filePath); + + if (deleted) { + console.log(`✅ 파일 삭제됨: ${template.filePath}`); + } else { + console.log(`⚠️ 파일 삭제 실패: ${template.filePath}`); + } + } + + + 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; +} + +const SCOPE_KEYS = [ + "shipBuildingApplicable", + "windApplicable", + "pcApplicable", + "nbApplicable", + "rcApplicable", + "gyApplicable", + "sysApplicable", + "infraApplicable", +] as const; + +function getBool(fd: FormData, key: string, defaultValue = false) { + const v = fd.get(key); + if (v === null) return defaultValue; + return v === "true"; +} + +export async function updateTemplate({ + id, + formData +}: UpdateTemplateParams): Promise<{ success?: boolean; error?: string }> { + unstable_noStore(); + + try { + // 기존 템플릿 조회 (revision 유지 및 중복 체크를 위해) + const existingTemplate = await db.query.basicContractTemplates.findFirst({ + where: eq(basicContractTemplates.id, id), + }); + + if (!existingTemplate) { + return { error: "템플릿을 찾을 수 없습니다." }; + } + + // 필수값 + const templateName = formData.get("templateName") as string | null; + if (!templateName) { + return { error: "템플릿 이름은 필수입니다." }; + } + + // revision 처리: FormData에 있으면 사용, 없으면 기존 값 유지 + const revisionStr = formData.get("revision")?.toString(); + const revision = revisionStr ? Number(revisionStr) : existingTemplate.revision; + + // templateName과 revision 조합이 unique이므로, 다른 레코드와 중복되는지 확인 + if (templateName !== existingTemplate.templateName || revision !== existingTemplate.revision) { + const duplicateCheck = await db.query.basicContractTemplates.findFirst({ + where: and( + eq(basicContractTemplates.templateName, templateName), + eq(basicContractTemplates.revision, revision), + ne(basicContractTemplates.id, id) // 자기 자신은 제외 + ), + }); + + if (duplicateCheck) { + return { + error: `템플릿 이름 "${templateName}"과 리비전 ${revision} 조합이 이미 존재합니다. 다른 리비전을 사용하거나 템플릿 이름을 변경해주세요.` + }; + } + } + + const legalReviewRequired = getBool(formData, "legalReviewRequired", false); + + // status는 프런트에서 ACTIVE만 넣고 있으나, 없으면 기존값 유지 or 기본값 설정 + const status = (formData.get("status") as "ACTIVE" | "INACTIVE" | null) ?? "ACTIVE"; + + // validityPeriod가 이제 필요없다면 제거하시고, 사용한다면 파싱 그대로 + const validityPeriodStr = formData.get("validityPeriod")?.toString(); + const validityPeriod = validityPeriodStr ? Number(validityPeriodStr) : undefined; + + // Scope booleans + const scopeData: Record<string, boolean> = {}; + for (const key of SCOPE_KEYS) { + scopeData[key] = getBool(formData, key, false); + } + + // 파일 처리 + const file = formData.get("file") as File | null; + let fileName: string | undefined = undefined; + let filePath: string | undefined = undefined; + + if (file) { + // 1) 새 파일 저장 (DRM 해제 로직 적용) + const saveResult = await saveDRMFile( + file, + decryptWithServerAction, + 'basicContract/template' + ); + + if (!saveResult.success) { + return { success: false, error: saveResult.error }; + } + fileName = file.name; + filePath = saveResult.publicPath; + + // 2) 기존 파일 삭제 (existingTemplate은 이미 위에서 조회됨) + if (existingTemplate?.filePath) { + const deleted = await deleteFile(existingTemplate.filePath); + if (deleted) { + console.log(`✅ 기존 파일 삭제됨: ${existingTemplate.filePath}`); + } else { + console.log(`⚠️ 기존 파일 삭제 실패: ${existingTemplate.filePath}`); + } + } + } + + // 업데이트할 데이터 구성 + const updateData: Record<string, any> = { + templateName, + revision, + legalReviewRequired, + status, + updatedAt: new Date(), + ...scopeData, + }; + + if (validityPeriod !== undefined) { + updateData.validityPeriod = validityPeriod; + } + if (fileName && filePath) { + updateData.fileName = fileName; + updateData.filePath = filePath; + } + + // 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 { + const isComplianceTemplate = template.templateName?.includes('준법'); + let selectedTemplateId = template.id; + let selectedTemplate = template; + + if (isComplianceTemplate) { + const vendorUser = await db.query.users.findFirst({ + where: and( + eq(users.email, vendor.email), + eq(users.domain, 'partners') + ) + }); + + const userLanguage = vendorUser?.language || 'en'; // 기본값은 영어 + + if (userLanguage === 'ko') { + // 한글 준법서약 템플릿 찾기 + const koreanTemplate = await db.query.basicContractTemplates.findFirst({ + where: and( + sql`${basicContractTemplates.templateName} LIKE '%준법%'`, + sql`${basicContractTemplates.templateName} NOT LIKE '%영문%'`, + eq(basicContractTemplates.status, 'ACTIVE') + ) + }); + + if (koreanTemplate) { + selectedTemplateId = koreanTemplate.id; + selectedTemplate = koreanTemplate; + } + } else { + // 영문 준법서약 템플릿 찾기 + const englishTemplate = await db.query.basicContractTemplates.findFirst({ + where: and( + sql`${basicContractTemplates.templateName} LIKE '%준법%'`, + sql`${basicContractTemplates.templateName} LIKE '%영문%'`, + eq(basicContractTemplates.status, 'ACTIVE') + ) + }); + + if (englishTemplate) { + selectedTemplateId = englishTemplate.id; + selectedTemplate = englishTemplate; + console.log(`✅ 영어 사용자 ${vendor.vendorName}에게 영문 준법서약 템플릿 전송`); + } + } + } + + // 3-1. basic_contract 테이블에 레코드 추가 + const [newContract] = await db + .insert(basicContract) + .values({ + templateId: selectedTemplateId, // 언어별로 선택된 템플릿 ID 사용 + vendorId: vendor.id, + requestedBy: requestedBy, + status: "PENDING", + fileName: selectedTemplate.fileName, // 선택된 템플릿 파일 이름 사용 + filePath: selectedTemplate.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: basicContractTemplateStatsView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(basicContractTemplateStatsView.templateName, 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(basicContractTemplateStatsView[item.id]) : asc(basicContractTemplateStatsView[item.id]) + ) + : [asc(basicContractTemplateStatsView.lastActivityDate)]; + + // 트랜잭션 내부에서 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) { + console.log(err) + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["basicContractTemplateStatsView"], // revalidateTag("basicContractTemplateStatsView") 호출 시 무효화 + } + )(); +} + + +export async function getBasicContractsByVendorId( + input: GetBasciContractsVendorSchema, + 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 selectBasicContractsVendor(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countBasicContractsVendor(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 getBasicContractsByTemplateId( + input: GetBasciContractsByIdSchema, + templateId: number +) { + // return unstable_cache( + // async () => { + try { + + console.log(input.sort) + 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 templateCondition = eq(basicContractView.templateId, templateId); + + const finalWhere = and( + // 항상 벤더 ID 조건을 포함 + templateCondition, + // 기존 조건들 + 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 selectBasicContractsById(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countBasicContractsById(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트\ + console.log(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 : "계약 상태 확인 중 오류가 발생했습니다." + }; + } +} + + +/** + * ID로 기본계약서 템플릿 조회 + */ +export async function getBasicContractTemplateByIdService(id: string) { + try { + const templateId = parseInt(id); + + if (isNaN(templateId)) { + return null; + } + + const templates = await db + .select() + .from(basicContractTemplates) + .where(eq(basicContractTemplates.id, templateId)) + .limit(1); + + if (templates.length === 0) { + return null; + } + + return templates[0]; + } catch (error) { + console.error("템플릿 조회 오류:", error); + return null; + } +} + +/** + * 템플릿 파일 저장 서버 액션 + */ +export async function saveTemplateFile(templateId: number, formData: FormData) { + try { + const file = formData.get("file") as File; + + if (!file) { + return { error: "파일이 필요합니다." }; + } + + // 기존 템플릿 정보 조회 + const existingTemplate = await db + .select() + .from(basicContractTemplates) + .where(eq(basicContractTemplates.id, templateId)) + .limit(1); + + if (existingTemplate.length === 0) { + return { error: "템플릿을 찾을 수 없습니다." }; + } + + const template = existingTemplate[0]; + + // 파일 저장 로직 (실제 파일 시스템에 저장) + const { writeFile, mkdir } = await import("fs/promises"); + const { join } = await import("path"); + + const bytes = await file.arrayBuffer(); + const buffer = Buffer.from(bytes); + + // 기존 파일 경로 사용 (덮어쓰기) + const uploadPath = join(process.cwd(), "public", template.filePath.replace(/^\//, "")); + + // 디렉토리 확인 및 생성 + const dirPath = uploadPath.substring(0, uploadPath.lastIndexOf('/')); + await mkdir(dirPath, { recursive: true }); + + // 파일 저장 + await writeFile(uploadPath, buffer); + + // 데이터베이스 업데이트 (수정일시만 업데이트) + await db + .update(basicContractTemplates) + .set({ + updatedAt: new Date(), + }) + .where(eq(basicContractTemplates.id, templateId)); + + // 캐시 무효화 + revalidatePath(`/evcp/basic-contract-template/${templateId}`); + revalidateTag("basic-contract-templates"); + + return { success: true, message: "템플릿이 성공적으로 저장되었습니다." }; + + } catch (error) { + console.error("템플릿 저장 오류:", error); + return { error: "저장 중 오류가 발생했습니다." }; + } +} + +/** + * 템플릿 페이지 새로고침 서버 액션 + */ +export async function refreshTemplatePage(templateId: string) { + revalidatePath(`/evcp/basic-contract-template/${templateId}`); + revalidateTag("basic-contract-templates"); +} + +// 새 리비전 생성 함수 +export async function createBasicContractTemplateRevision(input: any) { + unstable_noStore(); + + try { + // 기본 템플릿 존재 확인 + const baseTemplate = await db + .select() + .from(basicContractTemplates) + .where(eq(basicContractTemplates.id, input.baseTemplateId)) + .limit(1); + + if (baseTemplate.length === 0) { + return { data: null, error: "기본 템플릿을 찾을 수 없습니다." }; + } + + // 같은 템플릿 이름에 해당 리비전이 이미 존재하는지 확인 + const existingRevision = await db + .select() + .from(basicContractTemplates) + .where( + and( + eq(basicContractTemplates.templateName, input.templateName), + eq(basicContractTemplates.revision, input.revision) + ) + ) + .limit(1); + + if (existingRevision.length > 0) { + return { + data: null, + error: `${input.templateName} v${input.revision} 리비전이 이미 존재합니다.` + }; + } + + // 새 리비전이 기존 리비전들보다 큰 번호인지 확인 + const maxRevision = await db + .select({ maxRev: basicContractTemplates.revision }) + .from(basicContractTemplates) + .where(eq(basicContractTemplates.templateName, input.templateName)) + .orderBy(desc(basicContractTemplates.revision)) + .limit(1); + + if (maxRevision.length > 0 && input.revision <= maxRevision[0].maxRev) { + return { + data: null, + error: `새 리비전 번호는 현재 최대 리비전(v${maxRevision[0].maxRev})보다 커야 합니다.` + }; + } + + const newRevision = await db.transaction(async (tx) => { + const [row] = await insertBasicContractTemplate(tx, { + templateName: input.templateName, + revision: input.revision, + legalReviewRequired: input.legalReviewRequired, + status: "ACTIVE", + fileName: input.fileName, + filePath: input.filePath, + validityPeriod: null, + }); + return row; + }); + //기존 템플릿의 이전 리비전은 비활성으로 변경 + await db.update(basicContractTemplates).set({ + status: "DISPOSED", + }).where(and(eq(basicContractTemplates.templateName, input.templateName),ne(basicContractTemplates.revision, input.revision))); + //캐시 무효화 + revalidateTag("basic-contract-templates"); + + return { data: newRevision, error: null }; + } catch (error) { + return { data: null, error: getErrorMessage(error) }; + } +} + + + + +// 1) 전체 basicContractTemplates 조회 +export async function getALLBasicContractTemplates() { + return db + .select() + .from(basicContractTemplates) + .where(eq(basicContractTemplates.status,"ACTIVE")) + .orderBy(desc(basicContractTemplates.createdAt)); +} + +// 2) 등록된 templateName만 중복 없이 가져오기 +export async function getExistingTemplateNames(): Promise<string[]> { + try { + const templates = await db + .select({ + templateName: basicContractTemplates.templateName + }) + .from(basicContractTemplates) + .where( + and( + eq(basicContractTemplates.status, 'ACTIVE'), + // GTC가 아닌 것들만 중복 체크 (GTC는 프로젝트별로 여러 개 허용) + not(like(basicContractTemplates.templateName, '% GTC')) + ) + ); + + return templates.map(t => t.templateName); + } catch (error) { + console.error('Failed to fetch existing template names:', error); + throw new Error('기존 템플릿 이름을 가져오는데 실패했습니다.'); + } +} + +export async function getExistingTemplateNamesById(id:number): Promise<string> { + const rows = await db + .select({ + templateName: basicContractTemplates.templateName, + }) + .from(basicContractTemplates) + .where(and(eq(basicContractTemplates.status,"ACTIVE"),eq(basicContractTemplates.id,id))) + .limit(1) + + return rows[0].templateName; +} + +export async function getVendorAttachments(vendorId: number) { + try { + const attachments = await db + .select() + .from(vendorAttachments) + .where( + and( + eq(vendorAttachments.vendorId, vendorId), + eq(vendorAttachments.attachmentType, "NDA_ATTACHMENT") + ) + ); + + console.log(attachments,"attachments") + + return { + success: true, + data: attachments + }; + } catch (error) { + console.error("Error fetching vendor attachments:", error); + return { + success: false, + data: [], + error: "Failed to fetch vendor attachments" + }; + } +} + +// 설문조사 템플릿 전체 데이터 타입 +export interface SurveyTemplateWithQuestions { + id: number; + name: string; + description: string | null; + version: string; + questions: SurveyQuestion[]; +} + +export interface SurveyQuestion { + id: number; + questionNumber: string; + questionText: string; + questionType: string; + isRequired: boolean; + hasDetailText: boolean; + hasFileUpload: boolean; + parentQuestionId: number | null; + conditionalValue: string | null; + displayOrder: number; + options: SurveyQuestionOption[]; +} + +export interface SurveyQuestionOption { + id: number; + optionValue: string; + optionText: string; + allowsOtherInput: boolean; + displayOrder: number; +} + +/** + * 활성화된 첫 번째 설문조사 템플릿과 관련 데이터를 모두 가져오기 + */ +export async function getActiveSurveyTemplate(language: string = 'ko'): Promise<SurveyTemplateWithQuestions | null> { + try { + // 1. 활성화된 템플릿 가져오기 (언어에 따라 필터링) + const templates = await db + .select() + .from(complianceSurveyTemplates) + .where(eq(complianceSurveyTemplates.isActive, true)) + .orderBy(complianceSurveyTemplates.id); + + if (!templates || templates.length === 0) { + console.log('활성화된 설문조사 템플릿이 없습니다.'); + return null; + } + + // 언어에 따라 적절한 템플릿 선택 + let templateData; + if (language === 'en') { + // 영문 템플릿 찾기 (이름에 '영문' 또는 'English' 포함) + templateData = templates.find(t => + t.name.includes('영문') || + t.name.toLowerCase().includes('english') || + t.name.toLowerCase().includes('en') + ); + } else { + // 한글 템플릿 찾기 (영문이 아닌 것) + templateData = templates.find(t => + !t.name.includes('영문') && + !t.name.toLowerCase().includes('english') && + !t.name.toLowerCase().includes('en') + ); + } + + // 적절한 템플릿을 찾지 못하면 첫 번째 템플릿 사용 + if (!templateData) { + console.log(`언어 '${language}'에 맞는 템플릿을 찾지 못해 기본 템플릿을 사용합니다.`); + templateData = templates[0]; + } + + console.log(`✅ 선택된 설문조사 템플릿: ${templateData.name} (언어: ${language})`); + + // 2. 해당 템플릿의 모든 질문 가져오기 (displayOrder 순) + const questions = await db + .select() + .from(complianceQuestions) + .where(eq(complianceQuestions.templateId, templateData.id)) + .orderBy(asc(complianceQuestions.displayOrder)); + + // 3. 각 질문의 옵션들 가져오기 + const questionIds = questions.map(q => q.id); + const allOptions = questionIds.length > 0 + ? await db + .select() + .from(complianceQuestionOptions) + .where(inArray(complianceQuestionOptions.questionId, questionIds)) + .orderBy( + complianceQuestionOptions.questionId, + asc(complianceQuestionOptions.displayOrder) + ) + : []; + + + // 4. 질문별로 옵션들 그룹화 + const optionsByQuestionId = allOptions.reduce((acc, option) => { + if (!acc[option.questionId]) { + acc[option.questionId] = []; + } + acc[option.questionId].push({ + id: option.id, + optionValue: option.optionValue, + optionText: option.optionText, + allowsOtherInput: option.allowsOtherInput, + displayOrder: option.displayOrder, + }); + return acc; + }, {} as Record<number, SurveyQuestionOption[]>); + + // 5. 최종 데이터 구성 + const questionsWithOptions: SurveyQuestion[] = questions.map(question => ({ + id: question.id, + questionNumber: question.questionNumber, + questionText: question.questionText, + questionType: question.questionType, + isRequired: question.isRequired, + hasDetailText: question.hasDetailText, + hasFileUpload: question.hasFileUpload, + parentQuestionId: question.parentQuestionId, + conditionalValue: question.conditionalValue, + displayOrder: question.displayOrder, + options: optionsByQuestionId[question.id] || [], + })); + + return { + id: templateData.id, + name: templateData.name, + description: templateData.description, + version: templateData.version, + questions: questionsWithOptions, + }; + + } catch (error) { + console.error('설문조사 템플릿 로드 실패:', error); + return null; + } +} + +/** + * 특정 템플릿 ID로 설문조사 템플릿 가져오기 + */ +export async function getSurveyTemplateById(templateId: number): Promise<SurveyTemplateWithQuestions | null> { + try { + const template = await db + .select() + .from(complianceSurveyTemplates) + .where(eq(complianceSurveyTemplates.id, templateId)) + .limit(1); + + if (!template || template.length === 0) { + return null; + } + + const templateData = template[0]; + + const questions = await db + .select() + .from(complianceQuestions) + .where(eq(complianceQuestions.templateId, templateId)) + .orderBy(asc(complianceQuestions.displayOrder)); + + const questionIds = questions.map(q => q.id); + const allOptions = questionIds.length > 0 + ? await db + .select() + .from(complianceQuestionOptions) + .where( + complianceQuestionOptions.questionId.in ? + complianceQuestionOptions.questionId.in(questionIds) : + eq(complianceQuestionOptions.questionId, questionIds[0]) + ) + .orderBy( + complianceQuestionOptions.questionId, + asc(complianceQuestionOptions.displayOrder) + ) + : []; + + const optionsByQuestionId = allOptions.reduce((acc, option) => { + if (!acc[option.questionId]) { + acc[option.questionId] = []; + } + acc[option.questionId].push({ + id: option.id, + optionValue: option.optionValue, + optionText: option.optionText, + allowsOtherInput: option.allowsOtherInput, + displayOrder: option.displayOrder, + }); + return acc; + }, {} as Record<number, SurveyQuestionOption[]>); + + const questionsWithOptions: SurveyQuestion[] = questions.map(question => ({ + id: question.id, + questionNumber: question.questionNumber, + questionText: question.questionText, + questionType: question.questionType, + isRequired: question.isRequired, + hasDetailText: question.hasDetailText, + hasFileUpload: question.hasFileUpload, + parentQuestionId: question.parentQuestionId, + conditionalValue: question.conditionalValue, + displayOrder: question.displayOrder, + options: optionsByQuestionId[question.id] || [], + })); + + return { + id: templateData.id, + name: templateData.name, + description: templateData.description, + version: templateData.version, + questions: questionsWithOptions, + }; + + } catch (error) { + console.error('설문조사 템플릿 로드 실패:', error); + return null; + } +} + + +// 설문 답변 데이터 타입 정의 +export interface SurveyAnswerData { + questionId: number; + answerValue?: string; + detailText?: string; + otherText?: string; + files?: File[]; +} + +// 설문조사 완료 요청 데이터 타입 +export interface CompleteSurveyRequest { + contractId: number; + templateId: number; + answers: SurveyAnswerData[]; + progressStatus?: any; // 진행 상태 정보 (옵션) +} + +// 서버 액션: 설문조사 완료 처리 +export async function completeSurvey(data: CompleteSurveyRequest) { + try { + console.log('🚀 설문조사 완료 처리 시작:', { + contractId: data.contractId, + templateId: data.templateId, + answersCount: data.answers?.length || 0 + }); + + // 입력 검증 + if (!data.contractId || !data.templateId || !data.answers?.length) { + throw new Error('필수 데이터가 누락되었습니다.'); + } + + // 트랜잭션으로 처리 + const result = await db.transaction(async (tx) => { + // 1. complianceResponses 테이블 upsert + console.log('📋 complianceResponses 처리 중...'); + + // 기존 응답 확인 + const existingResponse = await tx + .select() + .from(complianceResponses) + .where( + and( + eq(complianceResponses.basicContractId, data.contractId), + eq(complianceResponses.templateId, data.templateId) + ) + ) + .limit(1); + + let responseId: number; + + if (existingResponse.length > 0) { + // 기존 응답 업데이트 + const updateData = { + status: 'COMPLETED' as const, + completedAt: new Date(), + updatedAt: new Date() + }; + + await tx + .update(complianceResponses) + .set(updateData) + .where(eq(complianceResponses.id, existingResponse[0].id)); + + responseId = existingResponse[0].id; + console.log(`✅ 기존 응답 업데이트 완료: ID ${responseId}`); + } else { + // 새 응답 생성 + const newResponse: NewComplianceResponse = { + basicContractId: data.contractId, + templateId: data.templateId, + status: 'COMPLETED', + completedAt: new Date() + }; + + const insertResult = await tx + .insert(complianceResponses) + .values(newResponse) + .returning({ id: complianceResponses.id }); + + responseId = insertResult[0].id; + console.log(`✅ 새 응답 생성 완료: ID ${responseId}`); + } + + // 2. 기존 답변들 삭제 (파일도 함께 삭제됨 - CASCADE 설정 필요) + console.log('🗑️ 기존 답변들 삭제 중...'); + + // 먼저 기존 답변에 연결된 파일들 삭제 + const existingAnswers = await tx + .select({ id: complianceResponseAnswers.id }) + .from(complianceResponseAnswers) + .where(eq(complianceResponseAnswers.responseId, responseId)); + + if (existingAnswers.length > 0) { + const answerIds = existingAnswers.map(a => a.id); + + // 파일들 먼저 삭제 + for (const answerId of answerIds) { + await tx + .delete(complianceResponseFiles) + .where(eq(complianceResponseFiles.answerId, answerId)); + } + + // 답변들 삭제 + await tx + .delete(complianceResponseAnswers) + .where(eq(complianceResponseAnswers.responseId, responseId)); + } + + // 3. 새로운 답변들 생성 + console.log('📝 새로운 답변들 생성 중...'); + const createdAnswers: { questionId: number; answerId: number; files?: File[] }[] = []; + + for (const answer of data.answers) { + // 빈 답변은 스킵 (선택적 질문의 경우) + if (!answer.answerValue && !answer.detailText && (!answer.files || answer.files.length === 0)) { + continue; + } + + const newAnswer: NewComplianceResponseAnswer = { + responseId, + questionId: answer.questionId, + answerValue: answer.answerValue || null, + detailText: answer.detailText || null, + otherText: answer.otherText || null, + // percentageValue는 필요시 추가 처리 + }; + + const answerResult = await tx + .insert(complianceResponseAnswers) + .values(newAnswer) + .returning({ id: complianceResponseAnswers.id }); + + const answerId = answerResult[0].id; + + createdAnswers.push({ + questionId: answer.questionId, + answerId, + files: answer.files + }); + + console.log(`✅ 답변 생성: 질문 ${answer.questionId} -> 답변 ID ${answerId}`); + } + + // 4. 파일 업로드 처리 (실제 파일 저장은 별도 로직 필요) + console.log('📎 파일 업로드 처리 중...'); + + for (const answerWithFiles of createdAnswers) { + if (answerWithFiles.files && answerWithFiles.files.length > 0) { + for (const file of answerWithFiles.files) { + // TODO: 실제 파일 저장 로직 구현 필요 + // 현재는 파일 메타데이터만 저장 + + + // 파일 저장 경로 생성 (예시) + const fileName = file.name; + const filePath = `/uploads/compliance/${data.contractId}/${responseId}/${answerWithFiles.answerId}/${fileName}`; + + const fileUpload = await saveFile({file,filePath }) + + const newFile: NewComplianceResponseFile = { + answerId: answerWithFiles.answerId, + fileName, + filePath, + fileSize: file.size, + mimeType: file.type || 'application/octet-stream' + }; + + await tx + .insert(complianceResponseFiles) + .values(newFile); + + console.log(`📎 파일 메타데이터 저장: ${fileName}`); + } + } + } + + return { + responseId, + answersCount: createdAnswers.length, + success: true + }; + }); + + console.log('🎉 설문조사 완료 처리 성공:', result); + + + return { + success: true, + message: '설문조사가 성공적으로 완료되었습니다.', + data: result + }; + + } catch (error) { + console.error('❌ 설문조사 완료 처리 실패:', error); + + return { + success: false, + message: error instanceof Error ? error.message : '설문조사 저장에 실패했습니다.', + data: null + }; + } +} + +// 설문조사 응답 조회 서버 액션 +export async function getSurveyResponse(contractId: number, templateId: number) { + try { + const response = await db + .select() + .from(complianceResponses) + .where( + and( + eq(complianceResponses.basicContractId, contractId), + eq(complianceResponses.templateId, templateId) + ) + ) + .limit(1); + + if (response.length === 0) { + return { success: true, data: null }; + } + + // 답변들과 파일들도 함께 조회 + const answers = await db + .select({ + id: complianceResponseAnswers.id, + questionId: complianceResponseAnswers.questionId, + answerValue: complianceResponseAnswers.answerValue, + detailText: complianceResponseAnswers.detailText, + otherText: complianceResponseAnswers.otherText, + percentageValue: complianceResponseAnswers.percentageValue, + }) + .from(complianceResponseAnswers) + .where(eq(complianceResponseAnswers.responseId, response[0].id)); + + // 각 답변의 파일들 조회 + const answersWithFiles = await Promise.all( + answers.map(async (answer) => { + const files = await db + .select() + .from(complianceResponseFiles) + .where(eq(complianceResponseFiles.answerId, answer.id)); + + return { + ...answer, + files + }; + }) + ); + + return { + success: true, + data: { + response: response[0], + answers: answersWithFiles + } + }; + + } catch (error) { + console.error('❌ 설문조사 응답 조회 실패:', error); + + return { + success: false, + message: error instanceof Error ? error.message : '설문조사 응답 조회에 실패했습니다.', + data: null + }; + } +} + +// 파일 업로드를 위한 별도 서버 액션 (실제 파일 저장 로직) +export async function uploadSurveyFile(file: File, contractId: number, answerId: number) { + try { + // TODO: 실제 파일 저장 구현 + // 예: AWS S3, 로컬 파일시스템, 등등 + + // 현재는 예시 구현 + const fileName = `${Date.now()}-${file.name}`; + const filePath = `/uploads/compliance/${contractId}/${answerId}/${fileName}`; + + // 실제로는 여기서 파일을 물리적으로 저장해야 함 + // const savedPath = await saveFileToStorage(file, filePath); + + return { + success: true, + filePath, + fileName: file.name, + fileSize: file.size, + mimeType: file.type + }; + + } catch (error) { + console.error('❌ 파일 업로드 실패:', error); + + return { + success: false, + message: error instanceof Error ? error.message : '파일 업로드에 실패했습니다.' + }; + } +} + + +// 기존 응답 조회를 위한 타입 +export interface ExistingResponse { + responseId: number; + status: string; + completedAt: string | null; + answers: { + questionId: number; + answerValue: string | null; + detailText: string | null; + otherText: string | null; + files: Array<{ + id: number; + fileName: string; + filePath: string; + fileSize: number; + }>; + }[]; +} + +// 기존 응답 조회 서버 액션 +export async function getExistingSurveyResponse( + contractId: number, + templateId: number +): Promise<{ success: boolean; data: ExistingResponse | null; message?: string }> { + try { + // 1. 해당 계약서의 응답 조회 + const response = await db + .select() + .from(complianceResponses) + .where( + and( + eq(complianceResponses.basicContractId, contractId), + eq(complianceResponses.templateId, templateId) + ) + ) + .limit(1); + + if (!response || response.length === 0) { + return { success: true, data: null }; + } + + const responseData = response[0]; + + // 2. 해당 응답의 모든 답변 조회 + const answers = await db + .select({ + questionId: complianceResponseAnswers.questionId, + answerValue: complianceResponseAnswers.answerValue, + detailText: complianceResponseAnswers.detailText, + otherText: complianceResponseAnswers.otherText, + answerId: complianceResponseAnswers.id, + }) + .from(complianceResponseAnswers) + .where(eq(complianceResponseAnswers.responseId, responseData.id)); + + // 3. 각 답변의 파일들 조회 + const answerIds = answers.map(a => a.answerId); + const files = answerIds.length > 0 + ? await db + .select() + .from(complianceResponseFiles) + .where(inArray(complianceResponseFiles.answerId, answerIds)) + : []; + + // 4. 답변별 파일 그룹화 + const filesByAnswerId = files.reduce((acc, file) => { + if (!acc[file.answerId]) { + acc[file.answerId] = []; + } + acc[file.answerId].push({ + id: file.id, + fileName: file.fileName, + filePath: file.filePath, + fileSize: file.fileSize || 0, + }); + return acc; + }, {} as Record<number, Array<{id: number; fileName: string; filePath: string; fileSize: number}>>); + + // 5. 최종 데이터 구성 + const answersWithFiles = answers.map(answer => ({ + questionId: answer.questionId, + answerValue: answer.answerValue, + detailText: answer.detailText, + otherText: answer.otherText, + files: filesByAnswerId[answer.answerId] || [], + })); + + return { + success: true, + data: { + responseId: responseData.id, + status: responseData.status, + completedAt: responseData.completedAt?.toISOString() || null, + answers: answersWithFiles, + }, + }; + + } catch (error) { + console.error('기존 설문 응답 조회 실패:', error); + return { + success: false, + data: null, + message: '기존 응답을 불러오는데 실패했습니다.' + }; + } +} + +export type GtcVendorData = { + vendorDocument: { + id: number; + name: string; + description: string | null; + version: string; + reviewStatus: string; + vendorId: number; + baseDocumentId: number; + vendorName: string; + vendorCode: string; + }; + clauses: Array<{ + id: number; + baseClauseId: number; + vendorDocumentId: number; + parentId: number | null; + depth: number; + sortOrder: string; + fullPath: string | null; + reviewStatus: string; + negotiationNote: string | null; + isExcluded: boolean; + + // 실제 표시될 값들 (수정된 값이 우선, 없으면 기본값) + effectiveItemNumber: string; + effectiveCategory: string | null; + effectiveSubtitle: string; + effectiveContent: string | null; + + // 기본 조항 정보 + baseItemNumber: string; + baseCategory: string | null; + baseSubtitle: string; + baseContent: string | null; + + // 수정 여부 + hasModifications: boolean; + isNumberModified: boolean; + isCategoryModified: boolean; + isSubtitleModified: boolean; + isContentModified: boolean; + + // 코멘트 관련 + hasComment: boolean; + pendingComment: string | null; + }>; +}; + +/** + * 현재 사용자(벤더)의 GTC 데이터를 가져옵니다. + * @param contractId 기본 GTC 문서 ID (선택사항, 없으면 가장 최신 문서 사용) + * @returns GTC 벤더 데이터 또는 null + */ +export async function getVendorGtcData(contractId?: number): Promise<GtcVendorData | null> { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.companyId) { + throw new Error("회사 정보가 없습니다."); + } + + console.log(contractId, "contractId"); + + const companyId = session.user.companyId; + const vendorId = companyId; // companyId를 vendorId로 사용 + + // 1. 계약 정보 가져오기 + const existingContract = await db.query.basicContract.findFirst({ + where: eq(basicContract.id, contractId), + }); + + if (!existingContract) { + throw new Error("계약을 찾을 수 없습니다."); + } + + // 2. 계약 템플릿 정보 가져오기 + const existingContractTemplate = await db.query.basicContractTemplates.findFirst({ + where: eq(basicContractTemplates.id, existingContract.templateId), // id가 아니라 templateId여야 할 것 같음 + }); + + if (!existingContractTemplate) { + throw new Error("계약 템플릿을 찾을 수 없습니다."); + } + + // 3. General 타입인지 확인 + const isGeneral = existingContractTemplate.templateName.toLowerCase().includes('general'); + + let targetBaseDocumentId: number; + + if (isGeneral) { + // General인 경우: type이 'standard'인 활성 상태의 첫 번째 문서 사용 + const standardGtcDoc = await db.query.gtcDocuments.findFirst({ + where: and( + eq(gtcDocuments.type, 'standard'), + eq(gtcDocuments.isActive, true) + ), + orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)] // 최신 리비전 우선 + }); + + if (!standardGtcDoc) { + throw new Error("표준 GTC 문서를 찾을 수 없습니다."); + } + + targetBaseDocumentId = standardGtcDoc.id; + console.log(`표준 GTC 문서 사용: ${targetBaseDocumentId}`); + + } else { + // General이 아닌 경우: 프로젝트별 GTC 문서 사용 + const projectCode = existingContractTemplate.templateName.split(" ")[0]; + + if (!projectCode) { + throw new Error("템플릿 이름에서 프로젝트 코드를 찾을 수 없습니다."); + } + + // 프로젝트 찾기 + const existingProject = await db.query.projects.findFirst({ + where: eq(projects.code, projectCode), + }); + + if (!existingProject) { + throw new Error(`프로젝트를 찾을 수 없습니다: ${projectCode}`); + } + + // 해당 프로젝트의 GTC 문서 찾기 + const projectGtcDoc = await db.query.gtcDocuments.findFirst({ + where: and( + eq(gtcDocuments.type, 'project'), + eq(gtcDocuments.projectId, existingProject.id), + eq(gtcDocuments.isActive, true) + ), + orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)] // 최신 리비전 우선 + }); + + if (!projectGtcDoc) { + console.warn(`프로젝트 ${projectCode}에 대한 GTC 문서가 없습니다. 표준 GTC 문서를 사용합니다.`); + + // 프로젝트별 GTC가 없으면 표준 GTC 사용 + const standardGtcDoc = await db.query.gtcDocuments.findFirst({ + where: and( + eq(gtcDocuments.type, 'standard'), + eq(gtcDocuments.isActive, true) + ), + orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)] + }); + + if (!standardGtcDoc) { + throw new Error("표준 GTC 문서도 찾을 수 없습니다."); + } + + targetBaseDocumentId = standardGtcDoc.id; + } else { + targetBaseDocumentId = projectGtcDoc.id; + console.log(`프로젝트 GTC 문서 사용: ${targetBaseDocumentId} (프로젝트: ${projectCode})`); + } + } + + // 4. 벤더 문서 정보 가져오기 (없어도 기본 조항은 보여줌) + const vendorDocResult = await db + .select({ + id: gtcVendorDocuments.id, + name: gtcVendorDocuments.name, + description: gtcVendorDocuments.description, + version: gtcVendorDocuments.version, + reviewStatus: gtcVendorDocuments.reviewStatus, + vendorId: gtcVendorDocuments.vendorId, + baseDocumentId: gtcVendorDocuments.baseDocumentId, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + }) + .from(gtcVendorDocuments) + .leftJoin(vendors, eq(gtcVendorDocuments.vendorId, vendors.id)) + .where( + and( + eq(gtcVendorDocuments.vendorId, vendorId), + eq(gtcVendorDocuments.baseDocumentId, targetBaseDocumentId), + eq(gtcVendorDocuments.isActive, true) + ) + ) + .limit(1); + + // 벤더 문서가 없으면 기본 정보로 생성 + const vendorDocument = vendorDocResult.length > 0 ? vendorDocResult[0] : { + id: null, // 벤더 문서가 아직 생성되지 않음 + name: `GTC 검토 (벤더 ID: ${vendorId})`, + description: "기본 GTC 협의", + version: "1.0", + reviewStatus: "pending", + vendorId: vendorId, + baseDocumentId: targetBaseDocumentId, + vendorName: "Unknown Vendor", + vendorCode: "UNKNOWN" + }; + + if (vendorDocResult.length === 0) { + console.info(`벤더 ID ${vendorId}에 대한 GTC 벤더 문서가 없습니다. 기본 조항만 표시합니다. (baseDocumentId: ${targetBaseDocumentId})`); + } + + // 5. 기본 조항들 가져오기 (벤더별 수정 사항과 함께) + const clausesResult = await db + .select({ + // 기본 조항 정보 (메인) + baseClauseId: gtcClauses.id, + baseItemNumber: gtcClauses.itemNumber, + baseCategory: gtcClauses.category, + baseSubtitle: gtcClauses.subtitle, + baseContent: gtcClauses.content, + baseParentId: gtcClauses.parentId, + baseDepth: gtcClauses.depth, + baseSortOrder: gtcClauses.sortOrder, + baseFullPath: gtcClauses.fullPath, + + // 벤더 조항 정보 (있는 경우만) + vendorClauseId: gtcVendorClauses.id, + vendorDocumentId: gtcVendorClauses.vendorDocumentId, + reviewStatus: gtcVendorClauses.reviewStatus, + negotiationNote: gtcVendorClauses.negotiationNote, + isExcluded: gtcVendorClauses.isExcluded, + + // 수정된 값들 (있는 경우만) + modifiedItemNumber: gtcVendorClauses.modifiedItemNumber, + modifiedCategory: gtcVendorClauses.modifiedCategory, + modifiedSubtitle: gtcVendorClauses.modifiedSubtitle, + modifiedContent: gtcVendorClauses.modifiedContent, + + // 수정 여부 + isNumberModified: gtcVendorClauses.isNumberModified, + isCategoryModified: gtcVendorClauses.isCategoryModified, + isSubtitleModified: gtcVendorClauses.isSubtitleModified, + isContentModified: gtcVendorClauses.isContentModified, + }) + .from(gtcClauses) + .leftJoin(gtcVendorClauses, and( + eq(gtcVendorClauses.baseClauseId, gtcClauses.id), + vendorDocument.id ? eq(gtcVendorClauses.vendorDocumentId, vendorDocument.id) : sql`false`, + eq(gtcVendorClauses.isActive, true) + )) + .where( + and( + eq(gtcClauses.documentId, targetBaseDocumentId), + eq(gtcClauses.isActive, true) + ) + ) + .orderBy(gtcClauses.sortOrder); + + let negotiationHistoryMap = new Map(); + + if (vendorDocument.id) { + const vendorClauseIds = clausesResult + .filter(c => c.vendorClauseId) + .map(c => c.vendorClauseId); + + if (vendorClauseIds.length > 0) { + const histories = await db + .select({ + vendorClauseId: gtcNegotiationHistory.vendorClauseId, + action: gtcNegotiationHistory.action, + previousStatus: gtcNegotiationHistory.previousStatus, + newStatus: gtcNegotiationHistory.newStatus, + comment: gtcNegotiationHistory.comment, + actorType: gtcNegotiationHistory.actorType, + actorId: gtcNegotiationHistory.actorId, + actorName: gtcNegotiationHistory.actorName, + actorEmail: gtcNegotiationHistory.actorEmail, + createdAt: gtcNegotiationHistory.createdAt, + changedFields: gtcNegotiationHistory.changedFields, + }) + .from(gtcNegotiationHistory) + .leftJoin(users, eq(gtcNegotiationHistory.actorId, users.id)) + .where(inArray(gtcNegotiationHistory.vendorClauseId, vendorClauseIds)) + .orderBy(desc(gtcNegotiationHistory.createdAt)); + + // 벤더 조항별로 이력 그룹화 + histories.forEach(history => { + if (!negotiationHistoryMap.has(history.vendorClauseId)) { + negotiationHistoryMap.set(history.vendorClauseId, []); + } + negotiationHistoryMap.get(history.vendorClauseId).push(history); + }); + } + } + + + + // 6. 데이터 변환 및 추가 정보 계산 + const clauses = clausesResult.map(clause => { + const hasVendorData = !!clause.vendorClauseId; + const negotiationHistory = hasVendorData ? + (negotiationHistoryMap.get(clause.vendorClauseId) || []) : []; + + // 코멘트가 있는 이력들만 필터링 + const commentHistory = negotiationHistory.filter(h => h.comment); + const latestComment = commentHistory[0]?.comment || null; + const hasComment = commentHistory.length > 0; + + return { + id: clause.baseClauseId, + vendorClauseId: clause.vendorClauseId, + vendorDocumentId: hasVendorData ? clause.vendorDocumentId : null, + + // 기본 조항의 계층 구조 정보 사용 + parentId: clause.baseParentId, + depth: clause.baseDepth, + sortOrder: clause.baseSortOrder, + fullPath: clause.baseFullPath, + + // 상태 정보 (벤더 데이터가 있는 경우만) + reviewStatus: clause.reviewStatus || 'pending', + negotiationNote: clause.negotiationNote, + isExcluded: clause.isExcluded || false, + + // 실제 표시될 값들 (수정된 값이 있으면 그것을, 없으면 기본값) + effectiveItemNumber: clause.modifiedItemNumber || clause.baseItemNumber, + effectiveCategory: clause.modifiedCategory || clause.baseCategory, + effectiveSubtitle: clause.modifiedSubtitle || clause.baseSubtitle, + effectiveContent: clause.modifiedContent || clause.baseContent, + + // 기본 조항 정보 + baseItemNumber: clause.baseItemNumber, + baseCategory: clause.baseCategory, + baseSubtitle: clause.baseSubtitle, + baseContent: clause.baseContent, + + // 수정 여부 + // hasModifications, + isNumberModified: clause.isNumberModified || false, + isCategoryModified: clause.isCategoryModified || false, + isSubtitleModified: clause.isSubtitleModified || false, + isContentModified: clause.isContentModified || false, + + hasComment, + latestComment, + commentHistory, // 전체 코멘트 이력 + negotiationHistory, // 전체 협의 이력 + }; + }); + + return { + vendorDocument, + clauses, + }; + + } catch (error) { + console.error('GTC 벤더 데이터 가져오기 실패:', error); + throw error; + } +} + + +interface ClauseUpdateData { + itemNumber: string; + category: string | null; + subtitle: string; + content: string | null; + comment: string; +} + +interface VendorDocument { + id: number | null; + vendorId: number; + baseDocumentId: number; + name: string; + description: string; + version: string; +} + +export async function updateVendorClause( + baseClauseId: number, + vendorClauseId: number | null, + clauseData: any, + vendorDocument: any +): Promise<{ success: boolean; error?: string; vendorClauseId?: number; vendorDocumentId?: number }> { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.companyId) { + return { success: false, error: "회사 정보가 없습니다." }; + } + + const companyId = session.user.companyId; + const vendorId = companyId; + const userId = Number(session.user.id); + + // 1. 기본 조항 정보 가져오기 + const baseClause = await db.query.gtcClauses.findFirst({ + where: eq(gtcClauses.id, baseClauseId), + }); + + if (!baseClause) { + return { success: false, error: "기본 조항을 찾을 수 없습니다." }; + } + + // 2. 이전 코멘트 가져오기 (vendorClauseId가 있는 경우) + let previousComment = null; + if (vendorClauseId) { + const previousData = await db + .select({ comment: gtcVendorClauses.negotiationNote }) + .from(gtcVendorClauses) + .where(eq(gtcVendorClauses.id, vendorClauseId)) + .limit(1); + + previousComment = previousData?.[0]?.comment || null; + } + + // 3. 벤더 문서 ID 확보 (없으면 생성) + let finalVendorDocumentId = vendorDocument?.id; + + if (!finalVendorDocumentId && vendorDocument) { + const newVendorDoc = await db.insert(gtcVendorDocuments).values({ + vendorId: vendorId, + baseDocumentId: vendorDocument.baseDocumentId, + name: vendorDocument.name, + description: vendorDocument.description, + version: vendorDocument.version, + reviewStatus: 'reviewing', + createdById: userId, + updatedById: userId, + }).returning({ id: gtcVendorDocuments.id }); + + if (newVendorDoc.length === 0) { + return { success: false, error: "벤더 문서 생성에 실패했습니다." }; + } + + finalVendorDocumentId = newVendorDoc[0].id; + console.log(`새 벤더 문서 생성: ${finalVendorDocumentId}`); + } + + if (!finalVendorDocumentId) { + return { success: false, error: "벤더 문서 ID를 확보할 수 없습니다." }; + } + + // 4. 수정 여부 확인 + const isNumberModified = clauseData.itemNumber !== baseClause.itemNumber; + const isCategoryModified = clauseData.category !== baseClause.category; + const isSubtitleModified = clauseData.subtitle !== baseClause.subtitle; + const isContentModified = clauseData.content !== baseClause.content; + + const hasAnyModifications = isNumberModified || isCategoryModified || isSubtitleModified || isContentModified; + const hasComment = !!(clauseData.comment?.trim()); + + // 5. 벤더 조항 데이터 준비 + const vendorClauseData = { + vendorDocumentId: finalVendorDocumentId, + baseClauseId: baseClauseId, + parentId: baseClause.parentId, + depth: baseClause.depth, + sortOrder: baseClause.sortOrder, + fullPath: baseClause.fullPath, + + modifiedItemNumber: isNumberModified ? clauseData.itemNumber : null, + modifiedCategory: isCategoryModified ? clauseData.category : null, + modifiedSubtitle: isSubtitleModified ? clauseData.subtitle : null, + modifiedContent: isContentModified ? clauseData.content : null, + + isNumberModified, + isCategoryModified, + isSubtitleModified, + isContentModified, + + reviewStatus: (hasAnyModifications || hasComment) ? 'reviewing' : 'draft', + negotiationNote: clauseData.comment?.trim() || null, + editReason: clauseData.comment?.trim() || null, + + updatedAt: new Date(), + updatedById: userId, + }; + + let finalVendorClauseId = vendorClauseId; + + // 6. 벤더 조항 생성 또는 업데이트 + if (vendorClauseId) { + await db + .update(gtcVendorClauses) + .set(vendorClauseData) + .where(eq(gtcVendorClauses.id, vendorClauseId)); + + console.log(`벤더 조항 업데이트: ${vendorClauseId}`); + } else { + const newVendorClause = await db.insert(gtcVendorClauses).values({ + ...vendorClauseData, + createdById: userId, + }).returning({ id: gtcVendorClauses.id }); + + if (newVendorClause.length === 0) { + return { success: false, error: "벤더 조항 생성에 실패했습니다." }; + } + + finalVendorClauseId = newVendorClause[0].id; + console.log(`새 벤더 조항 생성: ${finalVendorClauseId}`); + } + + // 7. 협의 이력에 기록 (코멘트가 변경된 경우만) + if (clauseData.comment !== previousComment) { + await db.insert(gtcNegotiationHistory).values({ + vendorClauseId: finalVendorClauseId, + action: previousComment ? "modified" : "commented", + comment: clauseData.comment || null, + previousStatus: null, + newStatus: 'reviewing', + actorType: "vendor", + actorId: userId, + actorName: session.user.name, + actorEmail: session.user.email, + changedFields: { + comment: { + from: previousComment, + to: clauseData.comment || null + } + } + }); + } + + return { + success: true, + vendorClauseId: finalVendorClauseId, + vendorDocumentId: finalVendorDocumentId + }; + + } catch (error) { + console.error('벤더 조항 업데이트 실패:', error); + return { + success: false, + error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' + }; + } +} +// 기존 함수는 호환성을 위해 유지하되, 새 함수를 호출하도록 변경 +export async function updateVendorClauseComment( + clauseId: number, + comment: string +): Promise<{ success: boolean; error?: string }> { + console.warn('updateVendorClauseComment is deprecated. Use updateVendorClause instead.'); + + // 기본 조항 정보 가져오기 + const baseClause = await db.query.gtcClauses.findFirst({ + where: eq(gtcClauses.id, clauseId), + }); + + if (!baseClause) { + return { success: false, error: "기본 조항을 찾을 수 없습니다." }; + } + + // 기존 벤더 조항 찾기 + const session = await getServerSession(authOptions); + const vendorId = session?.user?.companyId; + + const existingVendorClause = await db.query.gtcVendorClauses.findFirst({ + where: and( + eq(gtcVendorClauses.baseClauseId, clauseId), + eq(gtcVendorClauses.isActive, true) + ), + with: { + vendorDocument: true + } + }); + + const clauseData: ClauseUpdateData = { + itemNumber: baseClause.itemNumber, + category: baseClause.category, + subtitle: baseClause.subtitle, + content: baseClause.content, + comment: comment, + }; + + const result = await updateVendorClause( + clauseId, + existingVendorClause?.id || null, + clauseData, + existingVendorClause?.vendorDocument || undefined + ); + + return { + success: result.success, + error: result.error + }; +} + + +/** + * 벤더 조항 코멘트들의 상태 체크 + */ +export async function checkVendorClausesCommentStatus( + vendorDocumentId: number +): Promise<{ hasComments: boolean; commentCount: number }> { + try { + const clausesWithComments = await db + .select({ + id: gtcVendorClauses.id, + negotiationNote: gtcVendorClauses.negotiationNote + }) + .from(gtcVendorClauses) + .where( + and( + eq(gtcVendorClauses.vendorDocumentId, vendorDocumentId), + eq(gtcVendorClauses.isActive, true) + ) + ); + + const commentCount = clausesWithComments.filter( + clause => clause.negotiationNote && clause.negotiationNote.trim().length > 0 + ).length; + + return { + hasComments: commentCount > 0, + commentCount, + }; + + } catch (error) { + console.error('벤더 조항 코멘트 상태 체크 실패:', error); + return { hasComments: false, commentCount: 0 }; + } +} + +/** + * 특정 템플릿의 기본 정보를 조회하는 서버 액션 + * @param templateId - 조회할 템플릿의 ID + * @returns 템플릿 기본 정보 또는 null + */ +export async function getBasicContractTemplateInfo(templateId: number) { + try { + const templateInfo = await db + .select({ + templateId: basicContractTemplates.id, + templateName: basicContractTemplates.templateName, + revision: basicContractTemplates.revision, + status: basicContractTemplates.status, + legalReviewRequired: basicContractTemplates.legalReviewRequired, + validityPeriod: basicContractTemplates.validityPeriod, + fileName: basicContractTemplates.fileName, + filePath: basicContractTemplates.filePath, + createdAt: basicContractTemplates.createdAt, + updatedAt: basicContractTemplates.updatedAt, + createdBy: basicContractTemplates.createdBy, + updatedBy: basicContractTemplates.updatedBy, + disposedAt: basicContractTemplates.disposedAt, + restoredAt: basicContractTemplates.restoredAt, + }) + .from(basicContractTemplates) + .where(eq(basicContractTemplates.id, templateId)) + .then((res) => res[0] || null) + + return templateInfo + } catch (error) { + console.error("Error fetching template info:", error) + return null + } +} + + + +/** + * 카테고리 자동 분류 함수 + */ +function getCategoryFromTemplateName(templateName: string | null): string { + if (!templateName) return "기타" + + const templateNameLower = templateName.toLowerCase() + + if (templateNameLower.includes("준법")) { + return "CP" + } else if (templateNameLower.includes("gtc")) { + return "GTC" + } + + return "기타" +} + +/** + * 법무검토 요청 서버 액션 + */ +export async function requestLegalReviewAction( + contractIds: number[], + reviewNote?: string +): Promise<{ success: boolean; message: string; data?: any }> { + try { + // 세션 확인 + const session = await getServerSession(authOptions) + + if (!session?.user) { + return { + success: false, + message: "로그인이 필요합니다." + } + } + + // 계약서 정보 조회 + const contracts = await db + .select({ + id: basicContractView.id, + vendorId: basicContractView.vendorId, + vendorCode: basicContractView.vendorCode, + vendorName: basicContractView.vendorName, + templateName: basicContractView.templateName, + legalReviewRequired: basicContractView.legalReviewRequired, + legalReviewRequestedAt: basicContractView.legalReviewRequestedAt, + }) + .from(basicContractView) + .where(inArray(basicContractView.id, contractIds)) + + if (contracts.length === 0) { + return { + success: false, + message: "선택된 계약서를 찾을 수 없습니다." + } + } + + // 법무검토 요청 가능한 계약서 필터링 + const eligibleContracts = contracts.filter(contract => + contract.legalReviewRequired && !contract.legalReviewRequestedAt + ) + + if (eligibleContracts.length === 0) { + return { + success: false, + message: "법무검토 요청 가능한 계약서가 없습니다." + } + } + + const currentDate = new Date() + const reviewer = session.user.name || session.user.email || "알 수 없음" + + // 트랜잭션으로 처리 + const results = await db.transaction(async (tx) => { + const legalWorkResults = [] + const contractUpdateResults = [] + + // 각 계약서에 대해 legalWorks 레코드 생성 + for (const contract of eligibleContracts) { + const category = getCategoryFromTemplateName(contract.templateName) + + // legalWorks에 레코드 삽입 + const legalWorkResult = await tx.insert(legalWorks).values({ + basicContractId: contract.id, // 레퍼런스 ID + category: category, + status: "신규등록", + vendorId: contract.vendorId, + vendorCode: contract.vendorCode, + vendorName: contract.vendorName || "업체명 없음", + isUrgent: false, + consultationDate: currentDate.toISOString().split('T')[0], // YYYY-MM-DD 형식 + reviewer: reviewer, + hasAttachment: false, // 기본값, 나중에 첨부파일 로직 추가 시 수정 + createdAt: currentDate, + updatedAt: currentDate, + }).returning({ id: legalWorks.id }) + + legalWorkResults.push(legalWorkResult[0]) + + // basicContract 테이블의 legalReviewRequestedAt 업데이트 + const contractUpdateResult = await tx + .update(basicContract) + .set({ + legalReviewRequestedAt: currentDate, + updatedAt: currentDate, + }) + .where(eq(basicContract.id, contract.id)) + .returning({ id: basicContract.id }) + + contractUpdateResults.push(contractUpdateResult[0]) + } + + return { + legalWorks: legalWorkResults, + contractUpdates: contractUpdateResults + } + }) + + + console.log("법무검토 요청 완료:", { + requestedBy: reviewer, + contractIds: eligibleContracts.map(c => c.id), + legalWorkIds: results.legalWorks.map(r => r.id), + reviewNote, + }) + + return { + success: true, + message: `${eligibleContracts.length}건의 계약서에 대한 법무검토를 요청했습니다.`, + data: { + processedCount: eligibleContracts.length, + totalRequested: contractIds.length, + legalWorkIds: results.legalWorks.map(r => r.id), + } + } + + } catch (error) { + console.error("법무검토 요청 중 오류:", error) + + return { + success: false, + message: "법무검토 요청 중 오류가 발생했습니다. 다시 시도해 주세요.", + } + } +} + +export async function resendContractsAction(contractIds: number[]) { + try { + // 세션 확인 + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error('인증이 필요합니다.') + } + + // 계약서 정보 조회 + const contracts = await db + .select({ + id: basicContract.id, + vendorId: basicContract.vendorId, + fileName: basicContract.fileName, + deadline: basicContract.deadline, + status: basicContract.status, + createdAt: basicContract.createdAt, + }) + .from(basicContract) + .where(inArray(basicContract.id, contractIds)) + + if (contracts.length === 0) { + throw new Error('발송할 계약서를 찾을 수 없습니다.') + } + + // 각 계약서에 대해 이메일 발송 + const emailPromises = contracts.map(async (contract) => { + // 벤더 정보 조회 + const vendor = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + country: vendors.country, + email: vendors.email, + }) + .from(vendors) + .where(eq(vendors.id, contract.vendorId!)) + .limit(1) + + if (!vendor[0]) { + console.error(`벤더를 찾을 수 없습니다: vendorId ${contract.vendorId}`) + return null + } + + // 벤더 연락처 조회 (Primary 연락처 우선, 없으면 첫 번째 연락처) + const contacts = await db + .select({ + contactName: vendorContacts.contactName, + contactEmail: vendorContacts.contactEmail, + isPrimary: vendorContacts.isPrimary, + }) + .from(vendorContacts) + .where(eq(vendorContacts.vendorId, vendor[0].id)) + .orderBy(vendorContacts.isPrimary) + + // 이메일 수신자 결정 (Primary 연락처 > 첫 번째 연락처 > 벤더 기본 이메일) + const primaryContact = contacts.find(c => c.isPrimary) + const recipientEmail = primaryContact?.contactEmail || contacts[0]?.contactEmail || vendor[0].email + const recipientName = primaryContact?.contactName || contacts[0]?.contactName || vendor[0].vendorName + + if (!recipientEmail) { + console.error(`이메일 주소를 찾을 수 없습니다: vendorId ${vendor[0].id}`) + return null + } + + // 언어 결정 (한국 = 한글, 그 외 = 영어) + const isKorean = vendor[0].country === 'KR' + const template = isKorean ? 'contract-reminder-kr' : 'contract-reminder-en' + const subject = isKorean + ? '[eVCP] 계약서 서명 요청 리마인더' + : '[eVCP] Contract Signature Reminder' + + // 마감일 포맷팅 + const deadlineDate = new Date(contract.deadline) + const formattedDeadline = isKorean + ? `${deadlineDate.getFullYear()}년 ${deadlineDate.getMonth() + 1}월 ${deadlineDate.getDate()}일` + : deadlineDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) + + // 남은 일수 계산 + const today = new Date() + today.setHours(0, 0, 0, 0) + const deadline = new Date(contract.deadline) + deadline.setHours(0, 0, 0, 0) + const daysRemaining = Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)) + + // 이메일 발송 + await sendEmail({ + from: session.user.email, + to: recipientEmail, + subject, + template, + context: { + recipientName, + vendorName: vendor[0].vendorName, + vendorCode: vendor[0].vendorCode, + contractFileName: contract.fileName, + deadline: formattedDeadline, + daysRemaining, + senderName: session.user.name || session.user.email, + senderEmail: session.user.email, + // 계약서 링크 (실제 환경에 맞게 수정 필요) + contractLink: `${process.env.NEXT_PUBLIC_APP_URL}/contracts/${contract.id}`, + }, + }) + + console.log(`리마인더 이메일 발송 완료: ${recipientEmail} (계약서 ID: ${contract.id})`) + return { contractId: contract.id, email: recipientEmail } + }) + + const results = await Promise.allSettled(emailPromises) + + // 성공/실패 카운트 + const successful = results.filter(r => r.status === 'fulfilled' && r.value !== null).length + const failed = results.filter(r => r.status === 'rejected' || (r.status === 'fulfilled' && r.value === null)).length + + if (failed > 0) { + console.warn(`${failed}건의 이메일 발송 실패`) + } + + return { + success: true, + message: `${successful}건의 리마인더 이메일을 발송했습니다.`, + successful, + failed, + } + + } catch (error) { + console.error('계약서 재발송 중 오류:', error) + throw new Error('계약서 재발송 중 오류가 발생했습니다.') + } +} + + +export async function processBuyerSignatureAction( + contractId: number, + signedFileData: ArrayBuffer, + fileName: string +): Promise<{ success: boolean; message: string; data?: any }> { + try { + // 세션 확인 + const session = await getServerSession(authOptions) + + if (!session?.user) { + return { + success: false, + message: "로그인이 필요합니다." + } + } + + // 계약서 정보 조회 및 상태 확인 + const contract = await db + .select() + .from(basicContractView) + .where(eq(basicContractView.id, contractId)) + .limit(1) + + if (contract.length === 0) { + return { + success: false, + message: "계약서를 찾을 수 없습니다." + } + } + + const contractData = contract[0] + + // 최종승인 가능 상태 확인 + if (contractData.completedAt !== null) { + return { + success: false, + message: "이미 완료된 계약서입니다." + } + } + + if (!contractData.signedFilePath) { + return { + success: false, + message: "협력업체 서명이 완료되지 않았습니다." + } + } + + if (contractData.legalReviewRequestedAt && !contractData.legalReviewCompletedAt) { + return { + success: false, + message: "법무검토가 완료되지 않았습니다." + } + } + + // 파일 저장 로직 (기존 파일 덮어쓰기) + const saveResult = await saveBuffer({ + buffer: signedFileData, + fileName: fileName, + directory: "basicContract/signed" + }); + + if (!saveResult.success) { + return { + success: false, + message: `파일 저장 중 오류가 발생했습니다: ${saveResult.error}` + } + } + + const currentDate = new Date() + + // 계약서 상태 업데이트 + const updatedContract = await db + .update(basicContract) + .set({ + buyerSignedAt: currentDate, + completedAt: currentDate, + status: "COMPLETED", + updatedAt: currentDate, + filePath: saveResult.publicPath, // 웹 접근 가능한 경로로 업데이트 + }) + .where(eq(basicContract.id, contractId)) + .returning() + + // 캐시 재검증 + revalidatePath("/contracts") + + console.log("구매자 서명 및 최종승인 완료:", { + contractId, + buyerSigner: session.user.name || session.user.email, + completedAt: currentDate, + }) + + return { + success: true, + message: "계약서 최종승인이 완료되었습니다.", + data: { + contractId, + completedAt: currentDate, + } + } + + } catch (error) { + console.error("구매자 서명 처리 중 오류:", error) + + return { + success: false, + message: "최종승인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.", + } + } +} + +/** + * 일괄 최종승인 (서명 다이얼로그 호출용) + */ +export async function prepareFinalApprovalAction( + contractIds: number[] +): Promise<{ success: boolean; message: string; contracts?: any[] }> { + try { + // 세션 확인 + const session = await getServerSession(authOptions) + + if (!session?.user) { + return { + success: false, + message: "로그인이 필요합니다." + } + } + + // 계약서 정보 조회 + const contracts = await db + .select() + .from(basicContractView) + .where(inArray(basicContractView.id, contractIds)) + + if (contracts.length === 0) { + return { + success: false, + message: "선택된 계약서를 찾을 수 없습니다." + } + } + + // 최종승인 가능한 계약서 필터링 + const eligibleContracts = contracts.filter(contract => { + if (contract.completedAt !== null || !contract.signedFilePath) { + return false + } + if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) { + return false + } + return true + }) + + if (eligibleContracts.length === 0) { + return { + success: false, + message: "최종승인 가능한 계약서가 없습니다." + } + } + + // 서명 다이얼로그에서 사용할 수 있는 형태로 변환 + const contractsForSigning = eligibleContracts.map(contract => ({ + id: contract.id, + templateName: contract.templateName, + signedFilePath: contract.signedFilePath, + signedFileName: contract.signedFileName, + vendorName: contract.vendorName, + vendorCode: contract.vendorCode, + requestedByName: "구매팀", // 최종승인자 표시 + createdAt: contract.createdAt, + // 다른 필요한 필드들... + })) + + return { + success: true, + message: `${eligibleContracts.length}건의 계약서를 최종승인할 준비가 되었습니다.`, + contracts: contractsForSigning + } + + } catch (error) { + console.error("최종승인 준비 중 오류:", error) + + return { + success: false, + message: "최종승인 준비 중 오류가 발생했습니다.", + } + } +} + +/** + * 서명 없이 승인만 처리 (간단한 승인 방식) + */ +export async function quickFinalApprovalAction( + contractIds: number[] +): Promise<{ success: boolean; message: string; data?: any }> { + try { + // 세션 확인 + const session = await getServerSession(authOptions) + + if (!session?.user) { + return { + success: false, + message: "로그인이 필요합니다." + } + } + + // 계약서 정보 조회 + const contracts = await db + .select() + .from(basicContract) + .where(inArray(basicContract.id, contractIds)) + + // 승인 가능한 계약서 필터링 + const eligibleContracts = contracts.filter(contract => { + if (contract.completedAt !== null || !contract.signedFilePath) { + return false + } + if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) { + return false + } + return true + }) + + if (eligibleContracts.length === 0) { + return { + success: false, + message: "최종승인 가능한 계약서가 없습니다." + } + } + + const currentDate = new Date() + const approver = session.user.name || session.user.email || "알 수 없음" + + // 일괄 업데이트 + const updatedContracts = await db + .update(basicContract) + .set({ + buyerSignedAt: currentDate, + completedAt: currentDate, + status: "COMPLETED", + updatedAt: currentDate, + }) + .where(inArray(basicContract.id, eligibleContracts.map(c => c.id))) + .returning({ id: basicContract.id }) + + // 캐시 재검증 + revalidatePath("/contracts") + + console.log("일괄 최종승인 완료:", { + approver, + contractIds: updatedContracts.map(c => c.id), + completedAt: currentDate, + }) + + return { + success: true, + message: `${updatedContracts.length}건의 계약서 최종승인이 완료되었습니다.`, + data: { + processedCount: updatedContracts.length, + contractIds: updatedContracts.map(c => c.id), + } + } + + } catch (error) { + console.error("일괄 최종승인 중 오류:", error) + + return { + success: false, + message: "최종승인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.", + } + } +} + + +export async function getVendorSignatureFile() { + try { + // 세션에서 사용자 정보 가져오기 + const session = await getServerSession(authOptions) + + if (!session?.user?.companyId) { + throw new Error("인증되지 않은 사용자이거나 회사 정보가 없습니다.") + } + + // 조건에 맞는 vendor attachment 찾기 + const signatureAttachment = await db.query.vendorAttachments.findFirst({ + where: and( + eq(vendorAttachments.vendorId, session.user.companyId), + eq(vendorAttachments.attachmentType, "SIGNATURE") + ) + }) + + if (!signatureAttachment) { + return { + success: false, + error: "서명 파일을 찾을 수 없습니다." + } + } + + // 파일 읽기 + let filePath: string; + const nasPath = process.env.NAS_PATH || "/evcp_nas" + + + if (process.env.NODE_ENV === 'production') { + // ✅ 프로덕션: NAS 경로 사용 + filePath = path.join(nasPath, signatureAttachment.filePath); + + } else { + // 개발: public 폴더 + filePath = path.join(process.cwd(), 'public', signatureAttachment.filePath); + } + + const fileBuffer = await readFile(filePath) + + // Base64로 인코딩 + const base64File = fileBuffer.toString('base64') + + return { + success: true, + data: { + id: signatureAttachment.id, + fileName: signatureAttachment.fileName, + fileType: signatureAttachment.fileType, + base64: base64File, + // 웹에서 사용할 수 있는 data URL 형식도 제공 + dataUrl: `data:${signatureAttachment.fileType || 'application/octet-stream'};base64,${base64File}` + } + } + + } catch (error) { + console.error("서명 파일 조회 중 오류:", error) + console.log("서명 파일 조회 중 오류:", error) + + return { + success: false, + error: error instanceof Error ? error.message : "파일을 읽는 중 오류가 발생했습니다." + } + } +} + + + + +// templateName에서 project code 추출 +function extractProjectCodeFromTemplateName(templateName: string): string | null { + if (!templateName.includes('GTC')) return null; + if (templateName.toLowerCase().includes('general')) return null; + + // GTC 앞의 문자열을 추출 + const gtcIndex = templateName.indexOf('GTC'); + if (gtcIndex > 0) { + const beforeGTC = templateName.substring(0, gtcIndex).trim(); + // 마지막 단어를 project code로 간주 + const words = beforeGTC.split(/\s+/); + return words[words.length - 1]; + } + + return null; +} + +// 단일 contract에 대한 GTC 정보 확인 +async function checkGTCCommentsForContract( + templateName: string, + vendorId: number, + basicContractId?: number +): Promise<{ gtcDocumentId: number | null; hasComments: boolean }> { + try { + const projectCode = extractProjectCodeFromTemplateName(templateName); + let gtcDocumentId: number | null = null; + + console.log(projectCode,"projectCode") + + // 1. GTC Document ID 찾기 + if (projectCode && projectCode.trim() !== '') { + // Project GTC인 경우 + const project = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.code, projectCode.trim())) + .limit(1) + + if (project.length > 0) { + const projectGtcDoc = await db + .select({ id: gtcDocuments.id }) + .from(gtcDocuments) + .where( + and( + eq(gtcDocuments.projectId, project[0].id), + eq(gtcDocuments.isActive, true) + ) + ) + .orderBy(desc(gtcDocuments.revision)) + .limit(1) + + if (projectGtcDoc.length > 0) { + gtcDocumentId = projectGtcDoc[0].id + } + } + } else { + // Standard GTC인 경우 (general 포함하거나 project code가 없는 경우) + console.log(`🔍 [checkGTCCommentsForContract] Standard GTC 조회 중...`); + const standardGtcDoc = await db + .select({ id: gtcDocuments.id }) + .from(gtcDocuments) + .where( + and( + eq(gtcDocuments.type, "standard"), + eq(gtcDocuments.isActive, true), + isNull(gtcDocuments.projectId) + ) + ) + .orderBy(desc(gtcDocuments.revision)) + .limit(1) + + console.log(`📊 [checkGTCCommentsForContract] Standard GTC 조회 결과: ${standardGtcDoc.length}개`); + + if (standardGtcDoc.length > 0) { + gtcDocumentId = standardGtcDoc[0].id + console.log(`✅ [checkGTCCommentsForContract] Standard GTC 찾음: ${gtcDocumentId}`); + } else { + console.log(`❌ [checkGTCCommentsForContract] Standard GTC 없음 - gtc_documents 테이블에 standard 타입의 활성 문서가 없습니다`); + } + } + + console.log(`🎯 [checkGTCCommentsForContract] 최종 gtcDocumentId: ${gtcDocumentId}`) + + // GTC Document를 찾지 못한 경우 + if (basicContractId) { + console.log(`🔍 [checkGTCCommentsForContract] basicContractId: ${basicContractId} 로 코멘트 조회`); + const { agreementComments } = await import("@/db/schema"); + const newComments = await db + .select({ id: agreementComments.id }) + .from(agreementComments) + .where( + and( + eq(agreementComments.basicContractId, basicContractId), + eq(agreementComments.isDeleted, false) + ) + ) + .limit(1); + + console.log(`📊 [checkGTCCommentsForContract] basicContractId ${basicContractId}: 코멘트 ${newComments.length}개 발견`); + + if (newComments.length > 0) { + console.log(`✅ [checkGTCCommentsForContract] basicContractId ${basicContractId}: hasComments = true 반환`); + return { + gtcDocumentId, // null일 수 있음 + hasComments: true + }; + } + + console.log(`⚠️ [checkGTCCommentsForContract] basicContractId ${basicContractId}: agreementComments 없음`); + } + + // GTC Document를 찾지 못한 경우 (기존 방식도 체크할 수 없음) + if (!gtcDocumentId) { + console.log(`⚠️ [checkGTCCommentsForContract] gtcDocumentId null - 기존 방식 체크 불가`); + return { gtcDocumentId: null, hasComments: false }; + } + + // 2. 코멘트 존재 여부 확인 + // gtcDocumentId로 해당 벤더의 vendor documents 찾기 + const vendorDocuments = await db + .select({ id: gtcVendorDocuments.id }) + .from(gtcVendorDocuments) + .where( + and( + eq(gtcVendorDocuments.baseDocumentId, gtcDocumentId), + eq(gtcVendorDocuments.vendorId, vendorId), + eq(gtcVendorDocuments.isActive, true) + ) + ) + .limit(1) + + if (vendorDocuments.length === 0) { + return { gtcDocumentId, hasComments: false }; + } + + // vendor document에 연결된 clauses에서 negotiation history 확인 + const commentsExist = await db + .select({ count: gtcNegotiationHistory.id }) + .from(gtcNegotiationHistory) + .innerJoin( + gtcVendorClauses, + eq(gtcNegotiationHistory.vendorClauseId, gtcVendorClauses.id) + ) + .where( + and( + eq(gtcVendorClauses.vendorDocumentId, vendorDocuments[0].id), + eq(gtcVendorClauses.isActive, true), + isNotNull(gtcNegotiationHistory.comment), + ne(gtcNegotiationHistory.comment, '') + ) + ) + .limit(1) + + return { + gtcDocumentId, + hasComments: commentsExist.length > 0 + }; + + } catch (error) { + console.error('Error checking GTC comments for contract:', error); + return { gtcDocumentId: null, hasComments: false }; + } +} + +// // 전체 contract 리스트에 대해 GTC document ID와 comment 정보 수집 +// export async function checkGTCCommentsForContracts( +// contracts: BasicContractView[] +// ): Promise<Record<number, { gtcDocumentId: number | null; hasComments: boolean }>> { +// const gtcData: Record<number, { gtcDocumentId: number | null; hasComments: boolean }> = {}; + +// // GTC가 포함된 contract만 필터링 +// const gtcContracts = contracts.filter(contract => +// contract.templateName?.includes('GTC') +// ); + +// if (gtcContracts.length === 0) { +// return gtcData; +// } + +// // Promise.all을 사용해서 병렬 처리 +// const checkPromises = gtcContracts.map(async (contract) => { +// try { +// const result = await checkGTCCommentsForContract( +// contract.templateName!, +// contract.vendorId! +// ); + +// return { +// contractId: contract.id, +// gtcDocumentId: result.gtcDocumentId, +// hasComments: result.hasComments +// }; +// } catch (error) { +// console.error(`Error checking GTC for contract ${contract.id}:`, error); +// return { +// contractId: contract.id, +// gtcDocumentId: null, +// hasComments: false +// }; +// } +// }); + +// const results = await Promise.all(checkPromises); + +// // 결과를 Record 형태로 변환 +// results.forEach(({ contractId, gtcDocumentId, hasComments }) => { +// gtcData[contractId] = { gtcDocumentId, hasComments }; +// }); + +// return gtcData; +// } + + + +export async function updateVendorDocumentStatus( + formData: FormData | { + status: string; + vendorDocumentId: number; + documentId: number; + vendorId: number; + } +) { + try { + // 세션 확인 + const session = await getServerSession(authOptions) + if (!session?.user) { + return { success: false, error: "인증되지 않은 사용자입니다." } + } + + // 데이터 파싱 + const rawData = formData instanceof FormData + ? { + status: formData.get("status") as string, + vendorDocumentId: Number(formData.get("vendorDocumentId")), + documentId: Number(formData.get("documentId")), + vendorId: Number(formData.get("vendorId")), + } + : formData + + // 유효성 검사 + const validatedData = updateStatusSchema.safeParse(rawData) + if (!validatedData.success) { + return { success: false, error: "유효하지 않은 데이터입니다." } + } + + const { status, vendorDocumentId, documentId, vendorId } = validatedData.data + + // 완료 상태로 변경 시, 모든 조항이 approved 상태인지 확인 + if (status === "complete") { + // 승인되지 않은 조항 확인 + const pendingClauses = await db + .select({ id: gtcVendorClauses.id }) + .from(gtcVendorClauses) + .where( + and( + eq(gtcVendorClauses.vendorDocumentId, vendorDocumentId), + eq(gtcVendorClauses.isActive, true), + not(eq(gtcVendorClauses.reviewStatus, "approved")), + not(eq(gtcVendorClauses.isExcluded, true)) // 제외된 조항은 검사에서 제외 + ) + ) + .limit(1) + + if (pendingClauses.length > 0) { + return { + success: false, + error: "모든 조항이 승인되어야 협의 완료 처리가 가능합니다." + } + } + } + + // 업데이트 실행 + await db + .update(gtcVendorDocuments) + .set({ + reviewStatus: status, + updatedAt: new Date(), + updatedById: Number(session.user.id), + // 완료 처리 시 협의 종료일 설정 + ...(status === "complete" ? { + negotiationEndDate: new Date(), + approvalDate: new Date() + } : {}) + }) + .where(eq(gtcVendorDocuments.id, vendorDocumentId)) + + // 캐시 무효화 + // revalidatePath(`/evcp/gtc/${documentId}?vendorId=${vendorId}`) + + return { success: true } + } catch (error) { + console.error("Error updating vendor document status:", error) + return { success: false, error: "상태 업데이트 중 오류가 발생했습니다." } + } +} + + +interface SaveDocumentParams { + documentId: number + pdfBuffer: Uint8Array + originalFileName: string + vendor: { + vendorName: string + } + userId: number +} + +export async function saveGtcDocumentAction({ + documentId, + pdfBuffer, + originalFileName, + vendor +}: SaveDocumentParams) { + try { + console.log("📄 GTC 문서 저장 시작:", { + documentId, + documentIdType: typeof documentId, + vendorName: vendor.vendorName, + originalFileName, + bufferSize: pdfBuffer.length + }) + + // documentId 유효성 검사 + if (!documentId || isNaN(Number(documentId))) { + throw new Error(`유효하지 않은 문서 ID: ${documentId}`) + } + + // 기본계약 존재 여부 확인 + const existingContract = await db.query.basicContract.findFirst({ + where: eq(basicContract.id, documentId), + }) + + if (!existingContract) { + throw new Error(`기본계약을 찾을 수 없습니다. ID: ${documentId}`) + } + + console.log("📋 기존 계약 정보:", { + contractId: existingContract.id, + templateId: existingContract.templateId, + vendorId: existingContract.vendorId, + currentStatus: existingContract.status, + currentFileName: existingContract.fileName, + currentFilePath: existingContract.filePath + }) + + const session = await getServerSession(authOptions) + + if (!session?.user) { + return { success: false, error: "인증되지 않은 사용자입니다." } + } + const userId = Number(session.user.id); + + // 1. PDF 파일명 생성 + const baseName = originalFileName.replace(/\.[^/.]+$/, "") // 확장자 제거 + const fileName = `GTC_${vendor.vendorName}_${baseName}_${new Date().toISOString().split('T')[0]}.pdf` + + // 2. 파일 저장 (공용 파일 저장 함수 사용) + const saveResult = await saveBuffer({ + buffer: Buffer.from(pdfBuffer), + fileName, + directory: 'basicContract', + originalName: fileName, + userId: userId.toString() + }) + + if (!saveResult.success) { + throw new Error(saveResult.error || '파일 저장 실패') + } + + // 3. 데이터베이스 업데이트 - 트랜잭션으로 처리하고 결과 확인 + const updateResult = await db.update(basicContract) + .set({ + fileName: saveResult.fileName!, + filePath: saveResult.publicPath!, + status: 'PENDING', + // 기존 서명 관련 timestamp들 리셋 + vendorSignedAt: null, + buyerSignedAt: null, + legalReviewRequestedAt: null, + legalReviewCompletedAt: null, + updatedAt: new Date() + }) + .where(eq(basicContract.id, documentId)) + .returning({ + id: basicContract.id, + fileName: basicContract.fileName, + filePath: basicContract.filePath + }) + + // DB 업데이트 성공 여부 확인 + if (!updateResult || updateResult.length === 0) { + throw new Error(`기본계약 ID ${documentId}를 찾을 수 없거나 업데이트에 실패했습니다.`) + } + + console.log("✅ GTC 문서 저장 완료:", { + documentId, + updatedRecord: updateResult[0], + fileName: saveResult.fileName, + filePath: saveResult.publicPath, + fileSize: saveResult.fileSize + }) + + // 캐시 무효화 + revalidateTag("basic-contract-requests") + revalidateTag("basic-contracts") + revalidatePath("/partners/basic-contract") + + return { + success: true, + fileName: saveResult.fileName, + filePath: saveResult.publicPath, + fileSize: saveResult.fileSize, + documentId: updateResult[0].id + } + + } catch (error) { + console.error("❌ GTC 문서 저장 실패:", error) + return { + success: false, + error: error instanceof Error ? error.message : '문서 저장 중 오류가 발생했습니다' + } + } }
\ No newline at end of file diff --git a/lib/compliance/questions/compliance-question-edit-sheet.tsx b/lib/compliance/questions/compliance-question-edit-sheet.tsx index 4b12e775..d34b3ecc 100644 --- a/lib/compliance/questions/compliance-question-edit-sheet.tsx +++ b/lib/compliance/questions/compliance-question-edit-sheet.tsx @@ -55,11 +55,18 @@ import { toast } from "sonner"; import { useRouter } from "next/navigation"; import { complianceQuestions } from "@/db/schema/compliance"; +type OptionItem = { optionValue: string; optionText: string; allowsOtherInput: boolean; displayOrder: number }; +const RED_FLAG_OPTIONS: OptionItem[] = [ + { optionValue: "YES", optionText: "YES", allowsOtherInput: false, displayOrder: 1 }, + { optionValue: "NO", optionText: "NO", allowsOtherInput: false, displayOrder: 2 }, +]; + const questionSchema = z.object({ questionNumber: z.string().min(1, "질문 번호를 입력하세요"), questionText: z.string().min(1, "질문 내용을 입력하세요"), questionType: z.string().min(1, "질문 유형을 선택하세요"), isRequired: z.boolean(), + isRedFlag: z.boolean(), hasDetailText: z.boolean(), hasFileUpload: z.boolean(), isConditional: z.boolean(), @@ -91,6 +98,7 @@ export function ComplianceQuestionEditSheet({ const [showOptionForm, setShowOptionForm] = React.useState(false); const [showOptionsDeleteDialog, setShowOptionsDeleteDialog] = React.useState(false); const [pendingQuestionTypeChange, setPendingQuestionTypeChange] = React.useState<string | null>(null); + const previousQuestionIdRef = React.useRef<number | null>(null); const form = useForm<QuestionFormData>({ resolver: zodResolver(questionSchema), @@ -99,6 +107,7 @@ export function ComplianceQuestionEditSheet({ questionText: question.questionText, questionType: question.questionType, isRequired: question.isRequired, + isRedFlag: question.isRedFlag || false, hasDetailText: question.hasDetailText, hasFileUpload: question.hasFileUpload, isConditional: !!question.parentQuestionId, @@ -107,9 +116,13 @@ export function ComplianceQuestionEditSheet({ }, }); + const isRedFlag = form.watch("isRedFlag"); + const questionTypeValue = form.watch("questionType"); + const isSelectionType = React.useMemo(() => { - return [QUESTION_TYPES.RADIO, QUESTION_TYPES.CHECKBOX, QUESTION_TYPES.DROPDOWN].includes((form.getValues("questionType") || "").toUpperCase() as any); - }, [form]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [QUESTION_TYPES.RADIO, QUESTION_TYPES.CHECKBOX, QUESTION_TYPES.DROPDOWN].includes((questionTypeValue || "").toUpperCase() as any); + }, [questionTypeValue]); const loadOptions = React.useCallback(async () => { if (!isSelectionType) return; @@ -121,11 +134,80 @@ export function ComplianceQuestionEditSheet({ } }, [isSelectionType, question.id]); + // 레드플래그 선택 시 질문 유형을 RADIO로 자동 설정 + React.useEffect(() => { + if (isRedFlag && open) { + form.setValue("questionType", QUESTION_TYPES.RADIO); + } + }, [form, isRedFlag, open]); + + // 레드플래그 선택 시 옵션을 YES/NO로 고정 React.useEffect(() => { - if (open) { - loadOptions(); + if (isRedFlag && open && isSelectionType) { + // 레드플래그가 켜지면 옵션을 YES/NO로 설정 + const redFlagOptionsList = RED_FLAG_OPTIONS.map((opt) => ({ + id: 0, // 임시 ID + optionValue: opt.optionValue, + optionText: opt.optionText, + allowsOtherInput: opt.allowsOtherInput, + displayOrder: opt.displayOrder, + })); + setOptions(redFlagOptionsList); + } else if (!isRedFlag && open && isSelectionType) { + // 레드플래그가 꺼지면 기존 옵션을 다시 로드 + void loadOptions(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isRedFlag, open, isSelectionType]); + + // 시트가 열릴 때마다 또는 question.id가 변경될 때만 폼을 원본 데이터로 리셋 + React.useEffect(() => { + // question.id가 변경되거나 시트가 새로 열릴 때만 reset + const shouldReset = open && (previousQuestionIdRef.current !== question.id || previousQuestionIdRef.current === null); + + if (shouldReset) { + previousQuestionIdRef.current = question.id; + + const isRedFlagValue = question.isRedFlag || false; + // 레드플래그가 활성화되어 있으면 질문 유형을 RADIO로 설정 + const questionTypeValue = isRedFlagValue ? QUESTION_TYPES.RADIO : (question.questionType as string); + + form.reset({ + questionNumber: question.questionNumber, + questionText: question.questionText, + questionType: questionTypeValue, + isRequired: question.isRequired, + isRedFlag: isRedFlagValue, + hasDetailText: question.hasDetailText, + hasFileUpload: question.hasFileUpload, + isConditional: !!question.parentQuestionId, + parentQuestionId: question.parentQuestionId || undefined, + conditionalValue: question.conditionalValue || "", + }); + setParentQuestionId(question.parentQuestionId || null); + + // 레드플래그가 활성화되어 있으면 YES/NO 옵션 설정, 아니면 기존 옵션 로드 + if (isRedFlagValue) { + const redFlagOptionsList = RED_FLAG_OPTIONS.map((opt) => ({ + id: 0, // 임시 ID + optionValue: opt.optionValue, + optionText: opt.optionText, + allowsOtherInput: opt.allowsOtherInput, + displayOrder: opt.displayOrder, + })); + setOptions(redFlagOptionsList); + } else { + // loadOptions는 별도로 호출 + if (isSelectionType) { + void loadOptions(); + } + } + } else if (!open) { + // 시트가 닫히면 previousQuestionIdRef를 초기화 + previousQuestionIdRef.current = null; } - }, [open, loadOptions]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, question.id, form, isSelectionType]); // 선택 가능한 부모 질문들 로드 (조건부 질문용) React.useEffect(() => { @@ -153,6 +235,7 @@ export function ComplianceQuestionEditSheet({ } try { const data = await getComplianceQuestionOptions(parentQuestionId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any setParentOptions(data.map((o: any) => ({ id: o.id, optionValue: o.optionValue, optionText: o.optionText }))); } catch (e) { console.error("loadParentOptions error", e); @@ -166,26 +249,44 @@ export function ComplianceQuestionEditSheet({ try { setIsLoading(true); - // 디버깅을 위한 로그 - console.log("Edit form data:", data); - console.log("Current isConditional:", data.isConditional); - console.log("Current parentQuestionId:", parentQuestionId); - console.log("Current conditionalValue:", data.conditionalValue); - + // 조건부 질문 관련 데이터 처리 const updateData = { ...data, - parentQuestionId: data.isConditional ? parentQuestionId : null, - conditionalValue: data.isConditional ? data.conditionalValue : null, + parentQuestionId: data.isConditional ? (parentQuestionId ?? undefined) : undefined, + conditionalValue: data.isConditional ? (data.conditionalValue ?? undefined) : undefined, }; // isConditional은 제거 (스키마에 없음) + // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (updateData as any).isConditional; console.log("Final updateData:", updateData); await updateComplianceQuestion(question.id, updateData); + // 레드플래그가 켜져 있고 질문 유형이 RADIO이면 옵션을 YES/NO로 교체 + if (data.isRedFlag && data.questionType === QUESTION_TYPES.RADIO) { + // 기존 옵션 가져오기 + const existingOptions = await getComplianceQuestionOptions(question.id); + + // 기존 옵션 삭제 + for (const option of existingOptions) { + await deleteComplianceQuestionOption(option.id); + } + + // YES/NO 옵션 추가 + for (const redFlagOption of RED_FLAG_OPTIONS) { + await createComplianceQuestionOption({ + questionId: question.id, + optionValue: redFlagOption.optionValue, + optionText: redFlagOption.optionText, + allowsOtherInput: redFlagOption.allowsOtherInput, + displayOrder: redFlagOption.displayOrder, + }); + } + } + toast.success("질문이 성공적으로 수정되었습니다."); setOpen(false); @@ -301,6 +402,35 @@ export function ComplianceQuestionEditSheet({ /> </div> + {/* 레드플래그 체크박스 */} + <FormField + control={form.control} + name="isRedFlag" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4 bg-red-50"> + <FormControl> + <Checkbox + checked={field.value || false} + onCheckedChange={(checked) => { + // 이벤트 전파 방지 + field.onChange(checked); + }} + onClick={(e) => { + // 클릭 이벤트 전파 방지 + e.stopPropagation(); + }} + /> + </FormControl> + <div className="space-y-1 leading-none"> + <FormLabel className="text-red-700">레드플래그 질문</FormLabel> + <FormDescription> + 질문 유형 - RADIO || 옵션 - YES/NO + </FormDescription> + </div> + </FormItem> + )} + /> + <FormField control={form.control} name="questionText" @@ -326,9 +456,13 @@ export function ComplianceQuestionEditSheet({ <FormItem> <FormLabel>질문 유형</FormLabel> <Select + value={field.value || ""} onValueChange={(newValue) => { + if (isRedFlag) return; // 레드플래그일 때는 변경 불가 const currentType = field.value; + // eslint-disable-next-line @typescript-eslint/no-explicit-any const isCurrentSelectionType = [QUESTION_TYPES.RADIO, QUESTION_TYPES.CHECKBOX, QUESTION_TYPES.DROPDOWN].includes((currentType || "").toUpperCase() as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const isNewSelectionType = [QUESTION_TYPES.RADIO, QUESTION_TYPES.CHECKBOX, QUESTION_TYPES.DROPDOWN].includes(newValue.toUpperCase() as any); // 선택형에서 비선택형으로 변경하고 기존 옵션이 있는 경우 @@ -339,7 +473,7 @@ export function ComplianceQuestionEditSheet({ field.onChange(newValue); } }} - defaultValue={(field.value || "").toUpperCase()} + disabled={isRedFlag} > <FormControl> <SelectTrigger> @@ -363,25 +497,27 @@ export function ComplianceQuestionEditSheet({ <div className="space-y-3"> <div className="flex items-center justify-between"> <div className="text-sm font-medium">옵션 관리</div> - <Button - type="button" - variant="outline" - size="sm" - onClick={() => { - setNewOptionValue(""); - setNewOptionText(""); - setNewOptionOther(false); - // 옵션 추가 모드 활성화 - setShowOptionForm(true); - }} - > - <Plus className="h-4 w-4 mr-1" /> - 옵션 추가 - </Button> + {!isRedFlag && ( + <Button + type="button" + variant="outline" + size="sm" + onClick={() => { + setNewOptionValue(""); + setNewOptionText(""); + setNewOptionOther(false); + // 옵션 추가 모드 활성화 + setShowOptionForm(true); + }} + > + <Plus className="h-4 w-4 mr-1" /> + 옵션 추가 + </Button> + )} </div> {/* 옵션 추가 폼 */} - {showOptionForm && ( + {showOptionForm && !isRedFlag && ( <div className="space-y-3 p-3 border rounded-lg bg-muted/50"> <div className="grid grid-cols-2 gap-3"> <div> @@ -467,23 +603,25 @@ export function ComplianceQuestionEditSheet({ <div className="text-sm font-mono">{opt.optionValue}</div> <div className="text-sm flex-1">{opt.optionText}</div> {opt.allowsOtherInput && <Badge variant="secondary">기타 허용</Badge>} - <Button - type="button" - variant="ghost" - size="icon" - onClick={async () => { - try { - await deleteComplianceQuestionOption(opt.id); - await loadOptions(); - toast.success("옵션이 삭제되었습니다."); - } catch (e) { - console.error(e); - toast.error("옵션 삭제 중 오류가 발생했습니다."); - } - }} - > - <Trash2 className="h-4 w-4" /> - </Button> + {!isRedFlag && ( + <Button + type="button" + variant="ghost" + size="icon" + onClick={async () => { + try { + await deleteComplianceQuestionOption(opt.id); + await loadOptions(); + toast.success("옵션이 삭제되었습니다."); + } catch (e) { + console.error(e); + toast.error("옵션 삭제 중 오류가 발생했습니다."); + } + }} + > + <Trash2 className="h-4 w-4" /> + </Button> + )} </div> )) )} diff --git a/lib/compliance/services.ts b/lib/compliance/services.ts index a603a091..8dc8e916 100644 --- a/lib/compliance/services.ts +++ b/lib/compliance/services.ts @@ -1,7 +1,7 @@ 'use server' import db from "@/db/db"; -import { eq, desc, count, and, ne, or, ilike, asc } from "drizzle-orm"; +import { eq, desc, count, and, ne, or, ilike, asc, inArray } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { complianceSurveyTemplates, @@ -787,24 +787,182 @@ export async function updateComplianceResponseStatus(responseId: number, status: } // 설문조사 템플릿 생성 +const DEFAULT_TEMPLATE_VERSION = "1.0"; + +function incrementVersionString(version?: string | null) { + if (!version) { + return DEFAULT_TEMPLATE_VERSION; + } + const numericValue = Number(version); + if (!Number.isNaN(numericValue)) { + const hasDecimal = version.includes("."); + const decimalDigits = hasDecimal ? version.split(".")[1]?.length ?? 0 : 0; + const incremented = numericValue + 1; + return hasDecimal ? incremented.toFixed(decimalDigits) : String(incremented); + } + return DEFAULT_TEMPLATE_VERSION; +} + export async function createComplianceSurveyTemplate(data: { name: string; description: string; - version: string; isActive?: boolean; + baseTemplateId?: number | null; }) { try { - const [template] = await db - .insert(complianceSurveyTemplates) - .values({ - name: data.name, - description: data.description, - version: data.version, - isActive: data.isActive ?? true, - }) - .returning(); + return await db.transaction(async (tx) => { + let baseTemplate: + | typeof complianceSurveyTemplates.$inferSelect + | undefined; + + if (data.baseTemplateId) { + const [explicitBase] = await tx + .select() + .from(complianceSurveyTemplates) + .where(eq(complianceSurveyTemplates.id, data.baseTemplateId)); + + if (!explicitBase) { + throw new Error("BASE_TEMPLATE_NOT_FOUND"); + } + baseTemplate = explicitBase; + } else { + const [latestSameName] = await tx + .select() + .from(complianceSurveyTemplates) + .where(eq(complianceSurveyTemplates.name, data.name)) + .orderBy(desc(complianceSurveyTemplates.createdAt)) + .limit(1); + + if (latestSameName) { + baseTemplate = latestSameName; + } + } - return template; + let version = DEFAULT_TEMPLATE_VERSION; + if (baseTemplate) { + version = incrementVersionString(baseTemplate.version); + } + + const [template] = await tx + .insert(complianceSurveyTemplates) + .values({ + name: data.name, + description: data.description, + version, + isActive: data.isActive ?? true, + updatedAt: new Date(), + }) + .returning(); + + if (baseTemplate) { + const questions = await tx + .select({ + id: complianceQuestions.id, + questionNumber: complianceQuestions.questionNumber, + questionText: complianceQuestions.questionText, + questionType: complianceQuestions.questionType, + isRequired: complianceQuestions.isRequired, + isRedFlag: complianceQuestions.isRedFlag, + hasDetailText: complianceQuestions.hasDetailText, + hasFileUpload: complianceQuestions.hasFileUpload, + parentQuestionId: complianceQuestions.parentQuestionId, + conditionalValue: complianceQuestions.conditionalValue, + displayOrder: complianceQuestions.displayOrder, + }) + .from(complianceQuestions) + .where(eq(complianceQuestions.templateId, baseTemplate.id)) + .orderBy(complianceQuestions.displayOrder); + + const questionIdMap = new Map<number, number>(); + let pending = [...questions]; + let safetyCounter = 0; + + while (pending.length > 0) { + if (safetyCounter > pending.length * 2) { + throw new Error("QUESTION_PARENT_MAPPING_FAILED"); + } + const nextPending: typeof pending = []; + + for (const question of pending) { + if ( + question.parentQuestionId && + !questionIdMap.has(question.parentQuestionId) + ) { + nextPending.push(question); + continue; + } + + const parentId = question.parentQuestionId + ? questionIdMap.get(question.parentQuestionId) ?? null + : null; + + const [newQuestion] = await tx + .insert(complianceQuestions) + .values({ + templateId: template.id, + questionNumber: question.questionNumber, + questionText: question.questionText, + questionType: question.questionType, + isRequired: question.isRequired, + isRedFlag: question.isRedFlag, + hasDetailText: question.hasDetailText, + hasFileUpload: question.hasFileUpload, + parentQuestionId: parentId, + conditionalValue: question.conditionalValue, + displayOrder: question.displayOrder, + }) + .returning({ id: complianceQuestions.id }); + + questionIdMap.set(question.id, newQuestion.id); + } + + if (nextPending.length === pending.length) { + throw new Error("QUESTION_PARENT_RESOLUTION_FAILED"); + } + + pending = nextPending; + safetyCounter += 1; + } + + if (questionIdMap.size > 0) { + const questionIds = Array.from(questionIdMap.keys()); + const options = await tx + .select({ + questionId: complianceQuestionOptions.questionId, + optionValue: complianceQuestionOptions.optionValue, + optionText: complianceQuestionOptions.optionText, + allowsOtherInput: complianceQuestionOptions.allowsOtherInput, + displayOrder: complianceQuestionOptions.displayOrder, + }) + .from(complianceQuestionOptions) + .where(inArray(complianceQuestionOptions.questionId, questionIds)); + + for (const option of options) { + const newQuestionId = questionIdMap.get(option.questionId); + if (!newQuestionId) continue; + + await tx.insert(complianceQuestionOptions).values({ + questionId: newQuestionId, + optionValue: option.optionValue, + optionText: option.optionText, + allowsOtherInput: option.allowsOtherInput, + displayOrder: option.displayOrder, + }); + } + } + + await tx + .update(complianceSurveyTemplates) + .set({ + isActive: false, + updatedAt: new Date(), + }) + .where(eq(complianceSurveyTemplates.id, baseTemplate.id)); + } + + revalidatePath("/evcp/compliance"); + return template; + }); } catch (error) { console.error("Error creating compliance survey template:", error); throw error; @@ -821,13 +979,14 @@ export async function createTemplateAction(formData: FormData) { const name = formData.get("name") as string const description = formData.get("description") as string - const version = formData.get("version") as string const isActive = formData.get("isActive") === "true" + const baseTemplateIdValue = formData.get("baseTemplateId") as string | null + const baseTemplateId = baseTemplateIdValue ? Number(baseTemplateIdValue) : undefined // 필수 필드 검증 - if (!name || !description || !version) { + if (!name || !description) { return { error: "필수 필드가 누락되었습니다." } } @@ -835,8 +994,8 @@ export async function createTemplateAction(formData: FormData) { await createComplianceSurveyTemplate({ name, description, - version, isActive, + baseTemplateId: baseTemplateId && !Number.isNaN(baseTemplateId) ? baseTemplateId : undefined, }) // 페이지 캐시 무효화 diff --git a/lib/compliance/table/compliance-survey-templates-toolbar.tsx b/lib/compliance/table/compliance-survey-templates-toolbar.tsx index 6776b70a..3e5f7f4d 100644 --- a/lib/compliance/table/compliance-survey-templates-toolbar.tsx +++ b/lib/compliance/table/compliance-survey-templates-toolbar.tsx @@ -16,6 +16,11 @@ interface ComplianceSurveyTemplatesToolbarActionsProps { } export function ComplianceSurveyTemplatesToolbarActions({ table }: ComplianceSurveyTemplatesToolbarActionsProps) { + const templates = React.useMemo( + () => table.getPreFilteredRowModel().rows.map((row) => row.original), + [table], + ); + return ( <div className="flex items-center gap-2"> {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} @@ -27,7 +32,7 @@ export function ComplianceSurveyTemplatesToolbarActions({ table }: ComplianceSur /> ) : null} - <ComplianceTemplateCreateDialog /> + <ComplianceTemplateCreateDialog templates={templates} /> {/** 2) 레드플래그 담당자 관리 */} <RedFlagManagersDialog /> diff --git a/lib/compliance/table/compliance-template-create-dialog.tsx b/lib/compliance/table/compliance-template-create-dialog.tsx index 5b7e1092..db4ede4e 100644 --- a/lib/compliance/table/compliance-template-create-dialog.tsx +++ b/lib/compliance/table/compliance-template-create-dialog.tsx @@ -29,19 +29,33 @@ import { Switch } from "@/components/ui/switch" import { Plus, Loader2 } from "lucide-react" import { toast } from "sonner" import { createComplianceSurveyTemplate } from "../services" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { complianceSurveyTemplates } from "@/db/schema/compliance" const createTemplateSchema = z.object({ name: z.string().min(1, "템플릿명을 입력해주세요.").max(100, "템플릿명은 100자 이하여야 합니다."), description: z.string().min(1, "설명을 입력해주세요.").max(500, "설명은 500자 이하여야 합니다."), - version: z.string().min(1, "버전을 입력해주세요.").max(20, "버전은 20자 이하여야 합니다."), isActive: z.boolean().default(true), }) type CreateTemplateFormValues = z.infer<typeof createTemplateSchema> -export function ComplianceTemplateCreateDialog() { +interface ComplianceTemplateCreateDialogProps { + templates?: typeof complianceSurveyTemplates.$inferSelect[] +} + +export function ComplianceTemplateCreateDialog({ + templates = [], +}: ComplianceTemplateCreateDialogProps) { const [open, setOpen] = React.useState(false) const [isSubmitting, setIsSubmitting] = React.useState(false) + const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>("none") // react-hook-form 세팅 const form = useForm<CreateTemplateFormValues>({ @@ -49,19 +63,54 @@ export function ComplianceTemplateCreateDialog() { defaultValues: { name: "", description: "", - version: "1.0", isActive: true, }, mode: "onChange", }) + const selectedTemplate = React.useMemo(() => { + if (selectedTemplateId === "none") { + return undefined + } + return templates.find((template) => String(template.id) === selectedTemplateId) + }, [selectedTemplateId, templates]) + + const nextVersionLabel = React.useMemo(() => { + if (!selectedTemplate) { + return null + } + const baseVersion = selectedTemplate.version || "1.0" + const numericValue = Number(baseVersion) + if (!Number.isNaN(numericValue)) { + const hasDecimal = baseVersion.includes(".") + const decimalDigits = hasDecimal ? baseVersion.split(".")[1]?.length ?? 0 : 0 + const incremented = numericValue + 1 + return hasDecimal ? incremented.toFixed(decimalDigits) : String(incremented) + } + return "1.0" + }, [selectedTemplate]) + + React.useEffect(() => { + if (selectedTemplate) { + form.setValue("name", selectedTemplate.name ?? "") + form.setValue("description", selectedTemplate.description ?? "") + } + }, [selectedTemplate, form]) + async function onSubmit(data: CreateTemplateFormValues) { setIsSubmitting(true) try { - const result = await createComplianceSurveyTemplate(data) + const baseTemplateNumericId = + selectedTemplateId !== "none" ? Number(selectedTemplateId) : undefined + + const result = await createComplianceSurveyTemplate({ + ...data, + baseTemplateId: baseTemplateNumericId, + }) if (result) { toast.success("새로운 설문조사 템플릿이 생성되었습니다.") form.reset() + setSelectedTemplateId("none") setOpen(false) // 페이지 새로고침으로 데이터 업데이트 window.location.reload() @@ -77,6 +126,7 @@ export function ComplianceTemplateCreateDialog() { function handleDialogOpenChange(nextOpen: boolean) { if (!nextOpen) { form.reset() + setSelectedTemplateId("none") } setOpen(nextOpen) } @@ -133,22 +183,37 @@ export function ComplianceTemplateCreateDialog() { )} /> - <FormField - control={form.control} - name="version" - render={({ field }) => ( - <FormItem> - <FormLabel>버전 *</FormLabel> - <FormControl> - <Input - placeholder="예: 1.0" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> + <div className="space-y-2"> + <FormLabel>기존 템플릿 불러오기 (선택)</FormLabel> + <Select + value={selectedTemplateId} + onValueChange={(value) => setSelectedTemplateId(value)} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="불러올 템플릿을 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="none">선택하지 않음</SelectItem> + {templates.map((template) => ( + <SelectItem key={template.id} value={String(template.id)}> + {template.name} (v{template.version}) + </SelectItem> + ))} + </SelectContent> + </Select> + {selectedTemplate ? ( + <p className="text-sm text-muted-foreground"> + 선택한 템플릿을 기반으로 새 버전(v{nextVersionLabel})이 생성되며, + 기존 템플릿은 자동으로 비활성화됩니다. + </p> + ) : ( + <p className="text-sm text-muted-foreground"> + 템플릿을 선택하지 않으면 새 템플릿이 기본 버전(v1.0)으로 생성됩니다. + </p> )} - /> + </div> <FormField control={form.control} diff --git a/lib/compliance/table/compliance-template-edit-sheet.tsx b/lib/compliance/table/compliance-template-edit-sheet.tsx index 3ac4870a..96ffa8f5 100644 --- a/lib/compliance/table/compliance-template-edit-sheet.tsx +++ b/lib/compliance/table/compliance-template-edit-sheet.tsx @@ -33,7 +33,6 @@ import { useRouter } from "next/navigation"; const templateSchema = z.object({ name: z.string().min(1, "템플릿명을 입력하세요"), description: z.string().min(1, "설명을 입력하세요"), - version: z.string().min(1, "버전을 입력하세요"), isActive: z.boolean(), }); @@ -58,7 +57,6 @@ export function ComplianceTemplateEditSheet({ defaultValues: { name: template.name, description: template.description, - version: template.version, isActive: template.isActive, }, }); @@ -128,20 +126,6 @@ export function ComplianceTemplateEditSheet({ <FormField control={form.control} - name="version" - render={({ field }) => ( - <FormItem> - <FormLabel>버전</FormLabel> - <FormControl> - <Input placeholder="버전을 입력하세요" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} name="isActive" render={({ field }) => ( <FormItem className="flex flex-row items-start space-x-3 space-y-0"> |
