From ba8cd44a0ed2c613a5f2cee06bfc9bd0f61f21c7 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 7 Nov 2025 08:39:04 +0000 Subject: (최겸) 입찰/견적 수정사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bidding/list/create-bidding-dialog.tsx | 4230 ++++++++++++++-------------- 1 file changed, 2051 insertions(+), 2179 deletions(-) (limited to 'lib/bidding/list/create-bidding-dialog.tsx') 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("basic") - const [showSuccessDialog, setShowSuccessDialog] = React.useState(false) // 추가 - const [createdBiddingId, setCreatedBiddingId] = React.useState(null) // 추가 - const [showCloseConfirmDialog, setShowCloseConfirmDialog] = React.useState(false) // 닫기 확인 다이얼로그 상태 - - // Procurement 데이터 상태들 - const [paymentTermsOptions, setPaymentTermsOptions] = React.useState>([]) - const [incotermsOptions, setIncotermsOptions] = React.useState>([]) - const [shippingPlaces, setShippingPlaces] = React.useState>([]) - const [destinationPlaces, setDestinationPlaces] = React.useState>([]) - const [procurementLoading, setProcurementLoading] = React.useState(false) - - // 사양설명회 정보 상태 - const [specMeetingInfo, setSpecMeetingInfo] = React.useState({ - meetingDate: "", - meetingTime: "", - location: "", - address: "", - contactPerson: "", - contactPhone: "", - contactEmail: "", - agenda: "", - materials: "", - notes: "", - isRequired: false, - meetingFiles: [], // 사양설명회 첨부파일 - }) - - // PR 아이템들 상태 - 기본적으로 하나의 빈 아이템 생성 - const [prItems, setPrItems] = React.useState([ - { - 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(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('basic') + const [showSuccessDialog, setShowSuccessDialog] = React.useState(false) + const [createdBiddingId, setCreatedBiddingId] = React.useState(null) + const [showCloseConfirmDialog, setShowCloseConfirmDialog] = React.useState(false) + + const [paymentTermsOptions, setPaymentTermsOptions] = React.useState< + Array<{ code: string; description: string }> + >([]) + const [incotermsOptions, setIncotermsOptions] = React.useState< + Array<{ code: string; description: string }> + >([]) + const [shippingPlaces, setShippingPlaces] = React.useState< + Array<{ code: string; description: string }> + >([]) + const [destinationPlaces, setDestinationPlaces] = React.useState< + Array<{ code: string; description: string }> + >([]) + + const [specMeetingInfo, setSpecMeetingInfo] = + React.useState({ + meetingDate: '', + meetingTime: '', + location: '', + address: '', + contactPerson: '', + contactPhone: '', + contactEmail: '', + agenda: '', + materials: '', + notes: '', + isRequired: false, + meetingFiles: [], }) - // 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([ + { + id: `pr-default`, + prNumber: '', + projectId: undefined, + projectInfo: '', + shi: '', + quantity: '', + quantityUnit: 'EA', + totalWeight: '', + weightUnit: 'KG', + materialDescription: '', + hasSpecDocument: false, + requestedDeliveryDate: '', + specFiles: [], + isRepresentative: true, + annualUnitPrice: '', + currency: 'KRW', + materialGroupNumber: '', + materialGroupInfo: '', + materialNumber: '', + materialInfo: '', + priceUnit: '', + purchaseUnit: '1', + materialWeight: '', + wbsCode: '', + wbsName: '', + costCenterCode: '', + costCenterName: '', + glAccountCode: '', + glAccountName: '', + targetUnitPrice: '', + targetAmount: '', + targetCurrency: 'KRW', + budgetAmount: '', + budgetCurrency: 'KRW', + actualAmount: '', + actualCurrency: 'KRW', + }, + ]) + const [selectedItemForFile, setSelectedItemForFile] = React.useState(null) + const [quantityWeightMode, setQuantityWeightMode] = React.useState<'quantity' | 'weight'>('quantity') + const [costCenterDialogOpen, setCostCenterDialogOpen] = React.useState(false) + const [glAccountDialogOpen, setGlAccountDialogOpen] = React.useState(false) + const [wbsCodeDialogOpen, setWbsCodeDialogOpen] = React.useState(false) + const [materialGroupDialogOpen, setMaterialGroupDialogOpen] = React.useState(false) + const [materialDialogOpen, setMaterialDialogOpen] = React.useState(false) + const [biddingConditions, setBiddingConditions] = React.useState({ + paymentTerms: '', + taxConditions: '', + incoterms: '', + incotermsOption: '', + contractDeliveryDate: '', + shippingPort: '', + destinationPort: '', + isPriceAdjustmentApplicable: false, + sparePartOptions: '', + }) + + // -- 데이터 로딩 및 상태 동기화 로직 + const loadPaymentTerms = React.useCallback(async () => { + try { + const data = await getPaymentTermsForSelection() + setPaymentTermsOptions(data) + const p008Exists = data.some((item) => item.code === 'P008') + if (p008Exists) { + setBiddingConditions((prev) => ({ ...prev, paymentTerms: 'P008' })) + } + } catch (error) { + console.error('Failed to load payment terms:', error) + toast.error('결제조건 목록을 불러오는데 실패했습니다.') } - - // 사양설명회 파일 제거 - const 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({ - 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({ + 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) => { - 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) => { + 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 ( - <> - - - + + ))} + + + ) + ))} + + + )} + + ) + } + + + return ( + <> + + + + + + {/* 고정 헤더 */} +
+ + 신규 입찰 생성 + + 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요. + + +
+ + + + {/* 탭 영역 */} +
+ setActiveTab(value as TabType)} className="h-full flex flex-col"> + {/* 탭 리스트 */} +
+
+ {TAB_ORDER.map((tab) => ( + - - - -
-
- -
- {/* 기본 정보 탭 */} - - - - 기본 정보 및 계약 정보 - - - {/* 프로젝트 선택 */} - ( - - - 프로젝트 - - - - - - - )} - /> - - {/*
*/} - {/* 품목명 */} - {/* ( - - - 품목명 * - - - - - - - )} - /> */} - - {/* 리비전 */} - {/* ( - - 리비전 - - field.onChange(parseInt(e.target.value) || 0)} - /> - - - - )} - /> */} - {/*
*/} - - {/* 입찰명 */} - ( - - - 입찰명 * - - - - - - - )} - /> - - {/* 설명 */} - ( - - 설명 - -