diff options
| -rw-r--r-- | components/bidding/create/bidding-create-dialog.tsx | 451 | ||||
| -rw-r--r-- | components/bidding/manage/bidding-basic-info-editor.tsx | 472 | ||||
| -rw-r--r-- | components/bidding/manage/bidding-companies-editor.tsx | 8 | ||||
| -rw-r--r-- | components/bidding/manage/bidding-items-editor.tsx | 35 | ||||
| -rw-r--r-- | components/bidding/manage/bidding-schedule-editor.tsx | 21 | ||||
| -rw-r--r-- | components/bidding/receive/bidding-participants-dialog.tsx | 216 | ||||
| -rw-r--r-- | db/schema/bidding.ts | 6 | ||||
| -rw-r--r-- | lib/bidding/detail/service.ts | 76 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-table-columns.tsx | 20 | ||||
| -rw-r--r-- | lib/bidding/list/edit-bidding-sheet.tsx | 14 | ||||
| -rw-r--r-- | lib/bidding/receive/biddings-receive-columns.tsx | 76 | ||||
| -rw-r--r-- | lib/bidding/receive/biddings-receive-table.tsx | 53 | ||||
| -rw-r--r-- | lib/bidding/selection/biddings-selection-columns.tsx | 10 | ||||
| -rw-r--r-- | lib/bidding/service.ts | 1 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-list-columns.tsx | 11 |
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> ) }, |
