diff options
Diffstat (limited to 'lib/rfq-last/vendor')
| -rw-r--r-- | lib/rfq-last/vendor/add-vendor-dialog.tsx | 122 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/cancel-vendor-response-dialog.tsx | 208 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/price-adjustment-dialog.tsx | 268 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 155 |
4 files changed, 712 insertions, 41 deletions
diff --git a/lib/rfq-last/vendor/add-vendor-dialog.tsx b/lib/rfq-last/vendor/add-vendor-dialog.tsx index 8566763f..6b4efe74 100644 --- a/lib/rfq-last/vendor/add-vendor-dialog.tsx +++ b/lib/rfq-last/vendor/add-vendor-dialog.tsx @@ -27,9 +27,10 @@ import { import { Check, ChevronsUpDown, Loader2, X, Plus, FileText, Shield, Globe, Settings } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; -import { addVendorsToRfq } from "../service"; +import { addVendorsToRfq, getRfqItemsAction } from "../service"; import { getVendorsForSelection } from "@/lib/b-rfq/service"; import { Badge } from "@/components/ui/badge"; +import { getMrcTypeByMatnr } from "@/lib/mdg/actions/material-service"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -68,13 +69,83 @@ export function AddVendorDialog({ // 각 벤더별 기본계약 요구사항 상태 const [vendorContracts, setVendorContracts] = React.useState<VendorContract[]>([]); - // 일괄 적용용 기본값 + // MRC Type이 "P"인지 확인하는 상태 + const [hasMrcTypeP, setHasMrcTypeP] = React.useState(false); + const [isCheckingMrcType, setIsCheckingMrcType] = React.useState(false); + + // 일괄 적용용 기본값 (MRC Type과 외자업체 여부에 따라 동적으로 설정) const [defaultContract, setDefaultContract] = React.useState({ - agreementYn: true, - ndaYn: true, + agreementYn: false, + ndaYn: false, gtcType: "none" as "general" | "project" | "none" }); + // MRC Type 확인 + const checkMrcType = React.useCallback(async () => { + setIsCheckingMrcType(true); + try { + const itemsResult = await getRfqItemsAction(rfqId); + if (itemsResult.success && itemsResult.data && itemsResult.data.length > 0) { + // 모든 품목의 MRC Type 확인 + const mrcTypeChecks = await Promise.all( + itemsResult.data + .filter(item => item.materialCode) // materialCode가 있는 경우만 + .map(async (item) => { + try { + console.log(item.materialCode, "item.materialCode"); + const mrcType = await getMrcTypeByMatnr(item.materialCode); + console.log(mrcType, "mrcType"); + return mrcType === "P"; + } catch (error) { + console.error(`Failed to get MRC Type for ${item.materialCode}:`, error); + return false; + } + }) + ); + console.log(mrcTypeChecks, "mrcTypeChecks"); + + // 하나라도 "P"가 있으면 true + const hasP = mrcTypeChecks.some(check => check === true); + setHasMrcTypeP(hasP); + console.log(hasP, "hasP"); + + // MRC Type이 "P"이고 국내업체인 경우에만 기본값을 true로 설정 + if (hasP) { + setDefaultContract(prev => ({ + ...prev, + agreementYn: true, + ndaYn: true + })); + } else { + setDefaultContract(prev => ({ + ...prev, + agreementYn: false, + ndaYn: false + })); + } + } else { + // 품목이 없으면 기본값 false + setHasMrcTypeP(false); + setDefaultContract(prev => ({ + ...prev, + agreementYn: false, + ndaYn: false + })); + } + } catch (error) { + console.error("Failed to check MRC Type:", error); + // 에러 발생 시 기본값 false + setHasMrcTypeP(false); + setDefaultContract(prev => ({ + ...prev, + agreementYn: false, + ndaYn: false + })); + } finally { + setIsCheckingMrcType(false); + } + }, [rfqId]); + // 벤더 로드 const loadVendors = React.useCallback(async () => { try { @@ -91,8 +162,9 @@ export function AddVendorDialog({ React.useEffect(() => { if (open) { loadVendors(); + checkMrcType(); } - }, [open, loadVendors]); + }, [open, loadVendors, checkMrcType]); // 초기화 React.useEffect(() => { @@ -100,14 +172,20 @@ export function AddVendorDialog({ setSelectedVendors([]); setVendorContracts([]); setActiveTab("vendors"); + setHasMrcTypeP(false); setDefaultContract({ - agreementYn: true, - ndaYn: true, + agreementYn: false, + ndaYn: false, gtcType: "none" }); } }, [open]); + // 외자업체 여부 확인 + const isInternationalVendor = (vendor: any) => { + return vendor.country && vendor.country !== "KR" && vendor.country !== "한국"; + }; + // 벤더 추가 const handleAddVendor = (vendor: any) => { if (!selectedVendors.find(v => v.id === vendor.id)) { @@ -115,13 +193,15 @@ export function AddVendorDialog({ setSelectedVendors(updatedVendors); // 해당 벤더의 기본계약 설정 추가 - const isInternational = vendor.country && vendor.country !== "KR" && vendor.country !== "한국"; + const isInternational = isInternationalVendor(vendor); + // 외자업체이거나 MRC Type이 "P"가 아닌 경우 false로 설정 + const shouldCheckAgreement = hasMrcTypeP && !isInternational; setVendorContracts([ ...vendorContracts, { vendorId: vendor.id, - agreementYn: defaultContract.agreementYn, - ndaYn: defaultContract.ndaYn, + agreementYn: shouldCheckAgreement, + ndaYn: shouldCheckAgreement, gtcType: isInternational ? defaultContract.gtcType : "none" } ]); @@ -149,11 +229,13 @@ export function AddVendorDialog({ setVendorContracts(contracts => contracts.map(c => { const vendor = selectedVendors.find(v => v.id === c.vendorId); - const isInternational = vendor?.country && vendor.country !== "KR" && vendor.country !== "한국"; + const isInternational = isInternationalVendor(vendor); + // 외자업체이거나 MRC Type이 "P"가 아닌 경우 false로 설정 + const shouldCheckAgreement = hasMrcTypeP && !isInternational; return { ...c, - agreementYn: defaultContract.agreementYn, - ndaYn: defaultContract.ndaYn, + agreementYn: shouldCheckAgreement, + ndaYn: shouldCheckAgreement, gtcType: isInternational ? defaultContract.gtcType : "none" }; }) @@ -236,7 +318,7 @@ export function AddVendorDialog({ {/* 탭 */} <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="flex-1 flex flex-col min-h-0"> - <TabsList className="mx-6 grid w-fit grid-cols-2"> + <TabsList className="ml-6 grid w-fit grid-cols-2"> <TabsTrigger value="vendors"> 1. 벤더 선택 {selectedVendors.length > 0 && ( @@ -378,7 +460,7 @@ export function AddVendorDialog({ <TabsContent value="contracts" className="flex-1 flex flex-col px-6 py-4 overflow-hidden min-h-0"> <div className="flex-1 overflow-y-auto space-y-4 min-h-0"> {/* 일괄 적용 카드 */} - <Card> + {/* <Card> <CardHeader className="pb-3"> <CardTitle className="text-base flex items-center gap-2"> <Settings className="h-4 w-4" /> @@ -395,6 +477,7 @@ export function AddVendorDialog({ <Checkbox id="default-agreement" checked={defaultContract.agreementYn} + disabled={!hasMrcTypeP || isCheckingMrcType} onCheckedChange={(checked) => setDefaultContract({ ...defaultContract, agreementYn: !!checked }) } @@ -407,6 +490,7 @@ export function AddVendorDialog({ <Checkbox id="default-nda" checked={defaultContract.ndaYn} + disabled={!hasMrcTypeP || isCheckingMrcType} onCheckedChange={(checked) => setDefaultContract({ ...defaultContract, ndaYn: !!checked }) } @@ -448,7 +532,7 @@ export function AddVendorDialog({ 모든 벤더에 적용 </Button> </CardContent> - </Card> + </Card> */} {/* 개별 벤더 설정 */} <Card className="flex flex-col min-h-0"> @@ -463,7 +547,7 @@ export function AddVendorDialog({ <div className="space-y-4"> {selectedVendors.map((vendor) => { const contract = vendorContracts.find(c => c.vendorId === vendor.id); - const isInternational = vendor.country && vendor.country !== "KR" && vendor.country !== "한국"; + const isInternational = isInternationalVendor(vendor); return ( <div key={vendor.id} className="border rounded-lg p-4 space-y-3"> @@ -485,6 +569,7 @@ export function AddVendorDialog({ <div className="flex items-center space-x-2"> <Checkbox checked={contract?.agreementYn || false} + disabled={!hasMrcTypeP || isInternational} onCheckedChange={(checked) => updateVendorContract(vendor.id, "agreementYn", !!checked) } @@ -494,11 +579,12 @@ export function AddVendorDialog({ <div className="flex items-center space-x-2"> <Checkbox checked={contract?.ndaYn || false} + disabled={!hasMrcTypeP || isInternational} onCheckedChange={(checked) => updateVendorContract(vendor.id, "ndaYn", !!checked) } /> - <label className="text-sm">NDA</label> + <label className="text-sm">비밀유지 계약 (NDA)</label> </div> </div> diff --git a/lib/rfq-last/vendor/cancel-vendor-response-dialog.tsx b/lib/rfq-last/vendor/cancel-vendor-response-dialog.tsx new file mode 100644 index 00000000..414cfa4b --- /dev/null +++ b/lib/rfq-last/vendor/cancel-vendor-response-dialog.tsx @@ -0,0 +1,208 @@ +"use client";
+
+import * as React from "react";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { cancelVendorResponse } from "@/lib/rfq-last/cancel-vendor-response-action";
+import { Loader2, AlertTriangle } from "lucide-react";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+
+interface CancelVendorResponseDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ rfqId: number;
+ selectedVendors: Array<{
+ detailId: number;
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+ }>;
+ onSuccess?: () => void;
+}
+
+export function CancelVendorResponseDialog({
+ open,
+ onOpenChange,
+ rfqId,
+ selectedVendors,
+ onSuccess,
+}: CancelVendorResponseDialogProps) {
+ const [isCancelling, setIsCancelling] = React.useState(false);
+ const [cancelReason, setCancelReason] = React.useState("");
+ const [error, setError] = React.useState<string | null>(null);
+ const [results, setResults] = React.useState<Array<{ detailId: number; success: boolean; error?: string }> | undefined>();
+
+ const handleCancel = async () => {
+ if (!cancelReason || cancelReason.trim() === "") {
+ setError("취소 사유를 입력해주세요.");
+ return;
+ }
+
+ setIsCancelling(true);
+ setError(null);
+ setResults(undefined);
+
+ try {
+ const detailIds = selectedVendors.map(v => v.detailId);
+ const result = await cancelVendorResponse(rfqId, detailIds, cancelReason.trim());
+
+ if (result.results) {
+ setResults(result.results);
+ }
+
+ if (result.success) {
+ // 성공 시 다이얼로그 닫기 및 콜백 호출
+ setTimeout(() => {
+ setCancelReason("");
+ onOpenChange(false);
+ onSuccess?.();
+ }, 1500);
+ } else {
+ setError(result.message);
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "RFQ 취소 중 오류가 발생했습니다.");
+ } finally {
+ setIsCancelling(false);
+ }
+ };
+
+ const handleClose = () => {
+ if (!isCancelling) {
+ setError(null);
+ setResults(undefined);
+ setCancelReason("");
+ onOpenChange(false);
+ }
+ };
+
+ return (
+ <AlertDialog open={open} onOpenChange={handleClose}>
+ <AlertDialogContent className="max-w-2xl">
+ <AlertDialogHeader>
+ <AlertDialogTitle>RFQ 취소</AlertDialogTitle>
+ <AlertDialogDescription className="space-y-4">
+ <div>
+ 선택된 벤더에 대한 RFQ를 취소합니다. 취소 후 해당 벤더는 더 이상 견적을 제출할 수 없습니다.
+ </div>
+
+ {/* 취소 대상 벤더 목록 */}
+ {selectedVendors.length > 0 && (
+ <div className="space-y-2">
+ <p className="font-medium text-sm">취소 대상 벤더 ({selectedVendors.length}건):</p>
+ <div className="max-h-40 overflow-y-auto border rounded-md p-3 space-y-1">
+ {selectedVendors.map((vendor) => (
+ <div key={vendor.detailId} className="text-sm">
+ <span className="font-medium">{vendor.vendorName}</span>
+ {vendor.vendorCode && (
+ <span className="text-muted-foreground ml-2">
+ ({vendor.vendorCode})
+ </span>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 취소 사유 입력 */}
+ <div className="space-y-2">
+ <Label htmlFor="cancelReason">취소 사유 *</Label>
+ <Textarea
+ id="cancelReason"
+ placeholder="RFQ 취소 사유를 입력해주세요..."
+ value={cancelReason}
+ onChange={(e) => setCancelReason(e.target.value)}
+ disabled={isCancelling || !!results}
+ rows={4}
+ className="resize-none"
+ />
+ </div>
+
+ {/* 진행 중 상태 */}
+ {isCancelling && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span>RFQ 취소 처리 중...</span>
+ </div>
+ )}
+
+ {/* 결과 표시 */}
+ {results && !isCancelling && (
+ <div className="space-y-2">
+ <p className="font-medium text-sm">처리 결과:</p>
+ <div className="max-h-40 overflow-y-auto border rounded-md p-3 space-y-2">
+ {results.map((result) => {
+ const vendor = selectedVendors.find(v => v.detailId === result.detailId);
+ return (
+ <div
+ key={result.detailId}
+ className={`text-sm ${
+ result.success ? "text-green-600" : "text-red-600"
+ }`}
+ >
+ <span className="font-medium">
+ {vendor?.vendorName || `Detail ID: ${result.detailId}`}
+ </span>
+ {result.success ? (
+ <span className="ml-2">✅ 취소 완료</span>
+ ) : (
+ <span className="ml-2">
+ ❌ 실패: {result.error || "알 수 없는 오류"}
+ </span>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ )}
+
+ {/* 오류 메시지 */}
+ {error && !isCancelling && (
+ <Alert variant="destructive">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>{error}</AlertDescription>
+ </Alert>
+ )}
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isCancelling}>취소</AlertDialogCancel>
+ {!results && (
+ <AlertDialogAction
+ onClick={handleCancel}
+ disabled={isCancelling || !cancelReason.trim()}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isCancelling ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 취소 중...
+ </>
+ ) : (
+ "RFQ 취소"
+ )}
+ </AlertDialogAction>
+ )}
+ {results && (
+ <AlertDialogAction onClick={handleClose}>
+ 닫기
+ </AlertDialogAction>
+ )}
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ );
+}
+
diff --git a/lib/rfq-last/vendor/price-adjustment-dialog.tsx b/lib/rfq-last/vendor/price-adjustment-dialog.tsx new file mode 100644 index 00000000..b7fd48a6 --- /dev/null +++ b/lib/rfq-last/vendor/price-adjustment-dialog.tsx @@ -0,0 +1,268 @@ +'use client'
+
+import React from 'react'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Badge } from '@/components/ui/badge'
+import { Separator } from '@/components/ui/separator'
+import { format } from 'date-fns'
+import { ko } from 'date-fns/locale'
+
+interface PriceAdjustmentData {
+ id: number
+ itemName?: string | null
+ adjustmentReflectionPoint?: string | null
+ majorApplicableRawMaterial?: string | null
+ adjustmentFormula?: string | null
+ rawMaterialPriceIndex?: string | null
+ referenceDate?: Date | string | null
+ comparisonDate?: Date | string | null
+ adjustmentRatio?: string | null
+ notes?: string | null
+ adjustmentConditions?: string | null
+ majorNonApplicableRawMaterial?: string | null
+ adjustmentPeriod?: string | null
+ contractorWriter?: string | null
+ adjustmentDate?: Date | string | null
+ nonApplicableReason?: string | null
+ createdAt: Date | string
+ updatedAt: Date | string
+}
+
+interface PriceAdjustmentDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ data: PriceAdjustmentData | null
+ vendorName: string
+}
+
+export function PriceAdjustmentDialog({
+ open,
+ onOpenChange,
+ data,
+ vendorName,
+}: PriceAdjustmentDialogProps) {
+ if (!data) return null
+
+ // 날짜 포맷팅 헬퍼
+ const formatDateValue = (date: Date | string | null) => {
+ if (!date) return '-'
+ try {
+ const dateObj = typeof date === 'string' ? new Date(date) : date
+ return format(dateObj, 'yyyy-MM-dd', { locale: ko })
+ } catch {
+ return '-'
+ }
+ }
+
+ // 연동제 적용 여부 판단 (majorApplicableRawMaterial이 있으면 적용, majorNonApplicableRawMaterial이 있으면 미적용)
+ const isApplied = !!data.majorApplicableRawMaterial
+ const isNotApplied = !!data.majorNonApplicableRawMaterial
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <span>하도급대금등 연동표</span>
+ <Badge variant="secondary">{vendorName}</Badge>
+ {isApplied && (
+ <Badge variant="default" className="bg-green-600 hover:bg-green-700">
+ 연동제 적용
+ </Badge>
+ )}
+ {isNotApplied && (
+ <Badge variant="outline" className="border-red-500 text-red-600">
+ 연동제 미적용
+ </Badge>
+ )}
+ </DialogTitle>
+ <DialogDescription>
+ 협력업체가 제출한 연동제 적용 정보입니다.
+ {isApplied && " (연동제 적용)"}
+ {isNotApplied && " (연동제 미적용)"}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* 기본 정보 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">기본 정보</h3>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">품목등의 명칭</label>
+ <p className="text-sm font-medium">{data.itemName || '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">연동제 적용 여부</label>
+ <div className="mt-1">
+ {isApplied && (
+ <Badge variant="default" className="bg-green-600 hover:bg-green-700">
+ 예 (연동제 적용)
+ </Badge>
+ )}
+ {isNotApplied && (
+ <Badge variant="outline" className="border-red-500 text-red-600">
+ 아니오 (연동제 미적용)
+ </Badge>
+ )}
+ {!isApplied && !isNotApplied && (
+ <span className="text-sm text-muted-foreground">-</span>
+ )}
+ </div>
+ </div>
+ {isApplied && (
+ <div>
+ <label className="text-xs text-gray-500">조정대금 반영시점</label>
+ <p className="text-sm font-medium">{data.adjustmentReflectionPoint || '-'}</p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 원재료 정보 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">원재료 정보</h3>
+ <div className="space-y-4">
+ {isApplied && (
+ <div>
+ <label className="text-xs text-gray-500">연동대상 주요 원재료</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.majorApplicableRawMaterial || '-'}
+ </p>
+ </div>
+ )}
+ {isNotApplied && (
+ <>
+ <div>
+ <label className="text-xs text-gray-500">연동 미적용 주요 원재료</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.majorNonApplicableRawMaterial || '-'}
+ </p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">연동 미적용 사유</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.nonApplicableReason || '-'}
+ </p>
+ </div>
+ </>
+ )}
+ </div>
+ </div>
+
+ {isApplied && (
+ <>
+ <Separator />
+
+ {/* 연동 공식 및 지표 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">연동 공식 및 지표</h3>
+ <div className="space-y-4">
+ <div>
+ <label className="text-xs text-gray-500">하도급대금등 연동 산식</label>
+ <div className="p-3 bg-gray-50 rounded-md">
+ <p className="text-sm font-mono whitespace-pre-wrap">
+ {data.adjustmentFormula || '-'}
+ </p>
+ </div>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">원재료 가격 기준지표</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.rawMaterialPriceIndex || '-'}
+ </p>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">기준시점</label>
+ <p className="text-sm font-medium">{data.referenceDate ? formatDateValue(data.referenceDate) : '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">비교시점</label>
+ <p className="text-sm font-medium">{data.comparisonDate ? formatDateValue(data.comparisonDate) : '-'}</p>
+ </div>
+ </div>
+ {data.adjustmentRatio && (
+ <div>
+ <label className="text-xs text-gray-500">연동 비율</label>
+ <p className="text-sm font-medium">
+ {data.adjustmentRatio}%
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 조정 조건 및 기타 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">조정 조건 및 기타</h3>
+ <div className="space-y-4">
+ <div>
+ <label className="text-xs text-gray-500">조정요건</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.adjustmentConditions || '-'}
+ </p>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">조정주기</label>
+ <p className="text-sm font-medium">{data.adjustmentPeriod || '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">조정일</label>
+ <p className="text-sm font-medium">{data.adjustmentDate ? formatDateValue(data.adjustmentDate) : '-'}</p>
+ </div>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">수탁기업(협력사) 작성자</label>
+ <p className="text-sm font-medium">{data.contractorWriter || '-'}</p>
+ </div>
+ {data.notes && (
+ <div>
+ <label className="text-xs text-gray-500">기타 사항</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.notes}
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+ </>
+ )}
+
+ {isNotApplied && (
+ <>
+ <Separator />
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">작성자 정보</h3>
+ <div>
+ <label className="text-xs text-gray-500">수탁기업(협력사) 작성자</label>
+ <p className="text-sm font-medium">{data.contractorWriter || '-'}</p>
+ </div>
+ </div>
+ </>
+ )}
+
+ <Separator />
+
+ {/* 메타 정보 */}
+ <div className="text-xs text-gray-500 space-y-1">
+ <p>작성일: {formatDateValue(data.createdAt)}</p>
+ <p>수정일: {formatDateValue(data.updatedAt)}</p>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index c0f80aca..29aa5f09 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -57,6 +57,7 @@ import { toast } from "sonner"; import { AddVendorDialog } from "./add-vendor-dialog"; import { BatchUpdateConditionsDialog } from "./batch-update-conditions-dialog"; import { SendRfqDialog } from "./send-rfq-dialog"; +import { CancelVendorResponseDialog } from "./cancel-vendor-response-dialog"; import { getRfqSendData, @@ -72,6 +73,7 @@ import { useRouter } from "next/navigation" import { EditContractDialog } from "./edit-contract-dialog"; import { createFilterFn } from "@/components/client-data-table/table-filters"; import { AvlVendorDialog } from "./avl-vendor-dialog"; +import { PriceAdjustmentDialog } from "./price-adjustment-dialog"; // 타입 정의 interface RfqDetail { @@ -286,6 +288,11 @@ export function RfqVendorTable({ const [editContractVendor, setEditContractVendor] = React.useState<any | null>(null); const [isUpdatingShortList, setIsUpdatingShortList] = React.useState(false); const [isAvlDialogOpen, setIsAvlDialogOpen] = React.useState(false); + const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<{ + data: any; + vendorName: string; + } | null>(null); + const [isCancelDialogOpen, setIsCancelDialogOpen] = React.useState(false); // AVL 연동 핸들러 const handleAvlIntegration = React.useCallback(() => { @@ -340,10 +347,20 @@ export function RfqVendorTable({ // 견적 비교 핸들러 const handleQuotationCompare = React.useCallback(() => { - const vendorsWithQuotation = selectedRows.filter(row => + // 취소되지 않은 벤더만 필터링 + const nonCancelledRows = selectedRows.filter(row => { + const isCancelled = row.response?.status === "취소" || row.cancelReason; + return !isCancelled; + }); + + const vendorsWithQuotation = nonCancelledRows.filter(row => row.response?.submission?.submittedAt ); + if (vendorsWithQuotation.length === 0) { + toast.warning("비교할 견적이 있는 벤더를 선택해주세요."); + return; + } // 견적 비교 페이지로 이동 또는 모달 열기 const vendorIds = vendorsWithQuotation @@ -356,20 +373,26 @@ export function RfqVendorTable({ // 일괄 발송 핸들러 const handleBulkSend = React.useCallback(async () => { - if (selectedRows.length === 0) { - toast.warning("발송할 벤더를 선택해주세요."); + // 취소되지 않은 벤더만 필터링 + const nonCancelledRows = selectedRows.filter(row => { + const isCancelled = row.response?.status === "취소" || row.cancelReason; + return !isCancelled; + }); + + if (nonCancelledRows.length === 0) { + toast.warning("발송할 벤더를 선택해주세요. (취소된 벤더는 제외됩니다)"); return; } try { setIsLoadingSendData(true); - // 선택된 벤더 ID들 추출 - const selectedVendorIds = rfqCode?.startsWith("I") ? selectedRows + // 선택된 벤더 ID들 추출 (취소되지 않은 벤더만) + const selectedVendorIds = rfqCode?.startsWith("I") ? nonCancelledRows // .filter(v => v.shortList) .map(row => row.vendorId) .filter(id => id != null) : - selectedRows + nonCancelledRows .map(row => row.vendorId) .filter(id => id != null) @@ -629,6 +652,20 @@ export function RfqVendorTable({ case "response-detail": toast.info(`${vendor.vendorName}의 회신 상세를 확인합니다.`); break; + + case "price-adjustment": + // 연동제 정보 다이얼로그 열기 + const priceAdjustmentForm = vendor.response?.priceAdjustmentForm || + vendor.response?.additionalRequirements?.materialPriceRelated?.priceAdjustmentForm; + if (!priceAdjustmentForm) { + toast.warning("연동제 정보가 없습니다."); + return; + } + setPriceAdjustmentData({ + data: priceAdjustmentForm, + vendorName: vendor.vendorName, + }); + break; } }, [rfqId]); @@ -1300,6 +1337,11 @@ export function RfqVendorTable({ const emailResentCount = vendor.response?.email?.emailResentCount || 0; const hasQuotation = !!vendor.quotationStatus; const isKorean = vendor.vendorCountry === "KR" || vendor.vendorCountry === "한국"; + // 연동제 정보는 최상위 레벨 또는 additionalRequirements에서 확인 + const hasPriceAdjustment = !!( + vendor.response?.priceAdjustmentForm || + vendor.response?.additionalRequirements?.materialPriceRelated?.priceAdjustmentForm + ); return ( <DropdownMenu> @@ -1317,6 +1359,14 @@ export function RfqVendorTable({ 상세보기 </DropdownMenuItem> + {/* 연동제 정보 메뉴 (연동제 정보가 있을 때만 표시) */} + {hasPriceAdjustment && ( + <DropdownMenuItem onClick={() => handleAction("price-adjustment", vendor)}> + <FileText className="mr-2 h-4 w-4" /> + 연동제 정보 + </DropdownMenuItem> + )} + {/* 기본계약 수정 메뉴 추가 */} <DropdownMenuItem onClick={() => handleAction("edit-contract", vendor)}> <FileText className="mr-2 h-4 w-4" /> @@ -1341,7 +1391,7 @@ export function RfqVendorTable({ </> )} - {!emailSentAt && ( + {/* {!emailSentAt && ( <DropdownMenuItem onClick={() => handleAction("send", vendor)} disabled={isLoadingSendData} @@ -1349,7 +1399,7 @@ export function RfqVendorTable({ <Send className="mr-2 h-4 w-4" /> RFQ 발송 </DropdownMenuItem> - )} + )} */} <DropdownMenuSeparator /> <DropdownMenuItem @@ -1545,23 +1595,35 @@ export function RfqVendorTable({ // 선택된 벤더 정보 (BatchUpdate용) const selectedVendorsForBatch = React.useMemo(() => { - return selectedRows.map(row => ({ - id: row.vendorId, - vendorName: row.vendorName, - vendorCode: row.vendorCode, - })); + // 취소되지 않은 벤더만 필터링 + return selectedRows + .filter(row => { + const isCancelled = row.response?.status === "취소" || row.cancelReason; + return !isCancelled; + }) + .map(row => ({ + id: row.vendorId, + vendorName: row.vendorName, + vendorCode: row.vendorCode, + })); }, [selectedRows]); // 추가 액션 버튼들 const additionalActions = React.useMemo(() => { + // 취소되지 않은 벤더만 필터링 (취소된 벤더는 제외) + const nonCancelledRows = selectedRows.filter(row => { + const isCancelled = row.response?.status === "취소" || row.cancelReason; + return !isCancelled; + }); + // 참여 의사가 있는 선택된 벤더 수 계산 const participatingCount = selectedRows.length; const shortListCount = selectedRows.filter(v => v.shortList).length; const vendorsWithResponseCount = selectedRows.filter(v => v.response && v.response.vendor && v.response.isDocumentConfirmed).length; - // 견적서가 있는 선택된 벤더 수 계산 - const quotationCount = selectedRows.filter(row => + // 견적서가 있는 선택된 벤더 수 계산 (취소되지 않은 벤더만) + const quotationCount = nonCancelledRows.filter(row => row.response?.submission?.submittedAt ).length; @@ -1591,23 +1653,23 @@ export function RfqVendorTable({ {selectedRows.length > 0 && ( <> - {/* 정보 일괄 입력 버튼 */} + {/* 정보 일괄 입력 버튼 - 취소되지 않은 벤더만 */} <Button variant="outline" size="sm" onClick={() => setIsBatchUpdateOpen(true)} - disabled={isLoadingSendData} + disabled={isLoadingSendData || nonCancelledRows.length === 0} > <Settings2 className="h-4 w-4 mr-2" /> - 협력업체 조건 설정 ({selectedRows.length}) + 협력업체 조건 설정 ({nonCancelledRows.length}) </Button> - {/* RFQ 발송 버튼 */} + {/* RFQ 발송 버튼 - 취소되지 않은 벤더만 */} <Button variant="outline" size="sm" onClick={handleBulkSend} - disabled={isLoadingSendData || selectedRows.length === 0} + disabled={isLoadingSendData || nonCancelledRows.length === 0} > {isLoadingSendData ? ( <> @@ -1617,11 +1679,24 @@ export function RfqVendorTable({ ) : ( <> <Send className="h-4 w-4 mr-2" /> - RFQ 발송 ({selectedRows.length}) + RFQ 발송 ({nonCancelledRows.length}) </> )} </Button> + {/* RFQ 취소 버튼 - RFQ 발송 후에만 표시 (emailSentAt이 있는 경우) 및 취소되지 않은 벤더만 */} + {rfqDetails.some(detail => detail.emailSentAt) && nonCancelledRows.length > 0 && ( + <Button + variant="destructive" + size="sm" + onClick={() => setIsCancelDialogOpen(true)} + disabled={nonCancelledRows.length === 0} + > + <XCircle className="h-4 w-4 mr-2" /> + RFQ 취소 ({nonCancelledRows.length}) + </Button> + )} + {/* Short List 확정 버튼 */} {!rfqCode?.startsWith("F") && <Button @@ -1646,7 +1721,7 @@ export function RfqVendorTable({ </Button> } - {/* 견적 비교 버튼 */} + {/* 견적 비교 버튼 - 취소되지 않은 벤더만 */} <Button variant="outline" size="sm" @@ -1678,7 +1753,7 @@ export function RfqVendorTable({ </Button> </div> ); - }, [selectedRows, isRefreshing, isLoadingSendData, handleBulkSend, handleShortListConfirm, handleQuotationCompare, isUpdatingShortList]); + }, [selectedRows, isRefreshing, isLoadingSendData, handleBulkSend, handleShortListConfirm, handleQuotationCompare, isUpdatingShortList, rfqInfo, rfqCode, handleAvlIntegration, rfqDetails]); return ( <> @@ -1779,6 +1854,40 @@ export function RfqVendorTable({ router.refresh(); }} /> + + {/* 연동제 정보 다이얼로그 */} + {priceAdjustmentData && ( + <PriceAdjustmentDialog + open={!!priceAdjustmentData} + onOpenChange={(open) => !open && setPriceAdjustmentData(null)} + data={priceAdjustmentData.data} + vendorName={priceAdjustmentData.vendorName} + /> + )} + + {/* RFQ 취소 다이얼로그 - 취소되지 않은 벤더만 전달 */} + <CancelVendorResponseDialog + open={isCancelDialogOpen} + onOpenChange={setIsCancelDialogOpen} + rfqId={rfqId} + selectedVendors={selectedRows + .filter(row => { + const isCancelled = row.response?.status === "취소" || row.cancelReason; + return !isCancelled; + }) + .map(row => ({ + detailId: row.detailId, + vendorId: row.vendorId, + vendorName: row.vendorName || "", + vendorCode: row.vendorCode, + }))} + onSuccess={() => { + setIsCancelDialogOpen(false); + setSelectedRows([]); + router.refresh(); + toast.success("RFQ 취소가 완료되었습니다."); + }} + /> </> ); }
\ No newline at end of file |
