diff options
Diffstat (limited to 'lib/bidding/pre-quote/table')
10 files changed, 0 insertions, 2623 deletions
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx deleted file mode 100644 index cfa629e3..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx +++ /dev/null @@ -1,224 +0,0 @@ -'use client' - -import * as React from 'react' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { - FileText, - Download, - User, - Calendar -} from 'lucide-react' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' -import { getPreQuoteDocuments, getPreQuoteDocumentForDownload } from '../service' -import { downloadFile } from '@/lib/file-download' - -interface UploadedDocument { - id: number - fileName: string - originalFileName: string - fileSize: number | null - filePath: string - title: string | null - description: string | null - uploadedAt: string - uploadedBy: string -} - -interface BiddingPreQuoteAttachmentsDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - biddingId: number - companyId: number - companyName: string -} - -export function BiddingPreQuoteAttachmentsDialog({ - open, - onOpenChange, - biddingId, - companyId, - companyName -}: BiddingPreQuoteAttachmentsDialogProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - const [documents, setDocuments] = React.useState<UploadedDocument[]>([]) - const [isLoading, setIsLoading] = React.useState(false) - - // 다이얼로그가 열릴 때 첨부파일 목록 로드 - React.useEffect(() => { - if (open) { - loadDocuments() - } - }, [open, biddingId, companyId]) - - const loadDocuments = async () => { - setIsLoading(true) - try { - const docs = await getPreQuoteDocuments(biddingId, companyId) - // Date를 string으로 변환 - const mappedDocs = docs.map(doc => ({ - ...doc, - uploadedAt: doc.uploadedAt.toString(), - uploadedBy: doc.uploadedBy || '' - })) - setDocuments(mappedDocs) - } catch (error) { - console.error('Failed to load documents:', error) - toast({ - title: '오류', - description: '첨부파일 목록을 불러오는데 실패했습니다.', - variant: 'destructive', - }) - } finally { - setIsLoading(false) - } - } - - // 파일 다운로드 - const handleDownload = (document: UploadedDocument) => { - startTransition(async () => { - const result = await getPreQuoteDocumentForDownload(document.id, biddingId, companyId) - - if (result.success) { - try { - await downloadFile(result.document?.filePath, result.document?.originalFileName, { - showToast: true - }) - } catch (error) { - toast({ - title: '다운로드 실패', - description: '파일 다운로드에 실패했습니다.', - variant: 'destructive', - }) - } - } else { - toast({ - title: '다운로드 실패', - description: result.error, - variant: 'destructive', - }) - } - }) - } - - // 파일 크기 포맷팅 - const formatFileSize = (bytes: number | null) => { - if (!bytes) return '-' - if (bytes === 0) return '0 Bytes' - const k = 1024 - const sizes = ['Bytes', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <FileText className="w-5 h-5" /> - <span>협력업체 첨부파일</span> - <span className="text-sm font-normal text-muted-foreground"> - - {companyName} - </span> - </DialogTitle> - <DialogDescription> - 협력업체가 제출한 견적 관련 첨부파일 목록입니다. - </DialogDescription> - </DialogHeader> - - {isLoading ? ( - <div className="flex items-center justify-center py-12"> - <div className="text-center"> - <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div> - <p className="text-muted-foreground">첨부파일 목록을 불러오는 중...</p> - </div> - </div> - ) : documents.length > 0 ? ( - <div className="space-y-4"> - <div className="flex items-center justify-between"> - <Badge variant="secondary" className="text-sm"> - 총 {documents.length}개 파일 - </Badge> - </div> - - <Table> - <TableHeader> - <TableRow> - <TableHead>파일명</TableHead> - <TableHead>크기</TableHead> - <TableHead>업로드일</TableHead> - <TableHead>작성자</TableHead> - <TableHead className="w-24">작업</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {documents.map((doc) => ( - <TableRow key={doc.id}> - <TableCell> - <div className="flex items-center gap-2"> - <FileText className="w-4 h-4 text-gray-500" /> - <span className="truncate max-w-48" title={doc.originalFileName}> - {doc.originalFileName} - </span> - </div> - </TableCell> - <TableCell className="text-sm text-gray-500"> - {formatFileSize(doc.fileSize)} - </TableCell> - <TableCell className="text-sm text-gray-500"> - <div className="flex items-center gap-1"> - <Calendar className="w-3 h-3" /> - {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')} - </div> - </TableCell> - <TableCell className="text-sm text-gray-500"> - <div className="flex items-center gap-1"> - <User className="w-3 h-3" /> - {doc.uploadedBy} - </div> - </TableCell> - <TableCell> - <Button - variant="outline" - size="sm" - onClick={() => handleDownload(doc)} - disabled={isPending} - title="다운로드" - > - <Download className="w-3 h-3" /> - </Button> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - ) : ( - <div className="text-center py-12 text-gray-500"> - <FileText className="w-12 h-12 mx-auto mb-4 opacity-50" /> - <p className="text-lg font-medium mb-2">첨부파일이 없습니다</p> - <p className="text-sm">협력업체가 아직 첨부파일을 업로드하지 않았습니다.</p> - </div> - )} - </DialogContent> - </Dialog> - ) -} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx deleted file mode 100644 index 91b80bd3..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx +++ /dev/null @@ -1,51 +0,0 @@ -'use client' - -import * as React from 'react' -import { Bidding } from '@/db/schema' -import { QuotationDetails } from '@/lib/bidding/detail/service' -import { getBiddingCompanies } from '../service' - -import { BiddingPreQuoteVendorTableContent } from './bidding-pre-quote-vendor-table' - -interface BiddingPreQuoteContentProps { - bidding: Bidding - quotationDetails: QuotationDetails | null - biddingCompanies: any[] - prItems: any[] -} - -export function BiddingPreQuoteContent({ - bidding, - quotationDetails, - biddingCompanies: initialBiddingCompanies, - prItems -}: BiddingPreQuoteContentProps) { - const [biddingCompanies, setBiddingCompanies] = React.useState(initialBiddingCompanies) - const [refreshTrigger, setRefreshTrigger] = React.useState(0) - - const handleRefresh = React.useCallback(async () => { - try { - const result = await getBiddingCompanies(bidding.id) - if (result.success && result.data) { - setBiddingCompanies(result.data) - } - setRefreshTrigger(prev => prev + 1) - } catch (error) { - console.error('Failed to refresh bidding companies:', error) - } - }, [bidding.id]) - - return ( - <div className="space-y-6"> - <BiddingPreQuoteVendorTableContent - biddingId={bidding.id} - bidding={bidding} - biddingCompanies={biddingCompanies} - onRefresh={handleRefresh} - onOpenItemsDialog={() => {}} - onOpenTargetPriceDialog={() => {}} - onOpenSelectionReasonDialog={() => {}} - /> - </div> - ) -} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx deleted file mode 100644 index 3205df08..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx +++ /dev/null @@ -1,770 +0,0 @@ -'use client' - -import * as React from 'react' -import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Badge } from '@/components/ui/badge' -import { BiddingCompany } from './bidding-pre-quote-vendor-columns' -import { sendPreQuoteInvitations, sendBiddingBasicContracts, getExistingBasicContractsForBidding } from '../service' -import { getActiveContractTemplates } from '../../service' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' -import { Mail, Building2, Calendar, FileText, CheckCircle, Info, RefreshCw } from 'lucide-react' -import { Progress } from '@/components/ui/progress' -import { Separator } from '@/components/ui/separator' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { cn } from '@/lib/utils' - -interface BiddingPreQuoteInvitationDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - companies: BiddingCompany[] - biddingId: number - biddingTitle: string - projectName?: string - onSuccess: () => void -} - -interface BasicContractTemplate { - id: number - templateName: string - revision: number - status: string - filePath: string | null - validityPeriod: number | null - legalReviewRequired: boolean - createdAt: Date | null -} - -interface SelectedContract { - templateId: number - templateName: string - contractType: string // templateName을 contractType으로 사용 - checked: boolean -} - -// PDF 생성 유틸리티 함수 -const generateBasicContractPdf = async ( - template: BasicContractTemplate, - vendorId: number -): Promise<{ buffer: number[]; fileName: string }> => { - try { - // 1. 템플릿 데이터 준비 (서버 API 호출) - const prepareResponse = await fetch("/api/contracts/prepare-template", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - templateName: template.templateName, - vendorId, - }), - }); - - if (!prepareResponse.ok) { - throw new Error("템플릿 준비 실패"); - } - - const { template: preparedTemplate, templateData } = await prepareResponse.json(); - - // 2. 템플릿 파일 다운로드 - const templateResponse = await fetch("/api/contracts/get-template", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ templatePath: preparedTemplate.filePath }), - }); - - const templateBlob = await templateResponse.blob(); - const templateFile = new window.File([templateBlob], "template.docx", { - type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - }); - - // 3. PDFTron WebViewer로 PDF 변환 - const { default: WebViewer } = await import("@pdftron/webviewer"); - - const tempDiv = document.createElement('div'); - tempDiv.style.display = 'none'; - document.body.appendChild(tempDiv); - - try { - const instance = await WebViewer( - { - path: "/pdftronWeb", - licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, - fullAPI: true, - }, - tempDiv - ); - - const { Core } = instance; - const { createDocument } = Core; - - const templateDoc = await createDocument(templateFile, { - filename: templateFile.name, - extension: 'docx', - }); - - // 변수 치환 적용 - await templateDoc.applyTemplateValues(templateData); - - // PDF 변환 - const fileData = await templateDoc.getFileData(); - const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' }); - - const fileName = `${template.templateName}_${Date.now()}.pdf`; - - return { - buffer: Array.from(pdfBuffer), // Uint8Array를 일반 배열로 변환 - fileName - }; - - } finally { - if (tempDiv.parentNode) { - document.body.removeChild(tempDiv); - } - } - } catch (error) { - console.error(`기본계약 PDF 생성 실패 (${template.templateName}):`, error); - throw error; - } -}; - -export function BiddingPreQuoteInvitationDialog({ - open, - onOpenChange, - companies, - biddingId, - biddingTitle, - projectName, - onSuccess -}: BiddingPreQuoteInvitationDialogProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - const [selectedCompanyIds, setSelectedCompanyIds] = React.useState<number[]>([]) - const [preQuoteDeadline, setPreQuoteDeadline] = React.useState('') - const [additionalMessage, setAdditionalMessage] = React.useState('') - - // 기본계약 관련 상태 - const [existingContracts, setExistingContracts] = React.useState<any[]>([]) - const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false) - const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0) - const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState('') - - // 기본계약서 템플릿 관련 상태 - const [availableTemplates, setAvailableTemplates] = React.useState<BasicContractTemplate[]>([]) - const [selectedContracts, setSelectedContracts] = React.useState<SelectedContract[]>([]) - const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false) - - // 초대 가능한 업체들 (pending 상태인 업체들) - const invitableCompanies = React.useMemo(() => companies.filter(company => - company.invitationStatus === 'pending' && company.companyName - ), [companies]) - - // 다이얼로그가 열릴 때 기존 계약 조회 및 템플릿 로드 - React.useEffect(() => { - if (open) { - const fetchInitialData = async () => { - setIsLoadingTemplates(true); - try { - const [contractsResult, templatesData] = await Promise.all([ - getExistingBasicContractsForBidding(biddingId), - getActiveContractTemplates() - ]); - - // 기존 계약 조회 - 서버 액션 사용 - const existingContractsResult = await getExistingBasicContractsForBidding(biddingId); - setExistingContracts(existingContractsResult.success ? existingContractsResult.contracts || [] : []); - - // 템플릿 로드 (4개 타입만 필터링) - // 4개 템플릿 타입만 필터링: 비밀, General, Project, 기술자료 - const allowedTemplateNames = ['비밀', 'General GTC', '기술', '기술자료']; - const filteredTemplates = (templatesData.templates || []).filter((template: any) => - allowedTemplateNames.some(allowedName => - template.templateName.includes(allowedName) || - allowedName.includes(template.templateName) - ) - ); - setAvailableTemplates(filteredTemplates as BasicContractTemplate[]); - const initialSelected = filteredTemplates.map((template: any) => ({ - templateId: template.id, - templateName: template.templateName, - contractType: template.templateName, - checked: false - })); - setSelectedContracts(initialSelected); - - } catch (error) { - console.error('초기 데이터 로드 실패:', error); - toast({ - title: '오류', - description: '기본 정보를 불러오는 데 실패했습니다.', - variant: 'destructive', - }); - setExistingContracts([]); - setAvailableTemplates([]); - setSelectedContracts([]); - } finally { - setIsLoadingTemplates(false); - } - } - fetchInitialData(); - } - }, [open, biddingId, toast]); - - const handleSelectAll = (checked: boolean | 'indeterminate') => { - if (checked) { - // 기존 계약이 없는 업체만 선택 - const availableCompanies = invitableCompanies.filter(company => - !existingContracts.some(ec => ec.vendorId === company.companyId) - ) - setSelectedCompanyIds(availableCompanies.map(company => company.id)) - } else { - setSelectedCompanyIds([]) - } - } - - const handleSelectCompany = (companyId: number, checked: boolean) => { - const company = invitableCompanies.find(c => c.id === companyId) - const hasExistingContract = company ? existingContracts.some(ec => ec.vendorId === company.companyId) : false - - if (hasExistingContract) { - toast({ - title: '선택 불가', - description: '이미 기본계약서를 받은 업체는 다시 선택할 수 없습니다.', - variant: 'default', - }) - return - } - - if (checked) { - setSelectedCompanyIds(prev => [...prev, companyId]) - } else { - setSelectedCompanyIds(prev => prev.filter(id => id !== companyId)) - } - } - - // 기본계약서 선택 토글 - const toggleContractSelection = (templateId: number) => { - setSelectedContracts(prev => - prev.map(contract => - contract.templateId === templateId - ? { ...contract, checked: !contract.checked } - : contract - ) - ) - } - - // 모든 기본계약서 선택/해제 - const toggleAllContractSelection = (checked: boolean | 'indeterminate') => { - setSelectedContracts(prev => - prev.map(contract => ({ ...contract, checked: !!checked })) - ) - } - - const handleSendInvitations = () => { - if (selectedCompanyIds.length === 0) { - toast({ - title: '알림', - description: '초대를 발송할 업체를 선택해주세요.', - variant: 'default', - }) - return - } - - const selectedContractTemplates = selectedContracts.filter(c => c.checked); - const companiesForContracts = invitableCompanies.filter(company => selectedCompanyIds.includes(company.id)); - - const vendorsToGenerateContracts = companiesForContracts.filter(company => - !existingContracts.some(ec => - ec.vendorId === company.companyId && ec.biddingCompanyId === company.id - ) - ); - - startTransition(async () => { - try { - // 1. 사전견적 초대 발송 - const invitationResponse = await sendPreQuoteInvitations( - selectedCompanyIds, - preQuoteDeadline || undefined - ) - - if (!invitationResponse.success) { - toast({ - title: '초대 발송 실패', - description: invitationResponse.error, - variant: 'destructive', - }) - return - } - - // 2. 기본계약 발송 (선택된 템플릿과 업체가 있는 경우) - let contractResponse: Awaited<ReturnType<typeof sendBiddingBasicContracts>> | null = null - if (selectedContractTemplates.length > 0 && selectedCompanyIds.length > 0) { - setIsGeneratingPdfs(true) - setPdfGenerationProgress(0) - - const generatedPdfsMap = new Map<string, { buffer: number[], fileName: string }>() - - let generatedCount = 0; - for (const vendor of vendorsToGenerateContracts) { - for (const contract of selectedContractTemplates) { - setCurrentGeneratingContract(`${vendor.companyName} - ${contract.templateName}`); - const templateDetails = availableTemplates.find(t => t.id === contract.templateId); - - if (templateDetails) { - const pdfData = await generateBasicContractPdf(templateDetails, vendor.companyId); - // sendBiddingBasicContracts와 동일한 키 형식 사용 - let contractType = ''; - if (contract.templateName.includes('비밀')) { - contractType = 'NDA'; - } else if (contract.templateName.includes('General GTC')) { - contractType = 'General_GTC'; - } else if (contract.templateName.includes('기술') && !contract.templateName.includes('기술자료')) { - contractType = 'Project_GTC'; - } else if (contract.templateName.includes('기술자료')) { - contractType = '기술자료'; - } - const key = `${vendor.companyId}_${contractType}_${contract.templateName}`; - generatedPdfsMap.set(key, pdfData); - } - } - generatedCount++; - setPdfGenerationProgress((generatedCount / vendorsToGenerateContracts.length) * 100); - } - - setIsGeneratingPdfs(false); - - const vendorData = companiesForContracts.map(company => { - // 선택된 템플릿에 따라 contractRequirements 동적으로 설정 - const contractRequirements = { - ndaYn: selectedContractTemplates.some(c => c.templateName.includes('비밀')), - generalGtcYn: selectedContractTemplates.some(c => c.templateName.includes('General GTC')), - projectGtcYn: selectedContractTemplates.some(c => c.templateName.includes('기술') && !c.templateName.includes('기술자료')), - agreementYn: selectedContractTemplates.some(c => c.templateName.includes('기술자료')) - }; - - return { - vendorId: company.companyId, - vendorName: company.companyName || '', - vendorCode: company.companyCode, - vendorCountry: '대한민국', - selectedMainEmail: company.contactEmail || '', - contactPerson: company.contactPerson, - contactEmail: company.contactEmail, - biddingCompanyId: company.id, - biddingId: biddingId, - hasExistingContracts: existingContracts.some(ec => - ec.vendorId === company.companyId && ec.biddingCompanyId === company.id - ), - contractRequirements, - additionalEmails: [], - customEmails: [] - }; - }); - - const pdfsArray = Array.from(generatedPdfsMap.entries()).map(([key, data]) => ({ - key, - buffer: data.buffer, - fileName: data.fileName, - })); - - console.log("Calling sendBiddingBasicContracts with biddingId:", biddingId); - console.log("vendorData:", vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId }))); - - contractResponse = await sendBiddingBasicContracts( - biddingId, - vendorData, - pdfsArray, - additionalMessage - ); - } - - let successMessage = '사전견적 초대가 성공적으로 발송되었습니다.'; - if (contractResponse && contractResponse.success) { - successMessage += `\n${contractResponse.message}`; - } - - toast({ - title: '성공', - description: successMessage, - }) - - // 상태 초기화 - setSelectedCompanyIds([]); - setPreQuoteDeadline(''); - setAdditionalMessage(''); - setExistingContracts([]); - setIsGeneratingPdfs(false); - setPdfGenerationProgress(0); - setCurrentGeneratingContract(''); - setSelectedContracts(prev => prev.map(c => ({ ...c, checked: false }))); - - onOpenChange(false); - onSuccess(); - - } catch (error) { - console.error('발송 실패:', error); - toast({ - title: '오류', - description: '발송 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', - variant: 'destructive', - }); - setIsGeneratingPdfs(false); - } - }) - } - - const handleOpenChange = (open: boolean) => { - onOpenChange(open) - if (!open) { - setSelectedCompanyIds([]) - setPreQuoteDeadline('') - setAdditionalMessage('') - setExistingContracts([]) - setIsGeneratingPdfs(false) - setPdfGenerationProgress(0) - setCurrentGeneratingContract('') - setSelectedContracts([]) - } - } - - const selectedContractCount = selectedContracts.filter(c => c.checked).length; - const selectedCompanyCount = selectedCompanyIds.length; - const companiesToReceiveContracts = invitableCompanies.filter(company => selectedCompanyIds.includes(company.id)); - - // 기존 계약이 없는 업체들만 계산 - const availableCompanies = invitableCompanies.filter(company => - !existingContracts.some(ec => ec.vendorId === company.companyId) - ); - const selectedAvailableCompanyCount = selectedCompanyIds.filter(id => - availableCompanies.some(company => company.id === id) - ).length; - - // 선택된 업체들 중 기존 계약이 있는 업체들 - const selectedCompaniesWithExistingContracts = invitableCompanies.filter(company => - selectedCompanyIds.includes(company.id) && - existingContracts.some(ec => ec.vendorId === company.companyId) - ); - - return ( - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent className="sm:max-w-[800px] max-h-[90vh] flex flex-col"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <Mail className="w-5 h-5" /> - 사전견적 초대 및 기본계약 발송 - </DialogTitle> - <DialogDescription> - 선택한 업체들에게 사전견적 요청과 기본계약서를 발송합니다. - </DialogDescription> - </DialogHeader> - - <div className="flex-1 overflow-y-auto px-1" style={{ maxHeight: 'calc(70vh - 200px)' }}> - <div className="space-y-6 pr-4"> - {/* 견적 마감일 설정 */} - <div className="mb-6 p-4 border rounded-lg bg-muted/30"> - <Label htmlFor="preQuoteDeadline" className="text-sm font-medium mb-2 flex items-center gap-2"> - <Calendar className="w-4 h-4" /> - 견적 마감일 - </Label> - <Input - id="preQuoteDeadline" - type="datetime-local" - value={preQuoteDeadline} - onChange={(e) => setPreQuoteDeadline(e.target.value)} - className="w-full" - /> - </div> - - {/* 기존 계약 정보 알림 */} - {existingContracts.length > 0 && ( - <Alert className="border-orange-500 bg-orange-50"> - <Info className="h-4 w-4 text-orange-600" /> - <AlertTitle className="text-orange-800">기존 계약 정보</AlertTitle> - <AlertDescription className="text-orange-700"> - 이미 기본계약을 받은 업체가 있습니다. - 해당 업체들은 초대 대상에서 제외되며, 계약서 재생성도 건너뜁니다. - </AlertDescription> - </Alert> - )} - - {/* 업체 선택 섹션 */} - <Card className="border-2 border-dashed"> - <CardHeader className="pb-3"> - <CardTitle className="flex items-center gap-2 text-base"> - <Building2 className="h-5 w-5 text-green-600" /> - 초대 대상 업체 - </CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - {invitableCompanies.length === 0 ? ( - <div className="text-center py-8 text-muted-foreground"> - 초대 가능한 업체가 없습니다. - </div> - ) : ( - <> - <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"> - <div className="flex items-center gap-2"> - <Checkbox - id="select-all-companies" - checked={selectedAvailableCompanyCount === availableCompanies.length && availableCompanies.length > 0} - onCheckedChange={handleSelectAll} - /> - <Label htmlFor="select-all-companies" className="font-medium"> - 전체 선택 ({availableCompanies.length}개 업체) - </Label> - </div> - <Badge variant="outline"> - {selectedCompanyCount}개 선택됨 - </Badge> - </div> - - <div className="space-y-3 max-h-80 overflow-y-auto"> - {invitableCompanies.map((company) => { - const hasExistingContract = existingContracts.some(ec => ec.vendorId === company.companyId); - return ( - <div key={company.id} className={cn("flex items-center space-x-3 p-3 border rounded-lg transition-colors", - selectedCompanyIds.includes(company.id) && !hasExistingContract && "border-green-500 bg-green-50", - hasExistingContract && "border-orange-500 bg-orange-50 opacity-75" - )}> - <Checkbox - id={`company-${company.id}`} - checked={selectedCompanyIds.includes(company.id)} - disabled={hasExistingContract} - onCheckedChange={(checked) => handleSelectCompany(company.id, !!checked)} - /> - <div className="flex-1"> - <div className="flex items-center gap-2"> - <span className={cn("font-medium", hasExistingContract && "text-muted-foreground")}> - {company.companyName} - </span> - <Badge variant="outline" className="text-xs"> - {company.companyCode} - </Badge> - {hasExistingContract && ( - <Badge variant="secondary" className="text-xs"> - <CheckCircle className="h-3 w-3 mr-1" /> - 계약 체결됨 - </Badge> - )} - </div> - {hasExistingContract && ( - <p className="text-xs text-orange-600 mt-1"> - 이미 기본계약서를 받은 업체입니다. 선택에서 제외됩니다. - </p> - )} - </div> - </div> - ) - })} - </div> - </> - )} - </CardContent> - </Card> - - {/* 선택된 업체 중 기존 계약이 있는 경우 경고 */} - {selectedCompaniesWithExistingContracts.length > 0 && ( - <Alert className="border-red-500 bg-red-50"> - <Info className="h-4 w-4 text-red-600" /> - <AlertTitle className="text-red-800">선택한 업체 중 제외될 업체</AlertTitle> - <AlertDescription className="text-red-700"> - 선택한 {selectedCompaniesWithExistingContracts.length}개 업체가 이미 기본계약서를 받았습니다. - 이 업체들은 초대 발송 및 계약서 생성에서 제외됩니다. - <br /> - <strong>실제 발송 대상: {selectedCompanyCount - selectedCompaniesWithExistingContracts.length}개 업체</strong> - </AlertDescription> - </Alert> - )} - - {/* 기본계약서 선택 섹션 */} - <Separator /> - <Card className="border-2 border-dashed"> - <CardHeader className="pb-3"> - <CardTitle className="flex items-center gap-2 text-base"> - <FileText className="h-5 w-5 text-blue-600" /> - 기본계약서 선택 (선택된 업체에만 발송) - </CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - {isLoadingTemplates ? ( - <div className="text-center py-6"> - <RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2 text-blue-600" /> - <p className="text-sm text-muted-foreground">기본계약서 템플릿을 불러오는 중...</p> - </div> - ) : ( - <div className="space-y-4"> - {selectedCompanyCount === 0 && ( - <Alert className="border-red-500 bg-red-50"> - <Info className="h-4 w-4 text-red-600" /> - <AlertTitle className="text-red-800">알림</AlertTitle> - <AlertDescription className="text-red-700"> - 기본계약서를 발송할 업체를 먼저 선택해주세요. - </AlertDescription> - </Alert> - )} - {availableTemplates.length === 0 ? ( - <div className="text-center py-8 text-muted-foreground"> - <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" /> - <p>사용 가능한 기본계약서 템플릿이 없습니다.</p> - </div> - ) : ( - <> - <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"> - <div className="flex items-center gap-2"> - <Checkbox - id="select-all-contracts" - checked={selectedContracts.length > 0 && selectedContracts.every(c => c.checked)} - onCheckedChange={toggleAllContractSelection} - /> - <Label htmlFor="select-all-contracts" className="font-medium"> - 전체 선택 ({availableTemplates.length}개 템플릿) - </Label> - </div> - <Badge variant="outline"> - {selectedContractCount}개 선택됨 - </Badge> - </div> - <div className="grid gap-3 max-h-60 overflow-y-auto"> - {selectedContracts.map((contract) => ( - <div - key={contract.templateId} - className={cn( - "flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer", - contract.checked && "border-blue-500 bg-blue-50" - )} - onClick={() => toggleContractSelection(contract.templateId)} - > - <div className="flex items-center gap-3"> - <Checkbox - id={`contract-${contract.templateId}`} - checked={contract.checked} - onCheckedChange={() => toggleContractSelection(contract.templateId)} - /> - <div className="flex-1"> - <Label - htmlFor={`contract-${contract.templateId}`} - className="font-medium cursor-pointer" - > - {contract.templateName} - </Label> - <p className="text-xs text-muted-foreground mt-1"> - {contract.contractType} - </p> - </div> - </div> - </div> - ))} - </div> - </> - )} - {selectedContractCount > 0 && ( - <div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg"> - <div className="flex items-center gap-2 mb-2"> - <CheckCircle className="h-4 w-4 text-green-600" /> - <span className="font-medium text-green-900 text-sm"> - 선택된 기본계약서 ({selectedContractCount}개) - </span> - </div> - <ul className="space-y-1 text-xs text-green-800 list-disc list-inside"> - {selectedContracts.filter(c => c.checked).map((contract) => ( - <li key={contract.templateId}> - {contract.templateName} - </li> - ))} - </ul> - </div> - )} - </div> - )} - </CardContent> - </Card> - - {/* 추가 메시지 */} - <div className="space-y-2"> - <Label htmlFor="contractMessage" className="text-sm font-medium"> - 계약서 추가 메시지 (선택사항) - </Label> - <textarea - id="contractMessage" - className="w-full min-h-[60px] p-3 text-sm border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary" - placeholder="기본계약서와 함께 보낼 추가 메시지를 입력하세요..." - value={additionalMessage} - onChange={(e) => setAdditionalMessage(e.target.value)} - /> - </div> - - {/* PDF 생성 진행 상황 */} - {isGeneratingPdfs && ( - <Alert className="border-blue-500 bg-blue-50"> - <div className="space-y-3"> - <div className="flex items-center gap-2"> - <RefreshCw className="h-4 w-4 animate-spin text-blue-600" /> - <AlertTitle className="text-blue-800">기본계약서 생성 중</AlertTitle> - </div> - <AlertDescription> - <div className="space-y-2"> - <p className="text-sm text-blue-700">{currentGeneratingContract}</p> - <Progress value={pdfGenerationProgress} className="h-2" /> - <p className="text-xs text-blue-600"> - {Math.round(pdfGenerationProgress)}% 완료 - </p> - </div> - </AlertDescription> - </div> - </Alert> - )} - </div> - </div> - - <DialogFooter className="flex-col sm:flex-row-reverse sm:justify-between items-center px-4 pt-4"> - <div className="flex gap-2 w-full sm:w-auto"> - <Button variant="outline" onClick={() => handleOpenChange(false)} className="w-full sm:w-auto"> - 취소 - </Button> - <Button - onClick={handleSendInvitations} - disabled={isPending || selectedCompanyCount === 0 || isGeneratingPdfs} - className="w-full sm:w-auto" - > - {isPending ? ( - <> - <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> - 발송 중... - </> - ) : ( - <> - <Mail className="w-4 h-4 mr-2" /> - 초대 발송 및 계약서 생성 - </> - )} - </Button> - </div> - {/* {(selectedCompanyCount > 0 || selectedContractCount > 0) && ( - <div className="mt-4 sm:mt-0 text-sm text-muted-foreground"> - {selectedCompanyCount > 0 && ( - <p> - <strong>{selectedCompanyCount}개 업체</strong>에 초대를 발송합니다. - </p> - )} - {selectedContractCount > 0 && selectedCompanyCount > 0 && ( - <p> - 이 중 <strong>{companiesToReceiveContracts.length}개 업체</strong>에 <strong>{selectedContractCount}개</strong>의 기본계약서를 발송합니다. - </p> - )} - </div> - )} */} - </DialogFooter> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx deleted file mode 100644 index f676709c..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx +++ /dev/null @@ -1,125 +0,0 @@ -'use client' - -import * as React from 'react' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { PrItemsPricingTable } from '../../vendor/components/pr-items-pricing-table' -import { getSavedPrItemQuotations } from '../service' - -interface PrItem { - id: number - itemNumber: string | null - prNumber: string | null - itemInfo: string | null - materialDescription: string | null - quantity: string | null - quantityUnit: string | null - totalWeight: string | null - weightUnit: string | null - currency: string | null - requestedDeliveryDate: string | null - hasSpecDocument: boolean | null -} - -interface PrItemQuotation { - prItemId: number - bidUnitPrice: number - bidAmount: number - proposedDeliveryDate?: string - technicalSpecification?: string -} - -interface BiddingPreQuoteItemDetailsDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - biddingId: number - biddingCompanyId: number - companyName: string - prItems: PrItem[] - currency?: string -} - -export function BiddingPreQuoteItemDetailsDialog({ - open, - onOpenChange, - biddingId, - biddingCompanyId, - companyName, - prItems, - currency = 'KRW' -}: BiddingPreQuoteItemDetailsDialogProps) { - const [prItemQuotations, setPrItemQuotations] = React.useState<PrItemQuotation[]>([]) - const [isLoading, setIsLoading] = React.useState(false) - - // 다이얼로그가 열릴 때 저장된 품목별 견적 데이터 로드 - React.useEffect(() => { - if (open && biddingCompanyId) { - loadSavedQuotations() - } - }, [open, biddingCompanyId]) - - const loadSavedQuotations = async () => { - setIsLoading(true) - try { - console.log('Loading saved quotations for biddingCompanyId:', biddingCompanyId) - const savedQuotations = await getSavedPrItemQuotations(biddingCompanyId) - console.log('Loaded saved quotations:', savedQuotations) - setPrItemQuotations(savedQuotations) - } catch (error) { - console.error('Failed to load saved quotations:', error) - } finally { - setIsLoading(false) - } - } - - const handleQuotationsChange = (quotations: PrItemQuotation[]) => { - // ReadOnly 모드이므로 변경사항을 저장하지 않음 - console.log('Quotations changed (readonly):', quotations) - } - - const handleTotalAmountChange = (total: number) => { - // ReadOnly 모드이므로 총 금액 변경을 처리하지 않음 - console.log('Total amount changed (readonly):', total) - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-7xl max-h-[90vh] overflow-y-auto"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <span>품목별 견적 상세</span> - <span className="text-sm font-normal text-muted-foreground"> - - {companyName} - </span> - </DialogTitle> - <DialogDescription> - 협력업체가 제출한 품목별 견적 상세 정보입니다. - </DialogDescription> - </DialogHeader> - - {isLoading ? ( - <div className="flex items-center justify-center py-12"> - <div className="text-center"> - <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div> - <p className="text-muted-foreground">견적 정보를 불러오는 중...</p> - </div> - </div> - ) : ( - <PrItemsPricingTable - prItems={prItems} - initialQuotations={prItemQuotations} - currency={currency} - onQuotationsChange={handleQuotationsChange} - onTotalAmountChange={handleTotalAmountChange} - readOnly={true} - /> - )} - </DialogContent> - </Dialog> - ) -} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx deleted file mode 100644 index e0194f2a..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx +++ /dev/null @@ -1,157 +0,0 @@ -'use client' - -import * as React from 'react' -import { BiddingCompany } from './bidding-pre-quote-vendor-columns' -import { updatePreQuoteSelection } from '../service' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Badge } from '@/components/ui/badge' -import { CheckCircle, XCircle, AlertCircle } from 'lucide-react' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' - -interface BiddingPreQuoteSelectionDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - selectedCompanies: BiddingCompany[] - onSuccess: () => void -} - -export function BiddingPreQuoteSelectionDialog({ - open, - onOpenChange, - selectedCompanies, - onSuccess -}: BiddingPreQuoteSelectionDialogProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - // 선택된 업체들의 현재 상태 분석 (선정만 가능) - const unselectedCompanies = selectedCompanies.filter(c => !c.isPreQuoteSelected) - const hasQuotationCompanies = selectedCompanies.filter(c => c.preQuoteAmount && Number(c.preQuoteAmount) > 0) - - const handleConfirm = () => { - const companyIds = selectedCompanies.map(c => c.id) - const isSelected = true // 항상 선정으로 고정 - - startTransition(async () => { - const result = await updatePreQuoteSelection( - companyIds, - isSelected - ) - - if (result.success) { - toast({ - title: '성공', - description: result.message, - }) - onSuccess() - onOpenChange(false) - } else { - toast({ - title: '오류', - description: result.error, - variant: 'destructive', - }) - } - }) - } - - const getActionIcon = (isSelected: boolean) => { - return isSelected ? - <CheckCircle className="h-4 w-4 text-muted-foreground" /> : - <CheckCircle className="h-4 w-4 text-green-600" /> - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[600px]"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <AlertCircle className="h-5 w-5 text-amber-500" /> - 본입찰 선정 상태 변경 - </DialogTitle> - <DialogDescription> - 선택된 {selectedCompanies.length}개 업체의 본입찰 선정 상태를 변경합니다. - </DialogDescription> - </DialogHeader> - - <div className="space-y-4"> - {/* 견적 제출 여부 안내 */} - {hasQuotationCompanies.length !== selectedCompanies.length && ( - <div className="bg-amber-50 border border-amber-200 rounded-lg p-3"> - <div className="flex items-center gap-2 text-amber-800"> - <AlertCircle className="h-4 w-4" /> - <span className="text-sm font-medium">알림</span> - </div> - <p className="text-sm text-amber-700 mt-1"> - 사전견적을 제출하지 않은 업체도 포함되어 있습니다. - 견적 미제출 업체도 본입찰에 참여시키시겠습니까? - </p> - </div> - )} - - {/* 업체 목록 */} - <div className="border rounded-lg"> - <div className="p-3 bg-muted/50 border-b"> - <h4 className="font-medium">대상 업체 목록</h4> - </div> - <div className="max-h-64 overflow-y-auto"> - {selectedCompanies.map((company) => ( - <div key={company.id} className="flex items-center justify-between p-3 border-b last:border-b-0"> - <div className="flex items-center gap-3"> - {getActionIcon(company.isPreQuoteSelected)} - <div> - <div className="font-medium">{company.companyName}</div> - <div className="text-sm text-muted-foreground">{company.companyCode}</div> - </div> - </div> - <div className="flex items-center gap-2"> - <Badge variant={company.isPreQuoteSelected ? 'default' : 'secondary'}> - {company.isPreQuoteSelected ? '현재 선정' : '현재 미선정'} - </Badge> - {company.preQuoteAmount && Number(company.preQuoteAmount) > 0 ? ( - <Badge variant="outline" className="text-green-600"> - 견적 제출 - </Badge> - ) : ( - <Badge variant="outline" className="text-muted-foreground"> - 견적 미제출 - </Badge> - )} - </div> - </div> - ))} - </div> - </div> - - {/* 결과 요약 */} - <div className="bg-blue-50 border border-blue-200 rounded-lg p-3"> - <h5 className="font-medium text-blue-900 mb-2">변경 결과</h5> - <div className="text-sm text-blue-800"> - <p>• {unselectedCompanies.length}개 업체가 본입찰 대상으로 <span className="font-medium text-green-600">선정</span>됩니다.</p> - {selectedCompanies.length > unselectedCompanies.length && ( - <p>• {selectedCompanies.length - unselectedCompanies.length}개 업체는 이미 선정 상태이므로 변경되지 않습니다.</p> - )} - </div> - </div> - </div> - - <DialogFooter> - <Button variant="outline" onClick={() => onOpenChange(false)}> - 취소 - </Button> - <Button onClick={handleConfirm} disabled={isPending}> - {isPending ? '처리 중...' : '확인'} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx deleted file mode 100644 index 3266a568..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx +++ /dev/null @@ -1,398 +0,0 @@ -"use client" - -import * as React from "react" -import { type ColumnDef } from "@tanstack/react-table" -import { Checkbox } from "@/components/ui/checkbox" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { - MoreHorizontal, Edit, Trash2, Paperclip -} from "lucide-react" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" - -// bidding_companies 테이블 타입 정의 (company_condition_responses와 join) -export interface BiddingCompany { - id: number - biddingId: number - companyId: number - invitationStatus: 'pending' | 'sent' | 'accepted' | 'declined' | 'submitted' - invitedAt: Date | null - respondedAt: Date | null - preQuoteAmount: string | null - preQuoteSubmittedAt: Date | null - preQuoteDeadline: Date | null - isPreQuoteSelected: boolean - isPreQuoteParticipated: boolean | null - isAttendingMeeting: boolean | null - notes: string | null - contactPerson: string | null - contactEmail: string | null - contactPhone: string | null - createdAt: Date - updatedAt: Date - - // company_condition_responses 필드들 - paymentTermsResponse: string | null - taxConditionsResponse: string | null - proposedContractDeliveryDate: string | null - priceAdjustmentResponse: boolean | null - isInitialResponse: boolean | null - incotermsResponse: string | null - proposedShippingPort: string | null - proposedDestinationPort: string | null - sparePartResponse: string | null - additionalProposals: string | null - - // 조인된 업체 정보 - companyName?: string - companyCode?: string -} - -interface GetBiddingCompanyColumnsProps { - onEdit: (company: BiddingCompany) => void - onDelete: (company: BiddingCompany) => void - onViewPriceAdjustment?: (company: BiddingCompany) => void - onViewItemDetails?: (company: BiddingCompany) => void - onViewAttachments?: (company: BiddingCompany) => void -} - -export function getBiddingPreQuoteVendorColumns({ - onEdit, - onDelete, - onViewPriceAdjustment, - onViewItemDetails, - onViewAttachments -}: GetBiddingCompanyColumnsProps): ColumnDef<BiddingCompany>[] { - return [ - { - id: 'select', - header: ({ table }) => ( - <Checkbox - checked={table.getIsAllPageRowsSelected()} - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="모두 선택" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - aria-label="행 선택" - /> - ), - enableSorting: false, - enableHiding: false, - }, - { - accessorKey: 'companyName', - header: '업체명', - cell: ({ row }) => ( - <div className="font-medium">{row.original.companyName || '-'}</div> - ), - }, - { - accessorKey: 'companyCode', - header: '업체코드', - cell: ({ row }) => ( - <div className="font-mono text-sm">{row.original.companyCode || '-'}</div> - ), - }, - { - accessorKey: 'invitationStatus', - header: '초대 상태', - cell: ({ row }) => { - const status = row.original.invitationStatus - let variant: any - let label: string - - if (status === 'accepted') { - variant = 'default' - label = '수락' - } else if (status === 'declined') { - variant = 'destructive' - label = '거절' - } else if (status === 'pending') { - variant = 'outline' - label = '대기중' - } else if (status === 'sent') { - variant = 'outline' - label = '요청됨' - } else if (status === 'submitted') { - variant = 'outline' - label = '제출됨' - } else { - variant = 'outline' - label = status || '-' - } - - return <Badge variant={variant}>{label}</Badge> - }, - }, - { - accessorKey: 'preQuoteAmount', - header: '사전견적금액', - cell: ({ row }) => { - const hasAmount = row.original.preQuoteAmount && Number(row.original.preQuoteAmount) > 0 - return ( - <div className="text-right font-mono"> - {hasAmount ? ( - <button - onClick={() => onViewItemDetails?.(row.original)} - className="text-primary hover:text-primary/80 hover:underline cursor-pointer" - title="품목별 견적 상세 보기" - > - {Number(row.original.preQuoteAmount).toLocaleString()} KRW - </button> - ) : ( - <span className="text-muted-foreground">-</span> - )} - </div> - ) - }, - }, - { - accessorKey: 'preQuoteSubmittedAt', - header: '사전견적 제출일', - cell: ({ row }) => ( - <div className="text-sm"> - {row.original.preQuoteSubmittedAt ? new Date(row.original.preQuoteSubmittedAt).toLocaleDateString('ko-KR') : '-'} - </div> - ), - }, - { - accessorKey: 'preQuoteDeadline', - header: '사전견적 마감일', - cell: ({ row }) => { - const deadline = row.original.preQuoteDeadline - if (!deadline) { - return <div className="text-muted-foreground text-sm">-</div> - } - - const now = new Date() - const deadlineDate = new Date(deadline) - const isExpired = deadlineDate < now - - return ( - <div className={`text-sm ${isExpired ? 'text-red-600' : ''}`}> - <div>{deadlineDate.toLocaleDateString('ko-KR')}</div> - {isExpired && ( - <Badge variant="destructive" className="text-xs mt-1"> - 마감 - </Badge> - )} - </div> - ) - }, - }, - { - accessorKey: 'attachments', - header: '첨부파일', - cell: ({ row }) => { - const hasAttachments = row.original.preQuoteSubmittedAt // 제출된 경우에만 첨부파일이 있을 수 있음 - return ( - <div className="text-center"> - {hasAttachments ? ( - <Button - variant="ghost" - size="sm" - onClick={() => onViewAttachments?.(row.original)} - className="h-8 w-8 p-0" - title="첨부파일 보기" - > - <Paperclip className="h-4 w-4" /> - </Button> - ) : ( - <span className="text-muted-foreground text-sm">-</span> - )} - </div> - ) - }, - }, - { - accessorKey: 'isPreQuoteParticipated', - header: '사전견적 참여의사', - cell: ({ row }) => { - const participated = row.original.isPreQuoteParticipated - if (participated === null) { - return <Badge variant="outline">미결정</Badge> - } - return ( - <Badge variant={participated ? 'default' : 'destructive'}> - {participated ? '참여' : '미참여'} - </Badge> - ) - }, - }, - { - accessorKey: 'isPreQuoteSelected', - header: '본입찰 선정', - cell: ({ row }) => ( - <Badge variant={row.original.isPreQuoteSelected ? 'default' : 'secondary'}> - {row.original.isPreQuoteSelected ? '선정' : '미선정'} - </Badge> - ), - }, - { - accessorKey: 'isAttendingMeeting', - header: '사양설명회 참석', - cell: ({ row }) => { - const isAttending = row.original.isAttendingMeeting - if (isAttending === null) return <div className="text-sm">-</div> - return ( - <Badge variant={isAttending ? 'default' : 'secondary'}> - {isAttending ? '참석' : '불참석'} - </Badge> - ) - }, - }, - { - accessorKey: 'paymentTermsResponse', - header: '지급조건', - cell: ({ row }) => ( - <div className="text-sm max-w-32 truncate" title={row.original.paymentTermsResponse || ''}> - {row.original.paymentTermsResponse || '-'} - </div> - ), - }, - { - accessorKey: 'taxConditionsResponse', - header: '세금조건', - cell: ({ row }) => ( - <div className="text-sm max-w-32 truncate" title={row.original.taxConditionsResponse || ''}> - {row.original.taxConditionsResponse || '-'} - </div> - ), - }, - { - accessorKey: 'incotermsResponse', - header: '운송조건', - cell: ({ row }) => ( - <div className="text-sm max-w-24 truncate" title={row.original.incotermsResponse || ''}> - {row.original.incotermsResponse || '-'} - </div> - ), - }, - { - accessorKey: 'isInitialResponse', - header: '초도여부', - cell: ({ row }) => { - const isInitial = row.original.isInitialResponse - if (isInitial === null) return <div className="text-sm">-</div> - return ( - <Badge variant={isInitial ? 'default' : 'secondary'}> - {isInitial ? 'Y' : 'N'} - </Badge> - ) - }, - }, - { - accessorKey: 'priceAdjustmentResponse', - header: '연동제', - cell: ({ row }) => { - const hasPriceAdjustment = row.original.priceAdjustmentResponse - if (hasPriceAdjustment === null) return <div className="text-sm">-</div> - return ( - <div className="flex items-center gap-2"> - <Badge variant={hasPriceAdjustment ? 'default' : 'secondary'}> - {hasPriceAdjustment ? '적용' : '미적용'} - </Badge> - {hasPriceAdjustment && onViewPriceAdjustment && ( - <Button - variant="ghost" - size="sm" - onClick={() => onViewPriceAdjustment(row.original)} - className="h-6 px-2 text-xs" - > - 상세 - </Button> - )} - </div> - ) - }, - }, - { - accessorKey: 'proposedContractDeliveryDate', - header: '제안납기일', - cell: ({ row }) => ( - <div className="text-sm"> - {row.original.proposedContractDeliveryDate ? - new Date(row.original.proposedContractDeliveryDate).toLocaleDateString('ko-KR') : '-'} - </div> - ), - }, - { - accessorKey: 'proposedShippingPort', - header: '제안선적지', - cell: ({ row }) => ( - <div className="text-sm max-w-24 truncate" title={row.original.proposedShippingPort || ''}> - {row.original.proposedShippingPort || '-'} - </div> - ), - }, - { - accessorKey: 'proposedDestinationPort', - header: '제안하역지', - cell: ({ row }) => ( - <div className="text-sm max-w-24 truncate" title={row.original.proposedDestinationPort || ''}> - {row.original.proposedDestinationPort || '-'} - </div> - ), - }, - { - accessorKey: 'sparePartResponse', - header: '스페어파트', - cell: ({ row }) => ( - <div className="text-sm max-w-24 truncate" title={row.original.sparePartResponse || ''}> - {row.original.sparePartResponse || '-'} - </div> - ), - }, - { - accessorKey: 'additionalProposals', - header: '추가제안', - cell: ({ row }) => ( - <div className="text-sm max-w-32 truncate" title={row.original.additionalProposals || ''}> - {row.original.additionalProposals || '-'} - </div> - ), - }, - { - id: 'actions', - header: '액션', - cell: ({ row }) => { - const company = row.original - - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" className="h-8 w-8 p-0"> - <span className="sr-only">메뉴 열기</span> - <MoreHorizontal className="h-4 w-4" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - {/* <DropdownMenuItem onClick={() => onEdit(company)}> - <Edit className="mr-2 h-4 w-4" /> - 수정 - </DropdownMenuItem> */} - <DropdownMenuItem - onClick={() => onDelete(company)} - className="text-destructive" - > - <Trash2 className="mr-2 h-4 w-4" /> - 삭제 - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - ) - }, - }, - ] -} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx deleted file mode 100644 index bd078192..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx +++ /dev/null @@ -1,311 +0,0 @@ -'use client' - -import * as React from 'react' -import { Button } from '@/components/ui/button' -import { Label } from '@/components/ui/label' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@/components/ui/command' -import { - Popover, - PopoverContent, - PopoverTrigger -} from '@/components/ui/popover' -import { Check, ChevronsUpDown, Loader2, X, Plus, Search } from 'lucide-react' -import { cn } from '@/lib/utils' -import { createBiddingCompany } from '@/lib/bidding/pre-quote/service' -import { searchVendorsForBidding } from '@/lib/bidding/service' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' -import { Badge } from '@/components/ui/badge' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { ScrollArea } from '@/components/ui/scroll-area' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { Info } from 'lucide-react' - -interface BiddingPreQuoteVendorCreateDialogProps { - biddingId: number - open: boolean - onOpenChange: (open: boolean) => void - onSuccess: () => void -} - -interface Vendor { - id: number - vendorName: string - vendorCode: string - status: string -} - -export function BiddingPreQuoteVendorCreateDialog({ - biddingId, - open, - onOpenChange, - onSuccess -}: BiddingPreQuoteVendorCreateDialogProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - - // Vendor 검색 상태 - const [vendorList, setVendorList] = React.useState<Vendor[]>([]) - const [selectedVendors, setSelectedVendors] = React.useState<Vendor[]>([]) - const [vendorOpen, setVendorOpen] = React.useState(false) - - - // 벤더 로드 - const loadVendors = React.useCallback(async () => { - try { - const result = await searchVendorsForBidding('', biddingId) // 빈 검색어로 모든 벤더 로드 - setVendorList(result || []) - } catch (error) { - console.error('Failed to load vendors:', error) - toast({ - title: '오류', - description: '벤더 목록을 불러오는데 실패했습니다.', - variant: 'destructive', - }) - setVendorList([]) - } - }, [biddingId]) - - React.useEffect(() => { - if (open) { - loadVendors() - } - }, [open, loadVendors]) - - // 초기화 - React.useEffect(() => { - if (!open) { - setSelectedVendors([]) - } - }, [open]) - - // 벤더 추가 - const handleAddVendor = (vendor: Vendor) => { - if (!selectedVendors.find(v => v.id === vendor.id)) { - setSelectedVendors([...selectedVendors, vendor]) - } - setVendorOpen(false) - } - - // 벤더 제거 - const handleRemoveVendor = (vendorId: number) => { - setSelectedVendors(selectedVendors.filter(v => v.id !== vendorId)) - } - - // 이미 선택된 벤더인지 확인 - const isVendorSelected = (vendorId: number) => { - return selectedVendors.some(v => v.id === vendorId) - } - - const handleCreate = () => { - if (selectedVendors.length === 0) { - toast({ - title: '오류', - description: '업체를 선택해주세요.', - variant: 'destructive', - }) - return - } - - startTransition(async () => { - let successCount = 0 - let errorMessages: string[] = [] - - for (const vendor of selectedVendors) { - try { - const response = await createBiddingCompany({ - biddingId, - companyId: vendor.id, - }) - - if (response.success) { - successCount++ - } else { - errorMessages.push(`${vendor.vendorName}: ${response.error}`) - } - } catch (error) { - errorMessages.push(`${vendor.vendorName}: 처리 중 오류가 발생했습니다.`) - } - } - - if (successCount > 0) { - toast({ - title: '성공', - description: `${successCount}개의 업체가 성공적으로 추가되었습니다.${errorMessages.length > 0 ? ` ${errorMessages.length}개는 실패했습니다.` : ''}`, - }) - onOpenChange(false) - resetForm() - onSuccess() - } - - if (errorMessages.length > 0 && successCount === 0) { - toast({ - title: '오류', - description: `업체 추가에 실패했습니다: ${errorMessages.join(', ')}`, - variant: 'destructive', - }) - } - }) - } - - const resetForm = () => { - setSelectedVendors([]) - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-4xl max-h-[90vh] p-0 flex flex-col"> - {/* 헤더 */} - <DialogHeader className="p-6 pb-0"> - <DialogTitle>사전견적 업체 추가</DialogTitle> - <DialogDescription> - 견적 요청을 보낼 업체를 선택하세요. 여러 개 선택 가능합니다. - </DialogDescription> - </DialogHeader> - - {/* 메인 컨텐츠 */} - <div className="flex-1 px-6 py-4 overflow-y-auto"> - <div className="space-y-6"> - {/* 업체 선택 카드 */} - <Card> - <CardHeader> - <CardTitle className="text-lg">업체 선택</CardTitle> - <CardDescription> - 사전견적을 발송할 업체를 선택하세요. - </CardDescription> - </CardHeader> - <CardContent> - <div className="space-y-4"> - {/* 업체 추가 버튼 */} - <Popover open={vendorOpen} onOpenChange={setVendorOpen}> - <PopoverTrigger asChild> - <Button - variant="outline" - role="combobox" - aria-expanded={vendorOpen} - className="w-full justify-between" - disabled={vendorList.length === 0} - > - <span className="flex items-center gap-2"> - <Plus className="h-4 w-4" /> - 업체 선택하기 - </span> - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-[500px] p-0" align="start"> - <Command> - <CommandInput placeholder="업체명 또는 코드로 검색..." /> - <CommandList> - <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> - <CommandGroup> - {vendorList - .filter(vendor => !isVendorSelected(vendor.id)) - .map((vendor) => ( - <CommandItem - key={vendor.id} - value={`${vendor.vendorCode} ${vendor.vendorName}`} - onSelect={() => handleAddVendor(vendor)} - > - <div className="flex items-center gap-2 w-full"> - <Badge variant="outline" className="shrink-0"> - {vendor.vendorCode} - </Badge> - <span className="truncate">{vendor.vendorName}</span> - </div> - </CommandItem> - ))} - </CommandGroup> - </CommandList> - </Command> - </PopoverContent> - </Popover> - - {/* 선택된 업체 목록 */} - {selectedVendors.length > 0 && ( - <div className="space-y-2"> - <div className="flex items-center justify-between"> - <h4 className="text-sm font-medium">선택된 업체 ({selectedVendors.length}개)</h4> - </div> - <div className="space-y-2"> - {selectedVendors.map((vendor, index) => ( - <div - key={vendor.id} - className="flex items-center justify-between p-3 rounded-lg bg-secondary/50" - > - <div className="flex items-center gap-3"> - <span className="text-sm text-muted-foreground"> - {index + 1}. - </span> - <Badge variant="outline"> - {vendor.vendorCode} - </Badge> - <span className="text-sm font-medium"> - {vendor.vendorName} - </span> - </div> - <Button - variant="ghost" - size="sm" - onClick={() => handleRemoveVendor(vendor.id)} - className="h-8 w-8 p-0" - > - <X className="h-4 w-4" /> - </Button> - </div> - ))} - </div> - </div> - )} - - {selectedVendors.length === 0 && ( - <div className="text-center py-8 text-muted-foreground"> - <p className="text-sm">아직 선택된 업체가 없습니다.</p> - <p className="text-xs mt-1">위 버튼을 클릭하여 업체를 추가하세요.</p> - </div> - )} - </div> - </CardContent> - </Card> - </div> - </div> - - {/* 푸터 */} - <DialogFooter className="p-6 pt-0 border-t"> - <Button - variant="outline" - onClick={() => onOpenChange(false)} - disabled={isPending} - > - 취소 - </Button> - <Button - onClick={handleCreate} - disabled={isPending || selectedVendors.length === 0} - > - {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - {selectedVendors.length > 0 - ? `${selectedVendors.length}개 업체 추가` - : '업체 추가' - } - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx deleted file mode 100644 index 03bf2ecb..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx +++ /dev/null @@ -1,200 +0,0 @@ -'use client' - -import * as React from 'react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Textarea } from '@/components/ui/textarea' -import { Checkbox } from '@/components/ui/checkbox' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { updateBiddingCompany } from '../service' -import { BiddingCompany } from './bidding-pre-quote-vendor-columns' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' - -interface BiddingPreQuoteVendorEditDialogProps { - company: BiddingCompany | null - open: boolean - onOpenChange: (open: boolean) => void - onSuccess: () => void -} - -export function BiddingPreQuoteVendorEditDialog({ - company, - open, - onOpenChange, - onSuccess -}: BiddingPreQuoteVendorEditDialogProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - - // 폼 상태 - const [formData, setFormData] = React.useState({ - contactPerson: '', - contactEmail: '', - contactPhone: '', - preQuoteAmount: 0, - notes: '', - invitationStatus: 'pending' as 'pending' | 'accepted' | 'declined', - isPreQuoteSelected: false, - isAttendingMeeting: false, - }) - - // company가 변경되면 폼 데이터 업데이트 - React.useEffect(() => { - if (company) { - setFormData({ - contactPerson: company.contactPerson || '', - contactEmail: company.contactEmail || '', - contactPhone: company.contactPhone || '', - preQuoteAmount: company.preQuoteAmount ? Number(company.preQuoteAmount) : 0, - notes: company.notes || '', - invitationStatus: company.invitationStatus, - isPreQuoteSelected: company.isPreQuoteSelected, - isAttendingMeeting: company.isAttendingMeeting || false, - }) - } - }, [company]) - - const handleEdit = () => { - if (!company) return - - startTransition(async () => { - const response = await updateBiddingCompany(company.id, formData) - - if (response.success) { - toast({ - title: '성공', - description: response.message, - }) - onOpenChange(false) - onSuccess() - } else { - toast({ - title: '오류', - description: response.error, - variant: 'destructive', - }) - } - }) - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[600px]"> - <DialogHeader> - <DialogTitle>사전견적 업체 수정</DialogTitle> - <DialogDescription> - {company?.companyName} 업체의 사전견적 정보를 수정해주세요. - </DialogDescription> - </DialogHeader> - <div className="grid gap-4 py-4"> - <div className="grid grid-cols-3 gap-4"> - <div className="space-y-2"> - <Label htmlFor="edit-contactPerson">담당자</Label> - <Input - id="edit-contactPerson" - value={formData.contactPerson} - onChange={(e) => setFormData({ ...formData, contactPerson: e.target.value })} - /> - </div> - <div className="space-y-2"> - <Label htmlFor="edit-contactEmail">이메일</Label> - <Input - id="edit-contactEmail" - type="email" - value={formData.contactEmail} - onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })} - /> - </div> - <div className="space-y-2"> - <Label htmlFor="edit-contactPhone">연락처</Label> - <Input - id="edit-contactPhone" - value={formData.contactPhone} - onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })} - /> - </div> - </div> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="edit-preQuoteAmount">사전견적금액</Label> - <Input - id="edit-preQuoteAmount" - type="number" - value={formData.preQuoteAmount} - onChange={(e) => setFormData({ ...formData, preQuoteAmount: Number(e.target.value) })} - /> - </div> - <div className="space-y-2"> - <Label htmlFor="edit-invitationStatus">초대 상태</Label> - <Select value={formData.invitationStatus} onValueChange={(value: any) => setFormData({ ...formData, invitationStatus: value })}> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="pending">대기중</SelectItem> - <SelectItem value="accepted">수락</SelectItem> - <SelectItem value="declined">거절</SelectItem> - </SelectContent> - </Select> - </div> - </div> - <div className="grid grid-cols-2 gap-4"> - <div className="flex items-center space-x-2"> - <Checkbox - id="edit-isPreQuoteSelected" - checked={formData.isPreQuoteSelected} - onCheckedChange={(checked) => - setFormData({ ...formData, isPreQuoteSelected: !!checked }) - } - /> - <Label htmlFor="edit-isPreQuoteSelected">본입찰 선정</Label> - </div> - <div className="flex items-center space-x-2"> - <Checkbox - id="edit-isAttendingMeeting" - checked={formData.isAttendingMeeting} - onCheckedChange={(checked) => - setFormData({ ...formData, isAttendingMeeting: !!checked }) - } - /> - <Label htmlFor="edit-isAttendingMeeting">사양설명회 참석</Label> - </div> - </div> - <div className="space-y-2"> - <Label htmlFor="edit-notes">특이사항</Label> - <Textarea - id="edit-notes" - value={formData.notes} - onChange={(e) => setFormData({ ...formData, notes: e.target.value })} - placeholder="특이사항을 입력해주세요..." - /> - </div> - </div> - <DialogFooter> - <Button variant="outline" onClick={() => onOpenChange(false)}> - 취소 - </Button> - <Button onClick={handleEdit} disabled={isPending}> - 수정 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx deleted file mode 100644 index 5f600882..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx +++ /dev/null @@ -1,257 +0,0 @@ -'use client' - -import * as React from 'react' -import { type DataTableAdvancedFilterField, type DataTableFilterField } from '@/types/table' -import { useDataTable } from '@/hooks/use-data-table' -import { DataTable } from '@/components/data-table/data-table' -import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar' -import { BiddingPreQuoteVendorToolbarActions } from './bidding-pre-quote-vendor-toolbar-actions' -import { BiddingPreQuoteVendorEditDialog } from './bidding-pre-quote-vendor-edit-dialog' -import { getBiddingPreQuoteVendorColumns, BiddingCompany } from './bidding-pre-quote-vendor-columns' -import { Bidding } from '@/db/schema' -import { - deleteBiddingCompany -} from '../service' -import { getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' -import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog' -import { BiddingPreQuoteItemDetailsDialog } from './bidding-pre-quote-item-details-dialog' -import { BiddingPreQuoteAttachmentsDialog } from './bidding-pre-quote-attachments-dialog' -import { getPrItemsForBidding } from '../service' - -interface BiddingPreQuoteVendorTableContentProps { - biddingId: number - bidding: Bidding - biddingCompanies: BiddingCompany[] - onRefresh: () => void - onOpenItemsDialog: () => void - onOpenTargetPriceDialog: () => void - onOpenSelectionReasonDialog: () => void - onEdit?: (company: BiddingCompany) => void - onDelete?: (company: BiddingCompany) => void -} - -const filterFields: DataTableFilterField<BiddingCompany>[] = [ - { - id: 'companyName', - label: '업체명', - placeholder: '업체명으로 검색...', - }, - { - id: 'companyCode', - label: '업체코드', - placeholder: '업체코드로 검색...', - }, - { - id: 'contactPerson', - label: '담당자', - placeholder: '담당자로 검색...', - }, -] - -const advancedFilterFields: DataTableAdvancedFilterField<BiddingCompany>[] = [ - { - id: 'companyName', - label: '업체명', - type: 'text', - }, - { - id: 'companyCode', - label: '업체코드', - type: 'text', - }, - { - id: 'contactPerson', - label: '담당자', - type: 'text', - }, - { - id: 'preQuoteAmount', - label: '사전견적금액', - type: 'number', - }, - { - id: 'invitationStatus', - label: '초대 상태', - type: 'multi-select', - options: [ - { label: '수락', value: 'accepted' }, - { label: '거절', value: 'declined' }, - { label: '요청됨', value: 'sent' }, - { label: '대기중', value: 'pending' }, - ], - }, -] - -export function BiddingPreQuoteVendorTableContent({ - biddingId, - bidding, - biddingCompanies, - onRefresh, - onOpenItemsDialog, - onOpenTargetPriceDialog, - onOpenSelectionReasonDialog, - onEdit, - onDelete -}: BiddingPreQuoteVendorTableContentProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - const [selectedCompany, setSelectedCompany] = React.useState<BiddingCompany | null>(null) - const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false) - const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false) - const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null) - const [isItemDetailsDialogOpen, setIsItemDetailsDialogOpen] = React.useState(false) - const [selectedCompanyForDetails, setSelectedCompanyForDetails] = React.useState<BiddingCompany | null>(null) - const [prItems, setPrItems] = React.useState<any[]>([]) - const [isAttachmentsDialogOpen, setIsAttachmentsDialogOpen] = React.useState(false) - const [selectedCompanyForAttachments, setSelectedCompanyForAttachments] = React.useState<BiddingCompany | null>(null) - - const handleDelete = (company: BiddingCompany) => { - startTransition(async () => { - const response = await deleteBiddingCompany(company.id) - - if (response.success) { - toast({ - title: '성공', - description: response.message, - }) - onRefresh() - } else { - toast({ - title: '오류', - description: response.error, - variant: 'destructive', - }) - } - }) - } - - const handleEdit = (company: BiddingCompany) => { - setSelectedCompany(company) - setIsEditDialogOpen(true) - } - - - const handleViewPriceAdjustment = async (company: BiddingCompany) => { - startTransition(async () => { - const priceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(company.id) - if (priceAdjustmentForm) { - setPriceAdjustmentData(priceAdjustmentForm) - setSelectedCompany(company) - setIsPriceAdjustmentDialogOpen(true) - } else { - toast({ - title: '정보 없음', - description: '연동제 정보가 없습니다.', - variant: 'destructive', - }) - } - }) - } - - const handleViewItemDetails = async (company: BiddingCompany) => { - startTransition(async () => { - try { - // PR 아이템 정보 로드 - const prItemsData = await getPrItemsForBidding(biddingId) - setPrItems(prItemsData) - setSelectedCompanyForDetails(company) - setIsItemDetailsDialogOpen(true) - } catch (error) { - console.error('Failed to load PR items:', error) - toast({ - title: '오류', - description: '품목 정보를 불러오는데 실패했습니다.', - variant: 'destructive', - }) - } - }) - } - - const handleViewAttachments = (company: BiddingCompany) => { - setSelectedCompanyForAttachments(company) - setIsAttachmentsDialogOpen(true) - } - - const columns = React.useMemo( - () => getBiddingPreQuoteVendorColumns({ - onEdit: onEdit || handleEdit, - onDelete: onDelete || handleDelete, - onViewPriceAdjustment: handleViewPriceAdjustment, - onViewItemDetails: handleViewItemDetails, - onViewAttachments: handleViewAttachments - }), - [onEdit, onDelete, handleEdit, handleDelete, handleViewPriceAdjustment, handleViewItemDetails, handleViewAttachments] - ) - - const { table } = useDataTable({ - data: biddingCompanies, - columns, - pageCount: 1, - filterFields, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: 'companyName', desc: false }], - columnPinning: { right: ['actions'] }, - }, - getRowId: (originalRow) => originalRow.id.toString(), - shallow: false, - clearOnDefault: true, - }) - - return ( - <> - <DataTable table={table}> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <BiddingPreQuoteVendorToolbarActions - table={table} - biddingId={biddingId} - bidding={bidding} - biddingCompanies={biddingCompanies} - onOpenItemsDialog={onOpenItemsDialog} - onOpenTargetPriceDialog={onOpenTargetPriceDialog} - onOpenSelectionReasonDialog={onOpenSelectionReasonDialog} - onSuccess={onRefresh} - /> - </DataTableAdvancedToolbar> - </DataTable> - - <BiddingPreQuoteVendorEditDialog - company={selectedCompany} - open={isEditDialogOpen} - onOpenChange={setIsEditDialogOpen} - onSuccess={onRefresh} - /> - - <PriceAdjustmentDialog - open={isPriceAdjustmentDialogOpen} - onOpenChange={setIsPriceAdjustmentDialogOpen} - data={priceAdjustmentData} - vendorName={selectedCompany?.companyName || ''} - /> - - <BiddingPreQuoteItemDetailsDialog - open={isItemDetailsDialogOpen} - onOpenChange={setIsItemDetailsDialogOpen} - biddingId={biddingId} - biddingCompanyId={selectedCompanyForDetails?.id || 0} - companyName={selectedCompanyForDetails?.companyName || ''} - prItems={prItems} - currency={bidding.currency || 'KRW'} - /> - - <BiddingPreQuoteAttachmentsDialog - open={isAttachmentsDialogOpen} - onOpenChange={setIsAttachmentsDialogOpen} - biddingId={biddingId} - companyId={selectedCompanyForAttachments?.companyId || 0} - companyName={selectedCompanyForAttachments?.companyName || ''} - /> - </> - ) -} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx deleted file mode 100644 index 34e53fb2..00000000 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx +++ /dev/null @@ -1,130 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { useTransition } from "react" -import { Button } from "@/components/ui/button" -import { Plus, Send, Mail, CheckSquare } from "lucide-react" -import { BiddingCompany } from "./bidding-pre-quote-vendor-columns" -import { BiddingPreQuoteVendorCreateDialog } from "./bidding-pre-quote-vendor-create-dialog" -import { BiddingPreQuoteInvitationDialog } from "./bidding-pre-quote-invitation-dialog" -import { BiddingPreQuoteSelectionDialog } from "./bidding-pre-quote-selection-dialog" -import { Bidding } from "@/db/schema" -import { useToast } from "@/hooks/use-toast" - -interface BiddingPreQuoteVendorToolbarActionsProps { - table: Table<BiddingCompany> - biddingId: number - bidding: Bidding - biddingCompanies: BiddingCompany[] - onOpenItemsDialog: () => void - onOpenTargetPriceDialog: () => void - onOpenSelectionReasonDialog: () => void - onSuccess: () => void -} - -export function BiddingPreQuoteVendorToolbarActions({ - table, - biddingId, - bidding, - biddingCompanies, - onOpenItemsDialog, - onOpenTargetPriceDialog, - onOpenSelectionReasonDialog, - onSuccess -}: BiddingPreQuoteVendorToolbarActionsProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false) - const [isInvitationDialogOpen, setIsInvitationDialogOpen] = React.useState(false) - const [isSelectionDialogOpen, setIsSelectionDialogOpen] = React.useState(false) - - const handleCreateCompany = () => { - setIsCreateDialogOpen(true) - } - - const handleSendInvitations = () => { - setIsInvitationDialogOpen(true) - } - - const handleManageSelection = () => { - const selectedRows = table.getFilteredSelectedRowModel().rows - if (selectedRows.length === 0) { - toast({ - title: '선택 필요', - description: '본입찰 선정 상태를 변경할 업체를 선택해주세요.', - variant: 'destructive', - }) - return - } - setIsSelectionDialogOpen(true) - } - - - - return ( - <> - <div className="flex items-center gap-2"> - <Button - variant="outline" - size="sm" - onClick={handleCreateCompany} - disabled={isPending} - > - <Plus className="mr-2 h-4 w-4" /> - 업체 추가 - </Button> - - <Button - variant="default" - size="sm" - onClick={handleSendInvitations} - disabled={isPending} - > - <Mail className="mr-2 h-4 w-4" /> - 초대 발송 - </Button> - - <Button - variant="secondary" - size="sm" - onClick={handleManageSelection} - disabled={isPending} - > - <CheckSquare className="mr-2 h-4 w-4" /> - 본입찰 선정 - </Button> - </div> - - <BiddingPreQuoteVendorCreateDialog - biddingId={biddingId} - open={isCreateDialogOpen} - onOpenChange={setIsCreateDialogOpen} - onSuccess={() => { - onSuccess() - setIsCreateDialogOpen(false) - }} - /> - - <BiddingPreQuoteInvitationDialog - open={isInvitationDialogOpen} - onOpenChange={setIsInvitationDialogOpen} - companies={biddingCompanies} - biddingId={biddingId} - biddingTitle={bidding.title} - projectName={bidding.projectName} - onSuccess={onSuccess} - /> - - <BiddingPreQuoteSelectionDialog - open={isSelectionDialogOpen} - onOpenChange={setIsSelectionDialogOpen} - selectedCompanies={table.getFilteredSelectedRowModel().rows.map(row => row.original)} - onSuccess={() => { - onSuccess() - table.resetRowSelection() - }} - /> - </> - ) -} |
