summaryrefslogtreecommitdiff
path: root/lib/bidding/detail/table
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-27 12:06:26 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-27 12:06:26 +0000
commit7548e2ad6948f1c6aa102fcac408bc6c9c0f9796 (patch)
tree8e66703ec821888ad51dcc242a508813a027bf71 /lib/bidding/detail/table
parent7eac558470ef179dad626a8e82db5784fe86a556 (diff)
(대표님, 최겸) 기본계약, 입찰, 파일라우트, 계약서명라우트, 인포메이션, 메뉴설정, PQ(메일템플릿 관련)
Diffstat (limited to 'lib/bidding/detail/table')
-rw-r--r--lib/bidding/detail/table/bidding-detail-content.tsx93
-rw-r--r--lib/bidding/detail/table/bidding-detail-header.tsx328
-rw-r--r--lib/bidding/detail/table/bidding-detail-items-dialog.tsx138
-rw-r--r--lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx167
-rw-r--r--lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx238
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx223
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx335
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx260
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-table.tsx225
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx79
10 files changed, 2086 insertions, 0 deletions
diff --git a/lib/bidding/detail/table/bidding-detail-content.tsx b/lib/bidding/detail/table/bidding-detail-content.tsx
new file mode 100644
index 00000000..090e7218
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-content.tsx
@@ -0,0 +1,93 @@
+'use client'
+
+import * as React from 'react'
+import { Bidding } from '@/db/schema'
+import { QuotationDetails, QuotationVendor } from '@/lib/bidding/detail/service'
+import { BiddingDetailHeader } from './bidding-detail-header'
+import { BiddingDetailVendorTableContent } from './bidding-detail-vendor-table'
+import { BiddingDetailItemsDialog } from './bidding-detail-items-dialog'
+import { BiddingDetailTargetPriceDialog } from './bidding-detail-target-price-dialog'
+import { BiddingDetailSelectionReasonDialog } from './bidding-detail-selection-reason-dialog'
+
+interface BiddingDetailContentProps {
+ bidding: Bidding
+ quotationDetails: QuotationDetails | null
+ quotationVendors: QuotationVendor[]
+ biddingCompanies: any[]
+ prItems: any[]
+}
+
+export function BiddingDetailContent({
+ bidding,
+ quotationDetails,
+ quotationVendors,
+ biddingCompanies,
+ prItems
+}: BiddingDetailContentProps) {
+ const [dialogStates, setDialogStates] = React.useState({
+ items: false,
+ targetPrice: false,
+ selectionReason: false
+ })
+
+ const [refreshTrigger, setRefreshTrigger] = React.useState(0)
+
+ const handleRefresh = React.useCallback(() => {
+ setRefreshTrigger(prev => prev + 1)
+ }, [])
+
+ const openDialog = React.useCallback((type: keyof typeof dialogStates) => {
+ setDialogStates(prev => ({ ...prev, [type]: true }))
+ }, [])
+
+ const closeDialog = React.useCallback((type: keyof typeof dialogStates) => {
+ setDialogStates(prev => ({ ...prev, [type]: false }))
+ }, [])
+
+ return (
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="p-6">
+ <BiddingDetailHeader bidding={bidding} />
+
+ <div className="mt-6">
+ <BiddingDetailVendorTableContent
+ biddingId={bidding.id}
+ vendors={quotationVendors}
+ biddingCompanies={biddingCompanies}
+ onRefresh={handleRefresh}
+ onOpenItemsDialog={() => openDialog('items')}
+ onOpenTargetPriceDialog={() => openDialog('targetPrice')}
+ onOpenSelectionReasonDialog={() => openDialog('selectionReason')}
+ onEdit={undefined}
+ onDelete={undefined}
+ onSelectWinner={undefined}
+ />
+ </div>
+ </div>
+ </section>
+
+ <BiddingDetailItemsDialog
+ open={dialogStates.items}
+ onOpenChange={(open) => closeDialog('items')}
+ prItems={prItems}
+ bidding={bidding}
+ />
+
+ <BiddingDetailTargetPriceDialog
+ open={dialogStates.targetPrice}
+ onOpenChange={(open) => closeDialog('targetPrice')}
+ quotationDetails={quotationDetails}
+ bidding={bidding}
+ onSuccess={handleRefresh}
+ />
+
+ <BiddingDetailSelectionReasonDialog
+ open={dialogStates.selectionReason}
+ onOpenChange={(open) => closeDialog('selectionReason')}
+ bidding={bidding}
+ onSuccess={handleRefresh}
+ />
+ </div>
+ )
+}
diff --git a/lib/bidding/detail/table/bidding-detail-header.tsx b/lib/bidding/detail/table/bidding-detail-header.tsx
new file mode 100644
index 00000000..3135f37d
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-header.tsx
@@ -0,0 +1,328 @@
+'use client'
+
+import * as React from 'react'
+import { useRouter } from 'next/navigation'
+import { Bidding, biddingStatusLabels, contractTypeLabels, biddingTypeLabels } from '@/db/schema'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ ArrowLeft,
+ Send,
+ RotateCcw,
+ XCircle,
+ Calendar,
+ Building2,
+ User,
+ Package,
+ DollarSign,
+ Hash
+} from 'lucide-react'
+
+import { formatDate } from '@/lib/utils'
+import {
+ registerBidding,
+ markAsDisposal,
+ createRebidding
+} from '@/lib/bidding/detail/service'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface BiddingDetailHeaderProps {
+ bidding: Bidding
+}
+
+export function BiddingDetailHeader({ bidding }: BiddingDetailHeaderProps) {
+ const router = useRouter()
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+
+ const handleGoBack = () => {
+ router.push('/evcp/bid')
+ }
+
+ const handleRegister = () => {
+ // 상태 검증
+ if (bidding.status !== 'bidding_generated') {
+ toast({
+ title: '실행 불가',
+ description: '입찰 등록은 입찰 생성 상태에서만 가능합니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ if (!confirm('입찰을 등록하시겠습니까?')) return
+
+ startTransition(async () => {
+ const result = await registerBidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: result.message,
+ })
+ router.refresh()
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const handleMarkAsDisposal = () => {
+ // 상태 검증
+ if (bidding.status !== 'bidding_closed') {
+ toast({
+ title: '실행 불가',
+ description: '유찰 처리는 입찰 마감 상태에서만 가능합니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ if (!confirm('입찰을 유찰 처리하시겠습니까?')) return
+
+ startTransition(async () => {
+ const result = await markAsDisposal(bidding.id, 'current-user') // TODO: 실제 사용자 ID
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: result.message,
+ })
+ router.refresh()
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const handleCreateRebidding = () => {
+ // 상태 검증
+ if (bidding.status !== 'bidding_disposal') {
+ toast({
+ title: '실행 불가',
+ description: '재입찰은 유찰 상태에서만 가능합니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ if (!confirm('재입찰을 생성하시겠습니까?')) return
+
+ startTransition(async () => {
+ const result = await createRebidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: result.message,
+ })
+ // 새로 생성된 입찰로 이동
+ if (result.data) {
+ router.push(`/evcp/bid/${result.data.id}`)
+ } else {
+ router.refresh()
+ }
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const getActionButtons = () => {
+ const buttons = []
+
+ // 기본 액션 버튼들 (항상 표시)
+ buttons.push(
+ <Button
+ key="back"
+ variant="outline"
+ onClick={handleGoBack}
+ disabled={isPending}
+ >
+ <ArrowLeft className="w-4 h-4 mr-2" />
+ 목록으로
+ </Button>
+ )
+
+ // 모든 액션 버튼을 항상 표시 (상태 검증은 각 핸들러에서)
+ buttons.push(
+ <Button
+ key="register"
+ onClick={handleRegister}
+ disabled={isPending}
+ >
+ <Send className="w-4 h-4 mr-2" />
+ 입찰등록
+ </Button>
+ )
+
+ buttons.push(
+ <Button
+ key="disposal"
+ variant="destructive"
+ onClick={handleMarkAsDisposal}
+ disabled={isPending}
+ >
+ <XCircle className="w-4 h-4 mr-2" />
+ 유찰
+ </Button>
+ )
+
+ buttons.push(
+ <Button
+ key="rebidding"
+ onClick={handleCreateRebidding}
+ disabled={isPending}
+ >
+ <RotateCcw className="w-4 h-4 mr-2" />
+ 재입찰
+ </Button>
+ )
+
+ return buttons
+ }
+
+ return (
+ <div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
+ <div className="px-6 py-4">
+ {/* 헤더 메인 영역 */}
+ <div className="flex items-center justify-between mb-4">
+ <div className="flex items-center gap-4 flex-1 min-w-0">
+ {/* 제목과 배지 */}
+ <div className="flex items-center gap-3 flex-1 min-w-0">
+ <h1 className="text-xl font-semibold truncate">{bidding.title}</h1>
+ <div className="flex items-center gap-2 flex-shrink-0">
+ <Badge variant="outline" className="font-mono text-xs">
+ <Hash className="w-3 h-3 mr-1" />
+ {bidding.biddingNumber}
+ {bidding.revision && bidding.revision > 0 && ` Rev.${bidding.revision}`}
+ </Badge>
+ <Badge variant={
+ bidding.status === 'bidding_disposal' ? 'destructive' :
+ bidding.status === 'vendor_selected' ? 'default' :
+ 'secondary'
+ } className="text-xs">
+ {biddingStatusLabels[bidding.status]}
+ </Badge>
+ </div>
+ </div>
+
+ {/* 액션 버튼들 */}
+ <div className="flex items-center gap-2 flex-shrink-0">
+ {getActionButtons()}
+ </div>
+ </div>
+ </div>
+
+ {/* 세부 정보 영역 */}
+ <div className="flex flex-wrap items-center gap-6 text-sm">
+ {/* 프로젝트 정보 */}
+ {bidding.projectName && (
+ <div className="flex items-center gap-1.5 text-muted-foreground">
+ <Building2 className="w-4 h-4" />
+ <span className="font-medium">프로젝트:</span>
+ <span>{bidding.projectName}</span>
+ </div>
+ )}
+
+ {/* 품목 정보 */}
+ {bidding.itemName && (
+ <div className="flex items-center gap-1.5 text-muted-foreground">
+ <Package className="w-4 h-4" />
+ <span className="font-medium">품목:</span>
+ <span>{bidding.itemName}</span>
+ </div>
+ )}
+
+ {/* 담당자 정보 */}
+ {bidding.managerName && (
+ <div className="flex items-center gap-1.5 text-muted-foreground">
+ <User className="w-4 h-4" />
+ <span className="font-medium">담당자:</span>
+ <span>{bidding.managerName}</span>
+ </div>
+ )}
+
+ {/* 계약구분 */}
+ <div className="flex items-center gap-1.5 text-muted-foreground">
+ <span className="font-medium">계약:</span>
+ <span>{contractTypeLabels[bidding.contractType]}</span>
+ </div>
+
+ {/* 입찰유형 */}
+ <div className="flex items-center gap-1.5 text-muted-foreground">
+ <span className="font-medium">유형:</span>
+ <span>{biddingTypeLabels[bidding.biddingType]}</span>
+ </div>
+
+ {/* 낙찰수 */}
+ <div className="flex items-center gap-1.5 text-muted-foreground">
+ <span className="font-medium">낙찰:</span>
+ <span>{bidding.awardCount === 'single' ? '단수' : '복수'}</span>
+ </div>
+
+ {/* 통화 */}
+ <div className="flex items-center gap-1.5 text-muted-foreground">
+ <DollarSign className="w-4 h-4" />
+ <span className="font-mono">{bidding.currency}</span>
+ </div>
+
+ {/* 예산 정보 */}
+ {bidding.budget && (
+ <div className="flex items-center gap-1.5">
+ <span className="font-medium text-muted-foreground">예산:</span>
+ <span className="font-semibold">
+ {new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: bidding.currency || 'KRW',
+ }).format(Number(bidding.budget))}
+ </span>
+ </div>
+ )}
+ </div>
+
+ {/* 일정 정보 */}
+ {(bidding.submissionStartDate || bidding.evaluationDate || bidding.preQuoteDate || bidding.biddingRegistrationDate) && (
+ <div className="flex flex-wrap items-center gap-4 mt-3 pt-3 border-t border-border/50">
+ <Calendar className="w-4 h-4 text-muted-foreground flex-shrink-0" />
+ <div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
+ {bidding.submissionStartDate && bidding.submissionEndDate && (
+ <div>
+ <span className="font-medium">제출기간:</span> {formatDate(bidding.submissionStartDate, 'KR')} ~ {formatDate(bidding.submissionEndDate, 'KR')}
+ </div>
+ )}
+ {bidding.evaluationDate && (
+ <div>
+ <span className="font-medium">평가일:</span> {formatDate(bidding.evaluationDate, 'KR')}
+ </div>
+ )}
+ {bidding.preQuoteDate && (
+ <div>
+ <span className="font-medium">사전견적일:</span> {formatDate(bidding.preQuoteDate, 'KR')}
+ </div>
+ )}
+ {bidding.biddingRegistrationDate && (
+ <div>
+ <span className="font-medium">입찰등록일:</span> {formatDate(bidding.biddingRegistrationDate, 'KR')}
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/bidding/detail/table/bidding-detail-items-dialog.tsx b/lib/bidding/detail/table/bidding-detail-items-dialog.tsx
new file mode 100644
index 00000000..2bab3ef0
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-items-dialog.tsx
@@ -0,0 +1,138 @@
+'use client'
+
+import * as React from 'react'
+import { Bidding } from '@/db/schema'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Badge } from '@/components/ui/badge'
+import { formatDate } from '@/lib/utils'
+
+interface PrItem {
+ id: number
+ biddingId: number
+ itemName: string
+ itemCode: string
+ specification: string
+ quantity: number
+ unit: string
+ estimatedPrice: number
+ budget: number
+ deliveryDate: Date
+ notes: string
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface BiddingDetailItemsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ prItems: PrItem[]
+ bidding: Bidding
+}
+
+export function BiddingDetailItemsDialog({
+ open,
+ onOpenChange,
+ prItems,
+ bidding
+}: BiddingDetailItemsDialogProps) {
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-6xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>품목 정보</DialogTitle>
+ <DialogDescription>
+ 입찰번호: {bidding.biddingNumber} - 품목 상세 정보
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">프로젝트:</span> {bidding.projectName || '-'}
+ </div>
+ <div>
+ <span className="font-medium">품목:</span> {bidding.itemName || '-'}
+ </div>
+ </div>
+
+ <div className="border rounded-lg">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>품목코드</TableHead>
+ <TableHead>품목명</TableHead>
+ <TableHead>규격</TableHead>
+ <TableHead className="text-right">수량</TableHead>
+ <TableHead>단위</TableHead>
+ <TableHead className="text-right">예상단가</TableHead>
+ <TableHead className="text-right">예산</TableHead>
+ <TableHead>납기요청일</TableHead>
+ <TableHead>비고</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {prItems.length > 0 ? (
+ prItems.map((item) => (
+ <TableRow key={item.id}>
+ <TableCell className="font-mono text-sm">
+ {item.itemCode}
+ </TableCell>
+ <TableCell className="font-medium">
+ {item.itemName}
+ </TableCell>
+ <TableCell className="text-sm">
+ {item.specification || '-'}
+ </TableCell>
+ <TableCell className="text-right">
+ {item.quantity ? Number(item.quantity).toLocaleString() : '-'}
+ </TableCell>
+ <TableCell>{item.unit}</TableCell>
+ <TableCell className="text-right font-mono">
+ {item.estimatedPrice ? Number(item.estimatedPrice).toLocaleString() : '-'} {bidding.currency}
+ </TableCell>
+ <TableCell className="text-right font-mono">
+ {item.budget ? Number(item.budget).toLocaleString() : '-'} {bidding.currency}
+ </TableCell>
+ <TableCell className="text-sm">
+ {item.deliveryDate ? formatDate(item.deliveryDate, 'KR') : '-'}
+ </TableCell>
+ <TableCell className="text-sm">
+ {item.notes || '-'}
+ </TableCell>
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell colSpan={9} className="text-center py-8">
+ 등록된 품목이 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+
+ {prItems.length > 0 && (
+ <div className="text-sm text-muted-foreground">
+ 총 {prItems.length}개 품목
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx b/lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx
new file mode 100644
index 00000000..0e7ca364
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx
@@ -0,0 +1,167 @@
+'use client'
+
+import * as React from 'react'
+import { Bidding } from '@/db/schema'
+import { updateVendorSelectionReason } from '@/lib/bidding/detail/service'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Textarea } from '@/components/ui/textarea'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface BiddingDetailSelectionReasonDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ bidding: Bidding
+ onSuccess: () => void
+}
+
+export function BiddingDetailSelectionReasonDialog({
+ open,
+ onOpenChange,
+ bidding,
+ onSuccess
+}: BiddingDetailSelectionReasonDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [selectedCompanyId, setSelectedCompanyId] = React.useState<number | null>(null)
+ const [selectionReason, setSelectionReason] = React.useState('')
+
+ // 낙찰된 업체 정보 조회 (실제로는 bidding_companies에서 isWinner가 true인 업체를 조회해야 함)
+ React.useEffect(() => {
+ if (open) {
+ // TODO: 실제로는 낙찰된 업체 정보를 조회하여 selectedCompanyId를 설정
+ setSelectedCompanyId(null)
+ setSelectionReason('')
+ }
+ }, [open])
+
+ const handleSave = () => {
+ if (!selectedCompanyId) {
+ toast({
+ title: '유효성 오류',
+ description: '선정된 업체를 선택해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ if (!selectionReason.trim()) {
+ toast({
+ title: '유효성 오류',
+ description: '선정 사유를 입력해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ const result = await updateVendorSelectionReason(
+ bidding.id,
+ selectedCompanyId,
+ selectionReason,
+ 'current-user' // TODO: 실제 사용자 ID
+ )
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: result.message,
+ })
+ onSuccess()
+ onOpenChange(false)
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <DialogTitle>업체 선정 사유</DialogTitle>
+ <DialogDescription>
+ 입찰번호: {bidding.biddingNumber} - 낙찰 업체 선정 사유 입력
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* 낙찰 정보 */}
+ <div className="space-y-4">
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <Label htmlFor="biddingNumber">입찰번호</Label>
+ <div className="text-sm font-mono mt-1 p-2 bg-muted rounded">
+ {bidding.biddingNumber}
+ </div>
+ </div>
+ <div>
+ <Label htmlFor="projectName">프로젝트명</Label>
+ <div className="text-sm mt-1 p-2 bg-muted rounded">
+ {bidding.projectName || '-'}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 선정 업체 선택 */}
+ <div className="space-y-2">
+ <Label htmlFor="selectedCompany">선정된 업체</Label>
+ <Select
+ value={selectedCompanyId?.toString() || ''}
+ onValueChange={(value) => setSelectedCompanyId(Number(value))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="선정된 업체를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {/* TODO: 실제로는 낙찰된 업체 목록을 조회하여 표시 */}
+ <SelectItem value="1">업체 A</SelectItem>
+ <SelectItem value="2">업체 B</SelectItem>
+ <SelectItem value="3">업체 C</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 선정 사유 입력 */}
+ <div className="space-y-2">
+ <Label htmlFor="selectionReason">선정 사유</Label>
+ <Textarea
+ id="selectionReason"
+ value={selectionReason}
+ onChange={(e) => setSelectionReason(e.target.value)}
+ placeholder="업체 선정 사유를 상세히 입력해주세요."
+ rows={6}
+ />
+ <div className="text-sm text-muted-foreground">
+ 선정 사유는 추후 검토 및 감사에 활용됩니다. 구체적인 선정 기준과 이유를 명확히 기재해주세요.
+ </div>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleSave} disabled={isPending}>
+ 저장
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx b/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx
new file mode 100644
index 00000000..b9dd44dd
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx
@@ -0,0 +1,238 @@
+'use client'
+
+import * as React from 'react'
+import { Bidding } from '@/db/schema'
+import { QuotationDetails, updateTargetPrice } from '@/lib/bidding/detail/service'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface BiddingDetailTargetPriceDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ quotationDetails: QuotationDetails | null
+ bidding: Bidding
+ onSuccess: () => void
+}
+
+export function BiddingDetailTargetPriceDialog({
+ open,
+ onOpenChange,
+ quotationDetails,
+ bidding,
+ onSuccess
+}: BiddingDetailTargetPriceDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [targetPrice, setTargetPrice] = React.useState(
+ bidding.targetPrice ? Number(bidding.targetPrice) : 0
+ )
+ const [calculationCriteria, setCalculationCriteria] = React.useState(
+ (bidding as any).targetPriceCalculationCriteria || ''
+ )
+
+ // Dialog가 열릴 때 상태 초기화
+ React.useEffect(() => {
+ if (open) {
+ setTargetPrice(bidding.targetPrice ? Number(bidding.targetPrice) : 0)
+ setCalculationCriteria((bidding as any).targetPriceCalculationCriteria || '')
+ }
+ }, [open, bidding])
+
+ const handleSave = () => {
+ // 필수값 검증
+ if (targetPrice <= 0) {
+ toast({
+ title: '유효성 오류',
+ description: '내정가는 0보다 큰 값을 입력해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ if (!calculationCriteria.trim()) {
+ toast({
+ title: '유효성 오류',
+ description: '내정가 산정 기준을 입력해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ const result = await updateTargetPrice(
+ bidding.id,
+ targetPrice,
+ calculationCriteria.trim(),
+ 'current-user' // TODO: 실제 사용자 ID
+ )
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: result.message,
+ })
+ onSuccess()
+ onOpenChange(false)
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const formatCurrency = (amount: number) => {
+ return new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: bidding.currency || 'KRW',
+ }).format(amount)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[800px]">
+ <DialogHeader>
+ <DialogTitle>내정가 산정</DialogTitle>
+ <DialogDescription>
+ 입찰번호: {bidding.biddingNumber} - 견적 통계 및 내정가 설정
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[200px]">항목</TableHead>
+ <TableHead>값</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {/* 견적 통계 정보 */}
+ <TableRow>
+ <TableCell className="font-medium">예상액</TableCell>
+ <TableCell className="font-semibold">
+ {quotationDetails?.estimatedPrice ? formatCurrency(quotationDetails.estimatedPrice) : '-'}
+ </TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">최저견적가</TableCell>
+ <TableCell className="font-semibold text-green-600">
+ {quotationDetails?.lowestQuote ? formatCurrency(quotationDetails.lowestQuote) : '-'}
+ </TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">평균견적가</TableCell>
+ <TableCell className="font-semibold">
+ {quotationDetails?.averageQuote ? formatCurrency(quotationDetails.averageQuote) : '-'}
+ </TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">견적 수</TableCell>
+ <TableCell className="font-semibold">
+ {quotationDetails?.quotationCount || 0}개
+ </TableCell>
+ </TableRow>
+
+ {/* 예산 정보 */}
+ {bidding.budget && (
+ <TableRow>
+ <TableCell className="font-medium">예산</TableCell>
+ <TableCell className="font-semibold">
+ {formatCurrency(Number(bidding.budget))}
+ </TableCell>
+ </TableRow>
+ )}
+
+ {/* 최종 업데이트 시간 */}
+ {quotationDetails?.lastUpdated && (
+ <TableRow>
+ <TableCell className="font-medium">최종 업데이트</TableCell>
+ <TableCell className="text-sm text-muted-foreground">
+ {new Date(quotationDetails.lastUpdated).toLocaleString('ko-KR')}
+ </TableCell>
+ </TableRow>
+ )}
+
+ {/* 내정가 입력 */}
+ <TableRow>
+ <TableCell className="font-medium">
+ <Label htmlFor="targetPrice" className="text-sm font-medium">
+ 내정가 *
+ </Label>
+ </TableCell>
+ <TableCell>
+ <div className="space-y-2">
+ <Input
+ id="targetPrice"
+ type="number"
+ value={targetPrice}
+ onChange={(e) => setTargetPrice(Number(e.target.value))}
+ placeholder="내정가를 입력하세요"
+ className="w-full"
+ />
+ <div className="text-sm text-muted-foreground">
+ {targetPrice > 0 ? formatCurrency(targetPrice) : ''}
+ </div>
+ </div>
+ </TableCell>
+ </TableRow>
+
+ {/* 내정가 산정 기준 입력 */}
+ <TableRow>
+ <TableCell className="font-medium align-top pt-2">
+ <Label htmlFor="calculationCriteria" className="text-sm font-medium">
+ 내정가 산정 기준 *
+ </Label>
+ </TableCell>
+ <TableCell>
+ <Textarea
+ id="calculationCriteria"
+ value={calculationCriteria}
+ onChange={(e) => setCalculationCriteria(e.target.value)}
+ placeholder="내정가 산정 기준을 자세히 입력해주세요. (예: 최저견적가 대비 10% 상향 조정, 시장 평균가 고려 등)"
+ className="w-full min-h-[100px]"
+ rows={4}
+ />
+ <div className="text-xs text-muted-foreground mt-1">
+ 필수 입력 사항입니다. 내정가 산정에 대한 근거를 명확히 기재해주세요.
+ </div>
+ </TableCell>
+ </TableRow>
+ </TableBody>
+ </Table>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleSave} disabled={isPending}>
+ 저장
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
new file mode 100644
index 00000000..ef075459
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
@@ -0,0 +1,223 @@
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ MoreHorizontal, Edit, Trash2, Trophy
+} from "lucide-react"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { QuotationVendor } from "@/lib/bidding/detail/service"
+
+interface GetVendorColumnsProps {
+ onEdit: (vendor: QuotationVendor) => void
+ onDelete: (vendor: QuotationVendor) => void
+ onSelectWinner: (vendor: QuotationVendor) => void
+}
+
+export function getBiddingDetailVendorColumns({
+ onEdit,
+ onDelete,
+ onSelectWinner
+}: GetVendorColumnsProps): ColumnDef<QuotationVendor>[] {
+ return [
+ {
+ id: 'select',
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getIsAllPageRowsSelected()}
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모두 선택"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="행 선택"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: 'vendorName',
+ header: '업체명',
+ cell: ({ row }) => (
+ <div className="font-medium">{row.original.vendorName}</div>
+ ),
+ },
+ {
+ accessorKey: 'vendorCode',
+ header: '업체코드',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.original.vendorCode}</div>
+ ),
+ },
+ {
+ accessorKey: 'contactPerson',
+ header: '담당자',
+ cell: ({ row }) => (
+ <div className="text-sm">{row.original.contactPerson || '-'}</div>
+ ),
+ },
+ {
+ accessorKey: 'quotationAmount',
+ header: '견적금액',
+ cell: ({ row }) => (
+ <div className="text-right font-mono">
+ {row.original.quotationAmount ? Number(row.original.quotationAmount).toLocaleString() : '-'} {row.original.currency}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'awardRatio',
+ header: '발주비율',
+ cell: ({ row }) => (
+ <div className="text-right">
+ {row.original.awardRatio ? `${row.original.awardRatio}%` : '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'status',
+ header: '상태',
+ cell: ({ row }) => {
+ const status = row.original.status
+ const variant = status === 'selected' ? 'default' :
+ status === 'submitted' ? 'secondary' :
+ status === 'rejected' ? 'destructive' : 'outline'
+
+ const label = status === 'selected' ? '선정' :
+ status === 'submitted' ? '제출' :
+ status === 'rejected' ? '거절' : '대기'
+
+ return <Badge variant={variant}>{label}</Badge>
+ },
+ },
+ {
+ accessorKey: 'submissionDate',
+ header: '제출일',
+ cell: ({ row }) => (
+ <div className="text-sm">
+ {row.original.submissionDate ? new Date(row.original.submissionDate).toLocaleDateString('ko-KR') : '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'offeredPaymentTerms',
+ header: '지급조건',
+ cell: ({ row }) => {
+ const terms = row.original.offeredPaymentTerms
+ if (!terms) return <div className="text-muted-foreground">-</div>
+
+ try {
+ const parsed = JSON.parse(terms)
+ return (
+ <div className="text-sm max-w-32 truncate" title={parsed.join(', ')}>
+ {parsed.join(', ')}
+ </div>
+ )
+ } catch {
+ return <div className="text-sm max-w-32 truncate">{terms}</div>
+ }
+ },
+ },
+ {
+ accessorKey: 'offeredTaxConditions',
+ header: '세금조건',
+ cell: ({ row }) => {
+ const conditions = row.original.offeredTaxConditions
+ if (!conditions) return <div className="text-muted-foreground">-</div>
+
+ try {
+ const parsed = JSON.parse(conditions)
+ return (
+ <div className="text-sm max-w-32 truncate" title={parsed.join(', ')}>
+ {parsed.join(', ')}
+ </div>
+ )
+ } catch {
+ return <div className="text-sm max-w-32 truncate">{conditions}</div>
+ }
+ },
+ },
+ {
+ accessorKey: 'offeredIncoterms',
+ header: '운송조건',
+ cell: ({ row }) => {
+ const terms = row.original.offeredIncoterms
+ if (!terms) return <div className="text-muted-foreground">-</div>
+
+ try {
+ const parsed = JSON.parse(terms)
+ return (
+ <div className="text-sm max-w-24 truncate" title={parsed.join(', ')}>
+ {parsed.join(', ')}
+ </div>
+ )
+ } catch {
+ return <div className="text-sm max-w-24 truncate">{terms}</div>
+ }
+ },
+ },
+ {
+ accessorKey: 'offeredContractDeliveryDate',
+ header: '납품요청일',
+ cell: ({ row }) => (
+ <div className="text-sm">
+ {row.original.offeredContractDeliveryDate ?
+ new Date(row.original.offeredContractDeliveryDate).toLocaleDateString('ko-KR') : '-'}
+ </div>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '작업',
+ cell: ({ row }) => {
+ const vendor = row.original
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="h-8 w-8 p-0">
+ <span className="sr-only">메뉴 열기</span>
+ <MoreHorizontal className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuLabel>작업</DropdownMenuLabel>
+ <DropdownMenuItem onClick={() => onEdit(vendor)}>
+ <Edit className="mr-2 h-4 w-4" />
+ 수정
+ </DropdownMenuItem>
+ {vendor.status !== 'selected' && (
+ <DropdownMenuItem onClick={() => onSelectWinner(vendor)}>
+ <Trophy className="mr-2 h-4 w-4" />
+ 낙찰 선정
+ </DropdownMenuItem>
+ )}
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onClick={() => onDelete(vendor)}
+ className="text-destructive"
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ },
+ ]
+}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx
new file mode 100644
index 00000000..9229b09c
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx
@@ -0,0 +1,335 @@
+'use client'
+
+import * as React from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from '@/components/ui/command'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import { Check, ChevronsUpDown, Search } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { createQuotationVendor } from '@/lib/bidding/detail/service'
+import { createQuotationVendorSchema } from '@/lib/bidding/validation'
+import { searchVendors } from '@/lib/vendors/service'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface BiddingDetailVendorCreateDialogProps {
+ biddingId: number
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSuccess: () => void
+}
+
+interface Vendor {
+ id: number
+ vendorName: string
+ vendorCode: string
+ status: string
+}
+
+export function BiddingDetailVendorCreateDialog({
+ biddingId,
+ open,
+ onOpenChange,
+ onSuccess
+}: BiddingDetailVendorCreateDialogProps) {
+ const { toast } = useToast()
+ 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 [formData, setFormData] = React.useState({
+ quotationAmount: 0,
+ currency: 'KRW',
+ paymentTerms: '',
+ taxConditions: '',
+ deliveryDate: '',
+ awardRatio: 0,
+ status: 'pending' as const,
+ })
+
+ // Vendor 검색
+ React.useEffect(() => {
+ const search = async () => {
+ if (vendorSearchValue.trim().length < 2) {
+ setVendors([])
+ return
+ }
+
+ try {
+ const result = await searchVendors(vendorSearchValue.trim(), 10)
+ setVendors(result)
+ } catch (error) {
+ console.error('Vendor search failed:', error)
+ setVendors([])
+ }
+ }
+
+ const debounceTimer = setTimeout(search, 300)
+ return () => clearTimeout(debounceTimer)
+ }, [vendorSearchValue])
+
+ const handleVendorSelect = (vendor: Vendor) => {
+ setSelectedVendor(vendor)
+ setVendorSearchValue(`${vendor.vendorName} (${vendor.vendorCode})`)
+ setVendorSearchOpen(false)
+ }
+
+ const handleCreate = () => {
+ if (!selectedVendor) {
+ toast({
+ title: '오류',
+ description: '업체를 선택해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ const result = createQuotationVendorSchema.safeParse({
+ biddingId,
+ vendorId: selectedVendor.id,
+ vendorName: selectedVendor.vendorName,
+ vendorCode: selectedVendor.vendorCode,
+ contactPerson: '',
+ contactEmail: '',
+ contactPhone: '',
+ ...formData,
+ })
+
+ if (!result.success) {
+ toast({
+ title: '유효성 오류',
+ description: result.error.issues[0]?.message || '입력값을 확인해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ const response = await createQuotationVendor(result.data, 'current-user')
+
+ if (response.success) {
+ toast({
+ title: '성공',
+ description: response.message,
+ })
+ onOpenChange(false)
+ resetForm()
+ onSuccess()
+ } else {
+ toast({
+ title: '오류',
+ description: response.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const resetForm = () => {
+ setSelectedVendor(null)
+ setVendorSearchValue('')
+ setFormData({
+ quotationAmount: 0,
+ currency: 'KRW',
+ paymentTerms: '',
+ taxConditions: '',
+ deliveryDate: '',
+ awardRatio: 0,
+ status: 'pending',
+ })
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <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)}
+ >
+ <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>
+ </div>
+
+ {/* 견적 정보 입력 */}
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="quotationAmount">견적금액</Label>
+ <Input
+ id="quotationAmount"
+ type="number"
+ value={formData.quotationAmount}
+ onChange={(e) => setFormData({ ...formData, quotationAmount: Number(e.target.value) })}
+ placeholder="견적금액을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="currency">통화</Label>
+ <Select value={formData.currency} onValueChange={(value) => setFormData({ ...formData, currency: value })}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="awardRatio">발주비율 (%)</Label>
+ <Input
+ id="awardRatio"
+ type="number"
+ min="0"
+ max="100"
+ value={formData.awardRatio}
+ onChange={(e) => setFormData({ ...formData, awardRatio: Number(e.target.value) })}
+ placeholder="발주비율을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="status">상태</Label>
+ <Select value={formData.status} onValueChange={(value: any) => setFormData({ ...formData, status: value })}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="pending">대기</SelectItem>
+ <SelectItem value="submitted">제출</SelectItem>
+ <SelectItem value="selected">선정</SelectItem>
+ <SelectItem value="rejected">거절</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="paymentTerms">지급조건</Label>
+ <Input
+ id="paymentTerms"
+ value={formData.paymentTerms}
+ onChange={(e) => setFormData({ ...formData, paymentTerms: e.target.value })}
+ placeholder="지급조건을 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="taxConditions">세금조건</Label>
+ <Input
+ id="taxConditions"
+ value={formData.taxConditions}
+ onChange={(e) => setFormData({ ...formData, taxConditions: e.target.value })}
+ placeholder="세금조건을 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="deliveryDate">납품일</Label>
+ <Input
+ id="deliveryDate"
+ type="date"
+ value={formData.deliveryDate}
+ onChange={(e) => setFormData({ ...formData, deliveryDate: e.target.value })}
+ />
+ </div>
+ </div>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleCreate} disabled={isPending || !selectedVendor}>
+ 추가
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx
new file mode 100644
index 00000000..a48aadd2
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx
@@ -0,0 +1,260 @@
+'use client'
+
+import * as React from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { updateQuotationVendor } from '@/lib/bidding/detail/service'
+import { updateQuotationVendorSchema } from '@/lib/bidding/validation'
+import { QuotationVendor } from '@/lib/bidding/detail/service'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface BiddingDetailVendorEditDialogProps {
+ vendor: QuotationVendor | null
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSuccess: () => void
+}
+
+export function BiddingDetailVendorEditDialog({
+ vendor,
+ open,
+ onOpenChange,
+ onSuccess
+}: BiddingDetailVendorEditDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+
+ // 폼 상태
+ const [formData, setFormData] = React.useState({
+ vendorName: '',
+ vendorCode: '',
+ contactPerson: '',
+ contactEmail: '',
+ contactPhone: '',
+ quotationAmount: 0,
+ currency: 'KRW',
+ paymentTerms: '',
+ taxConditions: '',
+ deliveryDate: '',
+ awardRatio: 0,
+ status: 'pending' as const,
+ })
+
+ // vendor가 변경되면 폼 데이터 업데이트
+ React.useEffect(() => {
+ if (vendor) {
+ setFormData({
+ vendorName: vendor.vendorName,
+ vendorCode: vendor.vendorCode,
+ contactPerson: vendor.contactPerson || '',
+ contactEmail: vendor.contactEmail || '',
+ contactPhone: vendor.contactPhone || '',
+ quotationAmount: vendor.quotationAmount,
+ currency: vendor.currency,
+ paymentTerms: vendor.paymentTerms || '',
+ taxConditions: vendor.taxConditions || '',
+ deliveryDate: vendor.deliveryDate || '',
+ awardRatio: vendor.awardRatio || 0,
+ status: vendor.status,
+ })
+ }
+ }, [vendor])
+
+ const handleEdit = () => {
+ if (!vendor) return
+
+ const result = updateQuotationVendorSchema.safeParse({
+ id: vendor.id,
+ ...formData,
+ })
+
+ if (!result.success) {
+ toast({
+ title: '유효성 오류',
+ description: result.error.issues[0]?.message || '입력값을 확인해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ const response = await updateQuotationVendor(vendor.id, result.data, 'current-user')
+
+ if (response.success) {
+ toast({
+ title: '성공',
+ description: response.message,
+ })
+ onOpenChange(false)
+ onSuccess()
+ } else {
+ toast({
+ title: '오류',
+ description: response.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <DialogTitle>협력업체 수정</DialogTitle>
+ <DialogDescription>
+ 협력업체 정보를 수정해주세요.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="edit-vendorName">업체명</Label>
+ <Input
+ id="edit-vendorName"
+ value={formData.vendorName}
+ onChange={(e) => setFormData({ ...formData, vendorName: e.target.value })}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-vendorCode">업체코드</Label>
+ <Input
+ id="edit-vendorCode"
+ value={formData.vendorCode}
+ onChange={(e) => setFormData({ ...formData, vendorCode: e.target.value })}
+ />
+ </div>
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="edit-contactPerson">담당자</Label>
+ <Input
+ id="edit-contactPerson"
+ value={formData.contactPerson}
+ onChange={(e) => setFormData({ ...formData, contactPerson: e.target.value })}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-contactEmail">이메일</Label>
+ <Input
+ id="edit-contactEmail"
+ type="email"
+ value={formData.contactEmail}
+ onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-contactPhone">연락처</Label>
+ <Input
+ id="edit-contactPhone"
+ value={formData.contactPhone}
+ onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })}
+ />
+ </div>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="edit-quotationAmount">견적금액</Label>
+ <Input
+ id="edit-quotationAmount"
+ type="number"
+ value={formData.quotationAmount}
+ onChange={(e) => setFormData({ ...formData, quotationAmount: Number(e.target.value) })}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-currency">통화</Label>
+ <Select value={formData.currency} onValueChange={(value) => setFormData({ ...formData, currency: value })}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="edit-awardRatio">발주비율 (%)</Label>
+ <Input
+ id="edit-awardRatio"
+ type="number"
+ min="0"
+ max="100"
+ value={formData.awardRatio}
+ onChange={(e) => setFormData({ ...formData, awardRatio: Number(e.target.value) })}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-status">상태</Label>
+ <Select value={formData.status} onValueChange={(value: any) => setFormData({ ...formData, status: value })}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="pending">대기</SelectItem>
+ <SelectItem value="submitted">제출</SelectItem>
+ <SelectItem value="selected">선정</SelectItem>
+ <SelectItem value="rejected">거절</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-paymentTerms">지급조건</Label>
+ <Input
+ id="edit-paymentTerms"
+ value={formData.paymentTerms}
+ onChange={(e) => setFormData({ ...formData, paymentTerms: e.target.value })}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-taxConditions">세금조건</Label>
+ <Input
+ id="edit-taxConditions"
+ value={formData.taxConditions}
+ onChange={(e) => setFormData({ ...formData, taxConditions: e.target.value })}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-deliveryDate">납품일</Label>
+ <Input
+ id="edit-deliveryDate"
+ type="date"
+ value={formData.deliveryDate}
+ onChange={(e) => setFormData({ ...formData, deliveryDate: e.target.value })}
+ />
+ </div>
+ </div>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleEdit} disabled={isPending}>
+ 수정
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
new file mode 100644
index 00000000..7ad7056c
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx
@@ -0,0 +1,225 @@
+'use client'
+
+import * as React from 'react'
+import { type DataTableAdvancedFilterField, type DataTableFilterField } from '@/types/table'
+import { useDataTable } from '@/hooks/use-data-table'
+import { DataTable } from '@/components/data-table/data-table'
+import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar'
+import { BiddingDetailVendorToolbarActions } from './bidding-detail-vendor-toolbar-actions'
+import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog'
+import { BiddingDetailVendorEditDialog } from './bidding-detail-vendor-edit-dialog'
+import { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns'
+import { QuotationVendor } from '@/lib/bidding/detail/service'
+import {
+ deleteQuotationVendor,
+ selectWinner
+} from '@/lib/bidding/detail/service'
+import { selectWinnerSchema } from '@/lib/bidding/validation'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface BiddingDetailVendorTableContentProps {
+ biddingId: number
+ vendors: QuotationVendor[]
+ onRefresh: () => void
+ onOpenItemsDialog: () => void
+ onOpenTargetPriceDialog: () => void
+ onOpenSelectionReasonDialog: () => void
+ onEdit?: (vendor: QuotationVendor) => void
+ onDelete?: (vendor: QuotationVendor) => void
+ onSelectWinner?: (vendor: QuotationVendor) => void
+}
+
+const filterFields: DataTableFilterField<QuotationVendor>[] = [
+ {
+ id: 'vendorName',
+ label: '업체명',
+ placeholder: '업체명으로 검색...',
+ },
+ {
+ id: 'vendorCode',
+ label: '업체코드',
+ placeholder: '업체코드로 검색...',
+ },
+ {
+ id: 'contactPerson',
+ label: '담당자',
+ placeholder: '담당자로 검색...',
+ },
+]
+
+const advancedFilterFields: DataTableAdvancedFilterField<QuotationVendor>[] = [
+ {
+ id: 'vendorName',
+ label: '업체명',
+ type: 'text',
+ },
+ {
+ id: 'vendorCode',
+ label: '업체코드',
+ type: 'text',
+ },
+ {
+ id: 'contactPerson',
+ label: '담당자',
+ type: 'text',
+ },
+ {
+ id: 'quotationAmount',
+ label: '견적금액',
+ type: 'number',
+ },
+ {
+ id: 'status',
+ label: '상태',
+ type: 'multi-select',
+ options: [
+ { label: '제출완료', value: 'submitted' },
+ { label: '선정완료', value: 'selected' },
+ { label: '미제출', value: 'pending' },
+ ],
+ },
+]
+
+export function BiddingDetailVendorTableContent({
+ biddingId,
+ vendors,
+ onRefresh,
+ onOpenItemsDialog,
+ onOpenTargetPriceDialog,
+ onOpenSelectionReasonDialog,
+ onEdit,
+ onDelete,
+ onSelectWinner
+}: BiddingDetailVendorTableContentProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null)
+ const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
+
+ const handleDelete = (vendor: QuotationVendor) => {
+ if (!confirm(`${vendor.vendorName} 업체를 삭제하시겠습니까?`)) return
+
+ startTransition(async () => {
+ const response = await deleteQuotationVendor(vendor.id)
+
+ if (response.success) {
+ toast({
+ title: '성공',
+ description: response.message,
+ })
+ onRefresh()
+ } else {
+ toast({
+ title: '오류',
+ description: response.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const handleSelectWinner = (vendor: QuotationVendor) => {
+ if (!vendor.awardRatio || vendor.awardRatio <= 0) {
+ toast({
+ title: '오류',
+ description: '발주비율을 먼저 설정해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ if (!confirm(`${vendor.vendorName} 업체를 낙찰자로 선정하시겠습니까?`)) return
+
+ startTransition(async () => {
+ const result = selectWinnerSchema.safeParse({
+ biddingId,
+ vendorId: vendor.id,
+ awardRatio: vendor.awardRatio,
+ })
+
+ if (!result.success) {
+ toast({
+ title: '유효성 오류',
+ description: result.error.issues[0]?.message || '입력값을 확인해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ const response = await selectWinner(biddingId, vendor.id, vendor.awardRatio, 'current-user')
+
+ if (response.success) {
+ toast({
+ title: '성공',
+ description: response.message,
+ })
+ onRefresh()
+ } else {
+ toast({
+ title: '오류',
+ description: response.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const handleEdit = (vendor: QuotationVendor) => {
+ setSelectedVendor(vendor)
+ setIsEditDialogOpen(true)
+ }
+
+ const columns = React.useMemo(
+ () => getBiddingDetailVendorColumns({
+ onEdit: onEdit || handleEdit,
+ onDelete: onDelete || handleDelete,
+ onSelectWinner: onSelectWinner || handleSelectWinner
+ }),
+ [onEdit, onDelete, onSelectWinner, handleEdit, handleDelete, handleSelectWinner]
+ )
+
+ const { table } = useDataTable({
+ data: vendors,
+ columns,
+ pageCount: 1,
+ filterFields,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: 'vendorName', desc: false }],
+ columnPinning: { right: ['actions'] },
+ },
+ getRowId: (originalRow) => originalRow.id.toString(),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <BiddingDetailVendorToolbarActions
+ table={table}
+ biddingId={biddingId}
+ onOpenItemsDialog={onOpenItemsDialog}
+ onOpenTargetPriceDialog={onOpenTargetPriceDialog}
+ onOpenSelectionReasonDialog={onOpenSelectionReasonDialog}
+
+ onSuccess={onRefresh}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <BiddingDetailVendorEditDialog
+ vendor={selectedVendor}
+ open={isEditDialogOpen}
+ onOpenChange={setIsEditDialogOpen}
+ onSuccess={onRefresh}
+ />
+ </>
+ )
+}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
new file mode 100644
index 00000000..00daa005
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -0,0 +1,79 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Button } from "@/components/ui/button"
+import { Plus } from "lucide-react"
+import { QuotationVendor } from "@/lib/bidding/detail/service"
+import { BiddingDetailVendorCreateDialog } from "./bidding-detail-vendor-create-dialog"
+
+interface BiddingDetailVendorToolbarActionsProps {
+ table: Table<QuotationVendor>
+ biddingId: number
+ onOpenItemsDialog: () => void
+ onOpenTargetPriceDialog: () => void
+ onOpenSelectionReasonDialog: () => void
+
+ onSuccess: () => void
+}
+
+export function BiddingDetailVendorToolbarActions({
+ table,
+ biddingId,
+ onOpenItemsDialog,
+ onOpenTargetPriceDialog,
+ onOpenSelectionReasonDialog,
+ onSuccess
+}: BiddingDetailVendorToolbarActionsProps) {
+ const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false)
+
+ const handleCreateVendor = () => {
+ setIsCreateDialogOpen(true)
+ }
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onOpenItemsDialog}
+ >
+ 품목 정보
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onOpenTargetPriceDialog}
+ >
+ 내정가 산정
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onOpenSelectionReasonDialog}
+ >
+ 선정 사유
+ </Button>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleCreateVendor}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 업체 추가
+ </Button>
+ </div>
+
+ <BiddingDetailVendorCreateDialog
+ biddingId={biddingId}
+ open={isCreateDialogOpen}
+ onOpenChange={setIsCreateDialogOpen}
+ onSuccess={() => {
+ onSuccess()
+ setIsCreateDialogOpen(false)
+ }}
+ />
+ </>
+ )
+}