summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-04 10:03:32 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-04 10:03:32 +0000
commit47fb72704161b4b58a27c7f5c679fc44618de9a1 (patch)
treeaf4fe1517352784d1876c164171f6dba2e40403a
parent1a034c7f6f50e443bc9f97c3d84bfb0a819af6ce (diff)
(최겸) 구매 견적 내 RFQ Cancel/Delete, 연동제 적용, MRC Type 개발
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/rfq-last/[id]/layout.tsx23
-rw-r--r--app/[lng]/partners/(partners)/rfq-last/[id]/page.tsx37
-rw-r--r--app/api/partners/rfq-last/[id]/response/route.ts144
-rw-r--r--db/schema/rfqLast.ts8
-rw-r--r--db/schema/rfqVendor.ts82
-rw-r--r--lib/rfq-last/attachment/vendor-response-table.tsx17
-rw-r--r--lib/rfq-last/cancel-vendor-response-action.ts185
-rw-r--r--lib/rfq-last/delete-action.ts199
-rw-r--r--lib/rfq-last/service.ts42
-rw-r--r--lib/rfq-last/table/delete-rfq-dialog.tsx254
-rw-r--r--lib/rfq-last/table/rfq-table-columns.tsx1
-rw-r--r--lib/rfq-last/table/rfq-table-toolbar-actions.tsx56
-rw-r--r--lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx271
-rw-r--r--lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx63
-rw-r--r--lib/rfq-last/vendor/add-vendor-dialog.tsx122
-rw-r--r--lib/rfq-last/vendor/cancel-vendor-response-dialog.tsx208
-rw-r--r--lib/rfq-last/vendor/price-adjustment-dialog.tsx268
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx155
-rw-r--r--lib/soap/ecc/send/delete-rfq.ts (renamed from lib/soap/ecc/send/cancel-rfq.ts)64
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'