summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/rfqs-tech/service.ts1780
-rw-r--r--lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table-column.tsx2
-rw-r--r--lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table.tsx68
-rw-r--r--lib/tech-vendor-rfq-response/service.ts541
-rw-r--r--lib/vendor-document-list/dolce-upload-service.ts655
-rw-r--r--lib/vendor-document-list/enhanced-document-service.ts2
-rw-r--r--lib/vendor-document-list/import-service.ts487
-rw-r--r--lib/vendor-document-list/sync-service.ts265
-rw-r--r--lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx57
-rw-r--r--lib/vendor-document-list/table/enhanced-documents-table.tsx2
-rw-r--r--lib/vendor-document-list/table/import-from-dolce-button.tsx356
-rw-r--r--lib/vendor-document-list/table/swp-workflow-panel.tsx370
-rw-r--r--lib/vendor-document-list/workflow-service.ts195
-rw-r--r--lib/welding/repository.ts49
-rw-r--r--lib/welding/service.ts87
-rw-r--r--lib/welding/table/ocr-table-columns.tsx312
-rw-r--r--lib/welding/table/ocr-table-toolbar-actions.tsx297
-rw-r--r--lib/welding/table/ocr-table.tsx143
-rw-r--r--lib/welding/validation.ts36
19 files changed, 4192 insertions, 1512 deletions
diff --git a/lib/rfqs-tech/service.ts b/lib/rfqs-tech/service.ts
index 26012491..fac18a43 100644
--- a/lib/rfqs-tech/service.ts
+++ b/lib/rfqs-tech/service.ts
@@ -1,11 +1,10 @@
// src/lib/tasks/service.ts
"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
-import { revalidatePath, revalidateTag, unstable_noStore } from "next/cache";
+import { revalidatePath, revalidateTag } from "next/cache";
import db from "@/db/db";
import { filterColumns } from "@/lib/filter-columns";
-import { unstable_cache } from "@/lib/unstable-cache";
import { getErrorMessage } from "@/lib/handle-error";
import { GetRfqsSchema, CreateRfqSchema, UpdateRfqSchema, CreateRfqItemSchema, GetMatchedVendorsSchema, UpdateRfqVendorSchema, GetTBESchema, GetCBESchema, createCbeEvaluationSchema } from "./validations";
@@ -41,113 +40,94 @@ interface InviteVendorsInput {
/**
* 복잡한 조건으로 Rfq 목록을 조회 (+ pagination) 하고,
* 총 개수에 따라 pageCount를 계산해서 리턴.
- * Next.js의 unstable_cache를 사용해 일정 시간 캐시.
*/
export async function getRfqs(input: GetRfqsSchema) {
- return unstable_cache(
- async () => {
- try {
- const offset = (input.page - 1) * input.perPage;
- // const advancedTable = input.flags.includes("advancedTable");
- const advancedTable = true;
-
- // advancedTable 모드면 filterColumns()로 where 절 구성
- const advancedWhere = filterColumns({
- table: rfqsView,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
+ try {
+ const offset = (input.page - 1) * input.perPage;
+ // const advancedTable = input.flags.includes("advancedTable");
+ const advancedTable = true;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: rfqsView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
- let globalWhere
- if (input.search) {
- const s = `%${input.search}%`
- globalWhere = or(ilike(rfqsView.rfqCode, s), ilike(rfqsView.projectCode, s)
- , ilike(rfqsView.projectName, s), ilike(rfqsView.dueDate, s), ilike(rfqsView.status, s)
- )
- // 필요시 여러 칼럼 OR조건 (status, priority, etc)
- }
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(rfqsView.rfqCode, s), ilike(rfqsView.projectCode, s)
+ , ilike(rfqsView.projectName, s), ilike(rfqsView.dueDate, s), ilike(rfqsView.status, s)
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
- const whereConditions = [];
- if (advancedWhere) whereConditions.push(advancedWhere);
- if (globalWhere) whereConditions.push(globalWhere);
+ const whereConditions = [];
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
- // 조건이 있을 때만 and() 사용
- const finalWhere = whereConditions.length > 0
- ? and(...whereConditions)
- : undefined;
+ // 조건이 있을 때만 and() 사용
+ const finalWhere = whereConditions.length > 0
+ ? and(...whereConditions)
+ : undefined;
- const orderBy =
- input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(rfqsView[item.id]) : asc(rfqsView[item.id])
- )
- : [asc(rfqsView.createdAt)];
-
- // 트랜잭션 내부에서 Repository 호출
- const { data, total } = await db.transaction(async (tx) => {
- const data = await selectRfqs(tx, {
- where: finalWhere,
- orderBy,
- offset,
- limit: input.perPage,
- });
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(rfqsView[item.id]) : asc(rfqsView[item.id])
+ )
+ : [asc(rfqsView.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectRfqs(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
- const total = await countRfqs(tx, finalWhere);
- return { data, total };
- });
+ const total = await countRfqs(tx, finalWhere);
+ return { data, total };
+ });
- const pageCount = Math.ceil(total / input.perPage);
+ const pageCount = Math.ceil(total / input.perPage);
- return { data, pageCount };
- } catch (err) {
- console.error("getRfqs 에러:", err); // 자세한 에러 로깅
+ return { data, pageCount };
+ } catch (err) {
+ console.error("getRfqs 에러:", err); // 자세한 에러 로깅
- // 에러 발생 시 디폴트
- return { data: [], pageCount: 0 };
- }
- },
- [JSON.stringify(input)],
- {
- revalidate: 3600,
- tags: [`rfqs`],
- }
- )();
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
}
/** Status별 개수 */
export async function getRfqStatusCounts() {
- return unstable_cache(
- async () => {
- try {
- const initial: Record<Rfq["status"], number> = {
- DRAFT: 0,
- PUBLISHED: 0,
- EVALUATION: 0,
- AWARDED: 0,
- };
+ try {
+ const initial: Record<Rfq["status"], number> = {
+ DRAFT: 0,
+ PUBLISHED: 0,
+ EVALUATION: 0,
+ AWARDED: 0,
+ };
- const result = await db.transaction(async (tx) => {
- const rows = await groupByStatus(tx);
- return rows.reduce<Record<Rfq["status"], number>>((acc, { status, count }) => {
- acc[status] = count;
- return acc;
- }, initial);
- });
+ const result = await db.transaction(async (tx) => {
+ const rows = await groupByStatus(tx);
+ return rows.reduce<Record<Rfq["status"], number>>((acc, { status, count }) => {
+ acc[status] = count;
+ return acc;
+ }, initial);
+ });
- return result;
- } catch {
- return {} as Record<Rfq["status"], number>;
- }
- },
- [`rfq-status-counts`],
- {
- revalidate: 3600,
- tags: [`rfqs`],
- }
- )();
+ return result;
+ } catch {
+ return {} as Record<Rfq["status"], number>;
+ }
}
@@ -161,7 +141,6 @@ export async function getRfqStatusCounts() {
* 전체 Rfq 개수를 고정
*/
export async function createRfq(input: CreateRfqSchema) {
- unstable_noStore();
try {
await db.transaction(async (tx) => {
await insertRfq(tx, {
@@ -174,8 +153,6 @@ export async function createRfq(input: CreateRfqSchema) {
});
});
- revalidateTag("rfqs");
-
return { data: null, error: null };
} catch (err) {
return { data: null, error: getErrorMessage(err) };
@@ -188,7 +165,6 @@ export async function createRfq(input: CreateRfqSchema) {
/** 단건 업데이트 */
export async function modifyRfq(input: UpdateRfqSchema & { id: number }) {
- unstable_noStore();
try {
await db.transaction(async (tx) => {
await updateRfq(tx, input.id, {
@@ -200,7 +176,6 @@ export async function modifyRfq(input: UpdateRfqSchema & { id: number }) {
});
});
- revalidateTag("rfqs");
return { data: null, error: null };
} catch (err) {
@@ -213,7 +188,6 @@ export async function modifyRfqs(input: {
status?: Rfq["status"];
dueDate?: Date
}) {
- unstable_noStore();
try {
await db.transaction(async (tx) => {
await updateRfqs(tx, input.ids, {
@@ -222,7 +196,7 @@ export async function modifyRfqs(input: {
});
});
- revalidateTag("rfqs");
+
return { data: null, error: null };
} catch (err) {
@@ -237,7 +211,6 @@ export async function modifyRfqs(input: {
/** 단건 삭제 */
export async function removeRfq(input: { id: number }) {
- unstable_noStore();
try {
await db.transaction(async (tx) => {
// 삭제
@@ -245,7 +218,7 @@ export async function removeRfq(input: { id: number }) {
// 바로 새 Rfq 생성
});
- revalidateTag("rfqs");
+
return { data: null, error: null };
} catch (err) {
@@ -255,14 +228,13 @@ export async function removeRfq(input: { id: number }) {
/** 복수 삭제 */
export async function removeRfqs(input: { ids: number[] }) {
- unstable_noStore();
try {
await db.transaction(async (tx) => {
// 삭제
await deleteRfqsByIds(tx, input.ids);
});
- revalidateTag("rfqs");
+
return { data: null, error: null };
} catch (err) {
@@ -275,8 +247,6 @@ export async function removeRfqs(input: { ids: number[] }) {
* RFQ 아이템 삭제 함수
*/
export async function deleteRfqItem(input: { id: number, rfqId: number }) {
- unstable_noStore(); // Next.js 서버 액션 캐싱 방지
-
try {
// 삭제 작업 수행
await db
@@ -288,12 +258,6 @@ export async function deleteRfqItem(input: { id: number, rfqId: number }) {
)
);
- console.log(`Deleted RFQ item: ${input.id} for RFQ ${input.rfqId}`);
-
- // 캐시 무효화
- revalidateTag("rfqs");
- revalidateTag(`rfq-${input.rfqId}`);
-
return { data: null, error: null };
} catch (err) {
console.error("Error in deleteRfqItem:", err);
@@ -303,8 +267,6 @@ export async function deleteRfqItem(input: { id: number, rfqId: number }) {
// createRfqItem 함수 수정 (id 파라미터 추가)
export async function createRfqItem(input: CreateRfqItemSchema & { id?: number }) {
- unstable_noStore();
-
try {
// DB 트랜잭션
await db.transaction(async (tx) => {
@@ -321,7 +283,6 @@ export async function createRfqItem(input: CreateRfqItemSchema & { id?: number }
})
.where(eq(rfqItems.id, input.id));
- console.log(`Updated RFQ item with id: ${input.id}`);
} else {
// 기존 로직: 같은 itemCode로 이미 존재하는지 확인 후 업데이트/생성
const existingItems = await tx
@@ -363,10 +324,6 @@ export async function createRfqItem(input: CreateRfqItemSchema & { id?: number }
}
});
- // 캐시 무효화
- revalidateTag("rfqs");
- revalidateTag(`rfq-${input.rfqId}`);
-
return { data: null, error: null };
} catch (err) {
console.error("Error in createRfqItem:", err);
@@ -463,9 +420,6 @@ export async function processRfqAttachments(args: {
const newCount = countRow?.cnt ?? 0;
- // 3) revalidateTag 등 캐시 무효화
- revalidateTag("rfq-attachments");
-
return { ok: true, updatedItemCount: newCount };
} catch (error) {
console.error("processRfqAttachments error:", error);
@@ -529,243 +483,240 @@ export const findRfqById = async (id: number): Promise<RfqViewWithItems | null>
};
export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: number) {
- return unstable_cache(
- async () => {
- // ─────────────────────────────────────────────────────
- // 1) rfq_items에서 distinct itemCode
- // ─────────────────────────────────────────────────────
- const itemRows = await db
- .select({ code: rfqItems.itemCode })
- .from(rfqItems)
- .where(eq(rfqItems.rfqId, rfqId))
- .groupBy(rfqItems.itemCode)
+ try {
+ // ─────────────────────────────────────────────────────
+ // 1) rfq_items에서 distinct itemCode
+ // ─────────────────────────────────────────────────────
+ const itemRows = await db
+ .select({ code: rfqItems.itemCode })
+ .from(rfqItems)
+ .where(eq(rfqItems.rfqId, rfqId))
+ .groupBy(rfqItems.itemCode)
- const itemCodes = itemRows.map((r) => r.code)
- const itemCount = itemCodes.length
- if (itemCount === 0) {
- return { data: [], pageCount: 0 }
- }
+ const itemCodes = itemRows.map((r) => r.code)
+ const itemCount = itemCodes.length
+ if (itemCount === 0) {
+ return { data: [], pageCount: 0 }
+ }
- // ─────────────────────────────────────────────────────
- // 2) vendorPossibleItems에서 모든 itemCodes를 보유한 vendor
- // ─────────────────────────────────────────────────────
- const inList = itemCodes.map((c) => `'${c}'`).join(",")
- const sqlVendorIds = await db.execute(
- sql`
- SELECT vpi.vendor_id AS "vendorId"
- FROM ${vendorPossibleItems} vpi
- WHERE vpi.item_code IN (${sql.raw(inList)})
- GROUP BY vpi.vendor_id
- HAVING COUNT(DISTINCT vpi.item_code) = ${itemCount}
- `
- )
- const vendorIdList = sqlVendorIds.rows.map((row: any) => +row.vendorId)
- if (vendorIdList.length === 0) {
- return { data: [], pageCount: 0 }
- }
+ // ─────────────────────────────────────────────────────
+ // 2) vendorPossibleItems에서 모든 itemCodes를 보유한 vendor
+ // ─────────────────────────────────────────────────────
+ const inList = itemCodes.map((c) => `'${c}'`).join(",")
+ const sqlVendorIds = await db.execute(
+ sql`
+ SELECT vpi.vendor_id AS "vendorId"
+ FROM ${vendorPossibleItems} vpi
+ WHERE vpi.item_code IN (${sql.raw(inList)})
+ GROUP BY vpi.vendor_id
+ HAVING COUNT(DISTINCT vpi.item_code) = ${itemCount}
+ `
+ )
+ const vendorIdList = sqlVendorIds.rows.map((row: any) => +row.vendorId)
+ if (vendorIdList.length === 0) {
+ return { data: [], pageCount: 0 }
+ }
- // ─────────────────────────────────────────────────────
- // 3) 필터/검색/정렬
- // ─────────────────────────────────────────────────────
- const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10)
- const limit = input.perPage ?? 10
+ // ─────────────────────────────────────────────────────
+ // 3) 필터/검색/정렬
+ // ─────────────────────────────────────────────────────
+ const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10)
+ const limit = input.perPage ?? 10
+
+ // (가) 커스텀 필터
+ // 여기서는 "뷰(vendorRfqView)"의 컬럼들에 대해 필터합니다.
+ const advancedWhere = filterColumns({
+ // 테이블이 아니라 "뷰"를 넘길 수도 있고,
+ // 혹은 columns 객체(연결된 모든 컬럼)로 넘겨도 됩니다.
+ table: vendorRfqView,
+ filters: input.filters ?? [],
+ joinOperator: input.joinOperator ?? "and",
+ })
- // (가) 커스텀 필터
- // 여기서는 "뷰(vendorRfqView)"의 컬럼들에 대해 필터합니다.
- const advancedWhere = filterColumns({
- // 테이블이 아니라 "뷰"를 넘길 수도 있고,
- // 혹은 columns 객체(연결된 모든 컬럼)로 넘겨도 됩니다.
- table: vendorRfqView,
- filters: input.filters ?? [],
- joinOperator: input.joinOperator ?? "and",
- })
+ // (나) 글로벌 검색
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ sql`${vendorRfqView.vendorName} ILIKE ${s}`,
+ sql`${vendorRfqView.vendorCode} ILIKE ${s}`,
+ sql`${vendorRfqView.email} ILIKE ${s}`
+ )
+ }
- // (나) 글로벌 검색
- let globalWhere
- if (input.search) {
- const s = `%${input.search}%`
- globalWhere = or(
- sql`${vendorRfqView.vendorName} ILIKE ${s}`,
- sql`${vendorRfqView.vendorCode} ILIKE ${s}`,
- sql`${vendorRfqView.email} ILIKE ${s}`
- )
- }
+ // (다) 최종 where
+ // vendorId가 vendorIdList 내에 있어야 하고,
+ // 특정 rfqId(뷰에 담긴 값)도 일치해야 함.
+ const finalWhere = and(
+ inArray(vendorRfqView.vendorId, vendorIdList),
+ // 아래 라인은 rfq에 초대된 벤더만 필터링하는 조건으로 추정되지만
+ // rfq 를 진행하기 전에도 벤더를 보여줘야 하므로 주석처리하겠습니다
+ // eq(vendorRfqView.rfqId, rfqId),
+ advancedWhere,
+ globalWhere
+ )
+
+ // (라) 정렬
+ const orderBy = input.sort?.length
+ ? input.sort.map((s) => {
+ // "column id" -> vendorRfqView.* 중 하나
+ const col = (vendorRfqView as any)[s.id]
+ return s.desc ? desc(col) : asc(col)
+ })
+ : [asc(vendorRfqView.vendorId)]
- // (다) 최종 where
- // vendorId가 vendorIdList 내에 있어야 하고,
- // 특정 rfqId(뷰에 담긴 값)도 일치해야 함.
- const finalWhere = and(
- inArray(vendorRfqView.vendorId, vendorIdList),
- // 아래 라인은 rfq에 초대된 벤더만 필터링하는 조건으로 추정되지만
- // rfq 를 진행하기 전에도 벤더를 보여줘야 하므로 주석처리하겠습니다
- // eq(vendorRfqView.rfqId, rfqId),
- advancedWhere,
- globalWhere
+ // ─────────────────────────────────────────────────────
+ // 4) View에서 데이터 SELECT
+ // ─────────────────────────────────────────────────────
+ const [rows, total] = await db.transaction(async (tx) => {
+ const data = await tx
+ .select({
+ id: vendorRfqView.vendorId,
+ vendorID: vendorRfqView.vendorId,
+ vendorName: vendorRfqView.vendorName,
+ vendorCode: vendorRfqView.vendorCode,
+ address: vendorRfqView.address,
+ country: vendorRfqView.country,
+ email: vendorRfqView.email,
+ website: vendorRfqView.website,
+ vendorStatus: vendorRfqView.vendorStatus,
+ // rfqVendorStatus와 rfqVendorUpdated는 나중에 정확한 데이터로 교체할 예정
+ rfqVendorStatus: vendorRfqView.rfqVendorStatus,
+ rfqVendorUpdated: vendorRfqView.rfqVendorUpdated,
+ })
+ .from(vendorRfqView)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(limit)
+
+ // 중복 제거된 데이터 생성
+ const distinctData = Array.from(
+ new Map(data.map(row => [row.id, row])).values()
)
- // (라) 정렬
- const orderBy = input.sort?.length
- ? input.sort.map((s) => {
- // "column id" -> vendorRfqView.* 중 하나
- const col = (vendorRfqView as any)[s.id]
- return s.desc ? desc(col) : asc(col)
- })
- : [asc(vendorRfqView.vendorId)]
+ // 중복 제거된 총 개수 계산
+ const [{ count }] = await tx
+ .select({ count: sql<number>`count(DISTINCT ${vendorRfqView.vendorId})`.as("count") })
+ .from(vendorRfqView)
+ .where(finalWhere)
- // ─────────────────────────────────────────────────────
- // 4) View에서 데이터 SELECT
- // ─────────────────────────────────────────────────────
- const [rows, total] = await db.transaction(async (tx) => {
- const data = await tx
- .select({
- id: vendorRfqView.vendorId,
- vendorID: vendorRfqView.vendorId,
- vendorName: vendorRfqView.vendorName,
- vendorCode: vendorRfqView.vendorCode,
- address: vendorRfqView.address,
- country: vendorRfqView.country,
- email: vendorRfqView.email,
- website: vendorRfqView.website,
- vendorStatus: vendorRfqView.vendorStatus,
- // rfqVendorStatus와 rfqVendorUpdated는 나중에 정확한 데이터로 교체할 예정
- rfqVendorStatus: vendorRfqView.rfqVendorStatus,
- rfqVendorUpdated: vendorRfqView.rfqVendorUpdated,
- })
- .from(vendorRfqView)
- .where(finalWhere)
- .orderBy(...orderBy)
- .offset(offset)
- .limit(limit)
+ return [distinctData, Number(count)]
+ })
- // 중복 제거된 데이터 생성
- const distinctData = Array.from(
- new Map(data.map(row => [row.id, row])).values()
- )
- // 중복 제거된 총 개수 계산
- const [{ count }] = await tx
- .select({ count: sql<number>`count(DISTINCT ${vendorRfqView.vendorId})`.as("count") })
- .from(vendorRfqView)
- .where(finalWhere)
+ // ─────────────────────────────────────────────────────
+ // 4-1) 정확한 rfqVendorStatus와 rfqVendorUpdated 조회
+ // ─────────────────────────────────────────────────────
+ const distinctVendorIds = [...new Set(rows.map((r) => r.id))]
- return [distinctData, Number(count)]
+ // vendorResponses 테이블에서 정확한 상태와 업데이트 시간 조회
+ const vendorStatuses = await db
+ .select({
+ vendorId: vendorResponses.vendorId,
+ status: vendorResponses.responseStatus,
+ updatedAt: vendorResponses.updatedAt
})
-
-
- // ─────────────────────────────────────────────────────
- // 4-1) 정확한 rfqVendorStatus와 rfqVendorUpdated 조회
- // ─────────────────────────────────────────────────────
- const distinctVendorIds = [...new Set(rows.map((r) => r.id))]
-
- // vendorResponses 테이블에서 정확한 상태와 업데이트 시간 조회
- const vendorStatuses = await db
- .select({
- vendorId: vendorResponses.vendorId,
- status: vendorResponses.responseStatus,
- updatedAt: vendorResponses.updatedAt
- })
- .from(vendorResponses)
- .where(
- and(
- inArray(vendorResponses.vendorId, distinctVendorIds),
- eq(vendorResponses.rfqId, rfqId)
- )
+ .from(vendorResponses)
+ .where(
+ and(
+ inArray(vendorResponses.vendorId, distinctVendorIds),
+ eq(vendorResponses.rfqId, rfqId)
)
+ )
- // vendorId별 상태정보 맵 생성
- const statusMap = new Map<number, { status: string, updatedAt: Date }>()
- for (const vs of vendorStatuses) {
- statusMap.set(vs.vendorId, {
- status: vs.status,
- updatedAt: vs.updatedAt
- })
- }
+ // vendorId별 상태정보 맵 생성
+ const statusMap = new Map<number, { status: string, updatedAt: Date }>()
+ for (const vs of vendorStatuses) {
+ statusMap.set(vs.vendorId, {
+ status: vs.status,
+ updatedAt: vs.updatedAt
+ })
+ }
- // 정확한 상태 정보로 업데이트된 rows 생성
- const updatedRows = rows.map(row => ({
- ...row,
- rfqVendorStatus: statusMap.get(row.id)?.status || null,
- rfqVendorUpdated: statusMap.get(row.id)?.updatedAt || null
- }))
-
- // ─────────────────────────────────────────────────────
- // 5) 코멘트 조회: 기존과 동일
- // ─────────────────────────────────────────────────────
- console.log("distinctVendorIds", distinctVendorIds)
- const commAll = await db
- .select()
- .from(rfqComments)
- .where(
- and(
- inArray(rfqComments.vendorId, distinctVendorIds),
- eq(rfqComments.rfqId, rfqId),
- isNull(rfqComments.evaluationId),
- isNull(rfqComments.cbeId)
- )
+ // 정확한 상태 정보로 업데이트된 rows 생성
+ const updatedRows = rows.map(row => ({
+ ...row,
+ rfqVendorStatus: statusMap.get(row.id)?.status || null,
+ rfqVendorUpdated: statusMap.get(row.id)?.updatedAt || null
+ }))
+
+ // ─────────────────────────────────────────────────────
+ // 5) 코멘트 조회: 기존과 동일
+ // ─────────────────────────────────────────────────────
+ console.log("distinctVendorIds", distinctVendorIds)
+ const commAll = await db
+ .select()
+ .from(rfqComments)
+ .where(
+ and(
+ inArray(rfqComments.vendorId, distinctVendorIds),
+ eq(rfqComments.rfqId, rfqId),
+ isNull(rfqComments.evaluationId),
+ isNull(rfqComments.cbeId)
)
+ )
- const commByVendorId = new Map<number, any[]>()
- // 먼저 모든 사용자 ID를 수집
- const userIds = new Set(commAll.map(c => c.commentedBy));
- const userIdsArray = Array.from(userIds);
-
- // Drizzle의 select 메서드를 사용하여 사용자 정보를 가져옴
- const usersData = await db
- .select({
- id: users.id,
- email: users.email,
- })
- .from(users)
- .where(inArray(users.id, userIdsArray));
-
- // 사용자 ID를 키로 하는 맵 생성
- const userMap = new Map();
- for (const user of usersData) {
- userMap.set(user.id, user);
- }
-
- // 댓글 정보를 협력업체 ID별로 그룹화하고, 사용자 이메일 추가
- for (const c of commAll) {
- const vid = c.vendorId!
- if (!commByVendorId.has(vid)) {
- commByVendorId.set(vid, [])
- }
+ const commByVendorId = new Map<number, any[]>()
+ // 먼저 모든 사용자 ID를 수집
+ const userIds = new Set(commAll.map(c => c.commentedBy));
+ const userIdsArray = Array.from(userIds);
- // 사용자 정보 가져오기
- const user = userMap.get(c.commentedBy);
- const userEmail = user ? user.email : 'unknown@example.com'; // 사용자를 찾지 못한 경우 기본값 설정
+ // Drizzle의 select 메서드를 사용하여 사용자 정보를 가져옴
+ const usersData = await db
+ .select({
+ id: users.id,
+ email: users.email,
+ })
+ .from(users)
+ .where(inArray(users.id, userIdsArray));
- commByVendorId.get(vid)!.push({
- id: c.id,
- commentText: c.commentText,
- vendorId: c.vendorId,
- evaluationId: c.evaluationId,
- createdAt: c.createdAt,
- commentedBy: c.commentedBy,
- commentedByEmail: userEmail, // 이메일 추가
- })
- }
- // ─────────────────────────────────────────────────────
- // 6) rows에 comments 병합
- // ─────────────────────────────────────────────────────
- const final = updatedRows.map((row) => ({
- ...row,
- comments: commByVendorId.get(row.id) ?? [],
- }))
+ // 사용자 ID를 키로 하는 맵 생성
+ const userMap = new Map();
+ for (const user of usersData) {
+ userMap.set(user.id, user);
+ }
- // ─────────────────────────────────────────────────────
- // 7) 반환
- // ─────────────────────────────────────────────────────
- const pageCount = Math.ceil(total / limit)
- return { data: final, pageCount }
- },
- [JSON.stringify({ input, rfqId })],
- { revalidate: 3600, tags: ["vendors", `rfq-${rfqId}`] }
- )()
+ // 댓글 정보를 협력업체 ID별로 그룹화하고, 사용자 이메일 추가
+ for (const c of commAll) {
+ const vid = c.vendorId!
+ if (!commByVendorId.has(vid)) {
+ commByVendorId.set(vid, [])
+ }
+
+ // 사용자 정보 가져오기
+ const user = userMap.get(c.commentedBy);
+ const userEmail = user ? user.email : 'unknown@example.com'; // 사용자를 찾지 못한 경우 기본값 설정
+
+ commByVendorId.get(vid)!.push({
+ id: c.id,
+ commentText: c.commentText,
+ vendorId: c.vendorId,
+ evaluationId: c.evaluationId,
+ createdAt: c.createdAt,
+ commentedBy: c.commentedBy,
+ commentedByEmail: userEmail, // 이메일 추가
+ })
+ }
+ // ─────────────────────────────────────────────────────
+ // 6) rows에 comments 병합
+ // ─────────────────────────────────────────────────────
+ const final = updatedRows.map((row) => ({
+ ...row,
+ comments: commByVendorId.get(row.id) ?? [],
+ }))
+
+ // ─────────────────────────────────────────────────────
+ // 7) 반환
+ // ─────────────────────────────────────────────────────
+ const pageCount = Math.ceil(total / limit)
+ return { data: final, pageCount }
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
}
export async function inviteVendors(input: InviteVendorsInput) {
- unstable_noStore() // 서버 액션 캐싱 방지
try {
const { rfqId, vendorIds } = input
if (!rfqId || !Array.isArray(vendorIds) || vendorIds.length === 0) {
@@ -840,7 +791,7 @@ export async function inviteVendors(input: InviteVendorsInput) {
})
const { rfqRow, items, vendorRows, attachments } = rfqData
- const loginUrl = `http://${host}/en/partners/rfq`
+ const loginUrl = `http://${host}/en/partners/rfq-tech`
// 이메일 전송 오류를 기록할 배열
const emailErrors = []
@@ -922,22 +873,6 @@ export async function inviteVendors(input: InviteVendorsInput) {
console.log(`Updated RFQ #${rfqId} status to PUBLISHED`)
})
-
- // 캐시 무효화
- revalidateTag("tbe-vendors")
- revalidateTag("all-tbe-vendors")
- revalidateTag("rfq-vendors")
- revalidateTag("cbe-vendors")
- revalidateTag("rfqs")
- revalidateTag(`rfq-${rfqId}`)
- // revalidateTag("rfqs");
- // revalidateTag(`rfq-${rfqId}`);
- // revalidateTag("vendors");
- // revalidateTag("rfq-vendors");
- // vendorIds.forEach(vendorId => {
- // revalidateTag(`vendor-${vendorId}`);
- // });
-
// 이메일 오류가 있었는지 확인
if (emailErrors.length > 0) {
return {
@@ -960,273 +895,268 @@ export async function inviteVendors(input: InviteVendorsInput) {
* TBE용 평가 데이터 목록 조회
*/
export async function getTBE(input: GetTBESchema, rfqId: number) {
- return unstable_cache(
- async () => {
- // 1) 페이징
- const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10)
- const limit = input.perPage ?? 10
-
- // 2) 고급 필터
- const advancedWhere = filterColumns({
- table: vendorTbeView,
- filters: input.filters ?? [],
- joinOperator: input.joinOperator ?? "and",
- })
-
- // 3) 글로벌 검색
- let globalWhere
- if (input.search) {
- const s = `%${input.search}%`
- globalWhere = or(
- sql`${vendorTbeView.vendorName} ILIKE ${s}`,
- sql`${vendorTbeView.vendorCode} ILIKE ${s}`,
- sql`${vendorTbeView.email} ILIKE ${s}`
- )
- }
+ try {
+ // 1) 페이징
+ const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10)
+ const limit = input.perPage ?? 10
+
+ // 2) 고급 필터
+ const advancedWhere = filterColumns({
+ table: vendorTbeView,
+ filters: input.filters ?? [],
+ joinOperator: input.joinOperator ?? "and",
+ })
- // 4) REJECTED 아니거나 NULL
- const notRejected = or(
- ne(vendorTbeView.rfqVendorStatus, "REJECTED"),
- isNull(vendorTbeView.rfqVendorStatus)
+ // 3) 글로벌 검색
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ sql`${vendorTbeView.vendorName} ILIKE ${s}`,
+ sql`${vendorTbeView.vendorCode} ILIKE ${s}`,
+ sql`${vendorTbeView.email} ILIKE ${s}`
)
+ }
- // 5) finalWhere
- const finalWhere = and(
- eq(vendorTbeView.rfqId, rfqId),
- advancedWhere,
- globalWhere
- )
+ // 4) REJECTED 아니거나 NULL
+ const notRejected = or(
+ ne(vendorTbeView.rfqVendorStatus, "REJECTED"),
+ isNull(vendorTbeView.rfqVendorStatus)
+ )
+
+ // 5) finalWhere
+ const finalWhere = and(
+ eq(vendorTbeView.rfqId, rfqId),
+ advancedWhere,
+ globalWhere
+ )
+
+ // 6) 정렬
+ const orderBy = input.sort?.length
+ ? input.sort.map((s) => {
+ const col = (vendorTbeView as any)[s.id]
+ return s.desc ? desc(col) : asc(col)
+ })
+ : [asc(vendorTbeView.vendorId)]
- // 6) 정렬
- const orderBy = input.sort?.length
- ? input.sort.map((s) => {
- const col = (vendorTbeView as any)[s.id]
- return s.desc ? desc(col) : asc(col)
+ // 7) 메인 SELECT
+ const [rows, total] = await db.transaction(async (tx) => {
+ const data = await tx
+ .select({
+ // 원하는 컬럼들
+ id: vendorTbeView.vendorId,
+ tbeId: vendorTbeView.tbeId,
+ vendorId: vendorTbeView.vendorId,
+ vendorName: vendorTbeView.vendorName,
+ vendorCode: vendorTbeView.vendorCode,
+ address: vendorTbeView.address,
+ country: vendorTbeView.country,
+ email: vendorTbeView.email,
+ website: vendorTbeView.website,
+ vendorStatus: vendorTbeView.vendorStatus,
+
+ rfqId: vendorTbeView.rfqId,
+ rfqCode: vendorTbeView.rfqCode,
+ projectCode: vendorTbeView.projectCode,
+ projectName: vendorTbeView.projectName,
+ description: vendorTbeView.description,
+ dueDate: vendorTbeView.dueDate,
+
+ rfqVendorStatus: vendorTbeView.rfqVendorStatus,
+ rfqVendorUpdated: vendorTbeView.rfqVendorUpdated,
+
+ tbeResult: vendorTbeView.tbeResult,
+ tbeNote: vendorTbeView.tbeNote,
+ tbeUpdated: vendorTbeView.tbeUpdated,
+
+ technicalResponseId:vendorTbeView.technicalResponseId,
+ technicalResponseStatus:vendorTbeView.technicalResponseStatus,
+ technicalSummary:vendorTbeView.technicalSummary,
+ technicalNotes:vendorTbeView.technicalNotes,
+ technicalUpdated:vendorTbeView.technicalUpdated,
})
- : [asc(vendorTbeView.vendorId)]
-
- // 7) 메인 SELECT
- const [rows, total] = await db.transaction(async (tx) => {
- const data = await tx
- .select({
- // 원하는 컬럼들
- id: vendorTbeView.vendorId,
- tbeId: vendorTbeView.tbeId,
- vendorId: vendorTbeView.vendorId,
- vendorName: vendorTbeView.vendorName,
- vendorCode: vendorTbeView.vendorCode,
- address: vendorTbeView.address,
- country: vendorTbeView.country,
- email: vendorTbeView.email,
- website: vendorTbeView.website,
- vendorStatus: vendorTbeView.vendorStatus,
-
- rfqId: vendorTbeView.rfqId,
- rfqCode: vendorTbeView.rfqCode,
- projectCode: vendorTbeView.projectCode,
- projectName: vendorTbeView.projectName,
- description: vendorTbeView.description,
- dueDate: vendorTbeView.dueDate,
-
- rfqVendorStatus: vendorTbeView.rfqVendorStatus,
- rfqVendorUpdated: vendorTbeView.rfqVendorUpdated,
-
- tbeResult: vendorTbeView.tbeResult,
- tbeNote: vendorTbeView.tbeNote,
- tbeUpdated: vendorTbeView.tbeUpdated,
+ .from(vendorTbeView)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(limit)
+
+ const [{ count }] = await tx
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(vendorTbeView)
+ .where(finalWhere)
+
+ return [data, Number(count)]
+ })
- technicalResponseId:vendorTbeView.technicalResponseId,
- technicalResponseStatus:vendorTbeView.technicalResponseStatus,
- technicalSummary:vendorTbeView.technicalSummary,
- technicalNotes:vendorTbeView.technicalNotes,
- technicalUpdated:vendorTbeView.technicalUpdated,
- })
- .from(vendorTbeView)
- .where(finalWhere)
- .orderBy(...orderBy)
- .offset(offset)
- .limit(limit)
+ if (!rows.length) {
+ return { data: [], pageCount: 0 }
+ }
- const [{ count }] = await tx
- .select({ count: sql<number>`count(*)`.as("count") })
- .from(vendorTbeView)
- .where(finalWhere)
+ // 8) Comments 조회
+ const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))]
- return [data, Number(count)]
+ const commAll = await db
+ .select({
+ id: rfqComments.id,
+ commentText: rfqComments.commentText,
+ vendorId: rfqComments.vendorId,
+ evaluationId: rfqComments.evaluationId,
+ createdAt: rfqComments.createdAt,
+ commentedBy: rfqComments.commentedBy,
+ evalType: rfqEvaluations.evalType,
})
-
- if (!rows.length) {
- return { data: [], pageCount: 0 }
- }
-
- // 8) Comments 조회
- const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))]
-
- const commAll = await db
- .select({
- id: rfqComments.id,
- commentText: rfqComments.commentText,
- vendorId: rfqComments.vendorId,
- evaluationId: rfqComments.evaluationId,
- createdAt: rfqComments.createdAt,
- commentedBy: rfqComments.commentedBy,
- evalType: rfqEvaluations.evalType,
- })
- .from(rfqComments)
- .innerJoin(
- rfqEvaluations,
- and(
- eq(rfqEvaluations.id, rfqComments.evaluationId),
- eq(rfqEvaluations.evalType, "TBE")
- )
+ .from(rfqComments)
+ .innerJoin(
+ rfqEvaluations,
+ and(
+ eq(rfqEvaluations.id, rfqComments.evaluationId),
+ eq(rfqEvaluations.evalType, "TBE")
)
- .where(
- and(
- isNotNull(rfqComments.evaluationId),
- eq(rfqComments.rfqId, rfqId),
- inArray(rfqComments.vendorId, distinctVendorIds)
- )
+ )
+ .where(
+ and(
+ isNotNull(rfqComments.evaluationId),
+ eq(rfqComments.rfqId, rfqId),
+ inArray(rfqComments.vendorId, distinctVendorIds)
)
+ )
- // 8-A) vendorId -> comments grouping
- const commByVendorId = new Map<number, any[]>()
- for (const c of commAll) {
- const vid = c.vendorId!
- if (!commByVendorId.has(vid)) {
- commByVendorId.set(vid, [])
- }
- commByVendorId.get(vid)!.push({
- id: c.id,
- commentText: c.commentText,
- vendorId: c.vendorId,
- evaluationId: c.evaluationId,
- createdAt: c.createdAt,
- commentedBy: c.commentedBy,
- })
- }
+ // 8-A) vendorId -> comments grouping
+ const commByVendorId = new Map<number, any[]>()
+ for (const c of commAll) {
+ const vid = c.vendorId!
+ if (!commByVendorId.has(vid)) {
+ commByVendorId.set(vid, [])
+ }
+ commByVendorId.get(vid)!.push({
+ id: c.id,
+ commentText: c.commentText,
+ vendorId: c.vendorId,
+ evaluationId: c.evaluationId,
+ createdAt: c.createdAt,
+ commentedBy: c.commentedBy,
+ })
+ }
- // 9) TBE 파일 조회 - vendorResponseAttachments로 대체
- // Step 1: Get vendorResponses for the rfqId and vendorIds
- const responsesAll = await db
- .select({
- id: vendorResponses.id,
- vendorId: vendorResponses.vendorId
- })
- .from(vendorResponses)
- .where(
- and(
- eq(vendorResponses.rfqId, rfqId),
- inArray(vendorResponses.vendorId, distinctVendorIds)
- )
- );
+ // 9) TBE 파일 조회 - vendorResponseAttachments로 대체
+ // Step 1: Get vendorResponses for the rfqId and vendorIds
+ const responsesAll = await db
+ .select({
+ id: vendorResponses.id,
+ vendorId: vendorResponses.vendorId
+ })
+ .from(vendorResponses)
+ .where(
+ and(
+ eq(vendorResponses.rfqId, rfqId),
+ inArray(vendorResponses.vendorId, distinctVendorIds)
+ )
+ );
- // Group responses by vendorId for later lookup
- const responsesByVendorId = new Map<number, number[]>();
- for (const resp of responsesAll) {
- if (!responsesByVendorId.has(resp.vendorId)) {
- responsesByVendorId.set(resp.vendorId, []);
- }
- responsesByVendorId.get(resp.vendorId)!.push(resp.id);
+ // Group responses by vendorId for later lookup
+ const responsesByVendorId = new Map<number, number[]>();
+ for (const resp of responsesAll) {
+ if (!responsesByVendorId.has(resp.vendorId)) {
+ responsesByVendorId.set(resp.vendorId, []);
}
+ responsesByVendorId.get(resp.vendorId)!.push(resp.id);
+ }
- // Step 2: Get all responseIds
- const allResponseIds = responsesAll.map(r => r.id);
+ // Step 2: Get all responseIds
+ const allResponseIds = responsesAll.map(r => r.id);
- // Step 3: Get technicalResponses for these responseIds
- const technicalResponsesAll = await db
- .select({
- id: vendorTechnicalResponses.id,
- responseId: vendorTechnicalResponses.responseId
- })
- .from(vendorTechnicalResponses)
- .where(inArray(vendorTechnicalResponses.responseId, allResponseIds));
+ // Step 3: Get technicalResponses for these responseIds
+ const technicalResponsesAll = await db
+ .select({
+ id: vendorTechnicalResponses.id,
+ responseId: vendorTechnicalResponses.responseId
+ })
+ .from(vendorTechnicalResponses)
+ .where(inArray(vendorTechnicalResponses.responseId, allResponseIds));
- // Create mapping from responseId to technicalResponseIds
- const technicalResponseIdsByResponseId = new Map<number, number[]>();
- for (const tr of technicalResponsesAll) {
- if (!technicalResponseIdsByResponseId.has(tr.responseId)) {
- technicalResponseIdsByResponseId.set(tr.responseId, []);
- }
- technicalResponseIdsByResponseId.get(tr.responseId)!.push(tr.id);
+ // Create mapping from responseId to technicalResponseIds
+ const technicalResponseIdsByResponseId = new Map<number, number[]>();
+ for (const tr of technicalResponsesAll) {
+ if (!technicalResponseIdsByResponseId.has(tr.responseId)) {
+ technicalResponseIdsByResponseId.set(tr.responseId, []);
}
+ technicalResponseIdsByResponseId.get(tr.responseId)!.push(tr.id);
+ }
- // Step 4: Get all technicalResponseIds
- const allTechnicalResponseIds = technicalResponsesAll.map(tr => tr.id);
+ // Step 4: Get all technicalResponseIds
+ const allTechnicalResponseIds = technicalResponsesAll.map(tr => tr.id);
- // Step 5: Get attachments for these technicalResponseIds
- const filesAll = await db
- .select({
- id: vendorResponseAttachments.id,
- fileName: vendorResponseAttachments.fileName,
- filePath: vendorResponseAttachments.filePath,
- technicalResponseId: vendorResponseAttachments.technicalResponseId,
- fileType: vendorResponseAttachments.fileType,
- attachmentType: vendorResponseAttachments.attachmentType,
- description: vendorResponseAttachments.description,
- uploadedAt: vendorResponseAttachments.uploadedAt,
- uploadedBy: vendorResponseAttachments.uploadedBy
- })
- .from(vendorResponseAttachments)
- .where(
- and(
- inArray(vendorResponseAttachments.technicalResponseId, allTechnicalResponseIds),
- isNotNull(vendorResponseAttachments.technicalResponseId)
- )
- );
-
- // Step 6: Create mapping from technicalResponseId to attachments
- const filesByTechnicalResponseId = new Map<number, any[]>();
- for (const file of filesAll) {
- // Skip if technicalResponseId is null (should never happen due to our filter above)
- if (file.technicalResponseId === null) continue;
+ // Step 5: Get attachments for these technicalResponseIds
+ const filesAll = await db
+ .select({
+ id: vendorResponseAttachments.id,
+ fileName: vendorResponseAttachments.fileName,
+ filePath: vendorResponseAttachments.filePath,
+ technicalResponseId: vendorResponseAttachments.technicalResponseId,
+ fileType: vendorResponseAttachments.fileType,
+ attachmentType: vendorResponseAttachments.attachmentType,
+ description: vendorResponseAttachments.description,
+ uploadedAt: vendorResponseAttachments.uploadedAt,
+ uploadedBy: vendorResponseAttachments.uploadedBy
+ })
+ .from(vendorResponseAttachments)
+ .where(
+ and(
+ inArray(vendorResponseAttachments.technicalResponseId, allTechnicalResponseIds),
+ isNotNull(vendorResponseAttachments.technicalResponseId)
+ )
+ );
- if (!filesByTechnicalResponseId.has(file.technicalResponseId)) {
- filesByTechnicalResponseId.set(file.technicalResponseId, []);
- }
- filesByTechnicalResponseId.get(file.technicalResponseId)!.push({
- id: file.id,
- fileName: file.fileName,
- filePath: file.filePath,
- fileType: file.fileType,
- attachmentType: file.attachmentType,
- description: file.description,
- uploadedAt: file.uploadedAt,
- uploadedBy: file.uploadedBy
- });
- }
+ // Step 6: Create mapping from technicalResponseId to attachments
+ const filesByTechnicalResponseId = new Map<number, any[]>();
+ for (const file of filesAll) {
+ // Skip if technicalResponseId is null (should never happen due to our filter above)
+ if (file.technicalResponseId === null) continue;
+
+ if (!filesByTechnicalResponseId.has(file.technicalResponseId)) {
+ filesByTechnicalResponseId.set(file.technicalResponseId, []);
+ }
+ filesByTechnicalResponseId.get(file.technicalResponseId)!.push({
+ id: file.id,
+ fileName: file.fileName,
+ filePath: file.filePath,
+ fileType: file.fileType,
+ attachmentType: file.attachmentType,
+ description: file.description,
+ uploadedAt: file.uploadedAt,
+ uploadedBy: file.uploadedBy
+ });
+ }
- // Step 7: Create the final filesByVendorId map
- const filesByVendorId = new Map<number, any[]>();
- for (const [vendorId, responseIds] of responsesByVendorId.entries()) {
- filesByVendorId.set(vendorId, []);
+ // Step 7: Create the final filesByVendorId map
+ const filesByVendorId = new Map<number, any[]>();
+ for (const [vendorId, responseIds] of responsesByVendorId.entries()) {
+ filesByVendorId.set(vendorId, []);
- for (const responseId of responseIds) {
- const technicalResponseIds = technicalResponseIdsByResponseId.get(responseId) || [];
+ for (const responseId of responseIds) {
+ const technicalResponseIds = technicalResponseIdsByResponseId.get(responseId) || [];
- for (const technicalResponseId of technicalResponseIds) {
- const files = filesByTechnicalResponseId.get(technicalResponseId) || [];
- filesByVendorId.get(vendorId)!.push(...files);
- }
+ for (const technicalResponseId of technicalResponseIds) {
+ const files = filesByTechnicalResponseId.get(technicalResponseId) || [];
+ filesByVendorId.get(vendorId)!.push(...files);
}
}
+ }
- // 10) 최종 합치기
- const final = rows.map((row) => ({
- ...row,
- dueDate: row.dueDate ? new Date(row.dueDate) : null,
- comments: commByVendorId.get(row.vendorId) ?? [],
- files: filesByVendorId.get(row.vendorId) ?? [],
- }))
+ // 10) 최종 합치기
+ const final = rows.map((row) => ({
+ ...row,
+ dueDate: row.dueDate ? new Date(row.dueDate) : null,
+ comments: commByVendorId.get(row.vendorId) ?? [],
+ files: filesByVendorId.get(row.vendorId) ?? [],
+ }))
- const pageCount = Math.ceil(total / limit)
- return { data: final, pageCount }
- },
- [JSON.stringify({ input, rfqId })],
- {
- revalidate: 3600,
- tags: ["tbe", `rfq-${rfqId}`],
- }
- )()
+ const pageCount = Math.ceil(total / limit)
+ return { data: final, pageCount }
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
}
export async function getTBEforVendor(input: GetTBESchema, vendorId: number) {
@@ -1235,246 +1165,239 @@ export async function getTBEforVendor(input: GetTBESchema, vendorId: number) {
throw new Error("유효하지 않은 vendorId: 숫자 값이 필요합니다");
}
- return unstable_cache(
- async () => {
- // 1) 페이징
- const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10)
- const limit = input.perPage ?? 10
-
- // 2) 고급 필터
- const advancedWhere = filterColumns({
- table: vendorTbeView,
- filters: input.filters ?? [],
- joinOperator: input.joinOperator ?? "and",
- })
-
- // 3) 글로벌 검색
- let globalWhere
- if (input.search) {
- const s = `%${input.search}%`
- globalWhere = or(
- sql`${vendorTbeView.vendorName} ILIKE ${s}`,
- sql`${vendorTbeView.vendorCode} ILIKE ${s}`,
- sql`${vendorTbeView.email} ILIKE ${s}`
- )
- }
+ try {
+ // 1) 페이징
+ const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10)
+ const limit = input.perPage ?? 10
+
+ // 2) 고급 필터
+ const advancedWhere = filterColumns({
+ table: vendorTbeView,
+ filters: input.filters ?? [],
+ joinOperator: input.joinOperator ?? "and",
+ })
- // 4) REJECTED 아니거나 NULL
- const notRejected = or(
- ne(vendorTbeView.rfqVendorStatus, "REJECTED"),
- isNull(vendorTbeView.rfqVendorStatus)
+ // 3) 글로벌 검색
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ sql`${vendorTbeView.vendorName} ILIKE ${s}`,
+ sql`${vendorTbeView.vendorCode} ILIKE ${s}`,
+ sql`${vendorTbeView.email} ILIKE ${s}`
)
+ }
- // 5) finalWhere
- const finalWhere = and(
- isNotNull(vendorTbeView.tbeId),
- eq(vendorTbeView.vendorId, vendorId),
- // notRejected,
- advancedWhere,
- globalWhere
- )
+ // 4) REJECTED 아니거나 NULL
+ const notRejected = or(
+ ne(vendorTbeView.rfqVendorStatus, "REJECTED"),
+ isNull(vendorTbeView.rfqVendorStatus)
+ )
+
+ // 5) finalWhere
+ const finalWhere = and(
+ isNotNull(vendorTbeView.tbeId),
+ eq(vendorTbeView.vendorId, vendorId),
+ // notRejected,
+ advancedWhere,
+ globalWhere
+ )
+
+ // 6) 정렬
+ const orderBy = input.sort?.length
+ ? input.sort.map((s) => {
+ const col = (vendorTbeView as any)[s.id]
+ return s.desc ? desc(col) : asc(col)
+ })
+ : [asc(vendorTbeView.vendorId)]
- // 6) 정렬
- const orderBy = input.sort?.length
- ? input.sort.map((s) => {
- const col = (vendorTbeView as any)[s.id]
- return s.desc ? desc(col) : asc(col)
+ // 7) 메인 SELECT - vendor 기준으로 GROUP BY
+ const [rows, total] = await db.transaction(async (tx) => {
+ const data = await tx
+ .select({
+ // 원하는 컬럼들
+ id: vendorTbeView.vendorId,
+ tbeId: vendorTbeView.tbeId,
+ vendorId: vendorTbeView.vendorId,
+ vendorName: vendorTbeView.vendorName,
+ vendorCode: vendorTbeView.vendorCode,
+ address: vendorTbeView.address,
+ country: vendorTbeView.country,
+ email: vendorTbeView.email,
+ website: vendorTbeView.website,
+ vendorStatus: vendorTbeView.vendorStatus,
+
+ rfqId: vendorTbeView.rfqId,
+ rfqCode: vendorTbeView.rfqCode,
+
+ rfqStatus:vendorTbeView.rfqStatus,
+ rfqDescription: vendorTbeView.description,
+ rfqDueDate: vendorTbeView.dueDate,
+
+ projectCode: vendorTbeView.projectCode,
+ projectName: vendorTbeView.projectName,
+ description: vendorTbeView.description,
+ dueDate: vendorTbeView.dueDate,
+
+ vendorResponseId: vendorTbeView.vendorResponseId,
+ rfqVendorStatus: vendorTbeView.rfqVendorStatus,
+ rfqVendorUpdated: vendorTbeView.rfqVendorUpdated,
+
+ tbeResult: vendorTbeView.tbeResult,
+ tbeNote: vendorTbeView.tbeNote,
+ tbeUpdated: vendorTbeView.tbeUpdated,
})
- : [asc(vendorTbeView.vendorId)]
-
- // 7) 메인 SELECT - vendor 기준으로 GROUP BY
- const [rows, total] = await db.transaction(async (tx) => {
- const data = await tx
- .select({
- // 원하는 컬럼들
- id: vendorTbeView.vendorId,
- tbeId: vendorTbeView.tbeId,
- vendorId: vendorTbeView.vendorId,
- vendorName: vendorTbeView.vendorName,
- vendorCode: vendorTbeView.vendorCode,
- address: vendorTbeView.address,
- country: vendorTbeView.country,
- email: vendorTbeView.email,
- website: vendorTbeView.website,
- vendorStatus: vendorTbeView.vendorStatus,
-
- rfqId: vendorTbeView.rfqId,
- rfqCode: vendorTbeView.rfqCode,
-
- rfqStatus:vendorTbeView.rfqStatus,
- rfqDescription: vendorTbeView.description,
- rfqDueDate: vendorTbeView.dueDate,
-
- projectCode: vendorTbeView.projectCode,
- projectName: vendorTbeView.projectName,
- description: vendorTbeView.description,
- dueDate: vendorTbeView.dueDate,
-
- vendorResponseId: vendorTbeView.vendorResponseId,
- rfqVendorStatus: vendorTbeView.rfqVendorStatus,
- rfqVendorUpdated: vendorTbeView.rfqVendorUpdated,
+ .from(vendorTbeView)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(limit)
+
+ const [{ count }] = await tx
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(vendorTbeView)
+ .where(finalWhere)
+
+ return [data, Number(count)]
+ })
- tbeResult: vendorTbeView.tbeResult,
- tbeNote: vendorTbeView.tbeNote,
- tbeUpdated: vendorTbeView.tbeUpdated,
- })
- .from(vendorTbeView)
- .where(finalWhere)
- .orderBy(...orderBy)
- .offset(offset)
- .limit(limit)
+ if (!rows.length) {
+ return { data: [], pageCount: 0 }
+ }
- const [{ count }] = await tx
- .select({ count: sql<number>`count(*)`.as("count") })
- .from(vendorTbeView)
- .where(finalWhere)
+ // 8) Comments 조회
+ // - evaluationId != null && evalType = "TBE"
+ // - => leftJoin(rfqEvaluations) or innerJoin
+ const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))]
+ const distinctTbeIds = [...new Set(rows.map((r) => r.tbeId).filter(Boolean))]
- return [data, Number(count)]
+ // (A) 조인 방식
+ const commAll = await db
+ .select({
+ id: rfqComments.id,
+ commentText: rfqComments.commentText,
+ vendorId: rfqComments.vendorId,
+ evaluationId: rfqComments.evaluationId,
+ createdAt: rfqComments.createdAt,
+ commentedBy: rfqComments.commentedBy,
+ evalType: rfqEvaluations.evalType, // (optional)
})
-
- if (!rows.length) {
- return { data: [], pageCount: 0 }
- }
-
- // 8) Comments 조회
- // - evaluationId != null && evalType = "TBE"
- // - => leftJoin(rfqEvaluations) or innerJoin
- const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))]
- const distinctTbeIds = [...new Set(rows.map((r) => r.tbeId).filter(Boolean))]
-
- // (A) 조인 방식
- const commAll = await db
- .select({
- id: rfqComments.id,
- commentText: rfqComments.commentText,
- vendorId: rfqComments.vendorId,
- evaluationId: rfqComments.evaluationId,
- createdAt: rfqComments.createdAt,
- commentedBy: rfqComments.commentedBy,
- evalType: rfqEvaluations.evalType, // (optional)
- })
- .from(rfqComments)
- // evalType = 'TBE'
- .innerJoin(
- rfqEvaluations,
- and(
- eq(rfqEvaluations.id, rfqComments.evaluationId),
- eq(rfqEvaluations.evalType, "TBE") // ★ TBE만
- )
+ .from(rfqComments)
+ // evalType = 'TBE'
+ .innerJoin(
+ rfqEvaluations,
+ and(
+ eq(rfqEvaluations.id, rfqComments.evaluationId),
+ eq(rfqEvaluations.evalType, "TBE") // ★ TBE만
)
- .where(
- and(
- isNotNull(rfqComments.evaluationId),
- inArray(rfqComments.vendorId, distinctVendorIds)
- )
+ )
+ .where(
+ and(
+ isNotNull(rfqComments.evaluationId),
+ inArray(rfqComments.vendorId, distinctVendorIds)
)
+ )
- // 8-A) vendorId -> comments grouping
- const commByVendorId = new Map<number, any[]>()
- for (const c of commAll) {
- const vid = c.vendorId!
- if (!commByVendorId.has(vid)) {
- commByVendorId.set(vid, [])
- }
- commByVendorId.get(vid)!.push({
- id: c.id,
- commentText: c.commentText,
- vendorId: c.vendorId,
- evaluationId: c.evaluationId,
- createdAt: c.createdAt,
- commentedBy: c.commentedBy,
- })
- }
+ // 8-A) vendorId -> comments grouping
+ const commByVendorId = new Map<number, any[]>()
+ for (const c of commAll) {
+ const vid = c.vendorId!
+ if (!commByVendorId.has(vid)) {
+ commByVendorId.set(vid, [])
+ }
+ commByVendorId.get(vid)!.push({
+ id: c.id,
+ commentText: c.commentText,
+ vendorId: c.vendorId,
+ evaluationId: c.evaluationId,
+ createdAt: c.createdAt,
+ commentedBy: c.commentedBy,
+ })
+ }
- // 9) TBE 템플릿 파일 수 조회
- const templateFiles = await db
- .select({
- tbeId: rfqAttachments.evaluationId,
- fileCount: sql<number>`count(*)`.as("file_count"),
- })
- .from(rfqAttachments)
- .where(
- and(
- inArray(rfqAttachments.evaluationId, distinctTbeIds),
- isNull(rfqAttachments.vendorId),
- isNull(rfqAttachments.commentId)
- )
+ // 9) TBE 템플릿 파일 수 조회
+ const templateFiles = await db
+ .select({
+ tbeId: rfqAttachments.evaluationId,
+ fileCount: sql<number>`count(*)`.as("file_count"),
+ })
+ .from(rfqAttachments)
+ .where(
+ and(
+ inArray(rfqAttachments.evaluationId, distinctTbeIds),
+ isNull(rfqAttachments.vendorId),
+ isNull(rfqAttachments.commentId)
)
- .groupBy(rfqAttachments.evaluationId)
+ )
+ .groupBy(rfqAttachments.evaluationId)
- // tbeId -> fileCount 매핑 - null 체크 추가
- const templateFileCountMap = new Map<number, number>()
- for (const tf of templateFiles) {
- if (tf.tbeId !== null) {
- templateFileCountMap.set(tf.tbeId, Number(tf.fileCount))
- }
+ // tbeId -> fileCount 매핑 - null 체크 추가
+ const templateFileCountMap = new Map<number, number>()
+ for (const tf of templateFiles) {
+ if (tf.tbeId !== null) {
+ templateFileCountMap.set(tf.tbeId, Number(tf.fileCount))
}
+ }
- // 10) TBE 응답 파일 확인 (각 tbeId + vendorId 조합에 대해)
- const tbeResponseFiles = await db
- .select({
- tbeId: rfqAttachments.evaluationId,
- vendorId: rfqAttachments.vendorId,
- responseFileCount: sql<number>`count(*)`.as("response_file_count"),
- })
- .from(rfqAttachments)
- .where(
- and(
- inArray(rfqAttachments.evaluationId, distinctTbeIds),
- inArray(rfqAttachments.vendorId, distinctVendorIds),
- isNull(rfqAttachments.commentId)
- )
+ // 10) TBE 응답 파일 확인 (각 tbeId + vendorId 조합에 대해)
+ const tbeResponseFiles = await db
+ .select({
+ tbeId: rfqAttachments.evaluationId,
+ vendorId: rfqAttachments.vendorId,
+ responseFileCount: sql<number>`count(*)`.as("response_file_count"),
+ })
+ .from(rfqAttachments)
+ .where(
+ and(
+ inArray(rfqAttachments.evaluationId, distinctTbeIds),
+ inArray(rfqAttachments.vendorId, distinctVendorIds),
+ isNull(rfqAttachments.commentId)
)
- .groupBy(rfqAttachments.evaluationId, rfqAttachments.vendorId)
+ )
+ .groupBy(rfqAttachments.evaluationId, rfqAttachments.vendorId)
- // 10-A) TBE 제출 파일 상세 정보 조회 (vendor별로 그룹화)
-
+ // 10-A) TBE 제출 파일 상세 정보 조회 (vendor별로 그룹화)
+
- // tbeId_vendorId -> hasResponse 매핑 - null 체크 추가
- const tbeResponseMap = new Map<string, number>()
- for (const rf of tbeResponseFiles) {
- if (rf.tbeId !== null && rf.vendorId !== null) {
- const key = `${rf.tbeId}_${rf.vendorId}`
- tbeResponseMap.set(key, Number(rf.responseFileCount))
- }
+ // tbeId_vendorId -> hasResponse 매핑 - null 체크 추가
+ const tbeResponseMap = new Map<string, number>()
+ for (const rf of tbeResponseFiles) {
+ if (rf.tbeId !== null && rf.vendorId !== null) {
+ const key = `${rf.tbeId}_${rf.vendorId}`
+ tbeResponseMap.set(key, Number(rf.responseFileCount))
}
+ }
- // 11) 최종 합치기
- const final = rows.map((row) => {
- const tbeId = row.tbeId
- const vendorId = row.vendorId
+ // 11) 최종 합치기
+ const final = rows.map((row) => {
+ const tbeId = row.tbeId
+ const vendorId = row.vendorId
- // 템플릿 파일 수
- const templateFileCount = tbeId !== null ? templateFileCountMap.get(tbeId) || 0 : 0
+ // 템플릿 파일 수
+ const templateFileCount = tbeId !== null ? templateFileCountMap.get(tbeId) || 0 : 0
- // 응답 파일 여부
- const responseKey = tbeId !== null ? `${tbeId}_${vendorId}` : ""
- const responseFileCount = responseKey ? tbeResponseMap.get(responseKey) || 0 : 0
+ // 응답 파일 여부
+ const responseKey = tbeId !== null ? `${tbeId}_${vendorId}` : ""
+ const responseFileCount = responseKey ? tbeResponseMap.get(responseKey) || 0 : 0
- return {
- ...row,
- dueDate: row.dueDate ? new Date(row.dueDate) : null,
- comments: commByVendorId.get(row.vendorId) ?? [],
- templateFileCount, // 추가: 템플릿 파일 수
- hasResponse: responseFileCount > 0, // 추가: 응답 파일 제출 여부
- }
- })
+ return {
+ ...row,
+ dueDate: row.dueDate ? new Date(row.dueDate) : null,
+ comments: commByVendorId.get(row.vendorId) ?? [],
+ templateFileCount, // 추가: 템플릿 파일 수
+ hasResponse: responseFileCount > 0, // 추가: 응답 파일 제출 여부
+ }
+ })
- const pageCount = Math.ceil(total / limit)
- return { data: final, pageCount }
- },
- [JSON.stringify(input), String(vendorId)], // 캐싱 키에 packagesId 추가
- {
- revalidate: 3600,
- tags: [`tbe-vendor-${vendorId}`],
- }
- )()
+ const pageCount = Math.ceil(total / limit)
+ return { data: final, pageCount }
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
}
export async function inviteTbeVendorsAction(formData: FormData) {
// 캐싱 방지
- unstable_noStore()
-
try {
// 1) FormData에서 기본 필드 추출
const rfqId = Number(formData.get("rfqId"))
@@ -1624,7 +1547,7 @@ export async function inviteTbeVendorsAction(formData: FormData) {
// 5) 각 고유 이메일 주소로 초대 메일 발송
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000'
- const loginUrl = `${baseUrl}/ko/partners/rfq`
+ const loginUrl = `${baseUrl}/ko/partners/rfq-tech`
console.log(`협력업체 ID ${vendor.id}(${vendor.name})에 대해 ${uniqueEmails.length}개의 고유 이메일로 TBE 초대 발송`)
@@ -1670,12 +1593,6 @@ export async function inviteTbeVendorsAction(formData: FormData) {
}
}
- // 6) 캐시 무효화
- revalidateTag("tbe")
- revalidateTag("vendors")
- revalidateTag(`rfq-${rfqId}`)
- revalidateTag("tbe-vendors")
-
})
// 성공
@@ -1689,7 +1606,6 @@ export async function inviteTbeVendorsAction(formData: FormData) {
export async function modifyRfqVendor(input: UpdateRfqVendorSchema) {
- unstable_noStore();
try {
const data = await db.transaction(async (tx) => {
const [res] = await updateRfqVendor(tx, input.id, {
@@ -1698,9 +1614,6 @@ export async function modifyRfqVendor(input: UpdateRfqVendorSchema) {
return res;
});
- revalidateTag("vendors");
- revalidateTag("tbe");
-
return { data: null, error: null };
} catch (err) {
return { data: null, error: getErrorMessage(err) };
@@ -1768,26 +1681,6 @@ export async function createRfqCommentWithAttachments(params: {
})
}
}
-
- revalidateTag("rfq-vendors");
- revalidateTag(`rfq-${rfqId}`);
-
- if (vendorId) {
- revalidateTag(`vendor-${vendorId}`);
- }
-
- if (evaluationId) {
- revalidateTag("tbe");
- revalidateTag(`tbe-vendor-${vendorId}`);
- revalidateTag("all-tbe-vendors");
- }
-
- if (cbeId) {
- revalidateTag("cbe");
- revalidateTag(`cbe-vendor-${vendorId}`);
- revalidateTag("all-cbe-vendors");
- }
-
return { ok: true, commentId: insertedComment.id, createdAt: insertedComment.createdAt }
}
@@ -1834,9 +1727,6 @@ export async function updateRfqComment(params: {
// 해당 id가 없으면 예외
throw new Error("Comment not found or already deleted.")
}
- revalidateTag("rfq-vendors");
- revalidateTag("tbe");
- revalidateTag("cbe");
return { ok: true }
}
@@ -1902,119 +1792,74 @@ export async function getBidProjects(): Promise<Project[]> {
}
}
+export async function getAllVendors(input?: {
+ page?: number;
+ perPage?: number;
+ search?: string;
+ filters?: any[];
+ sort?: { id: string; desc: boolean }[];
+ joinOperator?: "and" | "or";
+}) {
+ try {
+ const page = input?.page ?? 1;
+ const perPage = input?.perPage ?? 50;
+ const offset = (page - 1) * perPage;
+
+ // 고급 필터
+ const advancedWhere = input?.filters ? filterColumns({
+ table: vendors,
+ filters: input.filters,
+ joinOperator: input.joinOperator ?? "and",
+ }) : undefined;
+
+ // 글로벌 검색
+ let globalWhere;
+ if (input?.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(vendors.vendorName, s),
+ ilike(vendors.vendorCode, s),
+ ilike(vendors.email, s),
+ ilike(vendors.country, s),
+ ilike(vendors.phone, s)
+ );
+ }
-// 반환 타입 명시적 정의 - rfqCode가 null일 수 있음을 반영
-export interface BudgetaryRfq {
- id: number;
- rfqCode: string | null; // null 허용으로 변경
- description: string | null;
- projectId: number | null;
- projectCode: string | null;
- projectName: string | null;
-}
+ // 최종 where 조건
+ const finalWhere = and(advancedWhere, globalWhere);
-type GetBudgetaryRfqsResponse =
- | { rfqs: BudgetaryRfq[]; totalCount: number; error?: never }
- | { error: string; rfqs?: never; totalCount: number }
-/**
- * Budgetary 타입의 RFQ 목록을 가져오는 서버 액션
- * Purchase RFQ 생성 시 부모 RFQ로 선택할 수 있도록 함
- * 페이징 및 필터링 기능 포함
- */
-export interface GetBudgetaryRfqsParams {
- search?: string;
- projectId?: number;
- rfqId?: number; // 특정 ID로 단일 RFQ 검색
+ // 정렬
+ const orderBy = input?.sort?.length
+ ? input.sort.map((s) => {
+ const col = (vendors as any)[s.id];
+ return s.desc ? desc(col) : asc(col);
+ })
+ : [asc(vendors.vendorName)]; // 기본 정렬은 vendor 이름
- limit?: number;
- offset?: number;
-}
+ // 데이터 조회
+ const [data, total] = await db.transaction(async (tx) => {
+ const vendorData = await tx
+ .select()
+ .from(vendors)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(perPage);
-export async function getBudgetaryRfqs(params: GetBudgetaryRfqsParams = {}): Promise<GetBudgetaryRfqsResponse> {
- const { search, projectId, rfqId, limit = 50, offset = 0 } = params;
- const cacheKey = `rfqs-query-${JSON.stringify(params)}`;
+ const [{ count }] = await tx
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(vendors)
+ .where(finalWhere);
- return unstable_cache(
- async () => {
- try {
- // 기본 검색 조건 구성
- let baseCondition;
-
+ return [vendorData, Number(count)];
+ });
-
- // 특정 ID로 검색하는 경우
- if (rfqId) {
- baseCondition = and(baseCondition, eq(rfqs.id, rfqId));
- }
-
- let where1;
- // 검색어 조건 추가 (있을 경우)
- if (search && search.trim()) {
- const searchTerm = `%${search.trim()}%`;
- const searchCondition = or(
- ilike(rfqs.rfqCode, searchTerm),
- ilike(rfqs.description, searchTerm),
- ilike(projects.code, searchTerm),
- ilike(projects.name, searchTerm)
- );
- where1 = searchCondition;
- }
-
- let where2;
- // 프로젝트 ID 조건 추가 (있을 경우)
- if (projectId) {
- where2 = eq(rfqs.projectId, projectId);
- }
-
- const finalWhere = and(baseCondition, where1, where2);
-
- // 총 개수 조회
- const [countResult] = await db
- .select({ count: count() })
- .from(rfqs)
- .leftJoin(projects, eq(rfqs.projectId, projects.id))
- .where(finalWhere);
-
- // 실제 데이터 조회
- const resultRfqs = await db
- .select({
- id: rfqs.id,
- rfqCode: rfqs.rfqCode,
- description: rfqs.description,
- projectId: rfqs.projectId,
- projectCode: projects.code,
- projectName: projects.name,
- })
- .from(rfqs)
- .leftJoin(projects, eq(rfqs.projectId, projects.id))
- .where(finalWhere)
- .orderBy(desc(rfqs.createdAt))
- .limit(limit)
- .offset(offset);
-
- return {
- rfqs: resultRfqs,
- totalCount: Number(countResult?.count) || 0
- };
- } catch (error) {
- console.error("Error fetching RFQs:", error);
- return {
- error: "Failed to fetch RFQs",
- totalCount: 0
- };
- }
- },
- [cacheKey],
- {
- revalidate: 60, // 1분 캐시
- tags: ["rfqs-query"],
- }
- )();
-}
-export async function getAllVendors() {
- // Adjust the query as needed (add WHERE, ORDER, etc.)
- const allVendors = await db.select().from(vendors)
- return allVendors
+ const pageCount = Math.ceil(total / perPage);
+ return { data, pageCount, total };
+ } catch (error) {
+ console.error("Error fetching vendors:", error);
+ return { data: [], pageCount: 0, total: 0 };
+ }
}
@@ -2106,9 +1951,6 @@ export async function addItemToVendors(rfqId: number, vendorIds: number[]) {
insertedCount = recordsToInsert.length;
}
- // 5. Revalidate to refresh data
- revalidateTag("rfq-vendors");
-
// 6. Return success with counts
return {
success: true,
@@ -2259,11 +2101,6 @@ export async function uploadTbeResponseFile(formData: FormData) {
uploadedAt: new Date(),
});
- // 경로 재검증 (캐시된 데이터 새로고침)
- // revalidatePath(`/rfq/${rfqId}/tbe`) // 화면 새로고침 방지를 위해 제거
- revalidateTag(`tbe-vendor-${vendorId}`)
- revalidateTag("all-tbe-vendors")
-
return {
success: true,
message: "파일이 성공적으로 업로드되었습니다."
@@ -2384,8 +2221,6 @@ export async function getTbeFilesForVendor(rfqId: number, vendorId: number) {
}
export async function getAllTBE(input: GetTBESchema) {
- return unstable_cache(
- async () => {
// 1) 페이징
const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10)
const limit = input.perPage ?? 10
@@ -2657,19 +2492,10 @@ export async function getAllTBE(input: GetTBESchema) {
const pageCount = Math.ceil(total / limit)
return { data: final, pageCount }
- },
- [JSON.stringify(input)],
- {
- revalidate: 3600,
- tags: ["tbe"],
- }
- )()
}
export async function getCBE(input: GetCBESchema, rfqId: number) {
- return unstable_cache(
- async () => {
// [1] 페이징
const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10);
const limit = input.perPage ?? 10;
@@ -2933,14 +2759,6 @@ export async function getCBE(input: GetCBESchema, rfqId: number) {
pageCount,
total
};
- },
- // 캐싱 키 & 옵션
- [`cbe-vendors-${rfqId}-${JSON.stringify(input)}`],
- {
- revalidate: 3600,
- tags: [`cbe`, `rfq-${rfqId}`],
- }
- )();
}
export async function generateNextRfqCode(): Promise<{ code: string; error?: string }> {
@@ -3017,11 +2835,6 @@ export async function saveTbeResult({
)
)
- // Revalidate the tbe-vendors tag to refresh the data
- revalidateTag("tbe-vendors")
- revalidateTag("all-tbe-vendors")
-
-
return {
success: true,
message: "TBE evaluation updated successfully",
@@ -3286,7 +3099,7 @@ export async function createCbeEvaluation(formData: FormData) {
incoterms: validData.incoterms,
deliverySchedule: validData.deliverySchedule,
notes: validData.notes,
- loginUrl: `http://${host}/en/partners/cbe`
+ loginUrl: `http://${host}/en/partners/cbe-tech`
}
// 각 고유 이메일 주소로 이메일 발송
@@ -3321,11 +3134,6 @@ export async function createCbeEvaluation(formData: FormData) {
failedVendors.push({ id: vendorId, reason: "예기치 않은 오류" })
}
}
-
- // UI 업데이트를 위한 경로 재검증
- revalidatePath(`/rfq/${rfqId}`)
- revalidateTag(`cbe-vendors-${rfqId}`)
- revalidateTag("all-cbe-vendors")
// 결과 반환
if (createdCbeIds.length === 0) {
@@ -3347,8 +3155,6 @@ export async function createCbeEvaluation(formData: FormData) {
}
export async function getCBEbyVendorId(input: GetCBESchema, vendorId: number) {
- return unstable_cache(
- async () => {
// [1] 페이징
const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10);
const limit = input.perPage ?? 10;
@@ -3613,14 +3419,6 @@ export async function getCBEbyVendorId(input: GetCBESchema, vendorId: number) {
pageCount,
total
};
- },
- // 캐싱 키 & 옵션
- [`cbe-vendor-${vendorId}-${JSON.stringify(input)}`],
- {
- revalidate: 3600,
- tags: [`cbe-vendor-${vendorId}`],
- }
- )();
}
export async function fetchCbeFiles(vendorId: number, rfqId: number) {
@@ -3683,8 +3481,6 @@ export async function fetchCbeFiles(vendorId: number, rfqId: number) {
}
export async function getAllCBE(input: GetCBESchema) {
- return unstable_cache(
- async () => {
// [1] 페이징
const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10);
const limit = input.perPage ?? 10;
@@ -3987,12 +3783,4 @@ export async function getAllCBE(input: GetCBESchema) {
pageCount,
total
};
- },
- // 캐싱 키 & 옵션
- [`all-cbe-vendors-${JSON.stringify(input)}`],
- {
- revalidate: 3600,
- tags: ["all-cbe-vendors"],
- }
- )();
} \ No newline at end of file
diff --git a/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table-column.tsx b/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table-column.tsx
index bfcbe75b..f9c5d9df 100644
--- a/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table-column.tsx
+++ b/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table-column.tsx
@@ -14,13 +14,11 @@ export interface DataTableRowAction<TData> {
}
interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorData> | null>>
setSelectedVendorIds: React.Dispatch<React.SetStateAction<number[]>> // Changed to array
}
/** getColumns: return array of ColumnDef for 'vendors' data */
export function getColumns({
- setRowAction,
setSelectedVendorIds, // Changed parameter name
}: GetColumnsProps): ColumnDef<VendorData>[] {
return [
diff --git a/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table.tsx b/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table.tsx
index e34a5052..defbac86 100644
--- a/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table.tsx
+++ b/lib/rfqs-tech/vendor-table/vendor-list/vendor-list-table.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import { ClientDataTable } from "@/components/client-data-table/data-table"
-import { DataTableRowAction, getColumns } from "./vendor-list-table-column"
+import { getColumns } from "./vendor-list-table-column"
import { DataTableAdvancedFilterField } from "@/types/table"
import { addItemToVendors, getAllVendors } from "../../service"
import { Loader2, Plus } from "lucide-react"
@@ -30,27 +30,63 @@ interface VendorsListTableProps {
export function VendorsListTable({ rfqId }: VendorsListTableProps) {
const { toast } = useToast()
- const [rowAction, setRowAction] =
- React.useState<DataTableRowAction<VendorData> | null>(null)
// Changed to array for multiple selection
const [selectedVendorIds, setSelectedVendorIds] = React.useState<number[]>([])
const [isSubmitting, setIsSubmitting] = React.useState(false)
- const columns = React.useMemo(
- () => getColumns({ setRowAction, setSelectedVendorIds }),
- [setRowAction, setSelectedVendorIds]
- )
-
const [vendors, setVendors] = React.useState<VendorData[]>([])
const [isLoading, setIsLoading] = React.useState(false)
+ const columns = React.useMemo(
+ () => getColumns({ setSelectedVendorIds }),
+ [setSelectedVendorIds]
+ )
+
+ // 고급 필터 필드 정의
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = [
+ {
+ id: "vendorName",
+ label: "Vendor Name",
+ type: "text",
+ },
+ {
+ id: "vendorCode",
+ label: "Vendor Code",
+ type: "text",
+ },
+ {
+ id: "status",
+ label: "Status",
+ type: "select",
+ options: [
+ { label: "Active", value: "ACTIVE" },
+ { label: "Inactive", value: "INACTIVE" },
+ { label: "Pending", value: "PENDING" },
+ ],
+ },
+ {
+ id: "country",
+ label: "Country",
+ type: "text",
+ },
+ {
+ id: "email",
+ label: "Email",
+ type: "text",
+ },
+ ]
+
+ // 초기 데이터 로드
React.useEffect(() => {
- async function loadAllVendors() {
+ async function loadVendors() {
setIsLoading(true)
try {
- const allVendors = await getAllVendors()
- setVendors(allVendors)
+ const result = await getAllVendors()
+
+ if (result.data) {
+ setVendors(result.data)
+ }
} catch (error) {
console.error("협력업체 목록 로드 오류:", error)
toast({
@@ -62,11 +98,10 @@ export function VendorsListTable({ rfqId }: VendorsListTableProps) {
setIsLoading(false)
}
}
- loadAllVendors()
+
+ loadVendors()
}, [toast])
- const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = []
-
async function handleAddVendors() {
if (selectedVendorIds.length === 0) return // Safety check
@@ -100,9 +135,9 @@ export function VendorsListTable({ rfqId }: VendorsListTableProps) {
setIsSubmitting(false)
}
}
-
+
// If loading, show a flex container that fills the parent and centers the spinner
- if (isLoading) {
+ if (isLoading && vendors.length === 0) {
return (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -110,7 +145,6 @@ export function VendorsListTable({ rfqId }: VendorsListTableProps) {
)
}
- // Otherwise, show the table
return (
<ClientDataTable
data={vendors}
diff --git a/lib/tech-vendor-rfq-response/service.ts b/lib/tech-vendor-rfq-response/service.ts
index b706d42a..e6b67406 100644
--- a/lib/tech-vendor-rfq-response/service.ts
+++ b/lib/tech-vendor-rfq-response/service.ts
@@ -1,6 +1,6 @@
'use server'
-import { revalidateTag, unstable_cache } from "next/cache";
+import { revalidateTag } from "next/cache";
import db from "@/db/db";
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { rfqAttachments, rfqComments, rfqItems, vendorResponses } from "@/db/schema/rfq";
@@ -10,301 +10,294 @@ import { GetRfqsForVendorsSchema } from "../rfqs-tech/validations";
import { ItemData } from "./vendor-cbe-table/rfq-items-table/rfq-items-table";
import * as z from "zod"
-
-
export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, vendorId: number) {
- return unstable_cache(
- async () => {
- const offset = (input.page - 1) * input.perPage;
- const limit = input.perPage;
-
- // 1) 메인 쿼리: vendorResponsesView 사용
- const { rows, total } = await db.transaction(async (tx) => {
- // 검색 조건
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- sql`${vendorResponsesView.rfqCode} ILIKE ${s}`,
- sql`${vendorResponsesView.projectName} ILIKE ${s}`,
- sql`${vendorResponsesView.rfqDescription} ILIKE ${s}`
- );
- }
-
- // 협력업체 ID 필터링
- const mainWhere = and(eq(vendorResponsesView.vendorId, vendorId), globalWhere);
-
- // 정렬: 응답 시간순
- const orderBy = [desc(vendorResponsesView.respondedAt)];
-
- // (A) 데이터 조회
- const data = await tx
- .select()
- .from(vendorResponsesView)
- .where(mainWhere)
- .orderBy(...orderBy)
- .offset(offset)
- .limit(limit);
-
- // (B) 전체 개수 카운트
- const [{ count }] = await tx
- .select({
- count: sql<number>`count(*)`.as("count"),
- })
- .from(vendorResponsesView)
- .where(mainWhere);
+ try {
+ const offset = (input.page - 1) * input.perPage;
+ const limit = input.perPage;
+
+ // 1) 메인 쿼리: vendorResponsesView 사용
+ const { rows, total } = await db.transaction(async (tx) => {
+ // 검색 조건
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ sql`${vendorResponsesView.rfqCode} ILIKE ${s}`,
+ sql`${vendorResponsesView.projectName} ILIKE ${s}`,
+ sql`${vendorResponsesView.rfqDescription} ILIKE ${s}`
+ );
+ }
- return { rows: data, total: Number(count) };
- });
+ // 협력업체 ID 필터링
+ const mainWhere = and(eq(vendorResponsesView.vendorId, vendorId), globalWhere);
- // 2) rfqId 고유 목록 추출
- const distinctRfqs = [...new Set(rows.map((r) => r.rfqId))];
- if (distinctRfqs.length === 0) {
- return { data: [], pageCount: 0 };
- }
+ // 정렬: 응답 시간순
+ const orderBy = [desc(vendorResponsesView.respondedAt)];
- // 3) 추가 데이터 조회
- // 3-A) RFQ 아이템
- const itemsAll = await db
+ // (A) 데이터 조회
+ const data = await tx
+ .select()
+ .from(vendorResponsesView)
+ .where(mainWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(limit);
+
+ // (B) 전체 개수 카운트
+ const [{ count }] = await tx
.select({
- id: rfqItems.id,
- rfqId: rfqItems.rfqId,
- itemCode: rfqItems.itemCode,
- itemList: sql<string>`COALESCE(${itemOffshoreTop.itemList}, ${itemOffshoreHull.itemList})`.as('itemList'),
- subItemList: sql<string>`COALESCE(${itemOffshoreTop.subItemList}, ${itemOffshoreHull.subItemList})`.as('subItemList'),
- quantity: rfqItems.quantity,
- description: rfqItems.description,
- uom: rfqItems.uom,
+ count: sql<number>`count(*)`.as("count"),
})
- .from(rfqItems)
- .leftJoin(itemOffshoreTop, eq(rfqItems.itemCode, itemOffshoreTop.itemCode))
- .leftJoin(itemOffshoreHull, eq(rfqItems.itemCode, itemOffshoreHull.itemCode))
- .where(inArray(rfqItems.rfqId, distinctRfqs));
+ .from(vendorResponsesView)
+ .where(mainWhere);
- // 3-B) RFQ 첨부 파일 (협력업체용)
- const attachAll = await db
- .select()
- .from(rfqAttachments)
- .where(
- and(
- inArray(rfqAttachments.rfqId, distinctRfqs),
- isNull(rfqAttachments.vendorId)
- )
- );
-
- // 3-C) RFQ 코멘트
- const commAll = await db
- .select()
- .from(rfqComments)
- .where(
- and(
- inArray(rfqComments.rfqId, distinctRfqs),
- or(
- isNull(rfqComments.vendorId),
- eq(rfqComments.vendorId, vendorId)
- )
- )
- );
+ return { rows: data, total: Number(count) };
+ });
-
- // 3-E) 협력업체 응답 상세 - 기술
- const technicalResponsesAll = await db
- .select()
- .from(vendorTechnicalResponses)
- .where(
- inArray(
- vendorTechnicalResponses.responseId,
- rows.map((r) => r.responseId)
- )
- );
+ // 2) rfqId 고유 목록 추출
+ const distinctRfqs = [...new Set(rows.map((r) => r.rfqId))];
+ if (distinctRfqs.length === 0) {
+ return { data: [], pageCount: 0 };
+ }
- // 3-F) 협력업체 응답 상세 - 상업
- const commercialResponsesAll = await db
- .select()
- .from(vendorCommercialResponses)
- .where(
- inArray(
- vendorCommercialResponses.responseId,
- rows.map((r) => r.responseId)
- )
- );
+ // 3) 추가 데이터 조회
+ // 3-A) RFQ 아이템
+ const itemsAll = await db
+ .select({
+ id: rfqItems.id,
+ rfqId: rfqItems.rfqId,
+ itemCode: rfqItems.itemCode,
+ itemList: sql<string>`COALESCE(${itemOffshoreTop.itemList}, ${itemOffshoreHull.itemList})`.as('itemList'),
+ subItemList: sql<string>`COALESCE(${itemOffshoreTop.subItemList}, ${itemOffshoreHull.subItemList})`.as('subItemList'),
+ quantity: rfqItems.quantity,
+ description: rfqItems.description,
+ uom: rfqItems.uom,
+ })
+ .from(rfqItems)
+ .leftJoin(itemOffshoreTop, eq(rfqItems.itemCode, itemOffshoreTop.itemCode))
+ .leftJoin(itemOffshoreHull, eq(rfqItems.itemCode, itemOffshoreHull.itemCode))
+ .where(inArray(rfqItems.rfqId, distinctRfqs));
- // 3-G) 협력업체 응답 첨부 파일
- const responseAttachmentsAll = await db
- .select()
- .from(vendorResponseAttachments)
- .where(
- inArray(
- vendorResponseAttachments.responseId,
- rows.map((r) => r.responseId)
+ // 3-B) RFQ 첨부 파일 (협력업체용)
+ const attachAll = await db
+ .select()
+ .from(rfqAttachments)
+ .where(
+ and(
+ inArray(rfqAttachments.rfqId, distinctRfqs),
+ isNull(rfqAttachments.vendorId)
+ )
+ );
+
+ // 3-C) RFQ 코멘트
+ const commAll = await db
+ .select()
+ .from(rfqComments)
+ .where(
+ and(
+ inArray(rfqComments.rfqId, distinctRfqs),
+ or(
+ isNull(rfqComments.vendorId),
+ eq(rfqComments.vendorId, vendorId)
)
- );
+ )
+ );
- // 4) 데이터 그룹화
- // RFQ 아이템 그룹화
- const itemsByRfqId = new Map<number, any[]>();
- for (const it of itemsAll) {
- if (!itemsByRfqId.has(it.rfqId)) {
- itemsByRfqId.set(it.rfqId, []);
- }
- itemsByRfqId.get(it.rfqId)!.push({
- id: it.id,
- itemCode: it.itemCode,
- itemList: it.itemList,
- subItemList: it.subItemList,
- quantity: it.quantity,
- description: it.description,
- uom: it.uom,
- });
- }
- // RFQ 첨부 파일 그룹화
- const attachByRfqId = new Map<number, any[]>();
- for (const att of attachAll) {
- const rid = att.rfqId!;
- if (!attachByRfqId.has(rid)) {
- attachByRfqId.set(rid, []);
- }
- attachByRfqId.get(rid)!.push({
- id: att.id,
- fileName: att.fileName,
- filePath: att.filePath,
- vendorId: att.vendorId,
- evaluationId: att.evaluationId,
- });
+ // 3-E) 협력업체 응답 상세 - 기술
+ const technicalResponsesAll = await db
+ .select()
+ .from(vendorTechnicalResponses)
+ .where(
+ inArray(
+ vendorTechnicalResponses.responseId,
+ rows.map((r) => r.responseId)
+ )
+ );
+
+ // 3-F) 협력업체 응답 상세 - 상업
+ const commercialResponsesAll = await db
+ .select()
+ .from(vendorCommercialResponses)
+ .where(
+ inArray(
+ vendorCommercialResponses.responseId,
+ rows.map((r) => r.responseId)
+ )
+ );
+
+ // 3-G) 협력업체 응답 첨부 파일
+ const responseAttachmentsAll = await db
+ .select()
+ .from(vendorResponseAttachments)
+ .where(
+ inArray(
+ vendorResponseAttachments.responseId,
+ rows.map((r) => r.responseId)
+ )
+ );
+
+ // 4) 데이터 그룹화
+ // RFQ 아이템 그룹화
+ const itemsByRfqId = new Map<number, any[]>();
+ for (const it of itemsAll) {
+ if (!itemsByRfqId.has(it.rfqId)) {
+ itemsByRfqId.set(it.rfqId, []);
}
+ itemsByRfqId.get(it.rfqId)!.push({
+ id: it.id,
+ itemCode: it.itemCode,
+ itemList: it.itemList,
+ subItemList: it.subItemList,
+ quantity: it.quantity,
+ description: it.description,
+ uom: it.uom,
+ });
+ }
- // RFQ 코멘트 그룹화
- const commByRfqId = new Map<number, any[]>();
- for (const c of commAll) {
- const rid = c.rfqId!;
- if (!commByRfqId.has(rid)) {
- commByRfqId.set(rid, []);
- }
- commByRfqId.get(rid)!.push({
- id: c.id,
- commentText: c.commentText,
- vendorId: c.vendorId,
- evaluationId: c.evaluationId,
- createdAt: c.createdAt,
- });
+ // RFQ 첨부 파일 그룹화
+ const attachByRfqId = new Map<number, any[]>();
+ for (const att of attachAll) {
+ const rid = att.rfqId!;
+ if (!attachByRfqId.has(rid)) {
+ attachByRfqId.set(rid, []);
}
+ attachByRfqId.get(rid)!.push({
+ id: att.id,
+ fileName: att.fileName,
+ filePath: att.filePath,
+ vendorId: att.vendorId,
+ evaluationId: att.evaluationId,
+ });
+ }
-
- // 기술 응답 그룹화
- const techResponseByResponseId = new Map<number, any>();
- for (const tr of technicalResponsesAll) {
- techResponseByResponseId.set(tr.responseId, {
- id: tr.id,
- summary: tr.summary,
- notes: tr.notes,
- createdAt: tr.createdAt,
- updatedAt: tr.updatedAt,
- });
+ // RFQ 코멘트 그룹화
+ const commByRfqId = new Map<number, any[]>();
+ for (const c of commAll) {
+ const rid = c.rfqId!;
+ if (!commByRfqId.has(rid)) {
+ commByRfqId.set(rid, []);
}
+ commByRfqId.get(rid)!.push({
+ id: c.id,
+ commentText: c.commentText,
+ vendorId: c.vendorId,
+ evaluationId: c.evaluationId,
+ createdAt: c.createdAt,
+ });
+ }
- // 상업 응답 그룹화
- const commResponseByResponseId = new Map<number, any>();
- for (const cr of commercialResponsesAll) {
- commResponseByResponseId.set(cr.responseId, {
- id: cr.id,
- totalPrice: cr.totalPrice,
- currency: cr.currency,
- paymentTerms: cr.paymentTerms,
- incoterms: cr.incoterms,
- deliveryPeriod: cr.deliveryPeriod,
- warrantyPeriod: cr.warrantyPeriod,
- validityPeriod: cr.validityPeriod,
- priceBreakdown: cr.priceBreakdown,
- commercialNotes: cr.commercialNotes,
- createdAt: cr.createdAt,
- updatedAt: cr.updatedAt,
- });
- }
- // 응답 첨부 파일 그룹화
- const respAttachByResponseId = new Map<number, any[]>();
- for (const ra of responseAttachmentsAll) {
- const rid = ra.responseId!;
- if (!respAttachByResponseId.has(rid)) {
- respAttachByResponseId.set(rid, []);
- }
- respAttachByResponseId.get(rid)!.push({
- id: ra.id,
- fileName: ra.fileName,
- filePath: ra.filePath,
- attachmentType: ra.attachmentType,
- description: ra.description,
- uploadedAt: ra.uploadedAt,
- uploadedBy: ra.uploadedBy,
- });
- }
+ // 기술 응답 그룹화
+ const techResponseByResponseId = new Map<number, any>();
+ for (const tr of technicalResponsesAll) {
+ techResponseByResponseId.set(tr.responseId, {
+ id: tr.id,
+ summary: tr.summary,
+ notes: tr.notes,
+ createdAt: tr.createdAt,
+ updatedAt: tr.updatedAt,
+ });
+ }
- // 5) 최종 데이터 결합
- const final = rows.map((row) => {
- return {
- // 응답 정보
- responseId: row.responseId,
- responseStatus: row.responseStatus,
- respondedAt: row.respondedAt,
-
- // RFQ 기본 정보
- rfqId: row.rfqId,
- rfqCode: row.rfqCode,
- rfqDescription: row.rfqDescription,
- rfqDueDate: row.rfqDueDate,
- rfqStatus: row.rfqStatus,
-
- rfqCreatedAt: row.rfqCreatedAt,
- rfqUpdatedAt: row.rfqUpdatedAt,
- rfqCreatedBy: row.rfqCreatedBy,
-
- // 프로젝트 정보
- projectId: row.projectId,
- projectCode: row.projectCode,
- projectName: row.projectName,
-
- // 협력업체 정보
- vendorId: row.vendorId,
- vendorName: row.vendorName,
- vendorCode: row.vendorCode,
-
- // RFQ 관련 데이터
- items: itemsByRfqId.get(row.rfqId) || [],
- attachments: attachByRfqId.get(row.rfqId) || [],
- comments: commByRfqId.get(row.rfqId) || [],
-
- // 평가 정보
- tbeEvaluation: row.tbeId ? {
- id: row.tbeId,
- result: row.tbeResult,
- } : null,
- cbeEvaluation: row.cbeId ? {
- id: row.cbeId,
- result: row.cbeResult,
- } : null,
-
- // 협력업체 응답 상세
- technicalResponse: techResponseByResponseId.get(row.responseId) || null,
- commercialResponse: commResponseByResponseId.get(row.responseId) || null,
- responseAttachments: respAttachByResponseId.get(row.responseId) || [],
-
- // 응답 상태 표시
- hasTechnicalResponse: row.hasTechnicalResponse,
- hasCommercialResponse: row.hasCommercialResponse,
- attachmentCount: row.attachmentCount || 0,
- };
+ // 상업 응답 그룹화
+ const commResponseByResponseId = new Map<number, any>();
+ for (const cr of commercialResponsesAll) {
+ commResponseByResponseId.set(cr.responseId, {
+ id: cr.id,
+ totalPrice: cr.totalPrice,
+ currency: cr.currency,
+ paymentTerms: cr.paymentTerms,
+ incoterms: cr.incoterms,
+ deliveryPeriod: cr.deliveryPeriod,
+ warrantyPeriod: cr.warrantyPeriod,
+ validityPeriod: cr.validityPeriod,
+ priceBreakdown: cr.priceBreakdown,
+ commercialNotes: cr.commercialNotes,
+ createdAt: cr.createdAt,
+ updatedAt: cr.updatedAt,
});
+ }
- const pageCount = Math.ceil(total / input.perPage);
- return { data: final, pageCount };
- },
- [JSON.stringify(input), `${vendorId}`],
- {
- revalidate: 600,
- tags: ["rfqs-vendor", `vendor-${vendorId}`],
+ // 응답 첨부 파일 그룹화
+ const respAttachByResponseId = new Map<number, any[]>();
+ for (const ra of responseAttachmentsAll) {
+ const rid = ra.responseId!;
+ if (!respAttachByResponseId.has(rid)) {
+ respAttachByResponseId.set(rid, []);
+ }
+ respAttachByResponseId.get(rid)!.push({
+ id: ra.id,
+ fileName: ra.fileName,
+ filePath: ra.filePath,
+ attachmentType: ra.attachmentType,
+ description: ra.description,
+ uploadedAt: ra.uploadedAt,
+ uploadedBy: ra.uploadedBy,
+ });
}
- )();
+
+ // 5) 최종 데이터 결합
+ const final = rows.map((row) => {
+ return {
+ // 응답 정보
+ responseId: row.responseId,
+ responseStatus: row.responseStatus,
+ respondedAt: row.respondedAt,
+
+ // RFQ 기본 정보
+ rfqId: row.rfqId,
+ rfqCode: row.rfqCode,
+ rfqDescription: row.rfqDescription,
+ rfqDueDate: row.rfqDueDate,
+ rfqStatus: row.rfqStatus,
+
+ rfqCreatedAt: row.rfqCreatedAt,
+ rfqUpdatedAt: row.rfqUpdatedAt,
+ rfqCreatedBy: row.rfqCreatedBy,
+
+ // 프로젝트 정보
+ projectId: row.projectId,
+ projectCode: row.projectCode,
+ projectName: row.projectName,
+
+ // 협력업체 정보
+ vendorId: row.vendorId,
+ vendorName: row.vendorName,
+ vendorCode: row.vendorCode,
+
+ // RFQ 관련 데이터
+ items: itemsByRfqId.get(row.rfqId) || [],
+ attachments: attachByRfqId.get(row.rfqId) || [],
+ comments: commByRfqId.get(row.rfqId) || [],
+
+ // 평가 정보
+ tbeEvaluation: row.tbeId ? {
+ id: row.tbeId,
+ result: row.tbeResult,
+ } : null,
+ cbeEvaluation: row.cbeId ? {
+ id: row.cbeId,
+ result: row.cbeResult,
+ } : null,
+
+ // 협력업체 응답 상세
+ technicalResponse: techResponseByResponseId.get(row.responseId) || null,
+ commercialResponse: commResponseByResponseId.get(row.responseId) || null,
+ responseAttachments: respAttachByResponseId.get(row.responseId) || [],
+
+ // 응답 상태 표시
+ hasTechnicalResponse: row.hasTechnicalResponse,
+ hasCommercialResponse: row.hasCommercialResponse,
+ attachmentCount: row.attachmentCount || 0,
+ };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+ return { data: final, pageCount };
+ } catch (err) {
+ return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
+ }
}
@@ -425,9 +418,7 @@ export async function updateCommercialResponse(input: CommercialResponseInput):
.where(eq(vendorResponses.id, validated.responseId))
}
}
-
- // Use vendorId for revalidateTag
- revalidateTag(`cbe-vendor-${validated.vendorId}`)
+
return {
success: true,
diff --git a/lib/vendor-document-list/dolce-upload-service.ts b/lib/vendor-document-list/dolce-upload-service.ts
new file mode 100644
index 00000000..0396e819
--- /dev/null
+++ b/lib/vendor-document-list/dolce-upload-service.ts
@@ -0,0 +1,655 @@
+// lib/vendor-document-list/dolce-upload-service.ts
+import db from "@/db/db"
+import { documents, revisions, documentAttachments, contracts, projects, vendors, issueStages } from "@/db/schema"
+import { eq, and, desc, sql, inArray, min } from "drizzle-orm"
+import { v4 as uuidv4 } from "uuid"
+
+export interface DOLCEUploadResult {
+ success: boolean
+ uploadedDocuments: number
+ uploadedFiles: number
+ errors?: string[]
+ results?: {
+ documentResults?: any[]
+ fileResults?: any[]
+ mappingResults?: any[]
+ }
+}
+
+interface DOLCEDocument {
+ Mode: "ADD" | "MOD"
+ Status: string
+ RegisterId: number
+ ProjectNo: string
+ Discipline: string
+ DrawingKind: string
+ DrawingNo: string
+ DrawingName: string
+ RegisterGroupId: number
+ RegisterSerialNo: number
+ RegisterKind: string
+ DrawingRevNo: string
+ Category: string
+ Receiver: string | null
+ Manager: string
+ RegisterDesc: string
+ UploadId?: string
+ RegCompanyCode: string
+}
+
+interface DOLCEFileMapping {
+ CGbn?: string
+ Category?: string
+ CheckBox: string
+ DGbn?: string
+ DegreeGbn?: string
+ DeptGbn?: string
+ Discipline: string
+ DrawingKind: string
+ DrawingMoveGbn: string
+ DrawingName: string
+ DrawingNo: string
+ DrawingUsage: string
+ FileNm: string
+ JGbn?: string
+ Manager: string
+ MappingYN: string
+ NewOrNot: string
+ ProjectNo: string
+ RegisterGroup: number
+ RegisterGroupId: number
+ RegisterKindCode: string
+ RegisterSerialNo: number
+ RevNo?: string
+ SGbn?: string
+ UploadId: string
+}
+
+class DOLCEUploadService {
+ private readonly BASE_URL = process.env.DOLCE_API_URL || 'http://60.100.99.217:1111'
+ private readonly UPLOAD_SERVICE_URL = process.env.DOLCE_UPLOAD_URL || 'http://60.100.99.217:1111/PWPUploadService.ashx'
+
+ /**
+ * 메인 업로드 함수: 변경된 문서와 파일을 DOLCE로 업로드
+ */
+ async uploadToDoLCE(
+ contractId: number,
+ revisionIds: number[],
+ userId: string,
+ userName?: string
+ ): Promise<DOLCEUploadResult> {
+ try {
+ console.log(`Starting DOLCE upload for contract ${contractId}, revisions: ${revisionIds.join(', ')}`)
+
+ // 1. 계약 정보 조회 (프로젝트 코드, 벤더 코드 등)
+ const contractInfo = await this.getContractInfo(contractId)
+ if (!contractInfo) {
+ throw new Error(`Contract info not found for ID: ${contractId}`)
+ }
+
+ // 2. 업로드할 리비전 정보 조회
+ const revisionsToUpload = await this.getRevisionsForUpload(revisionIds)
+ if (revisionsToUpload.length === 0) {
+ return {
+ success: true,
+ uploadedDocuments: 0,
+ uploadedFiles: 0
+ }
+ }
+
+ // 3. 각 issueStageId별로 첫 번째 revision 정보를 미리 조회 (Mode 결정용)
+ const firstRevisionMap = await this.getFirstRevisionMap(revisionsToUpload.map(r => r.issueStageId))
+
+ let uploadedDocuments = 0
+ let uploadedFiles = 0
+ const errors: string[] = []
+ const results: any = {
+ documentResults: [],
+ fileResults: [],
+ mappingResults: []
+ }
+
+ // 4. 각 리비전별로 처리
+ for (const revision of revisionsToUpload) {
+ try {
+ console.log(`Processing revision ${revision.revision} for document ${revision.documentNo}`)
+
+ // 4-1. 파일이 있는 경우 먼저 업로드
+ let uploadId: string | undefined
+ if (revision.attachments && revision.attachments.length > 0) {
+ const fileUploadResults = await this.uploadFiles(revision.attachments)
+
+ if (fileUploadResults.length > 0) {
+ uploadId = fileUploadResults[0].uploadId // 첫 번째 파일의 UploadId 사용
+ uploadedFiles += fileUploadResults.length
+ results.fileResults.push(...fileUploadResults)
+ }
+ }
+
+ // 4-2. 문서 정보 업로드
+ const dolceDoc = this.transformToDoLCEDocument(
+ revision,
+ contractInfo,
+ uploadId,
+ contractInfo.vendorCode,
+ firstRevisionMap
+ )
+
+ const docResult = await this.uploadDocument([dolceDoc], userId)
+ if (docResult.success) {
+ uploadedDocuments++
+ results.documentResults.push(docResult)
+
+ // 4-3. 파일이 있는 경우 매핑 정보 전송
+ if (uploadId && revision.attachments && revision.attachments.length > 0) {
+ const mappingData = this.transformToFileMapping(
+ revision,
+ contractInfo,
+ uploadId,
+ revision.attachments[0].fileName
+ )
+
+ const mappingResult = await this.uploadFileMapping([mappingData], userId)
+ if (mappingResult.success) {
+ results.mappingResults.push(mappingResult)
+ } else {
+ errors.push(`File mapping failed for ${revision.documentNo}: ${mappingResult.error}`)
+ }
+ }
+
+ // 4-4. 성공한 리비전의 상태 업데이트
+ await this.updateRevisionStatus(revision.id, 'SUBMITTED', uploadId)
+
+ } else {
+ errors.push(`Document upload failed for ${revision.documentNo}: ${docResult.error}`)
+ }
+
+ } catch (error) {
+ const errorMessage = `Failed to process revision ${revision.revision}: ${error instanceof Error ? error.message : 'Unknown error'}`
+ errors.push(errorMessage)
+ console.error(errorMessage, error)
+ }
+ }
+
+ return {
+ success: errors.length === 0,
+ uploadedDocuments,
+ uploadedFiles,
+ errors: errors.length > 0 ? errors : undefined,
+ results
+ }
+
+ } catch (error) {
+ console.error('DOLCE upload failed:', error)
+ throw error
+ }
+ }
+
+ /**
+ * 계약 정보 조회
+ */
+ private async getContractInfo(contractId: number) {
+ const [result] = await db
+ .select({
+ projectCode: projects.code,
+ vendorCode: vendors.vendorCode,
+ contractNo: contracts.contractNo
+ })
+ .from(contracts)
+ .innerJoin(projects, eq(contracts.projectId, projects.id))
+ .innerJoin(vendors, eq(contracts.vendorId, vendors.id))
+ .where(eq(contracts.id, contractId))
+ .limit(1)
+
+ return result
+ }
+
+ /**
+ * 각 issueStageId별로 첫 번째 revision 정보를 조회
+ */
+ private async getFirstRevisionMap(issueStageIds: number[]): Promise<Map<number, string>> {
+ const firstRevisions = await db
+ .select({
+ issueStageId: revisions.issueStageId,
+ firstRevision: min(revisions.revision)
+ })
+ .from(revisions)
+ .where(inArray(revisions.issueStageId, issueStageIds))
+ .groupBy(revisions.issueStageId)
+
+ const map = new Map<number, string>()
+ firstRevisions.forEach(item => {
+ if (item.firstRevision) {
+ map.set(item.issueStageId, item.firstRevision)
+ }
+ })
+
+ return map
+ }
+
+ /**
+ * 업로드할 리비전 정보 조회 (문서 정보 및 첨부파일 포함)
+ */
+ private async getRevisionsForUpload(revisionIds: number[]) {
+ // revisions → issueStages → documents 순서로 join하여 정보 조회
+ const revisionResults = await db
+ .select({
+ // revision 테이블 정보
+ id: revisions.id,
+ revision: revisions.revision, // revisionNo가 아니라 revision
+ revisionStatus: revisions.revisionStatus,
+ uploaderId: revisions.uploaderId,
+ uploaderName: revisions.uploaderName,
+ submittedDate: revisions.submittedDate,
+ comment: revisions.comment,
+
+ // ✅ DOLCE 연동 필드들 (새로 추가)
+ externalUploadId: revisions.externalUploadId,
+ externalRegisterId: revisions.id,
+ externalSentAt: revisions.submittedDate,
+
+ // issueStages 테이블 정보
+ issueStageId: issueStages.id,
+ stageName: issueStages.stageName,
+ documentId: issueStages.documentId,
+
+ // documents 테이블 정보 (DOLCE 업로드에 필요한 모든 필드)
+ documentNo: documents.docNumber,
+ documentName: documents.title,
+ drawingKind: documents.drawingKind,
+ drawingMoveGbn: documents.drawingMoveGbn,
+ discipline: documents.discipline,
+ registerGroupId: documents.registerGroupId,
+
+ // DOLCE B4 전용 필드들
+ cGbn: documents.cGbn,
+ dGbn: documents.dGbn,
+ degreeGbn: documents.degreeGbn,
+ deptGbn: documents.deptGbn,
+ jGbn: documents.jGbn,
+ sGbn: documents.sGbn,
+
+ // DOLCE 추가 정보
+ manager: documents.manager,
+ managerENM: documents.managerENM,
+ managerNo: documents.managerNo,
+ shiDrawingNo: documents.shiDrawingNo,
+
+ // 외부 시스템 연동 정보
+ externalDocumentId: documents.externalDocumentId,
+ externalSystemType: documents.externalSystemType,
+ externalSyncedAt: documents.externalSyncedAt
+ })
+ .from(revisions)
+ .innerJoin(issueStages, eq(revisions.issueStageId, issueStages.id))
+ .innerJoin(documents, eq(issueStages.documentId, documents.id))
+ .where(inArray(revisions.id, revisionIds))
+
+ // 각 리비전의 첨부파일 정보도 조회
+ const revisionsWithAttachments = []
+ for (const revision of revisionResults) {
+ const attachments = await db
+ .select({
+ id: documentAttachments.id,
+ fileName: documentAttachments.fileName,
+ filePath: documentAttachments.filePath,
+ fileType: documentAttachments.fileType,
+ fileSize: documentAttachments.fileSize,
+ createdAt: documentAttachments.createdAt
+ })
+ .from(documentAttachments)
+ .where(eq(documentAttachments.revisionId, revision.id))
+
+ revisionsWithAttachments.push({
+ ...revision,
+ attachments
+ })
+ }
+
+ return revisionsWithAttachments
+ }
+
+ /**
+ * 파일 업로드 (PWPUploadService.ashx)
+ */
+ private async uploadFiles(attachments: any[]): Promise<Array<{uploadId: string, fileId: string, filePath: string}>> {
+ const uploadResults = []
+
+ for (const attachment of attachments) {
+ try {
+ // UploadId와 FileId 생성 (UUID 형태)
+ const uploadId = uuidv4()
+ const fileId = uuidv4()
+
+ // 파일 데이터 읽기 (실제 구현에서는 파일 시스템이나 S3에서 읽어옴)
+ const fileBuffer = await this.getFileBuffer(attachment.filePath)
+
+ const uploadUrl = `${this.UPLOAD_SERVICE_URL}?UploadId=${uploadId}&FileId=${fileId}`
+
+ const response = await fetch(uploadUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/octet-stream',
+ },
+ body: fileBuffer
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`File upload failed: HTTP ${response.status} - ${errorText}`)
+ }
+
+ const filePath = await response.text() // DOLCE에서 반환하는 파일 경로
+
+ uploadResults.push({
+ uploadId,
+ fileId,
+ filePath
+ })
+
+ console.log(`✅ File uploaded successfully: ${attachment.fileName} -> ${filePath}`)
+
+ } catch (error) {
+ console.error(`❌ File upload failed for ${attachment.fileName}:`, error)
+ throw error
+ }
+ }
+
+ return uploadResults
+ }
+
+ /**
+ * 문서 정보 업로드 (DetailDwgReceiptMgmtEdit)
+ */
+ private async uploadDocument(dwgList: DOLCEDocument[], userId: string): Promise<{success: boolean, error?: string, data?: any}> {
+ try {
+ const endpoint = `${this.BASE_URL}/Services/VDCSWebService.svc/DetailDwgReceiptMgmtEdit`
+
+ const requestBody = {
+ DwgList: dwgList,
+ UserID: userId
+ }
+
+ console.log('Uploading documents to DOLCE:', JSON.stringify(requestBody, null, 2))
+
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestBody)
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`HTTP ${response.status} - ${errorText}`)
+ }
+
+ const result = await response.json()
+
+ return {
+ success: true,
+ data: result
+ }
+
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error'
+ }
+ }
+ }
+
+ /**
+ * 파일 매핑 정보 업로드 (MatchBatchFileDwgEdit)
+ */
+ private async uploadFileMapping(mappingList: DOLCEFileMapping[], userId: string): Promise<{success: boolean, error?: string, data?: any}> {
+ try {
+ const endpoint = `${this.BASE_URL}/Services/VDCSWebService.svc/MatchBatchFileDwgEdit`
+
+ const requestBody = {
+ mappingSaveLists: mappingList,
+ UserID: userId
+ }
+
+ console.log('Uploading file mapping to DOLCE:', JSON.stringify(requestBody, null, 2))
+
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(requestBody)
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`HTTP ${response.status} - ${errorText}`)
+ }
+
+ const result = await response.json()
+
+ return {
+ success: true,
+ data: result
+ }
+
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error'
+ }
+ }
+ }
+
+ /**
+ * 리비전 데이터를 DOLCE 문서 형태로 변환 (업데이트된 스키마 사용)
+ */
+ private transformToDoLCEDocument(
+ revision: any,
+ contractInfo: any,
+ uploadId?: string,
+ vendorCode?: string,
+ firstRevisionMap?: Map<number, string>
+ ): DOLCEDocument {
+ // Mode 결정: 해당 issueStageId의 첫 번째 revision인지 확인
+ let mode: "ADD" | "MOD" = "MOD" // 기본값은 MOD
+
+ if (firstRevisionMap && firstRevisionMap.has(revision.issueStageId)) {
+ const firstRevision = firstRevisionMap.get(revision.issueStageId)
+ if (revision.revision === firstRevision) {
+ mode = "ADD"
+ }
+ }
+
+ // RegisterKind 결정: stageName에 따라 설정
+ let registerKind = "APPC" // 기본값
+ if (revision.stageName) {
+ const stageNameLower = revision.stageName.toLowerCase()
+ if (stageNameLower.includes("pre")) {
+ registerKind = "RECP"
+ } else if (stageNameLower.includes("working")) {
+ registerKind = "RECW"
+ }
+ }
+
+ const getSerialNumber = (revisionValue: string): number => {
+ // 먼저 숫자인지 확인
+ const numericValue = parseInt(revisionValue)
+ if (!isNaN(numericValue)) {
+ return numericValue
+ }
+
+ // 문자인 경우 (a=1, b=2, c=3, ...)
+ if (typeof revisionValue === 'string' && revisionValue.length === 1) {
+ const charCode = revisionValue.toLowerCase().charCodeAt(0)
+ if (charCode >= 97 && charCode <= 122) { // a-z
+ return charCode - 96 // a=1, b=2, c=3, ...
+ }
+ }
+
+ // 기본값
+ return 1
+ }
+
+
+ return {
+ Mode: mode,
+ Status: revision.revisionStatus || "Standby",
+ RegisterId: revision.externalRegisterId, // 업데이트된 필드 사용
+ ProjectNo: contractInfo.projectCode,
+ Discipline: revision.discipline,
+ DrawingKind: revision.drawingKind,
+ DrawingNo: revision.documentNo,
+ DrawingName: revision.documentName,
+ RegisterGroupId: revision.registerGroupId || 0,
+ RegisterSerialNo: getSerialNumber(revision.revision || "1"),
+ RegisterKind: registerKind, // stageName에 따라 동적 설정
+ DrawingRevNo: revision.revision || "-",
+ Category: revision.category || "TS",
+ Receiver: null,
+ Manager: revision.managerNo || "202206", // 담당자 번호 사용
+ RegisterDesc: revision.comment || "System upload",
+ UploadId: uploadId,
+ RegCompanyCode: vendorCode || "A0005531" // 벤더 코드
+ }
+ }
+ /**
+ * 파일 매핑 데이터 변환
+ */
+ private transformToFileMapping(
+ revision: any,
+ contractInfo: any,
+ uploadId: string,
+ fileName: string
+ ): DOLCEFileMapping {
+ return {
+ CGbn: revision.cGbn,
+ Category: revision.category,
+ CheckBox: "0",
+ DGbn: revision.dGbn,
+ DegreeGbn: revision.degreeGbn,
+ DeptGbn: revision.deptGbn,
+ Discipline: revision.discipline || "DL",
+ DrawingKind: revision.drawingKind || "B4",
+ DrawingMoveGbn: revision.drawingMoveGbn || "도면입수",
+ DrawingName: revision.documentName,
+ DrawingNo: revision.documentNo,
+ DrawingUsage: "입수용",
+ FileNm: fileName,
+ JGbn: revision.jGbn,
+ Manager: revision.managerNo || "970043",
+ MappingYN: "Y",
+ NewOrNot: "N",
+ ProjectNo: contractInfo.projectCode,
+ RegisterGroup: 0,
+ RegisterGroupId: revision.registerGroupId || 0,
+ RegisterKindCode: "RECW",
+ RegisterSerialNo: parseInt(revision.revision) || 1,
+ RevNo: revision.revision,
+ SGbn: revision.sGbn,
+ UploadId: uploadId
+ }
+ }
+
+ /**
+ * 파일 버퍼 읽기 (실제 파일 시스템 기반) - 타입 에러 수정
+ */
+ private async getFileBuffer(filePath: string): Promise<ArrayBuffer> {
+ try {
+ console.log(`Reading file from path: ${filePath}`)
+
+ if (filePath.startsWith('http')) {
+ // URL인 경우 직접 다운로드
+ const response = await fetch(filePath)
+ if (!response.ok) {
+ throw new Error(`Failed to download file: ${response.status}`)
+ }
+ return await response.arrayBuffer()
+ } else {
+ // 로컬 파일 경로인 경우
+ const fs = await import('fs')
+ const path = await import('path')
+
+ let actualFilePath: string
+
+ if (filePath.startsWith('/documents/')) {
+ // DB에 저장된 경로 형태: "/documents/[uuid].ext"
+ // 실제 파일 시스템 경로로 변환: "public/documents/[uuid].ext"
+ actualFilePath = path.join(process.cwd(), 'public', filePath)
+ } else if (filePath.startsWith('/')) {
+ // 절대 경로인 경우 public 디렉토리 기준으로 변환
+ actualFilePath = path.join(process.cwd(), 'public', filePath)
+ } else {
+ // 상대 경로인 경우 그대로 사용
+ actualFilePath = filePath
+ }
+
+ // 파일 존재 여부 확인
+ if (!fs.existsSync(actualFilePath)) {
+ throw new Error(`File not found: ${actualFilePath}`)
+ }
+
+ // 파일 읽기
+ const fileBuffer = fs.readFileSync(actualFilePath)
+ console.log(`✅ File read successfully: ${actualFilePath} (${fileBuffer.length} bytes)`)
+
+ // Buffer를 ArrayBuffer로 변환 (타입 안전성 보장)
+ return new ArrayBuffer(fileBuffer.length).slice(0).constructor(fileBuffer)
+ }
+ } catch (error) {
+ console.error(`❌ Failed to read file: ${filePath}`, error)
+ throw error
+ }
+ }
+
+ /**
+ * 리비전 상태 업데이트 (업데이트된 스키마 사용)
+ */
+ private async updateRevisionStatus(revisionId: number, status: string, uploadId?: string) {
+ const updateData: any = {
+ revisionStatus: status,
+ updatedAt: new Date()
+ }
+
+ // 업로드 성공 시 관련 날짜 설정
+ if (status === 'SUBMITTED') {
+ updateData.submittedDate = new Date().toISOString().slice(0, 10)
+ // updateData.externalSentAt = new Date().toISOString().slice(0, 10)
+ } else if (status === 'APPROVED') {
+ updateData.approvedDate = new Date().toISOString().slice(0, 10)
+ }
+
+ // DOLCE 업로드 ID 저장
+ if (uploadId) {
+ updateData.externalUploadId = uploadId
+ }
+
+ await db
+ .update(revisions)
+ .set(updateData)
+ .where(eq(revisions.id, revisionId))
+
+ console.log(`✅ Updated revision ${revisionId} status to ${status}${uploadId ? ` with upload ID: ${uploadId}` : ''}`)
+ }
+
+ /**
+ * 업로드 가능 여부 확인
+ */
+ isUploadEnabled(): boolean {
+ const enabled = process.env.DOLCE_UPLOAD_ENABLED
+ return enabled === 'true' || enabled === '1'
+ }
+}
+
+export const dolceUploadService = new DOLCEUploadService()
+
+// 편의 함수
+export async function uploadRevisionsToDOLCE(
+ contractId: number,
+ revisionIds: number[],
+ userId: string,
+ userName?: string
+): Promise<DOLCEUploadResult> {
+ return dolceUploadService.uploadToDoLCE(contractId, revisionIds, userId, userName)
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/enhanced-document-service.ts b/lib/vendor-document-list/enhanced-document-service.ts
index d39dfaa4..66758b89 100644
--- a/lib/vendor-document-list/enhanced-document-service.ts
+++ b/lib/vendor-document-list/enhanced-document-service.ts
@@ -140,8 +140,6 @@ export async function getEnhancedDocuments(
return { data, total }
})
- console.log(data)
-
const pageCount = Math.ceil(total / input.perPage)
return { data, pageCount, total }
diff --git a/lib/vendor-document-list/import-service.ts b/lib/vendor-document-list/import-service.ts
new file mode 100644
index 00000000..4a152299
--- /dev/null
+++ b/lib/vendor-document-list/import-service.ts
@@ -0,0 +1,487 @@
+// lib/vendor-document-list/import-service.ts - DOLCE API 연동 버전
+
+import db from "@/db/db"
+import { documents, issueStages, contracts, projects, vendors } from "@/db/schema"
+import { eq, and, desc, sql } from "drizzle-orm"
+
+export interface ImportResult {
+ success: boolean
+ newCount: number
+ updatedCount: number
+ skippedCount: number
+ errors?: string[]
+ message?: string
+}
+
+export interface ImportStatus {
+ lastImportAt?: string
+ availableDocuments: number
+ newDocuments: number
+ updatedDocuments: number
+ importEnabled: boolean
+}
+
+interface DOLCEDocument {
+ CGbn?: string
+ CreateDt: string
+ CreateUserENM: string
+ CreateUserId: string
+ CreateUserNo: string
+ DGbn?: string
+ DegreeGbn?: string
+ DeptGbn?: string
+ Discipline: string
+ DrawingKind: string // B3, B4, B5
+ DrawingMoveGbn: string
+ DrawingName: string
+ DrawingNo: string
+ GTTInput_PlanDate?: string
+ GTTInput_ResultDate?: string
+ GTTPreDwg_PlanDate?: string
+ GTTPreDwg_ResultDate?: string
+ GTTWorkingDwg_PlanDate?: string
+ GTTWorkingDwg_ResultDate?: string
+ JGbn?: string
+ Manager: string
+ ManagerENM: string
+ ManagerNo: string
+ ProjectNo: string
+ RegisterGroup: number
+ RegisterGroupId: number
+ SGbn?: string
+ SHIDrawingNo?: string
+}
+
+class ImportService {
+ /**
+ * DOLCE 시스템에서 문서 목록 가져오기
+ */
+ async importFromExternalSystem(
+ contractId: number,
+ sourceSystem: string = 'DOLCE'
+ ): Promise<ImportResult> {
+ try {
+ console.log(`Starting import from ${sourceSystem} for contract ${contractId}`)
+
+ // 1. 계약 정보를 통해 프로젝트 코드와 벤더 코드 조회
+ const contractInfo = await this.getContractInfoById(contractId)
+ if (!contractInfo?.projectCode || !contractInfo?.vendorCode) {
+ throw new Error(`Project code or vendor code not found for contract ${contractId}`)
+ }
+
+ // 2. 각 drawingKind별로 데이터 조회
+ const allDocuments: DOLCEDocument[] = []
+ const drawingKinds = ['B3', 'B4', 'B5']
+
+ for (const drawingKind of drawingKinds) {
+ try {
+ const documents = await this.fetchFromDOLCE(
+ contractInfo.projectCode,
+ contractInfo.vendorCode,
+ drawingKind
+ )
+ allDocuments.push(...documents)
+ console.log(`Fetched ${documents.length} documents for ${drawingKind}`)
+ } catch (error) {
+ console.warn(`Failed to fetch ${drawingKind} documents:`, error)
+ // 개별 drawingKind 실패는 전체 실패로 처리하지 않음
+ }
+ }
+
+ if (allDocuments.length === 0) {
+ return {
+ success: true,
+ newCount: 0,
+ updatedCount: 0,
+ skippedCount: 0,
+ message: '가져올 새로운 데이터가 없습니다.'
+ }
+ }
+
+ let newCount = 0
+ let updatedCount = 0
+ let skippedCount = 0
+ const errors: string[] = []
+
+ // 3. 각 문서 동기화 처리
+ for (const dolceDoc of allDocuments) {
+ try {
+ const result = await this.syncSingleDocument(contractId, dolceDoc, sourceSystem)
+
+ if (result === 'NEW') {
+ newCount++
+ // B4 문서의 경우 이슈 스테이지 자동 생성
+ if (dolceDoc.DrawingKind === 'B4') {
+ await this.createIssueStagesForB4Document(dolceDoc.DrawingNo, contractId, dolceDoc)
+ }
+ } else if (result === 'UPDATED') {
+ updatedCount++
+ } else {
+ skippedCount++
+ }
+
+ } catch (error) {
+ errors.push(`Document ${dolceDoc.DrawingNo}: ${error instanceof Error ? error.message : 'Unknown error'}`)
+ skippedCount++
+ }
+ }
+
+ console.log(`Import completed: ${newCount} new, ${updatedCount} updated, ${skippedCount} skipped`)
+
+ return {
+ success: errors.length === 0,
+ newCount,
+ updatedCount,
+ skippedCount,
+ errors: errors.length > 0 ? errors : undefined,
+ message: `가져오기 완료: 신규 ${newCount}건, 업데이트 ${updatedCount}건`
+ }
+
+ } catch (error) {
+ console.error('Import failed:', error)
+ throw error
+ }
+ }
+
+ /**
+ * 계약 ID로 프로젝트 코드와 벤더 코드 조회
+ */
+ private async getContractInfoById(contractId: number): Promise<{
+ projectCode: string;
+ vendorCode: string;
+ } | null> {
+ const [result] = await db
+ .select({
+ projectCode: projects.code,
+ vendorCode: vendors.vendorCode
+ })
+ .from(contracts)
+ .innerJoin(projects, eq(contracts.projectId, projects.id))
+ .innerJoin(vendors, eq(contracts.vendorId, vendors.id))
+ .where(eq(contracts.id, contractId))
+ .limit(1)
+
+
+ return result?.projectCode && result?.vendorCode
+ ? { projectCode: result.projectCode, vendorCode: result.vendorCode }
+ : null
+ }
+
+ /**
+ * DOLCE API에서 데이터 조회
+ */
+ private async fetchFromDOLCE(
+ projectCode: string,
+ vendorCode: string,
+ drawingKind: string
+ ): Promise<DOLCEDocument[]> {
+ const endpoint = process.env.DOLCE_DOC_LIST_API_URL || 'http://60.100.99.217:1111/Services/VDCSWebService.svc/DwgReceiptMgmt'
+
+ const requestBody = {
+ project: projectCode,
+ drawingKind: drawingKind, // B3, B4, B5
+ drawingMoveGbn: "",
+ drawingNo: "",
+ drawingName: "",
+ drawingVendor: vendorCode
+ }
+
+ console.log(`Fetching from DOLCE: ${projectCode} - ${drawingKind} = ${vendorCode}`)
+
+ try {
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ // DOLCE API에 특별한 인증이 필요하다면 여기에 추가
+ },
+ body: JSON.stringify(requestBody)
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`DOLCE API failed: HTTP ${response.status} - ${errorText}`)
+ }
+
+ const data = await response.json()
+
+ // 응답 구조에 따라 조정 필요 (실제 API 응답 구조 확인 후)
+ if (Array.isArray(data)) {
+ return data as DOLCEDocument[]
+ } else if (data.documents && Array.isArray(data.documents)) {
+ return data.documents as DOLCEDocument[]
+ } else if (data.data && Array.isArray(data.data)) {
+ return data.data as DOLCEDocument[]
+ } else {
+ console.warn(`Unexpected DOLCE response structure:`, data)
+ return []
+ }
+
+ } catch (error) {
+ console.error(`DOLCE API call failed for ${projectCode}/${drawingKind}:`, error)
+ throw error
+ }
+ }
+
+ /**
+ * 단일 문서 동기화
+ */
+ private async syncSingleDocument(
+ contractId: number,
+ dolceDoc: DOLCEDocument,
+ sourceSystem: string
+ ): Promise<'NEW' | 'UPDATED' | 'SKIPPED'> {
+ // 기존 문서 조회 (문서 번호로)
+ const existingDoc = await db
+ .select()
+ .from(documents)
+ .where(and(
+ eq(documents.contractId, contractId),
+ eq(documents.docNumber, dolceDoc.DrawingNo)
+ ))
+ .limit(1)
+
+ // DOLCE 문서를 DB 스키마에 맞게 변환
+ const documentData = {
+ contractId,
+ docNumber: dolceDoc.DrawingNo,
+ title: dolceDoc.DrawingName,
+ status: 'ACTIVE',
+
+ // DOLCE 전용 필드들
+ drawingKind: dolceDoc.DrawingKind,
+ drawingMoveGbn: dolceDoc.DrawingMoveGbn,
+ discipline: dolceDoc.Discipline,
+
+ // 외부 시스템 정보
+ externalDocumentId: dolceDoc.DrawingNo, // DOLCE에서는 DrawingNo가 ID 역할
+ externalSystemType: sourceSystem,
+ externalSyncedAt: new Date(),
+
+ // B4 전용 필드들
+ cGbn: dolceDoc.CGbn,
+ dGbn: dolceDoc.DGbn,
+ degreeGbn: dolceDoc.DegreeGbn,
+ deptGbn: dolceDoc.DeptGbn,
+ jGbn: dolceDoc.JGbn,
+ sGbn: dolceDoc.SGbn,
+
+ // 추가 정보
+ shiDrawingNo: dolceDoc.SHIDrawingNo,
+ manager: dolceDoc.Manager,
+ managerENM: dolceDoc.ManagerENM,
+ managerNo: dolceDoc.ManagerNo,
+ registerGroup: dolceDoc.RegisterGroup,
+ registerGroupId: dolceDoc.RegisterGroupId,
+
+ // 생성자 정보
+ createUserNo: dolceDoc.CreateUserNo,
+ createUserId: dolceDoc.CreateUserId,
+ createUserENM: dolceDoc.CreateUserENM,
+
+ updatedAt: new Date()
+ }
+
+ if (existingDoc.length > 0) {
+ // 업데이트 필요 여부 확인
+ const existing = existingDoc[0]
+ const hasChanges =
+ existing.title !== documentData.title ||
+ existing.drawingMoveGbn !== documentData.drawingMoveGbn ||
+ existing.manager !== documentData.manager
+
+ if (hasChanges) {
+ await db
+ .update(documents)
+ .set(documentData)
+ .where(eq(documents.id, existing.id))
+
+ console.log(`Updated document: ${dolceDoc.DrawingNo}`)
+ return 'UPDATED'
+ } else {
+ return 'SKIPPED'
+ }
+ } else {
+ // 새 문서 생성
+ const [newDoc] = await db
+ .insert(documents)
+ .values({
+ ...documentData,
+ createdAt: new Date()
+ })
+ .returning({ id: documents.id })
+
+ console.log(`Created new document: ${dolceDoc.DrawingNo}`)
+ return 'NEW'
+ }
+ }
+
+ /**
+ * B4 문서용 이슈 스테이지 자동 생성
+ */
+ private async createIssueStagesForB4Document(
+ drawingNo: string,
+ contractId: number,
+ dolceDoc: DOLCEDocument
+ ): Promise<void> {
+ try {
+ // 문서 ID 조회
+ const [document] = await db
+ .select({ id: documents.id })
+ .from(documents)
+ .where(and(
+ eq(documents.contractId, contractId),
+ eq(documents.docNumber, drawingNo)
+ ))
+ .limit(1)
+
+ if (!document) {
+ throw new Error(`Document not found: ${drawingNo}`)
+ }
+
+ const documentId = document.id
+
+ // 기존 이슈 스테이지 확인
+ const existingStages = await db
+ .select()
+ .from(issueStages)
+ .where(eq(issueStages.documentId, documentId))
+
+ const existingStageNames = existingStages.map(stage => stage.stageName)
+
+ // For Pre 스테이지 생성 (GTTPreDwg)
+ if (!existingStageNames.includes('For Pre')) {
+ await db.insert(issueStages).values({
+ documentId: documentId,
+ stageName: 'For Pre',
+ planDate: dolceDoc.GTTPreDwg_PlanDate ? dolceDoc.GTTPreDwg_PlanDate : null,
+ actualDate: dolceDoc.GTTPreDwg_ResultDate ? dolceDoc.GTTPreDwg_ResultDate : null,
+ stageStatus: dolceDoc.GTTPreDwg_ResultDate ? 'COMPLETED' : 'PLANNED',
+ stageOrder: 1,
+ description: 'GTT 예비 도면 단계'
+ })
+ }
+
+ // For Working 스테이지 생성 (GTTWorkingDwg)
+ if (!existingStageNames.includes('For Working')) {
+ await db.insert(issueStages).values({
+ documentId: documentId,
+ stageName: 'For Working',
+ planDate: dolceDoc.GTTWorkingDwg_PlanDate ? dolceDoc.GTTWorkingDwg_PlanDate : null,
+ actualDate: dolceDoc.GTTWorkingDwg_ResultDate ? dolceDoc.GTTWorkingDwg_ResultDate : null,
+ stageStatus: dolceDoc.GTTWorkingDwg_ResultDate ? 'COMPLETED' : 'PLANNED',
+ stageOrder: 2,
+ description: 'GTT 작업 도면 단계'
+ })
+ }
+
+ console.log(`Created issue stages for B4 document: ${drawingNo}`)
+
+ } catch (error) {
+ console.error(`Failed to create issue stages for ${drawingNo}:`, error)
+ // 이슈 스테이지 생성 실패는 전체 문서 생성을 막지 않음
+ }
+ }
+
+ /**
+ * 가져오기 상태 조회
+ */
+ async getImportStatus(
+ contractId: number,
+ sourceSystem: string = 'DOLCE'
+ ): Promise<ImportStatus> {
+ try {
+ // 마지막 가져오기 시간 조회
+ const [lastImport] = await db
+ .select({
+ lastSynced: sql<string>`MAX(${documents.externalSyncedAt})`
+ })
+ .from(documents)
+ .where(and(
+ eq(documents.contractId, contractId),
+ eq(documents.externalSystemType, sourceSystem)
+ ))
+
+ // 프로젝트 코드와 벤더 코드 조회
+ const contractInfo = await this.getContractInfoById(contractId)
+
+ console.log(contractInfo,"contractInfo")
+
+ if (!contractInfo?.projectCode || !contractInfo?.vendorCode) {
+ throw new Error(`Project code or vendor code not found for contract ${contractId}`)
+ }
+
+ let availableDocuments = 0
+ let newDocuments = 0
+ let updatedDocuments = 0
+
+ try {
+ // 각 drawingKind별로 확인
+ const drawingKinds = ['B3', 'B4', 'B5']
+
+ for (const drawingKind of drawingKinds) {
+ try {
+ const externalDocs = await this.fetchFromDOLCE(
+ contractInfo.projectCode,
+ contractInfo.vendorCode,
+ drawingKind
+ )
+ availableDocuments += externalDocs.length
+
+ // 신규/업데이트 문서 수 계산
+ for (const externalDoc of externalDocs) {
+ const existing = await db
+ .select({ id: documents.id, updatedAt: documents.updatedAt })
+ .from(documents)
+ .where(and(
+ eq(documents.contractId, contractId),
+ eq(documents.docNumber, externalDoc.DrawingNo)
+ ))
+ .limit(1)
+
+ if (existing.length === 0) {
+ newDocuments++
+ } else {
+ // DOLCE의 CreateDt와 로컬 updatedAt 비교
+ if (externalDoc.CreateDt && existing[0].updatedAt) {
+ const externalModified = new Date(externalDoc.CreateDt)
+ const localModified = new Date(existing[0].updatedAt)
+ if (externalModified > localModified) {
+ updatedDocuments++
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.warn(`Failed to check ${drawingKind} for status:`, error)
+ }
+ }
+ } catch (error) {
+ console.warn(`Failed to fetch external data for status: ${error}`)
+ }
+
+ return {
+ lastImportAt: lastImport?.lastSynced ? new Date(lastImport.lastSynced).toISOString() : undefined,
+ availableDocuments,
+ newDocuments,
+ updatedDocuments,
+ importEnabled: this.isImportEnabled(sourceSystem)
+ }
+
+ } catch (error) {
+ console.error('Failed to get import status:', error)
+ throw error
+ }
+ }
+
+ /**
+ * 가져오기 활성화 여부 확인
+ */
+ private isImportEnabled(sourceSystem: string): boolean {
+ const upperSystem = sourceSystem.toUpperCase()
+ const enabled = process.env[`IMPORT_${upperSystem}_ENABLED`]
+ return enabled === 'true' || enabled === '1'
+ }
+}
+
+export const importService = new ImportService() \ No newline at end of file
diff --git a/lib/vendor-document-list/sync-service.ts b/lib/vendor-document-list/sync-service.ts
index 1f2872c4..4c1f5786 100644
--- a/lib/vendor-document-list/sync-service.ts
+++ b/lib/vendor-document-list/sync-service.ts
@@ -1,4 +1,4 @@
-// lib/sync-service.ts (시스템별 분리 버전)
+// lib/sync-service.ts (시스템별 분리 버전 - DOLCE 업로드 통합)
import db from "@/db/db"
import {
changeLogs,
@@ -29,8 +29,6 @@ export interface SyncResult {
class SyncService {
private readonly CHUNK_SIZE = 50
-
-
/**
* 동기화 활성화 여부 확인
*/
@@ -294,7 +292,7 @@ class SyncService {
}
/**
- * DOLCE 시스템 전용 동기화 수행
+ * DOLCE 시스템 전용 동기화 수행 - 실제 업로드 서비스 사용
*/
private async performSyncDOLCE(
changes: ChangeLog[],
@@ -302,143 +300,72 @@ class SyncService {
): Promise<{ success: boolean; successCount: number; failureCount: number; errors?: string[]; endpointResults?: Record<string, any> }> {
const errors: string[] = []
const endpointResults: Record<string, any> = {}
- let overallSuccess = true
-
- // 변경사항을 DOLCE 시스템 형태로 변환
- const syncData = await this.transformChangesForDOLCE(changes)
-
- // DOLCE 엔드포인트 호출들을 직접 정의
- const endpointPromises = []
-
- // 1. DOLCE 메인 엔드포인트
- const mainUrl = process.env.SYNC_DOLCE_URL
- if (mainUrl) {
- endpointPromises.push(
- (async () => {
- try {
- console.log(`Sending to DOLCE main: ${mainUrl}`)
-
- const transformedData = {
- contractId,
- systemType: 'DOLCE',
- changes: syncData,
- batchSize: changes.length,
- timestamp: new Date().toISOString(),
- source: 'EVCP',
- version: '1.0'
- }
-
- // 헤더 구성 (토큰이 있을 때만 Authorization 포함)
- const headers: Record<string, string> = {
- 'Content-Type': 'application/json',
- 'X-API-Version': process.env.SYNC_DOLCE_VERSION || 'v1',
- 'X-System': 'DOLCE'
- }
-
- if (process.env.SYNC_DOLCE_TOKEN) {
- headers['Authorization'] = `Bearer ${process.env.SYNC_DOLCE_TOKEN}`
- }
-
- const response = await fetch(mainUrl, {
- method: 'POST',
- headers,
- body: JSON.stringify(transformedData)
- })
-
- if (!response.ok) {
- const errorText = await response.text()
- throw new Error(`DOLCE main: HTTP ${response.status} - ${errorText}`)
- }
-
- const result = await response.json()
- endpointResults['dolce_main'] = result
-
- console.log(`✅ DOLCE main sync successful`)
- return { success: true, endpoint: 'dolce_main', result }
- } catch (error) {
- const errorMessage = `DOLCE main: ${error instanceof Error ? error.message : 'Unknown error'}`
- errors.push(errorMessage)
- overallSuccess = false
-
- console.error(`❌ DOLCE main sync failed:`, error)
- return { success: false, endpoint: 'dolce_main', error: errorMessage }
- }
- })()
- )
- }
-
- // 2. DOLCE 문서 전용 엔드포인트 (선택사항)
- const docUrl = process.env.SYNC_DOLCE_DOCUMENT_URL
- if (docUrl) {
- endpointPromises.push(
- (async () => {
- try {
- console.log(`Sending to DOLCE documents: ${docUrl}`)
-
- const documentData = {
- documents: syncData.filter(item => item.entityType === 'document'),
- source: 'EVCP_DOLCE',
- timestamp: new Date().toISOString()
- }
-
- // 헤더 구성 (토큰이 있을 때만 Authorization 포함)
- const headers: Record<string, string> = {
- 'Content-Type': 'application/json'
- }
-
- if (process.env.SYNC_DOLCE_TOKEN) {
- headers['Authorization'] = `Bearer ${process.env.SYNC_DOLCE_TOKEN}`
- }
-
- const response = await fetch(docUrl, {
- method: 'PUT',
- headers,
- body: JSON.stringify(documentData)
- })
-
- if (!response.ok) {
- const errorText = await response.text()
- throw new Error(`DOLCE documents: HTTP ${response.status} - ${errorText}`)
- }
+ try {
+ // DOLCE 업로드 서비스 동적 임포트
+ const { dolceUploadService } = await import('./dolce-upload-service')
+
+ if (!dolceUploadService.isUploadEnabled()) {
+ throw new Error('DOLCE upload is not enabled')
+ }
- const result = await response.json()
- endpointResults['dolce_documents'] = result
+ // 변경사항에서 리비전 ID들 추출
+ const revisionIds = changes
+ .filter(change => change.entityType === 'revision')
+ .map(change => change.entityId)
- console.log(`✅ DOLCE documents sync successful`)
- return { success: true, endpoint: 'dolce_documents', result }
+ if (revisionIds.length === 0) {
+ return {
+ success: true,
+ successCount: 0,
+ failureCount: 0,
+ endpointResults: { message: 'No revisions to upload' }
+ }
+ }
- } catch (error) {
- const errorMessage = `DOLCE documents: ${error instanceof Error ? error.message : 'Unknown error'}`
- errors.push(errorMessage)
- overallSuccess = false
-
- console.error(`❌ DOLCE documents sync failed:`, error)
- return { success: false, endpoint: 'dolce_documents', error: errorMessage }
- }
- })()
+ // DOLCE 업로드 실행
+ const uploadResult = await dolceUploadService.uploadToDoLCE(
+ contractId,
+ revisionIds,
+ 'system_user', // 시스템 사용자 ID
+ 'System Upload'
)
- }
- if (endpointPromises.length === 0) {
- throw new Error('No DOLCE sync endpoints configured')
- }
+ endpointResults['dolce_upload'] = uploadResult
- // 모든 엔드포인트 요청 완료 대기
- const results = await Promise.allSettled(endpointPromises)
-
- // 결과 집계
- const successfulEndpoints = results.filter(r => r.status === 'fulfilled' && r.value.success).length
- const totalEndpoints = endpointPromises.length
+ if (uploadResult.success) {
+ console.log(`✅ DOLCE upload successful: ${uploadResult.uploadedDocuments} documents, ${uploadResult.uploadedFiles} files`)
+
+ return {
+ success: true,
+ successCount: changes.length,
+ failureCount: 0,
+ endpointResults
+ }
+ } else {
+ console.error(`❌ DOLCE upload failed:`, uploadResult.errors)
+
+ return {
+ success: false,
+ successCount: 0,
+ failureCount: changes.length,
+ errors: uploadResult.errors,
+ endpointResults
+ }
+ }
- console.log(`DOLCE endpoint results: ${successfulEndpoints}/${totalEndpoints} successful`)
+ } catch (error) {
+ const errorMessage = `DOLCE upload failed: ${error instanceof Error ? error.message : 'Unknown error'}`
+ errors.push(errorMessage)
+ console.error(`❌ DOLCE upload error:`, error)
- return {
- success: overallSuccess && errors.length === 0,
- successCount: overallSuccess ? changes.length : 0,
- failureCount: overallSuccess ? 0 : changes.length,
- errors: errors.length > 0 ? errors : undefined,
- endpointResults
+ return {
+ success: false,
+ successCount: 0,
+ failureCount: changes.length,
+ errors,
+ endpointResults
+ }
}
}
@@ -560,78 +487,6 @@ class SyncService {
}
/**
- * DOLCE 시스템용 데이터 변환
- */
- private async transformChangesForDOLCE(changes: ChangeLog[]): Promise<SyncableEntity[]> {
- const syncData: SyncableEntity[] = []
-
- for (const change of changes) {
- try {
- let entityData = null
-
- // 엔티티 타입별로 현재 데이터 조회
- switch (change.entityType) {
- case 'document':
- if (change.action !== 'DELETE') {
- const [document] = await db
- .select()
- .from(documents)
- .where(eq(documents.id, change.entityId))
- .limit(1)
- entityData = document
- }
- break
-
- case 'revision':
- if (change.action !== 'DELETE') {
- const [revision] = await db
- .select()
- .from(revisions)
- .where(eq(revisions.id, change.entityId))
- .limit(1)
- entityData = revision
- }
- break
-
- case 'attachment':
- if (change.action !== 'DELETE') {
- const [attachment] = await db
- .select()
- .from(documentAttachments)
- .where(eq(documentAttachments.id, change.entityId))
- .limit(1)
- entityData = attachment
- }
- break
- }
-
- // DOLCE 특화 데이터 구조
- syncData.push({
- entityType: change.entityType as any,
- entityId: change.entityId,
- action: change.action as any,
- data: entityData || change.oldValues,
- metadata: {
- changeId: change.id,
- changedAt: change.createdAt,
- changedBy: change.userName,
- changedFields: change.changedFields,
- // DOLCE 전용 메타데이터
- dolceVersion: '2.0',
- processingPriority: change.entityType === 'revision' ? 'HIGH' : 'NORMAL',
- requiresApproval: change.action === 'DELETE'
- }
- })
-
- } catch (error) {
- console.error(`Failed to transform change ${change.id} for DOLCE:`, error)
- }
- }
-
- return syncData
- }
-
- /**
* SWP 시스템용 데이터 변환
*/
private async transformChangesForSWP(changes: ChangeLog[]): Promise<SyncableEntity[]> {
@@ -759,7 +614,7 @@ class SyncService {
await db.update(revisions)
.set({
revisionStatus: "SUBMITTED",
- externalSentAt: new Date().toISOString().slice(0, 10)
+ submittedDate: new Date().toISOString().slice(0, 10)
})
.where(inArray(revisions.id, revisionIds))
}
diff --git a/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx b/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx
index fa1b957b..3960bbce 100644
--- a/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx
+++ b/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx
@@ -1,8 +1,7 @@
"use client"
-
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Download, Upload, Plus, Files } from "lucide-react"
+import { Download, Upload, Plus, Files, RefreshCw } from "lucide-react"
import { toast } from "sonner"
import { exportTableToExcel } from "@/lib/export"
@@ -13,6 +12,8 @@ import { DeleteDocumentsDialog } from "./delete-docs-dialog"
import { BulkUploadDialog } from "./bulk-upload-dialog"
import type { EnhancedDocument } from "@/types/enhanced-documents"
import { SendToSHIButton } from "./send-to-shi-button"
+import { ImportFromDOLCEButton } from "./import-from-dolce-button"
+import { SWPWorkflowPanel } from "./swp-workflow-panel"
interface EnhancedDocTableToolbarActionsProps {
table: Table<EnhancedDocument>
@@ -50,9 +51,17 @@ export function EnhancedDocTableToolbarActions({
}, 500)
}
+ const handleImportComplete = () => {
+ // 가져오기 완료 후 테이블 새로고침
+ table.resetRowSelection()
+ setTimeout(() => {
+ window.location.reload()
+ }, 500)
+ }
+
return (
<div className="flex items-center gap-2">
- {/* 기존 액션들 */}
+ {/* 삭제 버튼 */}
{table.getFilteredSelectedRowModel().rows.length > 0 ? (
<DeleteDocumentsDialog
documents={table
@@ -62,14 +71,27 @@ export function EnhancedDocTableToolbarActions({
/>
) : null}
- {/* ✅ AddDocumentListDialog에 필요한 props 전달 */}
- <AddDocumentListDialog
- projectType={projectType}
- contractId={selectedPackageId}
- onSuccess={handleDocumentAdded} // ✅ 성공 콜백 추가
- />
-
- {/* 일괄 업로드 버튼 */}
+ {/* projectType에 따른 조건부 렌더링 */}
+ {projectType === "ship" ? (
+ <>
+ {/* SHIP: DOLCE에서 목록 가져오기 */}
+ <ImportFromDOLCEButton
+ contractId={selectedPackageId}
+ onImportComplete={handleImportComplete}
+ />
+ </>
+ ) : (
+ <>
+ {/* PLANT: 수동 문서 추가 */}
+ <AddDocumentListDialog
+ projectType={projectType}
+ contractId={selectedPackageId}
+ onSuccess={handleDocumentAdded}
+ />
+ </>
+ )}
+
+ {/* 일괄 업로드 버튼 (공통) */}
<Button
variant="outline"
onClick={() => setBulkUploadDialogOpen(true)}
@@ -79,7 +101,7 @@ export function EnhancedDocTableToolbarActions({
일괄 업로드
</Button>
- {/* Export 버튼 */}
+ {/* Export 버튼 (공통) */}
<Button
variant="outline"
size="sm"
@@ -95,7 +117,7 @@ export function EnhancedDocTableToolbarActions({
<span className="hidden sm:inline">Export</span>
</Button>
- {/* ✅ 새로운 Send to SHI 버튼으로 교체 */}
+ {/* Send to SHI 버튼 (공통) - 내부 → 외부로 보내기 */}
<SendToSHIButton
contractId={selectedPackageId}
documents={allDocuments}
@@ -103,6 +125,15 @@ export function EnhancedDocTableToolbarActions({
projectType={projectType}
/>
+ {/* SWP 전용 워크플로우 패널 */}
+ {projectType === "plant" && (
+ <SWPWorkflowPanel
+ contractId={selectedPackageId}
+ documents={allDocuments}
+ onWorkflowUpdate={handleSyncComplete}
+ />
+ )}
+
{/* 일괄 업로드 다이얼로그 */}
<BulkUploadDialog
open={bulkUploadDialogOpen}
diff --git a/lib/vendor-document-list/table/enhanced-documents-table.tsx b/lib/vendor-document-list/table/enhanced-documents-table.tsx
index f840a10c..3bd6668d 100644
--- a/lib/vendor-document-list/table/enhanced-documents-table.tsx
+++ b/lib/vendor-document-list/table/enhanced-documents-table.tsx
@@ -46,8 +46,6 @@ export function EnhancedDocumentsTable({
// 데이터 로딩
const [{ data, pageCount, total }] = React.use(promises)
- console.log(data)
-
// 상태 관리
const [rowAction, setRowAction] = React.useState<DataTableRowAction<EnhancedDocument> | null>(null)
diff --git a/lib/vendor-document-list/table/import-from-dolce-button.tsx b/lib/vendor-document-list/table/import-from-dolce-button.tsx
new file mode 100644
index 00000000..519d40cb
--- /dev/null
+++ b/lib/vendor-document-list/table/import-from-dolce-button.tsx
@@ -0,0 +1,356 @@
+"use client"
+
+import * as React from "react"
+import { RefreshCw, Download, Loader2, CheckCircle, AlertTriangle } from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { Badge } from "@/components/ui/badge"
+import { Progress } from "@/components/ui/progress"
+import { Separator } from "@/components/ui/separator"
+
+interface ImportFromDOLCEButtonProps {
+ contractId: number
+ onImportComplete?: () => void
+}
+
+interface ImportStatus {
+ lastImportAt?: string
+ availableDocuments: number
+ newDocuments: number
+ updatedDocuments: number
+ importEnabled: boolean
+}
+
+export function ImportFromDOLCEButton({
+ contractId,
+ onImportComplete
+}: ImportFromDOLCEButtonProps) {
+ const [isDialogOpen, setIsDialogOpen] = React.useState(false)
+ const [importProgress, setImportProgress] = React.useState(0)
+ const [isImporting, setIsImporting] = React.useState(false)
+ const [importStatus, setImportStatus] = React.useState<ImportStatus | null>(null)
+ const [statusLoading, setStatusLoading] = React.useState(false)
+
+ // DOLCE 상태 조회
+ const fetchImportStatus = async () => {
+ setStatusLoading(true)
+ try {
+ const response = await fetch(`/api/sync/import/status?contractId=${contractId}&sourceSystem=DOLCE`)
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ throw new Error(errorData.message || 'Failed to fetch import status')
+ }
+
+ const status = await response.json()
+ setImportStatus(status)
+
+ // 프로젝트 코드가 없는 경우 에러 처리
+ if (status.error) {
+ toast.error(`상태 확인 실패: ${status.error}`)
+ setImportStatus(null)
+ }
+ } catch (error) {
+ console.error('Failed to fetch import status:', error)
+ toast.error('DOLCE 상태를 확인할 수 없습니다. 프로젝트 설정을 확인해주세요.')
+ setImportStatus(null)
+ } finally {
+ setStatusLoading(false)
+ }
+ }
+
+ // 컴포넌트 마운트 시 상태 조회
+ React.useEffect(() => {
+ fetchImportStatus()
+ }, [contractId])
+
+ const handleImport = async () => {
+ if (!contractId) return
+
+ setImportProgress(0)
+ setIsImporting(true)
+
+ try {
+ // 진행률 시뮬레이션
+ const progressInterval = setInterval(() => {
+ setImportProgress(prev => Math.min(prev + 15, 90))
+ }, 300)
+
+ const response = await fetch('/api/sync/import', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contractId,
+ sourceSystem: 'DOLCE'
+ })
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.message || 'Import failed')
+ }
+
+ const result = await response.json()
+
+ clearInterval(progressInterval)
+ setImportProgress(100)
+
+ setTimeout(() => {
+ setImportProgress(0)
+ setIsDialogOpen(false)
+ setIsImporting(false)
+
+ if (result?.success) {
+ const { newCount = 0, updatedCount = 0, skippedCount = 0 } = result
+ toast.success(
+ `DOLCE 가져오기 완료`,
+ {
+ description: `신규 ${newCount}건, 업데이트 ${updatedCount}건, 건너뜀 ${skippedCount}건 (B3/B4/B5 포함)`
+ }
+ )
+ } else {
+ toast.error(
+ `DOLCE 가져오기 부분 실패`,
+ {
+ description: result?.message || '일부 DrawingKind에서 가져오기에 실패했습니다.'
+ }
+ )
+ }
+
+ fetchImportStatus() // 상태 갱신
+ onImportComplete?.()
+ }, 500)
+
+ } catch (error) {
+ setImportProgress(0)
+ setIsImporting(false)
+
+ toast.error('DOLCE 가져오기 실패', {
+ description: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
+ })
+ }
+ }
+
+ const getStatusBadge = () => {
+ if (statusLoading) {
+ return <Badge variant="secondary">DOLCE 연결 확인 중...</Badge>
+ }
+
+ if (!importStatus) {
+ return <Badge variant="destructive">DOLCE 연결 오류</Badge>
+ }
+
+ if (!importStatus.importEnabled) {
+ return <Badge variant="secondary">DOLCE 가져오기 비활성화</Badge>
+ }
+
+ if (importStatus.newDocuments > 0 || importStatus.updatedDocuments > 0) {
+ return (
+ <Badge variant="default" className="gap-1 bg-blue-500 hover:bg-blue-600">
+ <AlertTriangle className="w-3 h-3" />
+ 업데이트 가능 (B3/B4/B5)
+ </Badge>
+ )
+ }
+
+ return (
+ <Badge variant="default" className="gap-1 bg-green-500 hover:bg-green-600">
+ <CheckCircle className="w-3 h-3" />
+ DOLCE와 동기화됨
+ </Badge>
+ )
+ }
+
+ const canImport = importStatus?.importEnabled &&
+ (importStatus?.newDocuments > 0 || importStatus?.updatedDocuments > 0)
+
+ return (
+ <>
+ <Popover>
+ <PopoverTrigger asChild>
+ <div className="flex items-center gap-3">
+ <Button
+ variant="outline"
+ size="sm"
+ className="flex items-center border-blue-200 hover:bg-blue-50"
+ disabled={isImporting || statusLoading}
+ >
+ {isImporting ? (
+ <Loader2 className="w-4 h-4 animate-spin" />
+ ) : (
+ <Download className="w-4 h-4" />
+ )}
+ <span className="hidden sm:inline">DOLCE에서 가져오기</span>
+ {importStatus && (importStatus.newDocuments > 0 || importStatus.updatedDocuments > 0) && (
+ <Badge
+ variant="default"
+ className="h-5 w-5 p-0 text-xs flex items-center justify-center bg-blue-500"
+ >
+ {importStatus.newDocuments + importStatus.updatedDocuments}
+ </Badge>
+ )}
+ </Button>
+ </div>
+ </PopoverTrigger>
+
+ <PopoverContent className="w-80">
+ <div className="space-y-4">
+ <div className="space-y-2">
+ <h4 className="font-medium">DOLCE 가져오기 상태</h4>
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">현재 상태</span>
+ {getStatusBadge()}
+ </div>
+ </div>
+
+ {importStatus && (
+ <div className="space-y-3">
+ <Separator />
+
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <div className="text-muted-foreground">신규 문서</div>
+ <div className="font-medium">{importStatus.newDocuments || 0}건</div>
+ </div>
+ <div>
+ <div className="text-muted-foreground">업데이트</div>
+ <div className="font-medium">{importStatus.updatedDocuments || 0}건</div>
+ </div>
+ </div>
+
+ <div className="text-sm">
+ <div className="text-muted-foreground">DOLCE 전체 문서 (B3/B4/B5)</div>
+ <div className="font-medium">{importStatus.availableDocuments || 0}건</div>
+ </div>
+
+ {importStatus.lastImportAt && (
+ <div className="text-sm">
+ <div className="text-muted-foreground">마지막 가져오기</div>
+ <div className="font-medium">
+ {new Date(importStatus.lastImportAt).toLocaleString()}
+ </div>
+ </div>
+ )}
+ </div>
+ )}
+
+ <Separator />
+
+ <div className="flex gap-2">
+ <Button
+ onClick={() => setIsDialogOpen(true)}
+ disabled={!canImport || isImporting}
+ className="flex-1"
+ size="sm"
+ >
+ {isImporting ? (
+ <>
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ 가져오는 중...
+ </>
+ ) : (
+ <>
+ <Download className="w-4 h-4 mr-2" />
+ 지금 가져오기
+ </>
+ )}
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={fetchImportStatus}
+ disabled={statusLoading}
+ >
+ {statusLoading ? (
+ <Loader2 className="w-4 h-4 animate-spin" />
+ ) : (
+ <RefreshCw className="w-4 h-4" />
+ )}
+ </Button>
+ </div>
+ </div>
+ </PopoverContent>
+ </Popover>
+
+ {/* 가져오기 진행 다이얼로그 */}
+ <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle>DOLCE에서 문서 목록 가져오기</DialogTitle>
+ <DialogDescription>
+ 삼성중공업 DOLCE 시스템에서 최신 문서 목록을 가져옵니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {importStatus && (
+ <div className="rounded-lg border p-4 space-y-3">
+ <div className="flex items-center justify-between text-sm">
+ <span>가져올 항목</span>
+ <span className="font-medium">
+ {(importStatus.newDocuments || 0) + (importStatus.updatedDocuments || 0)}건
+ </span>
+ </div>
+
+ <div className="text-xs text-muted-foreground">
+ 신규 문서와 업데이트된 문서가 포함됩니다. (B3, B4, B5)
+ <br />
+ B4 문서의 경우 GTTPreDwg, GTTWorkingDwg 이슈 스테이지가 자동 생성됩니다.
+ </div>
+
+ {isImporting && (
+ <div className="space-y-2">
+ <div className="flex items-center justify-between text-sm">
+ <span>진행률</span>
+ <span>{importProgress}%</span>
+ </div>
+ <Progress value={importProgress} className="h-2" />
+ </div>
+ )}
+ </div>
+ )}
+
+ <div className="flex justify-end gap-2">
+ <Button
+ variant="outline"
+ onClick={() => setIsDialogOpen(false)}
+ disabled={isImporting}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleImport}
+ disabled={isImporting || !canImport}
+ >
+ {isImporting ? (
+ <>
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ 가져오는 중...
+ </>
+ ) : (
+ <>
+ <Download className="w-4 h-4 mr-2" />
+ 가져오기 시작
+ </>
+ )}
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/table/swp-workflow-panel.tsx b/lib/vendor-document-list/table/swp-workflow-panel.tsx
new file mode 100644
index 00000000..ded306e7
--- /dev/null
+++ b/lib/vendor-document-list/table/swp-workflow-panel.tsx
@@ -0,0 +1,370 @@
+"use client"
+
+import * as React from "react"
+import { Send, Eye, CheckCircle, Clock, RefreshCw, AlertTriangle, Loader2 } from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import { Progress } from "@/components/ui/progress"
+import type { EnhancedDocument } from "@/types/enhanced-documents"
+
+interface SWPWorkflowPanelProps {
+ contractId: number
+ documents: EnhancedDocument[]
+ onWorkflowUpdate?: () => void
+}
+
+type WorkflowStatus =
+ | 'IDLE' // 대기 상태
+ | 'SUBMITTED' // 목록 전송됨
+ | 'UNDER_REVIEW' // 검토 중
+ | 'CONFIRMED' // 컨펌됨
+ | 'REVISION_REQUIRED' // 수정 요청됨
+ | 'RESUBMITTED' // 재전송됨
+ | 'APPROVED' // 최종 승인됨
+
+interface WorkflowState {
+ status: WorkflowStatus
+ lastUpdatedAt?: string
+ pendingActions: string[]
+ confirmationData?: any
+ revisionComments?: string[]
+ approvalData?: any
+}
+
+export function SWPWorkflowPanel({
+ contractId,
+ documents,
+ onWorkflowUpdate
+}: SWPWorkflowPanelProps) {
+ const [workflowState, setWorkflowState] = React.useState<WorkflowState | null>(null)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [actionProgress, setActionProgress] = React.useState(0)
+
+ // 워크플로우 상태 조회
+ const fetchWorkflowStatus = async () => {
+ setIsLoading(true)
+ try {
+ const response = await fetch(`/api/sync/workflow/status?contractId=${contractId}&targetSystem=SWP`)
+ if (!response.ok) throw new Error('Failed to fetch workflow status')
+
+ const status = await response.json()
+ setWorkflowState(status)
+ } catch (error) {
+ console.error('Failed to fetch workflow status:', error)
+ toast.error('워크플로우 상태를 확인할 수 없습니다')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 컴포넌트 마운트 시 상태 조회
+ React.useEffect(() => {
+ fetchWorkflowStatus()
+ }, [contractId])
+
+ // 워크플로우 액션 실행
+ const executeWorkflowAction = async (action: string) => {
+ setActionProgress(0)
+ setIsLoading(true)
+
+ try {
+ // 진행률 시뮬레이션
+ const progressInterval = setInterval(() => {
+ setActionProgress(prev => Math.min(prev + 20, 90))
+ }, 200)
+
+ const response = await fetch('/api/sync/workflow/action', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contractId,
+ targetSystem: 'SWP',
+ action,
+ documents: documents.map(doc => ({ id: doc.id, documentNo: doc.documentNo }))
+ })
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.message || 'Workflow action failed')
+ }
+
+ const result = await response.json()
+
+ clearInterval(progressInterval)
+ setActionProgress(100)
+
+ setTimeout(() => {
+ setActionProgress(0)
+
+ if (result?.success) {
+ toast.success(
+ `${getActionLabel(action)} 완료`,
+ { description: result?.message || '워크플로우가 성공적으로 진행되었습니다.' }
+ )
+ } else {
+ toast.error(
+ `${getActionLabel(action)} 실패`,
+ { description: result?.message || '워크플로우 실행에 실패했습니다.' }
+ )
+ }
+
+ fetchWorkflowStatus() // 상태 갱신
+ onWorkflowUpdate?.()
+ }, 500)
+
+ } catch (error) {
+ setActionProgress(0)
+
+ toast.error(`${getActionLabel(action)} 실패`, {
+ description: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const getActionLabel = (action: string): string => {
+ switch (action) {
+ case 'SUBMIT_LIST': return '목록 전송'
+ case 'CHECK_CONFIRMATION': return '컨펌 확인'
+ case 'RESUBMIT_REVISED': return '수정본 재전송'
+ case 'CHECK_APPROVAL': return '승인 확인'
+ default: return action
+ }
+ }
+
+ const getStatusBadge = () => {
+ if (isLoading) {
+ return <Badge variant="secondary">확인 중...</Badge>
+ }
+
+ if (!workflowState) {
+ return <Badge variant="destructive">오류</Badge>
+ }
+
+ switch (workflowState.status) {
+ case 'IDLE':
+ return <Badge variant="secondary">대기</Badge>
+ case 'SUBMITTED':
+ return (
+ <Badge variant="default" className="gap-1 bg-blue-500">
+ <Clock className="w-3 h-3" />
+ 전송됨
+ </Badge>
+ )
+ case 'UNDER_REVIEW':
+ return (
+ <Badge variant="default" className="gap-1 bg-yellow-500">
+ <Eye className="w-3 h-3" />
+ 검토 중
+ </Badge>
+ )
+ case 'CONFIRMED':
+ return (
+ <Badge variant="default" className="gap-1 bg-green-500">
+ <CheckCircle className="w-3 h-3" />
+ 컨펌됨
+ </Badge>
+ )
+ case 'REVISION_REQUIRED':
+ return (
+ <Badge variant="destructive" className="gap-1">
+ <AlertTriangle className="w-3 h-3" />
+ 수정 요청
+ </Badge>
+ )
+ case 'RESUBMITTED':
+ return (
+ <Badge variant="default" className="gap-1 bg-orange-500">
+ <RefreshCw className="w-3 h-3" />
+ 재전송됨
+ </Badge>
+ )
+ case 'APPROVED':
+ return (
+ <Badge variant="default" className="gap-1 bg-green-600">
+ <CheckCircle className="w-3 h-3" />
+ 승인 완료
+ </Badge>
+ )
+ default:
+ return <Badge variant="secondary">알 수 없음</Badge>
+ }
+ }
+
+ const getAvailableActions = (): string[] => {
+ if (!workflowState) return []
+
+ switch (workflowState.status) {
+ case 'IDLE':
+ return ['SUBMIT_LIST']
+ case 'SUBMITTED':
+ return ['CHECK_CONFIRMATION']
+ case 'UNDER_REVIEW':
+ return ['CHECK_CONFIRMATION']
+ case 'CONFIRMED':
+ return [] // 컨펌되면 자동으로 다음 단계로
+ case 'REVISION_REQUIRED':
+ return ['RESUBMIT_REVISED']
+ case 'RESUBMITTED':
+ return ['CHECK_APPROVAL']
+ case 'APPROVED':
+ return [] // 완료 상태
+ default:
+ return []
+ }
+ }
+
+ const availableActions = getAvailableActions()
+
+ return (
+ <Popover>
+ <PopoverTrigger asChild>
+ <div className="flex items-center gap-3">
+ <Button
+ variant="outline"
+ size="sm"
+ className="flex items-center border-orange-200 hover:bg-orange-50"
+ disabled={isLoading}
+ >
+ {isLoading ? (
+ <Loader2 className="w-4 h-4 animate-spin" />
+ ) : (
+ <RefreshCw className="w-4 h-4" />
+ )}
+ <span className="hidden sm:inline">SWP 워크플로우</span>
+ {workflowState?.pendingActions && workflowState.pendingActions.length > 0 && (
+ <Badge
+ variant="destructive"
+ className="h-5 w-5 p-0 text-xs flex items-center justify-center"
+ >
+ {workflowState.pendingActions.length}
+ </Badge>
+ )}
+ </Button>
+ </div>
+ </PopoverTrigger>
+
+ <PopoverContent className="w-80">
+ <div className="space-y-4">
+ <div className="space-y-2">
+ <h4 className="font-medium">SWP 워크플로우 상태</h4>
+ <div className="flex items-center justify-between">
+ <span className="text-sm text-muted-foreground">현재 상태</span>
+ {getStatusBadge()}
+ </div>
+ </div>
+
+ {workflowState && (
+ <div className="space-y-3">
+ <Separator />
+
+ {/* 대기 중인 액션들 */}
+ {workflowState.pendingActions && workflowState.pendingActions.length > 0 && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium">대기 중인 작업</div>
+ {workflowState.pendingActions.map((action, index) => (
+ <Badge key={index} variant="outline" className="mr-1">
+ {getActionLabel(action)}
+ </Badge>
+ ))}
+ </div>
+ )}
+
+ {/* 수정 요청 사항 */}
+ {workflowState.revisionComments && workflowState.revisionComments.length > 0 && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-red-600">수정 요청 사항</div>
+ <div className="text-xs text-muted-foreground space-y-1">
+ {workflowState.revisionComments.map((comment, index) => (
+ <div key={index} className="p-2 bg-red-50 rounded text-red-700">
+ {comment}
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 마지막 업데이트 시간 */}
+ {workflowState.lastUpdatedAt && (
+ <div className="text-sm">
+ <div className="text-muted-foreground">마지막 업데이트</div>
+ <div className="font-medium">
+ {new Date(workflowState.lastUpdatedAt).toLocaleString()}
+ </div>
+ </div>
+ )}
+
+ {/* 진행률 표시 */}
+ {isLoading && actionProgress > 0 && (
+ <div className="space-y-2">
+ <div className="flex items-center justify-between text-sm">
+ <span>진행률</span>
+ <span>{actionProgress}%</span>
+ </div>
+ <Progress value={actionProgress} className="h-2" />
+ </div>
+ )}
+ </div>
+ )}
+
+ <Separator />
+
+ {/* 액션 버튼들 */}
+ <div className="space-y-2">
+ {availableActions.length > 0 ? (
+ availableActions.map((action) => (
+ <Button
+ key={action}
+ onClick={() => executeWorkflowAction(action)}
+ disabled={isLoading}
+ className="w-full justify-start"
+ size="sm"
+ variant={action.includes('SUBMIT') || action.includes('RESUBMIT') ? 'default' : 'outline'}
+ >
+ {isLoading ? (
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ ) : (
+ <Send className="w-4 h-4 mr-2" />
+ )}
+ {getActionLabel(action)}
+ </Button>
+ ))
+ ) : (
+ <div className="text-sm text-muted-foreground text-center py-2">
+ {workflowState?.status === 'APPROVED'
+ ? '워크플로우가 완료되었습니다.'
+ : '실행 가능한 작업이 없습니다.'}
+ </div>
+ )}
+
+ {/* 상태 새로고침 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={fetchWorkflowStatus}
+ disabled={isLoading}
+ className="w-full"
+ >
+ {isLoading ? (
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ ) : (
+ <RefreshCw className="w-4 h-4 mr-2" />
+ )}
+ 상태 새로고침
+ </Button>
+ </div>
+ </div>
+ </PopoverContent>
+ </Popover>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/workflow-service.ts b/lib/vendor-document-list/workflow-service.ts
new file mode 100644
index 00000000..6efff012
--- /dev/null
+++ b/lib/vendor-document-list/workflow-service.ts
@@ -0,0 +1,195 @@
+// lib/vendor-document-list/workflow-service.ts
+class WorkflowService {
+ /**
+ * 워크플로우 상태 조회
+ */
+ async getWorkflowStatus(
+ contractId: number,
+ targetSystem: string = 'SWP'
+ ): Promise<any> {
+ try {
+ // 워크플로우 상태를 DB나 외부 시스템에서 조회
+ // 실제 구현에서는 workflow_states 테이블이나 외부 API를 사용
+
+ const mockStatus = {
+ status: 'IDLE',
+ lastUpdatedAt: new Date().toISOString(),
+ pendingActions: ['SUBMIT_LIST'],
+ confirmationData: null,
+ revisionComments: [],
+ approvalData: null
+ }
+
+ return mockStatus
+
+ } catch (error) {
+ console.error('Failed to get workflow status:', error)
+ throw error
+ }
+ }
+
+ /**
+ * 워크플로우 액션 실행
+ */
+ async executeWorkflowAction(
+ contractId: number,
+ targetSystem: string,
+ action: string,
+ documents: any[],
+ userId: number,
+ userName: string
+ ): Promise<any> {
+ try {
+ console.log(`Executing workflow action: ${action} for contract ${contractId}`)
+
+ switch (action) {
+ case 'SUBMIT_LIST':
+ return await this.submitDocumentList(contractId, targetSystem, documents, userId, userName)
+
+ case 'CHECK_CONFIRMATION':
+ return await this.checkConfirmation(contractId, targetSystem)
+
+ case 'RESUBMIT_REVISED':
+ return await this.resubmitRevisedList(contractId, targetSystem, documents, userId, userName)
+
+ case 'CHECK_APPROVAL':
+ return await this.checkApproval(contractId, targetSystem)
+
+ default:
+ throw new Error(`Unknown workflow action: ${action}`)
+ }
+
+ } catch (error) {
+ console.error('Workflow action failed:', error)
+ throw error
+ }
+ }
+
+ /**
+ * 문서 목록 전송
+ */
+ private async submitDocumentList(
+ contractId: number,
+ targetSystem: string,
+ documents: any[],
+ userId: number,
+ userName: string
+ ): Promise<any> {
+ // SWP 시스템으로 문서 목록 전송
+ const endpoint = process.env.SWP_SUBMIT_URL
+ if (!endpoint) {
+ throw new Error('SWP submit endpoint not configured')
+ }
+
+ const payload = {
+ contractId,
+ documents: documents.map(doc => ({
+ documentNo: doc.documentNo,
+ documentName: doc.documentName,
+ documentType: doc.documentType,
+ status: doc.documentStatus
+ })),
+ submittedBy: userName,
+ submittedAt: new Date().toISOString()
+ }
+
+ const response = await fetch(endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Basic ${Buffer.from(`${process.env.SWP_USER}:${process.env.SWP_PASSWORD}`).toString('base64')}`
+ },
+ body: JSON.stringify(payload)
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`SWP submit failed: HTTP ${response.status} - ${errorText}`)
+ }
+
+ const result = await response.json()
+
+ // 워크플로우 상태 업데이트 (DB 저장)
+ // await this.updateWorkflowStatus(contractId, targetSystem, 'SUBMITTED', result)
+
+ return {
+ success: true,
+ message: '문서 목록이 성공적으로 전송되었습니다.',
+ submissionId: result.submissionId
+ }
+ }
+
+ /**
+ * 컨펌 상태 확인
+ */
+ private async checkConfirmation(
+ contractId: number,
+ targetSystem: string
+ ): Promise<any> {
+ // SWP 시스템에서 컨펌 상태 조회
+ const endpoint = process.env.SWP_STATUS_URL
+ if (!endpoint) {
+ throw new Error('SWP status endpoint not configured')
+ }
+
+ const response = await fetch(`${endpoint}?contractId=${contractId}`, {
+ method: 'GET',
+ headers: {
+ 'Authorization': `Basic ${Buffer.from(`${process.env.SWP_USER}:${process.env.SWP_PASSWORD}`).toString('base64')}`
+ }
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ throw new Error(`SWP status check failed: HTTP ${response.status} - ${errorText}`)
+ }
+
+ const result = await response.json()
+
+ // 상태에 따른 다음 액션 결정
+ let nextStatus = 'UNDER_REVIEW'
+ let message = '검토가 진행 중입니다.'
+
+ if (result.status === 'CONFIRMED') {
+ nextStatus = 'CONFIRMED'
+ message = '문서 목록이 컨펌되었습니다.'
+ } else if (result.status === 'REVISION_REQUIRED') {
+ nextStatus = 'REVISION_REQUIRED'
+ message = '수정이 요청되었습니다.'
+ }
+
+ return {
+ success: true,
+ message,
+ status: nextStatus,
+ confirmationData: result
+ }
+ }
+
+ /**
+ * 수정된 목록 재전송
+ */
+ private async resubmitRevisedList(
+ contractId: number,
+ targetSystem: string,
+ documents: any[],
+ userId: number,
+ userName: string
+ ): Promise<any> {
+ // 수정된 문서 목록 재전송 로직
+ return await this.submitDocumentList(contractId, targetSystem, documents, userId, userName)
+ }
+
+ /**
+ * 최종 승인 확인
+ */
+ private async checkApproval(
+ contractId: number,
+ targetSystem: string
+ ): Promise<any> {
+ // 최종 승인 상태 확인 로직
+ return await this.checkConfirmation(contractId, targetSystem)
+ }
+}
+
+export const workflowService = new WorkflowService() \ No newline at end of file
diff --git a/lib/welding/repository.ts b/lib/welding/repository.ts
new file mode 100644
index 00000000..10e64f58
--- /dev/null
+++ b/lib/welding/repository.ts
@@ -0,0 +1,49 @@
+// src/lib/tasks/repository.ts
+import db from "@/db/db";
+import { ocrRows } from "@/db/schema";
+import {
+ eq,
+ inArray,
+ not,
+ asc,
+ desc,
+ and,
+ ilike,
+ gte,
+ lte,
+ count,
+ gt,
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+
+/**
+ * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시
+ * - 트랜잭션(tx)을 받아서 사용하도록 구현
+ */
+export async function selectOcrRows(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any; // drizzle-orm의 조건식 (and, eq...) 등
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ }
+ ) {
+ const { where, orderBy, offset = 0, limit = 10 } = params;
+
+ return tx
+ .select()
+ .from(ocrRows)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+ }
+/** 총 개수 count */
+export async function countOcrRows(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(ocrRows).where(where);
+ return res[0]?.count ?? 0;
+}
diff --git a/lib/welding/service.ts b/lib/welding/service.ts
new file mode 100644
index 00000000..3dce07f8
--- /dev/null
+++ b/lib/welding/service.ts
@@ -0,0 +1,87 @@
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { tagClasses } from "@/db/schema/vendorData";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm";
+import { GetOcrRowSchema } from "./validation";
+import { ocrRows } from "@/db/schema";
+import { countOcrRows, selectOcrRows } from "./repository";
+
+export async function getOcrRows(input: GetOcrRowSchema) {
+ // return unstable_cache(
+ // async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // const advancedTable = input.flags.includes("advancedTable");
+ const advancedTable = true;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: ocrRows,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(ocrRows.reportNo, s),
+ ilike(ocrRows.identificationNo, s),
+ ilike(ocrRows.tagNo, s),
+ ilike(ocrRows.jointNo, s)
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ const conditions = [];
+ if (advancedWhere) conditions.push(advancedWhere);
+ if (globalWhere) conditions.push(globalWhere);
+
+ let finalWhere;
+ if (conditions.length > 0) {
+ finalWhere = conditions.length > 1 ? and(...conditions) : conditions[0];
+ }
+
+ // 아니면 ilike, inArray, gte 등으로 where 절 구성
+ const where = finalWhere
+
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(ocrRows[item.id]) : asc(ocrRows[item.id])
+ )
+ : [asc(ocrRows.createdAt)];
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectOcrRows(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countOcrRows(tx, where);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ // },
+ // [JSON.stringify(input)], // 캐싱 키
+ // {
+ // revalidate: 3600,
+ // tags: ["equip-class"], // revalidateTag("items") 호출 시 무효화
+ // }
+ // )();
+ } \ No newline at end of file
diff --git a/lib/welding/table/ocr-table-columns.tsx b/lib/welding/table/ocr-table-columns.tsx
new file mode 100644
index 00000000..85830405
--- /dev/null
+++ b/lib/welding/table/ocr-table-columns.tsx
@@ -0,0 +1,312 @@
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { ArrowUpDown, Copy, MoreHorizontal } from "lucide-react"
+
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import { toast } from "sonner"
+import { formatDate } from "@/lib/utils"
+import { OcrRow } from "@/db/schema"
+import { type DataTableRowAction } from "@/types/table"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<OcrRow> | null>>
+}
+
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<OcrRow>[] {
+ return [
+ // 체크박스 컬럼
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // Report No 컬럼
+ {
+ accessorKey: "reportNo",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Report No" />
+ ),
+ cell: ({ getValue }) => {
+ const reportNo = getValue() as string
+ return (
+ <div className="flex items-center gap-2">
+ <Badge variant="outline" className="font-mono text-xs">
+ {reportNo || "N/A"}
+ </Badge>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-6"
+ onClick={() => {
+ navigator.clipboard.writeText(reportNo || "")
+ toast.success("Report No copied to clipboard")
+ }}
+ >
+ <Copy className="size-3" />
+ </Button>
+ </div>
+ )
+ },
+ enableSorting: true,
+ enableHiding: false,
+ },
+
+ // No 컬럼
+ {
+ accessorKey: "no",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="No" />
+ ),
+ cell: ({ getValue }) => {
+ const no = getValue() as string
+ return (
+ <div className="font-medium">
+ {no || "-"}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Identification No 컬럼
+ {
+ accessorKey: "identificationNo",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Identification No" />
+ ),
+ cell: ({ getValue }) => {
+ const identificationNo = getValue() as string
+ return (
+ <div className="max-w-[200px] truncate font-mono text-sm" title={identificationNo}>
+ {identificationNo || "-"}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Tag No 컬럼
+ {
+ accessorKey: "tagNo",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Tag No" />
+ ),
+ cell: ({ getValue }) => {
+ const tagNo = getValue() as string
+ return (
+ <div className="font-mono text-sm">
+ {tagNo || "-"}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Joint No 컬럼
+ {
+ accessorKey: "jointNo",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Joint No" />
+ ),
+ cell: ({ getValue }) => {
+ const jointNo = getValue() as string
+ return (
+ <div className="font-mono text-sm">
+ {jointNo || "-"}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Joint Type 컬럼
+ {
+ accessorKey: "jointType",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Joint Type" />
+ ),
+ cell: ({ getValue }) => {
+ const jointType = getValue() as string
+ return (
+ <Badge variant={jointType === "B" ? "default" : "secondary"}>
+ {jointType || "N/A"}
+ </Badge>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Welding Date 컬럼
+ {
+ accessorKey: "weldingDate",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Welding Date" />
+ ),
+ cell: ({ getValue }) => {
+ const weldingDate = getValue() as string
+ return (
+ <div className="text-sm">
+ {weldingDate || "-"}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Confidence 컬럼
+ {
+ accessorKey: "confidence",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Confidence" />
+ ),
+ cell: ({ getValue }) => {
+ const confidence = parseFloat(getValue() as string) || 0
+ const percentage = Math.round(confidence * 100)
+
+ let variant: "default" | "secondary" | "destructive" = "default"
+ if (percentage < 70) variant = "destructive"
+ else if (percentage < 90) variant = "secondary"
+
+ return (
+ <Badge variant={variant}>
+ {percentage}%
+ </Badge>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Source Table 컬럼
+ {
+ accessorKey: "sourceTable",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Table" />
+ ),
+ cell: ({ getValue }) => {
+ const sourceTable = getValue() as number
+ return (
+ <div className="text-center">
+ <Badge variant="outline" className="text-xs">
+ T{sourceTable}
+ </Badge>
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Source Row 컬럼
+ {
+ accessorKey: "sourceRow",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Row" />
+ ),
+ cell: ({ getValue }) => {
+ const sourceRow = getValue() as number
+ return (
+ <div className="text-center text-sm text-muted-foreground">
+ {sourceRow}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Created At 컬럼
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="생성일" />
+ ),
+ cell: ({ cell }) => {
+ const date = cell.getValue() as Date
+ return (
+ <div className="text-sm text-muted-foreground">
+ {formatDate(date)}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+
+ // Actions 컬럼
+ {
+ id: "actions",
+ cell: ({ row }) => (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <MoreHorizontal className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onClick={() => {
+ const rowData = row.original
+ navigator.clipboard.writeText(JSON.stringify(rowData, null, 2))
+ toast.success("Row data copied to clipboard")
+ }}
+ >
+ <Copy className="mr-2 size-4" aria-hidden="true" />
+ Copy Row Data
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onClick={() => {
+ setRowAction({ type: "view", row })
+ }}
+ >
+ View Details
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() => {
+ setRowAction({ type: "delete", row })
+ }}
+ className="text-destructive focus:text-destructive"
+ >
+ Delete
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/welding/table/ocr-table-toolbar-actions.tsx b/lib/welding/table/ocr-table-toolbar-actions.tsx
new file mode 100644
index 00000000..001b21cb
--- /dev/null
+++ b/lib/welding/table/ocr-table-toolbar-actions.tsx
@@ -0,0 +1,297 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, RefreshCcw, Upload, FileText, Loader2 } from "lucide-react"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { Progress } from "@/components/ui/progress"
+import { Badge } from "@/components/ui/badge"
+import { toast } from "sonner"
+import { OcrRow } from "@/db/schema"
+
+interface OcrTableToolbarActionsProps {
+ table: Table<OcrRow>
+}
+
+interface UploadProgress {
+ stage: string
+ progress: number
+ message: string
+}
+
+export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isUploading, setIsUploading] = React.useState(false)
+ const [uploadProgress, setUploadProgress] = React.useState<UploadProgress | null>(null)
+ const [isUploadDialogOpen, setIsUploadDialogOpen] = React.useState(false)
+ const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+
+ const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const file = event.target.files?.[0]
+ if (file) {
+ setSelectedFile(file)
+ }
+ }
+
+ const validateFile = (file: File): string | null => {
+ // 파일 크기 체크 (10MB)
+ if (file.size > 10 * 1024 * 1024) {
+ return "File size must be less than 10MB"
+ }
+
+ // 파일 타입 체크
+ const allowedTypes = [
+ 'application/pdf',
+ 'image/jpeg',
+ 'image/jpg',
+ 'image/png',
+ 'image/tiff',
+ 'image/bmp'
+ ]
+
+ if (!allowedTypes.includes(file.type)) {
+ return "Only PDF and image files (JPG, PNG, TIFF, BMP) are supported"
+ }
+
+ return null
+ }
+
+ const uploadFile = async () => {
+ if (!selectedFile) {
+ toast.error("Please select a file first")
+ return
+ }
+
+ const validationError = validateFile(selectedFile)
+ if (validationError) {
+ toast.error(validationError)
+ return
+ }
+
+ try {
+ setIsUploading(true)
+ setUploadProgress({
+ stage: "preparing",
+ progress: 10,
+ message: "Preparing file upload..."
+ })
+
+ const formData = new FormData()
+ formData.append('file', selectedFile)
+
+ setUploadProgress({
+ stage: "uploading",
+ progress: 30,
+ message: "Uploading file and processing..."
+ })
+
+ const response = await fetch('/api/ocr/enhanced', {
+ method: 'POST',
+ body: formData,
+ })
+
+ setUploadProgress({
+ stage: "processing",
+ progress: 70,
+ message: "Analyzing document with OCR..."
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.error || 'OCR processing failed')
+ }
+
+ const result = await response.json()
+
+ setUploadProgress({
+ stage: "saving",
+ progress: 90,
+ message: "Saving results to database..."
+ })
+
+ if (result.success) {
+ setUploadProgress({
+ stage: "complete",
+ progress: 100,
+ message: "OCR processing completed successfully!"
+ })
+
+ toast.success(
+ `OCR completed! Extracted ${result.metadata.totalRows} rows from ${result.metadata.totalTables} tables`,
+ {
+ description: result.warnings?.length
+ ? `Warnings: ${result.warnings.join(', ')}`
+ : undefined
+ }
+ )
+
+ // 성공 후 다이얼로그 닫기 및 상태 초기화
+ setTimeout(() => {
+ setIsUploadDialogOpen(false)
+ setSelectedFile(null)
+ setUploadProgress(null)
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ''
+ }
+
+ // 테이블 새로고침
+ window.location.reload()
+ }, 2000)
+
+ } else {
+ throw new Error(result.error || 'Unknown error occurred')
+ }
+
+ } catch (error) {
+ console.error('Error uploading file:', error)
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : 'An error occurred while processing the file'
+ )
+ setUploadProgress(null)
+ } finally {
+ setIsUploading(false)
+ }
+ }
+
+ const resetUpload = () => {
+ setSelectedFile(null)
+ setUploadProgress(null)
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ''
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* OCR 업로드 다이얼로그 */}
+ <Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}>
+ <DialogTrigger asChild>
+ <Button variant="samsung" size="sm" className="gap-2">
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Upload OCR</span>
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle>Upload Document for OCR</DialogTitle>
+ <DialogDescription>
+ Upload a PDF or image file to extract table data using OCR technology.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 파일 선택 */}
+ <div className="space-y-2">
+ <Label htmlFor="file-upload">Select File</Label>
+ <Input
+ ref={fileInputRef}
+ id="file-upload"
+ type="file"
+ accept=".pdf,.jpg,.jpeg,.png,.tiff,.bmp"
+ onChange={handleFileSelect}
+ disabled={isUploading}
+ />
+ <p className="text-xs text-muted-foreground">
+ Supported formats: PDF, JPG, PNG, TIFF, BMP (Max 10MB)
+ </p>
+ </div>
+
+ {/* 선택된 파일 정보 */}
+ {selectedFile && (
+ <div className="rounded-lg border p-3 space-y-2">
+ <div className="flex items-center gap-2">
+ <FileText className="size-4 text-muted-foreground" />
+ <span className="text-sm font-medium">{selectedFile.name}</span>
+ </div>
+ <div className="flex items-center gap-4 text-xs text-muted-foreground">
+ <span>Size: {(selectedFile.size / 1024 / 1024).toFixed(2)} MB</span>
+ <span>Type: {selectedFile.type}</span>
+ </div>
+ </div>
+ )}
+
+ {/* 업로드 진행상황 */}
+ {uploadProgress && (
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <span className="text-sm font-medium">Processing...</span>
+ <Badge variant={uploadProgress.stage === "complete" ? "default" : "secondary"}>
+ {uploadProgress.stage}
+ </Badge>
+ </div>
+ <Progress value={uploadProgress.progress} className="h-2" />
+ <p className="text-xs text-muted-foreground">
+ {uploadProgress.message}
+ </p>
+ </div>
+ )}
+
+ {/* 액션 버튼들 */}
+ <div className="flex justify-end gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ if (isUploading) {
+ // 업로드 중이면 취소 불가능하다는 메시지
+ toast.warning("Cannot cancel while processing. Please wait...")
+ } else {
+ setIsUploadDialogOpen(false)
+ resetUpload()
+ }
+ }}
+ disabled={isUploading && uploadProgress?.stage !== "complete"}
+ >
+ {isUploading ? "Close" : "Cancel"}
+ </Button>
+ <Button
+ size="sm"
+ onClick={uploadFile}
+ disabled={!selectedFile || isUploading}
+ className="gap-2"
+ >
+ {isUploading ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <Upload className="size-4" aria-hidden="true" />
+ )}
+ {isUploading ? "Processing..." : "Start OCR"}
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ {/* Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "OCR Result",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/welding/table/ocr-table.tsx b/lib/welding/table/ocr-table.tsx
new file mode 100644
index 00000000..91af1c67
--- /dev/null
+++ b/lib/welding/table/ocr-table.tsx
@@ -0,0 +1,143 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+
+import { OcrTableToolbarActions } from "./ocr-table-toolbar-actions"
+import { getColumns } from "./ocr-table-columns"
+import { OcrRow } from "@/db/schema"
+import { getOcrRows } from "../service"
+
+interface ItemsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getOcrRows>>,
+ ]
+ >
+}
+
+export function OcrTable({ promises }: ItemsTableProps) {
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<OcrRow> | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ /**
+ * This component can render either a faceted filter or a search filter based on the `options` prop.
+ *
+ * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered.
+ *
+ * Each `option` object has the following properties:
+ * @prop {string} label - The label for the filter option.
+ * @prop {string} value - The value for the filter option.
+ * @prop {React.ReactNode} [icon] - An optional icon to display next to the label.
+ * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option.
+ */
+ const filterFields: DataTableFilterField<OcrRow>[] = [
+
+ ]
+
+ /**
+ * Advanced filter fields for the data table.
+ * These fields provide more complex filtering options compared to the regular filterFields.
+ *
+ * Key differences from regular filterFields:
+ * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'.
+ * 2. Enhanced flexibility: Allows for more precise and varied filtering options.
+ * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI.
+ * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values.
+ */
+ const advancedFilterFields: DataTableAdvancedFilterField<OcrRow>[] = [
+ {
+ id: "reportNo",
+ label: "Report No",
+ type: "text",
+ // group: "Basic Info",
+ },
+ {
+ id: "no",
+ label: "No",
+ type: "text",
+ // group: "Basic Info",
+ },
+
+
+ {
+ id: "identificationNo",
+ label: "Identification No",
+ type: "text",
+ // group: "Metadata",a
+ },
+ {
+ id: "tagNo",
+ label: "Tag No",
+ type: "text",
+ // group: "Metadata",
+ },
+ {
+ id: "jointNo",
+ label: "Joint No",
+ type: "text",
+ // group: "Metadata",
+ },
+ {
+ id: "weldingDate",
+ label: "Welding Date",
+ type: "date",
+ // group: "Metadata",
+ },
+ {
+ id: "createdAt",
+ label: "생성일",
+ type: "date",
+ // group: "Metadata",
+ },
+ ]
+
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <OcrTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </>
+ )
+}
diff --git a/lib/welding/validation.ts b/lib/welding/validation.ts
new file mode 100644
index 00000000..fe5b2cbb
--- /dev/null
+++ b/lib/welding/validation.ts
@@ -0,0 +1,36 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { OcrRow } from "@/db/schema";
+
+
+// 검색 파라미터 캐시 정의
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 확장된 타입으로 정렬 파서 사용
+ sort: getSortingStateParser<OcrRow>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 기존 필터 옵션들
+ code: parseAsString.withDefault(""),
+ label: parseAsString.withDefault(""),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+});
+
+// 타입 내보내기
+export type GetOcrRowSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;