summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx112
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-table.tsx36
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx27
-rw-r--r--lib/bidding/list/biddings-table.tsx12
-rw-r--r--lib/bidding/list/edit-bidding-sheet.tsx578
-rw-r--r--lib/bidding/selection/actions.ts43
-rw-r--r--lib/bidding/selection/selection-result-form.tsx98
-rw-r--r--lib/bidding/service.ts231
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