diff options
Diffstat (limited to 'lib/bidding/list/create-bidding-dialog.tsx')
| -rw-r--r-- | lib/bidding/list/create-bidding-dialog.tsx | 2148 |
1 files changed, 0 insertions, 2148 deletions
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx deleted file mode 100644 index 90abda57..00000000 --- a/lib/bidding/list/create-bidding-dialog.tsx +++ /dev/null @@ -1,2148 +0,0 @@ -'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 { 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<TabType>('basic') - const [showSuccessDialog, setShowSuccessDialog] = React.useState(false) - const [createdBiddingId, setCreatedBiddingId] = React.useState<number | null>(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<SpecificationMeetingInfo>({ - meetingDate: '', - meetingTime: '', - location: '', - address: '', - contactPerson: '', - contactPhone: '', - contactEmail: '', - agenda: '', - materials: '', - notes: '', - isRequired: false, - meetingFiles: [], - }) - - const [prItems, setPrItems] = React.useState<PRItemInfo[]>([ - { - 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: '1', - purchaseUnit: 'EA', - materialWeight: '', - wbsCode: '', - wbsName: '', - costCenterCode: '', - costCenterName: '', - glAccountCode: '', - glAccountName: '', - targetUnitPrice: '', - targetAmount: '', - targetCurrency: 'KRW', - budgetAmount: '', - budgetCurrency: 'KRW', - actualAmount: '', - actualCurrency: 'KRW', - }, - ]) - const [selectedItemForFile, setSelectedItemForFile] = React.useState<string | null>(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<CreateBiddingSchema>({ - 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: '1', - purchaseUnit: 'EA', - 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<PRItemInfo>) => { - setPrItems((prev) => - prev.map((item) => { - if (item.id === id) { - const updatedItem = { ...item, ...updates } - // 내정단가, 수량, 중량, 가격단위가 변경되면 내정금액 재계산 - if (updates.targetUnitPrice || updates.quantity || updates.totalWeight || updates.priceUnit) { - 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 priceUnit = parseFloat(item.priceUnit) || 1 // 기본값 1 - let amount = 0 - - if (quantityWeightMode === 'quantity') { - const quantity = parseFloat(item.quantity) || 0 - // (수량 / 가격단위) * 내정단가 - amount = (quantity / priceUnit) * unitPrice - } else { - const weight = parseFloat(item.totalWeight) || 0 - // (중량 / 가격단위) * 내정단가 - amount = (weight / priceUnit) * 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 ( - <div className="border rounded-lg overflow-hidden"> - <div className="overflow-x-auto"> - <table className="w-full border-collapse"> - <thead className="bg-muted/50"> - <tr> - <th className="sticky left-0 z-10 bg-muted/50 border-r px-2 py-3 text-left text-xs font-medium min-w-[50px]"> - <span className="sr-only">대표</span> - </th> - <th className="sticky left-[50px] z-10 bg-muted/50 border-r px-3 py-3 text-left text-xs font-medium min-w-[40px]"> - # - </th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">프로젝트코드</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">프로젝트명</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재그룹코드 <span className="text-red-500">*</span></th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재그룹명 <span className="text-red-500">*</span></th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재코드</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재명</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">수량</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">단위</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">가격단위</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">구매단위</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정단가</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정금액</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">내정통화</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">예산금액</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">예산통화</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">실적금액</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">실적통화</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">WBS코드</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">WBS명</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">코스트센터코드</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">코스트센터명</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">GL계정코드</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">GL계정명</th> - <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">납품요청일</th> - <th className="sticky right-0 z-10 bg-muted/50 border-l px-3 py-3 text-center text-xs font-medium min-w-[100px]"> - 액션 - </th> - </tr> - </thead> - <tbody> - {prItems.map((item, index) => ( - <tr key={item.id} className="border-t hover:bg-muted/30"> - <td className="sticky left-0 z-10 bg-background border-r px-2 py-2 text-center"> - <Checkbox - checked={item.isRepresentative} - onCheckedChange={() => setRepresentativeItem(item.id)} - disabled={prItems.length <= 1 && item.isRepresentative} - title="대표 아이템" - /> - </td> - <td className="sticky left-[50px] z-10 bg-background border-r px-3 py-2 text-xs text-muted-foreground"> - {index + 1} - </td> - <td className="border-r px-3 py-2"> - <ProjectSelector - selectedProjectId={item.projectId || null} - onProjectSelect={(project) => { - updatePRItem(item.id, { - projectId: project.id, - projectInfo: project.projectName - }) - }} - placeholder="프로젝트 선택" - /> - </td> - <td className="border-r px-3 py-2"> - <Input - placeholder="프로젝트명" - value={item.projectInfo || ''} - readOnly - className="h-8 text-xs bg-muted/50" - /> - </td> - <td className="border-r px-3 py-2"> - <MaterialGroupSelectorDialogSingle - triggerLabel={item.materialGroupNumber || "자재그룹 선택"} - triggerVariant="outline" - selectedMaterial={item.materialGroupNumber ? { - materialGroupCode: item.materialGroupNumber, - materialGroupDescription: item.materialGroupInfo, - displayText: `${item.materialGroupNumber} - ${item.materialGroupInfo}` - } : null} - onMaterialSelect={(material) => { - if (material) { - updatePRItem(item.id, { - materialGroupNumber: material.materialGroupCode, - materialGroupInfo: material.materialGroupDescription - }) - } else { - updatePRItem(item.id, { - materialGroupNumber: '', - materialGroupInfo: '' - }) - } - }} - title="자재그룹 선택" - description="자재그룹을 검색하고 선택해주세요." - /> - </td> - <td className="border-r px-3 py-2"> - <Input - placeholder="자재그룹명" - value={item.materialGroupInfo} - readOnly - className="h-8 text-xs bg-muted/50" - /> - </td> - <td className="border-r px-3 py-2"> - <MaterialSelectorDialogSingle - triggerLabel={item.materialNumber || "자재 선택"} - triggerVariant="outline" - selectedMaterial={item.materialNumber ? { - materialCode: item.materialNumber, - materialName: item.materialInfo, - displayText: `${item.materialNumber} - ${item.materialInfo}` - } : null} - onMaterialSelect={(material) => { - if (material) { - updatePRItem(item.id, { - materialNumber: material.materialCode, - materialInfo: material.materialName - }) - } else { - updatePRItem(item.id, { - materialNumber: '', - materialInfo: '' - }) - } - }} - title="자재 선택" - description="자재를 검색하고 선택해주세요." - /> - </td> - <td className="border-r px-3 py-2"> - <Input - placeholder="자재명" - value={item.materialInfo} - readOnly - className="h-8 text-xs bg-muted/50" - /> - </td> - <td className="border-r px-3 py-2"> - {quantityWeightMode === 'quantity' ? ( - <Input - type="number" - min="0" - placeholder="수량" - value={item.quantity} - onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })} - className="h-8 text-xs" - /> - ) : ( - <Input - type="number" - min="0" - placeholder="중량" - value={item.totalWeight} - onChange={(e) => updatePRItem(item.id, { totalWeight: e.target.value })} - className="h-8 text-xs" - /> - )} - </td> - <td className="border-r px-3 py-2"> - {quantityWeightMode === 'quantity' ? ( - <Select - value={item.quantityUnit} - onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })} - > - <SelectTrigger className="h-8 text-xs"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="EA">EA</SelectItem> - <SelectItem value="SET">SET</SelectItem> - <SelectItem value="LOT">LOT</SelectItem> - <SelectItem value="M">M</SelectItem> - <SelectItem value="M2">M²</SelectItem> - <SelectItem value="M3">M³</SelectItem> - </SelectContent> - </Select> - ) : ( - <Select - value={item.weightUnit} - onValueChange={(value) => updatePRItem(item.id, { weightUnit: value })} - > - <SelectTrigger className="h-8 text-xs"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="KG">KG</SelectItem> - <SelectItem value="TON">TON</SelectItem> - <SelectItem value="G">G</SelectItem> - <SelectItem value="LB">LB</SelectItem> - </SelectContent> - </Select> - )} - </td> - <td className="border-r px-3 py-2"> - <Input - type="number" - min="1" - step="1" - placeholder="가격단위" - value={item.priceUnit || ''} - onChange={(e) => updatePRItem(item.id, { priceUnit: e.target.value })} - className="h-8 text-xs" - /> - </td> - <td className="border-r px-3 py-2"> - {quantityWeightMode === 'quantity' ? ( - <Select - value={item.purchaseUnit || item.quantityUnit || 'EA'} - onValueChange={(value) => updatePRItem(item.id, { purchaseUnit: value })} - > - <SelectTrigger className="h-8 text-xs"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="EA">EA</SelectItem> - <SelectItem value="SET">SET</SelectItem> - <SelectItem value="LOT">LOT</SelectItem> - <SelectItem value="M">M</SelectItem> - <SelectItem value="M2">M²</SelectItem> - <SelectItem value="M3">M³</SelectItem> - </SelectContent> - </Select> - ) : ( - <Select - value={item.purchaseUnit || item.weightUnit || 'KG'} - onValueChange={(value) => updatePRItem(item.id, { purchaseUnit: value })} - > - <SelectTrigger className="h-8 text-xs"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="KG">KG</SelectItem> - <SelectItem value="TON">TON</SelectItem> - <SelectItem value="G">G</SelectItem> - <SelectItem value="LB">LB</SelectItem> - </SelectContent> - </Select> - )} - </td> - <td className="border-r px-3 py-2"> - <Input - type="number" - min="0" - step="1" - placeholder="내정단가" - value={item.targetUnitPrice || ''} - onChange={(e) => updatePRItem(item.id, { targetUnitPrice: e.target.value })} - className="h-8 text-xs" - /> - </td> - <td className="border-r px-3 py-2"> - <Input - type="number" - min="0" - step="1" - placeholder="내정금액" - readOnly - value={item.targetAmount || ''} - className="h-8 text-xs bg-muted/50" - /> - </td> - <td className="border-r px-3 py-2"> - <Select - value={item.targetCurrency} - onValueChange={(value) => updatePRItem(item.id, { targetCurrency: value })} - > - <SelectTrigger className="h-8 text-xs"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="KRW">KRW</SelectItem> - <SelectItem value="USD">USD</SelectItem> - <SelectItem value="EUR">EUR</SelectItem> - <SelectItem value="JPY">JPY</SelectItem> - </SelectContent> - </Select> - </td> - <td className="border-r px-3 py-2"> - <Input - type="number" - min="0" - step="1" - placeholder="예산금액" - value={item.budgetAmount || ''} - onChange={(e) => updatePRItem(item.id, { budgetAmount: e.target.value })} - className="h-8 text-xs" - /> - </td> - <td className="border-r px-3 py-2"> - <Select - value={item.budgetCurrency} - onValueChange={(value) => updatePRItem(item.id, { budgetCurrency: value })} - > - <SelectTrigger className="h-8 text-xs"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="KRW">KRW</SelectItem> - <SelectItem value="USD">USD</SelectItem> - <SelectItem value="EUR">EUR</SelectItem> - <SelectItem value="JPY">JPY</SelectItem> - </SelectContent> - </Select> - </td> - <td className="border-r px-3 py-2"> - <Input - type="number" - min="0" - step="1" - placeholder="실적금액" - value={item.actualAmount || ''} - onChange={(e) => updatePRItem(item.id, { actualAmount: e.target.value })} - className="h-8 text-xs" - /> - </td> - <td className="border-r px-3 py-2"> - <Select - value={item.actualCurrency} - onValueChange={(value) => updatePRItem(item.id, { actualCurrency: value })} - > - <SelectTrigger className="h-8 text-xs"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="KRW">KRW</SelectItem> - <SelectItem value="USD">USD</SelectItem> - <SelectItem value="EUR">EUR</SelectItem> - <SelectItem value="JPY">JPY</SelectItem> - </SelectContent> - </Select> - </td> - <td className="border-r px-3 py-2"> - <Button - variant="outline" - onClick={() => setWbsCodeDialogOpen(true)} - className="w-full justify-start h-8 text-xs" - > - {item.wbsCode ? ( - <span className="truncate"> - {`${item.wbsCode}${item.wbsName ? ` - ${item.wbsName}` : ''}`} - </span> - ) : ( - <span className="text-muted-foreground">WBS 코드 선택</span> - )} - </Button> - <WbsCodeSingleSelector - open={wbsCodeDialogOpen} - onOpenChange={setWbsCodeDialogOpen} - selectedCode={item.wbsCode ? { - PROJ_NO: '', - WBS_ELMT: item.wbsCode, - WBS_ELMT_NM: item.wbsName || '', - WBS_LVL: '' - } : undefined} - onCodeSelect={(wbsCode) => { - updatePRItem(item.id, { - wbsCode: wbsCode.WBS_ELMT, - wbsName: wbsCode.WBS_ELMT_NM - }) - setWbsCodeDialogOpen(false) - }} - title="WBS 코드 선택" - description="WBS 코드를 선택하세요" - showConfirmButtons={false} - /> - </td> - <td className="border-r px-3 py-2"> - <Input - placeholder="WBS명" - value={item.wbsName || ''} - readOnly - className="h-8 text-xs bg-muted/50" - /> - </td> - <td className="border-r px-3 py-2"> - <Button - variant="outline" - onClick={() => setCostCenterDialogOpen(true)} - className="w-full justify-start h-8 text-xs" - > - {item.costCenterCode ? ( - <span className="truncate"> - {`${item.costCenterCode}${item.costCenterName ? ` - ${item.costCenterName}` : ''}`} - </span> - ) : ( - <span className="text-muted-foreground">코스트센터 선택</span> - )} - </Button> - <CostCenterSingleSelector - open={costCenterDialogOpen} - onOpenChange={setCostCenterDialogOpen} - selectedCode={item.costCenterCode ? { - KOSTL: item.costCenterCode, - KTEXT: '', - LTEXT: item.costCenterName || '', - DATAB: '', - DATBI: '' - } : undefined} - onCodeSelect={(costCenter) => { - updatePRItem(item.id, { - costCenterCode: costCenter.KOSTL, - costCenterName: costCenter.LTEXT - }) - setCostCenterDialogOpen(false) - }} - title="코스트센터 선택" - description="코스트센터를 선택하세요" - showConfirmButtons={false} - /> - </td> - <td className="border-r px-3 py-2"> - <Input - placeholder="코스트센터명" - value={item.costCenterName || ''} - readOnly - className="h-8 text-xs bg-muted/50" - /> - </td> - <td className="border-r px-3 py-2"> - <Button - variant="outline" - onClick={() => setGlAccountDialogOpen(true)} - className="w-full justify-start h-8 text-xs" - > - {item.glAccountCode ? ( - <span className="truncate"> - {`${item.glAccountCode}${item.glAccountName ? ` - ${item.glAccountName}` : ''}`} - </span> - ) : ( - <span className="text-muted-foreground">GL계정 선택</span> - )} - </Button> - <GlAccountSingleSelector - open={glAccountDialogOpen} - onOpenChange={setGlAccountDialogOpen} - selectedCode={item.glAccountCode ? { - SAKNR: item.glAccountCode, - FIPEX: '', - TEXT1: item.glAccountName || '' - } : undefined} - onCodeSelect={(glAccount) => { - updatePRItem(item.id, { - glAccountCode: glAccount.SAKNR, - glAccountName: glAccount.TEXT1 - }) - setGlAccountDialogOpen(false) - }} - title="GL 계정 선택" - description="GL 계정을 선택하세요" - showConfirmButtons={false} - /> - </td> - <td className="border-r px-3 py-2"> - <Input - placeholder="GL계정명" - value={item.glAccountName || ''} - readOnly - className="h-8 text-xs bg-muted/50" - /> - </td> - <td className="border-r px-3 py-2"> - <Input - type="date" - value={item.requestedDeliveryDate} - onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })} - className="h-8 text-xs" - /> - </td> - <td className="sticky right-0 z-10 bg-background border-l px-3 py-2"> - <div className="flex items-center justify-center gap-1"> - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => { - const fileInput = document.createElement('input') - fileInput.type = 'file' - fileInput.multiple = true - fileInput.accept = '.pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg' - fileInput.onchange = (e) => { - const files = Array.from((e.target as HTMLInputElement).files || []) - if (files.length > 0) { - addSpecFiles(item.id, files) - } - } - fileInput.click() - }} - className="h-7 w-7 p-0" - title="파일 첨부" - > - <Paperclip className="h-3.5 w-3.5" /> - {item.specFiles.length > 0 && ( - <span className="absolute -top-1 -right-1 bg-primary text-primary-foreground rounded-full w-4 h-4 text-[10px] flex items-center justify-center"> - {item.specFiles.length} - </span> - )} - </Button> - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => removePRItem(item.id)} - disabled={prItems.length <= 1} - className="h-7 w-7 p-0" - title="품목 삭제" - > - <Trash2 className="h-3.5 w-3.5" /> - </Button> - </div> - </td> - </tr> - ))} - </tbody> - </table> - </div> - - {/* 첨부된 파일 목록 표시 */} - {prItems.some(item => item.specFiles.length > 0) && ( - <div className="border-t p-4 bg-muted/20"> - <Label className="text-sm font-medium mb-2 block">첨부된 스펙 파일</Label> - <div className="space-y-3"> - {prItems.map((item, index) => ( - item.specFiles.length > 0 && ( - <div key={item.id} className="space-y-1"> - <div className="text-xs font-medium text-muted-foreground"> - {item.materialGroupInfo || item.materialGroupNumber || `ITEM-${index + 1}`} - </div> - <div className="flex flex-wrap gap-2"> - {item.specFiles.map((file, fileIndex) => ( - <div - key={fileIndex} - className="inline-flex items-center gap-1 px-2 py-1 bg-background border rounded text-xs" - > - <Paperclip className="h-3 w-3" /> - <span className="max-w-[200px] truncate">{file.name}</span> - <span className="text-muted-foreground"> - ({(file.size / 1024 / 1024).toFixed(2)} MB) - </span> - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => removeSpecFile(item.id, fileIndex)} - className="h-4 w-4 p-0 ml-1 hover:bg-destructive/20" - > - <X className="h-3 w-3" /> - </Button> - </div> - ))} - </div> - </div> - ) - ))} - </div> - </div> - )} - </div> - ) - } - - - return ( - <> - <Dialog open={open} onOpenChange={handleDialogOpenChange}> - <DialogTrigger asChild> - <Button variant="default" size="sm"> - <Plus className="mr-2 h-4 w-4" /> - 신규 입찰 - </Button> - </DialogTrigger> - <DialogContent className="h-[90vh] p-0 flex flex-col" style={{ maxWidth: '1400px' }}> - {/* 고정 헤더 */} - <div className="flex-shrink-0 p-6 border-b"> - <DialogHeader> - <DialogTitle>신규 입찰 생성</DialogTitle> - <DialogDescription> - 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요. - </DialogDescription> - </DialogHeader> - </div> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0" id="create-bidding-form"> - {/* 탭 영역 */} - <div className="flex-1 overflow-hidden"> - <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabType)} className="h-full flex flex-col"> - {/* 탭 리스트 */} - <div className="px-6"> - <div className="flex space-x-1 bg-muted p-1 rounded-lg overflow-x-auto"> - {TAB_ORDER.map((tab) => ( - <button - key={tab} - type="button" - onClick={() => setActiveTab(tab)} - className={cn( - 'relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0', - activeTab === tab ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground' - )} - > - {tab === 'basic' && '기본 정보'} - {tab === 'schedule' && '입찰 계획'} - {tab === 'details' && '세부 내역'} - {tab === 'manager' && '담당자'} - {!tabValidation[tab].isValid && ( - <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span> - )} - </button> - ))} - </div> - </div> - - {/* 탭 콘텐츠 */} - <div className="flex-1 overflow-y-auto p-6"> - <TabsContent value="basic" className="mt-0 space-y-6"> - <Card> - <CardHeader> - <CardTitle>기본 정보 및 계약 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-6"> - <FormField - control={form.control} - name="title" - render={({ field }) => ( - <FormItem> - <FormLabel>입찰명 <span className="text-red-500">*</span></FormLabel> - <FormControl> - <Input placeholder="입찰명을 입력하세요" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="description" - render={({ field }) => ( - <FormItem> - <FormLabel>입찰개요</FormLabel> - <FormControl> - <Textarea placeholder="입찰에 대한 설명을 입력하세요" rows={4} {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <div className="grid grid-cols-2 gap-6"> - <FormField - control={form.control} - name="contractType" - render={({ field }) => ( - <FormItem> - <FormLabel>계약구분 <span className="text-red-500">*</span></FormLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="계약구분 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {Object.entries(contractTypeLabels).map(([value, label]) => ( - <SelectItem key={value} value={value}> - {label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="biddingType" - render={({ field }) => ( - <FormItem> - <FormLabel>입찰유형 <span className="text-red-500">*</span></FormLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="입찰유형 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {Object.entries(biddingTypeLabels).map(([value, label]) => ( - <SelectItem key={value} value={value}> - {label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - {form.watch('biddingType') === 'other' && ( - <FormField - control={form.control} - name="biddingTypeCustom" - render={({ field }) => ( - <FormItem> - <FormLabel>기타 입찰유형 <span className="text-red-500">*</span></FormLabel> - <FormControl> - <Input placeholder="직접 입력하세요" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - )} - </div> - <div className="grid grid-cols-2 gap-6"> - <FormField - control={form.control} - name="awardCount" - render={({ field }) => ( - <FormItem> - <FormLabel>낙찰업체 수 <span className="text-red-500">*</span></FormLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="낙찰업체 수 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {Object.entries(awardCountLabels).map(([value, label]) => ( - <SelectItem key={value} value={value}> - {label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="currency" - render={({ field }) => ( - <FormItem> - <FormLabel>통화 <span className="text-red-500">*</span></FormLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="통화 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectItem value="KRW">KRW (원)</SelectItem> - <SelectItem value="USD">USD (달러)</SelectItem> - <SelectItem value="EUR">EUR (유로)</SelectItem> - <SelectItem value="JPY">JPY (엔)</SelectItem> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - </div> - <div className="grid grid-cols-2 gap-6"> - <FormField - control={form.control} - name="contractStartDate" - render={({ field }) => ( - <FormItem> - <FormLabel>계약시작일</FormLabel> - <FormControl> - <Input type="date" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="contractEndDate" - render={({ field }) => ( - <FormItem> - <FormLabel>계약종료일</FormLabel> - <FormControl> - <Input type="date" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - <FormField - control={form.control} - name="purchasingOrganization" - render={({ field }) => ( - <FormItem> - <FormLabel>구매조직</FormLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="구매조직 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectItem value="조선">조선</SelectItem> - <SelectItem value="해양">해양</SelectItem> - <SelectItem value="기타">기타</SelectItem> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - </CardContent> - </Card> - </TabsContent> - - <TabsContent value="schedule" className="mt-0 space-y-6"> - <Card> - <CardHeader> - <CardTitle>일정 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-6"> - <div className="grid grid-cols-2 gap-6"> - <FormField - control={form.control} - name="submissionStartDate" - render={({ field }) => ( - <FormItem> - <FormLabel>제출시작일시 <span className="text-red-500">*</span></FormLabel> - <FormControl> - <Input type="datetime-local" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="submissionEndDate" - render={({ field }) => ( - <FormItem> - <FormLabel>제출마감일시 <span className="text-red-500">*</span></FormLabel> - <FormControl> - <Input type="datetime-local" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - <FormField - control={form.control} - name="hasSpecificationMeeting" - render={({ field }) => ( - <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> - <div className="space-y-0.5"> - <FormLabel className="text-base">사양설명회 실시</FormLabel> - <FormDescription> - 사양설명회를 실시할 경우 상세 정보를 입력하세요 - </FormDescription> - </div> - <FormControl> - <Switch checked={field.value} onCheckedChange={field.onChange} /> - </FormControl> - </FormItem> - )} - /> - {form.watch('hasSpecificationMeeting') && ( - <div className="space-y-6 p-4 border rounded-lg bg-muted/50"> - <div className="grid grid-cols-2 gap-4"> - <div> - <Label>회의일시 <span className="text-red-500">*</span></Label> - <Input - type="datetime-local" - value={specMeetingInfo.meetingDate} - onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingDate: e.target.value }))} - className={!specMeetingInfo.meetingDate ? 'border-red-200' : ''} - /> - {!specMeetingInfo.meetingDate && ( - <p className="text-sm text-red-500 mt-1">회의일시는 필수입니다</p> - )} - </div> - <div> - <Label>회의시간</Label> - <Input - placeholder="예: 14:00 ~ 16:00" - value={specMeetingInfo.meetingTime} - onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingTime: e.target.value }))} - /> - </div> - </div> - <div> - <Label>장소 <span className="text-red-500">*</span></Label> - <Input - placeholder="회의 장소" - value={specMeetingInfo.location} - onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, location: e.target.value }))} - className={!specMeetingInfo.location ? 'border-red-200' : ''} - /> - {!specMeetingInfo.location && ( - <p className="text-sm text-red-500 mt-1">회의 장소는 필수입니다</p> - )} - </div> - <div className="grid grid-cols-3 gap-4"> - <div> - <Label>담당자 <span className="text-red-500">*</span></Label> - <Input - placeholder="담당자명" - value={specMeetingInfo.contactPerson} - onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPerson: e.target.value }))} - className={!specMeetingInfo.contactPerson ? 'border-red-200' : ''} - /> - {!specMeetingInfo.contactPerson && ( - <p className="text-sm text-red-500 mt-1">담당자는 필수입니다</p> - )} - </div> - <div> - <Label>연락처</Label> - <Input - placeholder="전화번호" - value={specMeetingInfo.contactPhone} - onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPhone: e.target.value }))} - /> - </div> - <div> - <Label>이메일</Label> - <Input - type="email" - placeholder="이메일" - value={specMeetingInfo.contactEmail} - onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactEmail: e.target.value }))} - /> - </div> - </div> - </div> - )} - </CardContent> - </Card> - - {/* 입찰 조건 섹션 */} - <Card> - <CardHeader> - <CardTitle>입찰 조건</CardTitle> - <p className="text-sm text-muted-foreground"> - 벤더가 사전견적 시 참고할 입찰 조건을 설정하세요 - </p> - </CardHeader> - <CardContent className="space-y-6"> - <div className="grid grid-cols-2 gap-6"> - <div className="space-y-2"> - <Label> - 지급조건 <span className="text-red-500">*</span> - </Label> - <Select - value={biddingConditions.paymentTerms} - onValueChange={(value) => setBiddingConditions(prev => ({ - ...prev, - paymentTerms: value - }))} - > - <SelectTrigger> - <SelectValue placeholder="지급조건 선택" /> - </SelectTrigger> - <SelectContent> - {paymentTermsOptions.length > 0 ? ( - paymentTermsOptions.map((option) => ( - <SelectItem key={option.code} value={option.code}> - {option.code} {option.description && `(${option.description})`} - </SelectItem> - )) - ) : ( - <SelectItem value="loading" disabled> - 데이터를 불러오는 중... - </SelectItem> - )} - </SelectContent> - </Select> - </div> - - <div className="space-y-2"> - <Label> - 세금조건 <span className="text-red-500">*</span> - </Label> - <Select - value={biddingConditions.taxConditions} - onValueChange={(value) => setBiddingConditions(prev => ({ - ...prev, - taxConditions: value - }))} - > - <SelectTrigger> - <SelectValue placeholder="세금조건 선택" /> - </SelectTrigger> - <SelectContent> - {TAX_CONDITIONS.map((condition) => ( - <SelectItem key={condition.code} value={condition.code}> - {condition.name} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - - <div className="space-y-2"> - <Label> - 운송조건(인코텀즈) <span className="text-red-500">*</span> - </Label> - <Select - value={biddingConditions.incoterms} - onValueChange={(value) => setBiddingConditions(prev => ({ - ...prev, - incoterms: value - }))} - > - <SelectTrigger> - <SelectValue placeholder="인코텀즈 선택" /> - </SelectTrigger> - <SelectContent> - {incotermsOptions.length > 0 ? ( - incotermsOptions.map((option) => ( - <SelectItem key={option.code} value={option.code}> - {option.code} {option.description && `(${option.description})`} - </SelectItem> - )) - ) : ( - <SelectItem value="loading" disabled> - 데이터를 불러오는 중... - </SelectItem> - )} - </SelectContent> - </Select> - </div> - - <div className="space-y-2"> - <Label>인코텀즈 옵션 (선택사항)</Label> - <Input - placeholder="예: 현지 배송 포함, 특정 주소 배송 등" - value={biddingConditions.incotermsOption} - onChange={(e) => setBiddingConditions(prev => ({ - ...prev, - incotermsOption: e.target.value - }))} - /> - <p className="text-xs text-muted-foreground"> - 인코텀즈와 관련된 추가 조건이나 특이사항을 입력하세요 - </p> - </div> - - <div className="space-y-2"> - <Label> - 계약 납품일 <span className="text-red-500">*</span> - </Label> - <Input - type="date" - value={biddingConditions.contractDeliveryDate} - onChange={(e) => setBiddingConditions(prev => ({ - ...prev, - contractDeliveryDate: e.target.value - }))} - /> - </div> - - <div className="space-y-2"> - <Label>선적지 (선택사항)</Label> - <Select - value={biddingConditions.shippingPort} - onValueChange={(value) => setBiddingConditions(prev => ({ - ...prev, - shippingPort: value - }))} - > - <SelectTrigger> - <SelectValue placeholder="선적지 선택" /> - </SelectTrigger> - <SelectContent> - {shippingPlaces.length > 0 ? ( - shippingPlaces.map((place) => ( - <SelectItem key={place.code} value={place.code}> - {place.code} {place.description && `(${place.description})`} - </SelectItem> - )) - ) : ( - <SelectItem value="loading" disabled> - 데이터를 불러오는 중... - </SelectItem> - )} - </SelectContent> - </Select> - </div> - - <div className="space-y-2"> - <Label>하역지 (선택사항)</Label> - <Select - value={biddingConditions.destinationPort} - onValueChange={(value) => setBiddingConditions(prev => ({ - ...prev, - destinationPort: value - }))} - > - <SelectTrigger> - <SelectValue placeholder="하역지 선택" /> - </SelectTrigger> - <SelectContent> - {destinationPlaces.length > 0 ? ( - destinationPlaces.map((place) => ( - <SelectItem key={place.code} value={place.code}> - {place.code} {place.description && `(${place.description})`} - </SelectItem> - )) - ) : ( - <SelectItem value="loading" disabled> - 데이터를 불러오는 중... - </SelectItem> - )} - </SelectContent> - </Select> - </div> - </div> - - <div className="flex items-center space-x-2"> - <Switch - id="price-adjustment" - checked={biddingConditions.isPriceAdjustmentApplicable} - onCheckedChange={(checked) => setBiddingConditions(prev => ({ - ...prev, - isPriceAdjustmentApplicable: checked - }))} - /> - <Label htmlFor="price-adjustment"> - 연동제 적용 요건 문의 - </Label> - </div> - - <div className="space-y-2"> - <Label>스페어파트 옵션</Label> - <Textarea - placeholder="스페어파트 관련 옵션을 입력하세요" - value={biddingConditions.sparePartOptions} - onChange={(e) => setBiddingConditions(prev => ({ - ...prev, - sparePartOptions: e.target.value - }))} - rows={3} - /> - </div> - </CardContent> - </Card> - </TabsContent> - - <TabsContent value="details" className="mt-0 space-y-6"> - <Card> - <CardHeader className="flex flex-row items-center justify-between"> - <div> - <CardTitle>세부내역 관리</CardTitle> - <p className="text-sm text-muted-foreground mt-1"> - 최소 하나의 아이템이 필요하며, 자재그룹코드는 필수입니다 - </p> - <p className="text-xs text-amber-600 mt-1"> - 수량/단위 또는 중량/중량단위를 선택해서 입력하세요 - </p> - </div> - <Button - type="button" - variant="outline" - onClick={addPRItem} - className="flex items-center gap-2" - > - <Plus className="h-4 w-4" /> - 아이템 추가 - </Button> - </CardHeader> - <CardContent className="space-y-6"> - <div className="flex items-center space-x-4 p-4 bg-muted rounded-lg"> - <div className="text-sm font-medium">계산 기준:</div> - <div className="flex items-center space-x-2"> - <input - type="radio" - id="quantity-mode" - name="quantityWeightMode" - checked={quantityWeightMode === 'quantity'} - onChange={() => handleQuantityWeightModeChange('quantity')} - className="h-4 w-4" - /> - <label htmlFor="quantity-mode" className="text-sm">수량 기준</label> - </div> - <div className="flex items-center space-x-2"> - <input - type="radio" - id="weight-mode" - name="quantityWeightMode" - checked={quantityWeightMode === 'weight'} - onChange={() => handleQuantityWeightModeChange('weight')} - className="h-4 w-4" - /> - <label htmlFor="weight-mode" className="text-sm">중량 기준</label> - </div> - </div> - <div className="space-y-4"> - {prItems.length > 0 ? ( - renderPrItemsTable() - ) : ( - <div className="text-center py-12 border-2 border-dashed border-gray-300 rounded-lg"> - <FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" /> - <p className="text-gray-500 mb-2">아직 아이템이 없습니다</p> - <p className="text-sm text-gray-400 mb-4"> - PR 아이템이나 수기 아이템을 추가하여 입찰 세부내역을 작성하세요 - </p> - <Button - type="button" - variant="outline" - onClick={addPRItem} - className="flex items-center gap-2 mx-auto" - > - <Plus className="h-4 w-4" /> - 첫 번째 아이템 추가 - </Button> - </div> - )} - </div> - </CardContent> - </Card> - </TabsContent> - - <TabsContent value="manager" className="mt-0 space-y-6"> - <Card> - <CardHeader> - <CardTitle>담당자 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-6"> - <div className="grid grid-cols-2 gap-6"> - <div className="space-y-2"> - <Label>입찰담당자 <span className="text-red-500">*</span></Label> - <PurchaseGroupCodeSelector - onCodeSelect={(code) => { - form.setValue('managerName', code.DISPLAY_NAME || '') - }} - placeholder="입찰담당자 선택" - /> - </div> - <div className="space-y-2"> - <Label>조달담당자 <span className="text-red-500">*</span></Label> - <ProcurementManagerSelector - onManagerSelect={(manager) => { - form.setValue('managerEmail', manager.DISPLAY_NAME || '') - }} - placeholder="조달담당자 선택" - /> - </div> - </div> - </CardContent> - </Card> - <Card> - <CardHeader> - <CardTitle>기타 설정</CardTitle> - </CardHeader> - <CardContent className="space-y-6"> - <FormField - control={form.control} - name="isPublic" - render={({ field }) => ( - <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> - <div className="space-y-0.5"> - <FormLabel className="text-base">공개 입찰</FormLabel> - <FormDescription> - 공개 입찰 여부를 설정합니다 - </FormDescription> - </div> - <FormControl> - <Switch checked={field.value} onCheckedChange={field.onChange} /> - </FormControl> - </FormItem> - )} - /> - <FormField - control={form.control} - name="remarks" - render={({ field }) => ( - <FormItem> - <FormLabel>비고</FormLabel> - <FormControl> - <Textarea placeholder="추가 메모나 특이사항을 입력하세요" rows={4} {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </CardContent> - </Card> - </TabsContent> - </div> - </Tabs> - </div> - - {/* 고정 버튼 영역 */} - <div className="flex-shrink-0 border-t bg-background p-6"> - <div className="flex justify-between items-center"> - <div className="text-sm text-muted-foreground"> - {activeTab === 'basic' && (<span>기본 정보를 입력하세요.</span>)} - {activeTab === 'schedule' && (<span>일정 및 사양설명회 정보를 입력하세요.</span>)} - {activeTab === 'details' && (<span>세부내역을 관리하세요.</span>)} - {activeTab === 'manager' && (<span>담당자 정보를 확인하고 입찰을 생성하세요.</span>)} - {!tabValidation[activeTab].isValid && ( - <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span> - )} - </div> - <div className="flex gap-3"> - <Button - type="button" - variant="outline" - onClick={() => setShowCloseConfirmDialog(true)} - disabled={isSubmitting} - > - 취소 - </Button> - {!isFirstTab && ( - <Button - type="button" - variant="outline" - onClick={goToPreviousTab} - disabled={isSubmitting} - className="flex items-center gap-2" - > - <ChevronLeft className="h-4 w-4" /> - 이전 - </Button> - )} - {isLastTab ? ( - <Button - type="button" - onClick={handleCreateBidding} - disabled={isSubmitting || !isCurrentTabValid()} - className="flex items-center gap-2" - > - {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - 입찰 생성 - </Button> - ) : ( - <Button - type="button" - onClick={handleNextClick} - disabled={isSubmitting || !isCurrentTabValid()} - className="flex items-center gap-2" - > - 다음 - <ChevronRight className="h-4 w-4" /> - </Button> - )} - </div> - </div> - </div> - </form> - </Form> - </DialogContent> - </Dialog> - {/* 닫기 확인 다이얼로그 */} - <AlertDialog open={showCloseConfirmDialog} onOpenChange={setShowCloseConfirmDialog}> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>입찰 생성을 취소하시겠습니까?</AlertDialogTitle> - <AlertDialogDescription> - 현재 입력 중인 내용이 모두 삭제되며, 생성되지 않습니다. 정말로 취소하시겠습니까? - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel onClick={() => handleCloseConfirm(false)}> - 아니오 (계속 입력) - </AlertDialogCancel> - <AlertDialogAction onClick={() => handleCloseConfirm(true)}> - 예 (취소) - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - {/* 성공 다이얼로그 */} - <AlertDialog open={showSuccessDialog} onOpenChange={setShowSuccessDialog}> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>입찰이 성공적으로 생성되었습니다</AlertDialogTitle> - <AlertDialogDescription> - 생성된 입찰의 상세페이지로 이동하시겠습니까? 아니면 현재 페이지에 남아있으시겠습니까? - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel onClick={handleStayOnPage}>현재 페이지에 남기</AlertDialogCancel> - <AlertDialogAction onClick={handleNavigateToDetail}>상세페이지로 이동</AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - </> - ) -}
\ No newline at end of file |
