summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-06-02 02:27:56 +0000
committerjoonhoekim <26rote@gmail.com>2025-06-02 02:27:56 +0000
commite5f4a774fabc17b5b18d50c96f5695d89dcabc86 (patch)
treeb1ef756d93f8e8d1d67998a5694aab379e34b5bc /lib/techsales-rfq
parent37611339fea096e47aaa42311a13a6313b4200db (diff)
(김준회) 기술영업 조선 RFQ 에러 처리 및 필터와 소팅 처리
Diffstat (limited to 'lib/techsales-rfq')
-rw-r--r--lib/techsales-rfq/repository.ts16
-rw-r--r--lib/techsales-rfq/service.ts350
-rw-r--r--lib/techsales-rfq/table/create-rfq-dialog.tsx17
-rw-r--r--lib/techsales-rfq/table/rfq-table-column.tsx4
-rw-r--r--lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx9
-rw-r--r--lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx2
-rw-r--r--lib/techsales-rfq/vendor-response/quotation-editor.tsx6
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx255
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx239
9 files changed, 724 insertions, 174 deletions
diff --git a/lib/techsales-rfq/repository.ts b/lib/techsales-rfq/repository.ts
index 8a579427..66c0b345 100644
--- a/lib/techsales-rfq/repository.ts
+++ b/lib/techsales-rfq/repository.ts
@@ -80,7 +80,7 @@ export async function selectTechSalesRfqsWithJoin(
// RFQ 기본 정보
id: techSalesRfqs.id,
rfqCode: techSalesRfqs.rfqCode,
- itemId: techSalesRfqs.itemId,
+ itemShipbuildingId: techSalesRfqs.itemShipbuildingId,
itemName: itemShipbuilding.itemList,
materialCode: techSalesRfqs.materialCode,
@@ -132,7 +132,7 @@ export async function selectTechSalesRfqsWithJoin(
)`,
})
.from(techSalesRfqs)
- .leftJoin(itemShipbuilding, sql`split_part(${techSalesRfqs.materialCode}, ',', 1) = ${itemShipbuilding.itemCode}`)
+ .leftJoin(itemShipbuilding, sql`${techSalesRfqs.itemShipbuildingId} = ${itemShipbuilding.id}`)
.leftJoin(sql`${users} AS created_user`, sql`${techSalesRfqs.createdBy} = created_user.id`)
.leftJoin(sql`${users} AS updated_user`, sql`${techSalesRfqs.updatedBy} = updated_user.id`)
.leftJoin(sql`${users} AS sent_user`, sql`${techSalesRfqs.sentBy} = sent_user.id`);
@@ -159,7 +159,7 @@ export async function countTechSalesRfqsWithJoin(
const res = await tx
.select({ count: count() })
.from(techSalesRfqs)
- .leftJoin(itemShipbuilding, sql`split_part(${techSalesRfqs.materialCode}, ',', 1) = ${itemShipbuilding.itemCode}`)
+ .leftJoin(itemShipbuilding, sql`${techSalesRfqs.itemShipbuildingId} = ${itemShipbuilding.id}`)
.where(where ?? undefined);
return res[0]?.count ?? 0;
}
@@ -210,7 +210,7 @@ export async function selectTechSalesVendorQuotationsWithJoin(
// 프로젝트 정보
materialCode: techSalesRfqs.materialCode,
- itemId: techSalesRfqs.itemId,
+ itemShipbuildingId: techSalesRfqs.itemShipbuildingId,
itemName: itemShipbuilding.itemList,
// 프로젝트 핵심 정보
@@ -228,7 +228,7 @@ export async function selectTechSalesVendorQuotationsWithJoin(
.from(techSalesVendorQuotations)
.leftJoin(techSalesRfqs, sql`${techSalesVendorQuotations.rfqId} = ${techSalesRfqs.id}`)
.leftJoin(vendors, sql`${techSalesVendorQuotations.vendorId} = ${vendors.id}`)
- .leftJoin(itemShipbuilding, sql`split_part(${techSalesRfqs.materialCode}, ',', 1) = ${itemShipbuilding.itemCode}`)
+ .leftJoin(itemShipbuilding, sql`${techSalesRfqs.itemShipbuildingId} = ${itemShipbuilding.id}`)
.leftJoin(sql`${users} AS created_user`, sql`${techSalesVendorQuotations.createdBy} = created_user.id`)
.leftJoin(sql`${users} AS updated_user`, sql`${techSalesVendorQuotations.updatedBy} = updated_user.id`);
@@ -256,7 +256,7 @@ export async function countTechSalesVendorQuotationsWithJoin(
.from(techSalesVendorQuotations)
.leftJoin(techSalesRfqs, sql`${techSalesVendorQuotations.rfqId} = ${techSalesRfqs.id}`)
.leftJoin(vendors, sql`${techSalesVendorQuotations.vendorId} = ${vendors.id}`)
- .leftJoin(itemShipbuilding, sql`split_part(${techSalesRfqs.materialCode}, ',', 1) = ${itemShipbuilding.itemCode}`)
+ .leftJoin(itemShipbuilding, sql`${techSalesRfqs.itemShipbuildingId} = ${itemShipbuilding.id}`)
.where(where ?? undefined);
return res[0]?.count ?? 0;
}
@@ -286,7 +286,7 @@ export async function selectTechSalesDashboardWithJoin(
materialCode: techSalesRfqs.materialCode,
// 아이템 정보
- itemId: techSalesRfqs.itemId,
+ itemShipbuildingId: techSalesRfqs.itemShipbuildingId,
itemName: itemShipbuilding.itemList,
// 프로젝트 정보
@@ -364,7 +364,7 @@ export async function selectTechSalesDashboardWithJoin(
createdByName: sql<string>`created_user.name`,
})
.from(techSalesRfqs)
- .leftJoin(itemShipbuilding, sql`split_part(${techSalesRfqs.materialCode}, ',', 1) = ${itemShipbuilding.itemCode}`)
+ .leftJoin(itemShipbuilding, sql`${techSalesRfqs.itemShipbuildingId} = ${itemShipbuilding.id}`)
.leftJoin(sql`${users} AS created_user`, sql`${techSalesRfqs.createdBy} = created_user.id`);
// where 조건 적용
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
index 735fcf68..26117452 100644
--- a/lib/techsales-rfq/service.ts
+++ b/lib/techsales-rfq/service.ts
@@ -6,7 +6,7 @@ import {
techSalesRfqs,
techSalesVendorQuotations,
techSalesAttachments,
- items,
+ itemShipbuilding,
users,
techSalesRfqComments
} from "@/db/schema";
@@ -186,7 +186,9 @@ async function generateRfqCodes(tx: any, count: number, year?: number): Promise<
export async function createTechSalesRfq(input: {
// 프로젝트 관련
biddingProjectId: number;
- // 자재 관련 (자재그룹 코드들)
+ // 조선 아이템 관련
+ itemShipbuildingId: number;
+ // 자재 관련 (자재그룹 코드들을 CSV로)
materialGroupCodes: string[];
// 기본 정보
dueDate?: Date;
@@ -194,8 +196,17 @@ export async function createTechSalesRfq(input: {
createdBy: number;
}) {
unstable_noStore();
+ console.log('🔍 createTechSalesRfq 호출됨:', {
+ biddingProjectId: input.biddingProjectId,
+ itemShipbuildingId: input.itemShipbuildingId,
+ materialGroupCodes: input.materialGroupCodes,
+ dueDate: input.dueDate,
+ remark: input.remark,
+ createdBy: input.createdBy
+ });
+
try {
- const results: typeof techSalesRfqs.$inferSelect[] = [];
+ let result: typeof techSalesRfqs.$inferSelect | undefined;
// 트랜잭션으로 처리
await db.transaction(async (tx) => {
@@ -250,58 +261,52 @@ export async function createTechSalesRfq(input: {
post1: series.post1 || undefined,
}));
- // 각 자재그룹 코드별로 RFQ 생성
- for (const materialCode of input.materialGroupCodes) {
- // RFQ 코드 생성
- const rfqCode = await generateRfqCodes(tx, 1);
-
- // 기본 due date 설정 (7일 후)
- const dueDate = input.dueDate || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
-
- // 기존 item 확인 또는 새로 생성
- let itemId: number;
- const existingItem = await tx.query.items.findFirst({
- where: (items, { eq }) => eq(items.itemCode, materialCode),
- columns: { id: true }
- });
-
- if (existingItem) {
- // 기존 item 사용
- itemId = existingItem.id;
- } else {
- // 새 item 생성
- const [newItem] = await tx.insert(items).values({
- itemCode: materialCode,
- itemName: `자재그룹 ${materialCode}`,
- description: `기술영업 자재그룹`,
- }).returning();
- itemId = newItem.id;
- }
-
- // 새 기술영업 RFQ 작성 (스냅샷 포함)
- const [newRfq] = await tx.insert(techSalesRfqs).values({
- rfqCode: rfqCode[0],
- itemId: itemId,
- biddingProjectId: input.biddingProjectId,
- materialCode,
- dueDate,
- remark: input.remark,
- createdBy: input.createdBy,
- updatedBy: input.createdBy,
- // 스냅샷 데이터 추가
- projectSnapshot,
- seriesSnapshot,
- }).returning();
-
- results.push(newRfq);
+ // RFQ 코드 생성
+ const rfqCode = await generateRfqCodes(tx, 1);
+
+ // 기본 due date 설정 (7일 후)
+ const dueDate = input.dueDate || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
+
+ // itemShipbuildingId 유효성 검증
+ console.log('🔍 itemShipbuildingId 검증:', input.itemShipbuildingId);
+ const existingItemShipbuilding = await tx.query.itemShipbuilding.findFirst({
+ where: (itemShipbuilding, { eq }) => eq(itemShipbuilding.id, input.itemShipbuildingId),
+ columns: { id: true, itemCode: true, itemList: true }
+ });
+
+ if (!existingItemShipbuilding) {
+ throw new Error(`itemShipbuildingId ${input.itemShipbuildingId}에 해당하는 itemShipbuilding 레코드를 찾을 수 없습니다.`);
}
+
+ console.log('✅ itemShipbuilding 찾음:', existingItemShipbuilding);
+
+ // 새 기술영업 RFQ 작성 (스냅샷 포함)
+ const [newRfq] = await tx.insert(techSalesRfqs).values({
+ rfqCode: rfqCode[0],
+ itemShipbuildingId: input.itemShipbuildingId,
+ biddingProjectId: input.biddingProjectId,
+ materialCode: input.materialGroupCodes.join(','), // 모든 materialCode를 CSV로 저장
+ dueDate,
+ remark: input.remark,
+ createdBy: input.createdBy,
+ updatedBy: input.createdBy,
+ // 스냅샷 데이터 추가
+ projectSnapshot,
+ seriesSnapshot,
+ }).returning();
+
+ result = newRfq;
});
// 캐시 무효화
revalidateTag("techSalesRfqs");
revalidatePath("/evcp/budgetary-tech-sales-ship");
- return { data: results, error: null };
+ if (!result) {
+ throw new Error(`RFQ 생성에 실패했습니다. 입력값: ${JSON.stringify(input)}`);
+ }
+
+ return { data: [result], error: null };
} catch (err) {
console.error("Error creating RFQ:", err);
return { data: null, error: getErrorMessage(err) };
@@ -715,13 +720,14 @@ export async function addVendorToTechSalesRfq(input: {
vendorId: input.vendorId,
status: "Draft",
totalPrice: "0",
- currency: "USD",
+ currency: null,
createdBy: input.createdBy,
updatedBy: input.createdBy,
})
.returning();
// 캐시 무효화
+ revalidateTag("techSalesRfqs");
revalidateTag("techSalesVendorQuotations");
revalidateTag(`techSalesRfq-${input.rfqId}`);
revalidateTag(`vendor-${input.vendorId}-quotations`);
@@ -816,6 +822,7 @@ export async function addVendorsToTechSalesRfq(input: {
});
// 캐시 무효화 추가
+ revalidateTag("techSalesRfqs");
revalidateTag("techSalesVendorQuotations");
revalidateTag(`techSalesRfq-${input.rfqId}`);
revalidatePath("/evcp/budgetary-tech-sales-ship");
@@ -1033,11 +1040,11 @@ export async function sendTechSalesRfqToVendors(input: {
seriesSnapshot: true,
},
with: {
- item: {
+ itemShipbuilding: {
columns: {
id: true,
itemCode: true,
- itemName: true,
+ itemList: true,
}
},
biddingProject: {
@@ -1186,7 +1193,7 @@ export async function sendTechSalesRfqToVendors(input: {
rfq: {
id: rfq.id,
code: rfq.rfqCode,
- title: rfq.item?.itemName || '',
+ title: rfq.itemShipbuilding?.itemList || '',
projectCode: rfq.biddingProject?.pspid || '',
projectName: rfq.biddingProject?.projNm || '',
description: rfq.remark || '',
@@ -1237,8 +1244,8 @@ export async function sendTechSalesRfqToVendors(input: {
await sendEmail({
to: vendorEmailsString,
subject: isResend
- ? `[기술영업 RFQ 재전송] ${rfq.rfqCode} - ${rfq.item?.itemName || '견적 요청'} ${emailContext.versionInfo}`
- : `[기술영업 RFQ] ${rfq.rfqCode} - ${rfq.item?.itemName || '견적 요청'}`,
+ ? `[기술영업 RFQ 재전송] ${rfq.rfqCode} - ${rfq.itemShipbuilding?.itemList || '견적 요청'} ${emailContext.versionInfo}`
+ : `[기술영업 RFQ] ${rfq.rfqCode} - ${rfq.itemShipbuilding?.itemList || '견적 요청'}`,
template: 'tech-sales-rfq-invite-ko', // 기술영업용 템플릿
context: emailContext,
cc: sender.email, // 발신자를 CC에 추가
@@ -1278,7 +1285,7 @@ export async function getTechSalesVendorQuotation(quotationId: number) {
with: {
rfq: {
with: {
- item: true,
+ itemShipbuilding: true,
biddingProject: true,
createdByUser: {
columns: {
@@ -1466,6 +1473,32 @@ export async function getVendorQuotations(input: {
return unstable_cache(
async () => {
try {
+ // 디버깅 로그 추가
+ console.log('🔍 [getVendorQuotations] 받은 파라미터:');
+ console.log(' 📊 기본 정보:', {
+ vendorId,
+ page: input.page,
+ perPage: input.perPage,
+ });
+ console.log(' 🔧 정렬 정보:', {
+ sort: input.sort,
+ sortLength: input.sort?.length,
+ sortDetails: input.sort?.map(s => `${s.id}:${s.desc ? 'DESC' : 'ASC'}`)
+ });
+ console.log(' 🔍 필터 정보:', {
+ filters: input.filters,
+ filtersLength: input.filters?.length,
+ joinOperator: input.joinOperator,
+ basicFilters: input.basicFilters,
+ basicFiltersLength: input.basicFilters?.length,
+ basicJoinOperator: input.basicJoinOperator
+ });
+ console.log(' 🔎 검색 정보:', {
+ search: input.search,
+ from: input.from,
+ to: input.to
+ });
+
const { page, perPage, sort, filters = [], search = "", from = "", to = "" } = input;
const offset = (page - 1) * perPage;
const limit = perPage;
@@ -1495,13 +1528,125 @@ export async function getVendorQuotations(input: {
// 고급 필터 처리
if (filters.length > 0) {
- const filterWhere = filterColumns({
- table: techSalesVendorQuotations,
- filters: filters as Filter<typeof techSalesVendorQuotations>[],
- joinOperator: input.joinOperator || "and",
- });
- if (filterWhere) {
- baseConditions.push(filterWhere);
+ // 조인된 테이블의 컬럼들을 분리
+ const joinedColumnFilters = [];
+ const baseTableFilters = [];
+
+ for (const filter of filters) {
+ const filterId = filter.id as string;
+
+ // 조인된 컬럼들인지 확인
+ if (['rfqCode', 'materialCode', 'dueDate', 'rfqStatus', 'itemName', 'projNm', 'pspid', 'sector', 'kunnrNm'].includes(filterId)) {
+ joinedColumnFilters.push(filter);
+ } else {
+ baseTableFilters.push(filter);
+ }
+ }
+
+ // 기본 테이블 필터 처리
+ if (baseTableFilters.length > 0) {
+ const filterWhere = filterColumns({
+ table: techSalesVendorQuotations,
+ filters: baseTableFilters as Filter<typeof techSalesVendorQuotations>[],
+ joinOperator: input.joinOperator || "and",
+ });
+ if (filterWhere) {
+ baseConditions.push(filterWhere);
+ }
+ }
+
+ // 조인된 컬럼 필터 처리
+ if (joinedColumnFilters.length > 0) {
+ const joinedConditions = joinedColumnFilters.map(filter => {
+ const filterId = filter.id as string;
+ const value = filter.value;
+ const operator = filter.operator || 'eq';
+
+ switch (filterId) {
+ case 'rfqCode':
+ if (operator === 'iLike') {
+ return ilike(techSalesRfqs.rfqCode, `%${value}%`);
+ } else if (operator === 'eq') {
+ return eq(techSalesRfqs.rfqCode, value as string);
+ }
+ break;
+
+ case 'materialCode':
+ if (operator === 'iLike') {
+ return ilike(techSalesRfqs.materialCode, `%${value}%`);
+ } else if (operator === 'eq') {
+ return eq(techSalesRfqs.materialCode, value as string);
+ }
+ break;
+
+ case 'dueDate':
+ if (operator === 'eq') {
+ return eq(techSalesRfqs.dueDate, new Date(value as string));
+ } else if (operator === 'gte') {
+ return sql`${techSalesRfqs.dueDate} >= ${new Date(value as string)}`;
+ } else if (operator === 'lte') {
+ return sql`${techSalesRfqs.dueDate} <= ${new Date(value as string)}`;
+ }
+ break;
+
+ case 'rfqStatus':
+ if (Array.isArray(value) && value.length > 0) {
+ return sql`${techSalesRfqs.status} IN (${value.map(v => `'${v}'`).join(',')})`;
+ } else if (typeof value === 'string') {
+ return eq(techSalesRfqs.status, value);
+ }
+ break;
+
+ case 'itemName':
+ if (operator === 'iLike') {
+ return ilike(itemShipbuilding.itemList, `%${value}%`);
+ } else if (operator === 'eq') {
+ return eq(itemShipbuilding.itemList, value as string);
+ }
+ break;
+
+ case 'projNm':
+ if (operator === 'iLike') {
+ return sql`${techSalesRfqs.projectSnapshot}->>'projNm' ILIKE ${'%' + value + '%'}`;
+ } else if (operator === 'eq') {
+ return sql`${techSalesRfqs.projectSnapshot}->>'projNm' = ${value}`;
+ }
+ break;
+
+ case 'pspid':
+ if (operator === 'iLike') {
+ return sql`${techSalesRfqs.projectSnapshot}->>'pspid' ILIKE ${'%' + value + '%'}`;
+ } else if (operator === 'eq') {
+ return sql`${techSalesRfqs.projectSnapshot}->>'pspid' = ${value}`;
+ }
+ break;
+
+ case 'sector':
+ if (operator === 'iLike') {
+ return sql`${techSalesRfqs.projectSnapshot}->>'sector' ILIKE ${'%' + value + '%'}`;
+ } else if (operator === 'eq') {
+ return sql`${techSalesRfqs.projectSnapshot}->>'sector' = ${value}`;
+ }
+ break;
+
+ case 'kunnrNm':
+ if (operator === 'iLike') {
+ return sql`${techSalesRfqs.projectSnapshot}->>'kunnrNm' ILIKE ${'%' + value + '%'}`;
+ } else if (operator === 'eq') {
+ return sql`${techSalesRfqs.projectSnapshot}->>'kunnrNm' = ${value}`;
+ }
+ break;
+ }
+ return undefined;
+ }).filter(Boolean);
+
+ if (joinedConditions.length > 0) {
+ const joinOperator = input.joinOperator || "and";
+ const combinedCondition = joinOperator === "and"
+ ? and(...joinedConditions)
+ : or(...joinedConditions);
+ baseConditions.push(combinedCondition);
+ }
}
}
@@ -1532,6 +1677,59 @@ export async function getVendorQuotations(input: {
return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt;
case 'updatedAt':
return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt;
+ case 'createdBy':
+ return item.desc ? desc(techSalesVendorQuotations.createdBy) : techSalesVendorQuotations.createdBy;
+ case 'updatedBy':
+ return item.desc ? desc(techSalesVendorQuotations.updatedBy) : techSalesVendorQuotations.updatedBy;
+ case 'quotationCode':
+ return item.desc ? desc(techSalesVendorQuotations.quotationCode) : techSalesVendorQuotations.quotationCode;
+ case 'quotationVersion':
+ return item.desc ? desc(techSalesVendorQuotations.quotationVersion) : techSalesVendorQuotations.quotationVersion;
+ case 'rejectionReason':
+ return item.desc ? desc(techSalesVendorQuotations.rejectionReason) : techSalesVendorQuotations.rejectionReason;
+ case 'acceptedAt':
+ return item.desc ? desc(techSalesVendorQuotations.acceptedAt) : techSalesVendorQuotations.acceptedAt;
+ // 조인된 RFQ 정보 정렬
+ case 'rfqCode':
+ return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode;
+ case 'materialCode':
+ return item.desc ? desc(techSalesRfqs.materialCode) : techSalesRfqs.materialCode;
+ case 'dueDate':
+ return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate;
+ case 'rfqStatus':
+ return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status;
+ // 조인된 아이템 정보 정렬
+ case 'itemName':
+ return item.desc ? desc(itemShipbuilding.itemList) : itemShipbuilding.itemList;
+ // JSON 필드에서 추출된 프로젝트 정보 정렬
+ case 'projNm':
+ return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'projNm'`) : sql`${techSalesRfqs.projectSnapshot}->>'projNm'`;
+ case 'projMsrm':
+ // 척수 (정수 캐스팅)
+ return item.desc ? desc(sql`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`) : sql`(${techSalesRfqs.projectSnapshot}->>'projMsrm')::int`;
+ case 'ptypeNm':
+ // 선종명
+ return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`) : sql`${techSalesRfqs.projectSnapshot}->>'ptypeNm'`;
+ case 'pspid':
+ // 프로젝트 ID
+ return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'pspid'`) : sql`${techSalesRfqs.projectSnapshot}->>'pspid'`;
+ case 'sector':
+ // 섹터
+ return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'sector'`) : sql`${techSalesRfqs.projectSnapshot}->>'sector'`;
+ case 'kunnrNm':
+ // 고객명
+ return item.desc ? desc(sql`${techSalesRfqs.projectSnapshot}->>'kunnrNm'`) : sql`${techSalesRfqs.projectSnapshot}->>'kunnrNm'`;
+ // 계산된 필드 정렬
+ case 'attachmentCount':
+ return item.desc ? desc(sql`(
+ SELECT COUNT(*)
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ )`) : sql`(
+ SELECT COUNT(*)
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ )`;
default:
return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt;
}
@@ -1554,13 +1752,17 @@ export async function getVendorQuotations(input: {
updatedAt: techSalesVendorQuotations.updatedAt,
createdBy: techSalesVendorQuotations.createdBy,
updatedBy: techSalesVendorQuotations.updatedBy,
+ quotationCode: techSalesVendorQuotations.quotationCode,
+ quotationVersion: techSalesVendorQuotations.quotationVersion,
+ rejectionReason: techSalesVendorQuotations.rejectionReason,
+ acceptedAt: techSalesVendorQuotations.acceptedAt,
// RFQ 정보
rfqCode: techSalesRfqs.rfqCode,
materialCode: techSalesRfqs.materialCode,
dueDate: techSalesRfqs.dueDate,
rfqStatus: techSalesRfqs.status,
// 아이템 정보
- itemName: items.itemName,
+ itemName: itemShipbuilding.itemList,
// 프로젝트 정보 (JSON에서 추출)
projNm: sql<string>`${techSalesRfqs.projectSnapshot}->>'projNm'`,
// 첨부파일 개수
@@ -1572,7 +1774,7 @@ export async function getVendorQuotations(input: {
})
.from(techSalesVendorQuotations)
.leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
- .leftJoin(items, eq(techSalesRfqs.itemId, items.id))
+ .leftJoin(itemShipbuilding, eq(techSalesRfqs.itemShipbuildingId, itemShipbuilding.id))
.where(finalWhere)
.orderBy(...orderBy)
.limit(limit)
@@ -1583,7 +1785,7 @@ export async function getVendorQuotations(input: {
.select({ count: sql<number>`count(*)` })
.from(techSalesVendorQuotations)
.leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
- .leftJoin(items, eq(techSalesRfqs.itemId, items.id))
+ .leftJoin(itemShipbuilding, eq(techSalesRfqs.itemShipbuildingId, itemShipbuilding.id))
.where(finalWhere);
const total = totalResult[0]?.count || 0;
@@ -2092,7 +2294,7 @@ export async function sendQuotationSubmittedNotificationToVendor(quotationId: nu
with: {
rfq: {
with: {
- item: true,
+ itemShipbuilding: true,
biddingProject: true,
createdByUser: {
columns: {
@@ -2169,7 +2371,7 @@ export async function sendQuotationSubmittedNotificationToVendor(quotationId: nu
rfq: {
id: quotation.rfq.id,
code: quotation.rfq.rfqCode,
- title: quotation.rfq.item?.itemName || '',
+ title: quotation.rfq.itemShipbuilding?.itemList || '',
projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '',
projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '',
dueDate: quotation.rfq.dueDate,
@@ -2202,7 +2404,7 @@ export async function sendQuotationSubmittedNotificationToVendor(quotationId: nu
// 이메일 발송
await sendEmail({
to: vendorEmails,
- subject: `[견적 제출 확인] ${quotation.rfq.rfqCode} - ${quotation.rfq.item?.itemName || '견적 요청'}`,
+ subject: `[견적 제출 확인] ${quotation.rfq.rfqCode} - ${quotation.rfq.itemShipbuilding?.itemList || '견적 요청'}`,
template: 'tech-sales-quotation-submitted-vendor-ko',
context: emailContext,
});
@@ -2226,7 +2428,7 @@ export async function sendQuotationSubmittedNotificationToManager(quotationId: n
with: {
rfq: {
with: {
- item: true,
+ itemShipbuilding: true,
biddingProject: true,
createdByUser: {
columns: {
@@ -2288,7 +2490,7 @@ export async function sendQuotationSubmittedNotificationToManager(quotationId: n
rfq: {
id: quotation.rfq.id,
code: quotation.rfq.rfqCode,
- title: quotation.rfq.item?.itemName || '',
+ title: quotation.rfq.itemShipbuilding?.itemList || '',
projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '',
projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '',
dueDate: quotation.rfq.dueDate,
@@ -2345,7 +2547,7 @@ export async function sendQuotationAcceptedNotification(quotationId: number) {
with: {
rfq: {
with: {
- item: true,
+ itemShipbuilding: true,
biddingProject: true,
createdByUser: {
columns: {
@@ -2422,7 +2624,7 @@ export async function sendQuotationAcceptedNotification(quotationId: number) {
rfq: {
id: quotation.rfq.id,
code: quotation.rfq.rfqCode,
- title: quotation.rfq.item?.itemName || '',
+ title: quotation.rfq.itemShipbuilding?.itemList || '',
projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '',
projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '',
dueDate: quotation.rfq.dueDate,
@@ -2479,7 +2681,7 @@ export async function sendQuotationRejectedNotification(quotationId: number) {
with: {
rfq: {
with: {
- item: true,
+ itemShipbuilding: true,
biddingProject: true,
createdByUser: {
columns: {
@@ -2556,7 +2758,7 @@ export async function sendQuotationRejectedNotification(quotationId: number) {
rfq: {
id: quotation.rfq.id,
code: quotation.rfq.rfqCode,
- title: quotation.rfq.item?.itemName || '',
+ title: quotation.rfq.itemShipbuilding?.itemList || '',
projectCode: projectInfo.pspid || quotation.rfq.biddingProject?.pspid || '',
projectName: projectInfo.projNm || quotation.rfq.biddingProject?.projNm || '',
dueDate: quotation.rfq.dueDate,
diff --git a/lib/techsales-rfq/table/create-rfq-dialog.tsx b/lib/techsales-rfq/table/create-rfq-dialog.tsx
index bacec02e..81c85649 100644
--- a/lib/techsales-rfq/table/create-rfq-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-dialog.tsx
@@ -237,8 +237,6 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
const query = itemSearchQuery.toLowerCase().trim()
filtered = filtered.filter(item =>
item.itemCode.toLowerCase().includes(query) ||
- item.itemName.toLowerCase().includes(query) ||
- (item.description && item.description.toLowerCase().includes(query)) ||
(item.itemList && item.itemList.toLowerCase().includes(query))
)
}
@@ -334,7 +332,8 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
const createPromises = rfqGroups.map(group =>
createTechSalesRfq({
biddingProjectId: data.biddingProjectId,
- materialGroupCodes: [group.joinedItemCodes], // 그룹화된 자재코드들
+ itemShipbuildingId: group.items[0].id, // 그룹의 첫 번째 아이템의 shipbuilding ID 사용
+ materialGroupCodes: group.itemCodes, // 해당 그룹의 자재코드들
createdBy: Number(session.user.id),
dueDate: data.dueDate,
})
@@ -496,7 +495,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
) : selectedShipType ? (
selectedShipType
) : (
- "미선택 (전체)"
+ "전체조회: 선종을 선택해야 생성가능합니다."
)}
<ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
</Button>
@@ -688,8 +687,8 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
[...availableItems]
.sort((a, b) => {
// itemList 기준으로 정렬 (없는 경우 itemName 사용, 둘 다 없으면 맨 뒤로)
- const aName = a.itemList || a.itemName || 'zzz'
- const bName = b.itemList || b.itemName || 'zzz'
+ const aName = a.itemList || 'zzz'
+ const bName = b.itemList || 'zzz'
return aName.localeCompare(bName, 'ko', { numeric: true })
})
.map((item) => {
@@ -711,10 +710,10 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
)}
<div className="flex-1">
<div className="font-medium">
- {item.itemList || item.itemName || '아이템명 없음'}
+ {item.itemList || '아이템명 없음'}
</div>
<div className="text-sm text-muted-foreground">
- {item.itemCode} • {item.description || '설명 없음'}
+ {item.itemCode || '자재그룹코드 없음'}
</div>
<div className="text-xs text-muted-foreground">
공종: {item.workType} • 선종: {item.shipTypes}
@@ -749,7 +748,7 @@ export function CreateRfqDialog({ onCreated }: CreateRfqDialogProps) {
variant="secondary"
className="flex items-center gap-1"
>
- {item.itemList || item.itemName || '아이템명 없음'} ({item.itemCode})
+ {item.itemList || '아이템명 없음'} ({item.itemCode})
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={() => handleRemoveItem(item.id)}
diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx
index dfb85420..2740170b 100644
--- a/lib/techsales-rfq/table/rfq-table-column.tsx
+++ b/lib/techsales-rfq/table/rfq-table-column.tsx
@@ -154,7 +154,7 @@ export function getColumns({
},
enableResizing: true,
minSize: 80,
- size: 120,
+ size: 250,
},
{
accessorKey: "itemName",
@@ -169,7 +169,7 @@ export function getColumns({
excelHeader: "자재명"
},
enableResizing: true,
- size: 180,
+ size: 250,
},
{
accessorKey: "projNm",
diff --git a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
index 7ba3320d..e4b1b8c3 100644
--- a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
+++ b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
@@ -48,7 +48,7 @@ interface ProjectInfoTabProps {
item?: {
id: number
itemCode: string | null
- itemName: string | null
+ itemList: string | null
} | null
biddingProject?: {
id: number
@@ -74,6 +74,8 @@ export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) {
const projectSnapshot = rfq?.projectSnapshot
const seriesSnapshot = rfq?.seriesSnapshot
+ console.log("rfq: ", rfq)
+
if (!rfq) {
return (
<div className="flex items-center justify-center h-full">
@@ -112,8 +114,9 @@ export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) {
<div className="text-sm">{rfq.materialCode || "N/A"}</div>
</div>
<div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">품목명</div>
- <div className="text-sm">{rfq.item?.itemName || "N/A"}</div>
+ <div className="text-sm font-medium text-muted-foreground">자재명</div>
+ {/* TODO : 타입 작업 (시연을 위해 빌드 중단 상태임. 추후 수정) */}
+ <div className="text-sm"><strong>{rfq.itemShipbuilding?.itemList || "N/A"}</strong></div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">마감일</div>
diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx
index a800dd95..97bba2bd 100644
--- a/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx
+++ b/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx
@@ -56,7 +56,7 @@ interface QuotationData {
item?: {
id: number
itemCode: string | null
- itemName: string | null
+ itemList: string | null
} | null
biddingProject?: {
id: number
diff --git a/lib/techsales-rfq/vendor-response/quotation-editor.tsx b/lib/techsales-rfq/vendor-response/quotation-editor.tsx
index f3fab10d..b30f612c 100644
--- a/lib/techsales-rfq/vendor-response/quotation-editor.tsx
+++ b/lib/techsales-rfq/vendor-response/quotation-editor.tsx
@@ -101,7 +101,7 @@ interface TechSalesVendorQuotation {
item?: {
id: number
itemCode: string | null
- itemName: string | null
+ itemList: string | null
} | null
biddingProject?: {
id: number
@@ -342,8 +342,8 @@ export default function TechSalesQuotationEditor({ quotation }: TechSalesQuotati
<p>{quotation.rfq.materialCode || "N/A"}</p>
</div>
<div>
- <label className="text-sm font-medium text-muted-foreground">품목명</label>
- <p>{quotation.rfq.item?.itemName || "N/A"}</p>
+ <label className="text-sm font-medium text-muted-foreground">자재명</label>
+ <p>{quotation.rfq.item?.itemList || "N/A"}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">마감일</label>
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
index 109698ea..cf1dac42 100644
--- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
@@ -13,24 +13,46 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
-import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
import {
TechSalesVendorQuotations,
TECH_SALES_QUOTATION_STATUS_CONFIG
} from "@/db/schema"
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
interface QuotationWithRfqCode extends TechSalesVendorQuotations {
+ // RFQ 관련 정보
rfqCode?: string;
materialCode?: string;
dueDate?: Date;
rfqStatus?: string;
+
+ // 아이템 정보
itemName?: string;
+ itemShipbuildingId?: number;
+
+ // 프로젝트 정보
projNm?: string;
+ pspid?: string;
+ sector?: string;
+
+ // 벤더 정보
+ vendorName?: string;
+ vendorCode?: string;
+
+ // 사용자 정보
+ createdByName?: string | null;
+ updatedByName?: string | null;
+
+ // 견적 코드 및 버전
quotationCode?: string | null;
quotationVersion?: number | null;
+
+ // 추가 상태 정보
rejectionReason?: string | null;
acceptedAt?: Date | null;
+
+ // 첨부파일 개수
attachmentCount?: number;
}
@@ -65,23 +87,23 @@ export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): C
enableSorting: false,
enableHiding: false,
},
- {
- accessorKey: "id",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="ID" />
- ),
- cell: ({ row }) => (
- <div className="w-20">
- <span className="font-mono text-xs">{row.getValue("id")}</span>
- </div>
- ),
- enableSorting: true,
- enableHiding: true,
- },
+ // {
+ // accessorKey: "id",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="ID" />
+ // ),
+ // cell: ({ row }) => (
+ // <div className="w-20">
+ // <span className="font-mono text-xs">{row.getValue("id")}</span>
+ // </div>
+ // ),
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
{
accessorKey: "rfqCode",
header: ({ column }) => (
- <DataTableColumnHeader column={column} title="RFQ 번호" />
+ <DataTableColumnHeaderSimple column={column} title="RFQ 번호" />
),
cell: ({ row }) => {
const rfqCode = row.getValue("rfqCode") as string;
@@ -94,26 +116,58 @@ export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): C
enableSorting: true,
enableHiding: false,
},
- {
- accessorKey: "materialCode",
- header: ({ column }) => (
- <DataTableColumnHeader column={column} title="자재 코드" />
- ),
- cell: ({ row }) => {
- const materialCode = row.getValue("materialCode") as string;
- return (
- <div className="min-w-32">
- <span className="font-mono text-sm">{materialCode || "N/A"}</span>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
+ // {
+ // accessorKey: "vendorName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="벤더명" />
+ // ),
+ // cell: ({ row }) => {
+ // const vendorName = row.getValue("vendorName") as string;
+ // return (
+ // <div className="min-w-32">
+ // <span className="text-sm">{vendorName || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: false,
+ // },
+ // {
+ // accessorKey: "vendorCode",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
+ // ),
+ // cell: ({ row }) => {
+ // const vendorCode = row.getValue("vendorCode") as string;
+ // return (
+ // <div className="min-w-24">
+ // <span className="font-mono text-sm">{vendorCode || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ // {
+ // accessorKey: "materialCode",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="자재 코드" />
+ // ),
+ // cell: ({ row }) => {
+ // const materialCode = row.getValue("materialCode") as string;
+ // return (
+ // <div className="min-w-32">
+ // <span className="font-mono text-sm">{materialCode || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
{
accessorKey: "itemName",
header: ({ column }) => (
- <DataTableColumnHeader column={column} title="품목명" />
+ <DataTableColumnHeaderSimple column={column} title="자재명" />
),
cell: ({ row }) => {
const itemName = row.getValue("itemName") as string;
@@ -134,13 +188,13 @@ export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): C
</div>
);
},
- enableSorting: false,
+ enableSorting: true,
enableHiding: true,
},
{
accessorKey: "projNm",
header: ({ column }) => (
- <DataTableColumnHeader column={column} title="프로젝트명" />
+ <DataTableColumnHeaderSimple column={column} title="프로젝트명" />
),
cell: ({ row }) => {
const projNm = row.getValue("projNm") as string;
@@ -161,13 +215,45 @@ export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): C
</div>
);
},
- enableSorting: false,
+ enableSorting: true,
enableHiding: true,
},
+ // {
+ // accessorKey: "quotationCode",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="견적서 번호" />
+ // ),
+ // cell: ({ row }) => {
+ // const quotationCode = row.getValue("quotationCode") as string;
+ // return (
+ // <div className="min-w-32">
+ // <span className="font-mono text-sm">{quotationCode || "미부여"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ // {
+ // accessorKey: "quotationVersion",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="버전" />
+ // ),
+ // cell: ({ row }) => {
+ // const quotationVersion = row.getValue("quotationVersion") as number;
+ // return (
+ // <div className="w-16 text-center">
+ // <span className="text-sm">{quotationVersion || 1}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
{
id: "attachments",
header: ({ column }) => (
- <DataTableColumnHeader column={column} title="첨부파일" />
+ <DataTableColumnHeaderSimple column={column} title="첨부파일" />
),
cell: ({ row }) => {
const quotation = row.original
@@ -216,7 +302,7 @@ export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): C
{
accessorKey: "status",
header: ({ column }) => (
- <DataTableColumnHeader column={column} title="상태" />
+ <DataTableColumnHeaderSimple column={column} title="상태" />
),
cell: ({ row }) => {
const status = row.getValue("status") as string;
@@ -243,7 +329,7 @@ export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): C
{
accessorKey: "currency",
header: ({ column }) => (
- <DataTableColumnHeader column={column} title="통화" />
+ <DataTableColumnHeaderSimple column={column} title="통화" />
),
cell: ({ row }) => {
const currency = row.getValue("currency") as string;
@@ -259,7 +345,7 @@ export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): C
{
accessorKey: "totalPrice",
header: ({ column }) => (
- <DataTableColumnHeader column={column} title="총액" />
+ <DataTableColumnHeaderSimple column={column} title="총액" />
),
cell: ({ row }) => {
const totalPrice = row.getValue("totalPrice") as string;
@@ -287,7 +373,7 @@ export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): C
{
accessorKey: "validUntil",
header: ({ column }) => (
- <DataTableColumnHeader column={column} title="유효기간" />
+ <DataTableColumnHeaderSimple column={column} title="유효기간" />
),
cell: ({ row }) => {
const validUntil = row.getValue("validUntil") as Date;
@@ -305,7 +391,7 @@ export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): C
{
accessorKey: "submittedAt",
header: ({ column }) => (
- <DataTableColumnHeader column={column} title="제출일" />
+ <DataTableColumnHeaderSimple column={column} title="제출일" />
),
cell: ({ row }) => {
const submittedAt = row.getValue("submittedAt") as Date;
@@ -320,10 +406,28 @@ export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): C
enableSorting: true,
enableHiding: true,
},
+ // {
+ // accessorKey: "acceptedAt",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="승인일" />
+ // ),
+ // cell: ({ row }) => {
+ // const acceptedAt = row.getValue("acceptedAt") as Date;
+ // return (
+ // <div className="w-36">
+ // <span className="text-sm">
+ // {acceptedAt ? formatDateTime(acceptedAt) : "미승인"}
+ // </span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
{
accessorKey: "dueDate",
header: ({ column }) => (
- <DataTableColumnHeader column={column} title="마감일" />
+ <DataTableColumnHeaderSimple column={column} title="마감일" />
),
cell: ({ row }) => {
const dueDate = row.getValue("dueDate") as Date;
@@ -340,10 +444,41 @@ export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): C
enableSorting: true,
enableHiding: true,
},
+ // {
+ // accessorKey: "rejectionReason",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="반려사유" />
+ // ),
+ // cell: ({ row }) => {
+ // const rejectionReason = row.getValue("rejectionReason") as string;
+ // return (
+ // <div className="min-w-48 max-w-64">
+ // {rejectionReason ? (
+ // <TooltipProvider>
+ // <Tooltip>
+ // <TooltipTrigger asChild>
+ // <span className="truncate block text-sm text-red-600">
+ // {rejectionReason}
+ // </span>
+ // </TooltipTrigger>
+ // <TooltipContent>
+ // <p className="max-w-xs">{rejectionReason}</p>
+ // </TooltipContent>
+ // </Tooltip>
+ // </TooltipProvider>
+ // ) : (
+ // <span className="text-sm text-muted-foreground">N/A</span>
+ // )}
+ // </div>
+ // );
+ // },
+ // enableSorting: false,
+ // enableHiding: true,
+ // },
{
accessorKey: "createdAt",
header: ({ column }) => (
- <DataTableColumnHeader column={column} title="생성일" />
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
),
cell: ({ row }) => {
const createdAt = row.getValue("createdAt") as Date;
@@ -361,7 +496,7 @@ export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): C
{
accessorKey: "updatedAt",
header: ({ column }) => (
- <DataTableColumnHeader column={column} title="수정일" />
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
),
cell: ({ row }) => {
const updatedAt = row.getValue("updatedAt") as Date;
@@ -376,6 +511,38 @@ export function getColumns({ router, openAttachmentsSheet }: GetColumnsProps): C
enableSorting: true,
enableHiding: true,
},
+ // {
+ // accessorKey: "createdByName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="생성자" />
+ // ),
+ // cell: ({ row }) => {
+ // const createdByName = row.getValue("createdByName") as string;
+ // return (
+ // <div className="w-24">
+ // <span className="text-sm">{createdByName || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ // {
+ // accessorKey: "updatedByName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="수정자" />
+ // ),
+ // cell: ({ row }) => {
+ // const updatedByName = row.getValue("updatedByName") as string;
+ // return (
+ // <div className="w-24">
+ // <span className="text-sm">{updatedByName || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
{
id: "actions",
header: "작업",
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
index e1b82579..e98d6bdc 100644
--- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
@@ -2,6 +2,7 @@
"use client"
import * as React from "react"
+import { useSearchParams } from "next/navigation"
import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table"
import { useDataTable } from "@/hooks/use-data-table"
import { DataTable } from "@/components/data-table/data-table"
@@ -10,41 +11,192 @@ import { TechSalesVendorQuotations, TECH_SALES_QUOTATION_STATUSES, TECH_SALES_QU
import { useRouter } from "next/navigation"
import { getColumns } from "./vendor-quotations-table-columns"
import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "../../table/tech-sales-rfq-attachments-sheet"
-import { getTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
+import { getTechSalesRfqAttachments, getVendorQuotations } from "@/lib/techsales-rfq/service"
import { toast } from "sonner"
+import { Skeleton } from "@/components/ui/skeleton"
interface QuotationWithRfqCode extends TechSalesVendorQuotations {
- rfqCode?: string;
- materialCode?: string;
+ rfqCode?: string | null;
+ materialCode?: string | null;
dueDate?: Date;
rfqStatus?: string;
- itemName?: string;
- projNm?: string;
+ itemName?: string | null;
+ projNm?: string | null;
quotationCode?: string | null;
- quotationVersion: number | null;
+ quotationVersion?: number | null;
rejectionReason?: string | null;
acceptedAt?: Date | null;
attachmentCount?: number;
}
interface VendorQuotationsTableProps {
- promises: Promise<[{ data: QuotationWithRfqCode[], pageCount: number, total?: number }]>;
+ vendorId: string;
}
-export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps) {
+// 로딩 스켈레톤 컴포넌트
+function TableLoadingSkeleton() {
+ return (
+ <div className="w-full space-y-3">
+ {/* 툴바 스켈레톤 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center space-x-2">
+ <Skeleton className="h-10 w-[250px]" />
+ <Skeleton className="h-10 w-[100px]" />
+ </div>
+ <div className="flex items-center space-x-2">
+ <Skeleton className="h-10 w-[120px]" />
+ <Skeleton className="h-10 w-[100px]" />
+ </div>
+ </div>
+
+ {/* 테이블 헤더 스켈레톤 */}
+ <div className="rounded-md border">
+ <div className="border-b p-4">
+ <div className="flex items-center space-x-4">
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[150px]" />
+ <Skeleton className="h-4 w-[120px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[130px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[80px]" />
+ </div>
+ </div>
+
+ {/* 테이블 행 스켈레톤 */}
+ {Array.from({ length: 5 }).map((_, index) => (
+ <div key={index} className="border-b p-4 last:border-b-0">
+ <div className="flex items-center space-x-4">
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[150px]" />
+ <Skeleton className="h-4 w-[120px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[130px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[80px]" />
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* 페이지네이션 스켈레톤 */}
+ <div className="flex items-center justify-between">
+ <Skeleton className="h-8 w-[200px]" />
+ <div className="flex items-center space-x-2">
+ <Skeleton className="h-8 w-[100px]" />
+ <Skeleton className="h-8 w-[60px]" />
+ <Skeleton className="h-8 w-[100px]" />
+ </div>
+ </div>
+ </div>
+ )
+}
- // TODO: 안정화 이후 삭제
- console.log("렌더링 사이클 점검용 로그: VendorQuotationsTable 렌더링됨");
+// 중앙 로딩 인디케이터 컴포넌트
+function CenterLoadingIndicator() {
+ return (
+ <div className="flex flex-col items-center justify-center py-12 space-y-4">
+ <div className="relative">
+ <div className="w-12 h-12 border-4 border-gray-200 border-t-blue-600 rounded-full animate-spin"></div>
+ </div>
+ <div className="text-center space-y-1">
+ <p className="text-sm font-medium text-gray-900">데이터를 불러오는 중...</p>
+ <p className="text-xs text-gray-500">잠시만 기다려주세요.</p>
+ </div>
+ </div>
+ )
+}
- const [{ data, pageCount }] = React.use(promises);
- const router = useRouter();
+export function VendorQuotationsTable({ vendorId }: VendorQuotationsTableProps) {
+ const searchParams = useSearchParams()
+ const router = useRouter()
// 첨부파일 시트 상태
const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
const [selectedRfqForAttachments, setSelectedRfqForAttachments] = React.useState<{ id: number; rfqCode: string | null; status: string } | null>(null)
const [attachmentsDefault, setAttachmentsDefault] = React.useState<ExistingTechSalesAttachment[]>([])
- // 데이터 안정성을 위한 메모이제이션 - 핵심 속성만 비교
+ // 데이터 로딩 상태
+ const [data, setData] = React.useState<QuotationWithRfqCode[]>([])
+ const [pageCount, setPageCount] = React.useState(0)
+ const [total, setTotal] = React.useState(0)
+ const [isLoading, setIsLoading] = React.useState(true)
+ const [isInitialLoad, setIsInitialLoad] = React.useState(true) // 최초 로딩 구분
+
+ // URL 파라미터에서 설정 읽기
+ const initialSettings = React.useMemo(() => ({
+ page: parseInt(searchParams?.get('page') || '1'),
+ perPage: parseInt(searchParams?.get('perPage') || '10'),
+ sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }],
+ filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
+ joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and",
+ basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [],
+ basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and",
+ search: searchParams?.get('search') || '',
+ from: searchParams?.get('from') || '',
+ to: searchParams?.get('to') || '',
+ }), [searchParams])
+
+ // 데이터 로드 함수
+ const loadData = React.useCallback(async () => {
+ try {
+ setIsLoading(true)
+
+ console.log('🔍 [VendorQuotationsTable] 데이터 로드 요청:', {
+ vendorId,
+ settings: initialSettings
+ })
+
+ const result = await getVendorQuotations({
+ page: initialSettings.page,
+ perPage: initialSettings.perPage,
+ sort: initialSettings.sort,
+ filters: initialSettings.filters,
+ joinOperator: initialSettings.joinOperator,
+ basicFilters: initialSettings.basicFilters,
+ basicJoinOperator: initialSettings.basicJoinOperator,
+ search: initialSettings.search,
+ from: initialSettings.from,
+ to: initialSettings.to,
+ }, vendorId)
+
+ console.log('🔍 [VendorQuotationsTable] 데이터 로드 결과:', {
+ dataLength: result.data.length,
+ pageCount: result.pageCount,
+ total: result.total
+ })
+
+ setData(result.data as QuotationWithRfqCode[])
+ setPageCount(result.pageCount)
+ setTotal(result.total)
+ } catch (error) {
+ console.error('데이터 로드 오류:', error)
+ toast.error('데이터를 불러오는 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ setIsInitialLoad(false)
+ }
+ }, [vendorId, initialSettings])
+
+ // URL 파라미터 변경 감지 및 데이터 재로드 (초기 로드 포함)
+ React.useEffect(() => {
+ loadData()
+ }, [
+ searchParams?.get('page'),
+ searchParams?.get('perPage'),
+ searchParams?.get('sort'),
+ searchParams?.get('filters'),
+ searchParams?.get('joinOperator'),
+ searchParams?.get('basicFilters'),
+ searchParams?.get('basicJoinOperator'),
+ searchParams?.get('search'),
+ searchParams?.get('from'),
+ searchParams?.get('to'),
+ // vendorId 변경도 감지
+ vendorId
+ ])
+
+ // 데이터 안정성을 위한 메모이제이션
const stableData = React.useMemo(() => {
return data;
}, [data.length, data.map(item => `${item.id}-${item.status}-${item.updatedAt}`).join(',')]);
@@ -95,13 +247,13 @@ export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps)
}
}, [data])
- // 테이블 컬럼 정의 - router는 안정적이므로 한 번만 생성
+ // 테이블 컬럼 정의
const columns = React.useMemo(() => getColumns({
router,
openAttachmentsSheet,
- }), [router, openAttachmentsSheet]);
+ }), [router, openAttachmentsSheet])
- // 필터 필드 - 중앙화된 상태 상수 사용
+ // 필터 필드
const filterFields = React.useMemo<DataTableFilterField<QuotationWithRfqCode>[]>(() => [
{
id: "status",
@@ -121,9 +273,9 @@ export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps)
label: "자재 코드",
placeholder: "자재 코드 검색...",
}
- ], []);
+ ], [])
- // 고급 필터 필드 - 중앙화된 상태 상수 사용
+ // 고급 필터 필드
const advancedFilterFields = React.useMemo<DataTableAdvancedFilterField<QuotationWithRfqCode>[]>(() => [
{
id: "rfqCode",
@@ -154,20 +306,21 @@ export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps)
label: "제출일",
type: "date",
},
- ], []);
+ ], [])
// useDataTable 훅 사용
const { table } = useDataTable({
data: stableData,
columns,
pageCount,
+ rowCount: total,
filterFields,
enablePinning: true,
enableAdvancedFilter: true,
enableColumnResizing: true,
columnResizeMode: 'onChange',
initialState: {
- sorting: [{ id: "updatedAt", desc: true }],
+ sorting: initialSettings.sort,
columnPinning: { right: ["actions"] },
},
getRowId: (originalRow) => String(originalRow.id),
@@ -177,22 +330,48 @@ export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps)
minSize: 50,
maxSize: 500,
},
- });
+ })
+
+ // 최초 로딩 시 전체 스켈레톤 표시
+ if (isInitialLoad && isLoading) {
+ return (
+ <div className="w-full">
+ <div className="overflow-x-auto">
+ <TableLoadingSkeleton />
+ </div>
+ </div>
+ )
+ }
return (
<div className="w-full">
<div className="overflow-x-auto">
- <DataTable
- table={table}
- className="min-w-full"
- >
- <DataTableAdvancedToolbar
+ <div className="relative">
+ {/* 로딩 오버레이 (재로딩 시) */}
+ {/* {!isInitialLoad && isLoading && (
+ <div className="absolute h-full w-full inset-0 bg-background/90 backdrop-blur-md z-10 flex items-center justify-center">
+ <CenterLoadingIndicator />
+ </div>
+ )} */}
+
+ <DataTable
table={table}
- filterFields={advancedFilterFields}
- shallow={false}
+ className="min-w-full"
>
- </DataTableAdvancedToolbar>
- </DataTable>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ {!isInitialLoad && isLoading && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
+ 데이터 업데이트 중...
+ </div>
+ )}
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
</div>
{/* 첨부파일 관리 시트 (읽기 전용) */}