diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-27 12:06:26 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-27 12:06:26 +0000 |
| commit | 7548e2ad6948f1c6aa102fcac408bc6c9c0f9796 (patch) | |
| tree | 8e66703ec821888ad51dcc242a508813a027bf71 /lib | |
| parent | 7eac558470ef179dad626a8e82db5784fe86a556 (diff) | |
(대표님, 최겸) 기본계약, 입찰, 파일라우트, 계약서명라우트, 인포메이션, 메뉴설정, PQ(메일템플릿 관련)
Diffstat (limited to 'lib')
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: '',
|
