'use client' import * as React from 'react' import { useRouter } from 'next/navigation' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { Loader2, Plus, Trash2, FileText, Paperclip, ChevronRight, ChevronLeft, X, } from 'lucide-react' import { toast } from 'sonner' import { useSession } from 'next-auth/react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogTrigger, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Switch } from '@/components/ui/switch' import { Label } from '@/components/ui/label' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Tabs, TabsContent } from '@/components/ui/tabs' import { Checkbox } from '@/components/ui/checkbox' import { createBidding } from '@/lib/bidding/service' import { getIncotermsForSelection, getPaymentTermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection, } from '@/lib/procurement-select/service' import { TAX_CONDITIONS } from '@/lib/tax-conditions/types' import { createBiddingSchema, type CreateBiddingSchema } from '@/lib/bidding/validation' import { contractTypeLabels, biddingTypeLabels, awardCountLabels } from '@/db/schema' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog' import { cn } from '@/lib/utils' import { MaterialGroupSingleSelector } from '@/components/common/material/material-group-single-selector' import { MaterialSingleSelector } from '@/components/common/selectors/material/material-single-selector' import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchase-group-code/purchase-group-code-selector' import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager/procurement-manager-selector' import { WbsCodeSingleSelector } from '@/components/common/selectors/wbs-code/wbs-code-single-selector' import { CostCenterSingleSelector } from '@/components/common/selectors/cost-center/cost-center-single-selector' import { GlAccountSingleSelector } from '@/components/common/selectors/gl-account/gl-account-single-selector' import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single' import { MaterialSelectorDialogSingle } from '@/components/common/selectors/material/material-selector-dialog-single' import { ProjectSelector } from '@/components/ProjectSelector' // 사양설명회 정보 타입 interface SpecificationMeetingInfo { meetingDate: string meetingTime: string location: string address: string contactPerson: string contactPhone: string contactEmail: string agenda: string materials: string notes: string isRequired: boolean meetingFiles: File[] // 사양설명회 첨부파일 } // PR 아이템 정보 타입 interface PRItemInfo { id: string // 임시 ID for UI prNumber: string projectId?: number // 프로젝트 ID 추가 projectInfo?: string // 프로젝트 정보 (기존 호환성 유지) shi?: string // SHI 정보 추가 quantity: string quantityUnit: string totalWeight: string weightUnit: string materialDescription: string hasSpecDocument: boolean requestedDeliveryDate: string specFiles: File[] isRepresentative: boolean // 대표 아이템 여부 // 가격 정보 annualUnitPrice: string currency: string // 자재 그룹 정보 (필수) materialGroupNumber: string // 자재그룹코드 - 필수 materialGroupInfo: string // 자재그룹명 - 필수 // 자재 정보 materialNumber: string // 자재코드 materialInfo: string // 자재명 // 단위 정보 priceUnit: string // 가격단위 purchaseUnit: string // 구매단위 materialWeight: string // 자재순중량 // WBS 정보 wbsCode: string // WBS 코드 wbsName: string // WBS 명칭 // Cost Center 정보 costCenterCode: string // 코스트센터 코드 costCenterName: string // 코스트센터 명칭 // GL Account 정보 glAccountCode: string // GL 계정 코드 glAccountName: string // GL 계정 명칭 // 내정 정보 targetUnitPrice: string targetAmount: string targetCurrency: string // 예산 정보 budgetAmount: string budgetCurrency: string // 실적 정보 actualAmount: string actualCurrency: string } const TAB_ORDER = ['basic', 'schedule', 'details', 'manager'] as const type TabType = typeof TAB_ORDER[number] export function CreateBiddingDialog() { const router = useRouter() const [isSubmitting, setIsSubmitting] = React.useState(false) const { data: session } = useSession() const [open, setOpen] = React.useState(false) const [activeTab, setActiveTab] = React.useState('basic') const [showSuccessDialog, setShowSuccessDialog] = React.useState(false) const [createdBiddingId, setCreatedBiddingId] = React.useState(null) const [showCloseConfirmDialog, setShowCloseConfirmDialog] = React.useState(false) const [paymentTermsOptions, setPaymentTermsOptions] = React.useState< Array<{ code: string; description: string }> >([]) const [incotermsOptions, setIncotermsOptions] = React.useState< Array<{ code: string; description: string }> >([]) const [shippingPlaces, setShippingPlaces] = React.useState< Array<{ code: string; description: string }> >([]) const [destinationPlaces, setDestinationPlaces] = React.useState< Array<{ code: string; description: string }> >([]) const [specMeetingInfo, setSpecMeetingInfo] = React.useState({ meetingDate: '', meetingTime: '', location: '', address: '', contactPerson: '', contactPhone: '', contactEmail: '', agenda: '', materials: '', notes: '', isRequired: false, meetingFiles: [], }) const [prItems, setPrItems] = React.useState([ { id: `pr-default`, prNumber: '', projectId: undefined, projectInfo: '', shi: '', quantity: '', quantityUnit: 'EA', totalWeight: '', weightUnit: 'KG', materialDescription: '', hasSpecDocument: false, requestedDeliveryDate: '', specFiles: [], isRepresentative: true, annualUnitPrice: '', currency: 'KRW', materialGroupNumber: '', materialGroupInfo: '', materialNumber: '', materialInfo: '', priceUnit: '', purchaseUnit: '1', materialWeight: '', wbsCode: '', wbsName: '', costCenterCode: '', costCenterName: '', glAccountCode: '', glAccountName: '', targetUnitPrice: '', targetAmount: '', targetCurrency: 'KRW', budgetAmount: '', budgetCurrency: 'KRW', actualAmount: '', actualCurrency: 'KRW', }, ]) const [selectedItemForFile, setSelectedItemForFile] = React.useState(null) const [quantityWeightMode, setQuantityWeightMode] = React.useState<'quantity' | 'weight'>('quantity') const [costCenterDialogOpen, setCostCenterDialogOpen] = React.useState(false) const [glAccountDialogOpen, setGlAccountDialogOpen] = React.useState(false) const [wbsCodeDialogOpen, setWbsCodeDialogOpen] = React.useState(false) const [materialGroupDialogOpen, setMaterialGroupDialogOpen] = React.useState(false) const [materialDialogOpen, setMaterialDialogOpen] = React.useState(false) const [biddingConditions, setBiddingConditions] = React.useState({ paymentTerms: '', taxConditions: '', incoterms: '', incotermsOption: '', contractDeliveryDate: '', shippingPort: '', destinationPort: '', isPriceAdjustmentApplicable: false, sparePartOptions: '', }) // -- 데이터 로딩 및 상태 동기화 로직 const loadPaymentTerms = React.useCallback(async () => { try { const data = await getPaymentTermsForSelection() setPaymentTermsOptions(data) const p008Exists = data.some((item) => item.code === 'P008') if (p008Exists) { setBiddingConditions((prev) => ({ ...prev, paymentTerms: 'P008' })) } } catch (error) { console.error('Failed to load payment terms:', error) toast.error('결제조건 목록을 불러오는데 실패했습니다.') } }, []) const loadIncoterms = React.useCallback(async () => { try { const data = await getIncotermsForSelection() setIncotermsOptions(data) const dapExists = data.some((item) => item.code === 'DAP') if (dapExists) { setBiddingConditions((prev) => ({ ...prev, incoterms: 'DAP' })) } } catch (error) { console.error('Failed to load incoterms:', error) toast.error('운송조건 목록을 불러오는데 실패했습니다.') } }, []) const loadShippingPlaces = React.useCallback(async () => { try { const data = await getPlaceOfShippingForSelection() setShippingPlaces(data) } catch (error) { console.error('Failed to load shipping places:', error) toast.error('선적지 목록을 불러오는데 실패했습니다.') } }, []) const loadDestinationPlaces = React.useCallback(async () => { try { const data = await getPlaceOfDestinationForSelection() setDestinationPlaces(data) } catch (error) { console.error('Failed to load destination places:', error) toast.error('하역지 목록을 불러오는데 실패했습니다.') } }, []) React.useEffect(() => { if (open) { loadPaymentTerms() loadIncoterms() loadShippingPlaces() loadDestinationPlaces() const v1Exists = TAX_CONDITIONS.some((item) => item.code === 'V1') if (v1Exists) { setBiddingConditions((prev) => ({ ...prev, taxConditions: 'V1' })) } } }, [open, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces]) const hasPrDocuments = React.useMemo(() => { return prItems.some((item) => item.prNumber.trim() !== '' || item.specFiles.length > 0) }, [prItems]) const form = useForm({ resolver: zodResolver(createBiddingSchema), defaultValues: { revision: 0, projectId: 0, projectName: '', itemName: '', title: '', description: '', content: '', contractType: 'general', biddingType: 'equipment', biddingTypeCustom: '', awardCount: 'single', contractStartDate: '', contractEndDate: '', submissionStartDate: '', submissionEndDate: '', hasSpecificationMeeting: false, prNumber: '', currency: 'KRW', status: 'bidding_generated', isPublic: false, purchasingOrganization: '', managerName: '', managerEmail: '', managerPhone: '', remarks: '', }, }) const currentTabIndex = TAB_ORDER.indexOf(activeTab) const isLastTab = currentTabIndex === TAB_ORDER.length - 1 const isFirstTab = currentTabIndex === 0 const goToNextTab = () => { if (!isLastTab) { setActiveTab(TAB_ORDER[currentTabIndex + 1]) } } const goToPreviousTab = () => { if (!isFirstTab) { setActiveTab(TAB_ORDER[currentTabIndex - 1]) } } const getTabValidationState = React.useCallback(() => { const formValues = form.getValues() const formErrors = form.formState.errors return { basic: { isValid: formValues.title.trim() !== '', hasErrors: !!formErrors.title, }, schedule: { isValid: formValues.submissionStartDate && formValues.submissionEndDate && (!formValues.hasSpecificationMeeting || (specMeetingInfo.meetingDate && specMeetingInfo.location && specMeetingInfo.contactPerson)), hasErrors: !!(formErrors.submissionStartDate || formErrors.submissionEndDate), }, details: { // 임시로 자재그룹코드 필수 체크 해제 // isValid: prItems.length > 0 && prItems.every(item => item.materialGroupNumber.trim() !== ''), isValid: prItems.length > 0, hasErrors: false, }, manager: { // 임시로 담당자 필수 체크 해제 isValid: true, hasErrors: false, // !!(formErrors.managerName || formErrors.managerEmail || formErrors.managerPhone), }, } }, [form, specMeetingInfo.meetingDate, specMeetingInfo.location, specMeetingInfo.contactPerson, prItems]) const tabValidation = getTabValidationState() const isCurrentTabValid = () => { const validation = tabValidation[activeTab as keyof typeof tabValidation] return validation?.isValid ?? true } const representativePrNumber = React.useMemo(() => { const representativeItem = prItems.find((item) => item.isRepresentative) return representativeItem?.prNumber || '' }, [prItems]) const representativeItemName = React.useMemo(() => { const representativeItem = prItems.find((item) => item.isRepresentative) return representativeItem?.materialGroupInfo || '' }, [prItems]) React.useEffect(() => { form.setValue('hasPrDocument', hasPrDocuments) form.setValue('prNumber', representativePrNumber) form.setValue('itemName', representativeItemName) }, [hasPrDocuments, representativePrNumber, representativeItemName, form]) const addPRItem = () => { const newItem: PRItemInfo = { id: `pr-${Math.random().toString(36).substr(2, 9)}`, prNumber: '', projectId: undefined, projectInfo: '', shi: '', quantity: '', quantityUnit: 'EA', totalWeight: '', weightUnit: 'KG', materialDescription: '', hasSpecDocument: false, requestedDeliveryDate: '', specFiles: [], isRepresentative: prItems.length === 0, annualUnitPrice: '', currency: 'KRW', materialGroupNumber: '', materialGroupInfo: '', materialNumber: '', materialInfo: '', priceUnit: '', purchaseUnit: '1', materialWeight: '', wbsCode: '', wbsName: '', costCenterCode: '', costCenterName: '', glAccountCode: '', glAccountName: '', targetUnitPrice: '', targetAmount: '', targetCurrency: 'KRW', budgetAmount: '', budgetCurrency: 'KRW', actualAmount: '', actualCurrency: 'KRW', } setPrItems((prev) => [...prev, newItem]) } const removePRItem = (id: string) => { if (prItems.length <= 1) { toast.error('최소 하나의 품목이 필요합니다.') return } setPrItems((prev) => { const filteredItems = prev.filter((item) => item.id !== id) const removedItem = prev.find((item) => item.id === id) if (removedItem?.isRepresentative && filteredItems.length > 0) { filteredItems[0].isRepresentative = true } return filteredItems }) if (selectedItemForFile === id) { setSelectedItemForFile(null) } } const updatePRItem = (id: string, updates: Partial) => { setPrItems((prev) => prev.map((item) => { if (item.id === id) { const updatedItem = { ...item, ...updates } // 내정단가, 수량, 중량, 구매단위가 변경되면 내정금액 재계산 if (updates.targetUnitPrice || updates.quantity || updates.totalWeight || updates.purchaseUnit) { updatedItem.targetAmount = calculateTargetAmount(updatedItem) } return updatedItem } return item }) ) } const setRepresentativeItem = (id: string) => { setPrItems((prev) => prev.map((item) => ({ ...item, isRepresentative: item.id === id, })) ) } const handleQuantityWeightModeChange = (mode: 'quantity' | 'weight') => { setQuantityWeightMode(mode) } const calculateTargetAmount = (item: PRItemInfo) => { const unitPrice = parseFloat(item.targetUnitPrice) || 0 const purchaseUnit = parseFloat(item.purchaseUnit) || 1 // 기본값 1 let amount = 0 if (quantityWeightMode === 'quantity') { const quantity = parseFloat(item.quantity) || 0 // (수량 / 구매단위) * 내정단가 amount = (quantity / purchaseUnit) * unitPrice } else { const weight = parseFloat(item.totalWeight) || 0 // (중량 / 구매단위) * 내정단가 amount = (weight / purchaseUnit) * unitPrice } // 소수점 버림 return Math.floor(amount).toString() } const addSpecFiles = (itemId: string, files: File[]) => { updatePRItem(itemId, { specFiles: [...(prItems.find((item) => item.id === itemId)?.specFiles || []), ...files], }) setSelectedItemForFile(null) } const removeSpecFile = (itemId: string, fileIndex: number) => { const item = prItems.find((item) => item.id === itemId) if (item) { const newFiles = item.specFiles.filter((_, index) => index !== fileIndex) updatePRItem(itemId, { specFiles: newFiles }) } } const handleNextClick = () => { if (!isCurrentTabValid()) { if (activeTab === 'basic') { toast.error('기본 정보를 모두 입력해주세요.') } else if (activeTab === 'schedule') { if (form.watch('hasSpecificationMeeting')) { toast.error('사양설명회 필수 정보를 입력해주세요.') } else { toast.error('제출 시작일시와 마감일시를 입력해주세요.') } } else if (activeTab === 'details') { toast.error('최소 하나의 아이템이 필요하며, 모든 아이템에 자재그룹코드가 필수입니다.') } return } goToNextTab() } async function onSubmit(data: CreateBiddingSchema) { if (data.hasSpecificationMeeting) { const requiredFields = [ { field: specMeetingInfo.meetingDate, name: '회의일시' }, { field: specMeetingInfo.location, name: '회의 장소' }, { field: specMeetingInfo.contactPerson, name: '담당자' }, ] const missingFields = requiredFields.filter((item) => !item.field.trim()) if (missingFields.length > 0) { toast.error(`사양설명회 필수 정보가 누락되었습니다: ${missingFields.map((f) => f.name).join(', ')}`) setActiveTab('schedule') return } } setIsSubmitting(true) try { const userId = session?.user?.id?.toString() || '1' const extendedData = { ...data, hasPrDocument: hasPrDocuments, prNumber: representativePrNumber, specificationMeeting: data.hasSpecificationMeeting ? { ...specMeetingInfo, meetingFiles: specMeetingInfo.meetingFiles, } : null, prItems: prItems.length > 0 ? prItems : [], biddingConditions: biddingConditions, } const result = await createBidding(extendedData, userId) if (result.success) { toast.success( (result as { success: true; message: string }).message || '입찰이 성공적으로 생성되었습니다.' ) setOpen(false) router.refresh() if (result.success && 'data' in result && result.data?.id) { setCreatedBiddingId(result.data.id) setShowSuccessDialog(true) } } else { const errorMessage = result.success === false && 'error' in result ? result.error : '입찰 생성에 실패했습니다.' toast.error(errorMessage) } } catch (error) { console.error('Error creating bidding:', error) toast.error('입찰 생성 중 오류가 발생했습니다.') } finally { setIsSubmitting(false) } } const resetAllStates = React.useCallback(() => { form.reset({ revision: 0, projectId: 0, projectName: '', itemName: '', title: '', description: '', content: '', contractType: 'general', biddingType: 'equipment', biddingTypeCustom: '', awardCount: 'single', contractStartDate: '', contractEndDate: '', submissionStartDate: '', submissionEndDate: '', hasSpecificationMeeting: false, prNumber: '', currency: 'KRW', status: 'bidding_generated', isPublic: false, purchasingOrganization: '', managerName: '', managerEmail: '', managerPhone: '', remarks: '', }) setSpecMeetingInfo({ meetingDate: '', meetingTime: '', location: '', address: '', contactPerson: '', contactPhone: '', contactEmail: '', agenda: '', materials: '', notes: '', isRequired: false, meetingFiles: [], }) setPrItems([ { id: `pr-default`, prNumber: '', projectId: undefined, projectInfo: '', shi: '', quantity: '', quantityUnit: 'EA', totalWeight: '', weightUnit: 'KG', materialDescription: '', hasSpecDocument: false, requestedDeliveryDate: '', specFiles: [], isRepresentative: true, annualUnitPrice: '', currency: 'KRW', materialGroupNumber: '', materialGroupInfo: '', materialNumber: '', materialInfo: '', priceUnit: '', purchaseUnit: '', materialWeight: '', wbsCode: '', wbsName: '', costCenterCode: '', costCenterName: '', glAccountCode: '', glAccountName: '', targetUnitPrice: '', targetAmount: '', targetCurrency: 'KRW', budgetAmount: '', budgetCurrency: 'KRW', actualAmount: '', actualCurrency: 'KRW', }, ]) setSelectedItemForFile(null) setCostCenterDialogOpen(false) setGlAccountDialogOpen(false) setWbsCodeDialogOpen(false) setMaterialGroupDialogOpen(false) setMaterialDialogOpen(false) setBiddingConditions({ paymentTerms: '', taxConditions: '', incoterms: '', incotermsOption: '', contractDeliveryDate: '', shippingPort: '', destinationPort: '', isPriceAdjustmentApplicable: false, sparePartOptions: '', }) setActiveTab('basic') setShowSuccessDialog(false) setCreatedBiddingId(null) }, [form]) function handleDialogOpenChange(nextOpen: boolean) { if (!nextOpen) { setShowCloseConfirmDialog(true) } else { setOpen(nextOpen) } } const handleCloseConfirm = (confirmed: boolean) => { setShowCloseConfirmDialog(false) if (confirmed) { resetAllStates() setOpen(false) } } const handleCreateBidding = () => { if (!isCurrentTabValid()) { toast.error('필수 정보를 모두 입력해주세요.') return } form.handleSubmit(onSubmit)() } const handleNavigateToDetail = () => { if (createdBiddingId) { router.push(`/evcp/bid/${createdBiddingId}`) } setShowSuccessDialog(false) setCreatedBiddingId(null) } const handleStayOnPage = () => { setShowSuccessDialog(false) setCreatedBiddingId(null) } // PR 아이템 테이블 렌더링 const renderPrItemsTable = () => { return (
{prItems.map((item, index) => ( ))}
대표 # 프로젝트코드 프로젝트명 자재그룹코드 * 자재그룹명 * 자재코드 자재명 수량 단위 구매단위 내정단가 내정금액 내정통화 예산금액 예산통화 실적금액 실적통화 WBS코드 WBS명 코스트센터코드 코스트센터명 GL계정코드 GL계정명 납품요청일 액션
setRepresentativeItem(item.id)} disabled={prItems.length <= 1 && item.isRepresentative} title="대표 아이템" /> {index + 1} { updatePRItem(item.id, { projectId: project.id, projectInfo: project.projectName }) }} placeholder="프로젝트 선택" /> { if (material) { updatePRItem(item.id, { materialGroupNumber: material.materialGroupCode, materialGroupInfo: material.materialGroupDescription }) } else { updatePRItem(item.id, { materialGroupNumber: '', materialGroupInfo: '' }) } }} title="자재그룹 선택" description="자재그룹을 검색하고 선택해주세요." /> { if (material) { updatePRItem(item.id, { materialNumber: material.materialCode, materialInfo: material.materialName }) } else { updatePRItem(item.id, { materialNumber: '', materialInfo: '' }) } }} title="자재 선택" description="자재를 검색하고 선택해주세요." /> {quantityWeightMode === 'quantity' ? ( updatePRItem(item.id, { quantity: e.target.value })} className="h-8 text-xs" /> ) : ( updatePRItem(item.id, { totalWeight: e.target.value })} className="h-8 text-xs" /> )} {quantityWeightMode === 'quantity' ? ( ) : ( )} updatePRItem(item.id, { purchaseUnit: e.target.value })} className="h-8 text-xs" /> updatePRItem(item.id, { targetUnitPrice: e.target.value })} className="h-8 text-xs" /> updatePRItem(item.id, { budgetAmount: e.target.value })} className="h-8 text-xs" /> updatePRItem(item.id, { actualAmount: e.target.value })} className="h-8 text-xs" /> { updatePRItem(item.id, { wbsCode: wbsCode.WBS_ELMT, wbsName: wbsCode.WBS_ELMT_NM }) setWbsCodeDialogOpen(false) }} title="WBS 코드 선택" description="WBS 코드를 선택하세요" showConfirmButtons={false} /> { updatePRItem(item.id, { costCenterCode: costCenter.KOSTL, costCenterName: costCenter.LTEXT }) setCostCenterDialogOpen(false) }} title="코스트센터 선택" description="코스트센터를 선택하세요" showConfirmButtons={false} /> { updatePRItem(item.id, { glAccountCode: glAccount.SAKNR, glAccountName: glAccount.TEXT1 }) setGlAccountDialogOpen(false) }} title="GL 계정 선택" description="GL 계정을 선택하세요" showConfirmButtons={false} /> updatePRItem(item.id, { requestedDeliveryDate: e.target.value })} className="h-8 text-xs" />
{/* 첨부된 파일 목록 표시 */} {prItems.some(item => item.specFiles.length > 0) && (
{prItems.map((item, index) => ( item.specFiles.length > 0 && (
{item.materialGroupInfo || item.materialGroupNumber || `ITEM-${index + 1}`}
{item.specFiles.map((file, fileIndex) => (
{file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)
))}
) ))}
)}
) } return ( <> {/* 고정 헤더 */}
신규 입찰 생성 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요.
{/* 탭 영역 */}
setActiveTab(value as TabType)} className="h-full flex flex-col"> {/* 탭 리스트 */}
{TAB_ORDER.map((tab) => ( ))}
{/* 탭 콘텐츠 */}
기본 정보 및 계약 정보 ( 입찰명 * )} /> ( 입찰개요