summaryrefslogtreecommitdiff
path: root/lib/rfq-last
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfq-last')
-rw-r--r--lib/rfq-last/contract-actions.ts329
-rw-r--r--lib/rfq-last/quotation-compare-view.tsx447
-rw-r--r--lib/rfq-last/service.ts134
-rw-r--r--lib/rfq-last/table/create-general-rfq-dialog.tsx4
-rw-r--r--lib/rfq-last/table/rfq-assign-pic-dialog.tsx311
-rw-r--r--lib/rfq-last/table/rfq-table-toolbar-actions.tsx402
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx100
-rw-r--r--lib/rfq-last/vendor/send-rfq-dialog.tsx378
8 files changed, 1489 insertions, 616 deletions
diff --git a/lib/rfq-last/contract-actions.ts b/lib/rfq-last/contract-actions.ts
index 1144cf4f..082716a0 100644
--- a/lib/rfq-last/contract-actions.ts
+++ b/lib/rfq-last/contract-actions.ts
@@ -1,9 +1,15 @@
"use server";
import db from "@/db/db";
-import { rfqsLast, rfqLastDetails } from "@/db/schema";
+import { rfqsLast, rfqLastDetails,rfqPrItems,
+ prItemsForBidding,biddingConditions,biddingCompanies, projects,
+ biddings,generalContracts ,generalContractItems} from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { revalidatePath } from "next/cache";
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { generateContractNumber } from "../general-contracts/service";
+import { generateBiddingNumber } from "../bidding/service";
// ===== PO (SAP) 생성 =====
interface CreatePOParams {
@@ -63,13 +69,13 @@ export async function createPO(params: CreatePOParams) {
);
// RFQ 상태 업데이트
- await tx
- .update(rfqsLast)
- .set({
- status: "PO 생성 완료",
- updatedAt: new Date(),
- })
- .where(eq(rfqsLast.id, params.rfqId));
+ // await tx
+ // .update(rfqsLast)
+ // .set({
+ // status: "PO 생성 완료",
+ // updatedAt: new Date(),
+ // })
+ // .where(eq(rfqsLast.id, params.rfqId));
});
revalidatePath(`/rfq/${params.rfqId}`);
@@ -89,14 +95,13 @@ export async function createPO(params: CreatePOParams) {
}
}
-// ===== 일반계약 생성 =====
interface CreateGeneralContractParams {
rfqId: number;
vendorId: number;
vendorName: string;
totalAmount: number;
currency: string;
- contractType?: string;
+ contractType: string; // 계약종류 추가 (UP, LE, IL 등)
contractStartDate?: Date;
contractEndDate?: Date;
contractTerms?: string;
@@ -104,40 +109,126 @@ interface CreateGeneralContractParams {
export async function createGeneralContract(params: CreateGeneralContractParams) {
try {
- const userId = 1; // TODO: 실제 사용자 ID 가져오기
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
- // 1. 선정된 업체 확인
- const [selectedVendor] = await db
- .select()
- .from(rfqLastDetails)
+ const userId = session.user.id;
+
+ // 1. RFQ 정보 및 선정된 업체 확인
+ const [rfqData] = await db
+ .select({
+ rfq: rfqsLast,
+ vendor: rfqLastDetails,
+ project: projects,
+ })
+ .from(rfqsLast)
+ .leftJoin(rfqLastDetails, eq(rfqLastDetails.rfqsLastId, rfqsLast.id))
+ .leftJoin(projects, eq(projects.id, rfqsLast.projectId))
.where(
and(
- eq(rfqLastDetails.rfqsLastId, params.rfqId),
+ eq(rfqsLast.id, params.rfqId),
eq(rfqLastDetails.vendorsId, params.vendorId),
eq(rfqLastDetails.isSelected, true),
eq(rfqLastDetails.isLatest, true)
)
);
- if (!selectedVendor) {
- throw new Error("선정된 업체 정보를 찾을 수 없습니다.");
+ if (!rfqData || !rfqData.vendor) {
+ throw new Error("RFQ 정보 또는 선정된 업체 정보를 찾을 수 없습니다.");
}
- // 2. 계약 생성 로직 (TODO: 실제 구현 필요)
- // - 계약서 템플릿 선택
- // - 계약 조건 설정
- // - 계약서 문서 생성
- // - 전자서명 프로세스 시작
+ // 2. PR 아이템 정보 조회 (계약 아이템으로 변환용)
+ const prItems = await db
+ .select()
+ .from(rfqPrItems)
+ .where(eq(rfqPrItems.rfqsLastId, params.rfqId));
- // 3. 계약 상태 업데이트
- await db.transaction(async (tx) => {
- // rfqLastDetails에 계약 정보 업데이트
+ // 3. 계약번호 생성 - generateContractNumber 함수 사용
+ const contractNumber = await generateContractNumber(
+ params.contractType, // 계약종류 (UP, LE, IL 등)
+ rfqData.rfq.picCode || undefined // 발주담당자 코드
+ );
+
+ // 4. 트랜잭션으로 계약 생성
+ const result = await db.transaction(async (tx) => {
+ // 4-1. generalContracts 테이블에 계약 생성
+ const [newContract] = await tx
+ .insert(generalContracts)
+ .values({
+ contractNumber,
+ contractSourceType: 'estimate', // 견적에서 넘어온 계약
+ status: 'Draft', // 초안 상태로 시작
+ category: '일반계약',
+ type: params.contractType, // 계약종류 저장 (UP, LE, IL 등)
+ executionMethod: '일반계약',
+ name: `${rfqData.project?.name || rfqData.rfq.itemName || ''} 일반계약`,
+ selectionMethod: '견적',
+
+ // 업체 정보
+ vendorId: params.vendorId,
+
+ // 계약 기간
+ startDate: params.contractStartDate || new Date(),
+ endDate: params.contractEndDate || null,
+
+ // 연계 정보
+ linkedRfqOrItb: rfqData.rfq.rfqCode || undefined,
+
+ // 금액 정보
+ contractAmount: params.totalAmount,
+ currency: params.currency || 'KRW',
+ totalAmount: params.totalAmount,
+
+ // 조건 정보 (RFQ에서 가져옴)
+ contractCurrency: rfqData.vendor.currency || 'KRW',
+ paymentTerm: rfqData.vendor.paymentTermsCode || undefined,
+ taxType: rfqData.vendor.taxCode || undefined,
+ deliveryTerm: rfqData.vendor.incotermsCode || undefined,
+ shippingLocation: rfqData.vendor.placeOfShipping || undefined,
+ dischargeLocation: rfqData.vendor.placeOfDestination || undefined,
+ contractDeliveryDate: rfqData.vendor.deliveryDate || undefined,
+
+ // 연동제 적용 여부
+ interlockingSystem: rfqData.vendor.materialPriceRelatedYn ? 'Y' : 'N',
+
+ // 시스템 정보
+ registeredById: userId,
+ registeredAt: new Date(),
+ lastUpdatedById: userId,
+ lastUpdatedAt: new Date(),
+ notes: params.contractTerms || rfqData.vendor.remark || undefined,
+ })
+ .returning();
+
+ // 4-2. generalContractItems 테이블에 아이템 생성
+ if (prItems.length > 0) {
+ const contractItems = prItems.map(item => ({
+ contractId: newContract.id,
+ project: rfqData.project?.name || undefined,
+ itemCode: item.materialCode || undefined,
+ itemInfo: `${item.materialCategory || ''} / ${item.materialCode || ''}`,
+ specification: item.materialDescription || undefined,
+ quantity: item.quantity,
+ quantityUnit: item.uom || undefined,
+ contractDeliveryDate: item.deliveryDate || undefined,
+ contractCurrency: params.currency || 'KRW',
+ // 단가와 금액은 견적 데이터에서 가져와야 함 (현재는 총액을 아이템 수로 나눔)
+ contractUnitPrice: params.totalAmount / prItems.length,
+ contractAmount: params.totalAmount / prItems.length,
+ }));
+
+ await tx.insert(generalContractItems).values(contractItems);
+ }
+
+ // 4-3. rfqLastDetails 상태 업데이트
await tx
.update(rfqLastDetails)
.set({
- contractStatus: "진행중",
+ contractStatus: "일반계약 진행중",
contractCreatedAt: new Date(),
- contractNo: `CONTRACT-${Date.now()}`, // TODO: 실제 계약번호로 변경
+ contractNo: contractNumber,
updatedAt: new Date(),
updatedBy: userId,
})
@@ -149,23 +240,18 @@ export async function createGeneralContract(params: CreateGeneralContractParams)
)
);
- // RFQ 상태 업데이트
- await tx
- .update(rfqsLast)
- .set({
- status: "일반계약 진행중",
- updatedAt: new Date(),
- })
- .where(eq(rfqsLast.id, params.rfqId));
+ return newContract;
});
revalidatePath(`/rfq/${params.rfqId}`);
revalidatePath("/rfq");
+ revalidatePath("/contracts");
return {
success: true,
message: "일반계약이 성공적으로 생성되었습니다.",
- contractNumber: `CONTRACT-${Date.now()}`, // TODO: 실제 계약번호 반환
+ contractNumber: result.contractNumber,
+ contractId: result.id,
};
} catch (error) {
console.error("일반계약 생성 오류:", error);
@@ -183,49 +269,162 @@ interface CreateBiddingParams {
vendorName: string;
totalAmount: number;
currency: string;
- biddingType?: string; // 공개입찰, 제한입찰 등
- biddingStartDate?: Date;
- biddingEndDate?: Date;
+ contractType: "unit_price" | "general" | "sale"; // 계약구분
+ biddingType: string; // 입찰유형 (equipment, construction 등)
+ awardCount: "single" | "multiple"; // 낙찰수
+ biddingStartDate: Date;
+ biddingEndDate: Date;
biddingRequirements?: string;
}
export async function createBidding(params: CreateBiddingParams) {
try {
- const userId = 1; // TODO: 실제 사용자 ID 가져오기
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.")
+ }
- // 1. 선정된 업체 확인
- const [selectedVendor] = await db
- .select()
- .from(rfqLastDetails)
+ const userId = session.user.id;
+
+ // 1. RFQ 정보 및 선정된 업체 확인
+ const [rfqData] = await db
+ .select({
+ rfq: rfqsLast,
+ vendor: rfqLastDetails,
+ project: projects,
+ })
+ .from(rfqsLast)
+ .leftJoin(rfqLastDetails, eq(rfqLastDetails.rfqsLastId, rfqsLast.id))
+ .leftJoin(projects, eq(projects.id, rfqsLast.projectId))
.where(
and(
- eq(rfqLastDetails.rfqsLastId, params.rfqId),
+ eq(rfqsLast.id, params.rfqId),
eq(rfqLastDetails.vendorsId, params.vendorId),
eq(rfqLastDetails.isSelected, true),
eq(rfqLastDetails.isLatest, true)
)
);
- if (!selectedVendor) {
- throw new Error("선정된 업체 정보를 찾을 수 없습니다.");
+ if (!rfqData || !rfqData.vendor) {
+ throw new Error("RFQ 정보 또는 선정된 업체 정보를 찾을 수 없습니다.");
}
- // 2. 입찰 생성 로직 (TODO: 실제 구현 필요)
- // - 입찰 공고 생성
- // - 입찰 참가자격 설정
- // - 입찰 일정 등록
- // - 평가 기준 설정
- // - 입찰 시스템 등록
+ // 2. PR 아이템 정보 조회
+ const prItems = await db
+ .select()
+ .from(rfqPrItems)
+ .where(eq(rfqPrItems.rfqsLastId, params.rfqId));
- // 3. 입찰 상태 업데이트
- await db.transaction(async (tx) => {
- // rfqLastDetails에 입찰 정보 업데이트
+ // 3. 트랜잭션으로 입찰 생성
+ const result = await db.transaction(async (tx) => {
+ // 3-1. 입찰번호 생성 - generateBiddingNumber 함수 사용
+ const biddingNumber = await generateBiddingNumber(
+ rfqData.rfq.picCode || undefined, // 발주담당자 코드
+ tx // 트랜잭션 컨텍스트 전달
+ );
+
+ // 3-2. biddings 테이블에 입찰 생성
+ const [newBidding] = await tx
+ .insert(biddings)
+ .values({
+ biddingNumber,
+ revision: 0,
+ biddingSourceType: 'estimate',
+ projectId: rfqData.rfq.projectId || undefined,
+
+ // 기본 정보
+ projectName: rfqData.project?.name || undefined,
+ itemName: rfqData.rfq.itemName || undefined,
+ title: `${rfqData.project?.name || rfqData.rfq.itemName || ''} 입찰`,
+ description: params.biddingRequirements || rfqData.rfq.remark || undefined,
+
+ // 계약 정보 - 파라미터에서 받은 값 사용
+ contractType: params.contractType,
+ biddingType: params.biddingType,
+ awardCount: params.awardCount,
+
+ // 일정 관리 - 파라미터에서 받은 날짜 사용
+ biddingRegistrationDate: new Date(),
+ submissionStartDate: params.biddingStartDate,
+ submissionEndDate: params.biddingEndDate,
+
+ // 예산 및 가격 정보
+ currency: params.currency || 'KRW',
+ budget: params.totalAmount,
+ targetPrice: params.totalAmount,
+
+ // PR 정보
+ prNumber: rfqData.rfq.prNumber || undefined,
+ hasPrDocument: false,
+
+ // 상태
+ status: 'bidding_generated',
+ isPublic: false, // 다이얼로그에서 체크박스로 받을 수 있음
+ isUrgent: false, // 다이얼로그에서 체크박스로 받을 수 있음
+
+ // 담당자 정보
+ managerName: rfqData.rfq.picName || undefined,
+
+ // 메타 정보
+ createdBy: String(userId),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ updatedBy: String(userId),
+ ANFNR: rfqData.rfq.ANFNR || undefined,
+ })
+ .returning();
+
+ // 3-3. PR 아이템을 입찰 아이템으로 변환
+ if (prItems.length > 0) {
+ const biddingPrItems = prItems.map(item => ({
+ biddingId: newBidding.id,
+ itemNumber: item.rfqItem || undefined,
+ projectInfo: rfqData.project?.name || undefined, // 프로젝트 이름 사용
+ itemInfo: item.materialDescription || undefined,
+ requestedDeliveryDate: item.deliveryDate || undefined,
+ currency: params.currency || 'KRW',
+ quantity: item.quantity,
+ quantityUnit: item.uom || undefined,
+ totalWeight: item.grossWeight || undefined,
+ weightUnit: item.gwUom || undefined,
+ materialDescription: item.materialDescription || undefined,
+ prNumber: item.prNo || undefined,
+ hasSpecDocument: !!item.specUrl,
+ }));
+
+ await tx.insert(prItemsForBidding).values(biddingPrItems);
+ }
+
+ // 3-4. 선정된 업체를 입찰 참여 업체로 등록
+ await tx.insert(biddingCompanies).values({
+ biddingId: newBidding.id,
+ companyId: params.vendorId,
+ invitationStatus: 'pending',
+ preQuoteAmount: params.totalAmount, // 견적 금액을 사전견적으로 사용
+ isPreQuoteSelected: true, // 본입찰 대상으로 자동 선정
+ isBiddingInvited: true,
+ notes: '견적에서 선정된 업체',
+ });
+
+ // 3-5. 입찰 조건 생성 (RFQ 조건 활용)
+ await tx.insert(biddingConditions).values({
+ biddingId: newBidding.id,
+ paymentTerms: JSON.stringify([rfqData.vendor.paymentTermsCode]),
+ taxConditions: JSON.stringify([rfqData.vendor.taxCode]),
+ contractDeliveryDate: rfqData.vendor.deliveryDate || undefined,
+ isPriceAdjustmentApplicable: rfqData.vendor.materialPriceRelatedYn || false,
+ incoterms: JSON.stringify([rfqData.vendor.incotermsCode]),
+ shippingPort: rfqData.vendor.placeOfShipping || undefined,
+ destinationPort: rfqData.vendor.placeOfDestination || undefined,
+ });
+
+ // 3-6. rfqLastDetails 상태 업데이트
await tx
.update(rfqLastDetails)
.set({
contractStatus: "입찰진행중",
contractCreatedAt: new Date(),
- contractNo: `BID-${Date.now()}`, // TODO: 실제 입찰번호로 변경
+ contractNo: biddingNumber,
updatedAt: new Date(),
updatedBy: userId,
})
@@ -237,23 +436,18 @@ export async function createBidding(params: CreateBiddingParams) {
)
);
- // RFQ 상태 업데이트
- await tx
- .update(rfqsLast)
- .set({
- status: "입찰 진행중",
- updatedAt: new Date(),
- })
- .where(eq(rfqsLast.id, params.rfqId));
+ return newBidding;
});
revalidatePath(`/rfq/${params.rfqId}`);
revalidatePath("/rfq");
+ revalidatePath("/biddings");
return {
success: true,
message: "입찰이 성공적으로 생성되었습니다.",
- biddingNumber: `BID-${Date.now()}`, // TODO: 실제 입찰번호 반환
+ biddingNumber: result.biddingNumber,
+ biddingId: result.id,
};
} catch (error) {
console.error("입찰 생성 오류:", error);
@@ -263,7 +457,6 @@ export async function createBidding(params: CreateBiddingParams) {
};
}
}
-
// ===== 계약 타입 확인 =====
export async function checkContractStatus(rfqId: number) {
try {
diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx
index 491a1962..28c8b3b1 100644
--- a/lib/rfq-last/quotation-compare-view.tsx
+++ b/lib/rfq-last/quotation-compare-view.tsx
@@ -46,6 +46,7 @@ import {
import { ComparisonData, selectVendor, cancelVendorSelection } from "./compare-action";
import { createPO, createGeneralContract, createBidding } from "./contract-actions";
import { toast } from "sonner";
+import { useRouter } from "next/navigation"
interface QuotationCompareViewProps {
data: ComparisonData;
@@ -61,6 +62,61 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
const [selectionReason, setSelectionReason] = React.useState("");
const [cancelReason, setCancelReason] = React.useState("");
const [isSubmitting, setIsSubmitting] = React.useState(false);
+ const router = useRouter()
+
+ const [selectedGeneralContractType, setSelectedGeneralContractType] = React.useState("");
+ const [contractStartDate, setContractStartDate] = React.useState("");
+ const [contractEndDate, setContractEndDate] = React.useState("");
+
+ // 계약종류 옵션
+ const contractTypes = [
+ { value: 'UP', label: 'UP - 자재단가계약' },
+ { value: 'LE', label: 'LE - 임대차계약' },
+ { value: 'IL', label: 'IL - 개별운송계약' },
+ { value: 'AL', label: 'AL - 연간운송계약' },
+ { value: 'OS', label: 'OS - 외주용역계약' },
+ { value: 'OW', label: 'OW - 도급계약' },
+ { value: 'IS', label: 'IS - 검사계약' },
+ { value: 'LO', label: 'LO - LOI (의향서)' },
+ { value: 'FA', label: 'FA - Frame Agreement' },
+ { value: 'SC', label: 'SC - 납품합의계약' },
+ { value: 'OF', label: 'OF - 클레임상계계약' },
+ { value: 'AW', label: 'AW - 사전작업합의' },
+ { value: 'AD', label: 'AD - 사전납품합의' },
+ { value: 'AM', label: 'AM - 설계계약' },
+ { value: 'SC_SELL', label: 'SC - 폐기물매각계약' },
+ ];
+
+ // 입찰 관련 state 추가
+ const [biddingContractType, setBiddingContractType] = React.useState<"unit_price" | "general" | "sale" | "">("");
+ const [biddingType, setBiddingType] = React.useState<string>("");
+ const [awardCount, setAwardCount] = React.useState<"single" | "multiple">("single");
+ const [biddingStartDate, setBiddingStartDate] = React.useState("");
+ const [biddingEndDate, setBiddingEndDate] = React.useState("");
+
+ // 입찰 옵션들
+ const biddingContractTypes = [
+ { value: 'unit_price', label: '단가계약' },
+ { value: 'general', label: '일반계약' },
+ { value: 'sale', label: '매각계약' }
+ ];
+
+ const biddingTypes = [
+ { value: 'equipment', label: '기자재' },
+ { value: 'construction', label: '공사' },
+ { value: 'service', label: '용역' },
+ { value: 'lease', label: '임차' },
+ { value: 'steel_stock', label: '형강스톡' },
+ { value: 'piping', label: '배관' },
+ { value: 'transport', label: '운송' },
+ { value: 'waste', label: '폐기물' },
+ { value: 'sale', label: '매각' }
+ ];
+
+ const awardCounts = [
+ { value: 'single', label: '단수' },
+ { value: 'multiple', label: '복수' }
+ ];
// 선정된 업체 정보 확인
const selectedVendor = data.vendors.find(v => v.isSelected);
@@ -76,15 +132,56 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
return;
}
+ // 일반계약인 경우 계약종류와 날짜 확인
+ if (selectedContractType === "CONTRACT") {
+ if (!selectedGeneralContractType) {
+ toast.error("계약종류를 선택해주세요.");
+ return;
+ }
+ if (!contractStartDate) {
+ toast.error("계약 시작일을 선택해주세요.");
+ return;
+ }
+ if (!contractEndDate) {
+ toast.error("계약 종료일을 선택해주세요.");
+ return;
+ }
+ if (new Date(contractStartDate) >= new Date(contractEndDate)) {
+ toast.error("계약 종료일은 시작일보다 이후여야 합니다.");
+ return;
+ }
+ }
+
+ // 입찰 검증
+ if (selectedContractType === "BIDDING") {
+ if (!biddingContractType) {
+ toast.error("계약구분을 선택해주세요.");
+ return;
+ }
+ if (!biddingType) {
+ toast.error("입찰유형을 선택해주세요.");
+ return;
+ }
+ if (!biddingStartDate || !biddingEndDate) {
+ toast.error("입찰 기간을 입력해주세요.");
+ return;
+ }
+ if (new Date(biddingStartDate) >= new Date(biddingEndDate)) {
+ toast.error("입찰 마감일은 시작일보다 이후여야 합니다.");
+ return;
+ }
+ }
+
if (!selectedVendor) {
toast.error("선정된 업체가 없습니다.");
return;
}
setIsSubmitting(true);
+
try {
let result;
-
+
switch (selectedContractType) {
case "PO":
result = await createPO({
@@ -94,9 +191,10 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
totalAmount: selectedVendor.totalAmount,
currency: selectedVendor.currency,
selectionReason: selectedVendor.selectionReason,
+
});
break;
-
+
case "CONTRACT":
result = await createGeneralContract({
rfqId: data.rfqInfo.id,
@@ -104,9 +202,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
vendorName: selectedVendor.vendorName,
totalAmount: selectedVendor.totalAmount,
currency: selectedVendor.currency,
+ contractStartDate: new Date(contractStartDate),
+ contractEndDate: new Date(contractEndDate),
+ contractType: selectedGeneralContractType, // 계약종류 추가
+
});
break;
-
+
case "BIDDING":
result = await createBidding({
rfqId: data.rfqInfo.id,
@@ -114,9 +216,14 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
vendorName: selectedVendor.vendorName,
totalAmount: selectedVendor.totalAmount,
currency: selectedVendor.currency,
+ contractType: biddingContractType,
+ biddingType: biddingType,
+ awardCount: awardCount,
+ biddingStartDate: new Date(biddingStartDate),
+ biddingEndDate: new Date(biddingEndDate),
});
break;
-
+
default:
throw new Error("올바른 계약 유형이 아닙니다.");
}
@@ -124,8 +231,17 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
if (result.success) {
toast.success(result.message || "계약 프로세스가 시작되었습니다.");
setShowContractDialog(false);
+ // 모든 state 초기화
setSelectedContractType("");
- window.location.reload();
+ setSelectedGeneralContractType("");
+ setContractStartDate("");
+ setContractEndDate("");
+ setBiddingContractType("");
+ setBiddingType("");
+ setAwardCount("single");
+ setBiddingStartDate("");
+ setBiddingEndDate("");
+ router.refresh();
} else {
throw new Error(result.error || "계약 진행 중 오류가 발생했습니다.");
}
@@ -224,7 +340,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
toast.success("업체가 성공적으로 선정되었습니다.");
setShowSelectionDialog(false);
setSelectionReason("");
- window.location.reload(); // 페이지 새로고침으로 선정 상태 반영
+ router.refresh()
} else {
throw new Error(result.error || "업체 선정 중 오류가 발생했습니다.");
}
@@ -246,13 +362,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
setIsSubmitting(true);
try {
// 파라미터를 올바르게 전달
- const result = await cancelVendorSelection(Number(data.rfqInfo.id),cancelReason);
+ const result = await cancelVendorSelection(Number(data.rfqInfo.id), cancelReason);
if (result.success) {
toast.success("업체 선정이 취소되었습니다.");
setShowCancelDialog(false);
setCancelReason("");
- window.location.reload();
+ router.refresh()
} else {
throw new Error(result.error || "선정 취소 중 오류가 발생했습니다.");
}
@@ -312,13 +428,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
{hasSelection && (
<Alert className={cn(
"border-2",
- hasContract
+ hasContract
? "border-purple-500 bg-purple-50"
- : isSelectionApproved
- ? "border-green-500 bg-green-50"
- : isPendingApproval
- ? "border-yellow-500 bg-yellow-50"
- : "border-blue-500 bg-blue-50"
+ : isSelectionApproved
+ ? "border-green-500 bg-green-50"
+ : isPendingApproval
+ ? "border-yellow-500 bg-yellow-50"
+ : "border-blue-500 bg-blue-50"
)}>
<div className="flex items-start justify-between">
<div className="flex gap-3">
@@ -335,11 +451,11 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
<AlertTitle className="text-lg">
{hasContract
? "계약 진행중"
- : isSelectionApproved
- ? "업체 선정 승인 완료"
- : isPendingApproval
- ? "업체 선정 승인 대기중"
- : "업체 선정 완료"}
+ : isSelectionApproved
+ ? "업체 선정 승인 완료"
+ : isPendingApproval
+ ? "업체 선정 승인 대기중"
+ : "업체 선정 완료"}
</AlertTitle>
<AlertDescription className="space-y-1">
<p className="font-semibold">선정 업체: {selectedVendor.vendorName} ({selectedVendor.vendorCode})</p>
@@ -521,13 +637,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
key={vendor.vendorId}
className={cn(
"flex items-center justify-between p-4 border rounded-lg transition-colors",
- vendor.isSelected
- ? "bg-blue-100 border-blue-400 border-2"
+ vendor.isSelected
+ ? "bg-blue-100 border-blue-400 border-2"
: hasSelection
- ? "opacity-60"
- : selectedVendorId === vendor.vendorId.toString()
- ? "bg-blue-50 border-blue-300 cursor-pointer"
- : "hover:bg-gray-50 cursor-pointer"
+ ? "opacity-60"
+ : selectedVendorId === vendor.vendorId.toString()
+ ? "bg-blue-50 border-blue-300 cursor-pointer"
+ : "hover:bg-gray-50 cursor-pointer"
)}
onClick={() => {
if (!hasSelection) {
@@ -683,10 +799,10 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
</TooltipContent>
</Tooltip>
</TooltipProvider>
- {vendor.vendorConditions.paymentTermsCode &&
- vendor.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && (
- <Badge variant="outline" className="text-xs">변경</Badge>
- )}
+ {vendor.vendorConditions.paymentTermsCode &&
+ vendor.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && (
+ <Badge variant="outline" className="text-xs">변경</Badge>
+ )}
</div>
</td>
))}
@@ -770,8 +886,8 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
{quote.deliveryDate
? format(new Date(quote.deliveryDate), "yyyy-MM-dd")
: quote.leadTime
- ? `${quote.leadTime}일`
- : "-"}
+ ? `${quote.leadTime}일`
+ : "-"}
</td>
<td className="p-2">
{quote.manufacturer && (
@@ -817,7 +933,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
<div>
<p className="text-muted-foreground">가격 차이</p>
<p className="font-semibold">
- {((item.priceAnalysis.highestPrice - item.priceAnalysis.lowestPrice) /
+ {((item.priceAnalysis.highestPrice - item.priceAnalysis.lowestPrice) /
item.priceAnalysis.lowestPrice * 100).toFixed(1)}%
</p>
</div>
@@ -848,7 +964,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
<div className="space-y-3">
{data.vendors.map((vendor) => {
if (!vendor.conditionDifferences.hasDifferences) return null;
-
+
return (
<div key={vendor.vendorId} className="p-3 border rounded-lg">
<p className="font-medium mb-2">{vendor.vendorName}</p>
@@ -930,12 +1046,12 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
// 가격 순위와 조건 차이를 고려한 점수 계산
const scoredVendors = data.vendors.map(v => ({
...v,
- score: (v.rank || 10) + v.conditionDifferences.criticalDifferences.length * 3 +
- v.conditionDifferences.differences.length
+ score: (v.rank || 10) + v.conditionDifferences.criticalDifferences.length * 3 +
+ v.conditionDifferences.differences.length
}));
scoredVendors.sort((a, b) => a.score - b.score);
const recommended = scoredVendors[0];
-
+
return (
<div>
<p className="text-sm text-purple-700">
@@ -963,7 +1079,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
<div className="space-y-4">
{data.vendors.map((vendor) => {
if (!vendor.generalRemark && !vendor.technicalProposal) return null;
-
+
return (
<div key={vendor.vendorId} className="border rounded-lg p-4">
<p className="font-medium mb-2">{vendor.vendorName}</p>
@@ -996,7 +1112,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
<h3 className="text-lg font-semibold mb-4">
{hasSelection ? "업체 재선정 확인" : "업체 선정 확인"}
</h3>
-
+
{selectedVendorId && (
<div className="space-y-4">
<div className="rounded-lg border p-4">
@@ -1076,7 +1192,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center">
<div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4">
<h3 className="text-lg font-semibold mb-4">업체 선정 취소</h3>
-
+
<Alert className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
@@ -1141,13 +1257,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
{/* 계약 진행 모달 */}
{showContractDialog && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center">
- <div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4">
+ <div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold mb-4">
{selectedContractType === "PO" && "PO (SAP) 생성"}
{selectedContractType === "CONTRACT" && "일반계약 생성"}
{selectedContractType === "BIDDING" && "입찰 생성"}
</h3>
-
+
{selectedVendor && (
<div className="space-y-4">
<div className="rounded-lg border p-4 bg-gray-50">
@@ -1180,32 +1296,222 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
</AlertDescription>
</Alert>
- {/* 추가 옵션이 필요한 경우 여기에 추가 */}
+ {/* 일반계약 선택 시 계약종류 및 기간 선택 */}
{selectedContractType === "CONTRACT" && (
- <div className="space-y-2">
- <p className="text-sm font-medium">계약 옵션</p>
- <div className="space-y-2 text-sm">
- <label className="flex items-center gap-2">
- <input type="checkbox" className="rounded" />
- <span>표준계약서 사용</span>
- </label>
- <label className="flex items-center gap-2">
- <input type="checkbox" className="rounded" />
- <span>전자서명 요청</span>
+ <div className="space-y-4">
+ {/* 계약종류 선택 */}
+ <div className="space-y-2">
+ <label htmlFor="contract-type" className="text-sm font-medium">
+ 계약종류 선택 *
</label>
+ <select
+ id="contract-type"
+ className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+ value={selectedGeneralContractType}
+ onChange={(e) => setSelectedGeneralContractType(e.target.value)}
+ required
+ >
+ <option value="">계약종류를 선택하세요</option>
+ {contractTypes.map(type => (
+ <option key={type.value} value={type.value}>
+ {type.label}
+ </option>
+ ))}
+ </select>
+ </div>
+
+ {/* 계약 기간 */}
+ <div className="grid grid-cols-2 gap-3">
+ <div className="space-y-2">
+ <label htmlFor="start-date" className="text-sm font-medium">
+ 계약 시작일 *
+ </label>
+ <input
+ id="start-date"
+ type="date"
+ className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+ value={contractStartDate}
+ onChange={(e) => setContractStartDate(e.target.value)}
+ min={new Date().toISOString().split('T')[0]}
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <label htmlFor="end-date" className="text-sm font-medium">
+ 계약 종료일 *
+ </label>
+ <input
+ id="end-date"
+ type="date"
+ className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+ value={contractEndDate}
+ onChange={(e) => setContractEndDate(e.target.value)}
+ min={contractStartDate || new Date().toISOString().split('T')[0]}
+ required
+ />
+ </div>
+ </div>
+
+ {/* 계약 기간 표시 */}
+ {contractStartDate && contractEndDate && (
+ <div className="p-3 bg-blue-50 rounded-md">
+ <p className="text-sm text-blue-700">
+ 계약 기간: {format(new Date(contractStartDate), "yyyy년 MM월 dd일", { locale: ko })} ~ {format(new Date(contractEndDate), "yyyy년 MM월 dd일", { locale: ko })}
+ <span className="ml-2 font-medium">
+ ({Math.ceil((new Date(contractEndDate).getTime() - new Date(contractStartDate).getTime()) / (1000 * 60 * 60 * 24))}일간)
+ </span>
+ </p>
+ </div>
+ )}
+
+ {/* 계약 옵션 */}
+ <div className="space-y-2">
+ <p className="text-sm font-medium">추가 옵션</p>
+ <div className="space-y-2 text-sm">
+ <label className="flex items-center gap-2">
+ <input type="checkbox" className="rounded" />
+ <span>표준계약서 사용</span>
+ </label>
+ <label className="flex items-center gap-2">
+ <input type="checkbox" className="rounded" />
+ <span>전자서명 요청</span>
+ </label>
+ <label className="flex items-center gap-2">
+ <input type="checkbox" className="rounded" />
+ <span>자동 연장 조항 포함</span>
+ </label>
+ </div>
</div>
</div>
)}
-
+ {/* 입찰 옵션 */}
{selectedContractType === "BIDDING" && (
- <div className="space-y-2">
- <p className="text-sm font-medium">입찰 유형</p>
- <select className="w-full px-3 py-2 border rounded-md">
- <option value="">선택하세요</option>
- <option value="open">공개입찰</option>
- <option value="limited">제한입찰</option>
- <option value="private">지명입찰</option>
- </select>
+ <div className="space-y-4">
+ {/* 계약구분 선택 */}
+ <div className="space-y-2">
+ <label htmlFor="bidding-contract-type" className="text-sm font-medium">
+ 계약구분 *
+ </label>
+ <select
+ id="bidding-contract-type"
+ className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
+ value={biddingContractType}
+ onChange={(e) => setBiddingContractType(e.target.value as any)}
+ required
+ >
+ <option value="">계약구분을 선택하세요</option>
+ {biddingContractTypes.map(type => (
+ <option key={type.value} value={type.value}>
+ {type.label}
+ </option>
+ ))}
+ </select>
+ </div>
+
+ {/* 입찰유형 선택 */}
+ <div className="space-y-2">
+ <label htmlFor="bidding-type" className="text-sm font-medium">
+ 입찰유형 *
+ </label>
+ <select
+ id="bidding-type"
+ className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
+ value={biddingType}
+ onChange={(e) => setBiddingType(e.target.value)}
+ required
+ >
+ <option value="">입찰유형을 선택하세요</option>
+ {biddingTypes.map(type => (
+ <option key={type.value} value={type.value}>
+ {type.label}
+ </option>
+ ))}
+ </select>
+ </div>
+
+ {/* 낙찰수 선택 */}
+ <div className="space-y-2">
+ <label htmlFor="award-count" className="text-sm font-medium">
+ 낙찰수
+ </label>
+ <select
+ id="award-count"
+ className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
+ value={awardCount}
+ onChange={(e) => setAwardCount(e.target.value as any)}
+ >
+ {awardCounts.map(type => (
+ <option key={type.value} value={type.value}>
+ {type.label}
+ </option>
+ ))}
+ </select>
+ </div>
+
+ {/* 입찰 기간 */}
+ <div className="grid grid-cols-2 gap-3">
+ <div className="space-y-2">
+ <label htmlFor="bidding-start-date" className="text-sm font-medium">
+ 입찰 시작일 *
+ </label>
+ <input
+ id="bidding-start-date"
+ type="date"
+ className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
+ value={biddingStartDate}
+ onChange={(e) => setBiddingStartDate(e.target.value)}
+ min={new Date().toISOString().split('T')[0]}
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <label htmlFor="bidding-end-date" className="text-sm font-medium">
+ 입찰 마감일 *
+ </label>
+ <input
+ id="bidding-end-date"
+ type="date"
+ className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
+ value={biddingEndDate}
+ onChange={(e) => setBiddingEndDate(e.target.value)}
+ min={biddingStartDate || new Date().toISOString().split('T')[0]}
+ required
+ />
+ </div>
+ </div>
+
+ {/* 입찰 기간 표시 */}
+ {biddingStartDate && biddingEndDate && (
+ <div className="p-3 bg-purple-50 rounded-md">
+ <p className="text-sm text-purple-700">
+ 입찰 기간: {format(new Date(biddingStartDate), "yyyy년 MM월 dd일", { locale: ko })} ~ {format(new Date(biddingEndDate), "yyyy년 MM월 dd일", { locale: ko })}
+ <span className="ml-2 font-medium">
+ ({Math.ceil((new Date(biddingEndDate).getTime() - new Date(biddingStartDate).getTime()) / (1000 * 60 * 60 * 24))}일간)
+ </span>
+ </p>
+ </div>
+ )}
+
+ {/* 추가 옵션 */}
+ <div className="space-y-2">
+ <p className="text-sm font-medium">추가 옵션</p>
+ <div className="space-y-2 text-sm">
+ <label className="flex items-center gap-2">
+ <input type="checkbox" className="rounded" />
+ <span>긴급입찰</span>
+ </label>
+ <label className="flex items-center gap-2">
+ <input type="checkbox" className="rounded" />
+ <span>사양설명회 개최</span>
+ </label>
+ <label className="flex items-center gap-2">
+ <input type="checkbox" className="rounded" />
+ <span>공개입찰</span>
+ </label>
+ </div>
+ </div>
</div>
)}
</div>
@@ -1216,7 +1522,16 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
variant="outline"
onClick={() => {
setShowContractDialog(false);
+ // 모든 state 초기화
setSelectedContractType("");
+ setSelectedGeneralContractType("");
+ setContractStartDate("");
+ setContractEndDate("");
+ setBiddingContractType("");
+ setBiddingType("");
+ setAwardCount("single");
+ setBiddingStartDate("");
+ setBiddingEndDate("");
}}
disabled={isSubmitting}
>
@@ -1224,7 +1539,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
</Button>
<Button
onClick={handleContractCreation}
- disabled={isSubmitting}
+ disabled={
+ isSubmitting ||
+ (selectedContractType === "CONTRACT" &&
+ (!selectedGeneralContractType || !contractStartDate || !contractEndDate)) ||
+ (selectedContractType === "BIDDING" &&
+ (!biddingContractType || !biddingType || !biddingStartDate || !biddingEndDate))
+ }
>
{isSubmitting ? "처리 중..." : "진행"}
</Button>
@@ -1232,6 +1553,8 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) {
</div>
</div>
)}
+
+
</div>
);
} \ No newline at end of file
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index 723a69fe..85db1ea7 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -16,6 +16,7 @@ import { addDays, format } from "date-fns"
import { ko, enUS } from "date-fns/locale"
import { generateBasicContractsForVendor } from "../basic-contract/gen-service";
import { writeFile, mkdir } from "fs/promises";
+import { generateItbRfqCode } from "../itb/service";
export async function getRfqs(input: GetRfqsSchema) {
@@ -37,17 +38,14 @@ export async function getRfqs(input: GetRfqsSchema) {
break;
case "itb":
// ITB: projectCompany가 있는 경우
- typeFilter = and(
- isNotNull(rfqsLastView.projectCompany),
- ne(rfqsLastView.projectCompany, '')
- );
+ typeFilter =
+ like(rfqsLastView.rfqCode,'I%')
+
+ ;
break;
case "rfq":
// RFQ: prNumber가 있는 경우
- typeFilter = and(
- isNotNull(rfqsLastView.prNumber),
- ne(rfqsLastView.prNumber, '')
- );
+ typeFilter = like(rfqsLastView.rfqCode,'R%');
break;
}
}
@@ -1854,6 +1852,26 @@ export async function getRfqWithDetails(rfqId: number) {
)
.orderBy(desc(rfqLastDetailsView.detailId));
+ const tbeSessionsData = await db
+ .select({
+ vendorId: rfqLastTbeSessions.vendorId,
+ sessionCode: rfqLastTbeSessions.sessionCode,
+ status: rfqLastTbeSessions.status,
+ evaluationResult: rfqLastTbeSessions.evaluationResult,
+ conditionalRequirements: rfqLastTbeSessions.conditionalRequirements,
+ conditionsFulfilled: rfqLastTbeSessions.conditionsFulfilled,
+ plannedStartDate: rfqLastTbeSessions.plannedStartDate,
+ actualStartDate: rfqLastTbeSessions.actualStartDate,
+ actualEndDate: rfqLastTbeSessions.actualEndDate,
+ })
+ .from(rfqLastTbeSessions)
+ .where(eq(rfqLastTbeSessions.rfqsLastId, rfqId));
+
+ const tbeByVendor = tbeSessionsData.reduce((acc, tbe) => {
+ acc[tbe.vendorId] = tbe;
+ return acc;
+ }, {} as Record<number, typeof tbeSessionsData[0]>);
+
return {
success: true,
data: {
@@ -1990,6 +2008,12 @@ export async function getRfqWithDetails(rfqId: number) {
emailResentCount: d.emailResentCount,
lastEmailSentAt: d.lastEmailSentAt,
emailStatus: d.emailStatus,
+
+ // TBE 정보 추가
+ tbeSession: d.vendorId ? tbeByVendor[d.vendorId] : null,
+ tbeStatus: d.vendorId ? tbeByVendor[d.vendorId]?.status : null,
+ tbeEvaluationResult: d.vendorId ? tbeByVendor[d.vendorId]?.evaluationResult : null,
+ tbeSessionCode: d.vendorId ? tbeByVendor[d.vendorId]?.sessionCode : null,
})),
}
};
@@ -2987,19 +3011,25 @@ async function prepareEmailAttachments(rfqId: number, attachmentIds: number[]) {
for (const { attachment, revision } of attachments) {
if (revision?.filePath) {
+
+ const cleanPath = revision.filePath.startsWith('/api/files')
+ ? revision.filePath.slice('/api/files'.length)
+ : revision.filePath;
+
try {
const isProduction = process.env.NODE_ENV === "production";
- const fullPath = isProduction
+
+ const fullPath = !isProduction
? path.join(
process.cwd(),
`public`,
- revision.filePath
+ cleanPath
)
: path.join(
`${process.env.NAS_PATH}`,
- revision.filePath
+ cleanPath
);
const fileBuffer = await fs.readFile(fullPath);
@@ -3009,7 +3039,8 @@ async function prepareEmailAttachments(rfqId: number, attachmentIds: number[]) {
contentType: revision.fileType || 'application/octet-stream'
});
} catch (error) {
- console.error(`첨부파일 읽기 실패: ${revision.filePath}`, error);
+
+ console.error(`첨부파일 읽기 실패: ${cleanPath}`, error);
}
}
}
@@ -4320,4 +4351,83 @@ export async function updateShortList(
console.error("Short List 업데이트 실패:", error);
throw new Error("Short List 업데이트에 실패했습니다.");
}
+}
+
+interface AssignPicParams {
+ rfqIds: number[];
+ picUserId: number;
+}
+
+export async function assignPicToRfqs({ rfqIds, picUserId }: AssignPicParams) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.");
+ }
+
+ // 선택된 담당자 정보 조회
+ const picUser = await db.query.users.findFirst({
+ where: eq(users.id, picUserId),
+ });
+
+ if (!picUser) {
+ throw new Error("선택한 담당자를 찾을 수 없습니다.");
+ }
+
+ // RFQ 코드가 "I"로 시작하는 것들만 필터링 (추가 검증)
+ const targetRfqs = await db.query.rfqsLast.findMany({
+ where: inArray(rfqsLast.id, rfqIds),
+ });
+
+ // "I"로 시작하는 RFQ만 필터링
+ const validRfqs = targetRfqs.filter(rfq => rfq.rfqCode?.startsWith("I"));
+
+ if (validRfqs.length === 0) {
+ throw new Error("담당자를 지정할 수 있는 ITB가 없습니다.");
+ }
+
+ // 트랜잭션으로 처리하여 동시성 문제 방지
+ const updatedCount = await db.transaction(async (tx) => {
+ let successCount = 0;
+
+ for (const rfq of validRfqs) {
+ // 각 RFQ에 대해 새로운 코드 생성
+ const newRfqCode = await generateItbRfqCode(picUser.id);
+
+ // RFQ 업데이트
+ const result = await tx.update(rfqsLast)
+ .set({
+ rfqCode: newRfqCode, // 새로운 RFQ 코드로 업데이트
+ pic: picUser.id,
+ picCode: picUser.userCode || undefined,
+ picName: picUser.name,
+ status: "구매담당지정", // 상태도 업데이트
+ updatedBy: parseInt(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(rfqsLast.id, rfq.id));
+
+ if (result) {
+ successCount++;
+ console.log(`RFQ ${rfq.rfqCode} -> ${newRfqCode} 업데이트 완료`);
+ }
+ }
+
+ return successCount;
+ });
+
+ revalidatePath("/evcp/rfq-last");
+
+ return {
+ success: true,
+ message: `${updatedCount}건의 ITB에 담당자가 지정되고 코드가 재발급되었습니다.`,
+ updatedCount
+ };
+ } catch (error) {
+ console.error("담당자 지정 오류:", error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : "담당자 지정 중 오류가 발생했습니다."
+ };
+ }
} \ No newline at end of file
diff --git a/lib/rfq-last/table/create-general-rfq-dialog.tsx b/lib/rfq-last/table/create-general-rfq-dialog.tsx
index 14564686..7abf06a3 100644
--- a/lib/rfq-last/table/create-general-rfq-dialog.tsx
+++ b/lib/rfq-last/table/create-general-rfq-dialog.tsx
@@ -60,7 +60,7 @@ import { createGeneralRfqAction, getPUsersForFilter, previewGeneralRfqCode } fro
// 아이템 스키마
const itemSchema = z.object({
- itemCode: z.string().min(1, "자재코드를 입력해주세요"),
+ itemCode: z.string().optional(),
itemName: z.string().min(1, "자재명을 입력해주세요"),
quantity: z.number().min(1, "수량은 1 이상이어야 합니다"),
uom: z.string().min(1, "단위를 입력해주세요"),
@@ -645,7 +645,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">
- 자재코드 <span className="text-red-500">*</span>
+ 자재코드
</FormLabel>
<FormControl>
<Input
diff --git a/lib/rfq-last/table/rfq-assign-pic-dialog.tsx b/lib/rfq-last/table/rfq-assign-pic-dialog.tsx
new file mode 100644
index 00000000..89dda979
--- /dev/null
+++ b/lib/rfq-last/table/rfq-assign-pic-dialog.tsx
@@ -0,0 +1,311 @@
+"use client";
+
+import * as React from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Check, ChevronsUpDown, Loader2, User, Users } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { toast } from "sonner";
+import { getPUsersForFilter } from "@/lib/rfq-last/service";
+import { assignPicToRfqs } from "../service";
+import { Badge } from "@/components/ui/badge";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+
+interface User {
+ id: number;
+ name: string;
+ userCode?: string;
+ email?: string;
+}
+
+interface RfqAssignPicDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedRfqIds: number[];
+ selectedRfqCodes: string[];
+ onSuccess?: () => void;
+}
+
+export function RfqAssignPicDialog({
+ open,
+ onOpenChange,
+ selectedRfqIds,
+ selectedRfqCodes,
+ onSuccess,
+}: RfqAssignPicDialogProps) {
+ const [users, setUsers] = React.useState<User[]>([]);
+ const [isLoadingUsers, setIsLoadingUsers] = React.useState(false);
+ const [isAssigning, setIsAssigning] = React.useState(false);
+ const [selectedUser, setSelectedUser] = React.useState<User | null>(null);
+ const [userPopoverOpen, setUserPopoverOpen] = React.useState(false);
+ const [userSearchTerm, setUserSearchTerm] = React.useState("");
+
+ // ITB만 필터링 (rfqCode가 "I"로 시작하는 것)
+ const itbCodes = React.useMemo(() => {
+ return selectedRfqCodes.filter(code => code.startsWith("I"));
+ }, [selectedRfqCodes]);
+
+ const itbIds = React.useMemo(() => {
+ return selectedRfqIds.filter((id, index) => selectedRfqCodes[index]?.startsWith("I"));
+ }, [selectedRfqIds, selectedRfqCodes]);
+
+ // 유저 목록 로드
+ React.useEffect(() => {
+ const loadUsers = async () => {
+ setIsLoadingUsers(true);
+ try {
+ const userList = await getPUsersForFilter();
+ setUsers(userList);
+ } catch (error) {
+ console.log("사용자 목록 로드 오류:", error);
+ toast.error("사용자 목록을 불러오는데 실패했습니다");
+ } finally {
+ setIsLoadingUsers(false);
+ }
+ };
+
+ if (open) {
+ loadUsers();
+ // 다이얼로그 열릴 때 초기화
+ setSelectedUser(null);
+ setUserSearchTerm("");
+ }
+ }, [open]);
+
+ // 유저 검색
+ const filteredUsers = React.useMemo(() => {
+ if (!userSearchTerm) return users;
+
+ const lowerSearchTerm = userSearchTerm.toLowerCase();
+ return users.filter(
+ (user) =>
+ user.name.toLowerCase().includes(lowerSearchTerm) ||
+ user.userCode?.toLowerCase().includes(lowerSearchTerm)
+ );
+ }, [users, userSearchTerm]);
+
+ const handleSelectUser = (user: User) => {
+ setSelectedUser(user);
+ setUserPopoverOpen(false);
+ };
+
+ const handleAssign = async () => {
+ if (!selectedUser) {
+ toast.error("담당자를 선택해주세요");
+ return;
+ }
+
+ if (itbIds.length === 0) {
+ toast.error("선택한 항목 중 ITB가 없습니다");
+ return;
+ }
+
+ setIsAssigning(true);
+ try {
+ const result = await assignPicToRfqs({
+ rfqIds: itbIds,
+ picUserId: selectedUser.id,
+ });
+
+ if (result.success) {
+ toast.success(result.message);
+ onSuccess?.();
+ onOpenChange(false);
+ } else {
+ toast.error(result.message);
+ }
+ } catch (error) {
+ console.error("담당자 지정 오류:", error);
+ toast.error("담당자 지정 중 오류가 발생했습니다");
+ } finally {
+ setIsAssigning(false);
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Users className="h-5 w-5" />
+ 담당자 지정
+ </DialogTitle>
+ <DialogDescription>
+ 선택한 ITB에 구매 담당자를 지정합니다
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 선택된 ITB 정보 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">선택된 ITB</label>
+ <div className="p-3 bg-muted rounded-md">
+ <div className="flex items-center gap-2 mb-2">
+ <Badge variant="secondary">{itbCodes.length}건</Badge>
+ {itbCodes.length !== selectedRfqCodes.length && (
+ <span className="text-xs text-muted-foreground">
+ (전체 {selectedRfqCodes.length}건 중)
+ </span>
+ )}
+ </div>
+ <div className="max-h-[100px] overflow-y-auto">
+ <div className="flex flex-wrap gap-1">
+ {itbCodes.slice(0, 10).map((code, index) => (
+ <Badge key={index} variant="outline" className="text-xs">
+ {code}
+ </Badge>
+ ))}
+ {itbCodes.length > 10 && (
+ <Badge variant="outline" className="text-xs">
+ +{itbCodes.length - 10}개
+ </Badge>
+ )}
+ </div>
+ </div>
+ </div>
+ {itbCodes.length === 0 && (
+ <Alert className="border-orange-200 bg-orange-50">
+ <AlertDescription className="text-orange-800">
+ 선택한 항목 중 ITB (I로 시작하는 코드)가 없습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+ </div>
+
+ {/* 담당자 선택 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">구매 담당자</label>
+ <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ type="button"
+ variant="outline"
+ className="w-full justify-between h-10"
+ disabled={isLoadingUsers || itbCodes.length === 0}
+ >
+ {isLoadingUsers ? (
+ <>
+ <span>담당자 로딩 중...</span>
+ <Loader2 className="ml-2 h-4 w-4 animate-spin" />
+ </>
+ ) : (
+ <>
+ <span className="flex items-center gap-2">
+ <User className="h-4 w-4" />
+ {selectedUser ? (
+ <>
+ {selectedUser.name}
+ {selectedUser.userCode && (
+ <span className="text-muted-foreground">
+ ({selectedUser.userCode})
+ </span>
+ )}
+ </>
+ ) : (
+ <span className="text-muted-foreground">
+ 구매 담당자를 선택하세요
+ </span>
+ )}
+ </span>
+ <ChevronsUpDown className="h-4 w-4 opacity-50" />
+ </>
+ )}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[460px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="이름 또는 코드로 검색..."
+ value={userSearchTerm}
+ onValueChange={setUserSearchTerm}
+ />
+ <CommandList className="max-h-[300px]">
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+ <CommandGroup>
+ {filteredUsers.map((user) => (
+ <CommandItem
+ key={user.id}
+ onSelect={() => handleSelectUser(user)}
+ className="flex items-center justify-between"
+ >
+ <span className="flex items-center gap-2">
+ <User className="h-4 w-4" />
+ {user.name}
+ {user.userCode && (
+ <span className="text-muted-foreground text-sm">
+ ({user.userCode})
+ </span>
+ )}
+ </span>
+ <Check
+ className={cn(
+ "h-4 w-4",
+ selectedUser?.id === user.id
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ {selectedUser && (
+ <p className="text-xs text-muted-foreground">
+ 선택한 담당자: {selectedUser.name}
+ {selectedUser.userCode && ` (${selectedUser.userCode})`}
+ </p>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isAssigning}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ onClick={handleAssign}
+ disabled={!selectedUser || itbCodes.length === 0 || isAssigning}
+ >
+ {isAssigning ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 지정 중...
+ </>
+ ) : (
+ "담당자 지정"
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
index 91b2798f..d933fa95 100644
--- a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
+++ b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
@@ -1,308 +1,148 @@
"use client";
import * as React from "react";
-import { type Table } from "@tanstack/react-table";
-import { Download, RefreshCw, Plus, Lock, LockOpen } from "lucide-react";
-
+import { Table } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
-import { toast } from "sonner";
+import { Users, RefreshCw, FileDown, Plus } from "lucide-react";
import { RfqsLastView } from "@/db/schema";
-import { CreateGeneralRfqDialog } from "./create-general-rfq-dialog";
-import { sealMultipleRfqs, unsealMultipleRfqs } from "../service";
-
-interface RfqTableToolbarActionsProps {
- table: Table<RfqsLastView>;
- onRefresh?: () => void;
+import { RfqAssignPicDialog } from "./rfq-assign-pic-dialog";
+import { Badge } from "@/components/ui/badge";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+interface RfqTableToolbarActionsProps<TData> {
+ table: Table<TData>;
rfqCategory?: "general" | "itb" | "rfq";
+ onRefresh?: () => void;
}
-export function RfqTableToolbarActions({
- table,
- onRefresh,
+export function RfqTableToolbarActions<TData>({
+ table,
rfqCategory = "itb",
-}: RfqTableToolbarActionsProps) {
- const [isExporting, setIsExporting] = React.useState(false);
- const [isSealing, setIsSealing] = React.useState(false);
- const [sealDialogOpen, setSealDialogOpen] = React.useState(false);
- const [sealAction, setSealAction] = React.useState<"seal" | "unseal">("seal");
-
+ onRefresh
+}: RfqTableToolbarActionsProps<TData>) {
+ const [showAssignDialog, setShowAssignDialog] = React.useState(false);
+
+ // 선택된 행 가져오기
const selectedRows = table.getFilteredSelectedRowModel().rows;
- const selectedRfqIds = selectedRows.map(row => row.original.id);
- // 선택된 항목들의 밀봉 상태 확인
- const sealedCount = selectedRows.filter(row => row.original.rfqSealedYn).length;
- const unsealedCount = selectedRows.filter(row => !row.original.rfqSealedYn).length;
-
- const handleSealAction = React.useCallback(async (action: "seal" | "unseal") => {
- setSealAction(action);
- setSealDialogOpen(true);
- }, []);
-
- const confirmSealAction = React.useCallback(async () => {
- setIsSealing(true);
- try {
- const result = sealAction === "seal"
- ? await sealMultipleRfqs(selectedRfqIds)
- : await unsealMultipleRfqs(selectedRfqIds);
-
- if (result.success) {
- toast.success(result.message);
- table.toggleAllRowsSelected(false); // 선택 해제
- onRefresh?.(); // 데이터 새로고침
- } else {
- toast.error(result.error);
- }
- } catch (error) {
- toast.error("작업 중 오류가 발생했습니다.");
- } finally {
- setIsSealing(false);
- setSealDialogOpen(false);
- }
- }, [sealAction, selectedRfqIds, table, onRefresh]);
-
- const handleExportCSV = React.useCallback(async () => {
- setIsExporting(true);
- try {
- const data = table.getFilteredRowModel().rows.map((row) => {
- const original = row.original;
- return {
- "RFQ 코드": original.rfqCode || "",
- "상태": original.status || "",
- "밀봉여부": original.rfqSealedYn ? "밀봉" : "미밀봉",
- "프로젝트 코드": original.projectCode || "",
- "프로젝트명": original.projectName || "",
- "자재코드": original.itemCode || "",
- "자재명": original.itemName || "",
- "패키지 번호": original.packageNo || "",
- "패키지명": original.packageName || "",
- "구매담당자": original.picUserName || original.picName || "",
- "엔지니어링 담당": original.engPicName || "",
- "발송일": original.rfqSendDate ? new Date(original.rfqSendDate).toLocaleDateString("ko-KR") : "",
- "마감일": original.dueDate ? new Date(original.dueDate).toLocaleDateString("ko-KR") : "",
- "업체수": original.vendorCount || 0,
- "Short List": original.shortListedVendorCount || 0,
- "견적접수": original.quotationReceivedCount || 0,
- "PR Items": original.prItemsCount || 0,
- "주요 Items": original.majorItemsCount || 0,
- "시리즈": original.series || "",
- "견적 유형": original.rfqType || "",
- "견적 제목": original.rfqTitle || "",
- "프로젝트 회사": original.projectCompany || "",
- "프로젝트 사이트": original.projectSite || "",
- "SM 코드": original.smCode || "",
- "PR 번호": original.prNumber || "",
- "PR 발행일": original.prIssueDate ? new Date(original.prIssueDate).toLocaleDateString("ko-KR") : "",
- "생성자": original.createdByUserName || "",
- "생성일": original.createdAt ? new Date(original.createdAt).toLocaleDateString("ko-KR") : "",
- "수정자": original.updatedByUserName || "",
- "수정일": original.updatedAt ? new Date(original.updatedAt).toLocaleDateString("ko-KR") : "",
- };
- });
-
- const fileName = `RFQ_목록_${new Date().toISOString().split("T")[0]}.csv`;
- exportTableToCSV({ data, filename: fileName });
- } catch (error) {
- console.error("Export failed:", error);
- } finally {
- setIsExporting(false);
- }
- }, [table]);
-
- const handleExportSelected = React.useCallback(async () => {
- setIsExporting(true);
- try {
- const selectedRows = table.getFilteredSelectedRowModel().rows;
- if (selectedRows.length === 0) {
- alert("선택된 항목이 없습니다.");
- return;
- }
-
- const data = selectedRows.map((row) => {
- const original = row.original;
- return {
- "RFQ 코드": original.rfqCode || "",
- "상태": original.status || "",
- "밀봉여부": original.rfqSealedYn ? "밀봉" : "미밀봉",
- "프로젝트 코드": original.projectCode || "",
- "프로젝트명": original.projectName || "",
- "자재코드": original.itemCode || "",
- "자재명": original.itemName || "",
- "패키지 번호": original.packageNo || "",
- "패키지명": original.packageName || "",
- "구매담당자": original.picUserName || original.picName || "",
- "엔지니어링 담당": original.engPicName || "",
- "발송일": original.rfqSendDate ? new Date(original.rfqSendDate).toLocaleDateString("ko-KR") : "",
- "마감일": original.dueDate ? new Date(original.dueDate).toLocaleDateString("ko-KR") : "",
- "업체수": original.vendorCount || 0,
- "Short List": original.shortListedVendorCount || 0,
- "견적접수": original.quotationReceivedCount || 0,
- };
- });
-
- const fileName = `RFQ_선택항목_${new Date().toISOString().split("T")[0]}.csv`;
- exportTableToCSV({ data, filename: fileName });
- } catch (error) {
- console.error("Export failed:", error);
- } finally {
- setIsExporting(false);
- }
- }, [table]);
+ // 선택된 RFQ의 ID와 코드 추출
+ const selectedRfqData = React.useMemo(() => {
+ const rows = selectedRows.map(row => row.original as RfqsLastView);
+ return {
+ ids: rows.map(row => row.id),
+ codes: rows.map(row => row.rfqCode || ""),
+ // "I"로 시작하는 ITB만 필터링
+ itbCount: rows.filter(row => row.rfqCode?.startsWith("I")).length,
+ totalCount: rows.length
+ };
+ }, [selectedRows]);
+
+ // 담당자 지정 가능 여부 체크 ("I"로 시작하는 항목이 있는지)
+ const canAssignPic = selectedRfqData.itbCount > 0;
+
+ const handleAssignSuccess = () => {
+ // 테이블 선택 초기화
+ table.toggleAllPageRowsSelected(false);
+ // 데이터 새로고침
+ onRefresh?.();
+ };
return (
<>
<div className="flex items-center gap-2">
- {onRefresh && (
- <Button
- variant="outline"
- size="sm"
- onClick={onRefresh}
- className="h-8 px-2 lg:px-3"
- >
- <RefreshCw className="mr-2 h-4 w-4" />
- 새로고침
- </Button>
+ {/* 담당자 지정 버튼 - 선택된 항목 중 ITB가 있을 때만 표시 */}
+ {selectedRfqData.totalCount > 0 && canAssignPic && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => setShowAssignDialog(true)}
+ className="flex items-center gap-2"
+ >
+ <Users className="h-4 w-4" />
+ 담당자 지정
+ <Badge variant="secondary" className="ml-1">
+ {selectedRfqData.itbCount}건
+ </Badge>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>선택한 ITB에 구매 담당자를 지정합니다</p>
+ {selectedRfqData.itbCount !== selectedRfqData.totalCount && (
+ <p className="text-xs text-muted-foreground mt-1">
+ 전체 {selectedRfqData.totalCount}건 중 ITB {selectedRfqData.itbCount}건만 지정됩니다
+ </p>
+ )}
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
)}
- {/* 견적 밀봉/해제 버튼 */}
- {selectedRfqIds.length > 0 && (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- className="h-8 px-2 lg:px-3"
- disabled={isSealing}
- >
- <Lock className="mr-2 h-4 w-4" />
- 견적 밀봉
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem
- onClick={() => handleSealAction("seal")}
- disabled={unsealedCount === 0}
- >
- <Lock className="mr-2 h-4 w-4" />
- 선택 항목 밀봉 ({unsealedCount}개)
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={() => handleSealAction("unseal")}
- disabled={sealedCount === 0}
- >
- <LockOpen className="mr-2 h-4 w-4" />
- 선택 항목 밀봉 해제 ({sealedCount}개)
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <div className="px-2 py-1.5 text-xs text-muted-foreground">
- 전체 {selectedRfqIds.length}개 선택됨
- </div>
- </DropdownMenuContent>
- </DropdownMenu>
+ {/* 선택된 항목 표시 */}
+ {selectedRfqData.totalCount > 0 && (
+ <div className="flex items-center gap-2 px-3 py-1.5 bg-muted rounded-md">
+ <span className="text-sm text-muted-foreground">
+ 선택된 항목:
+ </span>
+ <Badge variant="secondary">
+ {selectedRfqData.totalCount}건
+ </Badge>
+ {selectedRfqData.totalCount !== selectedRfqData.itbCount && (
+ <Badge variant="outline" className="text-xs">
+ ITB {selectedRfqData.itbCount}건
+ </Badge>
+ )}
+ </div>
)}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- className="h-8 px-2 lg:px-3"
- disabled={isExporting}
- >
- <Download className="mr-2 h-4 w-4" />
- {isExporting ? "내보내는 중..." : "내보내기"}
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem onClick={handleExportCSV}>
- 전체 데이터 내보내기
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={handleExportSelected}
- disabled={table.getFilteredSelectedRowModel().rows.length === 0}
- >
- 선택한 항목 내보내기 ({table.getFilteredSelectedRowModel().rows.length}개)
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
+ {/* 기존 버튼들 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onRefresh}
+ className="flex items-center gap-2"
+ >
+ <RefreshCw className="h-4 w-4" />
+ 새로고침
+ </Button>
- {/* rfqCategory가 'general'일 때만 일반견적 생성 다이얼로그 표시 */}
{rfqCategory === "general" && (
- <CreateGeneralRfqDialog onSuccess={onRefresh} />
+ <Button
+ variant="outline"
+ size="sm"
+ className="flex items-center gap-2"
+ >
+ <Plus className="h-4 w-4" />
+ 일반견적 생성
+ </Button>
)}
+
+ <Button
+ variant="outline"
+ size="sm"
+ className="flex items-center gap-2"
+ disabled={selectedRfqData.totalCount === 0}
+ >
+ <FileDown className="h-4 w-4" />
+ 엑셀 다운로드
+ </Button>
</div>
- {/* 밀봉 확인 다이얼로그 */}
- <AlertDialog open={sealDialogOpen} onOpenChange={setSealDialogOpen}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>
- {sealAction === "seal" ? "견적 밀봉 확인" : "견적 밀봉 해제 확인"}
- </AlertDialogTitle>
- <AlertDialogDescription>
- {sealAction === "seal"
- ? `선택한 ${unsealedCount}개의 견적을 밀봉하시겠습니까? 밀봉된 견적은 업체에서 수정할 수 없습니다.`
- : `선택한 ${sealedCount}개의 견적 밀봉을 해제하시겠습니까? 밀봉이 해제되면 업체에서 견적을 수정할 수 있습니다.`}
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel disabled={isSealing}>취소</AlertDialogCancel>
- <AlertDialogAction
- onClick={confirmSealAction}
- disabled={isSealing}
- className={sealAction === "seal" ? "bg-red-600 hover:bg-red-700" : ""}
- >
- {isSealing ? "처리 중..." : "확인"}
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
+ {/* 담당자 지정 다이얼로그 */}
+ <RfqAssignPicDialog
+ open={showAssignDialog}
+ onOpenChange={setShowAssignDialog}
+ selectedRfqIds={selectedRfqData.ids}
+ selectedRfqCodes={selectedRfqData.codes}
+ onSuccess={handleAssignSuccess}
+ />
</>
);
-}
-
-// CSV 내보내기 유틸리티 함수
-function exportTableToCSV({ data, filename }: { data: any[]; filename: string }) {
- if (!data || data.length === 0) {
- console.warn("No data to export");
- return;
- }
-
- const headers = Object.keys(data[0]);
- const csvContent = [
- headers.join(","),
- ...data.map(row =>
- headers.map(header => {
- const value = row[header];
- // 값에 쉼표, 줄바꿈, 따옴표가 있으면 따옴표로 감싸기
- if (typeof value === "string" && (value.includes(",") || value.includes("\n") || value.includes('"'))) {
- return `"${value.replace(/"/g, '""')}"`;
- }
- return value;
- }).join(",")
- )
- ].join("\n");
-
- const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" });
- const link = document.createElement("a");
- link.href = URL.createObjectURL(blob);
- link.download = filename;
- link.click();
- URL.revokeObjectURL(link.href);
} \ No newline at end of file
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx
index 88ae968a..72539113 100644
--- a/lib/rfq-last/vendor/rfq-vendor-table.tsx
+++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx
@@ -657,6 +657,102 @@ export function RfqVendorTable({
},
{
+ accessorKey: "tbeStatus",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="TBE 상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.original.tbeStatus;
+
+ if (!status || status === "준비중") {
+ return (
+ <Badge variant="outline" className="text-gray-500">
+ <Clock className="h-3 w-3 mr-1" />
+ 대기
+ </Badge>
+ );
+ }
+
+ const statusConfig = {
+ "진행중": { variant: "default", icon: <Clock className="h-3 w-3 mr-1" />, color: "text-blue-600" },
+ "검토중": { variant: "secondary", icon: <Eye className="h-3 w-3 mr-1" />, color: "text-orange-600" },
+ "보류": { variant: "outline", icon: <AlertCircle className="h-3 w-3 mr-1" />, color: "text-yellow-600" },
+ "완료": { variant: "success", icon: <CheckCircle className="h-3 w-3 mr-1" />, color: "text-green-600" },
+ "취소": { variant: "destructive", icon: <XCircle className="h-3 w-3 mr-1" />, color: "text-red-600" },
+ }[status] || { variant: "outline", icon: null, color: "text-gray-600" };
+
+ return (
+ <Badge variant={statusConfig.variant as any} className={statusConfig.color}>
+ {statusConfig.icon}
+ {status}
+ </Badge>
+ );
+ },
+ size: 100,
+ },
+
+ {
+ accessorKey: "tbeEvaluationResult",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="TBE 평가" />
+ ),
+ cell: ({ row }) => {
+ const result = row.original.tbeEvaluationResult;
+ const status = row.original.tbeStatus;
+
+ // TBE가 완료되지 않았으면 표시하지 않음
+ if (status !== "완료" || !result) {
+ return <span className="text-xs text-muted-foreground">-</span>;
+ }
+
+ const resultConfig = {
+ "Acceptable": {
+ variant: "success",
+ icon: <CheckCircle className="h-3 w-3" />,
+ text: "적합",
+ color: "bg-green-50 text-green-700 border-green-200"
+ },
+ "Acceptable with Comment": {
+ variant: "warning",
+ icon: <AlertCircle className="h-3 w-3" />,
+ text: "조건부 적합",
+ color: "bg-yellow-50 text-yellow-700 border-yellow-200"
+ },
+ "Not Acceptable": {
+ variant: "destructive",
+ icon: <XCircle className="h-3 w-3" />,
+ text: "부적합",
+ color: "bg-red-50 text-red-700 border-red-200"
+ },
+ }[result];
+
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Badge className={cn("text-xs", resultConfig?.color)}>
+ {resultConfig?.icon}
+ <span className="ml-1">{resultConfig?.text}</span>
+ </Badge>
+ </TooltipTrigger>
+ <TooltipContent>
+ <div className="text-xs">
+ <p className="font-semibold">{result}</p>
+ {row.original.conditionalRequirements && (
+ <p className="mt-1 text-muted-foreground">
+ 조건: {row.original.conditionalRequirements}
+ </p>
+ )}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ );
+ },
+ size: 120,
+ },
+
+ {
accessorKey: "contractRequirements",
header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약 요청" />,
cell: ({ row }) => {
@@ -785,8 +881,8 @@ export function RfqVendorTable({
// emailSentTo JSON 파싱
let recipients = { to: [], cc: [], sentBy: "" };
try {
- if (response?.email?.emailSentTo) {
- recipients = JSON.parse(response.email.emailSentTo);
+ if (response?.emailSentTo) {
+ recipients = JSON.parse(response.emailSentTo);
}
} catch (e) {
console.error("Failed to parse emailSentTo", e);
diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx
index ce97dcde..34777864 100644
--- a/lib/rfq-last/vendor/send-rfq-dialog.tsx
+++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx
@@ -123,13 +123,13 @@ interface Vendor {
contactsByPosition?: Record<string, ContactDetail[]>;
primaryEmail?: string | null;
currency?: string | null;
-
+
// 기본계약 정보
ndaYn?: boolean;
generalGtcYn?: boolean;
projectGtcYn?: boolean;
agreementYn?: boolean;
-
+
// 발송 정보
sendVersion?: number;
}
@@ -243,15 +243,15 @@ export function SendRfqDialog({
const [showCustomEmailForm, setShowCustomEmailForm] = React.useState<Record<number, boolean>>({});
const [showResendConfirmDialog, setShowResendConfirmDialog] = React.useState(false);
const [resendVendorsInfo, setResendVendorsInfo] = React.useState<{ count: number; names: string[] }>({ count: 0, names: [] });
-
+
const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false);
const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0);
const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState("");
const [generatedPdfs, setGeneratedPdfs] = React.useState<Map<string, { buffer: number[], fileName: string }>>(new Map());
-
+
// 재전송 시 기본계약 스킵 옵션 - 업체별 관리
const [skipContractsForVendor, setSkipContractsForVendor] = React.useState<Record<number, boolean>>({});
-
+
const generateContractPdf = async (
vendor: VendorWithRecipients,
contractType: string,
@@ -288,9 +288,9 @@ export function SendRfqDialog({
// 3. PDFtron WebViewer로 PDF 변환
const pdfBuffer = await convertToPdfWithWebViewer(templateFile, templateData);
-
+
const fileName = `${contractType}_${vendor.vendorCode || vendor.vendorId}_${Date.now()}.pdf`;
-
+
return {
buffer: Array.from(pdfBuffer), // Uint8Array를 일반 배열로 변환
fileName
@@ -374,7 +374,7 @@ export function SendRfqDialog({
// 초기화
setCustomEmailInputs({});
setShowCustomEmailForm({});
-
+
// 재전송 업체들의 기본계약 스킵 옵션 초기화 (기본값: false - 재생성)
const skipOptions: Record<number, boolean> = {};
selectedVendors.forEach(v => {
@@ -511,137 +511,137 @@ export function SendRfqDialog({
const proceedWithSend = React.useCallback(async () => {
try {
setIsSending(true);
-
- // 기본계약이 필요한 계약서 목록 수집
- const contractsToGenerate: ContractToGenerate[] = [];
-
- for (const vendor of vendorsWithRecipients) {
- // 재전송 업체이고 해당 업체의 스킵 옵션이 켜져 있으면 계약서 생성 건너뛰기
- const isResendVendor = vendor.sendVersion && vendor.sendVersion > 0;
- if (isResendVendor && skipContractsForVendor[vendor.vendorId]) {
- continue; // 이 벤더의 계약서 생성을 스킵
- }
-
- if (vendor.ndaYn) {
- contractsToGenerate.push({
- vendorId: vendor.vendorId,
- vendorName: vendor.vendorName,
- type: "NDA",
- templateName: "비밀"
- });
- }
- if (vendor.generalGtcYn) {
- contractsToGenerate.push({
- vendorId: vendor.vendorId,
- vendorName: vendor.vendorName,
- type: "General_GTC",
- templateName: "General GTC"
- });
- }
- if (vendor.projectGtcYn && rfqInfo?.projectCode) {
- contractsToGenerate.push({
- vendorId: vendor.vendorId,
- vendorName: vendor.vendorName,
- type: "Project_GTC",
- templateName: rfqInfo.projectCode
- });
- }
- if (vendor.agreementYn) {
- contractsToGenerate.push({
- vendorId: vendor.vendorId,
- vendorName: vendor.vendorName,
- type: "기술자료",
- templateName: "기술"
- });
- }
- }
-
- let pdfsMap = new Map<string, { buffer: number[], fileName: string }>();
-
- // PDF 생성이 필요한 경우
- if (contractsToGenerate.length > 0) {
- setIsGeneratingPdfs(true);
- setPdfGenerationProgress(0);
-
- try {
- let completed = 0;
-
- for (const contract of contractsToGenerate) {
- setCurrentGeneratingContract(`${contract.vendorName} - ${contract.type}`);
-
- const vendor = vendorsWithRecipients.find(v => v.vendorId === contract.vendorId);
- if (!vendor) continue;
-
- const pdf = await generateContractPdf(vendor, contract.type, contract.templateName);
- pdfsMap.set(`${contract.vendorId}_${contract.type}_${contract.templateName}`, pdf);
-
- completed++;
- setPdfGenerationProgress((completed / contractsToGenerate.length) * 100);
-
- await new Promise(resolve => setTimeout(resolve, 100));
+
+ // 기본계약이 필요한 계약서 목록 수집
+ const contractsToGenerate: ContractToGenerate[] = [];
+
+ for (const vendor of vendorsWithRecipients) {
+ // 재전송 업체이고 해당 업체의 스킵 옵션이 켜져 있으면 계약서 생성 건너뛰기
+ const isResendVendor = vendor.sendVersion && vendor.sendVersion > 0;
+ if (isResendVendor && skipContractsForVendor[vendor.vendorId]) {
+ continue; // 이 벤더의 계약서 생성을 스킵
+ }
+
+ if (vendor.ndaYn) {
+ contractsToGenerate.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ type: "NDA",
+ templateName: "비밀"
+ });
+ }
+ if (vendor.generalGtcYn) {
+ contractsToGenerate.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ type: "General_GTC",
+ templateName: "General GTC"
+ });
+ }
+ if (vendor.projectGtcYn && rfqInfo?.projectCode) {
+ contractsToGenerate.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ type: "Project_GTC",
+ templateName: rfqInfo.projectCode
+ });
+ }
+ if (vendor.agreementYn) {
+ contractsToGenerate.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ type: "기술자료",
+ templateName: "기술"
+ });
+ }
}
- setGeneratedPdfs(pdfsMap); // UI 업데이트용
+ let pdfsMap = new Map<string, { buffer: number[], fileName: string }>();
+
+ // PDF 생성이 필요한 경우
+ if (contractsToGenerate.length > 0) {
+ setIsGeneratingPdfs(true);
+ setPdfGenerationProgress(0);
+
+ try {
+ let completed = 0;
+
+ for (const contract of contractsToGenerate) {
+ setCurrentGeneratingContract(`${contract.vendorName} - ${contract.type}`);
+
+ const vendor = vendorsWithRecipients.find(v => v.vendorId === contract.vendorId);
+ if (!vendor) continue;
+
+ const pdf = await generateContractPdf(vendor, contract.type, contract.templateName);
+ pdfsMap.set(`${contract.vendorId}_${contract.type}_${contract.templateName}`, pdf);
+
+ completed++;
+ setPdfGenerationProgress((completed / contractsToGenerate.length) * 100);
+
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+
+ setGeneratedPdfs(pdfsMap); // UI 업데이트용
+ } catch (error) {
+ console.error("PDF 생성 실패:", error);
+ toast.error("기본계약서 생성에 실패했습니다.");
+ setIsGeneratingPdfs(false);
+ setPdfGenerationProgress(0);
+ return;
+ }
+ }
+
+ // RFQ 발송 - pdfsMap을 직접 사용
+ setIsGeneratingPdfs(false);
+ setIsSending(true);
+
+ await onSend({
+ vendors: vendorsWithRecipients.map(v => ({
+ vendorId: v.vendorId,
+ vendorName: v.vendorName,
+ vendorCode: v.vendorCode,
+ vendorCountry: v.vendorCountry,
+ selectedMainEmail: v.selectedMainEmail,
+ additionalEmails: v.additionalEmails,
+ customEmails: v.customEmails.map(c => ({ email: c.email, name: c.name })),
+ currency: v.currency,
+ contractRequirements: {
+ ndaYn: v.ndaYn || false,
+ generalGtcYn: v.generalGtcYn || false,
+ projectGtcYn: v.projectGtcYn || false,
+ agreementYn: v.agreementYn || false,
+ projectCode: v.projectGtcYn ? rfqInfo?.projectCode : undefined,
+ },
+ isResend: (v.sendVersion || 0) > 0,
+ sendVersion: v.sendVersion,
+ contractsSkipped: ((v.sendVersion || 0) > 0) && skipContractsForVendor[v.vendorId],
+ })),
+ attachments: selectedAttachments,
+ message: additionalMessage,
+ // 생성된 PDF 데이터 추가
+ generatedPdfs: Array.from(pdfsMap.entries()).map(([key, data]) => ({
+ key,
+ ...data
+ })),
+ });
+
+ toast.success(
+ `${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.` +
+ (contractsToGenerate.length > 0 ? ` ${contractsToGenerate.length}개의 기본계약서가 포함되었습니다.` : '')
+ );
+ onOpenChange(false);
+
} catch (error) {
- console.error("PDF 생성 실패:", error);
- toast.error("기본계약서 생성에 실패했습니다.");
+ console.error("RFQ 발송 실패:", error);
+ toast.error("RFQ 발송에 실패했습니다.");
+ } finally {
+ setIsSending(false);
setIsGeneratingPdfs(false);
setPdfGenerationProgress(0);
- return;
+ setCurrentGeneratingContract("");
+ setSkipContractsForVendor({}); // 초기화
}
- }
-
- // RFQ 발송 - pdfsMap을 직접 사용
- setIsGeneratingPdfs(false);
- setIsSending(true);
-
- await onSend({
- vendors: vendorsWithRecipients.map(v => ({
- vendorId: v.vendorId,
- vendorName: v.vendorName,
- vendorCode: v.vendorCode,
- vendorCountry: v.vendorCountry,
- selectedMainEmail: v.selectedMainEmail,
- additionalEmails: v.additionalEmails,
- customEmails: v.customEmails.map(c => ({ email: c.email, name: c.name })),
- currency: v.currency,
- contractRequirements: {
- ndaYn: v.ndaYn || false,
- generalGtcYn: v.generalGtcYn || false,
- projectGtcYn: v.projectGtcYn || false,
- agreementYn: v.agreementYn || false,
- projectCode: v.projectGtcYn ? rfqInfo?.projectCode : undefined,
- },
- isResend: (v.sendVersion || 0) > 0,
- sendVersion: v.sendVersion,
- contractsSkipped: ((v.sendVersion || 0) > 0) && skipContractsForVendor[v.vendorId],
- })),
- attachments: selectedAttachments,
- message: additionalMessage,
- // 생성된 PDF 데이터 추가
- generatedPdfs: Array.from(pdfsMap.entries()).map(([key, data]) => ({
- key,
- ...data
- })),
- });
-
- toast.success(
- `${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.` +
- (contractsToGenerate.length > 0 ? ` ${contractsToGenerate.length}개의 기본계약서가 포함되었습니다.` : '')
- );
- onOpenChange(false);
-
- } catch (error) {
- console.error("RFQ 발송 실패:", error);
- toast.error("RFQ 발송에 실패했습니다.");
- } finally {
- setIsSending(false);
- setIsGeneratingPdfs(false);
- setPdfGenerationProgress(0);
- setCurrentGeneratingContract("");
- setSkipContractsForVendor({}); // 초기화
- }
-}, [vendorsWithRecipients, selectedAttachments, additionalMessage, onSend, onOpenChange, rfqInfo, skipContractsForVendor]);
+ }, [vendorsWithRecipients, selectedAttachments, additionalMessage, onSend, onOpenChange, rfqInfo, skipContractsForVendor]);
// 전송 처리
const handleSend = async () => {
@@ -712,7 +712,7 @@ export function SendRfqDialog({
<li>업체는 새로운 버전의 견적서를 작성해야 합니다.</li>
<li>이전에 제출한 견적서는 더 이상 유효하지 않습니다.</li>
</ul>
-
+
{/* 기본계약 재발송 정보 */}
<div className="mt-3 pt-3 border-t border-yellow-400">
<div className="space-y-2">
@@ -836,8 +836,8 @@ export function SendRfqDialog({
setSkipContractsForVendor(newSkipOptions);
}}
>
- {Object.values(skipContractsForVendor).every(v => v) ? "전체 재생성" :
- Object.values(skipContractsForVendor).every(v => !v) ? "전체 유지" : "전체 유지"}
+ {Object.values(skipContractsForVendor).every(v => v) ? "전체 재생성" :
+ Object.values(skipContractsForVendor).every(v => !v) ? "전체 유지" : "전체 유지"}
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -993,7 +993,7 @@ export function SendRfqDialog({
[vendor.vendorId]: !checked
}));
}}
- // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600"
+ // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600"
/>
<span className="text-xs">
{skipContractsForVendor[vendor.vendorId] ? "유지" : "재생성"}
@@ -1002,8 +1002,8 @@ export function SendRfqDialog({
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
- {skipContractsForVendor[vendor.vendorId]
- ? "기존 계약서를 그대로 유지합니다"
+ {skipContractsForVendor[vendor.vendorId]
+ ? "기존 계약서를 그대로 유지합니다"
: "기존 계약서를 삭제하고 새로 생성합니다"}
</p>
</TooltipContent>
@@ -1306,9 +1306,9 @@ export function SendRfqDialog({
onChange={(e) => setAdditionalMessage(e.target.value)}
/>
</div>
-
- {/* PDF 생성 진행 상황 표시 */}
- {isGeneratingPdfs && (
+
+ {/* PDF 생성 진행 상황 표시 */}
+ {isGeneratingPdfs && (
<Alert className="border-blue-500 bg-blue-50">
<div className="space-y-3">
<div className="flex items-center gap-2">
@@ -1327,8 +1327,8 @@ export function SendRfqDialog({
</div>
</Alert>
)}
-
-
+
+
</div>
</div>
@@ -1371,7 +1371,7 @@ export function SendRfqDialog({
</Button>
</DialogFooter>
</DialogContent>
-
+
{/* 재발송 확인 다이얼로그 */}
<AlertDialog open={showResendConfirmDialog} onOpenChange={setShowResendConfirmDialog}>
<AlertDialogContent className="max-w-2xl">
@@ -1385,7 +1385,7 @@ export function SendRfqDialog({
<p className="text-sm">
<span className="font-semibold text-yellow-700">{resendVendorsInfo.count}개 업체</span>가 재발송 대상입니다.
</p>
-
+
{/* 재발송 대상 업체 목록 및 계약서 설정 */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<p className="text-sm font-medium text-yellow-800 mb-3">재발송 대상 업체 및 계약서 설정:</p>
@@ -1398,7 +1398,7 @@ export function SendRfqDialog({
if (vendor.generalGtcYn) contracts.push("General GTC");
if (vendor.projectGtcYn) contracts.push("Project GTC");
if (vendor.agreementYn) contracts.push("기술자료");
-
+
return (
<div key={vendor.vendorId} className="flex items-center justify-between p-2 bg-white rounded border border-yellow-100">
<div className="flex items-center gap-3">
@@ -1422,7 +1422,7 @@ export function SendRfqDialog({
[vendor.vendorId]: !checked
}));
}}
- // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600"
+ // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600"
/>
<span className="text-xs text-yellow-800">
{skipContractsForVendor[vendor.vendorId] ? "계약서 유지" : "계약서 재생성"}
@@ -1433,43 +1433,43 @@ export function SendRfqDialog({
);
})}
</div>
-
+
{/* 전체 선택 버튼 */}
- {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0 &&
+ {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0 &&
(v.ndaYn || v.generalGtcYn || v.projectGtcYn || v.agreementYn)) && (
- <div className="mt-3 pt-3 border-t border-yellow-300 flex justify-end gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0);
- const newSkipOptions: Record<number, boolean> = {};
- resendVendors.forEach(v => {
- newSkipOptions[v.vendorId] = true; // 모두 유지
- });
- setSkipContractsForVendor(newSkipOptions);
- }}
- >
- 전체 계약서 유지
- </Button>
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0);
- const newSkipOptions: Record<number, boolean> = {};
- resendVendors.forEach(v => {
- newSkipOptions[v.vendorId] = false; // 모두 재생성
- });
- setSkipContractsForVendor(newSkipOptions);
- }}
- >
- 전체 계약서 재생성
- </Button>
- </div>
- )}
+ <div className="mt-3 pt-3 border-t border-yellow-300 flex justify-end gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0);
+ const newSkipOptions: Record<number, boolean> = {};
+ resendVendors.forEach(v => {
+ newSkipOptions[v.vendorId] = true; // 모두 유지
+ });
+ setSkipContractsForVendor(newSkipOptions);
+ }}
+ >
+ 전체 계약서 유지
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0);
+ const newSkipOptions: Record<number, boolean> = {};
+ resendVendors.forEach(v => {
+ newSkipOptions[v.vendorId] = false; // 모두 재생성
+ });
+ setSkipContractsForVendor(newSkipOptions);
+ }}
+ >
+ 전체 계약서 재생성
+ </Button>
+ </div>
+ )}
</div>
-
+
{/* 경고 메시지 */}
<Alert className="border-red-200 bg-red-50">
<AlertCircle className="h-4 w-4 text-red-600" />
@@ -1479,17 +1479,17 @@ export function SendRfqDialog({
<li>기존에 작성된 견적 데이터가 <strong>모두 초기화</strong>됩니다.</li>
<li>업체는 처음부터 새로 견적서를 작성해야 합니다.</li>
<li>이전에 제출한 견적서는 더 이상 유효하지 않습니다.</li>
- {Object.entries(skipContractsForVendor).some(([vendorId, skip]) => !skip &&
+ {Object.entries(skipContractsForVendor).some(([vendorId, skip]) => !skip &&
vendorsWithRecipients.find(v => v.vendorId === Number(vendorId))) && (
- <li className="text-orange-700 font-medium">
- ⚠️ 선택한 업체의 기존 기본계약서가 <strong>삭제</strong>되고 새로운 계약서가 발송됩니다.
- </li>
- )}
+ <li className="text-orange-700 font-medium">
+ ⚠️ 선택한 업체의 기존 기본계약서가 <strong>삭제</strong>되고 새로운 계약서가 발송됩니다.
+ </li>
+ )}
<li>이 작업은 <strong>취소할 수 없습니다</strong>.</li>
</ul>
</AlertDescription>
</Alert>
-
+
<p className="text-sm text-muted-foreground">
재발송을 진행하시겠습니까?
</p>
@@ -1503,7 +1503,7 @@ export function SendRfqDialog({
}}>
취소
</AlertDialogCancel>
- <AlertDialogAction
+ <AlertDialogAction
onClick={() => {
setShowResendConfirmDialog(false);
proceedWithSend();