summaryrefslogtreecommitdiff
path: root/lib/rfq-last
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfq-last')
-rw-r--r--lib/rfq-last/attachment/vendor-response-table.tsx387
-rw-r--r--lib/rfq-last/compare-action.ts500
-rw-r--r--lib/rfq-last/quotation-compare-view.tsx755
-rw-r--r--lib/rfq-last/service.ts376
-rw-r--r--lib/rfq-last/table/rfq-seal-toggle-cell.tsx93
-rw-r--r--lib/rfq-last/table/rfq-table-columns.tsx73
-rw-r--r--lib/rfq-last/table/rfq-table-toolbar-actions.tsx222
-rw-r--r--lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx335
-rw-r--r--lib/rfq-last/vendor-response/service.ts175
-rw-r--r--lib/rfq-last/vendor-response/validations.ts4
-rw-r--r--lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx2
-rw-r--r--lib/rfq-last/vendor-response/vendor-quotations-table.tsx2
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx223
13 files changed, 2600 insertions, 547 deletions
diff --git a/lib/rfq-last/attachment/vendor-response-table.tsx b/lib/rfq-last/attachment/vendor-response-table.tsx
index 6e1a02c8..f9388752 100644
--- a/lib/rfq-last/attachment/vendor-response-table.tsx
+++ b/lib/rfq-last/attachment/vendor-response-table.tsx
@@ -17,7 +17,7 @@ import {
FileCode,
Building2,
Calendar,
- AlertCircle
+ AlertCircle, X
} from "lucide-react";
import { format, formatDistanceToNow, isValid, isBefore, isAfter } from "date-fns";
import { ko } from "date-fns/locale";
@@ -46,6 +46,22 @@ import { cn } from "@/lib/utils";
import { getRfqVendorAttachments } from "@/lib/rfq-last/service";
import { downloadFile } from "@/lib/file-download";
import { toast } from "sonner";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
// 타입 정의
interface VendorAttachment {
@@ -138,24 +154,79 @@ export function VendorResponseTable({
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [selectedRows, setSelectedRows] = React.useState<VendorAttachment[]>([]);
- // 데이터 새로고침
- const handleRefresh = React.useCallback(async () => {
- setIsRefreshing(true);
+
+
+ const [isUpdating, setIsUpdating] = React.useState(false);
+ const [showTypeDialog, setShowTypeDialog] = React.useState(false);
+ const [selectedType, setSelectedType] = React.useState<"구매" | "설계" | "">("");
+ console.log(data,"data")
+
+ const [selectedVendor, setSelectedVendor] = React.useState<string | null>(null);
+
+ const filteredData = React.useMemo(() => {
+ if (!selectedVendor) return data;
+ return data.filter(item => item.vendorName === selectedVendor);
+ }, [data, selectedVendor]);
+
+
+
+ // 데이터 새로고침
+ const handleRefresh = React.useCallback(async () => {
+ setIsRefreshing(true);
+ try {
+ const result = await getRfqVendorAttachments(rfqId);
+ if (result.vendorSuccess && result.vendorData) {
+ setData(result.vendorData);
+ toast.success("데이터를 새로고침했습니다.");
+ } else {
+ toast.error("데이터를 불러오는데 실패했습니다.");
+ }
+ } catch (error) {
+ console.error("Refresh error:", error);
+ toast.error("새로고침 중 오류가 발생했습니다.");
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, [rfqId]);
+
+ const toggleVendorFilter = (vendor: string) => {
+ if (selectedVendor === vendor) {
+ setSelectedVendor(null); // 이미 선택된 벤더를 다시 클릭하면 필터 해제
+ } else {
+ setSelectedVendor(vendor);
+ // 필터 변경 시 선택 초기화 (옵션)
+ setSelectedRows([]);
+ }
+ };
+
+ // 문서 유형 일괄 변경
+ const handleBulkTypeChange = React.useCallback(async () => {
+ if (!selectedType || selectedRows.length === 0) return;
+
+ setIsUpdating(true);
try {
- const result = await getRfqVendorAttachments(rfqId);
- if (result.success && result.data) {
- setData(result.data);
- toast.success("데이터를 새로고침했습니다.");
+ const ids = selectedRows.map(row => row.id);
+ const result = await updateAttachmentTypes(ids, selectedType as "구매" | "설계");
+
+ if (result.success) {
+ toast.success(result.message);
+ // 데이터 새로고침
+ await handleRefresh();
+ // 선택 초기화
+ setSelectedRows([]);
+ setShowTypeDialog(false);
+ setSelectedType("");
} else {
- toast.error("데이터를 불러오는데 실패했습니다.");
+ toast.error(result.message);
}
} catch (error) {
- console.error("Refresh error:", error);
- toast.error("새로고침 중 오류가 발생했습니다.");
+ toast.error("문서 유형 변경 중 오류가 발생했습니다.");
} finally {
- setIsRefreshing(false);
+ setIsUpdating(false);
}
- }, [rfqId]);
+ }, [selectedType, selectedRows, handleRefresh]);
+
+
// 액션 처리
const handleAction = React.useCallback(async (action: DataTableRowAction<VendorAttachment>) => {
@@ -282,56 +353,56 @@ export function VendorResponseTable({
},
size: 300,
},
- {
- accessorKey: "description",
- header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="설명" />,
- cell: ({ row }) => (
- <div className="max-w-[200px] truncate" title={row.original.description || ""}>
- {row.original.description || "-"}
- </div>
- ),
- size: 200,
- },
- {
- accessorKey: "validTo",
- header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="유효기간" />,
- cell: ({ row }) => {
- const { validFrom, validTo } = row.original;
- const validity = checkValidity(validTo);
+ // {
+ // accessorKey: "description",
+ // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="설명" />,
+ // cell: ({ row }) => (
+ // <div className="max-w-[200px] truncate" title={row.original.description || ""}>
+ // {row.original.description || "-"}
+ // </div>
+ // ),
+ // size: 200,
+ // },
+ // {
+ // accessorKey: "validTo",
+ // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="유효기간" />,
+ // cell: ({ row }) => {
+ // const { validFrom, validTo } = row.original;
+ // const validity = checkValidity(validTo);
- if (!validTo) return <span className="text-muted-foreground">-</span>;
+ // if (!validTo) return <span className="text-muted-foreground">-</span>;
- return (
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <div className="flex items-center gap-2">
- {validity === "expired" && (
- <AlertCircle className="h-4 w-4 text-red-500" />
- )}
- {validity === "expiring-soon" && (
- <AlertCircle className="h-4 w-4 text-yellow-500" />
- )}
- <span className={cn(
- "text-sm",
- validity === "expired" && "text-red-500",
- validity === "expiring-soon" && "text-yellow-500"
- )}>
- {format(new Date(validTo), "yyyy-MM-dd")}
- </span>
- </div>
- </TooltipTrigger>
- <TooltipContent>
- <p>유효기간: {validFrom ? format(new Date(validFrom), "yyyy-MM-dd") : "?"} ~ {format(new Date(validTo), "yyyy-MM-dd")}</p>
- {validity === "expired" && <p className="text-red-500">만료됨</p>}
- {validity === "expiring-soon" && <p className="text-yellow-500">곧 만료 예정</p>}
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- );
- },
- size: 120,
- },
+ // return (
+ // <TooltipProvider>
+ // <Tooltip>
+ // <TooltipTrigger asChild>
+ // <div className="flex items-center gap-2">
+ // {validity === "expired" && (
+ // <AlertCircle className="h-4 w-4 text-red-500" />
+ // )}
+ // {validity === "expiring-soon" && (
+ // <AlertCircle className="h-4 w-4 text-yellow-500" />
+ // )}
+ // <span className={cn(
+ // "text-sm",
+ // validity === "expired" && "text-red-500",
+ // validity === "expiring-soon" && "text-yellow-500"
+ // )}>
+ // {format(new Date(validTo), "yyyy-MM-dd")}
+ // </span>
+ // </div>
+ // </TooltipTrigger>
+ // <TooltipContent>
+ // <p>유효기간: {validFrom ? format(new Date(validFrom), "yyyy-MM-dd") : "?"} ~ {format(new Date(validTo), "yyyy-MM-dd")}</p>
+ // {validity === "expired" && <p className="text-red-500">만료됨</p>}
+ // {validity === "expiring-soon" && <p className="text-yellow-500">곧 만료 예정</p>}
+ // </TooltipContent>
+ // </Tooltip>
+ // </TooltipProvider>
+ // );
+ // },
+ // size: 120,
+ // },
{
accessorKey: "responseStatus",
header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="응답 상태" />,
@@ -424,13 +495,13 @@ export function VendorResponseTable({
label: "문서 유형",
type: "select",
options: [
- { label: "견적서", value: "견적서" },
- { label: "기술제안서", value: "기술제안서" },
- { label: "인증서", value: "인증서" },
- { label: "카탈로그", value: "카탈로그" },
- { label: "도면", value: "도면" },
- { label: "테스트성적서", value: "테스트성적서" },
- { label: "기타", value: "기타" },
+ { label: "구매", value: "구매" },
+ { label: "설계", value: "설계" },
+ // { label: "인증서", value: "인증서" },
+ // { label: "카탈로그", value: "카탈로그" },
+ // { label: "도면", value: "도면" },
+ // { label: "테스트성적서", value: "테스트성적서" },
+ // { label: "기타", value: "기타" },
]
},
{ id: "documentNo", label: "문서번호", type: "text" },
@@ -448,23 +519,35 @@ export function VendorResponseTable({
{ label: "취소", value: "취소" },
]
},
- { id: "validFrom", label: "유효시작일", type: "date" },
- { id: "validTo", label: "유효종료일", type: "date" },
+ // { id: "validFrom", label: "유효시작일", type: "date" },
+ // { id: "validTo", label: "유효종료일", type: "date" },
{ id: "uploadedAt", label: "업로드일", type: "date" },
];
- // 추가 액션 버튼들
+ // 추가 액션 버튼들 수정
const additionalActions = React.useMemo(() => (
<div className="flex items-center gap-2">
{selectedRows.length > 0 && (
- <Button
- variant="outline"
- size="sm"
- onClick={handleBulkDownload}
- >
- <Download className="h-4 w-4 mr-2" />
- 다운로드 ({selectedRows.length})
- </Button>
+ <>
+ {/* 문서 유형 변경 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setShowTypeDialog(true)}
+ >
+ <FileText className="h-4 w-4 mr-2" />
+ 유형 변경 ({selectedRows.length})
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleBulkDownload}
+ >
+ <Download className="h-4 w-4 mr-2" />
+ 다운로드 ({selectedRows.length})
+ </Button>
+ </>
)}
<Button
variant="outline"
@@ -476,7 +559,7 @@ export function VendorResponseTable({
새로고침
</Button>
</div>
- ), [selectedRows, isRefreshing, handleBulkDownload, handleRefresh]);
+ ), [selectedRows, isRefreshing, handleBulkDownload, handleRefresh])
// 벤더별 그룹 카운트
const vendorCounts = React.useMemo(() => {
@@ -490,18 +573,71 @@ export function VendorResponseTable({
return (
<div className={cn("w-full space-y-4")}>
- {/* 벤더별 요약 정보 */}
- <div className="flex gap-2 flex-wrap">
- {Array.from(vendorCounts.entries()).map(([vendor, count]) => (
- <Badge key={vendor} variant="secondary">
- {vendor}: {count}
- </Badge>
- ))}
+ {/* 벤더 필터 섹션 */}
+ <div className="space-y-2">
+ {/* 필터 헤더 */}
+ <div className="flex items-center justify-between">
+ <span className="text-sm font-medium text-muted-foreground">
+ 벤더별 필터
+ </span>
+ {selectedVendor && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setSelectedVendor(null)}
+ className="h-7 px-2 text-xs"
+ >
+ <X className="h-3 w-3 mr-1" />
+ 필터 초기화
+ </Button>
+ )}
+ </div>
+
+ {/* 벤더 버튼들 */}
+ <div className="flex gap-2 flex-wrap">
+ {/* 전체 보기 버튼 */}
+ <Button
+ variant={selectedVendor === null ? "default" : "outline"}
+ size="sm"
+ onClick={() => setSelectedVendor(null)}
+ className="h-7"
+ >
+ <span className="text-xs">
+ 전체 ({data.length})
+ </span>
+ </Button>
+
+ {/* 각 벤더별 버튼 */}
+ {Array.from(vendorCounts.entries()).map(([vendor, count]) => (
+ <Button
+ key={vendor}
+ variant={selectedVendor === vendor ? "default" : "outline"}
+ size="sm"
+ onClick={() => toggleVendorFilter(vendor)}
+ className="h-7"
+ >
+ <Building2 className="h-3 w-3 mr-1" />
+ <span className="text-xs">
+ {vendor} ({count})
+ </span>
+ </Button>
+ ))}
+ </div>
+
+ {/* 현재 필터 상태 표시 */}
+ {selectedVendor && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <AlertCircle className="h-3 w-3" />
+ <span>
+ "{selectedVendor}" 벤더의 {filteredData.length}개 항목만 표시 중
+ </span>
+ </div>
+ )}
</div>
<ClientDataTable
columns={columns}
- data={data}
+ data={filteredData} // 필터링된 데이터 사용
advancedFilterFields={advancedFilterFields}
autoSizeColumns={true}
compact={true}
@@ -514,6 +650,81 @@ export function VendorResponseTable({
>
{additionalActions}
</ClientDataTable>
+
+ {/* 문서 유형 변경 다이얼로그 */}
+ <Dialog open={showTypeDialog} onOpenChange={setShowTypeDialog}>
+ <DialogContent className="sm:max-w-[425px]">
+ <DialogHeader>
+ <DialogTitle>문서 유형 변경</DialogTitle>
+ <DialogDescription>
+ 선택한 {selectedRows.length}개 항목의 문서 유형을 변경합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="grid gap-4 py-4">
+ <div className="grid grid-cols-4 items-center gap-4">
+ <label htmlFor="type" className="text-right">
+ 문서 유형
+ </label>
+ <Select
+ value={selectedType}
+ onValueChange={(value) => setSelectedType(value as "구매" | "설계")}
+ >
+ <SelectTrigger className="col-span-3">
+ <SelectValue placeholder="문서 유형 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="구매">구매</SelectItem>
+ <SelectItem value="설계">설계</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 현재 선택된 항목들의 정보 표시 */}
+ <div className="text-sm text-muted-foreground">
+ <p>변경될 항목:</p>
+ <ul className="mt-2 max-h-32 overflow-y-auto space-y-1">
+ {selectedRows.slice(0, 5).map((row) => (
+ <li key={row.id} className="text-xs">
+ • {row.vendorName} - {row.originalFileName}
+ </li>
+ ))}
+ {selectedRows.length > 5 && (
+ <li className="text-xs italic">
+ ... 외 {selectedRows.length - 5}개
+ </li>
+ )}
+ </ul>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setShowTypeDialog(false);
+ setSelectedType("");
+ }}
+ disabled={isUpdating}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleBulkTypeChange}
+ disabled={!selectedType || isUpdating}
+ >
+ {isUpdating ? (
+ <>
+ <RefreshCw className="mr-2 h-4 w-4 animate-spin" />
+ 변경 중...
+ </>
+ ) : (
+ "변경"
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
</div>
);
} \ No newline at end of file
diff --git a/lib/rfq-last/compare-action.ts b/lib/rfq-last/compare-action.ts
new file mode 100644
index 00000000..5d210631
--- /dev/null
+++ b/lib/rfq-last/compare-action.ts
@@ -0,0 +1,500 @@
+"use server";
+
+import db from "@/db/db";
+import { eq, and, inArray } from "drizzle-orm";
+import {
+ rfqsLast,
+ rfqLastDetails,
+ rfqPrItems,
+ rfqLastVendorResponses,
+ rfqLastVendorQuotationItems,
+ vendors,
+ paymentTerms,
+ incoterms,
+} from "@/db/schema";
+
+export interface ComparisonData {
+ rfqInfo: {
+ id: number;
+ rfqCode: string;
+ rfqTitle: string;
+ rfqType: string;
+ projectCode?: string;
+ projectName?: string;
+ dueDate: Date | null;
+ packageNo?: string;
+ packageName?: string;
+ };
+ vendors: VendorComparison[];
+ prItems: PrItemComparison[];
+ summary: {
+ lowestBidder: string;
+ highestBidder: string;
+ priceRange: {
+ min: number;
+ max: number;
+ average: number;
+ };
+ currency: string;
+ };
+}
+
+export interface VendorComparison {
+ vendorId: number;
+ vendorName: string;
+ vendorCode: string;
+ vendorCountry?: string;
+
+ // 응답 정보
+ responseId: number;
+ participationStatus: string;
+ responseStatus: string;
+ submittedAt: Date | null;
+
+ // 가격 정보
+ totalAmount: number;
+ currency: string;
+ rank?: number;
+ priceVariance?: number; // 평균 대비 차이 %
+
+ // 구매자 제시 조건
+ buyerConditions: {
+ currency: string;
+ paymentTermsCode: string;
+ paymentTermsDesc?: string;
+ incotermsCode: string;
+ incotermsDesc?: string;
+ deliveryDate: Date | null;
+ contractDuration?: string;
+ taxCode?: string;
+ placeOfShipping?: string;
+ placeOfDestination?: string;
+
+ // 추가 조건
+ firstYn: boolean;
+ firstDescription?: string;
+ sparepartYn: boolean;
+ sparepartDescription?: string;
+ materialPriceRelatedYn: boolean;
+ };
+
+ // 벤더 제안 조건
+ vendorConditions: {
+ currency?: string;
+ paymentTermsCode?: string;
+ paymentTermsDesc?: string;
+ incotermsCode?: string;
+ incotermsDesc?: string;
+ deliveryDate?: Date | null;
+ contractDuration?: string;
+ taxCode?: string;
+ placeOfShipping?: string;
+ placeOfDestination?: string;
+
+ // 추가 조건 응답
+ firstAcceptance?: "수용" | "부분수용" | "거부";
+ firstDescription?: string;
+ sparepartAcceptance?: "수용" | "부분수용" | "거부";
+ sparepartDescription?: string;
+ materialPriceRelatedYn?: boolean;
+ materialPriceRelatedReason?: string;
+ };
+
+ // 조건 차이 분석
+ conditionDifferences: {
+ hasDifferences: boolean;
+ differences: string[];
+ criticalDifferences: string[]; // 중요한 차이점
+ };
+
+ // 비고
+ generalRemark?: string;
+ technicalProposal?: string;
+}
+
+export interface PrItemComparison {
+ prItemId: number;
+ prNo: string;
+ prItem: string;
+ materialCode: string;
+ materialDescription: string;
+ requestedQuantity: number;
+ uom: string;
+ requestedDeliveryDate: Date | null;
+
+ vendorQuotes: {
+ vendorId: number;
+ vendorName: string;
+ unitPrice: number;
+ totalPrice: number;
+ currency: string;
+ quotedQuantity: number;
+ deliveryDate?: Date | null;
+ leadTime?: number;
+ manufacturer?: string;
+ modelNo?: string;
+ technicalCompliance: boolean;
+ alternativeProposal?: string;
+ itemRemark?: string;
+ priceRank?: number;
+ }[];
+
+ priceAnalysis: {
+ lowestPrice: number;
+ highestPrice: number;
+ averagePrice: number;
+ priceVariance: number; // 표준편차
+ };
+}
+
+export async function getComparisonData(
+ rfqId: number,
+ vendorIds: number[]
+): Promise<ComparisonData | null> {
+ try {
+ // 1. RFQ 기본 정보 조회
+ const rfqData = await db
+ .select({
+ id: rfqsLast.id,
+ rfqCode: rfqsLast.rfqCode,
+ rfqTitle: rfqsLast.rfqTitle,
+ rfqType: rfqsLast.rfqType,
+ // projectCode: rfqsLast.projectCode,
+ // projectName: rfqsLast.projectName,
+ dueDate: rfqsLast.dueDate,
+ packageNo: rfqsLast.packageNo,
+ packageName: rfqsLast.packageName,
+ })
+ .from(rfqsLast)
+ .where(eq(rfqsLast.id, rfqId))
+ .limit(1);
+
+ if (!rfqData[0]) return null;
+
+ // 2. 벤더별 정보 및 응답 조회
+ const vendorData = await db
+ .select({
+ // 벤더 정보
+ vendorId: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ vendorCountry: vendors.country,
+
+ // RFQ Details (구매자 조건)
+ detailId: rfqLastDetails.id,
+ buyerCurrency: rfqLastDetails.currency,
+ buyerPaymentTermsCode: rfqLastDetails.paymentTermsCode,
+ buyerIncotermsCode: rfqLastDetails.incotermsCode,
+ buyerIncotermsDetail: rfqLastDetails.incotermsDetail,
+ buyerDeliveryDate: rfqLastDetails.deliveryDate,
+ buyerContractDuration: rfqLastDetails.contractDuration,
+ buyerTaxCode: rfqLastDetails.taxCode,
+ buyerPlaceOfShipping: rfqLastDetails.placeOfShipping,
+ buyerPlaceOfDestination: rfqLastDetails.placeOfDestination,
+ buyerFirstYn: rfqLastDetails.firstYn,
+ buyerFirstDescription: rfqLastDetails.firstDescription,
+ buyerSparepartYn: rfqLastDetails.sparepartYn,
+ buyerSparepartDescription: rfqLastDetails.sparepartDescription,
+ buyerMaterialPriceRelatedYn: rfqLastDetails.materialPriceRelatedYn,
+
+ // 벤더 응답
+ responseId: rfqLastVendorResponses.id,
+ participationStatus: rfqLastVendorResponses.participationStatus,
+ responseStatus: rfqLastVendorResponses.status,
+ submittedAt: rfqLastVendorResponses.submittedAt,
+ totalAmount: rfqLastVendorResponses.totalAmount,
+ responseCurrency: rfqLastVendorResponses.currency,
+
+ // 벤더 제안 조건
+ vendorCurrency: rfqLastVendorResponses.vendorCurrency,
+ vendorPaymentTermsCode: rfqLastVendorResponses.vendorPaymentTermsCode,
+ vendorIncotermsCode: rfqLastVendorResponses.vendorIncotermsCode,
+ vendorIncotermsDetail: rfqLastVendorResponses.vendorIncotermsDetail,
+ vendorDeliveryDate: rfqLastVendorResponses.vendorDeliveryDate,
+ vendorContractDuration: rfqLastVendorResponses.vendorContractDuration,
+ vendorTaxCode: rfqLastVendorResponses.vendorTaxCode,
+ vendorPlaceOfShipping: rfqLastVendorResponses.vendorPlaceOfShipping,
+ vendorPlaceOfDestination: rfqLastVendorResponses.vendorPlaceOfDestination,
+
+ // 추가 조건 응답
+ vendorFirstAcceptance: rfqLastVendorResponses.vendorFirstAcceptance,
+ vendorFirstDescription: rfqLastVendorResponses.vendorFirstDescription,
+ vendorSparepartAcceptance: rfqLastVendorResponses.vendorSparepartAcceptance,
+ vendorSparepartDescription: rfqLastVendorResponses.vendorSparepartDescription,
+ vendorMaterialPriceRelatedYn: rfqLastVendorResponses.vendorMaterialPriceRelatedYn,
+ vendorMaterialPriceRelatedReason: rfqLastVendorResponses.vendorMaterialPriceRelatedReason,
+
+ // 비고
+ generalRemark: rfqLastVendorResponses.generalRemark,
+ technicalProposal: rfqLastVendorResponses.technicalProposal,
+ })
+ .from(vendors)
+ .innerJoin(
+ rfqLastDetails,
+ and(
+ eq(rfqLastDetails.vendorsId, vendors.id),
+ eq(rfqLastDetails.rfqsLastId, rfqId),
+ eq(rfqLastDetails.isLatest, true)
+ )
+ )
+ .leftJoin(
+ rfqLastVendorResponses,
+ and(
+ eq(rfqLastVendorResponses.vendorId, vendors.id),
+ eq(rfqLastVendorResponses.rfqsLastId, rfqId),
+ eq(rfqLastVendorResponses.isLatest, true)
+ )
+ )
+ .where(inArray(vendors.id, vendorIds));
+
+ // 3. Payment Terms와 Incoterms 설명 조회
+ const paymentTermsData = await db
+ .select({
+ code: paymentTerms.code,
+ description: paymentTerms.description,
+ })
+ .from(paymentTerms);
+
+ const incotermsData = await db
+ .select({
+ code: incoterms.code,
+ description: incoterms.description,
+ })
+ .from(incoterms);
+
+ const paymentTermsMap = new Map(
+ paymentTermsData.map(pt => [pt.code, pt.description])
+ );
+ const incotermsMap = new Map(
+ incotermsData.map(ic => [ic.code, ic.description])
+ );
+
+ // 4. PR Items 조회
+ const prItems = await db
+ .select({
+ id: rfqPrItems.id,
+ prNo: rfqPrItems.prNo,
+ prItem: rfqPrItems.prItem,
+ materialCode: rfqPrItems.materialCode,
+ materialDescription: rfqPrItems.materialDescription,
+ quantity: rfqPrItems.quantity,
+ uom: rfqPrItems.uom,
+ deliveryDate: rfqPrItems.deliveryDate,
+ })
+ .from(rfqPrItems)
+ .where(eq(rfqPrItems.rfqsLastId, rfqId));
+
+ // 5. 벤더별 견적 아이템 조회
+ const quotationItems = await db
+ .select({
+ vendorResponseId: rfqLastVendorQuotationItems.vendorResponseId,
+ prItemId: rfqLastVendorQuotationItems.rfqPrItemId,
+ unitPrice: rfqLastVendorQuotationItems.unitPrice,
+ totalPrice: rfqLastVendorQuotationItems.totalPrice,
+ currency: rfqLastVendorQuotationItems.currency,
+ quantity: rfqLastVendorQuotationItems.quantity,
+ deliveryDate: rfqLastVendorQuotationItems.vendorDeliveryDate,
+ leadTime: rfqLastVendorQuotationItems.leadTime,
+ manufacturer: rfqLastVendorQuotationItems.manufacturer,
+ modelNo: rfqLastVendorQuotationItems.modelNo,
+ technicalCompliance: rfqLastVendorQuotationItems.technicalCompliance,
+ alternativeProposal: rfqLastVendorQuotationItems.alternativeProposal,
+ itemRemark: rfqLastVendorQuotationItems.itemRemark,
+ })
+ .from(rfqLastVendorQuotationItems)
+ .where(
+ inArray(
+ rfqLastVendorQuotationItems.vendorResponseId,
+ vendorData.map(v => v.responseId).filter(id => id != null)
+ )
+ );
+
+ // 6. 데이터 가공 및 분석
+ const validAmounts = vendorData
+ .map(v => v.totalAmount)
+ .filter(a => a != null && a > 0);
+
+ const minAmount = Math.min(...validAmounts);
+ const maxAmount = Math.max(...validAmounts);
+ const avgAmount = validAmounts.reduce((a, b) => a + b, 0) / validAmounts.length;
+
+ // 벤더별 비교 데이터 구성
+ const vendorComparisons: VendorComparison[] = vendorData.map((v, index) => {
+ const differences: string[] = [];
+ const criticalDifferences: string[] = [];
+
+ // 조건 차이 분석
+ if (v.vendorCurrency && v.vendorCurrency !== v.buyerCurrency) {
+ criticalDifferences.push(`통화: ${v.buyerCurrency} → ${v.vendorCurrency}`);
+ }
+
+ if (v.vendorPaymentTermsCode && v.vendorPaymentTermsCode !== v.buyerPaymentTermsCode) {
+ differences.push(`지급조건: ${v.buyerPaymentTermsCode} → ${v.vendorPaymentTermsCode}`);
+ }
+
+ if (v.vendorIncotermsCode && v.vendorIncotermsCode !== v.buyerIncotermsCode) {
+ differences.push(`인코텀즈: ${v.buyerIncotermsCode} → ${v.vendorIncotermsCode}`);
+ }
+
+ if (v.vendorDeliveryDate && v.buyerDeliveryDate) {
+ const buyerDate = new Date(v.buyerDeliveryDate);
+ const vendorDate = new Date(v.vendorDeliveryDate);
+ if (vendorDate > buyerDate) {
+ criticalDifferences.push(`납기: ${Math.ceil((vendorDate.getTime() - buyerDate.getTime()) / (1000 * 60 * 60 * 24))}일 지연`);
+ }
+ }
+
+ if (v.vendorFirstAcceptance === "거부" && v.buyerFirstYn) {
+ criticalDifferences.push("초도품 거부");
+ }
+
+ if (v.vendorSparepartAcceptance === "거부" && v.buyerSparepartYn) {
+ criticalDifferences.push("스페어파트 거부");
+ }
+
+ return {
+ vendorId: v.vendorId,
+ vendorName: v.vendorName,
+ vendorCode: v.vendorCode,
+ vendorCountry: v.vendorCountry,
+
+ responseId: v.responseId || 0,
+ participationStatus: v.participationStatus || "미응답",
+ responseStatus: v.responseStatus || "대기중",
+ submittedAt: v.submittedAt,
+
+ totalAmount: v.totalAmount || 0,
+ currency: v.responseCurrency || v.buyerCurrency || "USD",
+ rank: 0, // 나중에 계산
+ priceVariance: v.totalAmount ? ((v.totalAmount - avgAmount) / avgAmount) * 100 : 0,
+
+ buyerConditions: {
+ currency: v.buyerCurrency || "USD",
+ paymentTermsCode: v.buyerPaymentTermsCode || "",
+ paymentTermsDesc: paymentTermsMap.get(v.buyerPaymentTermsCode || ""),
+ incotermsCode: v.buyerIncotermsCode || "",
+ incotermsDesc: incotermsMap.get(v.buyerIncotermsCode || ""),
+ deliveryDate: v.buyerDeliveryDate,
+ contractDuration: v.buyerContractDuration,
+ taxCode: v.buyerTaxCode,
+ placeOfShipping: v.buyerPlaceOfShipping,
+ placeOfDestination: v.buyerPlaceOfDestination,
+ firstYn: v.buyerFirstYn || false,
+ firstDescription: v.buyerFirstDescription,
+ sparepartYn: v.buyerSparepartYn || false,
+ sparepartDescription: v.buyerSparepartDescription,
+ materialPriceRelatedYn: v.buyerMaterialPriceRelatedYn || false,
+ },
+
+ vendorConditions: {
+ currency: v.vendorCurrency,
+ paymentTermsCode: v.vendorPaymentTermsCode,
+ paymentTermsDesc: paymentTermsMap.get(v.vendorPaymentTermsCode || ""),
+ incotermsCode: v.vendorIncotermsCode,
+ incotermsDesc: incotermsMap.get(v.vendorIncotermsCode || ""),
+ deliveryDate: v.vendorDeliveryDate,
+ contractDuration: v.vendorContractDuration,
+ taxCode: v.vendorTaxCode,
+ placeOfShipping: v.vendorPlaceOfShipping,
+ placeOfDestination: v.vendorPlaceOfDestination,
+ firstAcceptance: v.vendorFirstAcceptance,
+ firstDescription: v.vendorFirstDescription,
+ sparepartAcceptance: v.vendorSparepartAcceptance,
+ sparepartDescription: v.vendorSparepartDescription,
+ materialPriceRelatedYn: v.vendorMaterialPriceRelatedYn,
+ materialPriceRelatedReason: v.vendorMaterialPriceRelatedReason,
+ },
+
+ conditionDifferences: {
+ hasDifferences: differences.length > 0 || criticalDifferences.length > 0,
+ differences,
+ criticalDifferences,
+ },
+
+ generalRemark: v.generalRemark,
+ technicalProposal: v.technicalProposal,
+ };
+ });
+
+ // 가격 순위 계산
+ vendorComparisons.sort((a, b) => a.totalAmount - b.totalAmount);
+ vendorComparisons.forEach((v, index) => {
+ v.rank = index + 1;
+ });
+
+ // PR 아이템별 비교 데이터 구성
+ const prItemComparisons: PrItemComparison[] = prItems.map(item => {
+ const itemQuotes = quotationItems
+ .filter(q => q.prItemId === item.id)
+ .map(q => {
+ const vendor = vendorData.find(v => v.responseId === q.vendorResponseId);
+ return {
+ vendorId: vendor?.vendorId || 0,
+ vendorName: vendor?.vendorName || "",
+ unitPrice: q.unitPrice || 0,
+ totalPrice: q.totalPrice || 0,
+ currency: q.currency || "USD",
+ quotedQuantity: q.quantity || 0,
+ deliveryDate: q.deliveryDate,
+ leadTime: q.leadTime,
+ manufacturer: q.manufacturer,
+ modelNo: q.modelNo,
+ technicalCompliance: q.technicalCompliance || true,
+ alternativeProposal: q.alternativeProposal,
+ itemRemark: q.itemRemark,
+ priceRank: 0,
+ };
+ });
+
+ // 아이템별 가격 순위
+ itemQuotes.sort((a, b) => a.unitPrice - b.unitPrice);
+ itemQuotes.forEach((q, index) => {
+ q.priceRank = index + 1;
+ });
+
+ const unitPrices = itemQuotes.map(q => q.unitPrice);
+ const avgPrice = unitPrices.reduce((a, b) => a + b, 0) / unitPrices.length || 0;
+ const variance = Math.sqrt(
+ unitPrices.reduce((sum, price) => sum + Math.pow(price - avgPrice, 2), 0) / unitPrices.length
+ );
+
+ return {
+ prItemId: item.id,
+ prNo: item.prNo || "",
+ prItem: item.prItem || "",
+ materialCode: item.materialCode || "",
+ materialDescription: item.materialDescription || "",
+ requestedQuantity: item.quantity || 0,
+ uom: item.uom || "",
+ requestedDeliveryDate: item.deliveryDate,
+ vendorQuotes: itemQuotes,
+ priceAnalysis: {
+ lowestPrice: Math.min(...unitPrices) || 0,
+ highestPrice: Math.max(...unitPrices) || 0,
+ averagePrice: avgPrice,
+ priceVariance: variance,
+ },
+ };
+ });
+
+ // 최종 데이터 구성
+ return {
+ rfqInfo: rfqData[0],
+ vendors: vendorComparisons,
+ prItems: prItemComparisons,
+ summary: {
+ lowestBidder: vendorComparisons[0]?.vendorName || "",
+ highestBidder: vendorComparisons[vendorComparisons.length - 1]?.vendorName || "",
+ priceRange: {
+ min: minAmount,
+ max: maxAmount,
+ average: avgAmount,
+ },
+ currency: vendorComparisons[0]?.currency || "USD",
+ },
+ };
+ } catch (error) {
+ console.error("견적 비교 데이터 조회 실패:", error);
+ return null;
+ }
+} \ No newline at end of file
diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx
new file mode 100644
index 00000000..0e15a7bf
--- /dev/null
+++ b/lib/rfq-last/quotation-compare-view.tsx
@@ -0,0 +1,755 @@
+"use client";
+
+import * as React from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Trophy,
+ TrendingUp,
+ TrendingDown,
+ AlertCircle,
+ CheckCircle,
+ XCircle,
+ ChevronDown,
+ ChevronUp,
+ Info,
+ DollarSign,
+ Calendar,
+ Package,
+ Globe,
+ FileText,
+ Truck,
+ AlertTriangle,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+import { format } from "date-fns";
+import { ko } from "date-fns/locale";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import type { ComparisonData, VendorComparison, PrItemComparison } from "../actions";
+
+interface QuotationCompareViewProps {
+ data: ComparisonData;
+}
+
+export function QuotationCompareView({ data }: QuotationCompareViewProps) {
+ const [expandedItems, setExpandedItems] = React.useState<Set<number>>(new Set());
+ const [selectedMetric, setSelectedMetric] = React.useState<"price" | "delivery" | "compliance">("price");
+
+ // 아이템 확장/축소 토글
+ const toggleItemExpansion = (itemId: number) => {
+ setExpandedItems((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(itemId)) {
+ newSet.delete(itemId);
+ } else {
+ newSet.add(itemId);
+ }
+ return newSet;
+ });
+ };
+
+ // 순위에 따른 색상
+ const getRankColor = (rank: number) => {
+ switch (rank) {
+ case 1:
+ return "text-green-600 bg-green-50";
+ case 2:
+ return "text-blue-600 bg-blue-50";
+ case 3:
+ return "text-orange-600 bg-orange-50";
+ default:
+ return "text-gray-600 bg-gray-50";
+ }
+ };
+
+ // 가격 차이 색상
+ const getVarianceColor = (variance: number) => {
+ if (variance < -5) return "text-green-600";
+ if (variance > 5) return "text-red-600";
+ return "text-gray-600";
+ };
+
+ // 조건 일치 여부 아이콘
+ const getComplianceIcon = (matches: boolean) => {
+ return matches ? (
+ <CheckCircle className="h-4 w-4 text-green-500" />
+ ) : (
+ <XCircle className="h-4 w-4 text-red-500" />
+ );
+ };
+
+ // 금액 포맷
+ const formatAmount = (amount: number, currency: string = "USD") => {
+ return new Intl.NumberFormat("ko-KR", {
+ style: "currency",
+ currency: currency,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2,
+ }).format(amount);
+ };
+
+ return (
+ <div className="space-y-6">
+ {/* 요약 카드 */}
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
+ {/* 최저가 벤더 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
+ <Trophy className="h-4 w-4 text-yellow-500" />
+ 최저가 벤더
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <p className="text-lg font-bold">{data.summary.lowestBidder}</p>
+ <p className="text-sm text-muted-foreground">
+ {formatAmount(data.summary.priceRange.min, data.summary.currency)}
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* 평균 가격 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
+ <DollarSign className="h-4 w-4" />
+ 평균 가격
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <p className="text-lg font-bold">
+ {formatAmount(data.summary.priceRange.average, data.summary.currency)}
+ </p>
+ <p className="text-sm text-muted-foreground">
+ {data.vendors.length}개 업체 평균
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* 가격 범위 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
+ <TrendingUp className="h-4 w-4" />
+ 가격 범위
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <p className="text-lg font-bold">
+ {((data.summary.priceRange.max - data.summary.priceRange.min) / data.summary.priceRange.min * 100).toFixed(1)}%
+ </p>
+ <p className="text-sm text-muted-foreground">
+ 최저가 대비 최고가 차이
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* 조건 불일치 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
+ <AlertCircle className="h-4 w-4 text-orange-500" />
+ 조건 불일치
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <p className="text-lg font-bold">
+ {data.vendors.filter(v => v.conditionDifferences.hasDifferences).length}개
+ </p>
+ <p className="text-sm text-muted-foreground">
+ 제시 조건과 차이 있음
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* 탭 뷰 */}
+ <Tabs defaultValue="overview" className="w-full">
+ <TabsList className="grid w-full grid-cols-4">
+ <TabsTrigger value="overview">종합 비교</TabsTrigger>
+ <TabsTrigger value="conditions">조건 비교</TabsTrigger>
+ <TabsTrigger value="items">아이템별 비교</TabsTrigger>
+ <TabsTrigger value="analysis">상세 분석</TabsTrigger>
+ </TabsList>
+
+ {/* 종합 비교 */}
+ <TabsContent value="overview" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>가격 순위</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-4">
+ {data.vendors.map((vendor) => (
+ <div
+ key={vendor.vendorId}
+ className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 transition-colors"
+ >
+ <div className="flex items-center gap-4">
+ <div
+ className={cn(
+ "w-10 h-10 rounded-full flex items-center justify-center font-bold",
+ getRankColor(vendor.rank || 0)
+ )}
+ >
+ {vendor.rank}
+ </div>
+ <div>
+ <p className="font-semibold">{vendor.vendorName}</p>
+ <p className="text-sm text-muted-foreground">
+ {vendor.vendorCode} • {vendor.vendorCountry}
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-6">
+ {/* 조건 차이 표시 */}
+ {vendor.conditionDifferences.criticalDifferences.length > 0 && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Badge variant="destructive" className="gap-1">
+ <AlertTriangle className="h-3 w-3" />
+ 중요 차이 {vendor.conditionDifferences.criticalDifferences.length}
+ </Badge>
+ </TooltipTrigger>
+ <TooltipContent>
+ <div className="space-y-1">
+ {vendor.conditionDifferences.criticalDifferences.map((diff, idx) => (
+ <p key={idx} className="text-xs">{diff}</p>
+ ))}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+
+ {/* 가격 정보 */}
+ <div className="text-right">
+ <p className="text-lg font-bold">
+ {formatAmount(vendor.totalAmount, vendor.currency)}
+ </p>
+ <p className={cn("text-sm", getVarianceColor(vendor.priceVariance || 0))}>
+ {vendor.priceVariance && vendor.priceVariance > 0 ? "+" : ""}
+ {vendor.priceVariance?.toFixed(1)}% vs 평균
+ </p>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 조건 비교 */}
+ <TabsContent value="conditions" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>거래 조건 비교</CardTitle>
+ </CardHeader>
+ <CardContent className="overflow-x-auto">
+ <table className="w-full">
+ <thead>
+ <tr className="border-b">
+ <th className="text-left p-2">항목</th>
+ <th className="text-left p-2">구매자 제시</th>
+ {data.vendors.map((vendor) => (
+ <th key={vendor.vendorId} className="text-left p-2">
+ {vendor.vendorName}
+ </th>
+ ))}
+ </tr>
+ </thead>
+ <tbody className="divide-y">
+ {/* 통화 */}
+ <tr>
+ <td className="p-2 font-medium">통화</td>
+ <td className="p-2">{data.vendors[0]?.buyerConditions.currency}</td>
+ {data.vendors.map((vendor) => (
+ <td key={vendor.vendorId} className="p-2">
+ <div className="flex items-center gap-2">
+ {vendor.vendorConditions.currency || vendor.buyerConditions.currency}
+ {vendor.vendorConditions.currency !== vendor.buyerConditions.currency && (
+ <Badge variant="outline" className="text-xs">변경</Badge>
+ )}
+ </div>
+ </td>
+ ))}
+ </tr>
+
+ {/* 지급조건 */}
+ <tr>
+ <td className="p-2 font-medium">지급조건</td>
+ <td className="p-2">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ {data.vendors[0]?.buyerConditions.paymentTermsCode}
+ </TooltipTrigger>
+ <TooltipContent>
+ {data.vendors[0]?.buyerConditions.paymentTermsDesc}
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </td>
+ {data.vendors.map((vendor) => (
+ <td key={vendor.vendorId} className="p-2">
+ <div className="flex items-center gap-2">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ {vendor.vendorConditions.paymentTermsCode || vendor.buyerConditions.paymentTermsCode}
+ </TooltipTrigger>
+ <TooltipContent>
+ {vendor.vendorConditions.paymentTermsDesc || vendor.buyerConditions.paymentTermsDesc}
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ {vendor.vendorConditions.paymentTermsCode &&
+ vendor.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && (
+ <Badge variant="outline" className="text-xs">변경</Badge>
+ )}
+ </div>
+ </td>
+ ))}
+ </tr>
+
+ {/* 인코텀즈 */}
+ <tr>
+ <td className="p-2 font-medium">인코텀즈</td>
+ <td className="p-2">{data.vendors[0]?.buyerConditions.incotermsCode}</td>
+ {data.vendors.map((vendor) => (
+ <td key={vendor.vendorId} className="p-2">
+ <div className="flex items-center gap-2">
+ {vendor.vendorConditions.incotermsCode || vendor.buyerConditions.incotermsCode}
+ {vendor.vendorConditions.incotermsCode !== vendor.buyerConditions.incotermsCode && (
+ <Badge variant="outline" className="text-xs">변경</Badge>
+ )}
+ </div>
+ </td>
+ ))}
+ </tr>
+
+ {/* 납기 */}
+ <tr>
+ <td className="p-2 font-medium">납기</td>
+ <td className="p-2">
+ {data.vendors[0]?.buyerConditions.deliveryDate
+ ? format(new Date(data.vendors[0].buyerConditions.deliveryDate), "yyyy-MM-dd")
+ : "-"}
+ </td>
+ {data.vendors.map((vendor) => {
+ const vendorDate = vendor.vendorConditions.deliveryDate || vendor.buyerConditions.deliveryDate;
+ const isDelayed = vendorDate && vendor.buyerConditions.deliveryDate &&
+ new Date(vendorDate) > new Date(vendor.buyerConditions.deliveryDate);
+
+ return (
+ <td key={vendor.vendorId} className="p-2">
+ <div className="flex items-center gap-2">
+ {vendorDate ? format(new Date(vendorDate), "yyyy-MM-dd") : "-"}
+ {isDelayed && (
+ <Badge variant="destructive" className="text-xs">지연</Badge>
+ )}
+ </div>
+ </td>
+ );
+ })}
+ </tr>
+
+ {/* 초도품 */}
+ <tr>
+ <td className="p-2 font-medium">초도품</td>
+ <td className="p-2">
+ {data.vendors[0]?.buyerConditions.firstYn ? "요구" : "해당없음"}
+ </td>
+ {data.vendors.map((vendor) => (
+ <td key={vendor.vendorId} className="p-2">
+ {vendor.buyerConditions.firstYn && (
+ <Badge
+ variant={
+ vendor.vendorConditions.firstAcceptance === "수용"
+ ? "default"
+ : vendor.vendorConditions.firstAcceptance === "부분수용"
+ ? "secondary"
+ : vendor.vendorConditions.firstAcceptance === "거부"
+ ? "destructive"
+ : "outline"
+ }
+ >
+ {vendor.vendorConditions.firstAcceptance || "미응답"}
+ </Badge>
+ )}
+ {!vendor.buyerConditions.firstYn && "-"}
+ </td>
+ ))}
+ </tr>
+
+ {/* 스페어파트 */}
+ <tr>
+ <td className="p-2 font-medium">스페어파트</td>
+ <td className="p-2">
+ {data.vendors[0]?.buyerConditions.sparepartYn ? "요구" : "해당없음"}
+ </td>
+ {data.vendors.map((vendor) => (
+ <td key={vendor.vendorId} className="p-2">
+ {vendor.buyerConditions.sparepartYn && (
+ <Badge
+ variant={
+ vendor.vendorConditions.sparepartAcceptance === "수용"
+ ? "default"
+ : vendor.vendorConditions.sparepartAcceptance === "부분수용"
+ ? "secondary"
+ : vendor.vendorConditions.sparepartAcceptance === "거부"
+ ? "destructive"
+ : "outline"
+ }
+ >
+ {vendor.vendorConditions.sparepartAcceptance || "미응답"}
+ </Badge>
+ )}
+ {!vendor.buyerConditions.sparepartYn && "-"}
+ </td>
+ ))}
+ </tr>
+
+ {/* 연동제 */}
+ <tr>
+ <td className="p-2 font-medium">연동제</td>
+ <td className="p-2">
+ {data.vendors[0]?.buyerConditions.materialPriceRelatedYn ? "적용" : "미적용"}
+ </td>
+ {data.vendors.map((vendor) => (
+ <td key={vendor.vendorId} className="p-2">
+ <div className="flex items-center gap-2">
+ {vendor.vendorConditions.materialPriceRelatedYn !== undefined
+ ? vendor.vendorConditions.materialPriceRelatedYn ? "적용" : "미적용"
+ : vendor.buyerConditions.materialPriceRelatedYn ? "적용" : "미적용"}
+ {vendor.vendorConditions.materialPriceRelatedReason && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Info className="h-3 w-3" />
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="max-w-xs text-xs">
+ {vendor.vendorConditions.materialPriceRelatedReason}
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+ </div>
+ </td>
+ ))}
+ </tr>
+ </tbody>
+ </table>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 아이템별 비교 */}
+ <TabsContent value="items" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>PR 아이템별 가격 비교</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-2">
+ {data.prItems.map((item) => (
+ <Collapsible
+ key={item.prItemId}
+ open={expandedItems.has(item.prItemId)}
+ onOpenChange={() => toggleItemExpansion(item.prItemId)}
+ >
+ <div className="border rounded-lg">
+ <CollapsibleTrigger className="w-full p-4 hover:bg-gray-50 transition-colors">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-4 text-left">
+ <div className="flex items-center gap-2">
+ {expandedItems.has(item.prItemId) ? (
+ <ChevronUp className="h-4 w-4" />
+ ) : (
+ <ChevronDown className="h-4 w-4" />
+ )}
+ <Package className="h-4 w-4 text-muted-foreground" />
+ </div>
+ <div>
+ <p className="font-medium">{item.materialDescription}</p>
+ <p className="text-sm text-muted-foreground">
+ {item.materialCode} • {item.prNo} • {item.requestedQuantity} {item.uom}
+ </p>
+ </div>
+ </div>
+ <div className="text-right">
+ <p className="text-sm text-muted-foreground">단가 범위</p>
+ <p className="font-semibold">
+ {formatAmount(item.priceAnalysis.lowestPrice)} ~ {formatAmount(item.priceAnalysis.highestPrice)}
+ </p>
+ </div>
+ </div>
+ </CollapsibleTrigger>
+
+ <CollapsibleContent>
+ <div className="p-4 pt-0">
+ <table className="w-full">
+ <thead>
+ <tr className="border-b text-sm">
+ <th className="text-left p-2">벤더</th>
+ <th className="text-right p-2">단가</th>
+ <th className="text-right p-2">총액</th>
+ <th className="text-right p-2">수량</th>
+ <th className="text-left p-2">납기</th>
+ <th className="text-left p-2">제조사</th>
+ <th className="text-center p-2">순위</th>
+ </tr>
+ </thead>
+ <tbody className="divide-y">
+ {item.vendorQuotes.map((quote) => (
+ <tr key={quote.vendorId} className="text-sm">
+ <td className="p-2 font-medium">{quote.vendorName}</td>
+ <td className="p-2 text-right">
+ {formatAmount(quote.unitPrice, quote.currency)}
+ </td>
+ <td className="p-2 text-right">
+ {formatAmount(quote.totalPrice, quote.currency)}
+ </td>
+ <td className="p-2 text-right">{quote.quotedQuantity}</td>
+ <td className="p-2">
+ {quote.deliveryDate
+ ? format(new Date(quote.deliveryDate), "yyyy-MM-dd")
+ : quote.leadTime
+ ? `${quote.leadTime}일`
+ : "-"}
+ </td>
+ <td className="p-2">
+ {quote.manufacturer && (
+ <div>
+ <p>{quote.manufacturer}</p>
+ {quote.modelNo && (
+ <p className="text-xs text-muted-foreground">{quote.modelNo}</p>
+ )}
+ </div>
+ )}
+ </td>
+ <td className="p-2 text-center">
+ <Badge className={cn("", getRankColor(quote.priceRank || 0))}>
+ #{quote.priceRank}
+ </Badge>
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+
+ {/* 가격 분석 요약 */}
+ <div className="mt-4 p-3 bg-gray-50 rounded-lg">
+ <div className="grid grid-cols-4 gap-4 text-sm">
+ <div>
+ <p className="text-muted-foreground">평균 단가</p>
+ <p className="font-semibold">
+ {formatAmount(item.priceAnalysis.averagePrice)}
+ </p>
+ </div>
+ <div>
+ <p className="text-muted-foreground">가격 편차</p>
+ <p className="font-semibold">
+ ±{formatAmount(item.priceAnalysis.priceVariance)}
+ </p>
+ </div>
+ <div>
+ <p className="text-muted-foreground">최저가 업체</p>
+ <p className="font-semibold">
+ {item.vendorQuotes.find(q => q.priceRank === 1)?.vendorName}
+ </p>
+ </div>
+ <div>
+ <p className="text-muted-foreground">가격 차이</p>
+ <p className="font-semibold">
+ {((item.priceAnalysis.highestPrice - item.priceAnalysis.lowestPrice) /
+ item.priceAnalysis.lowestPrice * 100).toFixed(1)}%
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </CollapsibleContent>
+ </div>
+ </Collapsible>
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 상세 분석 */}
+ <TabsContent value="analysis" className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* 위험 요소 분석 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <AlertTriangle className="h-5 w-5 text-orange-500" />
+ 위험 요소 분석
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-3">
+ {data.vendors.map((vendor) => {
+ if (!vendor.conditionDifferences.hasDifferences) return null;
+
+ return (
+ <div key={vendor.vendorId} className="p-3 border rounded-lg">
+ <p className="font-medium mb-2">{vendor.vendorName}</p>
+ {vendor.conditionDifferences.criticalDifferences.length > 0 && (
+ <div className="space-y-1 mb-2">
+ <p className="text-xs font-medium text-red-600">중요 차이점:</p>
+ {vendor.conditionDifferences.criticalDifferences.map((diff, idx) => (
+ <p key={idx} className="text-xs text-red-600 pl-2">• {diff}</p>
+ ))}
+ </div>
+ )}
+ {vendor.conditionDifferences.differences.length > 0 && (
+ <div className="space-y-1">
+ <p className="text-xs font-medium text-orange-600">일반 차이점:</p>
+ {vendor.conditionDifferences.differences.map((diff, idx) => (
+ <p key={idx} className="text-xs text-orange-600 pl-2">• {diff}</p>
+ ))}
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 추천 사항 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Info className="h-5 w-5 text-blue-500" />
+ 선정 추천
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-4">
+ {/* 가격 기준 추천 */}
+ <div className="p-3 bg-green-50 border border-green-200 rounded-lg">
+ <p className="font-medium text-green-800 mb-1">가격 우선 선정</p>
+ <p className="text-sm text-green-700">
+ {data.vendors[0]?.vendorName} - {formatAmount(data.vendors[0]?.totalAmount || 0)}
+ </p>
+ {data.vendors[0]?.conditionDifferences.hasDifferences && (
+ <p className="text-xs text-orange-600 mt-1">
+ ⚠️ 조건 차이 검토 필요
+ </p>
+ )}
+ </div>
+
+ {/* 조건 준수 기준 추천 */}
+ <div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
+ <p className="font-medium text-blue-800 mb-1">조건 준수 우선 선정</p>
+ {(() => {
+ const compliantVendor = data.vendors.find(v => !v.conditionDifferences.hasDifferences);
+ if (compliantVendor) {
+ return (
+ <div>
+ <p className="text-sm text-blue-700">
+ {compliantVendor.vendorName} - {formatAmount(compliantVendor.totalAmount)}
+ </p>
+ <p className="text-xs text-blue-600 mt-1">
+ 모든 조건 충족 (가격 순위: #{compliantVendor.rank})
+ </p>
+ </div>
+ );
+ }
+ return (
+ <p className="text-sm text-blue-700">
+ 모든 조건을 충족하는 벤더 없음
+ </p>
+ );
+ })()}
+ </div>
+
+ {/* 균형 추천 */}
+ <div className="p-3 bg-purple-50 border border-purple-200 rounded-lg">
+ <p className="font-medium text-purple-800 mb-1">균형 선정 (추천)</p>
+ {(() => {
+ // 가격 순위와 조건 차이를 고려한 점수 계산
+ const scoredVendors = data.vendors.map(v => ({
+ ...v,
+ score: (v.rank || 10) + v.conditionDifferences.criticalDifferences.length * 3 +
+ v.conditionDifferences.differences.length
+ }));
+ scoredVendors.sort((a, b) => a.score - b.score);
+ const recommended = scoredVendors[0];
+
+ return (
+ <div>
+ <p className="text-sm text-purple-700">
+ {recommended.vendorName} - {formatAmount(recommended.totalAmount)}
+ </p>
+ <p className="text-xs text-purple-600 mt-1">
+ 가격 순위 #{recommended.rank}, 조건 차이 최소화
+ </p>
+ </div>
+ );
+ })()}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* 벤더별 비고사항 */}
+ {data.vendors.some(v => v.generalRemark || v.technicalProposal) && (
+ <Card>
+ <CardHeader>
+ <CardTitle>벤더 제안사항 및 비고</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-4">
+ {data.vendors.map((vendor) => {
+ if (!vendor.generalRemark && !vendor.technicalProposal) return null;
+
+ return (
+ <div key={vendor.vendorId} className="border rounded-lg p-4">
+ <p className="font-medium mb-2">{vendor.vendorName}</p>
+ {vendor.generalRemark && (
+ <div className="mb-2">
+ <p className="text-sm font-medium text-muted-foreground">일반 비고:</p>
+ <p className="text-sm">{vendor.generalRemark}</p>
+ </div>
+ )}
+ {vendor.technicalProposal && (
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">기술 제안:</p>
+ <p className="text-sm">{vendor.technicalProposal}</p>
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </TabsContent>
+ </Tabs>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index 9943c02d..02429b6a 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -2847,7 +2847,7 @@ export async function sendRfqToVendors({
const picInfo = await getPicInfo(rfqData.picId, rfqData.picName);
// 3. 프로젝트 정보 조회
- const projectInfo = rfqData.projectId
+ const projectInfo = rfqData.projectId
? await getProjectInfo(rfqData.projectId)
: null;
@@ -2856,7 +2856,7 @@ export async function sendRfqToVendors({
const designAttachments = await getDesignAttachments(rfqId);
// 5. 벤더별 처리
- const { results, errors, savedContracts, tbeSessionsCreated } =
+ const { results, errors, savedContracts, tbeSessionsCreated } =
await processVendors({
rfqId,
rfqData,
@@ -2979,17 +2979,26 @@ async function prepareEmailAttachments(rfqId: number, attachmentIds: number[]) {
);
const emailAttachments = [];
-
+
for (const { attachment, revision } of attachments) {
if (revision?.filePath) {
try {
- const fullPath = path.join(
- process.cwd(),
- `${process.env.NAS_PATH}`,
+
+ const isProduction = process.env.NODE_ENV === "production";
+
+ const fullPath = isProduction
+
+ path.join(
+ process.cwd(),
+ `public`,
+ revision.filePath
+ )
+ : path.join(
+ `${process.env.NAS_PATH}`,
revision.filePath
);
const fileBuffer = await fs.readFile(fullPath);
-
+
emailAttachments.push({
filename: revision.originalFileName,
content: fileBuffer,
@@ -3052,9 +3061,9 @@ async function processVendors({
// PDF 저장 디렉토리 준비
const contractsDir = path.join(
- process.cwd(),
- `${process.env.NAS_PATH}`,
- "contracts",
+ process.cwd(),
+ `${process.env.NAS_PATH}`,
+ "contracts",
"generated"
);
await mkdir(contractsDir, { recursive: true });
@@ -3077,18 +3086,18 @@ async function processVendors({
});
results.push(vendorResult.result);
-
+
if (vendorResult.contracts) {
savedContracts.push(...vendorResult.contracts);
}
-
+
if (vendorResult.tbeSession) {
tbeSessionsCreated.push(vendorResult.tbeSession);
}
} catch (error) {
console.error(`벤더 ${vendor.vendorName} 처리 실패:`, error);
-
+
errors.push({
vendorId: vendor.vendorId,
vendorName: vendor.vendorName,
@@ -3182,7 +3191,7 @@ function prepareEmailRecipients(vendor: any, picEmail: string) {
vendor.customEmails?.forEach((custom: any) => {
if (custom.email !== vendor.selectedMainEmail &&
- !vendor.additionalEmails.includes(custom.email)) {
+ !vendor.additionalEmails.includes(custom.email)) {
ccEmails.push(custom.email);
}
});
@@ -3235,14 +3244,14 @@ async function handleRfqDetail({
);
// 새 detail 생성
- const {
- id,
- updatedBy,
- updatedAt,
- isLatest,
- sendVersion: oldSendVersion,
- emailResentCount,
- ...restRfqDetail
+ const {
+ id,
+ updatedBy,
+ updatedAt,
+ isLatest,
+ sendVersion: oldSendVersion,
+ emailResentCount,
+ ...restRfqDetail
} = rfqDetail;
const [newRfqDetail] = await tx
@@ -3265,7 +3274,7 @@ async function handleRfqDetail({
})
.returning();
- await tx
+ await tx
.update(basicContract)
.set({
rfqCompanyId: newRfqDetail.id,
@@ -3273,7 +3282,7 @@ async function handleRfqDetail({
.where(
and(
eq(basicContract.rfqCompanyId, rfqDetail.id),
- eq(rfqLastDetails.vendorsId, vendor.vendorId),
+ eq(basicContract.vendorId, vendor.vendorId),
)
);
@@ -3382,7 +3391,7 @@ async function createOrUpdateContract({
})
.where(eq(basicContract.id, existingContract.id))
.returning();
-
+
return { ...updated, isUpdated: true };
} else {
// 새로 생성
@@ -3401,7 +3410,7 @@ async function createOrUpdateContract({
updatedAt: new Date()
})
.returning();
-
+
return { ...created, isUpdated: false };
}
}
@@ -3503,11 +3512,11 @@ async function handleTbeSession({
sessionType: "initial",
status: "준비중",
evaluationResult: null,
- plannedStartDate: rfqData.dueDate
- ? addDays(new Date(rfqData.dueDate), 1)
+ plannedStartDate: rfqData.dueDate
+ ? addDays(new Date(rfqData.dueDate), 1)
: addDays(new Date(), 14),
- plannedEndDate: rfqData.dueDate
- ? addDays(new Date(rfqData.dueDate), 7)
+ plannedEndDate: rfqData.dueDate
+ ? addDays(new Date(rfqData.dueDate), 7)
: addDays(new Date(), 21),
leadEvaluatorId: rfqData.picId,
createdBy: Number(currentUser.id),
@@ -3536,11 +3545,11 @@ async function handleTbeSession({
async function generateTbeSessionCode(tx: any) {
const year = new Date().getFullYear();
const pattern = `TBE-${year}-%`;
-
+
const [lastTbeSession] = await tx
.select({ sessionCode: rfqLastTbeSessions.sessionCode })
.from(rfqLastTbeSessions)
- .where(like(rfqLastTbeSessions.sessionCode,pattern ))
+ .where(like(rfqLastTbeSessions.sessionCode, pattern))
.orderBy(sql`${rfqLastTbeSessions.sessionCode} DESC`)
.limit(1);
@@ -3624,7 +3633,7 @@ async function updateRfqStatus(rfqId: number, userId: number) {
updatedAt: new Date()
})
.where(eq(rfqsLast.id, rfqId));
- }
+}
export async function updateRfqDueDate(
rfqId: number,
@@ -4006,4 +4015,305 @@ function getTemplateNameByType(
case "기술자료": return "기술";
default: return contractType;
}
+}
+
+
+export async function updateAttachmentTypes(
+ attachmentIds: number[],
+ attachmentType: "구매" | "설계"
+) {
+ try {
+ // 권한 체크 등 필요시 추가
+
+ await db
+ .update(rfqLastVendorAttachments)
+ .set({ attachmentType })
+ .where(inArray(rfqLastVendorAttachments.id, attachmentIds));
+
+ // 페이지 리밸리데이션
+ // revalidatePath("/rfq");
+
+ return { success: true, message: `${attachmentIds.length}개 항목이 "${attachmentType}"로 변경되었습니다.` };
+ } catch (error) {
+ console.error("Failed to update attachment types:", error);
+ return { success: false, message: "문서 유형 변경에 실패했습니다." };
+ }
+}
+
+// 단일 RFQ 밀봉 토글
+export async function toggleRfqSealed(rfqId: number) {
+ try {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+ // 현재 상태 조회
+ const [currentRfq] = await db
+ .select({ rfqSealedYn: rfqsLast.rfqSealedYn })
+ .from(rfqsLast)
+ .where(eq(rfqsLast.id, rfqId));
+
+ if (!currentRfq) {
+ throw new Error("RFQ를 찾을 수 없습니다.");
+ }
+
+ // 상태 토글
+ const [updated] = await db
+ .update(rfqsLast)
+ .set({
+ rfqSealedYn: !currentRfq.rfqSealedYn,
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(rfqsLast.id, rfqId))
+ .returning();
+
+ revalidatePath("/evcp/rfq-last");
+
+ return {
+ success: true,
+ data: updated,
+ message: updated.rfqSealedYn ? "견적이 밀봉되었습니다." : "견적 밀봉이 해제되었습니다.",
+ };
+ } catch (error) {
+ console.error("RFQ 밀봉 상태 변경 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 여러 RFQ 일괄 밀봉
+export async function sealMultipleRfqs(rfqIds: number[]) {
+ try {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ if (!rfqIds || rfqIds.length === 0) {
+ throw new Error("선택된 RFQ가 없습니다.");
+ }
+
+ const updated = await db
+ .update(rfqsLast)
+ .set({
+ rfqSealedYn: true,
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(inArray(rfqsLast.id, rfqIds))
+ .returning();
+
+ revalidatePath("/evcp/rfq-last");
+
+ return {
+ success: true,
+ count: updated.length,
+ message: `${updated.length}건의 견적이 밀봉되었습니다.`,
+ };
+ } catch (error) {
+ console.error("RFQ 일괄 밀봉 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 여러 RFQ 일괄 밀봉 해제
+export async function unsealMultipleRfqs(rfqIds: number[]) {
+ try {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+ if (!rfqIds || rfqIds.length === 0) {
+ throw new Error("선택된 RFQ가 없습니다.");
+ }
+
+ const updated = await db
+ .update(rfqsLast)
+ .set({
+ rfqSealedYn: false,
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(inArray(rfqsLast.id, rfqIds))
+ .returning();
+
+ revalidatePath("/evcp/rfq-last");
+
+ return {
+ success: true,
+ count: updated.length,
+ message: `${updated.length}건의 견적 밀봉이 해제되었습니다.`,
+ };
+ } catch (error) {
+ console.error("RFQ 밀봉 해제 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 단일 RFQ 밀봉 (밀봉만)
+export async function sealRfq(rfqId: number) {
+ try {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const [updated] = await db
+ .update(rfqsLast)
+ .set({
+ rfqSealedYn: true,
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(rfqsLast.id, rfqId))
+ .returning();
+
+ if (!updated) {
+ throw new Error("RFQ를 찾을 수 없습니다.");
+ }
+
+ revalidatePath("/evcp/rfq-last");
+
+ return {
+ success: true,
+ data: updated,
+ message: "견적이 밀봉되었습니다.",
+ };
+ } catch (error) {
+ console.error("RFQ 밀봉 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 단일 RFQ 밀봉 해제
+export async function unsealRfq(rfqId: number) {
+ try {
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const [updated] = await db
+ .update(rfqsLast)
+ .set({
+ rfqSealedYn: false,
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(rfqsLast.id, rfqId))
+ .returning();
+
+ if (!updated) {
+ throw new Error("RFQ를 찾을 수 없습니다.");
+ }
+
+ revalidatePath("/evcp/rfq-last");
+
+ return {
+ success: true,
+ data: updated,
+ message: "견적 밀봉이 해제되었습니다.",
+ };
+ } catch (error) {
+ console.error("RFQ 밀봉 해제 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
+ };
+ }
+}
+
+
+
+export async function updateShortList(
+ rfqId: number,
+ vendorIds: number[],
+ shortListStatus: boolean = true
+) {
+ try {
+ // 권한 체크 등 필요한 검증
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ // 트랜잭션으로 처리
+ const result = await db.transaction(async (tx) => {
+ // 해당 RFQ의 모든 벤더들의 shortList를 먼저 false로 설정 (선택적)
+ // 만약 선택된 것만 true로 하고 나머지는 그대로 두려면 이 부분 제거
+ await tx
+ .update(rfqLastDetails)
+ .set({
+ shortList: false,
+ updatedBy: session.user.id,
+ updatedAt: new Date()
+ })
+ .where(
+ and(
+ eq(rfqLastDetails.rfqsLastId, rfqId),
+ eq(rfqLastDetails.isLatest, true)
+ )
+ );
+
+ // 선택된 벤더들의 shortList를 true로 설정
+ if (vendorIds.length > 0) {
+ const updates = await Promise.all(
+ vendorIds.map(vendorId =>
+ tx
+ .update(rfqLastDetails)
+ .set({
+ shortList: shortListStatus,
+ updatedBy: session.user.id,
+ updatedAt: new Date()
+ })
+ .where(
+ and(
+ eq(rfqLastDetails.rfqsLastId, rfqId),
+ eq(rfqLastDetails.vendorsId, vendorId),
+ eq(rfqLastDetails.isLatest, true)
+ )
+ )
+ .returning()
+ )
+ );
+
+ return {
+ success: true,
+ updatedCount: updates.length,
+ vendorIds
+ };
+ }
+
+ return {
+ success: true,
+ updatedCount: 0,
+ vendorIds: []
+ };
+ });
+
+ // revalidatePath(`/buyer/rfq/${rfqId}`);
+ return result;
+
+ } catch (error) {
+ console.error("Short List 업데이트 실패:", error);
+ throw new Error("Short List 업데이트에 실패했습니다.");
+ }
} \ No newline at end of file
diff --git a/lib/rfq-last/table/rfq-seal-toggle-cell.tsx b/lib/rfq-last/table/rfq-seal-toggle-cell.tsx
new file mode 100644
index 00000000..99360978
--- /dev/null
+++ b/lib/rfq-last/table/rfq-seal-toggle-cell.tsx
@@ -0,0 +1,93 @@
+
+"use client";
+
+import * as React from "react";
+import { Lock, LockOpen } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { toast } from "sonner";
+import { toggleRfqSealed } from "../service";
+
+interface RfqSealToggleCellProps {
+ rfqId: number;
+ isSealed: boolean;
+ onUpdate?: () => void;
+}
+
+export function RfqSealToggleCell({
+ rfqId,
+ isSealed,
+ onUpdate
+}: RfqSealToggleCellProps) {
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [currentSealed, setCurrentSealed] = React.useState(isSealed);
+
+ const handleToggle = async (e: React.MouseEvent) => {
+ e.stopPropagation(); // 행 선택 방지
+
+ setIsLoading(true);
+ try {
+ const result = await toggleRfqSealed(rfqId);
+
+ if (result.success) {
+ setCurrentSealed(result.data?.rfqSealedYn ?? !currentSealed);
+ toast.success(result.message);
+ onUpdate?.(); // 테이블 데이터 새로고침
+ } else {
+ toast.error(result.error);
+ }
+ } catch (error) {
+ toast.error("밀봉 상태 변경 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0"
+ onClick={handleToggle}
+ disabled={isLoading}
+ >
+ {currentSealed ? (
+ <Lock className="h-4 w-4 text-red-500" />
+ ) : (
+ <LockOpen className="h-4 w-4 text-gray-400" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{currentSealed ? "밀봉 해제하기" : "밀봉하기"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ );
+}
+
+export const sealColumn = {
+ accessorKey: "rfqSealedYn",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 밀봉" />,
+ cell: ({ row, table }) => (
+ <RfqSealToggleCell
+ rfqId={row.original.id}
+ isSealed={row.original.rfqSealedYn}
+ onUpdate={() => {
+ // 테이블 데이터를 새로고침하는 로직
+ // 이 부분은 상위 컴포넌트에서 refreshData 함수를 prop으로 전달받아 사용
+ const meta = table.options.meta as any;
+ meta?.refreshData?.();
+ }}
+ />
+ ),
+ size: 80,
+ }; \ No newline at end of file
diff --git a/lib/rfq-last/table/rfq-table-columns.tsx b/lib/rfq-last/table/rfq-table-columns.tsx
index 5f5efcb4..eaf00660 100644
--- a/lib/rfq-last/table/rfq-table-columns.tsx
+++ b/lib/rfq-last/table/rfq-table-columns.tsx
@@ -18,6 +18,7 @@ import { DataTableRowAction } from "@/types/table";
import { format, differenceInDays } from "date-fns";
import { ko } from "date-fns/locale";
import { useRouter } from "next/navigation";
+import { RfqSealToggleCell } from "./rfq-seal-toggle-cell";
type NextRouter = ReturnType<typeof useRouter>;
@@ -120,18 +121,18 @@ export function getRfqColumns({
{
accessorKey: "rfqSealedYn",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 밀봉" />,
- cell: ({ row }) => {
- const isSealed = row.original.rfqSealedYn;
- return (
- <div className="flex justify-center">
- {isSealed ? (
- <Lock className="h-4 w-4 text-red-500" />
- ) : (
- <LockOpen className="h-4 w-4 text-gray-400" />
- )}
- </div>
- );
- },
+ cell: ({ row, table }) => (
+ <RfqSealToggleCell
+ rfqId={row.original.id}
+ isSealed={row.original.rfqSealedYn}
+ onUpdate={() => {
+ // 테이블 데이터를 새로고침하는 로직
+ // 이 부분은 상위 컴포넌트에서 refreshData 함수를 prop으로 전달받아 사용
+ const meta = table.options.meta as any;
+ meta?.refreshData?.();
+ }}
+ />
+ ),
size: 80,
},
@@ -453,18 +454,18 @@ export function getRfqColumns({
{
accessorKey: "rfqSealedYn",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 밀봉" />,
- cell: ({ row }) => {
- const isSealed = row.original.rfqSealedYn;
- return (
- <div className="flex justify-center">
- {isSealed ? (
- <Lock className="h-4 w-4 text-red-500" />
- ) : (
- <LockOpen className="h-4 w-4 text-gray-400" />
- )}
- </div>
- );
- },
+ cell: ({ row, table }) => (
+ <RfqSealToggleCell
+ rfqId={row.original.id}
+ isSealed={row.original.rfqSealedYn}
+ onUpdate={() => {
+ // 테이블 데이터를 새로고침하는 로직
+ // 이 부분은 상위 컴포넌트에서 refreshData 함수를 prop으로 전달받아 사용
+ const meta = table.options.meta as any;
+ meta?.refreshData?.();
+ }}
+ />
+ ),
size: 80,
},
@@ -815,18 +816,18 @@ export function getRfqColumns({
{
accessorKey: "rfqSealedYn",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 밀봉" />,
- cell: ({ row }) => {
- const isSealed = row.original.rfqSealedYn;
- return (
- <div className="flex justify-center">
- {isSealed ? (
- <Lock className="h-4 w-4 text-red-500" />
- ) : (
- <LockOpen className="h-4 w-4 text-gray-400" />
- )}
- </div>
- );
- },
+ cell: ({ row, table }) => (
+ <RfqSealToggleCell
+ rfqId={row.original.id}
+ isSealed={row.original.rfqSealedYn}
+ onUpdate={() => {
+ // 테이블 데이터를 새로고침하는 로직
+ // 이 부분은 상위 컴포넌트에서 refreshData 함수를 prop으로 전달받아 사용
+ const meta = table.options.meta as any;
+ meta?.refreshData?.();
+ }}
+ />
+ ),
size: 80,
},
diff --git a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
index 9b696cbd..91b2798f 100644
--- a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
+++ b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
@@ -2,7 +2,7 @@
import * as React from "react";
import { type Table } from "@tanstack/react-table";
-import { Download, RefreshCw, Plus } from "lucide-react";
+import { Download, RefreshCw, Plus, Lock, LockOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -12,8 +12,20 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { toast } from "sonner";
import { RfqsLastView } from "@/db/schema";
import { CreateGeneralRfqDialog } from "./create-general-rfq-dialog";
+import { sealMultipleRfqs, unsealMultipleRfqs } from "../service";
interface RfqTableToolbarActionsProps {
table: Table<RfqsLastView>;
@@ -27,6 +39,43 @@ export function RfqTableToolbarActions({
rfqCategory = "itb",
}: RfqTableToolbarActionsProps) {
const [isExporting, setIsExporting] = React.useState(false);
+ const [isSealing, setIsSealing] = React.useState(false);
+ const [sealDialogOpen, setSealDialogOpen] = React.useState(false);
+ const [sealAction, setSealAction] = React.useState<"seal" | "unseal">("seal");
+
+ const selectedRows = table.getFilteredSelectedRowModel().rows;
+ const selectedRfqIds = selectedRows.map(row => row.original.id);
+
+ // 선택된 항목들의 밀봉 상태 확인
+ const sealedCount = selectedRows.filter(row => row.original.rfqSealedYn).length;
+ const unsealedCount = selectedRows.filter(row => !row.original.rfqSealedYn).length;
+
+ const handleSealAction = React.useCallback(async (action: "seal" | "unseal") => {
+ setSealAction(action);
+ setSealDialogOpen(true);
+ }, []);
+
+ const confirmSealAction = React.useCallback(async () => {
+ setIsSealing(true);
+ try {
+ const result = sealAction === "seal"
+ ? await sealMultipleRfqs(selectedRfqIds)
+ : await unsealMultipleRfqs(selectedRfqIds);
+
+ if (result.success) {
+ toast.success(result.message);
+ table.toggleAllRowsSelected(false); // 선택 해제
+ onRefresh?.(); // 데이터 새로고침
+ } else {
+ toast.error(result.error);
+ }
+ } catch (error) {
+ toast.error("작업 중 오류가 발생했습니다.");
+ } finally {
+ setIsSealing(false);
+ setSealDialogOpen(false);
+ }
+ }, [sealAction, selectedRfqIds, table, onRefresh]);
const handleExportCSV = React.useCallback(async () => {
setIsExporting(true);
@@ -36,6 +85,7 @@ export function RfqTableToolbarActions({
return {
"RFQ 코드": original.rfqCode || "",
"상태": original.status || "",
+ "밀봉여부": original.rfqSealedYn ? "밀봉" : "미밀봉",
"프로젝트 코드": original.projectCode || "",
"프로젝트명": original.projectName || "",
"자재코드": original.itemCode || "",
@@ -89,6 +139,7 @@ export function RfqTableToolbarActions({
return {
"RFQ 코드": original.rfqCode || "",
"상태": original.status || "",
+ "밀봉여부": original.rfqSealedYn ? "밀봉" : "미밀봉",
"프로젝트 코드": original.projectCode || "",
"프로젝트명": original.projectName || "",
"자재코드": original.itemCode || "",
@@ -115,48 +166,143 @@ export function RfqTableToolbarActions({
}, [table]);
return (
- <div className="flex items-center gap-2">
- {onRefresh && (
- <Button
- variant="outline"
- size="sm"
- onClick={onRefresh}
- className="h-8 px-2 lg:px-3"
- >
- <RefreshCw className="mr-2 h-4 w-4" />
- 새로고침
- </Button>
- )}
-
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
+ <>
+ <div className="flex items-center gap-2">
+ {onRefresh && (
<Button
variant="outline"
size="sm"
+ onClick={onRefresh}
className="h-8 px-2 lg:px-3"
- disabled={isExporting}
>
- <Download className="mr-2 h-4 w-4" />
- {isExporting ? "내보내는 중..." : "내보내기"}
+ <RefreshCw className="mr-2 h-4 w-4" />
+ 새로고침
</Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem onClick={handleExportCSV}>
- 전체 데이터 내보내기
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={handleExportSelected}
- disabled={table.getFilteredSelectedRowModel().rows.length === 0}
- >
- 선택한 항목 내보내기 ({table.getFilteredSelectedRowModel().rows.length}개)
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
-
- {/* rfqCategory가 'general'일 때만 일반견적 생성 다이얼로그 표시 */}
- {rfqCategory === "general" && (
- <CreateGeneralRfqDialog onSuccess={onRefresh} />
- ) }
- </div>
+ )}
+
+ {/* 견적 밀봉/해제 버튼 */}
+ {selectedRfqIds.length > 0 && (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="h-8 px-2 lg:px-3"
+ disabled={isSealing}
+ >
+ <Lock className="mr-2 h-4 w-4" />
+ 견적 밀봉
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={() => handleSealAction("seal")}
+ disabled={unsealedCount === 0}
+ >
+ <Lock className="mr-2 h-4 w-4" />
+ 선택 항목 밀봉 ({unsealedCount}개)
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() => handleSealAction("unseal")}
+ disabled={sealedCount === 0}
+ >
+ <LockOpen className="mr-2 h-4 w-4" />
+ 선택 항목 밀봉 해제 ({sealedCount}개)
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <div className="px-2 py-1.5 text-xs text-muted-foreground">
+ 전체 {selectedRfqIds.length}개 선택됨
+ </div>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )}
+
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="h-8 px-2 lg:px-3"
+ disabled={isExporting}
+ >
+ <Download className="mr-2 h-4 w-4" />
+ {isExporting ? "내보내는 중..." : "내보내기"}
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={handleExportCSV}>
+ 전체 데이터 내보내기
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={handleExportSelected}
+ disabled={table.getFilteredSelectedRowModel().rows.length === 0}
+ >
+ 선택한 항목 내보내기 ({table.getFilteredSelectedRowModel().rows.length}개)
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ {/* rfqCategory가 'general'일 때만 일반견적 생성 다이얼로그 표시 */}
+ {rfqCategory === "general" && (
+ <CreateGeneralRfqDialog onSuccess={onRefresh} />
+ )}
+ </div>
+
+ {/* 밀봉 확인 다이얼로그 */}
+ <AlertDialog open={sealDialogOpen} onOpenChange={setSealDialogOpen}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>
+ {sealAction === "seal" ? "견적 밀봉 확인" : "견적 밀봉 해제 확인"}
+ </AlertDialogTitle>
+ <AlertDialogDescription>
+ {sealAction === "seal"
+ ? `선택한 ${unsealedCount}개의 견적을 밀봉하시겠습니까? 밀봉된 견적은 업체에서 수정할 수 없습니다.`
+ : `선택한 ${sealedCount}개의 견적 밀봉을 해제하시겠습니까? 밀봉이 해제되면 업체에서 견적을 수정할 수 있습니다.`}
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isSealing}>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={confirmSealAction}
+ disabled={isSealing}
+ className={sealAction === "seal" ? "bg-red-600 hover:bg-red-700" : ""}
+ >
+ {isSealing ? "처리 중..." : "확인"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </>
);
+}
+
+// CSV 내보내기 유틸리티 함수
+function exportTableToCSV({ data, filename }: { data: any[]; filename: string }) {
+ if (!data || data.length === 0) {
+ console.warn("No data to export");
+ return;
+ }
+
+ const headers = Object.keys(data[0]);
+ const csvContent = [
+ headers.join(","),
+ ...data.map(row =>
+ headers.map(header => {
+ const value = row[header];
+ // 값에 쉼표, 줄바꿈, 따옴표가 있으면 따옴표로 감싸기
+ if (typeof value === "string" && (value.includes(",") || value.includes("\n") || value.includes('"'))) {
+ return `"${value.replace(/"/g, '""')}"`;
+ }
+ return value;
+ }).join(",")
+ )
+ ].join("\n");
+
+ const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" });
+ const link = document.createElement("a");
+ link.href = URL.createObjectURL(blob);
+ link.download = filename;
+ link.click();
+ URL.revokeObjectURL(link.href);
} \ No newline at end of file
diff --git a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
index c146e42b..34259d37 100644
--- a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
+++ b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx
@@ -1,6 +1,6 @@
"use client"
-import { useState } from "react"
+import { useState,useEffect } from "react"
import { useForm, FormProvider } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
@@ -163,18 +163,74 @@ export default function VendorResponseEditor({
const methods = useForm<VendorResponseFormData>({
resolver: zodResolver(vendorResponseSchema),
- defaultValues
+ defaultValues,
+ mode: 'onChange' // 추가: 실시간 validation
})
+ const { formState: { errors, isValid } } = methods
+
+ useEffect(() => {
+ if (Object.keys(errors).length > 0) {
+ console.log('Validation errors:', errors)
+ }
+ }, [errors])
+
+
+
+ const handleFormSubmit = (isSubmit: boolean = false) => {
+ // 임시저장일 경우 validation 없이 바로 저장
+ if (!isSubmit) {
+ const formData = methods.getValues()
+ onSubmit(formData, false)
+ return
+ }
+
+ // 제출일 경우에만 validation 수행
+ methods.handleSubmit(
+ (data) => onSubmit(data, isSubmit),
+ (errors) => {
+ console.error('Form validation errors:', errors)
+
+ // 첫 번째 에러 필드로 포커스 이동
+ const firstErrorField = Object.keys(errors)[0]
+ if (firstErrorField) {
+ // 어느 탭에 에러가 있는지 확인
+ if (firstErrorField.startsWith('vendor') &&
+ !firstErrorField.startsWith('vendorFirst') &&
+ !firstErrorField.startsWith('vendorSparepart')) {
+ setActiveTab('terms')
+ } else if (firstErrorField === 'quotationItems') {
+ setActiveTab('items')
+ }
+
+ // 구체적인 에러 메시지 표시
+ if (errors.quotationItems) {
+ toast.error("견적 품목 정보를 확인해주세요. 모든 품목의 단가와 총액을 입력해야 합니다.")
+ } else {
+ toast.error("입력 정보를 확인해주세요.")
+ }
+ }
+ }
+ )()
+ }
+
const onSubmit = async (data: VendorResponseFormData, isSubmit: boolean = false) => {
+ console.log('onSubmit called with:', { data, isSubmit }) // 디버깅용
+
setLoading(true)
setUploadProgress(0)
try {
const formData = new FormData()
+ const fileMetadata = attachments.map((file: any) => ({
+ attachmentType: file.attachmentType || "기타",
+ description: file.description || ""
+ }))
+
+
// 기본 데이터 추가
- formData.append('data', JSON.stringify({
+ const submitData = {
...data,
rfqsLastId: rfq.id,
rfqLastDetailsId: rfqDetail.id,
@@ -183,69 +239,76 @@ export default function VendorResponseEditor({
submittedAt: isSubmit ? new Date().toISOString() : null,
submittedBy: isSubmit ? userId : null,
totalAmount: data.quotationItems.reduce((sum, item) => sum + item.totalPrice, 0),
- updatedBy: userId
- }))
+ updatedBy: userId,
+ fileMetadata
+ }
+
+ console.log('Submitting data:', submitData) // 디버깅용
+
+ formData.append('data', JSON.stringify(submitData))
// 첨부파일 추가
attachments.forEach((file, index) => {
formData.append(`attachments`, file)
})
- // const response = await fetch(`/api/partners/rfq-last/${rfq.id}/response`, {
- // method: existingResponse ? 'PUT' : 'POST',
- // body: formData
- // })
-
- // if (!response.ok) {
- // throw new Error('응답 저장에 실패했습니다.')
- // }
-
- // XMLHttpRequest 사용하여 업로드 진행률 추적
- const xhr = new XMLHttpRequest()
-
- // Promise로 감싸서 async/await 사용 가능하게
- const uploadPromise = new Promise((resolve, reject) => {
- // 업로드 진행률 이벤트
- xhr.upload.addEventListener('progress', (event) => {
- if (event.lengthComputable) {
- const percentComplete = Math.round((event.loaded / event.total) * 100)
- setUploadProgress(percentComplete)
- }
- })
-
- // 완료 이벤트
- xhr.addEventListener('load', () => {
- if (xhr.status >= 200 && xhr.status < 300) {
- setUploadProgress(100)
- resolve(JSON.parse(xhr.responseText))
- } else {
- reject(new Error('응답 저장에 실패했습니다.'))
+ // XMLHttpRequest 사용하여 업로드 진행률 추적
+ const xhr = new XMLHttpRequest()
+
+ const uploadPromise = new Promise((resolve, reject) => {
+ xhr.upload.addEventListener('progress', (event) => {
+ if (event.lengthComputable) {
+ const percentComplete = Math.round((event.loaded / event.total) * 100)
+ setUploadProgress(percentComplete)
+ }
+ })
+
+ xhr.addEventListener('load', () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ setUploadProgress(100)
+ try {
+ const response = JSON.parse(xhr.responseText)
+ resolve(response)
+ } catch (e) {
+ console.error('Response parsing error:', e)
+ reject(new Error('응답 파싱 실패'))
}
- })
-
- // 에러 이벤트
- xhr.addEventListener('error', () => {
- reject(new Error('네트워크 오류가 발생했습니다.'))
- })
-
- // 요청 전송
- xhr.open(existingResponse ? 'PUT' : 'POST', `/api/partners/rfq-last/${rfq.id}/response`)
- xhr.send(formData)
+ } else {
+ console.error('Server error:', xhr.status, xhr.responseText)
+ reject(new Error(`서버 오류: ${xhr.status}`))
+ }
+ })
+
+ xhr.addEventListener('error', () => {
+ console.error('Network error')
+ reject(new Error('네트워크 오류가 발생했습니다.'))
})
+
+ // 요청 전송
+ const method = existingResponse ? 'PUT' : 'POST'
+ const url = `/api/partners/rfq-last/${rfq.id}/response`
+
+ console.log(`Sending ${method} request to ${url}`) // 디버깅용
- await uploadPromise
+ xhr.open(method, url)
+ xhr.send(formData)
+ })
+
+ await uploadPromise
toast.success(isSubmit ? "견적서가 제출되었습니다." : "견적서가 저장되었습니다.")
router.push('/partners/rfq-last')
router.refresh()
} catch (error) {
- console.error('Error:', error)
- toast.error("오류가 발생했습니다.")
+ console.error('Submit error:', error) // 더 상세한 에러 로깅
+ toast.error(error instanceof Error ? error.message : "오류가 발생했습니다.")
} finally {
setLoading(false)
+ setUploadProgress(0)
}
}
+
const totalAmount = methods.watch('quotationItems')?.reduce(
(sum, item) => sum + (item.totalPrice || 0), 0
) || 0
@@ -256,7 +319,10 @@ export default function VendorResponseEditor({
return (
<FormProvider {...methods}>
- <form onSubmit={methods.handleSubmit((data) => onSubmit(data, false))}>
+ <form onSubmit={(e) => {
+ e.preventDefault() // 기본 submit 동작 방지
+ handleFormSubmit(false)
+ }}>
<div className="space-y-6">
{/* 헤더 정보 */}
<RfqInfoHeader rfq={rfq} rfqDetail={rfqDetail} vendor={vendor} />
@@ -293,92 +359,92 @@ export default function VendorResponseEditor({
</CardDescription>
</CardHeader>
<CardContent>
- {basicContracts.length > 0 ? (
- <div className="space-y-4">
- {/* 계약 목록 - 그리드 레이아웃 */}
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
- {basicContracts.map((contract) => (
- <div
- key={contract.id}
- className="p-3 border rounded-lg bg-card hover:bg-muted/50 transition-colors"
- >
- <div className="flex items-start gap-2">
- <div className="p-1.5 bg-primary/10 rounded">
- <Shield className="h-3.5 w-3.5 text-primary" />
- </div>
- <div className="flex-1 min-w-0">
- <h4 className="font-medium text-sm truncate" title={contract.templateName}>
- {contract.templateName}
- </h4>
- <Badge
- variant={contract.signedAt ? "success" : "warning"}
- className="text-xs mt-1.5"
- >
- {contract.signedAt ? (
- <>
- <CheckCircle className="h-3 w-3 mr-1" />
- 서명완료
- </>
+ {basicContracts.length > 0 ? (
+ <div className="space-y-4">
+ {/* 계약 목록 - 그리드 레이아웃 */}
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
+ {basicContracts.map((contract) => (
+ <div
+ key={contract.id}
+ className="p-3 border rounded-lg bg-card hover:bg-muted/50 transition-colors"
+ >
+ <div className="flex items-start gap-2">
+ <div className="p-1.5 bg-primary/10 rounded">
+ <Shield className="h-3.5 w-3.5 text-primary" />
+ </div>
+ <div className="flex-1 min-w-0">
+ <h4 className="font-medium text-sm truncate" title={contract.templateName}>
+ {contract.templateName}
+ </h4>
+ <Badge
+ variant={contract.signedAt ? "success" : "warning"}
+ className="text-xs mt-1.5"
+ >
+ {contract.signedAt ? (
+ <>
+ <CheckCircle className="h-3 w-3 mr-1" />
+ 서명완료
+ </>
+ ) : (
+ <>
+ <Clock className="h-3 w-3 mr-1" />
+ 서명대기
+ </>
+ )}
+ </Badge>
+ <p className="text-xs text-muted-foreground mt-1">
+ {contract.signedAt
+ ? `${formatDate(new Date(contract.signedAt))}`
+ : contract.deadline
+ ? `~${formatDate(new Date(contract.deadline))}`
+ : '마감일 없음'}
+ </p>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* 서명 상태 요약 및 액션 */}
+ {basicContracts.some(contract => !contract.signedAt) ? (
+ <div className="flex items-center justify-between p-3 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900 rounded-lg">
+ <div className="flex items-center gap-2">
+ <AlertCircle className="h-4 w-4 text-amber-600" />
+ <div>
+ <p className="text-sm font-medium">
+ 서명 대기: {basicContracts.filter(c => !c.signedAt).length}/{basicContracts.length}개
+ </p>
+ <p className="text-xs text-muted-foreground">
+ 견적서 제출 전 모든 계약서 서명 필요
+ </p>
+ </div>
+ </div>
+ <Button
+ type="button"
+ size="sm"
+ onClick={() => router.push(`/partners/basic-contract`)}
+ >
+ 서명하기
+ </Button>
+ </div>
+ ) : (
+ <Alert className="border-green-200 bg-green-50 dark:bg-green-950/20">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <AlertDescription className="text-sm">
+ 모든 기본계약 서명 완료
+ </AlertDescription>
+ </Alert>
+ )}
+ </div>
) : (
- <>
- <Clock className="h-3 w-3 mr-1" />
- 서명대기
- </>
+ <div className="text-center py-8">
+ <FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
+ <p className="text-muted-foreground">
+ 이 RFQ에 요청된 기본계약이 없습니다
+ </p>
+ </div>
)}
- </Badge>
- <p className="text-xs text-muted-foreground mt-1">
- {contract.signedAt
- ? `${formatDate(new Date(contract.signedAt))}`
- : contract.deadline
- ? `~${formatDate(new Date(contract.deadline))}`
- : '마감일 없음'}
- </p>
- </div>
- </div>
- </div>
- ))}
- </div>
-
- {/* 서명 상태 요약 및 액션 */}
- {basicContracts.some(contract => !contract.signedAt) ? (
- <div className="flex items-center justify-between p-3 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900 rounded-lg">
- <div className="flex items-center gap-2">
- <AlertCircle className="h-4 w-4 text-amber-600" />
- <div>
- <p className="text-sm font-medium">
- 서명 대기: {basicContracts.filter(c => !c.signedAt).length}/{basicContracts.length}개
- </p>
- <p className="text-xs text-muted-foreground">
- 견적서 제출 전 모든 계약서 서명 필요
- </p>
- </div>
- </div>
- <Button
- type="button"
- size="sm"
- onClick={() => router.push(`/partners/basic-contract`)}
- >
- 서명하기
- </Button>
- </div>
- ) : (
- <Alert className="border-green-200 bg-green-50 dark:bg-green-950/20">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <AlertDescription className="text-sm">
- 모든 기본계약 서명 완료
- </AlertDescription>
- </Alert>
- )}
- </div>
- ) : (
- <div className="text-center py-8">
- <FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
- <p className="text-muted-foreground">
- 이 RFQ에 요청된 기본계약이 없습니다
- </p>
- </div>
- )}
-</CardContent>
+ </CardContent>
</Card>
</TabsContent>
@@ -429,8 +495,9 @@ export default function VendorResponseEditor({
취소
</Button>
<Button
- type="submit"
+ type="button" // submit에서 button으로 변경
variant="secondary"
+ onClick={() => handleFormSubmit(false)} // 직접 핸들러 호출
disabled={loading}
>
{loading ? (
@@ -448,7 +515,7 @@ export default function VendorResponseEditor({
<Button
type="button"
variant="default"
- onClick={methods.handleSubmit((data) => onSubmit(data, true))}
+ onClick={() => handleFormSubmit(true)} // 직접 핸들러 호출
disabled={loading || !allContractsSigned}
>
{!allContractsSigned ? (
diff --git a/lib/rfq-last/vendor-response/service.ts b/lib/rfq-last/vendor-response/service.ts
index 7de3ae58..04cc5234 100644
--- a/lib/rfq-last/vendor-response/service.ts
+++ b/lib/rfq-last/vendor-response/service.ts
@@ -7,7 +7,7 @@ import { and, or, eq, desc, asc, count, ilike, inArray } from "drizzle-orm";
import {
rfqsLastView,
rfqLastDetails,
- rfqLastVendorResponses,
+ rfqLastVendorResponses,vendorQuotationView,
type RfqsLastView
} from "@/db/schema";
import { filterColumns } from "@/lib/filter-columns";
@@ -26,25 +26,6 @@ export type VendorQuotationStatus =
| "최종확정" // 최종 확정됨
| "취소" // 취소됨
-// 벤더 견적 뷰 타입 확장
-export interface VendorQuotationView extends RfqsLastView {
- // 벤더 응답 정보
- responseStatus?: VendorQuotationStatus;
- displayStatus?:string;
- responseVersion?: number;
- submittedAt?: Date;
- totalAmount?: number;
- vendorCurrency?: string;
-
- // 벤더별 조건
- vendorPaymentTerms?: string;
- vendorIncoterms?: string;
- vendorDeliveryDate?: Date;
-
- participationStatus: "미응답" | "참여" | "불참" | null
- participationRepliedAt: Date | null
- nonParticipationReason: string | null
-}
/**
* 벤더별 RFQ 목록 조회
@@ -66,28 +47,9 @@ export async function getVendorQuotationsLast(
const perPage = input.perPage || 10;
const offset = (page - 1) * perPage;
- // 1. 먼저 벤더가 포함된 RFQ ID들 조회
- const vendorRfqIds = await db
- .select({ rfqsLastId: rfqLastDetails.rfqsLastId })
- .from(rfqLastDetails)
- .where(
- and(
- eq(rfqLastDetails.vendorsId, numericVendorId),
- eq(rfqLastDetails.isLatest, true)
- )
- );
-
-
- const rfqIds = vendorRfqIds.map(r => r.rfqsLastId).filter(id => id !== null);
-
- if (rfqIds.length === 0) {
- return { data: [], pageCount: 0 };
- }
-
- // 2. 필터링 설정
- // advancedTable 모드로 where 절 구성
+ // 필터링 설정
const advancedWhere = filterColumns({
- table: rfqsLastView,
+ table: vendorQuotationView,
filters: input.filters,
joinOperator: input.joinOperator,
});
@@ -97,148 +59,55 @@ export async function getVendorQuotationsLast(
if (input.search) {
const s = `%${input.search}%`;
globalWhere = or(
- ilike(rfqsLastView.rfqCode, s),
- ilike(rfqsLastView.rfqTitle, s),
- ilike(rfqsLastView.itemName, s),
- ilike(rfqsLastView.projectName, s),
- ilike(rfqsLastView.packageName, s),
- ilike(rfqsLastView.status, s)
+ ilike(vendorQuotationView.rfqCode, s),
+ ilike(vendorQuotationView.rfqTitle, s),
+ ilike(vendorQuotationView.itemName, s),
+ ilike(vendorQuotationView.projectName, s),
+ ilike(vendorQuotationView.packageName, s),
+ ilike(vendorQuotationView.status, s),
+ ilike(vendorQuotationView.displayStatus, s)
);
}
- // RFQ ID 조건 (벤더가 포함된 RFQ만)
- const rfqIdWhere = inArray(rfqsLastView.id, rfqIds);
+ // 벤더 ID 조건 (필수)
+ const vendorIdWhere = eq(vendorQuotationView.vendorId, numericVendorId);
// 모든 조건 결합
- let whereConditions = [rfqIdWhere]; // 필수 조건
+ let whereConditions = [vendorIdWhere];
if (advancedWhere) whereConditions.push(advancedWhere);
if (globalWhere) whereConditions.push(globalWhere);
- // 최종 조건
const finalWhere = and(...whereConditions);
- // 3. 정렬 설정
+ // 정렬 설정
const orderBy = input.sort && input.sort.length > 0
? input.sort.map((item) => {
- // @ts-ignore - 동적 속성 접근
- return item.desc ? desc(rfqsLastView[item.id]) : asc(rfqsLastView[item.id]);
+ // @ts-ignore
+ return item.desc ? desc(vendorQuotationView[item.id]) : asc(vendorQuotationView[item.id]);
})
- : [desc(rfqsLastView.updatedAt)];
+ : [desc(vendorQuotationView.updatedAt)];
- // 4. 메인 쿼리 실행
+ // 메인 쿼리 실행 - 이제 한 번의 쿼리로 모든 데이터를 가져옴
const quotations = await db
.select()
- .from(rfqsLastView)
+ .from(vendorQuotationView)
.where(finalWhere)
.orderBy(...orderBy)
.limit(perPage)
.offset(offset);
- // 5. 각 RFQ에 대한 벤더 응답 정보 조회
- const quotationsWithResponse = await Promise.all(
- quotations.map(async (rfq) => {
- // 벤더 응답 정보 조회
- const response = await db.query.rfqLastVendorResponses.findFirst({
- where: and(
- eq(rfqLastVendorResponses.rfqsLastId, rfq.id),
- eq(rfqLastVendorResponses.vendorId, numericVendorId),
- eq(rfqLastVendorResponses.isLatest, true)
- ),
- columns: {
- status: true,
- responseVersion: true,
- submittedAt: true,
- totalAmount: true,
- vendorCurrency: true,
- vendorPaymentTermsCode: true,
- vendorIncotermsCode: true,
- vendorDeliveryDate: true,
- participationStatus: true,
- participationRepliedAt: true,
- nonParticipationReason: true,
- }
- });
-
- // 벤더 상세 정보 조회
- const detail = await db.query.rfqLastDetails.findFirst({
- where: and(
- eq(rfqLastDetails.rfqsLastId, rfq.id),
- eq(rfqLastDetails.vendorsId, numericVendorId),
- eq(rfqLastDetails.isLatest, true)
- ),
- columns: {
- id: true, // rfqLastDetailsId 필요
- emailSentAt: true,
- emailStatus: true,
- shortList: true,
- }
- });
-
- // 표시할 상태 결정 (새로운 로직)
- let displayStatus: string | null = null;
-
- if (response) {
- // 응답 레코드가 있는 경우
- if (response.participationStatus === "불참") {
- displayStatus = "불참";
- } else if (response.participationStatus === "참여") {
- // 참여한 경우 실제 작업 상태 표시
- displayStatus = response.status || "작성중";
- } else {
- // participationStatus가 없거나 "미응답"인 경우
- displayStatus = "미응답";
- }
- } else {
- // 응답 레코드가 없는 경우
- if (detail?.emailSentAt) {
- displayStatus = "미응답"; // 초대는 받았지만 응답 안함
- } else {
- displayStatus = null; // 아직 초대도 안됨
- }
- }
-
- return {
- ...rfq,
- // 새로운 상태 체계
- displayStatus, // UI에서 표시할 통합 상태
-
- // 참여 관련 정보
- participationStatus: response?.participationStatus || "미응답",
- participationRepliedAt: response?.participationRepliedAt,
- nonParticipationReason: response?.nonParticipationReason,
-
- // 견적 작업 상태 (참여한 경우에만 의미 있음)
- responseStatus: response?.status,
- responseVersion: response?.responseVersion,
- submittedAt: response?.submittedAt,
- totalAmount: response?.totalAmount,
- vendorCurrency: response?.vendorCurrency,
- vendorPaymentTerms: response?.vendorPaymentTermsCode,
- vendorIncoterms: response?.vendorIncotermsCode,
- vendorDeliveryDate: response?.vendorDeliveryDate,
-
- // 초대 관련 정보
- rfqLastDetailsId: detail?.id, // 참여 결정 시 필요
- emailSentAt: detail?.emailSentAt,
- emailStatus: detail?.emailStatus,
- shortList: detail?.shortList,
- } as VendorQuotationView;
- })
- );
-
- // 6. 전체 개수 조회
+ // 전체 개수 조회
const { totalCount } = await db
.select({ totalCount: count() })
- .from(rfqsLastView)
+ .from(vendorQuotationView)
.where(finalWhere)
.then(rows => rows[0]);
// 페이지 수 계산
const pageCount = Math.ceil(Number(totalCount) / perPage);
-
return {
- data: quotationsWithResponse,
+ data: quotations,
pageCount
};
} catch (err) {
diff --git a/lib/rfq-last/vendor-response/validations.ts b/lib/rfq-last/vendor-response/validations.ts
index 033154c2..5834bbf6 100644
--- a/lib/rfq-last/vendor-response/validations.ts
+++ b/lib/rfq-last/vendor-response/validations.ts
@@ -7,7 +7,7 @@ import { createSearchParamsCache,
import * as z from "zod"
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { RfqsLastView } from "@/db/schema";
+import { VendorQuotationView } from "@/db/schema";
@@ -15,7 +15,7 @@ export const searchParamsVendorRfqCache = createSearchParamsCache({
flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<RfqsLastView>().withDefault([
+ sort: getSortingStateParser<VendorQuotationView>().withDefault([
{ id: "updatedAt", desc: true },
]),
diff --git a/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx b/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx
index 144c6c43..a7135ea5 100644
--- a/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx
+++ b/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx
@@ -27,8 +27,8 @@ import {
} from "@/components/ui/tooltip"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { useRouter } from "next/navigation"
-import type { VendorQuotationView } from "./service"
import { ParticipationDialog } from "./participation-dialog"
+import { VendorQuotationView } from "@/db/schema"
// 통합 상태 배지 컴포넌트 (displayStatus 사용)
function DisplayStatusBadge({ status }: { status: string | null }) {
diff --git a/lib/rfq-last/vendor-response/vendor-quotations-table.tsx b/lib/rfq-last/vendor-response/vendor-quotations-table.tsx
index 683a0318..2e4975f1 100644
--- a/lib/rfq-last/vendor-response/vendor-quotations-table.tsx
+++ b/lib/rfq-last/vendor-response/vendor-quotations-table.tsx
@@ -12,9 +12,9 @@ import { DataTable } from "@/components/data-table/data-table"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { useRouter } from "next/navigation"
import { getColumns } from "./vendor-quotations-table-columns"
-import type { VendorQuotationView } from "./service"
import { RfqAttachmentsDialog } from "./rfq-attachments-dialog";
import { RfqItemsDialog } from "./rfq-items-dialog";
+import { VendorQuotationView } from "@/db/schema"
interface VendorQuotationsTableLastProps {
promises: Promise<[{ data: VendorQuotationView[], pageCount: number }]>
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx
index 830fd448..d451b2ba 100644
--- a/lib/rfq-last/vendor/rfq-vendor-table.tsx
+++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx
@@ -27,7 +27,9 @@ import {
Info,
Loader2,
Router,
- Shield
+ Shield,
+ CheckSquare,
+ GitCompare
} from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
@@ -59,6 +61,7 @@ import {
getRfqSendData,
getSelectedVendorsWithEmails,
sendRfqToVendors,
+ updateShortList,
type RfqSendData,
type VendorEmailInfo
} from "../service"
@@ -278,7 +281,7 @@ export function RfqVendorTable({
});
const [editContractVendor, setEditContractVendor] = React.useState<any | null>(null);
-
+ const [isUpdatingShortList, setIsUpdatingShortList] = React.useState(false);
const router = useRouter()
@@ -290,6 +293,51 @@ export function RfqVendorTable({
console.log(mergedData, "mergedData")
+ // Short List 확정 핸들러
+ const handleShortListConfirm = React.useCallback(async () => {
+
+ try {
+ setIsUpdatingShortList(true);
+
+ const vendorIds = selectedRows
+ .map(vendor => vendor.vendorId)
+ .filter(id => id != null);
+
+ const result = await updateShortList(rfqId, vendorIds, true);
+
+ if (result.success) {
+ toast.success(`${result.updatedCount}개 벤더를 Short List로 확정했습니다.`);
+ setSelectedRows([]);
+ router.refresh();
+ }
+ } catch (error) {
+ console.error("Short List 확정 실패:", error);
+ toast.error("Short List 확정에 실패했습니다.");
+ } finally {
+ setIsUpdatingShortList(false);
+ }
+ }, [selectedRows, rfqId, router]);
+
+ // 견적 비교 핸들러
+ const handleQuotationCompare = React.useCallback(() => {
+ const vendorsWithQuotation = selectedRows.filter(row =>
+ row.response?.submission?.submittedAt
+ );
+
+ if (vendorsWithQuotation.length < 2) {
+ toast.warning("비교를 위해 최소 2개 이상의 견적서가 필요합니다.");
+ return;
+ }
+
+ // 견적 비교 페이지로 이동 또는 모달 열기
+ const vendorIds = vendorsWithQuotation
+ .map(v => v.vendorId)
+ .filter(id => id != null)
+ .join(',');
+
+ router.push(`/evcp/rfq-last/${rfqId}/compare?vendors=${vendorIds}`);
+ }, [selectedRows, rfqId, router]);
+
// 일괄 발송 핸들러
const handleBulkSend = React.useCallback(async () => {
if (selectedRows.length === 0) {
@@ -302,6 +350,7 @@ export function RfqVendorTable({
// 선택된 벤더 ID들 추출
const selectedVendorIds = selectedRows
+ .filter(v=>v.shortList)
.map(row => row.vendorId)
.filter(id => id != null);
@@ -1142,65 +1191,117 @@ export function RfqVendorTable({
}, [selectedRows]);
// 추가 액션 버튼들
- const additionalActions = React.useMemo(() => (
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsAddDialogOpen(true)}
- disabled={isLoadingSendData}
- >
- <Plus className="h-4 w-4 mr-2" />
- 벤더 추가
- </Button>
- {selectedRows.length > 0 && (
- <>
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsBatchUpdateOpen(true)}
- disabled={isLoadingSendData}
- >
- <Settings2 className="h-4 w-4 mr-2" />
- 정보 일괄 입력 ({selectedRows.length})
- </Button>
- <Button
- variant="outline"
- size="sm"
- onClick={handleBulkSend}
- disabled={isLoadingSendData || selectedRows.length === 0}
- >
- {isLoadingSendData ? (
- <>
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
- 데이터 준비중...
- </>
- ) : (
- <>
- <Send className="h-4 w-4 mr-2" />
- RFQ 발송 ({selectedRows.length})
- </>
- )}
- </Button>
- </>
- )}
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- setIsRefreshing(true);
- setTimeout(() => {
- setIsRefreshing(false);
- toast.success("데이터를 새로고침했습니다.");
- }, 1000);
- }}
- disabled={isRefreshing || isLoadingSendData}
- >
- <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} />
- 새로고침
- </Button>
- </div>
- ), [selectedRows, isRefreshing, isLoadingSendData, handleBulkSend]);
+ const additionalActions = React.useMemo(() => {
+
+ // 참여 의사가 있는 선택된 벤더 수 계산
+ const participatingCount = selectedRows.length;
+ const shortListCount = selectedRows.filter(v=>v.shortList).length;
+
+ // 견적서가 있는 선택된 벤더 수 계산
+ const quotationCount = selectedRows.filter(row =>
+ row.response?.submission?.submittedAt
+ ).length;
+
+ return (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsAddDialogOpen(true)}
+ disabled={isLoadingSendData}
+ >
+ <Plus className="h-4 w-4 mr-2" />
+ 벤더 추가
+ </Button>
+
+ {selectedRows.length > 0 && (
+ <>
+ {/* Short List 확정 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleShortListConfirm}
+ disabled={isUpdatingShortList }
+ // className={ "border-green-500 text-green-600 hover:bg-green-50" }
+ >
+ {isUpdatingShortList ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 처리중...
+ </>
+ ) : (
+ <>
+ <CheckSquare className="h-4 w-4 mr-2" />
+ Short List 확정
+ {participatingCount > 0 && ` (${participatingCount})`}
+ </>
+ )}
+ </Button>
+
+ {/* 견적 비교 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleQuotationCompare}
+ disabled={quotationCount < 1}
+ className={quotationCount >= 2 ? "border-blue-500 text-blue-600 hover:bg-blue-50" : ""}
+ >
+ <GitCompare className="h-4 w-4 mr-2" />
+ 견적 비교
+ {quotationCount > 0 && ` (${quotationCount})`}
+ </Button>
+
+ {/* 정보 일괄 입력 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setIsBatchUpdateOpen(true)}
+ disabled={isLoadingSendData}
+ >
+ <Settings2 className="h-4 w-4 mr-2" />
+ 정보 일괄 입력 ({selectedRows.length})
+ </Button>
+
+ {/* RFQ 발송 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleBulkSend}
+ disabled={isLoadingSendData || selectedRows.length === 0}
+ >
+ {isLoadingSendData ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 데이터 준비중...
+ </>
+ ) : (
+ <>
+ <Send className="h-4 w-4 mr-2" />
+ RFQ 발송 ({shortListCount})
+ </>
+ )}
+ </Button>
+ </>
+ )}
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ setIsRefreshing(true);
+ setTimeout(() => {
+ setIsRefreshing(false);
+ toast.success("데이터를 새로고침했습니다.");
+ }, 1000);
+ }}
+ disabled={isRefreshing || isLoadingSendData}
+ >
+ <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} />
+ 새로고침
+ </Button>
+ </div>
+ );
+ }, [selectedRows, isRefreshing, isLoadingSendData, handleBulkSend, handleShortListConfirm, handleQuotationCompare, isUpdatingShortList]);
return (
<>