diff options
| author | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2025-11-10 11:25:19 +0900 |
|---|---|---|
| committer | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2025-11-10 11:25:19 +0900 |
| commit | a5501ad1d1cb836d2b2f84e9b0f06049e22c901e (patch) | |
| tree | 667ed8c5d6ec35b109190e9f976d66ae54def4ce /lib/bidding/list/create-bidding-dialog.tsx | |
| parent | b0fe980376fcf1a19ff4b90851ca8b01f378fdc0 (diff) | |
| parent | f8a38907911d940cb2e8e6c9aa49488d05b2b578 (diff) | |
Merge remote-tracking branch 'origin/dujinkim' into master_homemaster
Diffstat (limited to 'lib/bidding/list/create-bidding-dialog.tsx')
| -rw-r--r-- | lib/bidding/list/create-bidding-dialog.tsx | 4230 |
1 files changed, 2051 insertions, 2179 deletions
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx index 50246f58..20ea740f 100644 --- a/lib/bidding/list/create-bidding-dialog.tsx +++ b/lib/bidding/list/create-bidding-dialog.tsx @@ -1,2242 +1,2114 @@ -"use client" +'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, CheckCircle2, ChevronRight, ChevronLeft } 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, - FormField, - FormItem, - FormLabel, - FormMessage, - FormDescription, -} 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import * as React from 'react' +import { useRouter } from 'next/navigation' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" + 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 { - Dropzone, - DropzoneDescription, - DropzoneInput, - DropzoneTitle, - DropzoneUploadIcon, - DropzoneZone, -} from "@/components/ui/dropzone" + Dialog, + DialogContent, + DialogTrigger, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { - FileList, - FileListAction, - FileListDescription, - FileListHeader, - FileListIcon, - FileListInfo, - FileListItem, - FileListName, - FileListSize, -} from "@/components/ui/file-list" -import { Checkbox } from "@/components/ui/checkbox" - -import { createBidding, type CreateBiddingInput } from "@/lib/bidding/service" -import { getIncotermsForSelection, getPaymentTermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection } from "@/lib/procurement-select/service" -import { TAX_CONDITIONS } from "@/lib/tax-conditions/types" + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' import { - createBiddingSchema, - type CreateBiddingSchema -} from "@/lib/bidding/validation" + 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 { - biddingStatusLabels, - contractTypeLabels, - biddingTypeLabels, - awardCountLabels -} from "@/db/schema" -import { ProjectSelector } from "@/components/ProjectSelector" -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog" + 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[] // 사양설명회 첨부파일 + 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 - itemCode: string // 기존 itemNumber에서 변경 - itemInfo: string - quantity: string - quantityUnit: string - totalWeight: string - weightUnit: string - materialDescription: string - hasSpecDocument: boolean - requestedDeliveryDate: string - specFiles: File[] - isRepresentative: boolean // 대표 아이템 여부 + 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 +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) // 닫기 확인 다이얼로그 상태 - - // Procurement 데이터 상태들 - 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 [procurementLoading, setProcurementLoading] = React.useState(false) - - // 사양설명회 정보 상태 - const [specMeetingInfo, setSpecMeetingInfo] = React.useState<SpecificationMeetingInfo>({ - meetingDate: "", - meetingTime: "", - location: "", - address: "", - contactPerson: "", - contactPhone: "", - contactEmail: "", - agenda: "", - materials: "", - notes: "", - isRequired: false, - meetingFiles: [], // 사양설명회 첨부파일 - }) - - // PR 아이템들 상태 - 기본적으로 하나의 빈 아이템 생성 - const [prItems, setPrItems] = React.useState<PRItemInfo[]>([ - { - id: `pr-default`, - prNumber: "", - itemCode: "", - itemInfo: "", - quantity: "", - quantityUnit: "EA", - totalWeight: "", - weightUnit: "KG", - materialDescription: "", - hasSpecDocument: false, - requestedDeliveryDate: "", - specFiles: [], - isRepresentative: true, // 첫 번째 아이템은 대표 아이템 - } - ]) - - // 파일 첨부를 위해 선택된 아이템 ID - const [selectedItemForFile, setSelectedItemForFile] = React.useState<string | null>(null) - - // 입찰 조건 상태 (기본값 설정 포함) - const [biddingConditions, setBiddingConditions] = React.useState({ - paymentTerms: "", // 초기값 빈값, 데이터 로드 후 설정 - taxConditions: "", // 초기값 빈값, 데이터 로드 후 설정 - incoterms: "", // 초기값 빈값, 데이터 로드 후 설정 - contractDeliveryDate: "", - shippingPort: "", - destinationPort: "", - isPriceAdjustmentApplicable: false, - sparePartOptions: "", + 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: [], }) - // Procurement 데이터 로드 함수들 - const loadPaymentTerms = React.useCallback(async () => { - setProcurementLoading(true); - try { - const data = await getPaymentTermsForSelection(); - setPaymentTermsOptions(data); - // 기본값 설정 로직: P008이 있으면 P008로, 없으면 첫 번째 항목으로 설정 - const setDefaultPaymentTerms = () => { - const p008Exists = data.some(item => item.code === "P008"); - if (p008Exists) { - setBiddingConditions(prev => ({ ...prev, paymentTerms: "P008" })); - } - }; - - setDefaultPaymentTerms(); - } catch (error) { - console.error("Failed to load payment terms:", error); - toast.error("결제조건 목록을 불러오는데 실패했습니다."); - // 에러 시 기본값 초기화 - if (biddingConditions.paymentTerms === "P008") { - setBiddingConditions(prev => ({ ...prev, paymentTerms: "" })); - } - } finally { - setProcurementLoading(false); - } - }, [biddingConditions.paymentTerms]); - - const loadIncoterms = React.useCallback(async () => { - setProcurementLoading(true); - try { - const data = await getIncotermsForSelection(); - setIncotermsOptions(data); - - // 기본값 설정 로직: DAP가 있으면 DAP로, 없으면 첫 번째 항목으로 설정 - const setDefaultIncoterms = () => { - const dapExists = data.some(item => item.code === "DAP"); - if (dapExists) { - setBiddingConditions(prev => ({ ...prev, incoterms: "DAP" })); - } - }; - - setDefaultIncoterms(); - } catch (error) { - console.error("Failed to load incoterms:", error); - toast.error("운송조건 목록을 불러오는데 실패했습니다."); - } finally { - setProcurementLoading(false); - } - }, [biddingConditions.incoterms]); - - const loadShippingPlaces = React.useCallback(async () => { - setProcurementLoading(true); - try { - const data = await getPlaceOfShippingForSelection(); - setShippingPlaces(data); - } catch (error) { - console.error("Failed to load shipping places:", error); - toast.error("선적지 목록을 불러오는데 실패했습니다."); - } finally { - setProcurementLoading(false); - } - }, []); - - const loadDestinationPlaces = React.useCallback(async () => { - setProcurementLoading(true); - try { - const data = await getPlaceOfDestinationForSelection(); - setDestinationPlaces(data); - } catch (error) { - console.error("Failed to load destination places:", error); - toast.error("하역지 목록을 불러오는데 실패했습니다."); - } finally { - setProcurementLoading(false); - } - }, []); - - // 다이얼로그 열릴 때 procurement 데이터 로드 및 기본값 설정 - React.useEffect(() => { - if (open) { - loadPaymentTerms(); - loadIncoterms(); - loadShippingPlaces(); - loadDestinationPlaces(); - - // 세금조건 기본값 설정 (V1이 있는지 확인하고 설정) - const v1Exists = TAX_CONDITIONS.some(item => item.code === "V1"); - if (v1Exists) { - setBiddingConditions(prev => ({ ...prev, taxConditions: "V1" })); - } - } - }, [open, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces]) - - - // 사양설명회 파일 추가 - const addMeetingFiles = (files: File[]) => { - setSpecMeetingInfo(prev => ({ - ...prev, - meetingFiles: [...prev.meetingFiles, ...files] - })) + 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: '', + purchaseUnit: '1', + 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 removeMeetingFile = (fileIndex: number) => { - setSpecMeetingInfo(prev => ({ - ...prev, - meetingFiles: prev.meetingFiles.filter((_, index) => index !== fileIndex) - })) + }, []) + + 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('운송조건 목록을 불러오는데 실패했습니다.') } - - // PR 문서 첨부 여부 자동 계산 - 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, // 임시 기본값, validation에서 체크 - projectName: "", - itemName: "", - title: "", - description: "", - content: "", - - contractType: "general", - biddingType: "equipment", - biddingTypeCustom: "", - awardCount: "single", - contractStartDate: "", - contractEndDate: "", - - submissionStartDate: "", - submissionEndDate: "", - - hasSpecificationMeeting: false, - prNumber: "", - - currency: "KRW", - budget: "", - targetPrice: "", - finalBidPrice: "", - - status: "bidding_generated", - isPublic: false, - 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 loadShippingPlaces = React.useCallback(async () => { + try { + const data = await getPlaceOfShippingForSelection() + setShippingPlaces(data) + } catch (error) { + console.error('Failed to load shipping places:', error) + toast.error('선적지 목록을 불러오는데 실패했습니다.') } - - const goToPreviousTab = () => { - if (!isFirstTab) { - setActiveTab(TAB_ORDER[currentTabIndex - 1]) - } + }, []) + + const loadDestinationPlaces = React.useCallback(async () => { + try { + const data = await getPlaceOfDestinationForSelection() + setDestinationPlaces(data) + } catch (error) { + console.error('Failed to load destination places:', error) + toast.error('하역지 목록을 불러오는데 실패했습니다.') } - - // 탭별 validation 상태 체크 - const getTabValidationState = React.useCallback(() => { - const formValues = form.getValues() - const formErrors = form.formState.errors - - return { - basic: { - isValid: formValues.title.trim() !== "", - hasErrors: !!(formErrors.title) - }, - contract: { - isValid: formValues.contractType && - formValues.biddingType && - formValues.awardCount && - formValues.contractStartDate && - formValues.contractEndDate && - formValues.currency, - hasErrors: !!(formErrors.contractType || formErrors.biddingType || formErrors.awardCount || formErrors.contractStartDate || formErrors.contractEndDate || formErrors.currency) - }, - schedule: { - isValid: formValues.submissionStartDate && - formValues.submissionEndDate && - (!formValues.hasSpecificationMeeting || - (specMeetingInfo.meetingDate && specMeetingInfo.location && specMeetingInfo.contactPerson)), - hasErrors: !!(formErrors.submissionStartDate || formErrors.submissionEndDate) - }, - conditions: { - isValid: biddingConditions.paymentTerms.trim() !== "" && - biddingConditions.taxConditions.trim() !== "" && - biddingConditions.incoterms.trim() !== "" && - biddingConditions.contractDeliveryDate.trim() !== "" && - biddingConditions.shippingPort.trim() !== "" && - biddingConditions.destinationPort.trim() !== "", - hasErrors: false - }, - details: { - isValid: prItems.length > 0, - hasErrors: false - }, - manager: { - isValid: true, // 담당자 정보는 자동 설정되므로 항상 유효 - hasErrors: !!(formErrors.managerName || formErrors.managerEmail || formErrors.managerPhone) - } - } - }, [form, specMeetingInfo.meetingDate, specMeetingInfo.location, specMeetingInfo.contactPerson, biddingConditions]) - - const tabValidation = getTabValidationState() - - // 현재 탭이 유효한지 확인 - const isCurrentTabValid = () => { - const validation = tabValidation[activeTab as keyof typeof tabValidation] - return validation?.isValid ?? true + }, []) + + React.useEffect(() => { + if (open) { + loadPaymentTerms() + loadIncoterms() + loadShippingPlaces() + loadDestinationPlaces() + const v1Exists = TAX_CONDITIONS.some((item) => item.code === 'V1') + if (v1Exists) { + setBiddingConditions((prev) => ({ ...prev, taxConditions: 'V1' })) + } } - - // 대표 PR 번호 자동 계산 - const representativePrNumber = React.useMemo(() => { - const representativeItem = prItems.find(item => item.isRepresentative) - return representativeItem?.prNumber || "" - }, [prItems]) - - // 대표 품목명 자동 계산 (첫 번째 PR 아이템의 itemInfo) - const representativeItemName = React.useMemo(() => { - const representativeItem = prItems.find(item => item.isRepresentative) - return representativeItem?.itemInfo || "" - }, [prItems]) - - // hasPrDocument 필드와 prNumber, itemName을 자동으로 업데이트 - React.useEffect(() => { - form.setValue("hasPrDocument", hasPrDocuments) - form.setValue("prNumber", representativePrNumber) - form.setValue("itemName", representativeItemName) - }, [hasPrDocuments, representativePrNumber, representativeItemName, form]) - - - - // 세션 정보로 담당자 정보 자동 채우기 - React.useEffect(() => { - if (session?.user) { - // 담당자명 설정 - if (session.user.name) { - form.setValue("managerName", session.user.name) - // 사양설명회 담당자도 동일하게 설정 - setSpecMeetingInfo(prev => ({ - ...prev, - contactPerson: session.user.name || "", - contactEmail: session.user.email || "", - })) - } - - // 담당자 이메일 설정 - if (session.user.email) { - form.setValue("managerEmail", session.user.email) - } - - // 담당자 전화번호는 세션에 있다면 설정 (보통 세션에 전화번호는 없지만, 있다면) - if ('phone' in session.user && session.user.phone) { - form.setValue("managerPhone", session.user.phone as string) - } - } - }, [session, form]) - - // PR 아이템 추가 - const addPRItem = () => { - const newItem: PRItemInfo = { - id: `pr-${Math.random().toString(36).substr(2, 9)}`, - prNumber: "", - itemCode: "", - itemInfo: "", - quantity: "", - quantityUnit: "EA", - totalWeight: "", - weightUnit: "KG", - materialDescription: "", - hasSpecDocument: false, - requestedDeliveryDate: "", - specFiles: [], - isRepresentative: prItems.length === 0, // 첫 번째 아이템은 자동으로 대표 아이템 - } - setPrItems(prev => [...prev, newItem]) + }, [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]) } + } - // PR 아이템 제거 - 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 goToPreviousTab = () => { + if (!isFirstTab) { + setActiveTab(TAB_ORDER[currentTabIndex - 1]) } - - // PR 아이템 업데이트 - const updatePRItem = (id: string, updates: Partial<PRItemInfo>) => { - setPrItems(prev => prev.map(item => - item.id === id ? { ...item, ...updates } : item - )) + } + + 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), + }, } - - // 대표 아이템 설정 (하나만 선택 가능) - const setRepresentativeItem = (id: string) => { - setPrItems(prev => prev.map(item => ({ - ...item, - isRepresentative: item.id === id - }))) + }, [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 addSpecFiles = (itemId: string, files: File[]) => { - updatePRItem(itemId, { - specFiles: [...(prItems.find(item => item.id === itemId)?.specFiles || []), ...files] - }) - // 파일 추가 후 선택 해제 - setSelectedItemForFile(null) + const removePRItem = (id: string) => { + if (prItems.length <= 1) { + toast.error('최소 하나의 품목이 필요합니다.') + return } - // 스펙 파일 제거 - 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 }) + 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.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 } - // ✅ 프로젝트 선택 핸들러 - const handleProjectSelect = React.useCallback((project: { id: number; code: string; name: string } | null) => { - if (project) { - form.setValue("projectId", project.id) - } else { - form.setValue("projectId", 0) - } - }, [form]) - - - // 다음 버튼 클릭 핸들러 - const handleNextClick = () => { - // 현재 탭 validation 체크 - if (!isCurrentTabValid()) { - // 특정 탭별 에러 메시지 - if (activeTab === "basic") { - toast.error("기본 정보를 모두 입력해주세요 (품목명, 입찰명)") - } else if (activeTab === "contract") { - toast.error("계약 정보를 모두 입력해주세요") - } else if (activeTab === "schedule") { - if (form.watch("hasSpecificationMeeting")) { - toast.error("사양설명회 필수 정보를 입력해주세요 (회의일시, 장소, 담당자)") - } else { - toast.error("제출 시작일시와 마감일시를 입력해주세요") - } - } else if (activeTab === "conditions") { - toast.error("입찰 조건을 모두 입력해주세요 (지급조건, 세금조건, 운송조건, 계약납품일)") - } else if (activeTab === "details") { - toast.error("품목정보, 수량/단위 또는 중량/중량단위를 입력해주세요") - } - return - } + // 소수점 버림 + return Math.floor(amount).toString() + } - goToNextTab() + 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 }) } - - // 폼 제출 - 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 - } + } + + 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 + } - setIsSubmitting(true) - try { - const userId = session?.user?.id?.toString() || "1" - - // 추가 데이터 준비 - const extendedData = { - ...data, - hasPrDocument: hasPrDocuments, // 자동 계산된 값 사용 - prNumber: representativePrNumber, // 대표 아이템의 PR 번호 사용 - 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) - } + 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 + } } - // 폼 및 상태 초기화 함수 - 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, - managerName: "", - managerEmail: "", - managerPhone: "", - remarks: "", - }) - - // 추가 상태들 초기화 - setSpecMeetingInfo({ - meetingDate: "", - meetingTime: "", - location: "", - address: "", - contactPerson: "", - contactPhone: "", - contactEmail: "", - agenda: "", - materials: "", - notes: "", - isRequired: false, - meetingFiles: [], - }) - setPrItems([ - { - id: `pr-default`, - prNumber: "", - itemCode: "", - itemInfo: "", - quantity: "", - quantityUnit: "EA", - totalWeight: "", - weightUnit: "KG", - materialDescription: "", - hasSpecDocument: false, - requestedDeliveryDate: "", - specFiles: [], - isRepresentative: true, // 첫 번째 아이템은 대표 아이템 + setIsSubmitting(true) + try { + const userId = session?.user?.id?.toString() || '1' + + const extendedData = { + ...data, + hasPrDocument: hasPrDocuments, + prNumber: representativePrNumber, + specificationMeeting: data.hasSpecificationMeeting + ? { + ...specMeetingInfo, + meetingFiles: specMeetingInfo.meetingFiles, } - ]) - setSelectedItemForFile(null) - setBiddingConditions({ - paymentTerms: "", - taxConditions: "", - incoterms: "", - contractDeliveryDate: "", - shippingPort: "", - destinationPort: "", - isPriceAdjustmentApplicable: false, - sparePartOptions: "", - }) - setActiveTab("basic") - setShowSuccessDialog(false) // 추가 - setCreatedBiddingId(null) // 추가 - }, [form]) - - // 다이얼로그 핸들러 - function handleDialogOpenChange(nextOpen: boolean) { - if (!nextOpen) { - // 닫으려 할 때 확인 창을 먼저 띄움 - setShowCloseConfirmDialog(true) - } else { - // 열 때는 바로 적용 - setOpen(nextOpen) + : 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: '', + }) - // 닫기 확인 핸들러 - const handleCloseConfirm = (confirmed: boolean) => { - setShowCloseConfirmDialog(false) - if (confirmed) { - // 사용자가 "예"를 선택한 경우 실제로 닫기 - resetAllStates() - setOpen(false) - } - // "아니오"를 선택한 경우는 아무것도 하지 않음 (다이얼로그 유지) + 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 handleCreateBidding = () => { - // 마지막 탭 validation 체크 - if (!isCurrentTabValid()) { - toast.error("필수 정보를 모두 입력해주세요.") - return - } - - // 수동으로 폼 제출 - form.handleSubmit(onSubmit)() + const handleCloseConfirm = (confirmed: boolean) => { + setShowCloseConfirmDialog(false) + if (confirmed) { + resetAllStates() + setOpen(false) } + } - // 성공 다이얼로그 핸들러들 - const handleNavigateToDetail = () => { - if (createdBiddingId) { - router.push(`/evcp/bid/${createdBiddingId}`) - } - setShowSuccessDialog(false) - setCreatedBiddingId(null) + const handleCreateBidding = () => { + if (!isCurrentTabValid()) { + toast.error('필수 정보를 모두 입력해주세요.') + return } - const handleStayOnPage = () => { - setShowSuccessDialog(false) - setCreatedBiddingId(null) + 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 ( - <> - <Dialog open={open} onOpenChange={handleDialogOpenChange}> - <DialogTrigger asChild> - <Button variant="default" size="sm"> - <Plus className="mr-2 h-4 w-4" /> - 신규 입찰 + <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-[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.purchaseUnit || ''} + onChange={(e) => updatePRItem(item.id, { purchaseUnit: 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="내정단가" + 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> - </DialogTrigger> - <DialogContent className="max-w-7xl h-[90vh] p-0 flex flex-col"> - {/* 고정 헤더 */} - <div className="flex-shrink-0 p-6 border-b"> - <DialogHeader> - <DialogTitle>신규 입찰 생성</DialogTitle> - <DialogDescription> - 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요. - </DialogDescription> - </DialogHeader> + <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> - - <Form {...form}> - <form - onSubmit={form.handleSubmit(onSubmit)} - className="flex flex-col flex-1 min-h-0" - id="create-bidding-form" + </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' + )} > - {/* 탭 영역 */} - <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"> - <button - type="button" - onClick={() => setActiveTab("basic")} - className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${ - activeTab === "basic" - ? "bg-background text-foreground shadow-sm" - : "text-muted-foreground hover:text-foreground" - }`} - > - 기본정보 - {!tabValidation.basic.isValid && ( - <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span> - )} - </button> - <button - type="button" - onClick={() => setActiveTab("schedule")} - className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${ - activeTab === "schedule" - ? "bg-background text-foreground shadow-sm" - : "text-muted-foreground hover:text-foreground" - }`} - > - 입찰계획 - {!tabValidation.schedule.isValid && ( - <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span> - )} - </button> - <button - type="button" - onClick={() => setActiveTab("details")} - className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${ - activeTab === "details" - ? "bg-background text-foreground shadow-sm" - : "text-muted-foreground hover:text-foreground" - }`} - > - 세부내역 - {!tabValidation.details.isValid && ( - <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span> - )} - </button> - <button - type="button" - onClick={() => setActiveTab("manager")} - className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${ - activeTab === "manager" - ? "bg-background text-foreground shadow-sm" - : "text-muted-foreground hover:text-foreground" - }`} - > - 담당자 - </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="projectId" - render={({ field }) => ( - <FormItem> - <FormLabel> - 프로젝트 - </FormLabel> - <FormControl> - <ProjectSelector - selectedProjectId={field.value} - onProjectSelect={handleProjectSelect} - placeholder="프로젝트 선택..." - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* <div className="grid grid-cols-2 gap-6"> */} - {/* 품목명 */} - {/* <FormField - control={form.control} - name="itemName" - render={({ field }) => ( - <FormItem> - <FormLabel> - 품목명 <span className="text-red-500">*</span> - </FormLabel> - <FormControl> - <Input - placeholder="품목명" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> */} - - {/* 리비전 */} - {/* <FormField - control={form.control} - name="revision" - render={({ field }) => ( - <FormItem> - <FormLabel>리비전</FormLabel> - <FormControl> - <Input - type="number" - min="0" - {...field} - onChange={(e) => field.onChange(parseInt(e.target.value) || 0)} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> */} - {/* </div> */} - - {/* 입찰명 */} - <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="contractStartDate" - render={({ field }) => ( - <FormItem> - <FormLabel>계약 시작일 <span className="text-red-500">*</span></FormLabel> - <FormControl> - <Input - type="date" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 계약 종료일 */} - <FormField - control={form.control} - name="contractEndDate" - render={({ field }) => ( - <FormItem> - <FormLabel>계약 종료일 <span className="text-red-500">*</span></FormLabel> - <FormControl> - <Input - type="date" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - {/* 통화 선택만 유지 */} - <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> - )} - /> - - {/* 입찰 조건 섹션 */} - <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 className="text-sm font-medium"> - 지급조건 <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 className="text-sm font-medium"> - 세금조건 <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 className="text-sm font-medium"> - 운송조건(인코텀즈) <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 className="text-sm font-medium"> - 계약 납품일 <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 className="text-sm font-medium">선적지 (선택사항)</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 className="text-sm font-medium">하역지 (선택사항)</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" className="text-sm font-medium"> - 연동제 적용 요건 문의 - </label> - </div> - - <div className="space-y-2"> - <label className="text-sm font-medium">스페어파트 옵션</label> - <Textarea - placeholder="스페어파트 관련 옵션을 입력하세요" - value={biddingConditions.sparePartOptions} - onChange={(e) => setBiddingConditions(prev => ({ - ...prev, - sparePartOptions: e.target.value - }))} - rows={3} - /> - </div> - </CardContent> - </Card> - </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> - </CardContent> - </Card> - - {/* 사양설명회 */} - <Card> - <CardHeader> - <CardTitle>사양설명회</CardTitle> - </CardHeader> - <CardContent className="space-y-6"> - <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 className="text-sm font-medium"> - 회의일시 <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 className="text-sm font-medium">회의시간</label> - <Input - placeholder="예: 14:00 ~ 16:00" - value={specMeetingInfo.meetingTime} - onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, meetingTime: e.target.value }))} - /> - </div> - </div> - - <div> - <label className="text-sm font-medium"> - 장소 <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> - <label className="text-sm font-medium">주소</label> - <Textarea - placeholder="상세 주소" - value={specMeetingInfo.address} - onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, address: e.target.value }))} - /> - </div> - - <div className="grid grid-cols-3 gap-4"> - <div> - <label className="text-sm font-medium"> - 담당자 <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 className="text-sm font-medium">연락처</label> - <Input - placeholder="전화번호" - value={specMeetingInfo.contactPhone} - onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactPhone: e.target.value }))} - /> - </div> - <div> - <label className="text-sm font-medium">이메일</label> - <Input - type="email" - placeholder="이메일" - value={specMeetingInfo.contactEmail} - onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactEmail: e.target.value }))} - /> - </div> - </div> - - <div className="grid grid-cols-2 gap-4"> - <div> - <label className="text-sm font-medium">회의 안건</label> - <Textarea - placeholder="회의 안건" - value={specMeetingInfo.agenda} - onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, agenda: e.target.value }))} - /> - </div> - <div> - <label className="text-sm font-medium">준비물 & 특이사항</label> - <Textarea - placeholder="준비물 및 특이사항" - value={specMeetingInfo.materials} - onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, materials: e.target.value }))} - /> - </div> - </div> - - <div className="flex items-center space-x-2"> - <Switch - id="required-meeting" - checked={specMeetingInfo.isRequired} - onCheckedChange={(checked) => setSpecMeetingInfo(prev => ({ ...prev, isRequired: checked }))} - /> - <label htmlFor="required-meeting" className="text-sm font-medium"> - 필수 참석 - </label> - </div> - - {/* 사양설명회 첨부 파일 */} - <div className="space-y-4"> - <label className="text-sm font-medium">사양설명회 관련 첨부 파일</label> - <Dropzone - onDrop={addMeetingFiles} - accept={{ - 'application/pdf': ['.pdf'], - 'application/msword': ['.doc'], - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], - 'application/vnd.ms-excel': ['.xls'], - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], - 'image/*': ['.png', '.jpg', '.jpeg'], - }} - multiple - className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors" - > - <DropzoneZone> - <DropzoneUploadIcon /> - <DropzoneTitle>사양설명회 관련 문서 업로드</DropzoneTitle> - <DropzoneDescription> - 안내문, 도면, 자료 등을 업로드하세요 (PDF, Word, Excel, 이미지 파일 지원) - </DropzoneDescription> - </DropzoneZone> - <DropzoneInput /> - </Dropzone> - - {specMeetingInfo.meetingFiles.length > 0 && ( - <FileList className="mt-4"> - <FileListHeader> - <span>업로드된 파일 ({specMeetingInfo.meetingFiles.length})</span> - </FileListHeader> - {specMeetingInfo.meetingFiles.map((file, fileIndex) => ( - <FileListItem key={fileIndex}> - <FileListIcon /> - <FileListInfo> - <FileListName>{file.name}</FileListName> - <FileListSize>{file.size}</FileListSize> - </FileListInfo> - <FileListAction> - <Button - type="button" - variant="outline" - size="sm" - onClick={() => removeMeetingFile(fileIndex)} - > - 삭제 - </Button> - </FileListAction> - </FileListItem> - ))} - </FileList> - )} - </div> - </div> - )} - </CardContent> - </Card> - - {/* 긴급 입찰 설정 */} - <Card> - <CardHeader> - <CardTitle>긴급 입찰 설정</CardTitle> - </CardHeader> - <CardContent className="space-y-6"> - <FormField - control={form.control} - name="isUrgent" - 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> - )} - /> - </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"> - {/* 아이템 테이블 */} - {prItems.length > 0 ? ( - <div className="space-y-4"> - <div className="border rounded-lg"> - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-[60px]">대표</TableHead> - <TableHead className="w-[120px]">PR 번호</TableHead> - <TableHead className="w-[120px]">품목코드</TableHead> - <TableHead>품목정보 *</TableHead> - <TableHead className="w-[80px]">수량</TableHead> - <TableHead className="w-[80px]">단위</TableHead> - <TableHead className="w-[80px]">중량</TableHead> - <TableHead className="w-[80px]">중량단위</TableHead> - <TableHead className="w-[140px]">납품요청일</TableHead> - <TableHead className="w-[80px]">스펙파일</TableHead> - <TableHead className="w-[80px]">액션</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {prItems.map((item, index) => ( - <TableRow key={item.id}> - <TableCell> - <div className="flex justify-center"> - <Checkbox - checked={item.isRepresentative} - onCheckedChange={() => setRepresentativeItem(item.id)} - /> - </div> - </TableCell> - <TableCell> - <Input - placeholder="PR 번호" - value={item.prNumber} - onChange={(e) => updatePRItem(item.id, { prNumber: e.target.value })} - className="h-8" - /> - </TableCell> - <TableCell> - <Input - placeholder={`ITEM-${index + 1}`} - value={item.itemCode} - onChange={(e) => updatePRItem(item.id, { itemCode: e.target.value })} - className="h-8" - /> - </TableCell> - <TableCell> - <Input - placeholder="품목정보 *" - value={item.itemInfo} - onChange={(e) => updatePRItem(item.id, { itemInfo: e.target.value })} - className="h-8" - /> - </TableCell> - <TableCell> - <Input - type="number" - min="0" - placeholder="수량" - value={item.quantity} - onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })} - className="h-8" - /> - </TableCell> - <TableCell> - <Select - value={item.quantityUnit} - onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })} - > - <SelectTrigger className="h-8"> - <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> - </TableCell> - <TableCell> - <Input - type="number" - min="0" - placeholder="중량" - value={item.totalWeight} - onChange={(e) => updatePRItem(item.id, { totalWeight: e.target.value })} - className="h-8" - /> - </TableCell> - <TableCell> - <Select - value={item.weightUnit} - onValueChange={(value) => updatePRItem(item.id, { weightUnit: value })} - > - <SelectTrigger className="h-8"> - <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> - </TableCell> - <TableCell> - <Input - type="date" - value={item.requestedDeliveryDate} - onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })} - className="h-8" - placeholder="납품요청일" - /> - </TableCell> - <TableCell> - <div className="flex items-center gap-2"> - <Button - type="button" - variant={selectedItemForFile === item.id ? "default" : "outline"} - size="sm" - onClick={() => setSelectedItemForFile(selectedItemForFile === item.id ? null : item.id)} - className="h-8 w-8 p-0" - > - <Paperclip className="h-4 w-4" /> - </Button> - <span className="text-sm">{item.specFiles.length}</span> - </div> - </TableCell> - <TableCell> - <Button - type="button" - variant="outline" - size="sm" - onClick={() => removePRItem(item.id)} - disabled={prItems.length <= 1} - className="h-8 w-8 p-0" - title={prItems.length <= 1 ? "최소 하나의 품목이 필요합니다" : "품목 삭제"} - > - <Trash2 className="h-4 w-4" /> - </Button> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - - {/* 대표 아이템 정보 표시 */} - {representativePrNumber && ( - <div className="flex items-center gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg"> - <CheckCircle2 className="h-4 w-4 text-blue-600" /> - <span className="text-sm text-blue-800"> - 대표 PR 번호: <strong>{representativePrNumber}</strong> - </span> - </div> - )} - - {/* 선택된 아이템의 파일 업로드 */} - {selectedItemForFile && ( - <div className="space-y-4 p-4 border rounded-lg bg-muted/50"> - {(() => { - const selectedItem = prItems.find(item => item.id === selectedItemForFile) - return ( - <> - <div className="flex items-center justify-between"> - <h6 className="font-medium text-sm"> - {selectedItem?.itemInfo || selectedItem?.itemCode || "선택된 아이템"}의 스펙 파일 - </h6> - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => setSelectedItemForFile(null)} - > - 닫기 - </Button> - </div> - - <Dropzone - onDrop={(files) => addSpecFiles(selectedItemForFile, files)} - accept={{ - 'application/pdf': ['.pdf'], - 'application/msword': ['.doc'], - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], - 'application/vnd.ms-excel': ['.xls'], - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], - }} - multiple - className="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-gray-400 transition-colors" - > - <DropzoneZone> - <DropzoneUploadIcon /> - <DropzoneTitle>스펙 문서 업로드</DropzoneTitle> - <DropzoneDescription> - PDF, Word, Excel 파일을 드래그하거나 클릭하여 선택 - </DropzoneDescription> - </DropzoneZone> - <DropzoneInput /> - </Dropzone> - - {selectedItem && selectedItem.specFiles.length > 0 && ( - <FileList className="mt-4"> - <FileListHeader> - <span>업로드된 파일 ({selectedItem.specFiles.length})</span> - </FileListHeader> - {selectedItem.specFiles.map((file, fileIndex) => ( - <FileListItem - key={fileIndex} - className="flex items-center justify-between p-3 border rounded-lg mb-2" - > - <div className="flex items-center gap-3 flex-1"> - <FileListIcon className="flex-shrink-0" /> - <FileListInfo className="flex items-center gap-3 flex-1"> - <FileListName className="font-medium text-gray-700"> - {file.name} - </FileListName> - <FileListSize className="text-sm text-gray-500"> - {file.size} - </FileListSize> - </FileListInfo> - </div> - <FileListAction className="flex-shrink-0"> - <Button - type="button" - variant="outline" - size="sm" - onClick={() => removeSpecFile(selectedItemForFile, fileIndex)} - > - 삭제 - </Button> - </FileListAction> - </FileListItem> - ))} - </FileList> - )} - </> - ) - })()} - </div> - )} - </div> - ) : ( - <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> - )} - </CardContent> - </Card> - </TabsContent> - - {/* 담당자 & 기타 탭 */} - <TabsContent value="manager" className="mt-0 space-y-6"> - {/* 담당자 정보 */} - <Card> - <CardHeader> - <CardTitle>담당자 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-6"> - <FormField - control={form.control} - name="managerName" - render={({ field }) => ( - <FormItem> - <FormLabel>담당자명</FormLabel> - <FormControl> - <Input - placeholder="담당자명" - {...field} - /> - </FormControl> - <FormDescription> - 현재 로그인한 사용자 정보로 자동 설정됩니다. - </FormDescription> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-6"> - <FormField - control={form.control} - name="managerEmail" - render={({ field }) => ( - <FormItem> - <FormLabel>담당자 이메일</FormLabel> - <FormControl> - <Input - type="email" - placeholder="email@example.com" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="managerPhone" - render={({ field }) => ( - <FormItem> - <FormLabel>담당자 전화번호</FormLabel> - <FormControl> - <Input - placeholder="010-1234-5678" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </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> - - {/* 입찰 생성 요약 */} - {/* <Card> - <CardHeader> - <CardTitle>입찰 생성 요약</CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-2 gap-4 text-sm"> - <div> - <span className="font-medium">프로젝트:</span> - <p className="text-muted-foreground"> - {form.watch("projectName") || "선택되지 않음"} - </p> - </div> - <div> - <span className="font-medium">입찰명:</span> - <p className="text-muted-foreground"> - {form.watch("title") || "입력되지 않음"} - </p> - </div> - <div> - <span className="font-medium">계약구분:</span> - <p className="text-muted-foreground"> - {contractTypeLabels[form.watch("contractType") as keyof typeof contractTypeLabels] || "선택되지 않음"} - </p> - </div> - <div> - <span className="font-medium">입찰유형:</span> - <p className="text-muted-foreground"> - {biddingTypeLabels[form.watch("biddingType") as keyof typeof biddingTypeLabels] || "선택되지 않음"} - </p> - </div> - <div> - <span className="font-medium">사양설명회:</span> - <p className="text-muted-foreground"> - {form.watch("hasSpecificationMeeting") ? "실시함" : "실시하지 않음"} - </p> - </div> - <div> - <span className="font-medium">대표 PR 번호:</span> - <p className="text-muted-foreground"> - {representativePrNumber || "설정되지 않음"} - </p> - </div> - <div> - <span className="font-medium">세부 아이템:</span> - <p className="text-muted-foreground"> - {prItems.length}개 아이템 - </p> - </div> - <div> - <span className="font-medium">사양설명회 파일:</span> - <p className="text-muted-foreground"> - {specMeetingInfo.meetingFiles.length}개 파일 - </p> - </div> - </div> - </CardContent> - </Card> */} - </TabsContent> - - </div> - </Tabs> + {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="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> - 기본 정보를 입력하세요 - {!tabValidation.basic.isValid && ( - <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span> - )} - </span> - )} - {activeTab === "contract" && ( - <span> - 계약 및 가격 정보를 입력하세요 - {!tabValidation.contract.isValid && ( - <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span> - )} - </span> - )} - {activeTab === "schedule" && ( - <span> - 일정 및 사양설명회 정보를 입력하세요 - {!tabValidation.schedule.isValid && ( - <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span> - )} - </span> - )} - {activeTab === "conditions" && ( - <span> - 입찰 조건을 설정하세요 - {!tabValidation.conditions.isValid && ( - <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span> - )} - </span> - )} - {activeTab === "details" && ( - <span> - 최소 하나의 품목을 입력하세요 - {!tabValidation.details.isValid && ( - <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span> - )} - </span> - )} - {activeTab === "manager" && "담당자 정보를 확인하고 입찰을 생성하세요"} - </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 ? ( - // 마지막 탭: 입찰 생성 버튼 (type="button"으로 변경) - <Button - type="button" - onClick={handleCreateBidding} - disabled={isSubmitting} - 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} - className="flex items-center gap-2" - > - 다음 - <ChevronRight className="h-4 w-4" /> - </Button> - )} - </div> - </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> - </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> - </> - ) + + <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 |
