diff options
Diffstat (limited to 'lib/rfq-last')
| -rw-r--r-- | lib/rfq-last/attachment/vendor-response-table.tsx | 387 | ||||
| -rw-r--r-- | lib/rfq-last/compare-action.ts | 500 | ||||
| -rw-r--r-- | lib/rfq-last/quotation-compare-view.tsx | 755 | ||||
| -rw-r--r-- | lib/rfq-last/service.ts | 376 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-seal-toggle-cell.tsx | 93 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-table-columns.tsx | 73 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-table-toolbar-actions.tsx | 222 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx | 335 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/service.ts | 175 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/validations.ts | 4 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx | 2 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/vendor-quotations-table.tsx | 2 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 223 |
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 ( <> |
