diff options
Diffstat (limited to 'lib/vendors/table/request-pq-dialog.tsx')
| -rw-r--r-- | lib/vendors/table/request-pq-dialog.tsx | 2275 |
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> + ) +} |
