summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-27 12:06:26 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-27 12:06:26 +0000
commit7548e2ad6948f1c6aa102fcac408bc6c9c0f9796 (patch)
tree8e66703ec821888ad51dcc242a508813a027bf71 /lib
parent7eac558470ef179dad626a8e82db5784fe86a556 (diff)
(대표님, 최겸) 기본계약, 입찰, 파일라우트, 계약서명라우트, 인포메이션, 메뉴설정, PQ(메일템플릿 관련)
Diffstat (limited to 'lib')
-rw-r--r--lib/basic-contract/repository.ts66
-rw-r--r--lib/basic-contract/service.ts1319
-rw-r--r--lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx622
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx418
-rw-r--r--lib/basic-contract/status-detail/basic-contracts-detail-table.tsx90
-rw-r--r--lib/basic-contract/status/basic-contract-columns.tsx363
-rw-r--r--lib/basic-contract/status/basic-contract-table.tsx44
-rw-r--r--lib/basic-contract/status/basicContract-table-toolbar-actions.tsx4
-rw-r--r--lib/basic-contract/template/add-basic-contract-template-dialog.tsx1
-rw-r--r--lib/basic-contract/validations.ts42
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx631
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-table.tsx2
-rw-r--r--lib/basic-contract/vendor-table/survey-conditional.ts180
-rw-r--r--lib/basic-contract/viewer/GtcClausesComponent.tsx837
-rw-r--r--lib/basic-contract/viewer/SurveyComponent.tsx922
-rw-r--r--lib/basic-contract/viewer/basic-contract-sign-viewer.tsx1515
-rw-r--r--lib/bidding/detail/service.ts970
-rw-r--r--lib/bidding/detail/table/bidding-detail-content.tsx93
-rw-r--r--lib/bidding/detail/table/bidding-detail-header.tsx328
-rw-r--r--lib/bidding/detail/table/bidding-detail-items-dialog.tsx138
-rw-r--r--lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx167
-rw-r--r--lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx238
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx223
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx335
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx260
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-table.tsx225
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx79
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx2
-rw-r--r--lib/bidding/list/biddings-table.tsx7
-rw-r--r--lib/bidding/validation.ts64
-rw-r--r--lib/bidding/vendor/partners-bidding-attendance-dialog.tsx252
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx562
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx260
-rw-r--r--lib/bidding/vendor/partners-bidding-list.tsx156
-rw-r--r--lib/file-download.ts4
-rw-r--r--lib/legal-review/status/legal-table.tsx2
-rw-r--r--lib/mail/templates/non-inspection-pq.hbs342
-rw-r--r--lib/mail/templates/pq.hbs6
-rw-r--r--lib/mail/templates/project-pq.hbs214
-rw-r--r--lib/vendor-regular-registrations/service.ts3
40 files changed, 10075 insertions, 1911 deletions
diff --git a/lib/basic-contract/repository.ts b/lib/basic-contract/repository.ts
index aab70106..237402d0 100644
--- a/lib/basic-contract/repository.ts
+++ b/lib/basic-contract/repository.ts
@@ -1,7 +1,7 @@
"use server";
import { asc, count,inArray ,eq} from "drizzle-orm";
-import { basicContractTemplates, basicContractView, type BasicContractTemplate } from "@/db/schema";
+import { basicContractTemplates, basicContractView,basicContractTemplateStatsView, type BasicContractTemplate } from "@/db/schema";
import { PgTransaction } from "drizzle-orm/pg-core";
import db from "@/db/db";
@@ -39,6 +39,47 @@ export async function selectBasicContracts(
return tx
.select()
+ .from(basicContractTemplateStatsView)
+ .where(where || undefined)
+ .orderBy(...(orderBy || [asc(basicContractTemplateStatsView.lastActivityDate)]))
+ .offset(offset || 0)
+ .limit(limit || 50);
+}
+
+export async function selectBasicContractsVendor(
+ tx: PgTransaction<any, any, any>,
+ options: {
+ where?: any;
+ orderBy?: any[];
+ offset?: number;
+ limit?: number;
+ }
+) {
+ const { where, orderBy, offset, limit } = options;
+
+ return tx
+ .select()
+ .from(basicContractView)
+ .where(where || undefined)
+ .orderBy(...(orderBy || [asc(basicContractView.createdAt)]))
+ .offset(offset || 0)
+ .limit(limit || 50);
+}
+
+
+export async function selectBasicContractsById(
+ tx: PgTransaction<any, any, any>,
+ options: {
+ where?: any;
+ orderBy?: any[];
+ offset?: number;
+ limit?: number;
+ }
+) {
+ const { where, orderBy, offset, limit } = options;
+
+ return tx
+ .select()
.from(basicContractView)
.where(where || undefined)
.orderBy(...(orderBy || [asc(basicContractView.createdAt)]))
@@ -65,12 +106,35 @@ export async function countBasicContracts(
) {
const result = await tx
.select({ count: count() })
+ .from(basicContractTemplateStatsView)
+ .where(where || undefined);
+
+ return result[0]?.count || 0;
+}
+
+export async function countBasicContractsVendor(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const result = await tx
+ .select({ count: count() })
.from(basicContractView)
.where(where || undefined);
return result[0]?.count || 0;
}
+export async function countBasicContractsById(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const result = await tx
+ .select({ count: count() })
+ .from(basicContractView)
+ .where(where || undefined);
+
+ return result[0]?.count || 0;
+}
// 템플릿 생성
export async function insertBasicContractTemplate(
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts
index 58463f16..194d27eb 100644
--- a/lib/basic-contract/service.ts
+++ b/lib/basic-contract/service.ts
@@ -17,19 +17,30 @@ import {
complianceResponseFiles,
complianceResponses,
complianceSurveyTemplates,
- vendorAttachments,
- vendors,
+ vendorAttachments, basicContractTemplateStatsView,
type BasicContractTemplate as DBBasicContractTemplate,
type NewComplianceResponse,
type NewComplianceResponseAnswer,
- type NewComplianceResponseFile
+ type NewComplianceResponseFile,
+ gtcVendorDocuments,
+ gtcVendorClauses,
+ gtcClauses,
+ gtcDocuments,
+ vendors,
+ gtcNegotiationHistory,
+ type GtcVendorClause,
+ type GtcClause,
+ projects,
+ legalWorks
} from "@/db/schema";
+import path from "path";
import {
GetBasicContractTemplatesSchema,
CreateBasicContractTemplateSchema,
GetBasciContractsSchema,
} from "./validations";
+import { readFile } from "fs/promises"
import {
insertBasicContractTemplate,
@@ -39,7 +50,11 @@ import {
getBasicContractTemplateById,
selectBasicContracts,
countBasicContracts,
- findAllTemplates
+ findAllTemplates,
+ countBasicContractsById,
+ selectBasicContractsById,
+ selectBasicContractsVendor,
+ countBasicContractsVendor
} from "./repository";
import { revalidatePath } from 'next/cache';
import { sendEmail } from "../mail/sendEmail";
@@ -47,7 +62,8 @@ import { headers } from 'next/headers';
import { filterColumns } from "@/lib/filter-columns";
import { differenceInDays, addYears, isBefore } from "date-fns";
import { deleteFile, saveFile } from "@/lib/file-stroage";
-
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
// 템플릿 추가
@@ -607,7 +623,7 @@ export async function getBasicContracts(input: GetBasciContractsSchema) {
// advancedTable 모드면 filterColumns()로 where 절 구성
const advancedWhere = filterColumns({
- table: basicContractView,
+ table: basicContractTemplateStatsView,
filters: input.filters,
joinOperator: input.joinOperator,
});
@@ -616,11 +632,7 @@ export async function getBasicContracts(input: GetBasciContractsSchema) {
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)
+ globalWhere = or(ilike(basicContractTemplateStatsView.templateName, s),
)
// 필요시 여러 칼럼 OR조건 (status, priority, etc)
}
@@ -638,9 +650,9 @@ export async function getBasicContracts(input: GetBasciContractsSchema) {
const orderBy =
input.sort.length > 0
? input.sort.map((item) =>
- item.desc ? desc(basicContractView[item.id]) : asc(basicContractView[item.id])
+ item.desc ? desc(basicContractTemplateStatsView[item.id]) : asc(basicContractTemplateStatsView[item.id])
)
- : [asc(basicContractView.createdAt)];
+ : [asc(basicContractTemplateStatsView.lastActivityDate)];
// 트랜잭션 내부에서 Repository 호출
const { data, total } = await db.transaction(async (tx) => {
@@ -659,6 +671,7 @@ export async function getBasicContracts(input: GetBasciContractsSchema) {
return { data, pageCount };
} catch (err) {
+ console.log(err)
// 에러 발생 시 디폴트
return { data: [], pageCount: 0 };
}
@@ -666,14 +679,14 @@ export async function getBasicContracts(input: GetBasciContractsSchema) {
[JSON.stringify(input)], // 캐싱 키
{
revalidate: 3600,
- tags: ["basicContractView"], // revalidateTag("basicContractView") 호출 시 무효화
+ tags: ["basicContractTemplateStatsView"], // revalidateTag("basicContractTemplateStatsView") 호출 시 무효화
}
)();
}
export async function getBasicContractsByVendorId(
- input: GetBasciContractsSchema,
+ input: GetBasciContractsVendorSchema,
vendorId: number
) {
// return unstable_cache(
@@ -726,14 +739,14 @@ export async function getBasicContractsByVendorId(
// 트랜잭션 내부에서 Repository 호출
const { data, total } = await db.transaction(async (tx) => {
- const data = await selectBasicContracts(tx, {
+ const data = await selectBasicContractsVendor(tx, {
where,
orderBy,
offset,
limit: input.perPage,
});
- const total = await countBasicContracts(tx, where);
+ const total = await countBasicContractsVendor(tx, where);
return { data, total };
});
@@ -753,6 +766,91 @@ export async function getBasicContractsByVendorId(
// )();
}
+
+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();
@@ -1677,4 +1775,1191 @@ export async function uploadSurveyFile(file: File, contractId: number, answerId:
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);
+
+ // 6. 데이터 변환 및 추가 정보 계산
+ const clauses = clausesResult.map(clause => {
+ // 벤더별 수정사항이 있는지 확인
+ const hasVendorData = !!clause.vendorClauseId;
+
+ const hasModifications = hasVendorData && (
+ clause.isNumberModified ||
+ clause.isCategoryModified ||
+ clause.isSubtitleModified ||
+ clause.isContentModified
+ );
+
+ const hasComment = hasVendorData && !!clause.negotiationNote;
+
+ return {
+ // 벤더 조항 ID (있는 경우만, 없으면 null)
+ // id: clause.vendorClauseId,
+ 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,
+ pendingComment: null, // 클라이언트에서 관리
+ };
+ });
+
+ 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: ClauseUpdateData,
+ vendorDocument?: VendorDocument
+): 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; // companyId를 vendorId로 사용
+ 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. 벤더 문서 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를 확보할 수 없습니다." };
+ }
+
+ // 3. 수정 여부 확인
+ 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());
+
+ // 4. 벤더 조항 데이터 준비
+ const vendorClauseData = {
+ vendorDocumentId: finalVendorDocumentId,
+ baseClauseId: baseClauseId,
+ parentId: baseClause.parentId,
+ depth: baseClause.depth,
+ sortOrder: baseClause.sortOrder,
+ fullPath: baseClause.fullPath,
+
+ // 수정된 값들 (수정되지 않았으면 null로 저장)
+ 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;
+
+ // 5. 벤더 조항 생성 또는 업데이트
+ 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}`);
+ }
+
+ // 6. 협의 이력에 기록
+ if (hasAnyModifications || hasComment) {
+ const historyAction = hasAnyModifications ? 'modified' : 'commented';
+ const historyComment = hasAnyModifications
+ ? `조항 수정: ${clauseData.comment || '수정 이유 없음'}`
+ : clauseData.comment;
+
+ await db.insert(gtcNegotiationHistory).values({
+ vendorClauseId: finalVendorClauseId,
+ action: historyAction,
+ comment: historyComment?.trim(),
+ actorType: 'vendor',
+ actorId: session.user.id,
+ actorName: session.user.name,
+ actorEmail: session.user.email,
+ });
+ }
+
+ 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 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: "법무검토가 완료되지 않았습니다."
+ }
+ }
+
+ // 파일 저장 로직 (기존 파일 덮어쓰기)
+ // TODO: 실제 파일 저장 구현
+ const saveResult = await saveFile({signedFileData,directory: "basicContract/signed" });
+
+ const currentDate = new Date()
+
+ // 계약서 상태 업데이트
+ const updatedContract = await db
+ .update(basicContract)
+ .set({
+ buyerSignedAt: currentDate,
+ completedAt: currentDate,
+ status: "COMPLETED",
+ updatedAt: currentDate,
+ // signedFilePath: savedFilePath, // 새로운 파일 경로로 업데이트
+ })
+ .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 : "파일을 읽는 중 오류가 발생했습니다."
+ }
+ }
} \ No newline at end of file
diff --git a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
new file mode 100644
index 00000000..3b5cdd21
--- /dev/null
+++ b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx
@@ -0,0 +1,622 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, FileDown, Mail, Scale, CheckCircle, AlertTriangle, Send, Gavel, Check, FileSignature } from "lucide-react"
+
+import { exportTableToExcel } from "@/lib/export"
+import { downloadFile } from "@/lib/file-download"
+import { Button } from "@/components/ui/button"
+import { BasicContractView } from "@/db/schema"
+import { toast } from "sonner"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { Textarea } from "@/components/ui/textarea"
+import { Label } from "@/components/ui/label"
+import { Separator } from "@/components/ui/separator"
+import { prepareFinalApprovalAction, quickFinalApprovalAction, requestLegalReviewAction } from "../service"
+import { BasicContractSignDialog } from "../vendor-table/basic-contract-sign-dialog"
+
+interface BasicContractDetailTableToolbarActionsProps {
+ table: Table<BasicContractView>
+}
+
+export function BasicContractDetailTableToolbarActions({ table }: BasicContractDetailTableToolbarActionsProps) {
+ // 선택된 행들 가져오기
+ const selectedRows = table.getSelectedRowModel().rows
+ const hasSelectedRows = selectedRows.length > 0
+
+ // 다이얼로그 상태
+ const [resendDialog, setResendDialog] = React.useState(false)
+ const [legalReviewDialog, setLegalReviewDialog] = React.useState(false)
+ const [finalApproveDialog, setFinalApproveDialog] = React.useState(false)
+ const [loading, setLoading] = React.useState(false)
+ const [reviewNote, setReviewNote] = React.useState("")
+ const [buyerSignDialog, setBuyerSignDialog] = React.useState(false)
+ const [contractsToSign, setContractsToSign] = React.useState<any[]>([])
+
+ // 각 버튼별 활성화 조건 계산
+ const canBulkDownload = hasSelectedRows && selectedRows.some(row =>
+ row.original.signedFilePath && row.original.signedFileName && row.original.vendorSignedAt
+ )
+
+ const canBulkResend = hasSelectedRows
+
+ const canRequestLegalReview = hasSelectedRows && selectedRows.some(row =>
+ row.original.legalReviewRequired && !row.original.legalReviewRequestedAt
+ )
+
+ const canFinalApprove = hasSelectedRows && selectedRows.some(row => {
+ const contract = row.original;
+ if (contract.completedAt !== null || !contract.signedFilePath) {
+ return false;
+ }
+ if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) {
+ return false;
+ }
+ return true;
+ });
+
+ // 필터링된 계약서들 계산
+ const resendContracts = selectedRows.map(row => row.original)
+
+ const legalReviewContracts = selectedRows
+ .map(row => row.original)
+ .filter(contract => contract.legalReviewRequired && !contract.legalReviewRequestedAt)
+
+ const finalApproveContracts = selectedRows
+ .map(row => row.original)
+ .filter(contract => {
+ if (contract.completedAt !== null || !contract.signedFilePath) {
+ return false;
+ }
+ if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) {
+ return false;
+ }
+ return true;
+ });
+
+ const contractsWithoutLegalReview = finalApproveContracts.filter(contract =>
+ !contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt
+ );
+
+ // 대량 재발송
+ const handleBulkResend = async () => {
+ if (!hasSelectedRows) {
+ toast.error("재발송할 계약서를 선택해주세요")
+ return
+ }
+ setResendDialog(true)
+ }
+
+ // 선택된 계약서들 일괄 다운로드
+ const handleBulkDownload = async () => {
+ if (!canBulkDownload) {
+ toast.error("다운로드할 파일이 있는 계약서를 선택해주세요")
+ return
+ }
+
+ const selectedContracts = selectedRows
+ .map(row => row.original)
+ .filter(contract => contract.signedFilePath && contract.signedFileName)
+
+ if (selectedContracts.length === 0) {
+ toast.error("다운로드할 파일이 없습니다")
+ return
+ }
+
+ // 다운로드 시작 알림
+ toast.success(`${selectedContracts.length}건의 파일 다운로드를 시작합니다`)
+
+ let successCount = 0
+ let failedCount = 0
+ const failedFiles: string[] = []
+
+ // 순차적으로 다운로드 (병렬 다운로드는 브라우저 제한으로 인해 문제가 될 수 있음)
+ for (let i = 0; i < selectedContracts.length; i++) {
+ const contract = selectedContracts[i]
+
+ try {
+ // 진행 상황 표시
+ if (selectedContracts.length > 3) {
+ toast.loading(`다운로드 중... (${i + 1}/${selectedContracts.length})`, {
+ id: 'bulk-download-progress'
+ })
+ }
+
+ const result = await downloadFile(
+ contract.signedFilePath!,
+ contract.signedFileName!,
+ {
+ action: 'download',
+ showToast: false, // 개별 토스트는 비활성화
+ onError: (error) => {
+ console.error(`다운로드 실패 - ${contract.signedFileName}:`, error)
+ failedFiles.push(`${contract.vendorName || '업체명 없음'} (${contract.signedFileName})`)
+ failedCount++
+ },
+ onSuccess: (fileName) => {
+ console.log(`다운로드 성공 - ${fileName}`)
+ successCount++
+ }
+ }
+ )
+
+ if (result.success) {
+ successCount++
+ } else {
+ failedCount++
+ failedFiles.push(`${contract.vendorName || '업체명 없음'} (${contract.signedFileName})`)
+ }
+
+ // 다운로드 간격 (브라우저 부하 방지)
+ if (i < selectedContracts.length - 1) {
+ await new Promise(resolve => setTimeout(resolve, 300))
+ }
+
+ } catch (error) {
+ console.error(`다운로드 에러 - ${contract.signedFileName}:`, error)
+ failedCount++
+ failedFiles.push(`${contract.vendorName || '업체명 없음'} (${contract.signedFileName})`)
+ }
+ }
+
+ // 진행 상황 토스트 제거
+ toast.dismiss('bulk-download-progress')
+
+ // 최종 결과 표시
+ if (successCount === selectedContracts.length) {
+ toast.success(`모든 파일 다운로드 완료 (${successCount}건)`)
+ } else if (successCount > 0) {
+ toast.warning(
+ `일부 파일 다운로드 완료\n성공: ${successCount}건, 실패: ${failedCount}건`,
+ {
+ duration: 5000,
+ description: failedFiles.length > 0
+ ? `실패한 파일: ${failedFiles.slice(0, 3).join(', ')}${failedFiles.length > 3 ? ` 외 ${failedFiles.length - 3}건` : ''}`
+ : undefined
+ }
+ )
+ } else {
+ toast.error(
+ `모든 파일 다운로드 실패 (${failedCount}건)`,
+ {
+ duration: 5000,
+ description: failedFiles.length > 0
+ ? `실패한 파일: ${failedFiles.slice(0, 3).join(', ')}${failedFiles.length > 3 ? ` 외 ${failedFiles.length - 3}건` : ''}`
+ : undefined
+ }
+ )
+ }
+
+ console.log("일괄 다운로드 완료:", {
+ total: selectedContracts.length,
+ success: successCount,
+ failed: failedCount,
+ failedFiles
+ })
+ }
+
+ // 법무검토 요청
+ const handleLegalReviewRequest = async () => {
+ if (!canRequestLegalReview) {
+ toast.error("법무검토 요청 가능한 계약서를 선택해주세요")
+ return
+ }
+ setLegalReviewDialog(true)
+ }
+
+ // 최종승인
+ const handleFinalApprove = async () => {
+ if (!canFinalApprove) {
+ toast.error("최종승인 가능한 계약서를 선택해주세요")
+ return
+ }
+ setFinalApproveDialog(true)
+ }
+
+ // 재발송 확인
+ const confirmResend = async () => {
+ setLoading(true)
+ try {
+ // TODO: 서버액션 호출
+ // await resendContractsAction(resendContracts.map(c => c.id))
+
+ console.log("대량 재발송:", resendContracts)
+ toast.success(`${resendContracts.length}건의 계약서 재발송을 완료했습니다`)
+ setResendDialog(false)
+ table.toggleAllPageRowsSelected(false) // 선택 해제
+ } catch (error) {
+ toast.error("재발송 중 오류가 발생했습니다")
+ console.error(error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // 법무검토 요청 확인
+ const confirmLegalReview = async () => {
+ setLoading(true)
+ try {
+ // TODO: 서버액션 호출
+ await requestLegalReviewAction(legalReviewContracts.map(c => c.id), reviewNote)
+
+ console.log("법무검토 요청:", legalReviewContracts, "메모:", reviewNote)
+ toast.success(`${legalReviewContracts.length}건의 법무검토 요청을 완료했습니다`)
+ setLegalReviewDialog(false)
+ setReviewNote("")
+ table.toggleAllPageRowsSelected(false) // 선택 해제
+ } catch (error) {
+ toast.error("법무검토 요청 중 오류가 발생했습니다")
+ console.error(error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // 최종승인 확인 (수정됨)
+ const confirmFinalApprove = async () => {
+ setLoading(true)
+ try {
+ // 먼저 서명 가능한 계약서들을 준비
+ const prepareResult = await prepareFinalApprovalAction(
+ finalApproveContracts.map(c => c.id)
+ )
+
+ if (prepareResult.success && prepareResult.contracts) {
+ // 서명이 필요한 경우 서명 다이얼로그 열기
+ setContractsToSign(prepareResult.contracts)
+ setFinalApproveDialog(false) // 기존 다이얼로그는 닫기
+ // buyerSignDialog는 더 이상 필요 없으므로 제거
+ } else {
+ toast.error(prepareResult.message)
+ }
+ } catch (error) {
+ toast.error("최종승인 준비 중 오류가 발생했습니다")
+ console.error(error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // 구매자 서명 완료 콜백
+ const handleBuyerSignComplete = () => {
+ setContractsToSign([]) // 계약서 목록 초기화하여 BasicContractSignDialog 언마운트
+ table.toggleAllPageRowsSelected(false)
+ toast.success("모든 계약서의 최종승인이 완료되었습니다!")
+ }
+
+ // 빠른 승인 (서명 없이)
+ const confirmQuickApproval = async () => {
+ setLoading(true)
+ try {
+ const result = await quickFinalApprovalAction(
+ finalApproveContracts.map(c => c.id)
+ )
+
+ if (result.success) {
+ toast.success(result.message)
+ setFinalApproveDialog(false)
+ table.toggleAllPageRowsSelected(false)
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ toast.error("최종승인 중 오류가 발생했습니다")
+ console.error(error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+ {/* 일괄 다운로드 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleBulkDownload}
+ disabled={!canBulkDownload}
+ className="gap-2"
+ title={!hasSelectedRows
+ ? "계약서를 선택해주세요"
+ : !canBulkDownload
+ ? "다운로드할 파일이 있는 계약서를 선택해주세요"
+ : `${selectedRows.filter(row => row.original.signedFilePath && row.original.signedFileName).length}건 다운로드`
+ }
+ >
+ <FileDown className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 일괄 다운로드 {hasSelectedRows ? `(${selectedRows.length})` : ''}
+ </span>
+ </Button>
+
+ {/* 재발송 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleBulkResend}
+ disabled={!canBulkResend}
+ className="gap-2"
+ title={!hasSelectedRows ? "계약서를 선택해주세요" : `${selectedRows.length}건 재발송`}
+ >
+ <Mail className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 재발송 {hasSelectedRows ? `(${selectedRows.length})` : ''}
+ </span>
+ </Button>
+
+ {/* 법무검토 요청 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleLegalReviewRequest}
+ disabled={!canRequestLegalReview}
+ className="gap-2"
+ title={!hasSelectedRows
+ ? "계약서를 선택해주세요"
+ : !canRequestLegalReview
+ ? "법무검토 요청 가능한 계약서가 없습니다"
+ : `${legalReviewContracts.length}건 법무검토 요청`
+ }
+ >
+ <Scale className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 법무검토 {hasSelectedRows ? `(${selectedRows.length})` : ''}
+ </span>
+ </Button>
+
+ {/* 최종승인 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleFinalApprove}
+ disabled={!canFinalApprove}
+ className="gap-2"
+ title={!hasSelectedRows
+ ? "계약서를 선택해주세요"
+ : !canFinalApprove
+ ? "최종승인 가능한 계약서가 없습니다"
+ : `${finalApproveContracts.length}건 최종승인`
+ }
+ >
+ <CheckCircle className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 최종승인 {hasSelectedRows ? `(${selectedRows.length})` : ''}
+ </span>
+ </Button>
+
+ {/* 실제 구매자 서명을 위한 BasicContractSignDialog */}
+ {contractsToSign.length > 0 && (
+ <BasicContractSignDialog
+ contracts={contractsToSign}
+ onSuccess={handleBuyerSignComplete}
+ hasSelectedRows={contractsToSign.length > 0}
+ mode="buyer" // 구매자 모드 prop
+ t={(key) => key}
+ />
+ )}
+
+ {/* Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "basic-contract-details",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+
+ {/* 재발송 다이얼로그 */}
+ <Dialog open={resendDialog} onOpenChange={setResendDialog}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Send className="size-5" />
+ 계약서 재발송 확인
+ </DialogTitle>
+ <DialogDescription>
+ 선택한 {resendContracts.length}건의 계약서를 재발송합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="max-h-60 overflow-y-auto">
+ <div className="space-y-3">
+ {resendContracts.map((contract, index) => (
+ <div key={contract.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
+ <div className="flex-1">
+ <div className="font-medium">{contract.vendorName || '업체명 없음'}</div>
+ <div className="text-sm text-gray-500">
+ {contract.vendorCode || '코드 없음'} | {contract.templateName || '템플릿명 없음'}
+ </div>
+ </div>
+ <Badge variant="secondary">{contract.status}</Badge>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setResendDialog(false)}
+ disabled={loading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={confirmResend}
+ disabled={loading}
+ className="gap-2"
+ >
+ <Send className="size-4" />
+ {loading ? "재발송 중..." : `${resendContracts.length}건 재발송`}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 법무검토 요청 다이얼로그 */}
+ <Dialog open={legalReviewDialog} onOpenChange={setLegalReviewDialog}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Gavel className="size-5" />
+ 법무검토 요청
+ </DialogTitle>
+ <DialogDescription>
+ 선택한 {legalReviewContracts.length}건의 계약서에 대한 법무검토를 요청합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="max-h-48 overflow-y-auto">
+ <div className="space-y-3">
+ {legalReviewContracts.map((contract, index) => (
+ <div key={contract.id} className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
+ <div className="flex-1">
+ <div className="font-medium">{contract.vendorName || '업체명 없음'}</div>
+ <div className="text-sm text-gray-500">
+ {contract.vendorCode || '코드 없음'} | {contract.templateName || '템플릿명 없음'}
+ </div>
+ </div>
+ <Badge variant="secondary">{contract.status}</Badge>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ <Separator />
+
+ <div className="space-y-2">
+ <Label htmlFor="review-note">검토 요청 메모 (선택사항)</Label>
+ <Textarea
+ id="review-note"
+ placeholder="법무팀에게 전달할 특별한 요청사항이나 검토 포인트를 입력해주세요..."
+ value={reviewNote}
+ onChange={(e) => setReviewNote(e.target.value)}
+ rows={3}
+ />
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setLegalReviewDialog(false)
+ setReviewNote("")
+ }}
+ disabled={loading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={confirmLegalReview}
+ disabled={loading}
+ className="gap-2"
+ >
+ <Gavel className="size-4" />
+ {loading ? "요청 중..." : `${legalReviewContracts.length}건 검토요청`}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 최종승인 다이얼로그 */}
+ <Dialog open={finalApproveDialog} onOpenChange={setFinalApproveDialog}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Check className="size-5" />
+ 최종승인 전 확인
+ </DialogTitle>
+ <DialogDescription>
+ 선택한 {finalApproveContracts.length}건의 계약서를 최종승인을 위해 서명을 호출합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {contractsWithoutLegalReview.length > 0 && (
+ <div className="flex items-start gap-3 p-4 bg-amber-50 border border-amber-200 rounded-lg">
+ <AlertTriangle className="size-5 text-amber-600 flex-shrink-0 mt-0.5" />
+ <div>
+ <div className="font-medium text-amber-800">법무검토 없이 승인되는 계약서</div>
+ <div className="text-sm text-amber-700 mt-1">
+ {contractsWithoutLegalReview.length}건의 계약서가 법무검토 없이 승인됩니다.
+ 승인 후에는 되돌릴 수 없으니 신중히 검토해주세요.
+ </div>
+ </div>
+ </div>
+ )}
+
+ <div className="max-h-60 overflow-y-auto">
+ <div className="space-y-3">
+ {finalApproveContracts.map((contract) => {
+ const hasLegalReview = contract.legalReviewRequestedAt && contract.legalReviewCompletedAt
+ const noLegalReview = !contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt
+
+ return (
+ <div
+ key={contract.id}
+ className={`flex items-center justify-between p-3 rounded-lg ${noLegalReview ? 'bg-amber-50 border border-amber-200' : 'bg-green-50 border border-green-200'
+ }`}
+ >
+ <div className="flex-1">
+ <div className="font-medium">{contract.vendorName || '업체명 없음'}</div>
+ <div className="text-sm text-gray-500">
+ {contract.vendorCode || '코드 없음'} | {contract.templateName || '템플릿명 없음'}
+ </div>
+ {noLegalReview && (
+ <div className="text-xs text-amber-600 mt-1">법무검토 없음</div>
+ )}
+ {hasLegalReview && (
+ <div className="text-xs text-green-600 mt-1">법무검토 완료</div>
+ )}
+ </div>
+ <Badge variant="secondary">{contract.status}</Badge>
+ </div>
+ )
+ })}
+ </div>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setFinalApproveDialog(false)}
+ disabled={loading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={confirmFinalApprove}
+ disabled={loading}
+ className="gap-2"
+ variant={contractsWithoutLegalReview.length > 0 ? "destructive" : "default"}
+ >
+ <Check className="size-4" />
+ {loading ? "호출 중..." : `${finalApproveContracts.length}건 서명호출`}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
new file mode 100644
index 00000000..80c39d1e
--- /dev/null
+++ b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx
@@ -0,0 +1,418 @@
+// basic-contracts-detail-columns.tsx
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+
+import { formatDateTime } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Checkbox } from "@/components/ui/checkbox"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { Button } from "@/components/ui/button"
+import { MoreHorizontal, Download, Eye, Mail, FileText, Clock } from "lucide-react"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { BasicContractView } from "@/db/schema"
+import { downloadFile, quickPreview } from "@/lib/file-download"
+import { toast } from "sonner"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BasicContractView> | null>>
+}
+
+const CONTRACT_STATUS_CONFIG = {
+ PENDING: { label: "발송완료", color: "gray" },
+ VENDOR_SIGNED: { label: "협력업체 서명완료", color: "blue" },
+ BUYER_SIGNED: { label: "구매팀 서명완료", color: "green" },
+ LEGAL_REVIEW_REQUESTED: { label: "법무검토 요청", color: "purple" },
+ LEGAL_REVIEW_COMPLETED: { label: "법무검토 완료", color: "indigo" },
+ COMPLETED: { label: "계약완료", color: "emerald" },
+ REJECTED: { label: "거절됨", color: "red" },
+} as const
+
+export function getDetailColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicContractView>[] {
+
+ const selectColumn: ColumnDef<BasicContractView> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ maxSize: 30,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ const actionsColumn: ColumnDef<BasicContractView> = {
+ id: "actions",
+ header: "작업",
+ cell: ({ row }) => {
+ const contract = row.original
+ const hasSignedFile = contract.signedFilePath && contract.signedFileName
+
+ const handleDownload = async () => {
+ if (!hasSignedFile) {
+ toast.error("다운로드할 파일이 없습니다")
+ return
+ }
+
+ await downloadFile(
+ contract.signedFilePath!,
+ contract.signedFileName!,
+ {
+ action: 'download',
+ showToast: true,
+ onError: (error) => {
+ console.error("Download failed:", error)
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`Downloaded: ${fileName} (${fileSize} bytes)`)
+ }
+ }
+ )
+ }
+
+ const handlePreview = async () => {
+ if (!hasSignedFile) {
+ toast.error("미리볼 파일이 없습니다")
+ return
+ }
+
+ await quickPreview(contract.signedFilePath!, contract.signedFileName!)
+ }
+
+ const handleResend = () => {
+ setRowAction({ type: "resend", row })
+ }
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="h-8 w-8 p-0">
+ <span className="sr-only">Open menu</span>
+ <MoreHorizontal className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ {hasSignedFile && (
+ <>
+ <DropdownMenuItem onClick={handlePreview}>
+ <Eye className="mr-2 h-4 w-4" />
+ 파일 미리보기
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={handleDownload}>
+ <Download className="mr-2 h-4 w-4" />
+ 파일 다운로드
+ </DropdownMenuItem>
+ </>
+ )}
+ <DropdownMenuItem onClick={handleResend}>
+ <Mail className="mr-2 h-4 w-4" />
+ 재발송
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => setRowAction({ type: "view", row })}>
+ <FileText className="mr-2 h-4 w-4" />
+ 상세 정보
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ enableSorting: false,
+ enableHiding: false,
+ maxSize: 80,
+ }
+
+ return [
+ selectColumn,
+
+ // 업체 코드
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="업체코드" />
+ ),
+ cell: ({ row }) => {
+ const code = row.getValue("vendorCode") as string | null
+ return code ? (
+ <span className="font-mono text-sm bg-gray-100 px-2 py-1 rounded">
+ {code}
+ </span>
+ ) : "-"
+ },
+ minSize: 120,
+ },
+
+ // 업체명
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="업체명" />
+ ),
+ cell: ({ row }) => {
+ const name = row.getValue("vendorName") as string | null
+ return (
+ <div className="font-medium">{name || "-"}</div>
+ )
+ },
+ minSize: 180,
+ },
+
+ // 진행상태
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="진행상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("status") as keyof typeof CONTRACT_STATUS_CONFIG
+ const config = CONTRACT_STATUS_CONFIG[status] || { label: status, color: "gray" }
+
+ const variantMap = {
+ gray: "secondary",
+ blue: "default",
+ green: "default",
+ purple: "secondary",
+ indigo: "secondary",
+ emerald: "default",
+ red: "destructive",
+ } as const
+
+ return (
+ <Badge variant={variantMap[config.color as keyof typeof variantMap]}>
+ {config.label}
+ </Badge>
+ )
+ },
+ minSize: 140,
+ filterFn: (row, id, value) => {
+ return value.includes(row.getValue(id))
+ },
+ },
+
+ // 요청일
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="요청일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("createdAt") as Date
+ return (
+ <div className="text-sm">
+ <div>{formatDateTime(date, "KR")}</div>
+ </div>
+ )
+ },
+ minSize: 130,
+ },
+
+ // 마감일
+ {
+ accessorKey: "deadline",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="마감일" />
+ ),
+ cell: ({ row }) => {
+ const deadline = row.getValue("deadline") as string | null
+ const status = row.getValue("status") as string
+
+ if (!deadline) return "-"
+
+ const deadlineDate = new Date(deadline)
+ const today = new Date()
+ const isOverdue = deadlineDate < today && !["COMPLETED", "REJECTED"].includes(status)
+ const isNearDeadline = !isOverdue && deadlineDate.getTime() - today.getTime() < 2 * 24 * 60 * 60 * 1000 // 2일 이내
+
+ return (
+ <div className={`text-sm flex items-center gap-1 ${
+ isOverdue ? 'text-red-600 font-medium' :
+ isNearDeadline ? 'text-orange-600' :
+ 'text-gray-900'
+ }`}>
+ <Clock className="h-3 w-3" />
+ <div>
+ <div>{deadlineDate.toLocaleDateString('ko-KR')}</div>
+ {isOverdue && <div className="text-xs">(지연)</div>}
+ {isNearDeadline && !isOverdue && <div className="text-xs">(임박)</div>}
+ </div>
+ </div>
+ )
+ },
+ minSize: 120,
+ },
+
+ // 협력업체 서명일
+ {
+ accessorKey: "vendorSignedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체 서명" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("vendorSignedAt") as Date | null
+ return date ? (
+ <div className="text-sm text-blue-600">
+ <div className="font-medium">완료</div>
+ <div className="text-xs">{formatDateTime(date, "KR")}</div>
+ </div>
+ ) : (
+ <div className="text-sm text-gray-400">미완료</div>
+ )
+ },
+ minSize: 130,
+ },
+
+ // 법무검토 요청일
+ {
+ accessorKey: "legalReviewRequestedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="법무검토 요청" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("legalReviewRequestedAt") as Date | null
+ return date ? (
+ <div className="text-sm text-purple-600">
+ <div className="font-medium">요청됨</div>
+ <div className="text-xs">{formatDateTime(date, "KR")}</div>
+ </div>
+ ) : (
+ <div className="text-sm text-gray-400">-</div>
+ )
+ },
+ minSize: 140,
+ },
+
+ // 법무검토 완료일
+ {
+ accessorKey: "legalReviewCompletedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="법무검토 완료" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("legalReviewCompletedAt") as Date | null
+ const requestedDate = row.getValue("legalReviewRequestedAt") as Date | null
+
+ return date ? (
+ <div className="text-sm text-indigo-600">
+ <div className="font-medium">완료</div>
+ <div className="text-xs">{formatDateTime(date, "KR")}</div>
+ </div>
+ ) : requestedDate ? (
+ <div className="text-sm text-orange-500">
+ <div className="font-medium">진행중</div>
+ <div className="text-xs">검토 대기</div>
+ </div>
+ ) : (
+ <div className="text-sm text-gray-400">-</div>
+ )
+ },
+ minSize: 140,
+ },
+
+ // 계약완료일
+ {
+ accessorKey: "completedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="계약완료" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("completedAt") as Date | null
+ return date ? (
+ <div className="text-sm text-emerald-600">
+ <div className="font-medium">완료</div>
+ <div className="text-xs">{formatDateTime(date, "KR")}</div>
+ </div>
+ ) : (
+ <div className="text-sm text-gray-400">미완료</div>
+ )
+ },
+ minSize: 120,
+ },
+
+
+ // 서명된 파일
+ {
+ accessorKey: "signedFileName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="서명파일" />
+ ),
+ cell: ({ row }) => {
+ const fileName = row.getValue("signedFileName") as string | null
+ const filePath = row.original.signedFilePath
+ const vendorSignedAt = row.original.vendorSignedAt
+
+ if (!fileName || !filePath|| !vendorSignedAt) {
+ return <div className="text-sm text-gray-400">파일 없음</div>
+ }
+
+ const handleQuickDownload = async (e: React.MouseEvent) => {
+ e.stopPropagation()
+ await downloadFile(filePath, fileName, {
+ action: 'download',
+ showToast: true
+ })
+ }
+
+ const handleQuickPreview = async (e: React.MouseEvent) => {
+ e.stopPropagation()
+ await quickPreview(filePath, fileName)
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ <div className="text-sm">
+ <div className="font-medium text-blue-600 truncate max-w-[150px]" title={fileName}>
+ 서명파일
+ </div>
+ <div className="text-xs text-gray-500">클릭하여 다운로드</div>
+ </div>
+ <div className="flex gap-1">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleQuickPreview}
+ className="h-6 w-6 p-0"
+ title="미리보기"
+ >
+ <Eye className="h-3 w-3" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleQuickDownload}
+ className="h-6 w-6 p-0"
+ title="다운로드"
+ >
+ <Download className="h-3 w-3" />
+ </Button>
+ </div>
+ </div>
+ )
+ },
+ minSize: 200,
+ enableSorting: false,
+ },
+
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
new file mode 100644
index 00000000..2698842e
--- /dev/null
+++ b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx
@@ -0,0 +1,90 @@
+"use client"
+
+import * as React from "react"
+import { DataTable } from "@/components/data-table/data-table"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { getDetailColumns } from "./basic-contracts-detail-columns"
+import { getBasicContractsByTemplateId } from "@/lib/basic-contract/service"
+import { BasicContractView } from "@/db/schema"
+import { BasicContractDetailTableToolbarActions } from "./basic-contract-detail-table-toolbar-actions"
+
+interface BasicContractsDetailTableProps {
+ templateId: number
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getBasicContractsByTemplateId>>,
+ ]
+ >
+}
+
+export function BasicContractsDetailTable({ templateId, promises }: BasicContractsDetailTableProps) {
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<BasicContractView> | null>(null)
+
+ const [{ data, pageCount }] = React.use(promises)
+
+ console.log(data,"data")
+
+ const columns = React.useMemo(
+ () => getDetailColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ const advancedFilterFields: DataTableAdvancedFilterField<BasicContractView>[] = [
+ { id: "vendorName", label: "업체명", type: "text" },
+ { id: "vendorCode", label: "업체코드", type: "text" },
+ {
+ id: "status",
+ label: "진행상태",
+ type: "select",
+ options: [
+ { label: "발송완료", value: "PENDING" },
+ { label: "협력업체 서명완료", value: "VENDOR_SIGNED" },
+ { label: "구매팀 서명완료", value: "BUYER_SIGNED" },
+ { label: "법무검토 요청", value: "LEGAL_REVIEW_REQUESTED" },
+ { label: "법무검토 완료", value: "LEGAL_REVIEW_COMPLETED" },
+ { label: "계약완료", value: "COMPLETED" },
+ { label: "거절됨", value: "REJECTED" },
+ ]
+ },
+ { id: "requestedByName", label: "요청자", type: "text" },
+ { id: "createdAt", label: "요청일", type: "date" },
+ { id: "deadline", label: "마감일", type: "date" },
+ { id: "vendorSignedAt", label: "협력업체 서명일", type: "date" },
+ { id: "buyerSignedAt", label: "구매팀 서명일", type: "date" },
+ { id: "completedAt", label: "계약완료일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ >
+ <BasicContractDetailTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/basic-contract/status/basic-contract-columns.tsx b/lib/basic-contract/status/basic-contract-columns.tsx
index cc9d9bff..8ae8fa1e 100644
--- a/lib/basic-contract/status/basic-contract-columns.tsx
+++ b/lib/basic-contract/status/basic-contract-columns.tsx
@@ -8,26 +8,34 @@ import { formatDateTime } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import {
- FileActionsDropdown,
- FileNameLink
-} from "@/components/ui/file-actions"
+import { Button } from "@/components/ui/button"
+import { MoreHorizontal, Eye, FileText, Calendar } from "lucide-react"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { useRouter } from "next/navigation"
+import { BasicContractTemplateStatsView } from "@/db/schema"
+
+type NextRouter = ReturnType<typeof useRouter>;
-import { basicContractColumnsConfig } from "@/config/basicContractColumnsConfig"
-import { BasicContractView } from "@/db/schema"
interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BasicContractView> | null>>
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BasicContractTemplateStatsView> | null>>
+ router: NextRouter;
+
}
/**
- * 공용 파일 다운로드 유틸리티를 사용하는 간소화된 컬럼 정의
+ * BasicContractTemplateStatsView용 컬럼 정의
*/
-export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicContractView>[] {
+export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<BasicContractTemplateStatsView>[] {
// ----------------------------------------------------------------
// 1) select 컬럼 (체크박스)
// ----------------------------------------------------------------
- const selectColumn: ColumnDef<BasicContractView> = {
+ const selectColumn: ColumnDef<BasicContractTemplateStatsView> = {
id: "select",
header: ({ table }) => (
<Checkbox
@@ -54,143 +62,252 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicCo
}
// ----------------------------------------------------------------
- // 2) 파일 다운로드 컬럼 (공용 컴포넌트 사용)
+ // 2) Actions 컬럼
// ----------------------------------------------------------------
- const downloadColumn: ColumnDef<BasicContractView> = {
- id: "download",
- header: "",
+ const actionsColumn: ColumnDef<BasicContractTemplateStatsView> = {
+ id: "actions",
+ header: "작업",
cell: ({ row }) => {
- const template = row.original;
-
- if (!template.filePath || !template.fileName) {
- return null;
- }
+ const template = row.original
+ const detailUrl = `/evcp/basic-contract/${template.templateId}`;
return (
- <FileActionsDropdown
- filePath={template.filePath}
- fileName={template.fileName}
- variant="ghost"
- size="icon"
- />
- );
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="h-8 w-8 p-0">
+ <span className="sr-only">Open menu</span>
+ <MoreHorizontal className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={() => router.push(detailUrl)}>
+ <Eye className="mr-2 h-4 w-4" />
+ 상세 보기
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => setRowAction({ type: "view", row })}>
+ <FileText className="mr-2 h-4 w-4" />
+ 템플릿 보기
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
},
- maxSize: 30,
enableSorting: false,
+ enableHiding: false,
+ maxSize: 80,
}
// ----------------------------------------------------------------
- // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // 3) 기본 정보 컬럼들
// ----------------------------------------------------------------
- const groupMap: Record<string, ColumnDef<BasicContractView>[]> = {}
-
- basicContractColumnsConfig.forEach((cfg) => {
- const groupName = cfg.group || "_noGroup"
-
- if (!groupMap[groupName]) {
- groupMap[groupName] = []
- }
+ const basicInfoColumns: ColumnDef<BasicContractTemplateStatsView>[] = [
+ {
+ accessorKey: "templateName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="템플릿명" />
+ ),
+ cell: ({ row }) => {
+ const name = row.getValue("templateName") as string
+ return (
+ <div className="font-medium">
+ {name}
+ </div>
+ )
+ },
+ minSize: 200,
+ },
+ {
+ accessorKey: "revision",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="리비전" />
+ ),
+ cell: ({ row }) => {
+ const revision = row.getValue("revision") as number
+ return (
+ <Badge variant="outline">
+ v{revision}
+ </Badge>
+ )
+ },
+ minSize: 80,
+ },
+ ]
- const childCol: ColumnDef<BasicContractView> = {
- accessorKey: cfg.id,
- enableResizing: true,
+ // ----------------------------------------------------------------
+ // 4) 발송/처리 현황 컬럼들
+ // ----------------------------------------------------------------
+ const processStatusColumns: ColumnDef<BasicContractTemplateStatsView>[] = [
+ {
+ accessorKey: "totalSentCount",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ <DataTableColumnHeaderSimple column={column} title="발송건수" />
),
- meta: {
- excelHeader: cfg.excelHeader,
- group: cfg.group,
- type: cfg.type,
+ cell: ({ row }) => {
+ const count = row.getValue("totalSentCount") as number
+ return (
+ <div className="text-center font-medium">
+ {count.toLocaleString()}
+ </div>
+ )
},
- cell: ({ row, cell }) => {
- // 날짜 형식 처리
- if (cfg.id === "createdAt" || cfg.id === "updatedAt") {
- const dateVal = cell.getValue() as Date
- return formatDateTime(dateVal, "KR")
- }
+ minSize: 100,
+ },
+ {
+ accessorKey: "overdueCount",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="지연건수" />
+ ),
+ cell: ({ row }) => {
+ const count = row.getValue("overdueCount") as number
+ const total = row.getValue("totalSentCount") as number
+ const isHigh = total > 0 && (count / total) > 0.2
- // Status 컬럼에 Badge 적용 (확장)
- if (cfg.id === "status") {
- const status = row.getValue(cfg.id) as string
-
- let variant: "default" | "secondary" | "destructive" | "outline" = "secondary";
- let label = status;
-
- switch (status) {
- case "ACTIVE":
- variant = "default";
- label = "활성";
- break;
- case "INACTIVE":
- variant = "secondary";
- label = "비활성";
- break;
- case "PENDING":
- variant = "outline";
- label = "대기중";
- break;
- case "COMPLETED":
- variant = "default";
- label = "완료";
- break;
- default:
- variant = "secondary";
- label = status;
- }
-
- return <Badge variant={variant}>{label}</Badge>
- }
-
- // ✅ 파일 이름 컬럼 (공용 컴포넌트 사용)
- if (cfg.id === "fileName") {
- const fileName = cell.getValue() as string;
- const filePath = row.original.filePath;
-
- if (fileName && filePath) {
- return (
- <FileNameLink
- filePath={filePath}
- fileName={fileName}
- maxLength={200}
- showIcon={true}
- />
- );
- }
- return fileName || "";
- }
-
- // 나머지 컬럼은 그대로 값 표시
- return row.getValue(cfg.id) ?? ""
+ return (
+ <div className={`text-center font-medium ${isHigh ? 'text-red-600' : 'text-gray-900'}`}>
+ {count.toLocaleString()}
+ </div>
+ )
},
- minSize: 80,
- }
+ minSize: 100,
+ },
+ {
+ accessorKey: "unsignedCount",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="미서명건수" />
+ ),
+ cell: ({ row }) => {
+ const count = row.getValue("unsignedCount") as number
+ return (
+ <div className="text-center font-medium text-orange-600">
+ {count.toLocaleString()}
+ </div>
+ )
+ },
+ minSize: 120,
+ },
+ ]
- groupMap[groupName].push(childCol)
- })
+ // ----------------------------------------------------------------
+ // 5) 법무검토 현황 컬럼들
+ // ----------------------------------------------------------------
+ const legalReviewColumns: ColumnDef<BasicContractTemplateStatsView>[] = [
+ {
+ accessorKey: "legalRequestCount",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="법무요청" />
+ ),
+ cell: ({ row }) => {
+ const count = row.getValue("legalRequestCount") as number
+ return (
+ <div className="text-center font-medium text-purple-600">
+ {count.toLocaleString()}
+ </div>
+ )
+ },
+ minSize: 100,
+ },
+ {
+ accessorKey: "legalCompletedCount",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="법무완료" />
+ ),
+ cell: ({ row }) => {
+ const count = row.getValue("legalCompletedCount") as number
+ return (
+ <div className="text-center font-medium text-indigo-600">
+ {count.toLocaleString()}
+ </div>
+ )
+ },
+ minSize: 100,
+ },
+ {
+ accessorKey: "contractCompletedCount",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="계약완료" />
+ ),
+ cell: ({ row }) => {
+ const count = row.getValue("contractCompletedCount") as number
+ return (
+ <div className="text-center font-medium text-indigo-600">
+ {count.toLocaleString()}
+ </div>
+ )
+ },
+ minSize: 100,
+ },
+ ]
// ----------------------------------------------------------------
- // 4) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // 6) 성과 지표 컬럼들
// ----------------------------------------------------------------
- const nestedColumns: ColumnDef<BasicContractView>[] = []
+ const performanceColumns: ColumnDef<BasicContractTemplateStatsView>[] = [
+ {
+ accessorKey: "avgProcessingDays",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="평균처리일" />
+ ),
+ cell: ({ row }) => {
+ const days = row.getValue("avgProcessingDays") as number | null
+ if (!days) return "-"
+
+ const rounded = Math.round(days * 10) / 10
+ const isGood = rounded <= 5
+ const isWarning = rounded > 5 && rounded <= 10
+
+ let colorClass = "text-gray-900"
+ if (isGood) colorClass = "text-green-600"
+ else if (isWarning) colorClass = "text-orange-600"
+ else colorClass = "text-red-600"
+
+ return (
+ <div className={`text-center font-medium ${colorClass}`}>
+ {rounded}일
+ </div>
+ )
+ },
+ minSize: 100,
+ },
+ ]
- Object.entries(groupMap).forEach(([groupName, colDefs]) => {
- if (groupName === "_noGroup") {
- nestedColumns.push(...colDefs)
- } else {
- nestedColumns.push({
- id: groupName,
- header: groupName,
- columns: colDefs,
- })
- }
- })
+ // ----------------------------------------------------------------
+ // 7) 시간 정보 컬럼들
+ // ----------------------------------------------------------------
+ const timeInfoColumns: ColumnDef<BasicContractTemplateStatsView>[] = [
+ {
+ accessorKey: "templateCreatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("templateCreatedAt") as Date
+ return formatDateTime(date, "KR")
+ },
+ minSize: 120,
+ },
+ {
+ accessorKey: "lastActivityDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="최근활동" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("lastActivityDate") as Date | null
+ return date ? formatDateTime(date, "KR") : "-"
+ },
+ minSize: 120,
+ },
+ ]
// ----------------------------------------------------------------
- // 5) 최종 컬럼 배열
+ // 8) 최종 컬럼 배열 (평면 구조)
// ----------------------------------------------------------------
return [
selectColumn,
- downloadColumn, // ✅ 공용 파일 액션 컴포넌트 사용
- ...nestedColumns,
+ ...basicInfoColumns,
+ ...processStatusColumns,
+ ...legalReviewColumns,
+ ...performanceColumns,
+ ...timeInfoColumns,
+ actionsColumn,
]
-}
+} \ No newline at end of file
diff --git a/lib/basic-contract/status/basic-contract-table.tsx b/lib/basic-contract/status/basic-contract-table.tsx
index 22845144..07707ff0 100644
--- a/lib/basic-contract/status/basic-contract-table.tsx
+++ b/lib/basic-contract/status/basic-contract-table.tsx
@@ -14,9 +14,9 @@ import type {
import { toast } from "sonner";
import { getColumns } from "./basic-contract-columns";
import { getBasicContracts } from "../service";
-import { BasicContractView } from "@/db/schema";
+import { BasicContractTemplateStatsView } from "@/db/schema";
import { BasicContractTableToolbarActions } from "./basicContract-table-toolbar-actions";
-
+import { useRouter } from "next/navigation"
interface BasicTemplateTableProps {
promises: Promise<
@@ -26,38 +26,36 @@ interface BasicTemplateTableProps {
>
}
-
export function BasicContractsTable({ promises }: BasicTemplateTableProps) {
-
const [rowAction, setRowAction] =
- React.useState<DataTableRowAction<BasicContractView> | null>(null)
-
+ React.useState<DataTableRowAction<BasicContractTemplateStatsView> | null>(null)
+ const router = useRouter()
const [{ data, pageCount }] =
React.use(promises)
// 컬럼 설정 - 외부 파일에서 가져옴
const columns = React.useMemo(
- () => getColumns({ setRowAction }),
- [setRowAction]
+ () => getColumns({ setRowAction,router }),
+ [setRowAction,router]
)
// config 기반으로 필터 필드 설정
- const advancedFilterFields: DataTableAdvancedFilterField<BasicContractView>[] = [
+ const advancedFilterFields: DataTableAdvancedFilterField<BasicContractTemplateStatsView>[] = [
{ id: "templateName", label: "템플릿명", type: "text" },
- {
- id: "status", label: "상태", type: "select", options: [
- { label: "활성", value: "ACTIVE" },
- { label: "비활성", value: "INACTIVE" },
- ]
- },
- { id: "userName", label: "요청자", type: "text" },
- { id: "vendorName", label: "업체명", type: "text" },
- { id: "vendorCode", label: "업체코드", type: "text" },
- { id: "vendorEmail", label: "업체대표이메일", type: "text" },
- { id: "createdAt", label: "생성일", type: "date" },
- { id: "updatedAt", label: "수정일", type: "date" },
+ { id: "revision", label: "리비전", type: "number" },
+ { id: "validityPeriod", label: "유효기간(개월)", type: "number" },
+ { id: "totalSentCount", label: "발송건수", type: "number" },
+ { id: "overdueCount", label: "지연건수", type: "number" },
+ { id: "unsignedCount", label: "미서명건수", type: "number" },
+ { id: "legalRequestCount", label: "법무요청건수", type: "number" },
+ { id: "legalCompletedCount", label: "법무완료건수", type: "number" },
+ { id: "contractCompletedCount", label: "계약완료건수", type: "number" },
+ { id: "rejectedCount", label: "거절건수", type: "number" },
+ { id: "avgProcessingDays", label: "평균처리일", type: "number" },
+ { id: "templateCreatedAt", label: "템플릿 생성일", type: "date" },
+ { id: "lastActivityDate", label: "최근활동일", type: "date" },
];
const { table } = useDataTable({
@@ -68,10 +66,10 @@ export function BasicContractsTable({ promises }: BasicTemplateTableProps) {
enablePinning: true,
enableAdvancedFilter: true,
initialState: {
- sorting: [{ id: "createdAt", desc: true }],
+ sorting: [{ id: "lastActivityDate", desc: true }],
columnPinning: { right: ["actions"] },
},
- getRowId: (originalRow) => String(originalRow.id),
+ getRowId: (originalRow) => String(originalRow.templateId),
shallow: false,
clearOnDefault: true,
})
diff --git a/lib/basic-contract/status/basicContract-table-toolbar-actions.tsx b/lib/basic-contract/status/basicContract-table-toolbar-actions.tsx
index cee94790..a74a6e4d 100644
--- a/lib/basic-contract/status/basicContract-table-toolbar-actions.tsx
+++ b/lib/basic-contract/status/basicContract-table-toolbar-actions.tsx
@@ -7,10 +7,10 @@ import { Download, Upload } from "lucide-react"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
-import { BasicContractView } from "@/db/schema"
+import { BasicContractTemplateStatsView } from "@/db/schema"
interface TemplateTableToolbarActionsProps {
- table: Table<BasicContractView>
+ table: Table<BasicContractTemplateStatsView>
}
export function BasicContractTableToolbarActions({ table }: TemplateTableToolbarActionsProps) {
diff --git a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx
index 141cb1e3..a248407e 100644
--- a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx
+++ b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx
@@ -149,6 +149,7 @@ export function AddTemplateDialog() {
setAvailableProjects(projects);
} catch (err) {
+ console.log("Failed to load template data", err);
console.error("Failed to load template data", err);
toast.error("템플릿 정보를 불러오는데 실패했습니다.");
}
diff --git a/lib/basic-contract/validations.ts b/lib/basic-contract/validations.ts
index bb9e3b8d..53738dfc 100644
--- a/lib/basic-contract/validations.ts
+++ b/lib/basic-contract/validations.ts
@@ -7,7 +7,7 @@ import { createSearchParamsCache,
} from "nuqs/server"
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { BasicContractTemplate, BasicContractView } from "@/db/schema";
+import { BasicContractTemplate, BasicContractTemplateStatsView, BasicContractView } from "@/db/schema";
export const basicContractTemplateSchema = z.object({
templateName: z.string().min(1, "템플릿 이름은 필수입니다."),
@@ -96,6 +96,25 @@ export const searchParamsCache = createSearchParamsCache({
flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<BasicContractTemplateStatsView>().withDefault([
+ { id: "lastActivityDate", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+});
+
+export type GetBasciContractsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
+
+
+
+export const searchParamsVendorCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
sort: getSortingStateParser<BasicContractView>().withDefault([
{ id: "createdAt", desc: true },
]),
@@ -107,4 +126,23 @@ export const searchParamsCache = createSearchParamsCache({
});
-export type GetBasciContractsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
+export type GetBasciContractsVendorSchema = Awaited<ReturnType<typeof searchParamsVendorCache.parse>>;
+
+
+export const searchParamsCacheByTemplateId = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<BasicContractView>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+});
+
+export type GetBasciContractsByIdSchema = Awaited<ReturnType<typeof searchParamsCacheByTemplateId.parse>>;
+
diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
index 319ae4b9..f70bed94 100644
--- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
+++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
@@ -22,14 +22,15 @@ import {
Loader2,
ArrowRight,
Trophy,
- Target
+ Target,
+ Shield
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { useRouter } from "next/navigation"
import { BasicContractSignViewer } from "../viewer/basic-contract-sign-viewer";
-import { getVendorAttachments } from "../service";
+import { getVendorAttachments, processBuyerSignatureAction } from "../service";
// 계약서 상태 타입 정의
interface ContractStatus {
@@ -42,16 +43,27 @@ interface BasicContractSignDialogProps {
contracts: BasicContractView[];
onSuccess?: () => void;
hasSelectedRows?: boolean;
+ mode?: 'vendor' | 'buyer';
+ onBuyerSignComplete?: (contractId: number, signedData: ArrayBuffer) => void;
t: (key: string) => string;
+ // 외부 상태 제어를 위한 새로운 props (선택적)
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
}
export function BasicContractSignDialog({
contracts,
onSuccess,
hasSelectedRows = false,
- t
+ mode = 'vendor',
+ onBuyerSignComplete,
+ t,
+ // 새로 추가된 props
+ open: externalOpen,
+ onOpenChange: externalOnOpenChange
}: BasicContractSignDialogProps) {
- const [open, setOpen] = React.useState(false);
+ // 내부 상태 (외부 제어가 없을 때 사용)
+ const [internalOpen, setInternalOpen] = React.useState(false);
const [selectedContract, setSelectedContract] = React.useState<BasicContractView | null>(null);
const [instance, setInstance] = React.useState<null | WebViewerInstance>(null);
const [searchTerm, setSearchTerm] = React.useState("");
@@ -64,14 +76,21 @@ export function BasicContractSignDialog({
// 계약서 상태 관리
const [contractStatuses, setContractStatuses] = React.useState<ContractStatus[]>([]);
- // 🔥 새로 추가: 서명/설문 완료 상태 관리
+ // 서명/설문/GTC 코멘트 완료 상태 관리
const [surveyCompletionStatus, setSurveyCompletionStatus] = React.useState<Record<number, boolean>>({});
const [signatureStatus, setSignatureStatus] = React.useState<Record<number, boolean>>({});
+ const [gtcCommentStatus, setGtcCommentStatus] = React.useState<Record<number, { hasComments: boolean; commentCount: number }>>({});
const router = useRouter()
- console.log(selectedContract,"selectedContract")
- console.log(additionalFiles,"additionalFiles")
+ // 실제 사용할 open 상태 (외부 제어가 있으면 외부 상태 사용, 없으면 내부 상태 사용)
+ const isControlledExternally = externalOpen !== undefined;
+ const open = isControlledExternally ? externalOpen : internalOpen;
+
+ // 모드에 따른 텍스트
+ const isBuyerMode = mode === 'buyer';
+ const dialogTitle = isBuyerMode ? "구매자 최종승인 서명" : t("basicContracts.dialog.title");
+ const signButtonText = isBuyerMode ? "최종승인 완료" : "서명 완료 및 저장";
// 버튼 비활성화 조건
const isButtonDisabled = !hasSelectedRows || contracts.length === 0;
@@ -87,29 +106,28 @@ export function BasicContractSignDialog({
return "";
};
- // 🔥 현재 선택된 계약서의 서명 완료 가능 여부 확인
+ // 현재 선택된 계약서의 서명 완료 가능 여부 확인
const canCompleteCurrentContract = React.useMemo(() => {
if (!selectedContract) return false;
const contractId = selectedContract.id;
+
+ // 구매자 모드에서는 설문조사나 GTC 체크 불필요
+ if (isBuyerMode) {
+ const signatureCompleted = signatureStatus[contractId] === true;
+ return signatureCompleted;
+ }
+
+ // 협력업체 모드의 기존 로직
const isComplianceTemplate = selectedContract.templateName?.includes('준법');
+ const isGTCTemplate = selectedContract.templateName?.includes('GTC');
- // 1. 준법 템플릿인 경우 설문조사 완료 여부 확인
const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true;
-
- // 2. 서명 완료 여부 확인
+ const gtcCompleted = isGTCTemplate ? (gtcCommentStatus[contractId]?.hasComments !== true) : true;
const signatureCompleted = signatureStatus[contractId] === true;
- console.log('🔍 서명 완료 가능 여부 체크:', {
- contractId,
- isComplianceTemplate,
- surveyCompleted,
- signatureCompleted,
- canComplete: surveyCompleted && signatureCompleted
- });
-
- return surveyCompleted && signatureCompleted;
- }, [selectedContract, surveyCompletionStatus, signatureStatus]);
+ return surveyCompleted && gtcCompleted && signatureCompleted;
+ }, [selectedContract, surveyCompletionStatus, signatureStatus, gtcCommentStatus, isBuyerMode]);
// 계약서별 상태 초기화
React.useEffect(() => {
@@ -142,7 +160,7 @@ export function BasicContractSignDialog({
return contracts.find(contract => contract.id === nextPendingId) || null;
};
- // 다이얼로그 열기/닫기 핸들러
+ // 다이얼로그 열기/닫기 핸들러 (외부 제어 지원)
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen && !allCompleted && completedCount > 0) {
// 완료되지 않은 계약서가 있으면 확인 대화상자
@@ -152,7 +170,12 @@ export function BasicContractSignDialog({
if (!confirmClose) return;
}
- setOpen(isOpen);
+ // 외부 제어가 있으면 외부 콜백 호출, 없으면 내부 상태 업데이트
+ if (isControlledExternally && externalOnOpenChange) {
+ externalOnOpenChange(isOpen);
+ } else {
+ setInternalOpen(isOpen);
+ }
if (!isOpen) {
// 다이얼로그 닫을 때 상태 초기화
@@ -160,8 +183,9 @@ export function BasicContractSignDialog({
setSearchTerm("");
setAdditionalFiles([]);
setContractStatuses([]);
- setSurveyCompletionStatus({}); // 🔥 추가
- setSignatureStatus({}); // 🔥 추가
+ setSurveyCompletionStatus({});
+ setSignatureStatus({});
+ setGtcCommentStatus({});
// WebViewer 인스턴스 정리
if (instance) {
try {
@@ -202,9 +226,14 @@ export function BasicContractSignDialog({
}
}
}, [open, contracts, selectedContract, contractStatuses]);
-
- // 추가 파일 가져오기 useEffect
+
+ // 추가 파일 가져오기 useEffect (구매자 모드에서는 스킵)
React.useEffect(() => {
+ if (isBuyerMode) {
+ setAdditionalFiles([]);
+ return;
+ }
+
const fetchAdditionalFiles = async () => {
if (!selectedContract) {
setAdditionalFiles([]);
@@ -235,9 +264,9 @@ export function BasicContractSignDialog({
};
fetchAdditionalFiles();
- }, [selectedContract]);
+ }, [selectedContract, isBuyerMode]);
- // 🔥 설문조사 완료 콜백 함수
+ // 설문조사 완료 콜백 함수
const handleSurveyComplete = React.useCallback((contractId: number) => {
console.log(`📋 설문조사 완료: 계약서 ${contractId}`);
setSurveyCompletionStatus(prev => ({
@@ -246,7 +275,7 @@ export function BasicContractSignDialog({
}));
}, []);
- // 🔥 서명 완료 콜백 함수
+ // 서명 완료 콜백 함수
const handleSignatureComplete = React.useCallback((contractId: number) => {
console.log(`✍️ 서명 완료: 계약서 ${contractId}`);
setSignatureStatus(prev => ({
@@ -255,31 +284,64 @@ export function BasicContractSignDialog({
}));
}, []);
- // 서명 완료 핸들러 (수정됨)
+ // GTC 코멘트 상태 변경 콜백 함수
+ const handleGtcCommentStatusChange = React.useCallback((contractId: number, hasComments: boolean, commentCount: number) => {
+ console.log(`📋 GTC 코멘트 상태 변경: 계약서 ${contractId}, 코멘트 ${commentCount}개`);
+ setGtcCommentStatus(prev => ({
+ ...prev,
+ [contractId]: { hasComments, commentCount }
+ }));
+ }, []);
+
+ // 서명 완료 핸들러
const completeSign = async () => {
if (!instance || !selectedContract) return;
- // 🔥 서명 완료 가능 여부 재확인
+ // 서명 완료 가능 여부 재확인
if (!canCompleteCurrentContract) {
const contractId = selectedContract.id;
- const isComplianceTemplate = selectedContract.templateName?.includes('준법');
- const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true;
- const signatureCompleted = signatureStatus[contractId] === true;
- if (!surveyCompleted) {
- toast.error("준법 설문조사를 먼저 완료해주세요.", {
- description: "설문조사 탭에서 모든 필수 항목을 완료해주세요.",
- icon: <AlertCircle className="h-5 w-5 text-red-500" />
- });
- return;
- }
-
- if (!signatureCompleted) {
- toast.error("계약서에 서명을 먼저 완료해주세요.", {
- description: "문서의 서명 필드에 서명해주세요.",
- icon: <Target className="h-5 w-5 text-blue-500" />
- });
- return;
+ if (isBuyerMode) {
+ const signatureCompleted = signatureStatus[contractId] === true;
+
+ if (!signatureCompleted) {
+ toast.error("계약서에 서명을 먼저 완료해주세요.", {
+ description: "문서의 서명 필드에 서명해주세요.",
+ icon: <Target className="h-5 w-5 text-blue-500" />
+ });
+ return;
+ }
+ } else {
+ // 협력업체 모드의 기존 검증 로직
+ const isComplianceTemplate = selectedContract.templateName?.includes('준법');
+ const isGTCTemplate = selectedContract.templateName?.includes('GTC');
+ const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true;
+ const gtcCompleted = isGTCTemplate ? (gtcCommentStatus[contractId]?.hasComments !== true) : true;
+ const signatureCompleted = signatureStatus[contractId] === true;
+
+ if (!surveyCompleted) {
+ toast.error("준법 설문조사를 먼저 완료해주세요.", {
+ description: "설문조사 탭에서 모든 필수 항목을 완료해주세요.",
+ icon: <AlertCircle className="h-5 w-5 text-red-500" />
+ });
+ return;
+ }
+
+ if (!gtcCompleted) {
+ toast.error("GTC 조항에 코멘트가 있어 서명할 수 없습니다.", {
+ description: "조항 검토 탭에서 모든 코멘트를 삭제하거나 협의를 완료해주세요.",
+ icon: <AlertCircle className="h-5 w-5 text-red-500" />
+ });
+ return;
+ }
+
+ if (!signatureCompleted) {
+ toast.error("계약서에 서명을 먼저 완료해주세요.", {
+ description: "문서의 서명 필드에 서명해주세요.",
+ icon: <Target className="h-5 w-5 text-blue-500" />
+ });
+ return;
+ }
}
return;
@@ -303,73 +365,135 @@ export function BasicContractSignDialog({
xfdfString,
downloadType: "pdf",
});
-
- // FormData 생성 및 파일 추가
- const submitFormData = new FormData();
- submitFormData.append('file', new Blob([data], { type: 'application/pdf' }));
- submitFormData.append('tableRowId', selectedContract.id.toString());
- submitFormData.append('templateName', selectedContract.signedFileName || '');
-
- // 폼 필드 데이터 추가
- if (Object.keys(formData).length > 0) {
- submitFormData.append('formData', JSON.stringify(formData));
- }
-
- // API 호출
- const response = await fetch('/api/upload/signed-contract', {
- method: 'POST',
- body: submitFormData,
- next: { tags: ["basicContractView-vendor"] },
- });
-
- const result = await response.json();
-
- if (result.result) {
- // 성공시 해당 계약서 상태를 완료로 업데이트
- setContractStatuses(prev =>
- prev.map(status =>
- status.id === selectedContract.id
- ? { ...status, status: 'completed' as const }
- : status
- )
+
+ if (isBuyerMode) {
+ // 구매자 모드: 최종승인 처리
+ const result = await processBuyerSignatureAction(
+ selectedContract.id,
+ data,
+ selectedContract.signedFileName || `contract_${selectedContract.id}_buyer_signed.pdf`
);
- toast.success("계약서 서명이 완료되었습니다!", {
- description: `${selectedContract.templateName} - ${completedCount + 1}/${totalCount}개 완료`,
- icon: <CheckCircle2 className="h-5 w-5 text-green-500" />
- });
+ if (result.success) {
+ // 성공시 해당 계약서 상태를 완료로 업데이트
+ setContractStatuses(prev =>
+ prev.map(status =>
+ status.id === selectedContract.id
+ ? { ...status, status: 'completed' as const }
+ : status
+ )
+ );
- // 다음 미완료 계약서로 자동 이동
- const nextContract = getNextPendingContract();
- if (nextContract) {
- setSelectedContract(nextContract);
- toast.info(`다음 계약서로 이동합니다`, {
- description: nextContract.templateName,
- icon: <ArrowRight className="h-4 w-4 text-blue-500" />
+ toast.success("최종승인이 완료되었습니다!", {
+ description: `${selectedContract.templateName} - ${completedCount + 1}/${totalCount}개 완료`,
+ icon: <CheckCircle2 className="h-5 w-5 text-green-500" />
});
+
+ // 구매자 서명 완료 콜백 호출
+ if (onBuyerSignComplete) {
+ onBuyerSignComplete(selectedContract.id, data);
+ }
+
+ // 다음 미완료 계약서로 자동 이동
+ const nextContract = getNextPendingContract();
+ if (nextContract) {
+ setSelectedContract(nextContract);
+ toast.info(`다음 계약서로 이동합니다`, {
+ description: nextContract.templateName,
+ icon: <ArrowRight className="h-4 w-4 text-blue-500" />
+ });
+ } else {
+ // 모든 계약서 완료시
+ toast.success("🎉 모든 계약서 최종승인이 완료되었습니다!", {
+ description: `총 ${totalCount}개 계약서 승인 완료`,
+ icon: <Trophy className="h-5 w-5 text-yellow-500" />
+ });
+ }
+
+ router.refresh();
} else {
- // 모든 계약서 완료시
- toast.success("🎉 모든 계약서 서명이 완료되었습니다!", {
- description: `총 ${totalCount}개 계약서 서명 완료`,
- icon: <Trophy className="h-5 w-5 text-yellow-500" />
+ // 실패시 에러 상태 업데이트
+ setContractStatuses(prev =>
+ prev.map(status =>
+ status.id === selectedContract.id
+ ? { ...status, status: 'error' as const, errorMessage: result.message }
+ : status
+ )
+ );
+
+ toast.error("최종승인 처리 중 오류가 발생했습니다", {
+ description: result.message,
+ icon: <AlertCircle className="h-5 w-5 text-red-500" />
});
}
-
- router.refresh();
} else {
- // 실패시 에러 상태 업데이트
- setContractStatuses(prev =>
- prev.map(status =>
- status.id === selectedContract.id
- ? { ...status, status: 'error' as const, errorMessage: result.error }
- : status
- )
- );
-
- toast.error("서명 처리 중 오류가 발생했습니다", {
- description: result.error,
- icon: <AlertCircle className="h-5 w-5 text-red-500" />
+ // 협력업체 모드: 기존 로직
+ const submitFormData = new FormData();
+ submitFormData.append('file', new Blob([data], { type: 'application/pdf' }));
+ submitFormData.append('tableRowId', selectedContract.id.toString());
+ submitFormData.append('templateName', selectedContract.signedFileName || '');
+
+ // 폼 필드 데이터 추가
+ if (Object.keys(formData).length > 0) {
+ submitFormData.append('formData', JSON.stringify(formData));
+ }
+
+ // API 호출
+ const response = await fetch('/api/upload/signed-contract', {
+ method: 'POST',
+ body: submitFormData,
+ next: { tags: ["basicContractView-vendor"] },
});
+
+ const result = await response.json();
+
+ if (result.result) {
+ // 성공시 해당 계약서 상태를 완료로 업데이트
+ setContractStatuses(prev =>
+ prev.map(status =>
+ status.id === selectedContract.id
+ ? { ...status, status: 'completed' as const }
+ : status
+ )
+ );
+
+ toast.success("계약서 서명이 완료되었습니다!", {
+ description: `${selectedContract.templateName} - ${completedCount + 1}/${totalCount}개 완료`,
+ icon: <CheckCircle2 className="h-5 w-5 text-green-500" />
+ });
+
+ // 다음 미완료 계약서로 자동 이동
+ const nextContract = getNextPendingContract();
+ if (nextContract) {
+ setSelectedContract(nextContract);
+ toast.info(`다음 계약서로 이동합니다`, {
+ description: nextContract.templateName,
+ icon: <ArrowRight className="h-4 w-4 text-blue-500" />
+ });
+ } else {
+ // 모든 계약서 완료시
+ toast.success("🎉 모든 계약서 서명이 완료되었습니다!", {
+ description: `총 ${totalCount}개 계약서 서명 완료`,
+ icon: <Trophy className="h-5 w-5 text-yellow-500" />
+ });
+ }
+
+ router.refresh();
+ } else {
+ // 실패시 에러 상태 업데이트
+ setContractStatuses(prev =>
+ prev.map(status =>
+ status.id === selectedContract.id
+ ? { ...status, status: 'error' as const, errorMessage: result.error }
+ : status
+ )
+ );
+
+ toast.error("서명 처리 중 오류가 발생했습니다", {
+ description: result.error,
+ icon: <AlertCircle className="h-5 w-5 text-red-500" />
+ });
+ }
}
} catch (error) {
console.error("서명 완료 중 오류:", error);
@@ -391,56 +515,99 @@ export function BasicContractSignDialog({
// 모든 서명 완료 핸들러
const completeAllSigns = () => {
- setOpen(false);
+ handleOpenChange(false);
if (onSuccess) {
onSuccess();
}
- toast.success("모든 계약서 서명이 완료되었습니다!", {
- description: "계약서 관리 페이지가 새로고침됩니다.",
+ const successMessage = isBuyerMode
+ ? "모든 계약서 최종승인이 완료되었습니다!"
+ : "모든 계약서 서명이 완료되었습니다!";
+
+ toast.success(successMessage, {
+ description: "계약서 관리 페이지가 새고침됩니다.",
icon: <Trophy className="h-5 w-5 text-yellow-500" />
});
};
return (
<>
- {/* 서명 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() => setOpen(true)}
- disabled={isButtonDisabled}
- className="gap-2 transition-all hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200 disabled:opacity-50 disabled:cursor-not-allowed"
- >
- <Upload
- className={`size-4 ${isButtonDisabled ? 'text-gray-400' : 'text-blue-500'}`}
- aria-hidden="true"
- />
- <span className="hidden sm:inline flex items-center">
- {t("basicContracts.toolbar.sign")}
- {contracts.length > 0 && !isButtonDisabled && (
- <Badge variant="secondary" className="ml-2 bg-blue-100 text-blue-700 hover:bg-blue-200">
- {contracts.length}
- </Badge>
+ {/* 서명 버튼 - 외부 제어가 없을 때만 표시 */}
+ {!isControlledExternally && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleOpenChange(true)}
+ disabled={isButtonDisabled}
+ className={cn(
+ "gap-2 transition-all disabled:opacity-50 disabled:cursor-not-allowed",
+ isBuyerMode
+ ? "hover:bg-green-50 hover:text-green-600 hover:border-green-200"
+ : "hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200"
)}
- {isButtonDisabled && (
- <span className="ml-2 text-xs text-gray-400">
- ({getDisabledReason()})
- </span>
+ >
+ {isBuyerMode ? (
+ <Shield
+ className={`size-4 ${isButtonDisabled ? 'text-gray-400' : 'text-green-500'}`}
+ aria-hidden="true"
+ />
+ ) : (
+ <Upload
+ className={`size-4 ${isButtonDisabled ? 'text-gray-400' : 'text-blue-500'}`}
+ aria-hidden="true"
+ />
)}
- </span>
- </Button>
+ <span className="hidden sm:inline flex items-center">
+ {isBuyerMode ? "구매자 승인" : t("basicContracts.toolbar.sign")}
+ {contracts.length > 0 && !isButtonDisabled && (
+ <Badge
+ variant="secondary"
+ className={cn(
+ "ml-2",
+ isBuyerMode
+ ? "bg-green-100 text-green-700 hover:bg-green-200"
+ : "bg-blue-100 text-blue-700 hover:bg-blue-200"
+ )}최
+ >
+ {contracts.length}
+ </Badge>
+ )}
+ {isButtonDisabled && (
+ <span className="ml-2 text-xs text-gray-400">
+ ({getDisabledReason()})
+ </span>
+ )}
+ </span>
+ </Button>
+ )}
{/* 서명 다이얼로그 */}
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-7xl w-[95vw] h-[90vh] p-0 flex flex-col overflow-hidden" style={{width:'95vw', maxWidth:'95vw'}}>
{/* 고정 헤더 - 진행 상황 표시 */}
- <DialogHeader className="px-6 py-4 bg-gradient-to-r from-blue-50 to-purple-50 border-b flex-shrink-0">
+ <DialogHeader className={cn(
+ "px-6 py-4 border-b flex-shrink-0",
+ isBuyerMode
+ ? "bg-gradient-to-r from-green-50 to-emerald-50"
+ : "bg-gradient-to-r from-blue-50 to-purple-50"
+ )}>
<DialogTitle className="text-xl font-bold flex items-center justify-between text-gray-800">
<div className="flex items-center">
- <FileSignature className="mr-2 h-5 w-5 text-blue-500" />
- {t("basicContracts.dialog.title")}
+ {isBuyerMode ? (
+ <Shield className="mr-2 h-5 w-5 text-green-500" />
+ ) : (
+ <FileSignature className="mr-2 h-5 w-5 text-blue-500" />
+ )}
+ {dialogTitle}
{/* 진행 상황 표시 */}
- <Badge variant="outline" className="ml-3 bg-blue-50 text-blue-700 border-blue-200">
+ <Badge
+ variant="outline"
+ className={cn(
+ "ml-3",
+ isBuyerMode
+ ? "bg-green-50 text-green-700 border-green-200"
+ : "bg-blue-50 text-blue-700 border-blue-200"
+ )}
+ >
{completedCount}/{totalCount} 완료
</Badge>
{/* 추가 파일 로딩 표시 */}
@@ -466,7 +633,12 @@ export function BasicContractSignDialog({
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
- className="bg-gradient-to-r from-blue-500 to-green-500 h-2 rounded-full transition-all duration-500"
+ className={cn(
+ "h-2 rounded-full transition-all duration-500",
+ isBuyerMode
+ ? "bg-gradient-to-r from-green-500 to-emerald-500"
+ : "bg-gradient-to-r from-blue-500 to-green-500"
+ )}
style={{ width: `${(completedCount / totalCount) * 100}%` }}
/>
</div>
@@ -506,9 +678,11 @@ export function BasicContractSignDialog({
const isCompleted = contractStatus?.status === 'completed';
const hasError = contractStatus?.status === 'error';
- // 🔥 계약서별 완료 상태 확인
+ // 계약서별 완료 상태 확인
const isComplianceTemplate = contract.templateName?.includes('준법');
+ const isGTCTemplate = contract.templateName?.includes('GTC');
const hasSurveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contract.id] === true : true;
+ const hasGtcCompleted = isGTCTemplate ? (gtcCommentStatus[contract.id]?.hasComments !== true) : true;
const hasSignatureCompleted = signatureStatus[contract.id] === true;
return (
@@ -530,7 +704,11 @@ export function BasicContractSignDialog({
{/* 첫 번째 줄: 제목 + 상태 */}
<div className="flex items-center justify-between w-full">
<span className="font-medium text-xs truncate text-gray-800 flex items-center min-w-0">
- <FileText className="h-3 w-3 mr-1 text-blue-500 flex-shrink-0" />
+ {isBuyerMode ? (
+ <Shield className="h-3 w-3 mr-1 text-green-500 flex-shrink-0" />
+ ) : (
+ <FileText className="h-3 w-3 mr-1 text-blue-500 flex-shrink-0" />
+ )}
<span className="truncate">{contract.templateName || t("basicContracts.dialog.document")}</span>
{/* 비밀유지 계약서인 경우 표시 */}
{contract.templateName === "비밀유지 계약서" && (
@@ -538,6 +716,12 @@ export function BasicContractSignDialog({
NDA
</Badge>
)}
+ {/* GTC 계약서인 경우 표시 */}
+ {contract.templateName?.includes('GTC') && (
+ <Badge variant="outline" className="ml-1 bg-purple-50 text-purple-700 border-purple-200 text-xs">
+ GTC
+ </Badge>
+ )}
</span>
{/* 상태 표시 */}
@@ -558,8 +742,8 @@ export function BasicContractSignDialog({
)}
</div>
- {/* 🔥 완료 상태 표시 */}
- {!isCompleted && !hasError && (
+ {/* 완료 상태 표시 (구매자 모드에서는 간소화) */}
+ {!isCompleted && !hasError && !isBuyerMode && (
<div className="flex items-center space-x-2 text-xs">
{isComplianceTemplate && (
<span className={`flex items-center ${hasSurveyCompleted ? 'text-green-600' : 'text-gray-400'}`}>
@@ -567,6 +751,12 @@ export function BasicContractSignDialog({
설문
</span>
)}
+ {isGTCTemplate && (
+ <span className={`flex items-center ${hasGtcCompleted ? 'text-green-600' : 'text-red-600'}`}>
+ <CheckCircle2 className={`h-3 w-3 mr-1 ${hasGtcCompleted ? 'text-green-500' : 'text-red-500'}`} />
+ 조항검토
+ </span>
+ )}
<span className={`flex items-center ${hasSignatureCompleted ? 'text-green-600' : 'text-gray-400'}`}>
<Target className={`h-3 w-3 mr-1 ${hasSignatureCompleted ? 'text-green-500' : 'text-gray-300'}`} />
서명
@@ -574,6 +764,16 @@ export function BasicContractSignDialog({
</div>
)}
+ {/* 구매자 모드의 간소화된 상태 표시 */}
+ {!isCompleted && !hasError && isBuyerMode && (
+ <div className="flex items-center space-x-2 text-xs">
+ <span className={`flex items-center ${hasSignatureCompleted ? 'text-green-600' : 'text-gray-400'}`}>
+ <Target className={`h-3 w-3 mr-1 ${hasSignatureCompleted ? 'text-green-500' : 'text-gray-300'}`} />
+ 구매자 서명
+ </span>
+ </div>
+ )}
+
{/* 두 번째 줄: 사용자 + 날짜 */}
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center min-w-0">
@@ -609,14 +809,18 @@ export function BasicContractSignDialog({
{/* 뷰어 헤더 */}
<div className="p-4 border-b bg-gray-50 flex-shrink-0">
<h3 className="font-semibold text-gray-800 flex items-center">
- <FileText className="h-4 w-4 mr-2 text-blue-500" />
+ {isBuyerMode ? (
+ <Shield className="h-4 w-4 mr-2 text-green-500" />
+ ) : (
+ <FileText className="h-4 w-4 mr-2 text-blue-500" />
+ )}
{selectedContract.templateName || t("basicContracts.dialog.document")}
{/* 현재 계약서 상태 표시 */}
{currentContractStatus?.status === 'completed' ? (
<Badge variant="outline" className="ml-2 bg-green-50 text-green-700 border-green-200">
<CheckCircle2 className="h-3 w-3 mr-1" />
- 서명 완료
+ {isBuyerMode ? "승인 완료" : "서명 완료"}
</Badge>
) : currentContractStatus?.status === 'error' ? (
<Badge variant="outline" className="ml-2 bg-red-50 text-red-700 border-red-200">
@@ -625,19 +829,32 @@ export function BasicContractSignDialog({
</Badge>
) : (
<Badge variant="outline" className="ml-2 bg-yellow-50 text-yellow-700 border-yellow-200">
- 서명 대기
+ {isBuyerMode ? "승인 대기" : "서명 대기"}
+ </Badge>
+ )}
+
+ {/* 구매자 모드 배지 */}
+ {isBuyerMode && (
+ <Badge variant="outline" className="ml-2 bg-green-50 text-green-700 border-green-200">
+ 구매자 모드
</Badge>
)}
- {/* 준법 템플릿 표시 */}
- {selectedContract.templateName?.includes('준법') && (
+ {/* 준법/GTC 템플릿 표시 (구매자 모드가 아닐 때만) */}
+ {!isBuyerMode && selectedContract.templateName?.includes('준법') && (
<Badge variant="outline" className="ml-2 bg-amber-50 text-amber-700 border-amber-200">
준법 서류
</Badge>
)}
+
+ {!isBuyerMode && selectedContract.templateName?.includes('GTC') && (
+ <Badge variant="outline" className="ml-2 bg-purple-50 text-purple-700 border-purple-200">
+ GTC 계약서
+ </Badge>
+ )}
- {/* 비밀유지 계약서인 경우 추가 파일 수 표시 */}
- {selectedContract.templateName === "비밀유지 계약서" && additionalFiles.length > 0 && (
+ {/* 비밀유지 계약서인 경우 추가 파일 수 표시 (구매자 모드가 아닐 때만) */}
+ {!isBuyerMode && selectedContract.templateName === "비밀유지 계약서" && additionalFiles.length > 0 && (
<Badge variant="outline" className="ml-2 bg-blue-50 text-blue-700 border-blue-200">
첨부파일 {additionalFiles.length}개
</Badge>
@@ -665,8 +882,12 @@ export function BasicContractSignDialog({
additionalFiles={additionalFiles}
instance={instance}
setInstance={setInstance}
- onSurveyComplete={() => handleSurveyComplete(selectedContract.id)} // 🔥 추가
- onSignatureComplete={() => handleSignatureComplete(selectedContract.id)} // 🔥 추가
+ onSurveyComplete={() => handleSurveyComplete(selectedContract.id)}
+ onSignatureComplete={() => handleSignatureComplete(selectedContract.id)}
+ onGtcCommentStatusChange={(hasComments, commentCount) =>
+ handleGtcCommentStatusChange(selectedContract.id, hasComments, commentCount)
+ }
+ mode={mode}
t={t}
/>
</div>
@@ -678,44 +899,58 @@ export function BasicContractSignDialog({
{currentContractStatus?.status === 'completed' ? (
<p className="text-sm text-green-600 flex items-center">
<CheckCircle2 className="h-4 w-4 text-green-500 mr-1" />
- 이 계약서는 이미 서명이 완료되었습니다
+ 이 계약서는 이미 {isBuyerMode ? "승인이" : "서명이"} 완료되었습니다
</p>
) : currentContractStatus?.status === 'error' ? (
<p className="text-sm text-red-600 flex items-center">
<AlertCircle className="h-4 w-4 text-red-500 mr-1" />
- 서명 처리 중 오류가 발생했습니다. 다시 시도해주세요.
+ {isBuyerMode ? "승인" : "서명"} 처리 중 오류가 발생했습니다. 다시 시도해주세요.
</p>
) : (
<>
- {/* 🔥 완료 조건 안내 메시지 개선 */}
+ {/* 완료 조건 안내 메시지 */}
<div className="flex flex-col space-y-1">
<p className="text-sm text-gray-600 flex items-center">
<AlertCircle className="h-4 w-4 text-yellow-500 mr-1" />
- {t("basicContracts.dialog.signWarning")}
+ {isBuyerMode
+ ? "계약서에 구매자 서명을 완료해주세요."
+ : t("basicContracts.dialog.signWarning")
+ }
</p>
{/* 완료 상태 체크리스트 */}
- <div className="flex items-center space-x-4 text-xs">
- {selectedContract.templateName?.includes('준법') && (
- <span className={`flex items-center ${surveyCompletionStatus[selectedContract.id] ? 'text-green-600' : 'text-red-600'}`}>
- <CheckCircle2 className={`h-3 w-3 mr-1 ${surveyCompletionStatus[selectedContract.id] ? 'text-green-500' : 'text-red-500'}`} />
- 설문조사 {surveyCompletionStatus[selectedContract.id] ? '완료' : '미완료'}
+ {!isBuyerMode && (
+ <div className="flex items-center space-x-4 text-xs">
+ {selectedContract.templateName?.includes('준법') && (
+ <span className={`flex items-center ${surveyCompletionStatus[selectedContract.id] ? 'text-green-600' : 'text-red-600'}`}>
+ <CheckCircle2 className={`h-3 w-3 mr-1 ${surveyCompletionStatus[selectedContract.id] ? 'text-green-500' : 'text-red-500'}`} />
+ 설문조사 {surveyCompletionStatus[selectedContract.id] ? '완료' : '미완료'}
+ </span>
+ )}
+ {selectedContract.templateName?.includes('GTC') && (
+ <span className={`flex items-center ${(gtcCommentStatus[selectedContract.id]?.hasComments !== true) ? 'text-green-600' : 'text-red-600'}`}>
+ <CheckCircle2 className={`h-3 w-3 mr-1 ${(gtcCommentStatus[selectedContract.id]?.hasComments !== true) ? 'text-green-500' : 'text-red-500'}`} />
+ 조항검토 {(gtcCommentStatus[selectedContract.id]?.hasComments !== true) ? '완료' :
+ `미완료 (코멘트 ${gtcCommentStatus[selectedContract.id]?.commentCount || 0}개)`}
+ </span>
+ )}
+ <span className={`flex items-center ${signatureStatus[selectedContract.id] ? 'text-green-600' : 'text-red-600'}`}>
+ <Target className={`h-3 w-3 mr-1 ${signatureStatus[selectedContract.id] ? 'text-green-500' : 'text-red-500'}`} />
+ 서명 {signatureStatus[selectedContract.id] ? '완료' : '미완료'}
</span>
- )}
- <span className={`flex items-center ${signatureStatus[selectedContract.id] ? 'text-green-600' : 'text-red-600'}`}>
- <Target className={`h-3 w-3 mr-1 ${signatureStatus[selectedContract.id] ? 'text-green-500' : 'text-red-500'}`} />
- 서명 {signatureStatus[selectedContract.id] ? '완료' : '미완료'}
- </span>
- </div>
+ </div>
+ )}
+
+ {/* 구매자 모드의 간소화된 체크리스트 */}
+ {isBuyerMode && (
+ <div className="flex items-center space-x-4 text-xs">
+ <span className={`flex items-center ${signatureStatus[selectedContract.id] ? 'text-green-600' : 'text-red-600'}`}>
+ <Target className={`h-3 w-3 mr-1 ${signatureStatus[selectedContract.id] ? 'text-green-500' : 'text-red-500'}`} />
+ 구매자 서명 {signatureStatus[selectedContract.id] ? '완료' : '미완료'}
+ </span>
+ </div>
+ )}
</div>
-
- {/* 비밀유지 계약서인 경우 추가 안내 */}
- {selectedContract.templateName === "비밀유지 계약서" && additionalFiles.length > 0 && (
- <p className="text-xs text-blue-600 flex items-center">
- <FileText className="h-3 w-3 text-blue-500 mr-1" />
- 첨부 서류도 확인해주세요
- </p>
- )}
</>
)}
</div>
@@ -725,11 +960,16 @@ export function BasicContractSignDialog({
{allCompleted ? (
// 모든 계약서 완료시
<Button
- className="gap-2 bg-green-600 hover:bg-green-700 transition-colors"
+ className={cn(
+ "gap-2 transition-colors",
+ isBuyerMode
+ ? "bg-green-600 hover:bg-green-700"
+ : "bg-green-600 hover:bg-green-700"
+ )}
onClick={completeAllSigns}
>
<Trophy className="h-4 w-4" />
- 모든 서명 완료
+ 모든 {isBuyerMode ? "승인" : "서명"} 완료
</Button>
) : currentContractStatus?.status === 'completed' ? (
// 현재 계약서가 완료된 경우
@@ -750,13 +990,16 @@ export function BasicContractSignDialog({
) : (
// 현재 계약서를 서명해야 하는 경우
<Button
- className={`gap-2 transition-colors ${
+ className={cn(
+ "gap-2 transition-colors",
canCompleteCurrentContract
- ? "bg-blue-600 hover:bg-blue-700"
+ ? isBuyerMode
+ ? "bg-green-600 hover:bg-green-700"
+ : "bg-blue-600 hover:bg-blue-700"
: "bg-gray-400 cursor-not-allowed"
- }`}
+ )}
onClick={completeSign}
- disabled={!canCompleteCurrentContract || isSubmitting} // 🔥 조건 수정
+ disabled={!canCompleteCurrentContract || isSubmitting}
>
{isSubmitting ? (
<>
@@ -768,11 +1011,15 @@ export function BasicContractSignDialog({
</>
) : (
<>
- <FileSignature className="h-4 w-4" />
- 서명 완료
+ {isBuyerMode ? (
+ <Shield className="h-4 w-4" />
+ ) : (
+ <FileSignature className="h-4 w-4" />
+ )}
+ {signButtonText}
{totalCount > 1 && (
<span className="ml-1 text-xs">
- ({completedCount + 1}/{totalCount})
+ ({completedCount}/{totalCount})
</span>
)}
</>
@@ -784,12 +1031,22 @@ export function BasicContractSignDialog({
</>
) : (
<div className="flex flex-col items-center justify-center h-full text-center p-6">
- <div className="bg-blue-50 p-6 rounded-full mb-4">
- <FileSignature className="h-12 w-12 text-blue-500" />
+ <div className={cn(
+ "p-6 rounded-full mb-4",
+ isBuyerMode ? "bg-green-50" : "bg-blue-50"
+ )}>
+ {isBuyerMode ? (
+ <Shield className="h-12 w-12 text-green-500" />
+ ) : (
+ <FileSignature className="h-12 w-12 text-blue-500" />
+ )}
</div>
<h3 className="text-xl font-medium text-gray-800 mb-2">{t("basicContracts.dialog.selectDocument")}</h3>
<p className="text-gray-500 max-w-md">
- {t("basicContracts.dialog.selectDocumentDescription")}
+ {isBuyerMode
+ ? "승인할 계약서를 선택해주세요."
+ : t("basicContracts.dialog.selectDocumentDescription")
+ }
</p>
</div>
)}
diff --git a/lib/basic-contract/vendor-table/basic-contract-table.tsx b/lib/basic-contract/vendor-table/basic-contract-table.tsx
index 48298f21..cefc0fb2 100644
--- a/lib/basic-contract/vendor-table/basic-contract-table.tsx
+++ b/lib/basic-contract/vendor-table/basic-contract-table.tsx
@@ -38,6 +38,8 @@ export function BasicContractsVendorTable({ promises }: BasicTemplateTableProps)
const [{ data, pageCount }] =
React.use(promises)
+ console.log(data,"data")
+
// 안전한 번역 함수 (fallback 포함)
const safeT = React.useCallback((key: string, fallback: string) => {
diff --git a/lib/basic-contract/vendor-table/survey-conditional.ts b/lib/basic-contract/vendor-table/survey-conditional.ts
index 71c2c1ff..686cc4ba 100644
--- a/lib/basic-contract/vendor-table/survey-conditional.ts
+++ b/lib/basic-contract/vendor-table/survey-conditional.ts
@@ -131,6 +131,22 @@ public getIncompleteReason(question: SurveyQuestion, surveyAnswers: Record<numbe
return true;
}
+ private isValidFile(file: any): boolean {
+ if (!file || typeof file !== 'object') return false;
+
+ // 브라우저 File 객체 체크 (새로 업로드한 파일)
+ if (file.name || file.size || file.type) return true;
+
+ // 서버 파일 메타데이터 체크 (기존 파일)
+ if (file.filename || file.originalName || file.id || file.mimeType) return true;
+
+ return false;
+ }
+
+ private hasValidFiles(files: any[]): boolean {
+ return files && files.length > 0 && files.every(file => this.isValidFile(file));
+ }
+
private isQuestionCompleteEnhanced(question: SurveyQuestion, surveyAnswers: Record<number, any>): boolean {
const answer = surveyAnswers[question.id];
@@ -157,60 +173,50 @@ public getIncompleteReason(question: SurveyQuestion, surveyAnswers: Record<numbe
const hasOtherText = answer.answerValue === 'OTHER' ?
(answer.otherText && answer.otherText.trim() !== '') : true;
- // 4️⃣ files 체크 (실제 파일이 있는 경우)
- const hasValidFiles = answer.files && answer.files.length > 0 &&
- answer.files.every((file: any) => file && typeof file === 'object' &&
- (file.name || file.size || file.type)); // 빈 객체 {} 제외
+ // 4️⃣ files 체크 (브라우저 File 객체와 서버 파일 메타데이터 모두 처리) - 수정된 부분
+ const hasValidFilesResult = this.hasValidFiles(answer.files || []);
console.log(`📊 Q${question.questionNumber} 완료 조건 체크:`, {
hasAnswerValue,
hasDetailText,
hasOtherText,
- hasValidFiles,
+ hasValidFiles: hasValidFilesResult,
questionType: question.questionType,
hasDetailTextRequired: question.hasDetailText,
hasFileUploadRequired: question.hasFileUpload || question.questionType === 'FILE'
});
- // 🎯 질문 타입별 완료 조건
+ // 질문 타입별 완료 조건은 동일하지만 hasValidFilesResult 변수 사용
switch (question.questionType) {
case 'RADIO':
case 'DROPDOWN':
- // 선택형: answerValue가 있고, OTHER인 경우 otherText도 필요
const isSelectComplete = hasAnswerValue && hasOtherText;
- // detailText가 필요한 경우 추가 체크
if (question.hasDetailText && isSelectComplete) {
return hasDetailText;
}
- // 파일 업로드가 필요한 경우 추가 체크
if ((question.hasFileUpload || question.questionType === 'FILE') && isSelectComplete) {
- return hasValidFiles;
+ return hasValidFilesResult; // 수정된 부분
}
return isSelectComplete;
case 'TEXTAREA':
- // 텍스트 영역: detailText 또는 answerValue 중 하나라도 있으면 됨
return hasDetailText || hasAnswerValue;
case 'FILE':
- // 파일 업로드: 유효한 파일이 있어야 함
- return hasValidFiles;
+ return hasValidFilesResult; // 수정된 부분
default:
- // 기본: answerValue, detailText, 파일 중 하나라도 있으면 완료
- let isComplete = hasAnswerValue || hasDetailText || hasValidFiles;
+ let isComplete = hasAnswerValue || hasDetailText || hasValidFilesResult; // 수정된 부분
- // detailText가 필수인 경우
if (question.hasDetailText) {
isComplete = isComplete && hasDetailText;
}
- // 파일 업로드가 필수인 경우
if (question.hasFileUpload) {
- isComplete = isComplete && hasValidFiles;
+ isComplete = isComplete && hasValidFilesResult; // 수정된 부분
}
return isComplete;
@@ -303,17 +309,7 @@ private getCompletionDetailsEnhanced(question: SurveyQuestion, answer: any): any
// 🎯 현재 답변 상태에 따라 표시되는 질문들 계산
const visibleQuestions = this.getVisibleQuestions(surveyAnswers);
- console.log('🔍 표시되는 모든 질문들의 상세 정보:', visibleQuestions.map(q => ({
- id: q.id,
- questionNumber: q.questionNumber,
- questionText: q.questionText?.substring(0, 30) + '...',
- isRequired: q.isRequired,
- parentQuestionId: q.parentQuestionId,
- conditionalValue: q.conditionalValue,
- isConditional: !!q.parentQuestionId,
- hasAnswer: !!surveyAnswers[q.id]?.answerValue,
- answerValue: surveyAnswers[q.id]?.answerValue
- })));
+
// 🚨 중요: 트리거된 조건부 질문들을 필수로 처리
const requiredQuestions = visibleQuestions.filter(q => {
@@ -334,26 +330,6 @@ private getCompletionDetailsEnhanced(question: SurveyQuestion, answer: any): any
return false;
});
- console.log('📊 필수 질문 필터링 결과:', {
- 전체질문수: this.questions.length,
- 표시되는질문수: visibleQuestions.length,
- 원래필수질문: visibleQuestions.filter(q => q.isRequired).length,
- 트리거된조건부질문: visibleQuestions.filter(q => {
- if (!q.parentQuestionId || !q.conditionalValue) return false;
- const parentAnswer = surveyAnswers[q.parentQuestionId];
- return parentAnswer?.answerValue === q.conditionalValue;
- }).length,
- 최종필수질문: requiredQuestions.length,
- 현재답변수: Object.keys(surveyAnswers).length,
- 필수질문들: requiredQuestions.map(q => ({
- id: q.id,
- questionNumber: q.questionNumber,
- isRequired: q.isRequired,
- isConditional: !!q.parentQuestionId,
- hasAnswer: !!surveyAnswers[q.id]?.answerValue,
- 처리방식: q.isRequired ? '원래필수' : '트리거됨'
- }))
- });
const completedQuestionIds: number[] = [];
const incompleteQuestionIds: number[] = [];
@@ -365,10 +341,7 @@ private getCompletionDetailsEnhanced(question: SurveyQuestion, answer: any): any
// 🎯 개선된 완료 체크: 모든 답변 형태를 고려
const isComplete = this.isQuestionCompleteEnhanced(question, surveyAnswers);
-
- console.log(`📊 Q${question.questionNumber} 완료 상태: ${isComplete}`);
- console.log(`📝 Q${question.questionNumber} 답변 내용:`, surveyAnswers[question.id]);
-
+
// 디버깅 정보 수집
debugInfo[question.id] = {
questionText: question.questionText,
@@ -414,25 +387,6 @@ private getCompletionDetailsEnhanced(question: SurveyQuestion, answer: any): any
if (process.env.NODE_ENV === 'development') {
(result as any).debugInfo = debugInfo;
- // 📋 상세한 진행 상황 로그
- console.log('📋 최종 진행 상황:', {
- 총필수질문: requiredQuestions.length,
- 완료된질문: completedQuestionIds.length,
- 미완료질문: incompleteQuestionIds.length,
- 진행률: `${Math.round(progressPercentage)}%`,
- 기본질문: visibleQuestions.filter(q => !q.parentQuestionId).length,
- 조건부질문: visibleQuestions.filter(q => q.parentQuestionId).length,
- 완료된기본질문: completedQuestionIds.filter(id => !visibleQuestions.find(q => q.id === id)?.parentQuestionId).length,
- 완료된조건부질문: completedQuestionIds.filter(id => !!visibleQuestions.find(q => q.id === id)?.parentQuestionId).length,
- 필수질문상세: requiredQuestions.map(q => ({
- id: q.id,
- questionNumber: q.questionNumber,
- isRequired: q.isRequired,
- isConditional: !!q.parentQuestionId,
- isComplete: completedQuestionIds.includes(q.id)
- }))
- });
-
// 🔍 미완료 질문들의 구체적 이유
if (incompleteQuestionIds.length > 0) {
console.log('🔍 미완료 질문들:', incompleteQuestionIds.map(id => ({
@@ -446,24 +400,7 @@ private getCompletionDetailsEnhanced(question: SurveyQuestion, answer: any): any
// ⚡ 조건부 질문 활성화 및 완료 현황
const conditionalQuestions = visibleQuestions.filter(q => q.parentQuestionId);
- if (conditionalQuestions.length > 0) {
- console.log('⚡ 조건부 질문 상세 현황:', conditionalQuestions.map(q => ({
- id: q.id,
- questionNumber: q.questionNumber,
- isRequired: q.isRequired,
- parentId: q.parentQuestionId,
- condition: q.conditionalValue,
- parentAnswer: surveyAnswers[q.parentQuestionId!]?.answerValue,
- isTriggered: surveyAnswers[q.parentQuestionId!]?.answerValue === q.conditionalValue,
- hasAnswer: !!surveyAnswers[q.id]?.answerValue,
- answerValue: surveyAnswers[q.id]?.answerValue,
- detailText: surveyAnswers[q.id]?.detailText,
- files: surveyAnswers[q.id]?.files,
- isComplete: debugInfo[q.id]?.isComplete,
- isIncludedInRequired: requiredQuestions.some(rq => rq.id === q.id),
- completionDetails: this.getCompletionDetailsEnhanced(q, surveyAnswers[q.id])
- })));
- }
+
}
return result;
@@ -606,37 +543,21 @@ getOverallProgressStatus(surveyAnswers: Record<number, any>): {
const childQuestion = this.questions.find(q => q.id === childId);
if (!childQuestion) return;
- console.log(`🔍 자식 질문 ${childId} 체크:`, {
- childId,
- questionNumber: childQuestion.questionNumber,
- conditionalValue: childQuestion.conditionalValue,
- newParentValue,
- shouldKeep: childQuestion.conditionalValue === newParentValue,
- currentAnswer: updatedAnswers[childId]?.answerValue
- });
-
// 새로운 부모 값이 자식의 조건과 맞지 않으면 자식 답변 삭제
if (childQuestion.conditionalValue !== newParentValue) {
- console.log(`🗑️ 자식 질문 Q${childQuestion.questionNumber} 답변 초기화 (조건 불일치)`);
delete updatedAnswers[childId];
// 재귀적으로 손자 질문들도 정리
const grandChildAnswers = this.clearAffectedChildAnswers(childId, '', updatedAnswers);
Object.assign(updatedAnswers, grandChildAnswers);
} else {
- console.log(`✅ 자식 질문 Q${childQuestion.questionNumber} 유지 (조건 일치)`);
}
});
const clearedCount = childQuestionIds.filter(childId => !updatedAnswers[childId]).length;
const keptCount = childQuestionIds.filter(childId => !!updatedAnswers[childId]).length;
- console.log(`📊 자식 질문 정리 완료:`, {
- parentQuestionId,
- 총자식질문: childQuestionIds.length,
- 초기화된질문: clearedCount,
- 유지된질문: keptCount
- });
+
return updatedAnswers;
}
@@ -680,21 +601,17 @@ getOverallProgressStatus(surveyAnswers: Record<number, any>): {
conditionalValue: question.conditionalValue
};
- console.log(`🔍 질문 완료 체크 [Q${question.questionNumber}]:`, logData);
if (!question.isRequired) {
- console.log(`✅ Q${question.questionNumber}: 선택 질문이므로 완료`);
return true;
}
if (!answer?.answerValue) {
- console.log(`❌ Q${question.questionNumber}: 답변이 없음`);
return false;
}
// 1. '기타' 선택 시 추가 입력이 필요한 경우
if (answer.answerValue === 'OTHER' && !answer.otherText?.trim()) {
- console.log(`❌ Q${question.questionNumber}: '기타' 선택했지만 상세 내용 없음`);
return false;
}
@@ -702,16 +619,8 @@ getOverallProgressStatus(surveyAnswers: Record<number, any>): {
if (question.hasDetailText) {
const needsDetailText = ['YES', '네', 'Y', 'true', '1'].includes(answer.answerValue);
- console.log(`📝 Q${question.questionNumber} 상세텍스트 체크:`, {
- hasDetailText: question.hasDetailText,
- answerValue: answer.answerValue,
- needsDetailText,
- detailText: answer.detailText?.length || 0,
- detailTextExists: !!answer.detailText?.trim()
- });
if (needsDetailText && !answer.detailText?.trim()) {
- console.log(`❌ Q${question.questionNumber}: '${answer.answerValue}' 선택했지만 상세 내용 없음`);
return false;
}
}
@@ -720,17 +629,7 @@ getOverallProgressStatus(surveyAnswers: Record<number, any>): {
if (question.hasFileUpload || question.questionType === 'FILE') {
const needsFileUpload = ['YES', '네', 'Y', 'true', '1'].includes(answer.answerValue);
- console.log(`📁 Q${question.questionNumber} 파일업로드 체크:`, {
- hasFileUpload: question.hasFileUpload,
- questionType: question.questionType,
- answerValue: answer.answerValue,
- needsFileUpload,
- filesCount: answer.files?.length || 0,
- hasFiles: !!answer.files && answer.files.length > 0
- });
-
if (needsFileUpload && (!answer.files || answer.files.length === 0)) {
- console.log(`❌ Q${question.questionNumber}: '${answer.answerValue}' 선택했지만 파일 업로드 없음`);
return false;
}
}
@@ -739,50 +638,27 @@ getOverallProgressStatus(surveyAnswers: Record<number, any>): {
const childQuestions = this.getChildQuestions(question.id);
if (childQuestions.length > 0) {
- console.log(`🔗 Q${question.questionNumber} 부모 질문 - 자식 질문들:`,
- childQuestions.map(c => ({
- id: c.id,
- questionNumber: c.questionNumber,
- condition: c.conditionalValue,
- required: c.isRequired,
- text: c.questionText?.substring(0, 20) + '...'
- }))
- );
// 현재 답변으로 트리거되는 자식 질문들 찾기
const triggeredChildren = childQuestions.filter(child =>
child.conditionalValue === answer.answerValue
);
- console.log(`🎯 Q${question.questionNumber} 답변 '${answer.answerValue}'로 트리거된 자식들:`,
- triggeredChildren.map(c => ({
- id: c.id,
- questionNumber: c.questionNumber,
- required: c.isRequired,
- text: c.questionText?.substring(0, 30) + '...'
- }))
- );
-
// 트리거된 필수 자식 질문들이 모두 완료되었는지 확인
for (const childQuestion of triggeredChildren) {
if (childQuestion.isRequired) {
- console.log(`🔄 자식 질문 Q${childQuestion.questionNumber} 완료 체크 시작...`);
const childComplete = this.isQuestionComplete(childQuestion, surveyAnswers);
- console.log(`📊 자식 질문 Q${childQuestion.questionNumber} 완료 상태: ${childComplete}`);
if (!childComplete) {
- console.log(`❌ 부모 Q${question.questionNumber}의 자식 Q${childQuestion.questionNumber} 미완료`);
return false;
}
}
}
if (triggeredChildren.filter(c => c.isRequired).length > 0) {
- console.log(`✅ Q${question.questionNumber}의 모든 필수 조건부 자식 질문들 완료됨`);
}
}
- console.log(`✅ Q${question.questionNumber} 완료 체크 통과`);
return true;
}
diff --git a/lib/basic-contract/viewer/GtcClausesComponent.tsx b/lib/basic-contract/viewer/GtcClausesComponent.tsx
new file mode 100644
index 00000000..8f565971
--- /dev/null
+++ b/lib/basic-contract/viewer/GtcClausesComponent.tsx
@@ -0,0 +1,837 @@
+"use client";
+
+import React, { useState, useEffect, useCallback,useRef } from 'react';
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import { Input } from "@/components/ui/input";
+import { toast } from "sonner";
+import {
+ FileText,
+ MessageSquare,
+ ChevronRight,
+ ChevronDown,
+ Search,
+ AlertTriangle,
+ CheckCircle2,
+ Edit3,
+ Save,
+ X,
+ Loader2,
+ Hash,
+ BookOpen,
+ Minimize2,
+ Maximize2,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+import {
+ getVendorGtcData,
+ updateVendorClause,
+ checkVendorClausesCommentStatus,
+ type GtcVendorData
+} from "../service";
+
+interface GtcClausesComponentProps {
+ contractId?: number;
+ onCommentStatusChange?: (hasComments: boolean, commentCount: number) => void;
+ t?: (key: string) => string;
+}
+
+// GTC 조항의 기본 타입 정의
+type GtcVendorClause = {
+ id: number;
+ vendorClauseId: number | null;
+ baseClauseId: number;
+ vendorDocumentId: number | null;
+ 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;
+
+ // 수정 여부 (코멘트만 있으면 false)
+ hasModifications: boolean;
+ isNumberModified: boolean;
+ isCategoryModified: boolean;
+ isSubtitleModified: boolean;
+ isContentModified: boolean;
+
+ // 코멘트 관련
+ hasComment: boolean;
+ pendingComment: string | null;
+};
+
+interface ClauseState extends GtcVendorClause {
+ isExpanded?: boolean;
+ isEditing?: boolean;
+ tempComment?: string;
+ isSaving?: boolean;
+ // 고유 식별자를 위한 헬퍼 속성
+ uniqueId: number;
+}
+
+export function GtcClausesComponent({
+ contractId,
+ onCommentStatusChange,
+ t = (key: string) => key
+}: GtcClausesComponentProps) {
+ const [gtcData, setGtcData] = useState<GtcVendorData | null>(null);
+ const [clauses, setClauses] = useState<ClauseState[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+ const [searchTerm, setSearchTerm] = useState("");
+ const [expandedItems, setExpandedItems] = useState<Set<number>>(new Set());
+ const [compactMode, setCompactMode] = useState(true); // 컴팩트 모드 상태 추가
+
+ const onCommentStatusChangeRef = useRef(onCommentStatusChange);
+ onCommentStatusChangeRef.current = onCommentStatusChange;
+
+ // 데이터 로드
+ const loadGtcData = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const data = await getVendorGtcData(contractId);
+
+ if (!data) {
+ setError("GTC 데이터를 찾을 수 없습니다.");
+ return;
+ }
+
+ setGtcData(data);
+
+ const initialClauses: ClauseState[] = data.clauses.map(clause => ({
+ ...clause,
+ uniqueId: clause.id,
+ isExpanded: false,
+ isEditing: false,
+ tempComment: clause.negotiationNote || "",
+ isSaving: false,
+ }));
+
+ setClauses(initialClauses);
+
+ } catch (err) {
+ console.error('GTC 데이터 로드 실패:', err);
+ setError(err instanceof Error ? err.message : 'GTC 데이터를 불러오는데 실패했습니다.');
+ } finally {
+ setLoading(false);
+ }
+ }, [contractId]);
+
+ const lastCommentStatusRef = useRef<{ hasComments: boolean; commentCount: number } | null>(null);
+
+ // 코멘트 상태 변경을 별도 useEffect로 처리
+ useEffect(() => {
+ if (clauses.length > 0) {
+ const commentCount = clauses.filter(c => c.hasComment).length;
+ const hasComments = commentCount > 0;
+
+ // Only call callback if status actually changed
+ const currentStatus = { hasComments, commentCount };
+ if (!lastCommentStatusRef.current ||
+ lastCommentStatusRef.current.hasComments !== hasComments ||
+ lastCommentStatusRef.current.commentCount !== commentCount) {
+
+ lastCommentStatusRef.current = currentStatus;
+ onCommentStatusChangeRef.current?.(hasComments, commentCount);
+ }
+ }
+ }, [clauses]);
+
+ useEffect(() => {
+ loadGtcData();
+ }, [loadGtcData]);
+
+ // 검색 필터링
+ const filteredClauses = React.useMemo(() => {
+ if (!searchTerm.trim()) return clauses;
+
+ const term = searchTerm.toLowerCase();
+ return clauses.filter(clause =>
+ clause.effectiveItemNumber.toLowerCase().includes(term) ||
+ clause.effectiveSubtitle.toLowerCase().includes(term) ||
+ clause.effectiveContent?.toLowerCase().includes(term) ||
+ clause.negotiationNote?.toLowerCase().includes(term)
+ );
+ }, [clauses, searchTerm]);
+
+ // 계층 구조로 조항 그룹화
+ const groupedClauses = React.useMemo(() => {
+ const grouped: { [key: number]: ClauseState[] } = { 0: [] }; // 최상위는 0
+
+ filteredClauses.forEach(clause => {
+ // parentId를 baseClauseId와 매핑 (parentId는 실제 baseClauseId를 가리킴)
+ let parentKey = 0; // 기본값은 최상위
+
+ if (clause.parentId !== null) {
+ // parentId에 해당하는 조항을 찾아서 그 조항의 uniqueId를 사용
+ const parentClause = filteredClauses.find(c => c.baseClauseId === clause.parentId);
+ if (parentClause) {
+ parentKey = parentClause.uniqueId;
+ }
+ }
+
+ if (!grouped[parentKey]) {
+ grouped[parentKey] = [];
+ }
+ grouped[parentKey].push(clause);
+ });
+
+ // 정렬
+ Object.keys(grouped).forEach(key => {
+ grouped[parseInt(key)].sort((a, b) => parseFloat(a.sortOrder) - parseFloat(b.sortOrder));
+ });
+
+ return grouped;
+ }, [filteredClauses]);
+
+ // 토글 확장/축소
+ const toggleExpand = useCallback((uniqueId: number) => {
+ setExpandedItems(prev => {
+ const next = new Set(prev);
+ if (next.has(uniqueId)) {
+ next.delete(uniqueId);
+ } else {
+ next.add(uniqueId);
+ }
+ return next;
+ });
+ }, []);
+
+ // 편집 모드 토글
+ const toggleEdit = useCallback((uniqueId: number) => {
+ setClauses(prev => prev.map(clause => {
+ if (clause.uniqueId === uniqueId) {
+ return {
+ ...clause,
+ isEditing: !clause.isEditing,
+ tempComment: clause.negotiationNote || "",
+ };
+ }
+ return clause;
+ }));
+ }, []);
+
+ // 임시 코멘트 업데이트
+ const updateTempComment = useCallback((uniqueId: number, comment: string) => {
+ setClauses(prev => prev.map(clause => {
+ if (clause.uniqueId === uniqueId) {
+ return { ...clause, tempComment: comment };
+ }
+ return clause;
+ }));
+ }, []);
+
+ // 코멘트 저장
+ const saveComment = useCallback(async (uniqueId: number) => {
+ const clause = clauses.find(c => c.uniqueId === uniqueId);
+ if (!clause) return;
+
+ setClauses(prev => prev.map(c =>
+ c.uniqueId === uniqueId ? { ...c, isSaving: true } : c
+ ));
+
+ try {
+ // 기본 조항 정보를 그대로 사용하고 코멘트만 처리
+ const clauseData = {
+ itemNumber: clause.effectiveItemNumber,
+ category: clause.effectiveCategory,
+ subtitle: clause.effectiveSubtitle,
+ content: clause.effectiveContent,
+ comment: clause.tempComment || "",
+ };
+
+ const result = await updateVendorClause(
+ clause.id,
+ clause.vendorClauseId,
+ clauseData,
+ gtcData?.vendorDocument
+ );
+
+ if (result.success) {
+ const hasComment = !!(clause.tempComment?.trim());
+
+ setClauses(prev => prev.map(c => {
+ if (c.uniqueId === uniqueId) {
+ return {
+ ...c,
+ vendorClauseId: result.vendorClauseId || c.vendorClauseId,
+ negotiationNote: clause.tempComment?.trim() || null,
+ hasComment,
+ isEditing: false,
+ isSaving: false,
+ };
+ }
+ return c;
+ }));
+
+ toast.success("코멘트가 저장되었습니다.");
+
+ } else {
+ toast.error(result.error || "코멘트 저장에 실패했습니다.");
+ setClauses(prev => prev.map(c =>
+ c.uniqueId === uniqueId ? { ...c, isSaving: false } : c
+ ));
+ }
+ } catch (error) {
+ console.error('코멘트 저장 실패:', error);
+ toast.error("코멘트 저장 중 오류가 발생했습니다.");
+ setClauses(prev => prev.map(c =>
+ c.uniqueId === uniqueId ? { ...c, isSaving: false } : c
+ ));
+ }
+ }, [clauses, gtcData]);
+
+ // 편집 취소
+ const cancelEdit = useCallback((uniqueId: number) => {
+ setClauses(prev => prev.map(clause => {
+ if (clause.uniqueId === uniqueId) {
+ return {
+ ...clause,
+ isEditing: false,
+ tempComment: clause.negotiationNote || "",
+ };
+ }
+ return clause;
+ }));
+ }, []);
+
+ // 컴팩트 모드 렌더링
+ const renderCompactClause = useCallback((clause: ClauseState, depth: number = 0): React.ReactNode => {
+ const isExpanded = expandedItems.has(clause.uniqueId);
+ const children = groupedClauses[clause.uniqueId] || [];
+ const hasChildren = children.length > 0;
+
+ return (
+ <div key={clause.uniqueId} className={`${depth > 0 ? 'ml-4' : ''}`}>
+ <div className={cn(
+ "flex items-center justify-between p-2 rounded border transition-all duration-200 hover:bg-gray-50 mb-1",
+ clause.hasComment && "border-amber-200 bg-amber-50",
+ clause.isExcluded && "opacity-50 border-gray-300"
+ )}>
+ <div className="flex items-center space-x-2 flex-1 min-w-0">
+ {/* 확장/축소 버튼 */}
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-5 w-5 p-0 flex-shrink-0"
+ onClick={() => toggleExpand(clause.uniqueId)}
+ >
+ {isExpanded ? (
+ <ChevronDown className="h-3 w-3" />
+ ) : (
+ <ChevronRight className="h-3 w-3" />
+ )}
+ </Button>
+
+ {/* 조항 번호 */}
+ <span className="font-mono text-blue-600 text-sm flex-shrink-0 min-w-0 font-medium">
+ {clause.effectiveItemNumber}
+ </span>
+
+ {/* 제목 */}
+ <span className="text-sm text-gray-800 truncate flex-1 min-w-0">
+ {clause.effectiveSubtitle}
+ </span>
+
+ {/* 상태 표시 */}
+ <div className="flex items-center space-x-1 flex-shrink-0">
+ {/* {clause.hasComment && (
+ <Badge variant="outline" className="text-xs px-1.5 py-0.5 h-5 bg-amber-50 text-amber-600 border-amber-200">
+ <MessageSquare className="h-2.5 w-2.5" />
+ </Badge>
+ )} */}
+ {clause.isExcluded && (
+ <Badge variant="outline" className="text-xs px-1.5 py-0.5 h-5 bg-gray-50 text-gray-500 border-gray-300">
+ 제외
+ </Badge>
+ )}
+ </div>
+ </div>
+
+ {/* 편집 버튼 */}
+ <div className="flex items-center space-x-1 flex-shrink-0 ml-2">
+ {clause.isEditing ? (
+ <div className="flex items-center space-x-1">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => saveComment(clause.uniqueId)}
+ disabled={clause.isSaving}
+ className="h-6 w-6 p-0 text-green-600 hover:text-green-700 hover:bg-green-50"
+ >
+ {clause.isSaving ? (
+ <Loader2 className="h-3 w-3 animate-spin" />
+ ) : (
+ <Save className="h-3 w-3" />
+ )}
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => cancelEdit(clause.uniqueId)}
+ disabled={clause.isSaving}
+ className="h-6 w-6 p-0 text-gray-500 hover:text-gray-700 hover:bg-gray-50"
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ) : (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => toggleEdit(clause.uniqueId)}
+ className={cn(
+ "h-6 w-6 p-0 transition-colors",
+ clause.hasComment
+ ? "text-amber-600 hover:text-amber-700 hover:bg-amber-50"
+ : "text-gray-500 hover:text-gray-700 hover:bg-gray-50"
+ )}
+ >
+ {clause.hasComment ? (
+ <MessageSquare className="h-3 w-3" />
+ ) : (
+ <Edit3 className="h-3 w-3" />
+ )}
+ </Button>
+ )}
+ </div>
+ </div>
+
+ {/* 확장된 내용 */}
+ {isExpanded && (
+ <div className="mt-1 ml-5 p-3 bg-white rounded border border-gray-200">
+ {/* 카테고리 */}
+ {clause.effectiveCategory && (
+ <div className="mb-2">
+ <span className="text-sm text-gray-500 font-medium">카테고리: </span>
+ <span className="text-sm text-gray-700">{clause.effectiveCategory}</span>
+ </div>
+ )}
+
+ {/* 내용 */}
+ {clause.effectiveContent && (
+ <p className="text-sm text-gray-700 leading-relaxed mb-3 whitespace-pre-wrap">
+ {clause.effectiveContent}
+ </p>
+ )}
+
+ {/* 코멘트 편집 영역 */}
+ {clause.isEditing && (
+ <div className="mb-3 p-2.5 bg-amber-50 rounded border border-amber-200">
+ <div className="flex items-center text-sm font-medium text-amber-800 mb-2">
+ <MessageSquare className="h-4 w-4 mr-2" />
+ 협의 코멘트
+ </div>
+ <Textarea
+ value={clause.tempComment || ""}
+ onChange={(e) => updateTempComment(clause.uniqueId, e.target.value)}
+ placeholder="이 조항에 대한 의견이나 수정 요청 사항을 입력해주세요..."
+ className="min-h-[60px] text-sm bg-white border-amber-200 focus:border-amber-300"
+ disabled={clause.isSaving}
+ />
+ <p className="text-xs text-amber-600 mt-1">
+ 코멘트를 입력하면 이 계약서는 서명할 수 없게 됩니다.
+ </p>
+ </div>
+ )}
+
+ {/* 기존 코멘트 표시 */}
+ {!clause.isEditing && clause.hasComment && clause.negotiationNote && (
+ <div className="mb-2 p-2.5 bg-amber-50 rounded border border-amber-200">
+ <div className="flex items-center text-sm font-medium text-amber-800 mb-2">
+ <MessageSquare className="h-4 w-4 mr-2" />
+ 협의 코멘트
+ </div>
+ <p className="text-sm text-amber-700 whitespace-pre-wrap">
+ {clause.negotiationNote}
+ </p>
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* 자식 조항들 */}
+ {hasChildren && (
+ <div className={isExpanded ? "mt-1 border-l border-gray-200 pl-3" : ""}>
+ {children.map(child => renderCompactClause(child, depth + 1))}
+ </div>
+ )}
+ </div>
+ );
+ }, [expandedItems, groupedClauses, toggleExpand, saveComment, cancelEdit, toggleEdit, updateTempComment]);
+
+ // 기본 모드 렌더링 (기존 코드 최적화)
+ const renderNormalClause = useCallback((clause: ClauseState, depth: number = 0): React.ReactNode => {
+ const isExpanded = expandedItems.has(clause.uniqueId);
+ const children = groupedClauses[clause.uniqueId] || [];
+ const hasChildren = children.length > 0;
+
+ return (
+ <div key={clause.uniqueId} className={`mb-1 ${depth > 0 ? 'ml-4' : ''}`}>
+ <Card className={cn(
+ "transition-all duration-200",
+ clause.hasComment && "border-amber-200 bg-amber-50",
+ clause.isExcluded && "opacity-50 border-gray-300"
+ )}>
+ <CardHeader className="pb-1 pt-2 px-3">
+ <div className="flex items-start justify-between">
+ <div className="flex items-start space-x-2 flex-1 min-w-0">
+ {/* 확장/축소 버튼 */}
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-5 w-5 p-0 flex-shrink-0"
+ onClick={() => toggleExpand(clause.uniqueId)}
+ >
+ {isExpanded ? (
+ <ChevronDown className="h-3 w-3" />
+ ) : (
+ <ChevronRight className="h-3 w-3" />
+ )}
+ </Button>
+
+ <div className="flex-1 min-w-0">
+ <CardTitle className="text-sm font-medium text-gray-800 flex items-center">
+ <Hash className="h-3 w-3 mr-1 text-blue-500" />
+ {clause.effectiveItemNumber}
+ {clause.hasComment && (
+ <Badge variant="outline" className="ml-2 text-xs bg-amber-50 text-amber-700 border-amber-200">
+ <MessageSquare className="h-2 w-2 mr-1" />
+ 코멘트
+ </Badge>
+ )}
+ {clause.isExcluded && (
+ <Badge variant="outline" className="ml-2 text-xs bg-gray-50 text-gray-500 border-gray-300">
+ 제외됨
+ </Badge>
+ )}
+ </CardTitle>
+ <p className="text-sm font-medium text-gray-700 mt-0.5">
+ {clause.effectiveSubtitle}
+ </p>
+ </div>
+
+ {/* 코멘트 버튼 */}
+ <div className="flex items-center space-x-0.5 flex-shrink-0">
+ {clause.isEditing ? (
+ <div className="flex items-center space-x-0.5">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => saveComment(clause.uniqueId)}
+ disabled={clause.isSaving}
+ className="h-6 px-2 text-green-600 hover:text-green-700 hover:bg-green-50"
+ >
+ {clause.isSaving ? (
+ <Loader2 className="h-3 w-3 animate-spin" />
+ ) : (
+ <Save className="h-3 w-3" />
+ )}
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => cancelEdit(clause.uniqueId)}
+ disabled={clause.isSaving}
+ className="h-6 px-2 text-gray-500 hover:text-gray-700 hover:bg-gray-50"
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ) : (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => toggleEdit(clause.uniqueId)}
+ className={cn(
+ "h-6 px-2 transition-colors",
+ clause.hasComment
+ ? "text-amber-600 hover:text-amber-700 hover:bg-amber-50"
+ : "text-gray-500 hover:text-gray-700 hover:bg-gray-50"
+ )}
+ >
+ {clause.hasComment ? (
+ <MessageSquare className="h-3 w-3" />
+ ) : (
+ <Edit3 className="h-3 w-3" />
+ )}
+ </Button>
+ )}
+ </div>
+ </div>
+ </div>
+ </CardHeader>
+
+ {isExpanded && (
+ <CardContent className="pt-0 px-3 pb-2">
+ {/* 카테고리 */}
+ {clause.effectiveCategory && (
+ <div className="mb-2">
+ <div className="flex items-center text-xs text-gray-500 mb-1">
+ <BookOpen className="h-3 w-3 mr-1" />
+ 카테고리
+ </div>
+ <p className="text-sm text-gray-700 bg-gray-50 p-2 rounded">
+ {clause.effectiveCategory}
+ </p>
+ </div>
+ )}
+
+ {/* 내용 */}
+ {clause.effectiveContent && (
+ <div className="mb-2">
+ <div className="flex items-center text-xs text-gray-500 mb-1">
+ <FileText className="h-3 w-3 mr-1" />
+ 내용
+ </div>
+ <p className="text-sm text-gray-700 bg-gray-50 p-2 rounded whitespace-pre-wrap leading-tight">
+ {clause.effectiveContent}
+ </p>
+ </div>
+ )}
+
+ {/* 코멘트 편집 영역 */}
+ {clause.isEditing && (
+ <div className="mb-2 p-2 bg-amber-50 rounded border border-amber-200">
+ <div className="flex items-center text-sm font-medium text-amber-800 mb-1">
+ <MessageSquare className="h-4 w-4 mr-2" />
+ 협의 코멘트
+ </div>
+ <Textarea
+ value={clause.tempComment || ""}
+ onChange={(e) => updateTempComment(clause.uniqueId, e.target.value)}
+ placeholder="이 조항에 대한 의견이나 수정 요청 사항을 입력해주세요..."
+ className="min-h-[60px] text-sm bg-white border-amber-200 focus:border-amber-300"
+ disabled={clause.isSaving}
+ />
+ <p className="text-xs text-amber-600 mt-1">
+ 코멘트를 입력하면 이 계약서는 서명할 수 없게 됩니다.
+ </p>
+ </div>
+ )}
+
+ {/* 기존 코멘트 표시 */}
+ {!clause.isEditing && clause.hasComment && clause.negotiationNote && (
+ <div className="mb-2 p-2 bg-amber-50 rounded border border-amber-200">
+ <div className="flex items-center text-sm font-medium text-amber-800 mb-1">
+ <MessageSquare className="h-4 w-4 mr-2" />
+ 협의 코멘트
+ </div>
+ <p className="text-sm text-amber-700 whitespace-pre-wrap">
+ {clause.negotiationNote}
+ </p>
+ </div>
+ )}
+
+ {/* 자식 조항들 */}
+ {hasChildren && (
+ <div className="mt-2 border-l-2 border-gray-200 pl-2">
+ {children.map(child => renderNormalClause(child, depth + 1))}
+ </div>
+ )}
+ </CardContent>
+ )}
+ </Card>
+
+ {/* 확장되지 않았을 때 자식 조항들 */}
+ {!isExpanded && hasChildren && (
+ <div className="ml-4">
+ {children.map(child => renderNormalClause(child, depth + 1))}
+ </div>
+ )}
+ </div>
+ );
+ }, [expandedItems, groupedClauses, toggleExpand, saveComment, cancelEdit, toggleEdit, updateTempComment]);
+
+ if (loading) {
+ return (
+ <div className="flex items-center justify-center h-64">
+ <div className="text-center">
+ <Loader2 className="h-8 w-8 text-blue-500 animate-spin mx-auto mb-4" />
+ <p className="text-sm text-gray-500">GTC 조항을 불러오는 중...</p>
+ </div>
+ </div>
+ );
+ }
+
+ if (error) {
+ return (
+ <div className="flex items-center justify-center h-64">
+ <div className="text-center">
+ <AlertTriangle className="h-8 w-8 text-red-500 mx-auto mb-4" />
+ <p className="text-sm text-gray-700 font-medium mb-2">GTC 데이터 로드 실패</p>
+ <p className="text-sm text-gray-500 mb-4">{error}</p>
+ <Button onClick={loadGtcData} size="sm">
+ 다시 시도
+ </Button>
+ </div>
+ </div>
+ );
+ }
+
+ if (!gtcData) {
+ return (
+ <div className="flex items-center justify-center h-64">
+ <div className="text-center">
+ <FileText className="h-8 w-8 text-gray-400 mx-auto mb-4" />
+ <p className="text-sm text-gray-500">GTC 데이터가 없습니다.</p>
+ </div>
+ </div>
+ );
+ }
+
+ const totalComments = clauses.filter(c => c.hasComment).length;
+
+ return (
+ <div className="h-full flex flex-col">
+ {/* 헤더 */}
+ <div className={cn(
+ "flex-shrink-0 border-b bg-gray-50",
+ compactMode ? "p-2.5" : "p-3"
+ )}>
+ <div className={cn(
+ "flex items-center justify-between",
+ compactMode ? "mb-2" : "mb-2"
+ )}>
+ <div className="flex-1 min-w-0">
+ <h3 className={cn(
+ "font-semibold text-gray-800 flex items-center",
+ compactMode ? "text-sm" : "text-base"
+ )}>
+ <FileText className={cn(
+ "mr-2 text-blue-500",
+ compactMode ? "h-4 w-4" : "h-5 w-5"
+ )} />
+ {gtcData.vendorDocument.name}
+ </h3>
+ {!compactMode && (
+ <p className="text-sm text-gray-500 mt-0.5">
+ {gtcData.vendorDocument.description || "GTC 조항 검토 및 협의"}
+ </p>
+ )}
+ </div>
+ <div className="flex items-center space-x-1.5">
+ {/* 모드 전환 버튼 */}
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setCompactMode(!compactMode)}
+ className={cn(
+ "transition-colors",
+ compactMode ? "h-7 px-2" : "h-7 px-2"
+ )}
+ title={compactMode ? "일반 모드로 전환" : "컴팩트 모드로 전환"}
+ >
+ {compactMode ? (
+ <Maximize2 className="h-3 w-3" />
+ ) : (
+ <Minimize2 className="h-3 w-3" />
+ )}
+ </Button>
+
+ <Badge variant="outline" className={cn(
+ "bg-blue-50 text-blue-700 border-blue-200",
+ compactMode ? "text-xs px-1.5 py-0.5" : "text-xs"
+ )}>
+ 총 {clauses.length}개 조항
+ </Badge>
+ {totalComments > 0 && (
+ <Badge variant="outline" className={cn(
+ "bg-amber-50 text-amber-700 border-amber-200",
+ compactMode ? "text-xs px-1.5 py-0.5" : "text-xs"
+ )}>
+ <MessageSquare className={cn(
+ "mr-1",
+ compactMode ? "h-2.5 w-2.5" : "h-3 w-3"
+ )} />
+ {totalComments}개 코멘트
+ </Badge>
+ )}
+ </div>
+ </div>
+
+ {/* 검색 */}
+ <div className="relative">
+ <div className="absolute inset-y-0 left-2 flex items-center pointer-events-none">
+ <Search className={cn(
+ "text-gray-400",
+ compactMode ? "h-3.5 w-3.5" : "h-4 w-4"
+ )} />
+ </div>
+ <Input
+ placeholder={compactMode ? "조항 검색..." : "조항 번호, 제목, 내용, 코멘트로 검색..."}
+ className={cn(
+ "bg-white text-gray-700",
+ compactMode ? "pl-8 text-sm h-8" : "pl-8 text-sm"
+ )}
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ />
+ </div>
+
+ {/* 안내 메시지 */}
+ {totalComments > 0 && (
+ <div className={cn(
+ "bg-amber-50 rounded border border-amber-200",
+ compactMode ? "mt-2 p-2" : "mt-2 p-2"
+ )}>
+ <div className={cn(
+ "flex items-center text-amber-800",
+ compactMode ? "text-sm" : "text-sm"
+ )}>
+ <AlertTriangle className={cn(
+ "mr-2",
+ compactMode ? "h-4 w-4" : "h-4 w-4"
+ )} />
+ <span className="font-medium">코멘트가 있어 서명할 수 없습니다.</span>
+ </div>
+ {!compactMode && (
+ <p className="text-sm text-amber-700 mt-0.5">
+ 모든 코멘트를 삭제하거나 협의를 완료한 후 서명해주세요.
+ </p>
+ )}
+ </div>
+ )}
+ </div>
+
+ {/* 조항 목록 */}
+ <ScrollArea className="flex-1">
+ <div className={compactMode ? "p-2.5" : "p-3"}>
+ {filteredClauses.length === 0 ? (
+ <div className="text-center py-6">
+ <FileText className="h-6 w-6 text-gray-300 mx-auto mb-2" />
+ <p className="text-sm text-gray-500">
+ {searchTerm ? "검색 결과가 없습니다." : "조항이 없습니다."}
+ </p>
+ </div>
+ ) : (
+ <div className={compactMode ? "space-y-0.5" : "space-y-1"}>
+ {(groupedClauses[0] || []).map(clause =>
+ compactMode ? renderCompactClause(clause) : renderNormalClause(clause)
+ )}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/lib/basic-contract/viewer/SurveyComponent.tsx b/lib/basic-contract/viewer/SurveyComponent.tsx
new file mode 100644
index 00000000..299fe6fa
--- /dev/null
+++ b/lib/basic-contract/viewer/SurveyComponent.tsx
@@ -0,0 +1,922 @@
+import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
+import { useForm, useWatch, Controller } from "react-hook-form";
+import { Loader2, FileText, ClipboardList, AlertTriangle, CheckCircle2, Upload, ChevronDown, ChevronUp } from "lucide-react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Label } from "@/components/ui/label";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ CompleteSurveyRequest,
+ ExistingResponse,
+ SurveyAnswerData,
+ completeSurvey,
+ getExistingSurveyResponse,
+ type SurveyTemplateWithQuestions
+} from '../service';
+import { ConditionalSurveyHandler } from '../vendor-table/survey-conditional';
+
+// 폼 데이터 타입 정의
+interface SurveyFormData {
+ [key: string]: {
+ answerValue?: string;
+ detailText?: string;
+ otherText?: string;
+ files?: File[];
+ };
+}
+
+interface SurveyComponentProps {
+ contractId?: number;
+ surveyTemplate: SurveyTemplateWithQuestions | null;
+ surveyLoading: boolean;
+ conditionalHandler: ConditionalSurveyHandler | null;
+ onSurveyComplete?: () => void;
+ onSurveyDataUpdate: (data: any) => void;
+ onLoadSurveyTemplate: () => void;
+ setActiveTab: (tab: string) => void;
+}
+
+export const SurveyComponent: React.FC<SurveyComponentProps> = ({
+ contractId,
+ surveyTemplate,
+ surveyLoading,
+ conditionalHandler,
+ onSurveyComplete,
+ onSurveyDataUpdate,
+ onLoadSurveyTemplate,
+ setActiveTab
+}) => {
+ const [uploadedFiles, setUploadedFiles] = useState<Record<number, File[]>>({});
+ const [existingResponse, setExistingResponse] = useState<ExistingResponse | null>(null);
+ const [loadingExistingResponse, setLoadingExistingResponse] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [formInitialized, setFormInitialized] = useState(false);
+ const [isHeaderExpanded, setIsHeaderExpanded] = useState(false);
+
+ console.log(uploadedFiles,"uploadedFiles")
+
+ // 무한 렌더링 방지를 위한 ref
+ const loadingRef = useRef(false);
+ const initializedRef = useRef(false);
+
+ // 기본 폼 설정 - 의존성 최소화
+ const { control, watch, setValue, getValues, formState: { errors }, trigger, reset } = useForm<SurveyFormData>({
+ defaultValues: {},
+ mode: 'onChange'
+ });
+
+ const watchedValues = watch();
+
+
+ // 기존 응답 로드 - 한 번만 실행되도록 최적화
+ useEffect(() => {
+ // 중복 실행 방지
+ if (loadingRef.current || !contractId || !surveyTemplate?.id || initializedRef.current) {
+ return;
+ }
+
+ loadingRef.current = true;
+ setLoadingExistingResponse(true);
+
+ const loadExistingResponse = async () => {
+ try {
+ console.log('📥 기존 설문 응답 조회 시작...', { contractId, templateId: surveyTemplate.id });
+
+ const result = await getExistingSurveyResponse(contractId, surveyTemplate.id);
+
+ if (result.success && result.data) {
+ console.log('✅ 기존 응답 발견:', result.data);
+ setExistingResponse(result.data);
+
+ // 폼 초기값 설정
+ const formValues: SurveyFormData = {};
+ const existingFiles: Record<number, File[]> = {};
+
+ result.data.answers.forEach(answer => {
+ formValues[answer.questionId] = {
+ answerValue: answer.answerValue || '',
+ detailText: answer.detailText || '',
+ otherText: answer.otherText || '',
+ files: answer.files || [], // 기존 파일 정보 유지
+ };
+
+ // 파일이 있다면 uploadedFiles에도 설정 (표시용)
+ if (answer.files && answer.files.length > 0) {
+ existingFiles[answer.questionId] = answer.files;
+ }
+ });
+
+ console.log('📝 폼 초기값 설정:', formValues);
+
+ // reset을 사용하여 폼 전체를 한 번에 초기화
+ reset(formValues);
+ setUploadedFiles(existingFiles)
+
+ // 기존 응답이 완료되었다면 부모에게 알림
+ if (result.data.status === 'COMPLETED') {
+ const completedData = {
+ completed: true,
+ answers: result.data.answers,
+ timestamp: result.data.completedAt || new Date().toISOString(),
+ responseId: result.data.responseId,
+ };
+
+ onSurveyDataUpdate(completedData);
+
+ // ⭐ 여기가 핵심: 기존 완료된 설문이 있으면 즉시 부모의 설문 완료 상태 업데이트
+ if (onSurveyComplete) {
+ console.log(`📋 기존 완료된 설문조사 감지 - 부모 상태 업데이트: 계약서 ${contractId}`);
+ onSurveyComplete();
+ }
+ }
+
+ } else {
+ console.log('📭 기존 응답 없음');
+ setExistingResponse(null);
+ }
+
+ initializedRef.current = true;
+ setFormInitialized(true);
+
+ } catch (error) {
+ console.error('❌ 기존 응답 로드 중 오류:', error);
+ setExistingResponse(null);
+ initializedRef.current = true;
+ setFormInitialized(true);
+ } finally {
+ setLoadingExistingResponse(false);
+ loadingRef.current = false;
+ }
+ };
+
+ loadExistingResponse();
+ }, [contractId, surveyTemplate?.id,onSurveyComplete]); // 의존성 최소화
+
+ // 실시간 진행 상태 계산 - 안정화
+ const progressStatus = useMemo(() => {
+ if (!formInitialized || !conditionalHandler || !surveyTemplate) {
+ return {
+ visibleQuestions: [],
+ totalRequired: 0,
+ completedRequired: 0,
+ completedQuestionIds: [],
+ incompleteQuestionIds: [],
+ progressPercentage: 0,
+ debugInfo: {}
+ };
+ }
+
+ // 현재 답변을 조건부 핸들러가 인식할 수 있는 형태로 변환
+ const convertedAnswers: Record<number, any> = {};
+ Object.entries(watchedValues || {}).forEach(([questionId, value]) => {
+ const id = parseInt(questionId);
+ if (!isNaN(id) && value) {
+ convertedAnswers[id] = {
+ questionId: id,
+ answerValue: value.answerValue || '',
+ detailText: value.detailText || '',
+ otherText: value.otherText || '',
+ files: value.files || []
+ };
+ }
+ });
+
+ console.log(convertedAnswers,"convertedAnswers")
+
+ return conditionalHandler.getSimpleProgressStatus(convertedAnswers);
+ }, [conditionalHandler, watchedValues, surveyTemplate, formInitialized]);
+
+ console.log(progressStatus,"progressStatus")
+
+ // 동적 상태 정보 - 메모화
+ const {
+ visibleQuestions,
+ totalVisibleQuestions,
+ baseQuestionCount,
+ conditionalQuestionCount,
+ hasConditionalQuestions,
+ canComplete
+ } = useMemo(() => {
+ const visibleQuestions = progressStatus.visibleQuestions;
+ const totalVisibleQuestions = visibleQuestions.length;
+ const baseQuestionCount = surveyTemplate?.questions.length || 0;
+ const conditionalQuestionCount = totalVisibleQuestions - baseQuestionCount;
+ const hasConditionalQuestions = conditionalQuestionCount > 0;
+ const canComplete = progressStatus.totalRequired > 0 &&
+ progressStatus.completedRequired === progressStatus.totalRequired;
+
+ return {
+ visibleQuestions,
+ totalVisibleQuestions,
+ baseQuestionCount,
+ conditionalQuestionCount,
+ hasConditionalQuestions,
+ canComplete
+ };
+ }, [progressStatus, surveyTemplate?.questions]);
+
+ // 파일 업로드 핸들러 - 안정적인 참조
+ const handleFileUpload = useCallback((questionId: number, files: FileList | null) => {
+ if (!files) return;
+
+ const fileArray = Array.from(files);
+ setUploadedFiles(prev => ({
+ ...prev,
+ [questionId]: fileArray
+ }));
+
+ setValue(`${questionId}.files`, fileArray);
+ }, [setValue]);
+
+ // 답변 변경 핸들러 - 안정적인 참조
+ const handleAnswerChange = useCallback((questionId: number, field: string, value: any) => {
+ console.log(`📝 답변 변경: 질문 ${questionId}, 필드 ${field}, 값:`, value);
+
+ setValue(`${questionId}.${field}`, value);
+
+ // 부모 질문의 답변이 변경되면 조건부 자식 질문들 처리
+ if (field === 'answerValue' && conditionalHandler) {
+ // setTimeout으로 다음 tick에서 처리하여 상태 업데이트 충돌 방지
+ setTimeout(() => {
+ const currentValues = getValues();
+ const convertedAnswers: Record<number, any> = {};
+
+ Object.entries(currentValues).forEach(([qId, qValue]) => {
+ const id = parseInt(qId);
+ if (!isNaN(id) && qValue) {
+ convertedAnswers[id] = {
+ questionId: id,
+ answerValue: qValue.answerValue || '',
+ detailText: qValue.detailText || '',
+ otherText: qValue.otherText || '',
+ files: qValue.files || []
+ };
+ }
+ });
+
+ // 새로운 답변 반영
+ convertedAnswers[questionId] = {
+ ...convertedAnswers[questionId],
+ questionId,
+ [field]: value
+ };
+
+ // 영향받는 자식 질문들의 답변 초기화
+ const clearedAnswers = conditionalHandler.clearAffectedChildAnswers(questionId, value, convertedAnswers);
+
+ // 삭제된 답변들을 폼에서도 제거
+ Object.keys(convertedAnswers).forEach(qId => {
+ const id = parseInt(qId);
+ if (id !== questionId && !clearedAnswers[id]) {
+ console.log(`🗑️ 질문 ${id} 답변 초기화`);
+ setValue(`${id}`, {
+ answerValue: '',
+ detailText: '',
+ otherText: '',
+ files: []
+ });
+
+ setUploadedFiles(prev => {
+ const updated = { ...prev };
+ delete updated[id];
+ return updated;
+ });
+ }
+ });
+ }, 0);
+ }
+ }, [setValue, getValues, conditionalHandler]);
+
+ // OTHER 텍스트 입력 컴포넌트
+ const OtherTextInput = useCallback(({ questionId, fieldName }: { questionId: number; fieldName: string }) => {
+ const answerValue = useWatch({
+ control,
+ name: `${fieldName}.answerValue`
+ });
+
+ const question = visibleQuestions.find(q => q.id === questionId);
+ const selectedOption = question?.options?.find(opt => opt.optionValue === answerValue);
+
+ if (!selectedOption?.allowsOtherInput) return null;
+
+ return (
+ <Controller
+ name={`${fieldName}.otherText`}
+ control={control}
+ render={({ field }) => (
+ <Input
+ {...field}
+ placeholder="기타 내용을 입력해주세요"
+ className="mt-2"
+ />
+ )}
+ />
+ );
+ }, [control, visibleQuestions]);
+
+ // 설문조사 완료 핸들러
+ const handleSurveyComplete = useCallback(async () => {
+ console.log('🎯 설문조사 완료 시도');
+
+ if (isSubmitting) {
+ console.log('⚠️ 이미 제출 중...');
+ return;
+ }
+
+ setIsSubmitting(true);
+
+ try {
+ const currentValues = getValues();
+ const isValid = await trigger();
+
+ if (!canComplete) {
+ let errorMessage = '모든 필수 항목을 완료해주세요.';
+ let errorDescription = `${progressStatus.completedRequired}/${progressStatus.totalRequired} 완료됨`;
+
+ if (progressStatus.incompleteQuestionIds.length > 0) {
+ const incompleteReasons = progressStatus.incompleteQuestionIds.map(id => {
+ const debug = progressStatus.debugInfo?.[id];
+ const question = visibleQuestions.find(q => q.id === id);
+ return `• Q${question?.questionNumber}: ${question?.questionText?.substring(0, 40)}...\n → ${debug?.incompleteReason || '답변 필요'}`;
+ }).slice(0, 3);
+
+ errorDescription = incompleteReasons.join('\n\n');
+
+ if (progressStatus.incompleteQuestionIds.length > 3) {
+ errorDescription += `\n\n... 외 ${progressStatus.incompleteQuestionIds.length - 3}개 항목`;
+ }
+ }
+
+ toast.error(errorMessage, {
+ description: errorDescription,
+ icon: <AlertTriangle className="h-5 w-5 text-red-500" />,
+ duration: 12000
+ });
+
+ if (progressStatus.incompleteQuestionIds.length > 0) {
+ const firstIncompleteId = progressStatus.incompleteQuestionIds[0];
+ const element = document.getElementById(`question-${firstIncompleteId}`);
+ if (element) {
+ element.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ }
+ }
+
+ return;
+ }
+
+ if (!contractId || !surveyTemplate?.id) {
+ toast.error('계약서 정보 또는 설문 템플릿 정보가 없습니다.');
+ return;
+ }
+
+ const surveyAnswers: SurveyAnswerData[] = Object.entries(currentValues)
+ .map(([questionId, value]) => ({
+ questionId: parseInt(questionId),
+ answerValue: value?.answerValue || '',
+ detailText: value?.detailText || '',
+ otherText: value?.otherText || '',
+ files: value?.files || []
+ }))
+ .filter(answer =>
+ answer.answerValue || answer.detailText || (answer.files && answer.files.length > 0)
+ );
+
+ const requestData: CompleteSurveyRequest = {
+ contractId: contractId,
+ templateId: surveyTemplate.id,
+ answers: surveyAnswers,
+ progressStatus: progressStatus
+ };
+
+ const submitToast = toast.loading('설문조사를 저장하는 중...', {
+ description: '잠시만 기다려주세요.',
+ duration: Infinity
+ });
+
+ const result = await completeSurvey(requestData);
+ toast.dismiss(submitToast);
+
+ if (result.success) {
+ const completedSurveyData = {
+ completed: true,
+ answers: surveyAnswers,
+ timestamp: new Date().toISOString(),
+ progressStatus: progressStatus,
+ totalQuestions: totalVisibleQuestions,
+ conditionalQuestions: conditionalQuestionCount,
+ responseId: result.data?.responseId
+ };
+
+ onSurveyDataUpdate(completedSurveyData);
+
+ if (onSurveyComplete) {
+ onSurveyComplete();
+ }
+
+ toast.success("🎉 설문조사가 완료되었습니다!", {
+ description: `총 ${progressStatus.totalRequired}개 필수 질문 완료${hasConditionalQuestions ? ` (조건부 ${conditionalQuestionCount}개 포함)` : ''}`,
+ icon: <CheckCircle2 className="h-5 w-5 text-green-500" />,
+ duration: 5000
+ });
+
+ setTimeout(() => {
+ setActiveTab('main');
+ }, 2000);
+
+ } else {
+ console.error('❌ 서버 응답 에러:', result.message);
+ toast.error('설문조사 저장 실패', {
+ description: result.message || '서버에서 오류가 발생했습니다.',
+ icon: <AlertTriangle className="h-5 w-5 text-red-500" />,
+ duration: 8000
+ });
+ }
+
+ } catch (error) {
+ console.error('❌ 설문조사 저장 중 예외 발생:', error);
+
+ let errorMessage = '설문조사 저장에 실패했습니다.';
+ let errorDescription = '네트워크 연결을 확인하고 다시 시도해주세요.';
+
+ if (error instanceof Error) {
+ errorDescription = error.message;
+ }
+
+ toast.error(errorMessage, {
+ description: errorDescription,
+ icon: <AlertTriangle className="h-5 w-5 text-red-500" />,
+ duration: 10000
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ }, [
+ getValues,
+ trigger,
+ canComplete,
+ progressStatus,
+ visibleQuestions,
+ contractId,
+ surveyTemplate?.id,
+ totalVisibleQuestions,
+ conditionalQuestionCount,
+ hasConditionalQuestions,
+ isSubmitting,
+ onSurveyComplete,
+ onSurveyDataUpdate,
+ setActiveTab
+ ]);
+
+ if (surveyLoading || loadingExistingResponse) {
+ return (
+ <div className="h-full w-full">
+ <Card className="h-full">
+ <CardContent className="flex flex-col items-center justify-center h-full py-12">
+ <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
+ <p className="text-sm text-muted-foreground">
+ {surveyLoading ? '설문조사를 불러오는 중...' : '기존 응답을 확인하는 중...'}
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+ );
+ }
+
+ if (!surveyTemplate) {
+ return (
+ <div className="h-full w-full">
+ <Card className="h-full">
+ <CardContent className="flex flex-col items-center justify-center h-full py-12">
+ <AlertTriangle className="h-8 w-8 text-red-500 mb-4" />
+ <p className="text-sm text-muted-foreground">설문조사 템플릿을 불러올 수 없습니다.</p>
+ <Button
+ variant="outline"
+ onClick={onLoadSurveyTemplate}
+ className="mt-2"
+ >
+ 다시 시도
+ </Button>
+ </CardContent>
+ </Card>
+ </div>
+ );
+ }
+
+ return (
+ <div className="h-full w-full flex flex-col">
+ <Card className="h-full flex flex-col">
+ <CardHeader className="pb-2">
+ {/* 항상 보이는 컴팩트 헤더 */}
+ <div className="flex items-center justify-between">
+ <CardTitle className="flex items-center text-lg">
+ <ClipboardList className="h-5 w-5 mr-2 text-amber-500" />
+ <span className="truncate max-w-[300px]">{surveyTemplate.name}</span>
+ {existingResponse && (
+ <Badge
+ variant={existingResponse.status === 'COMPLETED' ? 'default' : 'secondary'}
+ className="ml-2 text-xs"
+ >
+ {existingResponse.status === 'COMPLETED' ? '완료됨' : '작성중'}
+ </Badge>
+ )}
+ </CardTitle>
+
+ <div className="flex items-center space-x-3">
+ {/* 컴팩트 진행률 표시 */}
+ <div className="flex items-center space-x-2">
+ <div className="w-20 bg-gray-200 rounded-full h-1.5">
+ <div
+ className="bg-gradient-to-r from-blue-500 to-blue-600 h-1.5 rounded-full transition-all duration-300"
+ style={{ width: `${progressStatus.progressPercentage}%` }}
+ />
+ </div>
+ <span className="text-xs text-gray-600 whitespace-nowrap">
+ {progressStatus.completedRequired}/{progressStatus.totalRequired}
+ </span>
+ </div>
+
+ {/* 펼치기/접기 버튼 */}
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setIsHeaderExpanded(!isHeaderExpanded)}
+ className="p-1 h-8 w-8"
+ >
+ {isHeaderExpanded ? (
+ <ChevronUp className="h-4 w-4" />
+ ) : (
+ <ChevronDown className="h-4 w-4" />
+ )}
+ </Button>
+ </div>
+ </div>
+
+ {/* 접을 수 있는 상세 정보 - 조건부 렌더링 */}
+ {isHeaderExpanded && (
+ <div className="mt-3 pt-3 space-y-3 border-t">
+ {/* 기존 응답 정보 */}
+ {existingResponse && (
+ <div className="p-2 bg-blue-50 border border-blue-200 rounded-lg">
+ <div className="flex items-center text-sm">
+ <CheckCircle2 className="h-4 w-4 text-blue-600 mr-2" />
+ <span className="text-blue-800">
+ {existingResponse.status === 'COMPLETED'
+ ? '이미 완료된 설문조사입니다. 내용을 수정할 수 있습니다.'
+ : '이전에 작성하던 내용이 복원되었습니다.'}
+ </span>
+ </div>
+ {existingResponse.completedAt && (
+ <div className="text-xs text-blue-600 mt-1">
+ 완료일시: {new Date(existingResponse.completedAt).toLocaleString('ko-KR')}
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* 질문 정보 - 그리드로 컴팩트하게 */}
+ {/* <div className="grid grid-cols-2 gap-4 text-sm">
+ <div className="space-y-1">
+ <div className="text-gray-600">
+ 📋 총 {totalVisibleQuestions}개 질문
+ </div>
+ {hasConditionalQuestions && (
+ <div className="text-blue-600 text-xs">
+ ⚡ 조건부 {conditionalQuestionCount}개 추가됨
+ </div>
+ )}
+ </div>
+
+ <div className="space-y-1">
+ <div className="text-gray-600">
+ ✅ 완료: {progressStatus.completedRequired}개
+ </div>
+ <div className="text-gray-600">
+ ⏳ 남은 필수: {progressStatus.totalRequired - progressStatus.completedRequired}개
+ </div>
+ </div>
+ </div> */}
+
+ {/* 상세 진행률 바 */}
+ {/* <div className="space-y-2">
+ <div className="flex justify-between text-xs text-gray-600">
+ <span>필수 질문 진행률</span>
+ <span>
+ {Math.round(progressStatus.progressPercentage)}%
+ {hasConditionalQuestions && (
+ <span className="ml-1 text-blue-600">(조건부 포함)</span>
+ )}
+ </span>
+ </div>
+ <div className="w-full bg-gray-200 rounded-full h-3">
+ <div
+ className="bg-gradient-to-r from-blue-500 to-blue-600 h-3 rounded-full transition-all duration-500 ease-out"
+ style={{ width: `${progressStatus.progressPercentage}%` }}
+ />
+ </div>
+ </div> */}
+
+ {/* 중요 안내 - 컴팩트하게 */}
+ <div className="p-3 border rounded-lg bg-yellow-50 border-yellow-200">
+ <div className="flex items-start">
+ <AlertTriangle className="h-4 w-4 text-yellow-600 mt-0.5 mr-2 flex-shrink-0" />
+ <div>
+ <p className="font-medium text-yellow-800 text-sm">준법 의무 확인 필수</p>
+ <p className="text-xs text-yellow-700 mt-1">
+ 모든 필수 항목을 정확히 작성해주세요. 답변에 따라 추가 질문이 나타날 수 있습니다.
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* 헤더가 접혀있을 때 보이는 요약 정보 */}
+ {!isHeaderExpanded && (
+ <div className="flex items-center justify-between text-xs text-gray-500 mt-2 pt-2 border-t">
+ <span>
+ 📋 {totalVisibleQuestions}개 질문
+ {hasConditionalQuestions && (
+ <span className="text-blue-600 ml-1">(+{conditionalQuestionCount}개 조건부)</span>
+ )}
+ </span>
+ <span
+ className="text-blue-600 hover:underline cursor-pointer"
+ onClick={() => setIsHeaderExpanded(true)}
+ >
+ 상세 정보 보기 ↑
+ </span>
+ </div>
+ )}
+</CardHeader>
+
+ <CardContent className="flex-1 min-h-0 overflow-y-auto">
+ <form onSubmit={(e) => e.preventDefault()} className="space-y-6">
+ {/* <div className="p-4 border rounded-lg bg-yellow-50">
+ <div className="flex items-start">
+ <AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5 mr-2" />
+ <div>
+ <p className="font-medium text-yellow-800">중요 안내</p>
+ <p className="text-sm text-yellow-700 mt-1">
+ 본 설문조사는 준법 의무 확인을 위한 필수 절차입니다. 모든 항목을 정확히 작성해주세요.
+ {conditionalHandler && (
+ <span className="block mt-1">
+ ⚡ 답변에 따라 추가 질문이 나타날 수 있으며, 이 경우 모든 추가 질문도 완료해야 합니다.
+ </span>
+ )}
+ </p>
+ </div>
+ </div>
+ </div> */}
+
+ <div className="space-y-4">
+ {visibleQuestions.map((question: any) => {
+ const fieldName = `${question.id}`;
+ const isComplete = progressStatus.completedQuestionIds.includes(question.id);
+ const isConditional = !!question.parentQuestionId;
+
+ return (
+ <div
+ key={question.id}
+ id={`question-${question.id}`}
+ className={`mb-6 p-4 border rounded-lg transition-colors duration-200 ${
+ isConditional
+ ? 'bg-blue-50 border-blue-200'
+ : 'bg-gray-50 border-gray-200'
+ } ${!isComplete && question.isRequired ? 'ring-2 ring-red-100' : ''}`}
+ >
+ <div className="flex items-start justify-between mb-3">
+ <div className="flex-1">
+ <Label className="text-sm font-medium text-gray-900 flex items-center flex-wrap gap-2">
+ <span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
+ Q{question.questionNumber}
+ </span>
+
+ {isConditional && (
+ <span className="px-2 py-1 bg-amber-100 text-amber-700 rounded text-xs">
+ ⚡ 조건부 질문
+ </span>
+ )}
+
+ {question.questionType === 'FILE' && (
+ <span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">
+ 📎 파일 업로드
+ </span>
+ )}
+
+ <div className="w-full mt-1">
+ {question.questionText}
+ {question.isRequired && <span className="text-red-500 ml-1">*</span>}
+ </div>
+ </Label>
+ </div>
+
+ {isComplete && (
+ <CheckCircle2 className="h-5 w-5 text-green-500 ml-2 flex-shrink-0" />
+ )}
+ </div>
+
+ {/* 질문 타입별 렌더링 */}
+ {question.questionType === 'RADIO' && (
+ <Controller
+ name={`${fieldName}.answerValue`}
+ control={control}
+ rules={{ required: question.isRequired ? '필수 항목입니다.' : false }}
+ render={({ field }) => (
+ <RadioGroup
+ value={field.value || ''}
+ onValueChange={(value) => {
+ field.onChange(value);
+ handleAnswerChange(question.id, 'answerValue', value);
+ }}
+ className="space-y-2"
+ >
+ {question.options?.map((option: any) => (
+ <div key={option.id} className="flex items-center space-x-2">
+ <RadioGroupItem value={option.optionValue} id={`${question.id}-${option.id}`} />
+ <Label htmlFor={`${question.id}-${option.id}`} className="text-sm">
+ {option.optionText}
+ </Label>
+ </div>
+ ))}
+ </RadioGroup>
+ )}
+ />
+ )}
+
+ {question.questionType === 'DROPDOWN' && (
+ <div className="space-y-2">
+ <Controller
+ name={`${fieldName}.answerValue`}
+ control={control}
+ rules={{ required: question.isRequired ? '필수 항목입니다.' : false }}
+ render={({ field }) => (
+ <Select
+ value={field.value || ''}
+ onValueChange={(value) => {
+ field.onChange(value);
+ handleAnswerChange(question.id, 'answerValue', value);
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="선택해주세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {question.options?.map((option: any) => (
+ <SelectItem key={option.id} value={option.optionValue}>
+ {option.optionText}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ )}
+ />
+ <OtherTextInput questionId={question.id} fieldName={fieldName} />
+ </div>
+ )}
+
+ {question.questionType === 'TEXTAREA' && (
+ <Controller
+ name={`${fieldName}.detailText`}
+ control={control}
+ rules={{ required: question.isRequired ? '필수 항목입니다.' : false }}
+ render={({ field }) => (
+ <Textarea
+ {...field}
+ placeholder="상세한 내용을 입력해주세요"
+ rows={4}
+ />
+ )}
+ />
+ )}
+
+ {question.hasDetailText && (
+ <div className="mt-3">
+ <Label className="text-sm text-gray-700 mb-2 block">상세 내용을 기술해주세요:</Label>
+ <Controller
+ name={`${fieldName}.detailText`}
+ control={control}
+ rules={{ required: question.isRequired ? '상세 내용을 입력해주세요.' : false }}
+ render={({ field }) => (
+ <Textarea
+ {...field}
+ placeholder="상세한 내용을 입력해주세요"
+ rows={3}
+ className="w-full"
+ />
+ )}
+ />
+ </div>
+ )}
+
+ {(question.hasFileUpload || question.questionType === 'FILE') && (
+ <div className="mt-3">
+ <Label className="text-sm text-gray-700 mb-2 block">첨부파일:</Label>
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-4">
+ <input
+ type="file"
+ multiple
+ onChange={(e) => handleFileUpload(question.id, e.target.files)}
+ className="hidden"
+ id={`file-${question.id}`}
+ />
+ <label htmlFor={`file-${question.id}`} className="cursor-pointer">
+ <div className="flex flex-col items-center">
+ <Upload className="h-8 w-8 text-gray-400 mb-2" />
+ <span className="text-sm text-gray-500">파일을 선택하거나 여기에 드래그하세요</span>
+ </div>
+ </label>
+
+ {uploadedFiles[question.id] && uploadedFiles[question.id].length > 0 && (
+ <div className="mt-3 space-y-1">
+ {uploadedFiles[question.id].map((file, index) => (
+ <div key={index} className="flex items-center space-x-2 text-sm">
+ <FileText className="h-4 w-4 text-blue-500" />
+ <span>{file.fileName}</span>
+ <span className="text-gray-500">({(file.fileSize / 1024).toFixed(1)} KB)</span>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+
+ {errors[fieldName] && (
+ <p className="mt-2 text-sm text-red-600 flex items-center">
+ <AlertTriangle className="h-4 w-4 mr-1" />
+ {errors[fieldName]?.answerValue?.message ||
+ errors[fieldName]?.detailText?.message ||
+ '필수 항목을 완료해주세요.'}
+ </p>
+ )}
+ </div>
+ );
+ })}
+ </div>
+
+ <div className="flex justify-end pt-6 border-t">
+ <div className="flex items-center space-x-4">
+ <div className="text-sm">
+ {canComplete ? (
+ <div className="text-green-600 font-medium flex items-center">
+ <CheckCircle2 className="h-4 w-4 mr-1" />
+ 모든 필수 항목 완료됨
+ {hasConditionalQuestions && (
+ <span className="ml-2 text-xs text-blue-600">
+ (조건부 {conditionalQuestionCount}개 포함)
+ </span>
+ )}
+ </div>
+ ) : (
+ <div className="space-y-1">
+ <div className="flex items-center text-gray-600">
+ <AlertTriangle className="h-4 w-4 mr-1 text-red-500" />
+ {progressStatus.completedRequired}/{progressStatus.totalRequired} 완료
+ </div>
+ {hasConditionalQuestions && (
+ <div className="text-xs text-blue-600">
+ 기본 + 조건부 {conditionalQuestionCount}개 포함
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+
+ <Button
+ type="button"
+ onClick={handleSurveyComplete}
+ disabled={!canComplete || isSubmitting}
+ className={`transition-all duration-200 ${
+ canComplete && !isSubmitting
+ ? 'bg-green-600 hover:bg-green-700 shadow-lg'
+ : 'bg-gray-400 cursor-not-allowed'
+ }`}
+ >
+ {isSubmitting ? (
+ <>
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ <>
+ <CheckCircle2 className="h-4 w-4 mr-2" />
+ 설문조사 완료
+ </>
+ )}
+ <span className="ml-1 text-xs">
+ ({progressStatus.completedRequired}/{progressStatus.totalRequired})
+ </span>
+ </Button>
+ </div>
+ </div>
+ </form>
+ </CardContent>
+ </Card>
+ </div>
+ );
+}; \ No newline at end of file
diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
index fbf36738..943878da 100644
--- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
+++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
@@ -6,16 +6,13 @@ import React, {
useRef,
SetStateAction,
Dispatch,
- useMemo,
- useCallback,
} from "react";
import { WebViewerInstance } from "@pdftron/webviewer";
-import { Loader2, FileText, ClipboardList, AlertTriangle, FileSignature, Target, CheckCircle2 } from "lucide-react";
+import { Loader2, FileText, ClipboardList, AlertTriangle, FileSignature, Target, CheckCircle2, BookOpen } from "lucide-react";
import { toast } from "sonner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@@ -24,20 +21,15 @@ import {
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
-import { Label } from "@/components/ui/label";
-import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { Input } from "@/components/ui/input";
-import { Textarea } from "@/components/ui/textarea";
-import { Upload } from "lucide-react";
-import { CompleteSurveyRequest, SurveyAnswerData, completeSurvey, getActiveSurveyTemplate, type SurveyTemplateWithQuestions } from '../service';
-import { ConditionalSurveyHandler, useConditionalSurvey } from '../vendor-table/survey-conditional';
-import { useForm, useWatch, Controller } from "react-hook-form";
+import { getActiveSurveyTemplate, getVendorSignatureFile, type SurveyTemplateWithQuestions } from '../service';
+import { useConditionalSurvey } from '../vendor-table/survey-conditional';
+import { SurveyComponent } from './SurveyComponent';
+import { GtcClausesComponent } from './GtcClausesComponent';
interface FileInfo {
path: string;
name: string;
- type: 'main' | 'attachment' | 'survey';
+ type: 'main' | 'attachment' | 'survey' | 'clauses';
}
interface BasicContractSignViewerProps {
@@ -50,21 +42,13 @@ interface BasicContractSignViewerProps {
onSign?: (documentData: ArrayBuffer, surveyData?: any) => Promise<void>;
instance: WebViewerInstance | null;
setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>;
- onSurveyComplete?: () => void; // 🔥 새로 추가
- onSignatureComplete?: () => void; // 🔥 새로 추가
+ onSurveyComplete?: () => void;
+ onSignatureComplete?: () => void;
+ onGtcCommentStatusChange?: (hasComments: boolean, commentCount: number) => void;
+ mode?: 'vendor' | 'buyer'; // 추가된 mode prop
t?: (key: string) => string;
}
-// 폼 데이터 타입 정의
-interface SurveyFormData {
- [key: string]: {
- answerValue?: string;
- detailText?: string;
- otherText?: string;
- files?: File[];
- };
-}
-
// 자동 서명 필드 생성을 위한 타입 정의
interface SignaturePattern {
regex: RegExp;
@@ -80,9 +64,11 @@ interface SignaturePattern {
class AutoSignatureFieldDetector {
private instance: WebViewerInstance;
private signaturePatterns: SignaturePattern[];
+ private mode: 'vendor' | 'buyer'; // mode 추가
- constructor(instance: WebViewerInstance) {
+ constructor(instance: WebViewerInstance, mode: 'vendor' | 'buyer' = 'vendor') {
this.instance = instance;
+ this.mode = mode;
this.signaturePatterns = this.initializePatterns();
}
@@ -128,7 +114,7 @@ class AutoSignatureFieldDetector {
}
async detectAndCreateSignatureFields(): Promise<string[]> {
- console.log("🔍 안전한 서명 필드 감지 시작...");
+ console.log(`🔍 안전한 서명 필드 감지 시작... (모드: ${this.mode})`);
try {
if (!this.instance?.Core?.documentViewer) {
@@ -166,77 +152,87 @@ class AutoSignatureFieldDetector {
}
private async createSimpleSignatureField(): Promise<string> {
- try {
- const { Core } = this.instance;
- const { documentViewer, annotationManager, Annotations } = Core;
+ const { Core } = this.instance;
+ const { documentViewer, annotationManager, Annotations } = Core;
- const pageCount = documentViewer.getPageCount();
- const pageWidth = documentViewer.getPageWidth(pageCount) || 612;
- const pageHeight = documentViewer.getPageHeight(pageCount) || 792;
+ const page = documentViewer.getPageCount();
+ const w = documentViewer.getPageWidth(page) || 612;
+ const h = documentViewer.getPageHeight(page) || 792;
- console.log(`📏 페이지 정보: ${pageCount}페이지, 크기 ${pageWidth}x${pageHeight}`);
-
- const fieldName = `simple_signature_${Date.now()}`;
- const flags = new Annotations.WidgetFlags();
-
- const formField = new Core.Annotations.Forms.Field(
- `SignatureFormField`,
- {
- type: "Sig",
- flags,
- }
- );
-
- const signatureWidget = new Annotations.SignatureWidgetAnnotation(formField, {
- Width: 150,
- Height: 50
- });
+ const fieldName = `simple_signature_${Date.now()}`;
+ const flags = new Annotations.WidgetFlags();
+ flags.set('Required', true);
- signatureWidget.setPageNumber(pageCount);
- signatureWidget.setX(pageWidth * 0.7);
- signatureWidget.setY(pageHeight * 0.85);
- signatureWidget.setWidth(150);
- signatureWidget.setHeight(50);
+ const field = new Core.Annotations.Forms.Field(fieldName, { type: 'Sig', flags });
- annotationManager.addAnnotation(signatureWidget);
- annotationManager.redrawAnnotation(signatureWidget);
+ const widget = new Annotations.SignatureWidgetAnnotation(field, { Width: 150, Height: 50 });
+ widget.setPageNumber(page);
+
+ // 구매자 모드일 때는 왼쪽 하단으로 위치 설정
+ if (this.mode === 'buyer') {
+ widget.setX(w * 0.1); // 왼쪽 (10%)
+ widget.setY(h * 0.85); // 하단 (85%)
+ } else {
+ // 협력업체 모드일 때는 기존처럼 오른쪽
+ widget.setX(w * 0.7); // 오른쪽 (70%)
+ widget.setY(h * 0.85); // 하단 (85%)
+ }
+
+ widget.setWidth(150);
+ widget.setHeight(50);
- console.log(`✅ 서명 필드 생성: ${fieldName}`);
- return fieldName;
+ const fm = annotationManager.getFieldManager();
+ fm.addField(field);
+ annotationManager.addAnnotation(widget);
+ annotationManager.drawAnnotationsFromList([widget]);
- } catch (error) {
- console.error("📛 서명 필드 생성 실패:", error);
- return "manual_signature_required";
- }
+ return fieldName;
}
}
-function useAutoSignatureFields(instance: WebViewerInstance | null) {
+function useAutoSignatureFields(instance: WebViewerInstance | null, mode: 'vendor' | 'buyer' = 'vendor') {
const [signatureFields, setSignatureFields] = useState<string[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
+ // 한 번만 실행되도록 보장하는 플래그들
const processingRef = useRef(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
+ const processedDocumentRef = useRef<string | null>(null); // 처리된 문서 추적
+ const handlerRef = useRef<(() => void) | null>(null); // 핸들러 참조 저장
useEffect(() => {
if (!instance) return;
const { documentViewer } = instance.Core;
+ // 새로운 핸들러 생성 (참조가 변하지 않도록)
const handleDocumentLoaded = () => {
+ // 현재 문서의 고유 식별자 생성
+ const currentDoc = documentViewer.getDocument();
+ const documentId = currentDoc ? `${currentDoc.getFilename()}_${Date.now()}` : null;
+
+ // 같은 문서를 이미 처리했다면 스킵
+ if (documentId && processedDocumentRef.current === documentId) {
+ console.log("📛 이미 처리된 문서이므로 스킵:", documentId);
+ return;
+ }
+
if (processingRef.current) {
console.log("📛 이미 처리 중이므로 스킵");
return;
}
+ // 이전 타이머 클리어
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
timeoutRef.current = setTimeout(async () => {
+ // 다시 한번 중복 체크
if (processingRef.current) return;
+ if (documentId && processedDocumentRef.current === documentId) return;
processingRef.current = true;
setIsProcessing(true);
@@ -249,32 +245,58 @@ function useAutoSignatureFields(instance: WebViewerInstance | null) {
throw new Error("문서가 준비되지 않았습니다.");
}
- const detector = new AutoSignatureFieldDetector(instance);
+ // 기존 서명 필드 확인
+ const { annotationManager } = instance.Core;
+ const existingAnnotations = annotationManager.getAnnotationsList();
+ const existingSignatureFields = existingAnnotations.filter(
+ annot => annot instanceof instance.Core.Annotations.SignatureWidgetAnnotation
+ );
+
+ // 이미 서명 필드가 있으면 생성하지 않음
+ if (existingSignatureFields.length > 0) {
+ console.log("📋 기존 서명 필드 발견:", existingSignatureFields.length);
+ const fieldNames = existingSignatureFields.map((field, idx) =>
+ field.getField()?.name || `existing_signature_${idx}`
+ );
+ setSignatureFields(fieldNames);
+
+ // 처리 완료 표시
+ if (documentId) {
+ processedDocumentRef.current = documentId;
+ }
+
+ toast.success(`📋 ${fieldNames.length}개의 기존 서명 필드를 확인했습니다.`);
+ return;
+ }
+
+ const detector = new AutoSignatureFieldDetector(instance, mode); // mode 전달
const fields = await detector.detectAndCreateSignatureFields();
setSignatureFields(fields);
+ // 처리 완료 표시
+ if (documentId) {
+ processedDocumentRef.current = documentId;
+ }
+
if (fields.length > 0) {
const hasSimpleField = fields.some(field => field.startsWith('simple_signature_'));
if (hasSimpleField) {
+ const positionMessage = mode === 'buyer'
+ ? "마지막 페이지 왼쪽 하단의 파란색 영역에서 서명해주세요."
+ : "마지막 페이지 하단의 파란색 영역에서 서명해주세요.";
+
toast.success("📝 서명 필드가 생성되었습니다.", {
- description: "마지막 페이지 하단의 파란색 영역에서 서명해주세요.",
+ description: positionMessage,
icon: <FileSignature className="h-4 w-4 text-blue-500" />,
duration: 5000
});
- } else {
- toast.success(`📋 ${fields.length}개의 서명 필드를 확인했습니다.`, {
- description: "기존 서명 필드가 발견되었습니다.",
- icon: <CheckCircle2 className="h-4 w-4 text-green-500" />,
- duration: 4000
- });
}
}
} catch (error) {
console.error("📛 서명 필드 처리 실패:", error);
-
const errorMessage = error instanceof Error ? error.message : "서명 필드 처리에 실패했습니다.";
setError(errorMessage);
@@ -289,27 +311,53 @@ function useAutoSignatureFields(instance: WebViewerInstance | null) {
}, 3000);
};
- documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded);
+ // 핸들러 참조 저장
+ handlerRef.current = handleDocumentLoaded;
+
+ // 이전 리스너 제거 (저장된 참조 사용)
+ if (handlerRef.current) {
+ documentViewer.removeEventListener('documentLoaded', handlerRef.current);
+ }
+
+ // 새 리스너 등록
documentViewer.addEventListener('documentLoaded', handleDocumentLoaded);
+ // 이미 문서가 로드되어 있다면 즉시 실행
+ if (documentViewer.getDocument()) {
+ // 짧은 지연 후 실행 (WebViewer 초기화 완료 보장)
+ setTimeout(() => {
+ if (!processingRef.current) {
+ handleDocumentLoaded();
+ }
+ }, 1000);
+ }
+
return () => {
- documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded);
+ // 클리어 시 저장된 참조 사용
+ if (handlerRef.current) {
+ documentViewer.removeEventListener('documentLoaded', handlerRef.current);
+ }
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
+ // 상태 리셋
processingRef.current = false;
+ processedDocumentRef.current = null;
+ handlerRef.current = null;
};
- }, [instance]);
+ }, [instance, mode]); // mode 의존성 추가
+ // 컴포넌트 언마운트 시 정리
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
processingRef.current = false;
+ processedDocumentRef.current = null;
};
}, []);
@@ -320,198 +368,121 @@ function useAutoSignatureFields(instance: WebViewerInstance | null) {
error
};
}
-
-// 🔥 서명 감지를 위한 커스텀 훅 수정
+// XFDF 기반 서명 감지
function useSignatureDetection(instance: WebViewerInstance | null, onSignatureComplete?: () => void) {
const [hasValidSignature, setHasValidSignature] = useState(false);
- const checkIntervalRef = useRef<NodeJS.Timeout | null>(null);
- const lastSignatureStateRef = useRef(false);
- const onSignatureCompleteRef = useRef(onSignatureComplete);
+ const onCompleteRef = useRef(onSignatureComplete);
- // 콜백 레퍼런스 업데이트
useEffect(() => {
- onSignatureCompleteRef.current = onSignatureComplete;
+ onCompleteRef.current = onSignatureComplete
}, [onSignatureComplete]);
- const checkSignatureFields = useCallback(async () => {
- if (!instance?.Core?.annotationManager) {
- console.log('🔍 서명 체크: annotationManager 없음');
- return false;
- }
-
- try {
- const { annotationManager, documentViewer } = instance.Core;
-
- // 문서가 로드되지 않았으면 false 반환
- if (!documentViewer.getDocument()) {
- console.log('🔍 서명 체크: 문서 미로드');
- return false;
- }
+ useEffect(() => {
+ if (!instance?.Core) return;
- let hasSignature = false;
+ const { annotationManager, documentViewer } = instance.Core;
+ const checkSignedByAppearance = () => {
+ try {
+ const annotations = annotationManager.getAnnotationsList();
+ const signatureWidgets = annotations.filter(
+ annot => annot instanceof instance.Core.Annotations.SignatureWidgetAnnotation
+ );
- // 1. Form Fields 확인 (더 정확한 방법)
- const fieldManager = annotationManager.getFieldManager();
- const fields = fieldManager.getFields();
-
- console.log('🔍 폼 필드 확인:', fields.map(field => ({
- name: field.name,
- type: field.type,
- value: field.value,
- hasValue: !!field.value
- })));
-
- // 서명 필드 확인
- for (const field of fields) {
- // PDFTron에서 서명 필드는 보통 'Sig' 타입이지만, 값이 있는지 정확히 확인
- if (field.type === 'Sig' || field.name?.toLowerCase().includes('signature')) {
- if (field.value && (
- typeof field.value === 'string' && field.value.length > 0 ||
- typeof field.value === 'object' && field.value !== null
- )) {
- hasSignature = true;
- console.log('🔍 서명 필드에서 서명 발견:', field.name, field.value);
- break;
- }
+ if (signatureWidgets.length === 0) {
+ return false;
}
- }
- // 2. Signature Widget Annotations 확인
- if (!hasSignature) {
- const annotations = annotationManager.getAnnotationsList();
- console.log('🔍 주석 확인:', annotations.length, '개');
-
- for (const annotation of annotations) {
- // SignatureWidgetAnnotation 타입 확인
- if (annotation.elementName === 'signatureWidget' ||
- annotation.constructor.name === 'SignatureWidgetAnnotation' ||
- annotation.Subject === 'Signature') {
-
- // 서명 데이터가 있는지 확인
- const hasSignatureData = annotation.getImageData && annotation.getImageData() ||
- annotation.getPath && annotation.getPath() ||
- annotation.getCustomData && annotation.getCustomData('signature-data');
-
- if (hasSignatureData) {
- hasSignature = true;
- console.log('🔍 서명 위젯에서 서명 발견:', annotation);
- break;
- }
+ for (const widget of signatureWidgets) {
+ const isSignedByAppearance = widget.isSignedByAppearance();
+
+ if (isSignedByAppearance) {
+ return true;
}
}
+ return false;
+
+ } catch (error) {
+ console.error('서명 위젯 확인 중 오류:', error);
+ return false;
}
+ };
- // 3. Ink/FreeHand Annotations 확인 (직접 그린 서명)
- if (!hasSignature) {
- const annotations = annotationManager.getAnnotationsList();
-
- for (const annotation of annotations) {
- if (annotation.elementName === 'freeHand' ||
- annotation.elementName === 'ink' ||
- annotation.constructor.name === 'FreeHandAnnotation') {
-
- // 경로 데이터가 있으면 서명으로 간주
- const hasPath = annotation.getPath && annotation.getPath().length > 0;
- if (hasPath) {
- hasSignature = true;
- console.log('🔍 자유 그리기에서 서명 발견:', annotation);
- break;
- }
+ const checkSigned = async () => {
+ try {
+ const hasSignature = await checkSignedByAppearance();
+
+ if (hasSignature !== hasValidSignature) {
+ setHasValidSignature(hasSignature);
+
+ if (hasSignature && onCompleteRef.current) {
+ onCompleteRef.current();
}
}
+
+ } catch (error) {
+ console.error('서명 확인 중 오류:', error);
}
+ };
- console.log('🔍 최종 서명 감지 결과:', {
- hasSignature,
- fieldsCount: fields.length,
- annotationsCount: annotationManager.getAnnotationsList().length
- });
-
- return hasSignature;
- } catch (error) {
- console.error('📛 서명 확인 중 에러:', error);
- return false;
- }
- }, [instance]);
+ const onAnnotationChanged = (annotations: any[], action: string) => {
+ console.log("서명 변경")
+ // if (action === 'delete') return;
- // 실시간 서명 감지 (무한 렌더링 방지)
- useEffect(() => {
- if (!instance?.Core) return;
+ setTimeout(checkSigned, 800);
+ };
- const startMonitoring = () => {
- // 기존 인터벌 정리
- if (checkIntervalRef.current) {
- clearInterval(checkIntervalRef.current);
- checkIntervalRef.current = null;
- }
- console.log('🔍 서명 모니터링 시작');
- // 2초마다 서명 상태 확인 (1초보다 간격을 늘려 성능 개선)
- checkIntervalRef.current = setInterval(async () => {
- try {
- const hasSignature = await checkSignatureFields();
-
- // 상태가 실제로 변경되었을 때만 업데이트
- if (hasSignature !== lastSignatureStateRef.current) {
- console.log('🔍 서명 상태 변경:', lastSignatureStateRef.current, '->', hasSignature);
-
- lastSignatureStateRef.current = hasSignature;
- setHasValidSignature(hasSignature);
-
- // 서명이 완료되었을 때 콜백 실행
- if (hasSignature && onSignatureCompleteRef.current) {
- console.log('✍️ 서명 완료 콜백 실행!');
- onSignatureCompleteRef.current();
- }
- }
- } catch (error) {
- console.error('📛 서명 모니터링 에러:', error);
- }
- }, 2000);
+ const onDocumentLoaded = () => {
+ setTimeout(checkSigned, 2000);
};
- // 문서 로드 후 모니터링 시작
- const { documentViewer } = instance.Core;
-
- const handleDocumentLoaded = () => {
- console.log('📄 문서 로드 완료, 서명 모니터링 준비');
- // 문서 로드 후 3초 뒤에 모니터링 시작 (안정성 확보)
- setTimeout(startMonitoring, 3000);
+ const onPageUpdated = () => {
+ setTimeout(checkSigned, 1000);
};
- if (documentViewer?.getDocument()) {
- // 이미 문서가 로드되어 있다면 바로 시작
- setTimeout(startMonitoring, 1000);
- } else {
- // 문서 로드 대기
- documentViewer?.addEventListener('documentLoaded', handleDocumentLoaded);
+ const onAnnotationSelected = () => {
+ setTimeout(checkSigned, 500);
+ };
+
+ const onAnnotationUnselected = () => {
+ setTimeout(checkSigned, 1000);
+ };
+
+ try {
+ annotationManager.addEventListener('annotationChanged', onAnnotationChanged);
+ annotationManager.addEventListener('annotationSelected', onAnnotationSelected);
+ annotationManager.addEventListener('annotationUnselected', onAnnotationUnselected);
+ documentViewer.addEventListener('documentLoaded', onDocumentLoaded);
+ documentViewer.addEventListener('pageNumberUpdated', onPageUpdated);
+
+ } catch (error) {
+ console.error('이벤트 리스너 등록 실패:', error);
}
- // 클리너 함수
+ if (documentViewer.getDocument()) {
+ setTimeout(checkSigned, 1000);
+ }
+
+ const pollInterval = setInterval(() => {
+ checkSigned();
+ }, 5000);
+
return () => {
- console.log('🧹 서명 모니터링 정리');
- if (checkIntervalRef.current) {
- clearInterval(checkIntervalRef.current);
- checkIntervalRef.current = null;
+ try {
+ annotationManager.removeEventListener('annotationChanged', onAnnotationChanged);
+ annotationManager.removeEventListener('annotationSelected', onAnnotationSelected);
+ annotationManager.removeEventListener('annotationUnselected', onAnnotationUnselected);
+ documentViewer.removeEventListener('documentLoaded', onDocumentLoaded);
+ documentViewer.removeEventListener('pageNumberUpdated', onPageUpdated);
+ clearInterval(pollInterval);
+ } catch (error) {
+ console.error('클린업 중 오류:', error);
}
- documentViewer?.removeEventListener('documentLoaded', handleDocumentLoaded);
};
- }, [instance]); // onSignatureComplete 제거하여 무한 렌더링 방지
+ }, [instance, hasValidSignature]);
- // 수동 서명 확인 함수
- const manualCheckSignature = useCallback(async () => {
- console.log('🔍 수동 서명 확인 요청');
- const hasSignature = await checkSignatureFields();
- setHasValidSignature(hasSignature);
- lastSignatureStateRef.current = hasSignature;
- return hasSignature;
- }, [checkSignatureFields]);
-
- return {
- hasValidSignature,
- checkSignature: manualCheckSignature
- };
+ return { hasValidSignature };
}
export function BasicContractSignViewer({
@@ -524,8 +495,10 @@ export function BasicContractSignViewer({
onSign,
instance,
setInstance,
- onSurveyComplete, // 🔥 추가
- onSignatureComplete, // 🔥 추가
+ onSurveyComplete,
+ onSignatureComplete,
+ onGtcCommentStatusChange,
+ mode = 'vendor', // 기본값 vendor
t = (key: string) => key,
}: BasicContractSignViewerProps) {
@@ -535,7 +508,9 @@ export function BasicContractSignViewer({
const [isInitialLoaded, setIsInitialLoaded] = useState<boolean>(false);
const [surveyTemplate, setSurveyTemplate] = useState<SurveyTemplateWithQuestions | null>(null);
const [surveyLoading, setSurveyLoading] = useState<boolean>(false);
- const [isSubmitting, setIsSubmitting] = useState(false); // 제출 상태 추가
+ const [gtcCommentStatus, setGtcCommentStatus] = useState<{ hasComments: boolean; commentCount: number }>({ hasComments: false, commentCount: 0 });
+
+ console.log(surveyTemplate, "surveyTemplate")
const conditionalHandler = useConditionalSurvey(surveyTemplate);
@@ -546,13 +521,15 @@ export function BasicContractSignViewer({
const [showDialog, setShowDialog] = useState(isOpen);
const webViewerInstance = useRef<WebViewerInstance | null>(null);
- const { signatureFields, isProcessing: isAutoSignProcessing, hasSignatureFields, error: autoSignError } = useAutoSignatureFields(webViewerInstance.current || instance);
-
- // 🔥 서명 감지 훅 사용
+ // mode 전달
+ const { signatureFields, isProcessing: isAutoSignProcessing, hasSignatureFields, error: autoSignError } = useAutoSignatureFields(webViewerInstance.current || instance, mode);
+
const { hasValidSignature } = useSignatureDetection(webViewerInstance.current || instance, onSignatureComplete);
- const isComplianceTemplate = templateName.includes('준법');
- const isNDATemplate = templateName.includes('비밀유지') || templateName.includes('NDA');
+ // 구매자 모드일 때는 템플릿 관련 로직 비활성화
+ const isComplianceTemplate = mode === 'buyer' ? false : templateName.includes('준법');
+ const isNDATemplate = mode === 'buyer' ? false : (templateName.includes('비밀유지') || templateName.includes('NDA'));
+ const isGTCTemplate = mode === 'buyer' ? false : templateName.includes('GTC');
const allFiles: FileInfo[] = React.useMemo(() => {
const files: FileInfo[] = [];
@@ -565,6 +542,11 @@ export function BasicContractSignViewer({
});
}
+ // 구매자 모드일 때는 추가 파일, 설문조사, 조항 검토 탭 제외
+ if (mode === 'buyer') {
+ return files; // 메인 계약서 파일만 반환
+ }
+
const normalizedAttachments: FileInfo[] = (additionalFiles || [])
.map((f: any, idx: number) => ({
path: f.path ?? f.filePath ?? "",
@@ -583,8 +565,16 @@ export function BasicContractSignViewer({
});
}
+ if (isGTCTemplate) {
+ files.push({
+ path: "",
+ name: "조항 검토",
+ type: "clauses",
+ });
+ }
+
return files;
- }, [filePath, additionalFiles, templateName, isComplianceTemplate]);
+ }, [filePath, additionalFiles, templateName, isComplianceTemplate, isGTCTemplate, mode]);
const cleanupHtmlStyle = () => {
const elements = document.querySelectorAll('.Document_container');
@@ -623,7 +613,8 @@ export function BasicContractSignViewer({
useEffect(() => {
setShowDialog(isOpen);
- if (isOpen && isComplianceTemplate && !surveyTemplate) {
+ // 구매자 모드가 아닐 때만 설문조사 템플릿 로드
+ if (isOpen && isComplianceTemplate && !surveyTemplate && mode !== 'buyer') {
loadSurveyTemplate();
}
@@ -631,7 +622,7 @@ export function BasicContractSignViewer({
setIsInitialLoaded(false);
currentDocumentPath.current = "";
}
- }, [isOpen, isComplianceTemplate]);
+ }, [isOpen, isComplianceTemplate, mode]);
useEffect(() => {
if (!filePath) return;
@@ -709,7 +700,9 @@ export function BasicContractSignViewer({
setInstance(newInstance);
setFileLoading(false);
- const { documentViewer } = newInstance.Core;
+ const { documentViewer, annotationManager, Annotations } = newInstance.Core;
+
+ const { WidgetFlags } = Annotations;
const FitMode = newInstance.UI.FitMode;
const handleDocumentLoaded = () => {
@@ -730,6 +723,30 @@ export function BasicContractSignViewer({
documentViewer.addEventListener('documentLoaded', handleDocumentLoaded);
+ // 구매자 모드가 아닐 때만 자동 서명 적용
+ if (mode !== 'buyer') {
+ annotationManager.addEventListener('annotationChanged', async (annotList, type) => {
+ for (const annot of annotList) {
+ const { fieldName, X, Y, Width, Height, PageNumber } = annot;
+
+ if (type === "add" && annot.Subject === "Widget") {
+ const signatureImage = await getVendorSignatureFile()
+
+ const stamp = new Annotations.StampAnnotation();
+ stamp.PageNumber = PageNumber;
+ stamp.X = X;
+ stamp.Y = Y;
+ stamp.Width = Width;
+ stamp.Height = Height;
+
+ await stamp.setImageData(signatureImage.data.dataUrl);
+ annot.sign(stamp);
+ annot.setFieldFlag(WidgetFlags.READ_ONLY, true);
+ }
+ }
+ });
+ }
+
newInstance.UI.setMinZoomLevel('25%');
newInstance.UI.setMaxZoomLevel('400%');
@@ -789,7 +806,7 @@ export function BasicContractSignViewer({
isCancelled.current = true;
cleanupWebViewer();
};
- }, [setInstance]);
+ }, [setInstance, mode]);
const getExtFromPath = (p: string) => {
const m = p.toLowerCase().match(/\.([a-z0-9]+)(?:\?.*)?$/);
@@ -873,17 +890,21 @@ export function BasicContractSignViewer({
const handleTabChange = async (newTab: string) => {
setActiveTab(newTab);
- if (newTab === "survey") return;
+ if (newTab === "survey" || newTab === "clauses") return;
const currentInstance = webViewerInstance.current || instance;
if (!currentInstance || fileLoading) return;
+ if (newTab === 'survey' && !surveyTemplate && !surveyLoading && mode !== 'buyer') {
+ loadSurveyTemplate();
+ }
+
let targetFile: FileInfo | undefined;
if (newTab === "main") {
targetFile = allFiles.find(f => f.type === "main");
} else if (newTab.startsWith("file-")) {
const fileIndex = parseInt(newTab.replace("file-", ""), 10);
- targetFile = allFiles.filter(f => f.type !== 'survey')[fileIndex];
+ targetFile = allFiles.filter(f => f.type !== 'survey' && f.type !== 'clauses')[fileIndex];
}
if (!targetFile?.path) {
@@ -909,7 +930,7 @@ export function BasicContractSignViewer({
documentViewer.updateView();
window.dispatchEvent(new Event("resize"));
} catch (e) {
- console.warn("탭 변경 후 레이아웃 새로고침 스킵:", e);
+ console.warn("탭 변경 후 레이아웃 새고침 스킵:", e);
}
});
} catch (e) {
@@ -965,22 +986,34 @@ export function BasicContractSignViewer({
downloadType: "pdf",
});
- if (isComplianceTemplate && !surveyData.completed) {
- toast.error("준법 설문조사를 먼저 완료해주세요.");
- setActiveTab('survey');
- return;
+ // 구매자 모드일 때는 설문조사와 GTC 검증 건너뛰기
+ if (mode !== 'buyer') {
+ if (isComplianceTemplate && !surveyData.completed) {
+ toast.error("준법 설문조사를 먼저 완료해주세요.");
+ setActiveTab('survey');
+ return;
+ }
+
+ if (isGTCTemplate && gtcCommentStatus.hasComments) {
+ toast.error("GTC 조항에 코멘트가 있어 서명할 수 없습니다.");
+ toast.info("모든 코멘트를 삭제하거나 협의를 완료한 후 서명해주세요.");
+ setActiveTab('clauses');
+ return;
+ }
}
if (onSign) {
await onSign(documentData, { formData, surveyData, signatureFields });
} else {
- toast.success("계약서가 성공적으로 서명되었습니다.");
+ const actionText = mode === 'buyer' ? "최종승인" : "서명";
+ toast.success(`계약서가 성공적으로 ${actionText}되었습니다.`);
}
handleClose();
} catch (error) {
- console.error("📛 서명 저장 실패:", error);
- toast.error("서명을 저장하는데 실패했습니다.");
+ console.error(`📛 ${mode === 'buyer' ? '최종승인' : '서명'} 저장 실패:`, error);
+ const actionText = mode === 'buyer' ? "최종승인" : "서명";
+ toast.error(`${actionText}을 저장하는데 실패했습니다.`);
}
};
@@ -992,842 +1025,7 @@ export function BasicContractSignViewer({
}
};
- // 개선된 SurveyComponent
- const SurveyComponent = () => {
- const {
- control,
- watch,
- setValue,
- getValues,
- formState: { errors },
- trigger,
- } = useForm<SurveyFormData>({
- defaultValues: {},
- mode: 'onChange'
- });
-
- const watchedValues = watch();
- const [uploadedFiles, setUploadedFiles] = useState<Record<number, File[]>>({});
-
- // 📊 실시간 진행 상태 계산
- const progressStatus = useMemo(() => {
- if (!conditionalHandler || !surveyTemplate) {
- return {
- visibleQuestions: [],
- totalRequired: 0,
- completedRequired: 0,
- completedQuestionIds: [],
- incompleteQuestionIds: [],
- progressPercentage: 0,
- debugInfo: {}
- };
- }
-
- console.log('🔄 실시간 프로그레스 재계산 중...');
- console.log('📝 원본 watchedValues:', watchedValues);
-
- // 현재 답변을 조건부 핸들러가 인식할 수 있는 형태로 변환
- const convertedAnswers: Record<number, any> = {};
- Object.entries(watchedValues).forEach(([questionId, value]) => {
- const id = parseInt(questionId);
- const convertedValue = {
- questionId: id,
- answerValue: value?.answerValue || '',
- detailText: value?.detailText || '',
- otherText: value?.otherText || '',
- files: value?.files || []
- };
-
- convertedAnswers[id] = convertedValue;
-
- // 각 질문의 변환 과정 로그
- if (value?.answerValue) {
- console.log(`📝 질문 ${id} 변환:`, {
- 원본: value,
- 변환후: convertedValue
- });
- }
- });
-
- console.log('📝 변환된 답변들 최종:', convertedAnswers);
-
- const result = conditionalHandler.getSimpleProgressStatus(convertedAnswers);
-
- console.log('📊 실시간 진행 상태 최종 결과:', {
- 전체표시질문: result.visibleQuestions.length,
- 필수질문수: result.totalRequired,
- 완료된필수질문: result.completedRequired,
- 진행률: result.progressPercentage,
- 완료된질문들: result.completedQuestionIds,
- 미완료질문들: result.incompleteQuestionIds,
- 기본질문: result.visibleQuestions.filter(q => !q.parentQuestionId).length,
- 조건부질문: result.visibleQuestions.filter(q => q.parentQuestionId).length,
- 완료된기본질문: result.completedQuestionIds.filter(id => !result.visibleQuestions.find(q => q.id === id)?.parentQuestionId).length,
- 완료된조건부질문: result.completedQuestionIds.filter(id => !!result.visibleQuestions.find(q => q.id === id)?.parentQuestionId).length
- });
-
- // 🚨 조건부 질문들의 답변 상태 특별 점검
- const conditionalQuestions = result.visibleQuestions.filter(q => q.parentQuestionId);
- if (conditionalQuestions.length > 0) {
- console.log('🚨 조건부 질문들 답변 상태 점검:', conditionalQuestions.map(q => ({
- id: q.id,
- questionNumber: q.questionNumber,
- isRequired: q.isRequired,
- parentId: q.parentQuestionId,
- watchedValue: watchedValues[q.id],
- convertedAnswer: convertedAnswers[q.id],
- hasWatchedAnswer: !!watchedValues[q.id]?.answerValue,
- hasConvertedAnswer: !!convertedAnswers[q.id]?.answerValue,
- isInRequiredList: result.totalRequired,
- isCompleted: result.completedQuestionIds.includes(q.id)
- })));
- }
-
- return result;
- }, [conditionalHandler, watchedValues, surveyTemplate]);
-
- // 🎯 동적 상태 정보
- const visibleQuestions = progressStatus.visibleQuestions;
- const totalVisibleQuestions = visibleQuestions.length;
- const baseQuestionCount = surveyTemplate?.questions.length || 0;
- const conditionalQuestionCount = totalVisibleQuestions - baseQuestionCount;
- const hasConditionalQuestions = conditionalQuestionCount > 0;
-
- // ✅ 완료 가능 여부
- const canComplete = progressStatus.totalRequired > 0 &&
- progressStatus.completedRequired === progressStatus.totalRequired;
-
- if (surveyLoading) {
- return (
- <div className="h-full w-full">
- <Card className="h-full">
- <CardContent className="flex flex-col items-center justify-center h-full py-12">
- <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
- <p className="text-sm text-muted-foreground">설문조사를 불러오는 중...</p>
- </CardContent>
- </Card>
- </div>
- );
- }
-
- if (!surveyTemplate) {
- return (
- <div className="h-full w-full">
- <Card className="h-full">
- <CardContent className="flex flex-col items-center justify-center h-full py-12">
- <AlertTriangle className="h-8 w-8 text-red-500 mb-4" />
- <p className="text-sm text-muted-foreground">설문조사 템플릿을 불러올 수 없습니다.</p>
- <Button
- variant="outline"
- onClick={loadSurveyTemplate}
- className="mt-2"
- >
- 다시 시도
- </Button>
- </CardContent>
- </Card>
- </div>
- );
- }
-
- // 🚨 템플릿이 로드되면 모든 질문들의 isRequired 속성 확인
- React.useEffect(() => {
- if (surveyTemplate && surveyTemplate.questions) {
- console.log('🚨 설문 템플릿의 모든 질문들 isRequired 속성 확인:', surveyTemplate.questions.map(q => ({
- id: q.id,
- questionNumber: q.questionNumber,
- questionText: q.questionText?.substring(0, 30) + '...',
- isRequired: q.isRequired,
- parentQuestionId: q.parentQuestionId,
- conditionalValue: q.conditionalValue,
- isConditional: !!q.parentQuestionId
- })));
-
- const allQuestions = surveyTemplate.questions.length;
- const requiredQuestions = surveyTemplate.questions.filter(q => q.isRequired).length;
- const conditionalQuestions = surveyTemplate.questions.filter(q => q.parentQuestionId).length;
- const requiredConditionalQuestions = surveyTemplate.questions.filter(q => q.parentQuestionId && q.isRequired).length;
-
- console.log('📊 템플릿 질문 통계:', {
- 전체질문수: allQuestions,
- 전체필수질문수: requiredQuestions,
- 조건부질문수: conditionalQuestions,
- 필수조건부질문수: requiredConditionalQuestions,
- 기본질문수: allQuestions - conditionalQuestions,
- 필수기본질문수: requiredQuestions - requiredConditionalQuestions
- });
-
- // 🚨 만약 조건부 질문들이 필수가 아니라면 경고
- if (conditionalQuestions > 0 && requiredConditionalQuestions === 0) {
- console.warn('⚠️ 경고: 조건부 질문들이 모두 필수가 아닙니다! 데이터베이스 확인 필요');
- console.warn('조건부 질문들:', surveyTemplate.questions.filter(q => q.parentQuestionId));
- }
- }
- }, [surveyTemplate]);
-
- const handleFileUpload = useCallback((questionId: number, files: FileList | null) => {
- if (!files) return;
-
- const fileArray = Array.from(files);
- setUploadedFiles(prev => ({
- ...prev,
- [questionId]: fileArray
- }));
-
- setValue(`${questionId}.files`, fileArray);
- }, [setValue]);
-
- const handleAnswerChange = useCallback((questionId: number, field: string, value: any) => {
- console.log(`📝 답변 변경: 질문 ${questionId}, 필드 ${field}, 값:`, value);
-
- // 해당 질문이 조건부 질문인지 확인
- const question = visibleQuestions.find(q => q.id === questionId);
- if (question) {
- console.log(`📋 질문 ${questionId} 상세 정보:`, {
- id: question.id,
- questionNumber: question.questionNumber,
- isRequired: question.isRequired,
- parentQuestionId: question.parentQuestionId,
- conditionalValue: question.conditionalValue,
- isConditional: !!question.parentQuestionId
- });
- }
-
- setValue(`${questionId}.${field}`, value);
-
- // setValue 후 현재 값 확인
- setTimeout(() => {
- const currentFormValues = getValues();
- console.log(`✅ setValue 후 확인 - 질문 ${questionId}:`, {
- 설정한값: value,
- 저장된전체값: currentFormValues[questionId],
- 전체폼값: currentFormValues
- });
- }, 0);
-
- // 부모 질문의 답변이 변경되면 조건부 자식 질문들 처리
- if (field === 'answerValue' && conditionalHandler) {
- const currentValues = getValues();
- const convertedAnswers: Record<number, any> = {};
-
- Object.entries(currentValues).forEach(([qId, qValue]) => {
- const id = parseInt(qId);
- convertedAnswers[id] = {
- questionId: id,
- answerValue: qValue?.answerValue || '',
- detailText: qValue?.detailText || '',
- otherText: qValue?.otherText || '',
- files: qValue?.files || []
- };
- });
-
- // 새로운 답변 반영
- convertedAnswers[questionId] = {
- ...convertedAnswers[questionId],
- questionId,
- [field]: value
- };
-
- console.log(`🔄 질문 ${questionId}의 답변 변경으로 인한 조건부 질문 처리...`);
- console.log(`🔄 변경 후 전체 답변:`, convertedAnswers);
-
- // 영향받는 자식 질문들의 답변 초기화
- const clearedAnswers = conditionalHandler.clearAffectedChildAnswers(questionId, value, convertedAnswers);
-
- console.log(`🧹 정리된 답변들:`, clearedAnswers);
-
- // 삭제된 답변들을 폼에서도 제거
- Object.keys(convertedAnswers).forEach(qId => {
- const id = parseInt(qId);
- if (id !== questionId && !clearedAnswers[id]) {
- console.log(`🗑️ 질문 ${id} 답변 초기화`);
- setValue(`${id}`, {
- answerValue: '',
- detailText: '',
- otherText: '',
- files: []
- });
-
- // 업로드된 파일도 초기화
- setUploadedFiles(prev => {
- const updated = { ...prev };
- delete updated[id];
- return updated;
- });
- }
- });
- }
- }, [setValue, getValues, conditionalHandler, visibleQuestions]);
-
- // 🔥 설문조사 완료 핸들러 수정
- const handleSurveyComplete = useCallback(async () => {
- console.log('🎯 설문조사 완료 시도');
-
- // 이미 제출 중이면 중복 실행 방지
- if (isSubmitting) {
- console.log('⚠️ 이미 제출 중...');
- return;
- }
-
- setIsSubmitting(true);
-
- try {
- const currentValues = getValues();
- console.log('📝 현재 폼 값들:', currentValues);
-
- // 폼 검증
- const isValid = await trigger();
- console.log('🔍 폼 검증 결과:', isValid);
-
- // 진행 상태 최종 확인
- console.log('📊 최종 진행 상태:', {
- totalRequired: progressStatus.totalRequired,
- completedRequired: progressStatus.completedRequired,
- canComplete,
- 완료된질문들: progressStatus.completedQuestionIds,
- 미완료질문들: progressStatus.incompleteQuestionIds
- });
-
- if (!canComplete) {
- let errorMessage = '모든 필수 항목을 완료해주세요.';
- let errorDescription = `${progressStatus.completedRequired}/${progressStatus.totalRequired} 완료됨`;
-
- // 구체적인 미완료 이유 표시
- if (progressStatus.incompleteQuestionIds.length > 0) {
- const incompleteReasons = progressStatus.incompleteQuestionIds.map(id => {
- const debug = progressStatus.debugInfo?.[id];
- const question = visibleQuestions.find(q => q.id === id);
- return `• Q${question?.questionNumber}: ${question?.questionText?.substring(0, 40)}...\n → ${debug?.incompleteReason || '답변 필요'}`;
- }).slice(0, 3);
-
- errorDescription = incompleteReasons.join('\n\n');
-
- if (progressStatus.incompleteQuestionIds.length > 3) {
- errorDescription += `\n\n... 외 ${progressStatus.incompleteQuestionIds.length - 3}개 항목`;
- }
- }
-
- toast.error(errorMessage, {
- description: errorDescription,
- icon: <AlertTriangle className="h-5 w-5 text-red-500" />,
- duration: 12000
- });
-
- // 첫 번째 미완료 질문으로 스크롤
- if (progressStatus.incompleteQuestionIds.length > 0) {
- const firstIncompleteId = progressStatus.incompleteQuestionIds[0];
- const element = document.getElementById(`question-${firstIncompleteId}`);
- if (element) {
- element.scrollIntoView({ behavior: 'smooth', block: 'center' });
- }
- }
-
- return;
- }
-
- // 필수 데이터 확인
- if (!contractId || !surveyTemplate?.id) {
- toast.error('계약서 정보 또는 설문 템플릿 정보가 없습니다.');
- return;
- }
-
- // 서버 액션에 전달할 데이터 준비
- const surveyAnswers: SurveyAnswerData[] = Object.entries(currentValues)
- .map(([questionId, value]) => ({
- questionId: parseInt(questionId),
- answerValue: value?.answerValue || '',
- detailText: value?.detailText || '',
- otherText: value?.otherText || '',
- files: value?.files || []
- }))
- .filter(answer =>
- // 빈 답변 필터링 (하지만 필수 질문의 답변이 완료되었음을 이미 확인했음)
- answer.answerValue || answer.detailText || (answer.files && answer.files.length > 0)
- );
-
- const requestData: CompleteSurveyRequest = {
- contractId: contractId,
- templateId: surveyTemplate.id,
- answers: surveyAnswers,
- progressStatus: progressStatus // 디버깅용 추가 정보
- };
-
- console.log('📤 서버로 전송할 데이터:', {
- contractId: requestData.contractId,
- templateId: requestData.templateId,
- answersCount: requestData.answers.length,
- answers: requestData.answers.map(a => ({
- questionId: a.questionId,
- hasAnswer: !!a.answerValue,
- hasDetail: !!a.detailText,
- hasOther: !!a.otherText,
- filesCount: a.files?.length || 0
- }))
- });
-
- // 제출 중 토스트 표시
- const submitToast = toast.loading('설문조사를 저장하는 중...', {
- description: '잠시만 기다려주세요.',
- duration: Infinity
- });
-
- // 서버 액션 호출
- const result = await completeSurvey(requestData);
-
- // 로딩 토스트 제거
- toast.dismiss(submitToast);
-
- if (result.success) {
- // 클라이언트 상태 업데이트 (기존 로직 유지)
- setSurveyData({
- completed: true,
- answers: surveyAnswers,
- timestamp: new Date().toISOString(),
- progressStatus: progressStatus,
- totalQuestions: totalVisibleQuestions,
- conditionalQuestions: conditionalQuestionCount,
- responseId: result.data?.responseId // 서버에서 반환된 응답 ID 저장
- });
-
- // 🔥 부모 컴포넌트에 설문조사 완료 알림
- if (onSurveyComplete) {
- onSurveyComplete();
- }
-
- toast.success("🎉 설문조사가 완료되었습니다!", {
- description: `총 ${progressStatus.totalRequired}개 필수 질문 완료${hasConditionalQuestions ? ` (조건부 ${conditionalQuestionCount}개 포함)` : ''}`,
- icon: <CheckCircle2 className="h-5 w-5 text-green-500" />,
- duration: 5000
- });
-
- console.log('✅ 설문조사 완료:', {
- totalAnswered: surveyAnswers.length,
- totalRequired: progressStatus.totalRequired,
- conditionalQuestions: conditionalQuestionCount,
- responseId: result.data?.responseId
- });
-
- // 자동으로 메인 탭으로 이동 (선택사항)
- setTimeout(() => {
- setActiveTab('main');
- }, 2000);
-
- } else {
- // 서버 에러 처리
- console.error('❌ 서버 응답 에러:', result.message);
- toast.error('설문조사 저장 실패', {
- description: result.message || '서버에서 오류가 발생했습니다.',
- icon: <AlertTriangle className="h-5 w-5 text-red-500" />,
- duration: 8000
- });
- }
-
- } catch (error) {
- console.error('❌ 설문조사 저장 중 예외 발생:', error);
-
- let errorMessage = '설문조사 저장에 실패했습니다.';
- let errorDescription = '네트워크 연결을 확인하고 다시 시도해주세요.';
-
- if (error instanceof Error) {
- errorDescription = error.message;
- }
-
- toast.error(errorMessage, {
- description: errorDescription,
- icon: <AlertTriangle className="h-5 w-5 text-red-500" />,
- duration: 10000
- });
- } finally {
- setIsSubmitting(false);
- }
- }, [
- getValues,
- trigger,
- progressStatus,
- visibleQuestions,
- canComplete,
- contractId,
- surveyTemplate?.id,
- totalVisibleQuestions,
- conditionalQuestionCount,
- hasConditionalQuestions,
- isSubmitting,
- setActiveTab,
- onSurveyComplete // 🔥 추가
- ]);
-
- // OTHER 텍스트 입력 컴포넌트
- const OtherTextInput = ({ questionId, fieldName }: { questionId: number; fieldName: string }) => {
- const answerValue = useWatch({
- control,
- name: `${fieldName}.answerValue`
- });
-
- if (answerValue !== 'OTHER') return null;
-
- return (
- <Controller
- name={`${fieldName}.otherText`}
- control={control}
- render={({ field }) => (
- <Input
- {...field}
- placeholder="기타 내용을 입력해주세요"
- className="mt-2"
- />
- )}
- />
- );
- };
-
- return (
- <div className="h-full w-full flex flex-col">
- <Card className="h-full flex flex-col">
- <CardHeader className="flex-shrink-0">
- <CardTitle className="flex items-center justify-between">
- <div className="flex items-center">
- <ClipboardList className="h-5 w-5 mr-2 text-amber-500" />
- {surveyTemplate.name}
- {conditionalHandler && (
- <Badge variant="outline" className="ml-2 text-xs">
- 조건부 질문 지원
- </Badge>
- )}
- </div>
- <div className="text-sm text-gray-600">
- {progressStatus.completedRequired}/{progressStatus.totalRequired} 완료
- </div>
- </CardTitle>
-
- <CardDescription>
- {surveyTemplate.description}
-
- {/* 🎯 동적 질문 수 표시 */}
- <div className="mt-2 space-y-1">
- <div className="flex items-center text-sm">
- <span className="text-gray-600">
- 📋 총 {totalVisibleQuestions}개 질문
- {hasConditionalQuestions && (
- <span className="text-blue-600 ml-1">
- (기본 {baseQuestionCount}개 + 조건부 {conditionalQuestionCount}개)
- </span>
- )}
- </span>
- </div>
-
- {hasConditionalQuestions && (
- <div className="text-blue-600 text-sm">
- ⚡ 답변에 따라 {conditionalQuestionCount}개 추가 질문이 나타났습니다
- </div>
- )}
- </div>
- </CardDescription>
-
- {/* 📊 동적 프로그레스 바 */}
- <div className="space-y-2">
- <div className="flex justify-between text-xs text-gray-600">
- <span>필수 질문 진행률</span>
- <span>
- {Math.round(progressStatus.progressPercentage)}%
- {hasConditionalQuestions && (
- <span className="ml-1 text-blue-600">
- (조건부 포함)
- </span>
- )}
- </span>
- </div>
- <div className="w-full bg-gray-200 rounded-full h-2">
- <div
- className="bg-gradient-to-r from-blue-500 to-blue-600 h-2 rounded-full transition-all duration-500 ease-out"
- style={{ width: `${progressStatus.progressPercentage}%` }}
- />
- </div>
-
- {/* 세부 진행 상황 */}
- {progressStatus.totalRequired > 0 && (
- <div className="text-xs text-gray-500 flex justify-between">
- <span>완료: {progressStatus.completedRequired}개</span>
- <span>남은 필수: {progressStatus.totalRequired - progressStatus.completedRequired}개</span>
- </div>
- )}
- </div>
- </CardHeader>
-
- <CardContent className="flex-1 min-h-0 overflow-y-auto">
- <form onSubmit={(e) => e.preventDefault()} className="space-y-6">
- <div className="p-4 border rounded-lg bg-yellow-50">
- <div className="flex items-start">
- <AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5 mr-2" />
- <div>
- <p className="font-medium text-yellow-800">중요 안내</p>
- <p className="text-sm text-yellow-700 mt-1">
- 본 설문조사는 준법 의무 확인을 위한 필수 절차입니다. 모든 항목을 정확히 작성해주세요.
- {conditionalHandler && (
- <span className="block mt-1">
- ⚡ 답변에 따라 추가 질문이 나타날 수 있으며, 이 경우 모든 추가 질문도 완료해야 합니다.
- </span>
- )}
- </p>
- </div>
- </div>
- </div>
-
- <div className="space-y-4">
- {visibleQuestions.map((question: any) => {
- const fieldName = `${question.id}`;
- const isComplete = progressStatus.completedQuestionIds.includes(question.id);
- const isConditional = !!question.parentQuestionId;
-
- return (
- <div
- key={question.id}
- id={`question-${question.id}`}
- className={`mb-6 p-4 border rounded-lg transition-colors duration-200 ${isConditional
- ? 'bg-blue-50 border-blue-200'
- : 'bg-gray-50 border-gray-200'
- } ${!isComplete && question.isRequired ? 'ring-2 ring-red-100' : ''}`}
- >
- <div className="flex items-start justify-between mb-3">
- <div className="flex-1">
- <Label className="text-sm font-medium text-gray-900 flex items-center flex-wrap gap-2">
- <span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
- Q{question.questionNumber}
- </span>
-
- {isConditional && (
- <span className="px-2 py-1 bg-amber-100 text-amber-700 rounded text-xs">
- ⚡ 조건부 질문
- </span>
- )}
-
- {question.questionType === 'FILE' && (
- <span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">
- 📎 파일 업로드
- </span>
- )}
-
- <div className="w-full mt-1">
- {question.questionText}
- {question.isRequired && <span className="text-red-500 ml-1">*</span>}
- </div>
- </Label>
- </div>
-
- {isComplete && (
- <CheckCircle2 className="h-5 w-5 text-green-500 ml-2 flex-shrink-0" />
- )}
- </div>
-
- {/* 질문 타입별 렌더링 (기존 코드와 동일) */}
- {/* RADIO 타입 */}
- {question.questionType === 'RADIO' && (
- <Controller
- name={`${fieldName}.answerValue`}
- control={control}
- rules={{ required: question.isRequired ? '필수 항목입니다.' : false }}
- render={({ field }) => (
- <RadioGroup
- value={field.value || ''}
- onValueChange={(value) => {
- field.onChange(value);
- handleAnswerChange(question.id, 'answerValue', value);
- }}
- className="space-y-2"
- >
- {question.options?.map((option: any) => (
- <div key={option.id} className="flex items-center space-x-2">
- <RadioGroupItem value={option.optionValue} id={`${question.id}-${option.id}`} />
- <Label htmlFor={`${question.id}-${option.id}`} className="text-sm">
- {option.optionText}
- </Label>
- </div>
- ))}
- </RadioGroup>
- )}
- />
- )}
-
- {/* DROPDOWN 타입 */}
- {question.questionType === 'DROPDOWN' && (
- <div className="space-y-2">
- <Controller
- name={`${fieldName}.answerValue`}
- control={control}
- rules={{ required: question.isRequired ? '필수 항목입니다.' : false }}
- render={({ field }) => (
- <Select
- value={field.value || ''}
- onValueChange={(value) => {
- field.onChange(value);
- handleAnswerChange(question.id, 'answerValue', value);
- }}
- >
- <SelectTrigger>
- <SelectValue placeholder="선택해주세요" />
- </SelectTrigger>
- <SelectContent>
- {question.options?.map((option: any) => (
- <SelectItem key={option.id} value={option.optionValue}>
- {option.optionText}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- )}
- />
-
- <OtherTextInput questionId={question.id} fieldName={fieldName} />
- </div>
- )}
-
- {/* TEXTAREA 타입 */}
- {question.questionType === 'TEXTAREA' && (
- <Controller
- name={`${fieldName}.detailText`}
- control={control}
- rules={{ required: question.isRequired ? '필수 항목입니다.' : false }}
- render={({ field }) => (
- <Textarea
- {...field}
- placeholder="상세한 내용을 입력해주세요"
- rows={4}
- />
- )}
- />
- )}
-
- {/* 상세 텍스트 입력 */}
- {question.hasDetailText && (
- <div className="mt-3">
- <Label className="text-sm text-gray-700 mb-2 block">상세 내용을 기술해주세요:</Label>
- <Controller
- name={`${fieldName}.detailText`}
- control={control}
- rules={{ required: question.isRequired ? '상세 내용을 입력해주세요.' : false }}
- render={({ field }) => (
- <Textarea
- {...field}
- placeholder="상세한 내용을 입력해주세요"
- rows={3}
- className="w-full"
- />
- )}
- />
- </div>
- )}
-
- {/* 파일 업로드 */}
- {(question.hasFileUpload || question.questionType === 'FILE') && (
- <div className="mt-3">
- <Label className="text-sm text-gray-700 mb-2 block">첨부파일:</Label>
- <div className="border-2 border-dashed border-gray-300 rounded-lg p-4">
- <input
- type="file"
- multiple
- onChange={(e) => handleFileUpload(question.id, e.target.files)}
- className="hidden"
- id={`file-${question.id}`}
- />
- <label htmlFor={`file-${question.id}`} className="cursor-pointer">
- <div className="flex flex-col items-center">
- <Upload className="h-8 w-8 text-gray-400 mb-2" />
- <span className="text-sm text-gray-500">파일을 선택하거나 여기에 드래그하세요</span>
- </div>
- </label>
-
- {uploadedFiles[question.id] && uploadedFiles[question.id].length > 0 && (
- <div className="mt-3 space-y-1">
- {uploadedFiles[question.id].map((file, index) => (
- <div key={index} className="flex items-center space-x-2 text-sm">
- <FileText className="h-4 w-4 text-blue-500" />
- <span>{file.name}</span>
- <span className="text-gray-500">({(file.size / 1024).toFixed(1)} KB)</span>
- </div>
- ))}
- </div>
- )}
- </div>
- </div>
- )}
-
- {/* 에러 메시지 */}
- {errors[fieldName] && (
- <p className="mt-2 text-sm text-red-600 flex items-center">
- <AlertTriangle className="h-4 w-4 mr-1" />
- {errors[fieldName]?.answerValue?.message ||
- errors[fieldName]?.detailText?.message ||
- '필수 항목을 완료해주세요.'}
- </p>
- )}
- </div>
- );
- })}
- </div>
-
- {/* ✅ 향상된 완료 버튼 */}
- <div className="flex justify-end pt-6 border-t">
- <div className="flex items-center space-x-4">
- {/* 진행 상황 요약 */}
- <div className="text-sm">
- {canComplete ? (
- <div className="text-green-600 font-medium flex items-center">
- <CheckCircle2 className="h-4 w-4 mr-1" />
- 모든 필수 항목 완료됨
- {hasConditionalQuestions && (
- <span className="ml-2 text-xs text-blue-600">
- (조건부 {conditionalQuestionCount}개 포함)
- </span>
- )}
- </div>
- ) : (
- <div className="space-y-1">
- <div className="flex items-center text-gray-600">
- <AlertTriangle className="h-4 w-4 mr-1 text-red-500" />
- {progressStatus.completedRequired}/{progressStatus.totalRequired} 완료
- </div>
- {hasConditionalQuestions && (
- <div className="text-xs text-blue-600">
- 기본 + 조건부 {conditionalQuestionCount}개 포함
- </div>
- )}
- </div>
- )}
- </div>
-
- <Button
- type="button"
- onClick={handleSurveyComplete}
- disabled={!canComplete || isSubmitting}
- className={`transition-all duration-200 ${canComplete && !isSubmitting
- ? 'bg-green-600 hover:bg-green-700 shadow-lg'
- : 'bg-gray-400 cursor-not-allowed'
- }`}
- >
- {isSubmitting ? (
- <>
- <Loader2 className="h-4 w-4 mr-2 animate-spin" />
- 저장 중...
- </>
- ) : (
- <>
- <CheckCircle2 className="h-4 w-4 mr-2" />
- 설문조사 완료
- </>
- )}
- <span className="ml-1 text-xs">
- ({progressStatus.completedRequired}/{progressStatus.totalRequired})
- </span>
- </Button>
- </div>
- </div>
- </form>
- </CardContent>
- </Card>
- </div>
- );
- };
-
- // 🔥 서명 상태 표시 컴포넌트 개선
+ // 서명 상태 표시 컴포넌트
const SignatureFieldsStatus = () => {
if (!hasSignatureFields && !isAutoSignProcessing && !autoSignError && !hasValidSignature) return null;
@@ -1846,11 +1044,10 @@ export function BasicContractSignViewer({
) : hasSignatureFields ? (
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
<Target className="h-3 w-3 mr-1" />
- {signatureFields.length}개 서명 필드 자동 생성됨
+ {signatureFields.length}개 서명 필드 자동 생성됨 {mode === 'buyer' ? '(왼쪽 하단)' : ''}
</Badge>
) : null}
-
- {/* 🔥 서명 완료 상태 표시 */}
+
{hasValidSignature && (
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200">
<CheckCircle2 className="h-3 w-3 mr-1" />
@@ -1865,7 +1062,8 @@ export function BasicContractSignViewer({
if (!isOpen && !onClose) {
return (
<div className="h-full w-full flex flex-col overflow-hidden">
- {allFiles.length > 1 ? (
+ {/* 구매자 모드에서는 탭 없이 단일 뷰어만 표시 */}
+ {allFiles.length > 1 && mode !== 'buyer' ? (
<Tabs value={activeTab} onValueChange={handleTabChange} className="h-full flex flex-col">
<div className="border-b bg-gray-50 px-3 py-2 flex-shrink-0">
<SignatureFieldsStatus />
@@ -1876,8 +1074,10 @@ export function BasicContractSignViewer({
tabId = 'main';
} else if (file.type === 'survey') {
tabId = 'survey';
+ } else if (file.type === 'clauses') {
+ tabId = 'clauses';
} else {
- const fileOnlyIndex = allFiles.slice(0, index).filter(f => f.type !== 'survey').length;
+ const fileOnlyIndex = allFiles.slice(0, index).filter(f => f.type !== 'survey' && f.type !== 'clauses').length;
tabId = `file-${fileOnlyIndex}`;
}
@@ -1886,6 +1086,8 @@ export function BasicContractSignViewer({
<div className="flex items-center space-x-1">
{file.type === 'survey' ? (
<ClipboardList className="h-3 w-3" />
+ ) : file.type === 'clauses' ? (
+ <BookOpen className="h-3 w-3" />
) : (
<FileText className="h-3 w-3" />
)}
@@ -1893,6 +1095,11 @@ export function BasicContractSignViewer({
{file.type === 'survey' && surveyData.completed && (
<Badge variant="secondary" className="ml-1 h-4 px-1 text-xs">완료</Badge>
)}
+ {file.type === 'clauses' && gtcCommentStatus.hasComments && (
+ <Badge variant="destructive" className="ml-1 h-4 px-1 text-xs">
+ 코멘트 {gtcCommentStatus.commentCount}
+ </Badge>
+ )}
</div>
</TabsTrigger>
);
@@ -1904,11 +1111,35 @@ export function BasicContractSignViewer({
<div
className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`}
>
- <SurveyComponent />
+ {/* 분리된 SurveyComponent 사용 */}
+ <SurveyComponent
+ contractId={contractId}
+ surveyTemplate={surveyTemplate}
+ surveyLoading={surveyLoading}
+ conditionalHandler={conditionalHandler}
+ onSurveyComplete={onSurveyComplete}
+ onSurveyDataUpdate={setSurveyData}
+ onLoadSurveyTemplate={loadSurveyTemplate}
+ setActiveTab={setActiveTab}
+ />
+ </div>
+
+ <div
+ className={`absolute inset-0 p-3 ${activeTab === 'clauses' ? 'block' : 'hidden'}`}
+ >
+ {/* GTC 조항 컴포넌트 */}
+ <GtcClausesComponent
+ contractId={contractId}
+ onCommentStatusChange={(hasComments, commentCount) => {
+ setGtcCommentStatus({ hasComments, commentCount });
+ onGtcCommentStatusChange?.(hasComments, commentCount);
+ }}
+ t={t}
+ />
</div>
<div
- className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`}
+ className={`absolute inset-0 ${activeTab !== 'survey' && activeTab !== 'clauses' ? 'block' : 'hidden'}`}
>
<div className="w-full h-full overflow-auto">
<div
@@ -1962,49 +1193,84 @@ export function BasicContractSignViewer({
);
}
+ const handleGtcCommentStatusChange = React.useCallback((hasComments: boolean, commentCount: number) => {
+ setGtcCommentStatus({ hasComments, commentCount });
+ onGtcCommentStatusChange?.(hasComments, commentCount);
+ }, [onGtcCommentStatusChange]);
+
// 다이얼로그 뷰어 렌더링
return (
<Dialog open={showDialog} onOpenChange={handleClose}>
<DialogContent className="w-[90vw] max-w-6xl h-[90vh] flex flex-col p-0">
<DialogHeader className="px-6 py-4 border-b flex-shrink-0">
<DialogTitle className="flex items-center justify-between">
- <span>기본계약서 서명</span>
+ <span>{mode === 'buyer' ? '구매자 최종승인' : '기본계약서 서명'}</span>
<SignatureFieldsStatus />
</DialogTitle>
<DialogDescription>
- 계약서를 확인하고 서명을 진행해주세요.
- {isComplianceTemplate && (
+ 계약서를 확인하고 {mode === 'buyer' ? '최종승인을' : '서명을'} 진행해주세요.
+ {mode !== 'buyer' && isComplianceTemplate && (
<span className="block mt-1 text-amber-600">📋 준법 설문조사를 먼저 완료해주세요.</span>
)}
+ {mode !== 'buyer' && isGTCTemplate && (
+ <span className="block mt-1 text-blue-600">📋 GTC 조항을 검토하고 코멘트가 없는지 확인해주세요.</span>
+ )}
{hasSignatureFields && (
<span className="block mt-1 text-green-600">
- 🎯 서명 위치가 자동으로 감지되었습니다.
+ 🎯 서명 위치가 자동으로 감지되었습니다{mode === 'buyer' ? ' (왼쪽 하단)' : ''}.
</span>
)}
- {/* 🔥 서명 완료 상태 안내 */}
{hasValidSignature && (
<span className="block mt-1 text-green-600">
- ✅ 서명이 완료되었습니다.
+ ✅ {mode === 'buyer' ? '승인이' : '서명이'} 완료되었습니다.
+ </span>
+ )}
+ {mode !== 'buyer' && isGTCTemplate && gtcCommentStatus.hasComments && (
+ <span className="block mt-1 text-red-600">
+ ⚠️ GTC 조항에 {gtcCommentStatus.commentCount}개의 코멘트가 있어 서명할 수 없습니다.
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-hidden">
- {allFiles.length > 1 ? (
+ {/* 구매자 모드에서는 탭 없이 단일 뷰어만 표시 */}
+ {allFiles.length > 1 && mode !== 'buyer' ? (
<Tabs value={activeTab} onValueChange={handleTabChange} className="h-full flex flex-col">
<div className="border-b bg-gray-50 px-3 py-2 flex-shrink-0">
<TabsList className="grid w-full h-8" style={{ gridTemplateColumns: `repeat(${allFiles.length}, 1fr)` }}>
{allFiles.map((file, index) => {
- const tabId = index === 0 ? 'main' : file.type === 'survey' ? 'survey' : `file-${index}`;
+ let tabId: string;
+ if (index === 0) {
+ tabId = 'main';
+ } else if (file.type === 'survey') {
+ tabId = 'survey';
+ } else if (file.type === 'clauses') {
+ tabId = 'clauses';
+ } else {
+ const fileOnlyIndex = allFiles.slice(0, index).filter(f => f.type !== 'survey' && f.type !== 'clauses').length;
+ tabId = `file-${fileOnlyIndex}`;
+ }
+
return (
<TabsTrigger key={tabId} value={tabId} className="text-xs">
<div className="flex items-center space-x-1">
- {file.type === 'survey' ? <ClipboardList className="h-3 w-3" /> : <FileText className="h-3 w-3" />}
+ {file.type === 'survey' ? (
+ <ClipboardList className="h-3 w-3" />
+ ) : file.type === 'clauses' ? (
+ <BookOpen className="h-3 w-3" />
+ ) : (
+ <FileText className="h-3 w-3" />
+ )}
<span className="truncate">{file.name}</span>
{file.type === 'survey' && surveyData.completed && (
<Badge variant="secondary" className="ml-1 h-4 px-1 text-xs">완료</Badge>
)}
+ {file.type === 'clauses' && gtcCommentStatus.hasComments && (
+ <Badge variant="destructive" className="ml-1 h-4 px-1 text-xs">
+ 코멘트 {gtcCommentStatus.commentCount}
+ </Badge>
+ )}
</div>
</TabsTrigger>
);
@@ -2014,10 +1280,29 @@ export function BasicContractSignViewer({
<div className="flex-1 min-h-0 overflow-hidden relative">
<div className={`absolute inset-0 p-3 ${activeTab === 'survey' ? 'block' : 'hidden'}`}>
- <SurveyComponent />
+ {/* 분리된 SurveyComponent 사용 */}
+ <SurveyComponent
+ contractId={contractId}
+ surveyTemplate={surveyTemplate}
+ surveyLoading={surveyLoading}
+ conditionalHandler={conditionalHandler}
+ onSurveyComplete={onSurveyComplete}
+ onSurveyDataUpdate={setSurveyData}
+ onLoadSurveyTemplate={loadSurveyTemplate}
+ setActiveTab={setActiveTab}
+ />
+ </div>
+
+ <div className={`absolute inset-0 p-3 ${activeTab === 'clauses' ? 'block' : 'hidden'}`}>
+ {/* GTC 조항 컴포넌트 */}
+ <GtcClausesComponent
+ contractId={contractId}
+ onCommentStatusChange={handleGtcCommentStatusChange} // 메모이제이션된 콜백 사용
+ t={t}
+ />
</div>
- <div className={`absolute inset-0 ${activeTab !== 'survey' ? 'block' : 'hidden'}`}>
+ <div className={`absolute inset-0 ${activeTab !== 'survey' && activeTab !== 'clauses' ? 'block' : 'hidden'}`}>
<div className="w-full h-full overflow-auto">
<div
ref={viewer}
@@ -2069,7 +1354,7 @@ export function BasicContractSignViewer({
<Button variant="outline" onClick={handleClose} disabled={fileLoading}>취소</Button>
<Button onClick={handleSave} disabled={fileLoading || isAutoSignProcessing}>
<FileSignature className="h-4 w-4 mr-2" />
- 서명 완료
+ {mode === 'buyer' ? '최종승인 완료' : '서명 완료'}
</Button>
</DialogFooter>
</DialogContent>
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
new file mode 100644
index 00000000..d0dc6a08
--- /dev/null
+++ b/lib/bidding/detail/service.ts
@@ -0,0 +1,970 @@
+'use server'
+
+import db from '@/db/db'
+import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, BiddingListItem, biddingConditions } from '@/db/schema'
+import { eq, and, sql, desc, ne } from 'drizzle-orm'
+import { revalidatePath } from 'next/cache'
+
+// 데이터 조회 함수들
+export interface BiddingDetailData {
+ bidding: Awaited<ReturnType<typeof getBiddingById>>
+ quotationDetails: QuotationDetails | null
+ quotationVendors: QuotationVendor[]
+ biddingCompanies: Awaited<ReturnType<typeof getBiddingCompaniesData>>
+ prItems: Awaited<ReturnType<typeof getPRItemsForBidding>>
+}
+
+// getBiddingById 함수 임포트 (기존 함수 재사용)
+import { getBiddingById, getPRDetailsAction } from '@/lib/bidding/service'
+
+// Promise.all을 사용하여 모든 데이터를 병렬로 조회
+export async function getBiddingDetailData(biddingId: number): Promise<BiddingDetailData> {
+ const [
+ bidding,
+ quotationDetails,
+ quotationVendors,
+ biddingCompanies,
+ prItems
+ ] = await Promise.all([
+ getBiddingById(biddingId),
+ getQuotationDetails(biddingId),
+ getQuotationVendors(biddingId),
+ getBiddingCompaniesData(biddingId),
+ getPRItemsForBidding(biddingId)
+ ])
+
+ return {
+ bidding,
+ quotationDetails,
+ quotationVendors,
+ biddingCompanies,
+ prItems
+ }
+}
+export interface QuotationDetails {
+ biddingId: number
+ estimatedPrice: number // 예상액
+ lowestQuote: number // 최저견적가
+ averageQuote: number // 평균견적가
+ targetPrice: number // 내정가
+ quotationCount: number // 견적 수
+ lastUpdated: string // 최종 업데이트일
+}
+
+export interface QuotationVendor {
+ id: number
+ biddingId: number
+ vendorId: number
+ vendorName: string
+ vendorCode: string
+ contactPerson: string
+ contactEmail: string
+ contactPhone: string
+ quotationAmount: number // 견적금액
+ currency: string
+ paymentTerms: string // 지급조건 (응답)
+ taxConditions: string // 세금조건 (응답)
+ deliveryDate: string // 납품일 (응답)
+ submissionDate: string // 제출일
+ isWinner: boolean // 낙찰여부
+ awardRatio: number // 발주비율
+ status: 'pending' | 'submitted' | 'selected' | 'rejected'
+ // bidding_conditions에서 제시된 조건들
+ offeredPaymentTerms?: string // 제시된 지급조건
+ offeredTaxConditions?: string // 제시된 세금조건
+ offeredIncoterms?: string // 제시된 운송조건
+ offeredContractDeliveryDate?: string // 제시된 계약납기일
+ offeredShippingPort?: string // 제시된 선적지
+ offeredDestinationPort?: string // 제시된 도착지
+ isPriceAdjustmentApplicable?: boolean // 연동제 적용 여부
+ documents: Array<{
+ id: number
+ fileName: string
+ originalFileName: string
+ filePath: string
+ uploadedAt: string
+ }>
+}
+
+// 견적 시스템에서 내정가 및 관련 정보를 가져오는 함수
+export async function getQuotationDetails(biddingId: number): Promise<QuotationDetails | null> {
+ try {
+ // bidding_companies 테이블에서 견적 데이터를 집계
+ const quotationStats = await db
+ .select({
+ biddingId: biddingCompanies.biddingId,
+ estimatedPrice: sql<number>`AVG(${biddingCompanies.finalQuoteAmount})`.as('estimated_price'),
+ lowestQuote: sql<number>`MIN(${biddingCompanies.finalQuoteAmount})`.as('lowest_quote'),
+ averageQuote: sql<number>`AVG(${biddingCompanies.finalQuoteAmount})`.as('average_quote'),
+ targetPrice: sql<number>`AVG(${biddings.targetPrice})`.as('target_price'),
+ quotationCount: sql<number>`COUNT(*)`.as('quotation_count'),
+ lastUpdated: sql<string>`MAX(${biddingCompanies.updatedAt})`.as('last_updated')
+ })
+ .from(biddingCompanies)
+ .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ sql`${biddingCompanies.finalQuoteAmount} IS NOT NULL`
+ ))
+ .groupBy(biddingCompanies.biddingId)
+ .limit(1)
+
+ if (quotationStats.length === 0) {
+ return {
+ biddingId,
+ estimatedPrice: 0,
+ lowestQuote: 0,
+ averageQuote: 0,
+ targetPrice: 0,
+ quotationCount: 0,
+ lastUpdated: new Date().toISOString()
+ }
+ }
+
+ const stat = quotationStats[0]
+
+ return {
+ biddingId,
+ estimatedPrice: Number(stat.estimatedPrice) || 0,
+ lowestQuote: Number(stat.lowestQuote) || 0,
+ averageQuote: Number(stat.averageQuote) || 0,
+ targetPrice: Number(stat.targetPrice) || 0,
+ quotationCount: Number(stat.quotationCount) || 0,
+ lastUpdated: stat.lastUpdated || new Date().toISOString()
+ }
+ } catch (error) {
+ console.error('Failed to get quotation details:', error)
+ return null
+ }
+}
+
+// bidding_companies 테이블을 메인으로 vendors 테이블을 조인하여 협력업체 정보 조회
+export async function getBiddingCompaniesData(biddingId: number) {
+ try {
+ const companies = await db
+ .select({
+ id: biddingCompanies.id,
+ biddingId: biddingCompanies.biddingId,
+ companyId: biddingCompanies.companyId,
+ companyName: vendors.vendorName,
+ companyCode: vendors.vendorCode,
+ invitationStatus: biddingCompanies.invitationStatus,
+ invitedAt: biddingCompanies.invitedAt,
+ respondedAt: biddingCompanies.respondedAt,
+ preQuoteAmount: biddingCompanies.preQuoteAmount,
+ preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
+ isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ finalQuoteSubmittedAt: biddingCompanies.finalQuoteSubmittedAt,
+ isWinner: biddingCompanies.isWinner,
+ notes: biddingCompanies.notes,
+ contactPerson: biddingCompanies.contactPerson,
+ contactEmail: biddingCompanies.contactEmail,
+ contactPhone: biddingCompanies.contactPhone,
+ createdAt: biddingCompanies.createdAt,
+ updatedAt: biddingCompanies.updatedAt
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(eq(biddingCompanies.biddingId, biddingId))
+ .orderBy(desc(biddingCompanies.finalQuoteAmount))
+
+ return companies
+ } catch (error) {
+ console.error('Failed to get bidding companies data:', error)
+ return []
+ }
+}
+
+// prItemsForBidding 테이블에서 품목 정보 조회
+export async function getPRItemsForBidding(biddingId: number) {
+ try {
+ const items = await db
+ .select()
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+ .orderBy(prItemsForBidding.id)
+
+ return items
+ } catch (error) {
+ console.error('Failed to get PR items for bidding:', error)
+ return []
+ }
+}
+
+// 견적 시스템에서 협력업체 정보를 가져오는 함수
+export async function getQuotationVendors(biddingId: number): Promise<QuotationVendor[]> {
+ try {
+ // bidding_companies 테이블을 메인으로 vendors, bidding_conditions, company_condition_responses를 조인하여 협력업체 정보 조회
+ const vendorsData = await db
+ .select({
+ id: biddingCompanies.id,
+ biddingId: biddingCompanies.biddingId,
+ vendorId: biddingCompanies.companyId,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ contactPerson: biddingCompanies.contactPerson,
+ contactEmail: biddingCompanies.contactEmail,
+ contactPhone: biddingCompanies.contactPhone,
+ quotationAmount: biddingCompanies.finalQuoteAmount,
+ currency: sql<string>`'KRW'` as currency,
+ paymentTerms: sql<string>`COALESCE(${companyConditionResponses.paymentTermsResponse}, '')`,
+ taxConditions: sql<string>`COALESCE(${companyConditionResponses.taxConditionsResponse}, '')`,
+ deliveryDate: companyConditionResponses.proposedContractDeliveryDate,
+ submissionDate: biddingCompanies.finalQuoteSubmittedAt,
+ isWinner: biddingCompanies.isWinner,
+ awardRatio: sql<number>`CASE WHEN ${biddingCompanies.isWinner} THEN 100 ELSE 0 END`,
+ status: sql<string>`CASE
+ WHEN ${biddingCompanies.isWinner} THEN 'selected'
+ WHEN ${biddingCompanies.finalQuoteSubmittedAt} IS NOT NULL THEN 'submitted'
+ WHEN ${biddingCompanies.respondedAt} IS NOT NULL THEN 'submitted'
+ ELSE 'pending'
+ END`,
+ // bidding_conditions에서 제시된 조건들
+ offeredPaymentTerms: biddingConditions.paymentTerms,
+ offeredTaxConditions: biddingConditions.taxConditions,
+ offeredIncoterms: biddingConditions.incoterms,
+ offeredContractDeliveryDate: biddingConditions.contractDeliveryDate,
+ offeredShippingPort: biddingConditions.shippingPort,
+ offeredDestinationPort: biddingConditions.destinationPort,
+ isPriceAdjustmentApplicable: biddingConditions.isPriceAdjustmentApplicable,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .leftJoin(companyConditionResponses, eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId))
+ .leftJoin(biddingConditions, eq(biddingCompanies.id, biddingConditions.biddingCompanyId))
+ .where(eq(biddingCompanies.biddingId, biddingId))
+ .orderBy(desc(biddingCompanies.finalQuoteAmount))
+
+ return vendorsData.map(vendor => ({
+ id: vendor.id,
+ biddingId: vendor.biddingId,
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName || `Vendor ${vendor.vendorId}`,
+ vendorCode: vendor.vendorCode || '',
+ contactPerson: vendor.contactPerson || '',
+ contactEmail: vendor.contactEmail || '',
+ contactPhone: vendor.contactPhone || '',
+ quotationAmount: Number(vendor.quotationAmount) || 0,
+ currency: vendor.currency,
+ paymentTerms: vendor.paymentTerms,
+ taxConditions: vendor.taxConditions,
+ deliveryDate: vendor.deliveryDate ? vendor.deliveryDate.toISOString().split('T')[0] : '',
+ submissionDate: vendor.submissionDate ? vendor.submissionDate.toISOString().split('T')[0] : '',
+ isWinner: vendor.isWinner || false,
+ awardRatio: vendor.awardRatio || 0,
+ status: vendor.status as 'pending' | 'submitted' | 'selected' | 'rejected',
+ // bidding_conditions에서 제시된 조건들
+ offeredPaymentTerms: vendor.offeredPaymentTerms,
+ offeredTaxConditions: vendor.offeredTaxConditions,
+ offeredIncoterms: vendor.offeredIncoterms,
+ offeredContractDeliveryDate: vendor.offeredContractDeliveryDate ? vendor.offeredContractDeliveryDate.toISOString().split('T')[0] : undefined,
+ offeredShippingPort: vendor.offeredShippingPort,
+ offeredDestinationPort: vendor.offeredDestinationPort,
+ isPriceAdjustmentApplicable: vendor.isPriceAdjustmentApplicable,
+ documents: [] // TODO: 문서 정보 조회 로직 추가
+ }))
+ } catch (error) {
+ console.error('Failed to get quotation vendors:', error)
+ return []
+ }
+}
+
+// 내정가 수동 업데이트 (실제 저장)
+export async function updateTargetPrice(
+ biddingId: number,
+ targetPrice: number,
+ targetPriceCalculationCriteria: string,
+ userId: string
+) {
+ try {
+ await db
+ .update(biddings)
+ .set({
+ targetPrice: targetPrice.toString(),
+ targetPriceCalculationCriteria: targetPriceCalculationCriteria,
+ updatedAt: new Date()
+ })
+ .where(eq(biddings.id, biddingId))
+
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ return { success: true, message: '내정가가 성공적으로 업데이트되었습니다.' }
+ } catch (error) {
+ console.error('Failed to update target price:', error)
+ return { success: false, error: '내정가 업데이트에 실패했습니다.' }
+ }
+}
+
+// 협력업체 정보 저장 - biddingCompanies와 biddingConditions 테이블에 레코드 생성
+export async function createQuotationVendor(input: Omit<QuotationVendor, 'id'>, userId: string) {
+ try {
+ const result = await db.transaction(async (tx) => {
+ // 1. biddingCompanies에 레코드 생성
+ const biddingCompanyResult = await tx.insert(biddingCompanies).values({
+ biddingId: input.biddingId,
+ companyId: input.vendorId,
+ vendorId: input.vendorId,
+ quotationAmount: input.quotationAmount,
+ currency: input.currency,
+ status: input.status,
+ awardRatio: input.awardRatio,
+ isWinner: false,
+ contactPerson: input.contactPerson,
+ contactEmail: input.contactEmail,
+ contactPhone: input.contactPhone,
+ paymentTerms: input.paymentTerms,
+ taxConditions: input.taxConditions,
+ deliveryDate: input.deliveryDate ? new Date(input.deliveryDate) : null,
+ submissionDate: new Date(),
+ createdBy: userId,
+ updatedBy: userId,
+ }).returning({ id: biddingCompanies.id })
+
+ if (biddingCompanyResult.length === 0) {
+ throw new Error('협력업체 정보 저장에 실패했습니다.')
+ }
+
+ const biddingCompanyId = biddingCompanyResult[0].id
+
+ // 2. biddingConditions에 기본 조건 생성
+ await tx.insert(biddingConditions).values({
+ biddingCompanyId: biddingCompanyId,
+ paymentTerms: '["선금 30%, 잔금 70%"]', // 기본 지급조건
+ taxConditions: '["부가세 별도"]', // 기본 세금조건
+ contractDeliveryDate: null,
+ isPriceAdjustmentApplicable: false,
+ incoterms: '["FOB"]', // 기본 운송조건
+ shippingPort: null,
+ destinationPort: null,
+ sparePartOptions: '[]', // 기본 예비품 옵션
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+
+ return biddingCompanyId
+ })
+
+ revalidatePath(`/evcp/bid/[id]`)
+ return {
+ success: true,
+ message: '협력업체 정보가 성공적으로 저장되었습니다.',
+ data: { id: result }
+ }
+ } catch (error) {
+ console.error('Failed to create quotation vendor:', error)
+ return { success: false, error: '협력업체 정보 저장에 실패했습니다.' }
+ }
+}
+
+// 협력업체 정보 업데이트
+export async function updateQuotationVendor(id: number, input: Partial<QuotationVendor>, userId: string) {
+ try {
+ const result = await db.transaction(async (tx) => {
+ // 1. biddingCompanies 테이블 업데이트
+ const updateData: any = {}
+ if (input.quotationAmount !== undefined) updateData.finalQuoteAmount = input.quotationAmount
+ if (input.contactPerson !== undefined) updateData.contactPerson = input.contactPerson
+ if (input.contactEmail !== undefined) updateData.contactEmail = input.contactEmail
+ if (input.contactPhone !== undefined) updateData.contactPhone = input.contactPhone
+ if (input.awardRatio !== undefined) updateData.awardRatio = input.awardRatio
+ if (input.status !== undefined) updateData.status = input.status
+ updateData.updatedBy = userId
+ updateData.updatedAt = new Date()
+
+ if (Object.keys(updateData).length > 0) {
+ await tx.update(biddingCompanies)
+ .set(updateData)
+ .where(eq(biddingCompanies.id, id))
+ }
+
+ // 2. biddingConditions 테이블 업데이트 (제시된 조건들)
+ if (input.offeredPaymentTerms !== undefined ||
+ input.offeredTaxConditions !== undefined ||
+ input.offeredIncoterms !== undefined ||
+ input.offeredContractDeliveryDate !== undefined ||
+ input.offeredShippingPort !== undefined ||
+ input.offeredDestinationPort !== undefined ||
+ input.isPriceAdjustmentApplicable !== undefined) {
+
+ const conditionsUpdateData: any = {}
+ if (input.offeredPaymentTerms !== undefined) conditionsUpdateData.paymentTerms = input.offeredPaymentTerms
+ if (input.offeredTaxConditions !== undefined) conditionsUpdateData.taxConditions = input.offeredTaxConditions
+ if (input.offeredIncoterms !== undefined) conditionsUpdateData.incoterms = input.offeredIncoterms
+ if (input.offeredContractDeliveryDate !== undefined) conditionsUpdateData.contractDeliveryDate = input.offeredContractDeliveryDate ? new Date(input.offeredContractDeliveryDate) : null
+ if (input.offeredShippingPort !== undefined) conditionsUpdateData.shippingPort = input.offeredShippingPort
+ if (input.offeredDestinationPort !== undefined) conditionsUpdateData.destinationPort = input.offeredDestinationPort
+ if (input.isPriceAdjustmentApplicable !== undefined) conditionsUpdateData.isPriceAdjustmentApplicable = input.isPriceAdjustmentApplicable
+ conditionsUpdateData.updatedAt = new Date()
+
+ await tx.update(biddingConditions)
+ .set(conditionsUpdateData)
+ .where(eq(biddingConditions.biddingCompanyId, id))
+ }
+
+ return true
+ })
+
+ revalidatePath(`/evcp/bid/[id]`)
+ return {
+ success: true,
+ message: '협력업체 정보가 성공적으로 업데이트되었습니다.',
+ }
+ } catch (error) {
+ console.error('Failed to update quotation vendor:', error)
+ return { success: false, error: '협력업체 정보 업데이트에 실패했습니다.' }
+ }
+}
+
+// 협력업체 정보 삭제
+export async function deleteQuotationVendor(id: number) {
+ try {
+ // TODO: 실제로는 견적 시스템의 테이블에서 삭제
+ console.log(`[TODO] 견적 시스템에서 협력업체 정보 ${id} 삭제 예정`)
+
+ // 임시로 성공 응답
+ return { success: true, message: '협력업체 정보가 성공적으로 삭제되었습니다.' }
+ } catch (error) {
+ console.error('Failed to delete quotation vendor:', error)
+ return { success: false, error: '협력업체 정보 삭제에 실패했습니다.' }
+ }
+}
+
+// 낙찰 처리
+export async function selectWinner(biddingId: number, vendorId: number, awardRatio: number, userId: string) {
+ try {
+ // 트랜잭션으로 처리
+ await db.transaction(async (tx) => {
+ // 기존 낙찰자 초기화
+ await tx
+ .update(biddingCompanies)
+ .set({
+ isWinner: false,
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.biddingId, biddingId))
+
+ // 새로운 낙찰자 설정
+ const biddingCompany = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.companyId, vendorId)
+ ))
+ .limit(1)
+
+ if (biddingCompany.length > 0) {
+ await tx
+ .update(biddingCompanies)
+ .set({
+ isWinner: true,
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompany[0].id))
+ }
+
+ // biddings 테이블의 상태 업데이트
+ await tx
+ .update(biddings)
+ .set({
+ status: 'vendor_selected',
+ finalBidPrice: undefined, // TODO: 낙찰가 설정 로직 추가
+ updatedAt: new Date()
+ })
+ .where(eq(biddings.id, biddingId))
+ })
+
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ return { success: true, message: '낙찰 처리가 완료되었습니다.' }
+ } catch (error) {
+ console.error('Failed to select winner:', error)
+ return { success: false, error: '낙찰 처리에 실패했습니다.' }
+ }
+}
+
+// 유찰 처리
+export async function markAsDisposal(biddingId: number, userId: string) {
+ try {
+ await db
+ .update(biddings)
+ .set({
+ status: 'bidding_disposal',
+ updatedAt: new Date()
+ })
+ .where(eq(biddings.id, biddingId))
+
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ return { success: true, message: '유찰 처리가 완료되었습니다.' }
+ } catch (error) {
+ console.error('Failed to mark as disposal:', error)
+ return { success: false, error: '유찰 처리에 실패했습니다.' }
+ }
+}
+
+// 입찰 등록 (상태 변경)
+export async function registerBidding(biddingId: number, userId: string) {
+ try {
+ await db
+ .update(biddings)
+ .set({
+ status: 'bidding_opened',
+ updatedAt: new Date()
+ })
+ .where(eq(biddings.id, biddingId))
+ //todo 입찰 등록하면 bidding_companies invitationStatus를 sent로 변경!
+ await db
+ .update(biddingCompanies)
+ .set({
+ invitationStatus: 'sent',
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.biddingId, biddingId))
+
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ return { success: true, message: '입찰이 성공적으로 등록되었습니다.' }
+ } catch (error) {
+ console.error('Failed to register bidding:', error)
+ return { success: false, error: '입찰 등록에 실패했습니다.' }
+ }
+}
+
+// 재입찰 생성
+export async function createRebidding(originalBiddingId: number, userId: string) {
+ try {
+ // 원본 입찰 정보 조회
+ const originalBidding = await db
+ .select()
+ .from(biddings)
+ .where(eq(biddings.id, originalBiddingId))
+ .limit(1)
+
+ if (originalBidding.length === 0) {
+ return { success: false, error: '원본 입찰을 찾을 수 없습니다.' }
+ }
+
+ const original = originalBidding[0]
+
+ // 재입찰용 데이터 준비
+ const rebiddingData = {
+ ...original,
+ id: undefined,
+ biddingNumber: `${original.biddingNumber}-R${(original.revision || 0) + 1}`,
+ revision: (original.revision || 0) + 1,
+ status: 'bidding_generated' as const,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ }
+
+ // 새로운 입찰 생성
+ const [newBidding] = await db
+ .insert(biddings)
+ .values(rebiddingData)
+ .returning({ id: biddings.id, biddingNumber: biddings.biddingNumber })
+
+ revalidatePath('/evcp/bid')
+ revalidatePath(`/evcp/bid/${newBidding.id}`)
+
+ return {
+ success: true,
+ message: '재입찰이 성공적으로 생성되었습니다.',
+ data: newBidding
+ }
+ } catch (error) {
+ console.error('Failed to create rebidding:', error)
+ return { success: false, error: '재입찰 생성에 실패했습니다.' }
+ }
+}
+
+// 업체 선정 사유 업데이트
+export async function updateVendorSelectionReason(biddingId: number, selectedCompanyId: number, selectionReason: string, userId: string) {
+ try {
+ // vendorSelectionResults 테이블에 삽입 또는 업데이트
+ await db
+ .insert(vendorSelectionResults)
+ .values({
+ biddingId,
+ selectedCompanyId,
+ selectionReason,
+ selectedBy: userId,
+ selectedAt: new Date(),
+ createdAt: new Date(),
+ updatedAt: new Date()
+ })
+ .onConflictDoUpdate({
+ target: [vendorSelectionResults.biddingId],
+ set: {
+ selectedCompanyId,
+ selectionReason,
+ selectedBy: userId,
+ selectedAt: new Date(),
+ updatedAt: new Date()
+ }
+ })
+
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ return { success: true, message: '업체 선정 사유가 성공적으로 업데이트되었습니다.' }
+ } catch (error) {
+ console.error('Failed to update vendor selection reason:', error)
+ return { success: false, error: '업체 선정 사유 업데이트에 실패했습니다.' }
+ }
+}
+
+// PR 품목 정보 업데이트
+export async function updatePrItem(prItemId: number, input: Partial<typeof prItemsForBidding.$inferSelect>, userId: string) {
+ try {
+ await db
+ .update(prItemsForBidding)
+ .set({
+ ...input,
+ updatedAt: new Date()
+ })
+ .where(eq(prItemsForBidding.id, prItemId))
+
+ revalidatePath(`/evcp/bid/${input.biddingId}`)
+ return { success: true, message: '품목 정보가 성공적으로 업데이트되었습니다.' }
+ } catch (error) {
+ console.error('Failed to update PR item:', error)
+ return { success: false, error: '품목 정보 업데이트에 실패했습니다.' }
+ }
+}
+
+// 입찰에 협력업체 추가
+export async function addVendorToBidding(biddingId: number, companyId: number, userId: string) {
+ try {
+ // 이미 추가된 업체인지 확인
+ const existing = await db
+ .select()
+ .from(biddingCompanies)
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.companyId, companyId)
+ ))
+ .limit(1)
+
+ if (existing.length > 0) {
+ return { success: false, error: '이미 추가된 협력업체입니다.' }
+ }
+
+ // 새로운 협력업체 추가
+ await db
+ .insert(biddingCompanies)
+ .values({
+ biddingId,
+ companyId,
+ invitationStatus: 'pending',
+ invitedAt: new Date(),
+ createdAt: new Date(),
+ updatedAt: new Date()
+ })
+
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ return { success: true, message: '협력업체가 성공적으로 추가되었습니다.' }
+ } catch (error) {
+ console.error('Failed to add vendor to bidding:', error)
+ return { success: false, error: '협력업체 추가에 실패했습니다.' }
+ }
+}
+
+// =================================================
+// 협력업체 페이지용 함수들 (Partners)
+// =================================================
+
+// 협력업체용 입찰 목록 조회 (bidding_companies 기준)
+export interface PartnersBiddingListItem {
+ // bidding_companies 정보
+ id: number
+ biddingCompanyId: number
+ invitationStatus: string
+ respondedAt: string | null
+ finalQuoteAmount: number | null
+ finalQuoteSubmittedAt: string | null
+ isWinner: boolean | null
+ isAttendingMeeting: boolean | null
+ notes: string | null
+ createdAt: Date
+ updatedAt: Date
+ updatedBy: string | null
+
+ // biddings 정보
+ biddingId: number
+ biddingNumber: string
+ revision: number
+ projectName: string
+ itemName: string
+ title: string
+ contractType: string
+ biddingType: string
+ contractPeriod: string | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+ status: string
+ managerName: string | null
+ managerEmail: string | null
+ managerPhone: string | null
+ currency: string
+ budget: number | null
+
+ // 계산된 필드
+ responseDeadline: Date | null // 참여회신 마감일 (submissionStartDate 전 3일)
+ submissionDate: Date | null // 입찰제출일 (submissionEndDate)
+}
+
+export async function getBiddingListForPartners(companyId: number): Promise<PartnersBiddingListItem[]> {
+ try {
+ const result = await db
+ .select({
+ // bidding_companies 정보
+ id: biddingCompanies.id,
+ biddingCompanyId: biddingCompanies.id, // 동일
+ invitationStatus: biddingCompanies.invitationStatus,
+ respondedAt: biddingCompanies.respondedAt,
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ finalQuoteSubmittedAt: biddingCompanies.finalQuoteSubmittedAt,
+ isWinner: biddingCompanies.isWinner,
+ isAttendingMeeting: biddingCompanies.isAttendingMeeting,
+ notes: biddingCompanies.notes,
+ createdAt: biddingCompanies.createdAt,
+ updatedAt: biddingCompanies.updatedAt,
+ updatedBy: biddingCompanies.updatedBy,
+
+ // biddings 정보
+ biddingId: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ revision: biddings.revision,
+ projectName: biddings.projectName,
+ itemName: biddings.itemName,
+ title: biddings.title,
+ contractType: biddings.contractType,
+ biddingType: biddings.biddingType,
+ contractPeriod: biddings.contractPeriod,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+ status: biddings.status,
+ managerName: biddings.managerName,
+ managerEmail: biddings.managerEmail,
+ managerPhone: biddings.managerPhone,
+ currency: biddings.currency,
+ budget: biddings.budget,
+ })
+ .from(biddingCompanies)
+ .innerJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
+ .where(and(
+ eq(biddingCompanies.companyId, companyId),
+ ne(biddingCompanies.invitationStatus, 'pending') // 초대 대기 상태 제외
+ ))
+ .orderBy(desc(biddingCompanies.createdAt))
+
+ // console.log(result)
+
+ // 계산된 필드 추가
+ const resultWithCalculatedFields = result.map(item => ({
+ ...item,
+ responseDeadline: item.submissionStartDate
+ ? new Date(item.submissionStartDate.getTime() - 3 * 24 * 60 * 60 * 1000) // 3일 전
+ : null,
+ submissionDate: item.submissionEndDate,
+ }))
+
+ return resultWithCalculatedFields
+ } catch (error) {
+ console.error('Failed to get bidding list for partners:', error)
+ return []
+ }
+}
+
+// 협력업체용 입찰 상세 정보 조회
+export async function getBiddingDetailsForPartners(biddingId: number, companyId: number) {
+ try {
+ const result = await db
+ .select({
+ // 입찰 기본 정보
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ revision: biddings.revision,
+ projectName: biddings.projectName,
+ itemName: biddings.itemName,
+ title: biddings.title,
+ description: biddings.description,
+ content: biddings.content,
+
+ // 계약 정보
+ contractType: biddings.contractType,
+ biddingType: biddings.biddingType,
+ awardCount: biddings.awardCount,
+ contractPeriod: biddings.contractPeriod,
+
+ // 일정 정보
+ preQuoteDate: biddings.preQuoteDate,
+ biddingRegistrationDate: biddings.biddingRegistrationDate,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+ evaluationDate: biddings.evaluationDate,
+
+ // 가격 정보
+ currency: biddings.currency,
+ budget: biddings.budget,
+ targetPrice: biddings.targetPrice,
+
+ // 상태 및 담당자
+ status: biddings.status,
+ managerName: biddings.managerName,
+ managerEmail: biddings.managerEmail,
+ managerPhone: biddings.managerPhone,
+
+ // 협력업체 특정 정보
+ biddingCompanyId: biddingCompanies.id,
+ invitationStatus: biddingCompanies.invitationStatus,
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ finalQuoteSubmittedAt: biddingCompanies.finalQuoteSubmittedAt,
+ isWinner: biddingCompanies.isWinner,
+
+ // 제시된 조건들 (bidding_conditions)
+ offeredPaymentTerms: biddingConditions.paymentTerms,
+ offeredTaxConditions: biddingConditions.taxConditions,
+ offeredIncoterms: biddingConditions.incoterms,
+ offeredContractDeliveryDate: biddingConditions.contractDeliveryDate,
+ offeredShippingPort: biddingConditions.shippingPort,
+ offeredDestinationPort: biddingConditions.destinationPort,
+ isPriceAdjustmentApplicable: biddingConditions.isPriceAdjustmentApplicable,
+
+ // 응답한 조건들 (company_condition_responses)
+ responsePaymentTerms: companyConditionResponses.paymentTermsResponse,
+ responseTaxConditions: companyConditionResponses.taxConditionsResponse,
+ responseIncoterms: companyConditionResponses.incotermsResponse,
+ proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
+ proposedShippingPort: companyConditionResponses.proposedShippingPort,
+ proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
+ priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
+ additionalProposals: companyConditionResponses.additionalProposals,
+ responseSubmittedAt: companyConditionResponses.submittedAt,
+ })
+ .from(biddings)
+ .innerJoin(biddingCompanies, eq(biddings.id, biddingCompanies.biddingId))
+ .leftJoin(biddingConditions, eq(biddingCompanies.id, biddingConditions.biddingCompanyId))
+ .leftJoin(companyConditionResponses, eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId))
+ .where(and(
+ eq(biddings.id, biddingId),
+ eq(biddingCompanies.companyId, companyId)
+ ))
+ .limit(1)
+
+ return result[0] || null
+ } catch (error) {
+ console.error('Failed to get bidding details for partners:', error)
+ return null
+ }
+}
+
+// 협력업체 응찰 제출
+export async function submitPartnerResponse(
+ biddingCompanyId: number,
+ response: {
+ paymentTermsResponse?: string
+ taxConditionsResponse?: string
+ incotermsResponse?: string
+ proposedContractDeliveryDate?: string
+ proposedShippingPort?: string
+ proposedDestinationPort?: string
+ priceAdjustmentResponse?: boolean
+ additionalProposals?: string
+ finalQuoteAmount?: number
+ },
+ userId: string
+) {
+ try {
+ const result = await db.transaction(async (tx) => {
+ // 1. company_condition_responses 테이블에 응답 저장/업데이트
+ const responseData = {
+ paymentTermsResponse: response.paymentTermsResponse,
+ taxConditionsResponse: response.taxConditionsResponse,
+ incotermsResponse: response.incotermsResponse,
+ proposedContractDeliveryDate: response.proposedContractDeliveryDate ? new Date(response.proposedContractDeliveryDate) : null,
+ proposedShippingPort: response.proposedShippingPort,
+ proposedDestinationPort: response.proposedDestinationPort,
+ priceAdjustmentResponse: response.priceAdjustmentResponse,
+ additionalProposals: response.additionalProposals,
+ submittedAt: new Date(),
+ updatedAt: new Date(),
+ }
+
+ // 기존 응답이 있는지 확인
+ const existingResponse = await tx
+ .select()
+ .from(companyConditionResponses)
+ .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
+ .limit(1)
+
+ if (existingResponse.length > 0) {
+ // 업데이트
+ await tx
+ .update(companyConditionResponses)
+ .set(responseData)
+ .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
+ } else {
+ // 새로 생성
+ await tx
+ .insert(companyConditionResponses)
+ .values({
+ biddingCompanyId,
+ ...responseData,
+ })
+ }
+
+ // 2. biddingCompanies 테이블에 견적 금액과 상태 업데이트
+ const companyUpdateData: any = {
+ respondedAt: new Date(),
+ updatedAt: new Date(),
+ updatedBy: userId,
+ }
+
+ if (response.finalQuoteAmount !== undefined) {
+ companyUpdateData.finalQuoteAmount = response.finalQuoteAmount
+ companyUpdateData.finalQuoteSubmittedAt = new Date()
+ companyUpdateData.invitationStatus = 'submitted'
+ }
+
+ await tx
+ .update(biddingCompanies)
+ .set(companyUpdateData)
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ return true
+ })
+
+ revalidatePath(`/partners/bid/${biddingId}`)
+ return {
+ success: true,
+ message: '응찰이 성공적으로 제출되었습니다.',
+ }
+ } catch (error) {
+ console.error('Failed to submit partner response:', error)
+ return { success: false, error: '응찰 제출에 실패했습니다.' }
+ }
+}
+
+// 사양설명회 참석 여부 업데이트
+export async function updatePartnerAttendance(
+ biddingCompanyId: number,
+ isAttending: boolean,
+ userId: string
+) {
+ try {
+ await db
+ .update(biddingCompanies)
+ .set({
+ isAttendingMeeting: isAttending,
+ updatedAt: new Date(),
+ updatedBy: userId,
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ revalidatePath('/partners/bid/[id]')
+ return {
+ success: true,
+ message: `사양설명회 ${isAttending ? '참석' : '불참'}으로 설정되었습니다.`,
+ }
+ } catch (error) {
+ console.error('Failed to update partner attendance:', error)
+ return { success: false, error: '참석 여부 업데이트에 실패했습니다.' }
+ }
+}
diff --git a/lib/bidding/detail/table/bidding-detail-content.tsx b/lib/bidding/detail/table/bidding-detail-content.tsx
new file mode 100644
index 00000000..090e7218
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-content.tsx
@@ -0,0 +1,93 @@
+'use client'
+
+import * as React from 'react'
+import { Bidding } from '@/db/schema'
+import { QuotationDetails, QuotationVendor } from '@/lib/bidding/detail/service'
+import { BiddingDetailHeader } from './bidding-detail-header'
+import { BiddingDetailVendorTableContent } from './bidding-detail-vendor-table'
+import { BiddingDetailItemsDialog } from './bidding-detail-items-dialog'
+import { BiddingDetailTargetPriceDialog } from './bidding-detail-target-price-dialog'
+import { BiddingDetailSelectionReasonDialog } from './bidding-detail-selection-reason-dialog'
+
+interface BiddingDetailContentProps {
+ bidding: Bidding
+ quotationDetails: QuotationDetails | null
+ quotationVendors: QuotationVendor[]
+ biddingCompanies: any[]
+ prItems: any[]
+}
+
+export function BiddingDetailContent({
+ bidding,
+ quotationDetails,
+ quotationVendors,
+ biddingCompanies,
+ prItems
+}: BiddingDetailContentProps) {
+ const [dialogStates, setDialogStates] = React.useState({
+ items: false,
+ targetPrice: false,
+ selectionReason: false
+ })
+
+ const [refreshTrigger, setRefreshTrigger] = React.useState(0)
+
+ const handleRefresh = React.useCallback(() => {
+ setRefreshTrigger(prev => prev + 1)
+ }, [])
+
+ const openDialog = React.useCallback((type: keyof typeof dialogStates) => {
+ setDialogStates(prev => ({ ...prev, [type]: true }))
+ }, [])
+
+ const closeDialog = React.useCallback((type: keyof typeof dialogStates) => {
+ setDialogStates(prev => ({ ...prev, [type]: false }))
+ }, [])
+
+ return (
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="p-6">
+ <BiddingDetailHeader bidding={bidding} />
+
+ <div className="mt-6">
+ <BiddingDetailVendorTableContent
+ biddingId={bidding.id}
+ vendors={quotationVendors}
+ biddingCompanies={biddingCompanies}
+ onRefresh={handleRefresh}
+ onOpenItemsDialog={() => openDialog('items')}
+ onOpenTargetPriceDialog={() => openDialog('targetPrice')}
+ onOpenSelectionReasonDialog={() => openDialog('selectionReason')}
+ onEdit={undefined}
+ onDelete={undefined}
+ onSelectWinner={undefined}
+ />
+ </div>
+ </div>
+ </section>
+
+ <BiddingDetailItemsDialog
+ open={dialogStates.items}
+ onOpenChange={(open) => closeDialog('items')}
+ prItems={prItems}
+ bidding={bidding}
+ />
+
+ <BiddingDetailTargetPriceDialog
+ open={dialogStates.targetPrice}
+ onOpenChange={(open) => closeDialog('targetPrice')}
+ quotationDetails={quotationDetails}
+ bidding={bidding}
+ onSuccess={handleRefresh}
+ />
+
+ <BiddingDetailSelectionReasonDialog
+ open={dialogStates.selectionReason}
+ onOpenChange={(open) => closeDialog('selectionReason')}
+ bidding={bidding}
+ onSuccess={handleRefresh}
+ />
+ </div>
+ )
+}
diff --git a/lib/bidding/detail/table/bidding-detail-header.tsx b/lib/bidding/detail/table/bidding-detail-header.tsx
new file mode 100644
index 00000000..3135f37d
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-header.tsx
@@ -0,0 +1,328 @@
+'use client'
+
+import * as React from 'react'
+import { useRouter } from 'next/navigation'
+import { Bidding, biddingStatusLabels, contractTypeLabels, biddingTypeLabels } from '@/db/schema'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ ArrowLeft,
+ Send,
+ RotateCcw,
+ XCircle,
+ Calendar,
+ Building2,
+ User,
+ Package,
+ DollarSign,
+ Hash
+} from 'lucide-react'
+
+import { formatDate } from '@/lib/utils'
+import {
+ registerBidding,
+ markAsDisposal,
+ createRebidding
+} from '@/lib/bidding/detail/service'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface BiddingDetailHeaderProps {
+ bidding: Bidding
+}
+
+export function BiddingDetailHeader({ bidding }: BiddingDetailHeaderProps) {
+ const router = useRouter()
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+
+ const handleGoBack = () => {
+ router.push('/evcp/bid')
+ }
+
+ const handleRegister = () => {
+ // 상태 검증
+ if (bidding.status !== 'bidding_generated') {
+ toast({
+ title: '실행 불가',
+ description: '입찰 등록은 입찰 생성 상태에서만 가능합니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ if (!confirm('입찰을 등록하시겠습니까?')) return
+
+ startTransition(async () => {
+ const result = await registerBidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: result.message,
+ })
+ router.refresh()
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const handleMarkAsDisposal = () => {
+ // 상태 검증
+ if (bidding.status !== 'bidding_closed') {
+ toast({
+ title: '실행 불가',
+ description: '유찰 처리는 입찰 마감 상태에서만 가능합니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ if (!confirm('입찰을 유찰 처리하시겠습니까?')) return
+
+ startTransition(async () => {
+ const result = await markAsDisposal(bidding.id, 'current-user') // TODO: 실제 사용자 ID
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: result.message,
+ })
+ router.refresh()
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const handleCreateRebidding = () => {
+ // 상태 검증
+ if (bidding.status !== 'bidding_disposal') {
+ toast({
+ title: '실행 불가',
+ description: '재입찰은 유찰 상태에서만 가능합니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ if (!confirm('재입찰을 생성하시겠습니까?')) return
+
+ startTransition(async () => {
+ const result = await createRebidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: result.message,
+ })
+ // 새로 생성된 입찰로 이동
+ if (result.data) {
+ router.push(`/evcp/bid/${result.data.id}`)
+ } else {
+ router.refresh()
+ }
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const getActionButtons = () => {
+ const buttons = []
+
+ // 기본 액션 버튼들 (항상 표시)
+ buttons.push(
+ <Button
+ key="back"
+ variant="outline"
+ onClick={handleGoBack}
+ disabled={isPending}
+ >
+ <ArrowLeft className="w-4 h-4 mr-2" />
+ 목록으로
+ </Button>
+ )
+
+ // 모든 액션 버튼을 항상 표시 (상태 검증은 각 핸들러에서)
+ buttons.push(
+ <Button
+ key="register"
+ onClick={handleRegister}
+ disabled={isPending}
+ >
+ <Send className="w-4 h-4 mr-2" />
+ 입찰등록
+ </Button>
+ )
+
+ buttons.push(
+ <Button
+ key="disposal"
+ variant="destructive"
+ onClick={handleMarkAsDisposal}
+ disabled={isPending}
+ >
+ <XCircle className="w-4 h-4 mr-2" />
+ 유찰
+ </Button>
+ )
+
+ buttons.push(
+ <Button
+ key="rebidding"
+ onClick={handleCreateRebidding}
+ disabled={isPending}
+ >
+ <RotateCcw className="w-4 h-4 mr-2" />
+ 재입찰
+ </Button>
+ )
+
+ return buttons
+ }
+
+ return (
+ <div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
+ <div className="px-6 py-4">
+ {/* 헤더 메인 영역 */}
+ <div className="flex items-center justify-between mb-4">
+ <div className="flex items-center gap-4 flex-1 min-w-0">
+ {/* 제목과 배지 */}
+ <div className="flex items-center gap-3 flex-1 min-w-0">
+ <h1 className="text-xl font-semibold truncate">{bidding.title}</h1>
+ <div className="flex items-center gap-2 flex-shrink-0">
+ <Badge variant="outline" className="font-mono text-xs">
+ <Hash className="w-3 h-3 mr-1" />
+ {bidding.biddingNumber}
+ {bidding.revision && bidding.revision > 0 && ` Rev.${bidding.revision}`}
+ </Badge>
+ <Badge variant={
+ bidding.status === 'bidding_disposal' ? 'destructive' :
+ bidding.status === 'vendor_selected' ? 'default' :
+ 'secondary'
+ } className="text-xs">
+ {biddingStatusLabels[bidding.status]}
+ </Badge>
+ </div>
+ </div>
+
+ {/* 액션 버튼들 */}
+ <div className="flex items-center gap-2 flex-shrink-0">
+ {getActionButtons()}
+ </div>
+ </div>
+ </div>
+
+ {/* 세부 정보 영역 */}
+ <div className="flex flex-wrap items-center gap-6 text-sm">
+ {/* 프로젝트 정보 */}
+ {bidding.projectName && (
+ <div className="flex items-center gap-1.5 text-muted-foreground">
+ <Building2 className="w-4 h-4" />
+ <span className="font-medium">프로젝트:</span>
+ <span>{bidding.projectName}</span>
+ </div>
+ )}
+
+ {/* 품목 정보 */}
+ {bidding.itemName && (
+ <div className="flex items-center gap-1.5 text-muted-foreground">
+ <Package className="w-4 h-4" />
+ <span className="font-medium">품목:</span>
+ <span>{bidding.itemName}</span>
+ </div>
+ )}
+
+ {/* 담당자 정보 */}
+ {bidding.managerName && (
+ <div className="flex items-center gap-1.5 text-muted-foreground">
+ <User className="w-4 h-4" />
+ <span className="font-medium">담당자:</span>
+ <span>{bidding.managerName}</span>
+ </div>
+ )}
+
+ {/* 계약구분 */}
+ <div className="flex items-center gap-1.5 text-muted-foreground">
+ <span className="font-medium">계약:</span>
+ <span>{contractTypeLabels[bidding.contractType]}</span>
+ </div>
+
+ {/* 입찰유형 */}
+ <div className="flex items-center gap-1.5 text-muted-foreground">
+ <span className="font-medium">유형:</span>
+ <span>{biddingTypeLabels[bidding.biddingType]}</span>
+ </div>
+
+ {/* 낙찰수 */}
+ <div className="flex items-center gap-1.5 text-muted-foreground">
+ <span className="font-medium">낙찰:</span>
+ <span>{bidding.awardCount === 'single' ? '단수' : '복수'}</span>
+ </div>
+
+ {/* 통화 */}
+ <div className="flex items-center gap-1.5 text-muted-foreground">
+ <DollarSign className="w-4 h-4" />
+ <span className="font-mono">{bidding.currency}</span>
+ </div>
+
+ {/* 예산 정보 */}
+ {bidding.budget && (
+ <div className="flex items-center gap-1.5">
+ <span className="font-medium text-muted-foreground">예산:</span>
+ <span className="font-semibold">
+ {new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: bidding.currency || 'KRW',
+ }).format(Number(bidding.budget))}
+ </span>
+ </div>
+ )}
+ </div>
+
+ {/* 일정 정보 */}
+ {(bidding.submissionStartDate || bidding.evaluationDate || bidding.preQuoteDate || bidding.biddingRegistrationDate) && (
+ <div className="flex flex-wrap items-center gap-4 mt-3 pt-3 border-t border-border/50">
+ <Calendar className="w-4 h-4 text-muted-foreground flex-shrink-0" />
+ <div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
+ {bidding.submissionStartDate && bidding.submissionEndDate && (
+ <div>
+ <span className="font-medium">제출기간:</span> {formatDate(bidding.submissionStartDate, 'KR')} ~ {formatDate(bidding.submissionEndDate, 'KR')}
+ </div>
+ )}
+ {bidding.evaluationDate && (
+ <div>
+ <span className="font-medium">평가일:</span> {formatDate(bidding.evaluationDate, 'KR')}
+ </div>
+ )}
+ {bidding.preQuoteDate && (
+ <div>
+ <span className="font-medium">사전견적일:</span> {formatDate(bidding.preQuoteDate, 'KR')}
+ </div>
+ )}
+ {bidding.biddingRegistrationDate && (
+ <div>
+ <span className="font-medium">입찰등록일:</span> {formatDate(bidding.biddingRegistrationDate, 'KR')}
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/bidding/detail/table/bidding-detail-items-dialog.tsx b/lib/bidding/detail/table/bidding-detail-items-dialog.tsx
new file mode 100644
index 00000000..2bab3ef0
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-items-dialog.tsx
@@ -0,0 +1,138 @@
+'use client'
+
+import * as React from 'react'
+import { Bidding } from '@/db/schema'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Badge } from '@/components/ui/badge'
+import { formatDate } from '@/lib/utils'
+
+interface PrItem {
+ id: number
+ biddingId: number
+ itemName: string
+ itemCode: string
+ specification: string
+ quantity: number
+ unit: string
+ estimatedPrice: number
+ budget: number
+ deliveryDate: Date
+ notes: string
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface BiddingDetailItemsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ prItems: PrItem[]
+ bidding: Bidding
+}
+
+export function BiddingDetailItemsDialog({
+ open,
+ onOpenChange,
+ prItems,
+ bidding
+}: BiddingDetailItemsDialogProps) {
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-6xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>품목 정보</DialogTitle>
+ <DialogDescription>
+ 입찰번호: {bidding.biddingNumber} - 품목 상세 정보
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">프로젝트:</span> {bidding.projectName || '-'}
+ </div>
+ <div>
+ <span className="font-medium">품목:</span> {bidding.itemName || '-'}
+ </div>
+ </div>
+
+ <div className="border rounded-lg">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>품목코드</TableHead>
+ <TableHead>품목명</TableHead>
+ <TableHead>규격</TableHead>
+ <TableHead className="text-right">수량</TableHead>
+ <TableHead>단위</TableHead>
+ <TableHead className="text-right">예상단가</TableHead>
+ <TableHead className="text-right">예산</TableHead>
+ <TableHead>납기요청일</TableHead>
+ <TableHead>비고</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {prItems.length > 0 ? (
+ prItems.map((item) => (
+ <TableRow key={item.id}>
+ <TableCell className="font-mono text-sm">
+ {item.itemCode}
+ </TableCell>
+ <TableCell className="font-medium">
+ {item.itemName}
+ </TableCell>
+ <TableCell className="text-sm">
+ {item.specification || '-'}
+ </TableCell>
+ <TableCell className="text-right">
+ {item.quantity ? Number(item.quantity).toLocaleString() : '-'}
+ </TableCell>
+ <TableCell>{item.unit}</TableCell>
+ <TableCell className="text-right font-mono">
+ {item.estimatedPrice ? Number(item.estimatedPrice).toLocaleString() : '-'} {bidding.currency}
+ </TableCell>
+ <TableCell className="text-right font-mono">
+ {item.budget ? Number(item.budget).toLocaleString() : '-'} {bidding.currency}
+ </TableCell>
+ <TableCell className="text-sm">
+ {item.deliveryDate ? formatDate(item.deliveryDate, 'KR') : '-'}
+ </TableCell>
+ <TableCell className="text-sm">
+ {item.notes || '-'}
+ </TableCell>
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell colSpan={9} className="text-center py-8">
+ 등록된 품목이 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+
+ {prItems.length > 0 && (
+ <div className="text-sm text-muted-foreground">
+ 총 {prItems.length}개 품목
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx b/lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx
new file mode 100644
index 00000000..0e7ca364
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx
@@ -0,0 +1,167 @@
+'use client'
+
+import * as React from 'react'
+import { Bidding } from '@/db/schema'
+import { updateVendorSelectionReason } from '@/lib/bidding/detail/service'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Textarea } from '@/components/ui/textarea'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface BiddingDetailSelectionReasonDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ bidding: Bidding
+ onSuccess: () => void
+}
+
+export function BiddingDetailSelectionReasonDialog({
+ open,
+ onOpenChange,
+ bidding,
+ onSuccess
+}: BiddingDetailSelectionReasonDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [selectedCompanyId, setSelectedCompanyId] = React.useState<number | null>(null)
+ const [selectionReason, setSelectionReason] = React.useState('')
+
+ // 낙찰된 업체 정보 조회 (실제로는 bidding_companies에서 isWinner가 true인 업체를 조회해야 함)
+ React.useEffect(() => {
+ if (open) {
+ // TODO: 실제로는 낙찰된 업체 정보를 조회하여 selectedCompanyId를 설정
+ setSelectedCompanyId(null)
+ setSelectionReason('')
+ }
+ }, [open])
+
+ const handleSave = () => {
+ if (!selectedCompanyId) {
+ toast({
+ title: '유효성 오류',
+ description: '선정된 업체를 선택해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ if (!selectionReason.trim()) {
+ toast({
+ title: '유효성 오류',
+ description: '선정 사유를 입력해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ const result = await updateVendorSelectionReason(
+ bidding.id,
+ selectedCompanyId,
+ selectionReason,
+ 'current-user' // TODO: 실제 사용자 ID
+ )
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: result.message,
+ })
+ onSuccess()
+ onOpenChange(false)
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <DialogTitle>업체 선정 사유</DialogTitle>
+ <DialogDescription>
+ 입찰번호: {bidding.biddingNumber} - 낙찰 업체 선정 사유 입력
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* 낙찰 정보 */}
+ <div className="space-y-4">
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <Label htmlFor="biddingNumber">입찰번호</Label>
+ <div className="text-sm font-mono mt-1 p-2 bg-muted rounded">
+ {bidding.biddingNumber}
+ </div>
+ </div>
+ <div>
+ <Label htmlFor="projectName">프로젝트명</Label>
+ <div className="text-sm mt-1 p-2 bg-muted rounded">
+ {bidding.projectName || '-'}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 선정 업체 선택 */}
+ <div className="space-y-2">
+ <Label htmlFor="selectedCompany">선정된 업체</Label>
+ <Select
+ value={selectedCompanyId?.toString() || ''}
+ onValueChange={(value) => setSelectedCompanyId(Number(value))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="선정된 업체를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {/* TODO: 실제로는 낙찰된 업체 목록을 조회하여 표시 */}
+ <SelectItem value="1">업체 A</SelectItem>
+ <SelectItem value="2">업체 B</SelectItem>
+ <SelectItem value="3">업체 C</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 선정 사유 입력 */}
+ <div className="space-y-2">
+ <Label htmlFor="selectionReason">선정 사유</Label>
+ <Textarea
+ id="selectionReason"
+ value={selectionReason}
+ onChange={(e) => setSelectionReason(e.target.value)}
+ placeholder="업체 선정 사유를 상세히 입력해주세요."
+ rows={6}
+ />
+ <div className="text-sm text-muted-foreground">
+ 선정 사유는 추후 검토 및 감사에 활용됩니다. 구체적인 선정 기준과 이유를 명확히 기재해주세요.
+ </div>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleSave} disabled={isPending}>
+ 저장
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx b/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx
new file mode 100644
index 00000000..b9dd44dd
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx
@@ -0,0 +1,238 @@
+'use client'
+
+import * as React from 'react'
+import { Bidding } from '@/db/schema'
+import { QuotationDetails, updateTargetPrice } from '@/lib/bidding/detail/service'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface BiddingDetailTargetPriceDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ quotationDetails: QuotationDetails | null
+ bidding: Bidding
+ onSuccess: () => void
+}
+
+export function BiddingDetailTargetPriceDialog({
+ open,
+ onOpenChange,
+ quotationDetails,
+ bidding,
+ onSuccess
+}: BiddingDetailTargetPriceDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [targetPrice, setTargetPrice] = React.useState(
+ bidding.targetPrice ? Number(bidding.targetPrice) : 0
+ )
+ const [calculationCriteria, setCalculationCriteria] = React.useState(
+ (bidding as any).targetPriceCalculationCriteria || ''
+ )
+
+ // Dialog가 열릴 때 상태 초기화
+ React.useEffect(() => {
+ if (open) {
+ setTargetPrice(bidding.targetPrice ? Number(bidding.targetPrice) : 0)
+ setCalculationCriteria((bidding as any).targetPriceCalculationCriteria || '')
+ }
+ }, [open, bidding])
+
+ const handleSave = () => {
+ // 필수값 검증
+ if (targetPrice <= 0) {
+ toast({
+ title: '유효성 오류',
+ description: '내정가는 0보다 큰 값을 입력해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ if (!calculationCriteria.trim()) {
+ toast({
+ title: '유효성 오류',
+ description: '내정가 산정 기준을 입력해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ const result = await updateTargetPrice(
+ bidding.id,
+ targetPrice,
+ calculationCriteria.trim(),
+ 'current-user' // TODO: 실제 사용자 ID
+ )
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: result.message,
+ })
+ onSuccess()
+ onOpenChange(false)
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const formatCurrency = (amount: number) => {
+ return new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: bidding.currency || 'KRW',
+ }).format(amount)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[800px]">
+ <DialogHeader>
+ <DialogTitle>내정가 산정</DialogTitle>
+ <DialogDescription>
+ 입찰번호: {bidding.biddingNumber} - 견적 통계 및 내정가 설정
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[200px]">항목</TableHead>
+ <TableHead>값</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {/* 견적 통계 정보 */}
+ <TableRow>
+ <TableCell className="font-medium">예상액</TableCell>
+ <TableCell className="font-semibold">
+ {quotationDetails?.estimatedPrice ? formatCurrency(quotationDetails.estimatedPrice) : '-'}
+ </TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">최저견적가</TableCell>
+ <TableCell className="font-semibold text-green-600">
+ {quotationDetails?.lowestQuote ? formatCurrency(quotationDetails.lowestQuote) : '-'}
+ </TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">평균견적가</TableCell>
+ <TableCell className="font-semibold">
+ {quotationDetails?.averageQuote ? formatCurrency(quotationDetails.averageQuote) : '-'}
+ </TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">견적 수</TableCell>
+ <TableCell className="font-semibold">
+ {quotationDetails?.quotationCount || 0}개
+ </TableCell>
+ </TableRow>
+
+ {/* 예산 정보 */}
+ {bidding.budget && (
+ <TableRow>
+ <TableCell className="font-medium">예산</TableCell>
+ <TableCell className="font-semibold">
+ {formatCurrency(Number(bidding.budget))}
+ </TableCell>
+ </TableRow>
+ )}
+
+ {/* 최종 업데이트 시간 */}
+ {quotationDetails?.lastUpdated && (
+ <TableRow>
+ <TableCell className="font-medium">최종 업데이트</TableCell>
+ <TableCell className="text-sm text-muted-foreground">
+ {new Date(quotationDetails.lastUpdated).toLocaleString('ko-KR')}
+ </TableCell>
+ </TableRow>
+ )}
+
+ {/* 내정가 입력 */}
+ <TableRow>
+ <TableCell className="font-medium">
+ <Label htmlFor="targetPrice" className="text-sm font-medium">
+ 내정가 *
+ </Label>
+ </TableCell>
+ <TableCell>
+ <div className="space-y-2">
+ <Input
+ id="targetPrice"
+ type="number"
+ value={targetPrice}
+ onChange={(e) => setTargetPrice(Number(e.target.value))}
+ placeholder="내정가를 입력하세요"
+ className="w-full"
+ />
+ <div className="text-sm text-muted-foreground">
+ {targetPrice > 0 ? formatCurrency(targetPrice) : ''}
+ </div>
+ </div>
+ </TableCell>
+ </TableRow>
+
+ {/* 내정가 산정 기준 입력 */}
+ <TableRow>
+ <TableCell className="font-medium align-top pt-2">
+ <Label htmlFor="calculationCriteria" className="text-sm font-medium">
+ 내정가 산정 기준 *
+ </Label>
+ </TableCell>
+ <TableCell>
+ <Textarea
+ id="calculationCriteria"
+ value={calculationCriteria}
+ onChange={(e) => setCalculationCriteria(e.target.value)}
+ placeholder="내정가 산정 기준을 자세히 입력해주세요. (예: 최저견적가 대비 10% 상향 조정, 시장 평균가 고려 등)"
+ className="w-full min-h-[100px]"
+ rows={4}
+ />
+ <div className="text-xs text-muted-foreground mt-1">
+ 필수 입력 사항입니다. 내정가 산정에 대한 근거를 명확히 기재해주세요.
+ </div>
+ </TableCell>
+ </TableRow>
+ </TableBody>
+ </Table>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleSave} disabled={isPending}>
+ 저장
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
new file mode 100644
index 00000000..ef075459
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
@@ -0,0 +1,223 @@
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ MoreHorizontal, Edit, Trash2, Trophy
+} from "lucide-react"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { QuotationVendor } from "@/lib/bidding/detail/service"
+
+interface GetVendorColumnsProps {
+ onEdit: (vendor: QuotationVendor) => void
+ onDelete: (vendor: QuotationVendor) => void
+ onSelectWinner: (vendor: QuotationVendor) => void
+}
+
+export function getBiddingDetailVendorColumns({
+ onEdit,
+ onDelete,
+ onSelectWinner
+}: GetVendorColumnsProps): ColumnDef<QuotationVendor>[] {
+ return [
+ {
+ id: 'select',
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getIsAllPageRowsSelected()}
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모두 선택"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="행 선택"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: 'vendorName',
+ header: '업체명',
+ cell: ({ row }) => (
+ <div className="font-medium">{row.original.vendorName}</div>
+ ),
+ },
+ {
+ accessorKey: 'vendorCode',
+ header: '업체코드',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.original.vendorCode}</div>
+ ),
+ },
+ {
+ accessorKey: 'contactPerson',
+ header: '담당자',
+ cell: ({ row }) => (
+ <div className="text-sm">{row.original.contactPerson || '-'}</div>
+ ),
+ },
+ {
+ accessorKey: 'quotationAmount',
+ header: '견적금액',
+ cell: ({ row }) => (
+ <div className="text-right font-mono">
+ {row.original.quotationAmount ? Number(row.original.quotationAmount).toLocaleString() : '-'} {row.original.currency}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'awardRatio',
+ header: '발주비율',
+ cell: ({ row }) => (
+ <div className="text-right">
+ {row.original.awardRatio ? `${row.original.awardRatio}%` : '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'status',
+ header: '상태',
+ cell: ({ row }) => {
+ const status = row.original.status
+ const variant = status === 'selected' ? 'default' :
+ status === 'submitted' ? 'secondary' :
+ status === 'rejected' ? 'destructive' : 'outline'
+
+ const label = status === 'selected' ? '선정' :
+ status === 'submitted' ? '제출' :
+ status === 'rejected' ? '거절' : '대기'
+
+ return <Badge variant={variant}>{label}</Badge>
+ },
+ },
+ {
+ accessorKey: 'submissionDate',
+ header: '제출일',
+ cell: ({ row }) => (
+ <div className="text-sm">
+ {row.original.submissionDate ? new Date(row.original.submissionDate).toLocaleDateString('ko-KR') : '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'offeredPaymentTerms',
+ header: '지급조건',
+ cell: ({ row }) => {
+ const terms = row.original.offeredPaymentTerms
+ if (!terms) return <div className="text-muted-foreground">-</div>
+
+ try {
+ const parsed = JSON.parse(terms)
+ return (
+ <div className="text-sm max-w-32 truncate" title={parsed.join(', ')}>
+ {parsed.join(', ')}
+ </div>
+ )
+ } catch {
+ return <div className="text-sm max-w-32 truncate">{terms}</div>
+ }
+ },
+ },
+ {
+ accessorKey: 'offeredTaxConditions',
+ header: '세금조건',
+ cell: ({ row }) => {
+ const conditions = row.original.offeredTaxConditions
+ if (!conditions) return <div className="text-muted-foreground">-</div>
+
+ try {
+ const parsed = JSON.parse(conditions)
+ return (
+ <div className="text-sm max-w-32 truncate" title={parsed.join(', ')}>
+ {parsed.join(', ')}
+ </div>
+ )
+ } catch {
+ return <div className="text-sm max-w-32 truncate">{conditions}</div>
+ }
+ },
+ },
+ {
+ accessorKey: 'offeredIncoterms',
+ header: '운송조건',
+ cell: ({ row }) => {
+ const terms = row.original.offeredIncoterms
+ if (!terms) return <div className="text-muted-foreground">-</div>
+
+ try {
+ const parsed = JSON.parse(terms)
+ return (
+ <div className="text-sm max-w-24 truncate" title={parsed.join(', ')}>
+ {parsed.join(', ')}
+ </div>
+ )
+ } catch {
+ return <div className="text-sm max-w-24 truncate">{terms}</div>
+ }
+ },
+ },
+ {
+ accessorKey: 'offeredContractDeliveryDate',
+ header: '납품요청일',
+ cell: ({ row }) => (
+ <div className="text-sm">
+ {row.original.offeredContractDeliveryDate ?
+ new Date(row.original.offeredContractDeliveryDate).toLocaleDateString('ko-KR') : '-'}
+ </div>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '작업',
+ cell: ({ row }) => {
+ const vendor = row.original
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="h-8 w-8 p-0">
+ <span className="sr-only">메뉴 열기</span>
+ <MoreHorizontal className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuLabel>작업</DropdownMenuLabel>
+ <DropdownMenuItem onClick={() => onEdit(vendor)}>
+ <Edit className="mr-2 h-4 w-4" />
+ 수정
+ </DropdownMenuItem>
+ {vendor.status !== 'selected' && (
+ <DropdownMenuItem onClick={() => onSelectWinner(vendor)}>
+ <Trophy className="mr-2 h-4 w-4" />
+ 낙찰 선정
+ </DropdownMenuItem>
+ )}
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onClick={() => onDelete(vendor)}
+ className="text-destructive"
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ },
+ ]
+}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx
new file mode 100644
index 00000000..9229b09c
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx
@@ -0,0 +1,335 @@
+'use client'
+
+import * as React from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from '@/components/ui/command'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import { Check, ChevronsUpDown, Search } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { createQuotationVendor } from '@/lib/bidding/detail/service'
+import { createQuotationVendorSchema } from '@/lib/bidding/validation'
+import { searchVendors } from '@/lib/vendors/service'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface BiddingDetailVendorCreateDialogProps {
+ biddingId: number
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSuccess: () => void
+}
+
+interface Vendor {
+ id: number
+ vendorName: string
+ vendorCode: string
+ status: string
+}
+
+export function BiddingDetailVendorCreateDialog({
+ biddingId,
+ open,
+ onOpenChange,
+ onSuccess
+}: BiddingDetailVendorCreateDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+
+ // Vendor 검색 상태
+ const [vendors, setVendors] = React.useState<Vendor[]>([])
+ const [selectedVendor, setSelectedVendor] = React.useState<Vendor | null>(null)
+ const [vendorSearchOpen, setVendorSearchOpen] = React.useState(false)
+ const [vendorSearchValue, setVendorSearchValue] = React.useState('')
+
+ // 폼 상태
+ const [formData, setFormData] = React.useState({
+ quotationAmount: 0,
+ currency: 'KRW',
+ paymentTerms: '',
+ taxConditions: '',
+ deliveryDate: '',
+ awardRatio: 0,
+ status: 'pending' as const,
+ })
+
+ // Vendor 검색
+ React.useEffect(() => {
+ const search = async () => {
+ if (vendorSearchValue.trim().length < 2) {
+ setVendors([])
+ return
+ }
+
+ try {
+ const result = await searchVendors(vendorSearchValue.trim(), 10)
+ setVendors(result)
+ } catch (error) {
+ console.error('Vendor search failed:', error)
+ setVendors([])
+ }
+ }
+
+ const debounceTimer = setTimeout(search, 300)
+ return () => clearTimeout(debounceTimer)
+ }, [vendorSearchValue])
+
+ const handleVendorSelect = (vendor: Vendor) => {
+ setSelectedVendor(vendor)
+ setVendorSearchValue(`${vendor.vendorName} (${vendor.vendorCode})`)
+ setVendorSearchOpen(false)
+ }
+
+ const handleCreate = () => {
+ if (!selectedVendor) {
+ toast({
+ title: '오류',
+ description: '업체를 선택해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ const result = createQuotationVendorSchema.safeParse({
+ biddingId,
+ vendorId: selectedVendor.id,
+ vendorName: selectedVendor.vendorName,
+ vendorCode: selectedVendor.vendorCode,
+ contactPerson: '',
+ contactEmail: '',
+ contactPhone: '',
+ ...formData,
+ })
+
+ if (!result.success) {
+ toast({
+ title: '유효성 오류',
+ description: result.error.issues[0]?.message || '입력값을 확인해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ const response = await createQuotationVendor(result.data, 'current-user')
+
+ if (response.success) {
+ toast({
+ title: '성공',
+ description: response.message,
+ })
+ onOpenChange(false)
+ resetForm()
+ onSuccess()
+ } else {
+ toast({
+ title: '오류',
+ description: response.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const resetForm = () => {
+ setSelectedVendor(null)
+ setVendorSearchValue('')
+ setFormData({
+ quotationAmount: 0,
+ currency: 'KRW',
+ paymentTerms: '',
+ taxConditions: '',
+ deliveryDate: '',
+ awardRatio: 0,
+ status: 'pending',
+ })
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <DialogTitle>협력업체 추가</DialogTitle>
+ <DialogDescription>
+ 검색해서 업체를 선택하고 견적 정보를 입력해주세요.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ {/* Vendor 검색 */}
+ <div className="space-y-2">
+ <Label htmlFor="vendor-search">업체 검색</Label>
+ <Popover open={vendorSearchOpen} onOpenChange={setVendorSearchOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={vendorSearchOpen}
+ className="w-full justify-between"
+ >
+ {selectedVendor
+ ? `${selectedVendor.vendorName} (${selectedVendor.vendorCode})`
+ : "업체를 검색해서 선택하세요..."}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0">
+ <Command>
+ <CommandInput
+ placeholder="업체명 또는 코드를 입력하세요..."
+ value={vendorSearchValue}
+ onValueChange={setVendorSearchValue}
+ />
+ <CommandEmpty>
+ {vendorSearchValue.length < 2
+ ? "최소 2자 이상 입력해주세요"
+ : "검색 결과가 없습니다"}
+ </CommandEmpty>
+ <CommandGroup className="max-h-64 overflow-auto">
+ {vendors.map((vendor) => (
+ <CommandItem
+ key={vendor.id}
+ value={`${vendor.vendorName} ${vendor.vendorCode}`}
+ onSelect={() => handleVendorSelect(vendor)}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ selectedVendor?.id === vendor.id ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <div className="flex flex-col">
+ <span className="font-medium">{vendor.vendorName}</span>
+ <span className="text-sm text-muted-foreground">{vendor.vendorCode}</span>
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ {/* 견적 정보 입력 */}
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="quotationAmount">견적금액</Label>
+ <Input
+ id="quotationAmount"
+ type="number"
+ value={formData.quotationAmount}
+ onChange={(e) => setFormData({ ...formData, quotationAmount: Number(e.target.value) })}
+ placeholder="견적금액을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="currency">통화</Label>
+ <Select value={formData.currency} onValueChange={(value) => setFormData({ ...formData, currency: value })}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="awardRatio">발주비율 (%)</Label>
+ <Input
+ id="awardRatio"
+ type="number"
+ min="0"
+ max="100"
+ value={formData.awardRatio}
+ onChange={(e) => setFormData({ ...formData, awardRatio: Number(e.target.value) })}
+ placeholder="발주비율을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="status">상태</Label>
+ <Select value={formData.status} onValueChange={(value: any) => setFormData({ ...formData, status: value })}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="pending">대기</SelectItem>
+ <SelectItem value="submitted">제출</SelectItem>
+ <SelectItem value="selected">선정</SelectItem>
+ <SelectItem value="rejected">거절</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="paymentTerms">지급조건</Label>
+ <Input
+ id="paymentTerms"
+ value={formData.paymentTerms}
+ onChange={(e) => setFormData({ ...formData, paymentTerms: e.target.value })}
+ placeholder="지급조건을 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="taxConditions">세금조건</Label>
+ <Input
+ id="taxConditions"
+ value={formData.taxConditions}
+ onChange={(e) => setFormData({ ...formData, taxConditions: e.target.value })}
+ placeholder="세금조건을 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="deliveryDate">납품일</Label>
+ <Input
+ id="deliveryDate"
+ type="date"
+ value={formData.deliveryDate}
+ onChange={(e) => setFormData({ ...formData, deliveryDate: e.target.value })}
+ />
+ </div>
+ </div>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleCreate} disabled={isPending || !selectedVendor}>
+ 추가
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx
new file mode 100644
index 00000000..a48aadd2
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx
@@ -0,0 +1,260 @@
+'use client'
+
+import * as React from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { updateQuotationVendor } from '@/lib/bidding/detail/service'
+import { updateQuotationVendorSchema } from '@/lib/bidding/validation'
+import { QuotationVendor } from '@/lib/bidding/detail/service'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface BiddingDetailVendorEditDialogProps {
+ vendor: QuotationVendor | null
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSuccess: () => void
+}
+
+export function BiddingDetailVendorEditDialog({
+ vendor,
+ open,
+ onOpenChange,
+ onSuccess
+}: BiddingDetailVendorEditDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+
+ // 폼 상태
+ const [formData, setFormData] = React.useState({
+ vendorName: '',
+ vendorCode: '',
+ contactPerson: '',
+ contactEmail: '',
+ contactPhone: '',
+ quotationAmount: 0,
+ currency: 'KRW',
+ paymentTerms: '',
+ taxConditions: '',
+ deliveryDate: '',
+ awardRatio: 0,
+ status: 'pending' as const,
+ })
+
+ // vendor가 변경되면 폼 데이터 업데이트
+ React.useEffect(() => {
+ if (vendor) {
+ setFormData({
+ vendorName: vendor.vendorName,
+ vendorCode: vendor.vendorCode,
+ contactPerson: vendor.contactPerson || '',
+ contactEmail: vendor.contactEmail || '',
+ contactPhone: vendor.contactPhone || '',
+ quotationAmount: vendor.quotationAmount,
+ currency: vendor.currency,
+ paymentTerms: vendor.paymentTerms || '',
+ taxConditions: vendor.taxConditions || '',
+ deliveryDate: vendor.deliveryDate || '',
+ awardRatio: vendor.awardRatio || 0,
+ status: vendor.status,
+ })
+ }
+ }, [vendor])
+
+ const handleEdit = () => {
+ if (!vendor) return
+
+ const result = updateQuotationVendorSchema.safeParse({
+ id: vendor.id,
+ ...formData,
+ })
+
+ if (!result.success) {
+ toast({
+ title: '유효성 오류',
+ description: result.error.issues[0]?.message || '입력값을 확인해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ const response = await updateQuotationVendor(vendor.id, result.data, 'current-user')
+
+ if (response.success) {
+ toast({
+ title: '성공',
+ description: response.message,
+ })
+ onOpenChange(false)
+ onSuccess()
+ } else {
+ toast({
+ title: '오류',
+ description: response.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <DialogTitle>협력업체 수정</DialogTitle>
+ <DialogDescription>
+ 협력업체 정보를 수정해주세요.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="edit-vendorName">업체명</Label>
+ <Input
+ id="edit-vendorName"
+ value={formData.vendorName}
+ onChange={(e) => setFormData({ ...formData, vendorName: e.target.value })}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-vendorCode">업체코드</Label>
+ <Input
+ id="edit-vendorCode"
+ value={formData.vendorCode}
+ onChange={(e) => setFormData({ ...formData, vendorCode: e.target.value })}
+ />
+ </div>
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="edit-contactPerson">담당자</Label>
+ <Input
+ id="edit-contactPerson"
+ value={formData.contactPerson}
+ onChange={(e) => setFormData({ ...formData, contactPerson: e.target.value })}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-contactEmail">이메일</Label>
+ <Input
+ id="edit-contactEmail"
+ type="email"
+ value={formData.contactEmail}
+ onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-contactPhone">연락처</Label>
+ <Input
+ id="edit-contactPhone"
+ value={formData.contactPhone}
+ onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })}
+ />
+ </div>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="edit-quotationAmount">견적금액</Label>
+ <Input
+ id="edit-quotationAmount"
+ type="number"
+ value={formData.quotationAmount}
+ onChange={(e) => setFormData({ ...formData, quotationAmount: Number(e.target.value) })}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-currency">통화</Label>
+ <Select value={formData.currency} onValueChange={(value) => setFormData({ ...formData, currency: value })}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="edit-awardRatio">발주비율 (%)</Label>
+ <Input
+ id="edit-awardRatio"
+ type="number"
+ min="0"
+ max="100"
+ value={formData.awardRatio}
+ onChange={(e) => setFormData({ ...formData, awardRatio: Number(e.target.value) })}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-status">상태</Label>
+ <Select value={formData.status} onValueChange={(value: any) => setFormData({ ...formData, status: value })}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="pending">대기</SelectItem>
+ <SelectItem value="submitted">제출</SelectItem>
+ <SelectItem value="selected">선정</SelectItem>
+ <SelectItem value="rejected">거절</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-paymentTerms">지급조건</Label>
+ <Input
+ id="edit-paymentTerms"
+ value={formData.paymentTerms}
+ onChange={(e) => setFormData({ ...formData, paymentTerms: e.target.value })}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-taxConditions">세금조건</Label>
+ <Input
+ id="edit-taxConditions"
+ value={formData.taxConditions}
+ onChange={(e) => setFormData({ ...formData, taxConditions: e.target.value })}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-deliveryDate">납품일</Label>
+ <Input
+ id="edit-deliveryDate"
+ type="date"
+ value={formData.deliveryDate}
+ onChange={(e) => setFormData({ ...formData, deliveryDate: e.target.value })}
+ />
+ </div>
+ </div>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleEdit} disabled={isPending}>
+ 수정
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
new file mode 100644
index 00000000..7ad7056c
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
@@ -0,0 +1,225 @@
+'use client'
+
+import * as React from 'react'
+import { type DataTableAdvancedFilterField, type DataTableFilterField } from '@/types/table'
+import { useDataTable } from '@/hooks/use-data-table'
+import { DataTable } from '@/components/data-table/data-table'
+import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar'
+import { BiddingDetailVendorToolbarActions } from './bidding-detail-vendor-toolbar-actions'
+import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog'
+import { BiddingDetailVendorEditDialog } from './bidding-detail-vendor-edit-dialog'
+import { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns'
+import { QuotationVendor } from '@/lib/bidding/detail/service'
+import {
+ deleteQuotationVendor,
+ selectWinner
+} from '@/lib/bidding/detail/service'
+import { selectWinnerSchema } from '@/lib/bidding/validation'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface BiddingDetailVendorTableContentProps {
+ biddingId: number
+ vendors: QuotationVendor[]
+ onRefresh: () => void
+ onOpenItemsDialog: () => void
+ onOpenTargetPriceDialog: () => void
+ onOpenSelectionReasonDialog: () => void
+ onEdit?: (vendor: QuotationVendor) => void
+ onDelete?: (vendor: QuotationVendor) => void
+ onSelectWinner?: (vendor: QuotationVendor) => void
+}
+
+const filterFields: DataTableFilterField<QuotationVendor>[] = [
+ {
+ id: 'vendorName',
+ label: '업체명',
+ placeholder: '업체명으로 검색...',
+ },
+ {
+ id: 'vendorCode',
+ label: '업체코드',
+ placeholder: '업체코드로 검색...',
+ },
+ {
+ id: 'contactPerson',
+ label: '담당자',
+ placeholder: '담당자로 검색...',
+ },
+]
+
+const advancedFilterFields: DataTableAdvancedFilterField<QuotationVendor>[] = [
+ {
+ id: 'vendorName',
+ label: '업체명',
+ type: 'text',
+ },
+ {
+ id: 'vendorCode',
+ label: '업체코드',
+ type: 'text',
+ },
+ {
+ id: 'contactPerson',
+ label: '담당자',
+ type: 'text',
+ },
+ {
+ id: 'quotationAmount',
+ label: '견적금액',
+ type: 'number',
+ },
+ {
+ id: 'status',
+ label: '상태',
+ type: 'multi-select',
+ options: [
+ { label: '제출완료', value: 'submitted' },
+ { label: '선정완료', value: 'selected' },
+ { label: '미제출', value: 'pending' },
+ ],
+ },
+]
+
+export function BiddingDetailVendorTableContent({
+ biddingId,
+ vendors,
+ onRefresh,
+ onOpenItemsDialog,
+ onOpenTargetPriceDialog,
+ onOpenSelectionReasonDialog,
+ onEdit,
+ onDelete,
+ onSelectWinner
+}: BiddingDetailVendorTableContentProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null)
+ const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
+
+ const handleDelete = (vendor: QuotationVendor) => {
+ if (!confirm(`${vendor.vendorName} 업체를 삭제하시겠습니까?`)) return
+
+ startTransition(async () => {
+ const response = await deleteQuotationVendor(vendor.id)
+
+ if (response.success) {
+ toast({
+ title: '성공',
+ description: response.message,
+ })
+ onRefresh()
+ } else {
+ toast({
+ title: '오류',
+ description: response.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const handleSelectWinner = (vendor: QuotationVendor) => {
+ if (!vendor.awardRatio || vendor.awardRatio <= 0) {
+ toast({
+ title: '오류',
+ description: '발주비율을 먼저 설정해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ if (!confirm(`${vendor.vendorName} 업체를 낙찰자로 선정하시겠습니까?`)) return
+
+ startTransition(async () => {
+ const result = selectWinnerSchema.safeParse({
+ biddingId,
+ vendorId: vendor.id,
+ awardRatio: vendor.awardRatio,
+ })
+
+ if (!result.success) {
+ toast({
+ title: '유효성 오류',
+ description: result.error.issues[0]?.message || '입력값을 확인해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ const response = await selectWinner(biddingId, vendor.id, vendor.awardRatio, 'current-user')
+
+ if (response.success) {
+ toast({
+ title: '성공',
+ description: response.message,
+ })
+ onRefresh()
+ } else {
+ toast({
+ title: '오류',
+ description: response.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const handleEdit = (vendor: QuotationVendor) => {
+ setSelectedVendor(vendor)
+ setIsEditDialogOpen(true)
+ }
+
+ const columns = React.useMemo(
+ () => getBiddingDetailVendorColumns({
+ onEdit: onEdit || handleEdit,
+ onDelete: onDelete || handleDelete,
+ onSelectWinner: onSelectWinner || handleSelectWinner
+ }),
+ [onEdit, onDelete, onSelectWinner, handleEdit, handleDelete, handleSelectWinner]
+ )
+
+ const { table } = useDataTable({
+ data: vendors,
+ columns,
+ pageCount: 1,
+ filterFields,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: 'vendorName', desc: false }],
+ columnPinning: { right: ['actions'] },
+ },
+ getRowId: (originalRow) => originalRow.id.toString(),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <BiddingDetailVendorToolbarActions
+ table={table}
+ biddingId={biddingId}
+ onOpenItemsDialog={onOpenItemsDialog}
+ onOpenTargetPriceDialog={onOpenTargetPriceDialog}
+ onOpenSelectionReasonDialog={onOpenSelectionReasonDialog}
+
+ onSuccess={onRefresh}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <BiddingDetailVendorEditDialog
+ vendor={selectedVendor}
+ open={isEditDialogOpen}
+ onOpenChange={setIsEditDialogOpen}
+ onSuccess={onRefresh}
+ />
+ </>
+ )
+}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
new file mode 100644
index 00000000..00daa005
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -0,0 +1,79 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Button } from "@/components/ui/button"
+import { Plus } from "lucide-react"
+import { QuotationVendor } from "@/lib/bidding/detail/service"
+import { BiddingDetailVendorCreateDialog } from "./bidding-detail-vendor-create-dialog"
+
+interface BiddingDetailVendorToolbarActionsProps {
+ table: Table<QuotationVendor>
+ biddingId: number
+ onOpenItemsDialog: () => void
+ onOpenTargetPriceDialog: () => void
+ onOpenSelectionReasonDialog: () => void
+
+ onSuccess: () => void
+}
+
+export function BiddingDetailVendorToolbarActions({
+ table,
+ biddingId,
+ onOpenItemsDialog,
+ onOpenTargetPriceDialog,
+ onOpenSelectionReasonDialog,
+ onSuccess
+}: BiddingDetailVendorToolbarActionsProps) {
+ const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false)
+
+ const handleCreateVendor = () => {
+ setIsCreateDialogOpen(true)
+ }
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onOpenItemsDialog}
+ >
+ 품목 정보
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onOpenTargetPriceDialog}
+ >
+ 내정가 산정
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onOpenSelectionReasonDialog}
+ >
+ 선정 사유
+ </Button>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleCreateVendor}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 업체 추가
+ </Button>
+ </div>
+
+ <BiddingDetailVendorCreateDialog
+ biddingId={biddingId}
+ open={isCreateDialogOpen}
+ onOpenChange={setIsCreateDialogOpen}
+ onSuccess={() => {
+ onSuccess()
+ setIsCreateDialogOpen(false)
+ }}
+ />
+ </>
+ )
+}
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index fde77bfb..c936de33 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -554,7 +554,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
<Eye className="mr-2 h-4 w-4" />
상세보기
</DropdownMenuItem>
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "edit" })}>
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "update" })}>
<Edit className="mr-2 h-4 w-4" />
수정
</DropdownMenuItem>
diff --git a/lib/bidding/list/biddings-table.tsx b/lib/bidding/list/biddings-table.tsx
index 672b756b..3b60c69b 100644
--- a/lib/bidding/list/biddings-table.tsx
+++ b/lib/bidding/list/biddings-table.tsx
@@ -57,6 +57,13 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
setSelectedBidding(rowAction.row.original)
switch (rowAction.type) {
+ case "view":
+ // 상세 페이지로 이동
+ router.push(`/evcp/bid/${rowAction.row.original.id}`)
+ break
+ case "update":
+ // EditBiddingSheet는 아래에서 별도로 처리
+ break
case "specification_meeting":
setSpecMeetingDialogOpen(true)
break
diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts
index 556395b5..5dec3ab3 100644
--- a/lib/bidding/validation.ts
+++ b/lib/bidding/validation.ts
@@ -155,3 +155,67 @@ export const createBiddingSchema = z.object({
export type GetBiddingsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
export type CreateBiddingSchema = z.infer<typeof createBiddingSchema>
export type UpdateBiddingSchema = z.infer<typeof updateBiddingSchema>
+
+ // === 상세 페이지용 검증 스키마들 ===
+
+ // 내정가 업데이트 스키마
+ export const updateTargetPriceSchema = z.object({
+ biddingId: z.number().int().positive('입찰 ID는 필수입니다'),
+ targetPrice: z.number().min(0, '내정가는 0 이상이어야 합니다'),
+ })
+
+ // 협력업체 정보 생성 스키마
+ export const createQuotationVendorSchema = z.object({
+ biddingId: z.number().int().positive('입찰 ID는 필수입니다'),
+ vendorId: z.number().int().positive('업체 ID는 필수입니다'),
+ vendorName: z.string().min(1, '업체명은 필수입니다'),
+ vendorCode: z.string().min(1, '업체코드는 필수입니다'),
+ contactPerson: z.string().optional(),
+ contactEmail: z.string().email().optional().or(z.literal('')),
+ contactPhone: z.string().optional(),
+ quotationAmount: z.number().min(0, '견적금액은 0 이상이어야 합니다'),
+ currency: z.string().min(1, '통화는 필수입니다').default('KRW'),
+ paymentTerms: z.string().optional(),
+ taxConditions: z.string().optional(),
+ deliveryDate: z.string().optional(),
+ awardRatio: z.number().min(0).max(100, '발주비율은 0-100 사이여야 합니다').optional(),
+ status: z.enum(['pending', 'submitted', 'selected', 'rejected']).default('pending'),
+ })
+
+ // 협력업체 정보 업데이트 스키마
+ export const updateQuotationVendorSchema = z.object({
+ id: z.number().int().positive('협력업체 ID는 필수입니다'),
+ vendorName: z.string().min(1, '업체명은 필수입니다').optional(),
+ vendorCode: z.string().min(1, '업체코드는 필수입니다').optional(),
+ contactPerson: z.string().optional(),
+ contactEmail: z.string().email().optional().or(z.literal('')),
+ contactPhone: z.string().optional(),
+ quotationAmount: z.number().min(0, '견적금액은 0 이상이어야 합니다').optional(),
+ currency: z.string().min(1, '통화는 필수입니다').optional(),
+ paymentTerms: z.string().optional(),
+ taxConditions: z.string().optional(),
+ deliveryDate: z.string().optional(),
+ awardRatio: z.number().min(0).max(100, '발주비율은 0-100 사이여야 합니다').optional(),
+ status: z.enum(['pending', 'submitted', 'selected', 'rejected']).optional(),
+ })
+
+ // 낙찰 선택 스키마
+ export const selectWinnerSchema = z.object({
+ biddingId: z.number().int().positive('입찰 ID는 필수입니다'),
+ vendorId: z.number().int().positive('업체 ID는 필수입니다'),
+ awardRatio: z.number().min(0).max(100, '발주비율은 0-100 사이여야 합니다'),
+ })
+
+ // 입찰 상태 변경 스키마
+ export const updateBiddingStatusSchema = z.object({
+ biddingId: z.number().int().positive('입찰 ID는 필수입니다'),
+ status: z.enum(biddings.status.enumValues, {
+ required_error: '입찰 상태는 필수입니다'
+ }),
+ })
+
+ export type UpdateTargetPriceSchema = z.infer<typeof updateTargetPriceSchema>
+ export type CreateQuotationVendorSchema = z.infer<typeof createQuotationVendorSchema>
+ export type UpdateQuotationVendorSchema = z.infer<typeof updateQuotationVendorSchema>
+ export type SelectWinnerSchema = z.infer<typeof selectWinnerSchema>
+ export type UpdateBiddingStatusSchema = z.infer<typeof updateBiddingStatusSchema>
diff --git a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
new file mode 100644
index 00000000..270d9ccd
--- /dev/null
+++ b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
@@ -0,0 +1,252 @@
+'use client'
+
+import * as React from 'react'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Label } from '@/components/ui/label'
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
+import { Textarea } from '@/components/ui/textarea'
+import { Badge } from '@/components/ui/badge'
+import {
+ Calendar,
+ Users,
+ MapPin,
+ Clock,
+ FileText,
+ CheckCircle,
+ XCircle
+} from 'lucide-react'
+
+import { formatDate } from '@/lib/utils'
+import { updatePartnerAttendance } from '../detail/service'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface PartnersBiddingAttendanceDialogProps {
+ biddingDetail: {
+ id: number
+ biddingNumber: string
+ title: string
+ preQuoteDate: string | null
+ biddingRegistrationDate: string | null
+ evaluationDate: string | null
+ } | null
+ biddingCompanyId: number
+ isAttending: boolean | null
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSuccess: () => void
+}
+
+export function PartnersBiddingAttendanceDialog({
+ biddingDetail,
+ biddingCompanyId,
+ isAttending,
+ open,
+ onOpenChange,
+ onSuccess,
+}: PartnersBiddingAttendanceDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [attendance, setAttendance] = React.useState<string>('')
+ const [comments, setComments] = React.useState<string>('')
+
+ // 다이얼로그 열릴 때 기존 값으로 초기화
+ React.useEffect(() => {
+ if (open) {
+ if (isAttending === true) {
+ setAttendance('attending')
+ } else if (isAttending === false) {
+ setAttendance('not_attending')
+ } else {
+ setAttendance('')
+ }
+ setComments('')
+ }
+ }, [open, isAttending])
+
+ const handleSubmit = () => {
+ if (!attendance) {
+ toast({
+ title: '선택 필요',
+ description: '참석 여부를 선택해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ const result = await updatePartnerAttendance(
+ biddingCompanyId,
+ attendance === 'attending',
+ 'current-user' // TODO: 실제 사용자 ID
+ )
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: result.message,
+ })
+ onSuccess()
+ onOpenChange(false)
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ if (!biddingDetail) return null
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Users className="w-5 h-5" />
+ 사양설명회 참석 여부
+ </DialogTitle>
+ <DialogDescription>
+ 입찰에 대한 사양설명회 참석 여부를 선택해주세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* 입찰 정보 요약 */}
+ <div className="bg-muted p-4 rounded-lg space-y-3">
+ <div className="flex items-center gap-2">
+ <FileText className="w-4 h-4 text-muted-foreground" />
+ <span className="font-medium">{biddingDetail.title}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Badge variant="outline" className="font-mono">
+ {biddingDetail.biddingNumber}
+ </Badge>
+ </div>
+
+ {/* 주요 일정 */}
+ <div className="grid grid-cols-1 gap-2 text-sm">
+ {biddingDetail.preQuoteDate && (
+ <div className="flex items-center gap-2">
+ <Calendar className="w-4 h-4 text-muted-foreground" />
+ <span>사전견적 마감: {formatDate(biddingDetail.preQuoteDate, 'KR')}</span>
+ </div>
+ )}
+ {biddingDetail.biddingRegistrationDate && (
+ <div className="flex items-center gap-2">
+ <Calendar className="w-4 h-4 text-muted-foreground" />
+ <span>입찰등록 마감: {formatDate(biddingDetail.biddingRegistrationDate, 'KR')}</span>
+ </div>
+ )}
+ {biddingDetail.evaluationDate && (
+ <div className="flex items-center gap-2">
+ <Calendar className="w-4 h-4 text-muted-foreground" />
+ <span>평가일: {formatDate(biddingDetail.evaluationDate, 'KR')}</span>
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* 참석 여부 선택 */}
+ <div className="space-y-3">
+ <Label className="text-base font-medium">참석 여부를 선택해주세요</Label>
+ <RadioGroup
+ value={attendance}
+ onValueChange={setAttendance}
+ className="space-y-3"
+ >
+ <div className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
+ <RadioGroupItem value="attending" id="attending" />
+ <div className="flex items-center gap-2 flex-1">
+ <CheckCircle className="w-5 h-5 text-green-600" />
+ <Label htmlFor="attending" className="font-medium cursor-pointer">
+ 참석합니다
+ </Label>
+ </div>
+ </div>
+ <div className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
+ <RadioGroupItem value="not_attending" id="not_attending" />
+ <div className="flex items-center gap-2 flex-1">
+ <XCircle className="w-5 h-5 text-red-600" />
+ <Label htmlFor="not_attending" className="font-medium cursor-pointer">
+ 참석하지 않습니다
+ </Label>
+ </div>
+ </div>
+ </RadioGroup>
+ </div>
+
+ {/* 참석하지 않는 경우 의견 */}
+ {attendance === 'not_attending' && (
+ <div className="space-y-2">
+ <Label htmlFor="comments">불참 사유 (선택사항)</Label>
+ <Textarea
+ id="comments"
+ value={comments}
+ onChange={(e) => setComments(e.target.value)}
+ placeholder="참석하지 않는 이유를 간단히 설명해주세요."
+ rows={3}
+ className="resize-none"
+ />
+ </div>
+ )}
+
+ {/* 참석하는 경우 추가 정보 */}
+ {attendance === 'attending' && (
+ <div className="bg-green-50 border border-green-200 rounded-lg p-4">
+ <div className="flex items-start gap-2">
+ <CheckCircle className="w-5 h-5 text-green-600 mt-0.5" />
+ <div className="space-y-1">
+ <p className="font-medium text-green-800">참석 확인</p>
+ <p className="text-sm text-green-700">
+ 사양설명회에 참석하겠다고 응답하셨습니다.
+ 회의 일정 및 장소는 추후 별도 안내드리겠습니다.
+ </p>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* 현재 상태 표시 */}
+ {isAttending !== null && (
+ <div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
+ <div className="flex items-center gap-2 text-blue-800">
+ <Clock className="w-4 h-4" />
+ <span className="text-sm">
+ 현재 상태: {isAttending ? '참석' : '불참'} ({formatDate(new Date().toISOString(), 'KR')} 기준)
+ </span>
+ </div>
+ </div>
+ )}
+ </div>
+
+ <DialogFooter className="flex gap-2">
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={isPending || !attendance}
+ className="min-w-[100px]"
+ >
+ {isPending ? '저장 중...' : '저장'}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx
new file mode 100644
index 00000000..4c4db37f
--- /dev/null
+++ b/lib/bidding/vendor/partners-bidding-detail.tsx
@@ -0,0 +1,562 @@
+'use client'
+
+import * as React from 'react'
+import { useRouter } from 'next/navigation'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+ ArrowLeft,
+ Calendar,
+ Building2,
+ Package,
+ User,
+ DollarSign,
+ FileText,
+ Users,
+ Send,
+ CheckCircle,
+ XCircle
+} from 'lucide-react'
+
+import { formatDate } from '@/lib/utils'
+import {
+ getBiddingDetailsForPartners,
+ submitPartnerResponse,
+ updatePartnerAttendance
+} from '../detail/service'
+import { PartnersBiddingAttendanceDialog } from './partners-bidding-attendance-dialog'
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+ biddingTypeLabels
+} from '@/db/schema'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface PartnersBiddingDetailProps {
+ biddingId: number
+ companyId: number
+}
+
+interface BiddingDetail {
+ id: number
+ biddingNumber: string
+ revision: number
+ projectName: string
+ itemName: string
+ title: string
+ description: string
+ content: string
+ contractType: string
+ biddingType: string
+ awardCount: string
+ contractPeriod: string
+ preQuoteDate: string
+ biddingRegistrationDate: string
+ submissionStartDate: string
+ submissionEndDate: string
+ evaluationDate: string
+ currency: string
+ budget: number
+ targetPrice: number
+ status: string
+ managerName: string
+ managerEmail: string
+ managerPhone: string
+ biddingCompanyId: number
+ biddingId: number // bidding의 ID 추가
+ invitationStatus: string
+ finalQuoteAmount: number
+ finalQuoteSubmittedAt: string
+ isWinner: boolean
+ isAttendingMeeting: boolean | null
+ offeredPaymentTerms: string
+ offeredTaxConditions: string
+ offeredIncoterms: string
+ offeredContractDeliveryDate: string
+ offeredShippingPort: string
+ offeredDestinationPort: string
+ isPriceAdjustmentApplicable: boolean
+ responsePaymentTerms: string
+ responseTaxConditions: string
+ responseIncoterms: string
+ proposedContractDeliveryDate: string
+ proposedShippingPort: string
+ proposedDestinationPort: string
+ priceAdjustmentResponse: boolean
+ additionalProposals: string
+ responseSubmittedAt: string
+}
+
+export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingDetailProps) {
+ const router = useRouter()
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [biddingDetail, setBiddingDetail] = React.useState<BiddingDetail | null>(null)
+ const [isLoading, setIsLoading] = React.useState(true)
+
+ // 응찰 폼 상태
+ const [responseData, setResponseData] = React.useState({
+ finalQuoteAmount: '',
+ paymentTermsResponse: '',
+ taxConditionsResponse: '',
+ incotermsResponse: '',
+ proposedContractDeliveryDate: '',
+ proposedShippingPort: '',
+ proposedDestinationPort: '',
+ priceAdjustmentResponse: false,
+ additionalProposals: '',
+ isAttendingMeeting: false,
+ })
+
+ // 사양설명회 참석 여부 다이얼로그 상태
+ const [isAttendanceDialogOpen, setIsAttendanceDialogOpen] = React.useState(false)
+
+ // 데이터 로드
+ React.useEffect(() => {
+ const loadData = async () => {
+ try {
+ setIsLoading(true)
+ const result = await getBiddingDetailsForPartners(biddingId, companyId)
+ if (result) {
+ setBiddingDetail(result)
+
+ // 기존 응답 데이터로 폼 초기화
+ setResponseData({
+ finalQuoteAmount: result.finalQuoteAmount?.toString() || '',
+ paymentTermsResponse: result.responsePaymentTerms || '',
+ taxConditionsResponse: result.responseTaxConditions || '',
+ incotermsResponse: result.responseIncoterms || '',
+ proposedContractDeliveryDate: result.proposedContractDeliveryDate || '',
+ proposedShippingPort: result.proposedShippingPort || '',
+ proposedDestinationPort: result.proposedDestinationPort || '',
+ priceAdjustmentResponse: result.priceAdjustmentResponse || false,
+ additionalProposals: result.additionalProposals || '',
+ isAttendingMeeting: false, // TODO: biddingCompanies에서 가져와야 함
+ })
+ }
+ } catch (error) {
+ console.error('Failed to load bidding detail:', error)
+ toast({
+ title: '오류',
+ description: '입찰 정보를 불러오는데 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadData()
+ }, [biddingId, companyId, toast])
+
+ const handleSubmitResponse = () => {
+ if (!biddingDetail) return
+
+ // 필수값 검증
+ if (!responseData.finalQuoteAmount.trim()) {
+ toast({
+ title: '유효성 오류',
+ description: '견적 금액을 입력해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ const result = await submitPartnerResponse(
+ biddingDetail.biddingCompanyId,
+ {
+ finalQuoteAmount: parseFloat(responseData.finalQuoteAmount),
+ paymentTermsResponse: responseData.paymentTermsResponse,
+ taxConditionsResponse: responseData.taxConditionsResponse,
+ incotermsResponse: responseData.incotermsResponse,
+ proposedContractDeliveryDate: responseData.proposedContractDeliveryDate,
+ proposedShippingPort: responseData.proposedShippingPort,
+ proposedDestinationPort: responseData.proposedDestinationPort,
+ priceAdjustmentResponse: responseData.priceAdjustmentResponse,
+ additionalProposals: responseData.additionalProposals,
+ },
+ 'current-user' // TODO: 실제 사용자 ID
+ )
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: result.message,
+ })
+
+ // 사양설명회 참석 여부도 업데이트
+ if (responseData.isAttendingMeeting !== undefined) {
+ await updatePartnerAttendance(
+ biddingDetail.biddingCompanyId,
+ responseData.isAttendingMeeting,
+ 'current-user'
+ )
+ }
+
+ // 데이터 새로고침
+ const updatedDetail = await getBiddingDetailsForPartners(biddingId, companyId)
+ if (updatedDetail) {
+ setBiddingDetail(updatedDetail)
+ }
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const formatCurrency = (amount: number) => {
+ return new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: biddingDetail?.currency || 'KRW',
+ }).format(amount)
+ }
+
+ if (isLoading) {
+ return (
+ <div className="flex items-center justify-center py-12">
+ <div className="text-center">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
+ <p className="text-muted-foreground">입찰 정보를 불러오는 중...</p>
+ </div>
+ </div>
+ )
+ }
+
+ if (!biddingDetail) {
+ return (
+ <div className="text-center py-12">
+ <p className="text-muted-foreground">입찰 정보를 찾을 수 없습니다.</p>
+ <Button onClick={() => router.back()} className="mt-4">
+ <ArrowLeft className="w-4 h-4 mr-2" />
+ 돌아가기
+ </Button>
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-6">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-4">
+ <Button variant="outline" onClick={() => router.back()}>
+ <ArrowLeft className="w-4 h-4 mr-2" />
+ 목록으로
+ </Button>
+ <div>
+ <h1 className="text-2xl font-semibold">{biddingDetail.title}</h1>
+ <div className="flex items-center gap-2 mt-1">
+ <Badge variant="outline" className="font-mono">
+ {biddingDetail.biddingNumber}
+ {biddingDetail.revision > 0 && ` Rev.${biddingDetail.revision}`}
+ </Badge>
+ <Badge variant={
+ biddingDetail.status === 'bidding_disposal' ? 'destructive' :
+ biddingDetail.status === 'vendor_selected' ? 'default' :
+ 'secondary'
+ }>
+ {biddingStatusLabels[biddingDetail.status]}
+ </Badge>
+ </div>
+ </div>
+ </div>
+
+ {/* 사양설명회 참석 여부 버튼 */}
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ onClick={() => setIsAttendanceDialogOpen(true)}
+ className="flex items-center gap-2"
+ >
+ <Users className="w-4 h-4" />
+ 사양설명회 참석
+ {biddingDetail.isAttendingMeeting !== null && (
+ <div className="ml-1">
+ {biddingDetail.isAttendingMeeting ? (
+ <CheckCircle className="w-4 h-4 text-green-600" />
+ ) : (
+ <XCircle className="w-4 h-4 text-red-600" />
+ )}
+ </div>
+ )}
+ </Button>
+ </div>
+ </div>
+
+ {/* 입찰 공고 섹션 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="w-5 h-5" />
+ 입찰 공고
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div>
+ <Label className="text-sm font-medium text-muted-foreground">프로젝트</Label>
+ <div className="flex items-center gap-2 mt-1">
+ <Building2 className="w-4 h-4" />
+ <span>{biddingDetail.projectName}</span>
+ </div>
+ </div>
+ <div>
+ <Label className="text-sm font-medium text-muted-foreground">품목</Label>
+ <div className="flex items-center gap-2 mt-1">
+ <Package className="w-4 h-4" />
+ <span>{biddingDetail.itemName}</span>
+ </div>
+ </div>
+ <div>
+ <Label className="text-sm font-medium text-muted-foreground">계약구분</Label>
+ <div className="mt-1">{contractTypeLabels[biddingDetail.contractType]}</div>
+ </div>
+ <div>
+ <Label className="text-sm font-medium text-muted-foreground">입찰유형</Label>
+ <div className="mt-1">{biddingTypeLabels[biddingDetail.biddingType]}</div>
+ </div>
+ <div>
+ <Label className="text-sm font-medium text-muted-foreground">낙찰수</Label>
+ <div className="mt-1">{biddingDetail.awardCount === 'single' ? '단수' : '복수'}</div>
+ </div>
+ <div>
+ <Label className="text-sm font-medium text-muted-foreground">담당자</Label>
+ <div className="flex items-center gap-2 mt-1">
+ <User className="w-4 h-4" />
+ <span>{biddingDetail.managerName}</span>
+ </div>
+ </div>
+ </div>
+
+ {biddingDetail.budget && (
+ <div>
+ <Label className="text-sm font-medium text-muted-foreground">예산</Label>
+ <div className="flex items-center gap-2 mt-1">
+ <DollarSign className="w-4 h-4" />
+ <span className="font-semibold">{formatCurrency(biddingDetail.budget)}</span>
+ </div>
+ </div>
+ )}
+
+ {/* 일정 정보 */}
+ <div className="pt-4 border-t">
+ <Label className="text-sm font-medium text-muted-foreground mb-2 block">일정 정보</Label>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
+ {biddingDetail.submissionStartDate && biddingDetail.submissionEndDate && (
+ <div>
+ <span className="font-medium">제출기간:</span> {formatDate(biddingDetail.submissionStartDate, 'KR')} ~ {formatDate(biddingDetail.submissionEndDate, 'KR')}
+ </div>
+ )}
+ {biddingDetail.evaluationDate && (
+ <div>
+ <span className="font-medium">평가일:</span> {formatDate(biddingDetail.evaluationDate, 'KR')}
+ </div>
+ )}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 제시된 조건 섹션 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>제시된 입찰 조건</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-4">
+ <div>
+ <Label className="text-sm font-medium">지급조건</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ {biddingDetail.offeredPaymentTerms ?
+ JSON.parse(biddingDetail.offeredPaymentTerms).join(', ') :
+ '정보 없음'}
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-sm font-medium">세금조건</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ {biddingDetail.offeredTaxConditions ?
+ JSON.parse(biddingDetail.offeredTaxConditions).join(', ') :
+ '정보 없음'}
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-sm font-medium">운송조건</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ {biddingDetail.offeredIncoterms ?
+ JSON.parse(biddingDetail.offeredIncoterms).join(', ') :
+ '정보 없음'}
+ </div>
+ </div>
+
+ {biddingDetail.offeredContractDeliveryDate && (
+ <div>
+ <Label className="text-sm font-medium">계약납기일</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ {formatDate(biddingDetail.offeredContractDeliveryDate, 'KR')}
+ </div>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 응찰 폼 섹션 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Send className="w-5 h-5" />
+ 응찰하기
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ <div className="space-y-2">
+ <Label htmlFor="finalQuoteAmount">견적금액 *</Label>
+ <Input
+ id="finalQuoteAmount"
+ type="number"
+ value={responseData.finalQuoteAmount}
+ onChange={(e) => setResponseData({...responseData, finalQuoteAmount: e.target.value})}
+ placeholder="견적금액을 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="proposedContractDeliveryDate">제안 납품일</Label>
+ <Input
+ id="proposedContractDeliveryDate"
+ type="date"
+ value={responseData.proposedContractDeliveryDate}
+ onChange={(e) => setResponseData({...responseData, proposedContractDeliveryDate: e.target.value})}
+ />
+ </div>
+ </div>
+
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ <div className="space-y-2">
+ <Label htmlFor="paymentTermsResponse">응답 지급조건</Label>
+ <Input
+ id="paymentTermsResponse"
+ value={responseData.paymentTermsResponse}
+ onChange={(e) => setResponseData({...responseData, paymentTermsResponse: e.target.value})}
+ placeholder="지급조건에 대한 의견을 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="taxConditionsResponse">응답 세금조건</Label>
+ <Input
+ id="taxConditionsResponse"
+ value={responseData.taxConditionsResponse}
+ onChange={(e) => setResponseData({...responseData, taxConditionsResponse: e.target.value})}
+ placeholder="세금조건에 대한 의견을 입력하세요"
+ />
+ </div>
+ </div>
+
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ <div className="space-y-2">
+ <Label htmlFor="incotermsResponse">응답 운송조건</Label>
+ <Input
+ id="incotermsResponse"
+ value={responseData.incotermsResponse}
+ onChange={(e) => setResponseData({...responseData, incotermsResponse: e.target.value})}
+ placeholder="운송조건에 대한 의견을 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="proposedShippingPort">제안 선적지</Label>
+ <Input
+ id="proposedShippingPort"
+ value={responseData.proposedShippingPort}
+ onChange={(e) => setResponseData({...responseData, proposedShippingPort: e.target.value})}
+ placeholder="선적지를 입력하세요"
+ />
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="additionalProposals">추가 제안사항</Label>
+ <Textarea
+ id="additionalProposals"
+ value={responseData.additionalProposals}
+ onChange={(e) => setResponseData({...responseData, additionalProposals: e.target.value})}
+ placeholder="추가 제안사항을 입력하세요"
+ rows={4}
+ />
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="priceAdjustmentResponse"
+ checked={responseData.priceAdjustmentResponse}
+ onCheckedChange={(checked) =>
+ setResponseData({...responseData, priceAdjustmentResponse: !!checked})
+ }
+ />
+ <Label htmlFor="priceAdjustmentResponse">연동제 적용에 동의합니다</Label>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="isAttendingMeeting"
+ checked={responseData.isAttendingMeeting}
+ onCheckedChange={(checked) =>
+ setResponseData({...responseData, isAttendingMeeting: !!checked})
+ }
+ />
+ <Label htmlFor="isAttendingMeeting">사양설명회에 참석합니다</Label>
+ </div>
+
+ <div className="flex justify-end pt-4">
+ <Button onClick={handleSubmitResponse} disabled={isPending}>
+ <Send className="w-4 h-4 mr-2" />
+ 응찰 제출
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 사양설명회 참석 여부 다이얼로그 */}
+ <PartnersBiddingAttendanceDialog
+ biddingDetail={{
+ id: biddingDetail.id,
+ biddingNumber: biddingDetail.biddingNumber,
+ title: biddingDetail.title,
+ preQuoteDate: biddingDetail.preQuoteDate,
+ biddingRegistrationDate: biddingDetail.biddingRegistrationDate,
+ evaluationDate: biddingDetail.evaluationDate,
+ }}
+ biddingCompanyId={biddingDetail.biddingCompanyId}
+ isAttending={biddingDetail.isAttendingMeeting}
+ open={isAttendanceDialogOpen}
+ onOpenChange={setIsAttendanceDialogOpen}
+ onSuccess={() => {
+ // 데이터 새로고침
+ const refreshData = async () => {
+ const updatedDetail = await getBiddingDetailsForPartners(biddingId, companyId)
+ if (updatedDetail) {
+ setBiddingDetail(updatedDetail)
+ }
+ }
+ refreshData()
+ }}
+ />
+ </div>
+ )
+}
diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
new file mode 100644
index 00000000..b54ca967
--- /dev/null
+++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx
@@ -0,0 +1,260 @@
+'use client'
+
+import * as React from 'react'
+import { createColumnHelper } from '@tanstack/react-table'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import {
+ CheckCircle,
+ XCircle,
+ Users,
+ Eye,
+ MoreHorizontal,
+ Calendar,
+ User
+} from 'lucide-react'
+import { formatDate } from '@/lib/utils'
+import { biddingStatusLabels, contractTypeLabels } from '@/db/schema'
+import { PartnersBiddingListItem } from '../detail/service'
+
+const columnHelper = createColumnHelper<PartnersBiddingListItem>()
+
+interface PartnersBiddingListColumnsProps {
+ setRowAction?: (action: { type: string; row: { original: PartnersBiddingListItem } }) => void
+}
+
+export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingListColumnsProps = {}) {
+ return [
+ // 입찰 No.
+ columnHelper.accessor('biddingNumber', {
+ header: '입찰 No.',
+ cell: ({ row }) => {
+ const biddingNumber = row.original.biddingNumber
+ const revision = row.original.revision
+ return (
+ <div className="font-mono text-sm">
+ <div>{biddingNumber}</div>
+ {revision > 0 && (
+ <div className="text-muted-foreground">Rev.{revision}</div>
+ )}
+ </div>
+ )
+ },
+ }),
+
+ // 입찰상태
+ columnHelper.accessor('status', {
+ header: '입찰상태',
+ cell: ({ row }) => {
+ const status = row.original.status
+ return (
+ <Badge variant={
+ status === 'bidding_disposal' ? 'destructive' :
+ status === 'vendor_selected' ? 'default' :
+ status === 'bidding_generated' ? 'secondary' :
+ 'outline'
+ }>
+ {biddingStatusLabels[status] || status}
+ </Badge>
+ )
+ },
+ }),
+
+ // 상세 (액션 버튼)
+ columnHelper.display({
+ id: 'actions',
+ header: '상세',
+ cell: ({ row }) => {
+ const handleView = () => {
+ if (setRowAction) {
+ setRowAction({
+ type: 'view',
+ row: { original: row.original }
+ })
+ }
+ }
+
+ return (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleView}
+ className="h-8 w-8 p-0"
+ >
+ <Eye className="h-4 w-4" />
+ </Button>
+ )
+ },
+ }),
+
+ // 품목명
+ columnHelper.accessor('itemName', {
+ header: '품목명',
+ cell: ({ row }) => (
+ <div className="max-w-32 truncate" title={row.original.itemName}>
+ {row.original.itemName}
+ </div>
+ ),
+ }),
+
+ // 입찰명
+ columnHelper.accessor('title', {
+ header: '입찰명',
+ cell: ({ row }) => (
+ <div className="max-w-48 truncate" title={row.original.title}>
+ {row.original.title}
+ </div>
+ ),
+ }),
+
+ // 사양설명회
+ columnHelper.accessor('isAttendingMeeting', {
+ header: '사양설명회',
+ cell: ({ row }) => {
+ const isAttending = row.original.isAttendingMeeting
+ if (isAttending === null) {
+ return <div className="text-muted-foreground text-center">-</div>
+ }
+ return isAttending ? (
+ <CheckCircle className="h-5 w-5 text-green-600 mx-auto" />
+ ) : (
+ <XCircle className="h-5 w-5 text-red-600 mx-auto" />
+ )
+ },
+ }),
+
+ // 입찰 참여의사
+ columnHelper.accessor('invitationStatus', {
+ header: '입찰 참여의사',
+ cell: ({ row }) => {
+ const status = row.original.invitationStatus
+ const statusLabels = {
+ sent: '초대됨',
+ submitted: '참여',
+ declined: '불참',
+ pending: '대기중'
+ }
+ return (
+ <Badge variant={
+ status === 'submitted' ? 'default' :
+ status === 'declined' ? 'destructive' :
+ status === 'sent' ? 'secondary' :
+ 'outline'
+ }>
+ {statusLabels[status as keyof typeof statusLabels] || status}
+ </Badge>
+ )
+ },
+ }),
+
+ // 계약구분
+ columnHelper.accessor('contractType', {
+ header: '계약구분',
+ cell: ({ row }) => (
+ <div>{contractTypeLabels[row.original.contractType] || row.original.contractType}</div>
+ ),
+ }),
+
+ // 입찰기간
+ columnHelper.accessor('submissionStartDate', {
+ header: '입찰기간',
+ cell: ({ row }) => {
+ const startDate = row.original.submissionStartDate
+ const endDate = row.original.submissionEndDate
+ if (!startDate || !endDate) {
+ return <div className="text-muted-foreground">-</div>
+ }
+ return (
+ <div className="text-sm">
+ <div>{formatDate(startDate, 'KR')}</div>
+ <div className="text-muted-foreground">~</div>
+ <div>{formatDate(endDate, 'KR')}</div>
+ </div>
+ )
+ },
+ }),
+
+ // 계약기간
+ columnHelper.accessor('contractPeriod', {
+ header: '계약기간',
+ cell: ({ row }) => (
+ <div className="max-w-24 truncate" title={row.original.contractPeriod || ''}>
+ {row.original.contractPeriod || '-'}
+ </div>
+ ),
+ }),
+
+ // 참여회신 마감일
+ columnHelper.accessor('responseDeadline', {
+ header: '참여회신 마감일',
+ cell: ({ row }) => {
+ const deadline = row.original.responseDeadline
+ if (!deadline) {
+ return <div className="text-muted-foreground">-</div>
+ }
+ return <div className="text-sm">{formatDate(deadline, 'KR')}</div>
+ },
+ }),
+
+ // 입찰제출일
+ columnHelper.accessor('submissionDate', {
+ header: '입찰제출일',
+ cell: ({ row }) => {
+ const date = row.original.submissionDate
+ if (!date) {
+ return <div className="text-muted-foreground">-</div>
+ }
+ return <div className="text-sm">{formatDate(date, 'KR')}</div>
+ },
+ }),
+
+ // 입찰담당자
+ columnHelper.accessor('managerName', {
+ header: '입찰담당자',
+ cell: ({ row }) => {
+ const name = row.original.managerName
+ const email = row.original.managerEmail
+ if (!name) {
+ return <div className="text-muted-foreground">-</div>
+ }
+ return (
+ <div className="flex items-center gap-1">
+ <User className="h-4 w-4" />
+ <div>
+ <div className="text-sm">{name}</div>
+ {email && (
+ <div className="text-xs text-muted-foreground truncate max-w-32" title={email}>
+ {email}
+ </div>
+ )}
+ </div>
+ </div>
+ )
+ },
+ }),
+
+ // 최종수정일
+ columnHelper.accessor('updatedAt', {
+ header: '최종수정일',
+ cell: ({ row }) => (
+ <div className="text-sm">{formatDate(row.original.updatedAt, 'KR')}</div>
+ ),
+ }),
+
+ // 최종수정자
+ columnHelper.accessor('updatedBy', {
+ header: '최종수정자',
+ cell: ({ row }) => (
+ <div className="max-w-20 truncate" title={row.original.updatedBy || ''}>
+ {row.original.updatedBy || '-'}
+ </div>
+ ),
+ }),
+ ]
+}
diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx
new file mode 100644
index 00000000..c0356e22
--- /dev/null
+++ b/lib/bidding/vendor/partners-bidding-list.tsx
@@ -0,0 +1,156 @@
+'use client'
+
+import * as React from 'react'
+import { useRouter } from 'next/navigation'
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from '@/types/table'
+
+import { useDataTable } from '@/hooks/use-data-table'
+import { DataTable } from '@/components/data-table/data-table'
+import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar'
+import { getPartnersBiddingListColumns } from './partners-bidding-list-columns'
+import { getBiddingListForPartners, PartnersBiddingListItem } from '../detail/service'
+
+interface PartnersBiddingListProps {
+ companyId: number
+}
+
+export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
+ const [data, setData] = React.useState<PartnersBiddingListItem[]>([])
+ const [pageCount, setPageCount] = React.useState<number>(1)
+ const [isLoading, setIsLoading] = React.useState(true)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<PartnersBiddingListItem> | null>(null)
+
+ const router = useRouter()
+
+ // 데이터 로드
+ React.useEffect(() => {
+ const loadData = async () => {
+ try {
+ setIsLoading(true)
+ const result = await getBiddingListForPartners(companyId)
+ setData(result)
+ setPageCount(1) // 클라이언트 사이드 페이징이므로 1로 설정
+ } catch (error) {
+ console.error('Failed to load bidding list:', error)
+ setData([])
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadData()
+ }, [companyId])
+
+ // rowAction 변경 감지하여 해당 페이지로 이동
+ React.useEffect(() => {
+ if (rowAction) {
+ switch (rowAction.type) {
+ case 'view':
+ // 상세 페이지로 이동 (biddingId 사용)
+ router.push(`/partners/bid/${rowAction.row.original.biddingId}`)
+ break
+ default:
+ break
+ }
+ }
+ }, [rowAction, router])
+
+ const columns = React.useMemo(
+ () => getPartnersBiddingListColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ const filterFields: DataTableFilterField<PartnersBiddingListItem>[] = [
+ {
+ id: 'title',
+ label: '입찰명',
+ placeholder: '입찰명으로 검색...',
+ },
+ {
+ id: 'biddingNumber',
+ label: '입찰번호',
+ placeholder: '입찰번호로 검색...',
+ },
+ {
+ id: 'itemName',
+ label: '품목명',
+ placeholder: '품목명으로 검색...',
+ },
+ {
+ id: 'projectName',
+ label: '프로젝트명',
+ placeholder: '프로젝트명으로 검색...',
+ },
+ {
+ id: 'managerName',
+ label: '담당자',
+ placeholder: '담당자로 검색...',
+ },
+ {
+ id: 'invitationStatus',
+ label: '참여의사',
+ placeholder: '참여의사로 필터링...',
+ },
+ {
+ id: 'status',
+ label: '입찰상태',
+ placeholder: '입찰상태로 필터링...',
+ },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<PartnersBiddingListItem>[] = [
+ { id: 'title', label: '입찰명', type: 'text' },
+ { id: 'biddingNumber', label: '입찰번호', type: 'text' },
+ { id: 'itemName', label: '품목명', type: 'text' },
+ { id: 'projectName', label: '프로젝트명', type: 'text' },
+ { id: 'managerName', label: '담당자', type: 'text' },
+ { id: 'contractType', label: '계약구분', type: 'text' },
+ { id: 'invitationStatus', label: '참여의사', type: 'text' },
+ { id: 'status', label: '입찰상태', type: 'text' },
+ { id: 'submissionStartDate', label: '입찰시작일', type: 'date' },
+ { id: 'submissionEndDate', label: '입찰마감일', type: 'date' },
+ { id: 'responseDeadline', label: '참여회신마감일', type: 'date' },
+ { id: 'createdAt', label: '등록일', type: 'date' },
+ { id: 'updatedAt', label: '수정일', type: 'date' },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: 'createdAt', desc: true }],
+ columnPinning: { right: ['actions'] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ if (isLoading) {
+ return (
+ <div className="flex items-center justify-center py-12">
+ <div className="text-center">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
+ <p className="text-muted-foreground">입찰 목록을 불러오는 중...</p>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ />
+ </DataTable>
+ )
+}
diff --git a/lib/file-download.ts b/lib/file-download.ts
index f78ba8f9..dbf65847 100644
--- a/lib/file-download.ts
+++ b/lib/file-download.ts
@@ -490,7 +490,9 @@ export const downloadFile = async (
: `${window.location.origin}${filePath}`;
// 안전한 새 창 열기
- const newWindow = window.open('', '_blank', 'noopener,noreferrer');
+ // const newWindow = window.open('', '_blank', 'noopener,noreferrer');
+ const newWindow = window.open(previewUrl, '_blank');
+
if (newWindow) {
newWindow.location.href = previewUrl;
if (showToast) toast.success(`${fileInfo.icon} 파일을 새 탭에서 열었습니다`);
diff --git a/lib/legal-review/status/legal-table.tsx b/lib/legal-review/status/legal-table.tsx
index d68ffa4e..4df3568c 100644
--- a/lib/legal-review/status/legal-table.tsx
+++ b/lib/legal-review/status/legal-table.tsx
@@ -299,8 +299,6 @@ export function LegalWorksTable({ promises, currentYear = new Date().getFullYear
const [tableData, setTableData] = React.useState(initialPromiseData);
const [isDataLoading, setIsDataLoading] = React.useState(false);
-
-
const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => {
try {
const value = getSearchParam(key);
diff --git a/lib/mail/templates/non-inspection-pq.hbs b/lib/mail/templates/non-inspection-pq.hbs
index add5396b..291a427f 100644
--- a/lib/mail/templates/non-inspection-pq.hbs
+++ b/lib/mail/templates/non-inspection-pq.hbs
@@ -1,200 +1,158 @@
<!DOCTYPE html>
<html lang="ko">
<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>미실사 PQ 요청</title>
- <style>
- body {
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
- line-height: 1.6;
- color: #333;
- max-width: 600px;
- margin: 0 auto;
- padding: 20px;
- background-color: #f9f9f9;
- }
- .container {
- background-color: white;
- padding: 30px;
- border-radius: 8px;
- box-shadow: 0 2px 10px rgba(0,0,0,0.1);
- }
- .header {
- text-align: center;
- border-bottom: 3px solid #2563eb;
- padding-bottom: 20px;
- margin-bottom: 30px;
- }
- .logo {
- font-size: 24px;
- font-weight: bold;
- color: #2563eb;
- margin-bottom: 10px;
- }
- .title {
- font-size: 20px;
- font-weight: bold;
- color: #1f2937;
- margin-bottom: 5px;
- }
- .subtitle {
- color: #6b7280;
- font-size: 14px;
- }
- .content {
- margin-bottom: 30px;
- }
- .section {
- margin-bottom: 25px;
- }
- .section-title {
- font-weight: bold;
- color: #1f2937;
- margin-bottom: 10px;
- font-size: 16px;
- }
- .info-box {
- background-color: #f3f4f6;
- padding: 15px;
- border-radius: 6px;
- margin-bottom: 20px;
- }
- .info-item {
- margin-bottom: 8px;
- }
- .info-label {
- font-weight: bold;
- color: #374151;
- }
- .info-value {
- color: #1f2937;
- }
- .highlight {
- background-color: #fef3c7;
- padding: 15px;
- border-radius: 6px;
- border-left: 4px solid #f59e0b;
- margin: 20px 0;
- }
- .button {
- display: inline-block;
- background-color: #2563eb;
- color: white;
- padding: 12px 24px;
- text-decoration: none;
- border-radius: 6px;
- font-weight: bold;
- margin: 20px 0;
- }
- .button:hover {
- background-color: #1d4ed8;
- }
- .footer {
- text-align: center;
- margin-top: 30px;
- padding-top: 20px;
- border-top: 1px solid #e5e7eb;
- color: #6b7280;
- font-size: 12px;
- }
- .contracts {
- background-color: #f0f9ff;
- padding: 15px;
- border-radius: 6px;
- margin: 15px 0;
- }
- .contract-item {
- margin-bottom: 5px;
- color: #1e40af;
- }
- </style>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>eVCP 미실사 PQ 초대</title>
+ <style>
+ body {
+ margin: 0 !important;
+ padding: 20px !important;
+ background-color: #f4f4f4;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ }
+ .email-container {
+ max-width: 700px;
+ margin: 0 auto;
+ background-color: #ffffff;
+ padding: 24px;
+ border-radius: 8px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+ color: #111827;
+ }
+ .section-title {
+ font-weight: bold;
+ margin-top: 24px;
+ }
+ .contract-list {
+ margin-top: 8px;
+ margin-bottom: 8px;
+ padding-left: 1em;
+ }
+ .highlight {
+ background-color: #fef3c7;
+ padding: 15px;
+ border-radius: 6px;
+ border-left: 4px solid #f59e0b;
+ margin: 20px 0;
+ }
+ .contracts {
+ background-color: #f0f9ff;
+ padding: 15px;
+ border-radius: 6px;
+ margin: 15px 0;
+ }
+ .contract-item {
+ margin-bottom: 5px;
+ color: #1e40af;
+ }
+ </style>
</head>
<body>
- <div class="container">
- <div class="header">
- <div class="logo">eVCP</div>
- <div class="title">미실사 PQ 요청</div>
- <div class="subtitle">Non-Inspection Pre-Qualification Request</div>
- </div>
-
- <div class="content">
- <p>안녕하세요, <strong>{{vendorName}}</strong>님</p>
-
- <p>SHI에서 미실사 PQ(Pre-Qualification) 요청을 보냅니다.</p>
-
- <div class="info-box">
- <div class="info-item">
- <span class="info-label">PQ 번호:</span>
- <span class="info-value">{{pqNumber}}</span>
- </div>
- <div class="info-item">
- <span class="info-label">요청자:</span>
- <span class="info-value">{{senderName}}</span>
- </div>
- {{#if dueDate}}
- <div class="info-item">
- <span class="info-label">제출 마감일:</span>
- <span class="info-value">{{dueDate}}</span>
- </div>
- {{/if}}
- </div>
-
- <div class="section">
- <div class="section-title">📋 미실사 PQ란?</div>
- <p>미실사 PQ는 현장 방문 없이 서류 검토만으로 진행되는 사전 자격 검증입니다.
- 일반 PQ와 동일한 기준으로 평가되지만, 현장 실사 과정이 생략됩니다.</p>
- </div>
-
- {{#if pqItems}}
- <div class="section">
- <div class="section-title">🎯 PQ 대상 품목</div>
- <div class="highlight">
- {{pqItems}}
- </div>
- </div>
- {{/if}}
-
- {{#if contracts.length}}
- <div class="section">
- <div class="section-title">📄 포함된 계약 항목</div>
- <div class="contracts">
- {{#each contracts}}
- <div class="contract-item">• {{this}}</div>
- {{/each}}
- </div>
- </div>
- {{/if}}
-
- {{#if extraNote}}
- <div class="section">
- <div class="section-title">📝 추가 안내사항</div>
- <div class="highlight">
- {{extraNote}}
- </div>
- </div>
- {{/if}}
-
- <div class="section">
- <div class="section-title">🚀 PQ 제출하기</div>
- <p>아래 버튼을 클릭하여 미실사 PQ를 제출하세요:</p>
- <a href="{{loginUrl}}" class="button">PQ 제출하기</a>
- </div>
-
- <div class="section">
- <div class="section-title">⚠️ 중요 안내</div>
- <ul>
- <li>미실사 PQ는 서류 검토만으로 진행되므로, 모든 서류를 정확히 작성해주세요.</li>
- <li>제출 후에는 수정이 제한될 수 있으니 신중하게 작성해주세요.</li>
- <li>문의사항이 있으시면 언제든 연락주세요.</li>
- </ul>
- </div>
- </div>
-
- <div class="footer">
- <p>© {{currentYear}} eVCP. All rights reserved.</p>
- <p>본 메일은 자동으로 발송되었습니다. 문의사항은 {{senderEmail}}로 연락주세요.</p>
- </div>
+ <div class="email-container">
+ <table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px; border-bottom:1px solid #163CC4; padding-bottom:16px;">
+ <tr>
+ <td align="center">
+ <span style="display: block; text-align: left; color: #163CC4; font-weight: bold; font-size: 32px;">eVCP</span>
+ </td>
+ </tr>
+ </table>
+
+ <h1 style="font-size:28px; margin-bottom:16px;">
+ [SHI 미실사 PQ] Pre-Qualification Invitation _ {{vendorName}} _ PQ No. {{pqNumber}}
+ </h1>
+
+ <p style="font-size:16px; line-height:32px;">SHI 미실사 PQ No. : {{pqNumber}}</p>
+ <p style="font-size:16px; line-height:32px;">수신 : {{vendorName}} 귀하</p>
+ <p style="font-size:16px; line-height:32px;">발신 : {{senderName}} 프로 ({{senderEmail}})</p>
+
+ <p style="font-size:16px; line-height:32px; margin-top:16px;">
+ 귀사 일익 번창하심을 기원합니다.
+ </p>
+
+ <p style="font-size:16px; line-height:32px;">
+ 당사에선 귀사와의 정기적 거래를 위하여 미실사 PQ(Pre-Qualification)을 진행하고자 합니다.
+ </p>
+
+ <div class="highlight">
+ <p style="font-size:16px; line-height:24px; margin:0;">
+ <strong>미실사 PQ란?</strong><br>
+ 미실사 PQ는 현장 방문 없이 서류 검토만으로 진행되는 사전 자격 검증입니다.
+ 일반 PQ와 동일한 기준으로 평가되지만, 현장 실사 과정이 생략됩니다.
+ </p>
</div>
+
+ <p style="font-size:16px; line-height:32px;">
+ 아래의 해당 링크를 통해 당사 eVCP시스템에 접속하시어 요청드린 PQ 항목 및 자료에 대한 제출 요청드립니다.
+ </p>
+
+ <p style="font-size:16px; line-height:32px;">
+ 귀사의 제출 자료 및 정보는 아래의 제출 마감일 이전에 당사로 제출 되어야 하며,
+ 마감일 전 별도의 지연 통보 없이 미 제출될 경우에는 추후 계약대상자 등재에 어려움이 있을 수 있습니다.
+ </p>
+
+ <p class="section-title">- 아 래 -</p>
+
+ {{#if dueDate}}
+ <p style="font-size:16px; line-height:32px;">1) PQ 제출 마감일 : {{dueDate}}</p>
+ {{/if}}
+
+ <p style="font-size:16px; line-height:32px;">{{#if dueDate}}2{{else}}1{{/if}}) PQ 제출 방법</p>
+ <ul style="font-size:16px; line-height:32px; padding-left:1.2em; margin-top:4px;">
+ <li>아래 eVCP 접속 링크 클릭</li>
+ <li>eVCP 로그인 (계정이 없을 경우 계정 생성 필요)</li>
+ <li>PQ 필수 입력사항 및 제출자료 입력 후 제출 버튼 클릭</li>
+ </ul>
+
+ {{#if pqItems}}
+ <p style="font-size:16px; line-height:32px;">{{#if dueDate}}3{{else}}2{{/if}}) PQ 대상품목 : {{pqItems}}</p>
+ {{/if}}
+
+ {{#if contracts.length}}
+ <p style="font-size:16px; line-height:32px;">{{#if dueDate}}{{#if pqItems}}4{{else}}3{{/if}}{{else}}{{#if pqItems}}3{{else}}2{{/if}}{{/if}}) 기본계약서 승인(서명) 및 자료 제출 요청</p>
+ <div class="contracts">
+ {{#each contracts}}
+ <div class="contract-item">■ {{this}}</div>
+ {{/each}}
+ </div>
+ {{/if}}
+
+ {{#if extraNote}}
+ <p style="font-size:16px; line-height:32px;">{{#if dueDate}}{{#if pqItems}}{{#if contracts.length}}5{{else}}4{{/if}}{{else}}{{#if contracts.length}}4{{else}}3{{/if}}{{/if}}{{else}}{{#if pqItems}}{{#if contracts.length}}4{{else}}3{{/if}}{{else}}{{#if contracts.length}}3{{else}}2{{/if}}{{/if}}{{/if}}) 추가 안내사항</p>
+ <div style="white-space: pre-line; font-size:16px; line-height:32px;">
+ {{extraNote}}
+ </div>
+ {{/if}}
+
+ <p style="font-size:16px; line-height:32px; margin-top:24px;">
+ <strong>중요 안내:</strong> 미실사 PQ는 서류 검토만으로 진행되므로, 모든 서류를 정확히 작성해 주시고,
+ 제출 후에는 수정이 제한될 수 있으니 신중하게 작성해 주세요.
+ </p>
+
+ <p style="text-align: center; margin: 24px 0;">
+ <a href="{{loginUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px;">
+ eVCP 시스템 접속
+ </a>
+ </p>
+
+ <p style="font-size:16px; line-height:32px; margin-top:24px;">
+ 이번 기회를 통하여 귀사와의 협업으로 다가올 미래 조선/해양산업 시장에서 함께 성장해 나갈 수 있기를 기대합니다.
+ </p>
+
+ <p style="font-size:16px; line-height:32px; margin-top:24px;">
+ {{senderName}} / Procurement Manager / {{senderEmail}}<br>
+ SAMSUNG HEAVY INDUSTRIES CO., LTD.<br>
+ 80, Jangpyeong 3-ro, Geoje-si, Gyeongsangnam-do, Republic of Korea, 53261
+ </p>
+
+ <table width="100%" cellpadding="0" cellspacing="0" style="margin-top:32px; border-top:1px solid #e5e7eb; padding-top:16px;">
+ <tr>
+ <td align="center">
+ <p style="font-size:14px; color:#6b7280; margin:4px 0;">© {{currentYear}} EVCP. All rights reserved.</p>
+ <p style="font-size:14px; color:#6b7280; margin:4px 0;">본 메일은 발신전용입니다. 회신하지 마십시오.</p>
+ </td>
+ </tr>
+ </table>
+ </div>
</body>
</html> \ No newline at end of file
diff --git a/lib/mail/templates/pq.hbs b/lib/mail/templates/pq.hbs
index 0f54adb1..02523696 100644
--- a/lib/mail/templates/pq.hbs
+++ b/lib/mail/templates/pq.hbs
@@ -81,6 +81,12 @@
<li>PQ 필수 입력사항 및 제출자료 입력 후 제출 버튼 클릭</li>
</ul>
+ <p style="text-align: center; margin: 24px 0;">
+ <a href="{{loginUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px;">
+ eVCP 시스템 접속
+ </a>
+ </p>
+
<p style="font-size:16px; line-height:32px;">3) PQ 대상품목 : {{pqItems}}</p>
<p style="font-size:16px; line-height:32px;">4) 기본계약서 승인(서명) 및 자료 제출 요청</p>
diff --git a/lib/mail/templates/project-pq.hbs b/lib/mail/templates/project-pq.hbs
index a790e124..fc4337b8 100644
--- a/lib/mail/templates/project-pq.hbs
+++ b/lib/mail/templates/project-pq.hbs
@@ -1,99 +1,129 @@
<!DOCTYPE html>
-<html>
+<html lang="ko">
<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>eVCP 메일</title>
- <style>
- body {
- margin: 0 !important;
- padding: 20px !important;
- background-color: #f4f4f4;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
- }
- .email-container {
- max-width: 600px;
- margin: 0 auto;
- background-color: #ffffff;
- padding: 20px;
- border-radius: 8px;
- box-shadow: 0 2px 10px rgba(0,0,0,0.1);
- }
- </style>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>eVCP 프로젝트 PQ 초대</title>
+ <style>
+ body {
+ margin: 0 !important;
+ padding: 20px !important;
+ background-color: #f4f4f4;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ }
+ .email-container {
+ max-width: 700px;
+ margin: 0 auto;
+ background-color: #ffffff;
+ padding: 24px;
+ border-radius: 8px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+ color: #111827;
+ }
+ .section-title {
+ font-weight: bold;
+ margin-top: 24px;
+ }
+ .contract-list {
+ margin-top: 8px;
+ margin-bottom: 8px;
+ padding-left: 1em;
+ }
+ </style>
</head>
<body>
- <div class="email-container">
-<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px; border-bottom:1px solid #163CC4; padding-bottom:16px;">
- <tr>
- <td align="center">
- <span style="display: block; text-align: left; color: #163CC4; font-weight: bold; font-size: 32px;">eVCP</span>
- </td>
- </tr>
-</table>
-
-<h1 style="font-size:28px; margin-bottom:16px;">
- eVCP 프로젝트 PQ 초대
-</h1>
-
-<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
- {{vendorName}} 귀하,
-</p>
-
-<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
- 귀사는 다음 프로젝트의 사전적격심사(PQ) 과정에 참여하도록 선정되었습니다:
-</p>
-
-<div style="background-color: #e6f2ff; border-radius: 4px; padding: 15px; margin: 20px 0;">
- <p style="font-size:16px; margin:4px 0;"><strong>프로젝트 코드:</strong> {{projectCode}}</p>
- <p style="font-size:16px; margin:4px 0;"><strong>프로젝트명:</strong> {{projectName}}</p>
-</div>
-
-<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
- 이는 저희 업체 선정 과정에서 중요한 단계입니다. 가능한 빠른 시일 내에 프로젝트 PQ 설문지를 작성해 주세요.
-</p>
-
-<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
- 프로젝트 PQ 제출 방법:
-</p>
-
-<ol style="font-size:16px; line-height:32px; margin-bottom:16px;">
- <li>아래 버튼을 클릭하여 저희 업체 포털에 접속하세요</li>
- <li>계정에 로그인하세요 (아직 계정이 없으면 등록하세요)</li>
- <li>PQ 섹션으로 이동하여 {{projectCode}}에 대한 프로젝트 PQ를 찾으세요</li>
- <li>모든 필수 정보를 작성하세요</li>
-</ol>
-
-<p style="text-align: center;">
- <a href="{{loginUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px; margin-top: 16px;">
- 업체 포털 접속
- </a>
-</p>
-
-<p style="font-size:16px; line-height:32px; margin-top:16px;">
- 이 프로젝트 PQ를 완료하는 것은 이 프로젝트 고려 대상이 되기 위한 선행 조건임을 참고해 주세요.
-</p>
-
-<p style="font-size:16px; line-height:32px; margin-top:16px;">
- 문의사항이 있거나 도움이 필요하시면 저희 업체 관리팀에 문의해 주세요.
-</p>
-
-<p style="font-size:16px; line-height:32px; margin-top:16px;">
- 참여해 주셔서 감사합니다.
-</p>
-
-<p style="font-size:16px; line-height:32px; margin-top:16px;">
- 감사합니다,<br>
- eVCP 팀
-</p>
-
-<table width="100%" cellpadding="0" cellspacing="0" style="margin-top:32px; border-top:1px solid #e5e7eb; padding-top:16px;">
- <tr>
- <td align="center">
- <p style="font-size:16px; color:#6b7280; margin:4px 0;">© {{currentYear}} EVCP. {{t "email.vendor.invitation.copyright"}}</p>
- <p style="font-size:16px; color:#6b7280; margin:4px 0;">{{t "email.vendor.invitation.no_reply"}}</p>
- </td>
- </tr>
-</table>
+ <div class="email-container">
+ <table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px; border-bottom:1px solid #163CC4; padding-bottom:16px;">
+ <tr>
+ <td align="center">
+ <span style="display: block; text-align: left; color: #163CC4; font-weight: bold; font-size: 32px;">eVCP</span>
+ </td>
+ </tr>
+ </table>
+
+ <h1 style="font-size:28px; margin-bottom:16px;">
+ [SHI 프로젝트 PQ] Pre-Qualification Invitation _ {{vendorName}} _ 프로젝트: {{projectCode}}
+ </h1>
+
+ <p style="font-size:16px; line-height:32px;">프로젝트 코드 : {{projectCode}}</p>
+ <p style="font-size:16px; line-height:32px;">수신 : {{vendorName}} 귀하</p>
+ <p style="font-size:16px; line-height:32px;">발신 : eVCP 팀 ({{senderEmail}})</p>
+
+ <p style="font-size:16px; line-height:32px; margin-top:16px;">
+ 귀사 일익 번창하심을 기원합니다.
+ </p>
+
+ <p style="font-size:16px; line-height:32px;">
+ 귀사는 다음 프로젝트의 사전적격심사(PQ) 과정에 참여하도록 선정되었습니다:
+ </p>
+
+ <div style="background-color: #e6f2ff; border-radius: 4px; padding: 15px; margin: 20px 0;">
+ <p style="font-size:16px; margin:4px 0;"><strong>프로젝트 코드:</strong> {{projectCode}}</p>
+ <p style="font-size:16px; margin:4px 0;"><strong>프로젝트명:</strong> {{projectName}}</p>
</div>
+
+ <p style="font-size:16px; line-height:32px;">
+ 이는 저희 업체 선정 과정에서 중요한 단계입니다. 가능한 빠른 시일 내에 프로젝트 PQ 설문지를 작성해 주세요.
+ </p>
+
+ <p style="font-size:16px; line-height:32px;">
+ 아래의 해당 링크를 통해 당사 eVCP시스템에 접속하시어 요청드린 프로젝트 PQ 항목에 대한 제출 요청드립니다.
+ </p>
+
+ <p style="font-size:16px; line-height:32px;">
+ 귀사의 제출 자료 및 정보는 제출 마감일 이전에 당사로 제출 되어야 하며,
+ 마감일 전 별도의 지연 통보 없이 미 제출될 경우에는 추후 계약대상자 등재에 어려움이 있을 수 있습니다.
+ </p>
+
+ <p class="section-title">- 아 래 -</p>
+
+ {{#if dueDate}}
+ <p style="font-size:16px; line-height:32px;">1) 프로젝트 PQ 제출 마감일 : {{dueDate}}</p>
+ {{/if}}
+
+ <p style="font-size:16px; line-height:32px;">{{#if dueDate}}2{{else}}1{{/if}}) 프로젝트 PQ 제출 방법</p>
+ <ul style="font-size:16px; line-height:32px; padding-left:1.2em; margin-top:4px;">
+ <li>아래 eVCP 접속 링크 클릭</li>
+ <li>eVCP 로그인 (계정이 없을 경우 계정 생성 필요)</li>
+ <li>PQ 섹션으로 이동하여 {{projectCode}}에 대한 프로젝트 PQ를 찾으세요</li>
+ <li>모든 필수 정보를 작성 후 제출 버튼 클릭</li>
+ </ul>
+
+ <p style="text-align: center; margin: 24px 0;">
+ <a href="{{loginUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px;">
+ eVCP 시스템 접속
+ </a>
+ </p>
+
+ <p style="font-size:16px; line-height:32px;">
+ 이 프로젝트 PQ를 완료하는 것은 이 프로젝트 고려 대상이 되기 위한 선행 조건임을 참고해 주세요.
+ </p>
+
+ {{#if extraNote}}
+ <p class="section-title">{{#if dueDate}}3{{else}}2{{/if}}) 추가 안내사항</p>
+ <div style="white-space: pre-line; font-size:16px; line-height:32px;">
+ {{extraNote}}
+ </div>
+ {{/if}}
+
+ <p style="font-size:16px; line-height:32px; margin-top:24px;">
+ 이번 기회를 통하여 귀사와의 협업으로 다가올 미래 조선/해양산업 시장에서 함께 성장해 나갈 수 있기를 기대합니다.
+ </p>
+
+ <p style="font-size:16px; line-height:32px; margin-top:24px;">
+ eVCP 팀 / {{senderEmail}}<br>
+ SAMSUNG HEAVY INDUSTRIES CO., LTD.<br>
+ 80, Jangpyeong 3-ro, Geoje-si, Gyeongsangnam-do, Republic of Korea, 53261
+ </p>
+
+ <table width="100%" cellpadding="0" cellspacing="0" style="margin-top:32px; border-top:1px solid #e5e7eb; padding-top:16px;">
+ <tr>
+ <td align="center">
+ <p style="font-size:14px; color:#6b7280; margin:4px 0;">© {{currentYear}} EVCP. All rights reserved.</p>
+ <p style="font-size:14px; color:#6b7280; margin:4px 0;">본 메일은 발신전용입니다. 회신하지 마십시오.</p>
+ </td>
+ </tr>
+ </table>
+ </div>
</body>
</html> \ No newline at end of file
diff --git a/lib/vendor-regular-registrations/service.ts b/lib/vendor-regular-registrations/service.ts
index c4f1a2a8..42b43d6f 100644
--- a/lib/vendor-regular-registrations/service.ts
+++ b/lib/vendor-regular-registrations/service.ts
@@ -1281,6 +1281,7 @@ export async function sendRegistrationRequestToMDG(
const session = await getServerSession(authOptions);
const userId = session?.user?.id || 'EVCP_USER';
const userName = session?.user?.name || 'EVCP_USER';
+ const userKnoxId = session?.user?.knoxId || 'EVCP_USER';
// 등록 정보 조회
const registration = await db
.select()
@@ -1333,7 +1334,7 @@ export async function sendRegistrationRequestToMDG(
IBND_TYPE: 'I',
// 10. ZZREQID: SAP의 USER ID를 보내드리겠습니다. (필수)
- ZZREQID: userName,
+ ZZREQID: userKnoxId,
// 11. ADDRNO: I/F정의서에는 필수입력으로 되어 있습니다. -> 빈값으로 처리 (필수)
ADDRNO: '',