diff options
Diffstat (limited to 'components')
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: < > : " ' | ? * </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: < > : " ' | ? * </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" |
