summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/bidding/bidding-conditions-edit.tsx469
-rw-r--r--components/bidding/bidding-info-header.tsx217
-rw-r--r--components/bidding/bidding-round-actions.tsx201
-rw-r--r--components/bidding/create/bidding-create-dialog.tsx1281
-rw-r--r--components/bidding/manage/bidding-basic-info-editor.tsx1407
-rw-r--r--components/bidding/manage/bidding-companies-editor.tsx803
-rw-r--r--components/bidding/manage/bidding-detail-vendor-create-dialog.tsx437
-rw-r--r--components/bidding/manage/bidding-items-editor.tsx1143
-rw-r--r--components/bidding/manage/bidding-schedule-editor.tsx661
-rw-r--r--components/bidding/manage/create-pre-quote-rfq-dialog.tsx742
-rw-r--r--components/bidding/price-adjustment-dialog.tsx6
-rw-r--r--components/common/selectors/cost-center/cost-center-selector.tsx335
-rw-r--r--components/common/selectors/cost-center/cost-center-service.ts89
-rw-r--r--components/common/selectors/cost-center/cost-center-single-selector.tsx378
-rw-r--r--components/common/selectors/cost-center/index.ts12
-rw-r--r--components/common/selectors/gl-account/gl-account-selector.tsx311
-rw-r--r--components/common/selectors/gl-account/gl-account-service.ts79
-rw-r--r--components/common/selectors/gl-account/gl-account-single-selector.tsx358
-rw-r--r--components/common/selectors/gl-account/index.ts12
-rw-r--r--components/common/selectors/wbs-code/index.ts12
-rw-r--r--components/common/selectors/wbs-code/wbs-code-selector.tsx323
-rw-r--r--components/common/selectors/wbs-code/wbs-code-service.ts92
-rw-r--r--components/common/selectors/wbs-code/wbs-code-single-selector.tsx365
-rw-r--r--components/layout/HeaderSimple.tsx9
-rw-r--r--components/ship-vendor-document/add-attachment-dialog.tsx55
-rw-r--r--components/ship-vendor-document/new-revision-dialog.tsx51
26 files changed, 9285 insertions, 563 deletions
diff --git a/components/bidding/bidding-conditions-edit.tsx b/components/bidding/bidding-conditions-edit.tsx
deleted file mode 100644
index 1017597b..00000000
--- a/components/bidding/bidding-conditions-edit.tsx
+++ /dev/null
@@ -1,469 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { useTransition } from "react"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import { Label } from "@/components/ui/label"
-import { Switch } from "@/components/ui/switch"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Pencil, Save, X } from "lucide-react"
-import { getBiddingConditions, updateBiddingConditions } from "@/lib/bidding/service"
-import { getIncotermsForSelection, getPaymentTermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection } from "@/lib/procurement-select/service"
-import { TAX_CONDITIONS, getTaxConditionName } from "@/lib/tax-conditions/types"
-import { useToast } from "@/hooks/use-toast"
-
-interface BiddingConditionsEditProps {
- biddingId: number
- initialConditions?: any | null
-}
-
-export function BiddingConditionsEdit({ biddingId, initialConditions }: BiddingConditionsEditProps) {
- const router = useRouter()
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const [isEditing, setIsEditing] = React.useState(false)
-
- // Procurement 데이터 상태들
- const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<Array<{code: string, description: string}>>([])
- const [incotermsOptions, setIncotermsOptions] = React.useState<Array<{code: string, description: string}>>([])
- const [shippingPlaces, setShippingPlaces] = React.useState<Array<{code: string, description: string}>>([])
- const [destinationPlaces, setDestinationPlaces] = React.useState<Array<{code: string, description: string}>>([])
- const [procurementLoading, setProcurementLoading] = React.useState(false)
-
- const [conditions, setConditions] = React.useState({
- paymentTerms: initialConditions?.paymentTerms || "",
- taxConditions: initialConditions?.taxConditions || "",
- incoterms: initialConditions?.incoterms || "",
- contractDeliveryDate: initialConditions?.contractDeliveryDate
- ? new Date(initialConditions.contractDeliveryDate).toISOString().split('T')[0]
- : "",
- shippingPort: initialConditions?.shippingPort || "",
- destinationPort: initialConditions?.destinationPort || "",
- isPriceAdjustmentApplicable: initialConditions?.isPriceAdjustmentApplicable || false,
- sparePartOptions: initialConditions?.sparePartOptions || "",
- })
-
-
- const handleSave = () => {
- startTransition(async () => {
- try {
- const result = await updateBiddingConditions(biddingId, conditions)
-
- if (result.success) {
- toast({
- title: "성공",
- description: (result as { success: true; message: string }).message,
- variant: "default",
- })
- setIsEditing(false)
- router.refresh()
- } else {
- toast({
- title: "오류",
- description: (result as { success: false; error: string }).error || "입찰 조건 업데이트 중 오류가 발생했습니다.",
- variant: "destructive",
- })
- }
- } catch (error) {
- console.error('Error updating bidding conditions:', error)
- toast({
- title: "오류",
- description: "입찰 조건 업데이트 중 오류가 발생했습니다.",
- variant: "destructive",
- })
- }
- })
- }
-
- // Procurement 데이터 로드 함수들
- const loadPaymentTerms = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getPaymentTermsForSelection();
- setPaymentTermsOptions(data);
- } catch (error) {
- console.error("Failed to load payment terms:", error);
- toast({
- title: "오류",
- description: "결제조건 목록을 불러오는데 실패했습니다.",
- variant: "destructive",
- })
- } finally {
- setProcurementLoading(false);
- }
- }, [toast]);
-
- const loadIncoterms = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getIncotermsForSelection();
- setIncotermsOptions(data);
- } catch (error) {
- console.error("Failed to load incoterms:", error);
- toast({
- title: "오류",
- description: "운송조건 목록을 불러오는데 실패했습니다.",
- variant: "destructive",
- })
- } finally {
- setProcurementLoading(false);
- }
- }, [toast]);
-
- const loadShippingPlaces = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getPlaceOfShippingForSelection();
- setShippingPlaces(data);
- } catch (error) {
- console.error("Failed to load shipping places:", error);
- toast({
- title: "오류",
- description: "선적지 목록을 불러오는데 실패했습니다.",
- variant: "destructive",
- })
- } finally {
- setProcurementLoading(false);
- }
- }, [toast]);
-
- const loadDestinationPlaces = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getPlaceOfDestinationForSelection();
- setDestinationPlaces(data);
- } catch (error) {
- console.error("Failed to load destination places:", error);
- toast({
- title: "오류",
- description: "하역지 목록을 불러오는데 실패했습니다.",
- variant: "destructive",
- })
- } finally {
- setProcurementLoading(false);
- }
- }, [toast]);
-
- // 편집 모드로 전환할 때 procurement 데이터 로드
- React.useEffect(() => {
- if (isEditing) {
- loadPaymentTerms();
- loadIncoterms();
- loadShippingPlaces();
- loadDestinationPlaces();
- }
- }, [isEditing, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces]);
-
- const handleCancel = () => {
- setConditions({
- paymentTerms: initialConditions?.paymentTerms || "",
- taxConditions: initialConditions?.taxConditions || "",
- incoterms: initialConditions?.incoterms || "",
- contractDeliveryDate: initialConditions?.contractDeliveryDate
- ? new Date(initialConditions.contractDeliveryDate).toISOString().split('T')[0]
- : "",
- shippingPort: initialConditions?.shippingPort || "",
- destinationPort: initialConditions?.destinationPort || "",
- isPriceAdjustmentApplicable: initialConditions?.isPriceAdjustmentApplicable || false,
- sparePartOptions: initialConditions?.sparePartOptions || "",
- })
- setIsEditing(false)
- }
-
- if (!isEditing) {
- return (
- <Card className="mt-6">
- <CardHeader className="flex flex-row items-center justify-between">
- <CardTitle>입찰 조건</CardTitle>
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsEditing(true)}
- className="flex items-center gap-2"
- >
- <Pencil className="w-4 h-4" />
- 수정
- </Button>
- </CardHeader>
- <CardContent>
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
- <div>
- <Label className="text-muted-foreground">지급조건</Label>
- <p className="font-medium">
- {conditions.paymentTerms
- ? paymentTermsOptions.find(opt => opt.code === conditions.paymentTerms)?.code || conditions.paymentTerms
- : "미설정"
- }
- </p>
- </div>
- <div>
- <Label className="text-muted-foreground">세금조건</Label>
- <p className="font-medium">
- {conditions.taxConditions
- ? getTaxConditionName(conditions.taxConditions)
- : "미설정"
- }
- </p>
- </div>
- <div>
- <Label className="text-muted-foreground">운송조건</Label>
- <p className="font-medium">
- {conditions.incoterms
- ? incotermsOptions.find(opt => opt.code === conditions.incoterms)?.code || conditions.incoterms
- : "미설정"
- }
- </p>
- </div>
- <div>
- <Label className="text-muted-foreground">계약 납품일</Label>
- <p className="font-medium">
- {conditions.contractDeliveryDate
- ? new Date(conditions.contractDeliveryDate).toLocaleDateString('ko-KR')
- : "미설정"
- }
- </p>
- </div>
- <div>
- <Label className="text-muted-foreground">선적지</Label>
- <p className="font-medium">{conditions.shippingPort || "미설정"}</p>
- </div>
- <div>
- <Label className="text-muted-foreground">하역지</Label>
- <p className="font-medium">{conditions.destinationPort || "미설정"}</p>
- </div>
- <div>
- <Label className="text-muted-foreground">연동제 적용</Label>
- <p className="font-medium">{conditions.isPriceAdjustmentApplicable ? "적용 가능" : "적용 불가"}</p>
- </div>
- <div>
- <Label className="text-muted-foreground">스페어파트 옵션</Label>
- <p className="font-medium">{conditions.sparePartOptions}</p>
- </div>
-
- </div>
- </CardContent>
- </Card>
- )
- }
-
- return (
- <Card className="mt-6">
- <CardHeader className="flex flex-row items-center justify-between">
- <CardTitle>입찰 조건 수정</CardTitle>
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={handleCancel}
- disabled={isPending}
- className="flex items-center gap-2"
- >
- <X className="w-4 h-4" />
- 취소
- </Button>
- <Button
- size="sm"
- onClick={handleSave}
- disabled={isPending}
- className="flex items-center gap-2"
- >
- <Save className="w-4 h-4" />
- 저장
- </Button>
- </div>
- </CardHeader>
- <CardContent className="space-y-6">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
- <div className="space-y-2">
- <Label htmlFor="paymentTerms">지급조건 *</Label>
- <Select
- value={conditions.paymentTerms}
- onValueChange={(value) => setConditions(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>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="taxConditions">세금조건 *</Label>
- <Select
- value={conditions.taxConditions}
- onValueChange={(value) => setConditions(prev => ({
- ...prev,
- taxConditions: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="세금조건 선택" />
- </SelectTrigger>
- <SelectContent>
- {TAX_CONDITIONS.length > 0 ? (
- TAX_CONDITIONS.map((condition) => (
- <SelectItem key={condition.code} value={condition.code}>
- {condition.name}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="incoterms">운송조건(인코텀즈) *</Label>
- <Select
- value={conditions.incoterms}
- onValueChange={(value) => setConditions(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>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="contractDeliveryDate">계약 납품일</Label>
- <Input
- id="contractDeliveryDate"
- type="date"
- value={conditions.contractDeliveryDate}
- onChange={(e) => setConditions(prev => ({
- ...prev,
- contractDeliveryDate: e.target.value
- }))}
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="shippingPort">선적지</Label>
- <Select
- value={conditions.shippingPort}
- onValueChange={(value) => setConditions(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>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="destinationPort">하역지</Label>
- <Select
- value={conditions.destinationPort}
- onValueChange={(value) => setConditions(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>
- </div>
- </div>
-
- <div className="flex items-center space-x-2">
- <Switch
- id="isPriceAdjustmentApplicable"
- checked={conditions.isPriceAdjustmentApplicable}
- onCheckedChange={(checked) => setConditions(prev => ({
- ...prev,
- isPriceAdjustmentApplicable: checked
- }))}
- />
- <Label htmlFor="isPriceAdjustmentApplicable">연동제 적용 가능</Label>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="sparePartOptions">스페어파트 옵션</Label>
- <Textarea
- id="sparePartOptions"
- placeholder="스페어파트 관련 옵션을 입력하세요"
- value={conditions.sparePartOptions}
- onChange={(e) => setConditions(prev => ({
- ...prev,
- sparePartOptions: e.target.value
- }))}
- rows={3}
- />
- </div>
- </CardContent>
- </Card>
- )
-}
diff --git a/components/bidding/bidding-info-header.tsx b/components/bidding/bidding-info-header.tsx
index b897187d..0b2d2b47 100644
--- a/components/bidding/bidding-info-header.tsx
+++ b/components/bidding/bidding-info-header.tsx
@@ -1,6 +1,6 @@
import { Bidding } from '@/db/schema/bidding'
-import { Building2, Package, User, DollarSign, Calendar } from 'lucide-react'
-import { contractTypeLabels, biddingTypeLabels } from '@/db/schema/bidding'
+import { Building2, User, DollarSign, Calendar, FileText } from 'lucide-react'
+import { contractTypeLabels, biddingTypeLabels, awardCountLabels } from '@/db/schema/bidding'
import { formatDate } from '@/lib/utils'
interface BiddingInfoHeaderProps {
@@ -18,122 +18,175 @@ export function BiddingInfoHeader({ bidding }: BiddingInfoHeaderProps) {
return (
<div className="bg-white border rounded-lg p-6 mb-6 shadow-sm">
- {/* 3개 섹션을 Grid로 배치 - 각 섹션이 동일한 width로 꽉 채움 */}
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
- {/* 왼쪽 섹션: 프로젝트, 품목, 담당자 정보 */}
+ {/* 4개 섹션을 Grid로 배치 */}
+ <div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
+ {/* 1. 프로젝트 및 품목 정보 */}
<div className="w-full space-y-4">
- {/* 프로젝트 정보 */}
+ <div className="flex items-center gap-2 text-sm font-semibold text-gray-700 mb-3">
+ <Building2 className="w-4 h-4" />
+ <span>기본 정보</span>
+ </div>
+
{bidding.projectName && (
- <div className="mb-4">
- <div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
- <Building2 className="w-4 h-4" />
- <span>프로젝트</span>
- </div>
- <div className="font-medium text-gray-900">{bidding.projectName}</div>
+ <div>
+ <div className="text-xs text-gray-500 mb-1">프로젝트</div>
+ <div className="font-medium text-gray-900 text-sm">{bidding.projectName}</div>
</div>
)}
- {/* 품목 정보 */}
{bidding.itemName && (
- <div className="mb-4">
- <div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
- <Package className="w-4 h-4" />
- <span>품목</span>
- </div>
- <div className="font-medium text-gray-900">{bidding.itemName}</div>
+ <div>
+ <div className="text-xs text-gray-500 mb-1">품목</div>
+ <div className="font-medium text-gray-900 text-sm">{bidding.itemName}</div>
+ </div>
+ )}
+
+ {bidding.prNumber && (
+ <div>
+ <div className="text-xs text-gray-500 mb-1">PR No.</div>
+ <div className="font-mono text-sm font-medium text-gray-900">{bidding.prNumber}</div>
+ </div>
+ )}
+
+ {bidding.purchasingOrganization && (
+ <div>
+ <div className="text-xs text-gray-500 mb-1">구매조직</div>
+ <div className="font-medium text-gray-900 text-sm">{bidding.purchasingOrganization}</div>
</div>
)}
+ </div>
- {/* 담당자 정보 */}
- {bidding.managerName && (
- <div className="mb-4">
- <div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
- <User className="w-4 h-4" />
- <span>담당자</span>
+ {/* 2. 담당자 및 예산 정보 */}
+ <div className="w-full border-l border-gray-100 pl-6 space-y-4">
+ <div className="flex items-center gap-2 text-sm font-semibold text-gray-700 mb-3">
+ <User className="w-4 h-4" />
+ <span>담당자 정보</span>
+ </div>
+
+ {bidding.bidPicName && (
+ <div>
+ <div className="text-xs text-gray-500 mb-1">입찰담당자</div>
+ <div className="font-medium text-gray-900 text-sm">
+ {bidding.bidPicName}
+ {bidding.bidPicCode && (
+ <span className="ml-2 text-xs text-gray-500">({bidding.bidPicCode})</span>
+ )}
</div>
- <div className="font-medium text-gray-900">{bidding.managerName}</div>
</div>
)}
- {/* 예산 정보 */}
+ {bidding.supplyPicName && (
+ <div>
+ <div className="text-xs text-gray-500 mb-1">조달담당자</div>
+ <div className="font-medium text-gray-900 text-sm">
+ {bidding.supplyPicName}
+ {bidding.supplyPicCode && (
+ <span className="ml-2 text-xs text-gray-500">({bidding.supplyPicCode})</span>
+ )}
+ </div>
+ </div>
+ )}
+
{bidding.budget && (
- <div className="mb-4">
- <div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
- <DollarSign className="w-4 h-4" />
+ <div>
+ <div className="flex items-center gap-1.5 text-xs text-gray-500 mb-1">
+ <DollarSign className="w-3 h-3" />
<span>예산</span>
</div>
- <div className="font-semibold text-gray-900">
+ <div className="font-semibold text-gray-900 text-sm">
{new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: bidding.currency || 'KRW',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
}).format(Number(bidding.budget))}
</div>
</div>
)}
</div>
- {/* 가운데 섹션: 계약 정보 */}
- <div className="w-full border-l border-gray-100 pl-6">
- <div className="grid grid-cols-2 gap-4">
- <div className="flex flex-col gap-1">
- <span className="text-gray-500 text-sm">계약</span>
- <span className="font-medium">{contractTypeLabels[bidding.contractType]}</span>
+ {/* 3. 계약 정보 */}
+ <div className="w-full border-l border-gray-100 pl-6 space-y-4">
+ <div className="flex items-center gap-2 text-sm font-semibold text-gray-700 mb-3">
+ <FileText className="w-4 h-4" />
+ <span>계약 정보</span>
+ </div>
+
+ <div className="grid grid-cols-2 gap-3">
+ <div>
+ <div className="text-xs text-gray-500 mb-1">계약구분</div>
+ <div className="font-medium text-sm text-gray-900">{contractTypeLabels[bidding.contractType]}</div>
</div>
- <div className="flex flex-col gap-1">
- <span className="text-gray-500 text-sm">유형</span>
- <span className="font-medium">{biddingTypeLabels[bidding.biddingType]}</span>
+ <div>
+ <div className="text-xs text-gray-500 mb-1">입찰유형</div>
+ <div className="font-medium text-sm text-gray-900">{biddingTypeLabels[bidding.biddingType]}</div>
</div>
- <div className="flex flex-col gap-1">
- <span className="text-gray-500 text-sm">낙찰</span>
- <span className="font-medium">{bidding.awardCount === 'single' ? '단수' : '복수'}</span>
+ <div>
+ <div className="text-xs text-gray-500 mb-1">낙찰수</div>
+ <div className="font-medium text-sm text-gray-900">
+ {bidding.awardCount ? awardCountLabels[bidding.awardCount] : '-'}
+ </div>
</div>
- <div className="flex flex-col gap-1">
- <span className="text-gray-500 text-sm">통화</span>
- <span className="font-mono font-medium">{bidding.currency}</span>
+ <div>
+ <div className="text-xs text-gray-500 mb-1">통화</div>
+ <div className="font-mono font-medium text-sm text-gray-900">{bidding.currency}</div>
</div>
</div>
+
+ {(bidding.contractStartDate || bidding.contractEndDate) && (
+ <div>
+ <div className="text-xs text-gray-500 mb-1">계약기간</div>
+ <div className="font-medium text-sm text-gray-900">
+ {bidding.contractStartDate && formatDate(bidding.contractStartDate, 'KR')}
+ {bidding.contractStartDate && bidding.contractEndDate && ' ~ '}
+ {bidding.contractEndDate && formatDate(bidding.contractEndDate, 'KR')}
+ </div>
+ </div>
+ )}
</div>
- {/* 오른쪽 섹션: 일정 정보 */}
- {(bidding.submissionStartDate || bidding.evaluationDate || bidding.preQuoteDate || bidding.biddingRegistrationDate) && (
- <div className="w-full border-l border-gray-100 pl-6">
- <div className="flex items-center gap-2 mb-3 text-sm text-gray-500">
- <Calendar className="w-4 h-4" />
- <span>일정 정보</span>
- </div>
- <div className="space-y-3">
- {bidding.submissionStartDate && bidding.submissionEndDate && (
- <div>
- <span className="text-gray-500 text-sm">제출기간</span>
- <div className="font-medium">
- {formatDate(bidding.submissionStartDate, 'KR')} ~ {formatDate(bidding.submissionEndDate, 'KR')}
- </div>
- </div>
- )}
- {bidding.biddingRegistrationDate && (
- <div>
- <span className="text-gray-500 text-sm">입찰등록일</span>
- <div className="font-medium">{formatDate(bidding.biddingRegistrationDate, 'KR')}</div>
- </div>
- )}
- {bidding.preQuoteDate && (
- <div>
- <span className="text-gray-500 text-sm">사전견적일</span>
- <div className="font-medium">{formatDate(bidding.preQuoteDate, 'KR')}</div>
- </div>
- )}
- {bidding.evaluationDate && (
- <div>
- <span className="text-gray-500 text-sm">평가일</span>
- <div className="font-medium">{formatDate(bidding.evaluationDate, 'KR')}</div>
- </div>
- )}
- </div>
+ {/* 4. 일정 정보 */}
+ <div className="w-full border-l border-gray-100 pl-6 space-y-4">
+ <div className="flex items-center gap-2 text-sm font-semibold text-gray-700 mb-3">
+ <Calendar className="w-4 h-4" />
+ <span>일정 정보</span>
</div>
- )}
+
+ {bidding.biddingRegistrationDate && (
+ <div>
+ <div className="text-xs text-gray-500 mb-1">입찰등록일</div>
+ <div className="font-medium text-sm text-gray-900">{formatDate(bidding.biddingRegistrationDate, 'KR')}</div>
+ </div>
+ )}
+
+ {bidding.preQuoteDate && (
+ <div>
+ <div className="text-xs text-gray-500 mb-1">사전견적일</div>
+ <div className="font-medium text-sm text-gray-900">{formatDate(bidding.preQuoteDate, 'KR')}</div>
+ </div>
+ )}
+
+ {bidding.submissionStartDate && bidding.submissionEndDate && (
+ <div>
+ <div className="text-xs text-gray-500 mb-1">제출기간</div>
+ <div className="font-medium text-sm text-gray-900">
+ {formatDate(bidding.submissionStartDate, 'KR')}
+ <div className="text-xs text-gray-400">~</div>
+ {formatDate(bidding.submissionEndDate, 'KR')}
+ </div>
+ </div>
+ )}
+
+ {bidding.evaluationDate && (
+ <div>
+ <div className="text-xs text-gray-500 mb-1">평가일</div>
+ <div className="font-medium text-sm text-gray-900">{formatDate(bidding.evaluationDate, 'KR')}</div>
+ </div>
+ )}
+ </div>
</div>
</div>
)
diff --git a/components/bidding/bidding-round-actions.tsx b/components/bidding/bidding-round-actions.tsx
new file mode 100644
index 00000000..b2db0dfb
--- /dev/null
+++ b/components/bidding/bidding-round-actions.tsx
@@ -0,0 +1,201 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { useTransition } from "react"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { RefreshCw, RotateCw } from "lucide-react"
+import { increaseRoundOrRebid } from "@/lib/bidding/service"
+import { useToast } from "@/hooks/use-toast"
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+import { useSession } from "next-auth/react"
+
+interface BiddingRoundActionsProps {
+ biddingId: number
+ biddingStatus?: string
+}
+
+export function BiddingRoundActions({ biddingId, biddingStatus }: BiddingRoundActionsProps) {
+ const router = useRouter()
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [showRoundDialog, setShowRoundDialog] = React.useState(false)
+ const [showRebidDialog, setShowRebidDialog] = React.useState(false)
+ const { data: session } = useSession()
+ const userId = session?.user?.id
+
+ // 차수증가는 유찰 상태에서만 가능
+ const canIncreaseRound = biddingStatus === 'bidding_disposal'
+
+ // 재입찰도 유찰 상태에서만 가능
+ const canRebid = biddingStatus === 'bidding_disposal'
+
+ const handleRoundIncrease = () => {
+ startTransition(async () => {
+ try {
+ const result = await increaseRoundOrRebid(biddingId, userId, 'round_increase')
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: result.message,
+ variant: "default",
+ })
+ setShowRoundDialog(false)
+ // 새로 생성된 입찰 페이지로 이동
+ if (result.biddingId) {
+ router.push(`/evcp/bid/${result.biddingId}`)
+ router.refresh()
+ }
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "차수증가 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error('차수증가 실패:', error)
+ toast({
+ title: "오류",
+ description: "차수증가 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ })
+ }
+
+ const handleRebid = () => {
+ startTransition(async () => {
+ try {
+ const result = await increaseRoundOrRebid(biddingId, userId, 'rebidding')
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: result.message,
+ variant: "default",
+ })
+ setShowRebidDialog(false)
+ // 새로 생성된 입찰 페이지로 이동
+ if (result.biddingId) {
+ router.push(`/evcp/bid/${result.biddingId}`)
+ router.refresh()
+ }
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "재입찰 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error('재입찰 실패:', error)
+ toast({
+ title: "오류",
+ description: "재입찰 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ })
+ }
+
+ // 유찰 상태가 아니면 컴포넌트를 렌더링하지 않음
+ if (!canIncreaseRound && !canRebid) {
+ return null
+ }
+
+ return (
+ <>
+ <Card className="mt-6">
+ <CardHeader>
+ <CardTitle>입찰 차수 관리</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex gap-4">
+ <Button
+ variant="outline"
+ onClick={() => setShowRoundDialog(true)}
+ disabled={!canIncreaseRound || isPending}
+ className="flex items-center gap-2"
+ >
+ <RotateCw className="w-4 h-4" />
+ 차수증가
+ </Button>
+ <Button
+ variant="outline"
+ onClick={() => setShowRebidDialog(true)}
+ disabled={!canRebid || isPending}
+ className="flex items-center gap-2"
+ >
+ <RefreshCw className="w-4 h-4" />
+ 재입찰
+ </Button>
+ </div>
+ <p className="text-sm text-muted-foreground mt-2">
+ 유찰 상태에서 차수증가 또는 재입찰을 진행할 수 있습니다.
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* 차수증가 확인 다이얼로그 */}
+ <AlertDialog open={showRoundDialog} onOpenChange={setShowRoundDialog}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>차수증가</AlertDialogTitle>
+ <AlertDialogDescription>
+ 현재 입찰의 정보를 복제하여 새로운 차수의 입찰을 생성합니다.
+ <br />
+ 기존 입찰 조건, 아이템, 벤더 정보가 복제되며, 벤더 제출 정보는 초기화됩니다.
+ <br />
+ <br />
+ 계속하시겠습니까?
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isPending}>취소</AlertDialogCancel>
+ <AlertDialogAction onClick={handleRoundIncrease} disabled={isPending}>
+ {isPending ? "처리중..." : "확인"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+
+ {/* 재입찰 확인 다이얼로그 */}
+ <AlertDialog open={showRebidDialog} onOpenChange={setShowRebidDialog}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>재입찰</AlertDialogTitle>
+ <AlertDialogDescription>
+ 현재 입찰의 정보를 복제하여 재입찰을 생성합니다.
+ <br />
+ 기존 입찰 조건, 아이템, 벤더 정보가 복제되며, 벤더 제출 정보는 초기화됩니다.
+ <br />
+ <br />
+ 계속하시겠습니까?
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isPending}>취소</AlertDialogCancel>
+ <AlertDialogAction onClick={handleRebid} disabled={isPending}>
+ {isPending ? "처리중..." : "확인"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </>
+ )
+}
+
+
diff --git a/components/bidding/create/bidding-create-dialog.tsx b/components/bidding/create/bidding-create-dialog.tsx
new file mode 100644
index 00000000..4ef403c9
--- /dev/null
+++ b/components/bidding/create/bidding-create-dialog.tsx
@@ -0,0 +1,1281 @@
+'use client'
+
+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 { toast } from 'sonner'
+
+import { Button } from '@/components/ui/button'
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import { Switch } from '@/components/ui/switch'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
+
+import type { CreateBiddingSchema } from '@/lib/bidding/validation'
+import { contractTypeLabels, biddingTypeLabels, awardCountLabels, biddingNoticeTypeLabels } from '@/db/schema'
+import {
+ getIncotermsForSelection,
+ getPaymentTermsForSelection,
+ getPlaceOfShippingForSelection,
+ getPlaceOfDestinationForSelection,
+} from '@/lib/procurement-select/service'
+import { TAX_CONDITIONS } from '@/lib/tax-conditions/types'
+import { getBiddingNoticeTemplate } from '@/lib/bidding/service'
+import TiptapEditor from '@/components/qna/tiptap-editor'
+import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchase-group-code'
+import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager'
+import type { PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-service'
+import type { ProcurementManagerWithUser } from '@/components/common/selectors/procurement-manager/procurement-manager-service'
+import { createBidding } from '@/lib/bidding/service'
+
+interface BiddingCreateDialogProps {
+ form: UseFormReturn<CreateBiddingSchema>
+ onSuccess?: () => void
+}
+
+export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProps) {
+ const [isOpen, setIsOpen] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<Array<{ code: string; description: string }>>([])
+ const [incotermsOptions, setIncotermsOptions] = React.useState<Array<{ code: string; description: string }>>([])
+ const [shippingPlaces, setShippingPlaces] = React.useState<Array<{ code: string; description: string }>>([])
+ const [destinationPlaces, setDestinationPlaces] = React.useState<Array<{ code: string; description: string }>>([])
+
+ const [biddingConditions, setBiddingConditions] = React.useState({
+ paymentTerms: '',
+ taxConditions: 'V1',
+ incoterms: 'DAP',
+ incotermsOption: '',
+ contractDeliveryDate: '',
+ shippingPort: '',
+ destinationPort: '',
+ isPriceAdjustmentApplicable: false,
+ sparePartOptions: '',
+ })
+
+ // 구매요청자 정보 (현재 사용자)
+ // React.useEffect(() => {
+ // // 실제로는 현재 로그인한 사용자의 정보를 가져와야 함
+ // // 임시로 기본값 설정
+ // form.setValue('requesterName', '김두진') // 실제로는 API에서 가져와야 함
+ // }, [form])
+
+ const [shiAttachmentFiles, setShiAttachmentFiles] = React.useState<File[]>([])
+ const [vendorAttachmentFiles, setVendorAttachmentFiles] = React.useState<File[]>([])
+
+ // 담당자 selector 상태
+ const [selectedBidPic, setSelectedBidPic] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined)
+ const [selectedSupplyPic, setSelectedSupplyPic] = React.useState<ProcurementManagerWithUser | undefined>(undefined)
+
+ // 입찰공고 템플릿 관련 상태
+ const [noticeTemplate, setNoticeTemplate] = React.useState<string>('')
+ const [isLoadingTemplate, setIsLoadingTemplate] = React.useState(false)
+
+ // -- 데이터 로딩 및 상태 동기화 로직
+ const loadPaymentTerms = React.useCallback(async () => {
+ try {
+ const data = await getPaymentTermsForSelection()
+ setPaymentTermsOptions(data)
+ const p008Exists = data.some((item) => item.code === 'P008')
+ if (p008Exists) {
+ setBiddingConditions((prev) => ({ ...prev, paymentTerms: 'P008' }))
+ form.setValue('biddingConditions.paymentTerms', 'P008')
+ }
+ } catch (error) {
+ console.error('Failed to load payment terms:', error)
+ toast.error('결제조건 목록을 불러오는데 실패했습니다.')
+ }
+ }, [form])
+
+ const loadIncoterms = React.useCallback(async () => {
+ try {
+ const data = await getIncotermsForSelection()
+ setIncotermsOptions(data)
+ const dapExists = data.some((item) => item.code === 'DAP')
+ if (dapExists) {
+ setBiddingConditions((prev) => ({ ...prev, incoterms: 'DAP' }))
+ form.setValue('biddingConditions.incoterms', 'DAP')
+ }
+ } catch (error) {
+ console.error('Failed to load incoterms:', error)
+ toast.error('운송조건 목록을 불러오는데 실패했습니다.')
+ }
+ }, [form])
+
+ const loadShippingPlaces = React.useCallback(async () => {
+ try {
+ const data = await getPlaceOfShippingForSelection()
+ setShippingPlaces(data)
+ } catch (error) {
+ console.error('Failed to load shipping places:', error)
+ toast.error('선적지 목록을 불러오는데 실패했습니다.')
+ }
+ }, [])
+
+ const loadDestinationPlaces = React.useCallback(async () => {
+ try {
+ const data = await getPlaceOfDestinationForSelection()
+ setDestinationPlaces(data)
+ } catch (error) {
+ console.error('Failed to load destination places:', error)
+ toast.error('하역지 목록을 불러오는데 실패했습니다.')
+ }
+ }, [])
+
+ React.useEffect(() => {
+ if (isOpen) {
+ loadPaymentTerms()
+ loadIncoterms()
+ loadShippingPlaces()
+ loadDestinationPlaces()
+ const v1Exists = TAX_CONDITIONS.some((item) => item.code === 'V1')
+ if (v1Exists) {
+ setBiddingConditions((prev) => ({ ...prev, taxConditions: 'V1' }))
+ form.setValue('biddingConditions.taxConditions', 'V1')
+ }
+
+ // 초기 표준 템플릿 로드
+ const loadInitialTemplate = async () => {
+ try {
+ const standardTemplate = await getBiddingNoticeTemplate('standard')
+ if (standardTemplate) {
+ console.log('standardTemplate', standardTemplate)
+ setNoticeTemplate(standardTemplate.content)
+ form.setValue('content', standardTemplate.content)
+ }
+ } catch (error) {
+ console.error('Failed to load initial template:', error)
+ toast.error('기본 템플릿을 불러오는데 실패했습니다.')
+ }
+ }
+ loadInitialTemplate()
+ }
+ }, [isOpen, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces, form])
+
+ // 입찰공고 템플릿 로딩
+ const noticeTypeValue = form.watch('noticeType')
+ const selectedNoticeType = React.useMemo(() => noticeTypeValue, [noticeTypeValue])
+
+ React.useEffect(() => {
+ const loadNoticeTemplate = async () => {
+ if (selectedNoticeType) {
+ setIsLoadingTemplate(true)
+ try {
+ const template = await getBiddingNoticeTemplate(selectedNoticeType)
+ if (template) {
+ setNoticeTemplate(template.content)
+ // 폼의 content 필드도 업데이트
+ form.setValue('content', template.content)
+ } else {
+ // 템플릿이 없으면 표준 템플릿 사용
+ const defaultTemplate = await getBiddingNoticeTemplate('standard')
+ if (defaultTemplate) {
+ setNoticeTemplate(defaultTemplate.content)
+ form.setValue('content', defaultTemplate.content)
+ }
+ }
+ } catch (error) {
+ console.error('Failed to load notice template:', error)
+ toast.error('입찰공고 템플릿을 불러오는데 실패했습니다.')
+ } finally {
+ setIsLoadingTemplate(false)
+ }
+ }
+ }
+
+ loadNoticeTemplate()
+ }, [selectedNoticeType, form])
+
+ // SHI용 파일 첨부 핸들러
+ const handleShiFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const files = Array.from(event.target.files || [])
+ setShiAttachmentFiles(prev => [...prev, ...files])
+ }
+
+ const removeShiFile = (index: number) => {
+ setShiAttachmentFiles(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 협력업체용 파일 첨부 핸들러
+ const handleVendorFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const files = Array.from(event.target.files || [])
+ setVendorAttachmentFiles(prev => [...prev, ...files])
+ }
+
+ const removeVendorFile = (index: number) => {
+ setVendorAttachmentFiles(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 입찰담당자 선택 핸들러
+ const handleBidPicSelect = (code: PurchaseGroupCodeWithUser) => {
+ setSelectedBidPic(code)
+ form.setValue('bidPicName', code.DISPLAY_NAME || '')
+ form.setValue('bidPicCode', code.PURCHASE_GROUP_CODE || '')
+ // ID도 저장 (실제로는 사용자 ID가 필요)
+ if (code.user) {
+ form.setValue('bidPicId', code.user.id || undefined)
+ }
+ }
+
+ // 조달담당자 선택 핸들러
+ const handleSupplyPicSelect = (manager: ProcurementManagerWithUser) => {
+ setSelectedSupplyPic(manager)
+ form.setValue('supplyPicName', manager.DISPLAY_NAME || '')
+ form.setValue('supplyPicCode', manager.PROCUREMENT_MANAGER_CODE || '')
+ // ID도 저장 (실제로는 사용자 ID가 필요)
+ if (manager.user) {
+ form.setValue('supplyPicId', manager.user.id || undefined)
+ }
+ }
+
+ const handleSubmit = async (data: CreateBiddingSchema) => {
+ setIsSubmitting(true)
+ try {
+ // 폼 validation 실행
+ const isFormValid = await form.trigger()
+
+ if (!isFormValid) {
+ toast.error('필수 정보를 모두 입력해주세요.')
+ return
+ }
+
+ // 첨부파일 정보 설정 (실제로는 파일 업로드 후 저장해야 함)
+ const attachments = shiAttachmentFiles.map((file, index) => ({
+ id: `shi_${Date.now()}_${index}`,
+ fileName: file.name,
+ fileSize: file.size,
+ filePath: '', // 실제 업로드 후 경로
+ uploadedAt: new Date().toISOString(),
+ type: 'shi' as const,
+ }))
+
+ const vendorAttachments = vendorAttachmentFiles.map((file, index) => ({
+ id: `vendor_${Date.now()}_${index}`,
+ fileName: file.name,
+ fileSize: file.size,
+ filePath: '', // 실제 업로드 후 경로
+ uploadedAt: new Date().toISOString(),
+ type: 'vendor' as const,
+ }))
+
+ // sparePartOptions가 undefined인 경우 빈 문자열로 설정
+ const biddingData: CreateBiddingInput = {
+ ...data,
+ attachments,
+ vendorAttachments,
+ biddingConditions: {
+ ...data.biddingConditions,
+ sparePartOptions: data.biddingConditions.sparePartOptions || '',
+ incotermsOption: data.biddingConditions.incotermsOption || '',
+ contractDeliveryDate: data.biddingConditions.contractDeliveryDate || '',
+ shippingPort: data.biddingConditions.shippingPort || '',
+ destinationPort: data.biddingConditions.destinationPort || '',
+ },
+ }
+
+ const result = await createBidding(biddingData, '1') // 실제로는 현재 사용자 ID
+
+ if (result.success) {
+ toast.success("입찰이 성공적으로 생성되었습니다.")
+ setIsOpen(false)
+ form.reset()
+ setShiAttachmentFiles([])
+ setVendorAttachmentFiles([])
+ setSelectedBidPic(undefined)
+ setSelectedSupplyPic(undefined)
+ setNoticeTemplate('')
+ if (onSuccess) {
+ onSuccess()
+ }
+ } else {
+ toast.error((result as { success: false; error: string }).error || "입찰 생성에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("Failed to create bidding:", error)
+ toast.error("입찰 생성 중 오류가 발생했습니다.")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const handleOpenChange = (open: boolean) => {
+ setIsOpen(open)
+ if (!open) {
+ // 다이얼로그 닫을 때 폼 초기화
+ form.reset()
+ setShiAttachmentFiles([])
+ setVendorAttachmentFiles([])
+ setSelectedBidPic(undefined)
+ setSelectedSupplyPic(undefined)
+ setNoticeTemplate('')
+ setBiddingConditions({
+ paymentTerms: '',
+ taxConditions: 'V1',
+ incoterms: 'DAP',
+ incotermsOption: '',
+ contractDeliveryDate: '',
+ shippingPort: '',
+ destinationPort: '',
+ isPriceAdjustmentApplicable: false,
+ sparePartOptions: '',
+ })
+ }
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={handleOpenChange}>
+ <DialogTrigger asChild>
+ <Button>
+ <Plus className="mr-2 h-4 w-4" />
+ 입찰 신규생성
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>입찰 신규생성</DialogTitle>
+ <DialogDescription>
+ 새로운 입찰을 생성합니다. 기본 정보와 입찰 조건을 설정하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
+ {/* 통합된 기본 정보 및 입찰 조건 카드 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 기본 정보 및 입찰 조건
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 1행: 입찰명, 낙찰수, 입찰유형, 계약구분 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField
+ control={form.control}
+ name="title"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰명 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input placeholder="입찰명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="awardCount"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>낙찰수 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="낙찰수 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(awardCountLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="biddingType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰유형 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="입찰유형 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(biddingTypeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contractType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약구분 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="계약구분 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(contractTypeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 기타 입찰유형 선택 시 직접입력 필드 */}
+ {form.watch('biddingType') === 'other' && (
+ <div className="grid grid-cols-4 gap-4">
+ <div></div>
+ <div></div>
+ <FormField
+ control={form.control}
+ name="biddingTypeCustom"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>기타 입찰유형 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input placeholder="직접 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <div></div>
+ </div>
+ )}
+
+ {/* 2행: 예산, 실적가, 내정가, P/R번호 (조회용) */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField
+ control={form.control}
+ name="budget"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <DollarSign className="h-3 w-3" />
+ 예산
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="예산 입력" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="finalBidPrice"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <DollarSign className="h-3 w-3" />
+ 실적가
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="실적가" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="targetPrice"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Eye className="h-3 w-3" />
+ 내정가
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="내정가" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="prNumber"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Eye className="h-3 w-3" />
+ P/R번호
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="P/R번호" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 3행: 입찰담당자, 조달담당자 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="bidPicName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <User className="h-3 w-3" />
+ 입찰담당자 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <PurchaseGroupCodeSelector
+ selectedCode={selectedBidPic}
+ onCodeSelect={(code) => {
+ handleBidPicSelect(code)
+ field.onChange(code.DISPLAY_NAME || '')
+ }}
+ placeholder="입찰담당자 선택"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="supplyPicName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <User className="h-3 w-3" />
+ 조달담당자
+ </FormLabel>
+ <FormControl>
+ <ProcurementManagerSelector
+ selectedManager={selectedSupplyPic}
+ onManagerSelect={(manager) => {
+ handleSupplyPicSelect(manager)
+ field.onChange(manager.DISPLAY_NAME || '')
+ }}
+ placeholder="조달담당자 선택"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 4행: 하도급법적용여부, SHI 지급조건 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="biddingConditions.isPriceAdjustmentApplicable"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>하도급법적용여부</FormLabel>
+ <div className="flex items-center space-x-2">
+ <Switch
+ id="price-adjustment"
+ checked={biddingConditions.isPriceAdjustmentApplicable}
+ onCheckedChange={(checked) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ isPriceAdjustmentApplicable: checked
+ }))
+ field.onChange(checked)
+ }}
+ />
+ <FormLabel htmlFor="price-adjustment" className="text-sm">
+ 연동제 적용 요건
+ </FormLabel>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="biddingConditions.paymentTerms"
+ render={({ field }) => (
+ <FormItem>
+ <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>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 5행: SHI 인도조건, SHI 인도조건2 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="biddingConditions.incoterms"
+ render={({ field }) => (
+ <FormItem>
+ <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>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="biddingConditions.incotermsOption"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>SHI 인도조건2</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="인도조건 추가 정보"
+ value={biddingConditions.incotermsOption}
+ onChange={(e) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ incotermsOption: e.target.value
+ }))
+ field.onChange(e.target.value)
+ }}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 6행: SHI 매입부가가치세, SHI 선적지 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="biddingConditions.taxConditions"
+ render={({ field }) => (
+ <FormItem>
+ <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>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="biddingConditions.shippingPort"
+ render={({ field }) => (
+ <FormItem>
+ <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>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 7행: SHI 하역지, 계약 납품일 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="biddingConditions.destinationPort"
+ render={({ field }) => (
+ <FormItem>
+ <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>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="biddingConditions.contractDeliveryDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약 납품일</FormLabel>
+ <FormControl>
+ <Input
+ type="date"
+ value={biddingConditions.contractDeliveryDate}
+ onChange={(e) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ contractDeliveryDate: e.target.value
+ }))
+ field.onChange(e.target.value)
+ }}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 8행: 계약기간 시작/종료, 진행상태, 구매조직 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField
+ control={form.control}
+ name="contractStartDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Calendar className="h-3 w-3" />
+ 계약기간 시작
+ </FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contractEndDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Calendar className="h-3 w-3" />
+ 계약기간 종료
+ </FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Eye className="h-3 w-3" />
+ 진행상태
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="진행상태" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="purchasingOrganization"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Building className="h-3 w-3" />
+ 구매조직
+ </FormLabel>
+ <Select onValueChange={field.onChange} value={field.value || ''}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="구매조직 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="조선">조선</SelectItem>
+ <SelectItem value="해양">해양</SelectItem>
+ <SelectItem value="기타">기타</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 9행: 구매요청자, 구매유형, 통화, 스페어파트 옵션 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField
+ control={form.control}
+ name="requesterName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <User className="h-3 w-3" />
+ 구매요청자
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="구매요청자" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="noticeType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>구매유형 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="구매유형 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(biddingNoticeTypeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>통화 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="KRW">KRW (원)</SelectItem>
+ <SelectItem value="USD">USD (달러)</SelectItem>
+ <SelectItem value="EUR">EUR (유로)</SelectItem>
+ <SelectItem value="JPY">JPY (엔)</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="biddingConditions.sparePartOptions"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>스페어파트 옵션</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="스페어파트 관련 옵션을 입력하세요"
+ value={biddingConditions.sparePartOptions}
+ onChange={(e) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ sparePartOptions: e.target.value
+ }))
+ field.onChange(e.target.value)
+ }}
+ rows={3}
+ className="min-h-[40px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 입찰개요 추가 */}
+ <div className="mt-6 pt-4 border-t">
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰개요</FormLabel>
+ <FormControl>
+ <Textarea placeholder="입찰에 대한 설명을 입력하세요" rows={3} {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 입찰공고 내용 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>입찰공고 내용</CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 선택한 입찰공고 타입의 템플릿이 자동으로 로드됩니다. 필요에 따라 수정하세요.
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <FormField
+ control={form.control}
+ name="content"
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <div className="border rounded-lg">
+ <TiptapEditor
+ content={field.value || noticeTemplate}
+ setContent={(content) => {
+ field.onChange(content)
+ }}
+ disabled={isLoadingTemplate}
+ height="300px"
+ />
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {isLoadingTemplate && (
+ <div className="flex items-center justify-center p-4 text-sm text-muted-foreground">
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2"></div>
+ 입찰공고 템플릿을 불러오는 중...
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* SHI용 첨부파일 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Upload className="h-5 w-5" />
+ SHI용 첨부파일
+ </CardTitle>
+ <p className="text-sm text-muted-foreground">
+ SHI에서 제공하는 문서나 파일을 업로드하세요
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-6">
+ <div className="text-center">
+ <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" />
+ <div className="space-y-2">
+ <p className="text-sm text-gray-600">
+ 파일을 드래그 앤 드롭하거나{' '}
+ <label className="text-blue-600 hover:text-blue-500 cursor-pointer">
+ <input
+ type="file"
+ multiple
+ className="hidden"
+ onChange={handleShiFileUpload}
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
+ />
+ 찾아보세요
+ </label>
+ </p>
+ <p className="text-xs text-gray-500">
+ PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, JPEG 파일을 업로드할 수 있습니다
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {shiAttachmentFiles.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">업로드된 파일</h4>
+ <div className="space-y-2">
+ {shiAttachmentFiles.map((file, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{file.name}</p>
+ <p className="text-xs text-muted-foreground">
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+ </p>
+ </div>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeShiFile(index)}
+ >
+ 제거
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 협력업체용 첨부파일 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Upload className="h-5 w-5" />
+ 협력업체용 첨부파일
+ </CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 협력업체에서 제공하는 문서나 파일을 업로드하세요
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-6">
+ <div className="text-center">
+ <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" />
+ <div className="space-y-2">
+ <p className="text-sm text-gray-600">
+ 파일을 드래그 앤 드롭하거나{' '}
+ <label className="text-blue-600 hover:text-blue-500 cursor-pointer">
+ <input
+ type="file"
+ multiple
+ className="hidden"
+ onChange={handleVendorFileUpload}
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
+ />
+ 찾아보세요
+ </label>
+ </p>
+ <p className="text-xs text-gray-500">
+ PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, JPEG 파일을 업로드할 수 있습니다
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {vendorAttachmentFiles.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">업로드된 파일</h4>
+ <div className="space-y-2">
+ {vendorAttachmentFiles.map((file, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{file.name}</p>
+ <p className="text-xs text-muted-foreground">
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+ </p>
+ </div>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeVendorFile(index)}
+ >
+ 제거
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 네비게이션 버튼 */}
+ <div className="flex justify-between">
+ <div></div>
+ <div className="flex gap-2">
+ <Button
+ type="submit"
+ disabled={isSubmitting}
+ className="flex items-center gap-2"
+ >
+ {isSubmitting ? '저장 중...' : '저장'}
+ <ChevronRight className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx
new file mode 100644
index 00000000..d60c5d88
--- /dev/null
+++ b/components/bidding/manage/bidding-basic-info-editor.tsx
@@ -0,0 +1,1407 @@
+'use client'
+
+import * as React from 'react'
+import { useForm } from 'react-hook-form'
+import { ChevronRight, Upload, FileText, Eye, User, Building, Calendar, DollarSign } from 'lucide-react'
+import { toast } from 'sonner'
+
+import { Button } from '@/components/ui/button'
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import { Switch } from '@/components/ui/switch'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+// CreateBiddingInput 타입 정의가 없으므로 CreateBiddingSchema를 확장하여 사용합니다.
+import { getBiddingById, updateBiddingBasicInfo, getBiddingConditions, getBiddingNotice, updateBiddingConditions } from '@/lib/bidding/service'
+import { getBiddingNoticeTemplate } from '@/lib/bidding/service'
+import {
+ getIncotermsForSelection,
+ getPaymentTermsForSelection,
+ getPlaceOfShippingForSelection,
+ getPlaceOfDestinationForSelection,
+} from '@/lib/procurement-select/service'
+import { TAX_CONDITIONS } from '@/lib/tax-conditions/types'
+import { contractTypeLabels, biddingTypeLabels, awardCountLabels, biddingNoticeTypeLabels } from '@/db/schema'
+import TiptapEditor from '@/components/qna/tiptap-editor'
+import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchase-group-code'
+import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager'
+import type { PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-service'
+import type { ProcurementManagerWithUser } from '@/components/common/selectors/procurement-manager/procurement-manager-service'
+import { getBiddingDocuments, uploadBiddingDocument, deleteBiddingDocument } from '@/lib/bidding/detail/service'
+import { downloadFile } from '@/lib/file-download'
+
+// 입찰 기본 정보 에디터 컴포넌트
+interface BiddingBasicInfo {
+ title?: string
+ description?: string
+ content?: string
+ noticeType?: string
+ contractType?: string
+ biddingType?: string
+ biddingTypeCustom?: string
+ awardCount?: string
+ budget?: string
+ finalBidPrice?: string
+ targetPrice?: string
+ prNumber?: string
+ contractStartDate?: string
+ contractEndDate?: string
+ submissionStartDate?: string
+ submissionEndDate?: string
+ evaluationDate?: string
+ hasSpecificationMeeting?: boolean
+ hasPrDocument?: boolean
+ currency?: string
+ purchasingOrganization?: string
+ bidPicName?: string
+ bidPicCode?: string
+ supplyPicName?: string
+ supplyPicCode?: string
+ requesterName?: string
+ remarks?: string
+}
+
+interface BiddingBasicInfoEditorProps {
+ biddingId: number
+}
+
+interface UploadedDocument {
+ id: number
+ biddingId: number
+ companyId: number | null
+ documentType: string
+ fileName: string
+ originalFileName: string
+ fileSize: number | null
+ filePath: string
+ title: string | null
+ description: string | null
+ uploadedAt: string
+ uploadedBy: string
+}
+
+export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProps) {
+ const [isLoading, setIsLoading] = React.useState(true)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [isLoadingTemplate, setIsLoadingTemplate] = React.useState(false)
+ const [noticeTemplate, setNoticeTemplate] = React.useState('')
+
+ // 첨부파일 관련 상태
+ const [shiAttachmentFiles, setShiAttachmentFiles] = React.useState<File[]>([])
+ const [vendorAttachmentFiles, setVendorAttachmentFiles] = React.useState<File[]>([])
+ const [existingDocuments, setExistingDocuments] = React.useState<UploadedDocument[]>([])
+ const [isLoadingDocuments, setIsLoadingDocuments] = React.useState(false)
+
+ // 담당자 selector 상태
+ const [selectedBidPic, setSelectedBidPic] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined)
+ const [selectedSupplyPic, setSelectedSupplyPic] = React.useState<ProcurementManagerWithUser | undefined>(undefined)
+
+ // 입찰 조건 관련 상태
+ const [biddingConditions, setBiddingConditions] = React.useState({
+ paymentTerms: '',
+ taxConditions: 'V1',
+ incoterms: 'DAP',
+ incotermsOption: '',
+ contractDeliveryDate: '',
+ shippingPort: '',
+ destinationPort: '',
+ isPriceAdjustmentApplicable: false,
+ sparePartOptions: '',
+ })
+
+ // Procurement 데이터 상태들
+ const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<Array<{ code: string; description: string }>>([])
+ const [incotermsOptions, setIncotermsOptions] = React.useState<Array<{ code: string; description: string }>>([])
+ const [shippingPlaces, setShippingPlaces] = React.useState<Array<{ code: string; description: string }>>([])
+ const [destinationPlaces, setDestinationPlaces] = React.useState<Array<{ code: string; description: string }>>([])
+
+ const form = useForm<BiddingBasicInfo>({
+ defaultValues: {}
+ })
+
+
+ // 공고 템플릿 로드 - 현재 저장된 템플릿 우선
+ const loadNoticeTemplate = React.useCallback(async (noticeType?: string) => {
+ setIsLoadingTemplate(true)
+ try {
+ // 먼저 현재 입찰에 저장된 템플릿이 있는지 확인
+ const savedNotice = await getBiddingNotice(biddingId)
+ if (savedNotice && savedNotice.content) {
+ setNoticeTemplate(savedNotice.content)
+ const currentContent = form.getValues('content')
+ if (!currentContent || currentContent.trim() === '') {
+ form.setValue('content', savedNotice.content)
+ }
+ setIsLoadingTemplate(false)
+ return
+ }
+
+ // 저장된 템플릿이 없으면 타입별 템플릿 로드
+ if (noticeType) {
+ const template = await getBiddingNoticeTemplate(noticeType)
+ if (template) {
+ setNoticeTemplate(template.content || '')
+ const currentContent = form.getValues('content')
+ if (!currentContent || currentContent.trim() === '') {
+ form.setValue('content', template.content || '')
+ }
+ } else {
+ // 템플릿이 없으면 표준 템플릿 사용
+ const defaultTemplate = await getBiddingNoticeTemplate('standard')
+ if (defaultTemplate) {
+ setNoticeTemplate(defaultTemplate.content)
+ const currentContent = form.getValues('content')
+ if (!currentContent || currentContent.trim() === '') {
+ form.setValue('content', defaultTemplate.content)
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.warn('Failed to load notice template:', error)
+ } finally {
+ setIsLoadingTemplate(false)
+ }
+ }, [biddingId, form])
+
+ // 데이터 로딩
+ React.useEffect(() => {
+ const loadBiddingData = async () => {
+ setIsLoading(true)
+ try {
+ const bidding = await getBiddingById(biddingId)
+ if (bidding) {
+ // 타입 확장된 bidding 객체
+ const biddingExtended = bidding as typeof bidding & {
+ content?: string | null
+ noticeType?: string | null
+ biddingTypeCustom?: string | null
+ awardCount?: string | null
+ requesterName?: string | null
+ }
+
+ // 날짜를 문자열로 변환하는 헬퍼
+ const formatDate = (date: unknown): string => {
+ if (!date) return ''
+ if (typeof date === 'string') return date.split('T')[0]
+ if (date instanceof Date) return date.toISOString().split('T')[0]
+ return ''
+ }
+
+ const formatDateTime = (date: unknown): string => {
+ if (!date) return ''
+ if (typeof date === 'string') return date.slice(0, 16)
+ if (date instanceof Date) return date.toISOString().slice(0, 16)
+ return ''
+ }
+
+ // 폼 데이터 설정
+ form.reset({
+ title: bidding.title || '',
+ description: bidding.description || '',
+ content: biddingExtended.content || '',
+ noticeType: biddingExtended.noticeType || '',
+ contractType: bidding.contractType || '',
+ biddingType: bidding.biddingType || '',
+ biddingTypeCustom: biddingExtended.biddingTypeCustom || '',
+ awardCount: biddingExtended.awardCount || (bidding.awardCount ? String(bidding.awardCount) : ''),
+ budget: bidding.budget ? bidding.budget.toString() : '',
+ finalBidPrice: bidding.finalBidPrice ? bidding.finalBidPrice.toString() : '',
+ targetPrice: bidding.targetPrice ? bidding.targetPrice.toString() : '',
+ prNumber: bidding.prNumber || '',
+ contractStartDate: formatDate(bidding.contractStartDate),
+ contractEndDate: formatDate(bidding.contractEndDate),
+ submissionStartDate: formatDateTime(bidding.submissionStartDate),
+ submissionEndDate: formatDateTime(bidding.submissionEndDate),
+ evaluationDate: formatDateTime(bidding.evaluationDate),
+ hasSpecificationMeeting: bidding.hasSpecificationMeeting || false,
+ hasPrDocument: bidding.hasPrDocument || false,
+ currency: bidding.currency || 'KRW',
+ purchasingOrganization: bidding.purchasingOrganization || '',
+ bidPicName: bidding.bidPicName || '',
+ bidPicCode: bidding.bidPicCode || '',
+ supplyPicName: bidding.supplyPicName || '',
+ supplyPicCode: bidding.supplyPicCode || '',
+ requesterName: biddingExtended.requesterName || '',
+ remarks: bidding.remarks || '',
+ })
+
+ // 입찰 조건 로드
+ const conditions = await getBiddingConditions(biddingId)
+ if (conditions) {
+ setBiddingConditions({
+ paymentTerms: conditions.paymentTerms || '',
+ taxConditions: conditions.taxConditions || 'V1',
+ incoterms: conditions.incoterms || 'DAP',
+ incotermsOption: conditions.incotermsOption || '',
+ contractDeliveryDate: conditions.contractDeliveryDate
+ ? new Date(conditions.contractDeliveryDate).toISOString().split('T')[0]
+ : '',
+ shippingPort: conditions.shippingPort || '',
+ destinationPort: conditions.destinationPort || '',
+ isPriceAdjustmentApplicable: conditions.isPriceAdjustmentApplicable || false,
+ sparePartOptions: conditions.sparePartOptions || '',
+ })
+ }
+
+ // Procurement 데이터 로드
+ const [paymentTermsData, incotermsData, shippingData, destinationData] = await Promise.all([
+ getPaymentTermsForSelection().catch(() => []),
+ getIncotermsForSelection().catch(() => []),
+ getPlaceOfShippingForSelection().catch(() => []),
+ getPlaceOfDestinationForSelection().catch(() => []),
+ ])
+ setPaymentTermsOptions(paymentTermsData)
+ setIncotermsOptions(incotermsData)
+ setShippingPlaces(shippingData)
+ setDestinationPlaces(destinationData)
+
+ // 공고 템플릿 로드
+ await loadNoticeTemplate(biddingExtended.noticeType || undefined)
+ } else {
+ toast.error('입찰 정보를 찾을 수 없습니다.')
+ }
+ } catch (error) {
+ console.error('Error loading bidding data:', error)
+ toast.error('입찰 정보를 불러오는데 실패했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadBiddingData()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [biddingId, loadNoticeTemplate])
+
+ // 구매유형 변경 시 템플릿 자동 로드
+ const noticeTypeValue = form.watch('noticeType')
+ React.useEffect(() => {
+ if (noticeTypeValue) {
+ loadNoticeTemplate(noticeTypeValue)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [noticeTypeValue])
+
+ // 기존 첨부파일 로드
+ const loadExistingDocuments = async () => {
+ setIsLoadingDocuments(true)
+ try {
+ const docs = await getBiddingDocuments(biddingId)
+ const mappedDocs = docs.map((doc) => ({
+ ...doc,
+ uploadedAt: doc.uploadedAt?.toString() || '',
+ uploadedBy: doc.uploadedBy || ''
+ }))
+ setExistingDocuments(mappedDocs)
+ } catch (error) {
+ console.error('Failed to load documents:', error)
+ toast.error('첨부파일 목록을 불러오는데 실패했습니다.')
+ } finally {
+ setIsLoadingDocuments(false)
+ }
+ }
+
+ // 초기 로드 시 첨부파일도 함께 로드
+ React.useEffect(() => {
+ if (biddingId) {
+ loadExistingDocuments()
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [biddingId])
+
+ // SHI용 파일 첨부 핸들러
+ const handleShiFileUpload = async (files: File[]) => {
+ try {
+ // 파일을 업로드하고 기존 문서 목록 갱신
+ for (const file of files) {
+ const result = await uploadBiddingDocument(
+ biddingId,
+ file,
+ 'bid_attachment',
+ file.name,
+ 'SHI용 첨부파일',
+ '1' // TODO: 실제 사용자 ID 가져오기
+ )
+ if (result.success) {
+ toast.success(`${file.name} 업로드 완료`)
+ }
+ }
+ await loadExistingDocuments()
+ setShiAttachmentFiles([])
+ } catch (error) {
+ console.error('Failed to upload SHI files:', error)
+ toast.error('파일 업로드에 실패했습니다.')
+ }
+ }
+
+ const removeShiFile = (index: number) => {
+ setShiAttachmentFiles(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 협력업체용 파일 첨부 핸들러
+ const handleVendorFileUpload = async (files: File[]) => {
+ try {
+ // 파일을 업로드하고 기존 문서 목록 갱신
+ for (const file of files) {
+ const result = await uploadBiddingDocument(
+ biddingId,
+ file,
+ 'bid_attachment',
+ file.name,
+ '협력업체용 첨부파일',
+ '1' // TODO: 실제 사용자 ID 가져오기
+ )
+ if (result.success) {
+ toast.success(`${file.name} 업로드 완료`)
+ }
+ }
+ await loadExistingDocuments()
+ setVendorAttachmentFiles([])
+ } catch (error) {
+ console.error('Failed to upload vendor files:', error)
+ toast.error('파일 업로드에 실패했습니다.')
+ }
+ }
+
+ const removeVendorFile = (index: number) => {
+ setVendorAttachmentFiles(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 파일 삭제
+ const handleDeleteDocument = async (documentId: number) => {
+ if (!confirm('이 파일을 삭제하시겠습니까?')) {
+ return
+ }
+
+ try {
+ const result = await deleteBiddingDocument(documentId, biddingId, '1') // TODO: 실제 사용자 ID 가져오기
+ if (result.success) {
+ toast.success('파일이 삭제되었습니다.')
+ await loadExistingDocuments()
+ } else {
+ toast.error(result.error || '파일 삭제에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('Failed to delete document:', error)
+ toast.error('파일 삭제에 실패했습니다.')
+ }
+ }
+
+ // 파일 다운로드
+ const handleDownloadDocument = async (document: UploadedDocument) => {
+ try {
+ await downloadFile(document.filePath, document.originalFileName, {
+ showToast: true
+ })
+ } catch (error) {
+ console.error('Failed to download document:', error)
+ toast.error('파일 다운로드에 실패했습니다.')
+ }
+ }
+
+ // 입찰담당자 선택 핸들러
+ const handleBidPicSelect = (code: PurchaseGroupCodeWithUser) => {
+ setSelectedBidPic(code)
+ form.setValue('bidPicName', code.DISPLAY_NAME || '')
+ form.setValue('bidPicCode', code.PURCHASE_GROUP_CODE || '')
+ }
+
+ // 조달담당자 선택 핸들러
+ const handleSupplyPicSelect = (manager: ProcurementManagerWithUser) => {
+ setSelectedSupplyPic(manager)
+ form.setValue('supplyPicName', manager.DISPLAY_NAME || '')
+ form.setValue('supplyPicCode', manager.PROCUREMENT_MANAGER_CODE || '')
+ }
+
+ // 파일 크기 포맷팅
+ const formatFileSize = (bytes: number | null) => {
+ if (!bytes) return '-'
+ if (bytes === 0) return '0 Bytes'
+ const k = 1024
+ const sizes = ['Bytes', 'KB', 'MB', 'GB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+ }
+
+ // 저장 처리
+ const handleSave = async (data: BiddingBasicInfo) => {
+ setIsSubmitting(true)
+ try {
+ // 기본 정보 저장
+ const result = await updateBiddingBasicInfo(biddingId, data, '1') // TODO: 실제 사용자 ID 가져오기
+
+ if (result.success) {
+ // 입찰 조건 저장
+ const conditionsResult = await updateBiddingConditions(biddingId, {
+ paymentTerms: biddingConditions.paymentTerms,
+ taxConditions: biddingConditions.taxConditions,
+ incoterms: biddingConditions.incoterms,
+ incotermsOption: biddingConditions.incotermsOption,
+ contractDeliveryDate: biddingConditions.contractDeliveryDate || undefined,
+ shippingPort: biddingConditions.shippingPort,
+ destinationPort: biddingConditions.destinationPort,
+ isPriceAdjustmentApplicable: biddingConditions.isPriceAdjustmentApplicable,
+ sparePartOptions: biddingConditions.sparePartOptions,
+ })
+
+ if (conditionsResult.success) {
+ toast.success('입찰 기본 정보와 조건이 성공적으로 저장되었습니다.')
+ } else {
+ const errorMessage = 'error' in conditionsResult ? conditionsResult.error : '입찰 조건 저장에 실패했습니다.'
+ toast.error(errorMessage)
+ }
+ } else {
+ const errorMessage = 'error' in result ? result.error : '입찰 기본 정보 저장에 실패했습니다.'
+ toast.error(errorMessage)
+ }
+ } catch (error) {
+ console.error('Failed to save bidding basic info:', error)
+ toast.error('입찰 기본 정보 저장에 실패했습니다.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ if (isLoading) {
+ return (
+ <div className="flex items-center justify-center p-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
+ <span className="ml-2">입찰 정보를 불러오는 중...</span>
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 입찰 기본 정보
+ </CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 입찰의 기본 정보를 수정할 수 있습니다.
+ </p>
+ </CardHeader>
+ <CardContent>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSave)} className="space-y-4">
+ {/* 1행: 입찰명, PR번호, 입찰유형, 계약구분 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField control={form.control} name="title" render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰명 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input placeholder="입찰명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="prNumber" render={({ field }) => (
+ <FormItem>
+ <FormLabel>PR 번호</FormLabel>
+ <FormControl>
+ <Input placeholder="PR 번호를 입력하세요" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="biddingType" render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰유형</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="입찰유형 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(biddingTypeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="contractType" render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약구분</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="계약구분 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(contractTypeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )} />
+ </div>
+
+ {/* 기타 입찰유형 선택 시 직접입력 필드 */}
+ {form.watch('biddingType') === 'other' && (
+ <div className="grid grid-cols-4 gap-4">
+ <div></div>
+ <div></div>
+ <FormField control={form.control} name="biddingTypeCustom" render={({ field }) => (
+ <FormItem>
+ <FormLabel>기타 입찰유형 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input placeholder="직접 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+ <div></div>
+ </div>
+ )}
+
+ {/* 2행: 예산, 실적가, 내정가, 낙찰수 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField control={form.control} name="budget" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <DollarSign className="h-3 w-3" />
+ 예산
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="예산 입력" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="finalBidPrice" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <DollarSign className="h-3 w-3" />
+ 실적가
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="실적가" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="targetPrice" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Eye className="h-3 w-3" />
+ 내정가
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="내정가" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="awardCount" render={({ field }) => (
+ <FormItem>
+ <FormLabel>낙찰수</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="낙찰수 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(awardCountLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )} />
+ </div>
+
+ {/* 3행: 입찰담당자, 조달담당자, 구매조직, 통화 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField control={form.control} name="bidPicName" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <User className="h-3 w-3" />
+ 입찰담당자
+ </FormLabel>
+ <FormControl>
+ <PurchaseGroupCodeSelector
+ selectedCode={selectedBidPic}
+ onCodeSelect={(code) => {
+ handleBidPicSelect(code)
+ field.onChange(code.DISPLAY_NAME || '')
+ }}
+ placeholder="입찰담당자 선택"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="supplyPicName" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <User className="h-3 w-3" />
+ 조달담당자
+ </FormLabel>
+ <FormControl>
+ <ProcurementManagerSelector
+ selectedManager={selectedSupplyPic}
+ onManagerSelect={(manager) => {
+ handleSupplyPicSelect(manager)
+ field.onChange(manager.DISPLAY_NAME || '')
+ }}
+ placeholder="조달담당자 선택"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="purchasingOrganization" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Building className="h-3 w-3" />
+ 구매조직
+ </FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="구매조직 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="조선">조선</SelectItem>
+ <SelectItem value="해양">해양</SelectItem>
+ <SelectItem value="기타">기타</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="currency" render={({ field }) => (
+ <FormItem>
+ <FormLabel>통화</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="KRW">KRW (원)</SelectItem>
+ <SelectItem value="USD">USD (달러)</SelectItem>
+ <SelectItem value="EUR">EUR (유로)</SelectItem>
+ <SelectItem value="JPY">JPY (엔)</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )} />
+ </div>
+
+ {/* 구매유형 필드 추가 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField control={form.control} name="noticeType" render={({ field }) => (
+ <FormItem>
+ <FormLabel>구매유형 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="구매유형 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(biddingNoticeTypeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )} />
+ <div></div>
+ <div></div>
+ <div></div>
+ </div>
+
+ {/* 4행: 계약기간 시작/종료, 입찰서 제출 시작/마감 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField control={form.control} name="contractStartDate" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Calendar className="h-3 w-3" />
+ 계약기간 시작
+ </FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="contractEndDate" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Calendar className="h-3 w-3" />
+ 계약기간 종료
+ </FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ {/* <FormField control={form.control} name="submissionStartDate" render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰서 제출 시작</FormLabel>
+ <FormControl>
+ <Input type="datetime-local" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="submissionEndDate" render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰서 제출 마감</FormLabel>
+ <FormControl>
+ <Input type="datetime-local" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} /> */}
+ </div>
+
+ {/* 5행: 개찰 일시, 사양설명회, PR문서 */}
+ {/* <div className="grid grid-cols-3 gap-4">
+ <FormField control={form.control} name="evaluationDate" render={({ field }) => (
+ <FormItem>
+ <FormLabel>개찰 일시</FormLabel>
+ <FormControl>
+ <Input type="datetime-local" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} /> */}
+
+ {/* <FormField control={form.control} name="hasSpecificationMeeting" render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">사양설명회</FormLabel>
+ <div className="text-sm text-muted-foreground">
+ 사양설명회가 필요한 경우 체크
+ </div>
+ </div>
+ <FormControl>
+ <Switch checked={field.value} onCheckedChange={field.onChange} />
+ </FormControl>
+ </FormItem>
+ )} /> */}
+
+ {/* <FormField control={form.control} name="hasPrDocument" render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">PR 문서</FormLabel>
+ <div className="text-sm text-muted-foreground">
+ PR 문서가 있는 경우 체크
+ </div>
+ </div>
+ <FormControl>
+ <Switch checked={field.value} onCheckedChange={field.onChange} />
+ </FormControl>
+ </FormItem>
+ )} /> */}
+ {/* </div> */}
+
+ {/* 입찰개요 */}
+ <div className="pt-2">
+ <FormField control={form.control} name="description" render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰개요</FormLabel>
+ <FormControl>
+ <Textarea placeholder="입찰에 대한 설명을 입력하세요" rows={2} {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+ </div>
+
+ {/* 비고 */}
+ <div className="pt-2">
+ <FormField control={form.control} name="remarks" render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea placeholder="추가 사항이나 참고사항을 입력하세요" rows={3} {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+ </div>
+
+ {/* 입찰 조건 */}
+ <div className="pt-4 border-t">
+ <CardTitle className="text-lg mb-4">입찰 조건</CardTitle>
+
+ {/* 1행: SHI 지급조건, SHI 매입부가가치세 */}
+ <div className="grid grid-cols-2 gap-4 mb-4">
+ <div>
+ <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>
+ </div>
+
+ <div>
+ <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>
+ </div>
+ </div>
+
+ {/* 2행: SHI 인도조건, SHI 인도조건2 */}
+ <div className="grid grid-cols-2 gap-4 mb-4">
+ <div>
+ <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>
+ </div>
+
+ <div>
+ <FormLabel>SHI 인도조건2</FormLabel>
+ <Input
+ placeholder="인도조건 추가 정보"
+ value={biddingConditions.incotermsOption}
+ onChange={(e) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ incotermsOption: e.target.value
+ }))
+ }}
+ />
+ </div>
+ </div>
+
+ {/* 3행: SHI 선적지, SHI 하역지 */}
+ <div className="grid grid-cols-2 gap-4 mb-4">
+ <div>
+ <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>
+ </div>
+
+ <div>
+ <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>
+ </div>
+ </div>
+
+ {/* 4행: 계약 납품일, 연동제 적용 가능 */}
+ <div className="grid grid-cols-2 gap-4 mb-4">
+ <div>
+ <FormLabel>계약 납품일</FormLabel>
+ <Input
+ type="date"
+ value={biddingConditions.contractDeliveryDate}
+ onChange={(e) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ contractDeliveryDate: e.target.value
+ }))
+ }}
+ />
+ </div>
+
+ <div className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">연동제 적용 가능</FormLabel>
+ <div className="text-sm text-muted-foreground">
+ 연동제 적용 요건 여부
+ </div>
+ </div>
+ <Switch
+ checked={biddingConditions.isPriceAdjustmentApplicable}
+ onCheckedChange={(checked) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ isPriceAdjustmentApplicable: checked
+ }))
+ }}
+ />
+ </div>
+ </div>
+
+ {/* 5행: 스페어파트 옵션 */}
+ <div className="mb-4">
+ <div>
+ <FormLabel>스페어파트 옵션</FormLabel>
+ <Textarea
+ placeholder="스페어파트 관련 옵션을 입력하세요"
+ value={biddingConditions.sparePartOptions}
+ onChange={(e) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ sparePartOptions: e.target.value
+ }))
+ }}
+ rows={3}
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 입찰공고 내용 */}
+ <div className="pt-4 border-t">
+ <CardTitle className="text-lg mb-4">입찰공고 내용</CardTitle>
+ <FormField control={form.control} name="content" render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <div className="border rounded-lg">
+ <TiptapEditor
+ content={field.value || noticeTemplate}
+ setContent={field.onChange}
+ disabled={isLoadingTemplate}
+ height="300px"
+ />
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ {isLoadingTemplate && (
+ <div className="flex items-center justify-center p-4 text-sm text-muted-foreground">
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2"></div>
+ 입찰공고 템플릿을 불러오는 중...
+ </div>
+ )}
+ </div>
+
+ {/* 액션 버튼 */}
+ <div className="flex justify-end gap-4 pt-4">
+ <Button type="submit" disabled={isSubmitting} className="flex items-center gap-2">
+ {isSubmitting ? '저장 중...' : '저장'}
+ <ChevronRight className="h-4 w-4" />
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </CardContent>
+ </Card>
+
+ {/* SHI용 첨부파일 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Upload className="h-5 w-5" />
+ SHI용 첨부파일
+ </CardTitle>
+ <p className="text-sm text-muted-foreground">
+ SHI에서 제공하는 문서나 파일을 업로드하세요
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-6">
+ <div className="text-center">
+ <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" />
+ <div className="space-y-2">
+ <p className="text-sm text-gray-600">
+ 파일을 드래그 앤 드롭하거나{' '}
+ <label className="text-blue-600 hover:text-blue-500 cursor-pointer">
+ <input
+ type="file"
+ multiple
+ className="hidden"
+ onChange={(e) => {
+ const files = Array.from(e.target.files || [])
+ setShiAttachmentFiles(prev => [...prev, ...files])
+ }}
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
+ />
+ 찾아보세요
+ </label>
+ </p>
+ <p className="text-xs text-gray-500">
+ PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, JPEG 파일을 업로드할 수 있습니다
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {shiAttachmentFiles.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">업로드 예정 파일</h4>
+ <div className="space-y-2">
+ {shiAttachmentFiles.map((file, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{file.name}</p>
+ <p className="text-xs text-muted-foreground">
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+ </p>
+ </div>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ handleShiFileUpload([file])
+ }}
+ >
+ 업로드
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeShiFile(index)}
+ >
+ 제거
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 기존 문서 목록 */}
+ {isLoadingDocuments ? (
+ <div className="flex items-center justify-center p-4 text-sm text-muted-foreground">
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2"></div>
+ 문서 목록을 불러오는 중...
+ </div>
+ ) : existingDocuments.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">업로드된 문서</h4>
+ <div className="space-y-2">
+ {existingDocuments
+ .filter(doc => doc.description?.includes('SHI용') || doc.title?.includes('SHI'))
+ .map((doc) => (
+ <div
+ key={doc.id}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{doc.originalFileName}</p>
+ <p className="text-xs text-muted-foreground">
+ {formatFileSize(doc.fileSize)} • {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')}
+ </p>
+ </div>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDownloadDocument(doc)}
+ >
+ 다운로드
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeleteDocument(doc.id)}
+ >
+ 삭제
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 협력업체용 첨부파일 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Upload className="h-5 w-5" />
+ 협력업체용 첨부파일
+ </CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 협력업체에서 제공하는 문서나 파일을 업로드하세요
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-6">
+ <div className="text-center">
+ <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" />
+ <div className="space-y-2">
+ <p className="text-sm text-gray-600">
+ 파일을 드래그 앤 드롭하거나{' '}
+ <label className="text-blue-600 hover:text-blue-500 cursor-pointer">
+ <input
+ type="file"
+ multiple
+ className="hidden"
+ onChange={(e) => {
+ const files = Array.from(e.target.files || [])
+ setVendorAttachmentFiles(prev => [...prev, ...files])
+ }}
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
+ />
+ 찾아보세요
+ </label>
+ </p>
+ <p className="text-xs text-gray-500">
+ PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, JPEG 파일을 업로드할 수 있습니다
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {vendorAttachmentFiles.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">업로드 예정 파일</h4>
+ <div className="space-y-2">
+ {vendorAttachmentFiles.map((file, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{file.name}</p>
+ <p className="text-xs text-muted-foreground">
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+ </p>
+ </div>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ handleVendorFileUpload([file])
+ }}
+ >
+ 업로드
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeVendorFile(index)}
+ >
+ 제거
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 기존 문서 목록 */}
+ {existingDocuments.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">업로드된 문서</h4>
+ <div className="space-y-2">
+ {existingDocuments
+ .filter(doc => doc.description?.includes('협력업체용') || !doc.description?.includes('SHI용'))
+ .map((doc) => (
+ <div
+ key={doc.id}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{doc.originalFileName}</p>
+ <p className="text-xs text-muted-foreground">
+ {formatFileSize(doc.fileSize)} • {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')}
+ </p>
+ </div>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDownloadDocument(doc)}
+ >
+ 다운로드
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeleteDocument(doc.id)}
+ >
+ 삭제
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx
new file mode 100644
index 00000000..1ce8b014
--- /dev/null
+++ b/components/bidding/manage/bidding-companies-editor.tsx
@@ -0,0 +1,803 @@
+'use client'
+
+import * as React from 'react'
+import { Building, User, Plus, Trash2 } from 'lucide-react'
+import { toast } from 'sonner'
+
+import { Button } from '@/components/ui/button'
+import {
+ getBiddingVendors,
+ getBiddingCompanyContacts,
+ createBiddingCompanyContact,
+ deleteBiddingCompanyContact,
+ getVendorContactsByVendorId,
+ updateBiddingCompanyPriceAdjustmentQuestion
+} from '@/lib/bidding/service'
+import { deleteBiddingCompany } from '@/lib/bidding/pre-quote/service'
+import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Loader2 } from 'lucide-react'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+
+interface QuotationVendor {
+ id: number // biddingCompanies.id
+ companyId?: number // vendors.id (벤더 ID)
+ vendorName: string
+ vendorCode: string
+ contactPerson?: string
+ contactEmail?: string
+ contactPhone?: string
+ quotationAmount?: number
+ currency: string
+ invitationStatus: string
+ isPriceAdjustmentApplicableQuestion?: boolean
+}
+
+interface BiddingCompaniesEditorProps {
+ biddingId: number
+}
+
+interface VendorContact {
+ id: number
+ vendorId: number
+ contactName: string
+ contactPosition: string | null
+ contactDepartment: string | null
+ contactTask: string | null
+ contactEmail: string
+ contactPhone: string | null
+ isPrimary: boolean
+}
+
+interface BiddingCompanyContact {
+ id: number
+ biddingId: number
+ vendorId: number
+ contactName: string
+ contactEmail: string
+ contactNumber: string | null
+ createdAt: Date
+ updatedAt: Date
+}
+
+export function BiddingCompaniesEditor({ biddingId }: BiddingCompaniesEditorProps) {
+ const [vendors, setVendors] = React.useState<QuotationVendor[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [addVendorDialogOpen, setAddVendorDialogOpen] = React.useState(false)
+ const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null)
+ const [biddingCompanyContacts, setBiddingCompanyContacts] = React.useState<BiddingCompanyContact[]>([])
+ const [isLoadingContacts, setIsLoadingContacts] = React.useState(false)
+ // 각 업체별 첫 번째 담당자 정보 저장 (vendorId -> 첫 번째 담당자)
+ const [vendorFirstContacts, setVendorFirstContacts] = React.useState<Map<number, BiddingCompanyContact>>(new Map())
+
+ // 담당자 추가 다이얼로그
+ const [addContactDialogOpen, setAddContactDialogOpen] = React.useState(false)
+ const [newContact, setNewContact] = React.useState({
+ contactName: '',
+ contactEmail: '',
+ contactNumber: '',
+ })
+ const [addContactFromVendorDialogOpen, setAddContactFromVendorDialogOpen] = React.useState(false)
+ const [vendorContacts, setVendorContacts] = React.useState<VendorContact[]>([])
+ const [isLoadingVendorContacts, setIsLoadingVendorContacts] = React.useState(false)
+ const [selectedContactFromVendor, setSelectedContactFromVendor] = React.useState<VendorContact | null>(null)
+
+ // 업체 목록 다시 로딩 함수
+ const reloadVendors = React.useCallback(async () => {
+ try {
+ const result = await getBiddingVendors(biddingId)
+ if (result.success && result.data) {
+ const vendorsList = result.data.map(v => ({
+ ...v,
+ companyId: v.companyId || undefined,
+ vendorName: v.vendorName || '',
+ vendorCode: v.vendorCode || '',
+ contactPerson: v.contactPerson ?? undefined,
+ contactEmail: v.contactEmail ?? undefined,
+ contactPhone: v.contactPhone ?? undefined,
+ quotationAmount: v.quotationAmount ? parseFloat(v.quotationAmount) : undefined,
+ isPriceAdjustmentApplicableQuestion: v.isPriceAdjustmentApplicableQuestion ?? false,
+ }))
+ setVendors(vendorsList)
+
+ // 각 업체별 첫 번째 담당자 정보 로드
+ const firstContactsMap = new Map<number, BiddingCompanyContact>()
+ const contactPromises = vendorsList
+ .filter(v => v.companyId)
+ .map(async (vendor) => {
+ try {
+ const contactResult = await getBiddingCompanyContacts(biddingId, vendor.companyId!)
+ if (contactResult.success && contactResult.data && contactResult.data.length > 0) {
+ firstContactsMap.set(vendor.companyId!, contactResult.data[0])
+ }
+ } catch (error) {
+ console.error(`Failed to load contact for vendor ${vendor.companyId}:`, error)
+ }
+ })
+
+ await Promise.all(contactPromises)
+ setVendorFirstContacts(firstContactsMap)
+ }
+ } catch (error) {
+ console.error('Failed to reload vendors:', error)
+ }
+ }, [biddingId])
+
+ // 데이터 로딩
+ React.useEffect(() => {
+ const loadVendors = async () => {
+ setIsLoading(true)
+ try {
+ const result = await getBiddingVendors(biddingId)
+ if (result.success && result.data) {
+ const vendorsList = result.data.map(v => ({
+ id: v.id,
+ companyId: v.companyId || undefined,
+ vendorName: v.vendorName || '',
+ vendorCode: v.vendorCode || '',
+ contactPerson: v.contactPerson !== null ? v.contactPerson : undefined,
+ contactEmail: v.contactEmail !== null ? v.contactEmail : undefined,
+ contactPhone: v.contactPhone !== null ? v.contactPhone : undefined,
+ quotationAmount: v.quotationAmount ? parseFloat(v.quotationAmount) : undefined,
+ currency: v.currency || 'KRW',
+ invitationStatus: v.invitationStatus,
+ isPriceAdjustmentApplicableQuestion: v.isPriceAdjustmentApplicableQuestion ?? false,
+ }))
+ setVendors(vendorsList)
+
+ // 각 업체별 첫 번째 담당자 정보 로드
+ const firstContactsMap = new Map<number, BiddingCompanyContact>()
+ const contactPromises = vendorsList
+ .filter(v => v.companyId)
+ .map(async (vendor) => {
+ try {
+ const contactResult = await getBiddingCompanyContacts(biddingId, vendor.companyId!)
+ if (contactResult.success && contactResult.data && contactResult.data.length > 0) {
+ firstContactsMap.set(vendor.companyId!, contactResult.data[0])
+ }
+ } catch (error) {
+ console.error(`Failed to load contact for vendor ${vendor.companyId}:`, error)
+ }
+ })
+
+ await Promise.all(contactPromises)
+ setVendorFirstContacts(firstContactsMap)
+ } else {
+ toast.error(result.error || '업체 정보를 불러오는데 실패했습니다.')
+ setVendors([])
+ }
+ } catch (error) {
+ console.error('Failed to load vendors:', error)
+ toast.error('업체 정보를 불러오는데 실패했습니다.')
+ setVendors([])
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadVendors()
+ }, [biddingId])
+
+ // 업체 선택 핸들러 (단일 선택)
+ const handleVendorSelect = async (vendor: QuotationVendor) => {
+ // 이미 선택된 업체를 다시 클릭하면 선택 해제
+ if (selectedVendor?.id === vendor.id) {
+ setSelectedVendor(null)
+ setBiddingCompanyContacts([])
+ return
+ }
+
+ // 새 업체 선택
+ setSelectedVendor(vendor)
+
+ // 선택한 업체의 담당자 목록 로딩
+ if (vendor.companyId) {
+ setIsLoadingContacts(true)
+ try {
+ const result = await getBiddingCompanyContacts(biddingId, vendor.companyId)
+ if (result.success && result.data) {
+ setBiddingCompanyContacts(result.data)
+ } else {
+ toast.error(result.error || '담당자 목록을 불러오는데 실패했습니다.')
+ setBiddingCompanyContacts([])
+ }
+ } catch (error) {
+ console.error('Failed to load contacts:', error)
+ toast.error('담당자 목록을 불러오는데 실패했습니다.')
+ setBiddingCompanyContacts([])
+ } finally {
+ setIsLoadingContacts(false)
+ }
+ }
+ }
+
+ // 업체 삭제
+ const handleRemoveVendor = async (vendorId: number) => {
+ if (!confirm('정말로 이 업체를 삭제하시겠습니까?')) {
+ return
+ }
+
+ try {
+ const result = await deleteBiddingCompany(vendorId)
+ if (result.success) {
+ toast.success('업체가 삭제되었습니다.')
+ // 업체 목록 다시 로딩
+ await reloadVendors()
+ // 선택된 업체가 삭제된 경우 담당자 목록도 초기화
+ if (selectedVendor?.id === vendorId) {
+ setSelectedVendor(null)
+ setBiddingCompanyContacts([])
+ }
+ } else {
+ toast.error(result.error || '업체 삭제에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('Failed to remove vendor:', error)
+ toast.error('업체 삭제에 실패했습니다.')
+ }
+ }
+
+ // 담당자 추가 (직접 입력)
+ const handleAddContact = async () => {
+ if (!selectedVendor || !selectedVendor.companyId) {
+ toast.error('업체를 선택해주세요.')
+ return
+ }
+
+ if (!newContact.contactName || !newContact.contactEmail) {
+ toast.error('이름과 이메일은 필수입니다.')
+ return
+ }
+
+ try {
+ const result = await createBiddingCompanyContact(
+ biddingId,
+ selectedVendor.companyId,
+ {
+ contactName: newContact.contactName,
+ contactEmail: newContact.contactEmail,
+ contactNumber: newContact.contactNumber || undefined,
+ }
+ )
+
+ if (result.success) {
+ toast.success('담당자가 추가되었습니다.')
+ setAddContactDialogOpen(false)
+ setNewContact({ contactName: '', contactEmail: '', contactNumber: '' })
+
+ // 담당자 목록 새로고침
+ const contactsResult = await getBiddingCompanyContacts(biddingId, selectedVendor.companyId)
+ if (contactsResult.success && contactsResult.data) {
+ setBiddingCompanyContacts(contactsResult.data)
+ // 첫 번째 담당자 정보 업데이트
+ if (contactsResult.data.length > 0) {
+ setVendorFirstContacts(prev => {
+ const newMap = new Map(prev)
+ newMap.set(selectedVendor.companyId!, contactsResult.data[0])
+ return newMap
+ })
+ }
+ }
+ } else {
+ toast.error(result.error || '담당자 추가에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('Failed to add contact:', error)
+ toast.error('담당자 추가에 실패했습니다.')
+ }
+ }
+
+ // 담당자 추가 (벤더 목록에서 선택)
+ const handleOpenAddContactFromVendor = async () => {
+ if (!selectedVendor || !selectedVendor.companyId) {
+ toast.error('업체를 선택해주세요.')
+ return
+ }
+
+ setIsLoadingVendorContacts(true)
+ setAddContactFromVendorDialogOpen(true)
+ setSelectedContactFromVendor(null)
+
+ try {
+ const result = await getVendorContactsByVendorId(selectedVendor.companyId)
+ if (result.success && result.data) {
+ setVendorContacts(result.data)
+ } else {
+ toast.error(result.error || '벤더 담당자 목록을 불러오는데 실패했습니다.')
+ setVendorContacts([])
+ }
+ } catch (error) {
+ console.error('Failed to load vendor contacts:', error)
+ toast.error('벤더 담당자 목록을 불러오는데 실패했습니다.')
+ setVendorContacts([])
+ } finally {
+ setIsLoadingVendorContacts(false)
+ }
+ }
+
+ // 벤더 담당자 선택 후 저장
+ const handleAddContactFromVendor = async () => {
+ if (!selectedContactFromVendor || !selectedVendor || !selectedVendor.companyId) {
+ toast.error('담당자를 선택해주세요.')
+ return
+ }
+
+ try {
+ const result = await createBiddingCompanyContact(
+ biddingId,
+ selectedVendor.companyId,
+ {
+ contactName: selectedContactFromVendor.contactName,
+ contactEmail: selectedContactFromVendor.contactEmail,
+ contactNumber: selectedContactFromVendor.contactPhone || undefined,
+ }
+ )
+
+ if (result.success) {
+ toast.success('담당자가 추가되었습니다.')
+ setAddContactFromVendorDialogOpen(false)
+ setSelectedContactFromVendor(null)
+
+ // 담당자 목록 새로고침
+ const contactsResult = await getBiddingCompanyContacts(biddingId, selectedVendor.companyId)
+ if (contactsResult.success && contactsResult.data) {
+ setBiddingCompanyContacts(contactsResult.data)
+ // 첫 번째 담당자 정보 업데이트
+ if (contactsResult.data.length > 0) {
+ setVendorFirstContacts(prev => {
+ const newMap = new Map(prev)
+ newMap.set(selectedVendor.companyId!, contactsResult.data[0])
+ return newMap
+ })
+ }
+ }
+ } else {
+ toast.error(result.error || '담당자 추가에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('Failed to add contact:', error)
+ toast.error('담당자 추가에 실패했습니다.')
+ }
+ }
+
+ // 담당자 삭제
+ const handleDeleteContact = async (contactId: number) => {
+ if (!confirm('정말로 이 담당자를 삭제하시겠습니까?')) {
+ return
+ }
+
+ try {
+ const result = await deleteBiddingCompanyContact(contactId)
+ if (result.success) {
+ toast.success('담당자가 삭제되었습니다.')
+
+ // 담당자 목록 새로고침
+ if (selectedVendor && selectedVendor.companyId) {
+ const contactsResult = await getBiddingCompanyContacts(biddingId, selectedVendor.companyId)
+ if (contactsResult.success && contactsResult.data) {
+ setBiddingCompanyContacts(contactsResult.data)
+ // 첫 번째 담당자 정보 업데이트
+ if (contactsResult.data.length > 0) {
+ setVendorFirstContacts(prev => {
+ const newMap = new Map(prev)
+ newMap.set(selectedVendor.companyId!, contactsResult.data[0])
+ return newMap
+ })
+ } else {
+ // 담당자가 없으면 Map에서 제거
+ setVendorFirstContacts(prev => {
+ const newMap = new Map(prev)
+ newMap.delete(selectedVendor.companyId!)
+ return newMap
+ })
+ }
+ }
+ }
+ } else {
+ toast.error(result.error || '담당자 삭제에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('Failed to delete contact:', error)
+ toast.error('담당자 삭제에 실패했습니다.')
+ }
+ }
+
+ // 연동제 적용요건 문의 체크박스 변경
+ const handleTogglePriceAdjustmentQuestion = async (vendorId: number, checked: boolean) => {
+ try {
+ const result = await updateBiddingCompanyPriceAdjustmentQuestion(vendorId, checked)
+ if (result.success) {
+ // 로컬 상태 업데이트
+ setVendors(prev => prev.map(v =>
+ v.id === vendorId
+ ? { ...v, isPriceAdjustmentApplicableQuestion: checked }
+ : v
+ ))
+
+ // 선택된 업체 정보도 업데이트
+ if (selectedVendor?.id === vendorId) {
+ setSelectedVendor(prev => prev ? { ...prev, isPriceAdjustmentApplicableQuestion: checked } : null)
+ }
+
+ // 담당자 목록 새로고침 (첫 번째 담당자 정보 업데이트를 위해)
+ if (selectedVendor && selectedVendor.companyId) {
+ const contactsResult = await getBiddingCompanyContacts(biddingId, selectedVendor.companyId)
+ if (contactsResult.success && contactsResult.data) {
+ setBiddingCompanyContacts(contactsResult.data)
+ if (contactsResult.data.length > 0) {
+ setVendorFirstContacts(prev => {
+ const newMap = new Map(prev)
+ newMap.set(selectedVendor.companyId!, contactsResult.data[0])
+ return newMap
+ })
+ }
+ }
+ }
+ } else {
+ toast.error(result.error || '연동제 적용요건 문의 여부 업데이트에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('Failed to update price adjustment question:', error)
+ toast.error('연동제 적용요건 문의 여부 업데이트에 실패했습니다.')
+ }
+ }
+
+ if (isLoading) {
+ return (
+ <div className="flex items-center justify-center p-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
+ <span className="ml-2">업체 정보를 불러오는 중...</span>
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-6">
+ {/* 참여 업체 목록 테이블 */}
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ <Building className="h-5 w-5" />
+ 참여 업체 목록
+ </CardTitle>
+ <p className="text-sm text-muted-foreground mt-1">
+ 입찰에 참여하는 업체들을 관리합니다. 업체를 선택하면 하단에 담당자 목록이 표시됩니다.
+ </p>
+ </div>
+ <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2">
+ <Plus className="h-4 w-4" />
+ 업체 추가
+ </Button>
+ </CardHeader>
+ <CardContent>
+ {vendors.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ 참여 업체가 없습니다. 업체를 추가해주세요.
+ </div>
+ ) : (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[50px]">선택</TableHead>
+ <TableHead>업체명</TableHead>
+ <TableHead>업체코드</TableHead>
+ <TableHead>담당자 이름</TableHead>
+ <TableHead>담당자 이메일</TableHead>
+ <TableHead>담당자 연락처</TableHead>
+ <TableHead>상태</TableHead>
+ <TableHead className="w-[180px]">연동제 적용요건 문의</TableHead>
+ <TableHead className="w-[100px]">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {vendors.map((vendor) => (
+ <TableRow
+ key={vendor.id}
+ className={`cursor-pointer hover:bg-muted/50 ${selectedVendor?.id === vendor.id ? 'bg-muted/50' : ''}`}
+ onClick={() => handleVendorSelect(vendor)}
+ >
+ <TableCell onClick={(e) => e.stopPropagation()}>
+ <Checkbox
+ checked={selectedVendor?.id === vendor.id}
+ onCheckedChange={() => handleVendorSelect(vendor)}
+ />
+ </TableCell>
+ <TableCell className="font-medium">{vendor.vendorName}</TableCell>
+ <TableCell>{vendor.vendorCode}</TableCell>
+ <TableCell>
+ {vendor.companyId && vendorFirstContacts.has(vendor.companyId)
+ ? vendorFirstContacts.get(vendor.companyId)!.contactName
+ : '-'}
+ </TableCell>
+ <TableCell>
+ {vendor.companyId && vendorFirstContacts.has(vendor.companyId)
+ ? vendorFirstContacts.get(vendor.companyId)!.contactEmail
+ : '-'}
+ </TableCell>
+ <TableCell>
+ {vendor.companyId && vendorFirstContacts.has(vendor.companyId)
+ ? vendorFirstContacts.get(vendor.companyId)!.contactNumber || '-'
+ : '-'}
+ </TableCell>
+
+ <TableCell>
+ <span className="px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800">
+ {vendor.invitationStatus}
+ </span>
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-2">
+ <Checkbox
+ checked={vendor.isPriceAdjustmentApplicableQuestion || false}
+ onCheckedChange={(checked) =>
+ handleTogglePriceAdjustmentQuestion(vendor.id, checked as boolean)
+ }
+ />
+ <span className="text-sm text-muted-foreground">
+ {vendor.isPriceAdjustmentApplicableQuestion ? '예' : '아니오'}
+ </span>
+ </div>
+ </TableCell>
+ <TableCell>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveVendor(vendor.id)}
+ className="text-red-600 hover:text-red-800"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 선택한 업체의 담당자 목록 테이블 */}
+ {selectedVendor && (
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ <User className="h-5 w-5" />
+ {selectedVendor.vendorName} 담당자 목록
+ </CardTitle>
+ <p className="text-sm text-muted-foreground mt-1">
+ 선택한 업체의 선정된 담당자를 관리합니다.
+ </p>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ onClick={handleOpenAddContactFromVendor}
+ className="flex items-center gap-2"
+ >
+ <User className="h-4 w-4" />
+ 업체 담당자 추가
+ </Button>
+ <Button
+ onClick={() => setAddContactDialogOpen(true)}
+ className="flex items-center gap-2"
+ >
+ <Plus className="h-4 w-4" />
+ 담당자 수기 입력
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent>
+ {isLoadingContacts ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ <span className="text-sm text-muted-foreground">담당자 목록을 불러오는 중...</span>
+ </div>
+ ) : biddingCompanyContacts.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ 등록된 담당자가 없습니다. 담당자를 추가해주세요.
+ </div>
+ ) : (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>이름</TableHead>
+ <TableHead>이메일</TableHead>
+ <TableHead>전화번호</TableHead>
+ <TableHead className="w-[100px]">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {biddingCompanyContacts.map((biddingCompanyContact) => (
+ <TableRow key={biddingCompanyContact.id}>
+ <TableCell className="font-medium">{biddingCompanyContact.contactName}</TableCell>
+ <TableCell>{biddingCompanyContact.contactEmail}</TableCell>
+ <TableCell>{biddingCompanyContact.contactNumber || '-'}</TableCell>
+ <TableCell>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeleteContact(biddingCompanyContact.id)}
+ className="text-red-600 hover:text-red-800"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )}
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 업체 추가 다이얼로그 */}
+ <BiddingDetailVendorCreateDialog
+ biddingId={biddingId}
+ open={addVendorDialogOpen}
+ onOpenChange={setAddVendorDialogOpen}
+ onSuccess={reloadVendors}
+ />
+
+ {/* 담당자 추가 다이얼로그 (직접 입력) */}
+ <Dialog open={addContactDialogOpen} onOpenChange={setAddContactDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>담당자 추가</DialogTitle>
+ <DialogDescription>
+ 새로운 담당자 정보를 입력하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 py-4">
+ <div className="space-y-2">
+ <Label htmlFor="contactName">이름 *</Label>
+ <Input
+ id="contactName"
+ value={newContact.contactName}
+ onChange={(e) => setNewContact(prev => ({ ...prev, contactName: e.target.value }))}
+ placeholder="담당자 이름"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="contactEmail">이메일 *</Label>
+ <Input
+ id="contactEmail"
+ type="email"
+ value={newContact.contactEmail}
+ onChange={(e) => setNewContact(prev => ({ ...prev, contactEmail: e.target.value }))}
+ placeholder="example@email.com"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="contactNumber">전화번호</Label>
+ <Input
+ id="contactNumber"
+ value={newContact.contactNumber}
+ onChange={(e) => setNewContact(prev => ({ ...prev, contactNumber: e.target.value }))}
+ placeholder="010-1234-5678"
+ />
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setAddContactDialogOpen(false)
+ setNewContact({ contactName: '', contactEmail: '', contactNumber: '' })
+ }}
+ >
+ 취소
+ </Button>
+ <Button onClick={handleAddContact}>
+ 추가
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 벤더 담당자에서 추가 다이얼로그 */}
+ <Dialog open={addContactFromVendorDialogOpen} onOpenChange={setAddContactFromVendorDialogOpen}>
+ <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>
+ {selectedVendor ? `${selectedVendor.vendorName} 벤더 담당자에서 선택` : '벤더 담당자 선택'}
+ </DialogTitle>
+ <DialogDescription>
+ 벤더에 등록된 담당자 목록에서 선택하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 py-4">
+ {isLoadingVendorContacts ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ <span className="text-sm text-muted-foreground">담당자 목록을 불러오는 중...</span>
+ </div>
+ ) : vendorContacts.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ 등록된 담당자가 없습니다.
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {vendorContacts.map((contact) => (
+ <div
+ key={contact.id}
+ className={`flex items-center justify-between p-4 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors ${
+ selectedContactFromVendor?.id === contact.id ? 'bg-primary/10 border-primary' : ''
+ }`}
+ onClick={() => setSelectedContactFromVendor(contact)}
+ >
+ <div className="flex items-center gap-3 flex-1">
+ <Checkbox
+ checked={selectedContactFromVendor?.id === contact.id}
+ onCheckedChange={() => setSelectedContactFromVendor(contact)}
+ className="shrink-0"
+ />
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2">
+ <span className="font-medium">{contact.contactName}</span>
+ {contact.isPrimary && (
+ <span className="px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary">
+ 주담당자
+ </span>
+ )}
+ </div>
+ {contact.contactPosition && (
+ <p className="text-sm text-muted-foreground">{contact.contactPosition}</p>
+ )}
+ <div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
+ <span>{contact.contactEmail}</span>
+ {contact.contactPhone && <span>{contact.contactPhone}</span>}
+ </div>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setAddContactFromVendorDialogOpen(false)
+ setSelectedContactFromVendor(null)
+ }}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleAddContactFromVendor}
+ disabled={!selectedContactFromVendor || isLoadingVendorContacts}
+ >
+ 추가
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx
new file mode 100644
index 00000000..ed3e2be6
--- /dev/null
+++ b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx
@@ -0,0 +1,437 @@
+'use client'
+
+import * as React from 'react'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { ChevronsUpDown, Loader2, X, Plus } from 'lucide-react'
+import { createBiddingDetailVendor } from '@/lib/bidding/detail/service'
+import { searchVendorsForBidding } from '@/lib/bidding/service'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+import { Badge } from '@/components/ui/badge'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+
+interface BiddingDetailVendorCreateDialogProps {
+ biddingId: number
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSuccess: () => void
+}
+
+interface Vendor {
+ id: number
+ vendorName: string
+ vendorCode: string
+ status: string
+}
+
+interface SelectedVendorWithQuestion {
+ vendor: Vendor
+ isPriceAdjustmentApplicableQuestion: boolean
+}
+
+export function BiddingDetailVendorCreateDialog({
+ biddingId,
+ open,
+ onOpenChange,
+ onSuccess
+}: BiddingDetailVendorCreateDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [activeTab, setActiveTab] = React.useState('select')
+
+ // Vendor 검색 상태
+ const [vendorList, setVendorList] = React.useState<Vendor[]>([])
+ const [selectedVendorsWithQuestion, setSelectedVendorsWithQuestion] = React.useState<SelectedVendorWithQuestion[]>([])
+ const [vendorOpen, setVendorOpen] = React.useState(false)
+
+ // 벤더 로드
+ const loadVendors = React.useCallback(async () => {
+ try {
+ const result = await searchVendorsForBidding('', biddingId) // 빈 검색어로 모든 벤더 로드
+ setVendorList(result || [])
+ } catch (error) {
+ console.error('Failed to load vendors:', error)
+ toast({
+ title: '오류',
+ description: '벤더 목록을 불러오는데 실패했습니다.',
+ variant: 'destructive',
+ })
+ setVendorList([])
+ }
+ }, [biddingId, toast])
+
+ React.useEffect(() => {
+ if (open) {
+ loadVendors()
+ }
+ }, [open, loadVendors])
+
+ // 초기화
+ React.useEffect(() => {
+ if (!open) {
+ setSelectedVendorsWithQuestion([])
+ setActiveTab('select')
+ }
+ }, [open])
+
+ // 벤더 추가
+ const handleAddVendor = (vendor: Vendor) => {
+ if (!selectedVendorsWithQuestion.find(v => v.vendor.id === vendor.id)) {
+ setSelectedVendorsWithQuestion([
+ ...selectedVendorsWithQuestion,
+ {
+ vendor,
+ isPriceAdjustmentApplicableQuestion: false
+ }
+ ])
+ }
+ setVendorOpen(false)
+ }
+
+ // 벤더 제거
+ const handleRemoveVendor = (vendorId: number) => {
+ setSelectedVendorsWithQuestion(
+ selectedVendorsWithQuestion.filter(v => v.vendor.id !== vendorId)
+ )
+ }
+
+ // 이미 선택된 벤더인지 확인
+ const isVendorSelected = (vendorId: number) => {
+ return selectedVendorsWithQuestion.some(v => v.vendor.id === vendorId)
+ }
+
+ // 연동제 적용요건 문의 체크박스 토글
+ const handleTogglePriceAdjustmentQuestion = (vendorId: number, checked: boolean) => {
+ setSelectedVendorsWithQuestion(prev =>
+ prev.map(item =>
+ item.vendor.id === vendorId
+ ? { ...item, isPriceAdjustmentApplicableQuestion: checked }
+ : item
+ )
+ )
+ }
+
+ const handleCreate = () => {
+ if (selectedVendorsWithQuestion.length === 0) {
+ toast({
+ title: '오류',
+ description: '업체를 선택해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ // Tab 2로 이동하여 연동제 적용요건 문의를 확인하도록 유도
+ if (activeTab === 'select') {
+ setActiveTab('question')
+ toast({
+ title: '확인 필요',
+ description: '선택한 업체들의 연동제 적용요건 문의를 확인해주세요.',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ let successCount = 0
+ const errorMessages: string[] = []
+
+ for (const item of selectedVendorsWithQuestion) {
+ try {
+ const response = await createBiddingDetailVendor(
+ biddingId,
+ item.vendor.id,
+ item.isPriceAdjustmentApplicableQuestion
+ )
+
+ if (response.success) {
+ successCount++
+ } else {
+ errorMessages.push(`${item.vendor.vendorName}: ${response.error}`)
+ }
+ } catch {
+ errorMessages.push(`${item.vendor.vendorName}: 처리 중 오류가 발생했습니다.`)
+ }
+ }
+
+ if (successCount > 0) {
+ toast({
+ title: '성공',
+ description: `${successCount}개의 업체가 성공적으로 추가되었습니다.${errorMessages.length > 0 ? ` ${errorMessages.length}개는 실패했습니다.` : ''}`,
+ })
+ onOpenChange(false)
+ resetForm()
+ onSuccess()
+ }
+
+ if (errorMessages.length > 0 && successCount === 0) {
+ toast({
+ title: '오류',
+ description: `업체 추가에 실패했습니다: ${errorMessages.join(', ')}`,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const resetForm = () => {
+ setSelectedVendorsWithQuestion([])
+ setActiveTab('select')
+ }
+
+ const selectedVendors = selectedVendorsWithQuestion.map(item => item.vendor)
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[90vh] p-0 flex flex-col">
+ {/* 헤더 */}
+ <DialogHeader className="p-6 pb-0">
+ <DialogTitle>협력업체 추가</DialogTitle>
+ <DialogDescription>
+ 입찰에 참여할 업체를 선택하고 연동제 적용요건 문의를 설정하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 탭 네비게이션 */}
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col px-6">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="select">
+ 1. 입찰업체 선택 ({selectedVendors.length}개)
+ </TabsTrigger>
+ <TabsTrigger
+ value="question"
+ disabled={selectedVendors.length === 0}
+ >
+ 2. 연동제 적용요건 문의
+ </TabsTrigger>
+ </TabsList>
+
+ {/* Tab 1: 입찰업체 선택 */}
+ <TabsContent value="select" className="flex-1 overflow-y-auto mt-4 pb-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">업체 선택</CardTitle>
+ <CardDescription>
+ 입찰에 참여할 협력업체를 선택하세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-4">
+ {/* 업체 추가 버튼 */}
+ <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={vendorOpen}
+ className="w-full justify-between"
+ disabled={vendorList.length === 0}
+ >
+ <span className="flex items-center gap-2">
+ <Plus className="h-4 w-4" />
+ 업체 선택하기
+ </span>
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[500px] p-0" align="start">
+ <Command>
+ <CommandInput placeholder="업체명 또는 코드로 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {vendorList
+ .filter(vendor => !isVendorSelected(vendor.id))
+ .map((vendor) => (
+ <CommandItem
+ key={vendor.id}
+ value={`${vendor.vendorCode} ${vendor.vendorName}`}
+ onSelect={() => handleAddVendor(vendor)}
+ >
+ <div className="flex items-center gap-2 w-full">
+ <Badge variant="outline" className="shrink-0">
+ {vendor.vendorCode}
+ </Badge>
+ <span className="truncate">{vendor.vendorName}</span>
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+
+ {/* 선택된 업체 목록 */}
+ {selectedVendors.length > 0 && (
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <h4 className="text-sm font-medium">선택된 업체 ({selectedVendors.length}개)</h4>
+ </div>
+ <div className="space-y-2">
+ {selectedVendors.map((vendor, index) => (
+ <div
+ key={vendor.id}
+ className="flex items-center justify-between p-3 rounded-lg bg-secondary/50"
+ >
+ <div className="flex items-center gap-3">
+ <span className="text-sm text-muted-foreground">
+ {index + 1}.
+ </span>
+ <Badge variant="outline">
+ {vendor.vendorCode}
+ </Badge>
+ <span className="text-sm font-medium">
+ {vendor.vendorName}
+ </span>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveVendor(vendor.id)}
+ className="h-8 w-8 p-0"
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {selectedVendors.length === 0 && (
+ <div className="text-center py-8 text-muted-foreground">
+ <p className="text-sm">아직 선택된 업체가 없습니다.</p>
+ <p className="text-xs mt-1">위 버튼을 클릭하여 업체를 추가하세요.</p>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* Tab 2: 연동제 적용요건 문의 체크 */}
+ <TabsContent value="question" className="flex-1 overflow-y-auto mt-4 pb-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">연동제 적용요건 문의</CardTitle>
+ <CardDescription>
+ 선택한 업체별로 연동제 적용요건 문의 여부를 체크하세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ {selectedVendorsWithQuestion.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <p className="text-sm">선택된 업체가 없습니다.</p>
+ <p className="text-xs mt-1">먼저 입찰업체 선택 탭에서 업체를 선택해주세요.</p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {selectedVendorsWithQuestion.map((item, index) => (
+ <div
+ key={item.vendor.id}
+ className="flex items-center justify-between p-4 rounded-lg border"
+ >
+ <div className="flex items-center gap-4 flex-1">
+ <span className="text-sm text-muted-foreground w-8">
+ {index + 1}.
+ </span>
+ <div className="flex-1">
+ <div className="flex items-center gap-2 mb-1">
+ <Badge variant="outline">
+ {item.vendor.vendorCode}
+ </Badge>
+ <span className="font-medium">{item.vendor.vendorName}</span>
+ </div>
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <Checkbox
+ id={`question-${item.vendor.id}`}
+ checked={item.isPriceAdjustmentApplicableQuestion}
+ onCheckedChange={(checked) =>
+ handleTogglePriceAdjustmentQuestion(item.vendor.id, checked as boolean)
+ }
+ />
+ <Label
+ htmlFor={`question-${item.vendor.id}`}
+ className="text-sm cursor-pointer"
+ >
+ 연동제 적용요건 문의
+ </Label>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </Tabs>
+
+ {/* 푸터 */}
+ <DialogFooter className="p-6 pt-0 border-t">
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ {activeTab === 'select' ? (
+ <Button
+ onClick={() => {
+ if (selectedVendors.length > 0) {
+ setActiveTab('question')
+ } else {
+ toast({
+ title: '오류',
+ description: '업체를 선택해주세요.',
+ variant: 'destructive',
+ })
+ }
+ }}
+ disabled={isPending || selectedVendors.length === 0}
+ >
+ 다음 단계
+ </Button>
+ ) : (
+ <Button
+ onClick={handleCreate}
+ disabled={isPending || selectedVendorsWithQuestion.length === 0}
+ >
+ {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {selectedVendorsWithQuestion.length > 0
+ ? `${selectedVendorsWithQuestion.length}개 업체 추가`
+ : '업체 추가'
+ }
+ </Button>
+ )}
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx
new file mode 100644
index 00000000..96a8d2ae
--- /dev/null
+++ b/components/bidding/manage/bidding-items-editor.tsx
@@ -0,0 +1,1143 @@
+'use client'
+
+import * as React from 'react'
+import { Package, Plus, Trash2, Save, RefreshCw, FileText } from 'lucide-react'
+import { getPRItemsForBidding } from '@/lib/bidding/detail/service'
+import { updatePrItem } from '@/lib/bidding/detail/service'
+import { toast } from 'sonner'
+import { useSession } from 'next-auth/react'
+
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Checkbox } from '@/components/ui/checkbox'
+import { ProjectSelector } from '@/components/ProjectSelector'
+import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single'
+import { MaterialSelectorDialogSingle } from '@/components/common/selectors/material/material-selector-dialog-single'
+import { WbsCodeSingleSelector } from '@/components/common/selectors/wbs-code/wbs-code-single-selector'
+import { CostCenterSingleSelector } from '@/components/common/selectors/cost-center/cost-center-single-selector'
+import { GlAccountSingleSelector } from '@/components/common/selectors/gl-account/gl-account-single-selector'
+
+// PR 아이템 정보 타입 (create-bidding-dialog와 동일)
+interface PRItemInfo {
+ id: number // 실제 DB ID
+ prNumber?: string | null
+ projectId?: number | null
+ projectInfo?: string | null
+ shi?: string | null
+ quantity?: string | null
+ quantityUnit?: string | null
+ totalWeight?: string | null
+ weightUnit?: string | null
+ materialDescription?: string | null
+ hasSpecDocument?: boolean
+ requestedDeliveryDate?: string | null
+ isRepresentative?: boolean // 대표 아이템 여부
+ // 가격 정보
+ annualUnitPrice?: string | null
+ currency?: string | null
+ // 자재 그룹 정보 (필수)
+ materialGroupNumber?: string | null
+ materialGroupInfo?: string | null
+ // 자재 정보
+ materialNumber?: string | null
+ materialInfo?: string | null
+ // 단위 정보
+ priceUnit?: string | null
+ purchaseUnit?: string | null
+ materialWeight?: string | null
+ // WBS 정보
+ wbsCode?: string | null
+ wbsName?: string | null
+ // Cost Center 정보
+ costCenterCode?: string | null
+ costCenterName?: string | null
+ // GL Account 정보
+ glAccountCode?: string | null
+ glAccountName?: string | null
+ // 내정 정보
+ targetUnitPrice?: string | null
+ targetAmount?: string | null
+ targetCurrency?: string | null
+ // 예산 정보
+ budgetAmount?: string | null
+ budgetCurrency?: string | null
+ // 실적 정보
+ actualAmount?: string | null
+ actualCurrency?: string | null
+}
+
+interface BiddingItemsEditorProps {
+ biddingId: number
+}
+
+import { removeBiddingItem, addPRItemForBidding, getBiddingById, getBiddingConditions } from '@/lib/bidding/service'
+import { CreatePreQuoteRfqDialog } from './create-pre-quote-rfq-dialog'
+import { Textarea } from '@/components/ui/textarea'
+import { Label } from '@/components/ui/label'
+
+export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
+ const { data: session } = useSession()
+ const [items, setItems] = React.useState<PRItemInfo[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [quantityWeightMode, setQuantityWeightMode] = React.useState<'quantity' | 'weight'>('quantity')
+ const [costCenterDialogOpen, setCostCenterDialogOpen] = React.useState(false)
+ const [selectedItemForCostCenter, setSelectedItemForCostCenter] = React.useState<number | null>(null)
+ const [glAccountDialogOpen, setGlAccountDialogOpen] = React.useState(false)
+ const [selectedItemForGlAccount, setSelectedItemForGlAccount] = React.useState<number | null>(null)
+ const [wbsCodeDialogOpen, setWbsCodeDialogOpen] = React.useState(false)
+ const [selectedItemForWbs, setSelectedItemForWbs] = React.useState<number | null>(null)
+ const [tempIdCounter, setTempIdCounter] = React.useState(0) // 임시 ID 카운터
+ const [deletedItemIds, setDeletedItemIds] = React.useState<Set<number>>(new Set()) // 삭제된 아이템 ID 추적
+ const [preQuoteDialogOpen, setPreQuoteDialogOpen] = React.useState(false)
+ const [targetPriceCalculationCriteria, setTargetPriceCalculationCriteria] = React.useState('')
+ const [biddingPicUserId, setBiddingPicUserId] = React.useState<number | null>(null)
+ const [biddingConditions, setBiddingConditions] = React.useState<{
+ paymentTerms?: string | null
+ taxConditions?: string | null
+ incoterms?: string | null
+ incotermsOption?: string | null
+ contractDeliveryDate?: string | null
+ shippingPort?: string | null
+ destinationPort?: string | null
+ isPriceAdjustmentApplicable?: boolean | null
+ sparePartOptions?: string | null
+ } | null>(null)
+
+ // 초기 데이터 로딩 - 기존 품목이 있으면 자동으로 로드
+ React.useEffect(() => {
+ const loadItems = async () => {
+ if (!biddingId) return
+
+ setIsLoading(true)
+ try {
+ const prItems = await getPRItemsForBidding(biddingId)
+
+ if (prItems && prItems.length > 0) {
+ const formattedItems: PRItemInfo[] = prItems.map((item) => ({
+ id: item.id,
+ prNumber: item.prNumber || null,
+ projectId: item.projectId || null,
+ projectInfo: item.projectInfo || null,
+ shi: item.shi || null,
+ quantity: item.quantity ? item.quantity.toString() : null,
+ quantityUnit: item.quantityUnit || null,
+ totalWeight: item.totalWeight ? item.totalWeight.toString() : null,
+ weightUnit: item.weightUnit || null,
+ materialDescription: item.itemInfo || null,
+ hasSpecDocument: item.hasSpecDocument || false,
+ requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate).toISOString().split('T')[0] : null,
+ isRepresentative: false, // 첫 번째 아이템을 대표로 설정할 수 있음
+ annualUnitPrice: item.annualUnitPrice ? item.annualUnitPrice.toString() : null,
+ currency: item.currency || 'KRW',
+ materialGroupNumber: item.materialGroupNumber || null,
+ materialGroupInfo: item.materialGroupInfo || null,
+ materialNumber: item.materialNumber || null,
+ materialInfo: item.materialInfo || null,
+ priceUnit: item.priceUnit || null,
+ purchaseUnit: item.purchaseUnit || null,
+ materialWeight: item.materialWeight ? item.materialWeight.toString() : null,
+ wbsCode: item.wbsCode || null,
+ wbsName: item.wbsName || null,
+ costCenterCode: item.costCenterCode || null,
+ costCenterName: item.costCenterName || null,
+ glAccountCode: item.glAccountCode || null,
+ glAccountName: item.glAccountName || null,
+ targetUnitPrice: item.targetUnitPrice ? item.targetUnitPrice.toString() : null,
+ targetAmount: item.targetAmount ? item.targetAmount.toString() : null,
+ targetCurrency: item.targetCurrency || 'KRW',
+ budgetAmount: item.budgetAmount ? item.budgetAmount.toString() : null,
+ budgetCurrency: item.budgetCurrency || 'KRW',
+ actualAmount: item.actualAmount ? item.actualAmount.toString() : null,
+ actualCurrency: item.actualCurrency || 'KRW',
+ }))
+
+ // 첫 번째 아이템을 대표로 설정
+ formattedItems[0].isRepresentative = true
+
+ setItems(formattedItems)
+ setDeletedItemIds(new Set()) // 삭제 목록 초기화
+
+ // 기존 품목 로드 성공 알림 (조용히 표시, 선택적)
+ console.log(`기존 품목 ${formattedItems.length}개를 불러왔습니다.`)
+ } else {
+ // 품목이 없을 때는 빈 배열로 초기화
+ setItems([])
+ setDeletedItemIds(new Set())
+ }
+ } catch (error) {
+ console.error('Failed to load items:', error)
+ toast.error('품목 정보를 불러오는데 실패했습니다.')
+ // 에러 발생 시에도 빈 배열로 초기화하여 UI가 깨지지 않도록
+ setItems([])
+ setDeletedItemIds(new Set())
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadItems()
+ }, [biddingId])
+
+ // 입찰 정보 및 조건 로드 (사전견적 다이얼로그용)
+ React.useEffect(() => {
+ const loadBiddingInfo = async () => {
+ if (!biddingId) return
+
+ try {
+ const [bidding, conditions] = await Promise.all([
+ getBiddingById(biddingId),
+ getBiddingConditions(biddingId)
+ ])
+
+ if (bidding) {
+ setBiddingPicUserId(bidding.bidPicId || null)
+ setTargetPriceCalculationCriteria(bidding.targetPriceCalculationCriteria || '')
+ }
+
+ if (conditions) {
+ setBiddingConditions(conditions)
+ }
+ } catch (error) {
+ console.error('Failed to load bidding info:', error)
+ }
+ }
+
+ loadBiddingInfo()
+ }, [biddingId])
+
+ const handleSave = async () => {
+ setIsSubmitting(true)
+ try {
+ const userId = session?.user?.id?.toString() || '1'
+ let hasError = false
+
+ // 모든 아이템을 upsert 처리 (id가 있으면 update, 없으면 insert)
+ for (const item of items) {
+ const targetAmount = calculateTargetAmount(item)
+
+ let result
+ if (item.id > 0) {
+ // 기존 아이템 업데이트
+ result = await updatePrItem(item.id, {
+ projectId: item.projectId || null,
+ projectInfo: item.projectInfo || null,
+ shi: item.shi || null,
+ materialGroupNumber: item.materialGroupNumber || null,
+ materialGroupInfo: item.materialGroupInfo || null,
+ materialNumber: item.materialNumber || null,
+ materialInfo: item.materialInfo || null,
+ quantity: item.quantity ? parseFloat(item.quantity) : null,
+ quantityUnit: item.quantityUnit || null,
+ totalWeight: item.totalWeight ? parseFloat(item.totalWeight) : null,
+ weightUnit: item.weightUnit || null,
+ priceUnit: item.priceUnit || null,
+ purchaseUnit: item.purchaseUnit || null,
+ materialWeight: item.materialWeight ? parseFloat(item.materialWeight) : null,
+ wbsCode: item.wbsCode || null,
+ wbsName: item.wbsName || null,
+ costCenterCode: item.costCenterCode || null,
+ costCenterName: item.costCenterName || null,
+ glAccountCode: item.glAccountCode || null,
+ glAccountName: item.glAccountName || null,
+ targetUnitPrice: item.targetUnitPrice ? parseFloat(item.targetUnitPrice) : null,
+ targetAmount: targetAmount ? parseFloat(targetAmount) : null,
+ targetCurrency: item.targetCurrency || 'KRW',
+ budgetAmount: item.budgetAmount ? parseFloat(item.budgetAmount) : null,
+ budgetCurrency: item.budgetCurrency || 'KRW',
+ actualAmount: item.actualAmount ? parseFloat(item.actualAmount) : null,
+ actualCurrency: item.actualCurrency || 'KRW',
+ requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate) : null,
+ currency: item.currency || 'KRW',
+ annualUnitPrice: item.annualUnitPrice ? parseFloat(item.annualUnitPrice) : null,
+ prNumber: item.prNumber || null,
+ hasSpecDocument: item.hasSpecDocument || false,
+ } as Parameters<typeof updatePrItem>[1], userId)
+ } else {
+ // 새 아이템 추가 (문자열 타입만 허용)
+ result = await addPRItemForBidding(biddingId, {
+ projectId: item.projectId ?? undefined,
+ projectInfo: item.projectInfo ?? null,
+ shi: item.shi ?? null,
+ materialGroupNumber: item.materialGroupNumber ?? null,
+ materialGroupInfo: item.materialGroupInfo ?? null,
+ materialNumber: item.materialNumber ?? null,
+ materialInfo: item.materialInfo ?? null,
+ quantity: item.quantity ?? null,
+ quantityUnit: item.quantityUnit ?? null,
+ totalWeight: item.totalWeight ?? null,
+ weightUnit: item.weightUnit ?? null,
+ priceUnit: item.priceUnit ?? null,
+ purchaseUnit: item.purchaseUnit ?? null,
+ materialWeight: item.materialWeight ?? null,
+ wbsCode: item.wbsCode ?? null,
+ wbsName: item.wbsName ?? null,
+ costCenterCode: item.costCenterCode ?? null,
+ costCenterName: item.costCenterName ?? null,
+ glAccountCode: item.glAccountCode ?? null,
+ glAccountName: item.glAccountName ?? null,
+ targetUnitPrice: item.targetUnitPrice ?? null,
+ targetAmount: targetAmount ?? null,
+ targetCurrency: item.targetCurrency || 'KRW',
+ budgetAmount: item.budgetAmount ?? null,
+ budgetCurrency: item.budgetCurrency || 'KRW',
+ actualAmount: item.actualAmount ?? null,
+ actualCurrency: item.actualCurrency || 'KRW',
+ requestedDeliveryDate: item.requestedDeliveryDate ?? null,
+ currency: item.currency || 'KRW',
+ annualUnitPrice: item.annualUnitPrice ?? null,
+ prNumber: item.prNumber ?? null,
+ hasSpecDocument: item.hasSpecDocument || false,
+ })
+ }
+
+ if (!result.success) {
+ hasError = true
+ }
+ }
+
+ // 삭제된 아이템들 서버에서 삭제
+ for (const deletedId of deletedItemIds) {
+ const result = await removeBiddingItem(deletedId)
+ if (!result.success) {
+ hasError = true
+ }
+ }
+
+
+ if (hasError) {
+ toast.error('일부 품목 정보 저장에 실패했습니다.')
+ } else {
+ // 내정가 산정 기준 별도 저장 (서버 액션으로 처리)
+ if (targetPriceCalculationCriteria.trim()) {
+ try {
+ const { updateTargetPriceCalculationCriteria } = await import('@/lib/bidding/service')
+ const criteriaResult = await updateTargetPriceCalculationCriteria(biddingId, targetPriceCalculationCriteria.trim(), userId)
+ if (!criteriaResult.success) {
+ console.warn('Failed to save target price calculation criteria:', criteriaResult.error)
+ }
+ } catch (error) {
+ console.error('Failed to save target price calculation criteria:', error)
+ }
+ }
+
+ toast.success('품목 정보가 성공적으로 저장되었습니다.')
+ // 삭제 목록 초기화
+ setDeletedItemIds(new Set())
+ // 데이터 다시 로딩하여 최신 상태 반영
+ const prItems = await getPRItemsForBidding(biddingId)
+ const formattedItems: PRItemInfo[] = prItems.map((item) => ({
+ id: item.id,
+ prNumber: item.prNumber || null,
+ projectId: item.projectId || null,
+ projectInfo: item.projectInfo || null,
+ shi: item.shi || null,
+ quantity: item.quantity ? item.quantity.toString() : null,
+ quantityUnit: item.quantityUnit || null,
+ totalWeight: item.totalWeight ? item.totalWeight.toString() : null,
+ weightUnit: item.weightUnit || null,
+ materialDescription: item.itemInfo || null,
+ hasSpecDocument: item.hasSpecDocument || false,
+ requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate).toISOString().split('T')[0] : null,
+ isRepresentative: false,
+ annualUnitPrice: item.annualUnitPrice ? item.annualUnitPrice.toString() : null,
+ currency: item.currency || 'KRW',
+ materialGroupNumber: item.materialGroupNumber || null,
+ materialGroupInfo: item.materialGroupInfo || null,
+ materialNumber: item.materialNumber || null,
+ materialInfo: item.materialInfo || null,
+ priceUnit: item.priceUnit || null,
+ purchaseUnit: item.purchaseUnit || null,
+ materialWeight: item.materialWeight ? item.materialWeight.toString() : null,
+ wbsCode: item.wbsCode || null,
+ wbsName: item.wbsName || null,
+ costCenterCode: item.costCenterCode || null,
+ costCenterName: item.costCenterName || null,
+ glAccountCode: item.glAccountCode || null,
+ glAccountName: item.glAccountName || null,
+ targetUnitPrice: item.targetUnitPrice ? item.targetUnitPrice.toString() : null,
+ targetAmount: item.targetAmount ? item.targetAmount.toString() : null,
+ targetCurrency: item.targetCurrency || 'KRW',
+ budgetAmount: item.budgetAmount ? item.budgetAmount.toString() : null,
+ budgetCurrency: item.budgetCurrency || 'KRW',
+ actualAmount: item.actualAmount ? item.actualAmount.toString() : null,
+ actualCurrency: item.actualCurrency || 'KRW',
+ }))
+
+ // 첫 번째 아이템을 대표로 설정
+ if (formattedItems.length > 0) {
+ formattedItems[0].isRepresentative = true
+ }
+
+ setItems(formattedItems)
+ }
+ } catch (error) {
+ console.error('Failed to save items:', error)
+ toast.error('품목 정보 저장에 실패했습니다.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const handleAddItem = () => {
+ // 임시 ID 생성 (음수로 구분하여 실제 DB ID와 구분)
+ const tempId = -(tempIdCounter + 1)
+ setTempIdCounter(prev => prev + 1)
+
+ // 즉시 UI에 새 아이템 추가 (서버 저장 없음)
+ const newItem: PRItemInfo = {
+ id: tempId, // 임시 ID
+ prNumber: null,
+ projectId: null,
+ projectInfo: null,
+ shi: null,
+ quantity: null,
+ quantityUnit: 'EA',
+ totalWeight: null,
+ weightUnit: 'KG',
+ materialDescription: null,
+ hasSpecDocument: false,
+ requestedDeliveryDate: null,
+ isRepresentative: items.length === 0,
+ annualUnitPrice: null,
+ currency: 'KRW',
+ materialGroupNumber: null,
+ materialGroupInfo: null,
+ materialNumber: null,
+ materialInfo: null,
+ priceUnit: null,
+ purchaseUnit: '1',
+ materialWeight: null,
+ wbsCode: null,
+ wbsName: null,
+ costCenterCode: null,
+ costCenterName: null,
+ glAccountCode: null,
+ glAccountName: null,
+ targetUnitPrice: null,
+ targetAmount: null,
+ targetCurrency: 'KRW',
+ budgetAmount: null,
+ budgetCurrency: 'KRW',
+ actualAmount: null,
+ actualCurrency: 'KRW',
+ }
+
+ setItems((prev) => {
+ // 첫 번째 아이템이면 대표로 설정
+ if (prev.length === 0) {
+ return [newItem]
+ }
+ return [...prev, newItem]
+ })
+ }
+
+ const handleRemoveItem = (itemId: number) => {
+ if (items.length <= 1) {
+ toast.error('최소 하나의 품목이 필요합니다.')
+ return
+ }
+
+ // 실제 아이템인 경우 삭제 목록에 추가 (저장 시 서버에서 삭제됨)
+ if (itemId > 0) {
+ setDeletedItemIds(prev => new Set([...prev, itemId]))
+ }
+
+ // UI에서 즉시 제거
+ setItems((prev) => {
+ const filteredItems = prev.filter((item) => item.id !== itemId)
+ const removedItem = prev.find((item) => item.id === itemId)
+ if (removedItem?.isRepresentative && filteredItems.length > 0) {
+ filteredItems[0].isRepresentative = true
+ }
+ return filteredItems
+ })
+ }
+
+ const updatePRItem = (id: number, updates: Partial<PRItemInfo>) => {
+ setItems((prev) =>
+ prev.map((item) => {
+ if (item.id === id) {
+ const updatedItem = { ...item, ...updates }
+ // 내정단가, 수량, 중량, 구매단위가 변경되면 내정금액 재계산
+ if (updates.targetUnitPrice || updates.quantity || updates.totalWeight || updates.purchaseUnit) {
+ updatedItem.targetAmount = calculateTargetAmount(updatedItem)
+ }
+ return updatedItem
+ }
+ return item
+ })
+ )
+ }
+
+ const setRepresentativeItem = (id: number) => {
+ setItems((prev) =>
+ prev.map((item) => ({
+ ...item,
+ isRepresentative: item.id === id,
+ }))
+ )
+ }
+
+ const handleQuantityWeightModeChange = (mode: 'quantity' | 'weight') => {
+ setQuantityWeightMode(mode)
+ }
+
+ const calculateTargetAmount = (item: PRItemInfo) => {
+ const unitPrice = parseFloat(item.targetUnitPrice || '0') || 0
+ const purchaseUnit = parseFloat(item.purchaseUnit || '1') || 1
+ let amount = 0
+
+ if (quantityWeightMode === 'quantity') {
+ const quantity = parseFloat(item.quantity || '0') || 0
+ amount = (quantity / purchaseUnit) * unitPrice
+ } else {
+ const weight = parseFloat(item.totalWeight || '0') || 0
+ amount = (weight / purchaseUnit) * unitPrice
+ }
+
+ return Math.floor(amount).toString()
+ }
+
+ if (isLoading) {
+ return (
+ <div className="flex items-center justify-center p-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
+ <span className="ml-2">품목 정보를 불러오는 중...</span>
+ </div>
+ )
+ }
+
+ // PR 아이템 테이블 렌더링 (create-bidding-dialog와 동일한 구조)
+ const renderPrItemsTable = () => {
+ return (
+ <div className="border rounded-lg overflow-hidden">
+ <div className="overflow-x-auto">
+ <table className="w-full border-collapse">
+ <thead className="bg-muted/50">
+ <tr>
+ <th className="sticky left-0 z-10 bg-muted/50 border-r px-2 py-3 text-left text-xs font-medium min-w-[50px]">
+ <span className="sr-only">대표</span>
+ </th>
+ <th className="sticky left-[50px] z-10 bg-muted/50 border-r px-3 py-3 text-left text-xs font-medium min-w-[40px]">
+ #
+ </th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">프로젝트코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">프로젝트명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재그룹코드 <span className="text-red-500">*</span></th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재그룹명 <span className="text-red-500">*</span></th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">수량</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">단위</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">구매단위</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정단가</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정금액</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">내정통화</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">예산금액</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">예산통화</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">실적금액</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">실적통화</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">WBS코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">WBS명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">코스트센터코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">코스트센터명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">GL계정코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">GL계정명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">납품요청일</th>
+ <th className="sticky right-0 z-10 bg-muted/50 border-l px-3 py-3 text-center text-xs font-medium min-w-[100px]">
+ 액션
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {items.map((item, index) => (
+ <tr key={item.id} className="border-t hover:bg-muted/30">
+ <td className="sticky left-0 z-10 bg-background border-r px-2 py-2 text-center">
+ <Checkbox
+ checked={item.isRepresentative}
+ onCheckedChange={() => setRepresentativeItem(item.id)}
+ disabled={items.length <= 1 && item.isRepresentative}
+ title="대표 아이템"
+ />
+ </td>
+ <td className="sticky left-[50px] z-10 bg-background border-r px-3 py-2 text-xs text-muted-foreground">
+ {index + 1}
+ </td>
+ <td className="border-r px-3 py-2">
+ <ProjectSelector
+ selectedProjectId={item.projectId || null}
+ onProjectSelect={(project) => {
+ updatePRItem(item.id, {
+ projectId: project.id,
+ projectInfo: project.projectName
+ })
+ }}
+ placeholder="프로젝트 선택"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="프로젝트명"
+ value={item.projectInfo || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <MaterialGroupSelectorDialogSingle
+ triggerLabel={item.materialGroupNumber || "자재그룹 선택"}
+ triggerVariant="outline"
+ selectedMaterial={item.materialGroupNumber ? {
+ materialGroupCode: item.materialGroupNumber,
+ materialGroupDescription: item.materialGroupInfo || '',
+ displayText: `${item.materialGroupNumber} - ${item.materialGroupInfo || ''}`
+ } : null}
+ onMaterialSelect={(material) => {
+ if (material) {
+ updatePRItem(item.id, {
+ materialGroupNumber: material.materialGroupCode,
+ materialGroupInfo: material.materialGroupDescription
+ })
+ } else {
+ updatePRItem(item.id, {
+ materialGroupNumber: '',
+ materialGroupInfo: ''
+ })
+ }
+ }}
+ title="자재그룹 선택"
+ description="자재그룹을 검색하고 선택해주세요."
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="자재그룹명"
+ value={item.materialGroupInfo || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <MaterialSelectorDialogSingle
+ triggerLabel={item.materialNumber || "자재 선택"}
+ triggerVariant="outline"
+ selectedMaterial={item.materialNumber ? {
+ materialCode: item.materialNumber,
+ materialName: item.materialInfo || '',
+ displayText: `${item.materialNumber} - ${item.materialInfo || ''}`
+ } : null}
+ onMaterialSelect={(material) => {
+ if (material) {
+ updatePRItem(item.id, {
+ materialNumber: material.materialCode,
+ materialInfo: material.materialName
+ })
+ } else {
+ updatePRItem(item.id, {
+ materialNumber: '',
+ materialInfo: ''
+ })
+ }
+ }}
+ title="자재 선택"
+ description="자재를 검색하고 선택해주세요."
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="자재명"
+ value={item.materialInfo || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ {quantityWeightMode === 'quantity' ? (
+ <Input
+ type="number"
+ min="0"
+ placeholder="수량"
+ value={item.quantity || ''}
+ onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })}
+ className="h-8 text-xs"
+ />
+ ) : (
+ <Input
+ type="number"
+ min="0"
+ placeholder="중량"
+ value={item.totalWeight || ''}
+ onChange={(e) => updatePRItem(item.id, { totalWeight: e.target.value })}
+ className="h-8 text-xs"
+ />
+ )}
+ </td>
+ <td className="border-r px-3 py-2">
+ {quantityWeightMode === 'quantity' ? (
+ <Select
+ value={item.quantityUnit || 'EA'}
+ onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="EA">EA</SelectItem>
+ <SelectItem value="SET">SET</SelectItem>
+ <SelectItem value="LOT">LOT</SelectItem>
+ <SelectItem value="M">M</SelectItem>
+ <SelectItem value="M2">M²</SelectItem>
+ <SelectItem value="M3">M³</SelectItem>
+ </SelectContent>
+ </Select>
+ ) : (
+ <Select
+ value={item.weightUnit || 'KG'}
+ onValueChange={(value) => updatePRItem(item.id, { weightUnit: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KG">KG</SelectItem>
+ <SelectItem value="TON">TON</SelectItem>
+ <SelectItem value="G">G</SelectItem>
+ <SelectItem value="LB">LB</SelectItem>
+ </SelectContent>
+ </Select>
+ )}
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="1"
+ step="1"
+ placeholder="구매단위"
+ value={item.purchaseUnit || ''}
+ onChange={(e) => updatePRItem(item.id, { purchaseUnit: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="내정단가"
+ value={item.targetUnitPrice || ''}
+ onChange={(e) => updatePRItem(item.id, { targetUnitPrice: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="내정금액"
+ readOnly
+ value={item.targetAmount || ''}
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Select
+ value={item.targetCurrency || 'KRW'}
+ onValueChange={(value) => updatePRItem(item.id, { targetCurrency: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="예산금액"
+ value={item.budgetAmount || ''}
+ onChange={(e) => updatePRItem(item.id, { budgetAmount: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Select
+ value={item.budgetCurrency || 'KRW'}
+ onValueChange={(value) => updatePRItem(item.id, { budgetCurrency: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="실적금액"
+ value={item.actualAmount || ''}
+ onChange={(e) => updatePRItem(item.id, { actualAmount: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Select
+ value={item.actualCurrency || 'KRW'}
+ onValueChange={(value) => updatePRItem(item.id, { actualCurrency: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border-r px-3 py-2">
+ <Button
+ variant="outline"
+ onClick={() => {
+ setSelectedItemForWbs(item.id)
+ setWbsCodeDialogOpen(true)
+ }}
+ className="w-full justify-start h-8 text-xs"
+ >
+ {item.wbsCode ? (
+ <span className="truncate">
+ {`${item.wbsCode}${item.wbsName ? ` - ${item.wbsName}` : ''}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">WBS 코드 선택</span>
+ )}
+ </Button>
+ <WbsCodeSingleSelector
+ open={wbsCodeDialogOpen && selectedItemForWbs === item.id}
+ onOpenChange={(open) => {
+ setWbsCodeDialogOpen(open)
+ if (!open) setSelectedItemForWbs(null)
+ }}
+ selectedCode={item.wbsCode ? {
+ PROJ_NO: '',
+ WBS_ELMT: item.wbsCode,
+ WBS_ELMT_NM: item.wbsName || '',
+ WBS_LVL: ''
+ } : undefined}
+ onCodeSelect={(wbsCode) => {
+ updatePRItem(item.id, {
+ wbsCode: wbsCode.WBS_ELMT,
+ wbsName: wbsCode.WBS_ELMT_NM
+ })
+ setWbsCodeDialogOpen(false)
+ setSelectedItemForWbs(null)
+ }}
+ title="WBS 코드 선택"
+ description="WBS 코드를 선택하세요"
+ showConfirmButtons={false}
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="WBS명"
+ value={item.wbsName || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Button
+ variant="outline"
+ onClick={() => {
+ setSelectedItemForCostCenter(item.id)
+ setCostCenterDialogOpen(true)
+ }}
+ className="w-full justify-start h-8 text-xs"
+ >
+ {item.costCenterCode ? (
+ <span className="truncate">
+ {`${item.costCenterCode}${item.costCenterName ? ` - ${item.costCenterName}` : ''}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">코스트센터 선택</span>
+ )}
+ </Button>
+ <CostCenterSingleSelector
+ open={costCenterDialogOpen && selectedItemForCostCenter === item.id}
+ onOpenChange={(open) => {
+ setCostCenterDialogOpen(open)
+ if (!open) setSelectedItemForCostCenter(null)
+ }}
+ selectedCode={item.costCenterCode ? {
+ KOSTL: item.costCenterCode,
+ KTEXT: '',
+ LTEXT: item.costCenterName || '',
+ DATAB: '',
+ DATBI: ''
+ } : undefined}
+ onCodeSelect={(costCenter) => {
+ updatePRItem(item.id, {
+ costCenterCode: costCenter.KOSTL,
+ costCenterName: costCenter.LTEXT
+ })
+ setCostCenterDialogOpen(false)
+ setSelectedItemForCostCenter(null)
+ }}
+ title="코스트센터 선택"
+ description="코스트센터를 선택하세요"
+ showConfirmButtons={false}
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="코스트센터명"
+ value={item.costCenterName || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Button
+ variant="outline"
+ onClick={() => {
+ setSelectedItemForGlAccount(item.id)
+ setGlAccountDialogOpen(true)
+ }}
+ className="w-full justify-start h-8 text-xs"
+ >
+ {item.glAccountCode ? (
+ <span className="truncate">
+ {`${item.glAccountCode}${item.glAccountName ? ` - ${item.glAccountName}` : ''}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">GL계정 선택</span>
+ )}
+ </Button>
+ <GlAccountSingleSelector
+ open={glAccountDialogOpen && selectedItemForGlAccount === item.id}
+ onOpenChange={(open) => {
+ setGlAccountDialogOpen(open)
+ if (!open) setSelectedItemForGlAccount(null)
+ }}
+ selectedCode={item.glAccountCode ? {
+ SAKNR: item.glAccountCode,
+ FIPEX: '',
+ TEXT1: item.glAccountName || ''
+ } : undefined}
+ onCodeSelect={(glAccount) => {
+ updatePRItem(item.id, {
+ glAccountCode: glAccount.SAKNR,
+ glAccountName: glAccount.TEXT1
+ })
+ setGlAccountDialogOpen(false)
+ setSelectedItemForGlAccount(null)
+ }}
+ title="GL 계정 선택"
+ description="GL 계정을 선택하세요"
+ showConfirmButtons={false}
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="GL계정명"
+ value={item.glAccountName || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="date"
+ value={item.requestedDeliveryDate || ''}
+ onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="sticky right-0 z-10 bg-background border-l px-3 py-2">
+ <div className="flex items-center justify-center gap-1">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveItem(item.id)}
+ disabled={items.length <= 1}
+ className="h-7 w-7 p-0"
+ title="품목 삭제"
+ >
+ <Trash2 className="h-3.5 w-3.5" />
+ </Button>
+ </div>
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-6">
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ <Package className="h-5 w-5" />
+ 입찰 품목 목록
+ </CardTitle>
+ <p className="text-sm text-muted-foreground mt-1">
+ 입찰 대상 품목들을 관리합니다. 최소 하나의 아이템이 필요하며, 자재그룹코드는 필수입니다
+ </p>
+ <p className="text-xs text-amber-600 mt-1">
+ 수량/단위 또는 중량/중량단위를 선택해서 입력하세요
+ </p>
+ </div>
+ <div className="flex gap-2">
+ <Button onClick={() => setPreQuoteDialogOpen(true)} variant="outline" className="flex items-center gap-2">
+ <FileText className="h-4 w-4" />
+ 사전견적
+ </Button>
+ <Button onClick={handleAddItem} className="flex items-center gap-2">
+ <Plus className="h-4 w-4" />
+ 품목 추가
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* 내정가 산정 기준 입력 폼 */}
+ <div className="space-y-2">
+ <Label htmlFor="targetPriceCalculationCriteria">내정가 산정 기준 (선택)</Label>
+ <Textarea
+ id="targetPriceCalculationCriteria"
+ placeholder="내정가 산정 기준을 입력하세요"
+ value={targetPriceCalculationCriteria}
+ onChange={(e) => setTargetPriceCalculationCriteria(e.target.value)}
+ rows={3}
+ className="resize-none"
+ />
+ <p className="text-xs text-muted-foreground">
+ 내정가를 산정한 기준이나 방법을 입력하세요
+ </p>
+ </div>
+ <div className="flex items-center space-x-4 p-4 bg-muted rounded-lg">
+ <div className="text-sm font-medium">계산 기준:</div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="radio"
+ id="quantity-mode"
+ name="quantityWeightMode"
+ checked={quantityWeightMode === 'quantity'}
+ onChange={() => handleQuantityWeightModeChange('quantity')}
+ className="h-4 w-4"
+ />
+ <label htmlFor="quantity-mode" className="text-sm">수량 기준</label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="radio"
+ id="weight-mode"
+ name="quantityWeightMode"
+ checked={quantityWeightMode === 'weight'}
+ onChange={() => handleQuantityWeightModeChange('weight')}
+ className="h-4 w-4"
+ />
+ <label htmlFor="weight-mode" className="text-sm">중량 기준</label>
+ </div>
+ </div>
+ <div className="space-y-4">
+ {items.length > 0 ? (
+ renderPrItemsTable()
+ ) : (
+ <div className="text-center py-12 border-2 border-dashed border-gray-300 rounded-lg">
+ <Package className="h-12 w-12 text-gray-400 mx-auto mb-4" />
+ <p className="text-gray-500 mb-2">아직 품목이 없습니다</p>
+ <p className="text-sm text-gray-400 mb-4">
+ 품목을 추가하여 입찰 세부내역을 작성하세요
+ </p>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleAddItem}
+ className="flex items-center gap-2 mx-auto"
+ >
+ <Plus className="h-4 w-4" />
+ 첫 번째 품목 추가
+ </Button>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 액션 버튼 */}
+ <div className="flex justify-end gap-4">
+ <Button
+ onClick={handleSave}
+ disabled={isSubmitting}
+ className="min-w-[120px]"
+ >
+ {isSubmitting ? (
+ <>
+ <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ <>
+ <Save className="w-4 h-4 mr-2" />
+ 저장
+ </>
+ )}
+ </Button>
+ </div>
+
+ {/* 사전견적용 일반견적 생성 다이얼로그 */}
+ <CreatePreQuoteRfqDialog
+ open={preQuoteDialogOpen}
+ onOpenChange={setPreQuoteDialogOpen}
+ biddingId={biddingId}
+ biddingItems={items.map(item => ({
+ id: item.id,
+ materialGroupNumber: item.materialGroupNumber || undefined,
+ materialGroupInfo: item.materialGroupInfo || undefined,
+ materialNumber: item.materialNumber || undefined,
+ materialInfo: item.materialInfo || undefined,
+ quantity: item.quantity || undefined,
+ quantityUnit: item.quantityUnit || undefined,
+ totalWeight: item.totalWeight || undefined,
+ weightUnit: item.weightUnit || undefined,
+ }))}
+ picUserId={biddingPicUserId}
+ biddingConditions={biddingConditions}
+ onSuccess={() => {
+ toast.success('사전견적용 일반견적이 생성되었습니다')
+ }}
+ />
+
+ </div>
+ )
+}
diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx
new file mode 100644
index 00000000..d64c16c0
--- /dev/null
+++ b/components/bidding/manage/bidding-schedule-editor.tsx
@@ -0,0 +1,661 @@
+'use client'
+
+import * as React from 'react'
+import { Calendar, Save, RefreshCw, Clock, Send } from 'lucide-react'
+import { updateBiddingSchedule, getBiddingById, getSpecificationMeetingDetailsAction } from '@/lib/bidding/service'
+import { useSession } from 'next-auth/react'
+import { useRouter } from 'next/navigation'
+
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Switch } from '@/components/ui/switch'
+import { BiddingInvitationDialog } from '@/lib/bidding/detail/table/bidding-invitation-dialog'
+import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from '@/lib/bidding/pre-quote/service'
+import { registerBidding } from '@/lib/bidding/detail/service'
+import { useToast } from '@/hooks/use-toast'
+
+interface BiddingSchedule {
+ submissionStartDate?: string
+ submissionEndDate?: string
+ remarks?: string
+ isUrgent?: boolean
+ hasSpecificationMeeting?: boolean
+}
+
+interface SpecificationMeetingInfo {
+ meetingDate: string
+ meetingTime: string
+ location: string
+ address: string
+ contactPerson: string
+ contactPhone: string
+ contactEmail: string
+ agenda: string
+ materials: string
+ notes: string
+ isRequired: boolean
+}
+
+interface BiddingScheduleEditorProps {
+ biddingId: number
+}
+
+interface VendorContractRequirement {
+ vendorId: number
+ vendorName: string
+ vendorCode?: string | null
+ vendorCountry?: string
+ vendorEmail?: string | null
+ contactPerson?: string | null
+ contactEmail?: string | null
+ ndaYn?: boolean
+ generalGtcYn?: boolean
+ projectGtcYn?: boolean
+ agreementYn?: boolean
+ biddingCompanyId: number
+ biddingId: number
+}
+
+interface VendorWithContactInfo extends VendorContractRequirement {
+ contacts: Array<{
+ id: number
+ contactName: string
+ contactEmail: string
+ contactPhone?: string | null
+ contactPosition?: string | null
+ contactDepartment?: string | null
+ }>
+ selectedMainEmail: string
+ additionalEmails: string[]
+ customEmails: Array<{
+ id: string
+ email: string
+ name?: string
+ }>
+ hasExistingContracts: boolean
+}
+
+interface BiddingInvitationData {
+ vendors: VendorWithContactInfo[]
+ generatedPdfs: Array<{
+ key: string
+ buffer: number[]
+ fileName: string
+ }>
+ message?: string
+}
+
+export function BiddingScheduleEditor({ biddingId }: BiddingScheduleEditorProps) {
+ const { data: session } = useSession()
+ const router = useRouter()
+ const { toast } = useToast()
+ const [schedule, setSchedule] = React.useState<BiddingSchedule>({})
+ const [specMeetingInfo, setSpecMeetingInfo] = React.useState<SpecificationMeetingInfo>({
+ meetingDate: '',
+ meetingTime: '',
+ location: '',
+ address: '',
+ contactPerson: '',
+ contactPhone: '',
+ contactEmail: '',
+ agenda: '',
+ materials: '',
+ notes: '',
+ isRequired: false,
+ })
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [biddingInfo, setBiddingInfo] = React.useState<{ title: string; projectName?: string } | null>(null)
+ const [isBiddingInvitationDialogOpen, setIsBiddingInvitationDialogOpen] = React.useState(false)
+ const [selectedVendors, setSelectedVendors] = React.useState<VendorContractRequirement[]>([])
+
+ // 데이터 로딩
+ React.useEffect(() => {
+ const loadSchedule = async () => {
+ setIsLoading(true)
+ try {
+ const bidding = await getBiddingById(biddingId)
+ if (bidding) {
+ // 입찰 정보 저장
+ setBiddingInfo({
+ title: bidding.title || '',
+ projectName: bidding.projectName || undefined,
+ })
+
+ // 날짜를 문자열로 변환하는 헬퍼
+ const formatDateTime = (date: unknown): string => {
+ if (!date) return ''
+ if (typeof date === 'string') {
+ // 이미 datetime-local 형식인 경우
+ if (date.includes('T')) {
+ return date.slice(0, 16)
+ }
+ return date
+ }
+ if (date instanceof Date) return date.toISOString().slice(0, 16)
+ return ''
+ }
+
+ setSchedule({
+ submissionStartDate: formatDateTime(bidding.submissionStartDate),
+ submissionEndDate: formatDateTime(bidding.submissionEndDate),
+ remarks: bidding.remarks || '',
+ isUrgent: bidding.isUrgent || false,
+ hasSpecificationMeeting: bidding.hasSpecificationMeeting || false,
+ })
+
+ // 사양설명회 정보 로드
+ if (bidding.hasSpecificationMeeting) {
+ try {
+ const meetingDetails = await getSpecificationMeetingDetailsAction(biddingId)
+ if (meetingDetails.success && meetingDetails.data) {
+ const meeting = meetingDetails.data
+ setSpecMeetingInfo({
+ meetingDate: meeting.meetingDate ? new Date(meeting.meetingDate).toISOString().slice(0, 16) : '',
+ meetingTime: meeting.meetingTime || '',
+ location: meeting.location || '',
+ address: meeting.address || '',
+ contactPerson: meeting.contactPerson || '',
+ contactPhone: meeting.contactPhone || '',
+ contactEmail: meeting.contactEmail || '',
+ agenda: meeting.agenda || '',
+ materials: meeting.materials || '',
+ notes: meeting.notes || '',
+ isRequired: meeting.isRequired || false,
+ })
+ }
+ } catch (error) {
+ console.error('Failed to load specification meeting details:', error)
+ }
+ }
+ }
+ } catch (error) {
+ console.error('Failed to load schedule:', error)
+ toast({
+ title: '오류',
+ description: '일정 정보를 불러오는데 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadSchedule()
+ }, [biddingId, toast])
+
+ // 선정된 업체들 조회
+ const getSelectedVendors = React.useCallback(async (): Promise<VendorContractRequirement[]> => {
+ try {
+ const result = await getSelectedVendorsForBidding(biddingId)
+ if (result.success) {
+ // 타입 변환: null을 undefined로 변환
+ return result.vendors.map((vendor): VendorContractRequirement => ({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ vendorCode: vendor.vendorCode ?? undefined,
+ vendorCountry: vendor.vendorCountry,
+ vendorEmail: vendor.vendorEmail ?? undefined,
+ contactPerson: vendor.contactPerson ?? undefined,
+ contactEmail: vendor.contactEmail ?? undefined,
+ ndaYn: vendor.ndaYn,
+ generalGtcYn: vendor.generalGtcYn,
+ projectGtcYn: vendor.projectGtcYn,
+ agreementYn: vendor.agreementYn,
+ biddingCompanyId: vendor.biddingCompanyId,
+ biddingId: vendor.biddingId,
+ }))
+ } else {
+ console.error('선정된 업체 조회 실패:', 'error' in result ? result.error : '알 수 없는 오류')
+ return []
+ }
+ } catch (error) {
+ console.error('선정된 업체 조회 실패:', error)
+ return []
+ }
+ }, [biddingId])
+
+ // 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회
+ React.useEffect(() => {
+ if (isBiddingInvitationDialogOpen) {
+ getSelectedVendors().then(vendors => {
+ setSelectedVendors(vendors)
+ })
+ }
+ }, [isBiddingInvitationDialogOpen, getSelectedVendors])
+
+ // 입찰 초대 발송 핸들러
+ const handleBiddingInvitationSend = async (data: BiddingInvitationData) => {
+ try {
+ const userId = session?.user?.id?.toString() || '1'
+
+ // 1. 기본계약 발송
+ // sendBiddingBasicContracts에 필요한 형식으로 변환
+ const vendorDataForContract = data.vendors.map(vendor => ({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ vendorCode: vendor.vendorCode || undefined,
+ vendorCountry: vendor.vendorCountry,
+ selectedMainEmail: vendor.selectedMainEmail,
+ additionalEmails: vendor.additionalEmails,
+ customEmails: vendor.customEmails,
+ contractRequirements: {
+ ndaYn: vendor.ndaYn || false,
+ generalGtcYn: vendor.generalGtcYn || false,
+ projectGtcYn: vendor.projectGtcYn || false,
+ agreementYn: vendor.agreementYn || false,
+ },
+ biddingCompanyId: vendor.biddingCompanyId,
+ biddingId: vendor.biddingId,
+ hasExistingContracts: vendor.hasExistingContracts,
+ }))
+
+ const contractResult = await sendBiddingBasicContracts(
+ biddingId,
+ vendorDataForContract,
+ data.generatedPdfs,
+ data.message
+ )
+
+ if (!contractResult.success) {
+ const errorMessage = 'message' in contractResult
+ ? contractResult.message
+ : 'error' in contractResult
+ ? contractResult.error
+ : '기본계약 발송에 실패했습니다.'
+ toast({
+ title: '기본계약 발송 실패',
+ description: errorMessage,
+ variant: 'destructive',
+ })
+ return
+ }
+
+ // 2. 입찰 등록 진행
+ const registerResult = await registerBidding(biddingId, userId)
+
+ if (registerResult.success) {
+ toast({
+ title: '본입찰 초대 완료',
+ description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.',
+ })
+ setIsBiddingInvitationDialogOpen(false)
+ router.refresh()
+ } else {
+ toast({
+ title: '오류',
+ description: 'error' in registerResult ? registerResult.error : '입찰 등록에 실패했습니다.',
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ console.error('본입찰 초대 실패:', error)
+ toast({
+ title: '오류',
+ description: '본입찰 초대에 실패했습니다.',
+ variant: 'destructive',
+ })
+ }
+ }
+
+ const handleSave = async () => {
+ setIsSubmitting(true)
+ try {
+ const userId = session?.user?.id?.toString() || '1'
+
+ // 사양설명회 정보 유효성 검사
+ if (schedule.hasSpecificationMeeting) {
+ if (!specMeetingInfo.meetingDate || !specMeetingInfo.location || !specMeetingInfo.contactPerson) {
+ toast({
+ title: '오류',
+ description: '사양설명회 필수 정보가 누락되었습니다. (회의일시, 장소, 담당자)',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ }
+
+ const result = await updateBiddingSchedule(
+ biddingId,
+ schedule,
+ userId,
+ schedule.hasSpecificationMeeting ? specMeetingInfo : undefined
+ )
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: '일정 정보가 성공적으로 저장되었습니다.',
+ })
+ } else {
+ toast({
+ title: '오류',
+ description: 'error' in result ? result.error : '일정 정보 저장에 실패했습니다.',
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ console.error('Failed to save schedule:', error)
+ toast({
+ title: '오류',
+ description: '일정 정보 저장에 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean) => {
+ setSchedule(prev => ({ ...prev, [field]: value }))
+
+ // 사양설명회 실시 여부가 false로 변경되면 상세 정보 초기화
+ if (field === 'hasSpecificationMeeting' && value === false) {
+ setSpecMeetingInfo({
+ meetingDate: '',
+ meetingTime: '',
+ location: '',
+ address: '',
+ contactPerson: '',
+ contactPhone: '',
+ contactEmail: '',
+ agenda: '',
+ materials: '',
+ notes: '',
+ isRequired: false,
+ })
+ }
+ }
+
+ if (isLoading) {
+ return (
+ <div className="flex items-center justify-center p-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
+ <span className="ml-2">일정 정보를 불러오는 중...</span>
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Calendar className="h-5 w-5" />
+ 입찰 일정 관리
+ </CardTitle>
+ <p className="text-sm text-muted-foreground mt-1">
+ 입찰의 주요 일정들을 설정하고 관리합니다.
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* 입찰서 제출 기간 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-medium flex items-center gap-2">
+ <Clock className="h-4 w-4" />
+ 입찰서 제출 기간
+ </h3>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="submission-start">제출 시작일시</Label>
+ <Input
+ id="submission-start"
+ type="datetime-local"
+ value={schedule.submissionStartDate || ''}
+ onChange={(e) => handleScheduleChange('submissionStartDate', e.target.value)}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="submission-end">제출 마감일시</Label>
+ <Input
+ id="submission-end"
+ type="datetime-local"
+ value={schedule.submissionEndDate || ''}
+ onChange={(e) => handleScheduleChange('submissionEndDate', e.target.value)}
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 긴급 여부 */}
+ <div className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <Label className="text-base">긴급여부</Label>
+ <p className="text-sm text-muted-foreground">
+ 긴급 입찰로 표시할 경우 활성화하세요
+ </p>
+ </div>
+ <Switch
+ checked={schedule.isUrgent || false}
+ onCheckedChange={(checked) => handleScheduleChange('isUrgent', checked)}
+ />
+ </div>
+
+ {/* 사양설명회 실시 여부 */}
+ <div className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <Label className="text-base">사양설명회 실시</Label>
+ <p className="text-sm text-muted-foreground">
+ 사양설명회를 실시할 경우 상세 정보를 입력하세요
+ </p>
+ </div>
+ <Switch
+ checked={schedule.hasSpecificationMeeting || false}
+ onCheckedChange={(checked) => handleScheduleChange('hasSpecificationMeeting', checked)}
+ />
+ </div>
+
+ {/* 사양설명회 상세 정보 */}
+ {schedule.hasSpecificationMeeting && (
+ <div className="space-y-6 p-4 border rounded-lg bg-muted/50">
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <Label>회의일시 <span className="text-red-500">*</span></Label>
+ <Input
+ type="datetime-local"
+ value={specMeetingInfo.meetingDate}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingDate: e.target.value }))}
+ className={!specMeetingInfo.meetingDate ? 'border-red-200' : ''}
+ />
+ {!specMeetingInfo.meetingDate && (
+ <p className="text-sm text-red-500 mt-1">회의일시는 필수입니다</p>
+ )}
+ </div>
+ <div>
+ <Label>회의시간</Label>
+ <Input
+ placeholder="예: 14:00 ~ 16:00"
+ value={specMeetingInfo.meetingTime}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingTime: e.target.value }))}
+ />
+ </div>
+ </div>
+ <div>
+ <Label>장소 <span className="text-red-500">*</span></Label>
+ <Input
+ placeholder="회의 장소"
+ value={specMeetingInfo.location}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, location: e.target.value }))}
+ className={!specMeetingInfo.location ? 'border-red-200' : ''}
+ />
+ {!specMeetingInfo.location && (
+ <p className="text-sm text-red-500 mt-1">회의 장소는 필수입니다</p>
+ )}
+ </div>
+ <div>
+ <Label>주소</Label>
+ <Input
+ placeholder="회의 장소 주소"
+ value={specMeetingInfo.address}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, address: e.target.value }))}
+ />
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <div>
+ <Label>담당자 <span className="text-red-500">*</span></Label>
+ <Input
+ placeholder="담당자명"
+ value={specMeetingInfo.contactPerson}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPerson: e.target.value }))}
+ className={!specMeetingInfo.contactPerson ? 'border-red-200' : ''}
+ />
+ {!specMeetingInfo.contactPerson && (
+ <p className="text-sm text-red-500 mt-1">담당자는 필수입니다</p>
+ )}
+ </div>
+ <div>
+ <Label>연락처</Label>
+ <Input
+ placeholder="전화번호"
+ value={specMeetingInfo.contactPhone}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPhone: e.target.value }))}
+ />
+ </div>
+ <div>
+ <Label>이메일</Label>
+ <Input
+ type="email"
+ placeholder="이메일"
+ value={specMeetingInfo.contactEmail}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactEmail: e.target.value }))}
+ />
+ </div>
+ </div>
+ <div>
+ <Label>안건</Label>
+ <Textarea
+ placeholder="회의 안건을 입력하세요"
+ value={specMeetingInfo.agenda}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, agenda: e.target.value }))}
+ rows={3}
+ />
+ </div>
+ <div>
+ <Label>자료</Label>
+ <Textarea
+ placeholder="회의 자료 정보를 입력하세요"
+ value={specMeetingInfo.materials}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, materials: e.target.value }))}
+ rows={3}
+ />
+ </div>
+ <div>
+ <Label>비고</Label>
+ <Textarea
+ placeholder="추가 사항을 입력하세요"
+ value={specMeetingInfo.notes}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, notes: e.target.value }))}
+ rows={3}
+ />
+ </div>
+ </div>
+ )}
+
+ {/* 비고 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-medium">비고</h3>
+ <div className="space-y-2">
+ <Label htmlFor="remarks">추가 사항</Label>
+ <Textarea
+ id="remarks"
+ value={schedule.remarks || ''}
+ onChange={(e) => handleScheduleChange('remarks', e.target.value)}
+ placeholder="일정에 대한 추가 설명이나 참고사항을 입력하세요"
+ rows={4}
+ />
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 일정 요약 카드 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>일정 요약</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-2 text-sm">
+ <div className="flex justify-between">
+ <span className="font-medium">입찰서 제출 기간:</span>
+ <span>
+ {schedule.submissionStartDate && schedule.submissionEndDate
+ ? `${new Date(schedule.submissionStartDate).toLocaleString('ko-KR')} ~ ${new Date(schedule.submissionEndDate).toLocaleString('ko-KR')}`
+ : '미설정'
+ }
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span className="font-medium">긴급여부:</span>
+ <span>
+ {schedule.isUrgent ? '예' : '아니오'}
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span className="font-medium">사양설명회 실시:</span>
+ <span>
+ {schedule.hasSpecificationMeeting ? '예' : '아니오'}
+ </span>
+ </div>
+ {schedule.hasSpecificationMeeting && specMeetingInfo.meetingDate && (
+ <div className="flex justify-between">
+ <span className="font-medium">사양설명회 일시:</span>
+ <span>
+ {new Date(specMeetingInfo.meetingDate).toLocaleString('ko-KR')}
+ </span>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 액션 버튼 */}
+ <div className="flex justify-between gap-4">
+ <Button
+ variant="default"
+ onClick={() => setIsBiddingInvitationDialogOpen(true)}
+ disabled={!biddingInfo}
+ className="min-w-[120px]"
+ >
+ <Send className="w-4 h-4 mr-2" />
+ 입찰공고
+ </Button>
+ <div className="flex gap-4">
+ <Button
+ onClick={handleSave}
+ disabled={isSubmitting}
+ className="min-w-[120px]"
+ >
+ {isSubmitting ? (
+ <>
+ <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ <>
+ <Save className="w-4 h-4 mr-2" />
+ 저장
+ </>
+ )}
+ </Button>
+ </div>
+ </div>
+
+ {/* 입찰 초대 다이얼로그 */}
+ {biddingInfo && (
+ <BiddingInvitationDialog
+ open={isBiddingInvitationDialogOpen}
+ onOpenChange={setIsBiddingInvitationDialogOpen}
+ vendors={selectedVendors}
+ biddingId={biddingId}
+ biddingTitle={biddingInfo.title}
+ onSend={handleBiddingInvitationSend}
+ />
+ )}
+ </div>
+ )
+}
+
diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
new file mode 100644
index 00000000..88732deb
--- /dev/null
+++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
@@ -0,0 +1,742 @@
+"use client"
+
+import * as React from "react"
+import { useForm, useFieldArray } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { format } from "date-fns"
+import { CalendarIcon, Loader2, Trash2, PlusCircle } from "lucide-react"
+import { useSession } from "next-auth/react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { Calendar } from "@/components/ui/calendar"
+import { Badge } from "@/components/ui/badge"
+import { cn } from "@/lib/utils"
+import { toast } from "sonner"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Separator } from "@/components/ui/separator"
+import { createPreQuoteRfqAction, previewGeneralRfqCode } from "@/lib/bidding/service"
+import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single"
+import { MaterialSearchItem } from "@/lib/material/material-group-service"
+import { MaterialSelectorDialogSingle } from "@/components/common/selectors/material/material-selector-dialog-single"
+import { MaterialSearchItem as SAPMaterialSearchItem } from "@/components/common/selectors/material/material-service"
+import { ProcurementManagerSelector } from "@/components/common/selectors/procurement-manager"
+import type { ProcurementManagerWithUser } from "@/components/common/selectors/procurement-manager/procurement-manager-service"
+
+// 아이템 스키마
+const itemSchema = z.object({
+ itemCode: z.string().optional(),
+ itemName: z.string().optional(),
+ materialCode: z.string().optional(),
+ materialName: z.string().optional(),
+ quantity: z.number().min(1, "수량은 1 이상이어야 합니다"),
+ uom: z.string().min(1, "단위를 입력해주세요"),
+ remark: z.string().optional(),
+})
+
+// 사전견적용 일반견적 생성 폼 스키마
+const createPreQuoteRfqSchema = z.object({
+ rfqType: z.string().min(1, "견적 종류를 선택해주세요"),
+ rfqTitle: z.string().min(1, "견적명을 입력해주세요"),
+ dueDate: z.date({
+ required_error: "제출마감일을 선택해주세요",
+ }),
+ picUserId: z.number().optional(),
+ projectId: z.number().optional(),
+ remark: z.string().optional(),
+ items: z.array(itemSchema).min(1, "최소 하나의 자재를 추가해주세요"),
+})
+
+type CreatePreQuoteRfqFormValues = z.infer<typeof createPreQuoteRfqSchema>
+
+interface CreatePreQuoteRfqDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ biddingId: number
+ biddingItems: Array<{
+ id: number
+ materialGroupNumber?: string | null
+ materialGroupInfo?: string | null
+ materialNumber?: string | null
+ materialInfo?: string | null
+ quantity?: string | null
+ quantityUnit?: string | null
+ totalWeight?: string | null
+ weightUnit?: string | null
+ }>
+ biddingConditions?: {
+ paymentTerms?: string | null
+ taxConditions?: string | null
+ incoterms?: string | null
+ incotermsOption?: string | null
+ contractDeliveryDate?: string | null
+ shippingPort?: string | null
+ destinationPort?: string | null
+ isPriceAdjustmentApplicable?: boolean | null
+ sparePartOptions?: string | null
+ } | null
+ onSuccess?: () => void
+}
+
+export function CreatePreQuoteRfqDialog({
+ open,
+ onOpenChange,
+ biddingId,
+ biddingItems,
+ biddingConditions,
+ onSuccess
+}: CreatePreQuoteRfqDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [previewCode, setPreviewCode] = React.useState("")
+ const [isLoadingPreview, setIsLoadingPreview] = React.useState(false)
+ const [selectedManager, setSelectedManager] = React.useState<ProcurementManagerWithUser | undefined>(undefined)
+ const { data: session } = useSession()
+
+ const userId = React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : null;
+ }, [session]);
+
+ // 입찰품목을 일반견적 아이템으로 매핑
+ const initialItems = React.useMemo(() => {
+ return biddingItems.map((item) => ({
+ itemCode: item.materialGroupNumber || "",
+ itemName: item.materialGroupInfo || "",
+ materialCode: item.materialNumber || "",
+ materialName: item.materialInfo || "",
+ quantity: item.quantity ? parseFloat(item.quantity) : 1,
+ uom: item.quantityUnit || item.weightUnit || "EA",
+ remark: "",
+ }))
+ }, [biddingItems])
+
+ const form = useForm<CreatePreQuoteRfqFormValues>({
+ resolver: zodResolver(createPreQuoteRfqSchema),
+ defaultValues: {
+ rfqType: "",
+ rfqTitle: "",
+ dueDate: undefined,
+ picUserId: undefined,
+ projectId: undefined,
+ remark: "",
+ items: initialItems.length > 0 ? initialItems : [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ },
+ })
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "items",
+ })
+
+ // 다이얼로그가 열릴 때 폼 초기화
+ React.useEffect(() => {
+ if (open) {
+ form.reset({
+ rfqType: "",
+ rfqTitle: "",
+ dueDate: undefined,
+ picUserId: undefined,
+ projectId: undefined,
+ remark: "",
+ items: initialItems.length > 0 ? initialItems : [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ })
+ setSelectedManager(undefined)
+ setPreviewCode("")
+ }
+ }, [open, initialItems, form])
+
+ // 견적담당자 선택 시 RFQ 코드 미리보기 생성
+ React.useEffect(() => {
+ if (!selectedManager?.user?.id) {
+ setPreviewCode("")
+ return
+ }
+
+ // 즉시 실행 함수 패턴 사용
+ (async () => {
+ setIsLoadingPreview(true)
+ try {
+ const code = await previewGeneralRfqCode(selectedManager.user!.id!)
+ setPreviewCode(code)
+ } catch (error) {
+ console.error("코드 미리보기 오류:", error)
+ setPreviewCode("")
+ } finally {
+ setIsLoadingPreview(false)
+ }
+ })()
+ }, [selectedManager])
+
+ // 견적 종류 변경
+ const handleRfqTypeChange = (value: string) => {
+ form.setValue("rfqType", value)
+ }
+
+ const handleCancel = () => {
+ form.reset({
+ rfqType: "",
+ rfqTitle: "",
+ dueDate: undefined,
+ picUserId: undefined,
+ projectId: undefined,
+ remark: "",
+ items: initialItems.length > 0 ? initialItems : [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ })
+ setSelectedManager(undefined)
+ setPreviewCode("")
+ onOpenChange(false)
+ }
+
+ const onSubmit = async (data: CreatePreQuoteRfqFormValues) => {
+ if (!userId) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ if (!selectedManager?.user?.id) {
+ toast.error("견적담당자를 선택해주세요")
+ return
+ }
+
+ const picUserId = selectedManager.user.id
+
+ setIsLoading(true)
+
+ try {
+ // 서버 액션 호출 (입찰 조건 포함)
+ const result = await createPreQuoteRfqAction({
+ biddingId,
+ rfqType: data.rfqType,
+ rfqTitle: data.rfqTitle,
+ dueDate: data.dueDate,
+ picUserId,
+ projectId: data.projectId,
+ remark: data.remark || "",
+ items: data.items as Array<{
+ itemCode: string;
+ itemName: string;
+ materialCode?: string;
+ materialName?: string;
+ quantity: number;
+ uom: string;
+ remark?: string;
+ }>,
+ biddingConditions: biddingConditions || undefined,
+ createdBy: userId,
+ updatedBy: userId,
+ })
+
+ if (result.success) {
+ toast.success(result.message, {
+ description: result.data?.rfqCode ? `RFQ 코드: ${result.data.rfqCode}` : undefined,
+ })
+
+ // 다이얼로그 닫기
+ onOpenChange(false)
+
+ // 성공 콜백 실행
+ if (onSuccess) {
+ onSuccess()
+ }
+ } else {
+ toast.error(result.error || "사전견적용 일반견적 생성에 실패했습니다")
+ }
+
+ } catch (error) {
+ console.error('사전견적용 일반견적 생성 오류:', error)
+ toast.error("사전견적용 일반견적 생성에 실패했습니다", {
+ description: "알 수 없는 오류가 발생했습니다",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 아이템 추가
+ const handleAddItem = () => {
+ append({
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ })
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl h-[90vh] flex flex-col">
+ {/* 고정된 헤더 */}
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>사전견적용 일반견적 생성</DialogTitle>
+ <DialogDescription>
+ 입찰의 사전견적을 위한 일반견적을 생성합니다. 입찰품목이 자재정보로 매핑되어 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 스크롤 가능한 컨텐츠 영역 */}
+ <ScrollArea className="flex-1 px-1">
+ <Form {...form}>
+ <form id="createPreQuoteRfqForm" onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-2">
+
+ {/* 기본 정보 섹션 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">기본 정보</h3>
+
+ <div className="grid grid-cols-2 gap-4">
+ {/* 견적 종류 */}
+ <div className="space-y-2">
+ <FormField
+ control={form.control}
+ name="rfqType"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 견적 종류 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={handleRfqTypeChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="견적 종류 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="단가계약">단가계약</SelectItem>
+ <SelectItem value="매각계약">매각계약</SelectItem>
+ <SelectItem value="일반계약">일반계약</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 제출마감일 */}
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 제출마감일 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "yyyy-MM-dd")
+ ) : (
+ <span>제출마감일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 견적명 */}
+ <FormField
+ control={form.control}
+ name="rfqTitle"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 견적명 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 입찰 사전견적용 일반견적"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 견적의 목적이나 내용을 간단명료하게 입력해주세요
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트 선택 */}
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>프로젝트</FormLabel>
+ <FormControl>
+ {/* ProjectSelector는 별도 컴포넌트 필요 */}
+ <Input
+ placeholder="프로젝트 ID (선택사항)"
+ type="number"
+ {...field}
+ onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 담당자 정보 */}
+ <FormField
+ control={form.control}
+ name="picUserId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 견적담당자 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <ProcurementManagerSelector
+ selectedManager={selectedManager}
+ onManagerSelect={(manager) => {
+ setSelectedManager(manager)
+ field.onChange(manager.user?.id)
+ }}
+ placeholder="견적담당자를 선택하세요"
+ />
+ </FormControl>
+ <FormDescription>
+ 사전견적용 일반견적의 담당자를 선택합니다
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* RFQ 코드 미리보기 */}
+ {previewCode && (
+ <div className="flex items-center gap-2">
+ <Badge variant="secondary" className="font-mono text-sm">
+ 예상 RFQ 코드: {previewCode}
+ </Badge>
+ {isLoadingPreview && (
+ <Loader2 className="h-3 w-3 animate-spin" />
+ )}
+ </div>
+ )}
+
+ {/* 비고 */}
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="추가 비고사항을 입력하세요"
+ className="resize-none"
+ rows={3}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <Separator />
+
+ {/* 아이템 정보 섹션 */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-lg font-semibold">자재 정보</h3>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={handleAddItem}
+ >
+ <PlusCircle className="mr-2 h-4 w-4" />
+ 자재 추가
+ </Button>
+ </div>
+
+ <div className="space-y-3">
+ {fields.map((field, index) => (
+ <div key={field.id} className="border rounded-lg p-3 bg-gray-50/50">
+ <div className="flex items-center justify-between mb-3">
+ <span className="text-sm font-medium text-gray-700">
+ 자재 #{index + 1}
+ </span>
+ {fields.length > 1 && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => remove(index)}
+ className="h-6 w-6 p-0 text-destructive hover:text-destructive"
+ >
+ <Trash2 className="h-3 w-3" />
+ </Button>
+ )}
+ </div>
+
+ {/* 자재그룹 선택 */}
+ <div className="mb-3">
+ <FormLabel className="text-xs">
+ 자재그룹(자재그룹명) <span className="text-red-500">*</span>
+ </FormLabel>
+ <div className="mt-1">
+ <MaterialGroupSelectorDialogSingle
+ triggerLabel="자재그룹 선택"
+ selectedMaterial={(() => {
+ const itemCode = form.watch(`items.${index}.itemCode`);
+ const itemName = form.watch(`items.${index}.itemName`);
+ if (itemCode && itemName) {
+ return {
+ materialGroupCode: itemCode,
+ materialGroupDescription: itemName,
+ displayText: `${itemCode} - ${itemName}`
+ } as MaterialSearchItem;
+ }
+ return null;
+ })()}
+ onMaterialSelect={(material) => {
+ form.setValue(`items.${index}.itemCode`, material?.materialGroupCode || '');
+ form.setValue(`items.${index}.itemName`, material?.materialGroupDescription || '');
+ }}
+ placeholder="자재그룹을 검색하세요..."
+ title="자재그룹 선택"
+ description="원하는 자재그룹을 검색하고 선택해주세요."
+ triggerVariant="outline"
+ />
+ </div>
+ </div>
+
+ {/* 자재코드 선택 */}
+ <div className="mb-3">
+ <FormLabel className="text-xs">
+ 자재코드(자재명)
+ </FormLabel>
+ <div className="mt-1">
+ <MaterialSelectorDialogSingle
+ triggerLabel="자재코드 선택"
+ selectedMaterial={(() => {
+ const materialCode = form.watch(`items.${index}.materialCode`);
+ const materialName = form.watch(`items.${index}.materialName`);
+ if (materialCode && materialName) {
+ return {
+ materialCode: materialCode,
+ materialName: materialName,
+ displayText: `${materialCode} - ${materialName}`
+ } as SAPMaterialSearchItem;
+ }
+ return null;
+ })()}
+ onMaterialSelect={(material) => {
+ form.setValue(`items.${index}.materialCode`, material?.materialCode || '');
+ form.setValue(`items.${index}.materialName`, material?.materialName || '');
+ }}
+ placeholder="자재코드를 검색하세요..."
+ title="자재코드 선택"
+ description="원하는 자재코드를 검색하고 선택해주세요."
+ triggerVariant="outline"
+ />
+ </div>
+ </div>
+
+ <div className="grid grid-cols-2 gap-3">
+ {/* 수량 */}
+ <FormField
+ control={form.control}
+ name={`items.${index}.quantity`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">
+ 수량 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ min="1"
+ placeholder="1"
+ className="h-8 text-sm"
+ {...field}
+ onChange={(e) => field.onChange(Number(e.target.value))}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 단위 */}
+ <FormField
+ control={form.control}
+ name={`items.${index}.uom`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">
+ 단위 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger className="h-8 text-sm">
+ <SelectValue placeholder="단위 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="EA">EA (Each)</SelectItem>
+ <SelectItem value="KG">KG (Kilogram)</SelectItem>
+ <SelectItem value="M">M (Meter)</SelectItem>
+ <SelectItem value="L">L (Liter)</SelectItem>
+ <SelectItem value="PC">PC (Piece)</SelectItem>
+ <SelectItem value="BOX">BOX (Box)</SelectItem>
+ <SelectItem value="SET">SET (Set)</SelectItem>
+ <SelectItem value="LOT">LOT (Lot)</SelectItem>
+ <SelectItem value="PCS">PCS (Pieces)</SelectItem>
+ <SelectItem value="TON">TON (Ton)</SelectItem>
+ <SelectItem value="G">G (Gram)</SelectItem>
+ <SelectItem value="ML">ML (Milliliter)</SelectItem>
+ <SelectItem value="CM">CM (Centimeter)</SelectItem>
+ <SelectItem value="MM">MM (Millimeter)</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 비고 */}
+ <div className="mt-3">
+ <FormField
+ control={form.control}
+ name={`items.${index}.remark`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">비고</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="자재별 비고사항"
+ className="h-8 text-sm"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ </form>
+ </Form>
+ </ScrollArea>
+
+ {/* 고정된 푸터 */}
+ <DialogFooter className="flex-shrink-0">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleCancel}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ form="createPreQuoteRfqForm"
+ onClick={form.handleSubmit(onSubmit)}
+ disabled={isLoading}
+ >
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {isLoading ? "생성 중..." : "사전견적용 일반견적 생성"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/components/bidding/price-adjustment-dialog.tsx b/components/bidding/price-adjustment-dialog.tsx
index 982d8b90..149b8e9a 100644
--- a/components/bidding/price-adjustment-dialog.tsx
+++ b/components/bidding/price-adjustment-dialog.tsx
@@ -127,11 +127,11 @@ export function PriceAdjustmentDialog({
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-gray-500">기준시점</label>
- <p className="text-sm font-medium">{formatDate(data.referenceDate, "kr")}</p>
+ <p className="text-sm font-medium">{data.referenceDate ? formatDate(data.referenceDate, "kr") : '-'}</p>
</div>
<div>
<label className="text-xs text-gray-500">비교시점</label>
- <p className="text-sm font-medium">{formatDate(data.comparisonDate, "kr")}</p>
+ <p className="text-sm font-medium">{data.comparisonDate ? formatDate(data.comparisonDate, "kr") : '-'}</p>
</div>
</div>
<div>
@@ -162,7 +162,7 @@ export function PriceAdjustmentDialog({
</div>
<div>
<label className="text-xs text-gray-500">조정일</label>
- <p className="text-sm font-medium">{formatDate(data.adjustmentDate, "kr")}</p>
+ <p className="text-sm font-medium">{data.adjustmentDate ? formatDate(data.adjustmentDate, "kr") : '-'}</p>
</div>
</div>
<div>
diff --git a/components/common/selectors/cost-center/cost-center-selector.tsx b/components/common/selectors/cost-center/cost-center-selector.tsx
new file mode 100644
index 00000000..32c37973
--- /dev/null
+++ b/components/common/selectors/cost-center/cost-center-selector.tsx
@@ -0,0 +1,335 @@
+'use client'
+
+/**
+ * Cost Center 선택기
+ *
+ * @description
+ * - 오라클에서 Cost Center들을 조회
+ * - KOSTL: Cost Center 코드
+ * - KTEXT: 단축명
+ * - LTEXT: 설명
+ * - DATAB: 시작일
+ * - DATBI: 종료일
+ */
+
+import { useState, useCallback, useMemo, useTransition } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Search, Check } from 'lucide-react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ RowSelectionState,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ getCostCenters,
+ CostCenter
+} from './cost-center-service'
+import { toast } from 'sonner'
+
+export interface CostCenterSelectorProps {
+ selectedCode?: CostCenter
+ onCodeSelect: (code: CostCenter) => void
+ disabled?: boolean
+ placeholder?: string
+ className?: string
+}
+
+export interface CostCenterItem {
+ kostl: string // Cost Center
+ ktext: string // 단축명
+ ltext: string // 설명
+ datab: string // 시작일
+ datbi: string // 종료일
+ displayText: string // 표시용 텍스트
+}
+
+export function CostCenterSelector({
+ selectedCode,
+ onCodeSelect,
+ disabled,
+ placeholder = "코스트센터를 선택하세요",
+ className
+}: CostCenterSelectorProps) {
+ const [open, setOpen] = useState(false)
+ const [codes, setCodes] = useState<CostCenter[]>([])
+ const [sorting, setSorting] = useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
+ const [globalFilter, setGlobalFilter] = useState('')
+ const [isPending, startTransition] = useTransition()
+
+ // 날짜 포맷 함수 (YYYYMMDD -> YYYY-MM-DD)
+ const formatDate = (dateStr: string) => {
+ if (!dateStr || dateStr.length !== 8) return dateStr
+ return `${dateStr.substring(0, 4)}-${dateStr.substring(4, 6)}-${dateStr.substring(6, 8)}`
+ }
+
+ // Cost Center 선택 핸들러
+ const handleCodeSelect = useCallback(async (code: CostCenter) => {
+ onCodeSelect(code)
+ setOpen(false)
+ }, [onCodeSelect])
+
+ // 테이블 컬럼 정의
+ const columns: ColumnDef<CostCenter>[] = useMemo(() => [
+ {
+ accessorKey: 'KOSTL',
+ header: '코스트센터',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('KOSTL')}</div>
+ ),
+ },
+ {
+ accessorKey: 'KTEXT',
+ header: '단축명',
+ cell: ({ row }) => (
+ <div>{row.getValue('KTEXT')}</div>
+ ),
+ },
+ {
+ accessorKey: 'LTEXT',
+ header: '설명',
+ cell: ({ row }) => (
+ <div>{row.getValue('LTEXT')}</div>
+ ),
+ },
+ {
+ accessorKey: 'DATAB',
+ header: '시작일',
+ cell: ({ row }) => (
+ <div className="text-sm">{formatDate(row.getValue('DATAB'))}</div>
+ ),
+ },
+ {
+ accessorKey: 'DATBI',
+ header: '종료일',
+ cell: ({ row }) => (
+ <div className="text-sm">{formatDate(row.getValue('DATBI'))}</div>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleCodeSelect(row.original)
+ }}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ ),
+ },
+ ], [handleCodeSelect])
+
+ // Cost Center 테이블 설정
+ const table = useReactTable({
+ data: codes,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ })
+
+ // 서버에서 Cost Center 전체 목록 로드 (한 번만)
+ const loadCodes = useCallback(async () => {
+ startTransition(async () => {
+ try {
+ const result = await getCostCenters()
+
+ if (result.success) {
+ setCodes(result.data)
+
+ // 폴백 데이터를 사용하는 경우 알림
+ if (result.isUsingFallback) {
+ toast.info('Oracle 연결 실패', {
+ description: '테스트 데이터를 사용합니다.',
+ duration: 4000,
+ })
+ }
+ } else {
+ toast.error(result.error || '코스트센터를 불러오는데 실패했습니다.')
+ setCodes([])
+ }
+ } catch (error) {
+ console.error('코스트센터 목록 로드 실패:', error)
+ toast.error('코스트센터를 불러오는 중 오류가 발생했습니다.')
+ setCodes([])
+ }
+ })
+ }, [])
+
+ // 다이얼로그 열기 핸들러
+ const handleDialogOpenChange = useCallback((newOpen: boolean) => {
+ setOpen(newOpen)
+ if (newOpen && codes.length === 0) {
+ loadCodes()
+ }
+ }, [loadCodes, codes.length])
+
+ // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
+ const handleSearchChange = useCallback((value: string) => {
+ setGlobalFilter(value)
+ }, [])
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button
+ variant="outline"
+ disabled={disabled}
+ className={`w-full justify-start ${className || ''}`}
+ >
+ {selectedCode ? (
+ <div className="flex items-center gap-2 w-full">
+ <span className="font-mono text-sm">[{selectedCode.KOSTL}]</span>
+ <span className="truncate flex-1 text-left">{selectedCode.KTEXT}</span>
+ </div>
+ ) : (
+ <span className="text-muted-foreground">{placeholder}</span>
+ )}
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-5xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>코스트센터 선택</DialogTitle>
+ <div className="text-sm text-muted-foreground">
+ 코스트센터 조회
+ </div>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4" />
+ <Input
+ placeholder="코스트센터 코드, 단축명, 설명으로 검색..."
+ value={globalFilter}
+ onChange={(e) => handleSearchChange(e.target.value)}
+ className="flex-1"
+ />
+ </div>
+
+ {isPending ? (
+ <div className="flex justify-center py-8">
+ <div className="text-sm text-muted-foreground">코스트센터를 불러오는 중...</div>
+ </div>
+ ) : (
+ <div className="border rounded-md">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ className="cursor-pointer hover:bg-muted/50"
+ onClick={() => handleCodeSelect(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 검색 결과가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {table.getFilteredRowModel().rows.length}개 코스트센터
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/components/common/selectors/cost-center/cost-center-service.ts b/components/common/selectors/cost-center/cost-center-service.ts
new file mode 100644
index 00000000..844215f0
--- /dev/null
+++ b/components/common/selectors/cost-center/cost-center-service.ts
@@ -0,0 +1,89 @@
+"use server"
+
+import { oracleKnex } from '@/lib/oracle-db/db'
+
+// Cost Center 타입 정의
+export interface CostCenter {
+ KOSTL: string // Cost Center 코드
+ DATAB: string // 시작일
+ DATBI: string // 종료일
+ KTEXT: string // 단축 텍스트
+ LTEXT: string // 긴 텍스트
+}
+
+// 테스트 환경용 폴백 데이터
+const FALLBACK_TEST_DATA: CostCenter[] = [
+ { KOSTL: 'D6023930', DATAB: '20230101', DATBI: '99991231', KTEXT: '구매팀', LTEXT: '구매팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' },
+ { KOSTL: 'D6023931', DATAB: '20230101', DATBI: '99991231', KTEXT: '자재팀', LTEXT: '자재팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' },
+ { KOSTL: 'D6023932', DATAB: '20230101', DATBI: '99991231', KTEXT: '조달팀', LTEXT: '조달팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' },
+ { KOSTL: 'D6023933', DATAB: '20230101', DATBI: '99991231', KTEXT: '구매1팀', LTEXT: '구매1팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' },
+ { KOSTL: 'D6023934', DATAB: '20230101', DATBI: '99991231', KTEXT: '구매2팀', LTEXT: '구매2팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' },
+]
+
+/**
+ * Cost Center 목록 조회 (Oracle에서 전체 조회, 실패 시 폴백 데이터 사용)
+ * CMCTB_COSTCENTER 테이블에서 조회
+ * 현재 유효한(SYSDATE BETWEEN DATAB AND DATBI) Cost Center만 조회
+ */
+export async function getCostCenters(): Promise<{
+ success: boolean
+ data: CostCenter[]
+ error?: string
+ isUsingFallback?: boolean
+}> {
+ try {
+ console.log('📋 [getCostCenters] Oracle 쿼리 시작...')
+
+ const result = await oracleKnex.raw(`
+ SELECT
+ KOSTL,
+ DATAB,
+ DATBI,
+ KTEXT,
+ LTEXT
+ FROM CMCTB_COSTCENTER
+ WHERE ROWNUM < 100
+ AND NVL(BKZKP,' ') = ' '
+ AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN DATAB AND DATBI
+ AND KOKRS = 'H100'
+ ORDER BY KOSTL
+ `)
+
+ // Oracle raw query의 결과는 rows 배열에 들어있음
+ const rows = (result.rows || result) as Array<Record<string, unknown>>
+
+ console.log(`✅ [getCostCenters] Oracle 쿼리 성공 - ${rows.length}건 조회`)
+
+ // null 값 필터링
+ const cleanedResult = rows
+ .filter((item) =>
+ item.KOSTL &&
+ item.DATAB &&
+ item.DATBI
+ )
+ .map((item) => ({
+ KOSTL: String(item.KOSTL),
+ DATAB: String(item.DATAB),
+ DATBI: String(item.DATBI),
+ KTEXT: String(item.KTEXT || ''),
+ LTEXT: String(item.LTEXT || '')
+ }))
+
+ console.log(`✅ [getCostCenters] 필터링 후 ${cleanedResult.length}건`)
+
+ return {
+ success: true,
+ data: cleanedResult,
+ isUsingFallback: false
+ }
+ } catch (error) {
+ console.error('❌ [getCostCenters] Oracle 오류:', error)
+ console.log('🔄 [getCostCenters] 폴백 테스트 데이터 사용 (' + FALLBACK_TEST_DATA.length + '건)')
+ return {
+ success: true,
+ data: FALLBACK_TEST_DATA,
+ isUsingFallback: true
+ }
+ }
+}
+
diff --git a/components/common/selectors/cost-center/cost-center-single-selector.tsx b/components/common/selectors/cost-center/cost-center-single-selector.tsx
new file mode 100644
index 00000000..94d9a730
--- /dev/null
+++ b/components/common/selectors/cost-center/cost-center-single-selector.tsx
@@ -0,0 +1,378 @@
+'use client'
+
+/**
+ * Cost Center 단일 선택 다이얼로그
+ *
+ * @description
+ * - Cost Center를 하나만 선택할 수 있는 다이얼로그
+ * - 트리거 버튼과 다이얼로그가 분리된 구조
+ * - 외부에서 open 상태를 제어 가능
+ */
+
+import { useState, useCallback, useMemo, useTransition, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Search, Check, X } from 'lucide-react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ RowSelectionState,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ getCostCenters,
+ CostCenter
+} from './cost-center-service'
+import { toast } from 'sonner'
+
+export interface CostCenterSingleSelectorProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedCode?: CostCenter
+ onCodeSelect: (code: CostCenter) => void
+ onConfirm?: (code: CostCenter | undefined) => void
+ onCancel?: () => void
+ title?: string
+ description?: string
+ showConfirmButtons?: boolean
+}
+
+export function CostCenterSingleSelector({
+ open,
+ onOpenChange,
+ selectedCode,
+ onCodeSelect,
+ onConfirm,
+ onCancel,
+ title = "코스트센터 선택",
+ description = "코스트센터를 선택하세요",
+ showConfirmButtons = false
+}: CostCenterSingleSelectorProps) {
+ const [codes, setCodes] = useState<CostCenter[]>([])
+ const [sorting, setSorting] = useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
+ const [globalFilter, setGlobalFilter] = useState('')
+ const [isPending, startTransition] = useTransition()
+ const [tempSelectedCode, setTempSelectedCode] = useState<CostCenter | undefined>(selectedCode)
+
+ // 날짜 포맷 함수 (YYYYMMDD -> YYYY-MM-DD)
+ const formatDate = (dateStr: string) => {
+ if (!dateStr || dateStr.length !== 8) return dateStr
+ return `${dateStr.substring(0, 4)}-${dateStr.substring(4, 6)}-${dateStr.substring(6, 8)}`
+ }
+
+ // Cost Center 선택 핸들러
+ const handleCodeSelect = useCallback((code: CostCenter) => {
+ if (showConfirmButtons) {
+ setTempSelectedCode(code)
+ } else {
+ onCodeSelect(code)
+ onOpenChange(false)
+ }
+ }, [onCodeSelect, onOpenChange, showConfirmButtons])
+
+ // 확인 버튼 핸들러
+ const handleConfirm = useCallback(() => {
+ if (tempSelectedCode) {
+ onCodeSelect(tempSelectedCode)
+ }
+ onConfirm?.(tempSelectedCode)
+ onOpenChange(false)
+ }, [tempSelectedCode, onCodeSelect, onConfirm, onOpenChange])
+
+ // 취소 버튼 핸들러
+ const handleCancel = useCallback(() => {
+ setTempSelectedCode(selectedCode)
+ onCancel?.()
+ onOpenChange(false)
+ }, [selectedCode, onCancel, onOpenChange])
+
+ // 테이블 컬럼 정의
+ const columns: ColumnDef<CostCenter>[] = useMemo(() => [
+ {
+ accessorKey: 'KOSTL',
+ header: '코스트센터',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('KOSTL')}</div>
+ ),
+ },
+ {
+ accessorKey: 'KTEXT',
+ header: '단축명',
+ cell: ({ row }) => (
+ <div>{row.getValue('KTEXT')}</div>
+ ),
+ },
+ {
+ accessorKey: 'LTEXT',
+ header: '설명',
+ cell: ({ row }) => (
+ <div>{row.getValue('LTEXT')}</div>
+ ),
+ },
+ {
+ accessorKey: 'DATAB',
+ header: '시작일',
+ cell: ({ row }) => (
+ <div className="text-sm">{formatDate(row.getValue('DATAB'))}</div>
+ ),
+ },
+ {
+ accessorKey: 'DATBI',
+ header: '종료일',
+ cell: ({ row }) => (
+ <div className="text-sm">{formatDate(row.getValue('DATBI'))}</div>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => {
+ const isSelected = showConfirmButtons
+ ? tempSelectedCode?.KOSTL === row.original.KOSTL
+ : selectedCode?.KOSTL === row.original.KOSTL
+
+ return (
+ <Button
+ variant={isSelected ? "default" : "ghost"}
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleCodeSelect(row.original)
+ }}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ )
+ },
+ },
+ ], [handleCodeSelect, selectedCode, tempSelectedCode, showConfirmButtons])
+
+ // Cost Center 테이블 설정
+ const table = useReactTable({
+ data: codes,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ })
+
+ // 서버에서 Cost Center 전체 목록 로드 (한 번만)
+ const loadCodes = useCallback(async () => {
+ startTransition(async () => {
+ try {
+ const result = await getCostCenters()
+
+ if (result.success) {
+ setCodes(result.data)
+
+ // 폴백 데이터를 사용하는 경우 알림
+ if (result.isUsingFallback) {
+ toast.info('Oracle 연결 실패', {
+ description: '테스트 데이터를 사용합니다.',
+ duration: 4000,
+ })
+ }
+ } else {
+ toast.error(result.error || '코스트센터를 불러오는데 실패했습니다.')
+ setCodes([])
+ }
+ } catch (error) {
+ console.error('코스트센터 목록 로드 실패:', error)
+ toast.error('코스트센터를 불러오는 중 오류가 발생했습니다.')
+ setCodes([])
+ }
+ })
+ }, [])
+
+ // 다이얼로그 열릴 때 코드 로드 (open prop 변화 감지)
+ useEffect(() => {
+ if (open) {
+ setTempSelectedCode(selectedCode)
+ if (codes.length === 0) {
+ console.log('🚀 [CostCenterSingleSelector] 다이얼로그 열림 - loadCodes 호출')
+ loadCodes()
+ } else {
+ console.log('📦 [CostCenterSingleSelector] 다이얼로그 열림 - 기존 데이터 사용 (' + codes.length + '건)')
+ }
+ }
+ }, [open, selectedCode, loadCodes, codes.length])
+
+ // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
+ const handleSearchChange = useCallback((value: string) => {
+ setGlobalFilter(value)
+ }, [])
+
+ const currentSelectedCode = showConfirmButtons ? tempSelectedCode : selectedCode
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-5xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ <div className="text-sm text-muted-foreground">
+ {description}
+ </div>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 현재 선택된 코스트센터 표시 */}
+ {currentSelectedCode && (
+ <div className="p-3 bg-muted rounded-md">
+ <div className="text-sm font-medium">선택된 코스트센터:</div>
+ <div className="flex items-center gap-2 mt-1">
+ <span className="font-mono text-sm">[{currentSelectedCode.KOSTL}]</span>
+ <span>{currentSelectedCode.KTEXT}</span>
+ <span className="text-muted-foreground">- {currentSelectedCode.LTEXT}</span>
+ </div>
+ </div>
+ )}
+
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4" />
+ <Input
+ placeholder="코스트센터 코드, 단축명, 설명으로 검색..."
+ value={globalFilter}
+ onChange={(e) => handleSearchChange(e.target.value)}
+ className="flex-1"
+ />
+ </div>
+
+ {isPending ? (
+ <div className="flex justify-center py-8">
+ <div className="text-sm text-muted-foreground">코스트센터를 불러오는 중...</div>
+ </div>
+ ) : (
+ <div className="border rounded-md">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => {
+ const isRowSelected = currentSelectedCode?.KOSTL === row.original.KOSTL
+ return (
+ <TableRow
+ key={row.id}
+ data-state={isRowSelected && "selected"}
+ className={`cursor-pointer hover:bg-muted/50 ${
+ isRowSelected ? 'bg-muted' : ''
+ }`}
+ onClick={() => handleCodeSelect(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ )
+ })
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 검색 결과가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {table.getFilteredRowModel().rows.length}개 코스트센터
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ {showConfirmButtons && (
+ <DialogFooter>
+ <Button variant="outline" onClick={handleCancel}>
+ <X className="h-4 w-4 mr-2" />
+ 취소
+ </Button>
+ <Button onClick={handleConfirm} disabled={!tempSelectedCode}>
+ <Check className="h-4 w-4 mr-2" />
+ 확인
+ </Button>
+ </DialogFooter>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/components/common/selectors/cost-center/index.ts b/components/common/selectors/cost-center/index.ts
new file mode 100644
index 00000000..891e2e6c
--- /dev/null
+++ b/components/common/selectors/cost-center/index.ts
@@ -0,0 +1,12 @@
+// Cost Center 선택기 관련 컴포넌트와 타입 내보내기
+
+export { CostCenterSelector, CostCenterSingleSelector } from './cost-center-selector'
+export type { CostCenterSelectorProps, CostCenterSingleSelectorProps, CostCenterItem } from './cost-center-selector'
+
+export {
+ getCostCenters
+} from './cost-center-service'
+export type {
+ CostCenter
+} from './cost-center-service'
+
diff --git a/components/common/selectors/gl-account/gl-account-selector.tsx b/components/common/selectors/gl-account/gl-account-selector.tsx
new file mode 100644
index 00000000..81a33944
--- /dev/null
+++ b/components/common/selectors/gl-account/gl-account-selector.tsx
@@ -0,0 +1,311 @@
+'use client'
+
+/**
+ * GL 계정 선택기
+ *
+ * @description
+ * - 오라클에서 GL 계정들을 조회
+ * - SAKNR: 계정(G/L)
+ * - FIPEX: 세부계정
+ * - TEXT1: 계정명
+ */
+
+import { useState, useCallback, useMemo, useTransition } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Search, Check } from 'lucide-react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ RowSelectionState,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ getGlAccounts,
+ GlAccount
+} from './gl-account-service'
+import { toast } from 'sonner'
+
+export interface GlAccountSelectorProps {
+ selectedCode?: GlAccount
+ onCodeSelect: (code: GlAccount) => void
+ disabled?: boolean
+ placeholder?: string
+ className?: string
+}
+
+export interface GlAccountItem {
+ saknr: string // 계정(G/L)
+ fipex: string // 세부계정
+ text1: string // 계정명
+ displayText: string // 표시용 텍스트
+}
+
+export function GlAccountSelector({
+ selectedCode,
+ onCodeSelect,
+ disabled,
+ placeholder = "GL 계정을 선택하세요",
+ className
+}: GlAccountSelectorProps) {
+ const [open, setOpen] = useState(false)
+ const [codes, setCodes] = useState<GlAccount[]>([])
+ const [sorting, setSorting] = useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
+ const [globalFilter, setGlobalFilter] = useState('')
+ const [isPending, startTransition] = useTransition()
+
+ // GL 계정 선택 핸들러
+ const handleCodeSelect = useCallback(async (code: GlAccount) => {
+ onCodeSelect(code)
+ setOpen(false)
+ }, [onCodeSelect])
+
+ // 테이블 컬럼 정의
+ const columns: ColumnDef<GlAccount>[] = useMemo(() => [
+ {
+ accessorKey: 'SAKNR',
+ header: '계정(G/L)',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('SAKNR')}</div>
+ ),
+ },
+ {
+ accessorKey: 'FIPEX',
+ header: '세부계정',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('FIPEX')}</div>
+ ),
+ },
+ {
+ accessorKey: 'TEXT1',
+ header: '계정명',
+ cell: ({ row }) => (
+ <div>{row.getValue('TEXT1')}</div>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleCodeSelect(row.original)
+ }}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ ),
+ },
+ ], [handleCodeSelect])
+
+ // GL 계정 테이블 설정
+ const table = useReactTable({
+ data: codes,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ })
+
+ // 서버에서 GL 계정 전체 목록 로드 (한 번만)
+ const loadCodes = useCallback(async () => {
+ startTransition(async () => {
+ try {
+ const result = await getGlAccounts()
+
+ if (result.success) {
+ setCodes(result.data)
+
+ // 폴백 데이터를 사용하는 경우 알림
+ if (result.isUsingFallback) {
+ toast.info('Oracle 연결 실패', {
+ description: '테스트 데이터를 사용합니다.',
+ duration: 4000,
+ })
+ }
+ } else {
+ toast.error(result.error || 'GL 계정을 불러오는데 실패했습니다.')
+ setCodes([])
+ }
+ } catch (error) {
+ console.error('GL 계정 목록 로드 실패:', error)
+ toast.error('GL 계정을 불러오는 중 오류가 발생했습니다.')
+ setCodes([])
+ }
+ })
+ }, [])
+
+ // 다이얼로그 열기 핸들러
+ const handleDialogOpenChange = useCallback((newOpen: boolean) => {
+ setOpen(newOpen)
+ if (newOpen && codes.length === 0) {
+ loadCodes()
+ }
+ }, [loadCodes, codes.length])
+
+ // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
+ const handleSearchChange = useCallback((value: string) => {
+ setGlobalFilter(value)
+ }, [])
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button
+ variant="outline"
+ disabled={disabled}
+ className={`w-full justify-start ${className || ''}`}
+ >
+ {selectedCode ? (
+ <div className="flex items-center gap-2 w-full">
+ <span className="font-mono text-sm">[{selectedCode.SAKNR}]</span>
+ <span className="font-mono text-sm">{selectedCode.FIPEX}</span>
+ <span className="truncate flex-1 text-left">{selectedCode.TEXT1}</span>
+ </div>
+ ) : (
+ <span className="text-muted-foreground">{placeholder}</span>
+ )}
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-5xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>GL 계정 선택</DialogTitle>
+ <div className="text-sm text-muted-foreground">
+ GL 계정 조회
+ </div>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4" />
+ <Input
+ placeholder="계정, 세부계정, 계정명으로 검색..."
+ value={globalFilter}
+ onChange={(e) => handleSearchChange(e.target.value)}
+ className="flex-1"
+ />
+ </div>
+
+ {isPending ? (
+ <div className="flex justify-center py-8">
+ <div className="text-sm text-muted-foreground">GL 계정을 불러오는 중...</div>
+ </div>
+ ) : (
+ <div className="border rounded-md">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ className="cursor-pointer hover:bg-muted/50"
+ onClick={() => handleCodeSelect(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 검색 결과가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {table.getFilteredRowModel().rows.length}개 GL 계정
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/components/common/selectors/gl-account/gl-account-service.ts b/components/common/selectors/gl-account/gl-account-service.ts
new file mode 100644
index 00000000..75c82c95
--- /dev/null
+++ b/components/common/selectors/gl-account/gl-account-service.ts
@@ -0,0 +1,79 @@
+"use server"
+
+import { oracleKnex } from '@/lib/oracle-db/db'
+
+// GL 계정 타입 정의
+export interface GlAccount {
+ SAKNR: string // 계정 (G/L)
+ FIPEX: string // 세부계정
+ TEXT1: string // 계정명
+}
+
+// 테스트 환경용 폴백 데이터
+const FALLBACK_TEST_DATA: GlAccount[] = [
+ { SAKNR: '53351977', FIPEX: 'FIP001', TEXT1: '원재료 구매(테스트데이터 - 오라클 페칭 실패시)' },
+ { SAKNR: '53351978', FIPEX: 'FIP002', TEXT1: '소모품 구매(테스트데이터 - 오라클 페칭 실패시)' },
+ { SAKNR: '53351979', FIPEX: 'FIP003', TEXT1: '부품 구매(테스트데이터 - 오라클 페칭 실패시)' },
+ { SAKNR: '53351980', FIPEX: 'FIP004', TEXT1: '자재 구매(테스트데이터 - 오라클 페칭 실패시)' },
+ { SAKNR: '53351981', FIPEX: 'FIP005', TEXT1: '외주 가공비(테스트데이터 - 오라클 페칭 실패시)' },
+]
+
+/**
+ * GL 계정 목록 조회 (Oracle에서 전체 조회, 실패 시 폴백 데이터 사용)
+ * CMCTB_BGT_MNG_ITM 테이블에서 조회
+ */
+export async function getGlAccounts(): Promise<{
+ success: boolean
+ data: GlAccount[]
+ error?: string
+ isUsingFallback?: boolean
+}> {
+ try {
+ console.log('📋 [getGlAccounts] Oracle 쿼리 시작...')
+
+ const result = await oracleKnex.raw(`
+ SELECT
+ SAKNR,
+ FIPEX,
+ TEXT1"
+ FROM CMCTB_BGT_MNG_ITM
+ WHERE ROWNUM < 100
+ AND BUKRS = 'H100'
+ ORDER BY SAKNR
+ `)
+
+ // Oracle raw query의 결과는 rows 배열에 들어있음
+ const rows = (result.rows || result) as Array<Record<string, unknown>>
+
+ console.log(`✅ [getGlAccounts] Oracle 쿼리 성공 - ${rows.length}건 조회`)
+
+ // null 값 필터링
+ const cleanedResult = rows
+ .filter((item) =>
+ item['계정(G/L)'] &&
+ item['세부계정']
+ )
+ .map((item) => ({
+ SAKNR: String(item['계정(G/L)']),
+ FIPEX: String(item['세부계정']),
+ TEXT1: String(item['계정명'] || '')
+ }))
+
+ console.log(`✅ [getGlAccounts] 필터링 후 ${cleanedResult.length}건`)
+
+ return {
+ success: true,
+ data: cleanedResult,
+ isUsingFallback: false
+ }
+ } catch (error) {
+ console.error('❌ [getGlAccounts] Oracle 오류:', error)
+ console.log('🔄 [getGlAccounts] 폴백 테스트 데이터 사용 (' + FALLBACK_TEST_DATA.length + '건)')
+ return {
+ success: true,
+ data: FALLBACK_TEST_DATA,
+ isUsingFallback: true
+ }
+ }
+}
+
diff --git a/components/common/selectors/gl-account/gl-account-single-selector.tsx b/components/common/selectors/gl-account/gl-account-single-selector.tsx
new file mode 100644
index 00000000..2a6a7915
--- /dev/null
+++ b/components/common/selectors/gl-account/gl-account-single-selector.tsx
@@ -0,0 +1,358 @@
+'use client'
+
+/**
+ * GL 계정 단일 선택 다이얼로그
+ *
+ * @description
+ * - GL 계정을 하나만 선택할 수 있는 다이얼로그
+ * - 트리거 버튼과 다이얼로그가 분리된 구조
+ * - 외부에서 open 상태를 제어 가능
+ */
+
+import { useState, useCallback, useMemo, useTransition, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Search, Check, X } from 'lucide-react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ RowSelectionState,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ getGlAccounts,
+ GlAccount
+} from './gl-account-service'
+import { toast } from 'sonner'
+
+export interface GlAccountSingleSelectorProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedCode?: GlAccount
+ onCodeSelect: (code: GlAccount) => void
+ onConfirm?: (code: GlAccount | undefined) => void
+ onCancel?: () => void
+ title?: string
+ description?: string
+ showConfirmButtons?: boolean
+}
+
+export function GlAccountSingleSelector({
+ open,
+ onOpenChange,
+ selectedCode,
+ onCodeSelect,
+ onConfirm,
+ onCancel,
+ title = "GL 계정 선택",
+ description = "GL 계정을 선택하세요",
+ showConfirmButtons = false
+}: GlAccountSingleSelectorProps) {
+ const [codes, setCodes] = useState<GlAccount[]>([])
+ const [sorting, setSorting] = useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
+ const [globalFilter, setGlobalFilter] = useState('')
+ const [isPending, startTransition] = useTransition()
+ const [tempSelectedCode, setTempSelectedCode] = useState<GlAccount | undefined>(selectedCode)
+
+ // GL 계정 선택 핸들러
+ const handleCodeSelect = useCallback((code: GlAccount) => {
+ if (showConfirmButtons) {
+ setTempSelectedCode(code)
+ } else {
+ onCodeSelect(code)
+ onOpenChange(false)
+ }
+ }, [onCodeSelect, onOpenChange, showConfirmButtons])
+
+ // 확인 버튼 핸들러
+ const handleConfirm = useCallback(() => {
+ if (tempSelectedCode) {
+ onCodeSelect(tempSelectedCode)
+ }
+ onConfirm?.(tempSelectedCode)
+ onOpenChange(false)
+ }, [tempSelectedCode, onCodeSelect, onConfirm, onOpenChange])
+
+ // 취소 버튼 핸들러
+ const handleCancel = useCallback(() => {
+ setTempSelectedCode(selectedCode)
+ onCancel?.()
+ onOpenChange(false)
+ }, [selectedCode, onCancel, onOpenChange])
+
+ // 테이블 컬럼 정의
+ const columns: ColumnDef<GlAccount>[] = useMemo(() => [
+ {
+ accessorKey: 'SAKNR',
+ header: '계정(G/L)',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('SAKNR')}</div>
+ ),
+ },
+ {
+ accessorKey: 'FIPEX',
+ header: '세부계정',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('FIPEX')}</div>
+ ),
+ },
+ {
+ accessorKey: 'TEXT1',
+ header: '계정명',
+ cell: ({ row }) => (
+ <div>{row.getValue('TEXT1')}</div>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => {
+ const isSelected = showConfirmButtons
+ ? tempSelectedCode?.SAKNR === row.original.SAKNR
+ : selectedCode?.SAKNR === row.original.SAKNR
+
+ return (
+ <Button
+ variant={isSelected ? "default" : "ghost"}
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleCodeSelect(row.original)
+ }}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ )
+ },
+ },
+ ], [handleCodeSelect, selectedCode, tempSelectedCode, showConfirmButtons])
+
+ // GL 계정 테이블 설정
+ const table = useReactTable({
+ data: codes,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ })
+
+ // 서버에서 GL 계정 전체 목록 로드 (한 번만)
+ const loadCodes = useCallback(async () => {
+ startTransition(async () => {
+ try {
+ const result = await getGlAccounts()
+
+ if (result.success) {
+ setCodes(result.data)
+
+ // 폴백 데이터를 사용하는 경우 알림
+ if (result.isUsingFallback) {
+ toast.info('Oracle 연결 실패', {
+ description: '테스트 데이터를 사용합니다.',
+ duration: 4000,
+ })
+ }
+ } else {
+ toast.error(result.error || 'GL 계정을 불러오는데 실패했습니다.')
+ setCodes([])
+ }
+ } catch (error) {
+ console.error('GL 계정 목록 로드 실패:', error)
+ toast.error('GL 계정을 불러오는 중 오류가 발생했습니다.')
+ setCodes([])
+ }
+ })
+ }, [])
+
+ // 다이얼로그 열릴 때 코드 로드 (open prop 변화 감지)
+ useEffect(() => {
+ if (open) {
+ setTempSelectedCode(selectedCode)
+ if (codes.length === 0) {
+ console.log('🚀 [GlAccountSingleSelector] 다이얼로그 열림 - loadCodes 호출')
+ loadCodes()
+ } else {
+ console.log('📦 [GlAccountSingleSelector] 다이얼로그 열림 - 기존 데이터 사용 (' + codes.length + '건)')
+ }
+ }
+ }, [open, selectedCode, loadCodes, codes.length])
+
+ // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
+ const handleSearchChange = useCallback((value: string) => {
+ setGlobalFilter(value)
+ }, [])
+
+ const currentSelectedCode = showConfirmButtons ? tempSelectedCode : selectedCode
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-5xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ <div className="text-sm text-muted-foreground">
+ {description}
+ </div>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 현재 선택된 GL 계정 표시 */}
+ {currentSelectedCode && (
+ <div className="p-3 bg-muted rounded-md">
+ <div className="text-sm font-medium">선택된 GL 계정:</div>
+ <div className="flex items-center gap-2 mt-1">
+ <span className="font-mono text-sm">[{currentSelectedCode.SAKNR}]</span>
+ <span className="font-mono text-sm">{currentSelectedCode.FIPEX}</span>
+ <span>- {currentSelectedCode.TEXT1}</span>
+ </div>
+ </div>
+ )}
+
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4" />
+ <Input
+ placeholder="계정, 세부계정, 계정명으로 검색..."
+ value={globalFilter}
+ onChange={(e) => handleSearchChange(e.target.value)}
+ className="flex-1"
+ />
+ </div>
+
+ {isPending ? (
+ <div className="flex justify-center py-8">
+ <div className="text-sm text-muted-foreground">GL 계정을 불러오는 중...</div>
+ </div>
+ ) : (
+ <div className="border rounded-md">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => {
+ const isRowSelected = currentSelectedCode?.SAKNR === row.original.SAKNR
+ return (
+ <TableRow
+ key={row.id}
+ data-state={isRowSelected && "selected"}
+ className={`cursor-pointer hover:bg-muted/50 ${
+ isRowSelected ? 'bg-muted' : ''
+ }`}
+ onClick={() => handleCodeSelect(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ )
+ })
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 검색 결과가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {table.getFilteredRowModel().rows.length}개 GL 계정
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ {showConfirmButtons && (
+ <DialogFooter>
+ <Button variant="outline" onClick={handleCancel}>
+ <X className="h-4 w-4 mr-2" />
+ 취소
+ </Button>
+ <Button onClick={handleConfirm} disabled={!tempSelectedCode}>
+ <Check className="h-4 w-4 mr-2" />
+ 확인
+ </Button>
+ </DialogFooter>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/components/common/selectors/gl-account/index.ts b/components/common/selectors/gl-account/index.ts
new file mode 100644
index 00000000..f718f13f
--- /dev/null
+++ b/components/common/selectors/gl-account/index.ts
@@ -0,0 +1,12 @@
+// GL 계정 선택기 관련 컴포넌트와 타입 내보내기
+
+export { GlAccountSelector, GlAccountSingleSelector } from './gl-account-selector'
+export type { GlAccountSelectorProps, GlAccountSingleSelectorProps, GlAccountItem } from './gl-account-selector'
+
+export {
+ getGlAccounts
+} from './gl-account-service'
+export type {
+ GlAccount
+} from './gl-account-service'
+
diff --git a/components/common/selectors/wbs-code/index.ts b/components/common/selectors/wbs-code/index.ts
new file mode 100644
index 00000000..1a4653d2
--- /dev/null
+++ b/components/common/selectors/wbs-code/index.ts
@@ -0,0 +1,12 @@
+// WBS 코드 선택기 관련 컴포넌트와 타입 내보내기
+
+export { WbsCodeSelector, WbsCodeSingleSelector } from './wbs-code-single-selector'
+export type { WbsCodeSelectorProps, WbsCodeSingleSelectorProps } from './wbs-code-single-selector'
+
+export {
+ getWbsCodes
+} from './wbs-code-service'
+export type {
+ WbsCode
+} from './wbs-code-service'
+
diff --git a/components/common/selectors/wbs-code/wbs-code-selector.tsx b/components/common/selectors/wbs-code/wbs-code-selector.tsx
new file mode 100644
index 00000000..b701d090
--- /dev/null
+++ b/components/common/selectors/wbs-code/wbs-code-selector.tsx
@@ -0,0 +1,323 @@
+'use client'
+
+/**
+ * WBS 코드 선택기
+ *
+ * @description
+ * - 오라클에서 WBS 코드들을 조회
+ * - PROJ_NO: 프로젝트 번호
+ * - WBS_ELMT: WBS 요소
+ * - WBS_ELMT_NM: WBS 요소명
+ * - WBS_LVL: WBS 레벨
+ */
+
+import { useState, useCallback, useMemo, useTransition } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Search, Check } from 'lucide-react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ RowSelectionState,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ getWbsCodes,
+ WbsCode
+} from './wbs-code-service'
+import { toast } from 'sonner'
+
+export interface WbsCodeSelectorProps {
+ selectedCode?: WbsCode
+ onCodeSelect: (code: WbsCode) => void
+ disabled?: boolean
+ placeholder?: string
+ className?: string
+ projNo?: string // 프로젝트 번호 필터
+}
+
+export interface WbsCodeItem {
+ code: string // WBS 코드 (PROJ_NO + WBS_ELMT 조합)
+ projNo: string // 프로젝트 번호
+ wbsElmt: string // WBS 요소
+ wbsElmtNm: string // WBS 요소명
+ wbsLvl: string // WBS 레벨
+ displayText: string // 표시용 텍스트
+}
+
+export function WbsCodeSelector({
+ selectedCode,
+ onCodeSelect,
+ disabled,
+ placeholder = "WBS 코드를 선택하세요",
+ className,
+ projNo
+}: WbsCodeSelectorProps) {
+ const [open, setOpen] = useState(false)
+ const [codes, setCodes] = useState<WbsCode[]>([])
+ const [sorting, setSorting] = useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
+ const [globalFilter, setGlobalFilter] = useState('')
+ const [isPending, startTransition] = useTransition()
+
+ // WBS 코드 선택 핸들러
+ const handleCodeSelect = useCallback(async (code: WbsCode) => {
+ onCodeSelect(code)
+ setOpen(false)
+ }, [onCodeSelect])
+
+ // 테이블 컬럼 정의
+ const columns: ColumnDef<WbsCode>[] = useMemo(() => [
+ {
+ accessorKey: 'PROJ_NO',
+ header: '프로젝트 번호',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('PROJ_NO')}</div>
+ ),
+ },
+ {
+ accessorKey: 'WBS_ELMT',
+ header: 'WBS 요소',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('WBS_ELMT')}</div>
+ ),
+ },
+ {
+ accessorKey: 'WBS_ELMT_NM',
+ header: 'WBS 요소명',
+ cell: ({ row }) => (
+ <div>{row.getValue('WBS_ELMT_NM')}</div>
+ ),
+ },
+ {
+ accessorKey: 'WBS_LVL',
+ header: '레벨',
+ cell: ({ row }) => (
+ <div className="text-center">{row.getValue('WBS_LVL')}</div>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleCodeSelect(row.original)
+ }}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ ),
+ },
+ ], [handleCodeSelect])
+
+ // WBS 코드 테이블 설정
+ const table = useReactTable({
+ data: codes,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ })
+
+ // 서버에서 WBS 코드 전체 목록 로드 (한 번만)
+ const loadCodes = useCallback(async () => {
+ startTransition(async () => {
+ try {
+ const result = await getWbsCodes(projNo)
+
+ if (result.success) {
+ setCodes(result.data)
+
+ // 폴백 데이터를 사용하는 경우 알림
+ if (result.isUsingFallback) {
+ toast.info('Oracle 연결 실패', {
+ description: '테스트 데이터를 사용합니다.',
+ duration: 4000,
+ })
+ }
+ } else {
+ toast.error(result.error || 'WBS 코드를 불러오는데 실패했습니다.')
+ setCodes([])
+ }
+ } catch (error) {
+ console.error('WBS 코드 목록 로드 실패:', error)
+ toast.error('WBS 코드를 불러오는 중 오류가 발생했습니다.')
+ setCodes([])
+ }
+ })
+ }, [projNo])
+
+ // 다이얼로그 열기 핸들러
+ const handleDialogOpenChange = useCallback((newOpen: boolean) => {
+ setOpen(newOpen)
+ if (newOpen && codes.length === 0) {
+ loadCodes()
+ }
+ }, [loadCodes, codes.length])
+
+ // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
+ const handleSearchChange = useCallback((value: string) => {
+ setGlobalFilter(value)
+ }, [])
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button
+ variant="outline"
+ disabled={disabled}
+ className={`w-full justify-start ${className || ''}`}
+ >
+ {selectedCode ? (
+ <div className="flex items-center gap-2 w-full">
+ <span className="font-mono text-sm">[{selectedCode.PROJ_NO}]</span>
+ <span className="font-mono text-sm">{selectedCode.WBS_ELMT}</span>
+ <span className="truncate flex-1 text-left">{selectedCode.WBS_ELMT_NM}</span>
+ </div>
+ ) : (
+ <span className="text-muted-foreground">{placeholder}</span>
+ )}
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-5xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>WBS 코드 선택</DialogTitle>
+ <div className="text-sm text-muted-foreground">
+ WBS 코드 조회
+ </div>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4" />
+ <Input
+ placeholder="프로젝트 번호, WBS 요소, WBS 요소명으로 검색..."
+ value={globalFilter}
+ onChange={(e) => handleSearchChange(e.target.value)}
+ className="flex-1"
+ />
+ </div>
+
+ {isPending ? (
+ <div className="flex justify-center py-8">
+ <div className="text-sm text-muted-foreground">WBS 코드를 불러오는 중...</div>
+ </div>
+ ) : (
+ <div className="border rounded-md">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ className="cursor-pointer hover:bg-muted/50"
+ onClick={() => handleCodeSelect(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 검색 결과가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {table.getFilteredRowModel().rows.length}개 WBS 코드
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/components/common/selectors/wbs-code/wbs-code-service.ts b/components/common/selectors/wbs-code/wbs-code-service.ts
new file mode 100644
index 00000000..7d9c17b1
--- /dev/null
+++ b/components/common/selectors/wbs-code/wbs-code-service.ts
@@ -0,0 +1,92 @@
+"use server"
+
+import { oracleKnex } from '@/lib/oracle-db/db'
+
+// WBS 코드 타입 정의
+export interface WbsCode {
+ PROJ_NO: string // 프로젝트 번호
+ WBS_ELMT: string // WBS 요소
+ WBS_ELMT_NM: string // WBS 요소명
+ WBS_LVL: string // WBS 레벨
+}
+
+// 테스트 환경용 폴백 데이터
+const FALLBACK_TEST_DATA: WbsCode[] = [
+ { PROJ_NO: 'SN2661', WBS_ELMT: 'WBS001', WBS_ELMT_NM: 'WBS 항목 1(테스트데이터 - 오라클 페칭 실패시)', WBS_LVL: '1' },
+ { PROJ_NO: 'SN2661', WBS_ELMT: 'WBS002', WBS_ELMT_NM: 'WBS 항목 2(테스트데이터 - 오라클 페칭 실패시)', WBS_LVL: '2' },
+ { PROJ_NO: 'SN2661', WBS_ELMT: 'WBS003', WBS_ELMT_NM: 'WBS 항목 3(테스트데이터 - 오라클 페칭 실패시)', WBS_LVL: '1' },
+ { PROJ_NO: 'SN2661', WBS_ELMT: 'WBS004', WBS_ELMT_NM: 'WBS 항목 4(테스트데이터 - 오라클 페칭 실패시)', WBS_LVL: '2' },
+ { PROJ_NO: 'SN2661', WBS_ELMT: 'WBS005', WBS_ELMT_NM: 'WBS 항목 5(테스트데이터 - 오라클 페칭 실패시)', WBS_LVL: '3' },
+]
+
+/**
+ * WBS 코드 목록 조회 (Oracle에서 전체 조회, 실패 시 폴백 데이터 사용)
+ * CMCTB_PROJ_WBS 테이블에서 조회
+ * @param projNo - 프로젝트 번호 (선택적, 없으면 전체 조회)
+ */
+export async function getWbsCodes(projNo?: string): Promise<{
+ success: boolean
+ data: WbsCode[]
+ error?: string
+ isUsingFallback?: boolean
+}> {
+ try {
+ console.log('📋 [getWbsCodes] Oracle 쿼리 시작...', projNo ? `프로젝트: ${projNo}` : '전체')
+
+ let query = `
+ SELECT
+ PROJ_NO,
+ WBS_ELMT,
+ WBS_ELMT_NM,
+ WBS_LVL
+ FROM CMCTB_PROJ_WBS
+ WHERE ROWNUM < 100
+ `
+
+ if (projNo) {
+ query += ` AND PROJ_NO = :projNo`
+ }
+
+ query += ` ORDER BY PROJ_NO, WBS_ELMT`
+
+ const result = projNo
+ ? await oracleKnex.raw(query, { projNo })
+ : await oracleKnex.raw(query)
+
+ // Oracle raw query의 결과는 rows 배열에 들어있음
+ const rows = (result.rows || result) as Array<Record<string, unknown>>
+
+ console.log(`✅ [getWbsCodes] Oracle 쿼리 성공 - ${rows.length}건 조회`)
+
+ // null 값 필터링
+ const cleanedResult = rows
+ .filter((item) =>
+ item.PROJ_NO &&
+ item.WBS_ELMT &&
+ item.WBS_ELMT_NM
+ )
+ .map((item) => ({
+ PROJ_NO: String(item.PROJ_NO),
+ WBS_ELMT: String(item.WBS_ELMT),
+ WBS_ELMT_NM: String(item.WBS_ELMT_NM),
+ WBS_LVL: String(item.WBS_LVL || '')
+ }))
+
+ console.log(`✅ [getWbsCodes] 필터링 후 ${cleanedResult.length}건`)
+
+ return {
+ success: true,
+ data: cleanedResult,
+ isUsingFallback: false
+ }
+ } catch (error) {
+ console.error('❌ [getWbsCodes] Oracle 오류:', error)
+ console.log('🔄 [getWbsCodes] 폴백 테스트 데이터 사용 (' + FALLBACK_TEST_DATA.length + '건)')
+ return {
+ success: true,
+ data: FALLBACK_TEST_DATA,
+ isUsingFallback: true
+ }
+ }
+}
+
diff --git a/components/common/selectors/wbs-code/wbs-code-single-selector.tsx b/components/common/selectors/wbs-code/wbs-code-single-selector.tsx
new file mode 100644
index 00000000..34cbc975
--- /dev/null
+++ b/components/common/selectors/wbs-code/wbs-code-single-selector.tsx
@@ -0,0 +1,365 @@
+/**
+ * WBS 코드 단일 선택 다이얼로그
+ *
+ * @description
+ * - WBS 코드를 하나만 선택할 수 있는 다이얼로그
+ * - 트리거 버튼과 다이얼로그가 분리된 구조
+ * - 외부에서 open 상태를 제어 가능
+ */
+
+import { useState, useCallback, useMemo, useTransition, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Search, Check, X } from 'lucide-react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ RowSelectionState,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ getWbsCodes,
+ WbsCode
+} from './wbs-code-service'
+import { toast } from 'sonner'
+
+export interface WbsCodeSingleSelectorProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedCode?: WbsCode
+ onCodeSelect: (code: WbsCode) => void
+ onConfirm?: (code: WbsCode | undefined) => void
+ onCancel?: () => void
+ title?: string
+ description?: string
+ showConfirmButtons?: boolean
+ projNo?: string // 프로젝트 번호 필터
+}
+
+export function WbsCodeSingleSelector({
+ open,
+ onOpenChange,
+ selectedCode,
+ onCodeSelect,
+ onConfirm,
+ onCancel,
+ title = "WBS 코드 선택",
+ description = "WBS 코드를 선택하세요",
+ showConfirmButtons = false,
+ projNo
+}: WbsCodeSingleSelectorProps) {
+ const [codes, setCodes] = useState<WbsCode[]>([])
+ const [sorting, setSorting] = useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
+ const [globalFilter, setGlobalFilter] = useState('')
+ const [isPending, startTransition] = useTransition()
+ const [tempSelectedCode, setTempSelectedCode] = useState<WbsCode | undefined>(selectedCode)
+
+ // WBS 코드 선택 핸들러
+ const handleCodeSelect = useCallback((code: WbsCode) => {
+ if (showConfirmButtons) {
+ setTempSelectedCode(code)
+ } else {
+ onCodeSelect(code)
+ onOpenChange(false)
+ }
+ }, [onCodeSelect, onOpenChange, showConfirmButtons])
+
+ // 확인 버튼 핸들러
+ const handleConfirm = useCallback(() => {
+ if (tempSelectedCode) {
+ onCodeSelect(tempSelectedCode)
+ }
+ onConfirm?.(tempSelectedCode)
+ onOpenChange(false)
+ }, [tempSelectedCode, onCodeSelect, onConfirm, onOpenChange])
+
+ // 취소 버튼 핸들러
+ const handleCancel = useCallback(() => {
+ setTempSelectedCode(selectedCode)
+ onCancel?.()
+ onOpenChange(false)
+ }, [selectedCode, onCancel, onOpenChange])
+
+ // 테이블 컬럼 정의
+ const columns: ColumnDef<WbsCode>[] = useMemo(() => [
+ {
+ accessorKey: 'PROJ_NO',
+ header: '프로젝트 번호',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('PROJ_NO')}</div>
+ ),
+ },
+ {
+ accessorKey: 'WBS_ELMT',
+ header: 'WBS 요소',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('WBS_ELMT')}</div>
+ ),
+ },
+ {
+ accessorKey: 'WBS_ELMT_NM',
+ header: 'WBS 요소명',
+ cell: ({ row }) => (
+ <div>{row.getValue('WBS_ELMT_NM')}</div>
+ ),
+ },
+ {
+ accessorKey: 'WBS_LVL',
+ header: '레벨',
+ cell: ({ row }) => (
+ <div className="text-center">{row.getValue('WBS_LVL')}</div>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => {
+ const isSelected = showConfirmButtons
+ ? tempSelectedCode?.WBS_ELMT === row.original.WBS_ELMT && tempSelectedCode?.PROJ_NO === row.original.PROJ_NO
+ : selectedCode?.WBS_ELMT === row.original.WBS_ELMT && selectedCode?.PROJ_NO === row.original.PROJ_NO
+
+ return (
+ <Button
+ variant={isSelected ? "default" : "ghost"}
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleCodeSelect(row.original)
+ }}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ )
+ },
+ },
+ ], [handleCodeSelect, selectedCode, tempSelectedCode, showConfirmButtons])
+
+ // WBS 코드 테이블 설정
+ const table = useReactTable({
+ data: codes,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ })
+
+ // 서버에서 WBS 코드 전체 목록 로드 (한 번만)
+ const loadCodes = useCallback(async () => {
+ startTransition(async () => {
+ try {
+ const result = await getWbsCodes(projNo)
+
+ if (result.success) {
+ setCodes(result.data)
+
+ // 폴백 데이터를 사용하는 경우 알림
+ if (result.isUsingFallback) {
+ toast.info('Oracle 연결 실패', {
+ description: '테스트 데이터를 사용합니다.',
+ duration: 4000,
+ })
+ }
+ } else {
+ toast.error(result.error || 'WBS 코드를 불러오는데 실패했습니다.')
+ setCodes([])
+ }
+ } catch (error) {
+ console.error('WBS 코드 목록 로드 실패:', error)
+ toast.error('WBS 코드를 불러오는 중 오류가 발생했습니다.')
+ setCodes([])
+ }
+ })
+ }, [projNo])
+
+ // 다이얼로그 열릴 때 코드 로드 (open prop 변화 감지)
+ useEffect(() => {
+ if (open) {
+ setTempSelectedCode(selectedCode)
+ if (codes.length === 0) {
+ console.log('🚀 [WbsCodeSingleSelector] 다이얼로그 열림 - loadCodes 호출')
+ loadCodes()
+ } else {
+ console.log('📦 [WbsCodeSingleSelector] 다이얼로그 열림 - 기존 데이터 사용 (' + codes.length + '건)')
+ }
+ }
+ }, [open, selectedCode, loadCodes, codes.length])
+
+ // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
+ const handleSearchChange = useCallback((value: string) => {
+ setGlobalFilter(value)
+ }, [])
+
+ const currentSelectedCode = showConfirmButtons ? tempSelectedCode : selectedCode
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-5xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ <div className="text-sm text-muted-foreground">
+ {description}
+ </div>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 현재 선택된 WBS 코드 표시 */}
+ {currentSelectedCode && (
+ <div className="p-3 bg-muted rounded-md">
+ <div className="text-sm font-medium">선택된 WBS 코드:</div>
+ <div className="flex items-center gap-2 mt-1">
+ <span className="font-mono text-sm">[{currentSelectedCode.PROJ_NO}]</span>
+ <span className="font-mono text-sm">{currentSelectedCode.WBS_ELMT}</span>
+ <span>{currentSelectedCode.WBS_ELMT_NM}</span>
+ </div>
+ </div>
+ )}
+
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4" />
+ <Input
+ placeholder="프로젝트 번호, WBS 요소, WBS 요소명으로 검색..."
+ value={globalFilter}
+ onChange={(e) => handleSearchChange(e.target.value)}
+ className="flex-1"
+ />
+ </div>
+
+ {isPending ? (
+ <div className="flex justify-center py-8">
+ <div className="text-sm text-muted-foreground">WBS 코드를 불러오는 중...</div>
+ </div>
+ ) : (
+ <div className="border rounded-md">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => {
+ const isRowSelected = currentSelectedCode?.WBS_ELMT === row.original.WBS_ELMT &&
+ currentSelectedCode?.PROJ_NO === row.original.PROJ_NO
+ return (
+ <TableRow
+ key={row.id}
+ data-state={isRowSelected && "selected"}
+ className={`cursor-pointer hover:bg-muted/50 ${
+ isRowSelected ? 'bg-muted' : ''
+ }`}
+ onClick={() => handleCodeSelect(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ )
+ })
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 검색 결과가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {table.getFilteredRowModel().rows.length}개 WBS 코드
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ {showConfirmButtons && (
+ <DialogFooter>
+ <Button variant="outline" onClick={handleCancel}>
+ <X className="h-4 w-4 mr-2" />
+ 취소
+ </Button>
+ <Button onClick={handleConfirm} disabled={!tempSelectedCode}>
+ <Check className="h-4 w-4 mr-2" />
+ 확인
+ </Button>
+ </DialogFooter>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/components/layout/HeaderSimple.tsx b/components/layout/HeaderSimple.tsx
index 425bf796..82eebf2e 100644
--- a/components/layout/HeaderSimple.tsx
+++ b/components/layout/HeaderSimple.tsx
@@ -29,14 +29,17 @@ import Image from "next/image";
import { mainNav, additionalNav, MenuSection, MenuItem, mainNavVendor, additionalNavVendor } from "@/config/menuConfig"; // 메뉴 구성 임포트
import { MobileMenu } from "./MobileMenu";
import { CommandMenu } from "./command-menu";
-import { useSession, signOut } from "next-auth/react";
+import { useSession } from "next-auth/react";
+import { customSignOut } from "@/lib/auth/custom-signout";
import GroupedMenuRenderer from "./GroupedMenuRender";
+import { useTranslation } from '@/i18n/client';
export function HeaderSimple() {
const params = useParams();
const lng = params?.lng as string;
const pathname = usePathname();
const { data: session } = useSession();
+ const { t } = useTranslation(lng, 'menu');
const userName = session?.user?.name || "";
const domain = session?.user?.domain || "";
@@ -149,7 +152,7 @@ export function HeaderSimple() {
<Link href={`${basePath}/settings`}>Settings</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
- <DropdownMenuItem onSelect={() => signOut({ callbackUrl: `/${lng}/${domain}` })}>
+ <DropdownMenuItem onSelect={() => customSignOut({ callbackUrl: `${window.location.origin}${basePath}` })}>
Logout
</DropdownMenuItem>
</DropdownMenuContent>
@@ -159,7 +162,7 @@ export function HeaderSimple() {
</div>
{/* 모바일 메뉴 */}
- {isMobileMenuOpen && <MobileMenu lng={lng} onClose={toggleMobileMenu} />}
+ {isMobileMenuOpen && <MobileMenu lng={lng} onClose={toggleMobileMenu} t={t} />}
</header>
</>
);
diff --git a/components/ship-vendor-document/add-attachment-dialog.tsx b/components/ship-vendor-document/add-attachment-dialog.tsx
index 6765bcf5..4a51c3b5 100644
--- a/components/ship-vendor-document/add-attachment-dialog.tsx
+++ b/components/ship-vendor-document/add-attachment-dialog.tsx
@@ -38,7 +38,7 @@ import { useSession } from "next-auth/react"
* -----------------------------------------------------------------------------------------------*/
// 파일 검증 스키마
-const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 50MB
+const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB
const ACCEPTED_FILE_TYPES = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
@@ -73,7 +73,7 @@ const attachmentUploadSchema = z.object({
// .max(10, "Maximum 10 files can be uploaded")
.refine(
(files) => files.every((file) => file.size <= MAX_FILE_SIZE),
- "File size must be 50MB or less"
+ "File size must be 1GB or less"
)
// .refine(
// (files) => files.every((file) => ACCEPTED_FILE_TYPES.includes(file.type)),
@@ -101,10 +101,46 @@ function FileUploadArea({
}) {
const fileInputRef = React.useRef<HTMLInputElement>(null)
+ // 파일 검증 함수
+ const validateFiles = (filesToValidate: File[]): { valid: File[], invalid: string[] } => {
+ const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB
+ const FORBIDDEN_EXTENSIONS = ['exe', 'com', 'dll', 'vbs', 'js', 'asp', 'aspx', 'bat', 'cmd']
+
+ const valid: File[] = []
+ const invalid: string[] = []
+
+ filesToValidate.forEach(file => {
+ // 파일 크기 검증
+ if (file.size > MAX_FILE_SIZE) {
+ invalid.push(`${file.name}: 파일 크기가 1GB를 초과합니다 (${formatFileSize(file.size)})`)
+ return
+ }
+
+ // 파일 확장자 검증
+ const extension = file.name.split('.').pop()?.toLowerCase()
+ if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) {
+ invalid.push(`${file.name}: 금지된 파일 형식입니다 (.${extension})`)
+ return
+ }
+
+ valid.push(file)
+ })
+
+ return { valid, invalid }
+ }
+
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(event.target.files || [])
if (selectedFiles.length > 0) {
- onFilesChange([...files, ...selectedFiles])
+ const { valid, invalid } = validateFiles(selectedFiles)
+
+ if (invalid.length > 0) {
+ invalid.forEach(msg => toast.error(msg))
+ }
+
+ if (valid.length > 0) {
+ onFilesChange([...files, ...valid])
+ }
}
}
@@ -112,7 +148,15 @@ function FileUploadArea({
event.preventDefault()
const droppedFiles = Array.from(event.dataTransfer.files)
if (droppedFiles.length > 0) {
- onFilesChange([...files, ...droppedFiles])
+ const { valid, invalid } = validateFiles(droppedFiles)
+
+ if (invalid.length > 0) {
+ invalid.forEach(msg => toast.error(msg))
+ }
+
+ if (valid.length > 0) {
+ onFilesChange([...files, ...valid])
+ }
}
}
@@ -147,6 +191,9 @@ function FileUploadArea({
<p className="text-xs text-muted-foreground">
Supports PDF, Word, Excel, Image, Text, ZIP, CAD files (DWG, DXF, STEP, STL, IGES) (max 1GB)
</p>
+ <p className="text-xs text-red-600 mt-1 font-medium">
+ Forbidden file types: .exe, .com, .dll, .vbs, .js, .asp, .aspx, .bat, .cmd
+ </p>
<p className="text-xs text-orange-600 mt-1">
Note: File names cannot contain these characters: &lt; &gt; : &quot; &apos; | ? *
</p>
diff --git a/components/ship-vendor-document/new-revision-dialog.tsx b/components/ship-vendor-document/new-revision-dialog.tsx
index 91694827..bdbb1bc6 100644
--- a/components/ship-vendor-document/new-revision-dialog.tsx
+++ b/components/ship-vendor-document/new-revision-dialog.tsx
@@ -83,10 +83,46 @@ function FileUploadArea({
}) {
const fileInputRef = React.useRef<HTMLInputElement>(null)
+ // 파일 검증 함수
+ const validateFiles = (filesToValidate: File[]): { valid: File[], invalid: string[] } => {
+ const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB
+ const FORBIDDEN_EXTENSIONS = ['exe', 'com', 'dll', 'vbs', 'js', 'asp', 'aspx', 'bat', 'cmd']
+
+ const valid: File[] = []
+ const invalid: string[] = []
+
+ filesToValidate.forEach(file => {
+ // 파일 크기 검증
+ if (file.size > MAX_FILE_SIZE) {
+ invalid.push(`${file.name}: 파일 크기가 1GB를 초과합니다 (${formatFileSize(file.size)})`)
+ return
+ }
+
+ // 파일 확장자 검증
+ const extension = file.name.split('.').pop()?.toLowerCase()
+ if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) {
+ invalid.push(`${file.name}: 금지된 파일 형식입니다 (.${extension})`)
+ return
+ }
+
+ valid.push(file)
+ })
+
+ return { valid, invalid }
+ }
+
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(event.target.files || [])
if (selectedFiles.length > 0) {
- onFilesChange([...files, ...selectedFiles])
+ const { valid, invalid } = validateFiles(selectedFiles)
+
+ if (invalid.length > 0) {
+ invalid.forEach(msg => toast.error(msg))
+ }
+
+ if (valid.length > 0) {
+ onFilesChange([...files, ...valid])
+ }
}
}
@@ -94,7 +130,15 @@ function FileUploadArea({
event.preventDefault()
const droppedFiles = Array.from(event.dataTransfer.files)
if (droppedFiles.length > 0) {
- onFilesChange([...files, ...droppedFiles])
+ const { valid, invalid } = validateFiles(droppedFiles)
+
+ if (invalid.length > 0) {
+ invalid.forEach(msg => toast.error(msg))
+ }
+
+ if (valid.length > 0) {
+ onFilesChange([...files, ...valid])
+ }
}
}
@@ -132,6 +176,9 @@ function FileUploadArea({
<p className="text-xs text-orange-600 mt-1">
Note: File names cannot contain these characters: &lt; &gt; : &quot; &apos; | ? *
</p>
+ <p className="text-xs text-red-600 mt-1 font-medium">
+ Forbidden file types: .exe, .com, .dll, .vbs, .js, .asp, .aspx, .bat, .cmd
+ </p>
<input
ref={fileInputRef}
type="file"