diff options
Diffstat (limited to 'lib/bidding')
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-columns.tsx | 112 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-table.tsx | 36 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx | 27 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-table.tsx | 12 | ||||
| -rw-r--r-- | lib/bidding/list/edit-bidding-sheet.tsx | 578 | ||||
| -rw-r--r-- | lib/bidding/selection/actions.ts | 43 | ||||
| -rw-r--r-- | lib/bidding/selection/selection-result-form.tsx | 98 | ||||
| -rw-r--r-- | lib/bidding/service.ts | 231 |
8 files changed, 304 insertions, 833 deletions
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx index a0b69020..5368b287 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx @@ -19,22 +19,26 @@ import { import { QuotationVendor } from "@/lib/bidding/detail/service" interface GetVendorColumnsProps { - onEdit: (vendor: QuotationVendor) => void onViewPriceAdjustment?: (vendor: QuotationVendor) => void onViewItemDetails?: (vendor: QuotationVendor) => void onSendBidding?: (vendor: QuotationVendor) => void onUpdateParticipation?: (vendor: QuotationVendor, participated: boolean) => void onViewQuotationHistory?: (vendor: QuotationVendor) => void biddingStatus?: string // 입찰 상태 정보 추가 + biddingTargetPrice?: number | string | null // 입찰 내정가 + biddingFinalBidPrice?: number | string | null // 최종 확정금액 + biddingCurrency?: string // 입찰 통화 } export function getBiddingDetailVendorColumns({ - onEdit, onViewItemDetails, onSendBidding, onUpdateParticipation, onViewQuotationHistory, - biddingStatus + biddingStatus, + biddingTargetPrice, + biddingFinalBidPrice, + biddingCurrency }: GetVendorColumnsProps): ColumnDef<QuotationVendor>[] { return [ { @@ -97,6 +101,54 @@ export function getBiddingDetailVendorColumns({ }, }, { + accessorKey: 'targetPrice', + header: '내정가', + cell: ({ row }) => { + const hasTargetPrice = biddingTargetPrice && Number(biddingTargetPrice) > 0 + return ( + <div className="text-right font-mono text-sm text-muted-foreground"> + {hasTargetPrice ? ( + <> + {Number(biddingTargetPrice).toLocaleString()} {row.original.currency} + </> + ) : ( + <span>-</span> + )} + </div> + ) + }, + }, + { + accessorKey: 'priceRatio', + header: '내정가 대비', + cell: ({ row }) => { + const hasAmount = row.original.quotationAmount && Number(row.original.quotationAmount) > 0 + const hasTargetPrice = biddingTargetPrice && Number(biddingTargetPrice) > 0 + + if (!hasAmount || !hasTargetPrice) { + return <div className="text-right text-muted-foreground">-</div> + } + + const quotationAmount = Number(row.original.quotationAmount) + const targetPrice = Number(biddingTargetPrice) + const ratio = (quotationAmount / targetPrice) * 100 + + // 비율에 따른 색상 결정 + const getColorClass = (ratio: number) => { + if (ratio < 100) return 'text-blue-600 font-bold' // 내정가보다 낮음 + if (ratio === 100) return 'text-green-600 font-bold' // 내정가와 같음 + if (ratio <= 110) return 'text-orange-600 font-bold' // 10% 이내 초과 + return 'text-red-600 font-bold' // 10% 이상 초과 + } + + return ( + <div className={`text-right font-mono ${getColorClass(ratio)}`}> + {ratio.toFixed(1)}% + </div> + ) + }, + }, + { accessorKey: 'biddingResult', header: '입찰결과', cell: ({ row }) => { @@ -121,6 +173,25 @@ export function getBiddingDetailVendorColumns({ ), }, { + accessorKey: 'finalBidPrice', + header: '확정금액', + cell: ({ row }) => { + const hasFinalPrice = biddingFinalBidPrice && Number(biddingFinalBidPrice) > 0 + const currency = biddingCurrency || row.original.currency + return ( + <div className="text-right font-mono font-bold text-green-700"> + {hasFinalPrice ? ( + <> + {Number(biddingFinalBidPrice).toLocaleString()} {currency} + </> + ) : ( + <span className="text-muted-foreground">-</span> + )} + </div> + ) + }, + }, + { accessorKey: 'isBiddingParticipated', header: '입찰참여', cell: ({ row }) => { @@ -183,41 +254,6 @@ export function getBiddingDetailVendorColumns({ </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuLabel>작업</DropdownMenuLabel> - <DropdownMenuItem - onClick={() => onEdit(vendor)} - disabled={vendor.isBiddingParticipated !== true || biddingStatus === 'vendor_selected'} - > - 발주비율 산정 - {vendor.isBiddingParticipated !== true && ( - <span className="text-xs text-muted-foreground ml-2">(입찰참여 필요)</span> - )} - {biddingStatus === 'vendor_selected' && ( - <span className="text-xs text-muted-foreground ml-2">(낙찰 완료)</span> - )} - </DropdownMenuItem> - - {/* 입찰 참여여부 관리 */} - {vendor.isBiddingParticipated === null && onUpdateParticipation && ( - <> - <DropdownMenuSeparator /> - <DropdownMenuItem onClick={() => onUpdateParticipation(vendor, true)}> - 응찰 설정 - </DropdownMenuItem> - <DropdownMenuItem onClick={() => onUpdateParticipation(vendor, false)}> - 응찰포기 설정 - </DropdownMenuItem> - </> - )} - - {/* 입찰 보내기 (응찰한 업체만) */} - {vendor.isBiddingParticipated === true && onSendBidding && ( - <> - <DropdownMenuSeparator /> - <DropdownMenuItem onClick={() => onSendBidding(vendor)}> - 입찰 보내기 - </DropdownMenuItem> - </> - )} {/* 입찰 히스토리 (응찰한 업체만) */} {vendor.isBiddingParticipated === true && onViewQuotationHistory && ( diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx index edb72aca..fffac0c1 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx @@ -25,7 +25,6 @@ interface BiddingDetailVendorTableContentProps { vendors: QuotationVendor[] onRefresh: () => void onOpenSelectionReasonDialog: () => void - onEdit?: (vendor: QuotationVendor) => void onViewItemDetails?: (vendor: QuotationVendor) => void onViewQuotationHistory?: (vendor: QuotationVendor) => void } @@ -86,7 +85,6 @@ export function BiddingDetailVendorTableContent({ bidding, vendors, onRefresh, - onEdit, onViewItemDetails, onViewQuotationHistory }: BiddingDetailVendorTableContentProps) { @@ -96,8 +94,8 @@ export function BiddingDetailVendorTableContent({ // 세션에서 사용자 ID 가져오기 const userId = session?.user?.id || '' const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null) - const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false) const [isAwardDialogOpen, setIsAwardDialogOpen] = React.useState(false) + const [isAwardRatioDialogOpen, setIsAwardRatioDialogOpen] = React.useState(false) const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null) const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false) const [quotationHistoryData, setQuotationHistoryData] = React.useState<any>(null) @@ -116,11 +114,6 @@ export function BiddingDetailVendorTableContent({ } | null>(null) const [isApprovalPreviewDialogOpen, setIsApprovalPreviewDialogOpen] = React.useState(false) - const handleEdit = (vendor: QuotationVendor) => { - setSelectedVendor(vendor) - setIsEditDialogOpen(true) - } - const handleViewPriceAdjustment = async (vendor: QuotationVendor) => { try { const priceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(vendor.id) @@ -179,13 +172,15 @@ export function BiddingDetailVendorTableContent({ const columns = React.useMemo( () => getBiddingDetailVendorColumns({ - onEdit: onEdit || handleEdit, onViewPriceAdjustment: handleViewPriceAdjustment, onViewItemDetails: onViewItemDetails, onViewQuotationHistory: onViewQuotationHistory || handleViewQuotationHistory, - biddingStatus: bidding.status + biddingStatus: bidding.status, + biddingTargetPrice: bidding.targetPrice, + biddingFinalBidPrice: bidding.finalBidPrice, + biddingCurrency: bidding.currency || undefined }), - [onEdit, handleEdit, handleViewPriceAdjustment, onViewItemDetails, onViewQuotationHistory, handleViewQuotationHistory, bidding.status] + [handleViewPriceAdjustment, onViewItemDetails, onViewQuotationHistory, handleViewQuotationHistory, bidding.status, bidding.targetPrice, bidding.finalBidPrice, bidding.currency] ) const { table } = useDataTable({ @@ -203,6 +198,18 @@ export function BiddingDetailVendorTableContent({ clearOnDefault: true, }) + // single select된 vendor 가져오기 + const selectedRows = table.getSelectedRowModel().rows + const singleSelectedVendor = selectedRows.length === 1 ? selectedRows[0].original : null + + // 발주비율 산정 버튼 핸들러 + const handleOpenAwardRatioDialog = () => { + if (singleSelectedVendor) { + setSelectedVendor(singleSelectedVendor) + setIsAwardRatioDialogOpen(true) + } + } + // 낙찰 결재 상신 핸들러 const handleAwardApprovalConfirm = async (data: { approvers: string[]; title: string; attachments?: File[] }) => { if (!session?.user?.id || !approvalPreviewData) return @@ -258,16 +265,19 @@ export function BiddingDetailVendorTableContent({ bidding={bidding} userId={userId} onOpenAwardDialog={() => setIsAwardDialogOpen(true)} + onOpenAwardRatioDialog={handleOpenAwardRatioDialog} onSuccess={onRefresh} winnerVendor={vendors.find(v => v.awardRatio === 100)} + singleSelectedVendor={singleSelectedVendor} /> </DataTableAdvancedToolbar> </DataTable> + {/* 발주비율 산정 Dialog */} <BiddingDetailVendorEditDialog vendor={selectedVendor} - open={isEditDialogOpen} - onOpenChange={setIsEditDialogOpen} + open={isAwardRatioDialogOpen} + onOpenChange={setIsAwardRatioDialogOpen} onSuccess={onRefresh} biddingAwardCount={bidding.awardCount || undefined} biddingStatus={bidding.status} 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 53fe05f9..8df29289 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -6,7 +6,7 @@ import { useTransition } from "react" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw } from "lucide-react" -import { registerBidding, markAsDisposal, createRebidding, cancelAwardRatio } from "@/lib/bidding/detail/service" +import { registerBidding, markAsDisposal, cancelAwardRatio } from "@/lib/bidding/detail/service" import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service" import { increaseRoundOrRebid } from "@/lib/bidding/service" @@ -21,8 +21,10 @@ interface BiddingDetailVendorToolbarActionsProps { bidding: Bidding userId: string onOpenAwardDialog: () => void + onOpenAwardRatioDialog: () => void onSuccess: () => void winnerVendor?: QuotationVendor | null // 100% 낙찰된 벤더 + singleSelectedVendor?: QuotationVendor | null // single select된 벤더 } export function BiddingDetailVendorToolbarActions({ @@ -30,8 +32,10 @@ export function BiddingDetailVendorToolbarActions({ bidding, userId, onOpenAwardDialog, + onOpenAwardRatioDialog, onSuccess, - winnerVendor + winnerVendor, + singleSelectedVendor }: BiddingDetailVendorToolbarActionsProps) { const router = useRouter() const { toast } = useToast() @@ -210,18 +214,16 @@ export function BiddingDetailVendorToolbarActions({ const result = await increaseRoundOrRebid(bidding.id, userId, 'round_increase') if (result.success) { - const successResult = result as { success: true; message: string; biddingId: number; biddingNumber: string } toast({ title: "성공", - description: successResult.message, + description: '차수증가가 완료되었습니다.', }) router.push(`/evcp/bid`) onSuccess() } else { - const errorResult = result as { success: false; error: string } toast({ title: "오류", - description: errorResult.error || "차수증가 중 오류가 발생했습니다.", + description: result.error || "차수증가 중 오류가 발생했습니다.", variant: 'destructive', }) } @@ -245,6 +247,19 @@ export function BiddingDetailVendorToolbarActions({ </Button> )} + {/* 발주비율 산정: single select 시에만 활성화 */} + {(bidding.status === 'evaluation_of_bidding') && ( + <Button + variant="outline" + size="sm" + onClick={onOpenAwardRatioDialog} + disabled={!singleSelectedVendor || isPending || singleSelectedVendor.isBiddingParticipated !== true} + > + <DollarSign className="mr-2 h-4 w-4" /> + 발주비율 산정 + </Button> + )} + {/* 유찰/낙찰: 입찰공고 또는 입찰평가중 상태에서만 */} {(bidding.status === 'bidding_opened' || bidding.status === 'evaluation_of_bidding') && ( <> diff --git a/lib/bidding/list/biddings-table.tsx b/lib/bidding/list/biddings-table.tsx index 35d57726..f7d57cd7 100644 --- a/lib/bidding/list/biddings-table.tsx +++ b/lib/bidding/list/biddings-table.tsx @@ -67,9 +67,6 @@ export function BiddingsTable({ promises }: BiddingsTableProps) { // 상세 페이지로 이동 (info 페이지로) router.push(`/evcp/bid/${rowAction.row.original.id}/info`) break - case "update": - // EditBiddingSheet는 아래에서 별도로 처리 - break case "specification_meeting": setSpecMeetingDialogOpen(true) break @@ -175,14 +172,7 @@ export function BiddingsTable({ promises }: BiddingsTableProps) { /> </DataTableAdvancedToolbar> </DataTable> - - <EditBiddingSheet - open={rowAction?.type === "update"} - onOpenChange={() => setRowAction(null)} - bidding={rowAction?.row.original} - onSuccess={() => router.refresh()} - /> - + {/* 사양설명회 다이얼로그 */} <SpecificationMeetingDialog open={specMeetingDialogOpen} diff --git a/lib/bidding/list/edit-bidding-sheet.tsx b/lib/bidding/list/edit-bidding-sheet.tsx deleted file mode 100644 index 23f76f4a..00000000 --- a/lib/bidding/list/edit-bidding-sheet.tsx +++ /dev/null @@ -1,578 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { Loader2 } from "lucide-react" -import { toast } from "sonner" -import { useSession } from "next-auth/react" - -import { Button } from "@/components/ui/button" -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -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 { updateBidding, type UpdateBiddingInput } from "@/lib/bidding/service" -import { - updateBiddingSchema, - type UpdateBiddingSchema -} from "@/lib/bidding/validation" -import { BiddingListView } from "@/db/schema" -import { - biddingStatusLabels, - contractTypeLabels, - biddingTypeLabels, - awardCountLabels -} from "@/db/schema" -import { formatDate } from "@/lib/utils" - -interface EditBiddingSheetProps { - open: boolean - onOpenChange: (open: boolean) => void - bidding: BiddingListView | null - onSuccess?: () => void -} - -export function EditBiddingSheet({ - open, - onOpenChange, - bidding, - onSuccess -}: EditBiddingSheetProps) { - const router = useRouter() - const [isSubmitting, setIsSubmitting] = React.useState(false) - const { data: session } = useSession() - - const form = useForm<UpdateBiddingSchema>({ - resolver: zodResolver(updateBiddingSchema), - defaultValues: { - biddingNumber: "", - revision: 0, - projectName: "", - itemName: "", - title: "", - description: "", - content: "", - - contractType: "general", - biddingType: "equipment", - awardCount: "single", - contractStartDate: "", - contractEndDate: "", - - preQuoteDate: "", - biddingRegistrationDate: "", - submissionStartDate: "", - submissionEndDate: "", - evaluationDate: "", - - hasSpecificationMeeting: false, - hasPrDocument: false, - prNumber: "", - - currency: "KRW", - budget: "", - targetPrice: "", - finalBidPrice: "", - - isPublic: false, - isUrgent: false, - - status: "bidding_generated", - managerName: "", - managerEmail: "", - managerPhone: "", - - remarks: "", - }, - }) - - // 시트가 열릴 때 기존 데이터로 폼 초기화 - React.useEffect(() => { - if (open && bidding) { - form.reset({ - biddingNumber: bidding.biddingNumber || "", - revision: bidding.revision || 0, - projectName: bidding.projectName || "", - itemName: bidding.itemName || "", - title: bidding.title || "", - description: bidding.description || "", - content: bidding.content || "", - - contractType: bidding.contractType || "general", - biddingType: bidding.biddingType || "equipment", - awardCount: bidding.awardCount || "single", - contractStartDate: formatDate(bidding.contractStartDate, "kr"), - contractEndDate: formatDate(bidding.contractEndDate, "kr"), - - preQuoteDate: formatDate(bidding.preQuoteDate, "kr"), - biddingRegistrationDate: formatDate(bidding.biddingRegistrationDate, "kr"), - submissionStartDate: formatDate(bidding.submissionStartDate, "kr"), - submissionEndDate: formatDate(bidding.submissionEndDate, "kr"), - evaluationDate: formatDate(bidding.evaluationDate, "kr"), - - hasSpecificationMeeting: bidding.hasSpecificationMeeting || false, - hasPrDocument: bidding.hasPrDocument || false, - prNumber: bidding.prNumber || "", - - currency: bidding.currency || "KRW", - budget: bidding.budget?.toString() || "", - targetPrice: bidding.targetPrice?.toString() || "", - finalBidPrice: bidding.finalBidPrice?.toString() || "", - - status: bidding.status || "bidding_generated", - isPublic: bidding.isPublic || false, - isUrgent: bidding.isUrgent || false, - managerName: bidding.managerName || "", - managerEmail: bidding.managerEmail || "", - managerPhone: bidding.managerPhone || "", - - remarks: bidding.remarks || "", - }) - } - }, [open, bidding, form]) - - // 폼 제출 - async function onSubmit(data: UpdateBiddingSchema) { - if (!bidding) return - - setIsSubmitting(true) - try { - const userId = session?.user?.id?.toString() || "1" - const input: UpdateBiddingInput = { - id: bidding.id, - ...data, - } - - const result = await updateBidding(input, userId) - - if (result.success) { - toast.success(result.message) - onOpenChange(false) - onSuccess?.() - } else { - toast.error(result.error || "입찰 수정에 실패했습니다.") - } - } catch (error) { - console.error("Error updating bidding:", error) - toast.error("입찰 수정 중 오류가 발생했습니다.") - } finally { - setIsSubmitting(false) - } - } - - // 시트 닫기 핸들러 - const handleOpenChange = (open: boolean) => { - onOpenChange(open) - if (!open) { - form.reset() - } - } - - if (!bidding) { - return null - } - - return ( - <Sheet open={open} onOpenChange={handleOpenChange}> - <SheetContent className="flex flex-col h-full sm:max-w-2xl overflow-hidden"> - <SheetHeader className="flex-shrink-0 text-left pb-6"> - <SheetTitle>입찰 수정</SheetTitle> - <SheetDescription> - 입찰 정보를 수정합니다. ({bidding.biddingNumber}) - </SheetDescription> - </SheetHeader> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> - {/* 스크롤 가능한 컨텐츠 영역 */} - <div className="flex-1 overflow-y-auto pr-2 -mr-2"> - <div className="space-y-6"> - {/* 기본 정보 */} - <Card> - <CardHeader> - <CardTitle className="text-lg">기본 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="biddingNumber" - render={({ field }) => ( - <FormItem> - <FormLabel>입찰번호</FormLabel> - <FormControl> - <Input {...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>입찰명</FormLabel> - <FormControl> - <Input {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="projectName" - render={({ field }) => ( - <FormItem> - <FormLabel>프로젝트명</FormLabel> - <FormControl> - <Input {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="itemName" - render={({ field }) => ( - <FormItem> - <FormLabel>품목명</FormLabel> - <FormControl> - <Input {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - </CardContent> - </Card> - - {/* 계약 정보 */} - <Card> - <CardHeader> - <CardTitle className="text-lg">계약 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="contractType" - render={({ field }) => ( - <FormItem> - <FormLabel>계약구분</FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue /> - </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>입찰유형</FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - </FormControl> - <SelectContent> - {Object.entries(biddingTypeLabels).map(([value, label]) => ( - <SelectItem key={value} value={value}> - {label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - </div> - - {/* 계약 기간 */} - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="contractStartDate" - render={({ field }) => ( - <FormItem> - <FormLabel>계약 시작일</FormLabel> - <FormControl> - <Input - type="date" - {...field} - min="1900-01-01" - max="2100-12-31" - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="contractEndDate" - render={({ field }) => ( - <FormItem> - <FormLabel>계약 종료일</FormLabel> - <FormControl> - <Input - type="date" - {...field} - min="1900-01-01" - max="2100-12-31" - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - {/* <FormField - control={form.control} - name="status" - render={({ field }) => ( - <FormItem> - <FormLabel>상태</FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - </FormControl> - <SelectContent> - {Object.entries(biddingStatusLabels).map(([value, label]) => ( - <SelectItem key={value} value={value}> - {label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> */} - - <div className="space-y-3"> - <FormField - control={form.control} - name="isPublic" - render={({ field }) => ( - <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> - <div className="space-y-0.5"> - <FormLabel className="text-sm"> - 공개 입찰 - </FormLabel> - <FormDescription className="text-xs"> - 공개 입찰 여부를 설정합니다 - </FormDescription> - </div> - <FormControl> - <Switch - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="isUrgent" - render={({ field }) => ( - <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3"> - <div className="space-y-0.5"> - <FormLabel className="text-sm"> - 긴급 입찰 - </FormLabel> - <FormDescription className="text-xs"> - 긴급 입찰 여부를 설정합니다 - </FormDescription> - </div> - <FormControl> - <Switch - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - </FormItem> - )} - /> - </div> - </CardContent> - </Card> - - {/* 담당자 정보 */} - <Card> - <CardHeader> - <CardTitle className="text-lg">담당자 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - <FormField - control={form.control} - name="managerName" - render={({ field }) => ( - <FormItem> - <FormLabel>담당자명</FormLabel> - <FormControl> - <Input {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="managerEmail" - render={({ field }) => ( - <FormItem> - <FormLabel>이메일</FormLabel> - <FormControl> - <Input type="email" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="managerPhone" - render={({ field }) => ( - <FormItem> - <FormLabel>전화번호</FormLabel> - <FormControl> - <Input {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - </CardContent> - </Card> - - {/* 비고 */} - <Card> - <CardHeader> - <CardTitle className="text-lg">비고</CardTitle> - </CardHeader> - <CardContent> - <FormField - control={form.control} - name="remarks" - render={({ field }) => ( - <FormItem> - <FormControl> - <Textarea - placeholder="추가 메모나 특이사항" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </CardContent> - </Card> - </div> - </div> - - {/* 고정된 버튼 영역 */} - <div className="flex-shrink-0 flex justify-end gap-3 pt-6 mt-6 border-t bg-background"> - <Button - type="button" - variant="outline" - onClick={() => handleOpenChange(false)} - disabled={isSubmitting} - > - 취소 - </Button> - <Button - type="submit" - disabled={isSubmitting} - > - {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - 수정 - </Button> - </div> - </form> - </Form> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/bidding/selection/actions.ts b/lib/bidding/selection/actions.ts index 0d2a8a75..f19fbe6d 100644 --- a/lib/bidding/selection/actions.ts +++ b/lib/bidding/selection/actions.ts @@ -17,6 +17,7 @@ import { vendorSelectionResults, biddingDocuments } from "@/db/schema" +import { saveFile } from '@/lib/file-stroage' interface SaveSelectionResultData { biddingId: number @@ -82,19 +83,37 @@ export async function saveSelectionResult(data: SaveSelectionResultData) { )) // 새 첨부파일 저장 - const documentInserts = data.attachments.map(file => ({ - biddingId: data.biddingId, - companyId: null, - documentType: 'selection_result' as const, - fileName: file.name, - originalFileName: file.name, - fileSize: file.size, - mimeType: file.type, - filePath: `/uploads/bidding/${data.biddingId}/selection/${file.name}`, // 실제 파일 저장 로직 필요 - uploadedBy: session.user.id - })) + const documentInserts: Array<typeof biddingDocuments.$inferInsert> = [] + + for (const file of data.attachments) { + // saveFile을 사용하여 파일 저장 + const saveResult = await saveFile({ + file, + directory: `bidding/${data.biddingId}/selection`, + originalName: file.name, + userId: session.user.id + }) - await db.insert(biddingDocuments).values(documentInserts) + if (saveResult.success && saveResult.publicPath) { + documentInserts.push({ + biddingId: data.biddingId, + companyId: null, + documentType: 'selection_result' as const, + fileName: saveResult.fileName || file.name, + originalFileName: saveResult.originalName || file.name, + fileSize: saveResult.fileSize || file.size, + mimeType: file.type, + filePath: saveResult.publicPath, + uploadedBy: session.user.id + }) + } else { + console.error('Failed to save file:', saveResult.error) + } + } + + if (documentInserts.length > 0) { + await db.insert(biddingDocuments).values(documentInserts) + } } revalidatePath(`/evcp/bid-selection/${data.biddingId}/detail`) diff --git a/lib/bidding/selection/selection-result-form.tsx b/lib/bidding/selection/selection-result-form.tsx index 7f1229a2..54687cc9 100644 --- a/lib/bidding/selection/selection-result-form.tsx +++ b/lib/bidding/selection/selection-result-form.tsx @@ -8,14 +8,13 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' -import { FileUpload } from '@/components/ui/file-upload' import { useToast } from '@/hooks/use-toast' import { saveSelectionResult } from './actions' -import { Loader2, Save } from 'lucide-react' +import { Loader2, Save, FileText } from 'lucide-react' +import { Dropzone, DropzoneZone, DropzoneUploadIcon, DropzoneTitle, DropzoneDescription, DropzoneInput } from '@/components/ui/dropzone' const selectionResultSchema = z.object({ summary: z.string().min(1, '결과요약을 입력해주세요'), - attachments: z.array(z.any()).optional(), }) type SelectionResultFormData = z.infer<typeof selectionResultSchema> @@ -28,22 +27,26 @@ interface SelectionResultFormProps { export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFormProps) { const { toast } = useToast() const [isSubmitting, setIsSubmitting] = React.useState(false) + const [attachmentFiles, setAttachmentFiles] = React.useState<File[]>([]) const form = useForm<SelectionResultFormData>({ resolver: zodResolver(selectionResultSchema), defaultValues: { summary: '', - attachments: [], }, }) + const removeAttachmentFile = (index: number) => { + setAttachmentFiles(prev => prev.filter((_, i) => i !== index)) + } + const onSubmit = async (data: SelectionResultFormData) => { setIsSubmitting(true) try { const result = await saveSelectionResult({ biddingId, summary: data.summary, - attachments: data.attachments + attachments: attachmentFiles }) if (result.success) { @@ -99,33 +102,66 @@ export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFor /> {/* 첨부파일 */} - <FormField - control={form.control} - name="attachments" - render={({ field }) => ( - <FormItem> - <FormLabel>첨부파일</FormLabel> - <FormControl> - <FileUpload - value={field.value || []} - onChange={field.onChange} - 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', '.gif'], - }} - maxSize={10 * 1024 * 1024} // 10MB - maxFiles={5} - placeholder="선정결과 관련 문서를 업로드해주세요" - /> - </FormControl> - <FormMessage /> - </FormItem> + <div className="space-y-4"> + <FormLabel>첨부파일</FormLabel> + <Dropzone + maxSize={10 * 1024 * 1024} // 10MB + onDropAccepted={(files) => { + const newFiles = Array.from(files) + setAttachmentFiles(prev => [...prev, ...newFiles]) + }} + onDropRejected={() => { + toast({ + title: "파일 업로드 거부", + description: "파일 크기 및 형식을 확인해주세요.", + variant: "destructive", + }) + }} + > + <DropzoneZone> + <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" /> + <DropzoneTitle className="text-lg font-medium"> + 파일을 드래그하거나 클릭하여 업로드 + </DropzoneTitle> + <DropzoneDescription className="text-sm text-muted-foreground"> + PDF, Word, Excel, 이미지 파일 (최대 10MB) + </DropzoneDescription> + </DropzoneZone> + <DropzoneInput /> + </Dropzone> + + {attachmentFiles.length > 0 && ( + <div className="space-y-2"> + <h4 className="text-sm font-medium">업로드된 파일</h4> + <div className="space-y-2"> + {attachmentFiles.map((file, index) => ( + <div + key={index} + className="flex items-center justify-between p-3 bg-muted rounded-lg" + > + <div className="flex items-center gap-3"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <div> + <p className="text-sm font-medium">{file.name}</p> + <p className="text-xs text-muted-foreground"> + {(file.size / 1024 / 1024).toFixed(2)} MB + </p> + </div> + </div> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeAttachmentFile(index)} + > + 제거 + </Button> + </div> + ))} + </div> + </div> )} - /> + </div> {/* 저장 버튼 */} <div className="flex justify-end"> diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index 1ae23e81..0064b66f 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -39,6 +39,7 @@ import { import { revalidatePath } from 'next/cache' import { filterColumns } from '@/lib/filter-columns' import { GetBiddingsSchema, CreateBiddingSchema } from './validation' +import { saveFile } from '../file-stroage' @@ -57,7 +58,6 @@ export async function getUserCodeByEmail(email: string): Promise<string | null> return null } } -import { saveFile } from '../file-stroage' // userId를 user.name으로 변환하는 유틸리티 함수 async function getUserNameById(userId: string): Promise<string> { @@ -798,21 +798,47 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { } } - // 담당자 정보 준비 - let bidPicId = input.bidPicId ? parseInt(input.bidPicId.toString()) : null + // 담당자 정보 준비 - bidPicCode로 users 테이블에서 조회 + let bidPicId: number | null = null let bidPicName = input.bidPicName || null - if (!bidPicId && input.bidPicCode) { + if (input.bidPicCode) { try { - const userInfo = await findUserInfoByEKGRP(input.bidPicCode) - if (userInfo) { - bidPicId = userInfo.userId - bidPicName = userInfo.userName + const user = await db + .select({ id: users.id, name: users.name }) + .from(users) + .where(eq(users.userCode, input.bidPicCode)) + .limit(1) + + if (user.length > 0) { + bidPicId = user[0].id + bidPicName = bidPicName || user[0].name } } catch (e) { - console.error('Failed to find user info by EKGRP:', e) + console.error('Failed to find user by userCode:', e) } } + + // // 조달담당자 정보 준비 - supplyPicCode로 users 테이블에서 조회 + // let supplyPicId: number | null = null + // let supplyPicName = input.supplyPicName || null + + // if (input.supplyPicCode) { + // try { + // const user = await db + // .select({ id: users.id, name: users.name }) + // .from(users) + // .where(eq(users.userCode, input.supplyPicCode)) + // .limit(1) + + // if (user.length > 0) { + // supplyPicId = user[0].id + // supplyPicName = supplyPicName || user[0].name + // } + // } catch (e) { + // console.error('Failed to find user by procurementManagerCode:', e) + // } + // } // 1. 입찰 생성 const [newBidding] = await tx @@ -1161,140 +1187,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { } } } -// 입찰 수정 -export async function updateBidding(input: UpdateBiddingInput, userId: string) { - try { - const userName = await getUserNameById(userId) - // 존재 여부 확인 - const existing = await db - .select({ id: biddings.id }) - .from(biddings) - .where(eq(biddings.id, input.id)) - .limit(1) - - if (existing.length === 0) { - return { - success: false, - error: '존재하지 않는 입찰입니다.' - } - } - // 입찰번호 중복 체크 (다른 레코드에서) - if (input.biddingNumber) { - const duplicate = await db - .select({ id: biddings.id }) - .from(biddings) - .where(eq(biddings.biddingNumber, input.biddingNumber)) - .limit(1) - - if (duplicate.length > 0 && duplicate[0].id !== input.id) { - return { - success: false, - error: '이미 존재하는 입찰번호입니다.' - } - } - } - - // 날짜 문자열을 Date 객체로 변환 - const parseDate = (dateStr?: string) => { - if (!dateStr) return undefined - try { - return new Date(dateStr) - } catch { - return undefined - } - } - - // 업데이트할 데이터 준비 - const updateData: any = { - updatedAt: new Date(), - updatedBy: userName, - } - - // 정의된 필드들만 업데이트 - if (input.biddingNumber !== undefined) updateData.biddingNumber = input.biddingNumber - if (input.revision !== undefined) updateData.revision = input.revision - if (input.projectName !== undefined) updateData.projectName = input.projectName - if (input.itemName !== undefined) updateData.itemName = input.itemName - if (input.title !== undefined) updateData.title = input.title - if (input.description !== undefined) updateData.description = input.description - if (input.content !== undefined) updateData.content = input.content - - if (input.contractType !== undefined) updateData.contractType = input.contractType - if (input.noticeType !== undefined) updateData.noticeType = input.noticeType - if (input.biddingType !== undefined) updateData.biddingType = input.biddingType - if (input.awardCount !== undefined) updateData.awardCount = input.awardCount - if (input.contractStartDate !== undefined) updateData.contractStartDate = parseDate(input.contractStartDate) - if (input.contractEndDate !== undefined) updateData.contractEndDate = parseDate(input.contractEndDate) - - if (input.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(input.submissionStartDate) - if (input.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(input.submissionEndDate) - if (input.evaluationDate !== undefined) updateData.evaluationDate = parseDate(input.evaluationDate) - - if (input.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = input.hasSpecificationMeeting - if (input.hasPrDocument !== undefined) updateData.hasPrDocument = input.hasPrDocument - if (input.prNumber !== undefined) updateData.prNumber = input.prNumber - - if (input.currency !== undefined) updateData.currency = input.currency - if (input.budget !== undefined) updateData.budget = input.budget ? parseFloat(input.budget) : null - if (input.targetPrice !== undefined) updateData.targetPrice = input.targetPrice ? parseFloat(input.targetPrice) : null - if (input.finalBidPrice !== undefined) updateData.finalBidPrice = input.finalBidPrice ? parseFloat(input.finalBidPrice) : null - - if (input.status !== undefined) updateData.status = input.status - if (input.isPublic !== undefined) updateData.isPublic = input.isPublic - if (input.isUrgent !== undefined) updateData.isUrgent = input.isUrgent - - // 구매조직 - if (input.purchasingOrganization !== undefined) updateData.purchasingOrganization = input.purchasingOrganization - - // 담당자 정보 (user FK) - if (input.bidPicId !== undefined) updateData.bidPicId = input.bidPicId - if (input.bidPicName !== undefined) updateData.bidPicName = input.bidPicName - - // bidPicCode가 있으면 담당자 정보 자동 조회 및 업데이트 - if (input.bidPicCode !== undefined) { - updateData.bidPicCode = input.bidPicCode - // bidPicId가 명시적으로 제공되지 않았고 코드가 있는 경우 자동 조회 - if (!input.bidPicId && input.bidPicCode) { - try { - const userInfo = await findUserInfoByEKGRP(input.bidPicCode) - if (userInfo) { - updateData.bidPicId = userInfo.userId - updateData.bidPicName = userInfo.userName - } - } catch (e) { - console.error('Failed to find user info by EKGRP:', e) - } - } - } - - if (input.supplyPicId !== undefined) updateData.supplyPicId = input.supplyPicId - if (input.supplyPicName !== undefined) updateData.supplyPicName = input.supplyPicName - - if (input.remarks !== undefined) updateData.remarks = input.remarks - - // 입찰 수정 - await db - .update(biddings) - .set(updateData) - .where(eq(biddings.id, input.id)) - - revalidatePath('/evcp/bid') - revalidatePath(`/evcp/bid/${input.id}/info`) - - return { - success: true, - message: '입찰이 성공적으로 수정되었습니다.' - } - - } catch (error) { - console.error('Error updating bidding:', error) - return { - success: false, - error: '입찰 수정 중 오류가 발생했습니다.' - } - } -} // 입찰 삭제 export async function deleteBidding(id: number) { @@ -1904,12 +1797,62 @@ export async function updateBiddingBasicInfo( if (updates.hasPrDocument !== undefined) updateData.hasPrDocument = updates.hasPrDocument if (updates.currency !== undefined) updateData.currency = updates.currency if (updates.purchasingOrganization !== undefined) updateData.purchasingOrganization = updates.purchasingOrganization - if (updates.bidPicName !== undefined) updateData.bidPicName = updates.bidPicName - if (updates.bidPicCode !== undefined) updateData.bidPicCode = updates.bidPicCode if (updates.supplyPicName !== undefined) updateData.supplyPicName = updates.supplyPicName if (updates.supplyPicCode !== undefined) updateData.supplyPicCode = updates.supplyPicCode if (updates.requesterName !== undefined) updateData.requesterName = updates.requesterName if (updates.remarks !== undefined) updateData.remarks = updates.remarks + + // 담당자 정보 - bidPicCode로 users 테이블에서 조회 + if (updates.bidPicCode !== undefined) { + updateData.bidPicCode = updates.bidPicCode + + if (updates.bidPicCode) { + try { + const user = await db + .select({ id: users.id, name: users.name }) + .from(users) + .where(eq(users.userCode, updates.bidPicCode)) + .limit(1) + + if (user.length > 0) { + updateData.bidPicId = user[0].id + updateData.bidPicName = updates.bidPicName || user[0].name + } + } catch (e) { + console.error('Failed to find user by userCode:', e) + } + } + } + + if (updates.bidPicName !== undefined && updates.bidPicCode === undefined) { + updateData.bidPicName = updates.bidPicName + } + + // // 조달담당자 정보 - supplyPicCode로 users 테이블에서 조회 + // if (updates.supplyPicCode !== undefined) { + // updateData.supplyPicCode = updates.supplyPicCode + + // if (updates.supplyPicCode) { + // try { + // const user = await db + // .select({ id: users.id, name: users.name }) + // .from(users) + // .where(eq(users.userCode, updates.supplyPicCode)) + // .limit(1) + + // if (user.length > 0) { + // updateData.supplyPicId = user[0].id + // updateData.supplyPicName = updates.supplyPicName || user[0].name + // } + // } catch (e) { + // console.error('Failed to find user by procurementManagerCode:', e) + // } + // } + // } + + // if (updates.supplyPicName !== undefined && updates.supplyPicCode === undefined) { + // updateData.supplyPicName = updates.supplyPicName + // } // 데이터베이스 업데이트 await db |
