diff options
Diffstat (limited to 'lib/bidding/detail')
| -rw-r--r-- | lib/bidding/detail/service.ts | 22 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx | 298 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx | 4 |
3 files changed, 215 insertions, 109 deletions
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index 645ebeac..404bc3cd 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -475,6 +475,7 @@ export async function calculateAndUpdateTargetPrice( biddingId: number ) { try { + // 입찰 정보 조회 const bidding = await getBiddingById(biddingId) if (!bidding) { @@ -512,17 +513,16 @@ export async function calculateAndUpdateTargetPrice( const updateResult = await updateTargetPrice(biddingId, targetPrice, criteria) if (updateResult.success) { - // 내정가 산정 후 입찰 상태를 set_target_price로 변경 (received_quotation 상태에서만) - await db - .update(biddings) - .set({ - status: 'set_target_price', - updatedAt: new Date() - }) - .where(and( - eq(biddings.id, biddingId), - eq(biddings.status, 'received_quotation') - )) + // // 내정가 산정 후 입찰 상태를 set_target_price로 변경 (received_quotation 상태에서만) + // await db + // .update(biddings) + // .set({ + // status: 'set_target_price', + // updatedAt: new Date() + // }) + // .where(and( + // eq(biddings.id, biddingId) + // )) // 캐시 무효화 revalidateTag(`bidding-${biddingId}`) diff --git a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx index f35957bc..c1471a69 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx @@ -27,18 +27,21 @@ import { CommandGroup, CommandInput, CommandItem, + CommandList, } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover' -import { Check, ChevronsUpDown, Search } from 'lucide-react' +import { Check, ChevronsUpDown, Search, Loader2, X, Plus } from 'lucide-react' import { cn } from '@/lib/utils' import { createBiddingDetailVendor } from '@/lib/bidding/detail/service' import { searchVendorsForBidding } from '@/lib/bidding/service' import { useToast } from '@/hooks/use-toast' import { useTransition } from 'react' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' interface BiddingDetailVendorCreateDialogProps { biddingId: number @@ -64,45 +67,67 @@ export function BiddingDetailVendorCreateDialog({ const [isPending, startTransition] = useTransition() // Vendor 검색 상태 - const [vendors, setVendors] = React.useState<Vendor[]>([]) - const [selectedVendor, setSelectedVendor] = React.useState<Vendor | null>(null) - const [vendorSearchOpen, setVendorSearchOpen] = React.useState(false) - const [vendorSearchValue, setVendorSearchValue] = React.useState('') + const [vendorList, setVendorList] = React.useState<Vendor[]>([]) + const [selectedVendors, setSelectedVendors] = React.useState<Vendor[]>([]) + const [vendorOpen, setVendorOpen] = React.useState(false) // 폼 상태 (간소화 - 필수 항목만) const [formData, setFormData] = React.useState({ awardRatio: 100, // 기본 100% }) - // Vendor 검색 + // 벤더 로드 + const loadVendors = React.useCallback(async () => { + try { + const result = await searchVendorsForBidding('', biddingId, 50) // 빈 검색어로 모든 벤더 로드 + setVendorList(result || []) + } catch (error) { + console.error('Failed to load vendors:', error) + toast({ + title: '오류', + description: '벤더 목록을 불러오는데 실패했습니다.', + variant: 'destructive', + }) + setVendorList([]) + } + }, [biddingId]) + React.useEffect(() => { - const search = async () => { - if (vendorSearchValue.trim().length < 2) { - setVendors([]) - return - } + if (open) { + loadVendors() + } + }, [open, loadVendors]) - try { - const result = await searchVendorsForBidding(vendorSearchValue.trim(), biddingId, 10) - setVendors(result) - } catch (error) { - console.error('Vendor search failed:', error) - setVendors([]) - } + // 초기화 + React.useEffect(() => { + if (!open) { + setSelectedVendors([]) + setFormData({ + awardRatio: 100, // 기본 100% + }) } + }, [open]) - const debounceTimer = setTimeout(search, 300) - return () => clearTimeout(debounceTimer) - }, [vendorSearchValue]) + // 벤더 추가 + const handleAddVendor = (vendor: Vendor) => { + if (!selectedVendors.find(v => v.id === vendor.id)) { + setSelectedVendors([...selectedVendors, vendor]) + } + setVendorOpen(false) + } - const handleVendorSelect = (vendor: Vendor) => { - setSelectedVendor(vendor) - setVendorSearchValue(`${vendor.vendorName} (${vendor.vendorCode})`) - setVendorSearchOpen(false) + // 벤더 제거 + const handleRemoveVendor = (vendorId: number) => { + setSelectedVendors(selectedVendors.filter(v => v.id !== vendorId)) + } + + // 이미 선택된 벤더인지 확인 + const isVendorSelected = (vendorId: number) => { + return selectedVendors.some(v => v.id === vendorId) } const handleCreate = () => { - if (!selectedVendor) { + if (selectedVendors.length === 0) { toast({ title: '오류', description: '업체를 선택해주세요.', @@ -111,25 +136,41 @@ export function BiddingDetailVendorCreateDialog({ return } - startTransition(async () => { - const response = await createBiddingDetailVendor( - biddingId, - selectedVendor.id - ) + let successCount = 0 + let errorMessages: string[] = [] + + for (const vendor of selectedVendors) { + try { + const response = await createBiddingDetailVendor( + biddingId, + vendor.id + ) + + if (response.success) { + successCount++ + } else { + errorMessages.push(`${vendor.vendorName}: ${response.error}`) + } + } catch (error) { + errorMessages.push(`${vendor.vendorName}: 처리 중 오류가 발생했습니다.`) + } + } - if (response.success) { + if (successCount > 0) { toast({ title: '성공', - description: response.message, + description: `${successCount}개의 업체가 성공적으로 추가되었습니다.${errorMessages.length > 0 ? ` ${errorMessages.length}개는 실패했습니다.` : ''}`, }) onOpenChange(false) resetForm() onSuccess() - } else { + } + + if (errorMessages.length > 0 && successCount === 0) { toast({ title: '오류', - description: response.error, + description: `업체 추가에 실패했습니다: ${errorMessages.join(', ')}`, variant: 'destructive', }) } @@ -137,8 +178,7 @@ export function BiddingDetailVendorCreateDialog({ } const resetForm = () => { - setSelectedVendor(null) - setVendorSearchValue('') + setSelectedVendors([]) setFormData({ awardRatio: 100, // 기본 100% }) @@ -146,74 +186,140 @@ export function BiddingDetailVendorCreateDialog({ return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[600px]"> - <DialogHeader> + <DialogContent className="max-w-4xl max-h-[90vh] p-0 flex flex-col"> + {/* 헤더 */} + <DialogHeader className="p-6 pb-0"> <DialogTitle>협력업체 추가</DialogTitle> <DialogDescription> - 검색해서 업체를 선택하고 견적 정보를 입력해주세요. + 입찰에 참여할 업체를 선택하세요. 여러 개 선택 가능합니다. </DialogDescription> </DialogHeader> - <div className="grid gap-4 py-4"> - {/* Vendor 검색 */} - <div className="space-y-2"> - <Label htmlFor="vendor-search">업체 검색</Label> - <Popover open={vendorSearchOpen} onOpenChange={setVendorSearchOpen}> - <PopoverTrigger asChild> - <Button - variant="outline" - role="combobox" - aria-expanded={vendorSearchOpen} - className="w-full justify-between" - > - {selectedVendor - ? `${selectedVendor.vendorName} (${selectedVendor.vendorCode})` - : "업체를 검색해서 선택하세요..."} - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-full p-0"> - <Command> - <CommandInput - placeholder="업체명 또는 코드를 입력하세요..." - value={vendorSearchValue} - onValueChange={setVendorSearchValue} - /> - <CommandEmpty> - {vendorSearchValue.length < 2 - ? "최소 2자 이상 입력해주세요" - : "검색 결과가 없습니다"} - </CommandEmpty> - <CommandGroup className="max-h-64 overflow-auto"> - {vendors.map((vendor) => ( - <CommandItem - key={vendor.id} - value={`${vendor.vendorName} ${vendor.vendorCode}`} - onSelect={() => handleVendorSelect(vendor)} + + {/* 메인 컨텐츠 */} + <div className="flex-1 px-6 py-4 overflow-y-auto"> + <div className="space-y-6"> + {/* 업체 선택 카드 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">업체 선택</CardTitle> + <CardDescription> + 입찰에 참여할 협력업체를 선택하세요. + </CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {/* 업체 추가 버튼 */} + <Popover open={vendorOpen} onOpenChange={setVendorOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={vendorOpen} + className="w-full justify-between" + disabled={vendorList.length === 0} > - <Check - className={cn( - "mr-2 h-4 w-4", - selectedVendor?.id === vendor.id ? "opacity-100" : "opacity-0" - )} - /> - <div className="flex flex-col"> - <span className="font-medium">{vendor.vendorName}</span> - <span className="text-sm text-muted-foreground">{vendor.vendorCode}</span> - </div> - </CommandItem> - ))} - </CommandGroup> - </Command> - </PopoverContent> - </Popover> + <span className="flex items-center gap-2"> + <Plus className="h-4 w-4" /> + 업체 선택하기 + </span> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[500px] p-0" align="start"> + <Command> + <CommandInput placeholder="업체명 또는 코드로 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {vendorList + .filter(vendor => !isVendorSelected(vendor.id)) + .map((vendor) => ( + <CommandItem + key={vendor.id} + value={`${vendor.vendorCode} ${vendor.vendorName}`} + onSelect={() => handleAddVendor(vendor)} + > + <div className="flex items-center gap-2 w-full"> + <Badge variant="outline" className="shrink-0"> + {vendor.vendorCode} + </Badge> + <span className="truncate">{vendor.vendorName}</span> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + + {/* 선택된 업체 목록 */} + {selectedVendors.length > 0 && ( + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <h4 className="text-sm font-medium">선택된 업체 ({selectedVendors.length}개)</h4> + </div> + <div className="space-y-2"> + {selectedVendors.map((vendor, index) => ( + <div + key={vendor.id} + className="flex items-center justify-between p-3 rounded-lg bg-secondary/50" + > + <div className="flex items-center gap-3"> + <span className="text-sm text-muted-foreground"> + {index + 1}. + </span> + <Badge variant="outline"> + {vendor.vendorCode} + </Badge> + <span className="text-sm font-medium"> + {vendor.vendorName} + </span> + </div> + <Button + variant="ghost" + size="sm" + onClick={() => handleRemoveVendor(vendor.id)} + className="h-8 w-8 p-0" + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </div> + )} + + {selectedVendors.length === 0 && ( + <div className="text-center py-8 text-muted-foreground"> + <p className="text-sm">아직 선택된 업체가 없습니다.</p> + <p className="text-xs mt-1">위 버튼을 클릭하여 업체를 추가하세요.</p> + </div> + )} + </div> + </CardContent> + </Card> </div> </div> - <DialogFooter> - <Button variant="outline" onClick={() => onOpenChange(false)}> + + {/* 푸터 */} + <DialogFooter className="p-6 pt-0 border-t"> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isPending} + > 취소 </Button> - <Button onClick={handleCreate} disabled={isPending || !selectedVendor}> - 추가 + <Button + onClick={handleCreate} + disabled={isPending || selectedVendors.length === 0} + > + {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {selectedVendors.length > 0 + ? `${selectedVendors.length}개 업체 추가` + : '업체 추가' + } </Button> </DialogFooter> </DialogContent> diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx index 5d1bfde7..4655ed9f 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -63,10 +63,10 @@ export function BiddingDetailVendorToolbarActions({ } const handleRegister = () => { - if (bidding.status !== 'set_target_price') { + if (!bidding.targetPrice) { toast({ title: '오류', - description: '내정가 산정이 완료되어야 입찰 등록을 할 수 있습니다.', + description: '내정가가 산정되어야 입찰 초대를 할 수 있습니다.', variant: 'destructive', }) return |
