summaryrefslogtreecommitdiff
path: root/lib/vendors/table/request-pq-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendors/table/request-pq-dialog.tsx')
-rw-r--r--lib/vendors/table/request-pq-dialog.tsx2275
1 files changed, 1145 insertions, 1130 deletions
diff --git a/lib/vendors/table/request-pq-dialog.tsx b/lib/vendors/table/request-pq-dialog.tsx
index 83eab201..4197b879 100644
--- a/lib/vendors/table/request-pq-dialog.tsx
+++ b/lib/vendors/table/request-pq-dialog.tsx
@@ -1,1130 +1,1145 @@
-"use client"
-
-import * as React from "react"
-import { type Row } from "@tanstack/react-table"
-import { Loader, SendHorizonal, Search, X, Plus, Router } from "lucide-react"
-import { toast } from "sonner"
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { Checkbox } from "@/components/ui/checkbox"
-import { Label } from "@/components/ui/label"
-import { Input } from "@/components/ui/input"
-import { Badge } from "@/components/ui/badge"
-import { Progress } from "@/components/ui/progress"
-import { Vendor } from "@/db/schema/vendors"
-import { requestBasicContractInfo, requestPQVendors, sendBasicContractEmail } from "../service"
-import { getProjectsWithPQList, getNonInspectionPQLists } from "@/lib/pq/service"
-import type { Project } from "@/lib/pq/service"
-import { useSession } from "next-auth/react"
-import { DatePicker } from "@/components/ui/date-picker"
-import { getALLBasicContractTemplates } from "@/lib/basic-contract/service"
-import type { BasicContractTemplate } from "@/db/schema"
-import { saveNdaAttachments, getVendorPQHistory } from "../service"
-import { useRouter } from "next/navigation"
-import { createGtcVendorDocuments, createProjectGtcVendorDocuments, getStandardGtcDocumentId, getProjectGtcDocumentId } from "@/lib/gtc-contract/service"
-import { MaterialGroupSelectorDialogMulti } from "@/components/common/material/material-group-selector-dialog-multi"
-import type { MaterialSearchItem } from "@/lib/material/material-group-service"
-// import { PQContractViewer } from "../pq-contract-viewer" // 더 이상 사용하지 않음
-
-interface RequestPQDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> {
- vendors: Row<Vendor>["original"][]
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-// const AGREEMENT_LIST = [
-// "준법서약",
-// "표준하도급계약",
-// "안전보건관리계약",
-// "윤리규범 준수 서약",
-// "동반성장협약",
-// "내국신용장 미개설 합의",
-// "기술자료 제출 기본 동의",
-// "GTC 합의",
-// ]
-
-// PQ 대상 품목 타입 정의 (Material Group 기반) - MaterialSearchItem 사용
-
-export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...props }: RequestPQDialogProps) {
- const [isApprovePending, startApproveTransition] = React.useTransition()
- const isDesktop = useMediaQuery("(min-width: 640px)")
- const { data: session } = useSession()
- const router = useRouter()
- const [type, setType] = React.useState<"GENERAL" | "PROJECT" | "NON_INSPECTION" | null>(null)
- const [dueDate, setDueDate] = React.useState<string | null>(null)
- const [projects, setProjects] = React.useState<Project[]>([])
- const [selectedProjectId, setSelectedProjectId] = React.useState<number | null>(null)
- const [agreements, setAgreements] = React.useState<Record<string, boolean>>({})
- const [extraNote, setExtraNote] = React.useState<string>("")
- const [pqItems, setPqItems] = React.useState<MaterialSearchItem[]>([])
-
- // PQ 품목 선택 관련 상태는 MaterialGroupSelectorDialogMulti에서 관리됨
- const [isLoadingProjects, setIsLoadingProjects] = React.useState(false)
- const [basicContractTemplates, setBasicContractTemplates] = React.useState<BasicContractTemplate[]>([])
- const [selectedTemplateIds, setSelectedTemplateIds] = React.useState<number[]>([])
- const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false)
-
- // 미실사 PQ 관련 상태
- const [activeNonInspectionPQList, setActiveNonInspectionPQList] = React.useState<any>(null)
- const [isLoadingNonInspectionPQ, setIsLoadingNonInspectionPQ] = React.useState(false)
-
- // 비밀유지 계약서 첨부파일 관련 상태
- const [ndaAttachments, setNdaAttachments] = React.useState<File[]>([])
- const [isUploadingNdaFiles, setIsUploadingNdaFiles] = React.useState(false)
-
- // 프로그레스 관련 상태
- const [progressValue, setProgressValue] = React.useState(0)
- const [currentStep, setCurrentStep] = React.useState("")
- const [showProgress, setShowProgress] = React.useState(false)
-
- // PQ 히스토리 관련 상태
- const [pqHistory, setPqHistory] = React.useState<Record<number, any[]>>({})
- const [isLoadingHistory, setIsLoadingHistory] = React.useState(false)
-
-
- React.useEffect(() => {
- if (type === "PROJECT") {
- setIsLoadingProjects(true)
- getProjectsWithPQList().then(setProjects).catch(() => toast.error("프로젝트 로딩 실패"))
- .finally(() => setIsLoadingProjects(false))
- } else if (type === "NON_INSPECTION") {
- setIsLoadingNonInspectionPQ(true)
- // 활성화된 미실사 PQ 리스트 조회
- getNonInspectionPQLists().then(result => {
- if (result.success) {
- setActiveNonInspectionPQList(result.data)
- } else {
- setActiveNonInspectionPQList(null)
- }
- }).catch(() => {
- toast.error("미실사 PQ 리스트 로딩 실패")
- setActiveNonInspectionPQList(null)
- }).finally(() => setIsLoadingNonInspectionPQ(false))
- }
- }, [type])
-
- // 기본계약서 템플릿 로딩 및 자동 선택, PQ 히스토리 로딩
- React.useEffect(() => {
- setIsLoadingTemplates(true)
- const loadPQHistory = async () => {
- if (vendors.length === 0) return
-
- setIsLoadingHistory(true)
- try {
- const historyPromises = vendors.map(async (vendor) => {
- console.log("vendor.id", vendor.id)
- const result = await getVendorPQHistory(vendor.id)
- console.log("result", result)
- return { vendorId: vendor.id, history: result.success ? result.data : [] }
- })
-
- const results = await Promise.all(historyPromises)
- const historyMap: Record<number, any[]> = {}
-
- results.forEach(({ vendorId, history }) => {
- historyMap[vendorId] = history
- })
-
- setPqHistory(historyMap)
- } catch (error) {
- console.error('PQ 히스토리 로딩 실패:', error)
- toast.error('PQ 히스토리 로딩 중 오류가 발생했습니다')
- } finally {
- setIsLoadingHistory(false)
- }
- }
- loadPQHistory()
- getALLBasicContractTemplates()
- .then((templates) => {
-
- // 벤더 국가별 자동 선택 로직
- if (vendors.length > 0) {
- const isAllForeign = vendors.every(vendor => vendor.country !== 'KR')
- const isAllDomestic = vendors.every(vendor => vendor.country === 'KR')
- //외자면 리스트에 비밀유지계약, 기술자료, 내국신용장, 한글 제외
- if(isAllForeign) {
- const foreignTemplates = templates.filter(template => {
- const name = template.templateName?.toLowerCase() || ''
- return !name.includes('비밀유지') && !name.includes('기술자료') && !name.includes('내국신용장') && !name.includes('한글')
- })
- setBasicContractTemplates(foreignTemplates)
- }
- //내자면 리스트에 GTC 제외, 비밀유지, 기술자료, 영문서약 제외
- if(isAllDomestic) {
- const domesticTemplates = templates.filter(template => {
- const name = template.templateName?.toLowerCase() || ''
- return !name.includes('gtc') && !name.includes('비밀유지') && !name.includes('기술자료') && !name.includes('영문')
- })
- setBasicContractTemplates(domesticTemplates)
- }
- if (isAllForeign) {
- // 외자: 준법서약 (영문), GTC 선택 (GTC는 1개만 선택하도록)
- const foreignTemplates = templates.filter(template => {
- const name = template.templateName?.toLowerCase() || ''
- return (
- (template.templateName?.includes('준법서약') && template.templateName?.includes('영문')) ||
- template.templateName?.includes('gtc')
- )
- })
- // GTC 템플릿 중 최신 리비전의 것만 선택
- const gtcTemplates = foreignTemplates.filter(t => t.templateName?.includes('gtc'))
- const nonGtcTemplates = foreignTemplates.filter(t => !t.templateName?.includes('gtc'))
-
- if (gtcTemplates.length > 0) {
- // GTC 템플릿 중 이름이 가장 긴 것 (프로젝트 GTC 우선) 선택
- const selectedGtcTemplate = gtcTemplates.reduce((prev, current) =>
- (prev.templateName?.length || 0) > (current.templateName?.length || 0) ? prev : current
- )
- setSelectedTemplateIds([...nonGtcTemplates.map(t => t.id), selectedGtcTemplate.id])
- } else {
- setSelectedTemplateIds(nonGtcTemplates.map(t => t.id))
- }
- } else if (isAllDomestic) {
- // 내자: 준법서약 (영문), GTC 제외한 모든 템플릿 선택
- // 비밀유지 계약서, 기술자료 요구서 제외
- const domesticTemplates = templates.filter(template => {
- const name = template.templateName?.toLowerCase() || ''
- return !(name.includes('준법서약') && name.includes('영문')) &&
- !name.includes('gtc')
- })
- setSelectedTemplateIds(domesticTemplates.map(t => t.id))
- }
- }
- })
- .catch(() => toast.error("기본계약서 템플릿 로딩 실패"))
- .finally(() => setIsLoadingTemplates(false))
- }, [vendors])
-
- React.useEffect(() => {
- if (!props.open) {
- setType(null)
- setSelectedProjectId(null)
- setAgreements({})
- setDueDate(null)
- setPqItems([])
- setExtraNote("")
- setSelectedTemplateIds([])
- setNdaAttachments([])
- setIsUploadingNdaFiles(false)
- setProgressValue(0)
- setCurrentStep("")
- setShowProgress(false)
- setPqHistory({})
- setIsLoadingHistory(false)
- setActiveNonInspectionPQList(null)
- setIsLoadingNonInspectionPQ(false)
- }
- }, [props.open])
-
- // PQ 품목 선택 함수 (MaterialGroupSelectorDialogMulti에서 호출됨)
- const handlePQItemsChange = (items: MaterialSearchItem[]) => {
- setPqItems(items)
- }
-
- // PQ 품목 제거 함수
- const handleRemovePQItem = (materialGroupCode: string) => {
- setPqItems(prev => prev.filter(item => item.materialGroupCode !== materialGroupCode))
- }
-
- // 비밀유지 계약서 첨부파일 추가 함수
- const handleAddNdaAttachment = (event: React.ChangeEvent<HTMLInputElement>) => {
- const files = event.target.files
- if (files) {
- const newFiles = Array.from(files)
- setNdaAttachments(prev => [...prev, ...newFiles])
- }
- // input 초기화
- event.target.value = ''
- }
-
- // 비밀유지 계약서 첨부파일 제거 함수
- const handleRemoveNdaAttachment = (fileIndex: number) => {
- setNdaAttachments(prev => prev.filter((_, index) => index !== fileIndex))
- }
-
- // 비밀유지 계약서가 선택되었는지 확인하는 함수
- const isNdaTemplateSelected = () => {
- return basicContractTemplates.some(template =>
- selectedTemplateIds.includes(template.id) &&
- template.templateName?.includes("비밀유지")
- )
- }
-
- const onApprove = () => {
- if (!type) return toast.error("PQ 유형을 선택하세요.")
- if (type === "PROJECT" && !selectedProjectId) return toast.error("프로젝트를 선택하세요.")
- if (type === "NON_INSPECTION" && !activeNonInspectionPQList) return toast.error("활성화된 미실사 PQ 리스트가 없습니다.")
- if (!dueDate) return toast.error("마감일을 선택하세요.")
- if (pqItems.length === 0) return toast.error("PQ 대상 품목을 선택하세요.")
- if (!session?.user?.id) return toast.error("인증 실패")
-
- // GTC 템플릿 선택 검증
- const selectedGtcTemplates = basicContractTemplates.filter(template =>
- selectedTemplateIds.includes(template.id) &&
- template.templateName?.toLowerCase().includes('gtc')
- )
-
- if (selectedGtcTemplates.length > 1) {
- return toast.error("GTC 템플릿은 하나만 선택할 수 있습니다.")
- }
-
- // 프로그레스 바를 즉시 표시
- setShowProgress(true)
- setProgressValue(0)
- setCurrentStep("시작 중...")
-
- startApproveTransition(async () => {
- try {
-
- // 전체 단계 수 계산
- const gtcTemplates = basicContractTemplates.filter(template =>
- selectedTemplateIds.includes(template.id) &&
- template.templateName?.toLowerCase().includes('gtc')
- )
-
- const totalSteps = 1 +
- (selectedTemplateIds.length > 0 ? 1 : 0) +
- (isNdaTemplateSelected() && ndaAttachments.length > 0 ? 1 : 0) +
- (gtcTemplates.length > 0 ? 1 : 0)
- let completedSteps = 0
-
- // 1단계: PQ 생성
- setCurrentStep("PQ 생성 중...")
- console.log("🚀 PQ 생성 시작")
- const { error: pqError } = await requestPQVendors({
- ids: vendors.map((v) => v.id),
- userId: Number(session.user.id),
- agreements,
- dueDate,
- projectId: type === "PROJECT" ? selectedProjectId : null,
- type: type || "GENERAL",
- extraNote,
- pqItems: JSON.stringify(pqItems.map(item => ({
- materialGroupCode: item.materialGroupCode,
- materialGroupDescription: item.materialGroupDescription
- }))),
- templateId: selectedTemplateIds.length > 0 ? selectedTemplateIds[0] : null,
- })
-
- if (pqError) {
- setShowProgress(false)
- toast.error(`PQ 생성 실패: ${pqError}`)
- return
- }
-
- completedSteps++
- setProgressValue((completedSteps / totalSteps) * 100)
- console.log("✅ PQ 생성 완료")
- toast.success("PQ가 성공적으로 요청되었습니다")
-
- // 2단계: 기본계약서 템플릿이 선택된 경우 백그라운드에서 처리
- if (selectedTemplateIds.length > 0) {
- const templates = basicContractTemplates.filter(t =>
- selectedTemplateIds.includes(t.id)
- )
-
- setCurrentStep(`기본계약서 생성 중... (${templates.length}개 템플릿)`)
- console.log("📋 기본계약서 백그라운드 처리 시작", templates.length, "개 템플릿")
- await processBasicContractsInBackground(templates, vendors)
-
- completedSteps++
- setProgressValue((completedSteps / totalSteps) * 100)
- }
-
- // 3단계: 비밀유지 계약서 첨부파일이 있는 경우 저장
- if (isNdaTemplateSelected() && ndaAttachments.length > 0) {
- setCurrentStep(`비밀유지 계약서 첨부파일 저장 중... (${ndaAttachments.length}개 파일)`)
- console.log("📎 비밀유지 계약서 첨부파일 처리 시작", ndaAttachments.length, "개 파일")
-
- const ndaResult = await saveNdaAttachments({
- vendorIds: vendors.map((v) => v.id),
- files: ndaAttachments,
- userId: session.user.id.toString()
- })
-
- if (ndaResult.success) {
- toast.success(`비밀유지 계약서 첨부파일이 모두 저장되었습니다 (${ndaResult.summary?.success}/${ndaResult.summary?.total})`)
- } else {
- toast.error(`첨부파일 처리 중 일부 오류가 발생했습니다: ${ndaResult.error}`)
- }
-
- completedSteps++
- setProgressValue((completedSteps / totalSteps) * 100)
- }
- //4단계: GTC 템플릿 처리
- if (selectedGtcTemplates.length > 0) {
- setCurrentStep(`GTC 문서 생성 중... (${selectedGtcTemplates.length}개 템플릿)`)
- console.log("📋 GTC 문서 생성 시작", selectedGtcTemplates.length, "개 템플릿")
-
- try {
- await processGtcTemplates(selectedGtcTemplates, vendors)
- completedSteps++
- setProgressValue((completedSteps / totalSteps) * 100)
- } catch (error) {
- console.error("GTC 템플릿 처리 중 오류:", error)
- toast.error(`GTC 템플릿 처리 중 오류가 발생했습니다`)
- // GTC 처리 실패해도 PQ 생성은 성공으로 간주
- completedSteps++
- setProgressValue((completedSteps / totalSteps) * 100)
- }
- }
- //5단계: 각 협력업체들에게 기본계약서 이메일 발송
- if (selectedTemplateIds.length > 0) {
- setCurrentStep(`기본계약서 이메일 발송 중... (${selectedTemplateIds.length}개 템플릿)`)
- console.log("📋 기본계약서 이메일 발송 시작", selectedTemplateIds.length, "개 템플릿")
- await processBasicContractsEmail(selectedTemplateIds, vendors)
- completedSteps++
- setProgressValue((completedSteps / totalSteps) * 100)
- }
-
- setCurrentStep("완료!")
- setProgressValue(100)
-
- // 잠시 완료 상태를 보여준 후 다이얼로그 닫기
- setTimeout(() => {
- setShowProgress(false)
- props.onOpenChange?.(false)
- onSuccess?.()
- }, 1000)
-
- } catch (error) {
- console.error('PQ 생성 오류:', error)
- setShowProgress(false)
- toast.error(`처리 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
- }
- })
- }
-
- // 백그라운드에서 기본계약서 처리
- const processBasicContractsInBackground = async (templates: BasicContractTemplate[], vendors: any[]) => {
- if (!session?.user?.id) {
- toast.error("인증 정보가 없습니다")
- return
- }
-
- try {
- const totalContracts = templates.length * vendors.length
- let processedCount = 0
-
- // 각 벤더별로, 각 템플릿을 처리
- for (let vendorIndex = 0; vendorIndex < vendors.length; vendorIndex++) {
- const vendor = vendors[vendorIndex]
-
- // 벤더별 템플릿 데이터 생성 (한글 변수명 사용)
- const templateData = {
- company_name: vendor.vendorName || '협력업체명',
- company_address: vendor.address || '주소',
- representative_name: vendor.representativeName || '대표자명',
- signature_date: new Date().toLocaleDateString('ko-KR'),
- tax_id: vendor.taxId || '사업자번호',
- phone_number: vendor.phone || '전화번호',
- }
-
- console.log(`🔄 벤더 ${vendorIndex + 1}/${vendors.length} 템플릿 데이터:`, templateData)
-
- // 해당 벤더에 대해 각 템플릿을 순차적으로 처리
- for (let templateIndex = 0; templateIndex < templates.length; templateIndex++) {
- const template = templates[templateIndex]
- processedCount++
-
- // 진행률 업데이트 (2단계 범위 내에서)
- const baseProgress = 33.33 // 1단계 완료 후
- const contractProgress = (processedCount / totalContracts) * 33.33 // 2단계는 33.33% 차지
- const newProgress = baseProgress + contractProgress
- setProgressValue(newProgress)
- setCurrentStep(`기본계약서 생성 중... (${processedCount}/${totalContracts})`)
-
- console.log(`📄 처리 중: ${vendor.vendorName} - ${template.templateName} (${processedCount}/${totalContracts})`)
-
- // 개별 벤더에 대한 기본계약 생성
- await processTemplate(template, templateData, [vendor])
-
- console.log(`✅ 완료: ${vendor.vendorName} - ${template.templateName}`)
- }
- }
-
- toast.success(`총 ${totalContracts}개 기본계약이 모두 생성되었습니다`)
- router.refresh();
-
- } catch (error) {
- console.error('기본계약 처리 중 오류:', error)
- toast.error(`기본계약 처리 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
- }
- }
-
- const processTemplate = async (template: BasicContractTemplate, templateData: any, vendors: any[]) => {
- try {
- // 1. 템플릿 파일 가져오기
- const templateResponse = await fetch('/api/basic-contract/get-template', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ templateId: template.id })
- })
-
- if (!templateResponse.ok) {
- throw new Error(`템플릿 파일을 가져올 수 없습니다: ${template.templateName}`)
- }
-
- const templateBlob = await templateResponse.blob()
-
- // 2. PDFTron을 사용해서 변수 치환 및 PDF 변환
- // @ts-ignore
- const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
-
- // 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음)
- const tempDiv = document.createElement('div')
- tempDiv.style.display = 'none'
- document.body.appendChild(tempDiv)
-
- const instance = await WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- },
- tempDiv
- )
-
- try {
- const { Core } = instance
- const { createDocument } = Core
-
- // 3. 템플릿 문서 생성 및 변수 치환
- const templateDoc = await createDocument(templateBlob, {
- filename: template.fileName || 'template.docx',
- extension: 'docx',
- })
-
- console.log("🔄 변수 치환 시작:", templateData)
- await templateDoc.applyTemplateValues(templateData)
- console.log("✅ 변수 치환 완료")
-
- // 4. PDF 변환
- const fileData = await templateDoc.getFileData()
- const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' })
-
- console.log(`✅ PDF 변환 완료: ${template.templateName}`, `크기: ${pdfBuffer.byteLength} bytes`)
-
- // 5. 기본계약 생성 요청
- const { error: contractError } = await requestBasicContractInfo({
- vendorIds: vendors.map((v) => v.id),
- requestedBy: Number(session!.user.id),
- templateId: template.id,
- pdfBuffer: new Uint8Array(pdfBuffer),
- })
-
- if (contractError) {
- throw new Error(contractError)
- }
-
- console.log(`✅ 기본계약 생성 완료: ${template.templateName}`)
-
-
- } finally {
- // 임시 WebViewer 정리
- instance.UI.dispose()
- document.body.removeChild(tempDiv)
- }
-
- } catch (error) {
- console.error(`❌ 템플릿 처리 실패: ${template.templateName}`, error)
- throw error
- }
- }
-
- // GTC 템플릿 처리 함수
- const processGtcTemplates = async (gtcTemplates: BasicContractTemplate[], vendors: any[]) => {
- if (!session?.user?.id) {
- toast.error("인증 정보가 없습니다")
- return
- }
-
- try {
- const vendorIds = vendors.map(v => v.id)
- const userId = Number(session.user.id)
-
- for (const template of gtcTemplates) {
- const templateName = template.templateName?.toLowerCase() || ''
-
- if (templateName.includes('general gtc') || (templateName.includes('gtc') && !templateName.includes(' '))) {
- // General GTC 처리
- console.log(`📄 General GTC 템플릿 처리: ${template.templateName}`)
-
- const gtcDocument = await getStandardGtcDocumentId()
- if (!gtcDocument) {
- toast.error(`표준 GTC 문서를 찾을 수 없습니다.`)
- continue
- }
-
- const result = await createGtcVendorDocuments({
- baseDocumentId: gtcDocument.id,
- vendorIds,
- createdById: userId,
- documentTitle: gtcDocument.title
- })
-
- if (result.success) {
- console.log(`✅ General GTC 문서 생성 완료: ${result.count}개`)
- } else {
- toast.error(`General GTC 문서 생성 실패: ${result.error}`)
- }
-
- } else if (templateName.includes('gtc') && templateName.includes(' ')) {
- // 프로젝트 GTC 처리 (프로젝트 코드 추출)
- const projectCodeMatch = template.templateName?.match(/^([A-Z0-9]+)\s+GTC/)
- console.log("🔄 프로젝트 GTC 템플릿 처리: ", template.templateName, projectCodeMatch)
- console.log(` - 템플릿 이름 분석: "${template.templateName}"`)
- console.log(` - 소문자 변환: "${templateName}"`)
- if (projectCodeMatch) {
- const projectCode = projectCodeMatch[1]
- console.log(`📄 프로젝트 GTC 템플릿 처리: ${template.templateName} (프로젝트: ${projectCode})`)
-
- // const gtcDocument = await getProjectGtcDocumentId(projectCode)
- // if (!gtcDocument) {
- // toast.error(`프로젝트 "${projectCode}"의 GTC 문서를 찾을 수 없습니다.`)
- // continue
- // }
- // console.log("🔄 getProjectGtcDocumentId", gtcDocument)
-
- const result = await createProjectGtcVendorDocuments({
- projectCode,
- vendorIds,
- createdById: userId,
- documentTitle: template.templateName
- })
-
- if (result.success) {
- console.log(`✅ 프로젝트 GTC 문서 생성 완료: ${result.count}개`)
- } else {
- toast.error(`프로젝트 GTC 문서 생성 실패: ${result.error}`)
- }
- } else {
- toast.error(`프로젝트 GTC 템플릿 이름 형식이 올바르지 않습니다: ${template.templateName}`)
- }
- } else {
- console.log(`⚠️ GTC 템플릿이지만 처리할 수 없는 형식: ${template.templateName}`)
- }
- }
-
- toast.success(`GTC 문서가 ${gtcTemplates.length}개 템플릿에 대해 생성되었습니다`)
-
- } catch (error) {
- console.error('GTC 템플릿 처리 중 오류:', error)
- toast.error(`GTC 템플릿 처리 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
- }
- }
-
- const processBasicContractsEmail = async (templateIds: number[], vendors: any[]) => {
- if (!session?.user?.id) {
- toast.error("인증 정보가 없습니다")
- return
- }
- try {
- const vendorIds = vendors.map(v => v.id)
- const userId = Number(session.user.id)
-
- // 2. 성공한 템플릿이 있으면 이메일 발송
- if (templateIds.length > 0) {
- const emailResult = await sendBasicContractEmail({
- vendorIds,
- templateIds,
- requestedBy: userId
- })
-
- if (emailResult.success) {
- toast.success(`${vendorIds.length}개 협력업체에 이메일이 발송되었습니다`)
- } else {
- toast.warning(`계약서는 생성되었으나 일부 이메일 발송 실패: ${emailResult.error}`)
- }
- } else {
- toast.error("기본계약서 생성에 실패했습니다")
- }
-
- } catch (error) {
- console.error('기본계약서 이메일 발송 중 오류:', error)
- toast.error(`기본계약서 이메일 발송 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
- }
-}
-
- // PQ 히스토리 컴포넌트
- const PQHistorySection = () => {
- if (isLoadingHistory) {
- return (
- <div className="px-4 py-3 border-b bg-muted/30">
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
- <Loader className="h-4 w-4 animate-spin" />
- PQ 히스토리 로딩 중...
- </div>
- </div>
- )
- }
-
- const hasAnyHistory = Object.values(pqHistory).some(history => history.length > 0)
-
- if (!hasAnyHistory) {
- return (
- <div className="px-4 py-3 border-b bg-muted/30">
- <div className="text-sm text-muted-foreground">
- 최근 PQ 요청 내역이 없습니다.
- </div>
- </div>
- )
- }
-
- return (
- <div className="px-4 py-3 border-b bg-muted/30 max-h-48 overflow-y-auto">
- <div className="space-y-3">
- <div className="text-sm font-medium text-muted-foreground">
- 최근 PQ 요청 내역
- </div>
- {vendors.map((vendor) => {
- const vendorHistory = pqHistory[vendor.id] || []
- if (vendorHistory.length === 0) return null
-
- return (
- <div key={vendor.id} className="space-y-2">
- <div className="text-xs font-medium text-muted-foreground border-b pb-1">
- {vendor.vendorName}
- </div>
- <div className="space-y-1">
- {vendorHistory.slice(0, 3).map((pq) => {
- const createdDate = new Date(pq.createdAt).toLocaleDateString('ko-KR')
- const statusText =
- pq.status === 'REQUESTED' ? '요청됨' :
- pq.status === 'APPROVED' ? '승인됨' :
- pq.status === 'SUBMITTED' ? '제출됨' :
- pq.status === 'REJECTED' ? '거절됨' :
- pq.status
-
- return (
- <div key={pq.id} className="flex items-center justify-between text-xs bg-background rounded px-2 py-1">
- <div className="flex items-center gap-2 flex-1">
- <button
- type="button"
- onClick={() => router.push(`/evcp/pq_new?search=${encodeURIComponent(pq.pqNumber)}`)}
- className="font-mono text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
- >
- {pq.pqNumber}
- </button>
- <Badge variant={pq.status === 'SUBMITTED' ? 'default' : pq.status === 'COMPLETED' ? 'default' : 'outline'} className="text-xs">
- {statusText}
- </Badge>
- </div>
- <div className="text-right">
- <div className="text-muted-foreground">
- {pq.type === 'GENERAL' ? '일반' : pq.type === 'PROJECT' ? '프로젝트' : '미실사'}
- </div>
- <div className="text-muted-foreground text-xs">
- {createdDate}
- </div>
- </div>
- </div>
- )
- })}
- {vendorHistory.length > 3 && (
- <div className="text-xs text-muted-foreground text-center">
- 외 {vendorHistory.length - 3}건 더 있음
- </div>
- )}
- </div>
- </div>
- )
- })}
- </div>
- </div>
- )
- }
-
-
-
- const dialogContent = (
- <div className="space-y-4 py-2">
- {/* 선택된 협력업체 정보 */}
- <div className="space-y-2">
- <Label>선택된 협력업체 ({vendors.length}개)</Label>
- <div className="max-h-40 overflow-y-auto border rounded-md p-3 space-y-2">
- {vendors.map((vendor) => (
- <div key={vendor.id} className="flex items-center justify-between text-sm">
- <div className="flex-1">
- <div className="font-medium">{vendor.vendorName}</div>
- <div className="text-muted-foreground">
- {vendor.vendorCode} • {vendor.email || "이메일 없음"}
- </div>
- </div>
- </div>
- ))}
- </div>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="type">PQ 종류 선택</Label>
- <Select onValueChange={(val: "GENERAL" | "PROJECT" | "NON_INSPECTION") => setType(val)} value={type ?? undefined}>
- <SelectTrigger id="type"><SelectValue placeholder="PQ 종류를 선택하세요" /></SelectTrigger>
- <SelectContent>
- <SelectItem value="GENERAL">일반 PQ</SelectItem>
- <SelectItem value="PROJECT">프로젝트 PQ</SelectItem>
- <SelectItem value="NON_INSPECTION">미실사 PQ</SelectItem>
- </SelectContent>
- </Select>
- </div>
-
- {type === "PROJECT" && (
- <div className="space-y-2">
- <Label htmlFor="project">프로젝트 선택</Label>
- <Select onValueChange={(val) => setSelectedProjectId(Number(val))}>
- <SelectTrigger id="project">
- <SelectValue placeholder="프로젝트 선택" />
- </SelectTrigger>
- <SelectContent>
- {isLoadingProjects ? (
- <SelectItem value="loading" disabled>로딩 중...</SelectItem>
- ) : projects.map((p) => (
- <SelectItem key={p.id} value={p.id.toString()}>{p.projectCode} - {p.projectName}</SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
- )}
-
- {type === "NON_INSPECTION" && (
- <div className="space-y-2">
- <Label>활성화된 미실사 PQ 리스트</Label>
- {isLoadingNonInspectionPQ ? (
- <div className="text-sm text-muted-foreground">로딩 중...</div>
- ) : activeNonInspectionPQList ? (
- <div className="p-3 border rounded-md bg-muted/30">
- <div className="flex items-center gap-2">
- <Badge variant="secondary">미실사 PQ</Badge>
- <span className="font-medium">{activeNonInspectionPQList.name}</span>
- </div>
- <div className="text-sm text-muted-foreground mt-1">
- 활성화된 미실사 PQ 리스트를 기준으로 요청합니다.
- </div>
- </div>
- ) : (
- <div className="p-3 border rounded-md bg-destructive/10 border-destructive/20">
- <div className="text-sm text-destructive">
- 활성화된 미실사 PQ 리스트가 없습니다. 먼저 PQ 관리에서 미실사 PQ 리스트를 생성하고 활성화해주세요.
- </div>
- </div>
- )}
- </div>
- )}
-
- {/* 마감일 입력 */}
- <div className="space-y-2">
- <Label htmlFor="dueDate">PQ 제출 마감일</Label>
- <DatePicker
- date={dueDate ? new Date(dueDate) : undefined}
- onSelect={(date?: Date) => {
- if (date) {
- // 한국 시간대로 날짜 변환 (UTC 변환으로 인한 날짜 변경 방지)
- const kstDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
- setDueDate(kstDate.toISOString().slice(0, 10))
- } else {
- setDueDate("")
- }
- }}
- placeholder="마감일 선택"
- minDate={new Date()}
- />
- </div>
-
- {/* PQ 대상품목 */}
- <div className="space-y-2">
- <Label>PQ 대상품목 *</Label>
- <br />
- <MaterialGroupSelectorDialogMulti
- triggerLabel="자재 그룹 선택"
- selectedMaterials={pqItems}
- onMaterialsSelect={handlePQItemsChange}
- maxSelections={10}
- placeholder="PQ 대상 자재 그룹을 검색하세요"
- title="PQ 대상 자재 그룹 선택"
- description="PQ를 요청할 자재 그룹을 선택해주세요."
- />
-
- {pqItems.length > 0 && (
- <div className="text-xs text-muted-foreground">
- {pqItems.length}개 자재 그룹이 선택되었습니다.
- </div>
- )}
- </div>
-
- {/* 추가 안내사항 */}
- <div className="space-y-2">
- <Label htmlFor="extraNote">추가 안내사항</Label>
- <textarea
- id="extraNote"
- value={extraNote}
- onChange={(e) => setExtraNote(e.target.value)}
- placeholder="추가 안내사항을 입력하세요 (선택사항)"
- className="w-full rounded-md border px-3 py-2 text-sm min-h-20 resize-none"
- />
- </div>
-
- {/* 기본계약서 템플릿 선택 (다중 선택) */}
- <div className="space-y-2">
- <Label>기본계약서 템플릿 (선택사항, 복수 선택 가능)</Label>
- {isLoadingTemplates ? (
- <div className="text-sm text-muted-foreground">템플릿 로딩 중...</div>
- ) : (
- <div className="space-y-2 max-h-40 overflow-y-auto border rounded-md p-3">
- {basicContractTemplates.map((template) => (
- <div key={template.id} className="flex items-center gap-2">
- <Checkbox
- id={`template-${template.id}`}
- checked={selectedTemplateIds.includes(template.id)}
- onCheckedChange={(checked) => {
- if (checked) {
- setSelectedTemplateIds(prev => [...prev, template.id])
- } else {
- setSelectedTemplateIds(prev => prev.filter(id => id !== template.id))
- }
- }}
- />
- <Label htmlFor={`template-${template.id}`} className="text-sm">
- {template.templateName}
- </Label>
- </div>
- ))}
- {basicContractTemplates.length === 0 && (
- <div className="text-sm text-muted-foreground">사용 가능한 템플릿이 없습니다.</div>
- )}
- </div>
- )}
- {/* {selectedTemplateIds.length > 0 && (
- <div className="text-xs text-muted-foreground">
- {selectedTemplateIds.length}개 템플릿이 선택되었습니다.
- {vendors.length > 0 && vendors.every(v => v.country !== 'KR') &&
- " (외자 벤더 - 자동 선택됨)"}
- {vendors.length > 0 && vendors.every(v => v.country === 'KR') &&
- " (내자 벤더 - 자동 선택됨)"}
- </div>
- )} */}
- </div>
-
- {/* 비밀유지 계약서 첨부파일 */}
- {isNdaTemplateSelected() && (
- <div className="space-y-2">
- <Label>비밀유지 계약서 첨부파일</Label>
-
- {/* 선택된 파일들 표시 */}
- {ndaAttachments.length > 0 && (
- <div className="space-y-2">
- <div className="text-sm text-muted-foreground">
- 선택된 파일 ({ndaAttachments.length}개)
- </div>
- <div className="space-y-1 max-h-32 overflow-y-auto border rounded-md p-2">
- {ndaAttachments.map((file, index) => (
- <div key={index} className="flex items-center justify-between text-sm bg-muted/50 rounded px-2 py-1">
- <div className="flex-1 truncate">
- <span className="font-medium">{file.name}</span>
- <span className="text-muted-foreground ml-2">
- ({(file.size / 1024 / 1024).toFixed(2)} MB)
- </span>
- </div>
- <Button
- type="button"
- variant="ghost"
- size="sm"
- className="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
- onClick={() => handleRemoveNdaAttachment(index)}
- >
- <X className="h-3 w-3" />
- </Button>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* 파일 선택 버튼 */}
- <div className="flex items-center gap-2">
- <input
- type="file"
- multiple
- accept=".pdf,.doc,.docx,.xlsx,.xls,.png,.jpg,.jpeg"
- onChange={handleAddNdaAttachment}
- className="hidden"
- id="nda-file-input"
- />
- <Button
- type="button"
- variant="outline"
- size="sm"
- className="gap-2"
- onClick={() => document.getElementById('nda-file-input')?.click()}
- disabled={isUploadingNdaFiles}
- >
- <Plus className="h-4 w-4" />
- 파일 추가
- </Button>
- {isUploadingNdaFiles && (
- <div className="text-sm text-muted-foreground">
- 파일 업로드 중...
- </div>
- )}
- </div>
-
- <div className="text-xs text-muted-foreground">
- 비밀유지 계약서와 관련된 첨부파일을 업로드하세요.
- 각 벤더별로 동일한 파일이 저장됩니다.
- </div>
- </div>
- )}
-
- {/* <div className="space-y-2">
- <Label>계약 항목 선택</Label>
- {AGREEMENT_LIST.map((label) => (
- <div key={label} className="flex items-center gap-2">
- <Checkbox
- id={label}
- checked={agreements[label] || false}
- onCheckedChange={(val) =>
- setAgreements((prev) => ({ ...prev, [label]: Boolean(val) }))
- }
- />
- <Label htmlFor={label}>{label}</Label>
- </div>
- ))}
- </div> */}
- </div>
- )
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger && (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm" className="gap-2">
- <SendHorizonal className="size-4" /> PQ 요청 ({vendors.length})
- </Button>
- </DialogTrigger>
- )}
- <DialogContent className="max-h-[80vh] flex flex-col">
- <DialogHeader>
- <DialogTitle>PQ 요청</DialogTitle>
- <DialogDescription>
- <span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? "개 협력업체" : "개 협력업체들"}에게 PQ를 요청합니다.
- </DialogDescription>
- </DialogHeader>
- <PQHistorySection />
- <div className="flex-1 overflow-y-auto">
- {dialogContent}
- </div>
- <DialogFooter className="flex-col gap-4">
- {/* 프로그레스 바 */}
- {(showProgress || isApprovePending) && (
- <div className="w-full space-y-2">
- <div className="flex items-center justify-between text-sm">
- <span className="text-muted-foreground">{currentStep || "처리 중..."}</span>
- <span className="font-medium">{Math.round(progressValue)}%</span>
- </div>
- <Progress value={progressValue} className="w-full" />
- </div>
- )}
-
- {/* 버튼들 */}
- <div className="flex justify-end gap-2">
- <DialogClose asChild>
- <Button variant="outline" disabled={isApprovePending}>취소</Button>
- </DialogClose>
- <Button
- onClick={onApprove}
- disabled={isApprovePending || !type || (type === "PROJECT" && !selectedProjectId) || (type === "NON_INSPECTION" && !activeNonInspectionPQList)}
- >
- {isApprovePending && <Loader className="mr-2 size-4 animate-spin" />}
- 요청하기
- </Button>
- </div>
- </DialogFooter>
- </DialogContent>
-
-
- </Dialog>
- )
- }
-
- return (
- <Drawer {...props}>
- {showTrigger && (
- <DrawerTrigger asChild>
- <Button variant="outline" size="sm" className="gap-2">
- <SendHorizonal className="size-4" /> PQ 요청 ({vendors.length})
- </Button>
- </DrawerTrigger>
- )}
- <DrawerContent className="max-h-[80vh] flex flex-col">
- <DrawerHeader>
- <DrawerTitle>PQ 요청</DrawerTitle>
- <DrawerDescription>
- <span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? "개 협력업체" : "개 협력업체들"}에게 PQ를 요청합니다.
- </DrawerDescription>
- </DrawerHeader>
- <PQHistorySection />
- <div className="flex-1 overflow-y-auto px-4">
- {dialogContent}
- </div>
- <DrawerFooter className="gap-4">
- {/* 프로그레스 바 */}
- {(showProgress || isApprovePending) && (
- <div className="w-full space-y-2">
- <div className="flex items-center justify-between text-sm">
- <span className="text-muted-foreground">{currentStep || "처리 중..."}</span>
- <span className="font-medium">{Math.round(progressValue)}%</span>
- </div>
- <Progress value={progressValue} className="w-full" />
- </div>
- )}
-
- {/* 버튼들 */}
- <div className="flex gap-2">
- <DrawerClose asChild>
- <Button variant="outline" disabled={isApprovePending} className="flex-1">취소</Button>
- </DrawerClose>
- <Button
- onClick={onApprove}
- disabled={isApprovePending || !type || (type === "PROJECT" && !selectedProjectId) || (type === "NON_INSPECTION" && !activeNonInspectionPQList)}
- className="flex-1"
- >
- {isApprovePending && <Loader className="mr-2 size-4 animate-spin" />}
- 요청하기
- </Button>
- </div>
- </DrawerFooter>
- </DrawerContent>
-
-
- </Drawer>
- )
-}
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, SendHorizonal, Search, X, Plus, Router } from "lucide-react"
+import { toast } from "sonner"
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Label } from "@/components/ui/label"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import { Progress } from "@/components/ui/progress"
+import { Vendor } from "@/db/schema/vendors"
+import { requestBasicContractInfo, requestPQVendors, sendBasicContractEmail } from "../service"
+import { getProjectsWithPQList, getNonInspectionPQLists } from "@/lib/pq/service"
+import type { Project } from "@/lib/pq/service"
+import { useSession } from "next-auth/react"
+import { DatePicker } from "@/components/ui/date-picker"
+import { getALLBasicContractTemplates } from "@/lib/basic-contract/service"
+import type { BasicContractTemplate } from "@/db/schema"
+import { saveNdaAttachments, getVendorPQHistory } from "../service"
+import { useRouter } from "next/navigation"
+import { createGtcVendorDocuments, createProjectGtcVendorDocuments, getStandardGtcDocumentId, getProjectGtcDocumentId } from "@/lib/gtc-contract/service"
+import { MaterialGroupSelectorDialogMulti } from "@/components/common/material/material-group-selector-dialog-multi"
+import type { MaterialSearchItem } from "@/lib/material/material-group-service"
+// import { PQContractViewer } from "../pq-contract-viewer" // 더 이상 사용하지 않음
+
+interface RequestPQDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<Vendor>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+// const AGREEMENT_LIST = [
+// "준법서약",
+// "표준하도급계약",
+// "안전보건관리계약",
+// "윤리규범 준수 서약",
+// "동반성장협약",
+// "내국신용장 미개설 합의",
+// "기술자료 제출 기본 동의",
+// "GTC 합의",
+// ]
+
+// PQ 대상 품목 타입 정의 (Material Group 기반) - MaterialSearchItem 사용
+
+export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...props }: RequestPQDialogProps) {
+ const [isApprovePending, startApproveTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+ const { data: session } = useSession()
+ const router = useRouter()
+ const [type, setType] = React.useState<"GENERAL" | "PROJECT" | "NON_INSPECTION" | null>(null)
+ const [dueDate, setDueDate] = React.useState<string | null>(null)
+ const [projects, setProjects] = React.useState<Project[]>([])
+ const [selectedProjectId, setSelectedProjectId] = React.useState<number | null>(null)
+ const [agreements, setAgreements] = React.useState<Record<string, boolean>>({})
+ const [extraNote, setExtraNote] = React.useState<string>("")
+ const [pqItems, setPqItems] = React.useState<MaterialSearchItem[]>([])
+
+ // PQ 품목 선택 관련 상태는 MaterialGroupSelectorDialogMulti에서 관리됨
+ const [isLoadingProjects, setIsLoadingProjects] = React.useState(false)
+ const [basicContractTemplates, setBasicContractTemplates] = React.useState<BasicContractTemplate[]>([])
+ const [selectedTemplateIds, setSelectedTemplateIds] = React.useState<number[]>([])
+ const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false)
+
+ // 미실사 PQ 관련 상태
+ const [activeNonInspectionPQList, setActiveNonInspectionPQList] = React.useState<any>(null)
+ const [isLoadingNonInspectionPQ, setIsLoadingNonInspectionPQ] = React.useState(false)
+
+ // 비밀유지 계약서 첨부파일 관련 상태
+ const [ndaAttachments, setNdaAttachments] = React.useState<File[]>([])
+ const [isUploadingNdaFiles, setIsUploadingNdaFiles] = React.useState(false)
+
+ // 프로그레스 관련 상태
+ const [progressValue, setProgressValue] = React.useState(0)
+ const [currentStep, setCurrentStep] = React.useState("")
+ const [showProgress, setShowProgress] = React.useState(false)
+
+ // PQ 히스토리 관련 상태
+ const [pqHistory, setPqHistory] = React.useState<Record<number, any[]>>({})
+ const [isLoadingHistory, setIsLoadingHistory] = React.useState(false)
+
+
+ React.useEffect(() => {
+ if (type === "PROJECT") {
+ setIsLoadingProjects(true)
+ getProjectsWithPQList().then(setProjects).catch(() => toast.error("프로젝트 로딩 실패"))
+ .finally(() => setIsLoadingProjects(false))
+ } else if (type === "NON_INSPECTION") {
+ setIsLoadingNonInspectionPQ(true)
+ // 활성화된 미실사 PQ 리스트 조회
+ getNonInspectionPQLists().then(result => {
+ if (result.success) {
+ setActiveNonInspectionPQList(result.data)
+ } else {
+ setActiveNonInspectionPQList(null)
+ }
+ }).catch(() => {
+ toast.error("미실사 PQ 리스트 로딩 실패")
+ setActiveNonInspectionPQList(null)
+ }).finally(() => setIsLoadingNonInspectionPQ(false))
+ }
+ }, [type])
+
+ // 기본계약서 템플릿 로딩 및 자동 선택, PQ 히스토리 로딩
+ React.useEffect(() => {
+ setIsLoadingTemplates(true)
+ const loadPQHistory = async () => {
+ if (vendors.length === 0) return
+
+ setIsLoadingHistory(true)
+ try {
+ const historyPromises = vendors.map(async (vendor) => {
+ console.log("vendor.id", vendor.id)
+ const result = await getVendorPQHistory(vendor.id)
+ console.log("result", result)
+ return { vendorId: vendor.id, history: result.success ? result.data : [] }
+ })
+
+ const results = await Promise.all(historyPromises)
+ const historyMap: Record<number, any[]> = {}
+
+ results.forEach(({ vendorId, history }) => {
+ historyMap[vendorId] = history
+ })
+
+ setPqHistory(historyMap)
+ } catch (error) {
+ console.error('PQ 히스토리 로딩 실패:', error)
+ toast.error('PQ 히스토리 로딩 중 오류가 발생했습니다')
+ } finally {
+ setIsLoadingHistory(false)
+ }
+ }
+ loadPQHistory()
+ getALLBasicContractTemplates()
+ .then((templates) => {
+
+ // 벤더 국가별 자동 선택 로직
+ if (vendors.length > 0) {
+ const isAllForeign = vendors.every(vendor => vendor.country !== 'KR')
+ const isAllDomestic = vendors.every(vendor => vendor.country === 'KR')
+ //외자면 리스트에 비밀유지계약, 기술자료, 내국신용장, 한글 제외
+ if(isAllForeign) {
+ const foreignTemplates = templates.filter(template => {
+ const name = template.templateName?.toLowerCase() || ''
+ return !name.includes('비밀유지') && !name.includes('기술자료') && !name.includes('내국신용장') && !name.includes('한글')
+ })
+ setBasicContractTemplates(foreignTemplates)
+ }
+ //내자면 리스트에 GTC 제외, 비밀유지, 기술자료, 영문서약 제외
+ if(isAllDomestic) {
+ const domesticTemplates = templates.filter(template => {
+ const name = template.templateName?.toLowerCase() || ''
+ return !name.includes('gtc') && !name.includes('비밀유지') && !name.includes('기술자료') && !name.includes('영문')
+ })
+ setBasicContractTemplates(domesticTemplates)
+ }
+ if (isAllForeign) {
+ // 외자: 준법서약 (영문), GTC 선택 (GTC는 1개만 선택하도록)
+ const foreignTemplates = templates.filter(template => {
+ const name = template.templateName?.toLowerCase() || ''
+ return (
+ (template.templateName?.includes('준법서약') && template.templateName?.includes('영문')) ||
+ template.templateName?.includes('gtc')
+ )
+ })
+ // GTC 템플릿 중 최신 리비전의 것만 선택
+ const gtcTemplates = foreignTemplates.filter(t => t.templateName?.includes('gtc'))
+ const nonGtcTemplates = foreignTemplates.filter(t => !t.templateName?.includes('gtc'))
+
+ if (gtcTemplates.length > 0) {
+ // GTC 템플릿 중 이름이 가장 긴 것 (프로젝트 GTC 우선) 선택
+ const selectedGtcTemplate = gtcTemplates.reduce((prev, current) =>
+ (prev.templateName?.length || 0) > (current.templateName?.length || 0) ? prev : current
+ )
+ setSelectedTemplateIds([...nonGtcTemplates.map(t => t.id), selectedGtcTemplate.id])
+ } else {
+ setSelectedTemplateIds(nonGtcTemplates.map(t => t.id))
+ }
+ } else if (isAllDomestic) {
+ // 내자: 준법서약 (영문), GTC 제외한 모든 템플릿 선택
+ // 비밀유지 계약서, 기술자료 요구서 제외
+ const domesticTemplates = templates.filter(template => {
+ const name = template.templateName?.toLowerCase() || ''
+ return !(name.includes('준법서약') && name.includes('영문')) &&
+ !name.includes('gtc')
+ })
+ setSelectedTemplateIds(domesticTemplates.map(t => t.id))
+ }
+ }
+ })
+ .catch(() => toast.error("기본계약서 템플릿 로딩 실패"))
+ .finally(() => setIsLoadingTemplates(false))
+ }, [vendors])
+
+ React.useEffect(() => {
+ if (!props.open) {
+ setType(null)
+ setSelectedProjectId(null)
+ setAgreements({})
+ setDueDate(null)
+ setPqItems([])
+ setExtraNote("")
+ setSelectedTemplateIds([])
+ setNdaAttachments([])
+ setIsUploadingNdaFiles(false)
+ setProgressValue(0)
+ setCurrentStep("")
+ setShowProgress(false)
+ setPqHistory({})
+ setIsLoadingHistory(false)
+ setActiveNonInspectionPQList(null)
+ setIsLoadingNonInspectionPQ(false)
+ }
+ }, [props.open])
+
+ // PQ 품목 선택 함수 (MaterialGroupSelectorDialogMulti에서 호출됨)
+ const handlePQItemsChange = (items: MaterialSearchItem[]) => {
+ setPqItems(items)
+ }
+
+ // PQ 품목 제거 함수
+ const handleRemovePQItem = (materialGroupCode: string) => {
+ setPqItems(prev => prev.filter(item => item.materialGroupCode !== materialGroupCode))
+ }
+
+ // 비밀유지 계약서 첨부파일 추가 함수
+ const handleAddNdaAttachment = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const files = event.target.files
+ if (files) {
+ const newFiles = Array.from(files)
+ setNdaAttachments(prev => [...prev, ...newFiles])
+ }
+ // input 초기화
+ event.target.value = ''
+ }
+
+ // 비밀유지 계약서 첨부파일 제거 함수
+ const handleRemoveNdaAttachment = (fileIndex: number) => {
+ setNdaAttachments(prev => prev.filter((_, index) => index !== fileIndex))
+ }
+
+ // 비밀유지 계약서가 선택되었는지 확인하는 함수
+ const isNdaTemplateSelected = () => {
+ return basicContractTemplates.some(template =>
+ selectedTemplateIds.includes(template.id) &&
+ template.templateName?.includes("비밀유지")
+ )
+ }
+
+ const onApprove = () => {
+ if (!type) return toast.error("PQ 유형을 선택하세요.")
+ if (type === "PROJECT" && !selectedProjectId) return toast.error("프로젝트를 선택하세요.")
+ if (type === "NON_INSPECTION" && !activeNonInspectionPQList) return toast.error("활성화된 미실사 PQ 리스트가 없습니다.")
+ if (!dueDate) return toast.error("마감일을 선택하세요.")
+ if (pqItems.length === 0) return toast.error("PQ 대상 품목을 선택하세요.")
+ if (!session?.user?.id) return toast.error("인증 실패")
+
+ // GTC 템플릿 선택 검증
+ const selectedGtcTemplates = basicContractTemplates.filter(template =>
+ selectedTemplateIds.includes(template.id) &&
+ template.templateName?.toLowerCase().includes('gtc')
+ )
+
+ if (selectedGtcTemplates.length > 1) {
+ return toast.error("GTC 템플릿은 하나만 선택할 수 있습니다.")
+ }
+
+ // 프로그레스 바를 즉시 표시
+ setShowProgress(true)
+ setProgressValue(0)
+ setCurrentStep("시작 중...")
+
+ startApproveTransition(async () => {
+ try {
+
+ // 전체 단계 수 계산
+ const gtcTemplates = basicContractTemplates.filter(template =>
+ selectedTemplateIds.includes(template.id) &&
+ template.templateName?.toLowerCase().includes('gtc')
+ )
+
+ const totalSteps = 1 +
+ (selectedTemplateIds.length > 0 ? 1 : 0) +
+ (isNdaTemplateSelected() && ndaAttachments.length > 0 ? 1 : 0) +
+ (gtcTemplates.length > 0 ? 1 : 0)
+ let completedSteps = 0
+
+ // 1단계: PQ 생성
+ setCurrentStep("PQ 생성 중...")
+ console.log("🚀 PQ 생성 시작")
+ const { data: pqResult, error: pqError } = await requestPQVendors({
+ ids: vendors.map((v) => v.id),
+ userId: Number(session.user.id),
+ agreements,
+ dueDate,
+ projectId: type === "PROJECT" ? selectedProjectId : null,
+ type: type || "GENERAL",
+ extraNote,
+ pqItems: JSON.stringify(pqItems.map(item => ({
+ materialGroupCode: item.materialGroupCode,
+ materialGroupDescription: item.materialGroupDescription
+ }))),
+ templateId: selectedTemplateIds.length > 0 ? selectedTemplateIds[0] : null,
+ })
+
+ if (pqError) {
+ setShowProgress(false)
+ toast.error(`PQ 생성 실패: ${pqError}`)
+ return
+ }
+
+ const pqSubmissionMap = (pqResult?.pqSubmissions || []).reduce<Record<number, number>>((acc, cur) => {
+ acc[cur.vendorId] = cur.id
+ return acc
+ }, {})
+
+ completedSteps++
+ setProgressValue((completedSteps / totalSteps) * 100)
+ console.log("✅ PQ 생성 완료")
+ toast.success("PQ가 성공적으로 요청되었습니다")
+
+ // 2단계: 기본계약서 템플릿이 선택된 경우 백그라운드에서 처리
+ if (selectedTemplateIds.length > 0) {
+ const templates = basicContractTemplates.filter(t =>
+ selectedTemplateIds.includes(t.id)
+ )
+
+ setCurrentStep(`기본계약서 생성 중... (${templates.length}개 템플릿)`)
+ console.log("📋 기본계약서 백그라운드 처리 시작", templates.length, "개 템플릿")
+ await processBasicContractsInBackground(templates, vendors, pqSubmissionMap)
+
+ completedSteps++
+ setProgressValue((completedSteps / totalSteps) * 100)
+ }
+
+ // 3단계: 비밀유지 계약서 첨부파일이 있는 경우 저장
+ if (isNdaTemplateSelected() && ndaAttachments.length > 0) {
+ setCurrentStep(`비밀유지 계약서 첨부파일 저장 중... (${ndaAttachments.length}개 파일)`)
+ console.log("📎 비밀유지 계약서 첨부파일 처리 시작", ndaAttachments.length, "개 파일")
+
+ const ndaResult = await saveNdaAttachments({
+ vendorIds: vendors.map((v) => v.id),
+ files: ndaAttachments,
+ userId: session.user.id.toString()
+ })
+
+ if (ndaResult.success) {
+ toast.success(`비밀유지 계약서 첨부파일이 모두 저장되었습니다 (${ndaResult.summary?.success}/${ndaResult.summary?.total})`)
+ } else {
+ toast.error(`첨부파일 처리 중 일부 오류가 발생했습니다: ${ndaResult.error}`)
+ }
+
+ completedSteps++
+ setProgressValue((completedSteps / totalSteps) * 100)
+ }
+ //4단계: GTC 템플릿 처리
+ if (selectedGtcTemplates.length > 0) {
+ setCurrentStep(`GTC 문서 생성 중... (${selectedGtcTemplates.length}개 템플릿)`)
+ console.log("📋 GTC 문서 생성 시작", selectedGtcTemplates.length, "개 템플릿")
+
+ try {
+ await processGtcTemplates(selectedGtcTemplates, vendors)
+ completedSteps++
+ setProgressValue((completedSteps / totalSteps) * 100)
+ } catch (error) {
+ console.error("GTC 템플릿 처리 중 오류:", error)
+ toast.error(`GTC 템플릿 처리 중 오류가 발생했습니다`)
+ // GTC 처리 실패해도 PQ 생성은 성공으로 간주
+ completedSteps++
+ setProgressValue((completedSteps / totalSteps) * 100)
+ }
+ }
+ //5단계: 각 협력업체들에게 기본계약서 이메일 발송
+ if (selectedTemplateIds.length > 0) {
+ setCurrentStep(`기본계약서 이메일 발송 중... (${selectedTemplateIds.length}개 템플릿)`)
+ console.log("📋 기본계약서 이메일 발송 시작", selectedTemplateIds.length, "개 템플릿")
+ await processBasicContractsEmail(selectedTemplateIds, vendors)
+ completedSteps++
+ setProgressValue((completedSteps / totalSteps) * 100)
+ }
+
+ setCurrentStep("완료!")
+ setProgressValue(100)
+
+ // 잠시 완료 상태를 보여준 후 다이얼로그 닫기
+ setTimeout(() => {
+ setShowProgress(false)
+ props.onOpenChange?.(false)
+ onSuccess?.()
+ }, 1000)
+
+ } catch (error) {
+ console.error('PQ 생성 오류:', error)
+ setShowProgress(false)
+ toast.error(`처리 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+ }
+ })
+ }
+
+ // 백그라운드에서 기본계약서 처리
+ const processBasicContractsInBackground = async (
+ templates: BasicContractTemplate[],
+ vendors: any[],
+ pqSubmissionMap?: Record<number, number>
+ ) => {
+ if (!session?.user?.id) {
+ toast.error("인증 정보가 없습니다")
+ return
+ }
+
+ try {
+ const totalContracts = templates.length * vendors.length
+ let processedCount = 0
+
+ // 각 벤더별로, 각 템플릿을 처리
+ for (let vendorIndex = 0; vendorIndex < vendors.length; vendorIndex++) {
+ const vendor = vendors[vendorIndex]
+
+ // 벤더별 템플릿 데이터 생성 (한글 변수명 사용)
+ const templateData = {
+ company_name: vendor.vendorName || '협력업체명',
+ company_address: vendor.address || '주소',
+ representative_name: vendor.representativeName || '대표자명',
+ signature_date: new Date().toLocaleDateString('ko-KR'),
+ tax_id: vendor.taxId || '사업자번호',
+ phone_number: vendor.phone || '전화번호',
+ }
+
+ console.log(`🔄 벤더 ${vendorIndex + 1}/${vendors.length} 템플릿 데이터:`, templateData)
+
+ // 해당 벤더에 대해 각 템플릿을 순차적으로 처리
+ for (let templateIndex = 0; templateIndex < templates.length; templateIndex++) {
+ const template = templates[templateIndex]
+ processedCount++
+
+ // 진행률 업데이트 (2단계 범위 내에서)
+ const baseProgress = 33.33 // 1단계 완료 후
+ const contractProgress = (processedCount / totalContracts) * 33.33 // 2단계는 33.33% 차지
+ const newProgress = baseProgress + contractProgress
+ setProgressValue(newProgress)
+ setCurrentStep(`기본계약서 생성 중... (${processedCount}/${totalContracts})`)
+
+ console.log(`📄 처리 중: ${vendor.vendorName} - ${template.templateName} (${processedCount}/${totalContracts})`)
+
+ // 개별 벤더에 대한 기본계약 생성
+ await processTemplate(template, templateData, [vendor], pqSubmissionMap)
+
+ console.log(`✅ 완료: ${vendor.vendorName} - ${template.templateName}`)
+ }
+ }
+
+ toast.success(`총 ${totalContracts}개 기본계약이 모두 생성되었습니다`)
+ router.refresh();
+
+ } catch (error) {
+ console.error('기본계약 처리 중 오류:', error)
+ toast.error(`기본계약 처리 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+ }
+ }
+
+ const processTemplate = async (
+ template: BasicContractTemplate,
+ templateData: any,
+ vendors: any[],
+ pqSubmissionMap?: Record<number, number>
+ ) => {
+ try {
+ // 1. 템플릿 파일 가져오기
+ const templateResponse = await fetch('/api/basic-contract/get-template', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ templateId: template.id })
+ })
+
+ if (!templateResponse.ok) {
+ throw new Error(`템플릿 파일을 가져올 수 없습니다: ${template.templateName}`)
+ }
+
+ const templateBlob = await templateResponse.blob()
+
+ // 2. PDFTron을 사용해서 변수 치환 및 PDF 변환
+ // @ts-ignore
+ const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
+
+ // 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음)
+ const tempDiv = document.createElement('div')
+ tempDiv.style.display = 'none'
+ document.body.appendChild(tempDiv)
+
+ const instance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ tempDiv
+ )
+
+ try {
+ const { Core } = instance
+ const { createDocument } = Core
+
+ // 3. 템플릿 문서 생성 및 변수 치환
+ const templateDoc = await createDocument(templateBlob, {
+ filename: template.fileName || 'template.docx',
+ extension: 'docx',
+ })
+
+ console.log("🔄 변수 치환 시작:", templateData)
+ await templateDoc.applyTemplateValues(templateData)
+ console.log("✅ 변수 치환 완료")
+
+ // 4. PDF 변환
+ const fileData = await templateDoc.getFileData()
+ const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' })
+
+ console.log(`✅ PDF 변환 완료: ${template.templateName}`, `크기: ${pdfBuffer.byteLength} bytes`)
+
+ // 5. 기본계약 생성 요청
+ const { error: contractError } = await requestBasicContractInfo({
+ vendorIds: vendors.map((v) => v.id),
+ requestedBy: Number(session!.user.id),
+ templateId: template.id,
+ pdfBuffer: new Uint8Array(pdfBuffer),
+ pqSubmissionIdMap: pqSubmissionMap,
+ })
+
+ if (contractError) {
+ throw new Error(contractError)
+ }
+
+ console.log(`✅ 기본계약 생성 완료: ${template.templateName}`)
+
+
+ } finally {
+ // 임시 WebViewer 정리
+ instance.UI.dispose()
+ document.body.removeChild(tempDiv)
+ }
+
+ } catch (error) {
+ console.error(`❌ 템플릿 처리 실패: ${template.templateName}`, error)
+ throw error
+ }
+ }
+
+ // GTC 템플릿 처리 함수
+ const processGtcTemplates = async (gtcTemplates: BasicContractTemplate[], vendors: any[]) => {
+ if (!session?.user?.id) {
+ toast.error("인증 정보가 없습니다")
+ return
+ }
+
+ try {
+ const vendorIds = vendors.map(v => v.id)
+ const userId = Number(session.user.id)
+
+ for (const template of gtcTemplates) {
+ const templateName = template.templateName?.toLowerCase() || ''
+
+ if (templateName.includes('general gtc') || (templateName.includes('gtc') && !templateName.includes(' '))) {
+ // General GTC 처리
+ console.log(`📄 General GTC 템플릿 처리: ${template.templateName}`)
+
+ const gtcDocument = await getStandardGtcDocumentId()
+ if (!gtcDocument) {
+ toast.error(`표준 GTC 문서를 찾을 수 없습니다.`)
+ continue
+ }
+
+ const result = await createGtcVendorDocuments({
+ baseDocumentId: gtcDocument.id,
+ vendorIds,
+ createdById: userId,
+ documentTitle: gtcDocument.title
+ })
+
+ if (result.success) {
+ console.log(`✅ General GTC 문서 생성 완료: ${result.count}개`)
+ } else {
+ toast.error(`General GTC 문서 생성 실패: ${result.error}`)
+ }
+
+ } else if (templateName.includes('gtc') && templateName.includes(' ')) {
+ // 프로젝트 GTC 처리 (프로젝트 코드 추출)
+ const projectCodeMatch = template.templateName?.match(/^([A-Z0-9]+)\s+GTC/)
+ console.log("🔄 프로젝트 GTC 템플릿 처리: ", template.templateName, projectCodeMatch)
+ console.log(` - 템플릿 이름 분석: "${template.templateName}"`)
+ console.log(` - 소문자 변환: "${templateName}"`)
+ if (projectCodeMatch) {
+ const projectCode = projectCodeMatch[1]
+ console.log(`📄 프로젝트 GTC 템플릿 처리: ${template.templateName} (프로젝트: ${projectCode})`)
+
+ // const gtcDocument = await getProjectGtcDocumentId(projectCode)
+ // if (!gtcDocument) {
+ // toast.error(`프로젝트 "${projectCode}"의 GTC 문서를 찾을 수 없습니다.`)
+ // continue
+ // }
+ // console.log("🔄 getProjectGtcDocumentId", gtcDocument)
+
+ const result = await createProjectGtcVendorDocuments({
+ projectCode,
+ vendorIds,
+ createdById: userId,
+ documentTitle: template.templateName
+ })
+
+ if (result.success) {
+ console.log(`✅ 프로젝트 GTC 문서 생성 완료: ${result.count}개`)
+ } else {
+ toast.error(`프로젝트 GTC 문서 생성 실패: ${result.error}`)
+ }
+ } else {
+ toast.error(`프로젝트 GTC 템플릿 이름 형식이 올바르지 않습니다: ${template.templateName}`)
+ }
+ } else {
+ console.log(`⚠️ GTC 템플릿이지만 처리할 수 없는 형식: ${template.templateName}`)
+ }
+ }
+
+ toast.success(`GTC 문서가 ${gtcTemplates.length}개 템플릿에 대해 생성되었습니다`)
+
+ } catch (error) {
+ console.error('GTC 템플릿 처리 중 오류:', error)
+ toast.error(`GTC 템플릿 처리 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+ }
+ }
+
+ const processBasicContractsEmail = async (templateIds: number[], vendors: any[]) => {
+ if (!session?.user?.id) {
+ toast.error("인증 정보가 없습니다")
+ return
+ }
+ try {
+ const vendorIds = vendors.map(v => v.id)
+ const userId = Number(session.user.id)
+
+ // 2. 성공한 템플릿이 있으면 이메일 발송
+ if (templateIds.length > 0) {
+ const emailResult = await sendBasicContractEmail({
+ vendorIds,
+ templateIds,
+ requestedBy: userId
+ })
+
+ if (emailResult.success) {
+ toast.success(`${vendorIds.length}개 협력업체에 이메일이 발송되었습니다`)
+ } else {
+ toast.warning(`계약서는 생성되었으나 일부 이메일 발송 실패: ${emailResult.error}`)
+ }
+ } else {
+ toast.error("기본계약서 생성에 실패했습니다")
+ }
+
+ } catch (error) {
+ console.error('기본계약서 이메일 발송 중 오류:', error)
+ toast.error(`기본계약서 이메일 발송 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+ }
+}
+
+ // PQ 히스토리 컴포넌트
+ const PQHistorySection = () => {
+ if (isLoadingHistory) {
+ return (
+ <div className="px-4 py-3 border-b bg-muted/30">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Loader className="h-4 w-4 animate-spin" />
+ PQ 히스토리 로딩 중...
+ </div>
+ </div>
+ )
+ }
+
+ const hasAnyHistory = Object.values(pqHistory).some(history => history.length > 0)
+
+ if (!hasAnyHistory) {
+ return (
+ <div className="px-4 py-3 border-b bg-muted/30">
+ <div className="text-sm text-muted-foreground">
+ 최근 PQ 요청 내역이 없습니다.
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="px-4 py-3 border-b bg-muted/30 max-h-48 overflow-y-auto">
+ <div className="space-y-3">
+ <div className="text-sm font-medium text-muted-foreground">
+ 최근 PQ 요청 내역
+ </div>
+ {vendors.map((vendor) => {
+ const vendorHistory = pqHistory[vendor.id] || []
+ if (vendorHistory.length === 0) return null
+
+ return (
+ <div key={vendor.id} className="space-y-2">
+ <div className="text-xs font-medium text-muted-foreground border-b pb-1">
+ {vendor.vendorName}
+ </div>
+ <div className="space-y-1">
+ {vendorHistory.slice(0, 3).map((pq) => {
+ const createdDate = new Date(pq.createdAt).toLocaleDateString('ko-KR')
+ const statusText =
+ pq.status === 'REQUESTED' ? '요청됨' :
+ pq.status === 'APPROVED' ? '승인됨' :
+ pq.status === 'SUBMITTED' ? '제출됨' :
+ pq.status === 'REJECTED' ? '거절됨' :
+ pq.status
+
+ return (
+ <div key={pq.id} className="flex items-center justify-between text-xs bg-background rounded px-2 py-1">
+ <div className="flex items-center gap-2 flex-1">
+ <button
+ type="button"
+ onClick={() => router.push(`/evcp/pq_new?search=${encodeURIComponent(pq.pqNumber)}`)}
+ className="font-mono text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
+ >
+ {pq.pqNumber}
+ </button>
+ <Badge variant={pq.status === 'SUBMITTED' ? 'default' : pq.status === 'COMPLETED' ? 'default' : 'outline'} className="text-xs">
+ {statusText}
+ </Badge>
+ </div>
+ <div className="text-right">
+ <div className="text-muted-foreground">
+ {pq.type === 'GENERAL' ? '일반' : pq.type === 'PROJECT' ? '프로젝트' : '미실사'}
+ </div>
+ <div className="text-muted-foreground text-xs">
+ {createdDate}
+ </div>
+ </div>
+ </div>
+ )
+ })}
+ {vendorHistory.length > 3 && (
+ <div className="text-xs text-muted-foreground text-center">
+ 외 {vendorHistory.length - 3}건 더 있음
+ </div>
+ )}
+ </div>
+ </div>
+ )
+ })}
+ </div>
+ </div>
+ )
+ }
+
+
+
+ const dialogContent = (
+ <div className="space-y-4 py-2">
+ {/* 선택된 협력업체 정보 */}
+ <div className="space-y-2">
+ <Label>선택된 협력업체 ({vendors.length}개)</Label>
+ <div className="max-h-40 overflow-y-auto border rounded-md p-3 space-y-2">
+ {vendors.map((vendor) => (
+ <div key={vendor.id} className="flex items-center justify-between text-sm">
+ <div className="flex-1">
+ <div className="font-medium">{vendor.vendorName}</div>
+ <div className="text-muted-foreground">
+ {vendor.vendorCode} • {vendor.email || "이메일 없음"}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="type">PQ 종류 선택</Label>
+ <Select onValueChange={(val: "GENERAL" | "PROJECT" | "NON_INSPECTION") => setType(val)} value={type ?? undefined}>
+ <SelectTrigger id="type"><SelectValue placeholder="PQ 종류를 선택하세요" /></SelectTrigger>
+ <SelectContent>
+ <SelectItem value="GENERAL">일반 PQ</SelectItem>
+ <SelectItem value="PROJECT">프로젝트 PQ</SelectItem>
+ <SelectItem value="NON_INSPECTION">미실사 PQ</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ {type === "PROJECT" && (
+ <div className="space-y-2">
+ <Label htmlFor="project">프로젝트 선택</Label>
+ <Select onValueChange={(val) => setSelectedProjectId(Number(val))}>
+ <SelectTrigger id="project">
+ <SelectValue placeholder="프로젝트 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {isLoadingProjects ? (
+ <SelectItem value="loading" disabled>로딩 중...</SelectItem>
+ ) : projects.map((p) => (
+ <SelectItem key={p.id} value={p.id.toString()}>{p.projectCode} - {p.projectName}</SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ )}
+
+ {type === "NON_INSPECTION" && (
+ <div className="space-y-2">
+ <Label>활성화된 미실사 PQ 리스트</Label>
+ {isLoadingNonInspectionPQ ? (
+ <div className="text-sm text-muted-foreground">로딩 중...</div>
+ ) : activeNonInspectionPQList ? (
+ <div className="p-3 border rounded-md bg-muted/30">
+ <div className="flex items-center gap-2">
+ <Badge variant="secondary">미실사 PQ</Badge>
+ <span className="font-medium">{activeNonInspectionPQList.name}</span>
+ </div>
+ <div className="text-sm text-muted-foreground mt-1">
+ 활성화된 미실사 PQ 리스트를 기준으로 요청합니다.
+ </div>
+ </div>
+ ) : (
+ <div className="p-3 border rounded-md bg-destructive/10 border-destructive/20">
+ <div className="text-sm text-destructive">
+ 활성화된 미실사 PQ 리스트가 없습니다. 먼저 PQ 관리에서 미실사 PQ 리스트를 생성하고 활성화해주세요.
+ </div>
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* 마감일 입력 */}
+ <div className="space-y-2">
+ <Label htmlFor="dueDate">PQ 제출 마감일</Label>
+ <DatePicker
+ date={dueDate ? new Date(dueDate) : undefined}
+ onSelect={(date?: Date) => {
+ if (date) {
+ // 한국 시간대로 날짜 변환 (UTC 변환으로 인한 날짜 변경 방지)
+ const kstDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
+ setDueDate(kstDate.toISOString().slice(0, 10))
+ } else {
+ setDueDate("")
+ }
+ }}
+ placeholder="마감일 선택"
+ minDate={new Date()}
+ />
+ </div>
+
+ {/* PQ 대상품목 */}
+ <div className="space-y-2">
+ <Label>PQ 대상품목 *</Label>
+ <br />
+ <MaterialGroupSelectorDialogMulti
+ triggerLabel="자재 그룹 선택"
+ selectedMaterials={pqItems}
+ onMaterialsSelect={handlePQItemsChange}
+ maxSelections={10}
+ placeholder="PQ 대상 자재 그룹을 검색하세요"
+ title="PQ 대상 자재 그룹 선택"
+ description="PQ를 요청할 자재 그룹을 선택해주세요."
+ />
+
+ {pqItems.length > 0 && (
+ <div className="text-xs text-muted-foreground">
+ {pqItems.length}개 자재 그룹이 선택되었습니다.
+ </div>
+ )}
+ </div>
+
+ {/* 추가 안내사항 */}
+ <div className="space-y-2">
+ <Label htmlFor="extraNote">추가 안내사항</Label>
+ <textarea
+ id="extraNote"
+ value={extraNote}
+ onChange={(e) => setExtraNote(e.target.value)}
+ placeholder="추가 안내사항을 입력하세요 (선택사항)"
+ className="w-full rounded-md border px-3 py-2 text-sm min-h-20 resize-none"
+ />
+ </div>
+
+ {/* 기본계약서 템플릿 선택 (다중 선택) */}
+ <div className="space-y-2">
+ <Label>기본계약서 템플릿 (선택사항, 복수 선택 가능)</Label>
+ {isLoadingTemplates ? (
+ <div className="text-sm text-muted-foreground">템플릿 로딩 중...</div>
+ ) : (
+ <div className="space-y-2 max-h-40 overflow-y-auto border rounded-md p-3">
+ {basicContractTemplates.map((template) => (
+ <div key={template.id} className="flex items-center gap-2">
+ <Checkbox
+ id={`template-${template.id}`}
+ checked={selectedTemplateIds.includes(template.id)}
+ onCheckedChange={(checked) => {
+ if (checked) {
+ setSelectedTemplateIds(prev => [...prev, template.id])
+ } else {
+ setSelectedTemplateIds(prev => prev.filter(id => id !== template.id))
+ }
+ }}
+ />
+ <Label htmlFor={`template-${template.id}`} className="text-sm">
+ {template.templateName}
+ </Label>
+ </div>
+ ))}
+ {basicContractTemplates.length === 0 && (
+ <div className="text-sm text-muted-foreground">사용 가능한 템플릿이 없습니다.</div>
+ )}
+ </div>
+ )}
+ {/* {selectedTemplateIds.length > 0 && (
+ <div className="text-xs text-muted-foreground">
+ {selectedTemplateIds.length}개 템플릿이 선택되었습니다.
+ {vendors.length > 0 && vendors.every(v => v.country !== 'KR') &&
+ " (외자 벤더 - 자동 선택됨)"}
+ {vendors.length > 0 && vendors.every(v => v.country === 'KR') &&
+ " (내자 벤더 - 자동 선택됨)"}
+ </div>
+ )} */}
+ </div>
+
+ {/* 비밀유지 계약서 첨부파일 */}
+ {isNdaTemplateSelected() && (
+ <div className="space-y-2">
+ <Label>비밀유지 계약서 첨부파일</Label>
+
+ {/* 선택된 파일들 표시 */}
+ {ndaAttachments.length > 0 && (
+ <div className="space-y-2">
+ <div className="text-sm text-muted-foreground">
+ 선택된 파일 ({ndaAttachments.length}개)
+ </div>
+ <div className="space-y-1 max-h-32 overflow-y-auto border rounded-md p-2">
+ {ndaAttachments.map((file, index) => (
+ <div key={index} className="flex items-center justify-between text-sm bg-muted/50 rounded px-2 py-1">
+ <div className="flex-1 truncate">
+ <span className="font-medium">{file.name}</span>
+ <span className="text-muted-foreground ml-2">
+ ({(file.size / 1024 / 1024).toFixed(2)} MB)
+ </span>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
+ onClick={() => handleRemoveNdaAttachment(index)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 파일 선택 버튼 */}
+ <div className="flex items-center gap-2">
+ <input
+ type="file"
+ multiple
+ accept=".pdf,.doc,.docx,.xlsx,.xls,.png,.jpg,.jpeg"
+ onChange={handleAddNdaAttachment}
+ className="hidden"
+ id="nda-file-input"
+ />
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ onClick={() => document.getElementById('nda-file-input')?.click()}
+ disabled={isUploadingNdaFiles}
+ >
+ <Plus className="h-4 w-4" />
+ 파일 추가
+ </Button>
+ {isUploadingNdaFiles && (
+ <div className="text-sm text-muted-foreground">
+ 파일 업로드 중...
+ </div>
+ )}
+ </div>
+
+ <div className="text-xs text-muted-foreground">
+ 비밀유지 계약서와 관련된 첨부파일을 업로드하세요.
+ 각 벤더별로 동일한 파일이 저장됩니다.
+ </div>
+ </div>
+ )}
+
+ {/* <div className="space-y-2">
+ <Label>계약 항목 선택</Label>
+ {AGREEMENT_LIST.map((label) => (
+ <div key={label} className="flex items-center gap-2">
+ <Checkbox
+ id={label}
+ checked={agreements[label] || false}
+ onCheckedChange={(val) =>
+ setAgreements((prev) => ({ ...prev, [label]: Boolean(val) }))
+ }
+ />
+ <Label htmlFor={label}>{label}</Label>
+ </div>
+ ))}
+ </div> */}
+ </div>
+ )
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger && (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <SendHorizonal className="size-4" /> PQ 요청 ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ )}
+ <DialogContent className="max-h-[80vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>PQ 요청</DialogTitle>
+ <DialogDescription>
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개 협력업체" : "개 협력업체들"}에게 PQ를 요청합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <PQHistorySection />
+ <div className="flex-1 overflow-y-auto">
+ {dialogContent}
+ </div>
+ <DialogFooter className="flex-col gap-4">
+ {/* 프로그레스 바 */}
+ {(showProgress || isApprovePending) && (
+ <div className="w-full space-y-2">
+ <div className="flex items-center justify-between text-sm">
+ <span className="text-muted-foreground">{currentStep || "처리 중..."}</span>
+ <span className="font-medium">{Math.round(progressValue)}%</span>
+ </div>
+ <Progress value={progressValue} className="w-full" />
+ </div>
+ )}
+
+ {/* 버튼들 */}
+ <div className="flex justify-end gap-2">
+ <DialogClose asChild>
+ <Button variant="outline" disabled={isApprovePending}>취소</Button>
+ </DialogClose>
+ <Button
+ onClick={onApprove}
+ disabled={isApprovePending || !type || (type === "PROJECT" && !selectedProjectId) || (type === "NON_INSPECTION" && !activeNonInspectionPQList)}
+ >
+ {isApprovePending && <Loader className="mr-2 size-4 animate-spin" />}
+ 요청하기
+ </Button>
+ </div>
+ </DialogFooter>
+ </DialogContent>
+
+
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger && (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <SendHorizonal className="size-4" /> PQ 요청 ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ )}
+ <DrawerContent className="max-h-[80vh] flex flex-col">
+ <DrawerHeader>
+ <DrawerTitle>PQ 요청</DrawerTitle>
+ <DrawerDescription>
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개 협력업체" : "개 협력업체들"}에게 PQ를 요청합니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <PQHistorySection />
+ <div className="flex-1 overflow-y-auto px-4">
+ {dialogContent}
+ </div>
+ <DrawerFooter className="gap-4">
+ {/* 프로그레스 바 */}
+ {(showProgress || isApprovePending) && (
+ <div className="w-full space-y-2">
+ <div className="flex items-center justify-between text-sm">
+ <span className="text-muted-foreground">{currentStep || "처리 중..."}</span>
+ <span className="font-medium">{Math.round(progressValue)}%</span>
+ </div>
+ <Progress value={progressValue} className="w-full" />
+ </div>
+ )}
+
+ {/* 버튼들 */}
+ <div className="flex gap-2">
+ <DrawerClose asChild>
+ <Button variant="outline" disabled={isApprovePending} className="flex-1">취소</Button>
+ </DrawerClose>
+ <Button
+ onClick={onApprove}
+ disabled={isApprovePending || !type || (type === "PROJECT" && !selectedProjectId) || (type === "NON_INSPECTION" && !activeNonInspectionPQList)}
+ className="flex-1"
+ >
+ {isApprovePending && <Loader className="mr-2 size-4 animate-spin" />}
+ 요청하기
+ </Button>
+ </div>
+ </DrawerFooter>
+ </DrawerContent>
+
+
+ </Drawer>
+ )
+}