summaryrefslogtreecommitdiff
path: root/lib/rfq-last/vendor
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfq-last/vendor')
-rw-r--r--lib/rfq-last/vendor/add-vendor-dialog.tsx122
-rw-r--r--lib/rfq-last/vendor/cancel-vendor-response-dialog.tsx208
-rw-r--r--lib/rfq-last/vendor/price-adjustment-dialog.tsx268
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx155
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