summaryrefslogtreecommitdiff
path: root/lib/bidding/pre-quote/table
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-04 08:31:31 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-04 08:31:31 +0000
commitb67e36df49f067cbd5ba899f9fbcc755f38d4b4f (patch)
tree5a71c5960f90d988cd509e3ef26bff497a277661 /lib/bidding/pre-quote/table
parentb7f54b06c1ef9e619f5358fb0a5caad9703c8905 (diff)
(대표님, 최겸, 임수민) 작업사항 커밋
Diffstat (limited to 'lib/bidding/pre-quote/table')
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx57
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx185
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx303
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx205
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx200
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx189
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx92
7 files changed, 1231 insertions, 0 deletions
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx
new file mode 100644
index 00000000..692d12ea
--- /dev/null
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx
@@ -0,0 +1,57 @@
+'use client'
+
+import * as React from 'react'
+import { Bidding } from '@/db/schema'
+import { QuotationDetails, QuotationVendor } from '@/lib/bidding/detail/service'
+import { getBiddingCompanies } from '../service'
+
+import { BiddingPreQuoteVendorTableContent } from './bidding-pre-quote-vendor-table'
+
+interface BiddingPreQuoteContentProps {
+ bidding: Bidding
+ quotationDetails: QuotationDetails | null
+ quotationVendors: QuotationVendor[]
+ biddingCompanies: any[]
+ prItems: any[]
+}
+
+export function BiddingPreQuoteContent({
+ bidding,
+ quotationDetails,
+ quotationVendors,
+ biddingCompanies: initialBiddingCompanies,
+ prItems
+}: BiddingPreQuoteContentProps) {
+ const [biddingCompanies, setBiddingCompanies] = React.useState(initialBiddingCompanies)
+ const [refreshTrigger, setRefreshTrigger] = React.useState(0)
+
+ const handleRefresh = React.useCallback(async () => {
+ try {
+ const result = await getBiddingCompanies(bidding.id)
+ if (result.success && result.data) {
+ setBiddingCompanies(result.data)
+ }
+ setRefreshTrigger(prev => prev + 1)
+ } catch (error) {
+ console.error('Failed to refresh bidding companies:', error)
+ }
+ }, [bidding.id])
+
+ return (
+ <div className="space-y-6">
+ <BiddingPreQuoteVendorTableContent
+ biddingId={bidding.id}
+ bidding={bidding}
+ vendors={quotationVendors}
+ biddingCompanies={biddingCompanies}
+ onRefresh={handleRefresh}
+ onOpenItemsDialog={() => {}}
+ onOpenTargetPriceDialog={() => {}}
+ onOpenSelectionReasonDialog={() => {}}
+ onEdit={undefined}
+ onDelete={undefined}
+ onSelectWinner={undefined}
+ />
+ </div>
+ )
+}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx
new file mode 100644
index 00000000..84824c1e
--- /dev/null
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx
@@ -0,0 +1,185 @@
+'use client'
+
+import * as React from 'react'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Badge } from '@/components/ui/badge'
+import { BiddingCompany } from './bidding-pre-quote-vendor-columns'
+import { sendPreQuoteInvitations } from '../service'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+import { Mail, Building2 } from 'lucide-react'
+
+interface BiddingPreQuoteInvitationDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ companies: BiddingCompany[]
+ onSuccess: () => void
+}
+
+export function BiddingPreQuoteInvitationDialog({
+ open,
+ onOpenChange,
+ companies,
+ onSuccess
+}: BiddingPreQuoteInvitationDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [selectedCompanyIds, setSelectedCompanyIds] = React.useState<number[]>([])
+
+ // 초대 가능한 업체들 (pending 상태인 업체들)
+ const invitableCompanies = companies.filter(company =>
+ company.invitationStatus === 'pending' && company.companyName
+ )
+
+ const handleSelectAll = (checked: boolean) => {
+ if (checked) {
+ setSelectedCompanyIds(invitableCompanies.map(company => company.id))
+ } else {
+ setSelectedCompanyIds([])
+ }
+ }
+
+ const handleSelectCompany = (companyId: number, checked: boolean) => {
+ if (checked) {
+ setSelectedCompanyIds(prev => [...prev, companyId])
+ } else {
+ setSelectedCompanyIds(prev => prev.filter(id => id !== companyId))
+ }
+ }
+
+ const handleSendInvitations = () => {
+ if (selectedCompanyIds.length === 0) {
+ toast({
+ title: '알림',
+ description: '초대를 발송할 업체를 선택해주세요.',
+ variant: 'default',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ const response = await sendPreQuoteInvitations(selectedCompanyIds)
+
+ if (response.success) {
+ toast({
+ title: '성공',
+ description: response.message,
+ })
+ setSelectedCompanyIds([])
+ onOpenChange(false)
+ onSuccess()
+ } else {
+ toast({
+ title: '오류',
+ description: response.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const handleOpenChange = (open: boolean) => {
+ onOpenChange(open)
+ if (!open) {
+ setSelectedCompanyIds([])
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Mail className="w-5 h-5" />
+ 사전견적 초대 발송
+ </DialogTitle>
+ <DialogDescription>
+ 선택한 업체들에게 사전견적 요청을 발송합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="py-4">
+ {invitableCompanies.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ 초대 가능한 업체가 없습니다.
+ </div>
+ ) : (
+ <>
+ {/* 전체 선택 */}
+ <div className="flex items-center space-x-2 mb-4 pb-2 border-b">
+ <Checkbox
+ id="select-all"
+ checked={selectedCompanyIds.length === invitableCompanies.length}
+ onCheckedChange={handleSelectAll}
+ />
+ <label htmlFor="select-all" className="font-medium">
+ 전체 선택 ({invitableCompanies.length}개 업체)
+ </label>
+ </div>
+
+ {/* 업체 목록 */}
+ <div className="space-y-3 max-h-80 overflow-y-auto">
+ {invitableCompanies.map((company) => (
+ <div key={company.id} className="flex items-center space-x-3 p-3 border rounded-lg">
+ <Checkbox
+ id={`company-${company.id}`}
+ checked={selectedCompanyIds.includes(company.id)}
+ onCheckedChange={(checked) => handleSelectCompany(company.id, !!checked)}
+ />
+ <div className="flex-1">
+ <div className="flex items-center gap-2">
+ <Building2 className="w-4 h-4" />
+ <span className="font-medium">{company.companyName}</span>
+ <Badge variant="outline" className="text-xs">
+ {company.companyCode}
+ </Badge>
+ </div>
+ {company.notes && (
+ <p className="text-sm text-muted-foreground mt-1">
+ {company.notes}
+ </p>
+ )}
+ </div>
+ <Badge variant="outline">
+ 대기중
+ </Badge>
+ </div>
+ ))}
+ </div>
+
+ {selectedCompanyIds.length > 0 && (
+ <div className="mt-4 p-3 bg-primary/5 rounded-lg">
+ <p className="text-sm text-primary">
+ <strong>{selectedCompanyIds.length}개 업체</strong>에 사전견적 초대를 발송합니다.
+ </p>
+ </div>
+ )}
+ </>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => handleOpenChange(false)}>
+ 취소
+ </Button>
+ <Button
+ onClick={handleSendInvitations}
+ disabled={isPending || selectedCompanyIds.length === 0}
+ >
+ <Mail className="w-4 h-4 mr-2" />
+ 초대 발송
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx
new file mode 100644
index 00000000..30cddbce
--- /dev/null
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx
@@ -0,0 +1,303 @@
+"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, UserPlus
+} from "lucide-react"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+// bidding_companies 테이블 타입 정의 (company_condition_responses와 join)
+export interface BiddingCompany {
+ id: number
+ biddingId: number
+ companyId: number
+ invitationStatus: 'pending' | 'sent' | 'accepted' | 'declined' | 'submitted'
+ invitedAt: Date | null
+ respondedAt: Date | null
+ preQuoteAmount: string | null
+ preQuoteSubmittedAt: Date | null
+ isPreQuoteSelected: boolean
+ isAttendingMeeting: boolean | null
+ notes: string | null
+ contactPerson: string | null
+ contactEmail: string | null
+ contactPhone: string | null
+ createdAt: Date
+ updatedAt: Date
+
+ // company_condition_responses 필드들
+ paymentTermsResponse: string | null
+ taxConditionsResponse: string | null
+ proposedContractDeliveryDate: string | null
+ priceAdjustmentResponse: boolean | null
+ isInitialResponse: boolean | null
+ incotermsResponse: string | null
+ proposedShippingPort: string | null
+ proposedDestinationPort: string | null
+ sparePartResponse: string | null
+ additionalProposals: string | null
+
+ // 조인된 업체 정보
+ companyName?: string
+ companyCode?: string
+}
+
+interface GetBiddingCompanyColumnsProps {
+ onEdit: (company: BiddingCompany) => void
+ onDelete: (company: BiddingCompany) => void
+ onInvite: (company: BiddingCompany) => void
+}
+
+export function getBiddingPreQuoteVendorColumns({
+ onEdit,
+ onDelete,
+ onInvite
+}: GetBiddingCompanyColumnsProps): ColumnDef<BiddingCompany>[] {
+ 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: 'companyName',
+ header: '업체명',
+ cell: ({ row }) => (
+ <div className="font-medium">{row.original.companyName || '-'}</div>
+ ),
+ },
+ {
+ accessorKey: 'companyCode',
+ header: '업체코드',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.original.companyCode || '-'}</div>
+ ),
+ },
+ {
+ accessorKey: 'invitationStatus',
+ header: '초대 상태',
+ cell: ({ row }) => {
+ const status = row.original.invitationStatus
+ const variant = status === 'accepted' ? 'default' :
+ status === 'declined' ? 'destructive' : 'outline'
+
+ const label = status === 'accepted' ? '수락' :
+ status === 'declined' ? '거절' : '대기중'
+
+ return <Badge variant={variant}>{label}</Badge>
+ },
+ },
+ {
+ accessorKey: 'preQuoteAmount',
+ header: '사전견적금액',
+ cell: ({ row }) => (
+ <div className="text-right font-mono">
+ {row.original.preQuoteAmount ? Number(row.original.preQuoteAmount).toLocaleString() : '-'} KRW
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'preQuoteSubmittedAt',
+ header: '사전견적 제출일',
+ cell: ({ row }) => (
+ <div className="text-sm">
+ {row.original.preQuoteSubmittedAt ? new Date(row.original.preQuoteSubmittedAt).toLocaleDateString('ko-KR') : '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'isPreQuoteSelected',
+ header: '본입찰 선정',
+ cell: ({ row }) => (
+ <Badge variant={row.original.isPreQuoteSelected ? 'default' : 'secondary'}>
+ {row.original.isPreQuoteSelected ? '선정' : '미선정'}
+ </Badge>
+ ),
+ },
+ {
+ accessorKey: 'isAttendingMeeting',
+ header: '사양설명회 참석',
+ cell: ({ row }) => {
+ const isAttending = row.original.isAttendingMeeting
+ if (isAttending === null) return <div className="text-sm">-</div>
+ return (
+ <Badge variant={isAttending ? 'default' : 'secondary'}>
+ {isAttending ? '참석' : '불참석'}
+ </Badge>
+ )
+ },
+ },
+ {
+ accessorKey: 'paymentTermsResponse',
+ header: '지급조건',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-32 truncate" title={row.original.paymentTermsResponse || ''}>
+ {row.original.paymentTermsResponse || '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'taxConditionsResponse',
+ header: '세금조건',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-32 truncate" title={row.original.taxConditionsResponse || ''}>
+ {row.original.taxConditionsResponse || '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'incotermsResponse',
+ header: '운송조건',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-24 truncate" title={row.original.incotermsResponse || ''}>
+ {row.original.incotermsResponse || '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'isInitialResponse',
+ header: '초도여부',
+ cell: ({ row }) => {
+ const isInitial = row.original.isInitialResponse
+ if (isInitial === null) return <div className="text-sm">-</div>
+ return (
+ <Badge variant={isInitial ? 'default' : 'secondary'}>
+ {isInitial ? 'Y' : 'N'}
+ </Badge>
+ )
+ },
+ },
+ {
+ accessorKey: 'priceAdjustmentResponse',
+ header: '연동제',
+ cell: ({ row }) => {
+ const hasPriceAdjustment = row.original.priceAdjustmentResponse
+ if (hasPriceAdjustment === null) return <div className="text-sm">-</div>
+ return (
+ <Badge variant={hasPriceAdjustment ? 'default' : 'secondary'}>
+ {hasPriceAdjustment ? '적용' : '미적용'}
+ </Badge>
+ )
+ },
+ },
+ {
+ accessorKey: 'proposedContractDeliveryDate',
+ header: '제안납기일',
+ cell: ({ row }) => (
+ <div className="text-sm">
+ {row.original.proposedContractDeliveryDate ?
+ new Date(row.original.proposedContractDeliveryDate).toLocaleDateString('ko-KR') : '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'proposedShippingPort',
+ header: '제안선적지',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-24 truncate" title={row.original.proposedShippingPort || ''}>
+ {row.original.proposedShippingPort || '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'proposedDestinationPort',
+ header: '제안도착지',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-24 truncate" title={row.original.proposedDestinationPort || ''}>
+ {row.original.proposedDestinationPort || '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'sparePartResponse',
+ header: '스페어파트',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-24 truncate" title={row.original.sparePartResponse || ''}>
+ {row.original.sparePartResponse || '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'additionalProposals',
+ header: '추가제안',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-32 truncate" title={row.original.additionalProposals || ''}>
+ {row.original.additionalProposals || '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'notes',
+ header: '특이사항',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-32 truncate" title={row.original.notes || ''}>
+ {row.original.notes || '-'}
+ </div>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '작업',
+ cell: ({ row }) => {
+ const company = 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(company)}>
+ <Edit className="mr-2 h-4 w-4" />
+ 수정
+ </DropdownMenuItem>
+ {company.invitationStatus === 'pending' && (
+ <DropdownMenuItem onClick={() => onInvite(company)}>
+ <UserPlus className="mr-2 h-4 w-4" />
+ 초대 발송
+ </DropdownMenuItem>
+ )}
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onClick={() => onDelete(company)}
+ className="text-destructive"
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ },
+ ]
+}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx
new file mode 100644
index 00000000..e2a38547
--- /dev/null
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx
@@ -0,0 +1,205 @@
+'use client'
+
+import * as React from 'react'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from '@/components/ui/command'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import { Check, ChevronsUpDown } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { createBiddingCompany } from '@/lib/bidding/pre-quote/service'
+import { searchVendors } from '@/lib/vendors/service'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface BiddingPreQuoteVendorCreateDialogProps {
+ biddingId: number
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSuccess: () => void
+}
+
+interface Vendor {
+ id: number
+ vendorName: string
+ vendorCode: string
+ status: string
+}
+
+export function BiddingPreQuoteVendorCreateDialog({
+ biddingId,
+ open,
+ onOpenChange,
+ onSuccess
+}: BiddingPreQuoteVendorCreateDialogProps) {
+ 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('')
+
+
+ // 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
+ }
+
+ startTransition(async () => {
+ const response = await createBiddingCompany({
+ biddingId,
+ companyId: selectedVendor.id,
+ })
+ console.log(response)
+ 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('')
+ }
+
+ 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>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleCreate} disabled={isPending || !selectedVendor}>
+ 추가
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx
new file mode 100644
index 00000000..03bf2ecb
--- /dev/null
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx
@@ -0,0 +1,200 @@
+'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 { Textarea } from '@/components/ui/textarea'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { updateBiddingCompany } from '../service'
+import { BiddingCompany } from './bidding-pre-quote-vendor-columns'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface BiddingPreQuoteVendorEditDialogProps {
+ company: BiddingCompany | null
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSuccess: () => void
+}
+
+export function BiddingPreQuoteVendorEditDialog({
+ company,
+ open,
+ onOpenChange,
+ onSuccess
+}: BiddingPreQuoteVendorEditDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+
+ // 폼 상태
+ const [formData, setFormData] = React.useState({
+ contactPerson: '',
+ contactEmail: '',
+ contactPhone: '',
+ preQuoteAmount: 0,
+ notes: '',
+ invitationStatus: 'pending' as 'pending' | 'accepted' | 'declined',
+ isPreQuoteSelected: false,
+ isAttendingMeeting: false,
+ })
+
+ // company가 변경되면 폼 데이터 업데이트
+ React.useEffect(() => {
+ if (company) {
+ setFormData({
+ contactPerson: company.contactPerson || '',
+ contactEmail: company.contactEmail || '',
+ contactPhone: company.contactPhone || '',
+ preQuoteAmount: company.preQuoteAmount ? Number(company.preQuoteAmount) : 0,
+ notes: company.notes || '',
+ invitationStatus: company.invitationStatus,
+ isPreQuoteSelected: company.isPreQuoteSelected,
+ isAttendingMeeting: company.isAttendingMeeting || false,
+ })
+ }
+ }, [company])
+
+ const handleEdit = () => {
+ if (!company) return
+
+ startTransition(async () => {
+ const response = await updateBiddingCompany(company.id, formData)
+
+ 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>
+ {company?.companyName} 업체의 사전견적 정보를 수정해주세요.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <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-preQuoteAmount">사전견적금액</Label>
+ <Input
+ id="edit-preQuoteAmount"
+ type="number"
+ value={formData.preQuoteAmount}
+ onChange={(e) => setFormData({ ...formData, preQuoteAmount: Number(e.target.value) })}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-invitationStatus">초대 상태</Label>
+ <Select value={formData.invitationStatus} onValueChange={(value: any) => setFormData({ ...formData, invitationStatus: value })}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="pending">대기중</SelectItem>
+ <SelectItem value="accepted">수락</SelectItem>
+ <SelectItem value="declined">거절</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="edit-isPreQuoteSelected"
+ checked={formData.isPreQuoteSelected}
+ onCheckedChange={(checked) =>
+ setFormData({ ...formData, isPreQuoteSelected: !!checked })
+ }
+ />
+ <Label htmlFor="edit-isPreQuoteSelected">본입찰 선정</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="edit-isAttendingMeeting"
+ checked={formData.isAttendingMeeting}
+ onCheckedChange={(checked) =>
+ setFormData({ ...formData, isAttendingMeeting: !!checked })
+ }
+ />
+ <Label htmlFor="edit-isAttendingMeeting">사양설명회 참석</Label>
+ </div>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="edit-notes">특이사항</Label>
+ <Textarea
+ id="edit-notes"
+ value={formData.notes}
+ onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
+ placeholder="특이사항을 입력해주세요..."
+ />
+ </div>
+ </div>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleEdit} disabled={isPending}>
+ 수정
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx
new file mode 100644
index 00000000..a9d12629
--- /dev/null
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx
@@ -0,0 +1,189 @@
+'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 { BiddingPreQuoteVendorToolbarActions } from './bidding-pre-quote-vendor-toolbar-actions'
+import { BiddingPreQuoteVendorEditDialog } from './bidding-pre-quote-vendor-edit-dialog'
+import { getBiddingPreQuoteVendorColumns, BiddingCompany } from './bidding-pre-quote-vendor-columns'
+import { Bidding } from '@/db/schema'
+import {
+ deleteBiddingCompany
+} from '../service'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+
+interface BiddingPreQuoteVendorTableContentProps {
+ biddingId: number
+ bidding: Bidding
+ vendors: any[] // 사용하지 않음
+ biddingCompanies: BiddingCompany[]
+ onRefresh: () => void
+ onOpenItemsDialog: () => void
+ onOpenTargetPriceDialog: () => void
+ onOpenSelectionReasonDialog: () => void
+ onEdit?: (company: BiddingCompany) => void
+ onDelete?: (company: BiddingCompany) => void
+ onSelectWinner?: (company: BiddingCompany) => void
+}
+
+const filterFields: DataTableFilterField<BiddingCompany>[] = [
+ {
+ id: 'companyName',
+ label: '업체명',
+ placeholder: '업체명으로 검색...',
+ },
+ {
+ id: 'companyCode',
+ label: '업체코드',
+ placeholder: '업체코드로 검색...',
+ },
+ {
+ id: 'contactPerson',
+ label: '담당자',
+ placeholder: '담당자로 검색...',
+ },
+]
+
+const advancedFilterFields: DataTableAdvancedFilterField<BiddingCompany>[] = [
+ {
+ id: 'companyName',
+ label: '업체명',
+ type: 'text',
+ },
+ {
+ id: 'companyCode',
+ label: '업체코드',
+ type: 'text',
+ },
+ {
+ id: 'contactPerson',
+ label: '담당자',
+ type: 'text',
+ },
+ {
+ id: 'preQuoteAmount',
+ label: '사전견적금액',
+ type: 'number',
+ },
+ {
+ id: 'invitationStatus',
+ label: '초대 상태',
+ type: 'multi-select',
+ options: [
+ { label: '수락', value: 'accepted' },
+ { label: '거절', value: 'declined' },
+ { label: '대기중', value: 'pending' },
+ ],
+ },
+]
+
+export function BiddingPreQuoteVendorTableContent({
+ biddingId,
+ bidding,
+ vendors,
+ biddingCompanies,
+ onRefresh,
+ onOpenItemsDialog,
+ onOpenTargetPriceDialog,
+ onOpenSelectionReasonDialog,
+ onEdit,
+ onDelete,
+ onSelectWinner
+}: BiddingPreQuoteVendorTableContentProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [selectedCompany, setSelectedCompany] = React.useState<BiddingCompany | null>(null)
+ const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
+
+ const handleDelete = (company: BiddingCompany) => {
+ if (!confirm(`${company.companyName} 업체를 삭제하시겠습니까?`)) return
+
+ startTransition(async () => {
+ const response = await deleteBiddingCompany(company.id)
+
+ if (response.success) {
+ toast({
+ title: '성공',
+ description: response.message,
+ })
+ onRefresh()
+ } else {
+ toast({
+ title: '오류',
+ description: response.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const handleEdit = (company: BiddingCompany) => {
+ setSelectedCompany(company)
+ setIsEditDialogOpen(true)
+ }
+
+ const handleInvite = (company: BiddingCompany) => {
+ // TODO: 초대 발송 로직 구현
+ toast({
+ title: '알림',
+ description: `${company.companyName} 업체에 초대를 발송했습니다.`,
+ })
+ }
+
+ const columns = React.useMemo(
+ () => getBiddingPreQuoteVendorColumns({
+ onEdit: onEdit || handleEdit,
+ onDelete: onDelete || handleDelete,
+ onInvite: handleInvite
+ }),
+ [onEdit, onDelete, handleEdit, handleDelete, handleInvite]
+ )
+
+ const { table } = useDataTable({
+ data: biddingCompanies,
+ columns,
+ pageCount: 1,
+ filterFields,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: 'companyName', 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}
+ >
+ <BiddingPreQuoteVendorToolbarActions
+ table={table}
+ biddingId={biddingId}
+ bidding={bidding}
+ biddingCompanies={biddingCompanies}
+ onOpenItemsDialog={onOpenItemsDialog}
+ onOpenTargetPriceDialog={onOpenTargetPriceDialog}
+ onOpenSelectionReasonDialog={onOpenSelectionReasonDialog}
+ onSuccess={onRefresh}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <BiddingPreQuoteVendorEditDialog
+ company={selectedCompany}
+ open={isEditDialogOpen}
+ onOpenChange={setIsEditDialogOpen}
+ onSuccess={onRefresh}
+ />
+ </>
+ )
+}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx
new file mode 100644
index 00000000..c1b1baa5
--- /dev/null
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx
@@ -0,0 +1,92 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { useTransition } from "react"
+import { Button } from "@/components/ui/button"
+import { Plus, Send, Mail } from "lucide-react"
+import { BiddingCompany } from "./bidding-pre-quote-vendor-columns"
+import { BiddingPreQuoteVendorCreateDialog } from "./bidding-pre-quote-vendor-create-dialog"
+import { BiddingPreQuoteInvitationDialog } from "./bidding-pre-quote-invitation-dialog"
+import { Bidding } from "@/db/schema"
+import { useToast } from "@/hooks/use-toast"
+
+interface BiddingPreQuoteVendorToolbarActionsProps {
+ table: Table<BiddingCompany>
+ biddingId: number
+ bidding: Bidding
+ biddingCompanies: BiddingCompany[]
+ onOpenItemsDialog: () => void
+ onOpenTargetPriceDialog: () => void
+ onOpenSelectionReasonDialog: () => void
+ onSuccess: () => void
+}
+
+export function BiddingPreQuoteVendorToolbarActions({
+ table,
+ biddingId,
+ bidding,
+ biddingCompanies,
+ onOpenItemsDialog,
+ onOpenTargetPriceDialog,
+ onOpenSelectionReasonDialog,
+ onSuccess
+}: BiddingPreQuoteVendorToolbarActionsProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false)
+ const [isInvitationDialogOpen, setIsInvitationDialogOpen] = React.useState(false)
+
+ const handleCreateCompany = () => {
+ setIsCreateDialogOpen(true)
+ }
+
+ const handleSendInvitations = () => {
+ setIsInvitationDialogOpen(true)
+ }
+
+
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleCreateCompany}
+ disabled={isPending}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 업체 추가
+ </Button>
+
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleSendInvitations}
+ disabled={isPending}
+ >
+ <Mail className="mr-2 h-4 w-4" />
+ 초대 발송
+ </Button>
+ </div>
+
+ <BiddingPreQuoteVendorCreateDialog
+ biddingId={biddingId}
+ open={isCreateDialogOpen}
+ onOpenChange={setIsCreateDialogOpen}
+ onSuccess={() => {
+ onSuccess()
+ setIsCreateDialogOpen(false)
+ }}
+ />
+
+ <BiddingPreQuoteInvitationDialog
+ open={isInvitationDialogOpen}
+ onOpenChange={setIsInvitationDialogOpen}
+ companies={biddingCompanies}
+ onSuccess={onSuccess}
+ />
+ </>
+ )
+}