summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-12-09 05:35:23 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-12-09 05:35:23 +0000
commitea8aed1e1d62fb9fa6716347de73e4ef13040929 (patch)
tree133eea9c6be513670b7bb9b40e984543e5bdb4b9
parent3462d754574e2558c791c7958d3e5da013a7a573 (diff)
(임수민) 공동인증서 개발
-rw-r--r--app/api/basic-contract/[contractId]/internal-sign-complete/route.ts124
-rw-r--r--app/api/basic-contract/[contractId]/sign-source/route.ts118
-rw-r--r--lib/basic-contract/service-vendor-info.ts35
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-sign-dialog-test.tsx1166
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx138
-rw-r--r--lib/basic-contract/viewer/basic-contract-sign-viewer.tsx35
6 files changed, 448 insertions, 1168 deletions
diff --git a/app/api/basic-contract/[contractId]/internal-sign-complete/route.ts b/app/api/basic-contract/[contractId]/internal-sign-complete/route.ts
new file mode 100644
index 00000000..9ee46563
--- /dev/null
+++ b/app/api/basic-contract/[contractId]/internal-sign-complete/route.ts
@@ -0,0 +1,124 @@
+import { NextRequest, NextResponse } from "next/server";
+import db from "@/db/db";
+import { basicContract, vendors } from "@/db/schema";
+import { eq } from "drizzle-orm";
+
+// CORS 헤더 (credentials 사용 시 특정 origin 필요)
+function getCorsHeaders(request: NextRequest) {
+ const origin = request.headers.get('origin') || '*';
+ return {
+ 'Access-Control-Allow-Origin': origin === '*' ? '*' : origin,
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
+ 'Access-Control-Allow-Credentials': 'true',
+ };
+}
+
+export async function OPTIONS(request: NextRequest) {
+ return NextResponse.json({}, { headers: getCorsHeaders(request) });
+}
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ contractId: string }> }
+) {
+ try {
+ const { contractId: contractIdStr } = await params;
+ const contractId = parseInt(contractIdStr, 10);
+
+ if (isNaN(contractId)) {
+ return NextResponse.json(
+ { success: false, error: "유효하지 않은 계약서 ID입니다." },
+ { status: 400, headers: getCorsHeaders(request) }
+ );
+ }
+
+ const body = await request.json().catch(() => ({}));
+ const { pkcs7Data, signedBy } = body;
+
+ if (!pkcs7Data) {
+ return NextResponse.json(
+ { success: false, error: "서명 데이터가 없습니다." },
+ { status: 400, headers: getCorsHeaders(request) }
+ );
+ }
+
+ // 계약서 조회 (벤더 정보 확인용)
+ const [contract] = await db
+ .select({
+ id: basicContract.id,
+ vendorId: basicContract.vendorId,
+ taxId: vendors.taxId, // 협력업체 사업자등록번호
+ })
+ .from(basicContract)
+ .leftJoin(vendors, eq(basicContract.vendorId, vendors.id))
+ .where(eq(basicContract.id, contractId))
+ .limit(1);
+
+ if (!contract) {
+ return NextResponse.json(
+ { success: false, error: "계약서를 찾을 수 없습니다." },
+ { status: 404, headers: getCorsHeaders(request) }
+ );
+ }
+
+ // TODO: PKCS7 검증
+ // 1. PKCS7에서 인증서 추출
+ // 2. 인증서 주체(Subject)에서 사업자등록번호 확인
+ // 3. contract.taxId와 일치하는지 검증
+ // 현재는 signedBy로 전달받은 값과 비교 (임시)
+
+ if (signedBy && contract.taxId && signedBy !== contract.taxId) {
+ console.error('❌ 사업자등록번호 불일치:', {
+ expected: contract.taxId,
+ received: signedBy,
+ contractId
+ });
+ return NextResponse.json(
+ {
+ success: false,
+ error: "인증서의 사업자등록번호가 계약서와 일치하지 않습니다."
+ },
+ { status: 403, headers: getCorsHeaders(request) }
+ );
+ }
+
+ console.log('✅ 사업자등록번호 확인:', {
+ taxId: contract.taxId,
+ signedBy,
+ contractId
+ });
+
+ // 상태 업데이트: VENDOR_SIGNED
+ console.log('📝 DB 업데이트 시작:', {
+ contractId,
+ oldStatus: 'PENDING',
+ newStatus: 'VENDOR_SIGNED'
+ });
+
+ const updateResult = await db.update(basicContract)
+ .set({
+ status: "VENDOR_SIGNED",
+ vendorSignedAt: new Date(),
+ updatedAt: new Date()
+ })
+ .where(eq(basicContract.id, contractId));
+
+ console.log('✅ DB 업데이트 완료:', {
+ contractId,
+ status: 'VENDOR_SIGNED',
+ timestamp: new Date().toISOString()
+ });
+
+ return NextResponse.json({ success: true }, { headers: getCorsHeaders(request) });
+ } catch (error) {
+ console.error("Internal sign complete error:", error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: error instanceof Error ? error.message : "서명 완료 처리 중 오류가 발생했습니다."
+ },
+ { status: 500, headers: getCorsHeaders(request) }
+ );
+ }
+}
diff --git a/app/api/basic-contract/[contractId]/sign-source/route.ts b/app/api/basic-contract/[contractId]/sign-source/route.ts
new file mode 100644
index 00000000..9f51f47a
--- /dev/null
+++ b/app/api/basic-contract/[contractId]/sign-source/route.ts
@@ -0,0 +1,118 @@
+import { NextRequest, NextResponse } from "next/server";
+import db from "@/db/db";
+import { basicContract, basicContractTemplates } from "@/db/schema";
+import { eq } from "drizzle-orm";
+import { readFile } from "fs/promises";
+import path from "path";
+
+// CORS 헤더 (credentials 사용 시 특정 origin 필요)
+function getCorsHeaders(request: NextRequest) {
+ const origin = request.headers.get('origin') || '*';
+ return {
+ 'Access-Control-Allow-Origin': origin === '*' ? '*' : origin,
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
+ 'Access-Control-Allow-Credentials': 'true',
+ };
+}
+
+export async function OPTIONS(request: NextRequest) {
+ return NextResponse.json({}, { headers: getCorsHeaders(request) });
+}
+
+/**
+ * 계약서 파일을 읽어서 서명 원문으로 사용할 수 있는 형태로 반환
+ */
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ contractId: string }> }
+) {
+ console.log('🔵 [sign-source] API 호출됨:', request.url);
+ try {
+ const { contractId: contractIdStr } = await params;
+ console.log('🔵 [sign-source] contractId:', contractIdStr);
+ const contractId = parseInt(contractIdStr, 10);
+
+ if (isNaN(contractId)) {
+ return NextResponse.json(
+ { success: false, error: "유효하지 않은 계약서 ID입니다." },
+ { status: 400, headers: getCorsHeaders(request) }
+ );
+ }
+
+ // 계약서 정보 조회
+ const [contract] = await db
+ .select({
+ id: basicContract.id,
+ filePath: basicContract.filePath,
+ fileName: basicContract.fileName,
+ templateId: basicContract.templateId,
+ templateFilePath: basicContractTemplates.filePath,
+ templateFileName: basicContractTemplates.fileName,
+ })
+ .from(basicContract)
+ .leftJoin(
+ basicContractTemplates,
+ eq(basicContract.templateId, basicContractTemplates.id)
+ )
+ .where(eq(basicContract.id, contractId))
+ .limit(1);
+
+ if (!contract) {
+ return NextResponse.json(
+ { success: false, error: "계약서를 찾을 수 없습니다." },
+ { status: 404, headers: getCorsHeaders(request) }
+ );
+ }
+
+ // 계약서 파일 경로 결정 (서명된 파일이 있으면 그것을, 없으면 템플릿 파일 사용)
+ const filePath = contract.filePath || contract.templateFilePath;
+ const fileName = contract.fileName || contract.templateFileName || "contract.pdf";
+
+ if (!filePath) {
+ return NextResponse.json(
+ { success: false, error: "계약서 파일을 찾을 수 없습니다." },
+ { status: 404, headers: getCorsHeaders(request) }
+ );
+ }
+
+ // 파일 읽기
+ const nasPath = process.env.NAS_PATH || "/evcp_nas";
+ let fullPath: string;
+
+ if (process.env.NODE_ENV === "production") {
+ fullPath = path.join(nasPath, filePath);
+ } else {
+ fullPath = path.join(process.cwd(), "public", filePath);
+ }
+
+ const fileBuffer = await readFile(fullPath);
+
+ // 파일 내용을 Base64로 인코딩
+ const base64Content = fileBuffer.toString("base64");
+
+ // 서명 원문 형식: <html><title>contract file</title><body>파일내용|파일명</body></html>
+ const oriMsg = `<html><title>contract file</title><body>${base64Content}|${fileName}</body></html>`;
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ signSrc: oriMsg,
+ fileName: fileName,
+ fileSize: fileBuffer.length,
+ },
+ }, { headers: getCorsHeaders(request) });
+ } catch (error) {
+ console.error("계약서 서명 원문 조회 실패:", error);
+ return NextResponse.json(
+ {
+ success: false,
+ error:
+ error instanceof Error
+ ? error.message
+ : "계약서 파일을 읽는 중 오류가 발생했습니다.",
+ },
+ { status: 500, headers: getCorsHeaders(request) }
+ );
+ }
+}
diff --git a/lib/basic-contract/service-vendor-info.ts b/lib/basic-contract/service-vendor-info.ts
new file mode 100644
index 00000000..2fe2d512
--- /dev/null
+++ b/lib/basic-contract/service-vendor-info.ts
@@ -0,0 +1,35 @@
+"use server";
+
+import db from "@/db/db";
+import { vendors } from "@/db/schema";
+import { eq } from "drizzle-orm";
+
+/**
+ * 벤더 ID로 벤더 정보 조회 (사업자번호 등)
+ */
+export async function getVendorInfo(vendorId: number) {
+ try {
+ const result = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ taxId: vendors.taxId, // 사업자등록번호
+ corporateRegistrationNumber: vendors.corporateRegistrationNumber, // 법인등록번호
+ country: vendors.country, // 국가 코드 (KR: 내자, 그외: 외자)
+ })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .limit(1);
+
+ if (!result || result.length === 0) {
+ return { success: false, error: "Vendor not found" };
+ }
+
+ return { success: true, data: result[0] };
+ } catch (error) {
+ console.error("Error fetching vendor info:", error);
+ return { success: false, error: "Failed to fetch vendor info" };
+ }
+}
+
diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog-test.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog-test.tsx
deleted file mode 100644
index ace34454..00000000
--- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog-test.tsx
+++ /dev/null
@@ -1,1166 +0,0 @@
-"use client";
-
-import * as React from "react";
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
-import { Button } from "@/components/ui/button";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { formatDate } from "@/lib/utils";
-import { useToast } from "@/hooks/use-toast";
-import { cn } from "@/lib/utils";
-import type { WebViewerInstance } from "@pdftron/webviewer";
-import type { BasicContractView } from "@/db/schema";
-import {
- Upload,
- FileSignature,
- CheckCircle2,
- Search,
- Clock,
- FileText,
- User,
- AlertCircle,
- Calendar,
- Loader2,
- ArrowRight,
- Trophy,
- 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, processBuyerSignatureAction } from "../service";
-
-// 계약서 상태 타입 정의
-interface ContractStatus {
- id: number;
- status: 'pending' | 'completed' | 'error' | 'vendor_signed';
- errorMessage?: string;
-}
-
-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,
- mode = 'vendor',
- onBuyerSignComplete,
- t,
- // 새로 추가된 props
- open: externalOpen,
- onOpenChange: externalOnOpenChange
-}: BasicContractSignDialogProps) {
- const { toast } = useToast();
-
- // 내부 상태 (외부 제어가 없을 때 사용)
- 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("");
- const [isSubmitting, setIsSubmitting] = React.useState(false);
-
- // 추가된 state들
- const [additionalFiles, setAdditionalFiles] = React.useState<any[]>([]);
- const [isLoadingAttachments, setIsLoadingAttachments] = React.useState(false);
-
- // 계약서 상태 관리
- 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;
- reviewStatus?: string;
- isComplete?: boolean;
- }>>({});
-
- const router = useRouter()
-
- // 실제 사용할 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;
-
- // 비활성화 이유 텍스트
- const getDisabledReason = () => {
- if (!hasSelectedRows) {
- return t("basicContracts.toolbar.selectRows");
- }
- if (contracts.length === 0) {
- return t("basicContracts.toolbar.noPendingContracts");
- }
- return "";
- };
-
- // 현재 선택된 계약서의 서명 완료 가능 여부 확인
-const canCompleteCurrentContract = React.useMemo(() => {
- if (!selectedContract) return false;
-
- const contractId = selectedContract.id;
-
- // 구매자 모드: 기존 로직 유지
- if (isBuyerMode) {
- const signatureCompleted = signatureStatus[contractId] === true;
- return signatureCompleted;
- }
-
- // 협력업체 모드 + 내자: TrustNet으로 이동만 하면 되므로 버튼은 항상 활성화
- if (isDomesticVendorForContract(selectedContract)) {
- return true;
- }
-
- const isComplianceTemplate = selectedContract.templateName?.includes('준법');
- const isGTCTemplate = selectedContract.templateName?.includes('GTC');
- const requiresNegotiationComplete = isComplianceTemplate || isGTCTemplate;
-
- const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true;
-
- const negotiationStatus = gtcCommentStatus[contractId];
- const negotiationCleared = requiresNegotiationComplete
- ? (!negotiationStatus?.hasComments || negotiationStatus?.isComplete === true)
- : true;
-
- const signatureCompleted = signatureStatus[contractId] === true;
-
- return surveyCompleted && negotiationCleared && signatureCompleted;
-}, [selectedContract, surveyCompletionStatus, signatureStatus, gtcCommentStatus, isBuyerMode]);
-
-
- // 계약서별 상태 초기화
- // Vendor signed 상태의 계약서도 포함하여 초기화
- React.useEffect(() => {
- if (contracts.length > 0 && contractStatuses.length === 0) {
- setContractStatuses(
- contracts.map(contract => {
- // 이미 서명된 계약서는 vendor_signed 상태로 초기화
- const isSigned = contract.vendorSignedAt ||
- contract.status === "COMPLETED" ||
- contract.status === "VENDOR_SIGNED";
- return {
- id: contract.id,
- status: isSigned ? ('vendor_signed' as const) : ('pending' as const)
- };
- })
- );
- }
- }, [contracts, contractStatuses.length]);
-
- // 완료된 계약서 수 계산
- const completedCount = contractStatuses.filter(status =>
- status.status === 'completed' || status.status === 'vendor_signed'
- ).length;
- const totalCount = contracts.length;
- const allCompleted = completedCount === totalCount && totalCount > 0;
-
- // 현재 선택된 계약서의 상태
- const currentContractStatus = selectedContract
- ? contractStatuses.find(status => status.id === selectedContract.id)
- : null;
-
- // 내자/외자 판별 (vendors.country 기반, KR 앞 2자리)
- const getVendorCountryCode = React.useCallback((contract: BasicContractView | null) => {
- if (!contract || !contract.vendorCountry) return null;
- return contract.vendorCountry.substring(0, 2).toUpperCase();
- }, []);
-
- const isDomesticVendorForContract = React.useCallback((contract: BasicContractView | null) => {
- if (!contract) return false;
- const countryCode = getVendorCountryCode(contract);
- return countryCode === "KR";
- }, [getVendorCountryCode]);
-
- const isDomesticVendor = React.useMemo(() => {
- return isDomesticVendorForContract(selectedContract);
- }, [selectedContract, isDomesticVendorForContract]);
-
- // 다음 미완료 계약서 찾기
- const getNextPendingContract = () => {
- const pendingStatuses = contractStatuses.filter(status =>
- status.status === 'pending' || status.status === 'error'
- );
- if (pendingStatuses.length === 0) return null;
-
- const nextPendingId = pendingStatuses[0].id;
- return contracts.find(contract => contract.id === nextPendingId) || null;
- };
-
- // 다이얼로그 열기/닫기 핸들러 (외부 제어 지원)
- const handleOpenChange = (isOpen: boolean) => {
- if (!isOpen && !allCompleted && completedCount > 0) {
- // 완료되지 않은 계약서가 있으면 확인 대화상자
- // const confirmClose = window.confirm(
- // `${completedCount}/${totalCount}개 계약서가 완료되었습니다. 정말 나가시겠습니까?`
- // );
- const confirmClose = window.confirm(
- `정말 나가시겠습니까?`
- );
- if (!confirmClose) return;
- }
-
- // 외부 제어가 있으면 외부 콜백 호출, 없으면 내부 상태 업데이트
- if (isControlledExternally && externalOnOpenChange) {
- externalOnOpenChange(isOpen);
- } else {
- setInternalOpen(isOpen);
- }
-
- if (!isOpen) {
- // 다이얼로그 닫을 때 상태 초기화
- setSelectedContract(null);
- setSearchTerm("");
- setAdditionalFiles([]);
- setContractStatuses([]);
- setSurveyCompletionStatus({});
- setSignatureStatus({});
- setGtcCommentStatus({});
- // WebViewer 인스턴스 정리
- if (instance) {
- try {
- instance.UI.dispose();
- } catch (error) {
- console.log("WebViewer dispose error:", error);
- }
- setInstance(null);
- }
- }
- };
-
- // 계약서 선택 핸들러
- const handleSelectContract = (contract: BasicContractView) => {
- console.log("계약서 선택:", contract.id, contract.templateName);
- setSelectedContract(contract);
- };
-
- // 검색된 계약서 필터링
- const filteredContracts = React.useMemo(() => {
- if (!searchTerm.trim()) return contracts;
-
- const term = searchTerm.toLowerCase();
- return contracts.filter(contract =>
- (contract.templateName || '').toLowerCase().includes(term) ||
- (contract.requestedByName || '').toLowerCase().includes(term)
- );
- }, [contracts, searchTerm]);
-
- // 다이얼로그가 열릴 때 첫 번째 미완료 계약서 자동 선택
- React.useEffect(() => {
- if (open && contracts.length > 0 && !selectedContract) {
- const firstPending = getNextPendingContract();
- if (firstPending) {
- setSelectedContract(firstPending);
- } else {
- setSelectedContract(contracts[0]);
- }
- }
- }, [open, contracts, selectedContract, contractStatuses]);
-
- // 추가 파일 가져오기 useEffect (구매자 모드에서는 스킵)
- React.useEffect(() => {
- if (isBuyerMode) {
- setAdditionalFiles([]);
- return;
- }
-
- const fetchAdditionalFiles = async () => {
- if (!selectedContract) {
- setAdditionalFiles([]);
- return;
- }
-
- // "비밀유지 계약서"인 경우에만 추가 파일 가져오기
- if (selectedContract.templateName === "비밀유지 계약서" && selectedContract.vendorId) {
- setIsLoadingAttachments(true);
- try {
- const result = await getVendorAttachments(selectedContract.vendorId);
- if (result.success) {
- setAdditionalFiles(result.data);
- console.log("추가 파일 로드됨:", result.data);
- } else {
- console.error("Failed to fetch attachments:", result.error);
- setAdditionalFiles([]);
- }
- } catch (error) {
- console.error("Error fetching attachments:", error);
- setAdditionalFiles([]);
- } finally {
- setIsLoadingAttachments(false);
- }
- } else {
- setAdditionalFiles([]);
- }
- };
-
- fetchAdditionalFiles();
- }, [selectedContract, isBuyerMode]);
-
- // 설문조사 완료 콜백 함수
- const handleSurveyComplete = React.useCallback((contractId: number) => {
- console.log(`📋 설문조사 완료: 계약서 ${contractId}`);
- setSurveyCompletionStatus(prev => ({
- ...prev,
- [contractId]: true
- }));
- }, []);
-
- // 서명 완료 콜백 함수
- const handleSignatureComplete = React.useCallback((contractId: number) => {
- console.log(`✍️ 서명 완료: 계약서 ${contractId}`);
- setSignatureStatus(prev => ({
- ...prev,
- [contractId]: true
- }));
- }, []);
-
- // GTC 코멘트 상태 변경 콜백 함수
- const handleGtcCommentStatusChange = React.useCallback((
- contractId: number,
- hasComments: boolean,
- commentCount: number,
- reviewStatus?: string,
- isComplete?: boolean
- ) => {
- console.log(`📋 GTC 상태 변경: 계약서 ${contractId}, 코멘트 ${commentCount}개, 상태: ${reviewStatus}, 완료: ${isComplete}`);
- setGtcCommentStatus(prev => ({
- ...prev,
- [contractId]: { hasComments, commentCount, reviewStatus, isComplete }
- }));
- }, []);
-
- // 서명 완료 핸들러
- const completeSign = async () => {
- if (!selectedContract) return;
-
- // 협력업체 모드 + 내자일 때는 TrustNet으로 이동 (기존 PDF 서명 로직 사용 안 함)
- if (!isBuyerMode && isDomesticVendor) {
- try {
- const baseUrl = "https://partners.sevcp.com/trustnet/contract-req.html";
- const origin = typeof window !== "undefined" ? window.location.origin : "";
- const url = `${baseUrl}?contractId=${encodeURIComponent(
- String(selectedContract.id)
- )}&origin=${encodeURIComponent(origin)}`;
-
- window.open(url, "_blank", "noopener,noreferrer");
-
- toast({
- title: "공동인증서 서명 페이지가 새 창으로 열렸습니다.",
- description: "TrustNet 화면에서 전자서명을 완료해주세요.",
- });
- } catch (error) {
- console.error("TrustNet 서명 페이지 열기 실패:", error);
- toast({
- title: "TrustNet 서명 페이지를 여는 중 오류가 발생했습니다.",
- variant: "destructive",
- });
- }
-
- return;
- }
-
- if (!instance) return;
-
- // 서명 완료 가능 여부 재확인
- if (!canCompleteCurrentContract) {
- const contractId = selectedContract.id;
-
- if (isBuyerMode) {
- const signatureCompleted = signatureStatus[contractId] === true;
-
- if (!signatureCompleted) {
- toast({
- title: "계약서에 서명을 먼저 완료해주세요.",
- description: "문서의 서명 필드에 서명해주세요.",
- variant: "destructive"
- });
- return;
- }
- } else {
- // 협력업체 모드의 기존 검증 로직
- const isComplianceTemplate = selectedContract.templateName?.includes('준법');
- const isGTCTemplate = selectedContract.templateName?.includes('GTC');
- const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true;
- const requiresNegotiationComplete = isComplianceTemplate || isGTCTemplate;
- const negotiationStatus = gtcCommentStatus[contractId];
- const negotiationCleared = requiresNegotiationComplete
- ? (!negotiationStatus?.hasComments || negotiationStatus?.isComplete === true)
- : true;
- const signatureCompleted = signatureStatus[contractId] === true;
-
- if (!surveyCompleted) {
- toast({
- title: "준법 설문조사를 먼저 완료해주세요.",
- description: "설문조사 탭에서 모든 필수 항목을 완료해주세요.",
- variant: "destructive"
- });
- return;
- }
-
- if (!negotiationCleared) {
- toast({
- title: "코멘트가 있어 서명할 수 없습니다.",
- description: "협의 코멘트 탭에서 모든 코멘트를 삭제하거나 협의를 완료해주세요.",
- variant: "destructive"
- });
- return;
- }
-
- if (!signatureCompleted) {
- toast({
- title: "계약서에 서명을 먼저 완료해주세요.",
- description: "문서의 서명 필드에 서명해주세요.",
- variant: "destructive"
- });
- return;
- }
- }
-
- return;
- }
-
- setIsSubmitting(true);
- try {
- const { documentViewer, annotationManager } = instance.Core;
- const doc = documentViewer.getDocument();
- const xfdfString = await annotationManager.exportAnnotations();
-
- // 폼 필드 데이터 수집
- const fieldManager = annotationManager.getFieldManager();
- const fields = fieldManager.getFields();
- const formData: any = {};
- fields.forEach((field: any) => {
- formData[field.name] = field.value;
- });
-
- const data = await doc.getFileData({
- xfdfString,
- downloadType: "pdf",
- });
-
- if (isBuyerMode) {
- // 구매자 모드: 최종승인 처리
- const result = await processBuyerSignatureAction(
- selectedContract.id,
- data,
- selectedContract.signedFileName || `contract_${selectedContract.id}_buyer_signed.pdf`
- );
-
- if (result.success) {
- // 성공시 해당 계약서 상태를 완료로 업데이트
- setContractStatuses(prev =>
- prev.map(status =>
- status.id === selectedContract.id
- ? { ...status, status: 'completed' as const }
- : status
- )
- );
-
- toast({
- title: "최종승인이 완료되었습니다!",
- description: `${selectedContract.templateName} - ${completedCount + 1}/${totalCount}개 완료`
- });
-
- // 구매자 서명 완료 콜백 호출
- if (onBuyerSignComplete) {
- onBuyerSignComplete(selectedContract.id, data);
- }
-
- // 다음 미완료 계약서로 자동 이동
- const nextContract = getNextPendingContract();
- if (nextContract) {
- setSelectedContract(nextContract);
- } else {
- // 모든 계약서 완료시
- toast({
- title: "🎉 모든 계약서 최종승인이 완료되었습니다!",
- description: `총 ${totalCount}개 계약서 승인 완료`
- });
- }
-
- router.refresh();
- } else {
- // 실패시 에러 상태 업데이트
- setContractStatuses(prev =>
- prev.map(status =>
- status.id === selectedContract.id
- ? { ...status, status: 'error' as const, errorMessage: result.message }
- : status
- )
- );
-
- toast({
- title: "최종승인 처리 중 오류가 발생했습니다",
- description: result.message,
- variant: "destructive"
- });
- }
- } else {
- // 협력업체 모드: 기존 로직
- 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({
- title: "계약서 서명이 완료되었습니다!",
- description: `${selectedContract.templateName} - ${completedCount + 1}/${totalCount}개 완료`
- });
-
- // 다음 미완료 계약서로 자동 이동
- const nextContract = getNextPendingContract();
- if (nextContract) {
- setSelectedContract(nextContract);
- } else {
- // 모든 계약서 완료시
- toast({
- title: "🎉 모든 계약서 서명이 완료되었습니다!",
- description: `총 ${totalCount}개 계약서 서명 완료`
- });
- }
-
- router.refresh();
- } else {
- // 실패시 에러 상태 업데이트
- setContractStatuses(prev =>
- prev.map(status =>
- status.id === selectedContract.id
- ? { ...status, status: 'error' as const, errorMessage: result.error }
- : status
- )
- );
-
- toast({
- title: "서명 처리 중 오류가 발생했습니다",
- description: result.error,
- variant: "destructive"
- });
- }
- }
- } catch (error) {
- console.error("서명 완료 중 오류:", error);
-
- // 에러 상태 업데이트
- setContractStatuses(prev =>
- prev.map(status =>
- status.id === selectedContract.id
- ? { ...status, status: 'error' as const, errorMessage: '서명 처리 중 오류가 발생했습니다' }
- : status
- )
- );
-
- toast({
- title: "서명 처리 중 오류가 발생했습니다",
- variant: "destructive"
- });
- } finally {
- setIsSubmitting(false);
- }
- };
-
- // 모든 서명 완료 핸들러
- const completeAllSigns = () => {
- handleOpenChange(false);
- if (onSuccess) {
- onSuccess();
- }
- const successMessage = isBuyerMode
- ? "모든 계약서 최종승인이 완료되었습니다!"
- : "모든 계약서 서명이 완료되었습니다!";
-
- toast({
- title: successMessage,
- description: "계약서 관리 페이지가 새고침됩니다."
- });
- };
-
- return (
- <>
- {/* 서명 버튼 - 외부 제어가 없을 때만 표시 */}
- {!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"
- )}
- >
- {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 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={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">
- {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={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> */}
- {/* 추가 파일 로딩 표시 */}
- {isLoadingAttachments && (
- <Loader2 className="ml-2 h-4 w-4 animate-spin text-blue-500" />
- )}
- </div>
-
- {allCompleted && (
- <Badge variant="default" className="bg-green-100 text-green-700 border-green-200">
- <Trophy className="h-4 w-4 mr-1" />
- 전체 완료!
- </Badge>
- )}
- </DialogTitle>
-
- {/* 진행률 바 */}
- {totalCount > 1 && (
- <div className="mt-3">
- <div className="flex justify-between text-xs text-gray-600 mb-1">
- <span>전체 진행률</span>
- <span>{Math.round((completedCount / totalCount) * 100)}%</span>
- </div>
- <div className="w-full bg-gray-200 rounded-full h-2">
- <div
- 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>
- </div>
- )}
- </DialogHeader>
-
- {/* 메인 컨텐츠 영역 - Flexbox 사용 */}
- <div className="flex flex-1 min-h-0 overflow-hidden">
- {/* 왼쪽 영역 - 계약서 목록 (고정 너비) */}
- <div className="w-80 border-r border-gray-200 bg-gray-50 flex flex-col flex-shrink-0">
- <div className="p-3 border-b flex-shrink-0">
- <div className="relative">
- <div className="absolute inset-y-0 left-2 flex items-center pointer-events-none">
- <Search className="h-4 w-4 text-gray-400" />
- </div>
- <Input
- placeholder={t("basicContracts.dialog.searchPlaceholder")}
- className="bg-white pl-8 text-sm"
- value={searchTerm}
- onChange={(e) => setSearchTerm(e.target.value)}
- />
- </div>
- </div>
-
- <ScrollArea className="flex-1">
- <div className="p-2">
- {filteredContracts.length === 0 ? (
- <div className="flex flex-col items-center justify-center h-32 text-center">
- <FileText className="h-8 w-8 text-gray-300 mb-2" />
- <p className="text-gray-500 text-sm font-medium">{t("basicContracts.dialog.noDocuments")}</p>
- </div>
- ) : (
- <div className="space-y-2">
- {filteredContracts.map((contract) => {
- const contractStatus = contractStatuses.find(status => status.id === contract.id);
- const isCompleted = contractStatus?.status === 'completed' || contractStatus?.status === 'vendor_signed';
- const hasError = contractStatus?.status === 'error';
-
- // 계약서별 완료 상태 확인
- const isComplianceTemplate = contract.templateName?.includes('준법');
- const isGTCTemplate = contract.templateName?.includes('GTC');
- const requiresNegotiation = isComplianceTemplate || isGTCTemplate;
- const hasSurveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contract.id] === true : true;
- const negotiationStatus = gtcCommentStatus[contract.id];
- const hasNegotiationCompleted = requiresNegotiation
- ? (!negotiationStatus?.hasComments || negotiationStatus?.isComplete === true)
- : true;
- const hasSignatureCompleted = signatureStatus[contract.id] === true;
-
- return (
- <Button
- key={contract.id}
- variant="outline"
- className={cn(
- "w-full justify-start text-left h-auto p-2 bg-white transition-colors",
- "border border-gray-200 rounded-md",
- selectedContract?.id === contract.id && !isCompleted && "border-blue-500 bg-blue-50 shadow-sm",
- isCompleted && "border-green-200 bg-green-50",
- hasError && "border-red-200 bg-red-50",
- !isCompleted && !hasError && "hover:bg-blue-50 hover:border-blue-200"
- )}
- onClick={() => handleSelectContract(contract)}
- // Vendor signed 상태에서도 코멘트를 볼 수 있도록 비활성화하지 않음
- disabled={false}
- >
- <div className="flex flex-col w-full space-y-1">
- {/* 첫 번째 줄: 제목 + 상태 */}
- <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">
- {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 === "비밀유지 계약서" && (
- <Badge variant="outline" className="ml-1 bg-green-50 text-green-700 border-green-200 text-xs">
- 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>
-
- {/* 상태 표시 */}
- {isCompleted ? (
- <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 text-xs ml-2 flex-shrink-0">
- <CheckCircle2 className="h-3 w-3 mr-1" />
- 완료
- </Badge>
- ) : hasError ? (
- <Badge variant="outline" className="bg-red-50 text-red-700 border-red-200 text-xs ml-2 flex-shrink-0">
- <AlertCircle className="h-3 w-3 mr-1" />
- 오류
- </Badge>
- ) : (
- <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200 text-xs ml-2 flex-shrink-0">
- 대기
- </Badge>
- )}
- </div>
-
- {/* 완료 상태 표시 (구매자 모드에서는 간소화) */}
- {!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'}`}>
- <CheckCircle2 className={`h-3 w-3 mr-1 ${hasSurveyCompleted ? 'text-green-500' : 'text-gray-300'}`} />
- 설문
- </span>
- )}
- {requiresNegotiation && (
- <span className={`flex items-center ${hasNegotiationCompleted ? 'text-green-600' : 'text-red-600'}`}>
- <CheckCircle2 className={`h-3 w-3 mr-1 ${hasNegotiationCompleted ? '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'}`} />
- 서명
- </span>
- </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">
- <User className="h-3 w-3 mr-1 flex-shrink-0" />
- <span className="truncate">{contract.requestedByName || t("basicContracts.dialog.unknown")}</span>
- </div>
- <div className="flex items-center ml-2 flex-shrink-0">
- <Calendar className="h-3 w-3 mr-1 flex-shrink-0" />
- <span>{formatDate(contract.createdAt)}</span>
- </div>
- </div>
-
- {/* 에러 메시지 표시 */}
- {hasError && contractStatus?.errorMessage && (
- <div className="text-xs text-red-600 mt-1">
- {contractStatus.errorMessage}
- </div>
- )}
- </div>
- </Button>
- );
- })}
- </div>
- )}
- </div>
- </ScrollArea>
- </div>
-
- {/* 오른쪽 영역 - 문서 뷰어 (확장 가능) */}
- <div className="flex-1 bg-white flex flex-col min-w-0">
- {selectedContract ? (
- <>
- {/* 뷰어 헤더 */}
- <div className="p-4 border-b bg-gray-50 flex-shrink-0">
- <h3 className="font-semibold text-gray-800 flex items-center">
- {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' || currentContractStatus?.status === 'vendor_signed') ? (
- <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">
- <AlertCircle className="h-3 w-3 mr-1" />
- 처리 실패
- </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>
- )}
-
- {/* 준법/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>
- )}
-
- {/* 비밀유지 계약서인 경우 추가 파일 수 표시 (구매자 모드가 아닐 때만) */}
- {!isBuyerMode && selectedContract.templateName === "비밀유지 계약서" && additionalFiles.length > 0 && (
- <Badge variant="outline" className="ml-2 bg-blue-50 text-blue-700 border-blue-200">
- 첨부파일 {additionalFiles.length}개
- </Badge>
- )}
- </h3>
- <div className="flex justify-between items-center mt-2 text-sm text-gray-500">
- <span className="flex items-center">
- <User className="h-3 w-3 mr-1" />
- {t("basicContracts.dialog.requester")}: {selectedContract.requestedByName || t("basicContracts.dialog.unknown")}
- </span>
- <span className="flex items-center">
- <Clock className="h-3 w-3 mr-1" />
- {formatDate(selectedContract.createdAt)}
- </span>
- </div>
- </div>
-
- {/* 뷰어 영역 - 남은 공간 모두 사용 */}
- <div className="flex-1 min-h-0 overflow-hidden">
- <BasicContractSignViewer
- key={selectedContract.id}
- contractId={selectedContract.id}
- filePath={selectedContract.signedFilePath || undefined}
- templateName={selectedContract.templateName || ""}
- additionalFiles={additionalFiles}
- instance={instance}
- setInstance={setInstance}
- onSurveyComplete={() => handleSurveyComplete(selectedContract.id)}
- onSignatureComplete={() => handleSignatureComplete(selectedContract.id)}
- onGtcCommentStatusChange={(hasComments, commentCount, reviewStatus, isComplete) =>
- handleGtcCommentStatusChange(selectedContract.id, hasComments, commentCount, reviewStatus, isComplete)
- }
- mode={mode}
- t={t}
- negotiationCompletedAt={(selectedContract as any).negotiationCompletedAt || null}
- />
- </div>
-
- {/* 고정 푸터 - 동적 버튼 */}
- <div className="p-4 flex justify-between items-center bg-gray-50 border-t flex-shrink-0">
- <div className="flex items-center space-x-4">
- {/* 현재 계약서가 완료된 경우 */}
- {currentContractStatus?.status === 'completed' || currentContractStatus?.status === 'vendor_signed' ? (
- <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" />
- {isBuyerMode
- ? "계약서에 구매자 서명을 완료해주세요."
- : t("basicContracts.dialog.signWarning")
- }
- </p>
-
- {/* 완료 상태 체크리스트 */}
- {!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') || selectedContract.templateName?.includes('준법')) && (
- <span className={`flex items-center ${
- (gtcCommentStatus[selectedContract.id]?.isComplete === true) || !gtcCommentStatus[selectedContract.id]?.hasComments
- ? 'text-green-600'
- : 'text-red-600'
- }`}>
- <CheckCircle2 className={`h-3 w-3 mr-1 ${
- (gtcCommentStatus[selectedContract.id]?.isComplete === true) || !gtcCommentStatus[selectedContract.id]?.hasComments
- ? 'text-green-500'
- : 'text-red-500'
- }`} />
- 협의 {(!gtcCommentStatus[selectedContract.id]?.hasComments || gtcCommentStatus[selectedContract.id]?.isComplete === 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>
- </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>
- </>
- )}
- </div>
-
- {/* 동적 버튼 영역 */}
- <div className="flex items-center space-x-2">
- {allCompleted ? (
- // 모든 계약서 완료시
- <Button
- 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' || currentContractStatus?.status === 'vendor_signed') ? (
- // 현재 계약서가 완료된 경우 - 코멘트 확인만 가능
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- className="gap-2"
- onClick={() => {
- const nextContract = getNextPendingContract();
- if (nextContract) {
- setSelectedContract(nextContract);
- }
- }}
- disabled={!getNextPendingContract()}
- >
- <ArrowRight className="h-4 w-4" />
- 다음 계약서
- </Button>
- <p className="text-sm text-gray-500">
- 서명 완료 - 코멘트 확인 가능
- </p>
- </div>
- ) : (
- // 현재 계약서를 서명해야 하는 경우
- <Button
- className={cn(
- "gap-2 transition-colors",
- canCompleteCurrentContract
- ? 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}
- >
- {isSubmitting ? (
- <>
- <svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
- </svg>
- 처리중...
- </>
- ) : (
- <>
- {isBuyerMode ? (
- <Shield className="h-4 w-4" />
- ) : (
- <FileSignature className="h-4 w-4" />
- )}
- {signButtonText}
- {totalCount > 1 && (
- <span className="ml-1 text-xs">
- ({completedCount}/{totalCount})
- </span>
- )}
- </>
- )}
- </Button>
- )}
- </div>
- </div>
- </>
- ) : (
- <div className="flex flex-col items-center justify-center h-full text-center p-6">
- <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">
- {isBuyerMode
- ? "승인할 계약서를 선택해주세요."
- : t("basicContracts.dialog.selectDocumentDescription")
- }
- </p>
- </div>
- )}
- </div>
- </div>
- </DialogContent>
- </Dialog>
- </>
- );
-} \ No newline at end of file
diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
index 407a3c4d..3a6dcf9b 100644
--- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
+++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
@@ -31,6 +31,7 @@ import { Separator } from "@/components/ui/separator";
import { useRouter } from "next/navigation"
import { BasicContractSignViewer } from "../viewer/basic-contract-sign-viewer";
import { getVendorAttachments, processBuyerSignatureAction } from "../service";
+import { getVendorInfo } from "../service-vendor-info";
// 계약서 상태 타입 정의
interface ContractStatus {
@@ -77,6 +78,7 @@ export function BasicContractSignDialog({
// 계약서 상태 관리
const [contractStatuses, setContractStatuses] = React.useState<ContractStatus[]>([]);
+ const [vendorInfo, setVendorInfo] = React.useState<{taxId?: string | null, country?: string | null} | null>(null);
// 서명/설문/GTC 코멘트 완료 상태 관리
const [surveyCompletionStatus, setSurveyCompletionStatus] = React.useState<Record<number, boolean>>({});
@@ -97,7 +99,13 @@ export function BasicContractSignDialog({
// 모드에 따른 텍스트
const isBuyerMode = mode === 'buyer';
const dialogTitle = isBuyerMode ? "구매자 최종승인 서명" : t("basicContracts.dialog.title");
- const signButtonText = isBuyerMode ? "최종승인 완료" : "서명 완료 및 저장";
+
+ // 서명 버튼 텍스트 (내자/외자 구분)
+ const signButtonText = React.useMemo(() => {
+ if (isBuyerMode) return "최종승인 완료";
+ if (vendorInfo?.country === 'KR') return "공동인증 서명";
+ return "서명 완료 및 저장";
+ }, [isBuyerMode, vendorInfo]);
// 버튼 비활성화 조건
const isButtonDisabled = !hasSelectedRows || contracts.length === 0;
@@ -257,15 +265,26 @@ const canCompleteCurrentContract = React.useMemo(() => {
React.useEffect(() => {
if (isBuyerMode) {
setAdditionalFiles([]);
+ setVendorInfo(null);
return;
}
const fetchAdditionalFiles = async () => {
if (!selectedContract) {
setAdditionalFiles([]);
+ setVendorInfo(null);
return;
}
+ // 벤더 정보 가져오기 (사업자번호 등)
+ if (selectedContract.vendorId) {
+ getVendorInfo(selectedContract.vendorId).then(res => {
+ if (res.success && res.data) {
+ setVendorInfo(res.data);
+ }
+ });
+ }
+
// "비밀유지 계약서"인 경우에만 추가 파일 가져오기
if (selectedContract.templateName === "비밀유지 계약서" && selectedContract.vendorId) {
setIsLoadingAttachments(true);
@@ -329,6 +348,104 @@ const canCompleteCurrentContract = React.useMemo(() => {
const completeSign = async () => {
if (!instance || !selectedContract) return;
+ // 🔹 내자인 경우 공동인증 팝업 열기
+ if (!isBuyerMode && vendorInfo?.country === 'KR') {
+ console.log("🔐 내자 협력업체 - 공동인증 서명 시작");
+
+ // 검증 로직은 동일하게 수행
+ const contractId = selectedContract.id;
+ const isComplianceTemplate = selectedContract.templateName?.includes('준법');
+ const isGTCTemplate = selectedContract.templateName?.includes('GTC');
+ const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true;
+ const requiresNegotiationComplete = isComplianceTemplate || isGTCTemplate;
+ const negotiationStatus = gtcCommentStatus[contractId];
+ const negotiationCleared = requiresNegotiationComplete
+ ? (!negotiationStatus?.hasComments || negotiationStatus?.isComplete === true)
+ : true;
+
+ if (!surveyCompleted) {
+ toast({
+ title: "준법 설문조사를 먼저 완료해주세요.",
+ description: "설문조사 탭에서 모든 필수 항목을 완료해주세요.",
+ variant: "destructive"
+ });
+ return;
+ }
+
+ if (!negotiationCleared) {
+ toast({
+ title: "코멘트가 있어 서명할 수 없습니다.",
+ description: "협의 코멘트 탭에서 모든 코멘트를 삭제하거나 협의를 완료해주세요.",
+ variant: "destructive"
+ });
+ return;
+ }
+
+ // 공동인증 팝업 열기
+ // trustnet은 포트 없이 접근 (<ip>:<port> → <ip>/trustnet)
+ // 단, API 호출을 위해 부모 창의 origin (포트 포함)을 전달
+ const ssn = vendorInfo.taxId || '';
+ const ssnParam = ssn ? `&ssn=${encodeURIComponent(ssn)}` : '';
+ const baseUrl = `${window.location.protocol}//${window.location.hostname}`;
+ const apiOrigin = window.location.origin; // 포트 포함 (예: http://60.101.108.100:3001)
+ const popupUrl = `${baseUrl}/trustnet?contractId=${selectedContract.id}${ssnParam}&autoStart=true&apiOrigin=${encodeURIComponent(apiOrigin)}`;
+
+ console.log('🔐 공동인증 팝업 열기:', { baseUrl, apiOrigin, popupUrl, ssn });
+ const popupWidth = 600;
+ const popupHeight = 700;
+ const left = (window.screen.width - popupWidth) / 2;
+ const top = (window.screen.height - popupHeight) / 2;
+
+ const popup = window.open(
+ popupUrl,
+ 'ContractSign',
+ `width=${popupWidth},height=${popupHeight},left=${left},top=${top},resizable=yes,scrollbars=yes`
+ );
+
+ if (!popup) {
+ toast({
+ title: "팝업이 차단되었습니다",
+ description: "브라우저 팝업 차단을 해제해주세요.",
+ variant: "destructive"
+ });
+ return;
+ }
+
+ toast({
+ title: "공동인증 서명 창이 열렸습니다",
+ description: "공동인증서로 서명을 진행해주세요.",
+ duration: 3000 // 3초 후 자동으로 사라짐
+ });
+
+ // 팝업에서 서명 완료 메시지를 받으면 새로고침
+ const handleMessage = (event: MessageEvent) => {
+ console.log('📨 메시지 수신:', event.data);
+
+ if (event.data.type === 'INTERNAL_SIGN_COMPLETE') {
+ console.log('✅ 공동인증 서명 완료:', event.data.contractId);
+
+ toast({
+ title: "서명이 완료되었습니다",
+ description: "계약서 상태가 업데이트됩니다.",
+ duration: 3000 // 3초 후 자동으로 사라짐
+ });
+
+ // 팝업 닫힌 후 테이블 데이터만 재조회 (페이지 전체 리로드 대신)
+ if (onSuccess) {
+ onSuccess();
+ }
+ router.refresh();
+
+ // 리스너 제거
+ window.removeEventListener('message', handleMessage);
+ }
+ };
+
+ window.addEventListener('message', handleMessage);
+
+ return;
+ }
+
// 서명 완료 가능 여부 재확인
if (!canCompleteCurrentContract) {
const contractId = selectedContract.id;
@@ -345,7 +462,7 @@ const canCompleteCurrentContract = React.useMemo(() => {
return;
}
} else {
- // 협력업체 모드의 기존 검증 로직
+ // 협력업체 모드의 기존 검증 로직 (외자)
const isComplianceTemplate = selectedContract.templateName?.includes('준법');
const isGTCTemplate = selectedContract.templateName?.includes('GTC');
const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true;
@@ -882,6 +999,21 @@ const canCompleteCurrentContract = React.useMemo(() => {
</Badge>
)}
+ {/* 내자/외자 표시 (협력업체 모드일 때만) */}
+ {!isBuyerMode && vendorInfo && (
+ <Badge
+ variant="outline"
+ className={cn(
+ "ml-2",
+ vendorInfo.country === 'KR'
+ ? "bg-blue-50 text-blue-700 border-blue-200"
+ : "bg-gray-50 text-gray-700 border-gray-200"
+ )}
+ >
+ {vendorInfo.country === 'KR' ? '🔐 내자 (공동인증)' : '🌍 외자'}
+ </Badge>
+ )}
+
{/* 준법/GTC 템플릿 표시 (구매자 모드가 아닐 때만) */}
{!isBuyerMode && selectedContract.templateName?.includes('준법') && (
<Badge variant="outline" className="ml-2 bg-amber-50 text-amber-700 border-amber-200">
@@ -932,6 +1064,8 @@ const canCompleteCurrentContract = React.useMemo(() => {
mode={mode}
t={t}
negotiationCompletedAt={(selectedContract as any).negotiationCompletedAt || null}
+ vendorTaxId={vendorInfo?.taxId || undefined}
+ vendorCountry={vendorInfo?.country || undefined}
/>
</div>
diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
index 8185e33e..98204763 100644
--- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
+++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
@@ -54,6 +54,8 @@ interface BasicContractSignViewerProps {
mode?: 'vendor' | 'buyer'; // 추가된 mode prop
t?: (key: string) => string;
negotiationCompletedAt?: Date | null; // 협의 완료 시간 추가
+ vendorTaxId?: string;
+ vendorCountry?: string;
}
// 자동 서명 필드 생성을 위한 타입 정의
@@ -694,6 +696,8 @@ export function BasicContractSignViewer({
mode = 'vendor', // 기본값 vendor
t = (key: string) => key,
negotiationCompletedAt,
+ vendorTaxId,
+ vendorCountry,
}: BasicContractSignViewerProps) {
const { toast } = useToast();
@@ -1255,6 +1259,12 @@ export function BasicContractSignViewer({
const currentInstance = webViewerInstance.current || instance;
if (!currentInstance) return;
+ // 내자(KR)인 경우 TrustNet 공동인증 서명 팝업 호출 (구매자 모드가 아닐 때만)
+ if (mode === 'vendor' && vendorCountry === 'KR') {
+ handleCertificateSign();
+ return;
+ }
+
try {
const { documentViewer, annotationManager } = currentInstance.Core;
const doc = documentViewer.getDocument();
@@ -1325,6 +1335,31 @@ export function BasicContractSignViewer({
}
};
+ const handleCertificateSign = () => {
+ if (!contractId) {
+ toast({ title: "계약서 ID가 없습니다.", variant: "destructive" });
+ return;
+ }
+
+ // 사업자번호가 없으면 입력하도록 안내 (혹은 그냥 빈값으로 열어서 직접 입력하게 할 수도 있음)
+ // 여기서는 경고만 하고 열어줌
+ if (!vendorTaxId) {
+ // toast({ title: "사업자번호 정보를 불러올 수 없습니다.", description: "팝업에서 직접 입력해주세요.", variant: "default" });
+ }
+
+ const width = 600;
+ const height = 700;
+ const left = window.screenX + (window.outerWidth - width) / 2;
+ const top = window.screenY + (window.outerHeight - height) / 2;
+
+ const ssnParam = vendorTaxId ? `&ssn=${encodeURIComponent(vendorTaxId)}` : '';
+ const baseUrl = `${window.location.protocol}//${window.location.hostname}`;
+ const apiOrigin = encodeURIComponent(window.location.origin);
+ const url = `${baseUrl}/trustnet?contractId=${contractId}${ssnParam}&autoStart=true&apiOrigin=${apiOrigin}`;
+
+ window.open(url, 'TrustNetSign', `width=${width},height=${height},top=${top},left=${left}`);
+ };
+
// 서명 상태 표시 컴포넌트 - 처리 중이거나 오류일 때만 표시
const SignatureFieldsStatus = () => {
// 처리 중이거나 오류가 있을 때만 표시 (완료 후에는 자동으로 사라짐)