diff options
| author | joonhoekim <26rote@gmail.com> | 2025-06-02 02:27:56 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-06-02 02:27:56 +0000 |
| commit | e5f4a774fabc17b5b18d50c96f5695d89dcabc86 (patch) | |
| tree | b1ef756d93f8e8d1d67998a5694aab379e34b5bc | |
| parent | 37611339fea096e47aaa42311a13a6313b4200db (diff) | |
(김준회) 기술영업 조선 RFQ 에러 처리 및 필터와 소팅 처리
| -rw-r--r-- | app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx | 52 | ||||
| -rw-r--r-- | db/schema/techSales.ts | 14 | ||||
| -rw-r--r-- | lib/items-tech/service.ts | 5 | ||||
| -rw-r--r-- | lib/techsales-rfq/repository.ts | 16 | ||||
| -rw-r--r-- | lib/techsales-rfq/service.ts | 350 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/create-rfq-dialog.tsx | 17 | ||||
| -rw-r--r-- | lib/techsales-rfq/table/rfq-table-column.tsx | 4 | ||||
| -rw-r--r-- | lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx | 9 | ||||
| -rw-r--r-- | lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx | 2 | ||||
| -rw-r--r-- | lib/techsales-rfq/vendor-response/quotation-editor.tsx | 6 | ||||
| -rw-r--r-- | lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx | 255 | ||||
| -rw-r--r-- | lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx | 239 |
12 files changed, 736 insertions, 233 deletions
diff --git a/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx b/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx index 5b0ffb61..12ff9a81 100644 --- a/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx +++ b/app/[lng]/partners/(partners)/techsales/rfq-ship/page.tsx @@ -7,17 +7,13 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { LogIn } from "lucide-react"; -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; import { Shell } from "@/components/shell"; -import { getValidFilters } from "@/lib/data-table"; -import { type SearchParams } from "@/types/table"; -import { searchParamsVendorRfqCache } from "@/lib/techsales-rfq/validations"; import { TECH_SALES_QUOTATION_STATUSES, TECH_SALES_QUOTATION_STATUS_CONFIG } from "@/db/schema"; -import { getQuotationStatusCounts, getVendorQuotations } from "@/lib/techsales-rfq/service"; +import { getQuotationStatusCounts } from "@/lib/techsales-rfq/service"; import { VendorQuotationsTable } from "@/lib/techsales-rfq/vendor-response/table/vendor-quotations-table"; export const metadata: Metadata = { @@ -25,13 +21,7 @@ export const metadata: Metadata = { description: "기술영업 RFQ 견적서를 관리합니다.", }; -interface VendorQuotationsPageProps { - searchParams: SearchParams; -} - -export default async function VendorQuotationsPage({ - searchParams, -}: VendorQuotationsPageProps) { +export default async function VendorQuotationsPage() { // 세션 확인 const session = await getServerSession(authOptions); @@ -73,29 +63,9 @@ export default async function VendorQuotationsPage({ ); } - // 검색 파라미터 파싱 및 검증 - const search = searchParamsVendorRfqCache.parse(searchParams); - const validFilters = getValidFilters(search.filters); - // 견적서 상태별 개수 조회 const statusCountsPromise = getQuotationStatusCounts(vendorId.toString()); - // 견적서 목록 조회 - const quotationsPromise = getVendorQuotations( - { - flags: search.flags, - page: search.page, - perPage: search.perPage, - sort: search.sort, - filters: validFilters, - joinOperator: search.joinOperator, - search: search.search, - from: search.from, - to: search.to, - }, - vendorId.toString() - ); - return ( <Shell variant="fullscreen" className="h-full"> {/* 고정 헤더 영역 */} @@ -135,21 +105,9 @@ export default async function VendorQuotationsPage({ {/* 견적서 테이블 */} <div className="flex-1 min-h-0 overflow-hidden"> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={12} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem"]} - shrinkZero - /> - } - > - <div className="h-full overflow-auto"> - <VendorQuotationsTable promises={Promise.all([quotationsPromise.then(result => ({ data: result.data, pageCount: result.pageCount }))])} /> - </div> - </React.Suspense> + <div className="h-full overflow-auto"> + <VendorQuotationsTable vendorId={vendorId.toString()} /> + </div> </div> </div> </Shell> diff --git a/db/schema/techSales.ts b/db/schema/techSales.ts index 590ddc76..082ab592 100644 --- a/db/schema/techSales.ts +++ b/db/schema/techSales.ts @@ -39,7 +39,7 @@ import { import { relations } from "drizzle-orm"; import { biddingProjects } from "./projects"; import { users } from "./users"; -import { items } from "./items"; +import { itemShipbuilding } from "./items"; import { vendors } from "./vendors"; // ===== 기술영업 상태 관리 상수 및 타입 ===== @@ -108,9 +108,9 @@ export const techSalesRfqs = pgTable("tech_sales_rfqs", { rfqCode: varchar("rfq_code", { length: 50 }).unique(), // ex) "RFQ-2025-001" // item에서 기술영업에서 사용하는 추가 정보는 itemShipbuilding 테이블에 저장되어 있다. - itemId: integer("item_id") + itemShipbuildingId: integer("item_shipbuilding_id") .notNull() - .references(() => items.id, { onDelete: "cascade" }), + .references(() => itemShipbuilding.id, { onDelete: "cascade" }), // 프로젝트 참조 ID biddingProjectId: integer("bidding_project_id").references(() => biddingProjects.id, { onDelete: "set null" }), @@ -324,10 +324,10 @@ export type TechSalesVendorQuotations = // Relations 정의 export const techSalesRfqsRelations = relations(techSalesRfqs, ({ one, many }) => ({ - // 아이템 관계 - item: one(items, { - fields: [techSalesRfqs.itemId], - references: [items.id], + // 조선 아이템 관계 + itemShipbuilding: one(itemShipbuilding, { + fields: [techSalesRfqs.itemShipbuildingId], + references: [itemShipbuilding.id], }), // 프로젝트 관계 diff --git a/lib/items-tech/service.ts b/lib/items-tech/service.ts index 158fce13..a14afa14 100644 --- a/lib/items-tech/service.ts +++ b/lib/items-tech/service.ts @@ -1089,8 +1089,6 @@ export type WorkType = '기장' | '전장' | '선실' | '배관' | '철의' export interface ShipbuildingItem {
id: number
itemCode: string
- itemName: string
- description: string | null
workType: WorkType
itemList: string | null // 실제 아이템명
shipTypes: string
@@ -1251,8 +1249,6 @@ export async function getAllShipbuildingItemsForCache() { .select({
id: itemShipbuilding.id,
itemCode: itemShipbuilding.itemCode,
- itemName: items.itemName,
- description: items.description,
workType: itemShipbuilding.workType,
itemList: itemShipbuilding.itemList,
shipTypes: itemShipbuilding.shipTypes,
@@ -1260,7 +1256,6 @@ export async function getAllShipbuildingItemsForCache() { updatedAt: itemShipbuilding.updatedAt,
})
.from(itemShipbuilding)
- .leftJoin(items, eq(itemShipbuilding.itemCode, items.itemCode))
return {
data: result as ShipbuildingItem[],
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> {/* 첨부파일 관리 시트 (읽기 전용) */} |
