From c92ddd6bae8e187cccfddb37373460ebea0ade27 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 1 Dec 2025 01:21:52 +0000 Subject: (최겸) 구매 입찰 SAP 삭제(취소) 개발 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/bidding/ProjectSelectorBid.tsx | 6 +- components/bidding/manage/bidding-items-editor.tsx | 3 + db/schema/bidding.ts | 6 +- lib/bidding/delete-action.ts | 166 +++++++++++++++++++++ lib/bidding/list/biddings-delete-dialog.tsx | 102 +++++++++++++ .../list/biddings-table-toolbar-actions.tsx | 38 ++++- 6 files changed, 312 insertions(+), 9 deletions(-) create mode 100644 lib/bidding/delete-action.ts create mode 100644 lib/bidding/list/biddings-delete-dialog.tsx diff --git a/components/bidding/ProjectSelectorBid.tsx b/components/bidding/ProjectSelectorBid.tsx index 0fc567b3..a87c8dce 100644 --- a/components/bidding/ProjectSelectorBid.tsx +++ b/components/bidding/ProjectSelectorBid.tsx @@ -13,13 +13,15 @@ interface ProjectSelectorProps { onProjectSelect: (project: Project | null) => void; placeholder?: string; filterType?: string; // 옵션으로 필터 타입 지정 가능 + disabled?: boolean; } export function ProjectSelector({ selectedProjectId, onProjectSelect, placeholder = "프로젝트 선택...", - filterType + filterType, + disabled = false }: ProjectSelectorProps) { const [open, setOpen] = React.useState(false) const [searchTerm, setSearchTerm] = React.useState("") @@ -95,7 +97,7 @@ export function ProjectSelector({ role="combobox" aria-expanded={open} className="w-full justify-between" - disabled={isLoading} + disabled={isLoading || disabled} > {isLoading ? ( "프로젝트 로딩 중..." diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx index 9d858f40..90e512d2 100644 --- a/components/bidding/manage/bidding-items-editor.tsx +++ b/components/bidding/manage/bidding-items-editor.tsx @@ -868,6 +868,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems }} title="1회성 품목 선택" description="1회성 품목을 검색하고 선택해주세요." + disabled={readonly} /> ) : ( )} @@ -928,6 +930,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems }} title="자재 선택" description="자재를 검색하고 선택해주세요." + disabled={readonly} /> diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts index bc31f6de..cc79f482 100644 --- a/db/schema/bidding.ts +++ b/db/schema/bidding.ts @@ -50,7 +50,8 @@ export const biddingStatusEnum = pgEnum('bidding_status', [ 'rebidding', // 재입찰 'disposal_cancelled', // 유찰취소 'bid_closure', // 폐찰 - 'round_increase' // 차수증가 + 'round_increase', // 차수증가 + 'deleted' // 삭제 ]) // 2. 계약구분 enum @@ -699,7 +700,8 @@ export const biddingStatusLabels = { rebidding: '입찰종료-재입찰', disposal_cancelled: '유찰취소', bid_closure: '폐찰', - round_increase: '입찰종료-차수증가' + round_increase: '입찰종료-차수증가', + deleted: '삭제' } as const export const contractTypeLabels = { diff --git a/lib/bidding/delete-action.ts b/lib/bidding/delete-action.ts new file mode 100644 index 00000000..32dc32ad --- /dev/null +++ b/lib/bidding/delete-action.ts @@ -0,0 +1,166 @@ +'use server' + +import { revalidatePath } from "next/cache"; +import db from "@/db/db"; +import { biddings, biddingCompanies } from "@/db/schema"; +import { eq, and, inArray } from "drizzle-orm"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { cancelRFQ } from "@/lib/soap/ecc/send/delete-rfq"; + +/** + * 입찰(Bidding) 삭제 서버 액션 + * 조건: + * 1. SAP 연동 건(ANFNR 존재)이어야 함 (필수) + * 2. 입찰 상태가 '입찰생성(bidding_generated)'이어야 함 + * + * 동작: + * 1. SAP 취소 I/F 전송 (필수) + * 2. 성공 시 입찰 상태를 '삭제(deleted)'로 변경 + * 3. 연관된 데이터(업체 응답 등) 상태 변경 + */ +export async function deleteBidding(biddingIds: number[], deleteReason?: string): Promise<{ + success: boolean; + message: string; + results?: Array<{ biddingId: number; success: boolean; error?: string }>; +}> { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.id) { + return { + success: false, + message: "인증이 필요합니다." + }; + } + + const userName = session.user.name || session.user.email || "Unknown"; + + // 1. Bidding 정보 조회 + const targets = await db.select({ + id: biddings.id, + biddingNumber: biddings.biddingNumber, + ANFNR: biddings.ANFNR, + status: biddings.status, + title: biddings.title + }) + .from(biddings) + .where(inArray(biddings.id, biddingIds)); + + // 2. 유효성 검증 + // 조건 1: ANFNR이 있어야 함 + // 조건 2: 상태가 'bidding_generated'(입찰생성) 이어야 함 + const validTargets = targets.filter(b => + b.status === 'bidding_generated' && + b.ANFNR && + b.ANFNR.trim() !== "" + ); + + if (validTargets.length === 0) { + // 실패 사유 분석 + const noAnfnr = targets.filter(b => !b.ANFNR || b.ANFNR.trim() === ""); + const wrongStatus = targets.filter(b => b.status !== 'bidding_generated'); + + let errorMsg = "삭제 가능한 입찰이 없습니다."; + if (noAnfnr.length > 0) errorMsg += " (SAP 연동 건(ANFNR)이 아님)"; + if (wrongStatus.length > 0) errorMsg += " ('입찰생성' 상태가 아님)"; + + return { + success: false, + message: errorMsg + }; + } + + const results: Array<{ biddingId: number; success: boolean; error?: string }> = []; + + // 3. 각 Bidding에 대해 처리 + for (const bidding of validTargets) { + try { + // 3-1. SAP 취소 요청 전송 (ANFNR 필수이므로 바로 호출) + const cancelResult = await cancelRFQ(bidding.ANFNR!); + + if (!cancelResult.success) { + results.push({ + biddingId: bidding.id, + success: false, + error: `SAP 전송 실패: ${cancelResult.message}` + }); + continue; + } + + // 3-2. DB 상태 변경 및 연관 데이터 정리 + await db.transaction(async (tx) => { + // 입찰 상태 변경 + await tx + .update(biddings) + .set({ + status: 'deleted', // 삭제 상태 처리 + remarks: deleteReason ? `[삭제사유] ${deleteReason}` : `[삭제됨] 사용자에 의한 삭제`, + updatedBy: userName, + updatedAt: new Date() + }) + .where(eq(biddings.id, bidding.id)); + + }); + + results.push({ + biddingId: bidding.id, + success: true + }); + + } catch (error) { + console.error(`입찰 삭제 실패 (ID: ${bidding.id}, ANFNR: ${bidding.ANFNR}):`, error); + results.push({ + biddingId: bidding.id, + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류" + }); + } + } + + const successCount = results.filter(r => r.success).length; + const failCount = results.length - successCount; + + // 캐시 갱신 + revalidatePath("/evcp/bid"); + + // 결과 메시지 조합 + let message = ""; + const invalidStatusCount = targets.filter(b => b.status !== 'bidding_generated').length; + const noAnfnrCount = targets.filter(b => !b.ANFNR || b.ANFNR.trim() === "").length; + + if (invalidStatusCount > 0) { + message += `'입찰생성' 상태가 아닌 건(${invalidStatusCount}건) 제외. `; + } + if (noAnfnrCount > 0) { + message += `SAP 미연동 건(${noAnfnrCount}건) 제외. `; + } + + if (failCount === 0 && successCount > 0) { + return { + success: true, + message: message + `입찰 삭제가 완료되었습니다. (${successCount}건)`, + results + }; + } else if (successCount > 0) { + return { + success: false, // 부분 성공 + message: message + `일부 삭제 실패 (성공: ${successCount}건, 실패: ${failCount}건)`, + results + }; + } else { + return { + success: false, + message: message + (failCount > 0 ? "삭제 처리에 실패했습니다." : "처리할 대상이 없습니다."), + results + }; + } + + } catch (error) { + console.error("입찰 삭제 처리 중 오류:", error); + return { + success: false, + message: error instanceof Error ? error.message : "입찰 삭제 처리 중 오류가 발생했습니다." + }; + } +} diff --git a/lib/bidding/list/biddings-delete-dialog.tsx b/lib/bidding/list/biddings-delete-dialog.tsx new file mode 100644 index 00000000..9291742f --- /dev/null +++ b/lib/bidding/list/biddings-delete-dialog.tsx @@ -0,0 +1,102 @@ +"use client" + +import * as React from "react" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { BiddingListItem } from "@/db/schema" +import { deleteBidding } from "@/lib/bidding/delete-action" +import { toast } from "sonner" +import { Loader2 } from "lucide-react" + +interface BiddingDeleteDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + bidding: BiddingListItem + onSuccess?: () => void +} + +export function BiddingDeleteDialog({ + open, + onOpenChange, + bidding, + onSuccess +}: BiddingDeleteDialogProps) { + const [isDeleting, setIsDeleting] = React.useState(false) + const [deleteReason, setDeleteReason] = React.useState("") + + const handleDelete = async () => { + if (!bidding) return + + setIsDeleting(true) + try { + const result = await deleteBidding([bidding.id], deleteReason) + + if (result.success) { + toast.success(result.message) + onOpenChange(false) + onSuccess?.() + } else { + toast.error(result.message) + } + } catch (error) { + toast.error("삭제 중 오류가 발생했습니다.") + console.error(error) + } finally { + setIsDeleting(false) + } + } + + return ( + + + + 입찰 삭제 + + 선택한 입찰({bidding?.biddingNumber})을 삭제하시겠습니까?
+ 삭제된 입찰은 복구할 수 없습니다. +
+ + setDeleteReason(e.target.value)} + placeholder="삭제 사유를 입력하세요" + /> +
+
+
+ + 취소 + { + e.preventDefault() + handleDelete() + }} + disabled={isDeleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeleting ? ( + <> + + 삭제 중... + + ) : ( + "삭제" + )} + + +
+
+ ) +} + diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx index a3851630..33368218 100644 --- a/lib/bidding/list/biddings-table-toolbar-actions.tsx +++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" import { - Send, Download, FileSpreadsheet + Send, Download, FileSpreadsheet, Trash } from "lucide-react" import { toast } from "sonner" import { useSession } from "next-auth/react" @@ -21,6 +21,8 @@ import { BiddingCreateDialog } from "@/components/bidding/create/bidding-create- import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { createBiddingSchema } from "@/lib/bidding/validation" +import { deleteBidding } from "@/lib/bidding/delete-action" +import { BiddingDeleteDialog } from "./biddings-delete-dialog" interface BiddingsTableToolbarActionsProps { table: Table @@ -30,7 +32,8 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio const { data: session } = useSession() const [isExporting, setIsExporting] = React.useState(false) const [isTransmissionDialogOpen, setIsTransmissionDialogOpen] = React.useState(false) - + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false) + const userId = session?.user?.id ? Number(session.user.id) : 1 // 입찰 생성 폼 @@ -83,8 +86,11 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio // 업체선정이 완료된 입찰만 전송 가능 const canTransmit = true - console.log(canTransmit, 'canTransmit') - console.log(selectedBiddings, 'selectedBiddings') + + // 삭제 가능 여부: 선택된 항목이 정확히 1개이고, '입찰생성' 상태여야 함 + const canDelete = React.useMemo(() => { + return selectedBiddings.length === 1 && selectedBiddings[0].status === 'bidding_generated' + }, [selectedBiddings]) return ( <> @@ -94,7 +100,6 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio // 성공 시 테이블 새로고침 등 추가 작업 // window.location.reload() }} /> - {/* 전송하기 (업체선정 완료된 입찰만) */} + {/* 삭제 버튼 */} + + + + + {/* 전송 다이얼로그 */} @@ -115,6 +135,14 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio bidding={selectedBiddings[0]} userId={userId} /> + + {/* 삭제 다이얼로그 */} + table.resetRowSelection()} + /> ) } \ No newline at end of file -- cgit v1.2.3