summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-28 03:12:57 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-28 03:12:57 +0000
commit9cda8482660a87fd98c9ee43f507d75ff75b4e23 (patch)
tree67eb1fc24eec7c4e61d3154f7b09fc5349454672
parentf57898bd240d068301ce3ef477f52cff1234e4ee (diff)
(최겸) 구매 입찰 피드백 반영(90%)
-rw-r--r--components/bidding/create/bidding-create-dialog.tsx451
-rw-r--r--components/bidding/manage/bidding-basic-info-editor.tsx472
-rw-r--r--components/bidding/manage/bidding-companies-editor.tsx8
-rw-r--r--components/bidding/manage/bidding-items-editor.tsx35
-rw-r--r--components/bidding/manage/bidding-schedule-editor.tsx21
-rw-r--r--components/bidding/receive/bidding-participants-dialog.tsx216
-rw-r--r--db/schema/bidding.ts6
-rw-r--r--lib/bidding/detail/service.ts76
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx20
-rw-r--r--lib/bidding/list/edit-bidding-sheet.tsx14
-rw-r--r--lib/bidding/receive/biddings-receive-columns.tsx76
-rw-r--r--lib/bidding/receive/biddings-receive-table.tsx53
-rw-r--r--lib/bidding/selection/biddings-selection-columns.tsx10
-rw-r--r--lib/bidding/service.ts1
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx11
15 files changed, 1107 insertions, 363 deletions
diff --git a/components/bidding/create/bidding-create-dialog.tsx b/components/bidding/create/bidding-create-dialog.tsx
index f298721b..bb7880f5 100644
--- a/components/bidding/create/bidding-create-dialog.tsx
+++ b/components/bidding/create/bidding-create-dialog.tsx
@@ -2,7 +2,7 @@
import * as React from 'react'
import { UseFormReturn } from 'react-hook-form'
-import { ChevronRight, Upload, FileText, Eye, User, Building, Calendar, DollarSign, Plus } from 'lucide-react'
+import { ChevronRight, Upload, FileText, Eye, User, Building, Calendar, DollarSign, Plus, Check, ChevronsUpDown } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
@@ -26,6 +26,20 @@ import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import { cn } from '@/lib/utils'
import type { CreateBiddingSchema } from '@/lib/bidding/validation'
import { contractTypeLabels, biddingTypeLabels, awardCountLabels, biddingNoticeTypeLabels } from '@/db/schema'
@@ -589,37 +603,62 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
control={form.control}
name="biddingConditions.paymentTerms"
render={({ field }) => (
- <FormItem>
+ <FormItem className="flex flex-col">
<FormLabel>SHI 지급조건 <span className="text-red-500">*</span></FormLabel>
- <FormControl>
- <Select
- value={biddingConditions.paymentTerms}
- onValueChange={(value) => {
- setBiddingConditions(prev => ({
- ...prev,
- paymentTerms: value
- }))
- field.onChange(value)
- }}
- >
- <SelectTrigger>
- <SelectValue placeholder="지급조건 선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.length > 0 ? (
- paymentTermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code}>
- {option.code} {option.description && `(${option.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </FormControl>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "justify-between",
+ !biddingConditions.paymentTerms && "text-muted-foreground"
+ )}
+ >
+ {biddingConditions.paymentTerms
+ ? paymentTermsOptions.find((option) => option.code === biddingConditions.paymentTerms)
+ ? `${paymentTermsOptions.find((option) => option.code === biddingConditions.paymentTerms)?.code} ${paymentTermsOptions.find((option) => option.code === biddingConditions.paymentTerms)?.description ? `(${paymentTermsOptions.find((option) => option.code === biddingConditions.paymentTerms)?.description})` : ''}`
+ : "지급조건 선택"
+ : "지급조건 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="지급조건 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {paymentTermsOptions.map((option) => (
+ <CommandItem
+ key={option.code}
+ value={`${option.code} ${option.description || ''}`}
+ onSelect={() => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ paymentTerms: option.code
+ }))
+ field.onChange(option.code)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ option.code === biddingConditions.paymentTerms
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {option.code} {option.description && `(${option.description})`}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
<FormMessage />
</FormItem>
)}
@@ -632,37 +671,62 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
control={form.control}
name="biddingConditions.incoterms"
render={({ field }) => (
- <FormItem>
+ <FormItem className="flex flex-col">
<FormLabel>SHI 인도조건 <span className="text-red-500">*</span></FormLabel>
- <Select
- value={biddingConditions.incoterms}
- onValueChange={(value) => {
- setBiddingConditions(prev => ({
- ...prev,
- incoterms: value
- }))
- field.onChange(value)
- }}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="인코텀즈 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {incotermsOptions.length > 0 ? (
- incotermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code}>
- {option.code} {option.description && `(${option.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "justify-between",
+ !biddingConditions.incoterms && "text-muted-foreground"
+ )}
+ >
+ {biddingConditions.incoterms
+ ? incotermsOptions.find((option) => option.code === biddingConditions.incoterms)
+ ? `${incotermsOptions.find((option) => option.code === biddingConditions.incoterms)?.code} ${incotermsOptions.find((option) => option.code === biddingConditions.incoterms)?.description ? `(${incotermsOptions.find((option) => option.code === biddingConditions.incoterms)?.description})` : ''}`
+ : "인코텀즈 선택"
+ : "인코텀즈 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="인코텀즈 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {incotermsOptions.map((option) => (
+ <CommandItem
+ key={option.code}
+ value={`${option.code} ${option.description || ''}`}
+ onSelect={() => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ incoterms: option.code
+ }))
+ field.onChange(option.code)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ option.code === biddingConditions.incoterms
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {option.code} {option.description && `(${option.description})`}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
<FormMessage />
</FormItem>
)}
@@ -699,31 +763,60 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
control={form.control}
name="biddingConditions.taxConditions"
render={({ field }) => (
- <FormItem>
+ <FormItem className="flex flex-col">
<FormLabel>SHI 매입부가가치세 <span className="text-red-500">*</span></FormLabel>
- <FormControl>
- <Select
- value={biddingConditions.taxConditions}
- onValueChange={(value) => {
- setBiddingConditions(prev => ({
- ...prev,
- taxConditions: value
- }))
- field.onChange(value)
- }}
- >
- <SelectTrigger>
- <SelectValue placeholder="세금조건 선택" />
- </SelectTrigger>
- <SelectContent>
- {TAX_CONDITIONS.map((condition) => (
- <SelectItem key={condition.code} value={condition.code}>
- {condition.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </FormControl>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "justify-between",
+ !biddingConditions.taxConditions && "text-muted-foreground"
+ )}
+ >
+ {biddingConditions.taxConditions
+ ? TAX_CONDITIONS.find((condition) => condition.code === biddingConditions.taxConditions)?.name
+ : "세금조건 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="세금조건 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {TAX_CONDITIONS.map((condition) => (
+ <CommandItem
+ key={condition.code}
+ value={`${condition.code} ${condition.name}`}
+ onSelect={() => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ taxConditions: condition.code
+ }))
+ field.onChange(condition.code)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ condition.code === biddingConditions.taxConditions
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {condition.name}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
<FormMessage />
</FormItem>
)}
@@ -733,37 +826,62 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
control={form.control}
name="biddingConditions.shippingPort"
render={({ field }) => (
- <FormItem>
+ <FormItem className="flex flex-col">
<FormLabel>SHI 선적지</FormLabel>
- <Select
- value={biddingConditions.shippingPort}
- onValueChange={(value) => {
- setBiddingConditions(prev => ({
- ...prev,
- shippingPort: value
- }))
- field.onChange(value)
- }}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="선적지 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {shippingPlaces.length > 0 ? (
- shippingPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code}>
- {place.code} {place.description && `(${place.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "justify-between",
+ !biddingConditions.shippingPort && "text-muted-foreground"
+ )}
+ >
+ {biddingConditions.shippingPort
+ ? shippingPlaces.find((place) => place.code === biddingConditions.shippingPort)
+ ? `${shippingPlaces.find((place) => place.code === biddingConditions.shippingPort)?.code} ${shippingPlaces.find((place) => place.code === biddingConditions.shippingPort)?.description ? `(${shippingPlaces.find((place) => place.code === biddingConditions.shippingPort)?.description})` : ''}`
+ : "선적지 선택"
+ : "선적지 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="선적지 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {shippingPlaces.map((place) => (
+ <CommandItem
+ key={place.code}
+ value={`${place.code} ${place.description || ''}`}
+ onSelect={() => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ shippingPort: place.code
+ }))
+ field.onChange(place.code)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ place.code === biddingConditions.shippingPort
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {place.code} {place.description && `(${place.description})`}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
<FormMessage />
</FormItem>
)}
@@ -776,43 +894,68 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
control={form.control}
name="biddingConditions.destinationPort"
render={({ field }) => (
- <FormItem>
+ <FormItem className="flex flex-col">
<FormLabel>SHI 하역지</FormLabel>
- <Select
- value={biddingConditions.destinationPort}
- onValueChange={(value) => {
- setBiddingConditions(prev => ({
- ...prev,
- destinationPort: value
- }))
- field.onChange(value)
- }}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="하역지 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {destinationPlaces.length > 0 ? (
- destinationPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code}>
- {place.code} {place.description && `(${place.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "justify-between",
+ !biddingConditions.destinationPort && "text-muted-foreground"
+ )}
+ >
+ {biddingConditions.destinationPort
+ ? destinationPlaces.find((place) => place.code === biddingConditions.destinationPort)
+ ? `${destinationPlaces.find((place) => place.code === biddingConditions.destinationPort)?.code} ${destinationPlaces.find((place) => place.code === biddingConditions.destinationPort)?.description ? `(${destinationPlaces.find((place) => place.code === biddingConditions.destinationPort)?.description})` : ''}`
+ : "하역지 선택"
+ : "하역지 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="하역지 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {destinationPlaces.map((place) => (
+ <CommandItem
+ key={place.code}
+ value={`${place.code} ${place.description || ''}`}
+ onSelect={() => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ destinationPort: place.code
+ }))
+ field.onChange(place.code)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ place.code === biddingConditions.destinationPort
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {place.code} {place.description && `(${place.description})`}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
<FormMessage />
</FormItem>
)}
/>
- {/* <FormField
+ {/* <FormField
control={form.control}
name="biddingConditions.contractDeliveryDate"
render={({ field }) => (
@@ -829,6 +972,8 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
}))
field.onChange(e.target.value)
}}
+ min="1900-01-01"
+ max="2100-12-31"
/>
</FormControl>
<FormMessage />
@@ -849,7 +994,12 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
계약기간 시작
</FormLabel>
<FormControl>
- <Input type="date" {...field} />
+ <Input
+ type="date"
+ {...field}
+ min="1900-01-01"
+ max="2100-12-31"
+ />
</FormControl>
<FormMessage />
</FormItem>
@@ -866,7 +1016,12 @@ export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProp
계약기간 종료
</FormLabel>
<FormControl>
- <Input type="date" {...field} />
+ <Input
+ type="date"
+ {...field}
+ min="1900-01-01"
+ max="2100-12-31"
+ />
</FormControl>
<FormMessage />
</FormItem>
diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx
index 90923825..27a2c097 100644
--- a/components/bidding/manage/bidding-basic-info-editor.tsx
+++ b/components/bidding/manage/bidding-basic-info-editor.tsx
@@ -2,7 +2,7 @@
import * as React from 'react'
import { useForm } from 'react-hook-form'
-import { ChevronRight, Upload, FileText, Eye, User, Building, Calendar, DollarSign } from 'lucide-react'
+import { ChevronRight, Upload, FileText, Eye, User, Building, Calendar, DollarSign, Check, ChevronsUpDown } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
@@ -25,6 +25,20 @@ 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 {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import { cn } from '@/lib/utils'
// CreateBiddingInput 타입 정의가 없으므로 CreateBiddingSchema를 확장하여 사용합니다.
import { getBiddingById, updateBiddingBasicInfo, getBiddingConditions, getBiddingNotice, updateBiddingConditions, getBiddingNoticeTemplate } from '@/lib/bidding/service'
import { getPurchaseGroupCodes } from '@/components/common/selectors/purchase-group-code'
@@ -270,7 +284,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
}
// Procurement 데이터 로드
- const [paymentTermsData, incotermsData, shippingData, destinationData, purchaseGroupCodes, procurementManagers] = await Promise.all([
+ const [paymentTermsData, incotermsData, shippingData, destinationData] = await Promise.all([
getPaymentTermsForSelection().catch(() => []),
getIncotermsForSelection().catch(() => []),
getPlaceOfShippingForSelection().catch(() => []),
@@ -284,14 +298,20 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
DISPLAY_NAME: bidding.bidPicName || '',
PURCHASE_GROUP_CODE: bidding.bidPicCode || '',
user: {
- id: bidding.bidPicUserId || undefined,
+ id: bidding.bidPicId || undefined,
+ name: bidding.bidPicName || '',
+ email: '',
+ employeeNumber: null,
}
})
setSelectedSupplyPic({
DISPLAY_NAME: bidding.supplyPicName || '',
PROCUREMENT_MANAGER_CODE: bidding.supplyPicCode || '',
user: {
- id: bidding.supplyPicUserId || undefined,
+ id: bidding.supplyPicId || undefined,
+ name: bidding.supplyPicName || '',
+ email: '',
+ employeeNumber: null,
}
})
// // 입찰담당자 및 조달담당자 초기 선택값 설정
@@ -554,7 +574,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
<FormField control={form.control} name="biddingType" render={({ field }) => (
<FormItem>
<FormLabel>입찰유형</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
+ <Select onValueChange={field.onChange} value={field.value} disabled={readonly}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="입찰유형 선택" />
@@ -575,7 +595,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
<FormField control={form.control} name="contractType" render={({ field }) => (
<FormItem>
<FormLabel>계약구분</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
+ <Select onValueChange={field.onChange} value={field.value} disabled={readonly}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="계약구분 선택" />
@@ -603,7 +623,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
<FormItem>
<FormLabel>기타 입찰유형 <span className="text-red-500">*</span></FormLabel>
<FormControl>
- <Input placeholder="직접 입력하세요" {...field} />
+ <Input placeholder="직접 입력하세요" {...field} disabled={readonly} />
</FormControl>
<FormMessage />
</FormItem>
@@ -656,7 +676,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
<FormField control={form.control} name="awardCount" render={({ field }) => (
<FormItem>
<FormLabel>낙찰업체 수</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
+ <Select onValueChange={field.onChange} value={field.value} disabled={readonly}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="낙찰업체 수 선택" />
@@ -691,6 +711,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
field.onChange(code.DISPLAY_NAME || '')
}}
placeholder="입찰담당자 선택"
+ disabled={readonly}
/>
</FormControl>
<FormMessage />
@@ -711,6 +732,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
field.onChange(manager.DISPLAY_NAME || '')
}}
placeholder="조달담당자 선택"
+ disabled={readonly}
/>
</FormControl>
<FormMessage />
@@ -723,7 +745,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
<Building className="h-3 w-3" />
구매조직 <span className="text-red-500">*</span>
</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
+ <Select onValueChange={field.onChange} value={field.value} disabled={readonly}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="구매조직 선택" />
@@ -747,7 +769,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
<FormField control={form.control} name="currency" render={({ field }) => (
<FormItem>
<FormLabel>통화</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
+ <Select onValueChange={field.onChange} value={field.value} disabled={readonly}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="통화 선택" />
@@ -770,7 +792,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
<FormField control={form.control} name="noticeType" render={({ field }) => (
<FormItem>
<FormLabel>구매유형 <span className="text-red-500">*</span></FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
+ <Select onValueChange={field.onChange} value={field.value} disabled={readonly}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="구매유형 선택" />
@@ -801,7 +823,13 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
계약기간 시작
</FormLabel>
<FormControl>
- <Input type="date" {...field} />
+ <Input
+ type="date"
+ {...field}
+ disabled={readonly}
+ min="1900-01-01"
+ max="2100-12-31"
+ />
</FormControl>
<FormMessage />
</FormItem>
@@ -814,7 +842,13 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
계약기간 종료
</FormLabel>
<FormControl>
- <Input type="date" {...field} />
+ <Input
+ type="date"
+ {...field}
+ disabled={readonly}
+ min="1900-01-01"
+ max="2100-12-31"
+ />
</FormControl>
<FormMessage />
</FormItem>
@@ -853,91 +887,173 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
{/* 1행: SHI 지급조건, SHI 매입부가가치세 */}
<div className="grid grid-cols-2 gap-4 mb-4">
- <div>
+ <div className="flex flex-col space-y-2">
<FormLabel>SHI 지급조건 <span className="text-red-500">*</span></FormLabel>
- <Select
- value={biddingConditions.paymentTerms}
- onValueChange={(value) => {
- setBiddingConditions(prev => ({
- ...prev,
- paymentTerms: value
- }))
- }}
- >
- <SelectTrigger>
- <SelectValue placeholder="지급조건 선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.length > 0 ? (
- paymentTermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code}>
- {option.code} {option.description && `(${option.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "justify-between",
+ !biddingConditions.paymentTerms && "text-muted-foreground"
+ )}
+ disabled={readonly}
+ >
+ {biddingConditions.paymentTerms
+ ? paymentTermsOptions.find((option) => option.code === biddingConditions.paymentTerms)
+ ? `${paymentTermsOptions.find((option) => option.code === biddingConditions.paymentTerms)?.code} ${paymentTermsOptions.find((option) => option.code === biddingConditions.paymentTerms)?.description ? `(${paymentTermsOptions.find((option) => option.code === biddingConditions.paymentTerms)?.description})` : ''}`
+ : "지급조건 선택"
+ : "지급조건 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="지급조건 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {paymentTermsOptions.map((option) => (
+ <CommandItem
+ key={option.code}
+ value={`${option.code} ${option.description || ''}`}
+ onSelect={() => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ paymentTerms: option.code
+ }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ option.code === biddingConditions.paymentTerms
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {option.code} {option.description && `(${option.description})`}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
- <div>
+ <div className="flex flex-col space-y-2">
<FormLabel>SHI 매입부가가치세 <span className="text-red-500">*</span></FormLabel>
- <Select
- value={biddingConditions.taxConditions}
- onValueChange={(value) => {
- setBiddingConditions(prev => ({
- ...prev,
- taxConditions: value
- }))
- }}
- >
- <SelectTrigger>
- <SelectValue placeholder="세금조건 선택" />
- </SelectTrigger>
- <SelectContent>
- {TAX_CONDITIONS.map((condition) => (
- <SelectItem key={condition.code} value={condition.code}>
- {condition.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "justify-between",
+ !biddingConditions.taxConditions && "text-muted-foreground"
+ )}
+ disabled={readonly}
+ >
+ {biddingConditions.taxConditions
+ ? TAX_CONDITIONS.find((condition) => condition.code === biddingConditions.taxConditions)?.name
+ : "세금조건 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="세금조건 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {TAX_CONDITIONS.map((condition) => (
+ <CommandItem
+ key={condition.code}
+ value={`${condition.code} ${condition.name}`}
+ onSelect={() => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ taxConditions: condition.code
+ }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ condition.code === biddingConditions.taxConditions
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {condition.name}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
</div>
{/* 2행: SHI 인도조건, SHI 인도조건2 */}
<div className="grid grid-cols-2 gap-4 mb-4">
- <div>
+ <div className="flex flex-col space-y-2">
<FormLabel>SHI 인도조건 <span className="text-red-500">*</span></FormLabel>
- <Select
- value={biddingConditions.incoterms}
- onValueChange={(value) => {
- setBiddingConditions(prev => ({
- ...prev,
- incoterms: value
- }))
- }}
- >
- <SelectTrigger>
- <SelectValue placeholder="인코텀즈 선택" />
- </SelectTrigger>
- <SelectContent>
- {incotermsOptions.length > 0 ? (
- incotermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code}>
- {option.code} {option.description && `(${option.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "justify-between",
+ !biddingConditions.incoterms && "text-muted-foreground"
+ )}
+ disabled={readonly}
+ >
+ {biddingConditions.incoterms
+ ? incotermsOptions.find((option) => option.code === biddingConditions.incoterms)
+ ? `${incotermsOptions.find((option) => option.code === biddingConditions.incoterms)?.code} ${incotermsOptions.find((option) => option.code === biddingConditions.incoterms)?.description ? `(${incotermsOptions.find((option) => option.code === biddingConditions.incoterms)?.description})` : ''}`
+ : "인코텀즈 선택"
+ : "인코텀즈 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="인코텀즈 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {incotermsOptions.map((option) => (
+ <CommandItem
+ key={option.code}
+ value={`${option.code} ${option.description || ''}`}
+ onSelect={() => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ incoterms: option.code
+ }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ option.code === biddingConditions.incoterms
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {option.code} {option.description && `(${option.description})`}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
<div>
@@ -951,70 +1067,123 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
incotermsOption: e.target.value
}))
}}
+ disabled={readonly}
/>
</div>
</div>
{/* 3행: SHI 선적지, SHI 하역지 */}
<div className="grid grid-cols-2 gap-4 mb-4">
- <div>
+ <div className="flex flex-col space-y-2">
<FormLabel>SHI 선적지</FormLabel>
- <Select
- value={biddingConditions.shippingPort}
- onValueChange={(value) => {
- setBiddingConditions(prev => ({
- ...prev,
- shippingPort: value
- }))
- }}
- >
- <SelectTrigger>
- <SelectValue placeholder="선적지 선택" />
- </SelectTrigger>
- <SelectContent>
- {shippingPlaces.length > 0 ? (
- shippingPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code}>
- {place.code} {place.description && `(${place.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "justify-between",
+ !biddingConditions.shippingPort && "text-muted-foreground"
+ )}
+ disabled={readonly}
+ >
+ {biddingConditions.shippingPort
+ ? shippingPlaces.find((place) => place.code === biddingConditions.shippingPort)
+ ? `${shippingPlaces.find((place) => place.code === biddingConditions.shippingPort)?.code} ${shippingPlaces.find((place) => place.code === biddingConditions.shippingPort)?.description ? `(${shippingPlaces.find((place) => place.code === biddingConditions.shippingPort)?.description})` : ''}`
+ : "선적지 선택"
+ : "선적지 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="선적지 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {shippingPlaces.map((place) => (
+ <CommandItem
+ key={place.code}
+ value={`${place.code} ${place.description || ''}`}
+ onSelect={() => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ shippingPort: place.code
+ }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ place.code === biddingConditions.shippingPort
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {place.code} {place.description && `(${place.description})`}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
- <div>
+ <div className="flex flex-col space-y-2">
<FormLabel>SHI 하역지</FormLabel>
- <Select
- value={biddingConditions.destinationPort}
- onValueChange={(value) => {
- setBiddingConditions(prev => ({
- ...prev,
- destinationPort: value
- }))
- }}
- >
- <SelectTrigger>
- <SelectValue placeholder="하역지 선택" />
- </SelectTrigger>
- <SelectContent>
- {destinationPlaces.length > 0 ? (
- destinationPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code}>
- {place.code} {place.description && `(${place.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "justify-between",
+ !biddingConditions.destinationPort && "text-muted-foreground"
+ )}
+ disabled={readonly}
+ >
+ {biddingConditions.destinationPort
+ ? destinationPlaces.find((place) => place.code === biddingConditions.destinationPort)
+ ? `${destinationPlaces.find((place) => place.code === biddingConditions.destinationPort)?.code} ${destinationPlaces.find((place) => place.code === biddingConditions.destinationPort)?.description ? `(${destinationPlaces.find((place) => place.code === biddingConditions.destinationPort)?.description})` : ''}`
+ : "하역지 선택"
+ : "하역지 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="하역지 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {destinationPlaces.map((place) => (
+ <CommandItem
+ key={place.code}
+ value={`${place.code} ${place.description || ''}`}
+ onSelect={() => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ destinationPort: place.code
+ }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ place.code === biddingConditions.destinationPort
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {place.code} {place.description && `(${place.description})`}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
</div>
@@ -1045,6 +1214,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
}))
}}
id="price-adjustment"
+ disabled={readonly}
/>
<FormLabel htmlFor="price-adjustment" className="text-sm">
{biddingConditions.isPriceAdjustmentApplicable ? "적용" : "미적용"}
@@ -1067,7 +1237,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
}))
}}
rows={3}
- readOnly={readonly}
+ disabled={readonly}
/>
</div>
</div>
@@ -1135,15 +1305,16 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
}}
onDropRejected={() => {
toast({
- title: "File upload rejected",
- description: "Please check file size and type.",
+ title: "파일 업로드 거부",
+ description: "파일 크기와 유형을 확인해주세요.",
variant: "destructive",
})
}}
+ disabled={readonly}
>
<DropzoneZone>
<DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" />
- <DropzoneTitle className="text-lg font-medium">
+ <DropzoneTitle>
파일을 드래그하거나 클릭하여 업로드
</DropzoneTitle>
<DropzoneDescription className="text-sm text-muted-foreground">
@@ -1194,6 +1365,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
variant="ghost"
size="sm"
onClick={() => handleDeleteDocument(doc.id)}
+ disabled={readonly}
>
삭제
</Button>
@@ -1227,15 +1399,16 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
}}
onDropRejected={() => {
toast({
- title: "File upload rejected",
- description: "Please check file size and type.",
+ title: "파일 업로드 거부",
+ description: "파일 크기와 유형을 확인해주세요.",
variant: "destructive",
})
}}
+ disabled={readonly}
>
<DropzoneZone>
<DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" />
- <DropzoneTitle className="text-lg font-medium">
+ <DropzoneTitle>
파일을 드래그하거나 클릭하여 업로드
</DropzoneTitle>
<DropzoneDescription className="text-sm text-muted-foreground">
@@ -1281,6 +1454,7 @@ export function BiddingBasicInfoEditor({ biddingId, readonly = false }: BiddingB
variant="ghost"
size="sm"
onClick={() => handleDeleteDocument(doc.id)}
+ disabled={readonly}
>
삭제
</Button>
diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx
index f6b3a3f0..6634f528 100644
--- a/components/bidding/manage/bidding-companies-editor.tsx
+++ b/components/bidding/manage/bidding-companies-editor.tsx
@@ -494,7 +494,7 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
</p>
</div>
{!readonly && (
- <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2">
+ <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2" disabled={readonly}>
<Plus className="h-4 w-4" />
업체 추가
</Button>
@@ -532,6 +532,7 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
<Checkbox
checked={selectedVendor?.id === vendor.id}
onCheckedChange={() => handleVendorSelect(vendor)}
+ disabled={readonly}
/>
</TableCell>
<TableCell className="font-medium">{vendor.vendorName}</TableCell>
@@ -565,6 +566,7 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
onCheckedChange={(checked) =>
handleTogglePriceAdjustmentQuestion(vendor.id, checked as boolean)
}
+ disabled={readonly}
/>
<span className="text-sm text-muted-foreground">
{vendor.isPriceAdjustmentApplicableQuestion ? '예' : '아니오'}
@@ -577,6 +579,7 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
size="sm"
onClick={() => handleRemoveVendor(vendor.id)}
className="text-red-600 hover:text-red-800"
+ disabled={readonly}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -607,6 +610,7 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
variant="outline"
onClick={handleOpenAddContactFromVendor}
className="flex items-center gap-2"
+ disabled={readonly}
>
<User className="h-4 w-4" />
업체 담당자 추가
@@ -614,6 +618,7 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
<Button
onClick={() => setAddContactDialogOpen(true)}
className="flex items-center gap-2"
+ disabled={readonly}
>
<Plus className="h-4 w-4" />
담당자 수기 입력
@@ -652,6 +657,7 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC
size="sm"
onClick={() => handleDeleteContact(biddingCompanyContact.id)}
className="text-red-600 hover:text-red-800"
+ disabled={readonly}
>
<Trash2 className="h-4 w-4" />
</Button>
diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx
index ef0aa568..9d858f40 100644
--- a/components/bidding/manage/bidding-items-editor.tsx
+++ b/components/bidding/manage/bidding-items-editor.tsx
@@ -807,7 +807,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
<Checkbox
checked={item.isRepresentative}
onCheckedChange={() => setRepresentativeItem(item.id)}
- disabled={items.length <= 1 && item.isRepresentative}
+ disabled={(items.length <= 1 && item.isRepresentative) || readonly}
title="대표 아이템"
/>
</td>
@@ -831,6 +831,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
}
}}
placeholder="프로젝트 선택"
+ disabled={readonly}
/>
</td>
<td className="border-r px-3 py-2">
@@ -942,21 +943,25 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
<Input
type="number"
min="0"
+ step="0.001"
placeholder="수량"
value={item.quantity || ''}
onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })}
className="h-8 text-xs"
required
+ disabled={readonly}
/>
) : (
<Input
type="number"
min="0"
+ step="0.001"
placeholder="중량"
value={item.totalWeight || ''}
onChange={(e) => updatePRItem(item.id, { totalWeight: e.target.value })}
className="h-8 text-xs"
required
+ disabled={readonly}
/>
)}
</td>
@@ -966,6 +971,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
value={item.quantityUnit || 'EA'}
onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })}
required
+ disabled={readonly}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
@@ -984,6 +990,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
value={item.weightUnit || 'KG'}
onValueChange={(value) => updatePRItem(item.id, { weightUnit: value })}
required
+ disabled={readonly}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
@@ -1004,6 +1011,9 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })}
className="h-8 text-xs"
required
+ disabled={readonly}
+ min="1900-01-01"
+ max="2100-12-31"
/>
</td>
<td className="border-r px-3 py-2">
@@ -1015,12 +1025,14 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
value={item.priceUnit || ''}
onChange={(e) => updatePRItem(item.id, { priceUnit: e.target.value })}
className="h-8 text-xs"
+ disabled={readonly}
/>
</td>
<td className="border-r px-3 py-2">
<Select
value={item.purchaseUnit || 'EA'}
onValueChange={(value) => updatePRItem(item.id, { purchaseUnit: value })}
+ disabled={readonly}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
@@ -1043,11 +1055,12 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
<Input
type="number"
min="0"
- step="0.01"
+ step="0.001"
placeholder="자재순중량"
value={item.materialWeight || ''}
onChange={(e) => updatePRItem(item.id, { materialWeight: e.target.value })}
className="h-8 text-xs"
+ disabled={readonly}
/>
</td>
<td className="border-r px-3 py-2">
@@ -1057,6 +1070,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
value={formatNumberWithCommas(item.targetUnitPrice)}
onChange={(e) => updatePRItem(item.id, { targetUnitPrice: parseNumberFromCommas(e.target.value) })}
className="h-8 text-xs"
+ disabled={readonly}
/>
</td>
<td className="border-r px-3 py-2">
@@ -1072,6 +1086,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
<Select
value={item.targetCurrency || 'KRW'}
onValueChange={(value) => updatePRItem(item.id, { targetCurrency: value })}
+ disabled={readonly}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
@@ -1091,12 +1106,14 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
value={formatNumberWithCommas(item.budgetAmount)}
onChange={(e) => updatePRItem(item.id, { budgetAmount: parseNumberFromCommas(e.target.value) })}
className="h-8 text-xs"
+ disabled={readonly}
/>
</td>
<td className="border-r px-3 py-2">
<Select
value={item.budgetCurrency || 'KRW'}
onValueChange={(value) => updatePRItem(item.id, { budgetCurrency: value })}
+ disabled={readonly}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
@@ -1116,12 +1133,14 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
value={formatNumberWithCommas(item.actualAmount)}
onChange={(e) => updatePRItem(item.id, { actualAmount: parseNumberFromCommas(e.target.value) })}
className="h-8 text-xs"
+ disabled={readonly}
/>
</td>
<td className="border-r px-3 py-2">
<Select
value={item.actualCurrency || 'KRW'}
onValueChange={(value) => updatePRItem(item.id, { actualCurrency: value })}
+ disabled={readonly}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
@@ -1148,6 +1167,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
setWbsCodeDialogOpen(true)
}}
className="w-full justify-start h-8 text-xs"
+ disabled={readonly}
>
{item.wbsCode ? (
<span className="truncate">
@@ -1201,6 +1221,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
setCostCenterDialogOpen(true)
}}
className="w-full justify-start h-8 text-xs"
+ disabled={readonly}
>
{item.costCenterCode ? (
<span className="truncate">
@@ -1254,6 +1275,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
setGlAccountDialogOpen(true)
}}
className="w-full justify-start h-8 text-xs"
+ disabled={readonly}
>
{item.glAccountCode ? (
<span className="truncate">
@@ -1309,7 +1331,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
variant="ghost"
size="sm"
onClick={() => handleRemoveItem(item.id)}
- disabled={items.length <= 1}
+ disabled={items.length <= 1 || readonly}
className="h-7 w-7 p-0"
title="품목 삭제"
>
@@ -1343,11 +1365,11 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
</p>
</div>
<div className="flex gap-2">
- <Button onClick={() => setPreQuoteDialogOpen(true)} variant="outline" className="flex items-center gap-2">
+ <Button onClick={() => setPreQuoteDialogOpen(true)} variant="outline" className="flex items-center gap-2" disabled={readonly}>
<FileText className="h-4 w-4" />
사전견적
</Button>
- <Button onClick={handleAddItem} className="flex items-center gap-2">
+ <Button onClick={handleAddItem} className="flex items-center gap-2" disabled={readonly}>
<Plus className="h-4 w-4" />
품목 추가
</Button>
@@ -1364,6 +1386,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
onChange={(e) => setTargetPriceCalculationCriteria(e.target.value)}
rows={3}
className="resize-none"
+ disabled={readonly}
/>
<p className="text-xs text-muted-foreground">
내정가를 산정한 기준이나 방법을 입력하세요
@@ -1379,6 +1402,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
checked={quantityWeightMode === 'quantity'}
onChange={() => handleQuantityWeightModeChange('quantity')}
className="h-4 w-4"
+ disabled={readonly}
/>
<label htmlFor="quantity-mode" className="text-sm">수량 기준</label>
</div>
@@ -1390,6 +1414,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems
checked={quantityWeightMode === 'weight'}
onChange={() => handleQuantityWeightModeChange('weight')}
className="h-4 w-4"
+ disabled={readonly}
/>
<label htmlFor="weight-mode" className="text-sm">중량 기준</label>
</div>
diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx
index 4ddaee08..49659ae7 100644
--- a/components/bidding/manage/bidding-schedule-editor.tsx
+++ b/components/bidding/manage/bidding-schedule-editor.tsx
@@ -633,6 +633,9 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
value={schedule.submissionStartDate}
onChange={(e) => handleScheduleChange('submissionStartDate', e.target.value)}
className={!schedule.submissionStartDate ? 'border-red-200' : ''}
+ disabled={readonly}
+ min="1900-01-01T00:00"
+ max="2100-12-31T23:59"
/>
{!schedule.submissionStartDate && (
<p className="text-sm text-red-500">제출 시작일시는 필수입니다</p>
@@ -646,6 +649,9 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
value={schedule.submissionEndDate}
onChange={(e) => handleScheduleChange('submissionEndDate', e.target.value)}
className={!schedule.submissionEndDate ? 'border-red-200' : ''}
+ disabled={readonly}
+ min="1900-01-01T00:00"
+ max="2100-12-31T23:59"
/>
{!schedule.submissionEndDate && (
<p className="text-sm text-red-500">제출 마감일시는 필수입니다</p>
@@ -665,6 +671,7 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
<Switch
checked={schedule.isUrgent || false}
onCheckedChange={(checked) => handleScheduleChange('isUrgent', checked)}
+ disabled={readonly}
/>
</div>
@@ -679,6 +686,7 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
<Switch
checked={schedule.hasSpecificationMeeting || false}
onCheckedChange={(checked) => handleScheduleChange('hasSpecificationMeeting', checked)}
+ disabled={readonly}
/>
</div>
@@ -693,6 +701,9 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
value={specMeetingInfo.meetingDate}
onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingDate: e.target.value }))}
className={!specMeetingInfo.meetingDate ? 'border-red-200' : ''}
+ disabled={readonly}
+ min="1900-01-01T00:00"
+ max="2100-12-31T23:59"
/>
{!specMeetingInfo.meetingDate && (
<p className="text-sm text-red-500 mt-1">회의일시는 필수입니다</p>
@@ -704,6 +715,7 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
placeholder="예: 14:00 ~ 16:00"
value={specMeetingInfo.meetingTime}
onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingTime: e.target.value }))}
+ disabled={readonly}
/>
</div>
</div>
@@ -714,6 +726,7 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
value={specMeetingInfo.location}
onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, location: e.target.value }))}
className={!specMeetingInfo.location ? 'border-red-200' : ''}
+ disabled={readonly}
/>
{!specMeetingInfo.location && (
<p className="text-sm text-red-500 mt-1">회의 장소는 필수입니다</p>
@@ -725,6 +738,7 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
placeholder="회의 장소 주소"
value={specMeetingInfo.address}
onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, address: e.target.value }))}
+ disabled={readonly}
/>
</div>
<div className="grid grid-cols-3 gap-4">
@@ -735,6 +749,7 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
value={specMeetingInfo.contactPerson}
onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPerson: e.target.value }))}
className={!specMeetingInfo.contactPerson ? 'border-red-200' : ''}
+ disabled={readonly}
/>
{!specMeetingInfo.contactPerson && (
<p className="text-sm text-red-500 mt-1">담당자는 필수입니다</p>
@@ -746,6 +761,7 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
placeholder="전화번호"
value={specMeetingInfo.contactPhone}
onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPhone: e.target.value }))}
+ disabled={readonly}
/>
</div>
<div>
@@ -755,6 +771,7 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
placeholder="이메일"
value={specMeetingInfo.contactEmail}
onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactEmail: e.target.value }))}
+ disabled={readonly}
/>
</div>
</div>
@@ -765,6 +782,7 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
value={specMeetingInfo.agenda}
onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, agenda: e.target.value }))}
rows={3}
+ disabled={readonly}
/>
</div>
<div>
@@ -774,6 +792,7 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
value={specMeetingInfo.materials}
onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, materials: e.target.value }))}
rows={3}
+ disabled={readonly}
/>
</div>
<div>
@@ -783,6 +802,7 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
value={specMeetingInfo.notes}
onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, notes: e.target.value }))}
rows={3}
+ disabled={readonly}
/>
</div>
</div>
@@ -799,6 +819,7 @@ export function BiddingScheduleEditor({ biddingId, readonly = false }: BiddingSc
onChange={(e) => handleScheduleChange('remarks', e.target.value)}
placeholder="일정에 대한 추가 설명이나 참고사항을 입력하세요"
rows={4}
+ disabled={readonly}
/>
</div>
</div>
diff --git a/components/bidding/receive/bidding-participants-dialog.tsx b/components/bidding/receive/bidding-participants-dialog.tsx
new file mode 100644
index 00000000..5739a07e
--- /dev/null
+++ b/components/bidding/receive/bidding-participants-dialog.tsx
@@ -0,0 +1,216 @@
+'use client'
+
+import * as React from 'react'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { DataTable } from '@/components/data-table/data-table'
+import { ColumnDef } from '@tanstack/react-table'
+import { Building2, User, Mail, Phone, Calendar, BadgeCheck } from 'lucide-react'
+import { formatDate } from '@/lib/utils'
+import { Badge } from '@/components/ui/badge'
+
+interface ParticipantCompany {
+ id: number
+ biddingId: number
+ companyId: number | null
+ vendorName: string
+ vendorCode: string
+ contactPerson: string | null
+ contactEmail: string | null
+ contactPhone: string | null
+ invitationStatus: string
+ updatedAt: Date | null
+}
+
+interface BiddingParticipantsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ biddingId: number | null
+ participantType: 'expected' | 'participated' | 'declined' | 'pending' | null
+ companies: ParticipantCompany[]
+}
+
+const invitationStatusLabels: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
+ invited: { label: '초대됨', variant: 'outline' },
+ accepted: { label: '참여확정', variant: 'default' },
+ declined: { label: '포기', variant: 'destructive' },
+ pending: { label: '미제출', variant: 'secondary' },
+ submitted: { label: '제출완료', variant: 'default' },
+}
+
+const participantTypeLabels: Record<string, string> = {
+ expected: '참여예정협력사',
+ participated: '참여협력사',
+ declined: '포기협력사',
+ pending: '미제출협력사',
+}
+
+export function BiddingParticipantsDialog({
+ open,
+ onOpenChange,
+ biddingId,
+ participantType,
+ companies,
+}: BiddingParticipantsDialogProps) {
+ const columns = React.useMemo<ColumnDef<ParticipantCompany>[]>(
+ () => [
+ {
+ id: 'vendorCode',
+ accessorKey: 'vendorCode',
+ header: '협력사코드',
+ cell: ({ row }) => (
+ <div className="flex items-center gap-2">
+ <BadgeCheck className="h-4 w-4 text-muted-foreground" />
+ <span className="font-mono text-sm">{row.original.vendorCode}</span>
+ </div>
+ ),
+ size: 120,
+ },
+ {
+ id: 'vendorName',
+ accessorKey: 'vendorName',
+ header: '협력사명',
+ cell: ({ row }) => (
+ <div className="flex items-center gap-2">
+ <Building2 className="h-4 w-4 text-muted-foreground" />
+ <span className="font-medium">{row.original.vendorName}</span>
+ </div>
+ ),
+ size: 200,
+ },
+ {
+ id: 'invitationStatus',
+ accessorKey: 'invitationStatus',
+ header: '구분',
+ cell: ({ row }) => {
+ const status = row.original.invitationStatus
+ const statusInfo = invitationStatusLabels[status] || { label: status, variant: 'outline' as const }
+ return (
+ <Badge variant={statusInfo.variant}>
+ {statusInfo.label}
+ </Badge>
+ )
+ },
+ size: 100,
+ },
+ {
+ id: 'updatedAt',
+ accessorKey: 'updatedAt',
+ header: '응찰/포기일시',
+ cell: ({ row }) => (
+ <div className="flex items-center gap-2">
+ <Calendar className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">
+ {row.original.updatedAt ? formatDate(row.original.updatedAt) : '-'}
+ </span>
+ </div>
+ ),
+ size: 150,
+ },
+ {
+ id: 'contactPerson',
+ accessorKey: 'contactPerson',
+ header: '협력사 담당자',
+ cell: ({ row }) => (
+ <div className="flex items-center gap-2">
+ <User className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">{row.original.contactPerson || '-'}</span>
+ </div>
+ ),
+ size: 120,
+ },
+ {
+ id: 'contactEmail',
+ accessorKey: 'contactEmail',
+ header: '담당자 이메일',
+ cell: ({ row }) => (
+ <div className="flex items-center gap-2">
+ <Mail className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm text-muted-foreground">{row.original.contactEmail || '-'}</span>
+ </div>
+ ),
+ size: 200,
+ },
+ {
+ id: 'contactPhone',
+ accessorKey: 'contactPhone',
+ header: '담당자 전화번호',
+ cell: ({ row }) => (
+ <div className="flex items-center gap-2">
+ <Phone className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm text-muted-foreground">{row.original.contactPhone || '-'}</span>
+ </div>
+ ),
+ size: 150,
+ },
+ ],
+ []
+ )
+
+ const table = React.useMemo(
+ () => ({
+ data: companies,
+ columns,
+ pageCount: 1,
+ }),
+ [companies, columns]
+ )
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-6xl max-h-[80vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle>
+ {'협력사 목록'}
+ {' '}
+ <span className="text-muted-foreground">({companies.length}개)</span>
+ </DialogTitle>
+ </DialogHeader>
+ <div className="flex-1 overflow-auto">
+ <div className="border rounded-lg">
+ <div className="overflow-x-auto">
+ <table className="w-full">
+ <thead className="bg-muted/50">
+ <tr>
+ {columns.map((column) => (
+ <th
+ key={column.id}
+ className="px-4 py-3 text-left text-sm font-medium"
+ style={{ width: column.size }}
+ >
+ {typeof column.header === 'function'
+ ? column.header({} as any)
+ : column.header}
+ </th>
+ ))}
+ </tr>
+ </thead>
+ <tbody>
+ {companies.length === 0 ? (
+ <tr>
+ <td colSpan={columns.length} className="px-4 py-8 text-center text-muted-foreground">
+ 협력사가 없습니다.
+ </td>
+ </tr>
+ ) : (
+ companies.map((company) => (
+ <tr key={company.id} className="border-t hover:bg-muted/50">
+ {columns.map((column) => (
+ <td key={column.id} className="px-4 py-3">
+ {column.cell
+ ? column.cell({ row: { original: company } } as any)
+ : (company as any)[column.accessorKey as string]}
+ </td>
+ ))}
+ </tr>
+ ))
+ )}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts
index d87f9fa8..bc31f6de 100644
--- a/db/schema/bidding.ts
+++ b/db/schema/bidding.ts
@@ -294,15 +294,15 @@ export const prItemsForBidding = pgTable('pr_items_for_bidding', {
currency: varchar('currency', { length: 3 }).default('KRW'),
// 수량 및 중량
- quantity: decimal('quantity', { precision: 10, scale: 2 }), // 수량
+ quantity: decimal('quantity', { precision: 10, scale: 3 }), // 수량
quantityUnit: varchar('quantity_unit', { length: 50 }), // 수량단위 (구매단위)
- totalWeight: decimal('total_weight', { precision: 10, scale: 2 }), // 총 중량
+ totalWeight: decimal('total_weight', { precision: 10, scale: 3 }), // 총 중량
weightUnit: varchar('weight_unit', { length: 50 }), // 중량단위 (자재순중량)
// 가격 단위 추가
priceUnit: varchar('price_unit', { length: 50 }), // 가격단위
purchaseUnit: varchar('purchase_unit', { length: 50 }), // 구매단위
- materialWeight: decimal('material_weight', { precision: 10, scale: 2 }), // 자재순중량
+ materialWeight: decimal('material_weight', { precision: 10, scale: 3 }), // 자재순중량
// WBS 정보
wbsCode: varchar('wbs_code', { length: 100 }), // WBS 코드
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index 0b68eaa7..e425959c 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -3,7 +3,7 @@
import db from '@/db/db'
import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, priceAdjustmentForms, users, vendorContacts } from '@/db/schema'
import { specificationMeetings, biddingCompaniesContacts } from '@/db/schema/bidding'
-import { eq, and, sql, desc, ne } from 'drizzle-orm'
+import { eq, and, sql, desc, ne, asc } from 'drizzle-orm'
import { revalidatePath, revalidateTag } from 'next/cache'
import { unstable_cache } from "@/lib/unstable-cache";
import { sendEmail } from '@/lib/mail/sendEmail'
@@ -207,6 +207,80 @@ export async function getBiddingCompaniesData(biddingId: number) {
}
}
+// 입찰 접수 화면용: 모든 초대된 협력사 조회 (필터링 없음, contact 정보 포함)
+export async function getAllBiddingCompanies(biddingId: number) {
+ try {
+ // 1. 기본 협력사 정보 조회
+ const companies = await db
+ .select({
+ id: biddingCompanies.id,
+ biddingId: biddingCompanies.biddingId,
+ companyId: biddingCompanies.companyId,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ invitationStatus: biddingCompanies.invitationStatus,
+ invitedAt: biddingCompanies.invitedAt,
+ respondedAt: biddingCompanies.respondedAt,
+ preQuoteAmount: biddingCompanies.preQuoteAmount,
+ preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
+ isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ finalQuoteSubmittedAt: biddingCompanies.finalQuoteSubmittedAt,
+ isWinner: biddingCompanies.isWinner,
+ notes: biddingCompanies.notes,
+ createdAt: biddingCompanies.createdAt,
+ updatedAt: biddingCompanies.updatedAt
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(eq(biddingCompanies.biddingId, biddingId))
+ .orderBy(biddingCompanies.invitedAt)
+
+ // 2. 각 협력사의 첫 번째 contact 정보 조회
+ const companiesWithContacts = await Promise.all(
+ companies.map(async (company) => {
+ if (!company.companyId) {
+ return {
+ ...company,
+ contactPerson: null,
+ contactEmail: null,
+ contactPhone: null
+ }
+ }
+
+ // biddingCompaniesContacts에서 첫 번째 contact 조회
+ const [firstContact] = await db
+ .select({
+ contactName: biddingCompaniesContacts.contactName,
+ contactEmail: biddingCompaniesContacts.contactEmail,
+ contactNumber: biddingCompaniesContacts.contactNumber,
+ })
+ .from(biddingCompaniesContacts)
+ .where(
+ and(
+ eq(biddingCompaniesContacts.biddingId, biddingId),
+ eq(biddingCompaniesContacts.vendorId, company.companyId)
+ )
+ )
+ .orderBy(asc(biddingCompaniesContacts.id))
+ .limit(1)
+
+ return {
+ ...company,
+ contactPerson: firstContact?.contactName || null,
+ contactEmail: firstContact?.contactEmail || null,
+ contactPhone: firstContact?.contactNumber || null
+ }
+ })
+ )
+
+ return companiesWithContacts
+ } catch (error) {
+ console.error('Failed to get all bidding companies:', error)
+ return []
+ }
+}
+
// prItemsForBidding 테이블에서 품목 정보 조회 (캐시 미적용, always fresh)
export async function getPRItemsForBidding(biddingId: number) {
try {
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index 907115b1..9b8c19c5 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -256,23 +256,17 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
- const now = new Date().toString()
- console.log(now, "now")
- const startIso = new Date(startDate).toISOString()
- const endIso = new Date(endDate).toISOString()
+ const startObj = new Date(startDate)
+ const endObj = new Date(endDate)
+
+ // UI 표시용 KST 변환
+ const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
- const isActive = new Date(now) >= new Date(startIso) && new Date(now) <= new Date(endIso)
- console.log(isActive, "isActive")
- const isPast = new Date(now) > new Date(endIso)
- console.log(isPast, "isPast")
return (
<div className="text-xs">
- <div className={`${isActive ? 'text-green-600 font-medium' : isPast ? 'text-red-600' : 'text-gray-600'}`}>
- {new Date(startDate).toISOString().slice(0, 16).replace('T', ' ')} ~ {new Date(endDate).toISOString().slice(0, 16).replace('T', ' ')}
+ <div>
+ {formatKst(startObj)} ~ {formatKst(endObj)}
</div>
- {isActive && (
- <Badge variant="default" className="text-xs mt-1">진행중</Badge>
- )}
</div>
)
},
diff --git a/lib/bidding/list/edit-bidding-sheet.tsx b/lib/bidding/list/edit-bidding-sheet.tsx
index ed3d3f41..23f76f4a 100644
--- a/lib/bidding/list/edit-bidding-sheet.tsx
+++ b/lib/bidding/list/edit-bidding-sheet.tsx
@@ -367,7 +367,12 @@ export function EditBiddingSheet({
<FormItem>
<FormLabel>계약 시작일</FormLabel>
<FormControl>
- <Input type="date" {...field} />
+ <Input
+ type="date"
+ {...field}
+ min="1900-01-01"
+ max="2100-12-31"
+ />
</FormControl>
<FormMessage />
</FormItem>
@@ -381,7 +386,12 @@ export function EditBiddingSheet({
<FormItem>
<FormLabel>계약 종료일</FormLabel>
<FormControl>
- <Input type="date" {...field} />
+ <Input
+ type="date"
+ {...field}
+ min="1900-01-01"
+ max="2100-12-31"
+ />
</FormControl>
<FormMessage />
</FormItem>
diff --git a/lib/bidding/receive/biddings-receive-columns.tsx b/lib/bidding/receive/biddings-receive-columns.tsx
index 4bde849c..9650574a 100644
--- a/lib/bidding/receive/biddings-receive-columns.tsx
+++ b/lib/bidding/receive/biddings-receive-columns.tsx
@@ -58,6 +58,7 @@ type BiddingReceiveItem = {
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingReceiveItem> | null>>
+ onParticipantClick?: (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => void
}
// 상태별 배지 색상
@@ -89,7 +90,7 @@ const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
}).format(numAmount)
}
-export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingReceiveItem>[] {
+export function getBiddingsReceiveColumns({ setRowAction, onParticipantClick }: GetColumnsProps): ColumnDef<BiddingReceiveItem>[] {
return [
// ░░░ 선택 ░░░
@@ -195,24 +196,17 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co
if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
- const now = new Date()
const startObj = new Date(startDate)
const endObj = new Date(endDate)
-
- const isActive = now >= startObj && now <= endObj
- const isPast = now > endObj
// UI 표시용 KST 변환
const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
return (
<div className="text-xs">
- <div className={`${isActive ? 'text-green-600 font-medium' : isPast ? 'text-red-600' : 'text-gray-600'}`}>
+ <div>
{formatKst(startObj)} ~ {formatKst(endObj)}
</div>
- {isActive && (
- <Badge variant="default" className="text-xs mt-1">진행중</Badge>
- )}
</div>
)
},
@@ -251,10 +245,18 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co
id: "participantExpected",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정협력사" />,
cell: ({ row }) => (
- <div className="flex items-center gap-1">
- <Users className="h-4 w-4 text-blue-500" />
- <span className="text-sm font-medium">{row.original.participantExpected}</span>
- </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-blue-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'expected')}
+ disabled={row.original.participantExpected === 0}
+ >
+ <div className="flex items-center gap-1">
+ <Users className="h-4 w-4 text-blue-500" />
+ <span className="text-sm font-medium">{row.original.participantExpected}</span>
+ </div>
+ </Button>
),
size: 100,
meta: { excelHeader: "참여예정협력사" },
@@ -265,10 +267,18 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co
id: "participantParticipated",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여협력사" />,
cell: ({ row }) => (
- <div className="flex items-center gap-1">
- <CheckCircle className="h-4 w-4 text-green-500" />
- <span className="text-sm font-medium">{row.original.participantParticipated}</span>
- </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-green-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'participated')}
+ disabled={row.original.participantParticipated === 0}
+ >
+ <div className="flex items-center gap-1">
+ <CheckCircle className="h-4 w-4 text-green-500" />
+ <span className="text-sm font-medium">{row.original.participantParticipated}</span>
+ </div>
+ </Button>
),
size: 100,
meta: { excelHeader: "참여협력사" },
@@ -279,10 +289,18 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co
id: "participantDeclined",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기협력사" />,
cell: ({ row }) => (
- <div className="flex items-center gap-1">
- <XCircle className="h-4 w-4 text-red-500" />
- <span className="text-sm font-medium">{row.original.participantDeclined}</span>
- </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-red-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'declined')}
+ disabled={row.original.participantDeclined === 0}
+ >
+ <div className="flex items-center gap-1">
+ <XCircle className="h-4 w-4 text-red-500" />
+ <span className="text-sm font-medium">{row.original.participantDeclined}</span>
+ </div>
+ </Button>
),
size: 100,
meta: { excelHeader: "포기협력사" },
@@ -293,10 +311,18 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co
id: "participantPending",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="미제출협력사" />,
cell: ({ row }) => (
- <div className="flex items-center gap-1">
- <Clock className="h-4 w-4 text-yellow-500" />
- <span className="text-sm font-medium">{row.original.participantPending}</span>
- </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-yellow-50"
+ onClick={() => onParticipantClick?.(row.original.id, 'pending')}
+ disabled={row.original.participantPending === 0}
+ >
+ <div className="flex items-center gap-1">
+ <Clock className="h-4 w-4 text-yellow-500" />
+ <span className="text-sm font-medium">{row.original.participantPending}</span>
+ </div>
+ </Button>
),
size: 100,
meta: { excelHeader: "미제출협력사" },
diff --git a/lib/bidding/receive/biddings-receive-table.tsx b/lib/bidding/receive/biddings-receive-table.tsx
index 5bda921e..2b141d5e 100644
--- a/lib/bidding/receive/biddings-receive-table.tsx
+++ b/lib/bidding/receive/biddings-receive-table.tsx
@@ -22,7 +22,9 @@ import {
contractTypeLabels,
} from "@/db/schema"
// import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
-import { openBiddingAction, earlyOpenBiddingAction } from "@/lib/bidding/actions"
+import { openBiddingAction } from "@/lib/bidding/actions"
+import { BiddingParticipantsDialog } from "@/components/bidding/receive/bidding-participants-dialog"
+import { getAllBiddingCompanies } from "@/lib/bidding/detail/service"
type BiddingReceiveItem = {
id: number
@@ -69,17 +71,49 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
const [isCompact, setIsCompact] = React.useState<boolean>(false)
// const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
// const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
- // const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null)
+ const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null)
const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingReceiveItem> | null>(null)
const [isOpeningBidding, setIsOpeningBidding] = React.useState(false)
+ // 협력사 다이얼로그 관련 상태
+ const [participantsDialogOpen, setParticipantsDialogOpen] = React.useState(false)
+ const [selectedParticipantType, setSelectedParticipantType] = React.useState<'expected' | 'participated' | 'declined' | 'pending' | null>(null)
+ const [selectedBiddingId, setSelectedBiddingId] = React.useState<number | null>(null)
+ const [participantCompanies, setParticipantCompanies] = React.useState<any[]>([])
+ const [isLoadingParticipants, setIsLoadingParticipants] = React.useState(false)
+
const router = useRouter()
const { data: session } = useSession()
+ // 협력사 클릭 핸들러
+ const handleParticipantClick = React.useCallback(async (biddingId: number, participantType: 'expected' | 'participated' | 'declined' | 'pending') => {
+ setSelectedBiddingId(biddingId)
+ setSelectedParticipantType(participantType)
+ setIsLoadingParticipants(true)
+ setParticipantsDialogOpen(true)
+
+ try {
+ // 협력사 데이터 로드 (모든 초대된 협력사)
+ const companies = await getAllBiddingCompanies(biddingId)
+
+ console.log('Loaded companies:', companies)
+
+ // 필터링 없이 모든 데이터 그대로 표시
+ // invitationStatus가 그대로 다이얼로그에 표시됨
+ setParticipantCompanies(companies)
+ } catch (error) {
+ console.error('Failed to load participant companies:', error)
+ toast.error('협력사 목록을 불러오는데 실패했습니다.')
+ setParticipantCompanies([])
+ } finally {
+ setIsLoadingParticipants(false)
+ }
+ }, [])
+
const columns = React.useMemo(
- () => getBiddingsReceiveColumns({ setRowAction }),
- [setRowAction]
+ () => getBiddingsReceiveColumns({ setRowAction, onParticipantClick: handleParticipantClick }),
+ [setRowAction, handleParticipantClick]
)
// rowAction 변경 감지하여 해당 다이얼로그 열기
@@ -96,7 +130,7 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
break
}
}
- }, [rowAction])
+ }, [rowAction, router])
const filterFields: DataTableFilterField<BiddingReceiveItem>[] = [
{
@@ -248,6 +282,15 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
onOpenChange={handlePrDocumentsDialogClose}
bidding={selectedBidding}
/> */}
+
+ {/* 참여 협력사 다이얼로그 */}
+ <BiddingParticipantsDialog
+ open={participantsDialogOpen}
+ onOpenChange={setParticipantsDialogOpen}
+ biddingId={selectedBiddingId}
+ participantType={selectedParticipantType}
+ companies={participantCompanies}
+ />
</>
)
}
diff --git a/lib/bidding/selection/biddings-selection-columns.tsx b/lib/bidding/selection/biddings-selection-columns.tsx
index 355d5aaa..87c489e3 100644
--- a/lib/bidding/selection/biddings-selection-columns.tsx
+++ b/lib/bidding/selection/biddings-selection-columns.tsx
@@ -175,23 +175,17 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps):
if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
- const now = new Date()
const startObj = new Date(startDate)
const endObj = new Date(endDate)
- const isPast = now > endObj
- const isClosed = isPast
-
+ // 비교로직만 유지, 색상표기/마감뱃지 제거
// UI 표시용 KST 변환
const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
return (
<div className="text-xs">
- <div className={`${isClosed ? 'text-red-600' : 'text-gray-600'}`}>
+ <div>
{formatKst(startObj)} ~ {formatKst(endObj)}
</div>
- {isClosed && (
- <Badge variant="destructive" className="text-xs mt-1">마감</Badge>
- )}
</div>
)
},
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 8fd1d368..1ae23e81 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -3635,7 +3635,6 @@ export async function getBiddingsForSelection(input: GetBiddingsSchema) {
// 'bidding_opened', 'bidding_closed', 'evaluation_of_bidding', 'vendor_selected' 상태만 조회
basicConditions.push(
or(
- eq(biddings.status, 'bidding_opened'),
eq(biddings.status, 'bidding_closed'),
eq(biddings.status, 'evaluation_of_bidding'),
eq(biddings.status, 'vendor_selected')
diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
index 64b4bebf..a122e87b 100644
--- a/lib/bidding/vendor/partners-bidding-list-columns.tsx
+++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx
@@ -348,11 +348,18 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
if (!startDate || !endDate) {
return <div className="text-muted-foreground">-</div>
}
+
+ const startObj = new Date(startDate)
+ const endObj = new Date(endDate)
+
+ // UI 표시용 KST 변환
+ const formatKst = (d: Date) => new Date(d.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 16).replace('T', ' ')
+
return (
<div className="text-sm">
- <div>{new Date(startDate).toISOString().slice(0, 16).replace('T', ' ')}</div>
+ <div>{formatKst(startObj)}</div>
<div className="text-muted-foreground">~</div>
- <div>{new Date(endDate).toISOString().slice(0, 16).replace('T', ' ')}</div>
+ <div>{formatKst(endObj)}</div>
</div>
)
},