summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-12-01 11:20:08 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-12-01 11:20:08 +0000
commitc691e816c56bbe0069e7f2da90466b8480f0cd15 (patch)
tree08747df599ea874a9437ce94555acdd64a8de423
parentc3a1e8480ab016d2bf0b1fc2470943a0d64749d5 (diff)
(임수민) 공동인증 테스트 파일
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-sign-dialog-test.tsx1166
1 files changed, 1166 insertions, 0 deletions
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
new file mode 100644
index 00000000..ace34454
--- /dev/null
+++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog-test.tsx
@@ -0,0 +1,1166 @@
+"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