summaryrefslogtreecommitdiff
path: root/components/basic-contract
diff options
context:
space:
mode:
Diffstat (limited to 'components/basic-contract')
-rw-r--r--components/basic-contract/send-contract-dialog.tsx709
1 files changed, 709 insertions, 0 deletions
diff --git a/components/basic-contract/send-contract-dialog.tsx b/components/basic-contract/send-contract-dialog.tsx
new file mode 100644
index 00000000..511011b0
--- /dev/null
+++ b/components/basic-contract/send-contract-dialog.tsx
@@ -0,0 +1,709 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, FileText, X, Plus } 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 { Checkbox } from "@/components/ui/checkbox"
+import { Label } from "@/components/ui/label"
+import { Badge } from "@/components/ui/badge"
+import { Progress } from "@/components/ui/progress"
+import { Vendor } from "@/db/schema/vendors"
+import { requestBasicContractInfo } from "@/lib/vendors/service"
+import { getALLBasicContractTemplates } from "@/lib/basic-contract/service"
+import type { BasicContractTemplate } from "@/db/schema"
+import { saveNdaAttachments } from "@/lib/vendors/service"
+import { useSession } from "next-auth/react"
+import { useRouter } from "next/navigation"
+import { createGtcVendorDocuments, createProjectGtcVendorDocuments, getStandardGtcDocumentId } from "@/lib/gtc-contract/service"
+
+export interface SendContractDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<Vendor>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+ title?: string
+ description?: string
+ triggerLabel?: string
+}
+
+export function SendContractDialog({
+ vendors,
+ showTrigger = true,
+ onSuccess,
+ title = "기본계약서 발송",
+ description = "선택된 협력업체들에게 기본계약서를 발송합니다.",
+ triggerLabel = "기본계약서 발송",
+ ...props
+}: SendContractDialogProps) {
+ const [isProcessing, startProcessingTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+ const { data: session } = useSession()
+ const router = useRouter()
+
+ // 상태 관리
+ const [basicContractTemplates, setBasicContractTemplates] = React.useState<BasicContractTemplate[]>([])
+ const [selectedTemplateIds, setSelectedTemplateIds] = React.useState<number[]>([])
+ const [isLoadingTemplates, setIsLoadingTemplates] = 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)
+
+ // 기본계약서 템플릿 로딩 및 자동 선택
+ React.useEffect(() => {
+ setIsLoadingTemplates(true)
+ getALLBasicContractTemplates()
+ .then((templates) => {
+ setBasicContractTemplates(templates)
+
+ // 벤더 국가별 자동 선택 로직
+ if (vendors.length > 0) {
+ const isAllForeign = vendors.every(vendor => vendor.country !== 'KR')
+ const isAllDomestic = vendors.every(vendor => vendor.country === 'KR')
+
+ if (isAllForeign) {
+ // 외자: 준법서약 (영문), GTC 선택 (GTC는 1개만 선택하도록)
+ const foreignTemplates = templates.filter(template =>
+ 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) {
+ setSelectedTemplateIds([])
+ setNdaAttachments([])
+ setIsUploadingNdaFiles(false)
+ setProgressValue(0)
+ setCurrentStep("")
+ setShowProgress(false)
+ }
+ }, [props.open])
+
+ // 비밀유지 계약서 첨부파일 추가 함수
+ 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 onSendContracts = () => {
+ if (selectedTemplateIds.length === 0) {
+ return toast.error("최소 하나의 기본계약서 템플릿을 선택하세요.")
+ }
+ 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("시작 중...")
+
+ startProcessingTransition(async () => {
+ try {
+ // 전체 단계 수 계산
+ const gtcTemplates = basicContractTemplates.filter(template =>
+ selectedTemplateIds.includes(template.id) &&
+ template.templateName?.toLowerCase().includes('gtc')
+ )
+
+ const totalSteps =
+ (selectedTemplateIds.length > 0 ? 1 : 0) +
+ (isNdaTemplateSelected() && ndaAttachments.length > 0 ? 1 : 0) +
+ (gtcTemplates.length > 0 ? 1 : 0)
+ let completedSteps = 0
+
+ // 1단계: 기본계약서 템플릿이 선택된 경우 백그라운드에서 처리
+ 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)
+ }
+
+ // 2단계: 비밀유지 계약서 첨부파일이 있는 경우 저장
+ 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)
+ }
+
+ // 3단계: 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 처리 실패해도 전체 프로세스는 성공으로 간주
+ completedSteps++
+ setProgressValue((completedSteps / totalSteps) * 100)
+ }
+ }
+
+ setCurrentStep("완료!")
+ setProgressValue(100)
+ toast.success("기본계약서가 성공적으로 발송되었습니다")
+
+ // 잠시 완료 상태를 보여준 후 다이얼로그 닫기
+ setTimeout(() => {
+ setShowProgress(false)
+ props.onOpenChange?.(false)
+ onSuccess?.()
+ }, 1000)
+
+ } catch (error) {
+ console.error('기본계약서 발송 오류:', 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++
+
+ // 진행률 업데이트
+ const contractProgress = (processedCount / totalContracts) * 100
+ setProgressValue(contractProgress)
+ 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 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 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>기본계약서 템플릿 (복수 선택 가능)</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>
+ )
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger && (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <FileText className="size-4" /> {triggerLabel} ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ )}
+ <DialogContent className="max-h-[80vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ <DialogDescription>
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개 협력업체" : "개 협력업체들"}에게 {description}
+ </DialogDescription>
+ </DialogHeader>
+ <div className="flex-1 overflow-y-auto">
+ {dialogContent}
+ </div>
+ <DialogFooter className="flex-col gap-4">
+ {/* 프로그레스 바 */}
+ {(showProgress || isProcessing) && (
+ <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={isProcessing}>취소</Button>
+ </DialogClose>
+ <Button
+ onClick={onSendContracts}
+ disabled={isProcessing || selectedTemplateIds.length === 0}
+ >
+ {isProcessing && <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">
+ <FileText className="size-4" /> {triggerLabel} ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ )}
+ <DrawerContent className="max-h-[80vh] flex flex-col">
+ <DrawerHeader>
+ <DrawerTitle>{title}</DrawerTitle>
+ <DrawerDescription>
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개 협력업체" : "개 협력업체들"}에게 {description}
+ </DrawerDescription>
+ </DrawerHeader>
+ <div className="flex-1 overflow-y-auto px-4">
+ {dialogContent}
+ </div>
+ <DrawerFooter className="gap-4">
+ {/* 프로그레스 바 */}
+ {(showProgress || isProcessing) && (
+ <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={isProcessing} className="flex-1">취소</Button>
+ </DrawerClose>
+ <Button
+ onClick={onSendContracts}
+ disabled={isProcessing || selectedTemplateIds.length === 0}
+ className="flex-1"
+ >
+ {isProcessing && <Loader className="mr-2 size-4 animate-spin" />}
+ 발송하기
+ </Button>
+ </div>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}