diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-17 10:40:12 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-17 10:40:12 +0000 |
| commit | 10cb50753ccf318024c4394282f9e8d968dcd1a5 (patch) | |
| tree | cf4edb96aa172c3b90d88532aff1f536944a2283 /lib/bidding/list/create-bidding-dialog.tsx | |
| parent | f7117370b9cc0c7b96bd1eb23a1b9f5b16cc8ceb (diff) | |
(최겸) 구매 입찰 오류 수정 및 선적지,하역지 연동,TO Cont, TO PO 개발
Diffstat (limited to 'lib/bidding/list/create-bidding-dialog.tsx')
| -rw-r--r-- | lib/bidding/list/create-bidding-dialog.tsx | 249 |
1 files changed, 212 insertions, 37 deletions
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx index 4fc4fd7b..a25dd363 100644 --- a/lib/bidding/list/create-bidding-dialog.tsx +++ b/lib/bidding/list/create-bidding-dialog.tsx @@ -67,7 +67,8 @@ import { } from "@/components/ui/file-list" import { Checkbox } from "@/components/ui/checkbox" -import { createBidding, type CreateBiddingInput, getActivePaymentTerms, getActiveIncoterms } from "@/lib/bidding/service" +import { createBidding, type CreateBiddingInput } from "@/lib/bidding/service" +import { getIncotermsForSelection, getPaymentTermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection } from "@/lib/procurement-select/service" import { createBiddingSchema, type CreateBiddingSchema @@ -127,12 +128,7 @@ interface PRItemInfo { const TAB_ORDER = ["basic", "contract", "schedule", "conditions", "details", "manager"] as const type TabType = typeof TAB_ORDER[number] -interface CreateBiddingDialogProps { - paymentTermsOptions?: Array<{code: string, description: string}> - incotermsOptions?: Array<{code: string, description: string}> -} - -export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions = [] }: CreateBiddingDialogProps) { +export function CreateBiddingDialog() { const router = useRouter() const [isSubmitting, setIsSubmitting] = React.useState(false) const { data: session } = useSession() @@ -141,6 +137,13 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions const [showSuccessDialog, setShowSuccessDialog] = React.useState(false) // 추가 const [createdBiddingId, setCreatedBiddingId] = React.useState<number | null>(null) // 추가 + // 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: "", @@ -157,8 +160,24 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions meetingFiles: [], // 사양설명회 첨부파일 }) - // PR 아이템들 상태 - const [prItems, setPrItems] = React.useState<PRItemInfo[]>([]) + // 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) @@ -175,6 +194,69 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions sparePartOptions: "", }) + // Procurement 데이터 로드 함수들 + const loadPaymentTerms = React.useCallback(async () => { + setProcurementLoading(true); + try { + const data = await getPaymentTermsForSelection(); + setPaymentTermsOptions(data); + } catch (error) { + console.error("Failed to load payment terms:", error); + toast.error("결제조건 목록을 불러오는데 실패했습니다."); + } finally { + setProcurementLoading(false); + } + }, []); + + const loadIncoterms = React.useCallback(async () => { + setProcurementLoading(true); + try { + const data = await getIncotermsForSelection(); + setIncotermsOptions(data); + } catch (error) { + console.error("Failed to load incoterms:", error); + toast.error("운송조건 목록을 불러오는데 실패했습니다."); + } finally { + setProcurementLoading(false); + } + }, []); + + 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(); + } + }, [open, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces]) + // 사양설명회 파일 추가 const addMeetingFiles = (files: File[]) => { @@ -211,7 +293,8 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions contractType: "general", biddingType: "equipment", awardCount: "single", - contractPeriod: "", + contractStartDate: "", + contractEndDate: "", submissionStartDate: "", submissionEndDate: "", @@ -268,9 +351,10 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions isValid: formValues.contractType && formValues.biddingType && formValues.awardCount && - formValues.contractPeriod.trim() !== "" && + formValues.contractStartDate && + formValues.contractEndDate && formValues.currency, - hasErrors: !!(formErrors.contractType || formErrors.biddingType || formErrors.awardCount || formErrors.contractPeriod || formErrors.currency) + hasErrors: !!(formErrors.contractType || formErrors.biddingType || formErrors.awardCount || formErrors.contractStartDate || formErrors.contractEndDate || formErrors.currency) }, schedule: { isValid: formValues.submissionStartDate && @@ -289,7 +373,7 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions hasErrors: false }, details: { - isValid: true, // 세부내역은 선택사항 + isValid: prItems.length > 0, hasErrors: false }, manager: { @@ -369,6 +453,12 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions // PR 아이템 제거 const removePRItem = (id: string) => { + // 최소 하나의 아이템은 유지해야 함 + if (prItems.length <= 1) { + toast.error("최소 하나의 품목이 필요합니다.") + return + } + setPrItems(prev => { const filteredItems = prev.filter(item => item.id !== id) // 만약 대표 아이템을 삭제했다면, 첫 번째 아이템을 대표로 설정 @@ -443,7 +533,9 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions toast.error("제출 시작일시와 마감일시를 입력해주세요") } } else if (activeTab === "conditions") { - toast.error("입찰 조건을 모두 입력해주세요 (지급조건, 세금조건, 운송조건, 계약납품일, 선적지, 도착지)") + toast.error("입찰 조건을 모두 입력해주세요 (지급조건, 세금조건, 운송조건, 계약납품일, 선적지, 하역지)") + } else if (activeTab === "details") { + toast.error("품목정보, 수량/단위 또는 중량/중량단위를 입력해주세요") } return } @@ -524,7 +616,8 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions contractType: "general", biddingType: "equipment", awardCount: "single", - contractPeriod: "", + contractStartDate: "", + contractEndDate: "", submissionStartDate: "", submissionEndDate: "", hasSpecificationMeeting: false, @@ -556,7 +649,23 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions isRequired: false, meetingFiles: [], }) - setPrItems([]) + setPrItems([ + { + id: `pr-default`, + prNumber: "", + itemCode: "", + itemInfo: "", + quantity: "", + quantityUnit: "EA", + totalWeight: "", + weightUnit: "KG", + materialDescription: "", + hasSpecDocument: false, + requestedDeliveryDate: "", + specFiles: [], + isRepresentative: true, // 첫 번째 아이템은 대표 아이템 + } + ]) setSelectedItemForFile(null) setBiddingConditions({ paymentTerms: "", @@ -705,6 +814,9 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions }`} > 세부내역 + {!tabValidation.details.isValid && ( + <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span> + )} </button> <button type="button" @@ -927,18 +1039,34 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions )} /> - {/* 계약기간 */} + {/* 계약 시작일 */} <FormField control={form.control} - name="contractPeriod" + name="contractStartDate" render={({ field }) => ( <FormItem> - <FormLabel> - 계약기간 <span className="text-red-500">*</span> - </FormLabel> + <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 - placeholder="예: 계약일로부터 60일" + type="date" {...field} /> </FormControl> @@ -1403,26 +1531,58 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions <div className="space-y-2"> <label className="text-sm font-medium">선적지 <span className="text-red-500">*</span></label> - <Input - placeholder="예: 부산항, 인천항" + <Select value={biddingConditions.shippingPort} - onChange={(e) => setBiddingConditions(prev => ({ + onValueChange={(value) => setBiddingConditions(prev => ({ ...prev, - shippingPort: e.target.value + 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">도착지 <span className="text-red-500">*</span></label> - <Input - placeholder="예: 현장 직납, 창고 납품" + <label className="text-sm font-medium">하역지 <span className="text-red-500">*</span></label> + <Select value={biddingConditions.destinationPort} - onChange={(e) => setBiddingConditions(prev => ({ + onValueChange={(value) => setBiddingConditions(prev => ({ ...prev, - destinationPort: e.target.value + 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> @@ -1463,7 +1623,10 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions <div> <CardTitle>세부내역 관리</CardTitle> <p className="text-sm text-muted-foreground mt-1"> - PR 아이템 또는 수기 아이템을 추가하여 입찰 세부내역을 관리하세요 + 최소 하나의 품목을 입력해야 합니다 + </p> + <p className="text-xs text-amber-600 mt-1"> + 수량/단위 또는 중량/중량단위를 선택해서 입력하세요 </p> </div> <Button @@ -1487,7 +1650,7 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions <TableHead className="w-[60px]">대표</TableHead> <TableHead className="w-[120px]">PR 번호</TableHead> <TableHead className="w-[120px]">품목코드</TableHead> - <TableHead>품목정보</TableHead> + <TableHead>품목정보 *</TableHead> <TableHead className="w-[80px]">수량</TableHead> <TableHead className="w-[80px]">단위</TableHead> <TableHead className="w-[80px]">중량</TableHead> @@ -1526,7 +1689,7 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions </TableCell> <TableCell> <Input - placeholder="품목정보" + placeholder="품목정보 *" value={item.itemInfo} onChange={(e) => updatePRItem(item.id, { itemInfo: e.target.value })} className="h-8" @@ -1535,6 +1698,7 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions <TableCell> <Input type="number" + min="0" placeholder="수량" value={item.quantity} onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })} @@ -1562,6 +1726,7 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions <TableCell> <Input type="number" + min="0" placeholder="중량" value={item.totalWeight} onChange={(e) => updatePRItem(item.id, { totalWeight: e.target.value })} @@ -1590,6 +1755,7 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions value={item.requestedDeliveryDate} onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })} className="h-8" + placeholder="납품요청일" /> </TableCell> <TableCell> @@ -1612,7 +1778,9 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions 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> @@ -1978,7 +2146,14 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions )} </span> )} - {activeTab === "details" && "세부내역 아이템을 관리하세요 (선택사항)"} + {activeTab === "details" && ( + <span> + 최소 하나의 품목을 입력하세요 + {!tabValidation.details.isValid && ( + <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span> + )} + </span> + )} {activeTab === "manager" && "담당자 정보를 확인하고 입찰을 생성하세요"} </div> |
