diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-04 10:03:32 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-04 10:03:32 +0000 |
| commit | 47fb72704161b4b58a27c7f5c679fc44618de9a1 (patch) | |
| tree | af4fe1517352784d1876c164171f6dba2e40403a | |
| parent | 1a034c7f6f50e443bc9f97c3d84bfb0a819af6ce (diff) | |
(최겸) 구매 견적 내 RFQ Cancel/Delete, 연동제 적용, MRC Type 개발
19 files changed, 2079 insertions, 120 deletions
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/layout.tsx index 6dcbf018..8fffb221 100644 --- a/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/layout.tsx +++ b/app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/layout.tsx @@ -4,7 +4,6 @@ import { Separator } from "@/components/ui/separator" import { SidebarNav } from "@/components/layout/sidebar-nav" import { formatDate } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" import { ArrowLeft, Clock, AlertTriangle, CheckCircle, XCircle, AlertCircle, Calendar, CalendarDays } from "lucide-react" import { RfqsLastView } from "@/db/schema" import { findRfqLastById } from "@/lib/rfq-last/service" @@ -47,7 +46,27 @@ export default async function RfqLayout({ // 2) DB에서 해당 협력업체 정보 조회 const rfq: RfqsLastView | null = await findRfqLastById(rfqId) - // 3) 사이드바 메뉴 + // 3) 취소된 RFQ 접근 제어 + if (rfq?.status === "RFQ 삭제") { + return ( + <div className="p-4 space-y-4"> + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertTitle>접근 불가</AlertTitle> + <AlertDescription> + 이 RFQ는 삭제되어 접근할 수 없습니다. + </AlertDescription> + </Alert> + <div className="p-4 bg-muted rounded-lg"> + <p className="text-sm font-medium mb-2">삭제 사유:</p> + <p className="text-sm text-muted-foreground whitespace-pre-wrap">{rfq.deleteReason}</p> + </div> + + </div> + ); + } + + // 4) 사이드바 메뉴 const sidebarNavItems = [ { title: "견적 문서관리", diff --git a/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx b/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx index 7a68e3a2..9052de6f 100644 --- a/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx +++ b/app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx @@ -86,6 +86,23 @@ export default async function VendorResponsePage({ params }: PageProps) { if (!rfq || !rfq.rfqDetails[0]) { notFound() } + + // 취소된 RFQ 접근 제어 + if (rfq.status === "RFQ 삭제") { + return ( + <div className="flex h-full items-center justify-center"> + <div className="text-center max-w-md"> + <h2 className="text-xl font-bold">접근 불가</h2> + <p className="mt-2 text-muted-foreground">이 RFQ는 삭제되어 접근할 수 없습니다.</p> + <div className="mt-4 p-4 bg-muted rounded-lg text-left"> + <p className="text-sm font-medium mb-2">삭제 사유:</p> + <p className="text-sm text-muted-foreground whitespace-pre-wrap">{rfq.deleteReason}</p> + </div> + + </div> + </div> + ) + } const rfqDetail = rfq.rfqDetails[0] @@ -99,8 +116,28 @@ export default async function VendorResponsePage({ params }: PageProps) { with: { quotationItems: true, attachments: true, + priceAdjustmentForm: true, } }) + + // 취소된 RFQ 접근 제어 (vendor response가 취소 상태인 경우) + if (existingResponse?.status === "취소" || rfqDetail.cancelReason) { + return ( + <div className="flex h-full items-center justify-center"> + <div className="text-center"> + <h2 className="text-xl font-bold">RFQ 취소됨</h2> + <p className="mt-2 text-muted-foreground"> + 이 RFQ는 취소되어 더 이상 견적을 제출할 수 없습니다. + </p> + {rfqDetail.cancelReason && ( + <p className="mt-4 text-sm text-muted-foreground"> + 취소 사유: {rfqDetail.cancelReason} + </p> + )} + </div> + </div> + ) + } // PR Items 가져오기 const prItems = await db.query.rfqPrItems.findMany({ diff --git a/app/api/partners/rfq-last/[id]/response/route.ts b/app/api/partners/rfq-last/[id]/response/route.ts index 5d05db50..06ace9a0 100644 --- a/app/api/partners/rfq-last/[id]/response/route.ts +++ b/app/api/partners/rfq-last/[id]/response/route.ts @@ -7,9 +7,10 @@ import { rfqLastVendorResponses, rfqLastVendorQuotationItems, rfqLastVendorAttachments, - rfqLastVendorResponseHistory + rfqLastVendorResponseHistory, + rfqLastPriceAdjustmentForms } from "@/db/schema" -import { eq, and, inArray } from "drizzle-orm" +import { eq, and } from "drizzle-orm" import { writeFile, mkdir } from "fs/promises" import { createWriteStream } from "fs" import { pipeline } from "stream/promises" @@ -139,17 +140,77 @@ export async function POST( await tx.insert(rfqLastVendorQuotationItems).values(quotationItemsData) } - // 이력 기록 + // 3. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우) + if (data.vendorMaterialPriceRelatedYn && data.priceAdjustmentForm && vendorResponse.id) { + const priceAdjustmentData: { + rfqLastVendorResponsesId: number; + itemName?: string | null; + adjustmentReflectionPoint?: string | null; + majorApplicableRawMaterial?: string | null; + adjustmentFormula?: string | null; + rawMaterialPriceIndex?: string | null; + referenceDate?: string | null; + comparisonDate?: string | null; + adjustmentRatio?: number | null; + notes?: string | null; + adjustmentConditions?: string | null; + majorNonApplicableRawMaterial?: string | null; + adjustmentPeriod?: string | null; + contractorWriter?: string | null; + adjustmentDate?: string | null; + nonApplicableReason?: string | null; + } = { + rfqLastVendorResponsesId: vendorResponse.id, + itemName: data.priceAdjustmentForm.itemName || null, + adjustmentReflectionPoint: data.priceAdjustmentForm.adjustmentReflectionPoint || null, + majorApplicableRawMaterial: data.priceAdjustmentForm.majorApplicableRawMaterial || null, + adjustmentFormula: data.priceAdjustmentForm.adjustmentFormula || null, + rawMaterialPriceIndex: data.priceAdjustmentForm.rawMaterialPriceIndex || null, + referenceDate: data.priceAdjustmentForm.referenceDate || null, + comparisonDate: data.priceAdjustmentForm.comparisonDate || null, + adjustmentRatio: data.priceAdjustmentForm.adjustmentRatio || null, + notes: data.priceAdjustmentForm.notes || null, + adjustmentConditions: data.priceAdjustmentForm.adjustmentConditions || null, + majorNonApplicableRawMaterial: data.priceAdjustmentForm.majorNonApplicableRawMaterial || null, + adjustmentPeriod: data.priceAdjustmentForm.adjustmentPeriod || null, + contractorWriter: data.priceAdjustmentForm.contractorWriter || null, + adjustmentDate: data.priceAdjustmentForm.adjustmentDate || null, + nonApplicableReason: data.priceAdjustmentForm.nonApplicableReason || null, + } + + // 기존 연동제 정보가 있는지 확인 + const existingPriceAdjustment = await tx + .select() + .from(rfqLastPriceAdjustmentForms) + .where(eq(rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, vendorResponse.id)) + .limit(1) + + if (existingPriceAdjustment.length > 0) { + // 업데이트 + await tx + .update(rfqLastPriceAdjustmentForms) + .set({ + ...priceAdjustmentData, + updatedAt: new Date(), + }) + .where(eq(rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, vendorResponse.id)) + } else { + // 새로 생성 + await tx.insert(rfqLastPriceAdjustmentForms).values(priceAdjustmentData) + } + } + + // 4. 이력 기록 await tx.insert(rfqLastVendorResponseHistory).values({ - vendorResponseId: vendorResponseId, - action: isNewResponse ? "생성" : (data.status === "제출완료" ? "제출" : "수정"), - previousStatus: existingResponse?.status || null, + vendorResponseId: vendorResponse.id, + action: "생성", + previousStatus: null, newStatus: data.status || "작성중", changeDetails: data, performedBy: session.user.id, }) - return { id: vendorResponseId, isNew: isNewResponse } + return { id: vendorResponse.id, isNew: true } }) // 파일 저장 (트랜잭션 밖에서 처리) @@ -262,7 +323,7 @@ export async function PUT( const previousStatus = existingResponse.status // 2. 새 버전 생성 (제출 시) 또는 기존 버전 업데이트 - let responseId = existingResponse.id + const responseId = existingResponse.id // if (data.status === "제출완료" && previousStatus !== "제출완료") { // // 기존 버전을 비활성화 @@ -362,7 +423,72 @@ export async function PUT( await tx.insert(rfqLastVendorQuotationItems).values(quotationItemsData) } - // 4. 이력 기록 + // 4. 연동제 정보 저장/업데이트 (연동제 적용이 true이고 연동제 정보가 있는 경우) + if (data.vendorMaterialPriceRelatedYn && data.priceAdjustmentForm && responseId) { + const priceAdjustmentData: { + rfqLastVendorResponsesId: number; + itemName?: string | null; + adjustmentReflectionPoint?: string | null; + majorApplicableRawMaterial?: string | null; + adjustmentFormula?: string | null; + rawMaterialPriceIndex?: string | null; + referenceDate?: string | null; + comparisonDate?: string | null; + adjustmentRatio?: number | null; + notes?: string | null; + adjustmentConditions?: string | null; + majorNonApplicableRawMaterial?: string | null; + adjustmentPeriod?: string | null; + contractorWriter?: string | null; + adjustmentDate?: string | null; + nonApplicableReason?: string | null; + } = { + rfqLastVendorResponsesId: responseId, + itemName: data.priceAdjustmentForm.itemName || null, + adjustmentReflectionPoint: data.priceAdjustmentForm.adjustmentReflectionPoint || null, + majorApplicableRawMaterial: data.priceAdjustmentForm.majorApplicableRawMaterial || null, + adjustmentFormula: data.priceAdjustmentForm.adjustmentFormula || null, + rawMaterialPriceIndex: data.priceAdjustmentForm.rawMaterialPriceIndex || null, + referenceDate: data.priceAdjustmentForm.referenceDate || null, + comparisonDate: data.priceAdjustmentForm.comparisonDate || null, + adjustmentRatio: data.priceAdjustmentForm.adjustmentRatio || null, + notes: data.priceAdjustmentForm.notes || null, + adjustmentConditions: data.priceAdjustmentForm.adjustmentConditions || null, + majorNonApplicableRawMaterial: data.priceAdjustmentForm.majorNonApplicableRawMaterial || null, + adjustmentPeriod: data.priceAdjustmentForm.adjustmentPeriod || null, + contractorWriter: data.priceAdjustmentForm.contractorWriter || null, + adjustmentDate: data.priceAdjustmentForm.adjustmentDate || null, + nonApplicableReason: data.priceAdjustmentForm.nonApplicableReason || null, + } + + // 기존 연동제 정보가 있는지 확인 + const existingPriceAdjustment = await tx + .select() + .from(rfqLastPriceAdjustmentForms) + .where(eq(rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, responseId)) + .limit(1) + + if (existingPriceAdjustment.length > 0) { + // 업데이트 + await tx + .update(rfqLastPriceAdjustmentForms) + .set({ + ...priceAdjustmentData, + updatedAt: new Date(), + }) + .where(eq(rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, responseId)) + } else { + // 새로 생성 + await tx.insert(rfqLastPriceAdjustmentForms).values(priceAdjustmentData) + } + } else if (data.vendorMaterialPriceRelatedYn === false && responseId) { + // 연동제 미적용 시 기존 데이터 삭제 + await tx + .delete(rfqLastPriceAdjustmentForms) + .where(eq(rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, responseId)) + } + + // 5. 이력 기록 await tx.insert(rfqLastVendorResponseHistory).values({ vendorResponseId: responseId, action: data.status === "제출완료" ? "제출" : "수정", diff --git a/db/schema/rfqLast.ts b/db/schema/rfqLast.ts index b4ec968b..19c213c0 100644 --- a/db/schema/rfqLast.ts +++ b/db/schema/rfqLast.ts @@ -14,7 +14,8 @@ export type RfqStatus = | "TBE 완료" | "RFQ 발송" | "견적접수" - | "최종업체선정"; + | "최종업체선정" + | "RFQ 삭제"; export const rfqsLast = pgTable( @@ -96,6 +97,9 @@ export const rfqsLast = pgTable( // SS = 시리즈 통합, II = 품목 통합, 공란 = 통합 없음 series: varchar("series", { length: 50 }), + // RFQ 삭제 사유 + deleteReason: text("delete_reason"), + }, ); @@ -296,6 +300,7 @@ export const rfqsLastView = pgView("rfqs_last_view").as((qb) => { // Basic RFQ identification id: sql<number>`${rfqsLast.id}`.as("id"), rfqCode: sql<string>`${rfqsLast.rfqCode}`.as("rfq_code"), + ANFNR: sql<string | null>`${rfqsLast.ANFNR}`.as("ANFNR"), series: sql<string | null>`${rfqsLast.series}`.as("series"), rfqSealedYn: sql<boolean | null>`${rfqsLast.rfqSealedYn}`.as("rfq_sealed_yn"), @@ -384,6 +389,7 @@ export const rfqsLastView = pgView("rfqs_last_view").as((qb) => { updatedAt: sql<Date>`${rfqsLast.updatedAt}`.as("updated_at"), remark: sql<string | null>`${rfqsLast.remark}`.as("remark"), + deleteReason: sql<string | null>`${rfqsLast.deleteReason}`.as("delete_reason"), // PR Items related information majorItemMaterialCode: sql<string | null>`( diff --git a/db/schema/rfqVendor.ts b/db/schema/rfqVendor.ts index dea196b1..f7fcce64 100644 --- a/db/schema/rfqVendor.ts +++ b/db/schema/rfqVendor.ts @@ -1,4 +1,4 @@ -import { pgTable, pgView, serial, varchar, text, timestamp, boolean, integer, numeric, date, alias, jsonb } from "drizzle-orm/pg-core"; +import { pgTable, pgView, serial, varchar, text, timestamp, boolean, integer, numeric, date, alias, jsonb, decimal } from "drizzle-orm/pg-core"; import { eq, sql, relations,and } from "drizzle-orm"; import { rfqsLast, rfqLastDetails, rfqPrItems } from "./rfqLast"; import { users } from "./users"; @@ -30,8 +30,6 @@ export const rfqLastVendorResponses = pgTable( isLatest: boolean("is_latest").notNull().default(true), isDocumentConfirmed: boolean("is_document_confirmed").default(false), - - // 참여 여부 관련 필드 (새로 추가) participationStatus: varchar("participation_status", { length: 20 }) .$type<"미응답" | "참여" | "불참">() @@ -44,10 +42,9 @@ export const rfqLastVendorResponses = pgTable( // 응답 상태 (수정: 참여 결정 후에만 의미 있음) status: varchar("status", { length: 30 }) - .$type<"대기중" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소">() + .$type<"대기중" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소" | "삭제">() .notNull() .default("대기중"), - // 제출 정보 submittedAt: timestamp("submitted_at"), submittedBy: integer("submitted_by") @@ -239,6 +236,65 @@ export const rfqLastVendorResponseHistory = pgTable( ); // ========================================== +// 5. RFQ-last 연동제 폼 테이블 +// ========================================== +export const rfqLastPriceAdjustmentForms = pgTable('rfq_last_price_adjustment_forms', { + id: serial('id').primaryKey(), + + // rfqLastVendorResponses 테이블과 외래 키로 연결 + rfqLastVendorResponsesId: integer('rfq_last_vendor_responses_id') + .notNull() + .references(() => rfqLastVendorResponses.id, { onDelete: 'cascade' }), + + // 품목등의 명칭 + itemName: varchar('item_name', { length: 255 }), + + // 조정대금 반영시점 + adjustmentReflectionPoint: varchar('adjustment_reflection_point', { length: 255 }), + + // 연동대상 주요 원재료 + majorApplicableRawMaterial: text('major_applicable_raw_material'), + + // 하도급대금등 연동 산식 + adjustmentFormula: text('adjustment_formula'), + + // 원재료 가격 기준지표 + rawMaterialPriceIndex: text('raw_material_price_index'), + + // 기준시점 및 비교시점 + referenceDate: date('reference_date'), // 기준시점 + comparisonDate: date('comparison_date'), // 비교시점 + + // 연동 비율 + adjustmentRatio: decimal('adjustment_ratio', { precision: 5, scale: 2 }), // 소수점 2자리까지 + + // 기타 사항 + notes: text('notes'), + + // 조정요건 + adjustmentConditions: text('adjustment_conditions'), + + // 연동 미적용 주요 원재료 + majorNonApplicableRawMaterial: text('major_non_applicable_raw_material'), + + // 조정주기 + adjustmentPeriod: varchar('adjustment_period', { length: 100 }), + + // 수탁기업(협력사) 작성자 + contractorWriter: varchar('contractor_writer', { length: 100 }), + + // 조정일 + adjustmentDate: date('adjustment_date'), + + // 연동 미적용 사유 + nonApplicableReason: text('non_applicable_reason'), + + // 메타 정보 + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// ========================================== // Views // ========================================== @@ -453,6 +509,22 @@ export const vendorQuotationItemsRelations = relations( quotationItems: many(rfqLastVendorQuotationItems), attachments: many(rfqLastVendorAttachments), history: many(rfqLastVendorResponseHistory), + priceAdjustmentForm: one(rfqLastPriceAdjustmentForms, { + fields: [rfqLastVendorResponses.id], + references: [rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId], + relationName: "vendorResponsePriceAdjustmentForm", + }), + }) + ); + + // 연동제 폼 테이블의 relation + export const priceAdjustmentFormRelations = relations( + rfqLastPriceAdjustmentForms, + ({ one }) => ({ + vendorResponse: one(rfqLastVendorResponses, { + fields: [rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId], + references: [rfqLastVendorResponses.id], + }), }) ); diff --git a/lib/rfq-last/attachment/vendor-response-table.tsx b/lib/rfq-last/attachment/vendor-response-table.tsx index 8488eea1..22f813b3 100644 --- a/lib/rfq-last/attachment/vendor-response-table.tsx +++ b/lib/rfq-last/attachment/vendor-response-table.tsx @@ -19,7 +19,8 @@ import { Calendar, AlertCircle, X, - CheckCircle2 + CheckCircle2, + XCircle } from "lucide-react"; import { format, formatDistanceToNow, isValid, isBefore, isAfter } from "date-fns"; import { ko } from "date-fns/locale"; @@ -184,6 +185,13 @@ export function VendorResponseTable({ return vendorItem?.vendorId || null; }, [selectedVendor, data]); + // 선택된 벤더의 응답 상태 확인 (취소 상태인지) + const isVendorCancelled = React.useMemo(() => { + if (!selectedVendor) return false; + // 선택된 벤더의 문서 중 하나라도 취소 상태인지 확인 + return filteredData.some(item => item.responseStatus === "취소"); + }, [selectedVendor, filteredData]); + // 데이터 새로고침 const handleRefresh = React.useCallback(async () => { setIsRefreshing(true); @@ -567,13 +575,18 @@ export function VendorResponseTable({ size="sm" onClick={() => setShowConfirmDialog(true)} className="h-7" - disabled={confirmedVendors.has(selectedVendorId)} + disabled={confirmedVendors.has(selectedVendorId) || isVendorCancelled} > {confirmedVendors.has(selectedVendorId) ? ( <> <CheckCircle2 className="h-3 w-3 mr-1" /> 확정완료 </> + ) : isVendorCancelled ? ( + <> + <XCircle className="h-3 w-3 mr-1" /> + 취소됨 (확정 불가) + </> ) : ( <> <CheckCircle2 className="h-3 w-3 mr-1" /> diff --git a/lib/rfq-last/cancel-vendor-response-action.ts b/lib/rfq-last/cancel-vendor-response-action.ts new file mode 100644 index 00000000..e329a551 --- /dev/null +++ b/lib/rfq-last/cancel-vendor-response-action.ts @@ -0,0 +1,185 @@ +'use server'
+
+import { revalidatePath } from "next/cache";
+import db from "@/db/db";
+import { rfqLastDetails, rfqLastVendorResponses, rfqLastTbeSessions } from "@/db/schema";
+import { eq, and, inArray } from "drizzle-orm";
+import { getServerSession } from "next-auth/next";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+
+/**
+ * RFQ 벤더 응답 취소 서버 액션
+ * RFQ 발송 후 특정 벤더에 한하여 취소 처리
+ * - vendor response를 "취소" 상태로 변경
+ * - cancelReason 업데이트
+ * - TBE 진행중이면 TBE 취소 처리
+ * - 업체선정 진행중이면 업체선정 취소 처리
+ */
+export async function cancelVendorResponse(
+ rfqId: number,
+ detailIds: number[],
+ cancelReason: string
+): Promise<{
+ success: boolean;
+ message: string;
+ results?: Array<{ detailId: number; success: boolean; error?: string }>;
+}> {
+ try {
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user?.id) {
+ return {
+ success: false,
+ message: "인증이 필요합니다."
+ };
+ }
+
+ const userId = Number(session.user.id);
+
+ if (!cancelReason || cancelReason.trim() === "") {
+ return {
+ success: false,
+ message: "취소 사유를 입력해주세요."
+ };
+ }
+
+ // 1. RFQ Detail 정보 조회
+ const rfqDetails = await db.query.rfqLastDetails.findMany({
+ where: and(
+ eq(rfqLastDetails.rfqsLastId, rfqId),
+ inArray(rfqLastDetails.id, detailIds),
+ eq(rfqLastDetails.isLatest, true)
+ ),
+ columns: {
+ id: true,
+ vendorsId: true,
+ }
+ });
+
+ if (rfqDetails.length === 0) {
+ return {
+ success: false,
+ message: "취소할 벤더를 찾을 수 없습니다."
+ };
+ }
+
+ const vendorIds = rfqDetails.map(d => d.vendorsId).filter(id => id != null) as number[];
+ const results: Array<{ detailId: number; success: boolean; error?: string }> = [];
+
+ // 2. 각 벤더에 대해 취소 처리
+ for (const detail of rfqDetails) {
+ try {
+ await db.transaction(async (tx) => {
+ const vendorId = detail.vendorsId;
+ if (!vendorId) {
+ throw new Error("벤더 ID가 없습니다.");
+ }
+
+ // 2-1. RFQ Detail의 cancelReason 업데이트
+ await tx
+ .update(rfqLastDetails)
+ .set({
+ cancelReason: cancelReason,
+ updatedBy: userId,
+ updatedAt: new Date()
+ })
+ .where(eq(rfqLastDetails.id, detail.id));
+
+ // 2-2. 업체선정이 되어 있다면 취소 처리
+ await tx
+ .update(rfqLastDetails)
+ .set({
+ isSelected: false,
+ selectionDate: null,
+ selectionReason: null,
+ selectedBy: null,
+ updatedBy: userId,
+ updatedAt: new Date()
+ })
+ .where(
+ and(
+ eq(rfqLastDetails.id, detail.id),
+ eq(rfqLastDetails.isSelected, true)
+ )
+ );
+
+ // 2-3. Vendor Response를 "취소" 상태로 변경
+ await tx
+ .update(rfqLastVendorResponses)
+ .set({
+ status: "취소",
+ updatedBy: userId,
+ updatedAt: new Date()
+ })
+ .where(
+ and(
+ eq(rfqLastVendorResponses.rfqsLastId, rfqId),
+ eq(rfqLastVendorResponses.vendorId, vendorId),
+ eq(rfqLastVendorResponses.isLatest, true),
+ // 이미 취소된 것은 제외
+ inArray(rfqLastVendorResponses.status, ["대기중", "작성중", "제출완료", "수정요청", "최종확정"])
+ )
+ );
+
+ // 2-4. TBE 세션이 진행중이면 취소 처리
+ await tx
+ .update(rfqLastTbeSessions)
+ .set({
+ status: "취소",
+ updatedBy: userId,
+ updatedAt: new Date()
+ })
+ .where(
+ and(
+ eq(rfqLastTbeSessions.rfqsLastId, rfqId),
+ eq(rfqLastTbeSessions.vendorId, vendorId),
+ inArray(rfqLastTbeSessions.status, ["생성중", "준비중", "진행중", "검토중", "보류"])
+ )
+ );
+ });
+
+ results.push({
+ detailId: detail.id,
+ success: true
+ });
+
+ } catch (error) {
+ console.error(`벤더 응답 취소 실패 (Detail ID: ${detail.id}):`, error);
+ results.push({
+ detailId: detail.id,
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ });
+ }
+ }
+
+ // 3. 캐시 갱신
+ revalidatePath(`/evcp/rfq-last/${rfqId}`);
+ revalidatePath(`/evcp/rfq-last/${rfqId}/vendor`);
+
+ const successCount = results.filter(r => r.success).length;
+ const failCount = results.length - successCount;
+
+ if (failCount === 0) {
+ return {
+ success: true,
+ message: `RFQ 취소가 완료되었습니다. (${successCount}건)`,
+ results
+ };
+ } else {
+ return {
+ success: false,
+ message: `RFQ 취소 중 일부 실패했습니다. (성공: ${successCount}건, 실패: ${failCount}건)`,
+ results
+ };
+ }
+
+ } catch (error) {
+ console.error("RFQ 벤더 응답 취소 처리 중 오류:", error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : "RFQ 취소 처리 중 오류가 발생했습니다."
+ };
+ }
+}
+
diff --git a/lib/rfq-last/delete-action.ts b/lib/rfq-last/delete-action.ts new file mode 100644 index 00000000..3b5f13de --- /dev/null +++ b/lib/rfq-last/delete-action.ts @@ -0,0 +1,199 @@ +'use server'
+
+import { revalidatePath } from "next/cache";
+import db from "@/db/db";
+import { rfqsLast, rfqLastTbeSessions, rfqLastDetails } from "@/db/schema";
+import { rfqLastVendorResponses } from "@/db/schema/rfqVendor";
+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";
+
+/**
+ * RFQ 삭제 (상태 변경) 서버 액션
+ * ANFNR이 있는 RFQ만 삭제 가능하며, ECC로 SOAP 취소 요청을 전송한 후
+ * 성공 시 RFQ 상태를 "RFQ 삭제"로 변경하고 연결된 TBE 세션을 "취소" 상태로 변경
+ * 또한 연결된 vendor response를 "RFQ 삭제" 상태로 변경하고,
+ * 업체선정이 진행중인 경우 업체선정 취소 처리
+ */
+export async function deleteRfq(rfqIds: number[], deleteReason?: string): Promise<{
+ success: boolean;
+ message: string;
+ results?: Array<{ rfqId: number; success: boolean; error?: string }>;
+}> {
+ try {
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user?.id) {
+ return {
+ success: false,
+ message: "인증이 필요합니다."
+ };
+ }
+
+ const userId = Number(session.user.id);
+
+ // 1. RFQ 정보 조회 및 ANFNR 유효성 검증
+ const rfqs = await db.query.rfqsLast.findMany({
+ where: inArray(rfqsLast.id, rfqIds),
+ columns: {
+ id: true,
+ rfqCode: true,
+ ANFNR: true,
+ status: true,
+ }
+ });
+
+ // ANFNR이 있는 RFQ만 필터링
+ const rfqsWithAnfnr = rfqs.filter(rfq => rfq.ANFNR && rfq.ANFNR.trim() !== "");
+
+ if (rfqsWithAnfnr.length === 0) {
+ return {
+ success: false,
+ message: "ANFNR이 있는 RFQ가 선택되지 않았습니다."
+ };
+ }
+
+ // 요청된 RFQ 중 일부가 없거나 ANFNR이 없는 경우 확인
+ const missingIds = rfqIds.filter(id => !rfqs.find(r => r.id === id));
+ const rfqsWithoutAnfnr = rfqs.filter(rfq => !rfq.ANFNR || rfq.ANFNR.trim() === "");
+
+ if (missingIds.length > 0 || rfqsWithoutAnfnr.length > 0) {
+ const warnings: string[] = [];
+ if (missingIds.length > 0) {
+ warnings.push(`존재하지 않는 RFQ: ${missingIds.join(", ")}`);
+ }
+ if (rfqsWithoutAnfnr.length > 0) {
+ warnings.push(`ANFNR이 없는 RFQ: ${rfqsWithoutAnfnr.map(r => r.rfqCode || r.id).join(", ")}`);
+ }
+ }
+
+ const results: Array<{ rfqId: number; success: boolean; error?: string }> = [];
+
+ // 2. 각 RFQ에 대해 ECC 취소 요청 및 상태 변경 처리
+ for (const rfq of rfqsWithAnfnr) {
+ try {
+ // 2-1. ECC로 SOAP 취소 요청 전송
+ const cancelResult = await cancelRFQ(rfq.ANFNR!);
+
+ if (!cancelResult.success) {
+ results.push({
+ rfqId: rfq.id,
+ success: false,
+ error: cancelResult.message
+ });
+ continue;
+ }
+
+ // 2-2. ECC 요청 성공 시 트랜잭션 내에서 상태 변경
+ await db.transaction(async (tx) => {
+ // RFQ 상태를 "RFQ 삭제"로 변경 및 삭제 사유 저장
+ await tx
+ .update(rfqsLast)
+ .set({
+ status: "RFQ 삭제",
+ deleteReason: deleteReason || null,
+ updatedBy: userId,
+ updatedAt: new Date()
+ })
+ .where(eq(rfqsLast.id, rfq.id));
+
+ // 연결된 모든 TBE 세션을 "취소" 상태로 변경
+ // TBE 세션이 없어도 정상 동작 (조건에 맞는 레코드가 없으면 업데이트 없이 종료)
+ await tx
+ .update(rfqLastTbeSessions)
+ .set({
+ status: "취소",
+ updatedBy: userId,
+ updatedAt: new Date()
+ })
+ .where(
+ and(
+ eq(rfqLastTbeSessions.rfqsLastId, rfq.id),
+ inArray(rfqLastTbeSessions.status, ["생성중", "준비중", "진행중", "검토중", "보류"])
+ )
+ );
+
+ // 연결된 모든 vendor response를 "취소" 상태로 변경 (RFQ 삭제 처리)
+ // 참고: 스키마에 "RFQ 삭제" 상태가 없으므로 "취소" 상태를 사용
+ await tx
+ .update(rfqLastVendorResponses)
+ .set({
+ status: "취소",
+ updatedBy: userId,
+ updatedAt: new Date()
+ })
+ .where(
+ and(
+ eq(rfqLastVendorResponses.rfqsLastId, rfq.id),
+ eq(rfqLastVendorResponses.isLatest, true),
+ inArray(rfqLastVendorResponses.status, ["대기중", "작성중", "제출완료", "수정요청", "최종확정"])
+ )
+ );
+
+ // 업체선정이 진행중인 경우 취소 처리
+ // isSelected가 true인 경우 또는 contractStatus가 "일반계약 진행중"인 경우
+ await tx
+ .update(rfqLastDetails)
+ .set({
+ isSelected: false,
+ selectionDate: null,
+ selectionReason: null,
+ selectedBy: null,
+ contractStatus: null, // 계약 상태 초기화
+ updatedBy: userId,
+ updatedAt: new Date()
+ })
+ .where(
+ and(
+ eq(rfqLastDetails.rfqsLastId, rfq.id),
+ eq(rfqLastDetails.isLatest, true)
+ )
+ );
+ });
+
+ // 2-3. 캐시 갱신
+ revalidatePath("/evcp/rfq-last");
+ revalidatePath(`/evcp/rfq-last/${rfq.id}`);
+
+ results.push({
+ rfqId: rfq.id,
+ success: true
+ });
+
+ } catch (error) {
+ console.error(`RFQ 삭제 실패 (ID: ${rfq.id}, ANFNR: ${rfq.ANFNR}):`, error);
+ results.push({
+ rfqId: rfq.id,
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ });
+ }
+ }
+
+ const successCount = results.filter(r => r.success).length;
+ const failCount = results.length - successCount;
+
+ if (failCount === 0) {
+ return {
+ success: true,
+ message: `RFQ 삭제가 완료되었습니다. (${successCount}건)`,
+ results
+ };
+ } else {
+ return {
+ success: false,
+ message: `RFQ 삭제 중 일부 실패했습니다. (성공: ${successCount}건, 실패: ${failCount}건)`,
+ results
+ };
+ }
+
+ } catch (error) {
+ console.error("RFQ 삭제 처리 중 오류:", error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : "RFQ 삭제 처리 중 오류가 발생했습니다."
+ };
+ }
+}
+
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index 461a635a..65ead12b 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -3,7 +3,7 @@ import { revalidatePath, unstable_cache, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import { avlVendorInfo, paymentTerms, incoterms, rfqLastVendorQuotationItems, rfqLastVendorAttachments, rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView, vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts, projects, basicContract, basicContractTemplates, rfqLastTbeSessions, rfqLastTbeDocumentReviews, templateDetailView, RfqStatus, purchaseRequests } from "@/db/schema"; +import { avlVendorInfo, paymentTerms, incoterms, rfqLastVendorQuotationItems, rfqLastVendorAttachments, rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView, vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts, projects, basicContract, basicContractTemplates, rfqLastTbeSessions, rfqLastTbeDocumentReviews, templateDetailView, RfqStatus, rfqLastPriceAdjustmentForms } from "@/db/schema"; import { sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm"; import { filterColumns } from "@/lib/filter-columns"; import { GetRfqLastAttachmentsSchema, GetRfqsSchema } from "./validations"; @@ -1851,13 +1851,41 @@ export async function getRfqVendorResponses(rfqId: number) { .where(eq(rfqLastVendorAttachments.vendorResponseId, response.id)) .orderBy(rfqLastVendorAttachments.attachmentType, rfqLastVendorAttachments.uploadedAt); + // 연동제 폼 조회 + const priceAdjustmentForm = await db + .select({ + id: rfqLastPriceAdjustmentForms.id, + rfqLastVendorResponsesId: rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, + itemName: rfqLastPriceAdjustmentForms.itemName, + adjustmentReflectionPoint: rfqLastPriceAdjustmentForms.adjustmentReflectionPoint, + majorApplicableRawMaterial: rfqLastPriceAdjustmentForms.majorApplicableRawMaterial, + adjustmentFormula: rfqLastPriceAdjustmentForms.adjustmentFormula, + rawMaterialPriceIndex: rfqLastPriceAdjustmentForms.rawMaterialPriceIndex, + referenceDate: rfqLastPriceAdjustmentForms.referenceDate, + comparisonDate: rfqLastPriceAdjustmentForms.comparisonDate, + adjustmentRatio: rfqLastPriceAdjustmentForms.adjustmentRatio, + notes: rfqLastPriceAdjustmentForms.notes, + adjustmentConditions: rfqLastPriceAdjustmentForms.adjustmentConditions, + majorNonApplicableRawMaterial: rfqLastPriceAdjustmentForms.majorNonApplicableRawMaterial, + adjustmentPeriod: rfqLastPriceAdjustmentForms.adjustmentPeriod, + contractorWriter: rfqLastPriceAdjustmentForms.contractorWriter, + adjustmentDate: rfqLastPriceAdjustmentForms.adjustmentDate, + nonApplicableReason: rfqLastPriceAdjustmentForms.nonApplicableReason, + createdAt: rfqLastPriceAdjustmentForms.createdAt, + updatedAt: rfqLastPriceAdjustmentForms.updatedAt, + }) + .from(rfqLastPriceAdjustmentForms) + .where(eq(rfqLastPriceAdjustmentForms.rfqLastVendorResponsesId, response.id)) + .limit(1); + // 해당 벤더의 총 응답 수 가져오기 const vendorResponseCount = responseCountMap.get(response.vendorId) || 0; return { ...response, - quotationItems, - attachments, + quotationItems: quotationItems || [], + attachments: attachments || [], + priceAdjustmentForm: priceAdjustmentForm && priceAdjustmentForm.length > 0 ? priceAdjustmentForm[0] : null, vendorResponseCount, }; }) @@ -1933,6 +1961,7 @@ export async function getRfqVendorResponses(rfqId: number) { materialPriceRelated: { required: response.vendorMaterialPriceRelatedYn, reason: response.vendorMaterialPriceRelatedReason, + priceAdjustmentForm: response.priceAdjustmentForm || null, }, }, @@ -1958,8 +1987,11 @@ export async function getRfqVendorResponses(rfqId: number) { technical: response.technicalProposal, }, + // 연동제 폼 정보 (최상위 레벨에 추가) + priceAdjustmentForm: response.priceAdjustmentForm || null, + // 견적 아이템 상세 - quotationItems: response.quotationItems.map(item => ({ + quotationItems: (response.quotationItems || []).map(item => ({ id: item.id, rfqPrItemId: item.rfqPrItemId, prNo: item.prNo, @@ -1984,7 +2016,7 @@ export async function getRfqVendorResponses(rfqId: number) { })), // 첨부파일 상세 - attachments: response.attachments.map(file => ({ + attachments: (response.attachments || []).map(file => ({ id: file.id, attachmentType: file.attachmentType, documentNo: file.documentNo, diff --git a/lib/rfq-last/table/delete-rfq-dialog.tsx b/lib/rfq-last/table/delete-rfq-dialog.tsx new file mode 100644 index 00000000..01af5453 --- /dev/null +++ b/lib/rfq-last/table/delete-rfq-dialog.tsx @@ -0,0 +1,254 @@ +"use client";
+
+import * as React from "react";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { RfqsLastView } from "@/db/schema";
+import { deleteRfq } from "@/lib/rfq-last/delete-action";
+import { Loader2, AlertTriangle } from "lucide-react";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { toast } from "sonner";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+
+interface DeleteRfqDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedRfqs: RfqsLastView[];
+ onSuccess?: () => void;
+}
+
+export function DeleteRfqDialog({
+ open,
+ onOpenChange,
+ selectedRfqs,
+ onSuccess,
+}: DeleteRfqDialogProps) {
+ const [isDeleting, setIsDeleting] = React.useState(false);
+ const [deleteReason, setDeleteReason] = React.useState("");
+
+ // ANFNR이 있는 RFQ만 필터링
+ const rfqsWithAnfnr = React.useMemo(() => {
+ return selectedRfqs.filter(rfq => rfq.ANFNR && rfq.ANFNR.trim() !== "");
+ }, [selectedRfqs]);
+
+ const handleDelete = async () => {
+ if (rfqsWithAnfnr.length === 0) {
+ toast.error("ANFNR이 있는 RFQ가 선택되지 않았습니다.");
+ return;
+ }
+
+ if (!deleteReason || deleteReason.trim() === "") {
+ toast.error("삭제 사유를 입력해주세요.");
+ return;
+ }
+
+ setIsDeleting(true);
+
+ try {
+ const rfqIds = rfqsWithAnfnr.map(rfq => rfq.id);
+ const result = await deleteRfq(rfqIds, deleteReason.trim());
+
+ if (result.results) {
+ const successCount = result.results.filter(r => r.success).length;
+ const failCount = result.results.length - successCount;
+
+ if (result.success) {
+ // 성공한 RFQ 목록
+ const successRfqs = result.results
+ .filter(r => r.success)
+ .map(r => {
+ const rfq = rfqsWithAnfnr.find(rf => rf.id === r.rfqId);
+ return rfq?.rfqCode || `RFQ ID: ${r.rfqId}`;
+ });
+
+ if (successCount > 0) {
+ toast.success(
+ `RFQ 삭제가 완료되었습니다. (${successCount}건)`,
+ {
+ description: successRfqs.length <= 3
+ ? successRfqs.join(", ")
+ : `${successRfqs.slice(0, 3).join(", ")} 외 ${successRfqs.length - 3}건`,
+ duration: 5000,
+ }
+ );
+ }
+
+ // 실패한 RFQ가 있는 경우
+ if (failCount > 0) {
+ const failRfqs = result.results
+ .filter(r => !r.success)
+ .map(r => {
+ const rfq = rfqsWithAnfnr.find(rf => rf.id === r.rfqId);
+ return `${rfq?.rfqCode || r.rfqId}: ${r.error || "알 수 없는 오류"}`;
+ });
+
+ toast.error(
+ `${failCount}건의 RFQ 삭제가 실패했습니다.`,
+ {
+ description: failRfqs.length <= 3
+ ? failRfqs.join(", ")
+ : `${failRfqs.slice(0, 3).join(", ")} 외 ${failRfqs.length - 3}건`,
+ duration: 7000,
+ }
+ );
+ }
+ } else {
+ // 전체 실패
+ toast.error(result.message || "RFQ 삭제에 실패했습니다.");
+ }
+ } else {
+ if (result.success) {
+ toast.success(result.message);
+ } else {
+ toast.error(result.message);
+ }
+ }
+
+ // 성공 여부와 관계없이 다이얼로그 닫기 및 콜백 호출
+ if (result.success) {
+ setDeleteReason(""); // 성공 시 입력 필드 초기화
+ onOpenChange(false);
+ onSuccess?.();
+ }
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "RFQ 삭제 중 오류가 발생했습니다.");
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ const handleClose = () => {
+ if (!isDeleting) {
+ setDeleteReason(""); // 다이얼로그 닫을 때 입력 필드 초기화
+ onOpenChange(false);
+ }
+ };
+
+ // ANFNR이 없는 RFQ가 포함된 경우 경고 표시
+ const rfqsWithoutAnfnr = selectedRfqs.filter(rfq => !rfq.ANFNR || rfq.ANFNR.trim() === "");
+ const hasWarning = rfqsWithoutAnfnr.length > 0;
+
+ return (
+ <AlertDialog open={open} onOpenChange={handleClose}>
+ <AlertDialogContent className="max-w-2xl">
+ <AlertDialogHeader>
+ <AlertDialogTitle>RFQ 삭제</AlertDialogTitle>
+ <AlertDialogDescription className="space-y-4">
+ {isDeleting ? (
+ /* 로딩 중 상태 - 다른 내용 숨김 */
+ <div className="flex items-center justify-center gap-3 py-8">
+ <Loader2 className="h-6 w-6 animate-spin text-primary" />
+ <div className="flex flex-col">
+ <span className="text-base font-medium">RFQ 삭제 처리 중...</span>
+ <span className="text-sm text-muted-foreground mt-1">
+ ECC로 취소 요청을 전송하고 있습니다.
+ </span>
+ </div>
+ </div>
+ ) : (
+ <>
+ <div>
+ 선택된 RFQ 중 ANFNR이 있는 RFQ만 삭제됩니다.
+ </div>
+
+ {/* 삭제 대상 RFQ 목록 */}
+ {rfqsWithAnfnr.length > 0 && (
+ <div className="space-y-2">
+ <p className="font-medium text-sm">삭제 대상 RFQ ({rfqsWithAnfnr.length}건):</p>
+ <div className="max-h-40 overflow-y-auto border rounded-md p-3 space-y-1">
+ {rfqsWithAnfnr.map((rfq) => (
+ <div key={rfq.id} className="text-sm">
+ <span className="font-mono font-medium">{rfq.rfqCode}</span>
+ {rfq.rfqTitle && (
+ <span className="text-muted-foreground ml-2">
+ - {rfq.rfqTitle}
+ </span>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* ANFNR이 없는 RFQ 경고 */}
+ {hasWarning && (
+ <Alert variant="destructive">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>
+ <div className="space-y-2">
+ <p className="font-medium">ANFNR이 없는 RFQ는 삭제할 수 없습니다 ({rfqsWithoutAnfnr.length}건):</p>
+ <div className="max-h-32 overflow-y-auto space-y-1">
+ {rfqsWithoutAnfnr.map((rfq) => (
+ <div key={rfq.id} className="text-sm">
+ <span className="font-mono">{rfq.rfqCode}</span>
+ {rfq.rfqTitle && (
+ <span className="text-muted-foreground ml-2">
+ - {rfq.rfqTitle}
+ </span>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 삭제 사유 입력 */}
+ <div className="space-y-2">
+ <Label htmlFor="delete-reason" className="text-sm font-medium">
+ 삭제 사유 <span className="text-destructive">*</span>
+ </Label>
+ <Textarea
+ id="delete-reason"
+ placeholder="RFQ 삭제 사유를 입력해주세요..."
+ value={deleteReason}
+ onChange={(e) => setDeleteReason(e.target.value)}
+ disabled={isDeleting}
+ className="min-h-[100px] resize-none"
+ required
+ />
+ </div>
+
+ {/* 안내 메시지 */}
+ <div className="text-sm text-muted-foreground space-y-1">
+ <p>• ANFNR이 있는 RFQ만 삭제됩니다.</p>
+ <p>• ECC로 SOAP 취소 요청이 전송됩니다.</p>
+ <p>• 성공 시 RFQ 상태가 RFQ 삭제로 변경됩니다.</p>
+ <p>• 연결된 TBE 세션도 취소 상태로 변경됩니다.</p>
+ </div>
+ </>
+ )}
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleDelete}
+ disabled={isDeleting || rfqsWithAnfnr.length === 0 || !deleteReason || deleteReason.trim() === ""}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isDeleting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 삭제 중...
+ </>
+ ) : (
+ "RFQ 삭제"
+ )}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ );
+}
+
diff --git a/lib/rfq-last/table/rfq-table-columns.tsx b/lib/rfq-last/table/rfq-table-columns.tsx index d0a9ee1e..e8a5ba94 100644 --- a/lib/rfq-last/table/rfq-table-columns.tsx +++ b/lib/rfq-last/table/rfq-table-columns.tsx @@ -39,6 +39,7 @@ const getStatusBadgeVariant = (status: string) => { case "RFQ 발송": return "default"; case "견적접수": return "default"; case "최종업체선정": return "default"; + case "RFQ 삭제": return "destructive"; default: return "outline"; } }; diff --git a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx index 148336fb..a6dc1ad4 100644 --- a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx +++ b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx @@ -3,11 +3,12 @@ import * as React from "react"; import { Table } from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; -import { Users, RefreshCw, FileDown, Plus, Edit } from "lucide-react"; +import { Users, RefreshCw, FileDown, Plus, Edit, Trash2 } from "lucide-react"; import { RfqsLastView } from "@/db/schema"; import { RfqAssignPicDialog } from "./rfq-assign-pic-dialog"; import { CreateGeneralRfqDialog } from "./create-general-rfq-dialog"; // 추가 import { UpdateGeneralRfqDialog } from "./update-general-rfq-dialog"; // 수정용 +import { DeleteRfqDialog } from "./delete-rfq-dialog"; import { Badge } from "@/components/ui/badge"; import { Tooltip, @@ -29,6 +30,7 @@ export function RfqTableToolbarActions<TData>({ }: RfqTableToolbarActionsProps<TData>) { const [showAssignDialog, setShowAssignDialog] = React.useState(false); const [showUpdateDialog, setShowUpdateDialog] = React.useState(false); + const [showDeleteDialog, setShowDeleteDialog] = React.useState(false); const [selectedRfqForUpdate, setSelectedRfqForUpdate] = React.useState<number | null>(null); console.log(rfqCategory) @@ -47,6 +49,9 @@ export function RfqTableToolbarActions<TData>({ // 수정 가능한 RFQ (general 카테고리에서 RFQ 생성 상태인 항목, 단일 선택만) const updatableRfq = rfqCategory === "general" && rows.length === 1 && rows[0].status === "RFQ 생성" ? rows[0] : null; + // ANFNR이 있는 RFQ만 필터링 (삭제 가능한 RFQ) + const deletableRows = rows.filter(row => row.ANFNR && row.ANFNR.trim() !== ""); + return { ids: rows.map(row => row.id), codes: rows.map(row => row.rfqCode || ""), @@ -61,6 +66,10 @@ export function RfqTableToolbarActions<TData>({ // 수정 가능한 RFQ 정보 updatableRfq: updatableRfq, canUpdate: updatableRfq !== null, + // 삭제 가능한 RFQ 정보 + deletableRows: deletableRows, + deletableCount: deletableRows.length, + canDelete: deletableRows.length > 0, }; }, [selectedRows, rfqCategory]); @@ -92,6 +101,13 @@ export function RfqTableToolbarActions<TData>({ } }; + const handleDeleteSuccess = () => { + // 테이블 선택 초기화 + table.toggleAllPageRowsSelected(false); + // 데이터 새로고침 + onRefresh?.(); + }; + return ( <> <div className="flex items-center gap-2"> @@ -125,6 +141,36 @@ export function RfqTableToolbarActions<TData>({ </TooltipProvider> )} + {/* RFQ 삭제 버튼 - ANFNR이 있는 RFQ가 선택된 경우에만 활성화 */} + {selectedRfqData.totalCount > 0 && selectedRfqData.canDelete && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="destructive" + size="sm" + onClick={() => setShowDeleteDialog(true)} + className="flex items-center gap-2" + > + <Trash2 className="h-4 w-4" /> + RFQ 삭제 + <Badge variant="secondary" className="ml-1"> + {selectedRfqData.deletableCount}건 + </Badge> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>선택한 RFQ를 삭제합니다 (ANFNR이 있는 RFQ만 삭제 가능)</p> + {selectedRfqData.deletableCount !== selectedRfqData.totalCount && ( + <p className="text-xs text-muted-foreground mt-1"> + 전체 {selectedRfqData.totalCount}건 중 {selectedRfqData.deletableCount}건만 삭제 가능합니다 + </p> + )} + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + {/* 선택된 항목 표시 */} {selectedRfqData.totalCount > 0 && ( <div className="flex items-center gap-2 px-3 py-1.5 bg-muted rounded-md"> @@ -198,6 +244,14 @@ export function RfqTableToolbarActions<TData>({ rfqId={selectedRfqForUpdate || 0} onSuccess={handleUpdateGeneralRfqSuccess} /> + + {/* RFQ 삭제 다이얼로그 */} + <DeleteRfqDialog + open={showDeleteDialog} + onOpenChange={setShowDeleteDialog} + selectedRfqs={selectedRfqData.deletableRows} + onSuccess={handleDeleteSuccess} + /> </> ); }
\ No newline at end of file diff --git a/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx b/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx index b8b3a830..f479b48d 100644 --- a/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx +++ b/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx @@ -707,29 +707,256 @@ export default function CommercialTermsForm({ rfqDetail, rfq }: CommercialTermsF {/* 연동제 적용 */} {rfqDetail.materialPriceRelatedYn && ( - <div className="space-y-4"> - <div className="flex items-center justify-between"> - <Label>연동제 적용 요청</Label> - <Badge variant="secondary">요청됨</Badge> - </div> - <div className="flex items-center space-x-2"> - <Checkbox - id="vendorMaterialPriceRelatedYn" - checked={vendorMaterialPriceRelatedYn} - onCheckedChange={(checked) => setValue("vendorMaterialPriceRelatedYn", checked)} - /> - <Label htmlFor="vendorMaterialPriceRelatedYn">연동제 적용 동의</Label> + <> + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <Label>연동제 적용 요청</Label> + <Badge variant="secondary">요청됨</Badge> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="vendorMaterialPriceRelatedYn" + checked={vendorMaterialPriceRelatedYn} + onCheckedChange={(checked) => { + setValue("vendorMaterialPriceRelatedYn", checked) + // 체크박스 해제 시 연동제 정보도 초기화 + if (!checked) { + setValue("priceAdjustmentForm.priceAdjustmentResponse", null) + } + }} + /> + <Label htmlFor="vendorMaterialPriceRelatedYn">연동제 적용 동의</Label> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorMaterialPriceRelatedReason">연동제 관련 의견</Label> + <Textarea + id="vendorMaterialPriceRelatedReason" + {...register("vendorMaterialPriceRelatedReason")} + placeholder="연동제 적용에 대한 의견을 입력하세요" + className="min-h-[100px]" + /> + </div> </div> - <div className="space-y-2"> - <Label htmlFor="vendorMaterialPriceRelatedReason">연동제 관련 의견</Label> - <Textarea - id="vendorMaterialPriceRelatedReason" - {...register("vendorMaterialPriceRelatedReason")} - placeholder="연동제 적용에 대한 의견을 입력하세요" - className="min-h-[100px]" - /> - </div> - </div> + + {/* 연동제 상세 정보 */} + {vendorMaterialPriceRelatedYn && ( + <Card className="mt-6"> + <CardHeader> + <CardTitle className="text-lg">하도급대금등 연동표</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* 연동제 적용 여부 선택 */} + <div className="space-y-3 p-4 border rounded-lg bg-muted/30"> + <Label className="font-semibold text-base">연동제 적용 여부 *</Label> + <RadioGroup + value={watch("priceAdjustmentForm.priceAdjustmentResponse") === null || watch("priceAdjustmentForm.priceAdjustmentResponse") === undefined ? 'none' : watch("priceAdjustmentForm.priceAdjustmentResponse") === true ? 'apply' : 'not-apply'} + onValueChange={(value) => { + const newValue = value === 'apply' ? true : value === 'not-apply' ? false : null + setValue("priceAdjustmentForm.priceAdjustmentResponse", newValue) + }} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="apply" id="price-adjustment-apply" /> + <Label htmlFor="price-adjustment-apply" className="font-normal cursor-pointer"> + 연동제 적용 + </Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="not-apply" id="price-adjustment-not-apply" /> + <Label htmlFor="price-adjustment-not-apply" className="font-normal cursor-pointer"> + 연동제 미적용 + </Label> + </div> + </RadioGroup> + </div> + + {/* 공통 필드 - 품목등의 명칭 */} + {watch("priceAdjustmentForm.priceAdjustmentResponse") !== null && watch("priceAdjustmentForm.priceAdjustmentResponse") !== undefined && ( + <div className="space-y-2"> + <Label htmlFor="priceAdjustmentForm.itemName">품목등의 명칭 *</Label> + <Input + id="priceAdjustmentForm.itemName" + {...register("priceAdjustmentForm.itemName")} + placeholder="품목명을 입력하세요" + required + /> + </div> + )} + + {/* 연동제 적용 시 - 모든 필드 표시 */} + {watch("priceAdjustmentForm.priceAdjustmentResponse") === true && ( + <> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="priceAdjustmentForm.adjustmentReflectionPoint">조정대금 반영시점 *</Label> + <Input + id="priceAdjustmentForm.adjustmentReflectionPoint" + {...register("priceAdjustmentForm.adjustmentReflectionPoint")} + placeholder="반영시점을 입력하세요" + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="priceAdjustmentForm.adjustmentRatio">연동 비율 (%) *</Label> + <Input + id="priceAdjustmentForm.adjustmentRatio" + type="number" + step="0.01" + {...register("priceAdjustmentForm.adjustmentRatio", { valueAsNumber: true })} + placeholder="비율을 입력하세요" + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="priceAdjustmentForm.adjustmentPeriod">조정주기 *</Label> + <Input + id="priceAdjustmentForm.adjustmentPeriod" + {...register("priceAdjustmentForm.adjustmentPeriod")} + placeholder="조정주기를 입력하세요" + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="priceAdjustmentForm.referenceDate">기준시점 *</Label> + <Input + id="priceAdjustmentForm.referenceDate" + type="date" + {...register("priceAdjustmentForm.referenceDate")} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="priceAdjustmentForm.comparisonDate">비교시점 *</Label> + <Input + id="priceAdjustmentForm.comparisonDate" + type="date" + {...register("priceAdjustmentForm.comparisonDate")} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="priceAdjustmentForm.contractorWriter">수탁기업(협력사) 작성자 *</Label> + <Input + id="priceAdjustmentForm.contractorWriter" + {...register("priceAdjustmentForm.contractorWriter")} + placeholder="작성자명을 입력하세요" + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="priceAdjustmentForm.adjustmentDate">조정일 *</Label> + <Input + id="priceAdjustmentForm.adjustmentDate" + type="date" + {...register("priceAdjustmentForm.adjustmentDate")} + required + /> + </div> + </div> + + <div className="space-y-2"> + <Label htmlFor="priceAdjustmentForm.majorApplicableRawMaterial">연동대상 주요 원재료 *</Label> + <Textarea + id="priceAdjustmentForm.majorApplicableRawMaterial" + {...register("priceAdjustmentForm.majorApplicableRawMaterial")} + placeholder="연동 대상 원재료를 입력하세요" + rows={3} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="priceAdjustmentForm.adjustmentFormula">하도급대금등 연동 산식 *</Label> + <Textarea + id="priceAdjustmentForm.adjustmentFormula" + {...register("priceAdjustmentForm.adjustmentFormula")} + placeholder="연동 산식을 입력하세요" + rows={3} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="priceAdjustmentForm.rawMaterialPriceIndex">원재료 가격 기준지표 *</Label> + <Textarea + id="priceAdjustmentForm.rawMaterialPriceIndex" + {...register("priceAdjustmentForm.rawMaterialPriceIndex")} + placeholder="가격 기준지표를 입력하세요" + rows={2} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="priceAdjustmentForm.adjustmentConditions">조정요건 *</Label> + <Textarea + id="priceAdjustmentForm.adjustmentConditions" + {...register("priceAdjustmentForm.adjustmentConditions")} + placeholder="조정요건을 입력하세요" + rows={2} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="priceAdjustmentForm.notes">기타 사항</Label> + <Textarea + id="priceAdjustmentForm.notes" + {...register("priceAdjustmentForm.notes")} + placeholder="기타 사항을 입력하세요" + rows={2} + /> + </div> + </> + )} + + {/* 연동제 미적용 시 - 제한된 필드만 표시 */} + {watch("priceAdjustmentForm.priceAdjustmentResponse") === false && ( + <> + <div className="space-y-2"> + <Label htmlFor="priceAdjustmentForm.majorNonApplicableRawMaterial">연동제 미적용 주요 원재료 *</Label> + <Textarea + id="priceAdjustmentForm.majorNonApplicableRawMaterial" + {...register("priceAdjustmentForm.majorNonApplicableRawMaterial")} + placeholder="연동 미적용 원재료를 입력하세요" + rows={2} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="priceAdjustmentForm.contractorWriterNonApplicable">수탁기업(협력사) 작성자 *</Label> + <Input + id="priceAdjustmentForm.contractorWriterNonApplicable" + {...register("priceAdjustmentForm.contractorWriter")} + placeholder="작성자명을 입력하세요" + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="priceAdjustmentForm.nonApplicableReason">연동제 미희망 사유 *</Label> + <Textarea + id="priceAdjustmentForm.nonApplicableReason" + {...register("priceAdjustmentForm.nonApplicableReason")} + placeholder="미희망 사유를 입력하세요" + rows={3} + required + /> + </div> + </> + )} + + </CardContent> + </Card> + )} + </> )} </CardContent> </Card> 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 48ebeb47..c983dd55 100644 --- a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx +++ b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx @@ -66,6 +66,24 @@ const vendorResponseSchema = z.object({ // 연동제 vendorMaterialPriceRelatedYn: z.boolean().optional(), vendorMaterialPriceRelatedReason: z.string().optional(), + priceAdjustmentForm: z.object({ + priceAdjustmentResponse: z.boolean().nullable().optional(), + itemName: z.string().optional(), + adjustmentReflectionPoint: z.string().optional(), + adjustmentRatio: z.number().optional(), + adjustmentPeriod: z.string().optional(), + referenceDate: z.string().optional(), + comparisonDate: z.string().optional(), + adjustmentDate: z.string().optional(), + contractorWriter: z.string().optional(), + majorApplicableRawMaterial: z.string().optional(), + adjustmentFormula: z.string().optional(), + rawMaterialPriceIndex: z.string().optional(), + adjustmentConditions: z.string().optional(), + notes: z.string().optional(), + majorNonApplicableRawMaterial: z.string().optional(), + nonApplicableReason: z.string().optional(), + }).optional(), // 변경 사유 currencyReason: z.string().optional(), @@ -162,6 +180,51 @@ export default function VendorResponseEditor({ vendorMaterialPriceRelatedYn: existingResponse?.vendorMaterialPriceRelatedYn ?? rfqDetail.materialPriceRelatedYn, vendorMaterialPriceRelatedReason: existingResponse?.vendorMaterialPriceRelatedReason || "", + priceAdjustmentForm: existingResponse?.priceAdjustmentForm ? { + priceAdjustmentResponse: existingResponse.priceAdjustmentForm.majorApplicableRawMaterial ? true : + existingResponse.priceAdjustmentForm.majorNonApplicableRawMaterial ? false : null, + itemName: existingResponse.priceAdjustmentForm.itemName || "", + adjustmentReflectionPoint: existingResponse.priceAdjustmentForm.adjustmentReflectionPoint || "", + adjustmentRatio: existingResponse.priceAdjustmentForm.adjustmentRatio ? Number(existingResponse.priceAdjustmentForm.adjustmentRatio) : undefined, + adjustmentPeriod: existingResponse.priceAdjustmentForm.adjustmentPeriod || "", + referenceDate: existingResponse.priceAdjustmentForm.referenceDate ? + (typeof existingResponse.priceAdjustmentForm.referenceDate === 'string' + ? existingResponse.priceAdjustmentForm.referenceDate + : existingResponse.priceAdjustmentForm.referenceDate.toISOString().split('T')[0]) : "", + comparisonDate: existingResponse.priceAdjustmentForm.comparisonDate ? + (typeof existingResponse.priceAdjustmentForm.comparisonDate === 'string' + ? existingResponse.priceAdjustmentForm.comparisonDate + : existingResponse.priceAdjustmentForm.comparisonDate.toISOString().split('T')[0]) : "", + adjustmentDate: existingResponse.priceAdjustmentForm.adjustmentDate ? + (typeof existingResponse.priceAdjustmentForm.adjustmentDate === 'string' + ? existingResponse.priceAdjustmentForm.adjustmentDate + : existingResponse.priceAdjustmentForm.adjustmentDate.toISOString().split('T')[0]) : "", + contractorWriter: existingResponse.priceAdjustmentForm.contractorWriter || "", + majorApplicableRawMaterial: existingResponse.priceAdjustmentForm.majorApplicableRawMaterial || "", + adjustmentFormula: existingResponse.priceAdjustmentForm.adjustmentFormula || "", + rawMaterialPriceIndex: existingResponse.priceAdjustmentForm.rawMaterialPriceIndex || "", + adjustmentConditions: existingResponse.priceAdjustmentForm.adjustmentConditions || "", + notes: existingResponse.priceAdjustmentForm.notes || "", + majorNonApplicableRawMaterial: existingResponse.priceAdjustmentForm.majorNonApplicableRawMaterial || "", + nonApplicableReason: existingResponse.priceAdjustmentForm.nonApplicableReason || "", + } : { + priceAdjustmentResponse: null, + itemName: "", + adjustmentReflectionPoint: "", + adjustmentRatio: undefined, + adjustmentPeriod: "", + referenceDate: "", + comparisonDate: "", + adjustmentDate: "", + contractorWriter: "", + majorApplicableRawMaterial: "", + adjustmentFormula: "", + rawMaterialPriceIndex: "", + adjustmentConditions: "", + notes: "", + majorNonApplicableRawMaterial: "", + nonApplicableReason: "", + }, currencyReason: existingResponse?.currencyReason || "", paymentTermsReason: existingResponse?.paymentTermsReason || "", diff --git a/lib/rfq-last/vendor/add-vendor-dialog.tsx b/lib/rfq-last/vendor/add-vendor-dialog.tsx index 8566763f..6b4efe74 100644 --- a/lib/rfq-last/vendor/add-vendor-dialog.tsx +++ b/lib/rfq-last/vendor/add-vendor-dialog.tsx @@ -27,9 +27,10 @@ import { import { Check, ChevronsUpDown, Loader2, X, Plus, FileText, Shield, Globe, Settings } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; -import { addVendorsToRfq } from "../service"; +import { addVendorsToRfq, getRfqItemsAction } from "../service"; import { getVendorsForSelection } from "@/lib/b-rfq/service"; import { Badge } from "@/components/ui/badge"; +import { getMrcTypeByMatnr } from "@/lib/mdg/actions/material-service"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -68,13 +69,83 @@ export function AddVendorDialog({ // 각 벤더별 기본계약 요구사항 상태 const [vendorContracts, setVendorContracts] = React.useState<VendorContract[]>([]); - // 일괄 적용용 기본값 + // MRC Type이 "P"인지 확인하는 상태 + const [hasMrcTypeP, setHasMrcTypeP] = React.useState(false); + const [isCheckingMrcType, setIsCheckingMrcType] = React.useState(false); + + // 일괄 적용용 기본값 (MRC Type과 외자업체 여부에 따라 동적으로 설정) const [defaultContract, setDefaultContract] = React.useState({ - agreementYn: true, - ndaYn: true, + agreementYn: false, + ndaYn: false, gtcType: "none" as "general" | "project" | "none" }); + // MRC Type 확인 + const checkMrcType = React.useCallback(async () => { + setIsCheckingMrcType(true); + try { + const itemsResult = await getRfqItemsAction(rfqId); + if (itemsResult.success && itemsResult.data && itemsResult.data.length > 0) { + // 모든 품목의 MRC Type 확인 + const mrcTypeChecks = await Promise.all( + itemsResult.data + .filter(item => item.materialCode) // materialCode가 있는 경우만 + .map(async (item) => { + try { + console.log(item.materialCode, "item.materialCode"); + const mrcType = await getMrcTypeByMatnr(item.materialCode); + console.log(mrcType, "mrcType"); + return mrcType === "P"; + } catch (error) { + console.error(`Failed to get MRC Type for ${item.materialCode}:`, error); + return false; + } + }) + ); + console.log(mrcTypeChecks, "mrcTypeChecks"); + + // 하나라도 "P"가 있으면 true + const hasP = mrcTypeChecks.some(check => check === true); + setHasMrcTypeP(hasP); + console.log(hasP, "hasP"); + + // MRC Type이 "P"이고 국내업체인 경우에만 기본값을 true로 설정 + if (hasP) { + setDefaultContract(prev => ({ + ...prev, + agreementYn: true, + ndaYn: true + })); + } else { + setDefaultContract(prev => ({ + ...prev, + agreementYn: false, + ndaYn: false + })); + } + } else { + // 품목이 없으면 기본값 false + setHasMrcTypeP(false); + setDefaultContract(prev => ({ + ...prev, + agreementYn: false, + ndaYn: false + })); + } + } catch (error) { + console.error("Failed to check MRC Type:", error); + // 에러 발생 시 기본값 false + setHasMrcTypeP(false); + setDefaultContract(prev => ({ + ...prev, + agreementYn: false, + ndaYn: false + })); + } finally { + setIsCheckingMrcType(false); + } + }, [rfqId]); + // 벤더 로드 const loadVendors = React.useCallback(async () => { try { @@ -91,8 +162,9 @@ export function AddVendorDialog({ React.useEffect(() => { if (open) { loadVendors(); + checkMrcType(); } - }, [open, loadVendors]); + }, [open, loadVendors, checkMrcType]); // 초기화 React.useEffect(() => { @@ -100,14 +172,20 @@ export function AddVendorDialog({ setSelectedVendors([]); setVendorContracts([]); setActiveTab("vendors"); + setHasMrcTypeP(false); setDefaultContract({ - agreementYn: true, - ndaYn: true, + agreementYn: false, + ndaYn: false, gtcType: "none" }); } }, [open]); + // 외자업체 여부 확인 + const isInternationalVendor = (vendor: any) => { + return vendor.country && vendor.country !== "KR" && vendor.country !== "한국"; + }; + // 벤더 추가 const handleAddVendor = (vendor: any) => { if (!selectedVendors.find(v => v.id === vendor.id)) { @@ -115,13 +193,15 @@ export function AddVendorDialog({ setSelectedVendors(updatedVendors); // 해당 벤더의 기본계약 설정 추가 - const isInternational = vendor.country && vendor.country !== "KR" && vendor.country !== "한국"; + const isInternational = isInternationalVendor(vendor); + // 외자업체이거나 MRC Type이 "P"가 아닌 경우 false로 설정 + const shouldCheckAgreement = hasMrcTypeP && !isInternational; setVendorContracts([ ...vendorContracts, { vendorId: vendor.id, - agreementYn: defaultContract.agreementYn, - ndaYn: defaultContract.ndaYn, + agreementYn: shouldCheckAgreement, + ndaYn: shouldCheckAgreement, gtcType: isInternational ? defaultContract.gtcType : "none" } ]); @@ -149,11 +229,13 @@ export function AddVendorDialog({ setVendorContracts(contracts => contracts.map(c => { const vendor = selectedVendors.find(v => v.id === c.vendorId); - const isInternational = vendor?.country && vendor.country !== "KR" && vendor.country !== "한국"; + const isInternational = isInternationalVendor(vendor); + // 외자업체이거나 MRC Type이 "P"가 아닌 경우 false로 설정 + const shouldCheckAgreement = hasMrcTypeP && !isInternational; return { ...c, - agreementYn: defaultContract.agreementYn, - ndaYn: defaultContract.ndaYn, + agreementYn: shouldCheckAgreement, + ndaYn: shouldCheckAgreement, gtcType: isInternational ? defaultContract.gtcType : "none" }; }) @@ -236,7 +318,7 @@ export function AddVendorDialog({ {/* 탭 */} <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="flex-1 flex flex-col min-h-0"> - <TabsList className="mx-6 grid w-fit grid-cols-2"> + <TabsList className="ml-6 grid w-fit grid-cols-2"> <TabsTrigger value="vendors"> 1. 벤더 선택 {selectedVendors.length > 0 && ( @@ -378,7 +460,7 @@ export function AddVendorDialog({ <TabsContent value="contracts" className="flex-1 flex flex-col px-6 py-4 overflow-hidden min-h-0"> <div className="flex-1 overflow-y-auto space-y-4 min-h-0"> {/* 일괄 적용 카드 */} - <Card> + {/* <Card> <CardHeader className="pb-3"> <CardTitle className="text-base flex items-center gap-2"> <Settings className="h-4 w-4" /> @@ -395,6 +477,7 @@ export function AddVendorDialog({ <Checkbox id="default-agreement" checked={defaultContract.agreementYn} + disabled={!hasMrcTypeP || isCheckingMrcType} onCheckedChange={(checked) => setDefaultContract({ ...defaultContract, agreementYn: !!checked }) } @@ -407,6 +490,7 @@ export function AddVendorDialog({ <Checkbox id="default-nda" checked={defaultContract.ndaYn} + disabled={!hasMrcTypeP || isCheckingMrcType} onCheckedChange={(checked) => setDefaultContract({ ...defaultContract, ndaYn: !!checked }) } @@ -448,7 +532,7 @@ export function AddVendorDialog({ 모든 벤더에 적용 </Button> </CardContent> - </Card> + </Card> */} {/* 개별 벤더 설정 */} <Card className="flex flex-col min-h-0"> @@ -463,7 +547,7 @@ export function AddVendorDialog({ <div className="space-y-4"> {selectedVendors.map((vendor) => { const contract = vendorContracts.find(c => c.vendorId === vendor.id); - const isInternational = vendor.country && vendor.country !== "KR" && vendor.country !== "한국"; + const isInternational = isInternationalVendor(vendor); return ( <div key={vendor.id} className="border rounded-lg p-4 space-y-3"> @@ -485,6 +569,7 @@ export function AddVendorDialog({ <div className="flex items-center space-x-2"> <Checkbox checked={contract?.agreementYn || false} + disabled={!hasMrcTypeP || isInternational} onCheckedChange={(checked) => updateVendorContract(vendor.id, "agreementYn", !!checked) } @@ -494,11 +579,12 @@ export function AddVendorDialog({ <div className="flex items-center space-x-2"> <Checkbox checked={contract?.ndaYn || false} + disabled={!hasMrcTypeP || isInternational} onCheckedChange={(checked) => updateVendorContract(vendor.id, "ndaYn", !!checked) } /> - <label className="text-sm">NDA</label> + <label className="text-sm">비밀유지 계약 (NDA)</label> </div> </div> diff --git a/lib/rfq-last/vendor/cancel-vendor-response-dialog.tsx b/lib/rfq-last/vendor/cancel-vendor-response-dialog.tsx new file mode 100644 index 00000000..414cfa4b --- /dev/null +++ b/lib/rfq-last/vendor/cancel-vendor-response-dialog.tsx @@ -0,0 +1,208 @@ +"use client";
+
+import * as React from "react";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { cancelVendorResponse } from "@/lib/rfq-last/cancel-vendor-response-action";
+import { Loader2, AlertTriangle } from "lucide-react";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+
+interface CancelVendorResponseDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ rfqId: number;
+ selectedVendors: Array<{
+ detailId: number;
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+ }>;
+ onSuccess?: () => void;
+}
+
+export function CancelVendorResponseDialog({
+ open,
+ onOpenChange,
+ rfqId,
+ selectedVendors,
+ onSuccess,
+}: CancelVendorResponseDialogProps) {
+ const [isCancelling, setIsCancelling] = React.useState(false);
+ const [cancelReason, setCancelReason] = React.useState("");
+ const [error, setError] = React.useState<string | null>(null);
+ const [results, setResults] = React.useState<Array<{ detailId: number; success: boolean; error?: string }> | undefined>();
+
+ const handleCancel = async () => {
+ if (!cancelReason || cancelReason.trim() === "") {
+ setError("취소 사유를 입력해주세요.");
+ return;
+ }
+
+ setIsCancelling(true);
+ setError(null);
+ setResults(undefined);
+
+ try {
+ const detailIds = selectedVendors.map(v => v.detailId);
+ const result = await cancelVendorResponse(rfqId, detailIds, cancelReason.trim());
+
+ if (result.results) {
+ setResults(result.results);
+ }
+
+ if (result.success) {
+ // 성공 시 다이얼로그 닫기 및 콜백 호출
+ setTimeout(() => {
+ setCancelReason("");
+ onOpenChange(false);
+ onSuccess?.();
+ }, 1500);
+ } else {
+ setError(result.message);
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "RFQ 취소 중 오류가 발생했습니다.");
+ } finally {
+ setIsCancelling(false);
+ }
+ };
+
+ const handleClose = () => {
+ if (!isCancelling) {
+ setError(null);
+ setResults(undefined);
+ setCancelReason("");
+ onOpenChange(false);
+ }
+ };
+
+ return (
+ <AlertDialog open={open} onOpenChange={handleClose}>
+ <AlertDialogContent className="max-w-2xl">
+ <AlertDialogHeader>
+ <AlertDialogTitle>RFQ 취소</AlertDialogTitle>
+ <AlertDialogDescription className="space-y-4">
+ <div>
+ 선택된 벤더에 대한 RFQ를 취소합니다. 취소 후 해당 벤더는 더 이상 견적을 제출할 수 없습니다.
+ </div>
+
+ {/* 취소 대상 벤더 목록 */}
+ {selectedVendors.length > 0 && (
+ <div className="space-y-2">
+ <p className="font-medium text-sm">취소 대상 벤더 ({selectedVendors.length}건):</p>
+ <div className="max-h-40 overflow-y-auto border rounded-md p-3 space-y-1">
+ {selectedVendors.map((vendor) => (
+ <div key={vendor.detailId} className="text-sm">
+ <span className="font-medium">{vendor.vendorName}</span>
+ {vendor.vendorCode && (
+ <span className="text-muted-foreground ml-2">
+ ({vendor.vendorCode})
+ </span>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 취소 사유 입력 */}
+ <div className="space-y-2">
+ <Label htmlFor="cancelReason">취소 사유 *</Label>
+ <Textarea
+ id="cancelReason"
+ placeholder="RFQ 취소 사유를 입력해주세요..."
+ value={cancelReason}
+ onChange={(e) => setCancelReason(e.target.value)}
+ disabled={isCancelling || !!results}
+ rows={4}
+ className="resize-none"
+ />
+ </div>
+
+ {/* 진행 중 상태 */}
+ {isCancelling && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span>RFQ 취소 처리 중...</span>
+ </div>
+ )}
+
+ {/* 결과 표시 */}
+ {results && !isCancelling && (
+ <div className="space-y-2">
+ <p className="font-medium text-sm">처리 결과:</p>
+ <div className="max-h-40 overflow-y-auto border rounded-md p-3 space-y-2">
+ {results.map((result) => {
+ const vendor = selectedVendors.find(v => v.detailId === result.detailId);
+ return (
+ <div
+ key={result.detailId}
+ className={`text-sm ${
+ result.success ? "text-green-600" : "text-red-600"
+ }`}
+ >
+ <span className="font-medium">
+ {vendor?.vendorName || `Detail ID: ${result.detailId}`}
+ </span>
+ {result.success ? (
+ <span className="ml-2">✅ 취소 완료</span>
+ ) : (
+ <span className="ml-2">
+ ❌ 실패: {result.error || "알 수 없는 오류"}
+ </span>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ )}
+
+ {/* 오류 메시지 */}
+ {error && !isCancelling && (
+ <Alert variant="destructive">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>{error}</AlertDescription>
+ </Alert>
+ )}
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isCancelling}>취소</AlertDialogCancel>
+ {!results && (
+ <AlertDialogAction
+ onClick={handleCancel}
+ disabled={isCancelling || !cancelReason.trim()}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isCancelling ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 취소 중...
+ </>
+ ) : (
+ "RFQ 취소"
+ )}
+ </AlertDialogAction>
+ )}
+ {results && (
+ <AlertDialogAction onClick={handleClose}>
+ 닫기
+ </AlertDialogAction>
+ )}
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ );
+}
+
diff --git a/lib/rfq-last/vendor/price-adjustment-dialog.tsx b/lib/rfq-last/vendor/price-adjustment-dialog.tsx new file mode 100644 index 00000000..b7fd48a6 --- /dev/null +++ b/lib/rfq-last/vendor/price-adjustment-dialog.tsx @@ -0,0 +1,268 @@ +'use client'
+
+import React from 'react'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Badge } from '@/components/ui/badge'
+import { Separator } from '@/components/ui/separator'
+import { format } from 'date-fns'
+import { ko } from 'date-fns/locale'
+
+interface PriceAdjustmentData {
+ id: number
+ itemName?: string | null
+ adjustmentReflectionPoint?: string | null
+ majorApplicableRawMaterial?: string | null
+ adjustmentFormula?: string | null
+ rawMaterialPriceIndex?: string | null
+ referenceDate?: Date | string | null
+ comparisonDate?: Date | string | null
+ adjustmentRatio?: string | null
+ notes?: string | null
+ adjustmentConditions?: string | null
+ majorNonApplicableRawMaterial?: string | null
+ adjustmentPeriod?: string | null
+ contractorWriter?: string | null
+ adjustmentDate?: Date | string | null
+ nonApplicableReason?: string | null
+ createdAt: Date | string
+ updatedAt: Date | string
+}
+
+interface PriceAdjustmentDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ data: PriceAdjustmentData | null
+ vendorName: string
+}
+
+export function PriceAdjustmentDialog({
+ open,
+ onOpenChange,
+ data,
+ vendorName,
+}: PriceAdjustmentDialogProps) {
+ if (!data) return null
+
+ // 날짜 포맷팅 헬퍼
+ const formatDateValue = (date: Date | string | null) => {
+ if (!date) return '-'
+ try {
+ const dateObj = typeof date === 'string' ? new Date(date) : date
+ return format(dateObj, 'yyyy-MM-dd', { locale: ko })
+ } catch {
+ return '-'
+ }
+ }
+
+ // 연동제 적용 여부 판단 (majorApplicableRawMaterial이 있으면 적용, majorNonApplicableRawMaterial이 있으면 미적용)
+ const isApplied = !!data.majorApplicableRawMaterial
+ const isNotApplied = !!data.majorNonApplicableRawMaterial
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <span>하도급대금등 연동표</span>
+ <Badge variant="secondary">{vendorName}</Badge>
+ {isApplied && (
+ <Badge variant="default" className="bg-green-600 hover:bg-green-700">
+ 연동제 적용
+ </Badge>
+ )}
+ {isNotApplied && (
+ <Badge variant="outline" className="border-red-500 text-red-600">
+ 연동제 미적용
+ </Badge>
+ )}
+ </DialogTitle>
+ <DialogDescription>
+ 협력업체가 제출한 연동제 적용 정보입니다.
+ {isApplied && " (연동제 적용)"}
+ {isNotApplied && " (연동제 미적용)"}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* 기본 정보 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">기본 정보</h3>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">품목등의 명칭</label>
+ <p className="text-sm font-medium">{data.itemName || '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">연동제 적용 여부</label>
+ <div className="mt-1">
+ {isApplied && (
+ <Badge variant="default" className="bg-green-600 hover:bg-green-700">
+ 예 (연동제 적용)
+ </Badge>
+ )}
+ {isNotApplied && (
+ <Badge variant="outline" className="border-red-500 text-red-600">
+ 아니오 (연동제 미적용)
+ </Badge>
+ )}
+ {!isApplied && !isNotApplied && (
+ <span className="text-sm text-muted-foreground">-</span>
+ )}
+ </div>
+ </div>
+ {isApplied && (
+ <div>
+ <label className="text-xs text-gray-500">조정대금 반영시점</label>
+ <p className="text-sm font-medium">{data.adjustmentReflectionPoint || '-'}</p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 원재료 정보 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">원재료 정보</h3>
+ <div className="space-y-4">
+ {isApplied && (
+ <div>
+ <label className="text-xs text-gray-500">연동대상 주요 원재료</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.majorApplicableRawMaterial || '-'}
+ </p>
+ </div>
+ )}
+ {isNotApplied && (
+ <>
+ <div>
+ <label className="text-xs text-gray-500">연동 미적용 주요 원재료</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.majorNonApplicableRawMaterial || '-'}
+ </p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">연동 미적용 사유</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.nonApplicableReason || '-'}
+ </p>
+ </div>
+ </>
+ )}
+ </div>
+ </div>
+
+ {isApplied && (
+ <>
+ <Separator />
+
+ {/* 연동 공식 및 지표 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">연동 공식 및 지표</h3>
+ <div className="space-y-4">
+ <div>
+ <label className="text-xs text-gray-500">하도급대금등 연동 산식</label>
+ <div className="p-3 bg-gray-50 rounded-md">
+ <p className="text-sm font-mono whitespace-pre-wrap">
+ {data.adjustmentFormula || '-'}
+ </p>
+ </div>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">원재료 가격 기준지표</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.rawMaterialPriceIndex || '-'}
+ </p>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">기준시점</label>
+ <p className="text-sm font-medium">{data.referenceDate ? formatDateValue(data.referenceDate) : '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">비교시점</label>
+ <p className="text-sm font-medium">{data.comparisonDate ? formatDateValue(data.comparisonDate) : '-'}</p>
+ </div>
+ </div>
+ {data.adjustmentRatio && (
+ <div>
+ <label className="text-xs text-gray-500">연동 비율</label>
+ <p className="text-sm font-medium">
+ {data.adjustmentRatio}%
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 조정 조건 및 기타 */}
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">조정 조건 및 기타</h3>
+ <div className="space-y-4">
+ <div>
+ <label className="text-xs text-gray-500">조정요건</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.adjustmentConditions || '-'}
+ </p>
+ </div>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <label className="text-xs text-gray-500">조정주기</label>
+ <p className="text-sm font-medium">{data.adjustmentPeriod || '-'}</p>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">조정일</label>
+ <p className="text-sm font-medium">{data.adjustmentDate ? formatDateValue(data.adjustmentDate) : '-'}</p>
+ </div>
+ </div>
+ <div>
+ <label className="text-xs text-gray-500">수탁기업(협력사) 작성자</label>
+ <p className="text-sm font-medium">{data.contractorWriter || '-'}</p>
+ </div>
+ {data.notes && (
+ <div>
+ <label className="text-xs text-gray-500">기타 사항</label>
+ <p className="text-sm font-medium whitespace-pre-wrap">
+ {data.notes}
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+ </>
+ )}
+
+ {isNotApplied && (
+ <>
+ <Separator />
+ <div>
+ <h3 className="text-sm font-medium text-gray-900 mb-3">작성자 정보</h3>
+ <div>
+ <label className="text-xs text-gray-500">수탁기업(협력사) 작성자</label>
+ <p className="text-sm font-medium">{data.contractorWriter || '-'}</p>
+ </div>
+ </div>
+ </>
+ )}
+
+ <Separator />
+
+ {/* 메타 정보 */}
+ <div className="text-xs text-gray-500 space-y-1">
+ <p>작성일: {formatDateValue(data.createdAt)}</p>
+ <p>수정일: {formatDateValue(data.updatedAt)}</p>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index c0f80aca..29aa5f09 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -57,6 +57,7 @@ import { toast } from "sonner"; import { AddVendorDialog } from "./add-vendor-dialog"; import { BatchUpdateConditionsDialog } from "./batch-update-conditions-dialog"; import { SendRfqDialog } from "./send-rfq-dialog"; +import { CancelVendorResponseDialog } from "./cancel-vendor-response-dialog"; import { getRfqSendData, @@ -72,6 +73,7 @@ import { useRouter } from "next/navigation" import { EditContractDialog } from "./edit-contract-dialog"; import { createFilterFn } from "@/components/client-data-table/table-filters"; import { AvlVendorDialog } from "./avl-vendor-dialog"; +import { PriceAdjustmentDialog } from "./price-adjustment-dialog"; // 타입 정의 interface RfqDetail { @@ -286,6 +288,11 @@ export function RfqVendorTable({ const [editContractVendor, setEditContractVendor] = React.useState<any | null>(null); const [isUpdatingShortList, setIsUpdatingShortList] = React.useState(false); const [isAvlDialogOpen, setIsAvlDialogOpen] = React.useState(false); + const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<{ + data: any; + vendorName: string; + } | null>(null); + const [isCancelDialogOpen, setIsCancelDialogOpen] = React.useState(false); // AVL 연동 핸들러 const handleAvlIntegration = React.useCallback(() => { @@ -340,10 +347,20 @@ export function RfqVendorTable({ // 견적 비교 핸들러 const handleQuotationCompare = React.useCallback(() => { - const vendorsWithQuotation = selectedRows.filter(row => + // 취소되지 않은 벤더만 필터링 + const nonCancelledRows = selectedRows.filter(row => { + const isCancelled = row.response?.status === "취소" || row.cancelReason; + return !isCancelled; + }); + + const vendorsWithQuotation = nonCancelledRows.filter(row => row.response?.submission?.submittedAt ); + if (vendorsWithQuotation.length === 0) { + toast.warning("비교할 견적이 있는 벤더를 선택해주세요."); + return; + } // 견적 비교 페이지로 이동 또는 모달 열기 const vendorIds = vendorsWithQuotation @@ -356,20 +373,26 @@ export function RfqVendorTable({ // 일괄 발송 핸들러 const handleBulkSend = React.useCallback(async () => { - if (selectedRows.length === 0) { - toast.warning("발송할 벤더를 선택해주세요."); + // 취소되지 않은 벤더만 필터링 + const nonCancelledRows = selectedRows.filter(row => { + const isCancelled = row.response?.status === "취소" || row.cancelReason; + return !isCancelled; + }); + + if (nonCancelledRows.length === 0) { + toast.warning("발송할 벤더를 선택해주세요. (취소된 벤더는 제외됩니다)"); return; } try { setIsLoadingSendData(true); - // 선택된 벤더 ID들 추출 - const selectedVendorIds = rfqCode?.startsWith("I") ? selectedRows + // 선택된 벤더 ID들 추출 (취소되지 않은 벤더만) + const selectedVendorIds = rfqCode?.startsWith("I") ? nonCancelledRows // .filter(v => v.shortList) .map(row => row.vendorId) .filter(id => id != null) : - selectedRows + nonCancelledRows .map(row => row.vendorId) .filter(id => id != null) @@ -629,6 +652,20 @@ export function RfqVendorTable({ case "response-detail": toast.info(`${vendor.vendorName}의 회신 상세를 확인합니다.`); break; + + case "price-adjustment": + // 연동제 정보 다이얼로그 열기 + const priceAdjustmentForm = vendor.response?.priceAdjustmentForm || + vendor.response?.additionalRequirements?.materialPriceRelated?.priceAdjustmentForm; + if (!priceAdjustmentForm) { + toast.warning("연동제 정보가 없습니다."); + return; + } + setPriceAdjustmentData({ + data: priceAdjustmentForm, + vendorName: vendor.vendorName, + }); + break; } }, [rfqId]); @@ -1300,6 +1337,11 @@ export function RfqVendorTable({ const emailResentCount = vendor.response?.email?.emailResentCount || 0; const hasQuotation = !!vendor.quotationStatus; const isKorean = vendor.vendorCountry === "KR" || vendor.vendorCountry === "한국"; + // 연동제 정보는 최상위 레벨 또는 additionalRequirements에서 확인 + const hasPriceAdjustment = !!( + vendor.response?.priceAdjustmentForm || + vendor.response?.additionalRequirements?.materialPriceRelated?.priceAdjustmentForm + ); return ( <DropdownMenu> @@ -1317,6 +1359,14 @@ export function RfqVendorTable({ 상세보기 </DropdownMenuItem> + {/* 연동제 정보 메뉴 (연동제 정보가 있을 때만 표시) */} + {hasPriceAdjustment && ( + <DropdownMenuItem onClick={() => handleAction("price-adjustment", vendor)}> + <FileText className="mr-2 h-4 w-4" /> + 연동제 정보 + </DropdownMenuItem> + )} + {/* 기본계약 수정 메뉴 추가 */} <DropdownMenuItem onClick={() => handleAction("edit-contract", vendor)}> <FileText className="mr-2 h-4 w-4" /> @@ -1341,7 +1391,7 @@ export function RfqVendorTable({ </> )} - {!emailSentAt && ( + {/* {!emailSentAt && ( <DropdownMenuItem onClick={() => handleAction("send", vendor)} disabled={isLoadingSendData} @@ -1349,7 +1399,7 @@ export function RfqVendorTable({ <Send className="mr-2 h-4 w-4" /> RFQ 발송 </DropdownMenuItem> - )} + )} */} <DropdownMenuSeparator /> <DropdownMenuItem @@ -1545,23 +1595,35 @@ export function RfqVendorTable({ // 선택된 벤더 정보 (BatchUpdate용) const selectedVendorsForBatch = React.useMemo(() => { - return selectedRows.map(row => ({ - id: row.vendorId, - vendorName: row.vendorName, - vendorCode: row.vendorCode, - })); + // 취소되지 않은 벤더만 필터링 + return selectedRows + .filter(row => { + const isCancelled = row.response?.status === "취소" || row.cancelReason; + return !isCancelled; + }) + .map(row => ({ + id: row.vendorId, + vendorName: row.vendorName, + vendorCode: row.vendorCode, + })); }, [selectedRows]); // 추가 액션 버튼들 const additionalActions = React.useMemo(() => { + // 취소되지 않은 벤더만 필터링 (취소된 벤더는 제외) + const nonCancelledRows = selectedRows.filter(row => { + const isCancelled = row.response?.status === "취소" || row.cancelReason; + return !isCancelled; + }); + // 참여 의사가 있는 선택된 벤더 수 계산 const participatingCount = selectedRows.length; const shortListCount = selectedRows.filter(v => v.shortList).length; const vendorsWithResponseCount = selectedRows.filter(v => v.response && v.response.vendor && v.response.isDocumentConfirmed).length; - // 견적서가 있는 선택된 벤더 수 계산 - const quotationCount = selectedRows.filter(row => + // 견적서가 있는 선택된 벤더 수 계산 (취소되지 않은 벤더만) + const quotationCount = nonCancelledRows.filter(row => row.response?.submission?.submittedAt ).length; @@ -1591,23 +1653,23 @@ export function RfqVendorTable({ {selectedRows.length > 0 && ( <> - {/* 정보 일괄 입력 버튼 */} + {/* 정보 일괄 입력 버튼 - 취소되지 않은 벤더만 */} <Button variant="outline" size="sm" onClick={() => setIsBatchUpdateOpen(true)} - disabled={isLoadingSendData} + disabled={isLoadingSendData || nonCancelledRows.length === 0} > <Settings2 className="h-4 w-4 mr-2" /> - 협력업체 조건 설정 ({selectedRows.length}) + 협력업체 조건 설정 ({nonCancelledRows.length}) </Button> - {/* RFQ 발송 버튼 */} + {/* RFQ 발송 버튼 - 취소되지 않은 벤더만 */} <Button variant="outline" size="sm" onClick={handleBulkSend} - disabled={isLoadingSendData || selectedRows.length === 0} + disabled={isLoadingSendData || nonCancelledRows.length === 0} > {isLoadingSendData ? ( <> @@ -1617,11 +1679,24 @@ export function RfqVendorTable({ ) : ( <> <Send className="h-4 w-4 mr-2" /> - RFQ 발송 ({selectedRows.length}) + RFQ 발송 ({nonCancelledRows.length}) </> )} </Button> + {/* RFQ 취소 버튼 - RFQ 발송 후에만 표시 (emailSentAt이 있는 경우) 및 취소되지 않은 벤더만 */} + {rfqDetails.some(detail => detail.emailSentAt) && nonCancelledRows.length > 0 && ( + <Button + variant="destructive" + size="sm" + onClick={() => setIsCancelDialogOpen(true)} + disabled={nonCancelledRows.length === 0} + > + <XCircle className="h-4 w-4 mr-2" /> + RFQ 취소 ({nonCancelledRows.length}) + </Button> + )} + {/* Short List 확정 버튼 */} {!rfqCode?.startsWith("F") && <Button @@ -1646,7 +1721,7 @@ export function RfqVendorTable({ </Button> } - {/* 견적 비교 버튼 */} + {/* 견적 비교 버튼 - 취소되지 않은 벤더만 */} <Button variant="outline" size="sm" @@ -1678,7 +1753,7 @@ export function RfqVendorTable({ </Button> </div> ); - }, [selectedRows, isRefreshing, isLoadingSendData, handleBulkSend, handleShortListConfirm, handleQuotationCompare, isUpdatingShortList]); + }, [selectedRows, isRefreshing, isLoadingSendData, handleBulkSend, handleShortListConfirm, handleQuotationCompare, isUpdatingShortList, rfqInfo, rfqCode, handleAvlIntegration, rfqDetails]); return ( <> @@ -1779,6 +1854,40 @@ export function RfqVendorTable({ router.refresh(); }} /> + + {/* 연동제 정보 다이얼로그 */} + {priceAdjustmentData && ( + <PriceAdjustmentDialog + open={!!priceAdjustmentData} + onOpenChange={(open) => !open && setPriceAdjustmentData(null)} + data={priceAdjustmentData.data} + vendorName={priceAdjustmentData.vendorName} + /> + )} + + {/* RFQ 취소 다이얼로그 - 취소되지 않은 벤더만 전달 */} + <CancelVendorResponseDialog + open={isCancelDialogOpen} + onOpenChange={setIsCancelDialogOpen} + rfqId={rfqId} + selectedVendors={selectedRows + .filter(row => { + const isCancelled = row.response?.status === "취소" || row.cancelReason; + return !isCancelled; + }) + .map(row => ({ + detailId: row.detailId, + vendorId: row.vendorId, + vendorName: row.vendorName || "", + vendorCode: row.vendorCode, + }))} + onSuccess={() => { + setIsCancelDialogOpen(false); + setSelectedRows([]); + router.refresh(); + toast.success("RFQ 취소가 완료되었습니다."); + }} + /> </> ); }
\ No newline at end of file diff --git a/lib/soap/ecc/send/cancel-rfq.ts b/lib/soap/ecc/send/delete-rfq.ts index aeba6dd6..f7e945fe 100644 --- a/lib/soap/ecc/send/cancel-rfq.ts +++ b/lib/soap/ecc/send/delete-rfq.ts @@ -2,17 +2,17 @@ import { sendSoapXml, type SoapSendConfig, type SoapLogInfo, type SoapSendResult } from "@/lib/soap/sender"; -// ECC RFQ 취소 엔드포인트 (WSDL에 명시된 P2038_D 사용) +// ECC RFQ 삭제 엔드포인트 (WSDL에 명시된 P2038_D 사용) const ECC_CANCEL_RFQ_ENDPOINT = "http://shii8dvddb01.hec.serp.shi.samsung.net:50000/sap/xi/engine?type=entry&version=3.0&Sender.Service=P2038_Q&Interface=http%3A%2F%2Fshi.samsung.co.kr%2FP2_MM%2FMMM%5EP2MM3016_SO"; -// RFQ 취소 요청 데이터 타입 +// RFQ 삭제 요청 데이터 타입 export interface CancelRFQRequest { T_ANFNR: Array<{ ANFNR: string; // RFQ Number (M) }>; } -// RFQ 취소 응답 데이터 타입 (참고용) +// RFQ 삭제 응답 데이터 타입 (참고용) export interface CancelRFQResponse { EV_TYPE?: string; // 응답 타입 (S: 성공, E: 에러) EV_MESSAGE?: string; // 응답 메시지 @@ -48,7 +48,7 @@ function validateCancelRFQData(rfqData: CancelRFQRequest): { isValid: boolean; e }; } -// ECC로 RFQ 취소 SOAP XML 전송하는 함수 +// ECC로 RFQ 삭제 SOAP XML 전송하는 함수 async function sendCancelRFQToECC(rfqData: CancelRFQRequest): Promise<SoapSendResult> { try { // 데이터 검증 @@ -68,7 +68,7 @@ async function sendCancelRFQToECC(rfqData: CancelRFQRequest): Promise<SoapSendRe endpoint: ECC_CANCEL_RFQ_ENDPOINT, envelope: soapBodyContent, soapAction: 'http://sap.com/xi/WebService/soap1.1', - timeout: 30000, // RFQ 취소는 30초 타임아웃 + timeout: 30000, // RFQ 삭제는 30초 타임아웃 retryCount: 3, retryDelay: 1000, namespace: 'http://shi.samsung.co.kr/P2_MM/MMM', // ECC MM 모듈 네임스페이스 @@ -83,7 +83,7 @@ async function sendCancelRFQToECC(rfqData: CancelRFQRequest): Promise<SoapSendRe }; const rfqNumbers = rfqData.T_ANFNR.map(item => item.ANFNR).join(', '); - console.log(`📤 RFQ 취소 요청 전송 시작 - RFQ Numbers: ${rfqNumbers}`); + console.log(`📤 RFQ 삭제 요청 전송 시작 - RFQ Numbers: ${rfqNumbers}`); console.log(`🔍 취소 대상 RFQ ${rfqData.T_ANFNR.length}개`); console.log(`🌐 엔드포인트: ${ECC_CANCEL_RFQ_ENDPOINT}`); console.log(`📋 네임스페이스: ${config.namespace}`); @@ -93,15 +93,15 @@ async function sendCancelRFQToECC(rfqData: CancelRFQRequest): Promise<SoapSendRe const result = await sendSoapXml(config, logInfo); if (result.success) { - console.log(`✅ RFQ 취소 요청 전송 성공 - RFQ Numbers: ${rfqNumbers}`); + console.log(`✅ RFQ 삭제 요청 전송 성공 - RFQ Numbers: ${rfqNumbers}`); } else { - console.error(`❌ RFQ 취소 요청 전송 실패 - RFQ Numbers: ${rfqNumbers}, 오류: ${result.message}`); + console.error(`❌ RFQ 삭제 요청 전송 실패 - RFQ Numbers: ${rfqNumbers}, 오류: ${result.message}`); } return result; } catch (error) { - console.error('❌ RFQ 취소 전송 중 오류 발생:', error); + console.error('❌ RFQ 삭제 전송 중 오류 발생:', error); return { success: false, message: error instanceof Error ? error.message : 'Unknown error' @@ -110,10 +110,10 @@ async function sendCancelRFQToECC(rfqData: CancelRFQRequest): Promise<SoapSendRe } // ======================================== -// 메인 RFQ 취소 서버 액션 함수들 +// 메인 RFQ 삭제 서버 액션 함수들 // ======================================== -// 단일 RFQ 취소 요청 처리 +// 단일 RFQ 삭제 요청 처리 export async function cancelRFQ(rfqNumber: string): Promise<{ success: boolean; message: string; @@ -125,7 +125,7 @@ export async function cancelRFQ(rfqNumber: string): Promise<{ rfqNumber?: string; }> { try { - console.log(`🚀 RFQ 취소 요청 시작 - RFQ Number: ${rfqNumber}`); + console.log(`🚀 RFQ 삭제 요청 시작 - RFQ Number: ${rfqNumber}`); const rfqData: CancelRFQRequest = { T_ANFNR: [{ @@ -137,7 +137,7 @@ export async function cancelRFQ(rfqNumber: string): Promise<{ return { success: result.success, - message: result.success ? 'RFQ 취소 요청이 성공적으로 전송되었습니다.' : result.message, + message: result.success ? 'RFQ 삭제 요청이 성공적으로 전송되었습니다.' : result.message, responseData: result.responseText, statusCode: result.statusCode, headers: result.headers, @@ -147,7 +147,7 @@ export async function cancelRFQ(rfqNumber: string): Promise<{ }; } catch (error) { - console.error('❌ RFQ 취소 요청 처리 실패:', error); + console.error('❌ RFQ 삭제 요청 처리 실패:', error); return { success: false, message: error instanceof Error ? error.message : 'Unknown error' @@ -162,7 +162,7 @@ export async function cancelMultipleRFQs(rfqNumbers: string[]): Promise<{ results?: Array<{ rfqNumber: string; success: boolean; error?: string }>; }> { try { - console.log(`🚀 배치 RFQ 취소 요청 시작: ${rfqNumbers.length}개`); + console.log(`🚀 배치 RFQ 삭제 요청 시작: ${rfqNumbers.length}개`); // 모든 RFQ를 하나의 요청으로 처리 const rfqData: CancelRFQRequest = { @@ -181,18 +181,18 @@ export async function cancelMultipleRFQs(rfqNumbers: string[]): Promise<{ const successCount = result.success ? rfqNumbers.length : 0; const failCount = rfqNumbers.length - successCount; - console.log(`🎉 배치 RFQ 취소 완료: 성공 ${successCount}개, 실패 ${failCount}개`); + console.log(`🎉 배치 RFQ 삭제 완료: 성공 ${successCount}개, 실패 ${failCount}개`); return { success: result.success, message: result.success - ? `배치 RFQ 취소 성공: ${successCount}개` - : `배치 RFQ 취소 실패: ${result.message}`, + ? `배치 RFQ 삭제 성공: ${successCount}개` + : `배치 RFQ 삭제 실패: ${result.message}`, results }; } catch (error) { - console.error('❌ 배치 RFQ 취소 중 전체 오류 발생:', error); + console.error('❌ 배치 RFQ 삭제 중 전체 오류 발생:', error); return { success: false, message: error instanceof Error ? error.message : 'Unknown error' @@ -200,20 +200,20 @@ export async function cancelMultipleRFQs(rfqNumbers: string[]): Promise<{ } } -// 개별 처리 방식의 배치 RFQ 취소 (각각 따로 전송) +// 개별 처리 방식의 배치 RFQ 삭제 (각각 따로 전송) export async function cancelMultipleRFQsIndividually(rfqNumbers: string[]): Promise<{ success: boolean; message: string; results?: Array<{ rfqNumber: string; success: boolean; error?: string }>; }> { try { - console.log(`🚀 개별 RFQ 취소 요청 시작: ${rfqNumbers.length}개`); + console.log(`🚀 개별 RFQ 삭제 요청 시작: ${rfqNumbers.length}개`); const results: Array<{ rfqNumber: string; success: boolean; error?: string }> = []; for (const rfqNumber of rfqNumbers) { try { - console.log(`📤 RFQ 취소 처리 중: ${rfqNumber}`); + console.log(`📤 RFQ 삭제 처리 중: ${rfqNumber}`); const rfqData: CancelRFQRequest = { T_ANFNR: [{ ANFNR: rfqNumber }] @@ -222,13 +222,13 @@ export async function cancelMultipleRFQsIndividually(rfqNumbers: string[]): Prom const result = await sendCancelRFQToECC(rfqData); if (result.success) { - console.log(`✅ RFQ 취소 성공: ${rfqNumber}`); + console.log(`✅ RFQ 삭제 성공: ${rfqNumber}`); results.push({ rfqNumber, success: true }); } else { - console.error(`❌ RFQ 취소 실패: ${rfqNumber}, 오류: ${result.message}`); + console.error(`❌ RFQ 삭제 실패: ${rfqNumber}, 오류: ${result.message}`); results.push({ rfqNumber, success: false, @@ -242,7 +242,7 @@ export async function cancelMultipleRFQsIndividually(rfqNumbers: string[]): Prom } } catch (error) { - console.error(`❌ RFQ 취소 처리 실패: ${rfqNumber}`, error); + console.error(`❌ RFQ 삭제 처리 실패: ${rfqNumber}`, error); results.push({ rfqNumber, success: false, @@ -254,16 +254,16 @@ export async function cancelMultipleRFQsIndividually(rfqNumbers: string[]): Prom const successCount = results.filter(r => r.success).length; const failCount = results.length - successCount; - console.log(`🎉 개별 RFQ 취소 완료: 성공 ${successCount}개, 실패 ${failCount}개`); + console.log(`🎉 개별 RFQ 삭제 완료: 성공 ${successCount}개, 실패 ${failCount}개`); return { success: failCount === 0, - message: `개별 RFQ 취소 완료: 성공 ${successCount}개, 실패 ${failCount}개`, + message: `개별 RFQ 삭제 완료: 성공 ${successCount}개, 실패 ${failCount}개`, results }; } catch (error) { - console.error('❌ 개별 RFQ 취소 중 전체 오류 발생:', error); + console.error('❌ 개별 RFQ 삭제 중 전체 오류 발생:', error); return { success: false, message: error instanceof Error ? error.message : 'Unknown error' @@ -271,7 +271,7 @@ export async function cancelMultipleRFQsIndividually(rfqNumbers: string[]): Prom } } -// 테스트용 RFQ 취소 함수 (샘플 데이터 포함) +// 테스트용 RFQ 삭제 함수 (샘플 데이터 포함) export async function cancelTestRFQ(): Promise<{ success: boolean; message: string; @@ -279,7 +279,7 @@ export async function cancelTestRFQ(): Promise<{ testData?: CancelRFQRequest; }> { try { - console.log('🧪 테스트용 RFQ 취소 시작'); + console.log('🧪 테스트용 RFQ 삭제 시작'); // 테스트용 샘플 데이터 생성 const testRFQData: CancelRFQRequest = { @@ -292,13 +292,13 @@ export async function cancelTestRFQ(): Promise<{ return { success: result.success, - message: result.success ? '테스트 RFQ 취소가 성공적으로 전송되었습니다.' : result.message, + message: result.success ? '테스트 RFQ 삭제가 성공적으로 전송되었습니다.' : result.message, responseData: result.responseText, testData: testRFQData }; } catch (error) { - console.error('❌ 테스트 RFQ 취소 실패:', error); + console.error('❌ 테스트 RFQ 삭제 실패:', error); return { success: false, message: error instanceof Error ? error.message : 'Unknown error' |
