summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-04-28 02:13:30 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-04-28 02:13:30 +0000
commitef4c533ebacc2cdc97e518f30e9a9350004fcdfb (patch)
tree345251a3ed0f4429716fa5edaa31024d8f4cb560 /lib
parent9ceed79cf32c896f8a998399bf1b296506b2cd4a (diff)
~20250428 작업사항
Diffstat (limited to 'lib')
-rw-r--r--lib/admin-users/service.ts7
-rw-r--r--lib/basic-contract/repository.ts167
-rw-r--r--lib/basic-contract/service.ts957
-rw-r--r--lib/basic-contract/status/basic-contract-columns.tsx213
-rw-r--r--lib/basic-contract/status/basic-contract-table.tsx95
-rw-r--r--lib/basic-contract/status/basicContract-table-toolbar-actions.tsx40
-rw-r--r--lib/basic-contract/template/add-basic-contract-template-dialog.tsx359
-rw-r--r--lib/basic-contract/template/basic-contract-template-columns.tsx245
-rw-r--r--lib/basic-contract/template/basic-contract-template.tsx104
-rw-r--r--lib/basic-contract/template/basicContract-table-toolbar-actions.tsx53
-rw-r--r--lib/basic-contract/template/delete-basicContract-dialog.tsx149
-rw-r--r--lib/basic-contract/template/update-basicContract-sheet.tsx300
-rw-r--r--lib/basic-contract/validations.ts87
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-columns.tsx214
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx318
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-table.tsx94
-rw-r--r--lib/basic-contract/vendor-table/basicContract-table-toolbar-actions.tsx56
-rw-r--r--lib/basic-contract/viewer/basic-contract-sign-viewer.tsx224
-rw-r--r--lib/bidding-projects/repository.ts44
-rw-r--r--lib/bidding-projects/service.ts117
-rw-r--r--lib/bidding-projects/table/project-series-dialog.tsx133
-rw-r--r--lib/bidding-projects/table/projects-table-columns.tsx102
-rw-r--r--lib/bidding-projects/table/projects-table-toolbar-actions.tsx89
-rw-r--r--lib/bidding-projects/table/projects-table.tsx156
-rw-r--r--lib/bidding-projects/validation.ts32
-rw-r--r--lib/cbe/table/cbe-table-columns.tsx241
-rw-r--r--lib/cbe/table/cbe-table-toolbar-actions.tsx72
-rw-r--r--lib/cbe/table/cbe-table.tsx192
-rw-r--r--lib/cbe/table/comments-sheet.tsx345
-rw-r--r--lib/cbe/table/invite-vendors-dialog.tsx428
-rw-r--r--lib/equip-class/service.ts1
-rw-r--r--lib/form-list/repository.ts2
-rw-r--r--lib/form-list/service.ts2
-rw-r--r--lib/form-list/table/formLists-table-toolbar-actions.tsx129
-rw-r--r--lib/form-list/table/formLists-table.tsx7
-rw-r--r--lib/form-list/table/meta-sheet.tsx85
-rw-r--r--lib/forms/services.ts637
-rw-r--r--lib/items/service.ts84
-rw-r--r--lib/items/table/import-excel-button.tsx266
-rw-r--r--lib/items/table/import-item-handler.tsx118
-rw-r--r--lib/items/table/item-excel-template.tsx94
-rw-r--r--lib/items/table/items-table-columns.tsx3
-rw-r--r--lib/items/table/items-table-toolbar-actions.tsx155
-rw-r--r--lib/mail/layouts/base.hbs22
-rw-r--r--lib/mail/mailer.ts39
-rw-r--r--lib/mail/partials/footer.hbs8
-rw-r--r--lib/mail/partials/header.hbs7
-rw-r--r--lib/mail/templates/admin-created.hbs91
-rw-r--r--lib/mail/templates/admin-email-changed.hbs108
-rw-r--r--lib/mail/templates/cbe-invitation.hbs108
-rw-r--r--lib/mail/templates/contract-sign-request.hbs116
-rw-r--r--lib/mail/templates/investigation-request.hbs31
-rw-r--r--lib/mail/templates/otp.hbs92
-rw-r--r--lib/mail/templates/pq-submitted-admin.hbs84
-rw-r--r--lib/mail/templates/pq-submitted-vendor.hbs93
-rw-r--r--lib/mail/templates/pq.hbs86
-rw-r--r--lib/mail/templates/project-pq.hbs99
-rw-r--r--lib/mail/templates/rfq-invite.hbs145
-rw-r--r--lib/mail/templates/vendor-active.hbs76
-rw-r--r--lib/mail/templates/vendor-additional-info.hbs89
-rw-r--r--lib/mail/templates/vendor-invitation.hbs104
-rw-r--r--lib/mail/templates/vendor-pq-comment.hbs165
-rw-r--r--lib/mail/templates/vendor-pq-status.hbs69
-rw-r--r--lib/mail/templates/vendor-project-pq-status.hbs42
-rw-r--r--lib/poa/table/poa-table.tsx2
-rw-r--r--lib/poa/validations.ts2
-rw-r--r--lib/pq/service.ts77
-rw-r--r--lib/pq/table/import-pq-handler.tsx11
-rw-r--r--lib/project-avl/repository.ts49
-rw-r--r--lib/project-avl/service.ts106
-rw-r--r--lib/project-avl/table/proejctAVL-table.tsx159
-rw-r--r--lib/project-avl/table/projectAVL-table-columns.tsx104
-rw-r--r--lib/project-avl/validations.ts41
-rw-r--r--lib/rfqs/cbe-table/cbe-table-columns.tsx92
-rw-r--r--lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx67
-rw-r--r--lib/rfqs/cbe-table/cbe-table.tsx123
-rw-r--r--lib/rfqs/cbe-table/comments-sheet.tsx328
-rw-r--r--lib/rfqs/cbe-table/invite-vendors-dialog.tsx423
-rw-r--r--lib/rfqs/cbe-table/vendor-contact-dialog.tsx71
-rw-r--r--lib/rfqs/repository.ts10
-rw-r--r--lib/rfqs/service.ts1596
-rw-r--r--lib/rfqs/table/add-rfq-dialog.tsx72
-rw-r--r--lib/rfqs/table/rfqs-table.tsx2
-rw-r--r--lib/rfqs/tbe-table/comments-sheet.tsx145
-rw-r--r--lib/rfqs/tbe-table/invite-vendors-dialog.tsx39
-rw-r--r--lib/rfqs/tbe-table/tbe-result-dialog.tsx208
-rw-r--r--lib/rfqs/tbe-table/tbe-table-columns.tsx99
-rw-r--r--lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx23
-rw-r--r--lib/rfqs/tbe-table/tbe-table.tsx66
-rw-r--r--lib/rfqs/tbe-table/vendor-contact-dialog.tsx71
-rw-r--r--lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx70
-rw-r--r--lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx89
-rw-r--r--lib/rfqs/validations.ts75
-rw-r--r--lib/rfqs/vendor-table/comments-sheet.tsx10
-rw-r--r--lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx2
-rw-r--r--lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx12
-rw-r--r--lib/rfqs/vendor-table/vendors-table.tsx16
-rw-r--r--lib/sedp/get-form-tags.ts380
-rw-r--r--lib/sedp/get-tags.ts263
-rw-r--r--lib/sedp/sync-form.ts756
-rw-r--r--lib/sedp/sync-object-class.ts257
-rw-r--r--lib/sedp/sync-projects.ts7
-rw-r--r--lib/sedp/sync-tag-types.ts88
-rw-r--r--lib/tag-numbering/service.ts4
-rw-r--r--lib/tag-numbering/table/meta-sheet.tsx2
-rw-r--r--lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx2
-rw-r--r--lib/tag-numbering/table/tagNumbering-table.tsx10
-rw-r--r--lib/tags/service.ts337
-rw-r--r--lib/tags/table/add-tag-dialog.tsx32
-rw-r--r--lib/tags/table/tag-table.tsx18
-rw-r--r--lib/tags/table/tags-export.tsx5
-rw-r--r--lib/tags/table/tags-table-toolbar-actions.tsx178
-rw-r--r--lib/tags/table/update-tag-sheet.tsx3
-rw-r--r--lib/tasks/service.ts1
-rw-r--r--lib/tbe/table/comments-sheet.tsx15
-rw-r--r--lib/tbe/table/invite-vendors-dialog.tsx8
-rw-r--r--lib/tbe/table/tbe-result-dialog.tsx208
-rw-r--r--lib/tbe/table/tbe-table-columns.tsx103
-rw-r--r--lib/tbe/table/tbe-table-toolbar-actions.tsx28
-rw-r--r--lib/tbe/table/tbe-table.tsx81
-rw-r--r--lib/tbe/table/vendor-contact-dialog.tsx71
-rw-r--r--lib/users/repository.ts16
-rw-r--r--lib/users/send-otp.ts75
-rw-r--r--lib/users/service.ts26
-rw-r--r--lib/users/verifyOtp.ts23
-rw-r--r--lib/vendor-candidates/service.ts421
-rw-r--r--lib/vendor-candidates/table/add-candidates-dialog.tsx171
-rw-r--r--lib/vendor-candidates/table/candidates-table-columns.tsx104
-rw-r--r--lib/vendor-candidates/table/candidates-table-floating-bar.tsx416
-rw-r--r--lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx4
-rw-r--r--lib/vendor-candidates/table/candidates-table.tsx19
-rw-r--r--lib/vendor-candidates/table/delete-candidates-dialog.tsx16
-rw-r--r--lib/vendor-candidates/table/excel-template-download.tsx44
-rw-r--r--lib/vendor-candidates/table/import-button.tsx78
-rw-r--r--lib/vendor-candidates/table/invite-candidates-dialog.tsx112
-rw-r--r--lib/vendor-candidates/table/update-candidate-sheet.tsx112
-rw-r--r--lib/vendor-candidates/table/view-candidate_logs-dialog.tsx246
-rw-r--r--lib/vendor-candidates/validations.ts50
-rw-r--r--lib/vendor-document/service.ts106
-rw-r--r--lib/vendor-investigation/service.ts85
-rw-r--r--lib/vendor-investigation/table/contract-dialog.tsx85
-rw-r--r--lib/vendor-investigation/table/investigation-table.tsx56
-rw-r--r--lib/vendor-investigation/table/items-dialog.tsx73
-rw-r--r--lib/vendor-rfq-response/service.ts181
-rw-r--r--lib/vendor-rfq-response/types.ts2
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx365
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx272
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx323
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx427
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx89
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx62
-rw-r--r--lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx86
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx14
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx86
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx65
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx72
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx4
-rw-r--r--lib/vendor-type/repository.ts130
-rw-r--r--lib/vendor-type/service.ts239
-rw-r--r--lib/vendor-type/table/add-vendorTypes-dialog.tsx158
-rw-r--r--lib/vendor-type/table/delete-vendorTypes-dialog.tsx149
-rw-r--r--lib/vendor-type/table/feature-flags-provider.tsx (renamed from lib/rfqs/cbe-table/feature-flags-provider.tsx)0
-rw-r--r--lib/vendor-type/table/feature-flags.tsx (renamed from lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx)44
-rw-r--r--lib/vendor-type/table/import-excel-button.tsx265
-rw-r--r--lib/vendor-type/table/import-vendorTypes-handler.tsx114
-rw-r--r--lib/vendor-type/table/update-vendorTypes-sheet.tsx151
-rw-r--r--lib/vendor-type/table/vendorTypes-excel-template.tsx78
-rw-r--r--lib/vendor-type/table/vendorTypes-table-columns.tsx179
-rw-r--r--lib/vendor-type/table/vendorTypes-table-toolbar-actions.tsx162
-rw-r--r--lib/vendor-type/table/vendorTypes-table.tsx129
-rw-r--r--lib/vendor-type/validations.ts46
-rw-r--r--lib/vendors/repository.ts34
-rw-r--r--lib/vendors/service.ts731
-rw-r--r--lib/vendors/table/approve-vendor-dialog.tsx11
-rw-r--r--lib/vendors/table/request-additional-Info-dialog.tsx22
-rw-r--r--lib/vendors/table/request-basicContract-dialog.tsx548
-rw-r--r--lib/vendors/table/request-project-pq-dialog.tsx24
-rw-r--r--lib/vendors/table/request-vendor-investigate-dialog.tsx243
-rw-r--r--lib/vendors/table/request-vendor-pg-dialog.tsx11
-rw-r--r--lib/vendors/table/update-vendor-sheet.tsx710
-rw-r--r--lib/vendors/table/vendor-all-export.ts486
-rw-r--r--lib/vendors/table/vendors-table-columns.tsx393
-rw-r--r--lib/vendors/table/vendors-table-toolbar-actions.tsx154
-rw-r--r--lib/vendors/table/vendors-table.tsx99
-rw-r--r--lib/vendors/table/view-vendors_logs-dialog.tsx244
-rw-r--r--lib/vendors/validations.ts79
186 files changed, 22891 insertions, 3135 deletions
diff --git a/lib/admin-users/service.ts b/lib/admin-users/service.ts
index 5d738d38..44111bef 100644
--- a/lib/admin-users/service.ts
+++ b/lib/admin-users/service.ts
@@ -7,6 +7,7 @@ import logger from '@/lib/logger';
import { Role, roles, users, userView, type User, type UserView } from "@/db/schema/users"; // User 테이블
import { type Company } from "@/db/schema/companies"; // User 테이블
import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm";
+import { headers } from 'next/headers';
// 레포지토리 함수들 (예시) - 아래처럼 작성했다고 가정
import {
@@ -196,7 +197,11 @@ export async function createAdminUser(input: CreateUserSchema & { language?: str
? "[eVCP] 어드민 계정이 생성되었습니다."
: "[eVCP] Admin Account Created";
- const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000'
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
+ const baseUrl = `http://${host}`
const loginUrl = userLang === "ko"
? `${baseUrl}/ko/partners`
diff --git a/lib/basic-contract/repository.ts b/lib/basic-contract/repository.ts
new file mode 100644
index 00000000..aab70106
--- /dev/null
+++ b/lib/basic-contract/repository.ts
@@ -0,0 +1,167 @@
+"use server";
+
+import { asc, count,inArray ,eq} from "drizzle-orm";
+import { basicContractTemplates, basicContractView, type BasicContractTemplate } from "@/db/schema";
+import { PgTransaction } from "drizzle-orm/pg-core";
+import db from "@/db/db";
+
+// 템플릿 목록 조회
+export async function selectBasicContractTemplates(
+ tx: PgTransaction<any, any, any>,
+ options: {
+ where?: any;
+ orderBy?: any[];
+ offset?: number;
+ limit?: number;
+ }
+) {
+ const { where, orderBy, offset, limit } = options;
+
+ return tx
+ .select()
+ .from(basicContractTemplates)
+ .where(where || undefined)
+ .orderBy(...(orderBy || [asc(basicContractTemplates.createdAt)]))
+ .offset(offset || 0)
+ .limit(limit || 50);
+}
+
+export async function selectBasicContracts(
+ tx: PgTransaction<any, any, any>,
+ options: {
+ where?: any;
+ orderBy?: any[];
+ offset?: number;
+ limit?: number;
+ }
+) {
+ const { where, orderBy, offset, limit } = options;
+
+ return tx
+ .select()
+ .from(basicContractView)
+ .where(where || undefined)
+ .orderBy(...(orderBy || [asc(basicContractView.createdAt)]))
+ .offset(offset || 0)
+ .limit(limit || 50);
+}
+
+// 템플릿 개수 조회
+export async function countBasicContractTemplates(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const result = await tx
+ .select({ count: count() })
+ .from(basicContractTemplates)
+ .where(where || undefined);
+
+ return result[0]?.count || 0;
+}
+
+export async function countBasicContracts(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const result = await tx
+ .select({ count: count() })
+ .from(basicContractView)
+ .where(where || undefined);
+
+ return result[0]?.count || 0;
+}
+
+
+// 템플릿 생성
+export async function insertBasicContractTemplate(
+ tx: PgTransaction<any, any, any>,
+ data: Omit<BasicContractTemplate, "id" | "createdAt" | "updatedAt">
+) {
+ return tx
+ .insert(basicContractTemplates)
+ .values({
+ ...data,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+}
+
+/**
+ * ID로 특정 기본 계약서 템플릿을 조회합니다.
+ * @param tx 데이터베이스 트랜잭션
+ * @param id 조회할 템플릿 ID (문자열로 받아서 숫자로 변환)
+ * @returns 조회된 템플릿 또는 에러가 있는 경우 null
+ */
+export async function getBasicContractTemplateById(
+ tx: PgTransaction<any, any, any>,
+ id: number
+): Promise<{ data: BasicContractTemplate | null; error: string | null }> {
+ try {
+
+ const templates = await tx
+ .select()
+ .from(basicContractTemplates)
+ .where(eq(basicContractTemplates.id, id));
+
+ if (!templates || templates.length === 0) {
+ return { data: null, error: null };
+ }
+
+ return { data: templates[0], error: null };
+ } catch (error) {
+ console.error(`템플릿 조회 중 오류 발생 (ID: ${id}):`, error);
+ return {
+ data: null,
+ error: error instanceof Error ? error.message : "템플릿 조회 중 오류가 발생했습니다."
+ };
+ }
+}
+
+/**
+ * 여러 기본 계약서 템플릿을 ID 배열 기반으로 삭제합니다.
+ * @param tx 데이터베이스 트랜잭션
+ * @param ids 삭제할 템플릿 ID 배열 (문자열로 받아서 숫자로 변환)
+ * @returns 삭제된 템플릿 배열 또는 에러 정보
+ */
+export async function deleteBasicContractTemplates(
+ tx: PgTransaction<any, any, any>,
+ ids: number[]
+): Promise<{ data: BasicContractTemplate[] | null; error: string | null }> {
+ if (!ids || ids.length === 0) {
+ return { data: [], error: null };
+ }
+
+ try {
+
+
+ // 삭제될 템플릿 정보를 반환하기 위해 먼저 조회
+ const templatesBeforeDelete = await tx
+ .select()
+ .from(basicContractTemplates)
+ .where(inArray(basicContractTemplates.id, ids));
+
+ // 삭제 실행
+ const deletedTemplates = await tx
+ .delete(basicContractTemplates)
+ .where(inArray(basicContractTemplates.id, ids))
+ .returning();
+
+ return {
+ data: templatesBeforeDelete.length > 0 ? templatesBeforeDelete : deletedTemplates,
+ error: null
+ };
+ } catch (error) {
+ console.error("템플릿 삭제 중 오류 발생:", error);
+ return {
+ data: null,
+ error: error instanceof Error ? error.message : "템플릿 삭제 중 오류가 발생했습니다."
+ };
+ }
+}
+
+
+
+export async function findAllTemplates(): Promise<BasicContractTemplate[]> {
+ return db.select().from(basicContractTemplates).orderBy(asc(basicContractTemplates.id));
+} \ No newline at end of file
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts
new file mode 100644
index 00000000..09f8f119
--- /dev/null
+++ b/lib/basic-contract/service.ts
@@ -0,0 +1,957 @@
+"use server";
+
+import { revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { getErrorMessage } from "@/lib/handle-error";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count } from "drizzle-orm";
+import { v4 as uuidv4 } from "uuid";
+import {
+ basicContract,
+ BasicContractTemplate,
+ basicContractTemplates,
+ basicContractView,
+ vendors,
+ type BasicContractTemplate as DBBasicContractTemplate,
+} from "@/db/schema";
+import { toast } from "sonner";
+import { promises as fs } from "fs";
+import path from "path";
+import crypto from "crypto";
+
+import {
+ GetBasicContractTemplatesSchema,
+ CreateBasicContractTemplateSchema,
+ GetBasciContractsSchema,
+} from "./validations";
+
+import {
+ insertBasicContractTemplate,
+ selectBasicContractTemplates,
+ countBasicContractTemplates,
+ deleteBasicContractTemplates,
+ getBasicContractTemplateById,
+ selectBasicContracts,
+ countBasicContracts,
+ findAllTemplates
+} from "./repository";
+import { revalidatePath } from 'next/cache';
+import { sendEmail } from "../mail/sendEmail";
+import { headers } from 'next/headers';
+import { filterColumns } from "@/lib/filter-columns";
+import { differenceInDays, addYears, isBefore } from "date-fns";
+
+
+
+// 템플릿 추가
+export async function addTemplate(
+ templateData: FormData | Omit<BasicContractTemplate, "id" | "createdAt" | "updatedAt">
+): Promise<{ success: boolean; data?: BasicContractTemplate; error?: string }> {
+ try {
+ // FormData인 경우 파일 추출 및 저장 처리
+ if (templateData instanceof FormData) {
+ const templateName = templateData.get("templateName") as string;
+ // 문자열을 숫자로 변환 (FormData의 get은 항상 string|File을 반환)
+ const validityPeriodStr = templateData.get("validityPeriod") as string;
+ const validityPeriod = validityPeriodStr ? parseInt(validityPeriodStr, 10) : 12; // 기본값 12개월
+ const status = templateData.get("status") as "ACTIVE" | "INACTIVE";
+ const file = templateData.get("file") as File;
+
+ // 유효성 검사
+ if (!templateName) {
+ return { success: false, error: "템플릿 이름은 필수입니다." };
+ }
+
+ if (!file) {
+ return { success: false, error: "파일은 필수입니다." };
+ }
+
+ if (isNaN(validityPeriod) || validityPeriod < 1 || validityPeriod > 120) {
+ return {
+ success: false,
+ error: "유효기간은 1~120개월 사이의 유효한 값이어야 합니다."
+ };
+ }
+
+ // 원본 파일 이름과 확장자 분리
+ const originalFileName = file.name;
+ const fileExtension = path.extname(originalFileName);
+ const fileNameWithoutExt = path.basename(originalFileName, fileExtension);
+
+ // 해시된 파일 이름 생성 (타임스탬프 + 랜덤 해시 + 확장자)
+ const timestamp = Date.now();
+ const randomHash = crypto.createHash('md5')
+ .update(`${fileNameWithoutExt}-${timestamp}-${Math.random()}`)
+ .digest('hex')
+ .substring(0, 8);
+
+ const hashedFileName = `${timestamp}-${randomHash}${fileExtension}`;
+
+ // 저장 디렉토리 설정 (uploads/contracts 폴더 사용)
+ const uploadDir = path.join(process.cwd(), "public", "basicContract", "template");
+
+ // 디렉토리가 없으면 생성
+ try {
+ await fs.mkdir(uploadDir, { recursive: true });
+ } catch (err) {
+ console.log("Directory already exists or creation failed:", err);
+ }
+
+ // 파일 경로 설정
+ const filePath = path.join(uploadDir, hashedFileName);
+ const publicFilePath = `/basicContract/template/${hashedFileName}`;
+
+ // 파일을 ArrayBuffer로 변환
+ const arrayBuffer = await file.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+
+ // 파일 저장
+ await fs.writeFile(filePath, buffer);
+
+ // DB에 저장할 데이터 구성
+ const formattedData = {
+ templateName,
+ status,
+ validityPeriod, // 숫자로 변환된 유효기간
+ fileName: originalFileName, // 원본 파일 이름
+ filePath: publicFilePath, // 공개 접근 가능한 경로
+ };
+
+ // DB에 저장
+ const { data, error } = await createBasicContractTemplate(formattedData);
+
+ if (error) {
+ // 파일 저장 후 DB 저장 실패 시 저장된 파일 삭제
+ try {
+ await fs.unlink(filePath);
+ } catch (unlinkError) {
+ console.error("파일 삭제 실패:", unlinkError);
+ }
+ return { success: false, error };
+ }
+
+ return { success: true, data: data || undefined };
+
+ }
+ // 기존 객체 형태인 경우 (호환성 유지)
+ else {
+ const formattedData = {
+ ...templateData,
+ status: templateData.status as "ACTIVE" | "INACTIVE",
+ // validityPeriod가 없으면 기본값 12개월 사용
+ validityPeriod: templateData.validityPeriod || 12,
+ };
+
+ const { data, error } = await createBasicContractTemplate(formattedData);
+
+ if (error) {
+ return { success: false, error };
+ }
+
+ return { success: true, data: data || undefined };
+
+ }
+ } catch (error) {
+ console.error("Template add error:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "템플릿 추가 중 오류가 발생했습니다."
+ };
+ }
+}
+// 기본 계약서 템플릿 목록 조회 (서버 액션)
+export async function getBasicContractTemplates(
+ input: GetBasicContractTemplatesSchema
+) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ const { data, total } = await db.transaction(async (tx) => {
+ // 간소화된 구현 - 실제 filterColumns 함수는 더 복잡할 수 있습니다
+ let whereCondition = undefined;
+
+ if (input.search) {
+ const s = `%${input.search}%`;
+ whereCondition = or(
+ ilike(basicContractTemplates.templateName, s),
+ ilike(basicContractTemplates.fileName, s),
+ ilike(basicContractTemplates.status, s)
+ );
+ }
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc
+ ? desc(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ basicContractTemplates[
+ item.id as keyof typeof basicContractTemplates
+ ] as any
+ )
+ : asc(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ basicContractTemplates[
+ item.id as keyof typeof basicContractTemplates
+ ] as any
+ )
+ )
+ : [desc(basicContractTemplates.createdAt)];
+
+ const dataResult = await selectBasicContractTemplates(tx, {
+ where: whereCondition,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+
+ const totalCount = await countBasicContractTemplates(
+ tx,
+ whereCondition
+ );
+ return { data: dataResult, total: totalCount };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (error) {
+ console.error("getBasicContractTemplates 에러:", error);
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 3600,
+ tags: ["basic-contract-templates"],
+ }
+ )();
+}
+
+
+// 템플릿 생성 (서버 액션)
+export async function createBasicContractTemplate(
+ input: CreateBasicContractTemplateSchema
+) {
+ unstable_noStore();
+ try {
+ const newTemplate = await db.transaction(async (tx) => {
+ const [newTemplate] = await insertBasicContractTemplate(tx, {
+ templateName: input.templateName,
+ validityPeriod: input.validityPeriod,
+ status: input.status,
+ fileName: input.fileName,
+ filePath: input.filePath,
+ });
+ return newTemplate;
+ });
+
+ // 캐시 무효화
+ revalidateTag("basic-contract-templates");
+ revalidateTag("template-status-counts");
+
+ return { data: newTemplate, error: null };
+ } catch (error) {
+ return { data: null, error: getErrorMessage(error) };
+ }
+}
+
+//서명 계약서 저장, 김기만 프로님 추가 코드
+export const saveSignedContract = async (
+ fileBuffer: ArrayBuffer,
+ templateName: string,
+ tableRowId: number
+): Promise<{ result: true } | { result: false; error: string }> => {
+ try {
+ const originalName = `${tableRowId}_${templateName}`;
+ const ext = path.extname(originalName);
+ const uniqueName = uuidv4() + ext;
+
+ const publicDir = path.join(process.cwd(), "public", "basicContract");
+ const relativePath = `/basicContract/${uniqueName}`;
+ const absolutePath = path.join(publicDir, uniqueName);
+ const buffer = Buffer.from(fileBuffer);
+
+ await fs.mkdir(publicDir, { recursive: true });
+ await fs.writeFile(absolutePath, buffer);
+
+ await db.transaction(async (tx) => {
+ await tx
+ .update(basicContract)
+ .set({
+ status: "COMPLETED",
+ fileName: originalName,
+ filePath: relativePath,
+ })
+ .where(eq(basicContract.id, tableRowId));
+ });
+ // 캐시 무효화
+ revalidateTag("basic-contract-requests");
+ revalidateTag("template-status-counts");
+
+ return { result: true };
+ } catch (err: unknown) {
+ const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류";
+ return { result: false, error: errorMessage };
+ }
+};
+
+interface RemoveTemplatesProps {
+ ids: number[];
+}
+
+
+interface TemplateFile {
+ id: number;
+ filePath: string;
+}
+
+export async function removeTemplates({
+ ids
+}: RemoveTemplatesProps): Promise<{ success?: boolean; error?: string }> {
+ if (!ids || ids.length === 0) {
+ return { error: "삭제할 템플릿이 선택되지 않았습니다." };
+ }
+
+ // unstable_noStore를 최상단에 배치
+ unstable_noStore();
+
+ try {
+ // 파일 삭제를 위한 템플릿 정보 조회 및 DB 삭제를 직접 트랜잭션으로 처리
+ // withTransaction 대신 db.transaction 직접 사용 (createBasicContractTemplate와 일관성 유지)
+ const templateFiles: TemplateFile[] = [];
+
+ const result = await db.transaction(async (tx) => {
+ // 각 템플릿의 파일 경로 가져오기
+ for (const id of ids) {
+ const { data: template, error } = await getBasicContractTemplateById(tx, id);
+ if (template && template.filePath) {
+ templateFiles.push({
+ id: template.id,
+ filePath: template.filePath
+ });
+ }
+ }
+
+ // DB에서 템플릿 삭제
+ const { data, error } = await deleteBasicContractTemplates(tx, ids);
+
+ if (error) {
+ throw new Error(`템플릿 DB 삭제 실패: ${error}`);
+ }
+
+ return { data };
+ });
+
+ // 파일 시스템 삭제는 트랜잭션 성공 후 수행
+ for (const template of templateFiles) {
+ if (template.filePath) {
+ const absoluteFilePath = path.join(process.cwd(), 'public', template.filePath);
+
+ try {
+ await fs.access(absoluteFilePath);
+ await fs.unlink(absoluteFilePath);
+ } catch (fileError) {
+ console.log(`파일 없음 또는 삭제 실패: ${template.filePath}`, fileError);
+ // 파일 삭제 실패는 전체 작업 성공에 영향 없음
+ }
+ }
+ }
+ revalidateTag("basic-contract-templates");
+ revalidateTag("template-status-counts");
+
+
+
+ // 디버깅을 위한 로그
+ console.log("캐시 무효화 완료:", ids);
+
+ return { success: true };
+ } catch (error) {
+ console.error("템플릿 삭제 중 오류 발생:", error);
+ return {
+ error: error instanceof Error
+ ? error.message
+ : "템플릿 삭제 중 오류가 발생했습니다."
+ };
+ }
+}
+
+
+interface UpdateTemplateParams {
+ id: number;
+ formData: FormData;
+}
+
+export async function updateTemplate({
+ id,
+ formData
+}: UpdateTemplateParams): Promise<{ success?: boolean; error?: string }> {
+ unstable_noStore();
+
+ try {
+ const templateName = formData.get("templateName") as string;
+ const validityPeriodStr = formData.get("validityPeriod") as string;
+ const validityPeriod = validityPeriodStr ? parseInt(validityPeriodStr, 10) : 12;
+ const status = formData.get("status") as "ACTIVE" | "INACTIVE";
+ const file = formData.get("file") as File | null;
+
+ if (!templateName) {
+ return { error: "템플릿 이름은 필수입니다." };
+ }
+
+ // 기본 업데이트 데이터
+ const updateData: Record<string, any> = {
+ templateName,
+ status,
+ validityPeriod,
+ updatedAt: new Date(),
+ };
+
+
+ // 파일이 있는 경우 처리
+ if (file) {
+ // 원본 파일 이름과 확장자 분리
+ const originalFileName = file.name;
+ const fileExtension = path.extname(originalFileName);
+ const fileNameWithoutExt = path.basename(originalFileName, fileExtension);
+
+ // 해시된 파일 이름 생성
+ const timestamp = Date.now();
+ const randomHash = crypto.createHash('md5')
+ .update(`${fileNameWithoutExt}-${timestamp}-${Math.random()}`)
+ .digest('hex')
+ .substring(0, 8);
+
+ const hashedFileName = `${timestamp}-${randomHash}${fileExtension}`;
+
+ // 저장 디렉토리 설정
+ const uploadDir = path.join(process.cwd(), "public", "basicContract", "template");
+
+ // 디렉토리가 없으면 생성
+ try {
+ await fs.mkdir(uploadDir, { recursive: true });
+ } catch (err) {
+ console.log("Directory already exists or creation failed:", err);
+ }
+
+ // 파일 경로 설정
+ const filePath = path.join(uploadDir, hashedFileName);
+ const publicFilePath = `/basicContract/template/${hashedFileName}`;
+
+ // 파일을 ArrayBuffer로 변환
+ const arrayBuffer = await file.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+
+ // 파일 저장
+ await fs.writeFile(filePath, buffer);
+
+ // 기존 파일 정보 가져오기
+ const existingTemplate = await db.query.basicContractTemplates.findFirst({
+ where: eq(basicContractTemplates.id, id)
+ });
+
+ // 기존 파일이 있다면 삭제
+ if (existingTemplate?.filePath) {
+ try {
+ const existingFilePath = path.join(process.cwd(), "public", existingTemplate.filePath);
+ await fs.access(existingFilePath); // 파일 존재 확인
+ await fs.unlink(existingFilePath); // 파일 삭제
+ } catch (error) {
+ console.log("기존 파일 삭제 실패 또는 파일이 없음:", error);
+ }
+ }
+
+ // 업데이트 데이터에 파일 정보 추가
+ updateData.fileName = originalFileName;
+ updateData.filePath = publicFilePath;
+ }
+
+ // DB 업데이트
+ await db.transaction(async (tx) => {
+ await tx
+ .update(basicContractTemplates)
+ .set(updateData)
+ .where(eq(basicContractTemplates.id, id));
+ });
+
+ // 캐시 무효화 (다양한 방법 시도)
+ revalidateTag("basic-contract-templates");
+ revalidateTag("template-status-counts");
+ revalidateTag("templates");
+
+ return { success: true };
+ } catch (error) {
+ console.error("템플릿 업데이트 오류:", error);
+ return {
+ error: error instanceof Error
+ ? error.message
+ : "템플릿 업데이트 중 오류가 발생했습니다."
+ };
+ }
+}
+
+interface RequestBasicContractInfoProps {
+ vendorIds: number[];
+ requestedBy: number;
+ templateId: number;
+}
+
+
+export async function requestBasicContractInfo({
+ vendorIds,
+ requestedBy,
+ templateId
+}: RequestBasicContractInfoProps): Promise<{ success?: boolean; error?: string }> {
+ unstable_noStore();
+
+ if (!vendorIds || vendorIds.length === 0) {
+ return { error: "요청할 협력업체가 선택되지 않았습니다." };
+ }
+
+ if (!templateId) {
+ return { error: "계약서 템플릿이 선택되지 않았습니다." };
+ }
+
+ try {
+ // 1. 선택된 템플릿 정보 가져오기
+ const template = await db.query.basicContractTemplates.findFirst({
+ where: eq(basicContractTemplates.id, templateId)
+ });
+
+ if (!template) {
+ return { error: "선택한 템플릿을 찾을 수 없습니다." };
+ }
+
+ // 2. 협력업체 정보 가져오기
+ const vendorList = await db
+ .select()
+ .from(vendors)
+ .where(inArray(vendors.id, vendorIds));
+
+ if (!vendorList || vendorList.length === 0) {
+ return { error: "선택한 협력업체 정보를 찾을 수 없습니다." };
+ }
+
+ // 3. 각 협력업체에 대해 기본계약 레코드 생성 및 이메일 발송
+ const results = await Promise.all(
+ vendorList.map(async (vendor) => {
+ if (!vendor.email) return; // 이메일이 없으면 스킵
+
+ try {
+ // 3-1. basic_contract 테이블에 레코드 추가
+ const [newContract] = await db
+ .insert(basicContract)
+ .values({
+ templateId: template.id,
+ vendorId: vendor.id,
+ requestedBy: requestedBy,
+ status: "PENDING",
+ fileName: template.fileName, // 템플릿 파일 이름 사용
+ filePath: template.filePath, // 템플릿 파일 경로 사용
+ })
+ .returning();
+
+ // 3-2. 협력업체에 이메일 발송
+ const subject = `[${process.env.COMPANY_NAME || '회사명'}] 기본계약서 서명 요청`;
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ // 로그인 또는 서명 페이지 URL 생성
+ const baseUrl = `http://${host}`
+ const loginUrl = `${baseUrl}/partners/basic-contract`;
+
+ // 사용자 언어 설정 (기본값은 한국어)
+ const userLang = "ko";
+
+ // 이메일 발송
+ await sendEmail({
+ to: vendor.email,
+ subject,
+ template: "contract-sign-request", // 이메일 템플릿 이름
+ context: {
+ vendorName: vendor.vendorName,
+ contractId: newContract.id,
+ templateName: template.templateName,
+ loginUrl,
+ language: userLang,
+ },
+ });
+
+ return { vendorId: vendor.id, success: true };
+ } catch (err) {
+ console.error(`협력업체 ${vendor.id} 처리 중 오류:`, err);
+ return { vendorId: vendor.id, success: false, error: getErrorMessage(err) };
+ }
+ })
+ );
+
+ // 4. 실패한 케이스가 있는지 확인
+ const failedVendors = results.filter(r => r && !r.success);
+
+ if (failedVendors.length > 0) {
+ console.error("일부 협력업체 처리 실패:", failedVendors);
+ if (failedVendors.length === vendorIds.length) {
+ // 모든 협력업체 처리 실패
+ return { error: "모든 협력업체에 대한 처리가 실패했습니다." };
+ } else {
+ // 일부 협력업체만 처리 실패
+ return {
+ success: true,
+ error: `${results.length - failedVendors.length}개 협력업체 처리 성공, ${failedVendors.length}개 처리 실패`
+ };
+ }
+ }
+
+ // 5. 캐시 무효화
+ revalidateTag("basic-contract-requests");
+
+ return { success: true };
+ } catch (error) {
+ console.error("기본계약서 요청 중 오류 발생:", error);
+ return {
+ error: error instanceof Error
+ ? error.message
+ : "기본계약서 요청 처리 중 오류가 발생했습니다."
+ };
+ }
+}
+
+
+export async function getBasicContracts(input: GetBasciContractsSchema) {
+ 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: basicContractView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(basicContractView.templateName, s),
+ ilike(basicContractView.vendorName, s)
+ , ilike(basicContractView.vendorCode, s)
+ , ilike(basicContractView.vendorEmail, s)
+ , ilike(basicContractView.status, s)
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ const finalWhere = and(
+ // advancedWhere or your existing conditions
+ advancedWhere,
+ globalWhere // and()함수로 결합 or or() 등으로 결합
+ )
+
+
+ // 아니면 ilike, inArray, gte 등으로 where 절 구성
+ const where = finalWhere
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(basicContractView[item.id]) : asc(basicContractView[item.id])
+ )
+ : [asc(basicContractView.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectBasicContracts(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countBasicContracts(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: ["basicContractView"], // revalidateTag("basicContractView") 호출 시 무효화
+ }
+ )();
+}
+
+
+export async function getBasicContractsByVendorId(
+ input: GetBasciContractsSchema,
+ vendorId: number
+) {
+ 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: basicContractView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(basicContractView.templateName, s),
+ ilike(basicContractView.vendorName, s),
+ ilike(basicContractView.vendorCode, s),
+ ilike(basicContractView.vendorEmail, s),
+ ilike(basicContractView.status, s)
+ );
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ // 벤더 ID 필터링 조건 추가
+ const vendorCondition = eq(basicContractView.vendorId, vendorId);
+
+ const finalWhere = and(
+ // 항상 벤더 ID 조건을 포함
+ vendorCondition,
+ // 기존 조건들
+ advancedWhere,
+ globalWhere
+ );
+
+ const where = finalWhere;
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(basicContractView[item.id]) : asc(basicContractView[item.id])
+ )
+ : [asc(basicContractView.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectBasicContracts(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countBasicContracts(tx, where);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input), String(vendorId)], // 캐싱 키에 vendorId 추가
+ {
+ revalidate: 3600,
+ tags: ["basicContractView-vendor"], // revalidateTag("basicContractView") 호출 시 무효화
+ }
+ )();
+}
+
+export async function getAllTemplates(): Promise<BasicContractTemplate[]> {
+ try {
+ return await findAllTemplates();
+ } catch (err) {
+ throw new Error("Failed to get templates");
+ }
+}
+
+
+interface VendorTemplateStatus {
+ vendorId: number;
+ vendorName: string;
+ templateId: number;
+ templateName: string;
+ status: string;
+ createdAt: Date;
+ isExpired: boolean; // 요청이 오래되었는지 (예: 30일 이상)
+ isUpdated: boolean; // 템플릿이 업데이트되었는지
+}
+
+/**
+ * 협력업체와 템플릿 조합에 대한 계약 요청 상태를 확인합니다.
+ */
+// 계약 상태 확인 API 함수
+export async function checkContractRequestStatus(
+ vendorIds: number[],
+ templateIds: number[]
+) {
+ try {
+ // 각 협력업체-템플릿 조합에 대한 최신 계약 요청 상태 확인
+ const requests = await db
+ .select({
+ id: basicContract.id,
+ vendorId: basicContract.vendorId,
+ templateId: basicContract.templateId,
+ status: basicContract.status,
+ createdAt: basicContract.createdAt,
+ updatedAt: basicContract.updatedAt,
+ // completedAt 필드 추가 필요
+ completedAt: basicContract.completedAt, // 계약 완료 날짜
+ })
+ .from(basicContract)
+ .where(
+ and(
+ inArray(basicContract.vendorId, vendorIds),
+ inArray(basicContract.templateId, templateIds)
+ )
+ )
+ .orderBy(desc(basicContract.createdAt));
+
+ // 협력업체 정보 가져오기
+ const vendorData = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, vendorIds));
+
+ // 템플릿 정보 가져오기
+ const templateData = await db
+ .select({
+ id: basicContractTemplates.id,
+ templateName: basicContractTemplates.templateName,
+ updatedAt: basicContractTemplates.updatedAt,
+ validityPeriod: basicContractTemplates.validityPeriod, // 템플릿별 유효기간(개월)
+ })
+ .from(basicContractTemplates)
+ .where(inArray(basicContractTemplates.id, templateIds));
+
+ // 데이터 가공 - 협력업체별, 템플릿별로 상태 매핑
+ const vendorMap = new Map(vendorData.map(v => [v.id, v]));
+ const templateMap = new Map(templateData.map(t => [t.id, t]));
+
+ const uniqueRequests = new Map();
+
+ // 각 협력업체-템플릿 조합에 대해 가장 최근 요청만 사용
+ requests.forEach(req => {
+ const key = `${req.vendorId}-${req.templateId}`;
+ if (!uniqueRequests.has(key)) {
+ uniqueRequests.set(key, req);
+ }
+ });
+
+ // 인터페이스를 임포트하거나 이 함수 내에서/위에서 재정의
+ interface VendorTemplateStatus {
+ vendorId: number;
+ vendorName: string;
+ templateId: number;
+ templateName: string;
+ status: string;
+ createdAt: Date;
+ completedAt?: Date;
+ isExpired: boolean;
+ isUpdated: boolean;
+ isContractExpired: boolean;
+ }
+
+ // 명시적 타입 지정
+ const statusData: VendorTemplateStatus[] = [];
+
+ // 요청 만료 기준 - 30일
+ const REQUEST_EXPIRATION_DAYS = 30;
+
+ // 기본 계약 유효기간 - 12개월 (템플릿별로 다르게 설정 가능)
+ const DEFAULT_CONTRACT_VALIDITY_MONTHS = 12;
+
+ const now = new Date();
+
+ // 모든 협력업체-템플릿 조합에 대해 상태 확인
+ vendorIds.forEach(vendorId => {
+ templateIds.forEach(templateId => {
+ const key = `${vendorId}-${templateId}`;
+ const request = uniqueRequests.get(key);
+ const vendor = vendorMap.get(vendorId);
+ const template = templateMap.get(templateId);
+
+ if (!vendor || !template) return;
+
+ let status = "NONE"; // 기본 상태: 요청 없음
+ let createdAt = new Date();
+ let completedAt = null;
+ let isExpired = false;
+ let isUpdated = false;
+ let isContractExpired = false;
+
+ if (request) {
+ status = request.status;
+ createdAt = request.createdAt;
+ completedAt = request.completedAt;
+
+ // 요청이 오래되었는지 확인 (PENDING 상태일 때만 적용)
+ if (status === "PENDING") {
+ isExpired = differenceInDays(now, createdAt) > REQUEST_EXPIRATION_DAYS;
+ }
+
+ // 요청 이후 템플릿이 업데이트되었는지 확인
+ if (template.updatedAt && request.createdAt) {
+ isUpdated = template.updatedAt > request.createdAt;
+ }
+
+ // 계약 유효기간 만료 확인 (COMPLETED 상태이고 completedAt이 있는 경우)
+ if (status === "COMPLETED" && completedAt) {
+ // 템플릿별 유효기간 또는 기본값 사용
+ const validityMonths = template.validityPeriod || DEFAULT_CONTRACT_VALIDITY_MONTHS;
+
+ // 계약 만료일 계산 (완료일 + 유효기간)
+ const expiryDate = addYears(completedAt, validityMonths / 12);
+
+ // 현재 날짜가 만료일 이후인지 확인
+ isContractExpired = isBefore(expiryDate, now);
+ }
+ }
+
+ statusData.push({
+ vendorId,
+ vendorName: vendor.vendorName,
+ templateId,
+ templateName: template.templateName,
+ status,
+ createdAt,
+ completedAt,
+ isExpired,
+ isUpdated,
+ isContractExpired,
+ });
+ });
+ });
+
+ return { data: statusData };
+ } catch (error) {
+ console.error("계약 상태 확인 중 오류:", error);
+ return {
+ data: [],
+ error: error instanceof Error ? error.message : "계약 상태 확인 중 오류가 발생했습니다."
+ };
+ }
+} \ No newline at end of file
diff --git a/lib/basic-contract/status/basic-contract-columns.tsx b/lib/basic-contract/status/basic-contract-columns.tsx
new file mode 100644
index 00000000..6ca4a096
--- /dev/null
+++ b/lib/basic-contract/status/basic-contract-columns.tsx
@@ -0,0 +1,213 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Paperclip } from "lucide-react"
+import { toast } from "sonner"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { basicContractColumnsConfig } from "@/config/basicContractColumnsConfig"
+import { BasicContractView } from "@/db/schema"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BasicContractView> | null>>
+}
+
+/**
+ * 파일 다운로드 함수
+ */
+/**
+ * 파일 다운로드 함수
+ */
+const handleFileDownload = (filePath: string | null, fileName: string | null) => {
+ if (!filePath || !fileName) {
+ toast.error("파일 정보가 없습니다.");
+ return;
+ }
+
+ try {
+ // 전체 URL 생성
+ const fullUrl = `${window.location.origin}${filePath}`;
+
+ // a 태그를 생성하여 다운로드 실행
+ const link = document.createElement('a');
+ link.href = fullUrl;
+ link.download = fileName; // 다운로드될 파일명 설정
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ toast.success("파일 다운로드를 시작합니다.");
+ } catch (error) {
+ console.error("파일 다운로드 오류:", error);
+ toast.error("파일 다운로드 중 오류가 발생했습니다.");
+ }
+};
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicContractView>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<BasicContractView> = {
+ 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"
+ />
+ ),
+ maxSize: 30,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) 파일 다운로드 컬럼 (아이콘)
+ // ----------------------------------------------------------------
+ const downloadColumn: ColumnDef<BasicContractView> = {
+ id: "download",
+ header: "",
+ cell: ({ row }) => {
+ const template = row.original;
+
+ return (
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => handleFileDownload(template.filePath, template.fileName)}
+ title={`${template.fileName} 다운로드`}
+ className="hover:bg-muted"
+ >
+ <Paperclip className="h-4 w-4" />
+ <span className="sr-only">다운로드</span>
+ </Button>
+ );
+ },
+ maxSize: 30,
+ enableSorting: false,
+ }
+
+
+ // ----------------------------------------------------------------
+ // 4) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 4-1) groupMap: { [groupName]: ColumnDef<BasicContractView>[] }
+ const groupMap: Record<string, ColumnDef<BasicContractView>[]> = {}
+
+ basicContractColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<BasicContractView> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+ // 날짜 형식 처리
+ if (cfg.id === "createdAt" || cfg.id === "updatedAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDateTime(dateVal)
+ }
+
+ // Status 컬럼에 Badge 적용
+ if (cfg.id === "status") {
+ const status = row.getValue(cfg.id) as string
+ const isActive = status === "ACTIVE"
+
+ return (
+ <Badge
+ variant={isActive ? "default" : "secondary"}
+ >
+ {isActive ? "활성" : "비활성"}
+ </Badge>
+ )
+ }
+
+ // 나머지 컬럼은 그대로 값 표시
+ return row.getValue(cfg.id) ?? ""
+ },
+ minSize: 80,
+
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 4-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<BasicContractView>[] = []
+
+ // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
+ // 여기서는 그냥 Object.entries 순서
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없음 → 그냥 최상위 레벨 컬럼
+ nestedColumns.push(...colDefs)
+ } else {
+ // 상위 컬럼
+ nestedColumns.push({
+ id: groupName,
+ header: groupName, // "Basic Info", "Metadata" 등
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 5) 최종 컬럼 배열: select, download, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ downloadColumn, // 다운로드 컬럼 추가
+ ...nestedColumns,
+ ]
+} \ No newline at end of file
diff --git a/lib/basic-contract/status/basic-contract-table.tsx b/lib/basic-contract/status/basic-contract-table.tsx
new file mode 100644
index 00000000..22845144
--- /dev/null
+++ b/lib/basic-contract/status/basic-contract-table.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import * as React from "react";
+import { DataTable } from "@/components/data-table/data-table";
+import { Button } from "@/components/ui/button";
+import { Plus, Loader2 } from "lucide-react";
+import { useDataTable } from "@/hooks/use-data-table";
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar";
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { toast } from "sonner";
+import { getColumns } from "./basic-contract-columns";
+import { getBasicContracts } from "../service";
+import { BasicContractView } from "@/db/schema";
+import { BasicContractTableToolbarActions } from "./basicContract-table-toolbar-actions";
+
+
+interface BasicTemplateTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getBasicContracts>>,
+ ]
+ >
+}
+
+
+export function BasicContractsTable({ promises }: BasicTemplateTableProps) {
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<BasicContractView> | null>(null)
+
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+ // 컬럼 설정 - 외부 파일에서 가져옴
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // config 기반으로 필터 필드 설정
+ const advancedFilterFields: DataTableAdvancedFilterField<BasicContractView>[] = [
+ { id: "templateName", label: "템플릿명", type: "text" },
+ {
+ id: "status", label: "상태", type: "select", options: [
+ { label: "활성", value: "ACTIVE" },
+ { label: "비활성", value: "INACTIVE" },
+ ]
+ },
+ { id: "userName", label: "요청자", type: "text" },
+ { id: "vendorName", label: "업체명", type: "text" },
+ { id: "vendorCode", label: "업체코드", type: "text" },
+ { id: "vendorEmail", label: "업체대표이메일", type: "text" },
+ { id: "createdAt", label: "생성일", type: "date" },
+ { id: "updatedAt", label: "수정일", type: "date" },
+ ];
+
+ 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}
+ >
+ <BasicContractTableToolbarActions table={table} />
+
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ </>
+
+ );
+} \ No newline at end of file
diff --git a/lib/basic-contract/status/basicContract-table-toolbar-actions.tsx b/lib/basic-contract/status/basicContract-table-toolbar-actions.tsx
new file mode 100644
index 00000000..cee94790
--- /dev/null
+++ b/lib/basic-contract/status/basicContract-table-toolbar-actions.tsx
@@ -0,0 +1,40 @@
+"use client"
+
+import * as React from "react"
+import { type Task } from "@/db/schema/tasks"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload } from "lucide-react"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { BasicContractView } from "@/db/schema"
+
+interface TemplateTableToolbarActionsProps {
+ table: Table<BasicContractView>
+}
+
+export function BasicContractTableToolbarActions({ table }: TemplateTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+
+
+ return (
+ <div className="flex items-center gap-2">
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "basci-contract",
+ 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/basic-contract/template/add-basic-contract-template-dialog.tsx b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx
new file mode 100644
index 00000000..cf0986f0
--- /dev/null
+++ b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx
@@ -0,0 +1,359 @@
+"use client";
+
+import * as React from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import * as z from "zod";
+import { toast } from "sonner";
+import { v4 as uuidv4 } from 'uuid';
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneUploadIcon,
+ DropzoneTitle,
+ DropzoneDescription,
+ DropzoneInput
+} from "@/components/ui/dropzone";
+import { Progress } from "@/components/ui/progress";
+import { useRouter } from "next/navigation"
+
+// 유효기간 필드가 추가된 계약서 템플릿 스키마 정의
+const templateFormSchema = z.object({
+ templateName: z.string().min(1, "템플릿 이름은 필수입니다."),
+ validityPeriod: z.coerce
+ .number({ invalid_type_error: "유효기간은 숫자여야 합니다." })
+ .int("유효기간은 정수여야 합니다.")
+ .min(1, "유효기간은 최소 1개월 이상이어야 합니다.")
+ .max(120, "유효기간은 최대 120개월(10년)을 초과할 수 없습니다.")
+ .default(12),
+ file: z
+ .instanceof(File, { message: "파일을 업로드해주세요." })
+ .refine((file) => file.size <= 100 * 1024 * 1024, {
+ message: "파일 크기는 100MB 이하여야 합니다.",
+ })
+ .refine(
+ (file) => file.type === 'application/pdf',
+ { message: "PDF 파일만 업로드 가능합니다." }
+ ),
+ status: z.enum(["ACTIVE", "DISPOSED"]).default("ACTIVE"),
+});
+
+type TemplateFormValues = z.infer<typeof templateFormSchema>;
+
+export function AddTemplateDialog() {
+ const [open, setOpen] = React.useState(false);
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [selectedFile, setSelectedFile] = React.useState<File | null>(null);
+ const [uploadProgress, setUploadProgress] = React.useState(0);
+ const [showProgress, setShowProgress] = React.useState(false);
+ const router = useRouter()
+
+ // 기본값 설정
+ const defaultValues: Partial<TemplateFormValues> = {
+ templateName: "",
+ validityPeriod: 12, // 기본값 1년
+ status: "ACTIVE",
+ };
+
+ // 폼 초기화
+ const form = useForm<TemplateFormValues>({
+ resolver: zodResolver(templateFormSchema),
+ defaultValues,
+ mode: "onChange",
+ });
+
+ // 폼 값 감시
+ const templateName = form.watch("templateName");
+ const validityPeriod = form.watch("validityPeriod");
+ const file = form.watch("file");
+
+ // 파일 선택 핸들러
+ const handleFileChange = (files: File[]) => {
+ if (files.length > 0) {
+ const file = files[0];
+ setSelectedFile(file);
+ form.setValue("file", file);
+ }
+ };
+
+ // 청크 크기 설정 (1MB)
+ const CHUNK_SIZE = 1 * 1024 * 1024;
+
+ // 파일을 청크로 분할하여 업로드하는 함수
+ const uploadFileInChunks = async (file: File, fileId: string) => {
+ const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
+ setShowProgress(true);
+ setUploadProgress(0);
+
+ for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
+ const start = chunkIndex * CHUNK_SIZE;
+ const end = Math.min(start + CHUNK_SIZE, file.size);
+ const chunk = file.slice(start, end);
+
+ const formData = new FormData();
+ formData.append('chunk', chunk);
+ formData.append('filename', file.name);
+ formData.append('chunkIndex', chunkIndex.toString());
+ formData.append('totalChunks', totalChunks.toString());
+ formData.append('fileId', fileId);
+
+ try {
+ const response = await fetch('/api/upload/basicContract/chunk', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!response.ok) {
+ throw new Error(`청크 업로드 실패: ${response.statusText}`);
+ }
+
+ // 진행률 업데이트
+ const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100);
+ setUploadProgress(progress);
+
+ const result = await response.json();
+
+ // 마지막 청크인 경우 파일 경로 반환
+ if (chunkIndex === totalChunks - 1) {
+ return result;
+ }
+ } catch (error) {
+ console.error(`청크 ${chunkIndex} 업로드 오류:`, error);
+ throw error;
+ }
+ }
+ };
+
+ // 폼 제출 핸들러
+ async function onSubmit(formData: TemplateFormValues) {
+ setIsLoading(true);
+ try {
+ if (!formData.file) {
+ throw new Error("파일이 선택되지 않았습니다.");
+ }
+
+ // 고유 파일 ID 생성
+ const fileId = uuidv4();
+
+ // 파일 청크 업로드
+ const uploadResult = await uploadFileInChunks(formData.file, fileId);
+
+ if (!uploadResult.success) {
+ throw new Error("파일 업로드에 실패했습니다.");
+ }
+
+ // 메타데이터 저장
+ const saveResponse = await fetch('/api/upload/basicContract/complete', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ templateName: formData.templateName,
+ validityPeriod: formData.validityPeriod, // 유효기간 추가
+ status: formData.status,
+ fileName: uploadResult.fileName,
+ filePath: uploadResult.filePath,
+ }),
+ next: { tags: ["basic-contract-templates"] },
+ });
+
+ const saveResult = await saveResponse.json();
+
+ if (!saveResult.success) {
+ throw new Error("템플릿 정보 저장에 실패했습니다.");
+ }
+
+ toast.success('템플릿이 성공적으로 추가되었습니다.');
+ form.reset();
+ setSelectedFile(null);
+ setOpen(false);
+ setShowProgress(false);
+
+ router.refresh();
+ } catch (error) {
+ console.error("Submit error:", error);
+ toast.error("템플릿 추가 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ // 모달이 닫힐 때 폼 초기화
+ React.useEffect(() => {
+ if (!open) {
+ form.reset();
+ setSelectedFile(null);
+ setShowProgress(false);
+ setUploadProgress(0);
+ }
+ }, [open, form]);
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset();
+ }
+ setOpen(nextOpen);
+ }
+
+ // 유효기간 선택 옵션
+ const validityOptions = [
+ { value: "3", label: "3개월" },
+ { value: "6", label: "6개월" },
+ { value: "12", label: "1년" },
+ { value: "24", label: "2년" },
+ { value: "36", label: "3년" },
+ { value: "60", label: "5년" },
+ ];
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ 템플릿 추가
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>새 기본계약서 템플릿 추가</DialogTitle>
+ <DialogDescription>
+ 템플릿 이름을 입력하고 계약서 파일을 업로드하세요.
+ <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span>
+ </DialogDescription>
+ </DialogHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="templateName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 템플릿 이름 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="템플릿 이름을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="validityPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 계약 유효기간 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select
+ value={field.value?.toString()}
+ onValueChange={(value) => field.onChange(parseInt(value))}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="유효기간을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {validityOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormDescription>
+ 계약서의 유효 기간을 설정합니다. 이 기간이 지나면 재계약이 필요합니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="file"
+ render={() => (
+ <FormItem>
+ <FormLabel>
+ 계약서 파일 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Dropzone
+ onDrop={handleFileChange}
+ accept={{
+ 'application/pdf': ['.pdf']
+ }}
+ >
+ <DropzoneZone>
+ <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" />
+ <DropzoneTitle>
+ {selectedFile ? selectedFile.name : "PDF 파일을 여기에 드래그하세요"}
+ </DropzoneTitle>
+ <DropzoneDescription>
+ {selectedFile
+ ? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`
+ : "또는 클릭하여 PDF 파일을 선택하세요 (최대 100MB)"}
+ </DropzoneDescription>
+ <DropzoneInput />
+ </DropzoneZone>
+ </Dropzone>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {showProgress && (
+ <div className="space-y-2">
+ <div className="flex justify-between text-sm">
+ <span>업로드 진행률</span>
+ <span>{uploadProgress}%</span>
+ </div>
+ <Progress value={uploadProgress} />
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isLoading || !templateName || !validityPeriod || !file}
+ >
+ {isLoading ? "처리 중..." : "추가"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/basic-contract/template/basic-contract-template-columns.tsx b/lib/basic-contract/template/basic-contract-template-columns.tsx
new file mode 100644
index 00000000..b0486fe4
--- /dev/null
+++ b/lib/basic-contract/template/basic-contract-template-columns.tsx
@@ -0,0 +1,245 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Download, Ellipsis, Paperclip } from "lucide-react"
+import { toast } from "sonner"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { basicContractTemplateColumnsConfig } from "@/config/basicContractColumnsConfig"
+import { BasicContractTemplate } from "@/db/schema"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BasicContractTemplate> | null>>
+}
+
+/**
+ * 파일 다운로드 함수
+ */
+const handleFileDownload = (filePath: string, fileName: string) => {
+ try {
+ // 전체 URL 생성
+ const fullUrl = `${window.location.origin}${filePath}`;
+
+ // a 태그를 생성하여 다운로드 실행
+ const link = document.createElement('a');
+ link.href = fullUrl;
+ link.download = fileName; // 다운로드될 파일명 설정
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ toast.success("파일 다운로드를 시작합니다.");
+ } catch (error) {
+ console.error("파일 다운로드 오류:", error);
+ toast.error("파일 다운로드 중 오류가 발생했습니다.");
+ }
+};
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicContractTemplate>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<BasicContractTemplate> = {
+ 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"
+ />
+ ),
+ maxSize: 30,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) 파일 다운로드 컬럼 (아이콘)
+ // ----------------------------------------------------------------
+ const downloadColumn: ColumnDef<BasicContractTemplate> = {
+ id: "download",
+ header: "",
+ cell: ({ row }) => {
+ const template = row.original;
+
+ return (
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => handleFileDownload(template.filePath, template.fileName)}
+ title={`${template.fileName} 다운로드`}
+ className="hover:bg-muted"
+ >
+ <Paperclip className="h-4 w-4" />
+ <span className="sr-only">다운로드</span>
+ </Button>
+ );
+ },
+ maxSize: 30,
+ enableSorting: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<BasicContractTemplate> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ maxSize: 30,
+ }
+
+ // ----------------------------------------------------------------
+ // 4) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 4-1) groupMap: { [groupName]: ColumnDef<BasicContractTemplate>[] }
+ const groupMap: Record<string, ColumnDef<BasicContractTemplate>[]> = {}
+
+ basicContractTemplateColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<BasicContractTemplate> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+ // 날짜 형식 처리
+ if (cfg.id === "createdAt" || cfg.id === "updatedAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDateTime(dateVal)
+ }
+
+ // Status 컬럼에 Badge 적용
+ if (cfg.id === "status") {
+ const status = row.getValue(cfg.id) as string
+ const isActive = status === "ACTIVE"
+
+ return (
+ <Badge
+ variant={isActive ? "default" : "secondary"}
+ >
+ {isActive ? "활성" : "비활성"}
+ </Badge>
+ )
+ }
+
+ // 나머지 컬럼은 그대로 값 표시
+ return row.getValue(cfg.id) ?? ""
+ },
+ minSize:80
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 4-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<BasicContractTemplate>[] = []
+
+ // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
+ // 여기서는 그냥 Object.entries 순서
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없음 → 그냥 최상위 레벨 컬럼
+ nestedColumns.push(...colDefs)
+ } else {
+ // 상위 컬럼
+ nestedColumns.push({
+ id: groupName,
+ header: groupName, // "Basic Info", "Metadata" 등
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 5) 최종 컬럼 배열: select, download, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ downloadColumn, // 다운로드 컬럼 추가
+ ...nestedColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/basic-contract/template/basic-contract-template.tsx b/lib/basic-contract/template/basic-contract-template.tsx
new file mode 100644
index 00000000..0cca3a41
--- /dev/null
+++ b/lib/basic-contract/template/basic-contract-template.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import * as React from "react";
+import { DataTable } from "@/components/data-table/data-table";
+import { useDataTable } from "@/hooks/use-data-table";
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar";
+import type {
+ DataTableAdvancedFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { getBasicContractTemplates} from "../service";
+import { getColumns } from "./basic-contract-template-columns";
+import { DeleteTemplatesDialog } from "./delete-basicContract-dialog";
+import { UpdateTemplateSheet } from "./update-basicContract-sheet";
+import { TemplateTableToolbarActions } from "./basicContract-table-toolbar-actions";
+import { BasicContractTemplate } from "@/db/schema";
+
+
+interface BasicTemplateTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getBasicContractTemplates>>,
+ ]
+ >
+}
+
+
+export function BasicContractTemplateTable({ promises }: BasicTemplateTableProps) {
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<BasicContractTemplate> | null>(null)
+
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+ // 컬럼 설정 - 외부 파일에서 가져옴
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // config 기반으로 필터 필드 설정
+ const advancedFilterFields: DataTableAdvancedFilterField<BasicContractTemplate>[] = [
+ { id: "templateName", label: "템플릿명", type: "text" },
+ {
+ id: "status", label: "상태", type: "select", options: [
+ { label: "활성", value: "ACTIVE" },
+ { label: "비활성", value: "INACTIVE" },
+ ]
+ },
+ { id: "fileName", label: "파일명", type: "text" },
+ { id: "createdAt", label: "생성일", type: "date" },
+ { id: "updatedAt", label: "수정일", type: "date" },
+ ];
+
+ 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}
+ >
+ <TemplateTableToolbarActions table={table} />
+
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <DeleteTemplatesDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ templates={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+
+ <UpdateTemplateSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ template={rowAction?.row.original ?? null}
+ />
+
+ </>
+
+ );
+} \ No newline at end of file
diff --git a/lib/basic-contract/template/basicContract-table-toolbar-actions.tsx b/lib/basic-contract/template/basicContract-table-toolbar-actions.tsx
new file mode 100644
index 00000000..439fea26
--- /dev/null
+++ b/lib/basic-contract/template/basicContract-table-toolbar-actions.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import * as React from "react"
+import { type Task } from "@/db/schema/tasks"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload } from "lucide-react"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { DeleteTemplatesDialog } from "./delete-basicContract-dialog"
+import { AddTemplateDialog } from "./add-basic-contract-template-dialog"
+import { BasicContractTemplate } from "@/db/schema"
+
+interface TemplateTableToolbarActionsProps {
+ table: Table<BasicContractTemplate>
+}
+
+export function TemplateTableToolbarActions({ table }: TemplateTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteTemplatesDialog
+ templates={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+ <AddTemplateDialog/>
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "basic-contract-template-list",
+ 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/basic-contract/template/delete-basicContract-dialog.tsx b/lib/basic-contract/template/delete-basicContract-dialog.tsx
new file mode 100644
index 00000000..307bd9aa
--- /dev/null
+++ b/lib/basic-contract/template/delete-basicContract-dialog.tsx
@@ -0,0 +1,149 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+
+import { removeTemplates } from "../service"
+import { BasicContractTemplate } from "@/db/schema"
+
+interface DeleteBasicContractsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ templates: Row<BasicContractTemplate>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteTemplatesDialog({
+ templates,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteBasicContractsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removeTemplates({
+ ids: templates.map((template) => template.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Templates deleted")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({templates.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Are you absolutely sure?</DialogTitle>
+ <DialogDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{templates.length}</span>
+ {templates.length === 1 ? " template" : " templates"} from our servers.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({templates.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Are you absolutely sure?</DrawerTitle>
+ <DrawerDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{templates.length}</span>
+ {templates.length === 1 ? " template" : " templates"} from our servers.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Delete
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}
diff --git a/lib/basic-contract/template/update-basicContract-sheet.tsx b/lib/basic-contract/template/update-basicContract-sheet.tsx
new file mode 100644
index 00000000..2c6efc9b
--- /dev/null
+++ b/lib/basic-contract/template/update-basicContract-sheet.tsx
@@ -0,0 +1,300 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import * as z from "zod"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Input } from "@/components/ui/input"
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneUploadIcon,
+ DropzoneTitle,
+ DropzoneDescription,
+ DropzoneInput
+} from "@/components/ui/dropzone"
+import { updateTemplate } from "../service"
+import { BasicContractTemplate } from "@/db/schema"
+
+// 업데이트 템플릿 스키마 정의 (유효기간 필드 추가)
+export const updateTemplateSchema = z.object({
+ templateName: z.string().min(1, "템플릿 이름은 필수입니다."),
+ validityPeriod: z.coerce
+ .number({ invalid_type_error: "유효기간은 숫자여야 합니다." })
+ .int("유효기간은 정수여야 합니다.")
+ .min(1, "유효기간은 최소 1개월 이상이어야 합니다.")
+ .max(120, "유효기간은 최대 120개월(10년)을 초과할 수 없습니다.")
+ .default(12),
+ status: z.enum(["ACTIVE", "INACTIVE"], {
+ required_error: "상태는 필수 선택사항입니다.",
+ }),
+ file: z.instanceof(File, { message: "파일을 업로드해주세요." }).optional(),
+})
+
+export type UpdateTemplateSchema = z.infer<typeof updateTemplateSchema>
+
+interface UpdateTemplateSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ template: BasicContractTemplate | null
+ onSuccess?: () => void
+}
+
+export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTemplateSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
+
+ // 템플릿 데이터 확인을 위한 로그
+ console.log(template)
+
+ const form = useForm<UpdateTemplateSchema>({
+ resolver: zodResolver(updateTemplateSchema),
+ defaultValues: {
+ templateName: template?.templateName ?? "",
+ validityPeriod: template?.validityPeriod ?? 12, // 기본값 12개월
+ status: (template?.status as "ACTIVE" | "INACTIVE") || "ACTIVE"
+ },
+ mode: "onChange"
+ })
+
+ // 파일 선택 핸들러
+ const handleFileChange = (files: File[]) => {
+ if (files.length > 0) {
+ const file = files[0];
+ setSelectedFile(file);
+ form.setValue("file", file);
+ }
+ };
+
+ // 템플릿 변경 시 폼 값 업데이트
+ React.useEffect(() => {
+ if (template) {
+ form.reset({
+ templateName: template.templateName,
+ validityPeriod: template.validityPeriod ?? 12, // 기존 값이 없으면 기본값 12개월
+ status: template.status as "ACTIVE" | "INACTIVE",
+ });
+ }
+ }, [template, form]);
+
+ // 유효기간 선택 옵션
+ const validityOptions = [
+ { value: "3", label: "3개월" },
+ { value: "6", label: "6개월" },
+ { value: "12", label: "1년" },
+ { value: "24", label: "2년" },
+ { value: "36", label: "3년" },
+ { value: "60", label: "5년" },
+ ];
+
+ function onSubmit(input: UpdateTemplateSchema) {
+ startUpdateTransition(async () => {
+ if (!template) return
+
+ // FormData 객체 생성하여 파일과 데이터를 함께 전송
+ const formData = new FormData();
+ formData.append("templateName", input.templateName);
+ formData.append("validityPeriod", input.validityPeriod.toString()); // 유효기간 추가
+ formData.append("status", input.status);
+
+ if (input.file) {
+ formData.append("file", input.file);
+ }
+
+ try {
+ // 서비스 함수 호출
+ const { error } = await updateTemplate({
+ id: template.id,
+ formData,
+ });
+
+ if (error) {
+ toast.error(error);
+ return;
+ }
+
+ form.reset();
+ setSelectedFile(null);
+ props.onOpenChange?.(false);
+ toast.success("템플릿이 성공적으로 업데이트되었습니다.");
+ onSuccess?.();
+ } catch (error) {
+ console.error("Update error:", error);
+ toast.error("템플릿 업데이트 중 오류가 발생했습니다.");
+ }
+ });
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>템플릿 업데이트</SheetTitle>
+ <SheetDescription>
+ 템플릿 정보를 수정하고 변경사항을 저장하세요
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ <FormField
+ control={form.control}
+ name="templateName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>템플릿 이름</FormLabel>
+ <FormControl>
+ <Input placeholder="템플릿 이름을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="validityPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약 유효기간</FormLabel>
+ <Select
+ value={field.value?.toString()}
+ onValueChange={(value) => field.onChange(parseInt(value))}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="유효기간을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {validityOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormDescription>
+ 계약서의 유효 기간을 설정합니다. 이 기간이 지나면 재계약이 필요합니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>상태</FormLabel>
+ <Select
+ defaultValue={field.value}
+ onValueChange={field.onChange}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="템플릿 상태 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="ACTIVE">활성</SelectItem>
+ <SelectItem value="INACTIVE">비활성</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="file"
+ render={() => (
+ <FormItem>
+ <FormLabel>템플릿 파일 (선택사항)</FormLabel>
+ <FormControl>
+ <Dropzone
+ onDrop={handleFileChange}
+ >
+ <DropzoneZone>
+ <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" />
+ <DropzoneTitle>
+ {selectedFile
+ ? selectedFile.name
+ : template?.fileName
+ ? `현재 파일: ${template.fileName}`
+ : "새 파일을 드래그하세요"}
+ </DropzoneTitle>
+ <DropzoneDescription>
+ {selectedFile
+ ? `파일 크기: ${(selectedFile.size / 1024).toFixed(2)} KB`
+ : "또는 클릭하여 파일을 선택하세요 (선택사항)"}
+ </DropzoneDescription>
+ <DropzoneInput />
+ </DropzoneZone>
+ </Dropzone>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button
+ type="submit"
+ disabled={isUpdatePending || !form.formState.isValid}
+ >
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/basic-contract/validations.ts b/lib/basic-contract/validations.ts
new file mode 100644
index 00000000..5a5bf5b8
--- /dev/null
+++ b/lib/basic-contract/validations.ts
@@ -0,0 +1,87 @@
+import * as z from "zod";
+import { createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,parseAsBoolean
+} from "nuqs/server"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { BasicContractTemplate, BasicContractView } from "@/db/schema";
+
+export const basicContractTemplateSchema = z.object({
+ templateName: z.string().min(1, "템플릿 이름은 필수입니다."),
+
+ // 유효기간을 숫자로 변경하고 적절한 검증 추가
+ validityPeriod: z.coerce
+ .number({
+ required_error: "유효기간은 필수입니다.",
+ invalid_type_error: "유효기간은 숫자여야 합니다."
+ })
+ .int("유효기간은 정수여야 합니다.")
+ .min(1, "유효기간은 최소 1개월 이상이어야 합니다.")
+ .max(120, "유효기간은 최대 120개월(10년)을 초과할 수 없습니다.")
+ .default(12) // 기본값 1년(12개월)
+ .describe("계약 유효기간(개월)"),
+
+ status: z.enum(["ACTIVE", "INACTIVE"], {
+ required_error: "상태는 필수 선택사항입니다.",
+ invalid_type_error: "올바른 상태 값이 아닙니다."
+ }).default("ACTIVE"),
+
+ fileName: z.string().min(1, "파일 이름은 필수입니다."),
+ filePath: z.string().min(1, "파일 경로는 필수입니다."),
+});
+
+export const searchParamsTemplatesCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<BasicContractTemplate>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+
+export const createBasicContractTemplateSchema = basicContractTemplateSchema.extend({});
+
+export const updateBasicContractTemplateSchema = basicContractTemplateSchema.partial().extend({
+ id: z.number(),
+});
+
+export const deleteBasicContractTemplateSchema = z.object({
+ id: z.number(),
+});
+
+export type GetBasicContractTemplatesSchema = Awaited<ReturnType<typeof searchParamsTemplatesCache.parse>>
+
+
+export type CreateBasicContractTemplateSchema = z.infer<typeof createBasicContractTemplateSchema>;
+export type UpdateBasicContractTemplateSchema = z.infer<typeof updateBasicContractTemplateSchema>;
+export type DeleteBasicContractTemplateSchema = z.infer<typeof deleteBasicContractTemplateSchema>;
+
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<BasicContractView>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+});
+
+export type GetBasciContractsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
diff --git a/lib/basic-contract/vendor-table/basic-contract-columns.tsx b/lib/basic-contract/vendor-table/basic-contract-columns.tsx
new file mode 100644
index 00000000..b79487d7
--- /dev/null
+++ b/lib/basic-contract/vendor-table/basic-contract-columns.tsx
@@ -0,0 +1,214 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Paperclip } from "lucide-react"
+import { toast } from "sonner"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { basicContractColumnsConfig, basicContractVendorColumnsConfig } from "@/config/basicContractColumnsConfig"
+import { BasicContractView } from "@/db/schema"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BasicContractView> | null>>
+}
+
+/**
+ * 파일 다운로드 함수
+ */
+/**
+ * 파일 다운로드 함수
+ */
+const handleFileDownload = (filePath: string | null, fileName: string | null) => {
+ if (!filePath || !fileName) {
+ toast.error("파일 정보가 없습니다.");
+ return;
+ }
+
+ try {
+ // 전체 URL 생성
+ const fullUrl = `${window.location.origin}${filePath}`;
+
+ // a 태그를 생성하여 다운로드 실행
+ const link = document.createElement('a');
+ link.href = fullUrl;
+ link.download = fileName; // 다운로드될 파일명 설정
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ toast.success("파일 다운로드를 시작합니다.");
+ } catch (error) {
+ console.error("파일 다운로드 오류:", error);
+ toast.error("파일 다운로드 중 오류가 발생했습니다.");
+ }
+};
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicContractView>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<BasicContractView> = {
+ 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"
+ />
+ ),
+ maxSize: 30,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) 파일 다운로드 컬럼 (아이콘)
+ // ----------------------------------------------------------------
+ const downloadColumn: ColumnDef<BasicContractView> = {
+ id: "download",
+ header: "",
+ cell: ({ row }) => {
+ const template = row.original;
+ const filePath = template.status === "PENDING" ? template.filePath : template.signedFilePath
+
+ return (
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => handleFileDownload(filePath, template.fileName)}
+ title={`${template.fileName} 다운로드`}
+ className="hover:bg-muted"
+ >
+ <Paperclip className="h-4 w-4" />
+ <span className="sr-only">다운로드</span>
+ </Button>
+ );
+ },
+ maxSize: 30,
+ enableSorting: false,
+ }
+
+
+ // ----------------------------------------------------------------
+ // 4) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 4-1) groupMap: { [groupName]: ColumnDef<BasicContractView>[] }
+ const groupMap: Record<string, ColumnDef<BasicContractView>[]> = {}
+
+ basicContractVendorColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<BasicContractView> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+ // 날짜 형식 처리
+ if (cfg.id === "createdAt" || cfg.id === "updatedAt" || cfg.id === "completedAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDateTime(dateVal)
+ }
+
+ // Status 컬럼에 Badge 적용
+ if (cfg.id === "status") {
+ const status = row.getValue(cfg.id) as string
+ const isPending = status === "PENDING"
+
+ return (
+ <Badge
+ variant={!isPending ? "default" : "secondary"}
+ >
+ {status}
+ </Badge>
+ )
+ }
+
+ // 나머지 컬럼은 그대로 값 표시
+ return row.getValue(cfg.id) ?? ""
+ },
+ minSize: 80,
+
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 4-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<BasicContractView>[] = []
+
+ // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
+ // 여기서는 그냥 Object.entries 순서
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없음 → 그냥 최상위 레벨 컬럼
+ nestedColumns.push(...colDefs)
+ } else {
+ // 상위 컬럼
+ nestedColumns.push({
+ id: groupName,
+ header: groupName, // "Basic Info", "Metadata" 등
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 5) 최종 컬럼 배열: select, download, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ downloadColumn, // 다운로드 컬럼 추가
+ ...nestedColumns,
+ ]
+} \ No newline at end of file
diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
new file mode 100644
index 00000000..28a4fd71
--- /dev/null
+++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx
@@ -0,0 +1,318 @@
+"use client";
+
+import * as React from "react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { formatDate } from "@/lib/utils";
+import { toast } from "sonner";
+import { cn } from "@/lib/utils";
+import { BasicContractSignViewer } from "@/lib/basic-contract/viewer/basic-contract-sign-viewer";
+import type { WebViewerInstance } from "@pdftron/webviewer";
+import type { BasicContractView } from "@/db/schema";
+import {
+ Upload,
+ FileSignature,
+ CheckCircle2,
+ Search,
+ Clock,
+ FileText,
+ User,
+ AlertCircle,
+ Calendar
+} from "lucide-react";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Input } from "@/components/ui/input";
+import { Badge } from "@/components/ui/badge";
+import { Separator } from "@/components/ui/separator";
+import { useRouter } from "next/navigation"
+
+// 수정된 props 인터페이스
+interface BasicContractSignDialogProps {
+ contracts: BasicContractView[];
+ onSuccess?: () => void;
+}
+
+export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractSignDialogProps) {
+ const [open, setOpen] = React.useState(false);
+ const [selectedContract, setSelectedContract] = React.useState<BasicContractView | null>(null);
+ const [instance, setInstance] = React.useState<null | WebViewerInstance>(null);
+ const [searchTerm, setSearchTerm] = React.useState("");
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
+ const router = useRouter()
+
+ // 다이얼로그 열기/닫기 핸들러
+ const handleOpenChange = (isOpen: boolean) => {
+ setOpen(isOpen);
+
+ // 다이얼로그가 열릴 때 첫 번째 계약서 자동 선택
+ if (isOpen && contracts.length > 0 && !selectedContract) {
+ setSelectedContract(contracts[0]);
+ }
+
+ if (!isOpen) {
+ setSelectedContract(null);
+ setSearchTerm("");
+ }
+ };
+
+ // 계약서 선택 핸들러
+ const handleSelectContract = (contract: BasicContractView) => {
+ setSelectedContract(contract);
+ };
+
+ // 검색된 계약서 필터링
+ const filteredContracts = React.useMemo(() => {
+ if (!searchTerm.trim()) return contracts;
+
+ const term = searchTerm.toLowerCase();
+ return contracts.filter(contract =>
+ (contract.templateName || '').toLowerCase().includes(term) ||
+ (contract.userName || '').toLowerCase().includes(term)
+ );
+ }, [contracts, searchTerm]);
+
+ // 다이얼로그가 열릴 때 첫 번째 계약서 자동 선택
+ React.useEffect(() => {
+ if (open && contracts.length > 0 && !selectedContract) {
+ setSelectedContract(contracts[0]);
+ }
+ }, [open, contracts, selectedContract]);
+
+ // 서명 완료 핸들러
+ const completeSign = async () => {
+ if (!instance || !selectedContract) return;
+
+ setIsSubmitting(true);
+ try {
+ const { documentViewer, annotationManager } = instance.Core;
+ const doc = documentViewer.getDocument();
+ const xfdfString = await annotationManager.exportAnnotations();
+
+ const data = await doc.getFileData({
+ xfdfString,
+ downloadType: "pdf",
+ });
+
+ // FormData 생성 및 파일 추가
+ const formData = new FormData();
+ formData.append('file', new Blob([data], { type: 'application/pdf' }));
+ formData.append('tableRowId', selectedContract.id.toString());
+ formData.append('templateName', selectedContract.fileName || '');
+
+ // API 호출
+ const response = await fetch('/api/upload/signed-contract', {
+ method: 'POST',
+ body: formData,
+ next: { tags: ["basicContractView-vendor"] },
+ });
+
+ const result = await response.json();
+
+ if (result.result) {
+ toast.success("서명이 성공적으로 완료되었습니다.", {
+ description: "문서가 성공적으로 처리되었습니다.",
+ icon: <CheckCircle2 className="h-5 w-5 text-green-500" />
+ });
+ router.refresh();
+ setOpen(false);
+ if (onSuccess) {
+ onSuccess();
+ }
+ } else {
+ toast.error("서명 처리 중 오류가 발생했습니다.", {
+ description: result.error,
+ icon: <AlertCircle className="h-5 w-5 text-red-500" />
+ });
+ }
+ } catch (error) {
+ console.error("서명 완료 중 오류:", error);
+ toast.error("서명 처리 중 오류가 발생했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // 서명 대기중(PENDING) 계약서가 있는지 확인
+ const hasPendingContracts = contracts.length > 0;
+
+ return (
+ <>
+ {/* 서명 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setOpen(true)}
+ disabled={!hasPendingContracts}
+ className="gap-2 transition-all hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200"
+ >
+ <Upload className="size-4 text-blue-500" aria-hidden="true" />
+ <span className="hidden sm:inline flex items-center">
+ 서명하기
+ {contracts.length > 0 && (
+ <Badge variant="secondary" className="ml-2 bg-blue-100 text-blue-700 hover:bg-blue-200">
+ {contracts.length}
+ </Badge>
+ )}
+ </span>
+ </Button>
+
+ {/* 서명 다이얼로그 - 고정 높이 유지 */}
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className="max-w-5xl h-[650px] w-[90vw] p-0 overflow-hidden rounded-lg shadow-lg border border-gray-200">
+ <DialogHeader className="p-6 bg-gradient-to-r from-blue-50 to-purple-50 border-b">
+ <DialogTitle className="text-xl font-bold flex items-center text-gray-800">
+ <FileSignature className="mr-2 h-5 w-5 text-blue-500" />
+ 기본계약서 및 관련문서 서명
+ </DialogTitle>
+ </DialogHeader>
+
+ <div className="grid grid-cols-2 h-[calc(100%-4rem)] overflow-hidden">
+ {/* 왼쪽 영역 - 계약서 목록 */}
+ <div className="col-span-1 border-r border-gray-200 bg-gray-50">
+ <div className="p-4 border-b">
+ <div className="relative mb-10">
+ <div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none">
+ <Search className="h-4 w-8 text-gray-400" />
+ </div>
+ <Input
+ placeholder="문서명 또는 요청자 검색"
+ className="bg-white"
+ style={{paddingLeft:25}}
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ />
+ </div>
+ <Tabs defaultValue="all" className="w-full">
+ <TabsList className="w-full">
+ <TabsTrigger value="all" className="flex-1">전체 ({contracts.length})</TabsTrigger>
+ <TabsTrigger value="contracts" className="flex-1">계약서</TabsTrigger>
+ <TabsTrigger value="docs" className="flex-1">관련문서</TabsTrigger>
+ </TabsList>
+ </Tabs>
+ </div>
+
+ <ScrollArea className="h-[calc(100%-6rem)]">
+ <div className="p-3">
+ {filteredContracts.length === 0 ? (
+ <div className="flex flex-col items-center justify-center h-40 text-center">
+ <FileText className="h-12 w-12 text-gray-300 mb-2" />
+ <p className="text-gray-500 font-medium">서명 요청된 문서가 없습니다.</p>
+ <p className="text-gray-400 text-sm mt-1">나중에 다시 확인해주세요.</p>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredContracts.map((contract) => (
+ <Button
+ key={contract.id}
+ variant="outline"
+ className={cn(
+ "w-full justify-start text-left h-auto p-3 bg-white hover:bg-blue-50 transition-colors",
+ "border border-gray-200 hover:border-blue-200 rounded-md",
+ selectedContract?.id === contract.id && "border-blue-500 bg-blue-50 shadow-sm"
+ )}
+ onClick={() => handleSelectContract(contract)}
+ >
+ <div className="flex flex-col w-full">
+ <div className="flex items-center justify-between w-full">
+ <span className="font-semibold truncate text-gray-800 flex items-center">
+ <FileText className="h-4 w-4 mr-2 text-blue-500" />
+ {contract.templateName || '문서'}
+ </span>
+ <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200">
+ 대기중
+ </Badge>
+ </div>
+ <Separator className="my-2 bg-gray-100" />
+ <div className="grid grid-cols-2 gap-1 mt-1 text-xs text-gray-500">
+ <div className="flex items-center">
+ <User className="h-3 w-3 mr-1" />
+ <span className="truncate">{contract.userName || '알 수 없음'}</span>
+ </div>
+ <div className="flex items-center justify-end">
+ <Calendar className="h-3 w-3 mr-1" />
+ <span>{formatDate(contract.createdAt)}</span>
+ </div>
+ </div>
+ </div>
+ </Button>
+ ))}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+
+ {/* 오른쪽 영역 - 문서 뷰어 */}
+ <div className="col-span-1 bg-white flex flex-col h-full">
+ {selectedContract ? (
+ <>
+ <div className="p-3 border-b bg-gray-50">
+ <h3 className="font-semibold text-gray-800 flex items-center">
+ <FileText className="h-4 w-4 mr-2 text-blue-500" />
+ {selectedContract.templateName || '문서'}
+ </h3>
+ <div className="flex justify-between items-center mt-1 text-xs text-gray-500">
+ <span className="flex items-center">
+ <User className="h-3 w-3 mr-1" />
+ 요청자: {selectedContract.userName || '알 수 없음'}
+ </span>
+ <span className="flex items-center">
+ <Clock className="h-3 w-3 mr-1" />
+ {formatDate(selectedContract.createdAt)}
+ </span>
+ </div>
+ </div>
+ <div className="flex-grow overflow-hidden border-b">
+ <BasicContractSignViewer
+ contractId={selectedContract.id}
+ filePath={selectedContract.filePath || undefined}
+ instance={instance}
+ setInstance={setInstance}
+ />
+ </div>
+ <div className="p-3 flex justify-between items-center bg-gray-50">
+ <p className="text-sm text-gray-600">
+ <AlertCircle className="h-4 w-4 text-yellow-500 inline mr-1" />
+ 서명 후에는 변경할 수 없습니다.
+ </p>
+ <Button
+ className="gap-2 bg-blue-600 hover:bg-blue-700 transition-colors"
+ onClick={completeSign}
+ disabled={isSubmitting}
+ >
+ {isSubmitting ? (
+ <>
+ <svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+ </svg>
+ 처리 중...
+ </>
+ ) : (
+ <>
+ <FileSignature className="h-4 w-4" />
+ 서명 완료
+ </>
+ )}
+ </Button>
+ </div>
+ </>
+ ) : (
+ <div className="flex flex-col items-center justify-center h-full text-center p-6">
+ <div className="bg-blue-50 p-6 rounded-full mb-4">
+ <FileSignature className="h-12 w-12 text-blue-500" />
+ </div>
+ <h3 className="text-xl font-medium text-gray-800 mb-2">문서를 선택해주세요</h3>
+ <p className="text-gray-500 max-w-md">
+ 왼쪽 목록에서 서명할 문서를 선택하면 여기에 문서 내용이 표시됩니다.
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
+} \ No newline at end of file
diff --git a/lib/basic-contract/vendor-table/basic-contract-table.tsx b/lib/basic-contract/vendor-table/basic-contract-table.tsx
new file mode 100644
index 00000000..34e15ae3
--- /dev/null
+++ b/lib/basic-contract/vendor-table/basic-contract-table.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import * as React from "react";
+import { DataTable } from "@/components/data-table/data-table";
+import { Button } from "@/components/ui/button";
+import { Plus, Loader2 } from "lucide-react";
+import { useDataTable } from "@/hooks/use-data-table";
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar";
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { toast } from "sonner";
+import { getColumns } from "./basic-contract-columns";
+import { getBasicContracts, getBasicContractsByVendorId } from "../service";
+import { BasicContractView } from "@/db/schema";
+import { BasicContractTableToolbarActions } from "./basicContract-table-toolbar-actions";
+
+
+interface BasicTemplateTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getBasicContractsByVendorId>>,
+ ]
+ >
+}
+
+
+export function BasicContractsVendorTable({ promises }: BasicTemplateTableProps) {
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<BasicContractView> | null>(null)
+
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+ // console.log(data)
+
+ // 컬럼 설정 - 외부 파일에서 가져옴
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // config 기반으로 필터 필드 설정
+ const advancedFilterFields: DataTableAdvancedFilterField<BasicContractView>[] = [
+ { id: "templateName", label: "템플릿명", type: "text" },
+ {
+ id: "status", label: "상태", type: "select", options: [
+ { label: "서명대기", value: "PENDING" },
+ { label: "서명완료", value: "COMPLETED" },
+ ]
+ },
+ { id: "userName", label: "요청자", type: "text" },
+ { id: "createdAt", label: "생성일", type: "date" },
+ { id: "updatedAt", label: "수정일", type: "date" },
+ ];
+
+ 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}
+ >
+ <BasicContractTableToolbarActions table={table} />
+
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ </>
+
+ );
+} \ No newline at end of file
diff --git a/lib/basic-contract/vendor-table/basicContract-table-toolbar-actions.tsx b/lib/basic-contract/vendor-table/basicContract-table-toolbar-actions.tsx
new file mode 100644
index 00000000..2e5e4471
--- /dev/null
+++ b/lib/basic-contract/vendor-table/basicContract-table-toolbar-actions.tsx
@@ -0,0 +1,56 @@
+"use client"
+
+import * as React from "react"
+import { type Task } from "@/db/schema/tasks"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload } from "lucide-react"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { BasicContractView } from "@/db/schema"
+import { BasicContractSignDialog } from "./basic-contract-sign-dialog"
+
+interface TemplateTableToolbarActionsProps {
+ table: Table<BasicContractView>
+}
+
+export function BasicContractTableToolbarActions({ table }: TemplateTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+
+ const inPendingContracts = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ .filter(contract => contract.status === "PENDING");
+ }, [table.getFilteredSelectedRowModel().rows]);
+
+
+ return (
+ <div className="flex items-center gap-2">
+
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <BasicContractSignDialog
+ contracts={inPendingContracts}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "basci-contract-requested-list",
+ 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/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
new file mode 100644
index 00000000..0409151e
--- /dev/null
+++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
@@ -0,0 +1,224 @@
+"use client";
+
+import React, {
+ useState,
+ useEffect,
+ useRef,
+ SetStateAction,
+ Dispatch,
+} from "react";
+import { WebViewerInstance } from "@pdftron/webviewer";
+import { Loader2 } from "lucide-react";
+import { toast } from "sonner";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+
+interface BasicContractSignViewerProps {
+ contractId?: number;
+ filePath?: string;
+ isOpen?: boolean;
+ onClose?: () => void;
+ onSign?: (documentData: ArrayBuffer) => Promise<void>;
+ instance: WebViewerInstance | null;
+ setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>;
+}
+
+export function BasicContractSignViewer({
+ contractId,
+ filePath,
+ isOpen = false,
+ onClose,
+ onSign,
+ instance,
+ setInstance,
+}: BasicContractSignViewerProps) {
+ const [fileLoading, setFileLoading] = useState<boolean>(true);
+ const viewer = useRef<HTMLDivElement>(null);
+ const initialized = useRef(false);
+ const isCancelled = useRef(false);
+ const [showDialog, setShowDialog] = useState(isOpen);
+
+ // 다이얼로그 상태 동기화
+ useEffect(() => {
+ setShowDialog(isOpen);
+ }, [isOpen]);
+
+ // WebViewer 초기화
+ useEffect(() => {
+ if (!initialized.current && viewer.current) {
+ initialized.current = true;
+ isCancelled.current = false;
+
+ requestAnimationFrame(() => {
+ if (viewer.current) {
+ import("@pdftron/webviewer").then(({ default: WebViewer }) => {
+ if (isCancelled.current) {
+ console.log("📛 WebViewer 초기화 취소됨");
+ return;
+ }
+
+ // viewerElement이 확실히 존재함을 확인
+ const viewerElement = viewer.current;
+ if (!viewerElement) return;
+
+ WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ viewerElement
+ ).then((instance: WebViewerInstance) => {
+ setInstance(instance);
+ setFileLoading(false);
+
+ const { disableElements, setToolbarGroup } = instance.UI;
+
+ disableElements([
+ "toolbarGroup-Annotate",
+ "toolbarGroup-Shapes",
+ "toolbarGroup-Insert",
+ "toolbarGroup-Edit",
+ // "toolbarGroup-FillAndSign",
+ "toolbarGroup-Forms",
+ ]);
+ setToolbarGroup("toolbarGroup-View");
+ });
+ });
+ }
+ });
+ }
+
+ return () => {
+ if (instance) {
+ instance.UI.dispose();
+ }
+ isCancelled.current = true;
+ setTimeout(() => cleanupHtmlStyle(), 500);
+ };
+ }, []);
+
+ // 문서 로드
+ useEffect(() => {
+ if (!instance || !filePath) return;
+
+ loadDocument(instance, filePath);
+ }, [instance, filePath]);
+
+ // 간소화된 문서 로드 함수
+ const loadDocument = async (instance: WebViewerInstance, documentPath: string) => {
+ setFileLoading(true);
+ try {
+ const { documentViewer } = instance.Core;
+
+ await documentViewer.loadDocument(documentPath, { extension: 'pdf' });
+
+ } catch (err) {
+ console.error("문서 로딩 중 오류 발생:", err);
+ toast.error("문서를 불러오는데 실패했습니다.");
+ } finally {
+ setFileLoading(false);
+ }
+ };
+
+ // 서명 저장 핸들러
+ const handleSave = async () => {
+ if (!instance) return;
+
+ try {
+ const { documentViewer } = instance.Core;
+ const doc = documentViewer.getDocument();
+
+ // 서명된 문서 데이터 가져오기
+ const documentData = await doc.getFileData({
+ includeAnnotations: true,
+ });
+
+ // 외부에서 제공된 onSign 핸들러가 있으면 호출
+ if (onSign) {
+ await onSign(documentData);
+ } else {
+ // 기본 동작 - 서명 성공 메시지 표시
+ toast.success("계약서가 성공적으로 서명되었습니다.");
+ }
+
+ handleClose();
+ } catch (err) {
+ console.error("서명 저장 중 오류 발생:", err);
+ toast.error("서명을 저장하는데 실패했습니다.");
+ }
+ };
+
+ // 다이얼로그 닫기 핸들러
+ const handleClose = () => {
+ if (onClose) {
+ onClose();
+ } else {
+ setShowDialog(false);
+ }
+ };
+
+ // 인라인 뷰어 렌더링 (다이얼로그 모드가 아닐 때)
+ if (!isOpen && !onClose) {
+ return (
+ <div className="border rounded-md overflow-hidden" style={{ height: '600px' }}>
+ <div ref={viewer} className="h-[100%]">
+ {fileLoading && (
+ <div className="flex flex-col items-center justify-center py-12">
+ <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
+ <p className="text-sm text-muted-foreground">문서 로딩 중...</p>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ }
+
+ // 다이얼로그 뷰어 렌더링
+ return (
+ <Dialog open={showDialog} onOpenChange={handleClose}>
+ <DialogContent className="w-[70vw]" style={{ maxWidth: "none" }}>
+ <DialogHeader>
+ <DialogTitle>기본계약서 서명</DialogTitle>
+ <DialogDescription>
+ 계약서를 확인하고 서명을 진행해주세요.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="h-[calc(70vh-60px)]">
+ <div ref={viewer} className="h-[100%]">
+ {fileLoading && (
+ <div className="flex flex-col items-center justify-center py-12">
+ <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
+ <p className="text-sm text-muted-foreground">문서 로딩 중...</p>
+ </div>
+ )}
+ </div>
+ </div>
+ <DialogFooter>
+ <Button variant="outline" onClick={handleClose} disabled={fileLoading}>
+ 취소
+ </Button>
+ <Button onClick={handleSave} disabled={fileLoading}>
+ 서명 완료
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+// WebViewer 정리 함수
+const cleanupHtmlStyle = () => {
+ // iframe 스타일 정리 (WebViewer가 추가한 스타일)
+ const elements = document.querySelectorAll('.Document_container');
+ elements.forEach((elem) => {
+ elem.remove();
+ });
+}; \ No newline at end of file
diff --git a/lib/bidding-projects/repository.ts b/lib/bidding-projects/repository.ts
new file mode 100644
index 00000000..44e61553
--- /dev/null
+++ b/lib/bidding-projects/repository.ts
@@ -0,0 +1,44 @@
+import db from "@/db/db";
+import { biddingProjects } 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";
+
+export async function selectProjectLists(
+ 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(biddingProjects)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+ }
+/** 총 개수 count */
+export async function countProjectLists(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(biddingProjects).where(where);
+ return res[0]?.count ?? 0;
+}
diff --git a/lib/bidding-projects/service.ts b/lib/bidding-projects/service.ts
new file mode 100644
index 00000000..569bd18f
--- /dev/null
+++ b/lib/bidding-projects/service.ts
@@ -0,0 +1,117 @@
+"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 { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm";
+import { countProjectLists, selectProjectLists } from "./repository";
+import { biddingProjects, ProjectSeries, projectSeries } from "@/db/schema";
+import { GetBidProjectListsSchema } from "./validation";
+
+export async function getBidProjectLists(input: GetBidProjectListsSchema) {
+
+ 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: biddingProjects,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ ilike(biddingProjects.pspid, s),
+ ilike(biddingProjects.projNm, s),
+ ilike(biddingProjects.kunnrNm, s),
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ const finalWhere = and(
+ // advancedWhere or your existing conditions
+ advancedWhere,
+ globalWhere // and()함수로 결합 or or() 등으로 결합
+ )
+
+
+ // 아니면 ilike, inArray, gte 등으로 where 절 구성
+ const where = finalWhere
+
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(biddingProjects[item.id]) : asc(biddingProjects[item.id])
+ )
+ : [asc(biddingProjects.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectProjectLists(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countProjectLists(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: ["project-lists"],
+ }
+ )();
+ }
+
+ /**
+ * 특정 프로젝트의 시리즈 데이터를 가져오는 서버 액션
+ * @param pspid 견적프로젝트번호
+ * @returns 프로젝트 시리즈 데이터 배열
+ */
+export async function getProjectSeriesForProject(pspid: string) {
+ try {
+ if (!pspid) {
+ throw new Error("프로젝트 ID가 제공되지 않았습니다.")
+ }
+
+ // 트랜잭션을 사용하여 데이터 조회
+ const seriesData = await db.transaction(async (tx) => {
+ const results = await tx
+ .select()
+ .from(projectSeries)
+ .where(eq(projectSeries.pspid, pspid))
+ .orderBy(projectSeries.sersNo)
+
+ return results
+ })
+
+
+
+ return seriesData
+ } catch (error) {
+ console.error(`프로젝트 시리즈 데이터 가져오기 실패 (pspid: ${pspid}):`, error)
+ return []
+ }
+}
diff --git a/lib/bidding-projects/table/project-series-dialog.tsx b/lib/bidding-projects/table/project-series-dialog.tsx
new file mode 100644
index 00000000..168ede7e
--- /dev/null
+++ b/lib/bidding-projects/table/project-series-dialog.tsx
@@ -0,0 +1,133 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { BiddingProjects } from "@/db/schema"
+import { useToast } from "@/hooks/use-toast"
+
+// Import the function
+import { getProjectSeriesForProject } from "../service"
+
+// Define the ProjectSeries type based on the schema
+interface ProjectSeries {
+ pspid: string;
+ sersNo: string;
+ scDt?: string | null;
+ klDt?: string | null;
+ lcDt?: string | null;
+ dlDt?: string | null;
+ dockNo?: string | null;
+ dockNm?: string | null;
+ projNo?: string | null;
+ post1?: string | null;
+}
+
+interface ProjectSeriesDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ project: BiddingProjects | null
+}
+
+export function ProjectSeriesDialog({
+ open,
+ onOpenChange,
+ project,
+}: ProjectSeriesDialogProps) {
+ const { toast } = useToast()
+
+ const [projectSeries, setProjectSeries] = React.useState<ProjectSeries[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ React.useEffect(() => {
+ async function loadItems() {
+ if (!project?.pspid) return;
+
+ setIsLoading(true)
+ try {
+ const result = await getProjectSeriesForProject(project.pspid)
+ setProjectSeries(result)
+ } catch (error) {
+ console.error("프로젝트 시리즈 로드 오류:", error)
+ toast({
+ title: "오류",
+ description: "프로젝트 시리즈 로드 실패",
+ variant: "destructive",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ if (open && project) {
+ loadItems()
+ }
+ }, [toast, project, open])
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[900px]">
+ <DialogHeader>
+ <DialogTitle>
+ {project ? `시리즈 목록 - ${project.projNm || project.pspid}` : "시리즈 목록"}
+ </DialogTitle>
+ </DialogHeader>
+ {isLoading ? (
+ <div className="flex items-center justify-center h-40">
+ 로딩 중...
+ </div>
+ ) : (
+ <div className="max-h-[500px] overflow-y-auto">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>시리즈번호</TableHead>
+ <TableHead>K/L 연도분기</TableHead>
+ <TableHead>도크코드</TableHead>
+ <TableHead>도크명</TableHead>
+ <TableHead>SN공사번호</TableHead>
+ <TableHead>SN공사명</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {projectSeries && projectSeries.length > 0 ? (
+ projectSeries.map((series) => (
+ <TableRow key={`${series.pspid}-${series.sersNo}`}>
+ <TableCell>{series.sersNo}</TableCell>
+ <TableCell>{series.scDt}</TableCell>
+ <TableCell>{series.klDt}</TableCell>
+ <TableCell>{series.lcDt}</TableCell>
+ <TableCell>{series.dlDt}</TableCell>
+ <TableCell>{series.dockNo}</TableCell>
+ <TableCell>{series.dockNm}</TableCell>
+ <TableCell>{series.projNo}</TableCell>
+ <TableCell>{series.post1}</TableCell>
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell colSpan={6} className="text-center h-24">
+ 시리즈 데이터가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/bidding-projects/table/projects-table-columns.tsx b/lib/bidding-projects/table/projects-table-columns.tsx
new file mode 100644
index 00000000..08530ff0
--- /dev/null
+++ b/lib/bidding-projects/table/projects-table-columns.tsx
@@ -0,0 +1,102 @@
+"use client"
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { formatDate } from "@/lib/utils"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { BiddingProjects } from "@/db/schema"
+import { bidProjectsColumnsConfig } from "@/config/bidProjectsColumnsConfig"
+import { Button } from "@/components/ui/button"
+import { ListFilter } from "lucide-react" // Import an icon for the button
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingProjects> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingProjects>[] {
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<BiddingProjects>[] }
+ const groupMap: Record<string, ColumnDef<BiddingProjects>[]> = {}
+ bidProjectsColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+ // child column 정의
+ const childCol: ColumnDef<BiddingProjects> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+ if (cfg.id === "createdAt" || cfg.id === "updatedAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<BiddingProjects>[] = []
+ // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
+ // 여기서는 그냥 Object.entries 순서
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없음 → 그냥 최상위 레벨 컬럼
+ nestedColumns.push(...colDefs)
+ } else {
+ // 상위 컬럼
+ nestedColumns.push({
+ id: groupName,
+ header: groupName, // "Basic Info", "Metadata" 등
+ columns: colDefs,
+ })
+ }
+ })
+
+ // Add action column
+ const actionColumn: ColumnDef<BiddingProjects> = {
+ id: "actions",
+ header: "Actions",
+ cell: ({ row }) => {
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="flex items-center gap-1"
+ onClick={() => {
+ setRowAction({ row,type: "view-series" })
+ }}
+ >
+ <ListFilter className="h-4 w-4" />
+ 시리즈 보기
+ </Button>
+ )
+ },
+ }
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: nestedColumns + actions
+ // ----------------------------------------------------------------
+ return [
+ ...nestedColumns,
+ actionColumn, // Add the action column
+ ]
+} \ No newline at end of file
diff --git a/lib/bidding-projects/table/projects-table-toolbar-actions.tsx b/lib/bidding-projects/table/projects-table-toolbar-actions.tsx
new file mode 100644
index 00000000..ee2f8c4e
--- /dev/null
+++ b/lib/bidding-projects/table/projects-table-toolbar-actions.tsx
@@ -0,0 +1,89 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, RefreshCcw } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { BiddingProjects } from "@/db/schema"
+
+interface ItemsTableToolbarActionsProps {
+ table: Table<BiddingProjects>
+}
+
+export function ProjectTableToolbarActions({ table }: ItemsTableToolbarActionsProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ // 프로젝트 동기화 API 호출 함수
+ const syncProjects = async () => {
+ try {
+ setIsLoading(true)
+
+ // API 엔드포인트 호출
+ const response = await fetch('/api/cron/bid-projects')
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.error || 'Failed to sync projects')
+ }
+
+ const data = await response.json()
+
+ // 성공 메시지 표시
+ toast.success(
+ `Projects synced successfully! ${data.result.items} items processed.`
+ )
+
+ // 페이지 새로고침으로 테이블 데이터 업데이트
+ window.location.reload()
+ } catch (error) {
+ console.error('Error syncing projects:', error)
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : 'An error occurred while syncing projects'
+ )
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="samsung"
+ size="sm"
+ className="gap-2"
+ onClick={syncProjects}
+ disabled={isLoading}
+ >
+ <RefreshCcw
+ className={`size-4 ${isLoading ? 'animate-spin' : ''}`}
+ aria-hidden="true"
+ />
+ <span className="hidden sm:inline">
+ {isLoading ? 'Syncing...' : 'Get Projects'}
+ </span>
+ </Button>
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "Projects",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ disabled={isLoading}
+ >
+ <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/bidding-projects/table/projects-table.tsx b/lib/bidding-projects/table/projects-table.tsx
new file mode 100644
index 00000000..0e0c48f9
--- /dev/null
+++ b/lib/bidding-projects/table/projects-table.tsx
@@ -0,0 +1,156 @@
+"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 { getColumns } from "./projects-table-columns"
+import { getBidProjectLists } from "../service"
+import { BiddingProjects } from "@/db/schema"
+import { ProjectTableToolbarActions } from "./projects-table-toolbar-actions"
+import { ProjectSeriesDialog } from "./project-series-dialog"
+
+interface ItemsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getBidProjectLists>>,
+ ]
+ >
+}
+
+export function BidProjectsTable({ promises }: ItemsTableProps) {
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<BiddingProjects> | 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<BiddingProjects>[] = [
+
+ ]
+
+ /**
+ * 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<BiddingProjects>[] = [
+ {
+ id: "pspid",
+ label: "견적프로젝트번호",
+ type: "text",
+ // group: "Basic Info",
+ },
+ {
+ id: "projNm",
+ label: "견적프로젝트명",
+ type: "text",
+ // group: "Basic Info",
+ },
+ {
+ id: "sector",
+ label: "부문(S / M)",
+ type: "text",
+ },
+ {
+ id: "kunnrNm",
+ label: "선주명",
+ type: "text",
+ },
+ {
+ id: "cls1Nm",
+ label: "선급명",
+ type: "text",
+ },
+ {
+ id: "ptypeNm",
+ label: "선종명",
+ type: "text",
+ },
+ {
+ id: "estmPm",
+ label: "견적대표PM 성명",
+ type: "text",
+ },
+ {
+ id: "createdAt",
+ label: "Created At",
+ type: "date",
+ // group: "Metadata",a
+ },
+ {
+ id: "updatedAt",
+ label: "Updated At",
+ 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.pspid),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <ProjectTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <ProjectSeriesDialog
+ open={rowAction?.type === "view-series"}
+ onOpenChange={() => setRowAction(null)}
+ project={rowAction?.row.original ?? null}
+ />
+ </>
+ )
+}
diff --git a/lib/bidding-projects/validation.ts b/lib/bidding-projects/validation.ts
new file mode 100644
index 00000000..e5f8b121
--- /dev/null
+++ b/lib/bidding-projects/validation.ts
@@ -0,0 +1,32 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { BiddingProjects } from "@/db/schema"
+
+export const searchParamsBidProjectsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<BiddingProjects>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+
+export type GetBidProjectListsSchema = Awaited<ReturnType<typeof searchParamsBidProjectsCache.parse>>
diff --git a/lib/cbe/table/cbe-table-columns.tsx b/lib/cbe/table/cbe-table-columns.tsx
new file mode 100644
index 00000000..2da62ea8
--- /dev/null
+++ b/lib/cbe/table/cbe-table-columns.tsx
@@ -0,0 +1,241 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Download, Ellipsis, MessageSquare } from "lucide-react"
+import { toast } from "sonner"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { useRouter } from "next/navigation"
+
+import { VendorWithCbeFields,vendorCbeColumnsConfig } from "@/config/vendorCbeColumnsConfig"
+
+
+type NextRouter = ReturnType<typeof useRouter>
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null>
+ >
+ router: NextRouter
+ openCommentSheet: (responseId: number) => void
+ openVendorContactsDialog: (vendorId: number, vendor: VendorWithCbeFields) => void // 수정된 시그니처
+
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ openVendorContactsDialog
+}: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] {
+ // ----------------------------------------------------------------
+ // 1) Select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<VendorWithCbeFields> = {
+ 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"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) 그룹화(Nested) 컬럼 구성
+ // ----------------------------------------------------------------
+ const groupMap: Record<string, ColumnDef<VendorWithCbeFields>[]> = {}
+
+ vendorCbeColumnsConfig.forEach((cfg) => {
+ const groupName = cfg.group || "_noGroup"
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // childCol: ColumnDef<VendorWithTbeFields>
+ const childCol: ColumnDef<VendorWithCbeFields> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ // 셀 렌더링
+ cell: ({ row, getValue }) => {
+ // 1) 필드값 가져오기
+ const val = getValue()
+
+ if (cfg.id === "vendorName") {
+ const vendor = row.original;
+ const vendorId = vendor.vendorId;
+
+ // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링
+ const handleVendorNameClick = () => {
+ if (vendorId) {
+ openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달
+ } else {
+ toast.error("협력업체 ID를 찾을 수 없습니다.");
+ }
+ };
+
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left font-normal justify-start hover:underline"
+ onClick={handleVendorNameClick}
+ >
+ {val as string}
+ </Button>
+ );
+ }
+
+
+ if (cfg.id === "vendorStatus") {
+ const statusVal = row.original.vendorStatus
+ if (!statusVal) return null
+ // const Icon = getStatusIcon(statusVal)
+ return (
+ <Badge variant="outline">
+ {statusVal}
+ </Badge>
+ )
+ }
+
+
+ if (cfg.id === "responseStatus") {
+ const statusVal = row.original.responseStatus
+ if (!statusVal) return null
+ // const Icon = getStatusIcon(statusVal)
+ const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline"
+ return (
+ <Badge variant={variant}>
+ {statusVal}
+ </Badge>
+ )
+ }
+
+ // 예) CBE Updated (날짜)
+ if (cfg.id === "respondedAt") {
+ const dateVal = val as Date | undefined
+ if (!dateVal) return null
+ return formatDate(dateVal)
+ }
+
+ // 그 외 필드는 기본 값 표시
+ return val ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // groupMap → nestedColumns
+ const nestedColumns: ColumnDef<VendorWithCbeFields>[] = []
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ nestedColumns.push(...colDefs)
+ } else {
+ nestedColumns.push({
+ id: groupName,
+ header: groupName,
+ columns: colDefs,
+ })
+ }
+ })
+
+// 댓글 칼럼
+const commentsColumn: ColumnDef<VendorWithCbeFields> = {
+ id: "comments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Comments" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const commCount = vendor.comments?.length ?? 0
+
+ function handleClick() {
+ // setRowAction() 로 type 설정
+ setRowAction({ row, type: "comments" })
+ // 필요하면 즉시 openCommentSheet() 직접 호출
+ openCommentSheet(vendor.responseId ?? 0)
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ commCount > 0 ? `View ${commCount} comments` : "No comments"
+ }
+ >
+ <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {commCount > 0 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
+ >
+ {commCount}
+ </Badge>
+ )}
+ <span className="sr-only">
+ {commCount > 0 ? `${commCount} Comments` : "No Comments"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ minSize: 80,
+}
+// ----------------------------------------------------------------
+// 5) 최종 컬럼 배열 - Update to include the files column
+// ----------------------------------------------------------------
+return [
+ selectColumn,
+ ...nestedColumns,
+ commentsColumn,
+ // actionsColumn,
+]
+
+} \ No newline at end of file
diff --git a/lib/cbe/table/cbe-table-toolbar-actions.tsx b/lib/cbe/table/cbe-table-toolbar-actions.tsx
new file mode 100644
index 00000000..34b5b46c
--- /dev/null
+++ b/lib/cbe/table/cbe-table-toolbar-actions.tsx
@@ -0,0 +1,72 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+
+
+import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { InviteVendorsDialog } from "./invite-vendors-dialog"
+
+interface VendorsTableToolbarActionsProps {
+ table: Table<VendorWithCbeFields>
+ rfqId: number
+}
+
+export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일이 선택되었을 때 처리
+
+ function handleImportClick() {
+ // 숨겨진 <input type="file" /> 요소를 클릭
+ fileInputRef.current?.click()
+ }
+
+ const uniqueRfqIds = table.getFilteredSelectedRowModel().rows.length > 0
+ ? [...new Set(table.getFilteredSelectedRowModel().rows.map(row => row.original.rfqId))]
+ : [];
+
+const hasMultipleRfqIds = uniqueRfqIds.length > 1;
+
+const invitationPossibeVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ .filter(vendor => vendor.commercialResponseStatus === null);
+}, [table.getFilteredSelectedRowModel().rows]);
+
+return (
+ <div className="flex items-center gap-2">
+ {invitationPossibeVendors.length > 0 && (
+ <InviteVendorsDialog
+ vendors={invitationPossibeVendors}
+ rfqId={rfqId}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ hasMultipleRfqIds={hasMultipleRfqIds}
+ />
+ )}
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "tasks",
+ 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/cbe/table/cbe-table.tsx b/lib/cbe/table/cbe-table.tsx
new file mode 100644
index 00000000..38a0a039
--- /dev/null
+++ b/lib/cbe/table/cbe-table.tsx
@@ -0,0 +1,192 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { toSentenceCase } from "@/lib/utils"
+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 { getColumns } from "./cbe-table-columns"
+import { CommentSheet, CbeComment } from "./comments-sheet"
+import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { fetchRfqAttachmentsbyCommentId, getAllCBE } from "@/lib/rfqs/service"
+import { VendorsTableToolbarActions } from "./cbe-table-toolbar-actions"
+import { InviteVendorsDialog } from "./invite-vendors-dialog"
+import { VendorContactsDialog } from "@/lib/rfqs/cbe-table/vendor-contact-dialog"
+import { useSession } from "next-auth/react" // Next-auth session hook 추가
+
+
+
+import { toast } from "sonner"
+
+interface VendorsTableProps {
+ promises: Promise<[
+ Awaited<ReturnType<typeof getAllCBE>>,
+ ]>
+}
+
+export function AllCbeTable({ promises }: VendorsTableProps) {
+
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+ const { data: session } = useSession() // 세션 정보 가져오기
+
+ const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0
+ const currentUser = session?.user
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null)
+ // **router** 획득
+ const router = useRouter()
+ // 댓글 시트 관련 state
+ const [initialComments, setInitialComments] = React.useState<CbeComment[]>([])
+ const [isLoadingComments, setIsLoadingComments] = React.useState(false)
+
+ const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
+ const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null)
+ const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null)
+ const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false)
+ const [selectedVendor, setSelectedVendor] = React.useState<VendorWithCbeFields | null>(null)
+ const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null)
+
+ // -----------------------------------------------------------
+ // 특정 action이 설정될 때마다 실행되는 effect
+ // -----------------------------------------------------------
+ React.useEffect(() => {
+ if (rowAction?.type === "comments") {
+ // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행
+ openCommentSheet(Number(rowAction.row.original.responseId))
+ }
+ }, [rowAction])
+
+ // -----------------------------------------------------------
+ // 댓글 시트 열기
+ // -----------------------------------------------------------
+ async function openCommentSheet(responseId: number) {
+ setInitialComments([])
+ setIsLoadingComments(true)
+ const comments = rowAction?.row.original.comments
+ const rfqId = rowAction?.row.original.rfqId
+ const vendorId = rowAction?.row.original.vendorId
+ try {
+ if (comments && comments.length > 0) {
+ const commentWithAttachments: CbeComment[] = await Promise.all(
+ comments.map(async (c) => {
+ const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
+
+ return {
+ ...c,
+ commentedBy: currentUserId, // DB나 API 응답에 있다고 가정
+ attachments,
+ }
+ })
+ )
+ // 3) state에 저장 -> CommentSheet에서 initialComments로 사용
+ setInitialComments(commentWithAttachments)
+ }
+
+ if(vendorId){ setSelectedVendorId(vendorId)}
+ if(rfqId){ setSelectedRfqId(rfqId)}
+ setSelectedCbeId(responseId)
+ setCommentSheetOpen(true)
+ }catch (error) {
+ console.error("Error loading comments:", error)
+ toast.error("Failed to load comments")
+ } finally {
+ // End loading regardless of success/failure
+ setIsLoadingComments(false)
+ }
+}
+
+const openVendorContactsDialog = (vendorId: number, vendor: VendorWithCbeFields) => {
+ setSelectedVendorId(vendorId)
+ setSelectedVendor(vendor)
+ setIsContactDialogOpen(true)
+}
+
+ // -----------------------------------------------------------
+ // 테이블 컬럼
+ // -----------------------------------------------------------
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, router, openCommentSheet, openVendorContactsDialog }),
+ [setRowAction, router]
+ )
+
+ // -----------------------------------------------------------
+ // 필터 필드
+ // -----------------------------------------------------------
+ const filterFields: DataTableFilterField<VendorWithCbeFields>[] = [
+ // 예: 표준 필터
+ ]
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [
+ { id: "vendorName", label: "Vendor Name", type: "text" },
+ { id: "vendorCode", label: "Vendor Code", type: "text" },
+ { id: "respondedAt", label: "Updated at", type: "date" },
+ ]
+
+ // -----------------------------------------------------------
+ // 테이블 생성 훅
+ // -----------------------------------------------------------
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "respondedAt", desc: true }],
+ columnPinning: { right: ["comments"] },
+ },
+ getRowId: (originalRow) => (`${originalRow.vendorId}${originalRow.rfqId}`),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <VendorsTableToolbarActions table={table} rfqId={selectedRfqId ?? 0} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 댓글 시트 */}
+ <CommentSheet
+ currentUserId={currentUserId}
+ open={commentSheetOpen}
+ onOpenChange={setCommentSheetOpen}
+ vendorId={selectedVendorId ?? 0}
+ rfqId={selectedRfqId ?? 0}
+ cbeId={selectedCbeId ?? 0}
+ isLoading={isLoadingComments}
+ initialComments={initialComments}
+ />
+
+ <InviteVendorsDialog
+ vendors={rowAction?.row.original ? [rowAction?.row.original] : []}
+ onOpenChange={() => setRowAction(null)}
+ rfqId={selectedRfqId ?? 0}
+ open={rowAction?.type === "invite"}
+ showTrigger={false}
+ currentUser={currentUser}
+ />
+
+ <VendorContactsDialog
+ isOpen={isContactDialogOpen}
+ onOpenChange={setIsContactDialogOpen}
+ vendorId={selectedVendorId}
+ vendor={selectedVendor}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/cbe/table/comments-sheet.tsx b/lib/cbe/table/comments-sheet.tsx
new file mode 100644
index 00000000..2ac9049f
--- /dev/null
+++ b/lib/cbe/table/comments-sheet.tsx
@@ -0,0 +1,345 @@
+"use client"
+
+import * as React from "react"
+import { useForm, useFieldArray } from "react-hook-form"
+import { z } from "zod"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader, Download, X ,Loader2} from "lucide-react"
+import prettyBytes from "pretty-bytes"
+import { toast } from "sonner"
+
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Textarea,
+} from "@/components/ui/textarea"
+
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneUploadIcon,
+ DropzoneTitle,
+ DropzoneDescription,
+ DropzoneInput
+} from "@/components/ui/dropzone"
+
+import {
+ Table,
+ TableHeader,
+ TableRow,
+ TableHead,
+ TableBody,
+ TableCell
+} from "@/components/ui/table"
+
+// DB 스키마에서 필요한 타입들을 가져온다고 가정
+// (실제 프로젝트에 맞춰 import를 수정하세요.)
+import { formatDate } from "@/lib/utils"
+import { createRfqCommentWithAttachments } from "@/lib/rfqs/service"
+
+// 코멘트 + 첨부파일 구조 (단순 예시)
+// 실제 DB 스키마에 맞춰 조정
+export interface CbeComment {
+ id: number
+ commentText: string
+ commentedBy?: number
+ commentedByEmail?: string
+ createdAt?: Date
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ }[]
+}
+
+interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ initialComments?: CbeComment[]
+ currentUserId: number
+ rfqId: number
+ // tbeId?: number
+ cbeId?: number
+ vendorId: number
+ onCommentsUpdated?: (comments: CbeComment[]) => void
+ isLoading?: boolean // New prop
+}
+
+// 새 코멘트 작성 폼 스키마
+const commentFormSchema = z.object({
+ commentText: z.string().min(1, "댓글을 입력하세요."),
+ newFiles: z.array(z.any()).optional() // File[]
+})
+type CommentFormValues = z.infer<typeof commentFormSchema>
+
+const MAX_FILE_SIZE = 30e6 // 30MB
+
+export function CommentSheet({
+ rfqId,
+ vendorId,
+ initialComments = [],
+ currentUserId,
+ // tbeId,
+ cbeId,
+ onCommentsUpdated,
+ isLoading = false, // Default to false
+ ...props
+}: CommentSheetProps) {
+ const [comments, setComments] = React.useState<CbeComment[]>(initialComments)
+ const [isPending, startTransition] = React.useTransition()
+
+ React.useEffect(() => {
+ setComments(initialComments)
+ }, [initialComments])
+
+
+ // RHF 세팅
+ const form = useForm<CommentFormValues>({
+ resolver: zodResolver(commentFormSchema),
+ defaultValues: {
+ commentText: "",
+ newFiles: []
+ }
+ })
+
+ // formFieldArray 예시 (파일 목록)
+ const { fields: newFileFields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "newFiles"
+ })
+
+ // (A) 기존 코멘트 렌더링
+ function renderExistingComments() {
+
+ if (isLoading) {
+ return (
+ <div className="flex justify-center items-center h-32">
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
+ </div>
+ )
+ }
+
+ if (comments.length === 0) {
+ return <p className="text-sm text-muted-foreground">No comments yet</p>
+ }
+
+ return (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-1/2">Comment</TableHead>
+ <TableHead>Attachments</TableHead>
+ <TableHead>Created At</TableHead>
+ <TableHead>Created By</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {comments.map((c) => (
+ <TableRow key={c.id}>
+ <TableCell>{c.commentText}</TableCell>
+ <TableCell>
+ {/* 첨부파일 표시 */}
+ {!c.attachments?.length && (
+ <span className="text-sm text-muted-foreground">No files</span>
+ )}
+ {c.attachments?.length && (
+ <div className="flex flex-col gap-1">
+ {c.attachments.map((att) => (
+ <div key={att.id} className="flex items-center gap-2">
+ <a
+ href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`}
+ download
+ target="_blank"
+ rel="noreferrer"
+ className="inline-flex items-center gap-1 text-blue-600 underline"
+ >
+ <Download className="h-4 w-4" />
+ {att.fileName}
+ </a>
+ </div>
+ ))}
+ </div>
+ )}
+ </TableCell>
+ <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell>
+ <TableCell>
+ {c.commentedByEmail ?? "-"}
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )
+ }
+
+ // 2) 새 파일 Drop
+ function handleDropAccepted(files: File[]) {
+ append(files)
+ }
+
+
+ // 3) 저장(Submit)
+ async function onSubmit(data: CommentFormValues) {
+
+ if (!rfqId) return
+ startTransition(async () => {
+ try {
+ // console.log("rfqId", rfqId)
+ // console.log("vendorId", vendorId)
+ // console.log("cbeId", cbeId)
+ // console.log("currentUserId", currentUserId)
+ const res = await createRfqCommentWithAttachments({
+ rfqId: rfqId,
+ vendorId: vendorId, // 필요시 세팅
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ evaluationId: null, // 필요시 세팅
+ cbeId: cbeId,
+ files: data.newFiles
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create comment")
+ }
+
+ toast.success("Comment created")
+
+ // 새 코멘트를 다시 불러오거나,
+ // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트
+ const newComment: CbeComment = {
+ id: res.commentId, // 서버에서 반환된 commentId
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ createdAt: res.createdAt,
+ attachments: (data.newFiles?.map((f, idx) => ({
+ id: Math.random() * 100000,
+ fileName: f.name,
+ filePath: "/uploads/" + f.name,
+ })) || [])
+ }
+ setComments((prev) => [...prev, newComment])
+ onCommentsUpdated?.([...comments, newComment])
+
+ // 폼 리셋
+ form.reset()
+ } catch (err: any) {
+ console.error(err)
+ toast.error("Error: " + err.message)
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
+ <SheetHeader className="text-left">
+ <SheetTitle>Comments</SheetTitle>
+ <SheetDescription>
+ 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ {/* 기존 코멘트 목록 */}
+ <div className="max-h-[300px] overflow-y-auto">
+ {renderExistingComments()}
+ </div>
+
+ {/* 새 코멘트 작성 Form */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <FormField
+ control={form.control}
+ name="commentText"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>New Comment</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Enter your comment..."
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Dropzone (파일 첨부) */}
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={(rej) => {
+ toast.error("File rejected: " + (rej[0]?.file?.name || ""))
+ }}
+ >
+ {({ maxSize }) => (
+ <DropzoneZone className="flex justify-center">
+ <DropzoneInput />
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>Drop to attach files</DropzoneTitle>
+ <DropzoneDescription>
+ Max size: {prettyBytes(maxSize || 0)}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
+
+ {/* 선택된 파일 목록 */}
+ {newFileFields.length > 0 && (
+ <div className="flex flex-col gap-2">
+ {newFileFields.map((field, idx) => {
+ const file = form.getValues(`newFiles.${idx}`)
+ if (!file) return null
+ return (
+ <div key={field.id} className="flex items-center justify-between border rounded p-2">
+ <span className="text-sm">{file.name} ({prettyBytes(file.size)})</span>
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ onClick={() => remove(idx)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ )
+ })}
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-4">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isPending}>
+ {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/cbe/table/invite-vendors-dialog.tsx b/lib/cbe/table/invite-vendors-dialog.tsx
new file mode 100644
index 00000000..6ebc087b
--- /dev/null
+++ b/lib/cbe/table/invite-vendors-dialog.tsx
@@ -0,0 +1,428 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader, Send, User } from "lucide-react"
+import { toast } from "sonner"
+import { z } from "zod"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { type Row } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
+import { createCbeEvaluation } from "@/lib/rfqs/service"
+
+// 컴포넌트 내부에서 사용할 폼 스키마 정의
+const formSchema = z.object({
+ paymentTerms: z.string().min(1, "결제 조건을 입력하세요"),
+ incoterms: z.string().min(1, "Incoterms를 입력하세요"),
+ deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"),
+ notes: z.string().optional(),
+})
+
+type FormValues = z.infer<typeof formSchema>
+
+interface InviteVendorsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ rfqId: number
+ vendors: Row<VendorWithCbeFields>["original"][]
+ currentUserId?: number
+ currentUser?: {
+ id: string
+ name?: string | null
+ email?: string | null
+ image?: string | null
+ companyId?: number | null
+ domain?: string | null
+ }
+ showTrigger?: boolean
+ onSuccess?: () => void
+ hasMultipleRfqIds?: boolean
+}
+
+export function InviteVendorsDialog({
+ rfqId,
+ vendors,
+ currentUserId,
+ currentUser,
+ showTrigger = true,
+ onSuccess,
+ hasMultipleRfqIds,
+ ...props
+}: InviteVendorsDialogProps) {
+ const [files, setFiles] = React.useState<FileList | null>(null)
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ // 로컬 스키마와 폼 값을 사용하도록 수정
+ const form = useForm<FormValues>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ paymentTerms: "",
+ incoterms: "",
+ deliverySchedule: "",
+ notes: "",
+ },
+ mode: "onChange",
+ })
+
+ // 폼 상태 감시
+ const { formState } = form
+ const isValid = formState.isValid &&
+ !!form.getValues("paymentTerms") &&
+ !!form.getValues("incoterms") &&
+ !!form.getValues("deliverySchedule")
+
+ // 디버깅용 상태 트래킹
+ React.useEffect(() => {
+ const subscription = form.watch((value) => {
+ // 폼 값이 변경될 때마다 실행되는 콜백
+ console.log("Form values changed:", value);
+ });
+
+ return () => subscription.unsubscribe();
+ }, [form]);
+
+ async function onSubmit(data: FormValues) {
+ try {
+ setIsSubmitting(true)
+
+ // 기본 FormData 생성
+ const formData = new FormData()
+
+ // rfqId 추가
+ formData.append("rfqId", String(rfqId))
+
+ // 폼 데이터 추가
+ Object.entries(data).forEach(([key, value]) => {
+ if (value !== undefined && value !== null) {
+ formData.append(key, String(value))
+ }
+ })
+
+ // 현재 사용자 ID 추가
+ if (currentUserId) {
+ formData.append("evaluatedBy", String(currentUserId))
+ }
+
+ // 협력업체 ID만 추가 (서버에서 연락처 정보를 조회)
+ vendors.forEach((vendor) => {
+ formData.append("vendorIds[]", String(vendor.vendorId))
+ })
+
+ // 파일 추가 (있는 경우에만)
+ if (files && files.length > 0) {
+ for (let i = 0; i < files.length; i++) {
+ formData.append("files", files[i])
+ }
+ }
+
+ // 서버 액션 호출
+ const response = await createCbeEvaluation(formData)
+
+ if (response.error) {
+ toast.error(response.error)
+ return
+ }
+
+ // 성공 처리
+ toast.success(`${vendors.length}개 협력업체에 CBE 평가가 성공적으로 전송되었습니다!`)
+ form.reset()
+ setFiles(null)
+ props.onOpenChange?.(false)
+ onSuccess?.()
+ } catch (error) {
+ console.error(error)
+ toast.error("CBE 평가 생성 중 오류가 발생했습니다.")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ setFiles(null)
+ }
+ props.onOpenChange?.(nextOpen)
+ }
+
+ // 필수 필드 라벨에 추가할 요소
+ const RequiredLabel = (
+ <span className="text-destructive ml-1 font-medium">*</span>
+ )
+
+ const formContent = (
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ {/* 선택된 협력업체 정보 표시 */}
+ <div className="space-y-2">
+ <FormLabel>선택된 협력업체 ({vendors.length})</FormLabel>
+ <ScrollArea className="h-20 border rounded-md p-2">
+ <div className="flex flex-wrap gap-2">
+ {vendors.map((vendor, index) => (
+ <Badge key={index} variant="secondary" className="py-1">
+ {vendor.vendorName || `협력업체 #${vendor.vendorCode}`}
+ </Badge>
+ ))}
+ </div>
+ </ScrollArea>
+ <FormDescription>
+ 선택된 모든 협력업체의 등록된 연락처에게 CBE 평가 알림이 전송됩니다.
+ </FormDescription>
+ </div>
+
+ {/* 작성자 정보 (읽기 전용) */}
+ {currentUser && (
+ <div className="border rounded-md p-3 space-y-2">
+ <FormLabel>작성자</FormLabel>
+ <div className="flex items-center gap-3">
+ {currentUser.image ? (
+ <Avatar className="h-8 w-8">
+ <AvatarImage src={currentUser.image} alt={currentUser.name || ""} />
+ <AvatarFallback>
+ {currentUser.name?.charAt(0) || <User className="h-4 w-4" />}
+ </AvatarFallback>
+ </Avatar>
+ ) : (
+ <Avatar className="h-8 w-8">
+ <AvatarFallback>
+ {currentUser.name?.charAt(0) || <User className="h-4 w-4" />}
+ </AvatarFallback>
+ </Avatar>
+ )}
+ <div>
+ <p className="text-sm font-medium">{currentUser.name || "Unknown User"}</p>
+ <p className="text-xs text-muted-foreground">{currentUser.email || ""}</p>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* 결제 조건 - 필수 필드 */}
+ <FormField
+ control={form.control}
+ name="paymentTerms"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 결제 조건{RequiredLabel}
+ </FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="예: Net 30" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Incoterms - 필수 필드 */}
+ <FormField
+ control={form.control}
+ name="incoterms"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ Incoterms{RequiredLabel}
+ </FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="예: FOB, CIF" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 배송 일정 - 필수 필드 */}
+ <FormField
+ control={form.control}
+ name="deliverySchedule"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 배송 일정{RequiredLabel}
+ </FormLabel>
+ <FormControl>
+ <Textarea
+ {...field}
+ placeholder="배송 일정 세부사항을 입력하세요"
+ rows={3}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 비고 - 선택적 필드 */}
+ <FormField
+ control={form.control}
+ name="notes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ {...field}
+ placeholder="추가 비고 사항을 입력하세요"
+ rows={3}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 파일 첨부 (옵션) */}
+ <div className="space-y-2">
+ <FormLabel htmlFor="files">첨부 파일 (선택사항)</FormLabel>
+ <Input
+ id="files"
+ type="file"
+ multiple
+ onChange={(e) => setFiles(e.target.files)}
+ />
+ {files && files.length > 0 && (
+ <p className="text-sm text-muted-foreground">
+ {files.length}개 파일이 첨부되었습니다
+ </p>
+ )}
+ </div>
+
+ {/* 필수 입력 항목 안내 */}
+ <div className="text-sm text-muted-foreground">
+ <span className="text-destructive">*</span> 표시는 필수 입력 항목입니다.
+ </div>
+
+ {/* 모바일에서는 Drawer 내부에서 버튼이 렌더링되므로 여기서는 숨김 */}
+ {isDesktop && (
+ <DialogFooter className="gap-2 pt-4">
+ <DialogClose asChild>
+ <Button
+ type="button"
+ variant="outline"
+ >
+ 취소
+ </Button>
+ </DialogClose>
+ <Button
+ type="submit"
+ disabled={isSubmitting || !isValid}
+ >
+ {isSubmitting && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"}
+ </Button>
+ </DialogFooter>
+ )}
+ </form>
+ </Form>
+ )
+ if (hasMultipleRfqIds) {
+ toast.error("동일한 RFQ에 대해 선택해주세요");
+ return;
+ }
+ // Desktop Dialog
+ if (isDesktop) {
+ return (
+ <Dialog {...props} onOpenChange={handleDialogOpenChange}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Send className="mr-2 size-4" aria-hidden="true" />
+ CBE 평가 전송 ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <DialogTitle>CBE 평가 생성 및 전송</DialogTitle>
+ <DialogDescription>
+ 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {formContent}
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ // Mobile Drawer
+ return (
+ <Drawer {...props} onOpenChange={handleDialogOpenChange}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Send className="mr-2 size-4" aria-hidden="true" />
+ CBE 평가 전송 ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>CBE 평가 생성 및 전송</DrawerTitle>
+ <DrawerDescription>
+ 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다.
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="px-4">
+ {formContent}
+ </div>
+
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ onClick={form.handleSubmit(onSubmit)}
+ disabled={isSubmitting || !isValid}
+ >
+ {isSubmitting && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"}
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/equip-class/service.ts b/lib/equip-class/service.ts
index deaacc58..91b165f4 100644
--- a/lib/equip-class/service.ts
+++ b/lib/equip-class/service.ts
@@ -11,7 +11,6 @@ import { countTagClassLists, selectTagClassLists } from "./repository";
import { projects } from "@/db/schema";
export async function getTagClassists(input: GetTagClassesSchema) {
-
return unstable_cache(
async () => {
try {
diff --git a/lib/form-list/repository.ts b/lib/form-list/repository.ts
index d3c555bf..9c7f6891 100644
--- a/lib/form-list/repository.ts
+++ b/lib/form-list/repository.ts
@@ -36,6 +36,8 @@ export async function selectFormLists(
classLabel: tagTypeClassFormMappings.classLabel,
formCode: tagTypeClassFormMappings.formCode,
formName: tagTypeClassFormMappings.formName,
+ ep: tagTypeClassFormMappings.ep,
+ remark: tagTypeClassFormMappings.remark,
createdAt: tagTypeClassFormMappings.createdAt,
updatedAt: tagTypeClassFormMappings.updatedAt,
// 프로젝트 정보 추가
diff --git a/lib/form-list/service.ts b/lib/form-list/service.ts
index 310930be..9887609f 100644
--- a/lib/form-list/service.ts
+++ b/lib/form-list/service.ts
@@ -74,6 +74,8 @@ export async function getFormLists(input: GetFormListsSchema) {
offset,
limit: input.perPage,
});
+
+ console.log("dbdata")
const total = await countFormLists(tx, where);
return { data, total };
diff --git a/lib/form-list/table/formLists-table-toolbar-actions.tsx b/lib/form-list/table/formLists-table-toolbar-actions.tsx
index 96494607..cf874985 100644
--- a/lib/form-list/table/formLists-table-toolbar-actions.tsx
+++ b/lib/form-list/table/formLists-table-toolbar-actions.tsx
@@ -2,71 +2,150 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Download, RefreshCcw, Upload } from "lucide-react"
+import { Download, RefreshCcw } from "lucide-react"
import { toast } from "sonner"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
import { ExtendedFormMappings } from "../validation"
-
-
interface ItemsTableToolbarActionsProps {
table: Table<ExtendedFormMappings>
}
export function FormListsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) {
const [isLoading, setIsLoading] = React.useState(false)
+ const [syncId, setSyncId] = React.useState<string | null>(null)
+ const pollingRef = React.useRef<NodeJS.Timeout | null>(null)
+
+ // Clean up polling on unmount
+ React.useEffect(() => {
+ return () => {
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current)
+ }
+ }
+ }, [])
- const syncForms = async () => {
+ const startFormSync = async () => {
try {
setIsLoading(true)
-
- // API 엔드포인트 호출
- const response = await fetch('/api/cron/forms')
-
+
+ // API 엔드포인트 호출 - 작업 시작만 요청
+ const response = await fetch('/api/cron/forms/start', {
+ method: 'POST'
+ })
+
if (!response.ok) {
const errorData = await response.json()
- throw new Error(errorData.error || 'Failed to sync forms')
+ throw new Error(errorData.error || 'Failed to start form sync')
}
-
+
const data = await response.json()
-
- // 성공 메시지 표시
- toast.success(
- `Forms synced successfully! ${data.result.items} items processed.`
- )
-
- // 페이지 새로고침으로 테이블 데이터 업데이트
- window.location.reload()
+
+ // 작업 ID 저장
+ if (data.syncId) {
+ setSyncId(data.syncId)
+ toast.info('Form sync started. This may take a while...')
+
+ // 상태 확인을 위한 폴링 시작
+ startPolling(data.syncId)
+ } else {
+ throw new Error('No sync ID returned from server')
+ }
} catch (error) {
- console.error('Error syncing forms:', error)
+ console.error('Error starting form sync:', error)
toast.error(
error instanceof Error
? error.message
- : 'An error occurred while syncing forms'
+ : 'An error occurred while starting form sync'
)
- } finally {
setIsLoading(false)
}
}
-
+
+ const startPolling = (id: string) => {
+ // 이전 폴링이 있다면 제거
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current)
+ }
+
+ // 5초마다 상태 확인
+ pollingRef.current = setInterval(async () => {
+ try {
+ const response = await fetch(`/api/cron/forms/status?id=${id}`)
+
+ if (!response.ok) {
+ throw new Error('Failed to get sync status')
+ }
+
+ const data = await response.json()
+
+ if (data.status === 'completed') {
+ // 폴링 중지
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current)
+ pollingRef.current = null
+ }
+
+ // 상태 초기화
+ setIsLoading(false)
+ setSyncId(null)
+
+ // 성공 메시지 표시
+ toast.success(
+ `Forms synced successfully! ${data.result?.items || 0} items processed.`
+ )
+
+ // 테이블 데이터 업데이트 - 전체 페이지 새로고침 대신 데이터만 갱신
+ table.resetRowSelection()
+ // 여기서 테이블 데이터를 다시 불러오는 함수를 호출
+ // 예: refetchTableData()
+ // 또는 SWR/React Query 등을 사용하고 있다면 mutate() 호출
+
+ // 리로드가 꼭 필요하다면 아래 주석 해제
+ // window.location.reload()
+ } else if (data.status === 'failed') {
+ // 에러 처리
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current)
+ pollingRef.current = null
+ }
+
+ setIsLoading(false)
+ setSyncId(null)
+ toast.error(data.error || 'Sync failed')
+ } else if (data.status === 'processing') {
+ // 진행 상태 업데이트 (선택적)
+ if (data.progress) {
+ toast.info(`Sync in progress: ${data.progress}%`, {
+ id: `sync-progress-${id}`,
+ })
+ }
+ }
+ } catch (error) {
+ console.error('Error checking sync status:', error)
+ }
+ }, 5000) // 5초마다 체크
+ }
return (
<div className="flex items-center gap-2">
- {/** 4) Export 버튼 */}
+ {/** Sync Forms 버튼 */}
<Button
variant="samsung"
size="sm"
className="gap-2"
+ onClick={startFormSync}
+ disabled={isLoading}
>
<RefreshCcw className={`size-4 ${isLoading ? 'animate-spin' : ''}`} aria-hidden="true" />
<span className="hidden sm:inline">
{isLoading ? 'Syncing...' : 'Get Forms'}
</span>
</Button>
-
- {/** 4) Export 버튼 */}
+
+ {/** Export 버튼 */}
<Button
variant="outline"
size="sm"
diff --git a/lib/form-list/table/formLists-table.tsx b/lib/form-list/table/formLists-table.tsx
index 58ac4671..aa5bfa09 100644
--- a/lib/form-list/table/formLists-table.tsx
+++ b/lib/form-list/table/formLists-table.tsx
@@ -32,7 +32,6 @@ export function FormListsTable({ promises }: ItemsTableProps) {
const [{ data, pageCount }] =
React.use(promises)
-
const [rowAction, setRowAction] =
React.useState<DataTableRowAction<ExtendedFormMappings> | null>(null)
@@ -100,6 +99,12 @@ export function FormListsTable({ promises }: ItemsTableProps) {
type: "text",
},
+ {
+ id: "remark",
+ label: "remark",
+ type: "text",
+
+ },
{
id: "createdAt",
diff --git a/lib/form-list/table/meta-sheet.tsx b/lib/form-list/table/meta-sheet.tsx
index 694ee845..7c15bdea 100644
--- a/lib/form-list/table/meta-sheet.tsx
+++ b/lib/form-list/table/meta-sheet.tsx
@@ -1,8 +1,9 @@
"use client"
import * as React from "react"
-import { useMemo } from "react"
+import { useState } from "react"
import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
import {
Sheet,
SheetContent,
@@ -34,6 +35,56 @@ import {
import type { TagTypeClassFormMappings } from "@/db/schema/vendorData" // or your actual type
import { fetchFormMetadata, FormColumn } from "@/lib/forms/services"
+// 옵션을 표시하기 위한 새로운 컴포넌트
+const CollapsibleOptions = ({ options }: { options?: string[] }) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ // 옵션이 없거나 5개 이하인 경우 모두 표시
+ if (!options || options.length <= 5) {
+ return (
+ <div className="flex flex-wrap gap-1">
+ {options?.map((option) => (
+ <Badge key={option} variant="outline" className="text-xs">
+ {option}
+ </Badge>
+ )) || "-"}
+ </div>
+ );
+ }
+
+ // 더 많은 옵션이 있는 경우 접었다 펼칠 수 있게 함
+ return (
+ <div>
+ <div className="flex flex-wrap gap-1 mb-1">
+ {isExpanded
+ ? options.map((option) => (
+ <Badge key={option} variant="outline" className="text-xs">
+ {option}
+ </Badge>
+ ))
+ : options.slice(0, 3).map((option) => (
+ <Badge key={option} variant="outline" className="text-xs">
+ {option}
+ </Badge>
+ ))
+ }
+ {!isExpanded && (
+ <Badge variant="outline" className="text-xs bg-muted">
+ +{options.length - 3} more
+ </Badge>
+ )}
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 text-xs px-2 py-0"
+ onClick={() => setIsExpanded(!isExpanded)}
+ >
+ {isExpanded ? "Show less" : "Show all"}
+ </Button>
+ </div>
+ );
+};
interface ViewMetasProps {
open: boolean
@@ -51,7 +102,7 @@ export function ViewMetas({ open, onOpenChange, form }: ViewMetasProps) {
const [loading, setLoading] = React.useState(false)
// Group columns by type for better organization
- const groupedColumns = useMemo(() => {
+ const groupedColumns = React.useMemo(() => {
if (!metadata?.columns) return {}
return metadata.columns.reduce((acc, column) => {
@@ -65,7 +116,7 @@ export function ViewMetas({ open, onOpenChange, form }: ViewMetasProps) {
}, [metadata])
// Types for the tabs
- const columnTypes = useMemo(() => {
+ const columnTypes = React.useMemo(() => {
return Object.keys(groupedColumns)
}, [groupedColumns])
@@ -165,17 +216,7 @@ export function ViewMetas({ open, onOpenChange, form }: ViewMetasProps) {
<Badge variant="secondary">{column.type}</Badge>
</TableCell>
<TableCell>
- {column.options ? (
- <div className="flex flex-wrap gap-1">
- {column.options.map((option) => (
- <Badge key={option} variant="outline" className="text-xs">
- {option}
- </Badge>
- ))}
- </div>
- ) : (
- "-"
- )}
+ <CollapsibleOptions options={column.options} />
</TableCell>
</TableRow>
))}
@@ -198,7 +239,7 @@ export function ViewMetas({ open, onOpenChange, form }: ViewMetasProps) {
<TableRow>
<TableHead>Key</TableHead>
<TableHead>Label</TableHead>
- {type === "select" && <TableHead>Options</TableHead>}
+ {type === "LIST" && <TableHead>Options</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
@@ -206,19 +247,9 @@ export function ViewMetas({ open, onOpenChange, form }: ViewMetasProps) {
<TableRow key={column.key}>
<TableCell className="font-mono text-sm">{column.key}</TableCell>
<TableCell>{column.label}</TableCell>
- {type === "select" && (
+ {type === "LIST" && (
<TableCell>
- {column.options ? (
- <div className="flex flex-wrap gap-1">
- {column.options.map((option) => (
- <Badge key={option} variant="outline" className="text-xs">
- {option}
- </Badge>
- ))}
- </div>
- ) : (
- "-"
- )}
+ <CollapsibleOptions options={column.options} />
</TableCell>
)}
</TableRow>
diff --git a/lib/forms/services.ts b/lib/forms/services.ts
index d77f91d3..bd6e4bbc 100644
--- a/lib/forms/services.ts
+++ b/lib/forms/services.ts
@@ -10,74 +10,78 @@ import {
formEntries,
formMetas,
forms,
+ tagClasses,
tags,
+ tagSubfieldOptions,
+ tagSubfields,
tagTypeClassFormMappings,
+ tagTypes,
vendorDataReportTemps,
VendorDataReportTemps,
} from "@/db/schema/vendorData";
-import { eq, and, desc, sql, DrizzleError, or } from "drizzle-orm";
+import { eq, and, desc, sql, DrizzleError, or,type SQL ,type InferSelectModel } from "drizzle-orm";
import { unstable_cache } from "next/cache";
import { revalidateTag } from "next/cache";
import { getErrorMessage } from "../handle-error";
import { DataTableColumnJSON } from "@/components/form-data/form-data-table-columns";
import { contractItems, contracts, projects } from "@/db/schema";
+import { getSEDPToken } from "../sedp/sedp-token";
-export interface FormInfo {
- id: number;
- formCode: string;
- formName: string;
- // tagType: string
-}
-export async function getFormsByContractItemId(contractItemId: number | null) {
+export type FormInfo = InferSelectModel<typeof forms>;
+export async function getFormsByContractItemId(
+ contractItemId: number | null,
+ mode: "ENG" | "IM" | "ALL" = "ALL"
+): Promise<{ forms: FormInfo[] }> {
// 유효성 검사
if (!contractItemId || contractItemId <= 0) {
console.warn(`Invalid contractItemId: ${contractItemId}`);
return { forms: [] };
}
- // 고유 캐시 키
- const cacheKey = `forms-${contractItemId}`;
+ // 고유 캐시 키 (모드 포함)
+ const cacheKey = `forms-${contractItemId}-${mode}`;
try {
return unstable_cache(
-
async () => {
- console.log(contractItemId,"contractItemId")
-
console.log(
- `[Forms Service] Fetching forms for contractItemId: ${contractItemId}`
+ `[Forms Service] Fetching forms for contractItemId: ${contractItemId}, mode: ${mode}`
);
try {
- // 데이터베이스에서 폼 조회
- const formRecords = await db
- .select({
- id: forms.id,
- formCode: forms.formCode,
- formName: forms.formName,
- // tagType: forms.tagType,
- })
- .from(forms)
- .where(eq(forms.contractItemId, contractItemId));
+ // 쿼리 생성
+ let query = db.select().from(forms).where(eq(forms.contractItemId, contractItemId));
+
+ // 모드에 따른 추가 필터
+ if (mode === "ENG") {
+ query = db.select().from(forms).where(
+ and(
+ eq(forms.contractItemId, contractItemId),
+ eq(forms.eng, true)
+ )
+ );
+ } else if (mode === "IM") {
+ query = db.select().from(forms).where(
+ and(
+ eq(forms.contractItemId, contractItemId),
+ eq(forms.im, true)
+ )
+ );
+ }
+
+ // 쿼리 실행
+ const formRecords = await query;
console.log(
- `[Forms Service] Found ${formRecords.length} forms for contractItemId: ${contractItemId}`
+ `[Forms Service] Found ${formRecords.length} forms for contractItemId: ${contractItemId}, mode: ${mode}`
);
- // 결과가 배열인지 확인
- if (!Array.isArray(formRecords)) {
- getErrorMessage(
- `Unexpected result format for contractItemId ${contractItemId} ${formRecords}`
- );
- return { forms: [] };
- }
-
return { forms: formRecords };
} catch (error) {
getErrorMessage(
- `Database error for contractItemId ${contractItemId}: ${error}`
+ `Database error for contractItemId ${contractItemId}, mode: ${mode}: ${error}`
);
throw error; // 캐시 함수에서 에러를 던져 캐싱이 발생하지 않도록 함
}
@@ -91,29 +95,42 @@ export async function getFormsByContractItemId(contractItemId: number | null) {
)();
} catch (error) {
getErrorMessage(
- `Cache operation failed for contractItemId ${contractItemId}: ${error}`
+ `Cache operation failed for contractItemId ${contractItemId}, mode: ${mode}: ${error}`
);
// 캐시 문제 시 직접 쿼리 시도
try {
console.log(
- `[Forms Service] Fallback: Direct query for contractItemId: ${contractItemId}`
+ `[Forms Service] Fallback: Direct query for contractItemId: ${contractItemId}, mode: ${mode}`
);
- const formRecords = await db
- .select({
- id: forms.id,
- formCode: forms.formCode,
- formName: forms.formName,
- // tagType: forms.tagType,
- })
- .from(forms)
- .where(eq(forms.contractItemId, contractItemId));
+ // 쿼리 생성
+ let query = db.select().from(forms).where(eq(forms.contractItemId, contractItemId));
+
+ // 모드에 따른 추가 필터
+ if (mode === "ENG") {
+ query = db.select().from(forms).where(
+ and(
+ eq(forms.contractItemId, contractItemId),
+ eq(forms.eng, true)
+ )
+ );
+ } else if (mode === "IM") {
+ query = db.select().from(forms).where(
+ and(
+ eq(forms.contractItemId, contractItemId),
+ eq(forms.im, true)
+ )
+ );
+ }
+
+ // 쿼리 실행
+ const formRecords = await query;
return { forms: formRecords };
} catch (dbError) {
getErrorMessage(
- `Fallback query failed for contractItemId ${contractItemId}:${dbError}`
+ `Fallback query failed for contractItemId ${contractItemId}, mode: ${mode}: ${dbError}`
);
return { forms: [] };
}
@@ -145,6 +162,7 @@ export async function revalidateForms(contractItemId: number) {
export async function getFormData(formCode: string, contractItemId: number) {
// 고유 캐시 키 (formCode + contractItemId)
const cacheKey = `form-data-${formCode}-${contractItemId}`;
+ console.log(cacheKey, "getFormData")
try {
// 1) unstable_cache로 전체 로직을 감싼다
@@ -338,88 +356,6 @@ export async function getFormData(formCode: string, contractItemId: number) {
}
}
-// export async function syncMissingTags(contractItemId: number, formCode: string) {
-
-// // (1) forms 테이블에서 (contractItemId, formCode) 찾기
-// const [formRow] = await db
-// .select()
-// .from(forms)
-// .where(and(eq(forms.contractItemId, contractItemId), eq(forms.formCode, formCode)))
-// .limit(1)
-
-// if (!formRow) {
-// throw new Error(`Form not found for contractItemId=${contractItemId}, formCode=${formCode}`)
-// }
-
-// const { tagType, class: className } = formRow
-
-// // (2) tags 테이블에서 (contractItemId, tagType, class)인 태그 찾기
-// const tagRows = await db
-// .select()
-// .from(tags)
-// .where(
-// and(
-// eq(tags.contractItemId, contractItemId),
-// eq(tags.tagType, tagType),
-// eq(tags.class, className),
-// )
-// )
-
-// if (tagRows.length === 0) {
-// console.log("No matching tags found.")
-// return { createdCount: 0 }
-// }
-
-// // (3) formEntries에서 (contractItemId, formCode)인 row 1개 조회
-// let [entry] = await db
-// .select()
-// .from(formEntries)
-// .where(
-// and(
-// eq(formEntries.contractItemId, contractItemId),
-// eq(formEntries.formCode, formCode)
-// )
-// )
-// .limit(1)
-
-// // (4) 만약 없다면 새로 insert: data = []
-// if (!entry) {
-// const [inserted] = await db.insert(formEntries).values({
-// contractItemId,
-// formCode,
-// data: [], // 초기 상태는 빈 배열
-// }).returning()
-// entry = inserted
-// }
-
-// // entry.data는 배열이라고 가정
-// // Drizzle에서 jsonb는 JS object로 파싱되어 들어오므로, 타입 캐스팅
-// const existingData = entry.data as Array<{ tagNumber: string }>
-// let createdCount = 0
-
-// // (5) tagRows 각각에 대해, 이미 배열에 존재하는지 확인 후 없으면 push
-// const updatedArray = [...existingData]
-// for (const tagRow of tagRows) {
-// const tagNo = tagRow.tagNo
-// const found = updatedArray.some(item => item.tagNumber === tagNo)
-// if (!found) {
-// updatedArray.push({ tagNumber: tagNo })
-// createdCount++
-// }
-// }
-
-// // (6) 변경이 있으면 UPDATE
-// if (createdCount > 0) {
-// await db
-// .update(formEntries)
-// .set({ data: updatedArray })
-// .where(eq(formEntries.id, entry.id))
-// }
-
-// revalidateTag(`form-data-${formCode}-${contractItemId}`);
-
-// return { createdCount }
-// }
export async function syncMissingTags(
contractItemId: number,
@@ -490,10 +426,10 @@ export async function syncMissingTags(
entry = inserted;
}
- // entry.data는 [{ tagNumber: string, tagDescription?: string }, ...] 형태라고 가정
+ // entry.data는 [{ TAG_NO: string, TAG_DESC?: string }, ...] 형태라고 가정
const existingData = entry.data as Array<{
- tagNumber: string;
- tagDescription?: string;
+ TAG_NO: string;
+ TAG_DESC?: string;
}>;
// Create a Set of valid tagNumbers from tagRows for efficient lookup
@@ -501,8 +437,8 @@ export async function syncMissingTags(
// Copy existing data to work with
let updatedData: Array<{
- tagNumber: string;
- tagDescription?: string;
+ TAG_NO: string;
+ TAG_DESC?: string;
}> = [];
let createdCount = 0;
@@ -511,7 +447,7 @@ export async function syncMissingTags(
// First, filter out items that should be deleted (not in validTagNumbers)
for (const item of existingData) {
- if (validTagNumbers.has(item.tagNumber)) {
+ if (validTagNumbers.has(item.TAG_NO)) {
updatedData.push(item);
} else {
deletedCount++;
@@ -523,25 +459,25 @@ export async function syncMissingTags(
for (const tagRow of tagRows) {
const { tagNo, description } = tagRow;
- // 5-1. 기존 데이터에서 tagNumber 매칭
+ // 5-1. 기존 데이터에서 TAG_NO 매칭
const existingIndex = updatedData.findIndex(
- (item) => item.tagNumber === tagNo
+ (item) => item.TAG_NO === tagNo
);
// 5-2. 없다면 새로 추가
if (existingIndex === -1) {
updatedData.push({
- tagNumber: tagNo,
- tagDescription: description ?? "",
+ TAG_NO: tagNo,
+ TAG_DESC: description ?? "",
});
createdCount++;
} else {
// 5-3. 이미 있으면, description이 다를 때만 업데이트(선택 사항)
const existingItem = updatedData[existingIndex];
- if (existingItem.tagDescription !== description) {
+ if (existingItem.TAG_DESC !== description) {
updatedData[existingIndex] = {
...existingItem,
- tagDescription: description ?? "",
+ TAG_DESC: description ?? "",
};
updatedCount++;
}
@@ -565,7 +501,7 @@ export async function syncMissingTags(
/**
* updateFormDataInDB:
* (formCode, contractItemId)에 해당하는 "단 하나의" formEntries row를 가져와,
- * data: [{ tagNumber, ...}, ...] 배열에서 tagNumber 매칭되는 항목을 업데이트
+ * data: [{ TAG_NO, ...}, ...] 배열에서 TAG_NO 매칭되는 항목을 업데이트
* 업데이트 후, revalidateTag()로 캐시 무효화.
*/
type UpdateResponse = {
@@ -581,8 +517,8 @@ export async function updateFormDataInDB(
): Promise<UpdateResponse> {
try {
// 1) tagNumber로 식별
- const tagNumber = newData.tagNumber;
- if (!tagNumber) {
+ const TAG_NO = newData.TAG_NO;
+ if (!TAG_NO) {
return {
success: false,
message: "tagNumber는 필수 항목입니다.",
@@ -626,12 +562,12 @@ export async function updateFormDataInDB(
};
}
- // 4) tagNumber = newData.tagNumber 항목 찾기
- const idx = dataArray.findIndex((item) => item.tagNumber === tagNumber);
+ // 4) TAG_NO = newData.TAG_NO 항목 찾기
+ const idx = dataArray.findIndex((item) => item.TAG_NO === TAG_NO);
if (idx < 0) {
return {
success: false,
- message: `태그 번호 "${tagNumber}"를 가진 항목을 찾을 수 없습니다.`,
+ message: `태그 번호 "${TAG_NO}"를 가진 항목을 찾을 수 없습니다.`,
};
}
@@ -640,7 +576,7 @@ export async function updateFormDataInDB(
const updatedItem = {
...oldItem,
...newData,
- tagNumber: oldItem.tagNumber, // tagNumber 변경 불가 시 유지
+ TAG_NO: oldItem.TAG_NO, // TAG_NO 변경 불가 시 유지
};
const updatedArray = [...dataArray];
@@ -675,6 +611,7 @@ export async function updateFormDataInDB(
try {
// 캐시 태그를 form-data-${formCode}-${contractItemId} 형태로 가정
const cacheTag = `form-data-${formCode}-${contractItemId}`;
+ console.log(cacheTag, "update")
revalidateTag(cacheTag);
} catch (cacheError) {
console.warn("Cache revalidation warning:", cacheError);
@@ -685,9 +622,9 @@ export async function updateFormDataInDB(
success: true,
message: "데이터가 성공적으로 업데이트되었습니다.",
data: {
- tagNumber,
+ TAG_NO,
updatedFields: Object.keys(newData).filter(
- (key) => key !== "tagNumber"
+ (key) => key !== "TAG_NO"
),
},
};
@@ -922,3 +859,405 @@ export const deleteReportTempFile: deleteReportTempFile = async (id) => {
return { result: false, error: (err as Error).message };
}
};
+
+
+/**
+ * Get tag type mappings specific to a form
+ * @param formCode The form code to filter mappings
+ * @param projectId The project ID
+ * @returns Array of tag type-class mappings for the form
+ */
+export async function getFormTagTypeMappings(formCode: string, projectId: number) {
+
+ try {
+ const mappings = await db.query.tagTypeClassFormMappings.findMany({
+ where: and(
+ eq(tagTypeClassFormMappings.formCode, formCode),
+ eq(tagTypeClassFormMappings.projectId, projectId)
+ )
+ });
+
+ return mappings;
+ } catch (error) {
+ console.error("Error fetching form tag type mappings:", error);
+ throw new Error("Failed to load form tag type mappings");
+ }
+}
+
+/**
+ * Get tag type by its description
+ * @param description The tag type description (used as tagTypeLabel in mappings)
+ * @param projectId The project ID
+ * @returns The tag type object
+ */
+export async function getTagTypeByDescription(description: string, projectId: number) {
+ try {
+ const tagType = await db.query.tagTypes.findFirst({
+ where: and(
+ eq(tagTypes.description, description),
+ eq(tagTypes.projectId, projectId)
+ )
+ });
+
+ return tagType;
+ } catch (error) {
+ console.error("Error fetching tag type by description:", error);
+ throw new Error("Failed to load tag type");
+ }
+}
+
+/**
+ * Get subfields for a specific tag type
+ * @param tagTypeCode The tag type code
+ * @param projectId The project ID
+ * @returns Object containing subfields with their options
+ */
+export async function getSubfieldsByTagTypeForForm(tagTypeCode: string, projectId: number) {
+ try {
+ const subfields = await db.query.tagSubfields.findMany({
+ where: and(
+ eq(tagSubfields.tagTypeCode, tagTypeCode),
+ eq(tagSubfields.projectId, projectId)
+ ),
+ orderBy: tagSubfields.sortOrder
+ });
+
+ const subfieldsWithOptions = await Promise.all(
+ subfields.map(async (subfield) => {
+ const options = await db.query.tagSubfieldOptions.findMany({
+ where: and(
+ eq(tagSubfieldOptions.attributesId, subfield.attributesId),
+ eq(tagSubfieldOptions.projectId, projectId)
+ )
+ });
+
+ return {
+ name: subfield.attributesId,
+ label: subfield.attributesDescription,
+ type: options.length > 0 ? "select" : "text",
+ options: options.map(opt => ({ value: opt.code, label: opt.label })),
+ expression: subfield.expression || undefined,
+ delimiter: subfield.delimiter || undefined
+ };
+ })
+ );
+
+ return { subFields: subfieldsWithOptions };
+ } catch (error) {
+ console.error("Error fetching subfields for form:", error);
+ throw new Error("Failed to load subfields");
+ }
+}
+
+interface GenericData {
+ [key: string]: any;
+}
+
+interface SEDPAttribute {
+ NAME: string;
+ VALUE: any;
+ UOM: string;
+ UOM_ID?: string;
+}
+
+interface SEDPDataItem {
+ TAG_NO: string;
+ TAG_DESC: string;
+ ATTRIBUTES: SEDPAttribute[];
+ SCOPE: string;
+ TOOLID: string;
+ ITM_NO: string;
+ OP_DELETE: boolean;
+ MAIN_YN: boolean;
+ LAST_REV_YN: boolean;
+ CRTER_NO: string;
+ CHGER_NO: string;
+ TYPE: string;
+ PROJ_NO: string;
+ REV_NO: string;
+ CRTE_DTM?: string;
+ CHGE_DTM?: string;
+ _id?: string;
+}
+
+async function transformDataToSEDPFormat(
+ tableData: GenericData[],
+ columnsJSON: DataTableColumnJSON[],
+ formCode: string,
+ objectCode: string,
+ projectNo: string,
+ designerNo: string = "253213"
+): Promise<SEDPDataItem[]> {
+ // Create a map for quick column lookup
+ const columnsMap = new Map<string, DataTableColumnJSON>();
+ columnsJSON.forEach(col => {
+ columnsMap.set(col.key, col);
+ });
+
+ // Current timestamp for CRTE_DTM and CHGE_DTM
+ const currentTimestamp = new Date().toISOString();
+
+ // Define the API base URL
+ const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api';
+
+ // Get the token
+ const apiKey = await getSEDPToken();
+
+ // Cache for UOM factors to avoid duplicate API calls
+ const uomFactorCache = new Map<string, number>();
+
+ // Transform each row
+ const transformedItems = [];
+
+ for (const row of tableData) {
+ // Create base SEDP item with required fields
+ const sedpItem: SEDPDataItem = {
+ TAG_NO: row.TAG_NO || "",
+ TAG_DESC: row.TAG_DESC || "",
+ ATTRIBUTES: [],
+ SCOPE: objectCode,
+ TOOLID: "eVCP", // Changed from VDCS
+ ITM_NO: row.TAG_NO || "",
+ OP_DELETE: false,
+ MAIN_YN: true,
+ LAST_REV_YN: true,
+ CRTER_NO: designerNo,
+ CHGER_NO: designerNo,
+ TYPE: formCode,
+ PROJ_NO: projectNo,
+ REV_NO: "00",
+ CRTE_DTM: currentTimestamp,
+ CHGE_DTM: currentTimestamp,
+ _id: ""
+ };
+
+ // Convert all other fields (except TAG_NO and TAG_DESC) to ATTRIBUTES
+ for (const key in row) {
+ if (key !== "TAG_NO" && key !== "TAG_DESC") {
+ const column = columnsMap.get(key);
+ let value = row[key];
+
+ // Only process non-empty values
+ if (value !== undefined && value !== null && value !== "") {
+ // Check if we need to apply UOM conversion
+ if (column?.uomId) {
+ // First check cache to avoid duplicate API calls
+ let factor = uomFactorCache.get(column.uomId);
+
+ // If not in cache, make API call to get the factor
+ if (factor === undefined) {
+ try {
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/UOM/GetByID`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectNo
+ },
+ body: JSON.stringify({
+ 'ProjectNo': projectNo,
+ 'UOMID': column.uomId,
+ 'ContainDeleted': false
+ })
+ }
+ );
+
+ if (response.ok) {
+ const uomData = await response.json();
+ if (uomData && uomData.FACTOR !== undefined && uomData.FACTOR !== null) {
+ factor = Number(uomData.FACTOR);
+ // Store in cache for future use (type assertion to ensure it's a number)
+ uomFactorCache.set(column.uomId, factor);
+ }
+ } else {
+ console.warn(`Failed to get UOM data for ${column.uomId}: ${response.statusText}`);
+ }
+ } catch (error) {
+ console.error(`Error fetching UOM data for ${column.uomId}:`, error);
+ }
+ }
+
+ // Apply the factor if we got one
+ if (factor !== undefined && typeof value === 'number') {
+ value = value * factor;
+ }
+ }
+
+ const attribute: SEDPAttribute = {
+ NAME: key,
+ VALUE: String(value), // 모든 값을 문자열로 변환
+ UOM: column?.uom || ""
+ };
+
+ // Add UOM_ID if present in column definition
+ if (column?.uomId) {
+ attribute.UOM_ID = column.uomId;
+ }
+
+ sedpItem.ATTRIBUTES.push(attribute);
+ }
+ }
+ }
+
+ transformedItems.push(sedpItem);
+ }
+
+ return transformedItems;
+}
+
+// Server Action wrapper (async)
+export async function transformFormDataToSEDP(
+ tableData: GenericData[],
+ columnsJSON: DataTableColumnJSON[],
+ formCode: string,
+ objectCode: string,
+ projectNo: string,
+ designerNo: string = "253213"
+): Promise<SEDPDataItem[]> {
+ // Use the utility function within the async Server Action
+ return transformDataToSEDPFormat(
+ tableData,
+ columnsJSON,
+ formCode,
+ objectCode,
+ projectNo,
+ designerNo
+ );
+}
+
+/**
+ * Get project code by project ID
+ */
+export async function getProjectCodeById(projectId: number): Promise<string> {
+ const projectRecord = await db
+ .select({ code: projects.code })
+ .from(projects)
+ .where(eq(projects.id, projectId))
+ .limit(1);
+
+ if (!projectRecord || projectRecord.length === 0) {
+ throw new Error(`Project not found with ID: ${projectId}`);
+ }
+
+ return projectRecord[0].code;
+}
+
+/**
+ * Send data to SEDP
+ */
+export async function sendDataToSEDP(
+ projectCode: string,
+ sedpData: SEDPDataItem[]
+): Promise<any> {
+ try {
+ // Get the token
+ const apiKey = await getSEDPToken();
+
+ // Define the API base URL
+ const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api';
+
+ console.log("Sending data to SEDP:", JSON.stringify(sedpData, null, 2));
+
+ // Make the API call
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/AdapterData/Create`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify(sedpData)
+ }
+ );
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`);
+ }
+
+ const data = await response.json();
+ return data;
+ } catch (error: any) {
+ console.error('Error calling SEDP API:', error);
+ throw new Error(`Failed to send data to SEDP API: ${error.message || 'Unknown error'}`);
+ }
+}
+
+/**
+ * Server action to send form data to SEDP
+ */
+export async function sendFormDataToSEDP(
+ formCode: string,
+ projectId: number,
+ formData: GenericData[],
+ columns: DataTableColumnJSON[]
+): Promise<{ success: boolean; message: string; data?: any }> {
+ try {
+ // 1. Get project code
+ const projectCode = await getProjectCodeById(projectId);
+
+ // 2. Get class mapping
+ const mappingsResult = await db.query.tagTypeClassFormMappings.findFirst({
+ where: and(
+ eq(tagTypeClassFormMappings.formCode, formCode),
+ eq(tagTypeClassFormMappings.projectId, projectId)
+ )
+ });
+
+ // Check if mappings is an array or a single object and handle accordingly
+ const mappings = Array.isArray(mappingsResult) ? mappingsResult[0] : mappingsResult;
+
+ // Default object code to fallback value if we can't find it
+ let objectCode = ""; // Default fallback
+
+ if (mappings && mappings.classLabel) {
+ const objectCodeResult = await db.query.tagClasses.findFirst({
+ where: and(
+ eq(tagClasses.label, mappings.classLabel),
+ eq(tagClasses.projectId, projectId)
+ )
+ });
+
+ // Check if result is an array or a single object
+ const objectCodeRecord = Array.isArray(objectCodeResult) ? objectCodeResult[0] : objectCodeResult;
+
+ if (objectCodeRecord && objectCodeRecord.code) {
+ objectCode = objectCodeRecord.code;
+ } else {
+ console.warn(`No tag class found for label ${mappings.classLabel} in project ${projectId}, using default`);
+ }
+ } else {
+ console.warn(`No mapping found for formCode ${formCode} in project ${projectId}, using default object code`);
+ }
+
+ // 4. Transform data to SEDP format
+ const sedpData = await transformFormDataToSEDP(
+ formData,
+ columns,
+ formCode,
+ objectCode,
+ projectCode
+ );
+
+ // 5. Send to SEDP API
+ const result = await sendDataToSEDP(projectCode, sedpData);
+
+ return {
+ success: true,
+ message: "Data successfully sent to SEDP",
+ data: result
+ };
+ } catch (error: any) {
+ console.error("Error sending data to SEDP:", error);
+ return {
+ success: false,
+ message: error.message || "Failed to send data to SEDP"
+ };
+ }
+} \ No newline at end of file
diff --git a/lib/items/service.ts b/lib/items/service.ts
index ef14a5f0..226742ca 100644
--- a/lib/items/service.ts
+++ b/lib/items/service.ts
@@ -9,7 +9,7 @@ import { filterColumns } from "@/lib/filter-columns";
import { unstable_cache } from "@/lib/unstable-cache";
import { getErrorMessage } from "@/lib/handle-error";
-import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or ,eq} from "drizzle-orm";
import { CreateItemSchema, GetItemsSchema, UpdateItemSchema } from "./validations";
import { Item, items } from "@/db/schema/items";
import { countItems, deleteItemById, deleteItemsByIds, findAllItems, insertItem, selectItems, updateItem } from "./repository";
@@ -102,32 +102,86 @@ export async function getItems(input: GetItemsSchema) {
/* -----------------------------------------------------
2) 생성(Create)
----------------------------------------------------- */
+export interface ItemCreateData {
+ itemCode: string
+ itemName: string
+ description: string | null
+}
/**
* Item 생성 후, (가장 오래된 Item 1개) 삭제로
* 전체 Item 개수를 고정
*/
-export async function createItem(input: CreateItemSchema) {
- unstable_noStore(); // Next.js 서버 액션 캐싱 방지
+export async function createItem(input: ItemCreateData) {
+ unstable_noStore() // Next.js 서버 액션 캐싱 방지
+
try {
- await db.transaction(async (tx) => {
- // 새 Item 생성
- const [newTask] = await insertItem(tx, {
- itemCode: input.itemCode,
- itemName: input.itemName,
- description: input.description,
- });
- return newTask;
+ if (!input.itemCode || !input.itemName) {
+ return {
+ success: false,
+ message: "아이템 코드와 아이템 명은 필수입니다",
+ data: null,
+ error: "필수 필드 누락"
+ }
+ }
- });
+ // result 변수에 명시적으로 타입과 초기값 할당
+ let result: any[] = []
+
+ // 트랜잭션 결과를 result에 할당
+ result = await db.transaction(async (tx) => {
+ // 기존 아이템 확인 (itemCode는 unique)
+ const existingItem = await tx.query.items.findFirst({
+ where: eq(items.itemCode, input.itemCode),
+ })
+
+ let txResult
+ if (existingItem) {
+ // 기존 아이템 업데이트
+ txResult = await updateItem(tx, existingItem.id, {
+ itemName: input.itemName,
+ description: input.description,
+ })
+ } else {
+ // 새 아이템 생성
+ txResult = await insertItem(tx, {
+ itemCode: input.itemCode,
+ itemName: input.itemName,
+ description: input.description,
+ })
+ }
+
+ return txResult
+ })
// 캐시 무효화
- revalidateTag("items");
+ revalidateTag("items")
- return { data: null, error: null };
+ return {
+ success: true,
+ data: result[0] || null,
+ error: null
+ }
} catch (err) {
- return { data: null, error: getErrorMessage(err) };
+ console.error("아이템 생성/업데이트 오류:", err)
+
+ // 중복 키 오류 처리
+ if (err instanceof Error && err.message.includes("unique constraint")) {
+ return {
+ success: false,
+ message: "이미 존재하는 아이템 코드입니다",
+ data: null,
+ error: "중복 키 오류"
+ }
+ }
+
+ return {
+ success: false,
+ message: getErrorMessage(err),
+ data: null,
+ error: getErrorMessage(err)
+ }
}
}
diff --git a/lib/items/table/import-excel-button.tsx b/lib/items/table/import-excel-button.tsx
new file mode 100644
index 00000000..484fd778
--- /dev/null
+++ b/lib/items/table/import-excel-button.tsx
@@ -0,0 +1,266 @@
+"use client"
+
+import * as React from "react"
+import { Upload } from "lucide-react"
+import { toast } from "sonner"
+import * as ExcelJS from 'exceljs'
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Progress } from "@/components/ui/progress"
+import { processFileImport } from "./import-item-handler" // 별도 파일로 분리
+
+interface ImportItemButtonProps {
+ onSuccess?: () => void
+}
+
+export function ImportItemButton({ onSuccess }: ImportItemButtonProps) {
+ const [open, setOpen] = React.useState(false)
+ const [file, setFile] = React.useState<File | null>(null)
+ const [isUploading, setIsUploading] = React.useState(false)
+ const [progress, setProgress] = React.useState(0)
+ const [error, setError] = React.useState<string | null>(null)
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일 선택 처리
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const selectedFile = e.target.files?.[0]
+ if (!selectedFile) return
+
+ if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) {
+ setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.")
+ return
+ }
+
+ setFile(selectedFile)
+ setError(null)
+ }
+
+ // 데이터 가져오기 처리
+ const handleImport = async () => {
+ if (!file) {
+ setError("가져올 파일을 선택해주세요.")
+ return
+ }
+
+ try {
+ setIsUploading(true)
+ setProgress(0)
+ setError(null)
+
+ // 파일을 ArrayBuffer로 읽기
+ const arrayBuffer = await file.arrayBuffer();
+
+ // ExcelJS 워크북 로드
+ const workbook = new ExcelJS.Workbook();
+ await workbook.xlsx.load(arrayBuffer);
+
+ // 첫 번째 워크시트 가져오기
+ const worksheet = workbook.worksheets[0];
+ if (!worksheet) {
+ throw new Error("Excel 파일에 워크시트가 없습니다.");
+ }
+
+ // 헤더 행 찾기
+ let headerRowIndex = 1;
+ let headerRow: ExcelJS.Row | undefined;
+ let headerValues: (string | null)[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ const values = row.values as (string | null)[];
+ if (!headerRow && values.some(v => v === "아이템 코드" || v === "itemCode" || v === "item_code")) {
+ headerRowIndex = rowNumber;
+ headerRow = row;
+ headerValues = [...values];
+ }
+ });
+
+ if (!headerRow) {
+ throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다.");
+ }
+
+ // 헤더를 기반으로 인덱스 매핑 생성
+ const headerMapping: Record<string, number> = {};
+ headerValues.forEach((value, index) => {
+ if (typeof value === 'string') {
+ headerMapping[value] = index;
+ }
+ });
+
+ // 필수 헤더 확인
+ const requiredHeaders = ["아이템 코드", "아이템 명", "설명"];
+ const alternativeHeaders = {
+ "아이템 코드": ["itemCode", "item_code"],
+ "아이템 명": ["itemName", "item_name"],
+ "설명": ["description"]
+ };
+
+ // 헤더 매핑 확인 (대체 이름 포함)
+ const missingHeaders = requiredHeaders.filter(header => {
+ const alternatives = alternativeHeaders[header as keyof typeof alternativeHeaders] || [];
+ return !(header in headerMapping) &&
+ !alternatives.some(alt => alt in headerMapping);
+ });
+
+ if (missingHeaders.length > 0) {
+ throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`);
+ }
+
+ // 데이터 행 추출 (헤더 이후 행부터)
+ const dataRows: Record<string, any>[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > headerRowIndex) {
+ const rowData: Record<string, any> = {};
+ const values = row.values as (string | null | undefined)[];
+
+ // 헤더 매핑에 따라 데이터 추출
+ Object.entries(headerMapping).forEach(([header, index]) => {
+ rowData[header] = values[index] || "";
+ });
+
+ // 빈 행이 아닌 경우만 추가
+ if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) {
+ dataRows.push(rowData);
+ }
+ }
+ });
+
+ if (dataRows.length === 0) {
+ throw new Error("Excel 파일에 가져올 데이터가 없습니다.");
+ }
+
+ // 진행 상황 업데이트를 위한 콜백
+ const updateProgress = (current: number, total: number) => {
+ const percentage = Math.round((current / total) * 100);
+ setProgress(percentage);
+ };
+
+ // 실제 데이터 처리는 별도 함수에서 수행
+ const result = await processFileImport(
+ dataRows,
+ updateProgress
+ );
+
+ // 처리 완료
+ toast.success(`${result.successCount}개의 아이템이 성공적으로 가져와졌습니다.`);
+
+ if (result.errorCount > 0) {
+ toast.warning(`${result.errorCount}개의 항목은 처리할 수 없었습니다.`);
+ }
+
+ // 상태 초기화 및 다이얼로그 닫기
+ setFile(null);
+ setOpen(false);
+
+ // 성공 콜백 호출
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("Excel 파일 처리 중 오류 발생:", error);
+ setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다.");
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ // 다이얼로그 열기/닫기 핸들러
+ const handleOpenChange = (newOpen: boolean) => {
+ if (!newOpen) {
+ // 닫을 때 상태 초기화
+ setFile(null)
+ setError(null)
+ setProgress(0)
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ""
+ }
+ }
+ setOpen(newOpen)
+ }
+
+ return (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ onClick={() => setOpen(true)}
+ disabled={isUploading}
+ >
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>아이템 가져오기</DialogTitle>
+ <DialogDescription>
+ 아이템을 Excel 파일에서 가져옵니다.
+ <br />
+ 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 py-4">
+ <div className="flex items-center gap-4">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium"
+ accept=".xlsx,.xls"
+ onChange={handleFileChange}
+ disabled={isUploading}
+ />
+ </div>
+
+ {file && (
+ <div className="text-sm text-muted-foreground">
+ 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB)
+ </div>
+ )}
+
+ {isUploading && (
+ <div className="space-y-2">
+ <Progress value={progress} />
+ <p className="text-sm text-muted-foreground text-center">
+ {progress}% 완료
+ </p>
+ </div>
+ )}
+
+ {error && (
+ <div className="text-sm font-medium text-destructive">
+ {error}
+ </div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isUploading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleImport}
+ disabled={!file || isUploading}
+ >
+ {isUploading ? "처리 중..." : "가져오기"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/items/table/import-item-handler.tsx b/lib/items/table/import-item-handler.tsx
new file mode 100644
index 00000000..541d6fe1
--- /dev/null
+++ b/lib/items/table/import-item-handler.tsx
@@ -0,0 +1,118 @@
+"use client"
+
+import { z } from "zod"
+import { createItem } from "../service" // 아이템 생성 서버 액션
+
+// 아이템 데이터 검증을 위한 Zod 스키마
+const itemSchema = z.object({
+ itemCode: z.string().min(1, "아이템 코드는 필수입니다"),
+ itemName: z.string().min(1, "아이템 명은 필수입니다"),
+ description: z.string().nullable().optional(),
+});
+
+interface ProcessResult {
+ successCount: number;
+ errorCount: number;
+ errors?: Array<{ row: number; message: string }>;
+}
+
+/**
+ * Excel 파일에서 가져온 아이템 데이터 처리하는 함수
+ */
+export async function processFileImport(
+ jsonData: any[],
+ progressCallback?: (current: number, total: number) => void
+): Promise<ProcessResult> {
+ // 결과 카운터 초기화
+ let successCount = 0;
+ let errorCount = 0;
+ const errors: Array<{ row: number; message: string }> = [];
+
+ // 빈 행 등 필터링
+ const dataRows = jsonData.filter(row => {
+ // 빈 행 건너뛰기
+ if (Object.values(row).every(val => !val)) {
+ return false;
+ }
+ return true;
+ });
+
+ // 데이터 행이 없으면 빈 결과 반환
+ if (dataRows.length === 0) {
+ return { successCount: 0, errorCount: 0 };
+ }
+
+ // 각 행에 대해 처리
+ for (let i = 0; i < dataRows.length; i++) {
+ const row = dataRows[i];
+ const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작
+
+ // 진행 상황 콜백 호출
+ if (progressCallback) {
+ progressCallback(i + 1, dataRows.length);
+ }
+
+ try {
+ // 필드 매핑 (한글/영문 필드명 모두 지원)
+ const itemCode = row["아이템 코드"] || row["itemCode"] || row["item_code"] || "";
+ const itemName = row["아이템 명"] || row["itemName"] || row["item_name"] || "";
+ const description = row["설명"] || row["description"] || null;
+
+ // 데이터 정제
+ const cleanedRow = {
+ itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(),
+ itemName: typeof itemName === 'string' ? itemName.trim() : String(itemName).trim(),
+ description: description ? (typeof description === 'string' ? description : String(description)) : null,
+ };
+
+ // 데이터 유효성 검사
+ const validationResult = itemSchema.safeParse(cleanedRow);
+
+ if (!validationResult.success) {
+ const errorMessage = validationResult.error.errors.map(
+ err => `${err.path.join('.')}: ${err.message}`
+ ).join(', ');
+
+ errors.push({ row: rowIndex, message: errorMessage });
+ errorCount++;
+ continue;
+ }
+
+ // 아이템 생성 서버 액션 호출
+ const result = await createItem({
+ itemCode: cleanedRow.itemCode,
+ itemName: cleanedRow.itemName,
+ description: cleanedRow.description,
+ });
+
+ if (result.success || !result.error) {
+ successCount++;
+ } else {
+ errors.push({
+ row: rowIndex,
+ message: result.message || result.error || "알 수 없는 오류"
+ });
+ errorCount++;
+ }
+ } catch (error) {
+ console.error(`${rowIndex}행 처리 오류:`, error);
+ errors.push({
+ row: rowIndex,
+ message: error instanceof Error ? error.message : "알 수 없는 오류"
+ });
+ errorCount++;
+ }
+
+ // 비동기 작업 쓰로틀링
+ if (i % 5 === 0) {
+ await new Promise(resolve => setTimeout(resolve, 10));
+ }
+ }
+
+ // 처리 결과 반환
+ return {
+ successCount,
+ errorCount,
+ errors: errors.length > 0 ? errors : undefined
+ };
+} \ No newline at end of file
diff --git a/lib/items/table/item-excel-template.tsx b/lib/items/table/item-excel-template.tsx
new file mode 100644
index 00000000..75338168
--- /dev/null
+++ b/lib/items/table/item-excel-template.tsx
@@ -0,0 +1,94 @@
+import * as ExcelJS from 'exceljs';
+import { saveAs } from "file-saver";
+
+/**
+ * 아이템 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드
+ */
+export async function exportItemTemplate() {
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+ workbook.creator = 'Item Management System';
+ workbook.created = new Date();
+
+ // 워크시트 생성
+ const worksheet = workbook.addWorksheet('아이템');
+
+ // 컬럼 헤더 정의 및 스타일 적용
+ worksheet.columns = [
+ { header: '아이템 코드', key: 'itemCode', width: 15 },
+ { header: '아이템 명', key: 'itemName', width: 30 },
+ { header: '설명', key: 'description', width: 50 }
+ ];
+
+ // 헤더 스타일 적용
+ const headerRow = worksheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ };
+ headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
+
+ // 테두리 스타일 적용
+ headerRow.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+
+ // 샘플 데이터 추가
+ const sampleData = [
+ { itemCode: 'ITEM001', itemName: '샘플 아이템 1', description: '이것은 샘플 아이템 1의 설명입니다.' },
+ { itemCode: 'ITEM002', itemName: '샘플 아이템 2', description: '이것은 샘플 아이템 2의 설명입니다.' }
+ ];
+
+ // 데이터 행 추가
+ sampleData.forEach(item => {
+ worksheet.addRow(item);
+ });
+
+ // 데이터 행 스타일 적용
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > 1) { // 헤더를 제외한 데이터 행
+ row.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+ }
+ });
+
+ // 워크시트 보호 (선택적)
+ worksheet.protect('', {
+ selectLockedCells: true,
+ selectUnlockedCells: true,
+ formatColumns: true,
+ formatRows: true,
+ insertColumns: false,
+ insertRows: true,
+ insertHyperlinks: false,
+ deleteColumns: false,
+ deleteRows: true,
+ sort: true,
+ autoFilter: true,
+ pivotTables: false
+ });
+
+ try {
+ // 워크북을 Blob으로 변환
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
+ saveAs(blob, 'item-template.xlsx');
+ return true;
+ } catch (error) {
+ console.error('Excel 템플릿 생성 오류:', error);
+ throw error;
+ }
+} \ No newline at end of file
diff --git a/lib/items/table/items-table-columns.tsx b/lib/items/table/items-table-columns.tsx
index 60043e8e..8dd84c58 100644
--- a/lib/items/table/items-table-columns.tsx
+++ b/lib/items/table/items-table-columns.tsx
@@ -26,9 +26,6 @@ import {
} from "@/components/ui/dropdown-menu"
import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
-import { modifiTask } from "@/lib/tasks/service"
-
-
import { itemsColumnsConfig } from "@/config/itemsColumnsConfig"
import { Item } from "@/db/schema/items"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
diff --git a/lib/items/table/items-table-toolbar-actions.tsx b/lib/items/table/items-table-toolbar-actions.tsx
index 3444daab..b3178ce1 100644
--- a/lib/items/table/items-table-toolbar-actions.tsx
+++ b/lib/items/table/items-table-toolbar-actions.tsx
@@ -2,37 +2,119 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Download, Upload } from "lucide-react"
-import { toast } from "sonner"
+import { Download, FileDown } from "lucide-react"
+import * as ExcelJS from 'exceljs'
+import { saveAs } from "file-saver"
-import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
-
-// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import
-import { importTasksExcel } from "@/lib/tasks/service" // 예시
import { Item } from "@/db/schema/items"
import { DeleteItemsDialog } from "./delete-items-dialog"
import { AddItemDialog } from "./add-items-dialog"
+import { exportItemTemplate } from "./item-excel-template"
+import { ImportItemButton } from "./import-excel-button"
interface ItemsTableToolbarActionsProps {
table: Table<Item>
}
export function ItemsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) {
- // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
- const fileInputRef = React.useRef<HTMLInputElement>(null)
-
+ const [refreshKey, setRefreshKey] = React.useState(0)
+ // 가져오기 성공 후 테이블 갱신
+ const handleImportSuccess = () => {
+ setRefreshKey(prev => prev + 1)
+ }
- function handleImportClick() {
- // 숨겨진 <input type="file" /> 요소를 클릭
- fileInputRef.current?.click()
+ // Excel 내보내기 함수
+ const exportTableToExcel = async (
+ table: Table<any>,
+ options: {
+ filename: string;
+ excludeColumns?: string[];
+ sheetName?: string;
+ }
+ ) => {
+ const { filename, excludeColumns = [], sheetName = "아이템 목록" } = options;
+
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+ workbook.creator = 'Item Management System';
+ workbook.created = new Date();
+
+ // 워크시트 생성
+ const worksheet = workbook.addWorksheet(sheetName);
+
+ // 테이블 데이터 가져오기
+ const data = table.getFilteredRowModel().rows.map(row => row.original);
+
+ // 테이블 헤더 가져오기
+ const headers = table.getAllColumns()
+ .filter(column => !excludeColumns.includes(column.id))
+ .map(column => ({
+ key: column.id,
+ header: column.columnDef.header?.toString() || column.id
+ }));
+
+ // 컬럼 정의
+ worksheet.columns = headers.map(header => ({
+ header: header.header,
+ key: header.key,
+ width: 20 // 기본 너비
+ }));
+
+ // 스타일 적용
+ const headerRow = worksheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ };
+ headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
+
+ // 데이터 행 추가
+ data.forEach(item => {
+ const row: Record<string, any> = {};
+ headers.forEach(header => {
+ row[header.key] = item[header.key];
+ });
+ worksheet.addRow(row);
+ });
+
+ // 전체 셀에 테두리 추가
+ worksheet.eachRow((row, rowNumber) => {
+ row.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+ });
+
+ try {
+ // 워크북을 Blob으로 변환
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
+ saveAs(blob, `${filename}.xlsx`);
+ return true;
+ } catch (error) {
+ console.error("Excel 내보내기 오류:", error);
+ return false;
+ }
}
return (
<div className="flex items-center gap-2">
- {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {/* 선택된 로우가 있으면 삭제 다이얼로그 */}
{table.getFilteredSelectedRowModel().rows.length > 0 ? (
<DeleteItemsDialog
items={table
@@ -42,26 +124,39 @@ export function ItemsTableToolbarActions({ table }: ItemsTableToolbarActionsProp
/>
) : null}
- {/** 2) 새 Task 추가 다이얼로그 */}
+ {/* 새 아이템 추가 다이얼로그 */}
<AddItemDialog />
-
+ {/* Import 버튼 */}
+ <ImportItemButton onSuccess={handleImportSuccess} />
- {/** 4) Export 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() =>
- exportTableToExcel(table, {
- filename: "tasks",
- excludeColumns: ["select", "actions"],
- })
- }
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
- </Button>
+ {/* Export 드롭다운 메뉴 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "items",
+ excludeColumns: ["select", "actions"],
+ sheetName: "아이템 목록"
+ })
+ }
+ >
+ <FileDown className="mr-2 h-4 w-4" />
+ <span>현재 데이터 내보내기</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => exportItemTemplate()}>
+ <FileDown className="mr-2 h-4 w-4" />
+ <span>템플릿 다운로드</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
</div>
)
} \ No newline at end of file
diff --git a/lib/mail/layouts/base.hbs b/lib/mail/layouts/base.hbs
new file mode 100644
index 00000000..2e18f035
--- /dev/null
+++ b/lib/mail/layouts/base.hbs
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <title>{{subject}}</title>
+ </head>
+ <body style="margin:0; padding:20px; background-color:#f5f5f5; font-family:Arial, sans-serif; color:#111827;">
+ <table width="100%" cellpadding="0" cellspacing="0" border="0" align="center">
+ <tr>
+ <td align="center">
+ <table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color:#ffffff; border:1px solid #e5e7eb; border-radius:6px; padding:24px;">
+ <tr>
+ <td>
+ {{{body}}}
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+ </table>
+ </body>
+</html>
diff --git a/lib/mail/mailer.ts b/lib/mail/mailer.ts
index 200a0ed9..3474a373 100644
--- a/lib/mail/mailer.ts
+++ b/lib/mail/mailer.ts
@@ -15,14 +15,45 @@ const transporter = nodemailer.createTransport({
},
});
-// Handlebars 템플릿 로더 함수
-function loadTemplate(templateName: string, data: Record<string, any>) {
+// // Handlebars 템플릿 로더 함수
+// function loadTemplate(templateName: string, data: Record<string, any>) {
+// const templatePath = path.join(process.cwd(), 'lib', 'mail', 'templates', `${templateName}.hbs`);
+// const source = fs.readFileSync(templatePath, 'utf8');
+// const template = handlebars.compile(source);
+// return template(data);
+// }
+function applyLayout(layoutName: string, content: string, context: Record<string, any>) {
+ const layoutPath = path.join(process.cwd(), 'lib', 'mail', 'layouts', `${layoutName}.hbs`);
+ const layoutSource = fs.readFileSync(layoutPath, 'utf8');
+ const layoutTemplate = handlebars.compile(layoutSource);
+ return layoutTemplate({ ...context, body: content });
+}
+
+// Partials 자동 등록
+function registerPartials() {
+ const partialsDir = path.join(process.cwd(), 'lib', 'mail', 'partials');
+ const filenames = fs.readdirSync(partialsDir);
+
+ filenames.forEach((filename) => {
+ const name = path.parse(filename).name;
+ const filepath = path.join(partialsDir, filename);
+ const source = fs.readFileSync(filepath, 'utf8');
+ handlebars.registerPartial(name, source); // {{> header }}, {{> footer }}
+ });
+}
+
+
+// 템플릿 불러오기 + layout/partials 적용
+function loadTemplate(templateName: string, context: Record<string, any>, layout = 'base') {
+ registerPartials();
+
const templatePath = path.join(process.cwd(), 'lib', 'mail', 'templates', `${templateName}.hbs`);
const source = fs.readFileSync(templatePath, 'utf8');
const template = handlebars.compile(source);
- return template(data);
-}
+ const content = template(context); // 본문 먼저 처리
+ return applyLayout(layout, content, context); // base.hbs로 감싸기
+}
handlebars.registerHelper('t', function(key: string, options: any) {
// options.hash에는 Handlebars에서 넘긴 named parameter들(location=location 등)이 들어있음
return i18next.t(key, options.hash || {});
diff --git a/lib/mail/partials/footer.hbs b/lib/mail/partials/footer.hbs
new file mode 100644
index 00000000..06aae57d
--- /dev/null
+++ b/lib/mail/partials/footer.hbs
@@ -0,0 +1,8 @@
+<table width="100%" cellpadding="0" cellspacing="0" style="margin-top:32px; border-top:1px solid #e5e7eb; padding-top:16px;">
+ <tr>
+ <td align="center">
+ <p style="font-size:16px; color:#6b7280; margin:4px 0;">© {{currentYear}} EVCP. {{t "email.vendor.invitation.copyright"}}</p>
+ <p style="font-size:16px; color:#6b7280; margin:4px 0;">{{t "email.vendor.invitation.no_reply"}}</p>
+ </td>
+ </tr>
+</table>
diff --git a/lib/mail/partials/header.hbs b/lib/mail/partials/header.hbs
new file mode 100644
index 00000000..7898c82e
--- /dev/null
+++ b/lib/mail/partials/header.hbs
@@ -0,0 +1,7 @@
+<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px; border-bottom:1px solid #163CC4; padding-bottom:16px;">
+ <tr>
+ <td align="center">
+ <span style="display: block; text-align: left; color: #163CC4; font-weight: bold; font-size: 32px;">eVCP</span>
+ </td>
+ </tr>
+</table>
diff --git a/lib/mail/templates/admin-created.hbs b/lib/mail/templates/admin-created.hbs
index 7be7f15d..3db6c433 100644
--- a/lib/mail/templates/admin-created.hbs
+++ b/lib/mail/templates/admin-created.hbs
@@ -1,78 +1,25 @@
-<!DOCTYPE html>
-<html>
- <head>
- <meta charset="utf-8" />
- <title>{{t "adminCreated.title" lng=language}}</title>
- <style>
- /* 간단한 스타일 예시 */
- body {
- font-family: Arial, sans-serif;
- margin: 0;
- padding: 16px;
- background-color: #f5f5f5;
- }
- .container {
- max-width: 600px;
- margin: 0 auto;
- background-color: #ffffff;
- padding: 24px;
- border-radius: 8px;
- }
- h1 {
- font-size: 20px;
- margin-bottom: 16px;
- }
- p {
- font-size: 14px;
- line-height: 1.6;
- }
- .btn {
- display: inline-block;
- margin-top: 16px;
- padding: 12px 24px;
- background-color: #1D4ED8;
- color: #ffffff !important;
- text-decoration: none;
- border-radius: 4px;
- }
- .footer {
- margin-top: 24px;
- font-size: 12px;
- color: #888888;
- }
- </style>
- </head>
- <body>
- <div class="container">
- <!-- 상단 로고/타이틀 영역 -->
- <div style="text-align: center;">
- <!-- 필요 시 로고 이미지 -->
- <!-- <img src="https://your-logo-url.com/logo.png" alt="EVCP" width="120" /> -->
- </div>
+{{> header logoUrl=logoUrl }}
- <h1>{{t "adminCreated.title" lng=language}}</h1>
+<h1 style="font-size:28px; margin-bottom:16px;">
+ {{t "adminCreated.title" lng=language}}
+</h1>
- <p>
- {{t "adminCreated.greeting" lng=language}}, <strong>{{name}}</strong>.
- </p>
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ {{t "adminCreated.greeting" lng=language}}, <strong>{{name}}</strong>.
+</p>
- <p>
- {{t "adminCreated.body1" lng=language}}
- </p>
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ {{t "adminCreated.body1" lng=language}}
+</p>
- <p>
- <a class="btn" href="{{loginUrl}}" target="_blank">{{t "adminCreated.loginCTA" lng=language}}</a>
- </p>
+<p>
+ <a href="{{loginUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px; margin-top: 16px;">
+ {{t "adminCreated.loginCTA" lng=language}}
+ </a>
+</p>
- <p>
- {{t "adminCreated.supportMsg" lng=language}}
- </p>
+<p style="font-size:16px; line-height:24px; margin-top:16px;">
+ {{t "adminCreated.supportMsg" lng=language}}
+</p>
- <div class="footer">
- <p>
- {{t "adminCreated.footerDisclaimer" lng=language}}
- </p>
- </div>
- </div>
- </body>
-</html> \ No newline at end of file
+{{> footer logoUrl=logoUrl companyName=companyName year=year }} \ No newline at end of file
diff --git a/lib/mail/templates/admin-email-changed.hbs b/lib/mail/templates/admin-email-changed.hbs
index 7b8ca473..fb88feab 100644
--- a/lib/mail/templates/admin-email-changed.hbs
+++ b/lib/mail/templates/admin-email-changed.hbs
@@ -1,90 +1,30 @@
-<!DOCTYPE html>
-<html>
- <head>
- <meta charset="utf-8" />
- <title>{{t "adminEmailChanged.title" lng=language}}</title>
- <style>
- /* 간단한 스타일 예시 */
- body {
- font-family: Arial, sans-serif;
- margin: 0;
- padding: 16px;
- background-color: #f5f5f5;
- }
- .container {
- max-width: 600px;
- margin: 0 auto;
- background-color: #ffffff;
- padding: 24px;
- border-radius: 8px;
- }
- h1 {
- font-size: 20px;
- margin-bottom: 16px;
- }
- p {
- font-size: 14px;
- line-height: 1.6;
- }
- .btn {
- display: inline-block;
- margin-top: 16px;
- padding: 12px 24px;
- background-color: #1D4ED8;
- color: #ffffff !important;
- text-decoration: none;
- border-radius: 4px;
- }
- .footer {
- margin-top: 24px;
- font-size: 12px;
- color: #888888;
- }
- </style>
- </head>
- <body>
- <div class="container">
- <!-- 상단 로고/타이틀 영역 -->
- <div style="text-align: center;">
- <!-- 필요 시 로고 이미지 -->
- <!-- <img src="https://your-logo-url.com/logo.png" alt="EVCP" width="120" /> -->
- </div>
+{{> header logoUrl=logoUrl }}
- <!-- 메일 제목 -->
- <h1>{{t "adminEmailChanged.title" lng=language}}</h1>
+<h1 style="font-size:28px; margin-bottom:16px;">
+ {{t "adminEmailChanged.title" lng=language}}
+</h1>
- <!-- 인사말 -->
- <p>
- {{t "adminEmailChanged.greeting" lng=language}}, <strong>{{name}}</strong>.
- </p>
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ {{t "adminEmailChanged.greeting" lng=language}}, <strong>{{name}}</strong>.
+</p>
- <!-- 이전 이메일 / 새 이메일 안내 -->
- <p>
- {{t "adminEmailChanged.body.intro" lng=language}}
- </p>
- <p>
- <strong>{{t "adminEmailChanged.body.oldEmail" lng=language}}:</strong> {{oldEmail}}<br />
- <strong>{{t "adminEmailChanged.body.newEmail" lng=language}}:</strong> {{newEmail}}
- </p>
+<p style="font-size:16px; line-height:32px; margin-bottom:8px;">
+ {{t "adminEmailChanged.body.intro" lng=language}}
+</p>
- <!-- 버튼(로그인 / 대시보드 등) -->
- <p>
- <a class="btn" href="{{loginUrl}}" target="_blank">
- {{t "adminEmailChanged.loginCTA" lng=language}}
- </a>
- </p>
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ <strong>{{t "adminEmailChanged.body.oldEmail" lng=language}}:</strong> {{oldEmail}}<br />
+ <strong>{{t "adminEmailChanged.body.newEmail" lng=language}}:</strong> {{newEmail}}
+</p>
- <!-- 도움 요청 문구 -->
- <p>
- {{t "adminEmailChanged.supportMsg" lng=language}}
- </p>
+<p>
+ <a href="{{loginUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px; margin-top: 16px;">
+ {{t "adminEmailChanged.loginCTA" lng=language}}
+ </a>
+</p>
- <!-- 푸터 -->
- <div class="footer">
- <p>
- {{t "adminEmailChanged.footerDisclaimer" lng=language}}
- </p>
- </div>
- </div>
- </body>
-</html> \ No newline at end of file
+<p style="font-size:16px; line-height:24px; margin-top:16px;">
+ {{t "adminEmailChanged.supportMsg" lng=language}}
+</p>
+
+{{> footer logoUrl=logoUrl companyName=companyName year=year }} \ No newline at end of file
diff --git a/lib/mail/templates/cbe-invitation.hbs b/lib/mail/templates/cbe-invitation.hbs
new file mode 100644
index 00000000..1d5e8eba
--- /dev/null
+++ b/lib/mail/templates/cbe-invitation.hbs
@@ -0,0 +1,108 @@
+{{#> layout title="상업 입찰 평가 (CBE) 알림"}}
+ <p style="font-size:16px;">안녕하세요, <strong>{{contactName}}</strong>님</p>
+
+ <p style="font-size:16px;"><strong>[RFQ {{rfqCode}}]</strong>에 대한 상업 입찰 평가(CBE)가 생성되어 알려드립니다.
+ 아래 세부 정보를 확인하시고 필요한 조치를 취해주시기 바랍니다.</p>
+
+ <div class="info-box" style="background-color:#F1F5F9; border-radius:6px; padding:16px; margin-bottom:20px;">
+ <h3 style="font-size:20px; color:#163CC4; margin-top:0; margin-bottom:12px;">RFQ 정보</h3>
+ <div class="info-item" style="margin-bottom:8px;"><span class="label" style="font-weight:bold; color:#4B5563;">RFQ 코드:</span> {{rfqCode}}</div>
+ <div class="info-item" style="margin-bottom:8px;"><span class="label" style="font-weight:bold; color:#4B5563;">프로젝트 코드:</span> {{projectCode}}</div>
+ <div class="info-item" style="margin-bottom:8px;"><span class="label" style="font-weight:bold; color:#4B5563;">프로젝트명:</span> {{projectName}}</div>
+ {{#if dueDate}}
+ <div class="info-item" style="margin-bottom:8px;"><span class="label" style="font-weight:bold; color:#4B5563;">마감일:</span> {{dueDate}}</div>
+ {{/if}}
+ </div>
+
+ <div class="info-box" style="background-color:#F1F5F9; border-radius:6px; padding:16px; margin-bottom:20px;">
+ <h3 style="font-size:20px; color:#163CC4; margin-top:0; margin-bottom:12px;">CBE 평가 세부사항</h3>
+ <div class="info-item" style="margin-bottom:8px;"><span class="label" style="font-weight:bold; color:#4B5563;">협력업체:</span> {{vendorName}} ({{vendorCode}})</div>
+ {{#if paymentTerms}}
+ <div class="info-item" style="margin-bottom:8px;"><span class="label" style="font-weight:bold; color:#4B5563;">결제 조건:</span> {{paymentTerms}}</div>
+ {{/if}}
+ {{#if incoterms}}
+ <div class="info-item" style="margin-bottom:8px;"><span class="label" style="font-weight:bold; color:#4B5563;">Incoterms:</span> {{incoterms}}</div>
+ {{/if}}
+ {{#if deliverySchedule}}
+ <div class="info-item" style="margin-bottom:8px;"><span class="label" style="font-weight:bold; color:#4B5563;">배송 일정:</span> {{deliverySchedule}}</div>
+ {{/if}}
+ </div>
+
+ {{#if description}}
+ <div class="info-box" style="background-color:#F1F5F9; border-radius:6px; padding:16px; margin-bottom:20px;">
+ <h3 style="font-size:20px; color:#163CC4; margin-top:0; margin-bottom:12px;">RFQ 설명</h3>
+ <p style="font-size:16px; margin:0;">{{description}}</p>
+ </div>
+ {{/if}}
+
+ {{#if notes}}
+ <div class="info-box" style="background-color:#F1F5F9; border-radius:6px; padding:16px; margin-bottom:20px;">
+ <h3 style="font-size:20px; color:#163CC4; margin-top:0; margin-bottom:12px;">비고</h3>
+ <p style="font-size:16px; margin:0;">{{notes}}</p>
+ </div>
+ {{/if}}
+
+ <p style="text-align: center; margin: 25px 0;">
+ <a href="{{loginUrl}}/rfq/{{rfqId}}/cbe/{{cbeId}}" class="button" style="display:inline-block; background-color:#163CC4; color:#ffffff; padding:10px 20px; text-decoration:none; border-radius:4px; font-weight:bold;">
+ CBE 평가 확인하기
+ </a>
+ </p>
+
+ <p style="font-size:16px;">이 이메일에 첨부된 파일을 확인하시거나, 시스템에 로그인하여 자세한 정보를 확인해 주세요.
+ 추가 문의사항이 있으시면 구매담당자에게 연락해 주시기 바랍니다.</p>
+
+ <p style="font-size:16px;">감사합니다.<br />eVCP 팀</p>
+{{/layout}}
+{{!-- {{#> layout title="상업 입찰 평가 (CBE) 알림"}}
+ <p>안녕하세요, <strong>{{contactName}}</strong>님</p>
+
+ <p><strong>[RFQ {{rfqCode}}]</strong>에 대한 상업 입찰 평가(CBE)가 생성되어 알려드립니다.
+ 아래 세부 정보를 확인하시고 필요한 조치를 취해주시기 바랍니다.</p>
+
+ <div class="info-box">
+ <h3>RFQ 정보</h3>
+ <div class="info-item"><span class="label">RFQ 코드:</span> {{rfqCode}}</div>
+ <div class="info-item"><span class="label">프로젝트 코드:</span> {{projectCode}}</div>
+ <div class="info-item"><span class="label">프로젝트명:</span> {{projectName}}</div>
+ {{#if dueDate}}
+ <div class="info-item"><span class="label">마감일:</span> {{dueDate}}</div>
+ {{/if}}
+ </div>
+
+ <div class="info-box">
+ <h3>CBE 평가 세부사항</h3>
+ <div class="info-item"><span class="label">협력업체:</span> {{vendorName}} ({{vendorCode}})</div>
+ {{#if paymentTerms}}
+ <div class="info-item"><span class="label">결제 조건:</span> {{paymentTerms}}</div>
+ {{/if}}
+ {{#if incoterms}}
+ <div class="info-item"><span class="label">Incoterms:</span> {{incoterms}}</div>
+ {{/if}}
+ {{#if deliverySchedule}}
+ <div class="info-item"><span class="label">배송 일정:</span> {{deliverySchedule}}</div>
+ {{/if}}
+ </div>
+
+ {{#if description}}
+ <div class="info-box">
+ <h3>RFQ 설명</h3>
+ <p>{{description}}</p>
+ </div>
+ {{/if}}
+
+ {{#if notes}}
+ <div class="info-box">
+ <h3>비고</h3>
+ <p>{{notes}}</p>
+ </div>
+ {{/if}}
+
+ <div class="button-container">
+ <a href="{{loginUrl}}/rfq/{{rfqId}}/cbe/{{cbeId}}" class="button">
+ CBE 평가 확인하기
+ </a>
+ </div>
+
+ <p>이 이메일에 첨부된 파일을 확인하시거나, 시스템에 로그인하여 자세한 정보를 확인해 주세요.
+ 추가 문의사항이 있으시면 구매담당자에게 연락해 주시기 바랍니다.</p>
+{{/layout}} --}} \ No newline at end of file
diff --git a/lib/mail/templates/contract-sign-request.hbs b/lib/mail/templates/contract-sign-request.hbs
new file mode 100644
index 00000000..410fdf6a
--- /dev/null
+++ b/lib/mail/templates/contract-sign-request.hbs
@@ -0,0 +1,116 @@
+<!DOCTYPE html>
+<html lang="{{language}}">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>기본계약서 서명 요청</title>
+ <style>
+ body {
+ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif;
+ line-height: 1.6;
+ color: #333;
+ background-color: #f9f9f9;
+ margin: 0;
+ padding: 0;
+ }
+ .container {
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 20px;
+ background-color: #ffffff;
+ border-radius: 8px;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+ }
+ .header {
+ text-align: center;
+ padding-bottom: 20px;
+ border-bottom: 1px solid #eee;
+ }
+ .logo {
+ max-height: 60px;
+ margin-bottom: 10px;
+ }
+ .content {
+ padding: 30px 20px;
+ }
+ .footer {
+ margin-top: 30px;
+ padding-top: 20px;
+ border-top: 1px solid #eee;
+ font-size: 12px;
+ color: #666;
+ text-align: center;
+ }
+ .button {
+ display: inline-block;
+ background-color: #4F46E5;
+ color: white;
+ text-decoration: none;
+ padding: 12px 24px;
+ border-radius: 4px;
+ margin: 20px 0;
+ font-weight: bold;
+ }
+ .button:hover {
+ background-color: #4338CA;
+ }
+ .info-box {
+ background-color: #f3f4f6;
+ border-radius: 4px;
+ padding: 15px;
+ margin: 20px 0;
+ }
+ @media only screen and (max-width: 600px) {
+ .container {
+ width: 100%;
+ border-radius: 0;
+ }
+ }
+ </style>
+</head>
+<body>
+ <div class="container">
+ <div class="header">
+ <img src="{{logoUrl}}" alt="회사 로고" class="logo">
+ <h1>기본계약서 서명 요청</h1>
+ </div>
+
+ <div class="content">
+ <p>안녕하세요, <strong>{{vendorName}}</strong>님.</p>
+
+ <p>귀사에 기본계약서 서명을 요청드립니다.</p>
+
+ <div class="info-box">
+ <p><strong>계약서 정보:</strong></p>
+ <p>계약서 종류: {{templateName}}</p>
+ <p>계약 번호: {{contractId}}</p>
+ </div>
+
+ <p>아래 버튼을 클릭하여 계약서를 확인하고 서명해 주시기 바랍니다.</p>
+
+ <div style="text-align: center;">
+ <a href="{{loginUrl}}" class="button">계약서 서명하기</a>
+ </div>
+
+ <p>본 링크는 30일간 유효하며, 이후에는 새로운 서명 요청이 필요합니다.</p>
+
+ <p>서명 과정에서 문의사항이 있으시면 담당자에게 연락해 주시기 바랍니다.</p>
+
+ <p>감사합니다.</p>
+
+ <div style="margin-top: 30px;">
+ <p><strong>담당자 연락처:</strong><br>
+ 이메일: contact@company.com<br>
+ 전화: 02-123-4567</p>
+ </div>
+ </div>
+
+ <div class="footer">
+ <p>본 메일은 발신 전용으로, 회신하실 경우 확인이 어려울 수 있습니다.</p>
+ <p>© {{currentYear}} 주식회사 회사명. 모든 권리 보유.</p>
+ <p>이 이메일에 포함된 정보는 기밀 정보이며, 특정 수신자만을 위한 것입니다.
+ 만약 귀하가 의도된 수신자가 아닌 경우, 본 이메일의 사용, 배포 또는 복사는 엄격히 금지됩니다.</p>
+ </div>
+ </div>
+</body>
+</html> \ No newline at end of file
diff --git a/lib/mail/templates/investigation-request.hbs b/lib/mail/templates/investigation-request.hbs
new file mode 100644
index 00000000..a69091a5
--- /dev/null
+++ b/lib/mail/templates/investigation-request.hbs
@@ -0,0 +1,31 @@
+{{> header logoUrl=logoUrl }}
+
+<h2 style="font-size:28px; margin-bottom:16px;"> 협력업체 실사 요청</h2>
+
+<p style="font-size:16px;">안녕하세요,</p>
+
+<p style="font-size:16px;">협력업체 실사 요청이 접수되었습니다.</p>
+
+<div style="background-color:#F1F5F9; padding:16px; border-radius:4px; margin:16px 0;">
+ <p style="font-size:16px; margin:0 0 8px 0;"><strong>협력업체 ID:</strong></p>
+ <ul style="margin:0; padding-left:20px;">
+ {{#each vendorIds}}
+ <li style="font-size:16px; margin-bottom:4px;">{{this}}</li>
+ {{/each}}
+ </ul>
+
+ {{#if notes}}
+ <p style="font-size:16px; margin:16px 0 0 0;"><strong>메모:</strong></p>
+ <p style="font-size:16px; margin:8px 0 0 0;">{{notes}}</p>
+ {{/if}}
+</div>
+
+<p style="text-align: center; margin: 25px 0;">
+ <a href="{{portalUrl}}" target="_blank" style="display:inline-block; background-color:#163CC4; color:#ffffff; padding:10px 20px; text-decoration:none; border-radius:4px;">벤더 포털 바로가기</a>
+</p>
+
+<p style="font-size:16px;">문의사항이 있으시면 시스템 관리자에게 연락해 주세요.</p>
+
+<p style="font-size:16px;">감사합니다.<br />eVCP 팀</p>
+
+{{> footer logoUrl=logoUrl companyName=companyName year=year }} \ No newline at end of file
diff --git a/lib/mail/templates/otp.hbs b/lib/mail/templates/otp.hbs
index adeda416..48df33f9 100644
--- a/lib/mail/templates/otp.hbs
+++ b/lib/mail/templates/otp.hbs
@@ -1,77 +1,27 @@
-<!DOCTYPE html>
-<html lang="en">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
- <title>{{subject}}</title>
- <style>
- body {
- font-family: Arial, sans-serif;
- background: #f9fafb;
- color: #111827;
- padding: 20px;
- }
- .container {
- max-width: 480px;
- margin: 0 auto;
- background: #ffffff;
- border: 1px solid #e5e7eb;
- border-radius: 6px;
- padding: 24px;
- }
- h1 {
- font-size: 20px;
- margin-bottom: 8px;
- color: #111827;
- }
- p {
- line-height: 1.5;
- margin-bottom: 16px;
- }
- .code {
- display: inline-block;
- font-size: 24px;
- font-weight: bold;
- letter-spacing: 2px;
- margin: 12px 0;
- background: #f3f4f6;
- padding: 8px 16px;
- border-radius: 4px;
- }
- a {
- color: #3b82f6;
- text-decoration: none;
- }
- .footer {
- font-size: 12px;
- color: #6b7280;
- margin-top: 24px;
- }
- </style>
- </head>
- <body>
- <div class="container">
- <h1>{{t "verifyYourEmailTitle"}}</h1>
- <p>{{t "greeting"}}, {{name}}</p>
+{{> header logoUrl=logoUrl }}
- <p>
- {{t "receivedSignInAttempt" location=location}}
- </p>
+<h1 style="font-size:28px; margin-bottom:20px; color:#111827;">{{t "verifyYourEmailTitle"}}</h1>
- <p>
- {{t "enterCodeInstruction"}}
- </p>
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">{{t "greeting"}}, {{name}}</p>
- <p class="code">{{otp}}</p>
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ {{t "receivedSignInAttempt" location=location}}
+</p>
- <p>
- <a href="{{verificationUrl}}">{{verificationUrl}}</a>
- </p>
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ {{t "enterCodeInstruction"}}
+</p>
+<p style="font-size:24px; font-weight:bold; letter-spacing:2px; background-color:#f3f4f6; padding:8px 16px; border-radius:4px; display:inline-block; margin:12px 0;">
+ {{otp}}
+</p>
- <div class="footer">
- {{t "securityWarning"}}
- </div>
- </div>
- </body>
-</html> \ No newline at end of file
+<p style="font-size:16px; line-height:24px; margin-bottom:16px;">
+ <a href="{{verificationUrl}}" style="color:#0284C7; text-decoration:none;">{{verificationUrl}}</a>
+</p>
+
+<p style="font-size:16px; color:#6b7280; margin-top:16px;">
+ {{t "securityWarning"}}
+</p>
+
+{{> footer logoUrl=logoUrl companyName=companyName year=year }} \ No newline at end of file
diff --git a/lib/mail/templates/pq-submitted-admin.hbs b/lib/mail/templates/pq-submitted-admin.hbs
new file mode 100644
index 00000000..0db3d6e4
--- /dev/null
+++ b/lib/mail/templates/pq-submitted-admin.hbs
@@ -0,0 +1,84 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>PQ Submission Notification</title>
+ <style>
+ body {
+ font-family: Arial, sans-serif;
+ line-height: 1.6;
+ color: #333;
+ padding: 20px;
+ max-width: 600px;
+ margin: 0 auto;
+ }
+ .header {
+ background-color: #0070f3;
+ color: white;
+ padding: 15px;
+ text-align: center;
+ margin-bottom: 20px;
+ border-radius: 5px;
+ }
+ .content {
+ background-color: #f9f9f9;
+ padding: 20px;
+ border-radius: 5px;
+ margin-bottom: 20px;
+ }
+ .details {
+ margin: 15px 0;
+ }
+ .details p {
+ margin: 5px 0;
+ }
+ .button {
+ display: inline-block;
+ background-color: #0070f3;
+ color: white;
+ text-decoration: none;
+ padding: 10px 20px;
+ border-radius: 5px;
+ margin: 20px 0;
+ text-align: center;
+ }
+ .footer {
+ text-align: center;
+ font-size: 12px;
+ color: #777;
+ margin-top: 30px;
+ }
+ </style>
+</head>
+<body>
+ <div class="header">
+ <h1>PQ Submission Notification</h1>
+ </div>
+
+ <div class="content">
+ <h2>New PQ Submission Received</h2>
+
+ <p>A new {{#if isProjectPQ}}project-specific{{else}}general{{/if}} PQ has been submitted and is ready for your review.</p>
+
+ <div class="details">
+ <p><strong>Vendor Name:</strong> {{vendorName}}</p>
+ <p><strong>Vendor ID:</strong> {{vendorId}}</p>
+ {{#if isProjectPQ}}
+ <p><strong>Project Name:</strong> {{projectName}}</p>
+ <p><strong>Project ID:</strong> {{projectId}}</p>
+ {{/if}}
+ <p><strong>Submission Date:</strong> {{submittedDate}}</p>
+ </div>
+
+ <p>Please review this submission at your earliest convenience.</p>
+
+ <a href="{{adminUrl}}" class="button">Review PQ Submission</a>
+ </div>
+
+ <div class="footer">
+ <p>This is an automated notification from the eVCP system. Please do not reply to this email.</p>
+ <p>© {{currentYear}} eVCP - Vendor Compliance Portal</p>
+ </div>
+</body>
+</html> \ No newline at end of file
diff --git a/lib/mail/templates/pq-submitted-vendor.hbs b/lib/mail/templates/pq-submitted-vendor.hbs
new file mode 100644
index 00000000..9cf8e133
--- /dev/null
+++ b/lib/mail/templates/pq-submitted-vendor.hbs
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>PQ Submission Confirmation</title>
+ <style>
+ body {
+ font-family: Arial, sans-serif;
+ line-height: 1.6;
+ color: #333;
+ padding: 20px;
+ max-width: 600px;
+ margin: 0 auto;
+ }
+ .header {
+ background-color: #0070f3;
+ color: white;
+ padding: 15px;
+ text-align: center;
+ margin-bottom: 20px;
+ border-radius: 5px;
+ }
+ .content {
+ background-color: #f9f9f9;
+ padding: 20px;
+ border-radius: 5px;
+ margin-bottom: 20px;
+ }
+ .details {
+ margin: 15px 0;
+ }
+ .details p {
+ margin: 5px 0;
+ }
+ .button {
+ display: inline-block;
+ background-color: #0070f3;
+ color: white;
+ text-decoration: none;
+ padding: 10px 20px;
+ border-radius: 5px;
+ margin: 20px 0;
+ text-align: center;
+ }
+ .footer {
+ text-align: center;
+ font-size: 12px;
+ color: #777;
+ margin-top: 30px;
+ }
+ .success-message {
+ padding: 15px;
+ background-color: #dff0d8;
+ border-left: 4px solid #5cb85c;
+ margin-bottom: 20px;
+ }
+ </style>
+</head>
+<body>
+ <div class="header">
+ <h1>PQ Submission Confirmation</h1>
+ </div>
+
+ <div class="content">
+ <div class="success-message">
+ <p>Thank you! Your {{#if isProjectPQ}}project-specific{{else}}general{{/if}} PQ has been successfully submitted.</p>
+ </div>
+
+ <h2>Submission Details</h2>
+
+ <div class="details">
+ <p><strong>Vendor Name:</strong> {{vendorName}}</p>
+ {{#if isProjectPQ}}
+ <p><strong>Project Name:</strong> {{projectName}}</p>
+ {{/if}}
+ <p><strong>Submission Date:</strong> {{submittedDate}}</p>
+ </div>
+
+ <p>Our team will review your submission and contact you if any additional information is needed.</p>
+
+ <p>You can access your dashboard to track the status of your submissions and manage your vendor profile.</p>
+
+ <a href="{{portalUrl}}" class="button">Go to Vendor Dashboard</a>
+ </div>
+
+ <div class="footer">
+ <p>This is an automated confirmation from the eVCP system. Please do not reply to this email.</p>
+ <p>If you have any questions, please contact your procurement representative.</p>
+ <p>© {{currentYear}} eVCP - Vendor Compliance Portal</p>
+ </div>
+</body>
+</html> \ No newline at end of file
diff --git a/lib/mail/templates/pq.hbs b/lib/mail/templates/pq.hbs
new file mode 100644
index 00000000..78fb6fcd
--- /dev/null
+++ b/lib/mail/templates/pq.hbs
@@ -0,0 +1,86 @@
+<!DOCTYPE html>
+<html lang="{{language}}">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>PQ Invitation</title>
+ <style>
+ body {
+ font-family: Arial, sans-serif;
+ line-height: 1.6;
+ color: #333;
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 20px;
+ }
+ .header {
+ background-color: #0066cc;
+ color: white;
+ padding: 20px;
+ text-align: center;
+ border-radius: 5px 5px 0 0;
+ }
+ .content {
+ background-color: #f9f9f9;
+ padding: 20px;
+ border-left: 1px solid #ddd;
+ border-right: 1px solid #ddd;
+ }
+ .footer {
+ background-color: #f1f1f1;
+ padding: 15px;
+ text-align: center;
+ font-size: 14px;
+ border-radius: 0 0 5px 5px;
+ border: 1px solid #ddd;
+ }
+ .button {
+ display: inline-block;
+ background-color: #0066cc;
+ color: white;
+ padding: 12px 25px;
+ text-decoration: none;
+ border-radius: 5px;
+ margin: 20px 0;
+ font-weight: bold;
+ }
+ </style>
+</head>
+<body>
+ <div class="header">
+ <h1>eVCP Pre-Qualification Invitation</h1>
+ </div>
+
+ <div class="content">
+ <p>Dear {{vendorName}},</p>
+
+ <p>You have been invited to submit your Pre-Qualification (PQ) information for our vendor database. Completing this process will allow us to consider your company for future projects and procurement opportunities.</p>
+
+ <p>To submit your PQ information:</p>
+ <ol>
+ <li>Click on the button below to access our vendor portal</li>
+ <li>Log in to your account (or register if you haven't already)</li>
+ <li>Navigate to the PQ section in your dashboard</li>
+ <li>Complete all required information about your company, capabilities, and experience</li>
+ </ol>
+
+ <center>
+ <a href="{{loginUrl}}" class="button">Access Vendor Portal</a>
+ </center>
+
+ <p>Maintaining up-to-date PQ information in our system is essential for your company to be considered for upcoming opportunities.</p>
+
+ <p>If you have any questions or need assistance, please contact our vendor management team.</p>
+
+ <p>We look forward to learning more about your company and potentially working together on future projects.</p>
+
+ <p>Best regards,<br>
+ The eVCP Team</p>
+ </div>
+
+ <div class="footer">
+ <p>This is an automated email. Please do not reply to this message.</p>
+ <p>&copy; eVCP Vendor Management System</p>
+ </div>
+</body>
+</html> \ No newline at end of file
diff --git a/lib/mail/templates/project-pq.hbs b/lib/mail/templates/project-pq.hbs
new file mode 100644
index 00000000..2ecbd3a2
--- /dev/null
+++ b/lib/mail/templates/project-pq.hbs
@@ -0,0 +1,99 @@
+<!DOCTYPE html>
+<html lang="{{language}}">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Project PQ Invitation</title>
+ <style>
+ body {
+ font-family: Arial, sans-serif;
+ line-height: 1.6;
+ color: #333;
+ max-width: 600px;
+ margin: 0 auto;
+ padding: 20px;
+ }
+ .header {
+ background-color: #0066cc;
+ color: white;
+ padding: 20px;
+ text-align: center;
+ border-radius: 5px 5px 0 0;
+ }
+ .content {
+ background-color: #f9f9f9;
+ padding: 20px;
+ border-left: 1px solid #ddd;
+ border-right: 1px solid #ddd;
+ }
+ .footer {
+ background-color: #f1f1f1;
+ padding: 15px;
+ text-align: center;
+ font-size: 14px;
+ border-radius: 0 0 5px 5px;
+ border: 1px solid #ddd;
+ }
+ .button {
+ display: inline-block;
+ background-color: #0066cc;
+ color: white;
+ padding: 12px 25px;
+ text-decoration: none;
+ border-radius: 5px;
+ margin: 20px 0;
+ font-weight: bold;
+ }
+ .project-info {
+ background-color: #e6f2ff;
+ padding: 15px;
+ border-radius: 5px;
+ margin: 15px 0;
+ }
+ </style>
+</head>
+<body>
+ <div class="header">
+ <h1>eVCP Project PQ Invitation</h1>
+ </div>
+
+ <div class="content">
+ <p>Dear {{vendorName}},</p>
+
+ <p>You have been selected to participate in the Pre-Qualification (PQ) process for the following project:</p>
+
+ <div class="project-info">
+ <p><strong>Project Code:</strong> {{projectCode}}</p>
+ <p><strong>Project Name:</strong> {{projectName}}</p>
+ </div>
+
+ <p>This is an important step in our vendor selection process. Please complete the Project PQ questionnaire at your earliest convenience.</p>
+
+ <p>To submit your Project PQ:</p>
+ <ol>
+ <li>Click on the button below to access our vendor portal</li>
+ <li>Log in to your account (or register if you haven't already)</li>
+ <li>Navigate to the PQ section where you'll find the Project PQ for {{projectCode}}</li>
+ <li>Complete all required information</li>
+ </ol>
+
+ <center>
+ <a href="{{loginUrl}}" class="button">Access Vendor Portal</a>
+ </center>
+
+ <p>Please note that completing this Project PQ is a prerequisite for being considered for this project.</p>
+
+ <p>If you have any questions or need assistance, please contact our vendor management team.</p>
+
+ <p>Thank you for your participation.</p>
+
+ <p>Best regards,<br>
+ The eVCP Team</p>
+ </div>
+
+ <div class="footer">
+ <p>This is an automated email. Please do not reply to this message.</p>
+ <p>&copy; eVCP Vendor Management System</p>
+ </div>
+</body>
+</html> \ No newline at end of file
diff --git a/lib/mail/templates/rfq-invite.hbs b/lib/mail/templates/rfq-invite.hbs
index 25bd96eb..8ec20a99 100644
--- a/lib/mail/templates/rfq-invite.hbs
+++ b/lib/mail/templates/rfq-invite.hbs
@@ -1,116 +1,43 @@
-<!DOCTYPE html>
-<html>
- <head>
- <meta charset="utf-8" />
- <title>{{t "rfqInvite.title" lng=language}} #{{rfqCode}}</title>
- <style>
- /* 간단한 스타일 예시 */
- body {
- font-family: Arial, sans-serif;
- margin: 0;
- padding: 16px;
- background-color: #f5f5f5;
- }
- .container {
- max-width: 600px;
- margin: 0 auto;
- background-color: #ffffff;
- padding: 24px;
- border-radius: 8px;
- }
- h1 {
- font-size: 20px;
- margin-bottom: 16px;
- }
- p {
- font-size: 14px;
- line-height: 1.6;
- }
- ul {
- margin-left: 20px;
- }
- li {
- font-size: 14px;
- line-height: 1.6;
- }
- .btn {
- display: inline-block;
- margin-top: 16px;
- padding: 12px 24px;
- background-color: #1D4ED8;
- color: #ffffff !important;
- text-decoration: none;
- border-radius: 4px;
- }
- .footer {
- margin-top: 24px;
- font-size: 12px;
- color: #888888;
- }
- </style>
- </head>
- <body>
- <div class="container">
- <!-- 상단 로고/타이틀 영역 -->
- <div style="text-align: center;">
- <!-- 필요 시 로고 이미지 -->
- <!-- <img src="https://your-logo-url.com/logo.png" alt="EVCP" width="120" /> -->
- </div>
+{{> header logoUrl=logoUrl }}
- <!-- 메인 타이틀: RFQ 초대 -->
- <h1>
- {{t "rfqInvite.heading" lng=language}}
- #{{rfqCode}}
- </h1>
+<h1 style="font-size:28px; line-height:40px; margin-bottom:16px;">
+ {{t "rfqInvite.heading" lng=language}} #{{rfqCode}}
+</h1>
- <!-- 벤더에게 인사말 -->
- <p>
- {{t "rfqInvite.greeting" lng=language}}, <strong>Vendor #{{vendorId}}</strong>.
- </p>
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ {{t "rfqInvite.greeting" lng=language}}, <strong>Vendor #{{vendorId}}</strong>.
+</p>
- <!-- 프로젝트/RFQ 정보 -->
- <p>
- {{t "rfqInvite.bodyIntro" lng=language}}
- <br />
- <strong>{{t "rfqInvite.projectName" lng=language}}:</strong> {{projectName}}<br />
- <strong>{{t "rfqInvite.projectCode" lng=language}}:</strong> {{projectCode}}<br />
- <strong>{{t "rfqInvite.dueDate" lng=language}}:</strong> {{dueDate}}<br />
- <strong>{{t "rfqInvite.description" lng=language}}:</strong> {{description}}
- </p>
+<p style="font-size:16px; line-height:32px; margin-bottom:16px;">
+ {{t "rfqInvite.bodyIntro" lng=language}}<br/>
+ <strong>{{t "rfqInvite.projectName" lng=language}}:</strong> {{projectName}}<br />
+ <strong>{{t "rfqInvite.projectCode" lng=language}}:</strong> {{projectCode}}<br />
+ <strong>{{t "rfqInvite.dueDate" lng=language}}:</strong> {{dueDate}}<br />
+ <strong>{{t "rfqInvite.description" lng=language}}:</strong> {{description}}
+</p>
- <!-- 아이템 목록 -->
- <p>
- {{t "rfqInvite.itemListTitle" lng=language}}
- </p>
- <ul>
- {{#each items}}
- <li>
- <strong>{{this.itemCode}}</strong>
- ({{this.quantity}} {{this.uom}})
- - {{this.description}}
- </li>
- {{/each}}
- </ul>
+<p style="font-size:16px; line-height:32px; margin-bottom:8px;">
+ {{t "rfqInvite.itemListTitle" lng=language}}
+</p>
- <!-- 로그인/접속 안내 -->
- <p>
- {{t "rfqInvite.moreDetail" lng=language}}
- </p>
- <a class="btn" href="{{loginUrl}}" target="_blank">
- {{t "rfqInvite.viewButton" lng=language}}
- </a>
+<ul style="margin-left:4px; font-size:16px; line-height:32px;">
+ {{#each items}}
+ <li><strong>{{this.itemCode}}</strong> ({{this.quantity}} {{this.uom}}) - {{this.description}}</li>
+ {{/each}}
+</ul>
- <!-- 기타 안내 문구 -->
- <p>
- {{t "rfqInvite.supportMsg" lng=language}}
- </p>
+<p style="font-size:14px; line-height:32px; margin-top:16px;">
+ {{t "rfqInvite.moreDetail" lng=language}}
+</p>
- <!-- 푸터 -->
- <div class="footer">
- <p>
- {{t "rfqInvite.footerDisclaimer" lng=language}}
- </p>
- </div>
- </div>
- </body>
-</html> \ No newline at end of file
+<p>
+ <a href="{{loginUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px;">
+ {{t "rfqInvite.viewButton" lng=language}}
+ </a>
+</p>
+
+<p style="font-size:16px; line-height:24px; margin-top:16px;">
+ {{t "rfqInvite.supportMsg" lng=language}}
+</p>
+
+{{> footer logoUrl=logoUrl companyName=companyName year=year }} \ No newline at end of file
diff --git a/lib/mail/templates/vendor-active.hbs b/lib/mail/templates/vendor-active.hbs
index 6458e2fb..a2643f94 100644
--- a/lib/mail/templates/vendor-active.hbs
+++ b/lib/mail/templates/vendor-active.hbs
@@ -1,51 +1,25 @@
-<!DOCTYPE html>
-<html>
-<head>
- <meta charset="utf-8">
- <title>벤더 등록이 완료되었습니다</title>
- <style>
- body { font-family: 'Malgun Gothic', sans-serif; line-height: 1.6; }
- .container { max-width: 600px; margin: 0 auto; padding: 20px; }
- .header { background-color: #f5f5f5; padding: 10px; text-align: center; }
- .content { padding: 20px 0; }
- .vendor-code { font-size: 18px; font-weight: bold; background-color: #f0f0f0;
- padding: 10px; margin: 15px 0; text-align: center; }
- .button { display: inline-block; background-color: #28a745; color: white;
- padding: 10px 20px; text-decoration: none; border-radius: 4px; }
- .footer { margin-top: 20px; font-size: 12px; color: #777; }
- </style>
-</head>
-<body>
- <div class="container">
- <div class="header">
- <h2>벤더 등록이 완료되었습니다</h2>
- </div>
-
- <div class="content">
- <p>{{vendorName}} 귀하,</p>
-
- <p>축하합니다! 귀사의 벤더 등록이 완료되었으며 벤더 정보가 당사 시스템에 성공적으로 등록되었습니다.</p>
-
- <p>귀사의 벤더 코드는 다음과 같습니다:</p>
- <div class="vendor-code">{{vendorCode}}</div>
-
- <p>향후 모든 의사소통 및 거래 시 이 벤더 코드를 사용해 주십시오. 이제 벤더 포털에 접속하여 계정 관리, 발주서 확인 및 인보이스 제출을 할 수 있습니다.</p>
-
- <p style="text-align: center; margin: 25px 0;">
- <a href="{{portalUrl}}" class="button">벤더 포털 접속</a>
- </p>
-
- <p>벤더 계정에 관한 질문이나 도움이 필요하시면 당사 벤더 관리팀에 문의해 주십시오.</p>
-
- <p>파트너십에 감사드립니다.</p>
-
- <p>감사합니다.<br>
- eVCP 팀</p>
- </div>
-
- <div class="footer">
- <p>이 메시지는 자동으로 발송되었습니다. 이 이메일에 회신하지 마십시오.</p>
- </div>
- </div>
-</body>
-</html> \ No newline at end of file
+{{> header logoUrl=logoUrl }}
+
+<h2 style="font-size:28px; margin-bottom:16px;">협력업체 등록이 완료되었습니다</h2>
+
+<p style="font-size:16px;">{{vendorName}} 귀하,</p>
+
+<p style="font-size:16px;">축하합니다! 귀사의 협력업체 등록이 완료되었으며 협력업체 정보가 당사 시스템에 성공적으로 등록되었습니다.</p>
+
+<p style="font-size:16px;">귀사의 협력업체 코드는 다음과 같습니다:</p>
+
+<div style="font-size:24px; font-weight:bold; letter-spacing:2px; background-color:#F1F5F9; padding:8px 16px; border-radius:4px; display:inline-block; margin:12px 0;">
+ {{vendorCode}}
+</div>
+
+<p style="font-size:1px;">이 코드를 사용하여 포털에 접속하고 계정을 관리할 수 있습니다.</p>
+
+<p style="text-align: center; margin: 25px 0;">
+ <a href="{{portalUrl}}" target="_blank" style="display:inline-block; background-color:#163CC4; color:#ffffff; padding:10px 20px; text-decoration:none; border-radius:4px;">협력업체 포털 접속</a>
+</p>
+
+<p style="font-size:16px;">문의사항이 있으시면 협력업체 관리팀에 연락해 주세요.</p>
+
+<p style="font-size:16px;">감사합니다.<br />eVCP 팀</p>
+
+{{> footer logoUrl=logoUrl companyName=companyName year=year }} \ No newline at end of file
diff --git a/lib/mail/templates/vendor-additional-info.hbs b/lib/mail/templates/vendor-additional-info.hbs
index 9d93bb7b..17d9b130 100644
--- a/lib/mail/templates/vendor-additional-info.hbs
+++ b/lib/mail/templates/vendor-additional-info.hbs
@@ -1,76 +1,19 @@
-<!DOCTYPE html>
-<html>
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>{{t "email.additionalInfo.title"}}</title>
- <style>
- body {
- font-family: Arial, sans-serif;
- line-height: 1.6;
- color: #333;
- max-width: 600px;
- margin: 0 auto;
- padding: 20px;
- }
- .header {
- background-color: #0056b3;
- color: white;
- padding: 20px;
- text-align: center;
- border-radius: 5px 5px 0 0;
- }
- .content {
- padding: 20px;
- border: 1px solid #ddd;
- border-top: none;
- border-radius: 0 0 5px 5px;
- }
- .button {
- background-color: #0056b3;
- color: white;
- padding: 12px 20px;
- text-decoration: none;
- border-radius: 5px;
- display: inline-block;
- margin-top: 15px;
- font-weight: bold;
- }
- .footer {
- margin-top: 30px;
- text-align: center;
- font-size: 0.8em;
- color: #777;
- }
- </style>
-</head>
-<body>
- <div class="header">
- <h1>{{t "email.additionalInfo.header"}}</h1>
- </div>
+{{#> layout title=(t "email.additionalInfo.title")}}
+ <p>{{t "email.additionalInfo.greeting" vendorName=vendorName}}</p>
- <div class="content">
- <p>{{t "email.additionalInfo.greeting" vendorName=vendorName}}</p>
-
- <p>{{t "email.additionalInfo.messageP1"}}</p>
-
- <p>{{t "email.additionalInfo.messageP2"}}</p>
-
- <p>{{t "email.additionalInfo.messageP3"}}</p>
-
- <div style="text-align: center;">
- <a href="{{vendorInfoUrl}}" class="button">{{t "email.additionalInfo.buttonText"}}</a>
- </div>
-
- <p>{{t "email.additionalInfo.messageP4"}}</p>
-
- <p>{{t "email.additionalInfo.closing"}}</p>
-
- <p>EVCP Team</p>
- </div>
+ <p>{{t "email.additionalInfo.messageP1"}}</p>
+
+ <p>{{t "email.additionalInfo.messageP2"}}</p>
+
+ <p>{{t "email.additionalInfo.messageP3"}}</p>
- <div class="footer">
- <p>© {{currentYear}} EVCP. {{t "email.additionalInfo.footerText"}}</p>
+ <div class="button-container">
+ <a href="{{vendorInfoUrl}}" class="button">{{t "email.additionalInfo.buttonText"}}</a>
</div>
-</body>
-</html> \ No newline at end of file
+
+ <p>{{t "email.additionalInfo.messageP4"}}</p>
+
+ <p>{{t "email.additionalInfo.closing"}}</p>
+
+ <p>EVCP Team</p>
+{{/layout}} \ No newline at end of file
diff --git a/lib/mail/templates/vendor-invitation.hbs b/lib/mail/templates/vendor-invitation.hbs
index d85067f4..9b68c10c 100644
--- a/lib/mail/templates/vendor-invitation.hbs
+++ b/lib/mail/templates/vendor-invitation.hbs
@@ -1,86 +1,20 @@
-<!DOCTYPE html>
-<html>
-<head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Vendor Registration Invitation</title>
- <style>
- body {
- font-family: Arial, sans-serif;
- line-height: 1.6;
- color: #333333;
- margin: 0;
- padding: 0;
- }
- .container {
- max-width: 600px;
- margin: 0 auto;
- padding: 20px;
- }
- .header {
- background-color: #2563EB;
- padding: 20px;
- text-align: center;
- color: white;
- }
- .content {
- padding: 20px;
- background-color: #ffffff;
- }
- .footer {
- padding: 20px;
- text-align: center;
- font-size: 12px;
- color: #666666;
- background-color: #f5f5f5;
- }
- .button {
- display: inline-block;
- background-color: #2563EB;
- color: white;
- padding: 12px 24px;
- text-decoration: none;
- border-radius: 4px;
- margin: 20px 0;
- font-weight: bold;
- }
- .highlight {
- background-color: #f8f9fa;
- padding: 15px;
- border-left: 4px solid #2563EB;
- margin: 20px 0;
- }
- </style>
-</head>
-<body>
- <div class="container">
- <div class="header">
- <h1>{{t "email.vendor.invitation.title"}}</h1>
- </div>
- <div class="content">
- <p>{{t "email.vendor.invitation.greeting"}} {{companyName}},</p>
-
- <p>{{t "email.vendor.invitation.message"}}</p>
-
- <div class="highlight">
- <p>{{t "email.vendor.invitation.details"}}</p>
- </div>
-
- <div style="text-align: center;">
- <a href="{{registrationLink}}" class="button">{{t "email.vendor.invitation.register_now"}}</a>
- </div>
-
- <p>{{t "email.vendor.invitation.expire_notice"}}</p>
-
- <p>{{t "email.vendor.invitation.footer"}}</p>
-
- <p>{{t "email.vendor.invitation.signature"}}<br>
- EVCP {{t "email.vendor.invitation.team"}}</p>
- </div>
- <div class="footer">
- <p>© {{currentYear}} EVCP. {{t "email.vendor.invitation.copyright"}}</p>
- <p>{{t "email.vendor.invitation.no_reply"}}</p>
- </div>
+{{#> layout title=(t "email.vendor.invitation.title")}}
+ <p>{{t "email.vendor.invitation.greeting"}} {{companyName}},</p>
+
+ <p>{{t "email.vendor.invitation.message"}}</p>
+
+ <div class="info-box">
+ <p>{{t "email.vendor.invitation.details"}}</p>
</div>
-</body>
-</html> \ No newline at end of file
+
+ <div class="button-container">
+ <a href="{{registrationLink}}" class="button">{{t "email.vendor.invitation.register_now"}}</a>
+ </div>
+
+ <p>{{t "email.vendor.invitation.expire_notice"}}</p>
+
+ <p>{{t "email.vendor.invitation.footer"}}</p>
+
+ <p>{{t "email.vendor.invitation.signature"}}<br>
+ EVCP {{t "email.vendor.invitation.team"}}</p>
+{{/layout}} \ No newline at end of file
diff --git a/lib/mail/templates/vendor-pq-comment.hbs b/lib/mail/templates/vendor-pq-comment.hbs
index b60deedc..3606bcdb 100644
--- a/lib/mail/templates/vendor-pq-comment.hbs
+++ b/lib/mail/templates/vendor-pq-comment.hbs
@@ -1,128 +1,41 @@
-<!DOCTYPE html>
-<html>
-<head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>PQ Review Comments</title>
- <style>
- body {
- font-family: Arial, sans-serif;
- line-height: 1.6;
- color: #333;
- margin: 0;
- padding: 0;
- }
- .container {
- max-width: 600px;
- margin: 0 auto;
- padding: 20px;
- }
- .header {
- text-align: center;
- padding: 20px 0;
- border-bottom: 1px solid #eee;
- }
- .content {
- padding: 20px 0;
- }
- .footer {
- text-align: center;
- padding: 20px 0;
- font-size: 12px;
- color: #999;
- border-top: 1px solid #eee;
- }
- .btn {
- display: inline-block;
- padding: 10px 20px;
- font-size: 16px;
- color: #fff;
- background-color: #0071bc;
- text-decoration: none;
- border-radius: 4px;
- margin: 20px 0;
- }
- .comment-section {
- margin: 20px 0;
- padding: 15px;
- background-color: #f9f9f9;
- border-left: 4px solid #0071bc;
- }
- .comment-item {
- margin-bottom: 15px;
- padding-bottom: 15px;
- border-bottom: 1px solid #eee;
- }
- .comment-item:last-child {
- border-bottom: none;
- }
- .comment-code {
- font-weight: bold;
- color: #0071bc;
- display: inline-block;
- min-width: 60px;
- }
- .comment-title {
- font-weight: bold;
- color: #333;
- }
- .important {
- color: #d14;
- font-weight: bold;
- }
- </style>
-</head>
-<body>
- <div class="container">
- <div class="header">
- <h1>PQ Review Comments</h1>
- </div>
-
- <div class="content">
- <p>Dear {{name}} ({{vendorCode}}),</p>
-
- <p>Thank you for submitting your PQ information. Our review team has completed the initial review and has requested some changes or additional information.</p>
-
- <p><span class="important">Action Required:</span> Please log in to your account and update your PQ submission based on the comments below.</p>
-
- {{#if hasGeneralComment}}
- <div class="comment-section">
- <h3>General Comments:</h3>
- <p>{{generalComment}}</p>
- </div>
- {{/if}}
-
- <div class="comment-section">
- <h3>Specific Item Comments ({{commentCount}}):</h3>
- {{#each comments}}
- <div class="comment-item">
- <div>
- <span class="comment-code">{{code}}</span>
- <span class="comment-title">{{checkPoint}}</span>
- </div>
- <p>{{text}}</p>
- </div>
- {{/each}}
- </div>
-
- <p>Please review these comments and make the necessary updates to your PQ submission. Once you have made the requested changes, you can resubmit your PQ for further review.</p>
-
- <div style="text-align: center;">
- <a href="{{loginUrl}}" class="btn">Log in to update your PQ</a>
- </div>
-
- <p>If you have any questions or need assistance, please contact our support team.</p>
-
- <p>Thank you for your cooperation.</p>
-
- <p>Best regards,<br>
- PQ Review Team</p>
- </div>
-
- <div class="footer">
- <p>This is an automated email. Please do not reply to this message.</p>
- <p>&copy; {{currentYear}} Your Company Name. All rights reserved.</p>
+{{> header logoUrl=logoUrl }}
+
+<h1 style="text-align:center; font-size:28px; margin-bottom:20px;">PQ Review Comments</h1>
+
+<p style="font-size:16px;">Dear {{name}} ({{vendorCode}}),</p>
+
+<p style="font-size:16px;">Thank you for submitting your PQ information. Our review team has completed the initial review and has requested some changes or additional information.</p>
+
+<p style="font-size:16px;"><span style="color:#d14; font-weight:bold;">Action Required:</span> Please log in to your account and update your PQ submission based on the comments below.</p>
+
+{{#if hasGeneralComment}}
+<div style="margin:20px 0; padding:15px; background-color:#f9f9f9; border-left:4px solid #0071bc;">
+ <h3>General Comments:</h3>
+ <p>{{generalComment}}</p>
+</div>
+{{/if}}
+
+<div style="margin:20px 0; padding:15px; background-color:#f9f9f9; border-left:4px solid #0071bc;">
+ <h3>Specific Item Comments ({{commentCount}}):</h3>
+ {{#each comments}}
+ <div style="margin-bottom:15px; border-bottom:1px solid #eee; padding-bottom:15px;">
+ <div>
+ <span style="font-weight:bold; color:#0071bc; display:inline-block; min-width:60px;">{{code}}</span>
+ <span style="font-weight:bold; color:#333;">{{checkPoint}}</span>
</div>
+ <p>{{text}}</p>
</div>
-</body>
-</html> \ No newline at end of file
+ {{/each}}
+</div>
+
+<p style="font-size:16px;">Please review these comments and update your PQ submission.</p>
+
+<div style="text-align:center; margin:20px 0;">
+ <a href="{{loginUrl}}" class="btn" style="padding:10px 20px; font-size:16px; background-color:#0071bc; color:#fff; text-decoration:none; border-radius:4px;">Log in to update your PQ</a>
+</div>
+
+<p style="font-size:16px;">If you have any questions, please contact our support team.</p>
+
+<p style="font-size:16px;">Thank you,<br />PQ Review Team</p>
+
+{{> footer logoUrl=logoUrl companyName=companyName year=currentYear }} \ No newline at end of file
diff --git a/lib/mail/templates/vendor-pq-status.hbs b/lib/mail/templates/vendor-pq-status.hbs
index 541a6137..4a3fece5 100644
--- a/lib/mail/templates/vendor-pq-status.hbs
+++ b/lib/mail/templates/vendor-pq-status.hbs
@@ -1,48 +1,23 @@
-<!-- file: templates/vendor-pq-status.hbs -->
+{{> header logoUrl=logoUrl }}
-<html>
- <body style="font-family: sans-serif; margin: 0; padding: 0;">
- <table width="100%" cellspacing="0" cellpadding="20" style="background-color: #f7f7f7;">
- <tr>
- <td>
- <table width="600" cellspacing="0" cellpadding="20" style="background-color: #ffffff; margin: 0 auto;">
- <tr>
- <td style="text-align: center;">
- <h1 style="margin-bottom: 0.5rem;">Vendor PQ Status Update</h1>
- </td>
- </tr>
- <tr>
- <td>
- <p>Hello {{name}},</p>
- <p>
- Your vendor status has been updated to
- <strong>{{status}}</strong>.
- </p>
- <p>
- You can log in to see details and take further action:
- <br />
- <a href="{{loginUrl}}" style="color: #007bff; text-decoration: underline;">
- Go to Portal
- </a>
- </p>
- <p>
- If you have any questions, feel free to contact us.
- </p>
- <p>Thank you,<br/>
- The PQ Team
- </p>
- </td>
- </tr>
- <tr>
- <td style="text-align: center; border-top: 1px solid #eee;">
- <small style="color: #999;">
- &copy; 2023 MyCompany
- </small>
- </td>
- </tr>
- </table>
- </td>
- </tr>
- </table>
- </body>
-</html> \ No newline at end of file
+<h1 style="text-align:center; font-size:28px; margin-bottom:20px;">Vendor PQ Status Update</h1>
+
+<p style="font-size:16px;">Hello {{name}},</p>
+
+<p style="font-size:16px;">
+ Your vendor status has been updated to <strong>{{status}}</strong>.
+</p>
+
+<p style="font-size:16px;">
+ You can log in to see details and take further action:
+ <br />
+ <a href="{{loginUrl}}" target="_blank" style="color:#163CC4; text-decoration:underline;">
+ Go to Portal
+ </a>
+</p>
+
+<p style="font-size:16px;">If you have any questions, feel free to contact us.</p>
+
+<p style="font-size:16px;">Thank you,<br/>The PQ Team</p>
+
+{{> footer logoUrl=logoUrl companyName=companyName year=year }} \ No newline at end of file
diff --git a/lib/mail/templates/vendor-project-pq-status.hbs b/lib/mail/templates/vendor-project-pq-status.hbs
new file mode 100644
index 00000000..c051ce02
--- /dev/null
+++ b/lib/mail/templates/vendor-project-pq-status.hbs
@@ -0,0 +1,42 @@
+{{> header logoUrl=logoUrl }}
+
+<h1 style="text-align:center; font-size:28px; margin-bottom:20px;">Vendor Project PQ Status Update</h1>
+
+<p style="font-size:16px;">Hello {{name}},</p>
+
+<p style="font-size:16px;">
+ Your vendor status for <strong>{{projectName}}</strong> has been updated to <strong>{{status}}</strong>.
+</p>
+
+{{#if hasRejectionReason}}
+<p style="font-size:16px; padding:15px; background-color:#f8f8f8; border-left:4px solid #e74c3c;">
+ <strong>Reason for rejection:</strong><br/>
+ {{rejectionReason}}
+</p>
+{{/if}}
+
+{{#if approvalDate}}
+<p style="font-size:16px;">
+ <strong>Approval Date:</strong> {{approvalDate}}
+</p>
+{{/if}}
+
+{{#if rejectionDate}}
+<p style="font-size:16px;">
+ <strong>Rejection Date:</strong> {{rejectionDate}}
+</p>
+{{/if}}
+
+<p style="font-size:16px;">
+ You can log in to see details and take further action:
+ <br />
+ <a href="{{loginUrl}}" target="_blank" style="color:#163CC4; text-decoration:underline;">
+ Go to Portal
+ </a>
+</p>
+
+<p style="font-size:16px;">If you have any questions, feel free to contact us.</p>
+
+<p style="font-size:16px;">Thank you,<br/>The PQ Team</p>
+
+{{> footer logoUrl=logoUrl companyName=companyName year=year }} \ No newline at end of file
diff --git a/lib/poa/table/poa-table.tsx b/lib/poa/table/poa-table.tsx
index a5cad02a..72b84d72 100644
--- a/lib/poa/table/poa-table.tsx
+++ b/lib/poa/table/poa-table.tsx
@@ -79,7 +79,7 @@ export function ChangeOrderListsTable({ promises }: ItemsTableProps) {
},
{
id: "vendorId",
- label: "벤더 ID",
+ label: "협력업체 ID",
type: "number",
},
{
diff --git a/lib/poa/validations.ts b/lib/poa/validations.ts
index eae1b5ab..8fdf9bdf 100644
--- a/lib/poa/validations.ts
+++ b/lib/poa/validations.ts
@@ -35,7 +35,7 @@ export const searchParamsCache = createSearchParamsCache({
projectCode: parseAsString.withDefault(""),
projectName: parseAsString.withDefault(""),
- // 벤더 정보
+ // 협력업체 정보
vendorId: parseAsString.withDefault(""),
vendorName: parseAsString.withDefault(""),
diff --git a/lib/pq/service.ts b/lib/pq/service.ts
index ad7e60c4..6159a307 100644
--- a/lib/pq/service.ts
+++ b/lib/pq/service.ts
@@ -19,6 +19,7 @@ import { writeFile, mkdir } from 'fs/promises';
import { GetVendorsSchema } from "../vendors/validations";
import { countVendors, selectVendors } from "../vendors/repository";
import { projects } from "@/db/schema";
+import { headers } from 'next/headers';
/**
* PQ 목록 조회
@@ -144,8 +145,8 @@ export async function getPQs(
const createPqSchema = z.object({
code: z.string().min(1, "Code is required"),
checkPoint: z.string().min(1, "Check point is required"),
- description: z.string().optional(),
- remarks: z.string().optional(),
+ description: z.string().optional().nullable(),
+ remarks: z.string().optional().nullable(),
groupName: z.string().optional()
});
@@ -409,7 +410,7 @@ export async function getPQDataByVendorId(
contractInfo: pqCriteriasExtension.contractInfo,
additionalRequirement: pqCriteriasExtension.additionalRequirement,
- // 벤더 응답 필드
+ // 협력업체 응답 필드
answer: vendorPqCriteriaAnswers.answer,
answerId: vendorPqCriteriaAnswers.id,
@@ -653,8 +654,8 @@ export async function savePQAnswersAction(input: SavePQInput) {
/**
- * PQ 제출 서버 액션 - 벤더 상태를 PQ_SUBMITTED로 업데이트
- * @param vendorId 벤더 ID
+ * PQ 제출 서버 액션 - 협력업체 상태를 PQ_SUBMITTED로 업데이트
+ * @param vendorId 협력업체 ID
*/
export async function submitPQAction({
vendorId,
@@ -666,6 +667,9 @@ export async function submitPQAction({
unstable_noStore();
try {
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
// 1. 모든 PQ 항목에 대한 응답이 있는지 검증
const queryConditions = [
eq(vendorPqCriteriaAnswers.vendorId, vendorId)
@@ -690,7 +694,7 @@ export async function submitPQAction({
return { ok: false, error: "No PQ answers found" };
}
- // 2. 벤더 정보 조회
+ // 2. 협력업체 정보 조회
const vendor = await db
.select({
id: vendors.id,
@@ -770,7 +774,7 @@ export async function submitPQAction({
});
}
} else {
- // 일반 PQ인 경우 벤더 상태 검증 및 업데이트
+ // 일반 PQ인 경우 협력업체 상태 검증 및 업데이트
const allowedStatuses = ["IN_PQ", "PENDING_REVIEW", "IN_REVIEW", "REJECTED", "PQ_FAILED"];
if (!allowedStatuses.includes(vendor.status)) {
@@ -798,8 +802,8 @@ export async function submitPQAction({
: `[eVCP] PQ Submitted: ${vendor.vendorName}`;
const adminUrl = projectId
- ? `${process.env.NEXT_PUBLIC_APP_URL}/admin/vendors/${vendorId}/projects/${projectId}/pq`
- : `${process.env.NEXT_PUBLIC_APP_URL}/admin/vendors/${vendorId}/pq`;
+ ? `http://${host}/evcp/pq/${vendorId}?projectId=${projectId}`
+ : `http://${host}/evcp/pq/${vendorId}`;
await sendEmail({
to: process.env.ADMIN_EMAIL,
@@ -828,9 +832,7 @@ export async function submitPQAction({
? `[eVCP] Project PQ Submission Confirmation for ${projectName}`
: "[eVCP] PQ Submission Confirmation";
- const portalUrl = projectId
- ? `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/projects/${projectId}`
- : `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`;
+ const portalUrl = `${host}/dashboard`;
await sendEmail({
to: vendor.email,
@@ -843,7 +845,7 @@ export async function submitPQAction({
isProjectPQ: !!projectId,
submittedDate: currentDate.toLocaleString(),
portalUrl,
- }
+ }
});
} catch (emailError) {
console.error("Failed to send vendor confirmation:", emailError);
@@ -1001,10 +1003,10 @@ export async function getVendorsInPQ(input: GetVendorsSchema) {
// 트랜잭션 내에서 데이터 조회
const { data, total } = await db.transaction(async (tx) => {
- // 벤더 ID 모음 (중복 제거용)
+ // 협력업체 ID 모음 (중복 제거용)
const vendorIds = new Set<number>();
- // 1-A) 일반 PQ 답변이 있는 벤더 찾기 (status와 상관없이)
+ // 1-A) 일반 PQ 답변이 있는 협력업체 찾기 (status와 상관없이)
const generalPqVendors = await tx
.select({
vendorId: vendorPqCriteriaAnswers.vendorId
@@ -1025,7 +1027,7 @@ export async function getVendorsInPQ(input: GetVendorsSchema) {
generalPqVendors.forEach(v => vendorIds.add(v.vendorId));
- // 1-B) 프로젝트 PQ 답변이 있는 벤더 ID 조회 (status와 상관없이)
+ // 1-B) 프로젝트 PQ 답변이 있는 협력업체 ID 조회 (status와 상관없이)
const projectPqVendors = await tx
.select({
vendorId: vendorProjectPQs.vendorId
@@ -1046,7 +1048,7 @@ export async function getVendorsInPQ(input: GetVendorsSchema) {
projectPqVendors.forEach(v => vendorIds.add(v.vendorId));
- // 중복 제거된 벤더 ID 배열
+ // 중복 제거된 협력업체 ID 배열
const uniqueVendorIds = Array.from(vendorIds);
// 총 개수 (중복 제거 후)
@@ -1059,13 +1061,13 @@ export async function getVendorsInPQ(input: GetVendorsSchema) {
// 페이징 처리 (정렬 후 limit/offset 적용)
const paginatedIds = uniqueVendorIds.slice(offset, offset + input.perPage);
- // 2) 페이징된 벤더 상세 정보 조회
+ // 2) 페이징된 협력업체 상세 정보 조회
const vendorsData = await selectVendors(tx, {
where: inArray(vendors.id, paginatedIds),
orderBy: input.sort.length > 0
? input.sort.map((item) =>
- item.desc ? desc(vendors[item.id]) : asc(vendors[item.id])
- )
+ item.desc ? desc(vendors.vendorName) : asc(vendors.vendorName)
+ )
: [asc(vendors.createdAt)],
});
@@ -1204,7 +1206,7 @@ function getPqStatusDisplay(
return "PQ 정보 없음";
}
-// 벤더 상태 텍스트 변환
+// 협력업체 상태 텍스트 변환
function getPqVendorStatusText(status: string): string {
switch (status) {
case "IN_PQ": return "진행중";
@@ -1249,6 +1251,9 @@ export type VendorStatus =
if (!vendor) {
return { ok: false, error: "Vendor not found" }
}
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ const loginUrl = `http://${host}/partners/pq`
// 3) Send email
await sendEmail({
@@ -1258,7 +1263,7 @@ export type VendorStatus =
context: {
name: vendor.vendorName,
status: newStatus,
- loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/pq`, // etc.
+ loginUrl: loginUrl, // etc.
},
})
revalidateTag("vendors")
@@ -1354,7 +1359,7 @@ export async function updateProjectPQStatusAction({
projectName: project.name,
rejectionReason: status === "REJECTED" ? comment : undefined,
hasRejectionReason: status === "REJECTED" && !!comment,
- loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/projects/${projectId}/pq`,
+ loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/pq?projectId=${projectId}`,
approvalDate: status === "APPROVED" ? currentDate.toLocaleDateString() : undefined,
rejectionDate: status === "REJECTED" ? currentDate.toLocaleDateString() : undefined,
},
@@ -1385,7 +1390,7 @@ interface ItemComment {
/**
* PQ 변경 요청 처리 서버 액션
*
- * @param vendorId 벤더 ID
+ * @param vendorId 협력업체 ID
* @param comment 항목별 코멘트 배열 (answerId, checkPoint, code, comment로 구성)
* @param generalComment 전체 PQ에 대한 일반 코멘트 (선택사항)
*/
@@ -1394,11 +1399,15 @@ export async function requestPqChangesAction({
projectId,
comment,
generalComment,
+ reviewerName,
+ reviewerId
}: {
vendorId: number;
projectId?: number; // Optional project ID for project-specific PQs
comment: ItemComment[];
generalComment?: string;
+ reviewerName?: string;
+ reviewerId?: string;
}) {
try {
// 1) 상태 업데이트 (PQ 타입에 따라 다르게 처리)
@@ -1442,7 +1451,7 @@ export async function requestPqChangesAction({
.where(eq(vendors.id, vendorId));
}
- // 2) 벤더 정보 가져오기
+ // 2) 협력업체 정보 가져오기
const vendor = await db
.select()
.from(vendors)
@@ -1469,8 +1478,7 @@ export async function requestPqChangesAction({
// 3) 각 항목별 코멘트 저장
const currentDate = new Date();
- const reviewerId = 1; // 관리자 ID (실제 구현에서는 세션에서 가져옵니다)
- const reviewerName = "AdminUser"; // 실제 구현에서는 세션에서 가져옵니다
+
// 병렬로 모든 코멘트 저장
if (comment && comment.length > 0) {
@@ -1508,7 +1516,7 @@ export async function requestPqChangesAction({
// 로그인 URL - 프로젝트 PQ인 경우 다른 경로로 안내
const loginUrl = projectId
- ? `${process.env.NEXT_PUBLIC_URL}/partners/projects/${projectId}/pq`
+ ? `${process.env.NEXT_PUBLIC_URL}/partners/pq?projectId=${projectId}`
: `${process.env.NEXT_PUBLIC_URL}/partners/pq`;
await sendEmail({
@@ -1676,9 +1684,22 @@ export async function getVendorPQsList(vendorId: number): Promise<VendorPQsList>
export async function loadGeneralPQData(vendorId: number) {
+ "use server";
return getPQDataByVendorId(vendorId)
}
export async function loadProjectPQData(vendorId: number, projectId: number) {
+ "use server";
return getPQDataByVendorId(vendorId, projectId)
+}
+
+export async function loadGeneralPQAction(vendorId: number) {
+ return getPQDataByVendorId(vendorId);
+}
+
+export async function loadProjectPQAction(vendorId: number, projectId?: number): Promise<PQGroupData[]> {
+ if (!projectId) {
+ throw new Error("Project ID is required for loading project PQ data");
+ }
+ return getPQDataByVendorId(vendorId, projectId);
} \ No newline at end of file
diff --git a/lib/pq/table/import-pq-handler.tsx b/lib/pq/table/import-pq-handler.tsx
index aa5e6c47..13431ba7 100644
--- a/lib/pq/table/import-pq-handler.tsx
+++ b/lib/pq/table/import-pq-handler.tsx
@@ -77,10 +77,10 @@ export async function processFileImport(
code: row.Code?.toString().trim() ?? "",
checkPoint: row["Check Point"]?.toString().trim() ?? "",
groupName: row["Group Name"]?.toString().trim() ?? "",
- description: row.Description?.toString() ?? null,
- remarks: row.Remarks?.toString() ?? null,
- contractInfo: row["Contract Info"]?.toString() ?? null,
- additionalRequirement: row["Additional Requirements"]?.toString() ?? null,
+ description: row.Description?.toString() ?? "",
+ remarks: row.Remarks?.toString() ?? "",
+ contractInfo: row["Contract Info"]?.toString() ?? "",
+ additionalRequirement: row["Additional Requirements"]?.toString() ?? "",
};
// 데이터 유효성 검사
@@ -109,8 +109,7 @@ export async function processFileImport(
// PQ 생성 서버 액션 호출
const createResult = await createPq({
...cleanedRow,
- projectId: projectId,
- isProjectSpecific: !!projectId,
+ projectId: projectId || 0
});
if (createResult.success) {
diff --git a/lib/project-avl/repository.ts b/lib/project-avl/repository.ts
new file mode 100644
index 00000000..5fef8560
--- /dev/null
+++ b/lib/project-avl/repository.ts
@@ -0,0 +1,49 @@
+// src/lib/projectApprovedVendors/repository.ts
+import { projectApprovedVendors } 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 selectProejctAVLs(
+ 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(projectApprovedVendors)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+/** 총 개수 count */
+export async function countProjectAVLs(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(projectApprovedVendors).where(where);
+ return res[0]?.count ?? 0;
+}
+
diff --git a/lib/project-avl/service.ts b/lib/project-avl/service.ts
new file mode 100644
index 00000000..6ba10c5e
--- /dev/null
+++ b/lib/project-avl/service.ts
@@ -0,0 +1,106 @@
+// src/lib/projectApprovedVendors/service.ts
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { customAlphabet } from "nanoid";
+
+import { filterColumns } from "@/lib/filter-columns";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { getErrorMessage } from "@/lib/handle-error";
+
+import { asc, desc, ilike, inArray, and, gte, lte, not, or ,eq} from "drizzle-orm";
+import { GetProjectAVLSchema } from "./validations";
+import { projectApprovedVendors } from "@/db/schema";
+import { countProjectAVLs, selectProejctAVLs } from "./repository";
+
+
+/* -----------------------------------------------------
+ 1) 조회 관련
+----------------------------------------------------- */
+
+/**
+ * 복잡한 조건으로 Item 목록을 조회 (+ pagination) 하고,
+ * 총 개수에 따라 pageCount를 계산해서 리턴.
+ * Next.js의 unstable_cache를 사용해 일정 시간 캐시.
+ */
+export async function getProjecTAVL(input: GetProjectAVLSchema) {
+
+ 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: projectApprovedVendors,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere =
+ or(
+ ilike(projectApprovedVendors.vendor_name, s),
+ ilike(projectApprovedVendors.tax_id, s)
+ , ilike(projectApprovedVendors.vendor_email, s)
+ , ilike(projectApprovedVendors.vendor_type_name_ko, s)
+ , ilike(projectApprovedVendors.project_name, s)
+ , ilike(projectApprovedVendors.project_code, s)
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ const finalWhere = and(
+ // advancedWhere or your existing conditions
+ advancedWhere,
+ globalWhere // and()함수로 결합 or or() 등으로 결합
+ )
+
+
+ // 아니면 ilike, inArray, gte 등으로 where 절 구성
+ const where = finalWhere
+
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(projectApprovedVendors[item.id]) : asc(projectApprovedVendors[item.id])
+ )
+ : [asc(projectApprovedVendors.approved_at)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectProejctAVLs(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countProjectAVLs(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: ["project-approved-vendors"], // revalidateTag("projectApprovedVendors") 호출 시 무효화
+ }
+ )();
+}
+
diff --git a/lib/project-avl/table/proejctAVL-table.tsx b/lib/project-avl/table/proejctAVL-table.tsx
new file mode 100644
index 00000000..b9d6e142
--- /dev/null
+++ b/lib/project-avl/table/proejctAVL-table.tsx
@@ -0,0 +1,159 @@
+"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 { getColumns } from "./projectAVL-table-columns"
+import { ProjectApprovedVendors } from "@/db/schema"
+import { getProjecTAVL } from "../service"
+
+interface ProjectAVLTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getProjecTAVL>>,
+ ]
+ >
+}
+
+export function ProjectAVLTable({ promises }: ProjectAVLTableProps) {
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<ProjectApprovedVendors> | 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<ProjectApprovedVendors>[] = [
+
+
+ ]
+
+ /**
+ * 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<ProjectApprovedVendors>[] = [
+ {
+ id: "project_code",
+ label: "프로젝트 코드",
+ type: "text",
+ },
+ {
+ id: "project_name",
+ label: "프로젝트명",
+ type: "text",
+ },
+ {
+ id: "vendor_name",
+ label: "업체명",
+ type: "text",
+ },
+ {
+ id: "vendor_code",
+ label: "업체코드",
+ type: "text",
+ },
+ {
+ id: "tax_id",
+ label: "사업자등록번호",
+ type: "text",
+ },
+ {
+ id: "vendor_email",
+ label: "대표이메일",
+ type: "text",
+ },
+ {
+ id: "vendor_phone",
+ label: "대표전화번호",
+ type: "text",
+ },
+ {
+ id: "vendor_type_name_ko",
+ label: "업체유형",
+ type: "text",
+ },
+
+
+ {
+ id: "submitted_at",
+ label: "PQ 제출일",
+ type: "date",
+
+ },
+ {
+ id: "approved_at",
+ label: "PQ 승인일",
+ type: "date",
+
+ },
+ ]
+
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "approved_at", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.vendor_id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+
+ >
+
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ </DataTableAdvancedToolbar>
+
+ </DataTable>
+
+
+ </>
+ )
+}
diff --git a/lib/project-avl/table/projectAVL-table-columns.tsx b/lib/project-avl/table/projectAVL-table-columns.tsx
new file mode 100644
index 00000000..916380e3
--- /dev/null
+++ b/lib/project-avl/table/projectAVL-table-columns.tsx
@@ -0,0 +1,104 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { InfoIcon } from "lucide-react"
+
+import { formatDate } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { formListsColumnsConfig } from "@/config/formListsColumnsConfig"
+import { ProjectApprovedVendors } from "@/db/schema"
+import { projectAVLColumnsConfig } from "@/config/projectAVLColumnsConfig"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ProjectApprovedVendors> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ProjectApprovedVendors>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+
+
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<TagTypeClassFormMappings>[] }
+ const groupMap: Record<string, ColumnDef<ProjectApprovedVendors>[]> = {}
+
+ projectAVLColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<ProjectApprovedVendors> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+
+ if (cfg.id === "submitted_at"||cfg.id === "approved_at") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<ProjectApprovedVendors>[] = []
+
+ // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
+ // 여기서는 그냥 Object.entries 순서
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없음 → 그냥 최상위 레벨 컬럼
+ nestedColumns.push(...colDefs)
+ } else {
+ // 상위 컬럼
+ nestedColumns.push({
+ id: groupName,
+ header: groupName, // "Basic Info", "Metadata" 등
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ ...nestedColumns,
+ ]
+} \ No newline at end of file
diff --git a/lib/project-avl/validations.ts b/lib/project-avl/validations.ts
new file mode 100644
index 00000000..2d3b262e
--- /dev/null
+++ b/lib/project-avl/validations.ts
@@ -0,0 +1,41 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { ProjectApprovedVendors } from "@/db/schema"
+
+export const searchProjectAVLParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<ProjectApprovedVendors>().withDefault([
+ { id: "approved_at", desc: true },
+ ]),
+ vendor_code: parseAsString.withDefault(""),
+ vendor_name: parseAsString.withDefault(""),
+ tax_id: parseAsString.withDefault(""),
+ vendor_email: parseAsString.withDefault(""),
+ vendor_phone: parseAsString.withDefault(""),
+ vendor_status: parseAsString.withDefault(""),
+ vendor_type_name_ko: parseAsString.withDefault(""),
+ project_code: parseAsString.withDefault(""),
+ project_name: parseAsString.withDefault(""),
+ pq_status: parseAsString.withDefault(""),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+
+export type GetProjectAVLSchema = Awaited<ReturnType<typeof searchProjectAVLParamsCache.parse>>
diff --git a/lib/rfqs/cbe-table/cbe-table-columns.tsx b/lib/rfqs/cbe-table/cbe-table-columns.tsx
index 325b0465..bc16496f 100644
--- a/lib/rfqs/cbe-table/cbe-table-columns.tsx
+++ b/lib/rfqs/cbe-table/cbe-table-columns.tsx
@@ -34,8 +34,9 @@ interface GetColumnsProps {
React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null>
>
router: NextRouter
- openCommentSheet: (vendorId: number) => void
- openFilesDialog: (cbeId:number , vendorId: number) => void
+ openCommentSheet: (responseId: number) => void
+ openVendorContactsDialog: (vendorId: number, vendor: VendorWithCbeFields) => void // 수정된 시그니처
+
}
/**
@@ -45,7 +46,7 @@ export function getColumns({
setRowAction,
router,
openCommentSheet,
- openFilesDialog
+ openVendorContactsDialog
}: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] {
// ----------------------------------------------------------------
// 1) Select 컬럼 (체크박스)
@@ -104,6 +105,30 @@ export function getColumns({
// 1) 필드값 가져오기
const val = getValue()
+ if (cfg.id === "vendorName") {
+ const vendor = row.original;
+ const vendorId = vendor.vendorId;
+
+ // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링
+ const handleVendorNameClick = () => {
+ if (vendorId) {
+ openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달
+ } else {
+ toast.error("협력업체 ID를 찾을 수 없습니다.");
+ }
+ };
+
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left font-normal justify-start hover:underline"
+ onClick={handleVendorNameClick}
+ >
+ {val as string}
+ </Button>
+ );
+ }
+
if (cfg.id === "vendorStatus") {
const statusVal = row.original.vendorStatus
if (!statusVal) return null
@@ -116,8 +141,8 @@ export function getColumns({
}
- if (cfg.id === "rfqVendorStatus") {
- const statusVal = row.original.rfqVendorStatus
+ if (cfg.id === "responseStatus") {
+ const statusVal = row.original.responseStatus
if (!statusVal) return null
// const Icon = getStatusIcon(statusVal)
const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline"
@@ -128,8 +153,8 @@ export function getColumns({
)
}
- // 예) TBE Updated (날짜)
- if (cfg.id === "cbeUpdated") {
+ // 예) CBE Updated (날짜)
+ if (cfg.id === "respondedAt" ) {
const dateVal = val as Date | undefined
if (!dateVal) return null
return formatDate(dateVal)
@@ -172,39 +197,32 @@ const commentsColumn: ColumnDef<VendorWithCbeFields> = {
function handleClick() {
// rowAction + openCommentSheet
setRowAction({ row, type: "comments" })
- openCommentSheet(vendor.cbeId ?? 0)
+ openCommentSheet(vendor.responseId ?? 0)
}
return (
- <div className="flex items-center justify-center">
- <Button
- variant="ghost"
- size="sm"
- className="h-8 w-8 p-0 group relative"
- onClick={handleClick}
- aria-label={commCount > 0 ? `View ${commCount} comments` : "Add comment"}
- >
- <div className="flex items-center justify-center relative">
- {commCount > 0 ? (
- <>
- <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- <Badge
- variant="secondary"
- className="absolute -top-2 -right-2 h-4 min-w-4 text-xs px-1 flex items-center justify-center"
- >
- {commCount}
- </Badge>
- </>
- ) : (
- <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- )}
- </div>
- <span className="sr-only">{commCount > 0 ? `${commCount} Comments` : "Add Comment"}</span>
- </Button>
- {/* <span className="ml-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={handleClick}>
- {commCount > 0 ? `${commCount} Comments` : "Add Comment"}
- </span> */}
- </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ commCount > 0 ? `View ${commCount} comments` : "No comments"
+ }
+ >
+ <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {commCount > 0 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
+ >
+ {commCount}
+ </Badge>
+ )}
+ <span className="sr-only">
+ {commCount > 0 ? `${commCount} Comments` : "No Comments"}
+ </span>
+ </Button>
)
},
enableSorting: false,
diff --git a/lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx b/lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx
new file mode 100644
index 00000000..fbcf9af9
--- /dev/null
+++ b/lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx
@@ -0,0 +1,67 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+
+
+import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { InviteVendorsDialog } from "./invite-vendors-dialog"
+
+interface VendorsTableToolbarActionsProps {
+ table: Table<VendorWithCbeFields>
+ rfqId: number
+}
+
+export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일이 선택되었을 때 처리
+
+ function handleImportClick() {
+ // 숨겨진 <input type="file" /> 요소를 클릭
+ fileInputRef.current?.click()
+ }
+
+ const invitationPossibeVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ .filter(vendor => vendor.commercialResponseStatus === null);
+ }, [table.getFilteredSelectedRowModel().rows]);
+
+ return (
+ <div className="flex items-center gap-2">
+ {invitationPossibeVendors.length > 0 &&
+ (
+ <InviteVendorsDialog
+ vendors={invitationPossibeVendors}
+ rfqId={rfqId}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ )
+ }
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "tasks",
+ 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/rfqs/cbe-table/cbe-table.tsx b/lib/rfqs/cbe-table/cbe-table.tsx
index b2a74466..37fbc3f4 100644
--- a/lib/rfqs/cbe-table/cbe-table.tsx
+++ b/lib/rfqs/cbe-table/cbe-table.tsx
@@ -8,16 +8,17 @@ import type {
DataTableRowAction,
} from "@/types/table"
-import { toSentenceCase } from "@/lib/utils"
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 { useFeatureFlags } from "./feature-flags-provider"
-import { Vendor, vendors } from "@/db/schema/vendors"
import { fetchRfqAttachmentsbyCommentId, getCBE } from "../service"
-import { TbeComment } from "../tbe-table/comments-sheet"
import { getColumns } from "./cbe-table-columns"
import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { CommentSheet, CbeComment } from "./comments-sheet"
+import { useSession } from "next-auth/react" // Next-auth session hook 추가
+import { VendorContactsDialog } from "./vendor-contact-dialog"
+import { InviteVendorsDialog } from "./invite-vendors-dialog"
+import { VendorsTableToolbarActions } from "./cbe-table-toolbar-actions"
interface VendorsTableProps {
promises: Promise<
@@ -30,56 +31,54 @@ interface VendorsTableProps {
export function CbeTable({ promises, rfqId }: VendorsTableProps) {
- const { featureFlags } = useFeatureFlags()
// Suspense로 받아온 데이터
const [{ data, pageCount }] = React.use(promises)
+ const { data: session } = useSession() // 세션 정보 가져오기
+
+ const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0
+ const currentUser = session?.user
- console.log(data, "data")
const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null)
// **router** 획득
const router = useRouter()
- const [initialComments, setInitialComments] = React.useState<TbeComment[]>([])
+ const [initialComments, setInitialComments] = React.useState<CbeComment[]>([])
const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
- const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
-
- const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false)
- const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null)
- const [selectedTbeId, setSelectedTbeId] = React.useState<number | null>(null)
+ const [isLoadingComments, setIsLoadingComments] = React.useState(false)
+ // const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
- // Add handleRefresh function
- const handleRefresh = React.useCallback(() => {
- router.refresh();
- }, [router]);
+ const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null)
+ const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null)
+ const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false)
+ const [selectedVendor, setSelectedVendor] = React.useState<VendorWithCbeFields | null>(null)
+ // console.log("selectedVendorId", selectedVendorId)
+ // console.log("selectedCbeId", selectedCbeId)
React.useEffect(() => {
if (rowAction?.type === "comments") {
// rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행
- openCommentSheet(Number(rowAction.row.original.id))
- } else if (rowAction?.type === "files") {
- // Handle files action
- const vendorId = rowAction.row.original.vendorId;
- const cbeId = rowAction.row.original.cbeId ?? 0;
- openFilesDialog(cbeId, vendorId);
- }
+ openCommentSheet(Number(rowAction.row.original.responseId))
+ }
}, [rowAction])
- async function openCommentSheet(vendorId: number) {
+ async function openCommentSheet(responseId: number) {
setInitialComments([])
-
+ setIsLoadingComments(true)
const comments = rowAction?.row.original.comments
+ // const rfqId = rowAction?.row.original.rfqId
+ const vendorId = rowAction?.row.original.vendorId
if (comments && comments.length > 0) {
- const commentWithAttachments: TbeComment[] = await Promise.all(
+ const commentWithAttachments: CbeComment[] = await Promise.all(
comments.map(async (c) => {
const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
return {
...c,
- commentedBy: 1, // DB나 API 응답에 있다고 가정
+ commentedBy: currentUserId, // DB나 API 응답에 있다고 가정
attachments,
}
})
@@ -88,20 +87,22 @@ export function CbeTable({ promises, rfqId }: VendorsTableProps) {
setInitialComments(commentWithAttachments)
}
- setSelectedRfqIdForComments(vendorId)
+ // if(rfqId){ setSelectedRfqIdForComments(rfqId)}
+ if(vendorId){ setSelectedVendorId(vendorId)}
+ setSelectedCbeId(responseId)
setCommentSheetOpen(true)
+ setIsLoadingComments(false)
}
- const openFilesDialog = (cbeId: number, vendorId: number) => {
- setSelectedTbeId(cbeId)
+ const openVendorContactsDialog = (vendorId: number, vendor: VendorWithCbeFields) => {
setSelectedVendorId(vendorId)
- setIsFileDialogOpen(true)
+ setSelectedVendor(vendor)
+ setIsContactDialogOpen(true)
}
-
// getColumns() 호출 시, router를 주입
const columns = React.useMemo(
- () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog }),
+ () => getColumns({ setRowAction, router, openCommentSheet, openVendorContactsDialog }),
[setRowAction, router]
)
@@ -111,18 +112,7 @@ export function CbeTable({ promises, rfqId }: VendorsTableProps) {
const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [
{ id: "vendorName", label: "Vendor Name", type: "text" },
{ id: "vendorCode", label: "Vendor Code", type: "text" },
- { id: "email", label: "Email", type: "text" },
- { id: "country", label: "Country", type: "text" },
- {
- id: "vendorStatus",
- label: "Vendor Status",
- type: "multi-select",
- options: vendors.status.enumValues.map((status) => ({
- label: toSentenceCase(status),
- value: status,
- })),
- },
- { id: "rfqVendorUpdated", label: "Updated at", type: "date" },
+ { id: "respondedAt", label: "Updated at", type: "date" },
]
@@ -134,32 +124,55 @@ export function CbeTable({ promises, rfqId }: VendorsTableProps) {
enablePinning: true,
enableAdvancedFilter: true,
initialState: {
- sorting: [{ id: "rfqVendorUpdated", desc: true }],
- columnPinning: { right: ["actions"] },
+ sorting: [{ id: "respondedAt", desc: true }],
+ columnPinning: { right: ["comments"] },
},
- getRowId: (originalRow) => String(originalRow.id),
+ getRowId: (originalRow) => String(originalRow.responseId),
shallow: false,
clearOnDefault: true,
})
return (
<>
-<div style={{ maxWidth: '80vw' }}>
-<DataTable
+ <DataTable
table={table}
- // tableContainerClass="sm:max-w-[80vw] md:max-w-[80vw] lg:max-w-[80vw]"
- // tableContainerClass="max-w-[80vw]"
>
<DataTableAdvancedToolbar
table={table}
filterFields={advancedFilterFields}
shallow={false}
>
- {/* <VendorsTableToolbarActions table={table} rfqId={rfqId} /> */}
+ <VendorsTableToolbarActions table={table} rfqId={rfqId} />
</DataTableAdvancedToolbar>
</DataTable>
- </div>
-
+
+ <CommentSheet
+ currentUserId={currentUserId}
+ open={commentSheetOpen}
+ onOpenChange={setCommentSheetOpen}
+ rfqId={rfqId}
+ cbeId={selectedCbeId ?? 0}
+ vendorId={selectedVendorId ?? 0}
+ isLoading={isLoadingComments}
+ initialComments={initialComments}
+ />
+
+ <InviteVendorsDialog
+ vendors={rowAction?.row.original ? [rowAction?.row.original] : []}
+ onOpenChange={() => setRowAction(null)}
+ rfqId={rfqId}
+ open={rowAction?.type === "invite"}
+ showTrigger={false}
+ currentUser={currentUser}
+ />
+
+ <VendorContactsDialog
+ isOpen={isContactDialogOpen}
+ onOpenChange={setIsContactDialogOpen}
+ vendorId={selectedVendorId}
+ vendor={selectedVendor}
+ />
+
</>
)
} \ No newline at end of file
diff --git a/lib/rfqs/cbe-table/comments-sheet.tsx b/lib/rfqs/cbe-table/comments-sheet.tsx
new file mode 100644
index 00000000..e91a0617
--- /dev/null
+++ b/lib/rfqs/cbe-table/comments-sheet.tsx
@@ -0,0 +1,328 @@
+"use client"
+
+import * as React from "react"
+import { useForm, useFieldArray } from "react-hook-form"
+import { z } from "zod"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Download, X, Loader2 } from "lucide-react"
+import prettyBytes from "pretty-bytes"
+import { toast } from "sonner"
+
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneUploadIcon,
+ DropzoneTitle,
+ DropzoneDescription,
+ DropzoneInput,
+} from "@/components/ui/dropzone"
+import {
+ Table,
+ TableHeader,
+ TableRow,
+ TableHead,
+ TableBody,
+ TableCell,
+} from "@/components/ui/table"
+
+import { createRfqCommentWithAttachments } from "../service"
+import { formatDate } from "@/lib/utils"
+
+
+export interface CbeComment {
+ id: number
+ commentText: string
+ commentedBy?: number
+ commentedByEmail?: string
+ createdAt?: Date
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ }[]
+}
+
+// 1) props 정의
+interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ initialComments?: CbeComment[]
+ currentUserId: number
+ rfqId: number
+ // tbeId?: number
+ cbeId?: number
+ vendorId: number
+ onCommentsUpdated?: (comments: CbeComment[]) => void
+ isLoading?: boolean // New prop
+}
+
+// 2) 폼 스키마
+const commentFormSchema = z.object({
+ commentText: z.string().min(1, "댓글을 입력하세요."),
+ newFiles: z.array(z.any()).optional(), // File[]
+})
+type CommentFormValues = z.infer<typeof commentFormSchema>
+
+const MAX_FILE_SIZE = 30e6 // 30MB
+
+export function CommentSheet({
+ rfqId,
+ vendorId,
+ initialComments = [],
+ currentUserId,
+ // tbeId,
+ cbeId,
+ onCommentsUpdated,
+ isLoading = false, // Default to false
+ ...props
+}: CommentSheetProps) {
+
+
+ const [comments, setComments] = React.useState<CbeComment[]>(initialComments)
+ const [isPending, startTransition] = React.useTransition()
+
+ React.useEffect(() => {
+ setComments(initialComments)
+ }, [initialComments])
+
+ const form = useForm<CommentFormValues>({
+ resolver: zodResolver(commentFormSchema),
+ defaultValues: {
+ commentText: "",
+ newFiles: [],
+ },
+ })
+
+ const { fields: newFileFields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "newFiles",
+ })
+
+ // (A) 기존 코멘트 렌더링
+ function renderExistingComments() {
+
+ if (isLoading) {
+ return (
+ <div className="flex justify-center items-center h-32">
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
+ </div>
+ )
+ }
+
+ if (comments.length === 0) {
+ return <p className="text-sm text-muted-foreground">No comments yet</p>
+ }
+ return (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-1/2">Comment</TableHead>
+ <TableHead>Attachments</TableHead>
+ <TableHead>Created At</TableHead>
+ <TableHead>Created By</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {comments.map((c) => (
+ <TableRow key={c.id}>
+ <TableCell>{c.commentText}</TableCell>
+ <TableCell>
+ {!c.attachments?.length && (
+ <span className="text-sm text-muted-foreground">No files</span>
+ )}
+ {c.attachments?.length && (
+ <div className="flex flex-col gap-1">
+ {c.attachments.map((att) => (
+ <div key={att.id} className="flex items-center gap-2">
+ <a
+ href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`}
+ download
+ target="_blank"
+ rel="noreferrer"
+ className="inline-flex items-center gap-1 text-blue-600 underline"
+ >
+ <Download className="h-4 w-4" />
+ {att.fileName}
+ </a>
+ </div>
+ ))}
+ </div>
+ )}
+ </TableCell>
+ <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell>
+ <TableCell>{c.commentedByEmail ?? "-"}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )
+ }
+
+ // (B) 파일 드롭
+ function handleDropAccepted(files: File[]) {
+ append(files)
+ }
+
+ // (C) Submit
+ async function onSubmit(data: CommentFormValues) {
+ if (!rfqId) return
+ startTransition(async () => {
+ try {
+ // console.log("rfqId", rfqId)
+ // console.log("vendorId", vendorId)
+ // console.log("cbeId", cbeId)
+ // console.log("currentUserId", currentUserId)
+
+ const res = await createRfqCommentWithAttachments({
+ rfqId,
+ vendorId,
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ evaluationId: null,
+ cbeId: cbeId,
+ files: data.newFiles,
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create comment")
+ }
+
+ toast.success("Comment created")
+
+ // 임시로 새 코멘트 추가
+ const newComment: CbeComment = {
+ id: res.commentId, // 서버 응답
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ createdAt: res.createdAt,
+ attachments:
+ data.newFiles?.map((f) => ({
+ id: Math.floor(Math.random() * 1e6),
+ fileName: f.name,
+ filePath: "/uploads/" + f.name,
+ })) || [],
+ }
+ setComments((prev) => [...prev, newComment])
+ onCommentsUpdated?.([...comments, newComment])
+
+ form.reset()
+ } catch (err: any) {
+ console.error(err)
+ toast.error("Error: " + err.message)
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
+ <SheetHeader className="text-left">
+ <SheetTitle>Comments</SheetTitle>
+ <SheetDescription>
+ 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <FormField
+ control={form.control}
+ name="commentText"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>New Comment</FormLabel>
+ <FormControl>
+ <Textarea placeholder="Enter your comment..." {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={(rej) => {
+ toast.error("File rejected: " + (rej[0]?.file?.name || ""))
+ }}
+ >
+ {({ maxSize }) => (
+ <DropzoneZone className="flex justify-center">
+ <DropzoneInput />
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>Drop to attach files</DropzoneTitle>
+ <DropzoneDescription>
+ Max size: {prettyBytes(maxSize || 0)}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
+
+ {newFileFields.length > 0 && (
+ <div className="flex flex-col gap-2">
+ {newFileFields.map((field, idx) => {
+ const file = form.getValues(`newFiles.${idx}`)
+ if (!file) return null
+ return (
+ <div
+ key={field.id}
+ className="flex items-center justify-between border rounded p-2"
+ >
+ <span className="text-sm">
+ {file.name} ({prettyBytes(file.size)})
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ onClick={() => remove(idx)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ )
+ })}
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-4">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isPending}>
+ {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/cbe-table/invite-vendors-dialog.tsx b/lib/rfqs/cbe-table/invite-vendors-dialog.tsx
new file mode 100644
index 00000000..18edbe80
--- /dev/null
+++ b/lib/rfqs/cbe-table/invite-vendors-dialog.tsx
@@ -0,0 +1,423 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader, Send, User } from "lucide-react"
+import { toast } from "sonner"
+import { z } from "zod"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { type Row } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
+import { createCbeEvaluation } from "../service"
+
+// 컴포넌트 내부에서 사용할 폼 스키마 정의
+const formSchema = z.object({
+ paymentTerms: z.string().min(1, "결제 조건을 입력하세요"),
+ incoterms: z.string().min(1, "Incoterms를 입력하세요"),
+ deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"),
+ notes: z.string().optional(),
+})
+
+type FormValues = z.infer<typeof formSchema>
+
+interface InviteVendorsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ rfqId: number
+ vendors: Row<VendorWithCbeFields>["original"][]
+ currentUserId?: number
+ currentUser?: {
+ id: string
+ name?: string | null
+ email?: string | null
+ image?: string | null
+ companyId?: number | null
+ domain?: string | null
+ }
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function InviteVendorsDialog({
+ rfqId,
+ vendors,
+ currentUserId,
+ currentUser,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: InviteVendorsDialogProps) {
+ const [files, setFiles] = React.useState<FileList | null>(null)
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ // 로컬 스키마와 폼 값을 사용하도록 수정
+ const form = useForm<FormValues>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ paymentTerms: "",
+ incoterms: "",
+ deliverySchedule: "",
+ notes: "",
+ },
+ mode: "onChange",
+ })
+
+ // 폼 상태 감시
+ const { formState } = form
+ const isValid = formState.isValid &&
+ !!form.getValues("paymentTerms") &&
+ !!form.getValues("incoterms") &&
+ !!form.getValues("deliverySchedule")
+
+ // 디버깅용 상태 트래킹
+ React.useEffect(() => {
+ const subscription = form.watch((value) => {
+ // 폼 값이 변경될 때마다 실행되는 콜백
+ console.log("Form values changed:", value);
+ });
+
+ return () => subscription.unsubscribe();
+ }, [form]);
+
+ async function onSubmit(data: FormValues) {
+ try {
+ setIsSubmitting(true)
+
+ // 기본 FormData 생성
+ const formData = new FormData()
+
+ // rfqId 추가
+ formData.append("rfqId", String(rfqId))
+
+ // 폼 데이터 추가
+ Object.entries(data).forEach(([key, value]) => {
+ if (value !== undefined && value !== null) {
+ formData.append(key, String(value))
+ }
+ })
+
+ // 현재 사용자 ID 추가
+ if (currentUserId) {
+ formData.append("evaluatedBy", String(currentUserId))
+ }
+
+ // 협력업체 ID만 추가 (서버에서 연락처 정보를 조회)
+ vendors.forEach((vendor) => {
+ formData.append("vendorIds[]", String(vendor.vendorId))
+ })
+
+ // 파일 추가 (있는 경우에만)
+ if (files && files.length > 0) {
+ for (let i = 0; i < files.length; i++) {
+ formData.append("files", files[i])
+ }
+ }
+
+ // 서버 액션 호출
+ const response = await createCbeEvaluation(formData)
+
+ if (response.error) {
+ toast.error(response.error)
+ return
+ }
+
+ // 성공 처리
+ toast.success(`${vendors.length}개 협력업체에 CBE 평가가 성공적으로 전송되었습니다!`)
+ form.reset()
+ setFiles(null)
+ props.onOpenChange?.(false)
+ onSuccess?.()
+ } catch (error) {
+ console.error(error)
+ toast.error("CBE 평가 생성 중 오류가 발생했습니다.")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ setFiles(null)
+ }
+ props.onOpenChange?.(nextOpen)
+ }
+
+ // 필수 필드 라벨에 추가할 요소
+ const RequiredLabel = (
+ <span className="text-destructive ml-1 font-medium">*</span>
+ )
+
+ const formContent = (
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ {/* 선택된 협력업체 정보 표시 */}
+ <div className="space-y-2">
+ <FormLabel>선택된 협력업체 ({vendors.length})</FormLabel>
+ <ScrollArea className="h-20 border rounded-md p-2">
+ <div className="flex flex-wrap gap-2">
+ {vendors.map((vendor, index) => (
+ <Badge key={index} variant="secondary" className="py-1">
+ {vendor.vendorName || `협력업체 #${vendor.vendorCode}`}
+ </Badge>
+ ))}
+ </div>
+ </ScrollArea>
+ <FormDescription>
+ 선택된 모든 협력업체의 등록된 연락처에게 CBE 평가 알림이 전송됩니다.
+ </FormDescription>
+ </div>
+
+ {/* 작성자 정보 (읽기 전용) */}
+ {currentUser && (
+ <div className="border rounded-md p-3 space-y-2">
+ <FormLabel>작성자</FormLabel>
+ <div className="flex items-center gap-3">
+ {currentUser.image ? (
+ <Avatar className="h-8 w-8">
+ <AvatarImage src={currentUser.image} alt={currentUser.name || ""} />
+ <AvatarFallback>
+ {currentUser.name?.charAt(0) || <User className="h-4 w-4" />}
+ </AvatarFallback>
+ </Avatar>
+ ) : (
+ <Avatar className="h-8 w-8">
+ <AvatarFallback>
+ {currentUser.name?.charAt(0) || <User className="h-4 w-4" />}
+ </AvatarFallback>
+ </Avatar>
+ )}
+ <div>
+ <p className="text-sm font-medium">{currentUser.name || "Unknown User"}</p>
+ <p className="text-xs text-muted-foreground">{currentUser.email || ""}</p>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* 결제 조건 - 필수 필드 */}
+ <FormField
+ control={form.control}
+ name="paymentTerms"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 결제 조건{RequiredLabel}
+ </FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="예: Net 30" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Incoterms - 필수 필드 */}
+ <FormField
+ control={form.control}
+ name="incoterms"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ Incoterms{RequiredLabel}
+ </FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="예: FOB, CIF" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 배송 일정 - 필수 필드 */}
+ <FormField
+ control={form.control}
+ name="deliverySchedule"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 배송 일정{RequiredLabel}
+ </FormLabel>
+ <FormControl>
+ <Textarea
+ {...field}
+ placeholder="배송 일정 세부사항을 입력하세요"
+ rows={3}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 비고 - 선택적 필드 */}
+ <FormField
+ control={form.control}
+ name="notes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ {...field}
+ placeholder="추가 비고 사항을 입력하세요"
+ rows={3}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 파일 첨부 (옵션) */}
+ <div className="space-y-2">
+ <FormLabel htmlFor="files">첨부 파일 (선택사항)</FormLabel>
+ <Input
+ id="files"
+ type="file"
+ multiple
+ onChange={(e) => setFiles(e.target.files)}
+ />
+ {files && files.length > 0 && (
+ <p className="text-sm text-muted-foreground">
+ {files.length}개 파일이 첨부되었습니다
+ </p>
+ )}
+ </div>
+
+ {/* 필수 입력 항목 안내 */}
+ <div className="text-sm text-muted-foreground">
+ <span className="text-destructive">*</span> 표시는 필수 입력 항목입니다.
+ </div>
+
+ {/* 모바일에서는 Drawer 내부에서 버튼이 렌더링되므로 여기서는 숨김 */}
+ {isDesktop && (
+ <DialogFooter className="gap-2 pt-4">
+ <DialogClose asChild>
+ <Button
+ type="button"
+ variant="outline"
+ >
+ 취소
+ </Button>
+ </DialogClose>
+ <Button
+ type="submit"
+ disabled={isSubmitting || !isValid}
+ >
+ {isSubmitting && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"}
+ </Button>
+ </DialogFooter>
+ )}
+ </form>
+ </Form>
+ )
+
+ // Desktop Dialog
+ if (isDesktop) {
+ return (
+ <Dialog {...props} onOpenChange={handleDialogOpenChange}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Send className="mr-2 size-4" aria-hidden="true" />
+ CBE 평가 전송 ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <DialogTitle>CBE 평가 생성 및 전송</DialogTitle>
+ <DialogDescription>
+ 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {formContent}
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ // Mobile Drawer
+ return (
+ <Drawer {...props} onOpenChange={handleDialogOpenChange}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Send className="mr-2 size-4" aria-hidden="true" />
+ CBE 평가 전송 ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>CBE 평가 생성 및 전송</DrawerTitle>
+ <DrawerDescription>
+ 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다.
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="px-4">
+ {formContent}
+ </div>
+
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ onClick={form.handleSubmit(onSubmit)}
+ disabled={isSubmitting || !isValid}
+ >
+ {isSubmitting && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"}
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/cbe-table/vendor-contact-dialog.tsx b/lib/rfqs/cbe-table/vendor-contact-dialog.tsx
new file mode 100644
index 00000000..180db392
--- /dev/null
+++ b/lib/rfqs/cbe-table/vendor-contact-dialog.tsx
@@ -0,0 +1,71 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { VendorContactsTable } from "../tbe-table/vendor-contact/vendor-contact-table"
+
+interface VendorContactsDialogProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ vendorId: number | null
+ vendor: VendorWithCbeFields | null
+}
+
+export function VendorContactsDialog({
+ isOpen,
+ onOpenChange,
+ vendorId,
+ vendor,
+}: VendorContactsDialogProps) {
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}>
+ <DialogHeader>
+ <div className="flex flex-col space-y-2">
+ <DialogTitle>협력업체 연락처</DialogTitle>
+ {vendor && (
+ <div className="flex flex-col space-y-1 mt-2">
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium text-foreground">{vendor.vendorName}</span>
+ {vendor.vendorCode && (
+ <span className="ml-2 text-xs text-muted-foreground">({vendor.vendorCode})</span>
+ )}
+ </div>
+ <div className="flex items-center">
+ {vendor.vendorStatus && (
+ <Badge variant="outline" className="mr-2">
+ {vendor.vendorStatus}
+ </Badge>
+ )}
+ {vendor.commercialResponseStatus && (
+ <Badge
+ variant={
+ vendor.commercialResponseStatus === "INVITED" ? "default" :
+ vendor.commercialResponseStatus === "DECLINED" ? "destructive" :
+ vendor.commercialResponseStatus === "ACCEPTED" ? "secondary" : "outline"
+ }
+ >
+ {vendor.commercialResponseStatus}
+ </Badge>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </DialogHeader>
+ {vendorId && (
+ <div className="py-4">
+ <VendorContactsTable vendorId={vendorId} />
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/repository.ts b/lib/rfqs/repository.ts
index ad44cf07..24d09ec3 100644
--- a/lib/rfqs/repository.ts
+++ b/lib/rfqs/repository.ts
@@ -1,7 +1,7 @@
// src/lib/tasks/repository.ts
import db from "@/db/db";
import { items } from "@/db/schema/items";
-import { rfqItems, rfqs, RfqWithItems, rfqsView, type Rfq,VendorResponse, vendorResponses } from "@/db/schema/rfq";
+import { rfqItems, rfqs, RfqWithItems, rfqsView, type Rfq,VendorResponse, vendorResponses, RfqViewWithItems } from "@/db/schema/rfq";
import { users } from "@/db/schema/users";
import {
eq,
@@ -177,12 +177,12 @@ export async function insertRfqItem(
return tx.insert(rfqItems).values(data).returning();
}
-export const getRfqById = async (id: number): Promise<RfqWithItems | null> => {
+export const getRfqById = async (id: number): Promise<RfqViewWithItems | null> => {
// 1) RFQ 단건 조회
const rfqsRes = await db
.select()
- .from(rfqs)
- .where(eq(rfqs.id, id))
+ .from(rfqsView)
+ .where(eq(rfqsView.id, id))
.limit(1);
if (rfqsRes.length === 0) return null;
@@ -197,7 +197,7 @@ export const getRfqById = async (id: number): Promise<RfqWithItems | null> => {
// itemsRes: RfqItem[]
// 3) RfqWithItems 형태로 반환
- const result: RfqWithItems = {
+ const result: RfqViewWithItems = {
...rfqRow,
lines: itemsRes,
};
diff --git a/lib/rfqs/service.ts b/lib/rfqs/service.ts
index b56349e2..c7d1c3cd 100644
--- a/lib/rfqs/service.ts
+++ b/lib/rfqs/service.ts
@@ -8,7 +8,7 @@ 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, GetRfqsForVendorsSchema, UpdateRfqVendorSchema, GetTBESchema, RfqType, GetCBESchema } from "./validations";
+import { GetRfqsSchema, CreateRfqSchema, UpdateRfqSchema, CreateRfqItemSchema, GetMatchedVendorsSchema, GetRfqsForVendorsSchema, UpdateRfqVendorSchema, GetTBESchema, RfqType, GetCBESchema, createCbeEvaluationSchema } from "./validations";
import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count } from "drizzle-orm";
import path from "path";
import fs from "fs/promises";
@@ -16,15 +16,16 @@ import { randomUUID } from "crypto";
import { writeFile, mkdir } from 'fs/promises'
import { join } from 'path'
-import { vendorResponses, vendorResponsesView, Rfq, rfqs, rfqAttachments, rfqItems, RfqWithItems, rfqComments, rfqEvaluations, vendorRfqView, vendorTbeView, rfqsView, vendorResponseAttachments, vendorTechnicalResponses, vendorCbeView, cbeEvaluations, vendorCommercialResponses } from "@/db/schema/rfq";
+import { vendorResponses, vendorResponsesView, Rfq, rfqs, rfqAttachments, rfqItems, RfqWithItems, rfqComments, rfqEvaluations, vendorRfqView, vendorTbeView, rfqsView, vendorResponseAttachments, vendorTechnicalResponses, vendorCbeView, cbeEvaluations, vendorCommercialResponses, vendorResponseCBEView, RfqViewWithItems } from "@/db/schema/rfq";
import { countRfqs, deleteRfqById, deleteRfqsByIds, getRfqById, groupByStatus, insertRfq, insertRfqItem, selectRfqs, updateRfq, updateRfqs, updateRfqVendor } from "./repository";
import logger from '@/lib/logger';
-import { vendorPossibleItems, vendors } from "@/db/schema/vendors";
+import { vendorContacts, vendorPossibleItems, vendors } from "@/db/schema/vendors";
import { sendEmail } from "../mail/sendEmail";
-import { projects } from "@/db/schema/projects";
+import { biddingProjects, projects } from "@/db/schema/projects";
import { items } from "@/db/schema/items";
import * as z from "zod"
import { users } from "@/db/schema/users";
+import { headers } from 'next/headers';
interface InviteVendorsInput {
@@ -176,6 +177,7 @@ export async function createRfq(input: CreateRfqSchema) {
const [newTask] = await insertRfq(tx, {
rfqCode: input.rfqCode,
projectId: input.projectId || null,
+ bidProjectId: input.bidProjectId || null,
description: input.description || null,
dueDate: input.dueDate,
status: input.status,
@@ -547,7 +549,7 @@ export async function fetchRfqItems(rfqId: number) {
}))
}
-export const findRfqById = async (id: number): Promise<RfqWithItems | null> => {
+export const findRfqById = async (id: number): Promise<RfqViewWithItems | null> => {
try {
logger.info({ id }, 'Fetching user by ID');
const rfq = await getRfqById(id);
@@ -726,13 +728,16 @@ export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: n
// ─────────────────────────────────────────────────────
// 5) 코멘트 조회: 기존과 동일
// ─────────────────────────────────────────────────────
+ console.log("distinctVendorIds", distinctVendorIds)
const commAll = await db
.select()
.from(rfqComments)
.where(
and(
inArray(rfqComments.vendorId, distinctVendorIds),
- eq(rfqComments.rfqId, rfqId)
+ eq(rfqComments.rfqId, rfqId),
+ isNull(rfqComments.evaluationId),
+ isNull(rfqComments.cbeId)
)
)
@@ -756,7 +761,7 @@ export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: n
userMap.set(user.id, user);
}
- // 댓글 정보를 벤더 ID별로 그룹화하고, 사용자 이메일 추가
+ // 댓글 정보를 협력업체 ID별로 그룹화하고, 사용자 이메일 추가
for (const c of commAll) {
const vid = c.vendorId!
if (!commByVendorId.has(vid)) {
@@ -804,6 +809,9 @@ export async function inviteVendors(input: InviteVendorsInput) {
throw new Error("Invalid input")
}
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
// DB 데이터 준비 및 첨부파일 처리를 위한 트랜잭션
const rfqData = await db.transaction(async (tx) => {
// 2-A) RFQ 기본 정보 조회
@@ -869,8 +877,7 @@ export async function inviteVendors(input: InviteVendorsInput) {
})
const { rfqRow, items, vendorRows, attachments } = rfqData
- const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000'
- const loginUrl = `${baseUrl}/en/partners/rfq`
+ const loginUrl = `http://${host}/en/partners/rfq`
// 이메일 전송 오류를 기록할 배열
const emailErrors = []
@@ -878,11 +885,11 @@ export async function inviteVendors(input: InviteVendorsInput) {
// 각 벤더에 대해 처리
for (const v of vendorRows) {
if (!v.email) {
- continue // 이메일 없는 벤더 무시
+ continue // 이메일 없는 협력업체 무시
}
try {
- // DB 업데이트: 각 벤더 상태 별도 트랜잭션
+ // DB 업데이트: 각 협력업체 상태 별도 트랜잭션
await db.transaction(async (tx) => {
// rfq_vendors upsert
const existing = await tx
@@ -932,10 +939,10 @@ export async function inviteVendors(input: InviteVendorsInput) {
attachments,
})
} catch (err) {
- // 개별 벤더 처리 실패 로깅
+ // 개별 협력업체 처리 실패 로깅
console.error(`Failed to process vendor ${v.id}: ${getErrorMessage(err)}`)
emailErrors.push({ vendorId: v.id, error: getErrorMessage(err) })
- // 계속 진행 (다른 벤더 처리)
+ // 계속 진행 (다른 협력업체 처리)
}
}
@@ -1015,7 +1022,7 @@ export async function getTBE(input: GetTBESchema, rfqId: number) {
// 5) finalWhere
const finalWhere = and(
eq(vendorTbeView.rfqId, rfqId),
- notRejected,
+ // notRejected,
advancedWhere,
globalWhere
)
@@ -1057,6 +1064,12 @@ export async function getTBE(input: GetTBESchema, rfqId: number) {
tbeResult: vendorTbeView.tbeResult,
tbeNote: vendorTbeView.tbeNote,
tbeUpdated: vendorTbeView.tbeUpdated,
+
+ technicalResponseId:vendorTbeView.technicalResponseId,
+ technicalResponseStatus:vendorTbeView.technicalResponseStatus,
+ technicalSummary:vendorTbeView.technicalSummary,
+ technicalNotes:vendorTbeView.technicalNotes,
+ technicalUpdated:vendorTbeView.technicalUpdated,
})
.from(vendorTbeView)
.where(finalWhere)
@@ -1286,8 +1299,7 @@ export async function getTBEforVendor(input: GetTBESchema, vendorId: number) {
const finalWhere = and(
isNotNull(vendorTbeView.tbeId),
eq(vendorTbeView.vendorId, vendorId),
-
- notRejected,
+ // notRejected,
advancedWhere,
globalWhere
)
@@ -1318,6 +1330,12 @@ export async function getTBEforVendor(input: GetTBESchema, vendorId: number) {
rfqId: vendorTbeView.rfqId,
rfqCode: vendorTbeView.rfqCode,
+ rfqType:vendorTbeView.rfqType,
+ rfqStatus:vendorTbeView.rfqStatus,
+ rfqDescription: vendorTbeView.description,
+ rfqDueDate: vendorTbeView.dueDate,
+
+
projectCode: vendorTbeView.projectCode,
projectName: vendorTbeView.projectName,
description: vendorTbeView.description,
@@ -1491,7 +1509,6 @@ export async function inviteTbeVendorsAction(formData: FormData) {
const vendorIdsRaw = formData.getAll("vendorIds[]")
const vendorIds = vendorIdsRaw.map((id) => Number(id))
-
// 2) FormData에서 파일들 추출 (multiple)
const tbeFiles = formData.getAll("tbeFiles") as File[]
if (!rfqId || !vendorIds.length || !tbeFiles.length) {
@@ -1500,7 +1517,13 @@ export async function inviteTbeVendorsAction(formData: FormData) {
// /public/rfq/[rfqId] 경로
const uploadDir = path.join(process.cwd(), "public", "rfq", String(rfqId))
-
+
+ // 디렉토리가 없다면 생성
+ try {
+ await fs.mkdir(uploadDir, { recursive: true })
+ } catch (err) {
+ console.error("디렉토리 생성 실패:", err)
+ }
// DB 트랜잭션
await db.transaction(async (tx) => {
@@ -1532,94 +1555,150 @@ export async function inviteTbeVendorsAction(formData: FormData) {
.from(rfqItems)
.where(eq(rfqItems.rfqId, rfqId))
- // (C) 대상 벤더들
+ // (C) 대상 벤더들 (이메일 정보 확장)
const vendorRows = await tx
- .select({ id: vendors.id, email: vendors.email })
+ .select({
+ id: vendors.id,
+ name: vendors.vendorName,
+ email: vendors.email,
+ representativeEmail: vendors.representativeEmail // 대표자 이메일 추가
+ })
.from(vendors)
.where(sql`${vendors.id} in (${vendorIds})`)
- // (D) 모든 TBE 파일 저장 & 이후 벤더 초대 처리
+ // (D) 모든 TBE 파일 저장 & 이후 협력업체 초대 처리
// 파일은 한 번만 저장해도 되지만, 각 벤더별로 따로 저장/첨부가 필요하다면 루프를 돌려도 됨.
- // 여기서는 "모든 파일"을 RFQ-DIR에 저장 + "각 벤더"에는 동일 파일 목록을 첨부한다는 예시.
+ // 여기서는 "모든 파일"을 RFQ-DIR에 저장 + "각 협력업체"에는 동일 파일 목록을 첨부한다는 예시.
const savedFiles = []
for (const file of tbeFiles) {
const originalName = file.name || "tbe-sheet.xlsx"
- const savePath = path.join(uploadDir, originalName)
+ // 파일명 충돌 방지를 위한 타임스탬프 추가
+ const timestamp = new Date().getTime()
+ const fileName = `${timestamp}-${originalName}`
+ const savePath = path.join(uploadDir, fileName)
// 파일 ArrayBuffer → Buffer 변환 후 저장
const arrayBuffer = await file.arrayBuffer()
- fs.writeFile(savePath, Buffer.from(arrayBuffer))
+ await fs.writeFile(savePath, Buffer.from(arrayBuffer))
// 저장 경로 & 파일명 기록
savedFiles.push({
- fileName: originalName,
- filePath: `/rfq/${rfqId}/${originalName}`, // public 이하 경로
+ fileName: originalName, // 원본 파일명으로 첨부
+ filePath: `/rfq/${rfqId}/${fileName}`, // public 이하 경로
absolutePath: savePath,
})
}
// (E) 각 벤더별로 TBE 평가 레코드, 초대 처리, 메일 발송
- for (const v of vendorRows) {
- if (!v.email) {
- // 이메일 없는 경우 로직 (스킵 or throw)
+ for (const vendor of vendorRows) {
+ // 1) 협력업체 연락처 조회 - 추가 이메일 수집
+ const contacts = await tx
+ .select({
+ contactName: vendorContacts.contactName,
+ contactEmail: vendorContacts.contactEmail,
+ isPrimary: vendorContacts.isPrimary,
+ })
+ .from(vendorContacts)
+ .where(eq(vendorContacts.vendorId, vendor.id))
+
+ // 2) 모든 이메일 주소 수집 및 중복 제거
+ const allEmails = new Set<string>()
+
+ // 협력업체 이메일 추가 (있는 경우에만)
+ if (vendor.email) {
+ allEmails.add(vendor.email.trim().toLowerCase())
+ }
+
+ // 협력업체 대표자 이메일 추가 (있는 경우에만)
+ if (vendor.representativeEmail) {
+ allEmails.add(vendor.representativeEmail.trim().toLowerCase())
+ }
+
+ // 연락처 이메일 추가
+ contacts.forEach(contact => {
+ if (contact.contactEmail) {
+ allEmails.add(contact.contactEmail.trim().toLowerCase())
+ }
+ })
+
+ // 중복이 제거된 이메일 주소 배열로 변환
+ const uniqueEmails = Array.from(allEmails)
+
+ if (uniqueEmails.length === 0) {
+ console.warn(`협력업체 ID ${vendor.id}에 등록된 이메일 주소가 없습니다. TBE 초대를 건너뜁니다.`)
continue
}
- // 1) TBE 평가 레코드 생성
+ // 3) TBE 평가 레코드 생성
const [evalRow] = await tx
.insert(rfqEvaluations)
.values({
rfqId,
- vendorId: v.id,
+ vendorId: vendor.id,
evalType: "TBE",
})
.returning({ id: rfqEvaluations.id })
- // 2) rfqAttachments에 저장한 파일들을 기록
+ // 4) rfqAttachments에 저장한 파일들을 기록
for (const sf of savedFiles) {
await tx.insert(rfqAttachments).values({
rfqId,
- // vendorId: v.id,
+ vendorId: vendor.id,
evaluationId: evalRow.id,
fileName: sf.fileName,
filePath: sf.filePath,
})
}
- // 4) 메일 발송
+ // 5) 각 고유 이메일 주소로 초대 메일 발송
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000'
const loginUrl = `${baseUrl}/ko/partners/rfq`
- await sendEmail({
- to: v.email,
- subject: `[RFQ ${rfqRow.rfqCode}] You are invited for TBE!`,
- template: "rfq-invite",
- context: {
- language: "en",
- rfqId,
- vendorId: v.id,
-
- rfqCode: rfqRow.rfqCode,
- projectCode: rfqRow.projectCode,
- projectName: rfqRow.projectName,
- dueDate: rfqRow.dueDate,
- description: rfqRow.description,
-
- items: items.map((it) => ({
- itemCode: it.itemCode,
- description: it.description,
- quantity: it.quantity,
- uom: it.uom,
- })),
- loginUrl,
- },
- attachments: savedFiles.map((sf) => ({
- path: sf.absolutePath,
- filename: sf.fileName,
- })),
- })
+
+ console.log(`협력업체 ID ${vendor.id}(${vendor.name})에 대해 ${uniqueEmails.length}개의 고유 이메일로 TBE 초대 발송`)
+
+ for (const email of uniqueEmails) {
+ try {
+ // 연락처 이름 찾기 (이메일과 일치하는 연락처가 있으면 사용, 없으면 '벤더명 담당자'로 대체)
+ const contact = contacts.find(c =>
+ c.contactEmail && c.contactEmail.toLowerCase() === email.toLowerCase()
+ )
+ const contactName = contact?.contactName || `${vendor.name} 담당자`
+
+ await sendEmail({
+ to: email,
+ subject: `[RFQ ${rfqRow.rfqCode}] You are invited for TBE!`,
+ template: "rfq-invite",
+ context: {
+ language: "en",
+ rfqId,
+ vendorId: vendor.id,
+ contactName, // 연락처 이름 추가
+ rfqCode: rfqRow.rfqCode,
+ projectCode: rfqRow.projectCode,
+ projectName: rfqRow.projectName,
+ dueDate: rfqRow.dueDate,
+ description: rfqRow.description,
+ items: items.map((it) => ({
+ itemCode: it.itemCode,
+ description: it.description,
+ quantity: it.quantity,
+ uom: it.uom,
+ })),
+ loginUrl,
+ },
+ attachments: savedFiles.map((sf) => ({
+ path: sf.absolutePath,
+ filename: sf.fileName,
+ })),
+ })
+ console.log(`이메일 전송 성공: ${email} (${contactName})`)
+ } catch (emailErr) {
+ console.error(`이메일 전송 실패 (${email}):`, emailErr)
+ }
+ }
}
- // 5) 캐시 무효화
+ // 6) 캐시 무효화
revalidateTag("tbe-vendors")
})
@@ -1662,8 +1741,8 @@ export async function createRfqCommentWithAttachments(params: {
files?: File[]
}) {
const { rfqId, vendorId, commentText, commentedBy, evaluationId,cbeId, files } = params
-
-
+ console.log("cbeId", cbeId)
+ console.log("evaluationId", evaluationId)
// 1) 새로운 코멘트 생성
const [insertedComment] = await db
.insert(rfqComments)
@@ -1797,6 +1876,37 @@ export async function getProjects(): Promise<Project[]> {
}
+export async function getBidProjects(): Promise<Project[]> {
+ try {
+ // 트랜잭션을 사용하여 프로젝트 데이터 조회
+ const projectList = await db.transaction(async (tx) => {
+ // 모든 프로젝트 조회
+ const results = await tx
+ .select({
+ id: biddingProjects.id,
+ projectCode: biddingProjects.pspid,
+ projectName: biddingProjects.projNm,
+ })
+ .from(biddingProjects)
+ .orderBy(biddingProjects.id);
+
+ return results;
+ });
+
+ // Handle null projectName values
+ const validProjectList = projectList.map(project => ({
+ ...project,
+ projectName: project.projectName || '' // Replace null with empty string
+ }));
+
+ return validProjectList;
+ } catch (error) {
+ console.error("프로젝트 목록 가져오기 실패:", error);
+ return []; // 오류 발생 시 빈 배열 반환
+ }
+}
+
+
// 반환 타입 명시적 정의 - rfqCode가 null일 수 있음을 반영
export interface BudgetaryRfq {
id: number;
@@ -1919,6 +2029,19 @@ export async function getAllVendors() {
return allVendors
}
+
+export async function getVendorContactsByVendorId(vendorId: number) {
+ try {
+ const contacts = await db.query.vendorContacts.findMany({
+ where: eq(vendorContacts.vendorId, vendorId),
+ });
+
+ return { success: true, data: contacts };
+ } catch (error) {
+ console.error("Error fetching vendor contacts:", error);
+ return { success: false, error: "Failed to fetch vendor contacts" };
+ }
+}
/**
* Server action to associate items from an RFQ with a vendor
*
@@ -2020,8 +2143,6 @@ export async function addItemToVendors(rfqId: number, vendorIds: number[]) {
* evaluationId가 일치하고 vendorId가 null인 파일 목록
*/
export async function fetchTbeTemplateFiles(evaluationId: number) {
-
- console.log(evaluationId, "evaluationId")
try {
const files = await db
.select({
@@ -2051,10 +2172,7 @@ export async function fetchTbeTemplateFiles(evaluationId: number) {
}
}
-/**
- * 특정 TBE 템플릿 파일 다운로드를 위한 정보 조회
- */
-export async function getTbeTemplateFileInfo(fileId: number) {
+export async function getFileFromRfqAttachmentsbyid(fileId: number) {
try {
const file = await db
.select({
@@ -2128,6 +2246,7 @@ export async function uploadTbeResponseFile(formData: FormData) {
responseId: vendorResponseId,
summary: "TBE 응답 파일 업로드", // 필요에 따라 수정
notes: `파일명: ${originalName}`,
+ responseStatus:"SUBMITTED"
})
.returning({ id: vendorTechnicalResponses.id });
@@ -2354,7 +2473,9 @@ export async function getAllTBE(input: GetTBESchema) {
rfqVendorStatus: vendorTbeView.rfqVendorStatus,
rfqVendorUpdated: vendorTbeView.rfqVendorUpdated,
+ technicalResponseStatus:vendorTbeView.technicalResponseStatus,
tbeResult: vendorTbeView.tbeResult,
+
tbeNote: vendorTbeView.tbeNote,
tbeUpdated: vendorTbeView.tbeUpdated,
})
@@ -2562,9 +2683,6 @@ export async function getAllTBE(input: GetTBESchema) {
}
-
-
-
export async function getCBE(input: GetCBESchema, rfqId: number) {
return unstable_cache(
async () => {
@@ -2574,7 +2692,7 @@ export async function getCBE(input: GetCBESchema, rfqId: number) {
// [2] 고급 필터
const advancedWhere = filterColumns({
- table: vendorCbeView,
+ table: vendorResponseCBEView,
filters: input.filters ?? [],
joinOperator: input.joinOperator ?? "and",
});
@@ -2584,73 +2702,83 @@ export async function getCBE(input: GetCBESchema, rfqId: number) {
if (input.search) {
const s = `%${input.search}%`;
globalWhere = or(
- sql`${vendorCbeView.vendorName} ILIKE ${s}`,
- sql`${vendorCbeView.vendorCode} ILIKE ${s}`,
- sql`${vendorCbeView.email} ILIKE ${s}`
+ sql`${vendorResponseCBEView.vendorName} ILIKE ${s}`,
+ sql`${vendorResponseCBEView.vendorCode} ILIKE ${s}`,
+ sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`,
+ sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}`
);
}
- // [4] REJECTED 아니거나 NULL
- const notRejected = or(
- ne(vendorCbeView.rfqVendorStatus, "REJECTED"),
- isNull(vendorCbeView.rfqVendorStatus)
- );
+ // [4] DECLINED 상태 제외 (거절된 업체는 표시하지 않음)
+ const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED");
- // [5] 최종 where
+ // [5] 최종 where 조건
const finalWhere = and(
- eq(vendorCbeView.rfqId, rfqId),
- notRejected,
- advancedWhere,
- globalWhere
+ eq(vendorResponseCBEView.rfqId, rfqId),
+ notDeclined,
+ advancedWhere ?? undefined,
+ globalWhere ?? undefined
);
// [6] 정렬
const orderBy = input.sort?.length
? input.sort.map((s) => {
- // vendor_cbe_view 컬럼 중 정렬 대상이 되는 것만 매핑
- const col = (vendorCbeView as any)[s.id];
- return s.desc ? desc(col) : asc(col);
- })
- : [asc(vendorCbeView.vendorId)];
+ // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑
+ const col = (vendorResponseCBEView as any)[s.id];
+ return s.desc ? desc(col) : asc(col);
+ })
+ : [asc(vendorResponseCBEView.vendorName)]; // 기본 정렬은 벤더명
// [7] 메인 SELECT
const [rows, total] = await db.transaction(async (tx) => {
const data = await tx
.select({
- // 필요한 컬럼만 추출
- id: vendorCbeView.vendorId,
- cbeId: vendorCbeView.cbeId,
- vendorId: vendorCbeView.vendorId,
- vendorName: vendorCbeView.vendorName,
- vendorCode: vendorCbeView.vendorCode,
- address: vendorCbeView.address,
- country: vendorCbeView.country,
- email: vendorCbeView.email,
- website: vendorCbeView.website,
- vendorStatus: vendorCbeView.vendorStatus,
-
- rfqId: vendorCbeView.rfqId,
- rfqCode: vendorCbeView.rfqCode,
- projectCode: vendorCbeView.projectCode,
- projectName: vendorCbeView.projectName,
- description: vendorCbeView.description,
- dueDate: vendorCbeView.dueDate,
-
- rfqVendorStatus: vendorCbeView.rfqVendorStatus,
- rfqVendorUpdated: vendorCbeView.rfqVendorUpdated,
-
- cbeResult: vendorCbeView.cbeResult,
- cbeNote: vendorCbeView.cbeNote,
- cbeUpdated: vendorCbeView.cbeUpdated,
-
- // 상업평가 정보
- totalCost: vendorCbeView.totalCost,
- currency: vendorCbeView.currency,
- paymentTerms: vendorCbeView.paymentTerms,
- incoterms: vendorCbeView.incoterms,
- deliverySchedule: vendorCbeView.deliverySchedule,
+ // 기본 식별 정보
+ responseId: vendorResponseCBEView.responseId,
+ vendorId: vendorResponseCBEView.vendorId,
+ rfqId: vendorResponseCBEView.rfqId,
+
+ // 협력업체 정보
+ vendorName: vendorResponseCBEView.vendorName,
+ vendorCode: vendorResponseCBEView.vendorCode,
+ vendorStatus: vendorResponseCBEView.vendorStatus,
+
+ // RFQ 정보
+ rfqCode: vendorResponseCBEView.rfqCode,
+ rfqDescription: vendorResponseCBEView.rfqDescription,
+ rfqDueDate: vendorResponseCBEView.rfqDueDate,
+ rfqStatus: vendorResponseCBEView.rfqStatus,
+ rfqType: vendorResponseCBEView.rfqType,
+
+ // 프로젝트 정보
+ projectId: vendorResponseCBEView.projectId,
+ projectCode: vendorResponseCBEView.projectCode,
+ projectName: vendorResponseCBEView.projectName,
+
+ // 응답 상태 정보
+ responseStatus: vendorResponseCBEView.responseStatus,
+ responseNotes: vendorResponseCBEView.notes,
+ respondedAt: vendorResponseCBEView.respondedAt,
+ respondedBy: vendorResponseCBEView.respondedBy,
+
+ // 상업 응답 정보
+ commercialResponseId: vendorResponseCBEView.commercialResponseId,
+ commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus,
+ totalPrice: vendorResponseCBEView.totalPrice,
+ currency: vendorResponseCBEView.currency,
+ paymentTerms: vendorResponseCBEView.paymentTerms,
+ incoterms: vendorResponseCBEView.incoterms,
+ deliveryPeriod: vendorResponseCBEView.deliveryPeriod,
+ warrantyPeriod: vendorResponseCBEView.warrantyPeriod,
+ validityPeriod: vendorResponseCBEView.validityPeriod,
+ commercialNotes: vendorResponseCBEView.commercialNotes,
+
+ // 첨부파일 카운트
+ attachmentCount: vendorResponseCBEView.attachmentCount,
+ commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount,
+ technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount,
})
- .from(vendorCbeView)
+ .from(vendorResponseCBEView)
.where(finalWhere)
.orderBy(...orderBy)
.offset(offset)
@@ -2658,122 +2786,89 @@ export async function getCBE(input: GetCBESchema, rfqId: number) {
const [{ count }] = await tx
.select({ count: sql<number>`count(*)`.as("count") })
- .from(vendorCbeView)
+ .from(vendorResponseCBEView)
.where(finalWhere);
return [data, Number(count)];
});
if (!rows.length) {
- return { data: [], pageCount: 0 };
+ return { data: [], pageCount: 0, total: 0 };
}
- // [8] Comments 조회
- // TBE 에서는 rfqComments + rfqEvaluations(evalType="TBE") 를 조인했지만,
- // CBE는 cbeEvaluations 또는 evalType="CBE"를 기준으로 바꾸면 됩니다.
- // 만약 cbeEvaluations.id 를 evaluationId 로 참조한다면 아래와 같이 innerJoin:
+ // [8] 협력업체 ID 목록 추출
const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))];
+ const distinctResponseIds = [...new Set(rows.map((r) => r.responseId))];
+ const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))];
- const commAll = await db
+ // [9] CBE 평가 관련 코멘트 조회
+ const commentsAll = await db
.select({
id: rfqComments.id,
commentText: rfqComments.commentText,
vendorId: rfqComments.vendorId,
- evaluationId: rfqComments.evaluationId,
+ cbeId: rfqComments.cbeId,
createdAt: rfqComments.createdAt,
commentedBy: rfqComments.commentedBy,
- // cbeEvaluations에는 evalType 컬럼이 별도로 없을 수도 있음(프로젝트 구조에 맞게 수정)
- // evalType: cbeEvaluations.evalType,
})
.from(rfqComments)
.innerJoin(
- cbeEvaluations,
- eq(cbeEvaluations.id, rfqComments.evaluationId)
+ vendorResponses,
+ eq(vendorResponses.id, rfqComments.cbeId)
)
.where(
and(
- isNotNull(rfqComments.evaluationId),
+ isNotNull(rfqComments.cbeId),
eq(rfqComments.rfqId, rfqId),
inArray(rfqComments.vendorId, distinctVendorIds)
)
);
- // 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, []);
+ // vendorId별 코멘트 그룹화
+ const commentsByVendorId = new Map<number, any[]>();
+ for (const comment of commentsAll) {
+ const vendorId = comment.vendorId!;
+ if (!commentsByVendorId.has(vendorId)) {
+ commentsByVendorId.set(vendorId, []);
}
- commByVendorId.get(vid)!.push({
- id: c.id,
- commentText: c.commentText,
- vendorId: c.vendorId,
- evaluationId: c.evaluationId,
- createdAt: c.createdAt,
- commentedBy: c.commentedBy,
+ commentsByVendorId.get(vendorId)!.push({
+ id: comment.id,
+ commentText: comment.commentText,
+ vendorId: comment.vendorId,
+ cbeId: comment.cbeId,
+ createdAt: comment.createdAt,
+ commentedBy: comment.commentedBy,
});
}
- // [9] CBE 파일 조회 (프로젝트에 따라 구조가 달라질 수 있음)
- // - TBE는 vendorTechnicalResponses 기준
- // - CBE는 vendorCommercialResponses(가정) 등이 있을 수 있음
- // - 여기서는 예시로 "동일한 vendorResponses + vendorResponseAttachments" 라고 가정
- // Step 1: vendorResponses 가져오기 (rfqId + vendorIds)
- const responsesAll = await db
+ // [10] 첨부 파일 조회 - 일반 응답 첨부파일
+ const responseAttachments = await db
.select({
- id: vendorResponses.id,
- vendorId: vendorResponses.vendorId,
+ id: vendorResponseAttachments.id,
+ fileName: vendorResponseAttachments.fileName,
+ filePath: vendorResponseAttachments.filePath,
+ responseId: vendorResponseAttachments.responseId,
+ fileType: vendorResponseAttachments.fileType,
+ attachmentType: vendorResponseAttachments.attachmentType,
+ description: vendorResponseAttachments.description,
+ uploadedAt: vendorResponseAttachments.uploadedAt,
+ uploadedBy: vendorResponseAttachments.uploadedBy,
})
- .from(vendorResponses)
+ .from(vendorResponseAttachments)
.where(
and(
- eq(vendorResponses.rfqId, rfqId),
- inArray(vendorResponses.vendorId, distinctVendorIds)
+ inArray(vendorResponseAttachments.responseId, distinctResponseIds),
+ isNotNull(vendorResponseAttachments.responseId)
)
);
- // Group responses by vendorId
- 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: responseIds
- const allResponseIds = responsesAll.map((r) => r.id);
-
-
- const commercialResponsesAll = await db
- .select({
- id: vendorCommercialResponses.id,
- responseId: vendorCommercialResponses.responseId,
- })
- .from(vendorCommercialResponses)
- .where(inArray(vendorCommercialResponses.responseId, allResponseIds));
-
- const commercialResponseIdsByResponseId = new Map<number, number[]>();
- for (const cr of commercialResponsesAll) {
- if (!commercialResponseIdsByResponseId.has(cr.responseId)) {
- commercialResponseIdsByResponseId.set(cr.responseId, []);
- }
- commercialResponseIdsByResponseId.get(cr.responseId)!.push(cr.id);
- }
-
- const allCommercialResponseIds = commercialResponsesAll.map((cr) => cr.id);
-
-
- // 여기서는 예시로 TBE와 마찬가지로 vendorResponseAttachments를
- // 직접 responseId로 관리한다고 가정(혹은 commercialResponseId로 연결)
- // Step 3: vendorResponseAttachments 조회
- const filesAll = await db
+ // [11] 첨부 파일 조회 - 상업 응답 첨부파일
+ const commercialResponseAttachments = await db
.select({
id: vendorResponseAttachments.id,
fileName: vendorResponseAttachments.fileName,
filePath: vendorResponseAttachments.filePath,
- responseId: vendorResponseAttachments.responseId,
+ commercialResponseId: vendorResponseAttachments.commercialResponseId,
fileType: vendorResponseAttachments.fileType,
attachmentType: vendorResponseAttachments.attachmentType,
description: vendorResponseAttachments.description,
@@ -2783,19 +2878,20 @@ export async function getCBE(input: GetCBESchema, rfqId: number) {
.from(vendorResponseAttachments)
.where(
and(
- inArray(vendorResponseAttachments.responseId, allCommercialResponseIds),
- isNotNull(vendorResponseAttachments.responseId)
+ inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds),
+ isNotNull(vendorResponseAttachments.commercialResponseId)
)
);
- // Step 4: responseId -> files
+ // [12] 첨부파일 그룹화
+ // responseId별 첨부파일 맵 생성
const filesByResponseId = new Map<number, any[]>();
- for (const file of filesAll) {
- const rid = file.responseId!;
- if (!filesByResponseId.has(rid)) {
- filesByResponseId.set(rid, []);
+ for (const file of responseAttachments) {
+ const responseId = file.responseId!;
+ if (!filesByResponseId.has(responseId)) {
+ filesByResponseId.set(responseId, []);
}
- filesByResponseId.get(rid)!.push({
+ filesByResponseId.get(responseId)!.push({
id: file.id,
fileName: file.fileName,
filePath: file.filePath,
@@ -2804,40 +2900,66 @@ export async function getCBE(input: GetCBESchema, rfqId: number) {
description: file.description,
uploadedAt: file.uploadedAt,
uploadedBy: file.uploadedBy,
+ attachmentSource: 'response'
});
}
- // Step 5: vendorId -> files
- const filesByVendorId = new Map<number, any[]>();
- for (const [vendorId, responseIds] of responsesByVendorId.entries()) {
- filesByVendorId.set(vendorId, []);
- for (const responseId of responseIds) {
- const files = filesByResponseId.get(responseId) || [];
- filesByVendorId.get(vendorId)!.push(...files);
+ // commercialResponseId별 첨부파일 맵 생성
+ const filesByCommercialResponseId = new Map<number, any[]>();
+ for (const file of commercialResponseAttachments) {
+ const commercialResponseId = file.commercialResponseId!;
+ if (!filesByCommercialResponseId.has(commercialResponseId)) {
+ filesByCommercialResponseId.set(commercialResponseId, []);
}
+ filesByCommercialResponseId.get(commercialResponseId)!.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,
+ attachmentSource: 'commercial'
+ });
}
- // [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) ?? [],
- }));
+ // [13] 최종 데이터 병합
+ const final = rows.map((row) => {
+ // 해당 응답의 모든 첨부파일 가져오기
+ const responseFiles = filesByResponseId.get(row.responseId) || [];
+ const commercialFiles = row.commercialResponseId
+ ? filesByCommercialResponseId.get(row.commercialResponseId) || []
+ : [];
+
+ // 모든 첨부파일 병합
+ const allFiles = [...responseFiles, ...commercialFiles];
+
+ return {
+ ...row,
+ rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null,
+ respondedAt: row.respondedAt ? new Date(row.respondedAt) : null,
+ comments: commentsByVendorId.get(row.vendorId) || [],
+ files: allFiles,
+ };
+ });
const pageCount = Math.ceil(total / limit);
- return { data: final, pageCount };
+ return {
+ data: final,
+ pageCount,
+ total
+ };
},
// 캐싱 키 & 옵션
- [JSON.stringify({ input, rfqId })],
+ [`cbe-vendors-${rfqId}-${JSON.stringify(input)}`],
{
revalidate: 3600,
- tags: ["cbe-vendors"],
+ tags: [`cbe-vendors-${rfqId}`],
}
)();
}
-
export async function generateNextRfqCode(rfqType: RfqType): Promise<{ code: string; error?: string }> {
try {
if (!rfqType) {
@@ -2880,4 +3002,1026 @@ export async function generateNextRfqCode(rfqType: RfqType): Promise<{ code: str
console.error('Error generating next RFQ code:', error);
return { code: "", error: '코드 생성에 실패했습니다' };
}
+}
+
+interface SaveTbeResultParams {
+ id: number // id from the rfq_evaluations table
+ vendorId: number // vendorId from the rfq_evaluations table
+ result: string // The selected evaluation result
+ notes: string // The evaluation notes
+}
+
+export async function saveTbeResult({
+ id,
+ vendorId,
+ result,
+ notes,
+}: SaveTbeResultParams) {
+ try {
+ // Check if we have all required data
+ if (!id || !vendorId || !result) {
+ return {
+ success: false,
+ message: "Missing required data for evaluation update",
+ }
+ }
+
+ // Update the record in the database
+ await db
+ .update(rfqEvaluations)
+ .set({
+ result: result,
+ notes: notes,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(rfqEvaluations.id, id),
+ eq(rfqEvaluations.vendorId, vendorId),
+ eq(rfqEvaluations.evalType, "TBE")
+ )
+ )
+
+ // Revalidate the tbe-vendors tag to refresh the data
+ revalidateTag("tbe-vendors")
+ revalidateTag("all-tbe-vendors")
+
+ return {
+ success: true,
+ message: "TBE evaluation updated successfully",
+ }
+ } catch (error) {
+ console.error("Failed to update TBE evaluation:", error)
+
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : "An unknown error occurred",
+ }
+ }
+}
+
+
+export async function createCbeEvaluation(formData: FormData) {
+ try {
+ // 폼 데이터 추출
+ const rfqId = Number(formData.get("rfqId"))
+ const vendorIds = formData.getAll("vendorIds[]").map(id => Number(id))
+ const evaluatedBy = formData.get("evaluatedBy") ? Number(formData.get("evaluatedBy")) : null
+
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
+ // 기본 CBE 데이터 추출
+ const rawData = {
+ rfqId,
+ paymentTerms: formData.get("paymentTerms") as string,
+ incoterms: formData.get("incoterms") as string,
+ deliverySchedule: formData.get("deliverySchedule") as string,
+ notes: formData.get("notes") as string,
+ // 단일 협력업체 처리 시 사용할 vendorId (여러 협력업체 처리에선 사용하지 않음)
+ // vendorId: vendorIds[0] || 0,
+ }
+
+ // zod 스키마 유효성 검사 (vendorId는 더미로 채워 검증하고 실제로는 배열로 처리)
+ const validationResult = createCbeEvaluationSchema.safeParse(rawData)
+ if (!validationResult.success) {
+ const errors = validationResult.error.format()
+ console.error("Validation errors:", errors)
+ return { error: "입력 데이터가 유효하지 않습니다." }
+ }
+
+ const validData = validationResult.data
+
+ // RFQ 정보 조회
+ const [rfqInfo] = await db
+ .select({
+ rfqCode: rfqsView.rfqCode,
+ projectCode: rfqsView.projectCode,
+ projectName: rfqsView.projectName,
+ dueDate: rfqsView.dueDate,
+ description: rfqsView.description,
+ })
+ .from(rfqsView)
+ .where(eq(rfqsView.id, rfqId))
+
+ if (!rfqInfo) {
+ return { error: "RFQ 정보를 찾을 수 없습니다." }
+ }
+
+ // 파일 처리 준비
+ const files = formData.getAll("files") as File[]
+ const hasFiles = files && files.length > 0 && files[0].size > 0
+
+ // 파일 저장을 위한 디렉토리 생성 (파일이 있는 경우에만)
+ let uploadDir = ""
+ if (hasFiles) {
+ uploadDir = path.join(process.cwd(), "public", "rfq", String(rfqId))
+ try {
+ await fs.mkdir(uploadDir, { recursive: true })
+ } catch (err) {
+ console.error("디렉토리 생성 실패:", err)
+ return { error: "파일 업로드를 위한 디렉토리 생성에 실패했습니다." }
+ }
+ }
+
+ // 첨부 파일 정보를 저장할 배열
+ const attachments: { filename: string; path: string }[] = []
+
+ // 파일이 있는 경우, 파일을 저장하고 첨부 파일 정보 준비
+ if (hasFiles) {
+ for (const file of files) {
+ if (file.size > 0) {
+ const originalFilename = file.name
+ const fileExtension = path.extname(originalFilename)
+ const timestamp = new Date().getTime()
+ const safeFilename = `cbe-${rfqId}-${timestamp}${fileExtension}`
+ const filePath = path.join("rfq", String(rfqId), safeFilename)
+ const fullPath = path.join(process.cwd(), "public", filePath)
+
+ try {
+ // File을 ArrayBuffer로 변환하여 파일 시스템에 저장
+ const arrayBuffer = await file.arrayBuffer()
+ const buffer = Buffer.from(arrayBuffer)
+ await fs.writeFile(fullPath, buffer)
+
+ // 첨부 파일 정보 추가
+ attachments.push({
+ filename: originalFilename,
+ path: fullPath, // 이메일 첨부를 위한 전체 경로
+ })
+ } catch (err) {
+ console.error(`파일 저장 실패:`, err)
+ // 파일 저장 실패를 기록하지만 전체 프로세스는 계속 진행
+ }
+ }
+ }
+ }
+
+ // 각 벤더별로 CBE 평가 레코드 생성 및 알림 전송
+ const createdCbeIds: number[] = []
+ const failedVendors: { id: number, reason: string }[] = []
+
+ for (const vendorId of vendorIds) {
+ try {
+ // 협력업체 정보 조회 (이메일 포함)
+ const [vendorInfo] = await db
+ .select({
+ id: vendors.id,
+ name: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ email: vendors.email, // 협력업체 자체 이메일 추가
+ representativeEmail: vendors.representativeEmail, // 협력업체 대표자 이메일 추가
+ })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+
+ if (!vendorInfo) {
+ failedVendors.push({ id: vendorId, reason: "협력업체 정보를 찾을 수 없습니다." })
+ continue
+ }
+
+ // 기존 협력업체 응답 레코드 찾기
+ const existingResponse = await db
+ .select({ id: vendorResponses.id })
+ .from(vendorResponses)
+ .where(
+ and(
+ eq(vendorResponses.rfqId, rfqId),
+ eq(vendorResponses.vendorId, vendorId)
+ )
+ )
+ .limit(1)
+
+ if (existingResponse.length === 0) {
+ console.error(`협력업체 ID ${vendorId}에 대한 응답 레코드가 존재하지 않습니다.`)
+ failedVendors.push({ id: vendorId, reason: "협력업체 응답 레코드를 찾을 수 없습니다" })
+ continue // 다음 벤더로 넘어감
+ }
+
+ // 1. CBE 평가 레코드 생성
+ const [newCbeEvaluation] = await db
+ .insert(cbeEvaluations)
+ .values({
+ rfqId,
+ vendorId,
+ evaluatedBy,
+ result: "PENDING", // 초기 상태는 PENDING으로 설정
+ totalCost: 0, // 초기값은 0으로 설정
+ currency: "USD", // 기본 통화 설정
+ paymentTerms: validData.paymentTerms || null,
+ incoterms: validData.incoterms || null,
+ deliverySchedule: validData.deliverySchedule || null,
+ notes: validData.notes || null,
+ })
+ .returning({ id: cbeEvaluations.id })
+
+ if (!newCbeEvaluation?.id) {
+ failedVendors.push({ id: vendorId, reason: "CBE 평가 생성 실패" })
+ continue
+ }
+
+ // 2. 상업 응답 레코드 생성
+ const [newCbeResponse] = await db
+ .insert(vendorCommercialResponses)
+ .values({
+ responseId: existingResponse[0].id,
+ responseStatus: "PENDING",
+ currency: "USD",
+ paymentTerms: validData.paymentTerms || null,
+ incoterms: validData.incoterms || null,
+ deliveryPeriod: validData.deliverySchedule || null,
+ })
+ .returning({ id: vendorCommercialResponses.id })
+
+ if (!newCbeResponse?.id) {
+ failedVendors.push({ id: vendorId, reason: "상업 응답 생성 실패" })
+ continue
+ }
+
+ createdCbeIds.push(newCbeEvaluation.id)
+
+ // 3. 첨부 파일이 있는 경우, 데이터베이스에 첨부 파일 레코드 생성
+ if (hasFiles) {
+ for (let i = 0; i < attachments.length; i++) {
+ const attachment = attachments[i]
+
+ await db.insert(rfqAttachments).values({
+ rfqId,
+ vendorId,
+ fileName: attachment.filename,
+ filePath: `/${path.relative(path.join(process.cwd(), "public"), attachment.path)}`, // URL 경로를 위해 public 기준 상대 경로로 저장
+ cbeId: newCbeEvaluation.id,
+ })
+ }
+ }
+
+ // 4. 협력업체 연락처 조회
+ const contacts = await db
+ .select({
+ contactName: vendorContacts.contactName,
+ contactEmail: vendorContacts.contactEmail,
+ isPrimary: vendorContacts.isPrimary,
+ })
+ .from(vendorContacts)
+ .where(eq(vendorContacts.vendorId, vendorId))
+
+ // 5. 모든 이메일 주소 수집 및 중복 제거
+ const allEmails = new Set<string>()
+
+ // 연락처 이메일 추가
+ contacts.forEach(contact => {
+ if (contact.contactEmail) {
+ allEmails.add(contact.contactEmail.trim().toLowerCase())
+ }
+ })
+
+ // 협력업체 자체 이메일 추가 (있는 경우에만)
+ if (vendorInfo.email) {
+ allEmails.add(vendorInfo.email.trim().toLowerCase())
+ }
+
+ // 협력업체 대표자 이메일 추가 (있는 경우에만)
+ if (vendorInfo.representativeEmail) {
+ allEmails.add(vendorInfo.representativeEmail.trim().toLowerCase())
+ }
+
+ // 중복이 제거된 이메일 주소 배열로 변환
+ const uniqueEmails = Array.from(allEmails)
+
+ if (uniqueEmails.length === 0) {
+ console.warn(`협력업체 ID ${vendorId}에 등록된 이메일 주소가 없습니다.`)
+ } else {
+ console.log(`협력업체 ID ${vendorId}에 대해 ${uniqueEmails.length}개의 고유 이메일 주소로 알림을 전송합니다.`)
+
+ // 이메일 발송에 필요한 공통 데이터 준비
+ const emailData = {
+ rfqId,
+ cbeId: newCbeEvaluation.id,
+ vendorId,
+ rfqCode: rfqInfo.rfqCode,
+ projectCode: rfqInfo.projectCode,
+ projectName: rfqInfo.projectName,
+ dueDate: rfqInfo.dueDate,
+ description: rfqInfo.description,
+ vendorName: vendorInfo.name,
+ vendorCode: vendorInfo.vendorCode,
+ paymentTerms: validData.paymentTerms,
+ incoterms: validData.incoterms,
+ deliverySchedule: validData.deliverySchedule,
+ notes: validData.notes,
+ loginUrl: `http://${host}/en/partners/cbe`
+ }
+
+ // 각 고유 이메일 주소로 이메일 발송
+ for (const email of uniqueEmails) {
+ try {
+ // 연락처 이름 찾기 (이메일과 일치하는 연락처가 있으면 사용, 없으면 '벤더명 담당자'로 대체)
+ const contact = contacts.find(c =>
+ c.contactEmail && c.contactEmail.toLowerCase() === email.toLowerCase()
+ )
+ const contactName = contact?.contactName || `${vendorInfo.name} 담당자`
+
+ await sendEmail({
+ to: email,
+ subject: `[RFQ ${rfqInfo.rfqCode}] 상업 입찰 평가 (CBE) 알림`,
+ template: "cbe-invitation",
+ context: {
+ language: "ko", // 또는 다국어 처리를 위한 설정
+ contactName,
+ ...emailData,
+ },
+ attachments: attachments,
+ })
+ console.log(`이메일 전송 성공: ${email}`)
+ } catch (emailErr) {
+ console.error(`이메일 전송 실패 (${email}):`, emailErr)
+ }
+ }
+ }
+
+ } catch (err) {
+ console.error(`협력업체 ID ${vendorId}의 CBE 생성 실패:`, err)
+ failedVendors.push({ id: vendorId, reason: "예기치 않은 오류" })
+ }
+ }
+
+ // UI 업데이트를 위한 경로 재검증
+ revalidatePath(`/rfq/${rfqId}`)
+ revalidateTag(`cbe-vendors-${rfqId}`)
+
+ // 결과 반환
+ if (createdCbeIds.length === 0) {
+ return { error: "어떤 벤더에 대해서도 CBE 평가를 생성하지 못했습니다." }
+ }
+
+ return {
+ success: true,
+ cbeIds: createdCbeIds,
+ totalCreated: createdCbeIds.length,
+ totalFailed: failedVendors.length,
+ failedVendors: failedVendors.length > 0 ? failedVendors : undefined
+ }
+
+ } catch (error) {
+ console.error("CBE 평가 생성 중 오류 발생:", error)
+ return { error: "예상치 못한 오류가 발생했습니다." }
+ }
+}
+
+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;
+
+ // [2] 고급 필터
+ const advancedWhere = filterColumns({
+ table: vendorResponseCBEView,
+ filters: input.filters ?? [],
+ joinOperator: input.joinOperator ?? "and",
+ });
+
+ // [3] 글로벌 검색
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`,
+ sql`${vendorResponseCBEView.projectCode} ILIKE ${s}`,
+ sql`${vendorResponseCBEView.projectName} ILIKE ${s}`,
+ sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}`
+ );
+ }
+
+ // [4] DECLINED 상태 제외 (거절된 응답은 표시하지 않음)
+ // const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED");
+
+ // [5] 최종 where 조건
+ const finalWhere = and(
+ eq(vendorResponseCBEView.vendorId, vendorId), // vendorId로 필터링
+ isNotNull(vendorResponseCBEView.commercialCreatedAt),
+ // notDeclined,
+ advancedWhere ?? undefined,
+ globalWhere ?? undefined
+ );
+
+ // [6] 정렬
+ const orderBy = input.sort?.length
+ ? input.sort.map((s) => {
+ // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑
+ const col = (vendorResponseCBEView as any)[s.id];
+ return s.desc ? desc(col) : asc(col);
+ })
+ : [desc(vendorResponseCBEView.rfqDueDate)]; // 기본 정렬은 RFQ 마감일 내림차순
+
+ // [7] 메인 SELECT
+ const [rows, total] = await db.transaction(async (tx) => {
+ const data = await tx
+ .select({
+ // 기본 식별 정보
+ responseId: vendorResponseCBEView.responseId,
+ vendorId: vendorResponseCBEView.vendorId,
+ rfqId: vendorResponseCBEView.rfqId,
+
+ // 협력업체 정보
+ vendorName: vendorResponseCBEView.vendorName,
+ vendorCode: vendorResponseCBEView.vendorCode,
+ vendorStatus: vendorResponseCBEView.vendorStatus,
+
+ // RFQ 정보
+ rfqCode: vendorResponseCBEView.rfqCode,
+ rfqDescription: vendorResponseCBEView.rfqDescription,
+ rfqDueDate: vendorResponseCBEView.rfqDueDate,
+ rfqStatus: vendorResponseCBEView.rfqStatus,
+ rfqType: vendorResponseCBEView.rfqType,
+
+ // 프로젝트 정보
+ projectId: vendorResponseCBEView.projectId,
+ projectCode: vendorResponseCBEView.projectCode,
+ projectName: vendorResponseCBEView.projectName,
+
+ // 응답 상태 정보
+ responseStatus: vendorResponseCBEView.responseStatus,
+ responseNotes: vendorResponseCBEView.notes,
+ respondedAt: vendorResponseCBEView.respondedAt,
+ respondedBy: vendorResponseCBEView.respondedBy,
+
+ // 상업 응답 정보
+ commercialResponseId: vendorResponseCBEView.commercialResponseId,
+ commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus,
+ totalPrice: vendorResponseCBEView.totalPrice,
+ currency: vendorResponseCBEView.currency,
+ paymentTerms: vendorResponseCBEView.paymentTerms,
+ incoterms: vendorResponseCBEView.incoterms,
+ deliveryPeriod: vendorResponseCBEView.deliveryPeriod,
+ warrantyPeriod: vendorResponseCBEView.warrantyPeriod,
+ validityPeriod: vendorResponseCBEView.validityPeriod,
+ commercialNotes: vendorResponseCBEView.commercialNotes,
+
+ // 첨부파일 카운트
+ attachmentCount: vendorResponseCBEView.attachmentCount,
+ commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount,
+ technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount,
+ })
+ .from(vendorResponseCBEView)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(limit);
+
+ const [{ count }] = await tx
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(vendorResponseCBEView)
+ .where(finalWhere);
+
+ return [data, Number(count)];
+ });
+
+ if (!rows.length) {
+ return { data: [], pageCount: 0, total: 0 };
+ }
+
+ // [8] RFQ ID 목록 추출
+ const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId))];
+ const distinctResponseIds = [...new Set(rows.map((r) => r.responseId))];
+ const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))];
+
+ // [9] CBE 평가 관련 코멘트 조회
+ const commentsAll = await db
+ .select({
+ id: rfqComments.id,
+ commentText: rfqComments.commentText,
+ rfqId: rfqComments.rfqId,
+ cbeId: rfqComments.cbeId,
+ createdAt: rfqComments.createdAt,
+ commentedBy: rfqComments.commentedBy,
+ })
+ .from(rfqComments)
+ .innerJoin(
+ vendorResponses,
+ eq(vendorResponses.id, rfqComments.cbeId)
+ )
+ .where(
+ and(
+ isNotNull(rfqComments.cbeId),
+ eq(rfqComments.vendorId, vendorId),
+ inArray(rfqComments.rfqId, distinctRfqIds)
+ )
+ );
+
+ // rfqId별 코멘트 그룹화
+ const commentsByRfqId = new Map<number, any[]>();
+ for (const comment of commentsAll) {
+ const rfqId = comment.rfqId!;
+ if (!commentsByRfqId.has(rfqId)) {
+ commentsByRfqId.set(rfqId, []);
+ }
+ commentsByRfqId.get(rfqId)!.push({
+ id: comment.id,
+ commentText: comment.commentText,
+ rfqId: comment.rfqId,
+ cbeId: comment.cbeId,
+ createdAt: comment.createdAt,
+ commentedBy: comment.commentedBy,
+ });
+ }
+
+ // [10] 첨부 파일 조회 - 일반 응답 첨부파일
+ const responseAttachments = await db
+ .select({
+ id: vendorResponseAttachments.id,
+ fileName: vendorResponseAttachments.fileName,
+ filePath: vendorResponseAttachments.filePath,
+ responseId: vendorResponseAttachments.responseId,
+ fileType: vendorResponseAttachments.fileType,
+ attachmentType: vendorResponseAttachments.attachmentType,
+ description: vendorResponseAttachments.description,
+ uploadedAt: vendorResponseAttachments.uploadedAt,
+ uploadedBy: vendorResponseAttachments.uploadedBy,
+ })
+ .from(vendorResponseAttachments)
+ .where(
+ and(
+ inArray(vendorResponseAttachments.responseId, distinctResponseIds),
+ isNotNull(vendorResponseAttachments.responseId)
+ )
+ );
+
+ // [11] 첨부 파일 조회 - 상업 응답 첨부파일
+ const commercialResponseAttachments = await db
+ .select({
+ id: vendorResponseAttachments.id,
+ fileName: vendorResponseAttachments.fileName,
+ filePath: vendorResponseAttachments.filePath,
+ commercialResponseId: vendorResponseAttachments.commercialResponseId,
+ fileType: vendorResponseAttachments.fileType,
+ attachmentType: vendorResponseAttachments.attachmentType,
+ description: vendorResponseAttachments.description,
+ uploadedAt: vendorResponseAttachments.uploadedAt,
+ uploadedBy: vendorResponseAttachments.uploadedBy,
+ })
+ .from(vendorResponseAttachments)
+ .where(
+ and(
+ inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds),
+ isNotNull(vendorResponseAttachments.commercialResponseId)
+ )
+ );
+
+ // [12] 첨부파일 그룹화
+ // responseId별 첨부파일 맵 생성
+ const filesByResponseId = new Map<number, any[]>();
+ for (const file of responseAttachments) {
+ const responseId = file.responseId!;
+ if (!filesByResponseId.has(responseId)) {
+ filesByResponseId.set(responseId, []);
+ }
+ filesByResponseId.get(responseId)!.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,
+ attachmentSource: 'response'
+ });
+ }
+
+ // commercialResponseId별 첨부파일 맵 생성
+ const filesByCommercialResponseId = new Map<number, any[]>();
+ for (const file of commercialResponseAttachments) {
+ const commercialResponseId = file.commercialResponseId!;
+ if (!filesByCommercialResponseId.has(commercialResponseId)) {
+ filesByCommercialResponseId.set(commercialResponseId, []);
+ }
+ filesByCommercialResponseId.get(commercialResponseId)!.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,
+ attachmentSource: 'commercial'
+ });
+ }
+
+ // [13] 최종 데이터 병합
+ const final = rows.map((row) => {
+ // 해당 응답의 모든 첨부파일 가져오기
+ const responseFiles = filesByResponseId.get(row.responseId) || [];
+ const commercialFiles = row.commercialResponseId
+ ? filesByCommercialResponseId.get(row.commercialResponseId) || []
+ : [];
+
+ // 모든 첨부파일 병합
+ const allFiles = [...responseFiles, ...commercialFiles];
+
+ return {
+ ...row,
+ rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null,
+ respondedAt: row.respondedAt ? new Date(row.respondedAt) : null,
+ comments: commentsByRfqId.get(row.rfqId) || [],
+ files: allFiles,
+ };
+ });
+
+ const pageCount = Math.ceil(total / limit);
+ return {
+ data: final,
+ pageCount,
+ total
+ };
+ },
+ // 캐싱 키 & 옵션
+ [`cbe-vendor-${vendorId}-${JSON.stringify(input)}`],
+ {
+ revalidate: 3600,
+ tags: [`cbe-vendor-${vendorId}`],
+ }
+ )();
+}
+
+export async function fetchCbeFiles(vendorId: number, rfqId: number) {
+ try {
+ // 1. 먼저 해당 RFQ와 벤더에 해당하는 CBE 평가 레코드를 찾습니다.
+ const cbeEval = await db
+ .select({ id: cbeEvaluations.id })
+ .from(cbeEvaluations)
+ .where(
+ and(
+ eq(cbeEvaluations.rfqId, rfqId),
+ eq(cbeEvaluations.vendorId, vendorId)
+ )
+ )
+ .limit(1)
+
+ if (!cbeEval.length) {
+ return {
+ files: [],
+ error: "해당 RFQ와 벤더에 대한 CBE 평가를 찾을 수 없습니다."
+ }
+ }
+
+ const cbeId = cbeEval[0].id
+
+ // 2. 관련 첨부 파일을 조회합니다.
+ // - commentId와 evaluationId는 null이어야 함
+ // - rfqId와 vendorId가 일치해야 함
+ // - cbeId가 위에서 찾은 CBE 평가 ID와 일치해야 함
+ const files = await db
+ .select({
+ id: rfqAttachments.id,
+ fileName: rfqAttachments.fileName,
+ filePath: rfqAttachments.filePath,
+ createdAt: rfqAttachments.createdAt
+ })
+ .from(rfqAttachments)
+ .where(
+ and(
+ eq(rfqAttachments.rfqId, rfqId),
+ eq(rfqAttachments.vendorId, vendorId),
+ eq(rfqAttachments.cbeId, cbeId),
+ isNull(rfqAttachments.commentId),
+ isNull(rfqAttachments.evaluationId)
+ )
+ )
+ .orderBy(rfqAttachments.createdAt)
+
+ return {
+ files,
+ cbeId
+ }
+ } catch (error) {
+ console.error("CBE 파일 조회 중 오류 발생:", error)
+ return {
+ files: [],
+ error: "CBE 파일을 가져오는 중 오류가 발생했습니다."
+ }
+ }
+}
+
+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;
+
+ // [2] 고급 필터
+ const advancedWhere = filterColumns({
+ table: vendorResponseCBEView,
+ filters: input.filters ?? [],
+ joinOperator: input.joinOperator ?? "and",
+ });
+
+ // [3] 글로벌 검색
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ sql`${vendorResponseCBEView.vendorName} ILIKE ${s}`,
+ sql`${vendorResponseCBEView.vendorCode} ILIKE ${s}`,
+ sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`,
+ sql`${vendorResponseCBEView.projectCode} ILIKE ${s}`,
+ sql`${vendorResponseCBEView.projectName} ILIKE ${s}`,
+ sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}`
+ );
+ }
+
+ // [4] DECLINED 상태 제외 (거절된 업체는 표시하지 않음)
+ const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED");
+
+ // [5] rfqType 필터 추가
+ const rfqTypeFilter = input.rfqType ? eq(vendorResponseCBEView.rfqType, input.rfqType) : undefined;
+
+ // [6] 최종 where 조건
+ const finalWhere = and(
+ notDeclined,
+ advancedWhere ?? undefined,
+ globalWhere ?? undefined,
+ rfqTypeFilter // 새로 추가된 rfqType 필터
+ );
+
+ // [7] 정렬
+ const orderBy = input.sort?.length
+ ? input.sort.map((s) => {
+ // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑
+ const col = (vendorResponseCBEView as any)[s.id];
+ return s.desc ? desc(col) : asc(col);
+ })
+ : [desc(vendorResponseCBEView.rfqId), asc(vendorResponseCBEView.vendorName)]; // 기본 정렬은 최신 RFQ 먼저, 그 다음 벤더명
+
+ // [8] 메인 SELECT
+ const [rows, total] = await db.transaction(async (tx) => {
+ const data = await tx
+ .select({
+ // 기본 식별 정보
+ responseId: vendorResponseCBEView.responseId,
+ vendorId: vendorResponseCBEView.vendorId,
+ rfqId: vendorResponseCBEView.rfqId,
+
+ // 협력업체 정보
+ vendorName: vendorResponseCBEView.vendorName,
+ vendorCode: vendorResponseCBEView.vendorCode,
+ vendorStatus: vendorResponseCBEView.vendorStatus,
+
+ // RFQ 정보
+ rfqCode: vendorResponseCBEView.rfqCode,
+ rfqDescription: vendorResponseCBEView.rfqDescription,
+ rfqDueDate: vendorResponseCBEView.rfqDueDate,
+ rfqStatus: vendorResponseCBEView.rfqStatus,
+ rfqType: vendorResponseCBEView.rfqType,
+
+ // 프로젝트 정보
+ projectId: vendorResponseCBEView.projectId,
+ projectCode: vendorResponseCBEView.projectCode,
+ projectName: vendorResponseCBEView.projectName,
+
+ // 응답 상태 정보
+ responseStatus: vendorResponseCBEView.responseStatus,
+ responseNotes: vendorResponseCBEView.notes,
+ respondedAt: vendorResponseCBEView.respondedAt,
+ respondedBy: vendorResponseCBEView.respondedBy,
+
+ // 상업 응답 정보
+ commercialResponseId: vendorResponseCBEView.commercialResponseId,
+ commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus,
+ totalPrice: vendorResponseCBEView.totalPrice,
+ currency: vendorResponseCBEView.currency,
+ paymentTerms: vendorResponseCBEView.paymentTerms,
+ incoterms: vendorResponseCBEView.incoterms,
+ deliveryPeriod: vendorResponseCBEView.deliveryPeriod,
+ warrantyPeriod: vendorResponseCBEView.warrantyPeriod,
+ validityPeriod: vendorResponseCBEView.validityPeriod,
+ commercialNotes: vendorResponseCBEView.commercialNotes,
+
+ // 첨부파일 카운트
+ attachmentCount: vendorResponseCBEView.attachmentCount,
+ commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount,
+ technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount,
+ })
+ .from(vendorResponseCBEView)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(limit);
+
+ const [{ count }] = await tx
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(vendorResponseCBEView)
+ .where(finalWhere);
+
+ return [data, Number(count)];
+ });
+
+ if (!rows.length) {
+ return { data: [], pageCount: 0, total: 0 };
+ }
+
+ // [9] 고유한 rfqIds와 vendorIds 추출 - null 필터링
+ const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId).filter(Boolean))] as number[];
+ const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId).filter(Boolean))] as number[];
+ const distinctResponseIds = [...new Set(rows.map((r) => r.responseId).filter(Boolean))] as number[];
+ const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))];
+
+ // [10] CBE 평가 관련 코멘트 조회
+ const commentsConditions = [isNotNull(rfqComments.cbeId)];
+
+ // 배열이 비어있지 않을 때만 조건 추가
+ if (distinctRfqIds.length > 0) {
+ commentsConditions.push(inArray(rfqComments.rfqId, distinctRfqIds));
+ }
+
+ if (distinctVendorIds.length > 0) {
+ commentsConditions.push(inArray(rfqComments.vendorId, distinctVendorIds));
+ }
+
+ const commentsAll = await db
+ .select({
+ id: rfqComments.id,
+ commentText: rfqComments.commentText,
+ vendorId: rfqComments.vendorId,
+ rfqId: rfqComments.rfqId,
+ cbeId: rfqComments.cbeId,
+ createdAt: rfqComments.createdAt,
+ commentedBy: rfqComments.commentedBy,
+ })
+ .from(rfqComments)
+ .innerJoin(
+ vendorResponses,
+ eq(vendorResponses.id, rfqComments.cbeId)
+ )
+ .where(and(...commentsConditions));
+
+ // [11] 복합 키(rfqId-vendorId)별 코멘트 그룹화
+ const commentsByCompositeKey = new Map<string, any[]>();
+ for (const comment of commentsAll) {
+ if (!comment.rfqId || !comment.vendorId) continue;
+
+ const compositeKey = `${comment.rfqId}-${comment.vendorId}`;
+ if (!commentsByCompositeKey.has(compositeKey)) {
+ commentsByCompositeKey.set(compositeKey, []);
+ }
+ commentsByCompositeKey.get(compositeKey)!.push({
+ id: comment.id,
+ commentText: comment.commentText,
+ vendorId: comment.vendorId,
+ cbeId: comment.cbeId,
+ createdAt: comment.createdAt,
+ commentedBy: comment.commentedBy,
+ });
+ }
+
+ // [12] 첨부 파일 조회 - 일반 응답 첨부파일
+ const responseAttachments = await db
+ .select({
+ id: vendorResponseAttachments.id,
+ fileName: vendorResponseAttachments.fileName,
+ filePath: vendorResponseAttachments.filePath,
+ responseId: vendorResponseAttachments.responseId,
+ fileType: vendorResponseAttachments.fileType,
+ attachmentType: vendorResponseAttachments.attachmentType,
+ description: vendorResponseAttachments.description,
+ uploadedAt: vendorResponseAttachments.uploadedAt,
+ uploadedBy: vendorResponseAttachments.uploadedBy,
+ })
+ .from(vendorResponseAttachments)
+ .where(
+ and(
+ inArray(vendorResponseAttachments.responseId, distinctResponseIds),
+ isNotNull(vendorResponseAttachments.responseId)
+ )
+ );
+
+ // [13] 첨부 파일 조회 - 상업 응답 첨부파일
+ const commercialResponseAttachments = await db
+ .select({
+ id: vendorResponseAttachments.id,
+ fileName: vendorResponseAttachments.fileName,
+ filePath: vendorResponseAttachments.filePath,
+ commercialResponseId: vendorResponseAttachments.commercialResponseId,
+ fileType: vendorResponseAttachments.fileType,
+ attachmentType: vendorResponseAttachments.attachmentType,
+ description: vendorResponseAttachments.description,
+ uploadedAt: vendorResponseAttachments.uploadedAt,
+ uploadedBy: vendorResponseAttachments.uploadedBy,
+ })
+ .from(vendorResponseAttachments)
+ .where(
+ and(
+ inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds),
+ isNotNull(vendorResponseAttachments.commercialResponseId)
+ )
+ );
+
+ // [14] 첨부파일 그룹화
+ // responseId별 첨부파일 맵 생성
+ const filesByResponseId = new Map<number, any[]>();
+ for (const file of responseAttachments) {
+ const responseId = file.responseId!;
+ if (!filesByResponseId.has(responseId)) {
+ filesByResponseId.set(responseId, []);
+ }
+ filesByResponseId.get(responseId)!.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,
+ attachmentSource: 'response'
+ });
+ }
+
+ // commercialResponseId별 첨부파일 맵 생성
+ const filesByCommercialResponseId = new Map<number, any[]>();
+ for (const file of commercialResponseAttachments) {
+ const commercialResponseId = file.commercialResponseId!;
+ if (!filesByCommercialResponseId.has(commercialResponseId)) {
+ filesByCommercialResponseId.set(commercialResponseId, []);
+ }
+ filesByCommercialResponseId.get(commercialResponseId)!.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,
+ attachmentSource: 'commercial'
+ });
+ }
+
+ // [15] 복합 키(rfqId-vendorId)별 첨부파일 맵 생성
+ const filesByCompositeKey = new Map<string, any[]>();
+
+ // responseId -> rfqId-vendorId 매핑 생성
+ const responseIdToCompositeKey = new Map<number, string>();
+ for (const row of rows) {
+ if (row.responseId) {
+ responseIdToCompositeKey.set(row.responseId, `${row.rfqId}-${row.vendorId}`);
+ }
+ if (row.commercialResponseId) {
+ responseIdToCompositeKey.set(row.commercialResponseId, `${row.rfqId}-${row.vendorId}`);
+ }
+ }
+
+ // responseId별 첨부파일을 복합 키별로 그룹화
+ for (const [responseId, files] of filesByResponseId.entries()) {
+ const compositeKey = responseIdToCompositeKey.get(responseId);
+ if (compositeKey) {
+ if (!filesByCompositeKey.has(compositeKey)) {
+ filesByCompositeKey.set(compositeKey, []);
+ }
+ filesByCompositeKey.get(compositeKey)!.push(...files);
+ }
+ }
+
+ // commercialResponseId별 첨부파일을 복합 키별로 그룹화
+ for (const [commercialResponseId, files] of filesByCommercialResponseId.entries()) {
+ const compositeKey = responseIdToCompositeKey.get(commercialResponseId);
+ if (compositeKey) {
+ if (!filesByCompositeKey.has(compositeKey)) {
+ filesByCompositeKey.set(compositeKey, []);
+ }
+ filesByCompositeKey.get(compositeKey)!.push(...files);
+ }
+ }
+
+ // [16] 최종 데이터 병합
+ const final = rows.map((row) => {
+ const compositeKey = `${row.rfqId}-${row.vendorId}`;
+
+ return {
+ ...row,
+ rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null,
+ respondedAt: row.respondedAt ? new Date(row.respondedAt) : null,
+ comments: commentsByCompositeKey.get(compositeKey) || [],
+ files: filesByCompositeKey.get(compositeKey) || [],
+ };
+ });
+
+ const pageCount = Math.ceil(total / limit);
+ return {
+ data: final,
+ 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/table/add-rfq-dialog.tsx b/lib/rfqs/table/add-rfq-dialog.tsx
index 41055608..9d4d7cf0 100644
--- a/lib/rfqs/table/add-rfq-dialog.tsx
+++ b/lib/rfqs/table/add-rfq-dialog.tsx
@@ -16,6 +16,7 @@ import { createRfq, generateNextRfqCode, getBudgetaryRfqs } from "../service"
import { ProjectSelector } from "@/components/ProjectSelector"
import { type Project } from "../service"
import { ParentRfqSelector } from "./ParentRfqSelector"
+import { EstimateProjectSelector } from "@/components/BidProjectSelector"
// 부모 RFQ 정보 타입 정의
interface ParentRfq {
@@ -43,18 +44,13 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
// Get the user ID safely, ensuring it's a valid number
const userId = React.useMemo(() => {
const id = session?.user?.id ? Number(session.user.id) : null;
-
- // Debug logging - remove in production
- console.log("Session status:", status);
- console.log("Session data:", session);
- console.log("User ID:", id);
-
+
return id;
}, [session, status]);
// RfqType에 따른 타이틀 생성
const getTitle = () => {
- switch(rfqType) {
+ switch (rfqType) {
case RfqType.PURCHASE:
return "Purchase RFQ";
case RfqType.BUDGETARY:
@@ -68,7 +64,7 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
// RfqType 설명 가져오기
const getTypeDescription = () => {
- switch(rfqType) {
+ switch (rfqType) {
case RfqType.PURCHASE:
return "실제 구매 발주 전에 가격을 요청";
case RfqType.BUDGETARY:
@@ -111,12 +107,12 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
try {
// 서버 액션 호출
const result = await generateNextRfqCode(rfqType);
-
+
if (result.error) {
toast.error(`RFQ 코드 생성 실패: ${result.error}`);
return;
}
-
+
// 생성된 코드를 폼에 설정
form.setValue("rfqCode", result.code);
} catch (error) {
@@ -126,14 +122,14 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
setIsLoadingRfqCode(false);
}
};
-
+
generateRfqCode();
}
}, [open, rfqType, form]);
// 현재 RFQ 타입에 따라 선택 가능한 부모 RFQ 타입들 결정
const getParentRfqTypes = (): RfqType[] => {
- switch(rfqType) {
+ switch (rfqType) {
case RfqType.PURCHASE:
// PURCHASE는 BUDGETARY와 PURCHASE_BUDGETARY를 부모로 가질 수 있음
return [RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY];
@@ -153,13 +149,13 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
try {
// 현재 RFQ 타입에 따라 선택 가능한, 부모가 될 수 있는 RFQ 타입들 가져오기
const parentTypes = getParentRfqTypes();
-
+
// 부모 RFQ 타입이 있을 때만 API 호출
if (parentTypes.length > 0) {
const result = await getBudgetaryRfqs({
rfqTypes: parentTypes // 서비스에 rfqTypes 파라미터 추가 필요
});
-
+
if ('rfqs' in result) {
setParentRfqs(result.rfqs as unknown as ParentRfq[]);
} else if ('error' in result) {
@@ -186,6 +182,14 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
form.setValue("projectId", project.id);
};
+ const handleBidProjectSelect = (project: Project | null) => {
+ if (project === null) {
+ return;
+ }
+
+ form.setValue("bidProjectId", project.id);
+ };
+
// 부모 RFQ 선택 처리
const handleParentRfqSelect = (rfq: ParentRfq | null) => {
setSelectedParentRfq(rfq);
@@ -212,7 +216,7 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
toast.error(`에러: ${result.error}`);
return;
}
-
+
toast.success("RFQ가 성공적으로 생성되었습니다.");
form.reset();
setSelectedParentRfq(null);
@@ -234,7 +238,8 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
// 타입에 따라 부모 RFQ 선택 필드를 보여줄지 결정
const shouldShowParentRfqSelector = rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY;
-
+ const shouldShowEstimateSelector = rfqType === RfqType.BUDGETARY;
+
// 부모 RFQ 선택기 레이블 및 설명 가져오기
const getParentRfqSelectorLabel = () => {
if (rfqType === RfqType.PURCHASE) {
@@ -294,11 +299,18 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
<FormItem>
<FormLabel>Project</FormLabel>
<FormControl>
- <ProjectSelector
- selectedProjectId={field.value}
- onProjectSelect={handleProjectSelect}
- placeholder="프로젝트 선택..."
- />
+
+ {shouldShowEstimateSelector ?
+ <EstimateProjectSelector
+ selectedProjectId={field.value}
+ onProjectSelect={handleBidProjectSelect}
+ placeholder="견적 프로젝트 선택..."
+ /> :
+ <ProjectSelector
+ selectedProjectId={field.value}
+ onProjectSelect={handleProjectSelect}
+ placeholder="프로젝트 선택..."
+ />}
</FormControl>
<FormMessage />
</FormItem>
@@ -317,11 +329,11 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
<ParentRfqSelector
selectedRfqId={field.value as number | undefined}
onRfqSelect={handleParentRfqSelect}
- rfqType={rfqType}
+ rfqType={rfqType}
parentRfqTypes={getParentRfqTypes()}
placeholder={
- rfqType === RfqType.PURCHASE
- ? "BUDGETARY 또는 PURCHASE_BUDGETARY RFQ 선택..."
+ rfqType === RfqType.PURCHASE
+ ? "BUDGETARY 또는 PURCHASE_BUDGETARY RFQ 선택..."
: "BUDGETARY RFQ 선택..."
}
/>
@@ -344,9 +356,9 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
<FormLabel>RFQ Code</FormLabel>
<FormControl>
<div className="flex">
- <Input
- placeholder="자동으로 생성 중..."
- {...field}
+ <Input
+ placeholder="자동으로 생성 중..."
+ {...field}
disabled={true}
className="bg-muted"
/>
@@ -416,7 +428,7 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
disabled
className="capitalize"
{...field}
- onChange={() => {}} // Prevent changes
+ onChange={() => { }} // Prevent changes
/>
</FormControl>
<FormMessage />
@@ -433,8 +445,8 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
>
Cancel
</Button>
- <Button
- type="submit"
+ <Button
+ type="submit"
disabled={form.formState.isSubmitting || status !== "authenticated"}
>
Create
diff --git a/lib/rfqs/table/rfqs-table.tsx b/lib/rfqs/table/rfqs-table.tsx
index e4ff47d8..287f1d53 100644
--- a/lib/rfqs/table/rfqs-table.tsx
+++ b/lib/rfqs/table/rfqs-table.tsx
@@ -216,7 +216,7 @@ export function RfqsTable({ promises, rfqType = RfqType.PURCHASE }: RfqsTablePro
<div style={{ maxWidth: '100vw' }}>
<DataTable
table={table}
- floatingBar={<RfqsTableFloatingBar table={table} />}
+ // floatingBar={<RfqsTableFloatingBar table={table} />}
>
<DataTableAdvancedToolbar
table={table}
diff --git a/lib/rfqs/tbe-table/comments-sheet.tsx b/lib/rfqs/tbe-table/comments-sheet.tsx
index bea1fc8e..6efd631f 100644
--- a/lib/rfqs/tbe-table/comments-sheet.tsx
+++ b/lib/rfqs/tbe-table/comments-sheet.tsx
@@ -4,7 +4,7 @@ import * as React from "react"
import { useForm, useFieldArray } from "react-hook-form"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
-import { Loader, Download, X } from "lucide-react"
+import { Download, X, Loader2 } from "lucide-react"
import prettyBytes from "pretty-bytes"
import { toast } from "sonner"
@@ -26,41 +26,34 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form"
-import {
- Textarea,
-} from "@/components/ui/textarea"
-
+import { Textarea } from "@/components/ui/textarea"
import {
Dropzone,
DropzoneZone,
DropzoneUploadIcon,
DropzoneTitle,
DropzoneDescription,
- DropzoneInput
+ DropzoneInput,
} from "@/components/ui/dropzone"
-
import {
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
- TableCell
+ TableCell,
} from "@/components/ui/table"
-// DB 스키마에서 필요한 타입들을 가져온다고 가정
-// (실제 프로젝트에 맞춰 import를 수정하세요.)
-import { RfqWithAll } from "@/db/schema/rfq"
import { createRfqCommentWithAttachments } from "../service"
import { formatDate } from "@/lib/utils"
-// 코멘트 + 첨부파일 구조 (단순 예시)
-// 실제 DB 스키마에 맞춰 조정
+
export interface TbeComment {
id: number
commentText: string
commentedBy?: number
- createdAt?: string | Date
+ commentedByEmail?: string
+ createdAt?: Date
attachments?: {
id: number
fileName: string
@@ -68,23 +61,21 @@ export interface TbeComment {
}[]
}
+// 1) props 정의
interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
- /** 코멘트를 작성할 RFQ 정보 */
- /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */
initialComments?: TbeComment[]
-
- /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */
currentUserId: number
- rfqId:number
- vendorId:number
- /** 댓글 저장 후 갱신용 콜백 (옵션) */
+ rfqId: number
+ tbeId: number
+ vendorId: number
onCommentsUpdated?: (comments: TbeComment[]) => void
+ isLoading?: boolean // New prop
}
-// 새 코멘트 작성 폼 스키마
+// 2) 폼 스키마
const commentFormSchema = z.object({
commentText: z.string().min(1, "댓글을 입력하세요."),
- newFiles: z.array(z.any()).optional() // File[]
+ newFiles: z.array(z.any()).optional(), // File[]
})
type CommentFormValues = z.infer<typeof commentFormSchema>
@@ -95,40 +86,48 @@ export function CommentSheet({
vendorId,
initialComments = [],
currentUserId,
+ tbeId,
onCommentsUpdated,
+ isLoading = false, // Default to false
...props
}: CommentSheetProps) {
+ console.log("tbeId", tbeId)
+
const [comments, setComments] = React.useState<TbeComment[]>(initialComments)
const [isPending, startTransition] = React.useTransition()
React.useEffect(() => {
setComments(initialComments)
}, [initialComments])
-
- // RHF 세팅
const form = useForm<CommentFormValues>({
resolver: zodResolver(commentFormSchema),
defaultValues: {
commentText: "",
- newFiles: []
- }
+ newFiles: [],
+ },
})
- // formFieldArray 예시 (파일 목록)
const { fields: newFileFields, append, remove } = useFieldArray({
control: form.control,
- name: "newFiles"
+ name: "newFiles",
})
- // 1) 기존 코멘트 + 첨부 보여주기
- // 간단히 테이블 하나로 표현
- // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음
+ // (A) 기존 코멘트 렌더링
function renderExistingComments() {
+
+ if (isLoading) {
+ return (
+ <div className="flex justify-center items-center h-32">
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
+ </div>
+ )
+ }
+
if (comments.length === 0) {
return <p className="text-sm text-muted-foreground">No comments yet</p>
}
-
return (
<Table>
<TableHeader>
@@ -144,16 +143,15 @@ export function CommentSheet({
<TableRow key={c.id}>
<TableCell>{c.commentText}</TableCell>
<TableCell>
- {/* 첨부파일 표시 */}
- {(!c.attachments || c.attachments.length === 0) && (
+ {!c.attachments?.length && (
<span className="text-sm text-muted-foreground">No files</span>
)}
- {c.attachments && c.attachments.length > 0 && (
+ {c.attachments?.length && (
<div className="flex flex-col gap-1">
{c.attachments.map((att) => (
<div key={att.id} className="flex items-center gap-2">
<a
- href={att.filePath}
+ href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`}
download
target="_blank"
rel="noreferrer"
@@ -167,10 +165,8 @@ export function CommentSheet({
</div>
)}
</TableCell>
- <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell>
- <TableCell>
- {c.commentedBy ?? "-"}
- </TableCell>
+ <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell>
+ <TableCell>{c.commentedByEmail ?? "-"}</TableCell>
</TableRow>
))}
</TableBody>
@@ -178,28 +174,28 @@ export function CommentSheet({
)
}
- // 2) 새 파일 Drop
+ // (B) 파일 드롭
function handleDropAccepted(files: File[]) {
- // 드롭된 File[]을 RHF field array에 추가
- const toAppend = files.map((f) => f)
- append(toAppend)
+ append(files)
}
-
- // 3) 저장(Submit)
+ // (C) Submit
async function onSubmit(data: CommentFormValues) {
-
if (!rfqId) return
startTransition(async () => {
try {
- // 서버 액션 호출
+ console.log("rfqId", rfqId)
+ console.log("vendorId", vendorId)
+ console.log("tbeId", tbeId)
+ console.log("currentUserId", currentUserId)
const res = await createRfqCommentWithAttachments({
- rfqId: rfqId,
- vendorId: vendorId, // 필요시 세팅
+ rfqId,
+ vendorId,
commentText: data.commentText,
commentedBy: currentUserId,
- evaluationId: null, // 필요시 세팅
- files: data.newFiles
+ evaluationId: tbeId,
+ cbeId: null,
+ files: data.newFiles,
})
if (!res.ok) {
@@ -208,23 +204,22 @@ export function CommentSheet({
toast.success("Comment created")
- // 새 코멘트를 다시 불러오거나,
- // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트
+ // 임시로 새 코멘트 추가
const newComment: TbeComment = {
- id: res.commentId, // 서버에서 반환된 commentId
+ id: res.commentId, // 서버 응답
commentText: data.commentText,
commentedBy: currentUserId,
- createdAt: new Date().toISOString(),
- attachments: (data.newFiles?.map((f, idx) => ({
- id: Math.random() * 100000,
- fileName: f.name,
- filePath: "/uploads/" + f.name,
- })) || [])
+ createdAt: res.createdAt,
+ attachments:
+ data.newFiles?.map((f) => ({
+ id: Math.floor(Math.random() * 1e6),
+ fileName: f.name,
+ filePath: "/uploads/" + f.name,
+ })) || [],
}
setComments((prev) => [...prev, newComment])
onCommentsUpdated?.([...comments, newComment])
- // 폼 리셋
form.reset()
} catch (err: any) {
console.error(err)
@@ -243,12 +238,8 @@ export function CommentSheet({
</SheetDescription>
</SheetHeader>
- {/* 기존 코멘트 목록 */}
- <div className="max-h-[300px] overflow-y-auto">
- {renderExistingComments()}
- </div>
+ <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div>
- {/* 새 코멘트 작성 Form */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
<FormField
@@ -258,17 +249,13 @@ export function CommentSheet({
<FormItem>
<FormLabel>New Comment</FormLabel>
<FormControl>
- <Textarea
- placeholder="Enter your comment..."
- {...field}
- />
+ <Textarea placeholder="Enter your comment..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
- {/* Dropzone (파일 첨부) */}
<Dropzone
maxSize={MAX_FILE_SIZE}
onDropAccepted={handleDropAccepted}
@@ -292,15 +279,19 @@ export function CommentSheet({
)}
</Dropzone>
- {/* 선택된 파일 목록 */}
{newFileFields.length > 0 && (
<div className="flex flex-col gap-2">
{newFileFields.map((field, idx) => {
const file = form.getValues(`newFiles.${idx}`)
if (!file) return null
return (
- <div key={field.id} className="flex items-center justify-between border rounded p-2">
- <span className="text-sm">{file.name} ({prettyBytes(file.size)})</span>
+ <div
+ key={field.id}
+ className="flex items-center justify-between border rounded p-2"
+ >
+ <span className="text-sm">
+ {file.name} ({prettyBytes(file.size)})
+ </span>
<Button
variant="ghost"
size="icon"
@@ -322,7 +313,7 @@ export function CommentSheet({
</Button>
</SheetClose>
<Button disabled={isPending}>
- {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save
</Button>
</SheetFooter>
diff --git a/lib/rfqs/tbe-table/invite-vendors-dialog.tsx b/lib/rfqs/tbe-table/invite-vendors-dialog.tsx
index e38e0ede..935d2bf3 100644
--- a/lib/rfqs/tbe-table/invite-vendors-dialog.tsx
+++ b/lib/rfqs/tbe-table/invite-vendors-dialog.tsx
@@ -32,6 +32,9 @@ import { Input } from "@/components/ui/input"
import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
import { inviteTbeVendorsAction } from "../service"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Badge } from "@/components/ui/badge"
+import { Label } from "@/components/ui/label"
interface InviteVendorsDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -94,6 +97,23 @@ export function InviteVendorsDialog({
// 파일 선택 UI
const fileInput = (
+<>
+ <div className="space-y-2">
+ <Label>선택된 협력업체 ({vendors.length})</Label>
+ <ScrollArea className="h-20 border rounded-md p-2">
+ <div className="flex flex-wrap gap-2">
+ {vendors.map((vendor, index) => (
+ <Badge key={index} variant="secondary" className="py-1">
+ {vendor.vendorName || `협력업체 #${vendor.vendorCode}`}
+ </Badge>
+ ))}
+ </div>
+ </ScrollArea>
+ <p className="text-[0.8rem] font-medium text-muted-foreground">
+ 선택된 모든 협력업체의 등록된 연락처에게 TBE 평가 알림이 전송됩니다.
+ </p>
+ </div>
+
<div className="mb-4">
<label className="mb-2 block font-medium">TBE Sheets</label>
<Input
@@ -104,6 +124,7 @@ export function InviteVendorsDialog({
}}
/>
</div>
+ </>
)
// Desktop Dialog
@@ -114,17 +135,15 @@ export function InviteVendorsDialog({
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Send className="mr-2 size-4" aria-hidden="true" />
- Invite ({vendors.length})
+ TBE 평가 생성 ({vendors.length})
</Button>
</DialogTrigger>
) : null}
<DialogContent>
<DialogHeader>
- <DialogTitle>Are you absolutely sure?</DialogTitle>
+ <DialogTitle>TBE 평가 시트 전송</DialogTitle>
<DialogDescription>
- This action cannot be undone. This will permanently invite{" "}
- <span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? " vendor" : " vendors"}. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다.
+ 선택한 {vendors.length}개 협력업체에 대한 기술 평가 시트와 알림을 전송합니다. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다.
</DialogDescription>
</DialogHeader>
@@ -169,12 +188,10 @@ export function InviteVendorsDialog({
) : null}
<DrawerContent>
<DrawerHeader>
- <DrawerTitle>Are you absolutely sure?</DrawerTitle>
- <DrawerDescription>
- This action cannot be undone. This will permanently invite{" "}
- <span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? " vendor" : " vendors"}.
- </DrawerDescription>
+ <DialogTitle>TBE 평가 시트 전송</DialogTitle>
+ <DialogDescription>
+ 선택한 {vendors.length}개 협력업체에 대한 기술 평가 시트와 알림을 전송합니다. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다.
+ </DialogDescription>
</DrawerHeader>
{/* 파일 첨부 */}
diff --git a/lib/rfqs/tbe-table/tbe-result-dialog.tsx b/lib/rfqs/tbe-table/tbe-result-dialog.tsx
new file mode 100644
index 00000000..8400ecac
--- /dev/null
+++ b/lib/rfqs/tbe-table/tbe-result-dialog.tsx
@@ -0,0 +1,208 @@
+"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Textarea } from "@/components/ui/textarea"
+import { Label } from "@/components/ui/label"
+import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
+import { getErrorMessage } from "@/lib/handle-error"
+import { saveTbeResult } from "../service"
+
+// Define the props for the TbeResultDialog component
+interface TbeResultDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ tbe: VendorWithTbeFields | null
+ onRefresh?: () => void
+}
+
+// Define TBE result options
+const TBE_RESULT_OPTIONS = [
+ { value: "pass", label: "Pass", badgeVariant: "default" },
+ { value: "non-pass", label: "Non-Pass", badgeVariant: "destructive" },
+ { value: "conditional pass", label: "Conditional Pass", badgeVariant: "secondary" },
+] as const
+
+type TbeResultOption = typeof TBE_RESULT_OPTIONS[number]["value"]
+
+export function TbeResultDialog({
+ open,
+ onOpenChange,
+ tbe,
+ onRefresh,
+}: TbeResultDialogProps) {
+ // Initialize state for form inputs
+ const [result, setResult] = React.useState<TbeResultOption | "">("")
+ const [note, setNote] = React.useState("")
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ // Update form values when the tbe prop changes
+ React.useEffect(() => {
+ if (tbe) {
+ setResult((tbe.tbeResult as TbeResultOption) || "")
+ setNote(tbe.tbeNote || "")
+ }
+ }, [tbe])
+
+ // Reset form when dialog closes
+ React.useEffect(() => {
+ if (!open) {
+ // Small delay to avoid visual glitches when dialog is closing
+ const timer = setTimeout(() => {
+ if (!tbe) {
+ setResult("")
+ setNote("")
+ }
+ }, 300)
+ return () => clearTimeout(timer)
+ }
+ }, [open, tbe])
+
+ // Handle form submission with server action
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!tbe || !result) return
+
+ setIsSubmitting(true)
+
+ try {
+ // Call the server action to save the TBE result
+ const response = await saveTbeResult({
+ id: tbe.tbeId ?? 0, // This is the id in the rfq_evaluations table
+ vendorId: tbe.vendorId, // This is the vendorId in the rfq_evaluations table
+ result: result, // The selected evaluation result
+ notes: note, // The evaluation notes
+ })
+
+ if (!response.success) {
+ throw new Error(response.message || "Failed to save TBE result")
+ }
+
+ // Show success toast
+ toast.success("TBE result saved successfully")
+
+ // Close the dialog
+ onOpenChange(false)
+
+ // Refresh the data if refresh callback is provided
+ if (onRefresh) {
+ onRefresh()
+ }
+ } catch (error) {
+ // Show error toast
+ toast.error(`Failed to save: ${getErrorMessage(error)}`)
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // Find the selected result option
+ const selectedOption = TBE_RESULT_OPTIONS.find(option => option.value === result)
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle className="text-xl font-semibold">
+ {tbe?.tbeResult ? "Edit TBE Result" : "Enter TBE Result"}
+ </DialogTitle>
+ {tbe && (
+ <DialogDescription className="text-sm text-muted-foreground mt-1">
+ <div className="flex flex-col gap-1">
+ <span>
+ <strong>Vendor:</strong> {tbe.vendorName}
+ </span>
+ <span>
+ <strong>RFQ Code:</strong> {tbe.rfqCode}
+ </span>
+ {tbe.email && (
+ <span>
+ <strong>Email:</strong> {tbe.email}
+ </span>
+ )}
+ </div>
+ </DialogDescription>
+ )}
+ </DialogHeader>
+
+ <form onSubmit={handleSubmit} className="space-y-6 py-2">
+ <div className="space-y-2">
+ <Label htmlFor="tbe-result" className="text-sm font-medium">
+ Evaluation Result
+ </Label>
+ <Select
+ value={result}
+ onValueChange={(value) => setResult(value as TbeResultOption)}
+ required
+ >
+ <SelectTrigger id="tbe-result" className="w-full">
+ <SelectValue placeholder="Select a result" />
+ </SelectTrigger>
+ <SelectContent>
+ {TBE_RESULT_OPTIONS.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ <div className="flex items-center">
+ <Badge variant={option.badgeVariant as any} className="mr-2">
+ {option.label}
+ </Badge>
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="tbe-note" className="text-sm font-medium">
+ Evaluation Note
+ </Label>
+ <Textarea
+ id="tbe-note"
+ placeholder="Enter evaluation notes..."
+ value={note}
+ onChange={(e) => setNote(e.target.value)}
+ className="min-h-[120px] resize-y"
+ />
+ </div>
+
+ <DialogFooter className="gap-2 sm:gap-0">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={!result || isSubmitting}
+ className="min-w-[100px]"
+ >
+ {isSubmitting ? "Saving..." : "Save"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/tbe-table/tbe-table-columns.tsx b/lib/rfqs/tbe-table/tbe-table-columns.tsx
index 0e9b7064..e8566831 100644
--- a/lib/rfqs/tbe-table/tbe-table-columns.tsx
+++ b/lib/rfqs/tbe-table/tbe-table-columns.tsx
@@ -11,21 +11,11 @@ import { formatDate } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
+
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { useRouter } from "next/navigation"
import {
- VendorTbeColumnConfig,
vendorTbeColumnsConfig,
VendorWithTbeFields,
} from "@/config/vendorTbeColumnsConfig"
@@ -39,6 +29,8 @@ interface GetColumnsProps {
router: NextRouter
openCommentSheet: (vendorId: number) => void
openFilesDialog: (tbeId:number , vendorId: number) => void
+ openVendorContactsDialog: (vendorId: number, vendor: VendorWithTbeFields) => void // 수정된 시그니처
+
}
/**
@@ -46,9 +38,9 @@ interface GetColumnsProps {
*/
export function getColumns({
setRowAction,
- router,
openCommentSheet,
- openFilesDialog
+ openFilesDialog,
+ openVendorContactsDialog
}: GetColumnsProps): ColumnDef<VendorWithTbeFields>[] {
// ----------------------------------------------------------------
// 1) Select 컬럼 (체크박스)
@@ -107,6 +99,85 @@ export function getColumns({
// 1) 필드값 가져오기
const val = getValue()
+ if (cfg.id === "vendorName") {
+ const vendor = row.original;
+ const vendorId = vendor.vendorId;
+
+ // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링
+ const handleVendorNameClick = () => {
+ if (vendorId) {
+ openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달
+ } else {
+ toast.error("협력업체 ID를 찾을 수 없습니다.");
+ }
+ };
+
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left font-normal justify-start hover:underline"
+ onClick={handleVendorNameClick}
+ >
+ {val as string}
+ </Button>
+ );
+ }
+
+ if (cfg.id === "tbeResult") {
+ const vendor = row.original;
+ const tbeResult = vendor.tbeResult;
+ const filesCount = vendor.files?.length ?? 0;
+
+ // Only show button or link if there are files
+ if (filesCount > 0) {
+ // Function to handle clicking on the result
+ const handleTbeResultClick = () => {
+ setRowAction({ row, type: "tbeResult" });
+ };
+
+ if (!tbeResult) {
+ // No result yet, but files exist - show "결과 입력" button
+ return (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleTbeResultClick}
+ >
+ 결과 입력
+ </Button>
+ );
+ } else {
+ // Result exists - show as a hyperlink
+ let badgeVariant: "default" | "outline" | "destructive" | "secondary" = "outline";
+
+ // Set badge variant based on result
+ if (tbeResult === "pass") {
+ badgeVariant = "default";
+ } else if (tbeResult === "non-pass") {
+ badgeVariant = "destructive";
+ } else if (tbeResult === "conditional pass") {
+ badgeVariant = "secondary";
+ }
+
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto underline"
+ onClick={handleTbeResultClick}
+ >
+ <Badge variant={badgeVariant}>
+ {tbeResult}
+ </Badge>
+ </Button>
+ );
+ }
+ }
+
+ // No files available, return empty cell
+ return null;
+ }
+
+
if (cfg.id === "vendorStatus") {
const statusVal = row.original.vendorStatus
if (!statusVal) return null
@@ -131,6 +202,8 @@ export function getColumns({
)
}
+
+
// 예) TBE Updated (날짜)
if (cfg.id === "tbeUpdated") {
const dateVal = val as Date | undefined
diff --git a/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx b/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx
index 6a336135..a8f8ea82 100644
--- a/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx
+++ b/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx
@@ -28,18 +28,25 @@ export function VendorsTableToolbarActions({ table,rfqId }: VendorsTableToolbarA
fileInputRef.current?.click()
}
+ const invitationPossibeVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ .filter(vendor => vendor.technicalResponseStatus === null);
+ }, [table.getFilteredSelectedRowModel().rows]);
+
return (
<div className="flex items-center gap-2">
- {table.getFilteredSelectedRowModel().rows.length > 0 ? (
- <InviteVendorsDialog
- vendors={table
- .getFilteredSelectedRowModel()
- .rows.map((row) => row.original)}
+ {invitationPossibeVendors.length > 0 &&
+ (
+ <InviteVendorsDialog
+ vendors={invitationPossibeVendors}
rfqId = {rfqId}
onSuccess={() => table.toggleAllRowsSelected(false)}
- />
- ) : null}
-
+ />
+ )
+ }
<Button
variant="outline"
diff --git a/lib/rfqs/tbe-table/tbe-table.tsx b/lib/rfqs/tbe-table/tbe-table.tsx
index 41eff0dc..0add8927 100644
--- a/lib/rfqs/tbe-table/tbe-table.tsx
+++ b/lib/rfqs/tbe-table/tbe-table.tsx
@@ -21,6 +21,9 @@ import { InviteVendorsDialog } from "./invite-vendors-dialog"
import { CommentSheet, TbeComment } from "./comments-sheet"
import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
import { TBEFileDialog } from "./file-dialog"
+import { TbeResultDialog } from "./tbe-result-dialog"
+import { VendorContactsDialog } from "./vendor-contact-dialog"
+import { useSession } from "next-auth/react" // Next-auth session hook 추가
interface VendorsTableProps {
promises: Promise<
@@ -37,8 +40,11 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) {
// Suspense로 받아온 데이터
const [{ data, pageCount }] = React.use(promises)
+ console.log("data", data)
+ const { data: session } = useSession() // 세션 정보 가져오기
+
+ const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0
- console.log(data)
const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithTbeFields> | null>(null)
@@ -48,13 +54,12 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) {
const [initialComments, setInitialComments] = React.useState<TbeComment[]>([])
const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
-
+
const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false)
const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null)
const [selectedTbeId, setSelectedTbeId] = React.useState<number | null>(null)
-
- console.log(selectedVendorId,"selectedVendorId")
- console.log(rfqId,"rfqId")
+ const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false)
+ const [selectedVendor, setSelectedVendor] = React.useState<VendorWithTbeFields | null>(null)
// Add handleRefresh function
const handleRefresh = React.useCallback(() => {
@@ -73,11 +78,14 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) {
}
}, [rowAction])
- async function openCommentSheet(vendorId: number) {
+ async function openCommentSheet(a: number) {
setInitialComments([])
-
+
const comments = rowAction?.row.original.comments
-
+ const rfqId = rowAction?.row.original.rfqId
+ const vendorId = rowAction?.row.original.vendorId
+ const tbeId = rowAction?.row.original.tbeId
+ console.log("original", rowAction?.row.original)
if (comments && comments.length > 0) {
const commentWithAttachments: TbeComment[] = await Promise.all(
comments.map(async (c) => {
@@ -85,7 +93,7 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) {
return {
...c,
- commentedBy: 1, // DB나 API 응답에 있다고 가정
+ commentedBy: currentUserId, // DB나 API 응답에 있다고 가정
attachments,
}
})
@@ -93,8 +101,9 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) {
// 3) state에 저장 -> CommentSheet에서 initialComments로 사용
setInitialComments(commentWithAttachments)
}
-
- setSelectedRfqIdForComments(vendorId)
+ setSelectedTbeId(tbeId ?? 0)
+ setSelectedVendorId(vendorId ?? 0)
+ setSelectedRfqIdForComments(rfqId ?? 0)
setCommentSheetOpen(true)
}
@@ -103,11 +112,15 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) {
setSelectedVendorId(vendorId)
setIsFileDialogOpen(true)
}
-
+ const openVendorContactsDialog = (vendorId: number, vendor: VendorWithTbeFields) => {
+ setSelectedVendorId(vendorId)
+ setSelectedVendor(vendor)
+ setIsContactDialogOpen(true)
+ }
// getColumns() 호출 시, router를 주입
const columns = React.useMemo(
- () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog }),
+ () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog, openVendorContactsDialog }),
[setRowAction, router]
)
@@ -141,18 +154,20 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) {
enableAdvancedFilter: true,
initialState: {
sorting: [{ id: "rfqVendorUpdated", desc: true }],
- columnPinning: { right: ["actions"] },
+ columnPinning: { right: ["comments"] },
},
getRowId: (originalRow) => String(originalRow.id),
shallow: false,
clearOnDefault: true,
})
+
+
return (
-<div style={{ maxWidth: '80vw' }}>
+ <div style={{ maxWidth: '80vw' }}>
<DataTable
table={table}
- >
+ >
<DataTableAdvancedToolbar
table={table}
filterFields={advancedFilterFields}
@@ -169,11 +184,12 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) {
showTrigger={false}
/>
<CommentSheet
- currentUserId={1}
+ currentUserId={currentUserId}
open={commentSheetOpen}
onOpenChange={setCommentSheetOpen}
rfqId={rfqId}
- vendorId={selectedRfqIdForComments ?? 0}
+ tbeId={selectedTbeId ?? 0}
+ vendorId={selectedVendorId ?? 0}
initialComments={initialComments}
/>
@@ -185,6 +201,20 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) {
rfqId={rfqId} // Use the prop directly instead of data[0]?.rfqId
onRefresh={handleRefresh}
/>
+
+ <TbeResultDialog
+ open={rowAction?.type === "tbeResult"}
+ onOpenChange={() => setRowAction(null)}
+ tbe={rowAction?.row.original ?? null}
+ />
+
+ <VendorContactsDialog
+ isOpen={isContactDialogOpen}
+ onOpenChange={setIsContactDialogOpen}
+ vendorId={selectedVendorId}
+ vendor={selectedVendor}
+ />
+
</div>
)
} \ No newline at end of file
diff --git a/lib/rfqs/tbe-table/vendor-contact-dialog.tsx b/lib/rfqs/tbe-table/vendor-contact-dialog.tsx
new file mode 100644
index 00000000..3619fe77
--- /dev/null
+++ b/lib/rfqs/tbe-table/vendor-contact-dialog.tsx
@@ -0,0 +1,71 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { VendorContactsTable } from "./vendor-contact/vendor-contact-table"
+import { Badge } from "@/components/ui/badge"
+import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
+
+interface VendorContactsDialogProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ vendorId: number | null
+ vendor: VendorWithTbeFields | null
+}
+
+export function VendorContactsDialog({
+ isOpen,
+ onOpenChange,
+ vendorId,
+ vendor,
+}: VendorContactsDialogProps) {
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}>
+ <DialogHeader>
+ <div className="flex flex-col space-y-2">
+ <DialogTitle>협력업체 연락처</DialogTitle>
+ {vendor && (
+ <div className="flex flex-col space-y-1 mt-2">
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium text-foreground">{vendor.vendorName}</span>
+ {vendor.vendorCode && (
+ <span className="ml-2 text-xs text-muted-foreground">({vendor.vendorCode})</span>
+ )}
+ </div>
+ <div className="flex items-center">
+ {vendor.vendorStatus && (
+ <Badge variant="outline" className="mr-2">
+ {vendor.vendorStatus}
+ </Badge>
+ )}
+ {vendor.rfqVendorStatus && (
+ <Badge
+ variant={
+ vendor.rfqVendorStatus === "INVITED" ? "default" :
+ vendor.rfqVendorStatus === "DECLINED" ? "destructive" :
+ vendor.rfqVendorStatus === "ACCEPTED" ? "secondary" : "outline"
+ }
+ >
+ {vendor.rfqVendorStatus}
+ </Badge>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </DialogHeader>
+ {vendorId && (
+ <div className="py-4">
+ <VendorContactsTable vendorId={vendorId} />
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx b/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx
new file mode 100644
index 00000000..fcd0c3fb
--- /dev/null
+++ b/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx
@@ -0,0 +1,70 @@
+"use client"
+// Because columns rely on React state/hooks for row actions
+
+import * as React from "react"
+import { ColumnDef, Row } from "@tanstack/react-table"
+import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"
+import { formatDate } from "@/lib/utils"
+import { Checkbox } from "@/components/ui/checkbox"
+import { VendorData } from "./vendor-contact-table"
+
+
+/** getColumns: return array of ColumnDef for 'vendors' data */
+export function getColumns(): ColumnDef<VendorData>[] {
+ return [
+
+ // Vendor Name
+ {
+ accessorKey: "contactName",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Contact Name" />
+ ),
+ cell: ({ row }) => row.getValue("contactName"),
+ },
+
+ // Vendor Code
+ {
+ accessorKey: "contactPosition",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Position" />
+ ),
+ cell: ({ row }) => row.getValue("contactPosition"),
+ },
+
+ // Status
+ {
+ accessorKey: "contactEmail",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Email" />
+ ),
+ cell: ({ row }) => row.getValue("contactEmail"),
+ },
+
+ // Country
+ {
+ accessorKey: "contactPhone",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Phone" />
+ ),
+ cell: ({ row }) => row.getValue("contactPhone"),
+ },
+
+ // Created At
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Created At" />
+ ),
+ cell: ({ cell }) => formatDate(cell.getValue() as Date),
+ },
+
+ // Updated At
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Updated At" />
+ ),
+ cell: ({ cell }) => formatDate(cell.getValue() as Date),
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx b/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx
new file mode 100644
index 00000000..c079da02
--- /dev/null
+++ b/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx
@@ -0,0 +1,89 @@
+'use client'
+
+import * as React from "react"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { getColumns } from "./vendor-contact-table-column"
+import { DataTableAdvancedFilterField } from "@/types/table"
+import { Loader2 } from "lucide-react"
+import { useToast } from "@/hooks/use-toast"
+import { getVendorContactsByVendorId } from "../../service"
+
+export interface VendorData {
+ id: number
+ contactName: string
+ contactPosition: string | null
+ contactEmail: string
+ contactPhone: string | null
+ isPrimary: boolean | null
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface VendorContactsTableProps {
+ vendorId: number
+}
+
+export function VendorContactsTable({ vendorId }: VendorContactsTableProps) {
+ const { toast } = useToast()
+
+ const columns = React.useMemo(
+ () => getColumns(),
+ []
+ )
+
+ const [vendorContacts, setVendorContacts] = React.useState<VendorData[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ React.useEffect(() => {
+ async function loadVendorContacts() {
+ setIsLoading(true)
+ try {
+ const result = await getVendorContactsByVendorId(vendorId)
+ if (result.success && result.data) {
+ // undefined 체크 추가 및 타입 캐스팅
+ setVendorContacts(result.data as VendorData[])
+ } else {
+ throw new Error(result.error || "Unknown error occurred")
+ }
+ } catch (error) {
+ console.error("협력업체 연락처 로드 오류:", error)
+ toast({
+ title: "Error",
+ description: "Failed to load vendor contacts",
+ variant: "destructive",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+ loadVendorContacts()
+ }, [toast, vendorId])
+
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = [
+ { id: "contactName", label: "Contact Name", type: "text" },
+ { id: "contactPosition", label: "Posiotion", type: "text" },
+ { id: "contactEmail", label: "Email", type: "text" },
+ { id: "contactPhone", label: "Phone", type: "text" },
+
+
+ ]
+
+ // If loading, show a flex container that fills the parent and centers the spinner
+ if (isLoading) {
+ return (
+ <div className="flex h-full w-full items-center justify-center">
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+ </div>
+ )
+ }
+
+ // Otherwise, show the table
+ return (
+ <ClientDataTable
+ data={vendorContacts}
+ columns={columns}
+ advancedFilterFields={advancedFilterFields}
+ >
+ </ClientDataTable>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/validations.ts b/lib/rfqs/validations.ts
index 9e9e96cc..59e9e362 100644
--- a/lib/rfqs/validations.ts
+++ b/lib/rfqs/validations.ts
@@ -2,18 +2,18 @@ import { createSearchParamsCache,
parseAsArrayOf,
parseAsInteger,
parseAsString,
- parseAsStringEnum,
+ parseAsStringEnum,parseAsBoolean
} from "nuqs/server"
import * as z from "zod"
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { Rfq, rfqs, RfqsView, VendorCbeView, VendorRfqViewBase, VendorTbeView } from "@/db/schema/rfq";
+import { Rfq, rfqs, RfqsView, VendorCbeView, VendorResponseCBEView, VendorRfqViewBase, VendorTbeView } from "@/db/schema/rfq";
import { Vendor, vendors } from "@/db/schema/vendors";
export const RfqType = {
PURCHASE_BUDGETARY: "PURCHASE_BUDGETARY",
PURCHASE: "PURCHASE",
- BUDGETARY: "BUDGETARY"
+ BUDGETARY: "c"
} as const;
export type RfqType = typeof RfqType[keyof typeof RfqType];
@@ -129,6 +129,7 @@ export const createRfqSchema = z.object({
rfqCode: z.string().min(3, "RFQ 코드는 최소 3글자 이상이어야 합니다"),
description: z.string().optional(),
projectId: z.number().nullable().optional(), // 프로젝트 ID (선택적)
+ bidProjectId: z.number().nullable().optional(), // 프로젝트 ID (선택적)
parentRfqId: z.number().nullable().optional(), // 부모 RFQ ID (선택적)
dueDate: z.date(),
status: z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]),
@@ -227,50 +228,70 @@ export const updateRfqVendorSchema = z.object({
export type UpdateRfqVendorSchema = z.infer<typeof updateRfqVendorSchema>
-
-
export const searchParamsCBECache = createSearchParamsCache({
// 1) 공통 플래그
flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
-
+
// 2) 페이지네이션
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
-
- // 3) 정렬 (Rfq 테이블)
- // getSortingStateParser<Rfq>() → Rfq 테이블의 컬럼명에 맞춘 유효성 검사
- sort: getSortingStateParser<VendorCbeView>().withDefault([
- { id: "cbeUpdated", desc: true },
+
+ // 3) 정렬 (VendorResponseCBEView 테이블)
+ // getSortingStateParser<VendorResponseCBEView>() → CBE 테이블의 컬럼명에 맞춤
+ sort: getSortingStateParser<VendorResponseCBEView>().withDefault([
+ { id: "totalPrice", desc: true },
]),
-
- // 4) 간단 검색 필드
+
+ // 4) 간단 검색 필드 - 기본 정보
vendorName: parseAsString.withDefault(""),
vendorCode: parseAsString.withDefault(""),
country: parseAsString.withDefault(""),
email: parseAsString.withDefault(""),
website: parseAsString.withDefault(""),
-
- cbeResult: parseAsString.withDefault(""),
- cbeNote: parseAsString.withDefault(""),
- cbeUpdated: parseAsString.withDefault(""),
- rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"),
-
-
- totalCost: parseAsInteger.withDefault(0),
+
+ // CBE 관련 필드
+ commercialResponseId: parseAsString.withDefault(""),
+ totalPrice: parseAsString.withDefault(""),
currency: parseAsString.withDefault(""),
paymentTerms: parseAsString.withDefault(""),
incoterms: parseAsString.withDefault(""),
- deliverySchedule: parseAsString.withDefault(""),
-
- // 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED"
- // rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리
+ deliveryPeriod: parseAsString.withDefault(""),
+ warrantyPeriod: parseAsString.withDefault(""),
+ validityPeriod: parseAsString.withDefault(""),
+
+ // RFQ 관련 필드
+ rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"),
+
+ // 응답 상태
+ responseStatus: parseAsStringEnum(["INVITED", "ACCEPTED", "DECLINED", "REVIEWING", "RESPONDED"]).withDefault("REVIEWING"),
+
+ // 5) 상태 (배열) - vendor 상태
vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]),
-
+
// 6) 고급 필터 (nuqs - filterColumns)
filters: getFiltersStateParser().withDefault([]),
joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
+
// 7) 글로벌 검색어
search: parseAsString.withDefault(""),
+
+ // 8) 첨부파일 관련 필터
+ hasAttachments: parseAsBoolean.withDefault(false),
+
+ // 9) 날짜 범위 필터
+ respondedAtRange: parseAsString.withDefault(""),
+ commercialUpdatedAtRange: parseAsString.withDefault(""),
})
+
export type GetCBESchema = Awaited<ReturnType<typeof searchParamsCBECache.parse>>;
+
+
+export const createCbeEvaluationSchema = z.object({
+ paymentTerms: z.string().min(1, "결제 조건을 입력하세요"),
+ incoterms: z.string().min(1, "Incoterms를 입력하세요"),
+ deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"),
+ notes: z.string().optional(),
+})
+
+// 타입 추출
+export type CreateCbeEvaluationSchema = z.infer<typeof createCbeEvaluationSchema> \ No newline at end of file
diff --git a/lib/rfqs/vendor-table/comments-sheet.tsx b/lib/rfqs/vendor-table/comments-sheet.tsx
index 3a2a9353..441fdcf1 100644
--- a/lib/rfqs/vendor-table/comments-sheet.tsx
+++ b/lib/rfqs/vendor-table/comments-sheet.tsx
@@ -53,7 +53,7 @@ export interface MatchedVendorComment {
commentText: string
commentedBy?: number
commentedByEmail?: string
- createdAt?: Date
+ createdAt?: Date
attachments?: {
id: number
fileName: string
@@ -90,8 +90,6 @@ export function CommentSheet({
...props
}: CommentSheetProps) {
- console.log(initialComments)
-
const [comments, setComments] = React.useState<MatchedVendorComment[]>(initialComments)
const [isPending, startTransition] = React.useTransition()
@@ -138,7 +136,7 @@ export function CommentSheet({
</TableRow>
</TableHeader>
<TableBody>
- {comments.map((c) => (
+ {comments.map((c) => (
<TableRow key={c.id}>
<TableCell>{c.commentText}</TableCell>
<TableCell>
@@ -150,7 +148,7 @@ export function CommentSheet({
{c.attachments.map((att) => (
<div key={att.id} className="flex items-center gap-2">
<a
- href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`}
+ href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`}
download
target="_blank"
rel="noreferrer"
@@ -164,7 +162,7 @@ export function CommentSheet({
</div>
)}
</TableCell>
- <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell>
+ <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell>
<TableCell>{c.commentedByEmail ?? "-"}</TableCell>
</TableRow>
))}
diff --git a/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx b/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx
index c436eebd..e34a5052 100644
--- a/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx
+++ b/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx
@@ -52,7 +52,7 @@ export function VendorsListTable({ rfqId }: VendorsListTableProps) {
const allVendors = await getAllVendors()
setVendors(allVendors)
} catch (error) {
- console.error("벤더 목록 로드 오류:", error)
+ console.error("협력업체 목록 로드 오류:", error)
toast({
title: "Error",
description: "Failed to load vendors",
diff --git a/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx b/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx
index abb34f85..864d0f4b 100644
--- a/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx
+++ b/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx
@@ -21,14 +21,14 @@ export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbar
// 선택된 모든 행
const selectedRows = table.getFilteredSelectedRowModel().rows
- // 조건에 맞는 벤더만 필터링
+ // 조건에 맞는 협력업체만 필터링
const eligibleVendors = React.useMemo(() => {
return selectedRows
.map(row => row.original)
.filter(vendor => !vendor.rfqVendorStatus || vendor.rfqVendorStatus === "INVITED")
}, [selectedRows])
- // 조건에 맞지 않는 벤더 수
+ // 조건에 맞지 않는 협력업체 수
const ineligibleCount = selectedRows.length - eligibleVendors.length
function handleImportClick() {
@@ -36,17 +36,17 @@ export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbar
}
function handleInviteClick() {
- // 조건에 맞지 않는 벤더가 있다면 토스트 메시지 표시
+ // 조건에 맞지 않는 협력업체가 있다면 토스트 메시지 표시
if (ineligibleCount > 0) {
toast({
- title: "일부 벤더만 초대됩니다",
+ title: "일부 협력업체만 초대됩니다",
description: `선택한 ${selectedRows.length}개 중 ${eligibleVendors.length}개만 초대 가능합니다. 나머지 ${ineligibleCount}개는 초대 불가능한 상태입니다.`,
// variant: "warning",
})
}
}
- // 다이얼로그 표시 여부 - 적합한 벤더가 1개 이상 있으면 표시
+ // 다이얼로그 표시 여부 - 적합한 협력업체가 1개 이상 있으면 표시
const showInviteDialog = eligibleVendors.length > 0
return (
@@ -70,7 +70,7 @@ export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbar
variant="default"
size="sm"
disabled={true}
- title="선택된 벤더 중 초대 가능한 벤더가 없습니다"
+ title="선택된 협력업체 중 초대 가능한 협력업체가 없습니다"
>
초대 불가
</Button>
diff --git a/lib/rfqs/vendor-table/vendors-table.tsx b/lib/rfqs/vendor-table/vendors-table.tsx
index ae9cba41..b2e4d5ad 100644
--- a/lib/rfqs/vendor-table/vendors-table.tsx
+++ b/lib/rfqs/vendor-table/vendors-table.tsx
@@ -74,17 +74,17 @@ export function MatchedVendorsTable({ promises, rfqId, rfqType }: VendorsTablePr
async function openCommentSheet(vendorId: number) {
// Clear previous comments
setInitialComments([])
-
+
// Start loading
setIsLoadingComments(true)
-
+
// Open the sheet immediately with loading state
setSelectedVendorIdForComments(vendorId)
setCommentSheetOpen(true)
-
+
// (a) 현재 Row의 comments 불러옴
const comments = rowAction?.row.original.comments
-
+
try {
if (comments && comments.length > 0) {
// (b) 각 comment마다 첨부파일 fetch
@@ -107,7 +107,7 @@ export function MatchedVendorsTable({ promises, rfqId, rfqType }: VendorsTablePr
setIsLoadingComments(false)
}
}
-
+
// 6) 컬럼 정의 (memo)
const columns = React.useMemo(
() => getColumns({ setRowAction, router, openCommentSheet }),
@@ -164,10 +164,8 @@ export function MatchedVendorsTable({ promises, rfqId, rfqType }: VendorsTablePr
// 세션에서 userId 추출하고 숫자로 변환
const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0
- console.log(currentUserId,"currentUserId")
-
return (
- <div style={{ maxWidth: '80vw' }}>
+ <>
<DataTable
table={table}
>
@@ -205,6 +203,6 @@ export function MatchedVendorsTable({ promises, rfqId, rfqType }: VendorsTablePr
rowAction.row.original.comments = updatedComments
}}
/>
- </div>
+ </>
)
} \ No newline at end of file
diff --git a/lib/sedp/get-form-tags.ts b/lib/sedp/get-form-tags.ts
new file mode 100644
index 00000000..b488bfad
--- /dev/null
+++ b/lib/sedp/get-form-tags.ts
@@ -0,0 +1,380 @@
+// lib/sedp/get-tag.ts
+import db from "@/db/db";
+import {
+ contractItems,
+ tags,
+ forms,
+ items,
+ tagTypeClassFormMappings,
+ projects,
+ tagTypes,
+ tagClasses,
+ formMetas,
+ formEntries
+} from "@/db/schema";
+import { eq, and, like, inArray } from "drizzle-orm";
+import { getSEDPToken } from "./sedp-token";
+
+interface Attribute {
+ ATT_ID: string;
+ VALUE: any;
+ VALUE_DBL: number;
+ UOM_ID: string | null;
+}
+
+interface TagEntry {
+ TAG_NO: string;
+ TAG_DESC: string;
+ EP_ID: string;
+ TAG_TYPE_ID: string;
+ CLS_ID: string;
+ ATTRIBUTES: Attribute[];
+ [key: string]: any;
+}
+
+interface Column {
+ key: string;
+ label: string;
+ type: string;
+ shi?: boolean;
+}
+
+/**
+ * 태그 가져오기 서비스 함수
+ * contractItemId(packageId)를 기반으로 외부 시스템에서 태그 데이터를 가져와 DB에 저장
+ *
+ * @param formCode 양식 코드
+ * @param projectCode 프로젝트 코드
+ * @param packageId 계약 아이템 ID (contractItemId)
+ * @param progressCallback 진행 상황을 보고하기 위한 콜백 함수
+ * @returns 처리 결과 정보 (처리된 태그 수, 오류 목록 등)
+ */
+export async function importTagsFromSEDP(
+ formCode: string,
+ projectCode: string,
+ packageId: number,
+ progressCallback?: (progress: number) => void
+): Promise<{
+ processedCount: number;
+ excludedCount: number;
+ totalEntries: number;
+ errors?: string[];
+}> {
+ try {
+ // 진행 상황 보고
+ if (progressCallback) progressCallback(5);
+
+ // 에러 수집 배열
+ const errors: string[] = [];
+
+ // SEDP API에서 태그 데이터 가져오기
+ const tagData = await fetchTagDataFromSEDP(projectCode, formCode);
+
+ // 데이터 형식 처리
+ const tableName = Object.keys(tagData)[0];
+ if (!tableName || !tagData[tableName]) {
+ throw new Error("Invalid tag data format from SEDP API");
+ }
+
+ const tagEntries: TagEntry[] = tagData[tableName];
+ if (!Array.isArray(tagEntries) || tagEntries.length === 0) {
+ return {
+ processedCount: 0,
+ excludedCount: 0,
+ totalEntries: 0,
+ errors: ["No tag entries found in API response"]
+ };
+ }
+
+ // 진행 상황 보고
+ if (progressCallback) progressCallback(20);
+
+ // 프로젝트 ID 가져오기
+ const projectRecord = await db.select({ id: projects.id })
+ .from(projects)
+ .where(eq(projects.code, projectCode))
+ .limit(1);
+
+ if (!projectRecord || projectRecord.length === 0) {
+ throw new Error(`Project not found for code: ${projectCode}`);
+ }
+
+ const projectId = projectRecord[0].id;
+
+ // 양식 메타데이터 가져오기
+ const formMetaRecord = await db.select({ columns: formMetas.columns })
+ .from(formMetas)
+ .where(and(
+ eq(formMetas.projectId, projectId),
+ eq(formMetas.formCode, formCode)
+ ))
+ .limit(1);
+
+ if (!formMetaRecord || formMetaRecord.length === 0) {
+ throw new Error(`Form metadata not found for formCode: ${formCode} and projectId: ${projectId}`);
+ }
+
+ // 진행 상황 보고
+ if (progressCallback) progressCallback(30);
+
+ // 컬럼 정보 파싱
+ const columnsJSON: Column[] = JSON.parse(formMetaRecord[0].columns as string);
+
+ // 현재 formEntries 데이터 가져오기
+ const existingEntries = await db.select({ id: formEntries.id, data: formEntries.data })
+ .from(formEntries)
+ .where(and(
+ eq(formEntries.formCode, formCode),
+ eq(formEntries.contractItemId, packageId)
+ ));
+
+ // 진행 상황 보고
+ if (progressCallback) progressCallback(50);
+
+ // 기존 데이터를 맵으로 변환하여 태그 번호로 빠르게 조회할 수 있게 함
+ const existingTagMap = new Map();
+ existingEntries.forEach(entry => {
+ const data = entry.data as any[];
+ data.forEach(item => {
+ if (item.TAG_NO) {
+ existingTagMap.set(item.TAG_NO, {
+ entryId: entry.id,
+ data: item
+ });
+ }
+ });
+ });
+
+ // 진행 상황 보고
+ if (progressCallback) progressCallback(60);
+
+ // 처리 결과 카운터
+ let processedCount = 0;
+ let excludedCount = 0;
+
+ // 새로운 태그 데이터와 업데이트할 데이터 준비
+ const newTagData: any[] = [];
+ const updateData: {entryId: number, tagNo: string, updates: any}[] = [];
+
+ // SEDP 태그 데이터 처리
+ for (const tagEntry of tagEntries) {
+ try {
+ if (!tagEntry.TAG_NO) {
+ excludedCount++;
+ errors.push(`Missing TAG_NO in tag entry`);
+ continue;
+ }
+
+ // 기본 태그 데이터 객체 생성
+ const tagObject: any = {
+ TAG_NO: tagEntry.TAG_NO,
+ TAG_DESC: tagEntry.TAG_DESC || ""
+ };
+
+ // ATTRIBUTES 필드에서 shi=true인 컬럼의 값 추출
+ if (Array.isArray(tagEntry.ATTRIBUTES)) {
+ for (const attr of tagEntry.ATTRIBUTES) {
+ // 해당 어트리뷰트가 양식 메타에 있는지 확인
+ const columnInfo = columnsJSON.find(col => col.key === attr.ATT_ID);
+ if (columnInfo) {
+ // shi가 true인 컬럼이거나 필수 컬럼만 처리
+ if (columnInfo.shi === true) {
+ // 값 타입에 따른 변환
+ if (columnInfo.type === "NUMBER") {
+ // // 먼저 VALUE_DBL이 있는지 확인
+ // if (attr.VALUE_DBL !== undefined && attr.VALUE_DBL !== null) {
+ // tagObject[attr.ATT_ID] = attr.VALUE_DBL;
+ // }
+ // VALUE_DBL이 없으면 VALUE 사용 시도
+ if (attr.VALUE !== undefined && attr.VALUE !== null) {
+ // 문자열에서 숫자 추출
+ if (typeof attr.VALUE === 'string') {
+ // 문자열에서 첫 번째 숫자 부분 추출
+ const numberMatch = attr.VALUE.match(/(-?\d+(\.\d+)?)/);
+ if (numberMatch) {
+ tagObject[attr.ATT_ID] = parseFloat(numberMatch[0]);
+ } else {
+ // 숫자로 직접 변환 시도
+ const parsed = parseFloat(attr.VALUE);
+ if (!isNaN(parsed)) {
+ tagObject[attr.ATT_ID] = parsed;
+ }
+ }
+ } else if (typeof attr.VALUE === 'number') {
+ // 이미 숫자인 경우
+ tagObject[attr.ATT_ID] = attr.VALUE;
+ }
+ }
+ } else if (attr.VALUE !== null && attr.VALUE !== undefined) {
+ // 숫자 타입이 아닌 경우 VALUE 그대로 사용
+ tagObject[attr.ATT_ID] = attr.VALUE;
+ }
+ }
+ }
+ }
+ }
+ // 기존 태그가 있는지 확인하고 처리
+ const existingTag = existingTagMap.get(tagEntry.TAG_NO);
+ if (existingTag) {
+ // 기존 태그가 있으면 업데이트할 필드 찾기
+ const updates: any = {};
+ let hasUpdates = false;
+
+ // shi=true인 필드만 업데이트
+ for (const key of Object.keys(tagObject)) {
+ if (key === "TAG_NO") continue; // TAG_NO는 업데이트 안 함
+
+ // TAG_DESC는 항상 업데이트
+ if (key === "TAG_DESC" && tagObject[key] !== existingTag.data[key]) {
+ updates[key] = tagObject[key];
+ hasUpdates = true;
+ continue;
+ }
+
+ // 그 외 필드는 컬럼 정보에서 shi=true인 것만 업데이트
+ const columnInfo = columnsJSON.find(col => col.key === key);
+ if (columnInfo && columnInfo.shi === true) {
+ if (existingTag.data[key] !== tagObject[key]) {
+ updates[key] = tagObject[key];
+ hasUpdates = true;
+ }
+ }
+ }
+
+ // 업데이트할 내용이 있으면 추가
+ if (hasUpdates) {
+ updateData.push({
+ entryId: existingTag.entryId,
+ tagNo: tagEntry.TAG_NO,
+ updates
+ });
+ }
+ } else {
+ // 기존 태그가 없으면 새로 추가
+ newTagData.push(tagObject);
+ }
+
+ processedCount++;
+ } catch (error) {
+ excludedCount++;
+ errors.push(`Error processing tag ${tagEntry.TAG_NO || 'unknown'}: ${error}`);
+ }
+ }
+
+ // 진행 상황 보고
+ if (progressCallback) progressCallback(80);
+
+ // 업데이트 실행
+ for (const update of updateData) {
+ try {
+ const entry = existingEntries.find(e => e.id === update.entryId);
+ if (!entry) continue;
+
+ const data = entry.data as any[];
+ const updatedData = data.map(item => {
+ if (item.TAG_NO === update.tagNo) {
+ return { ...item, ...update.updates };
+ }
+ return item;
+ });
+
+ await db.update(formEntries)
+ .set({
+ data: updatedData,
+ updatedAt: new Date()
+ })
+ .where(eq(formEntries.id, update.entryId));
+ } catch (error) {
+ errors.push(`Error updating tag ${update.tagNo}: ${error}`);
+ }
+ }
+
+ // 새 태그 추가
+ if (newTagData.length > 0) {
+ // 기존 엔트리가 있으면 첫 번째 것에 추가
+ if (existingEntries.length > 0) {
+ const firstEntry = existingEntries[0];
+ const existingData = firstEntry.data as any[];
+ const updatedData = [...existingData, ...newTagData];
+
+ await db.update(formEntries)
+ .set({
+ data: updatedData,
+ updatedAt: new Date()
+ })
+ .where(eq(formEntries.id, firstEntry.id));
+ } else {
+ // 기존 엔트리가 없으면 새로 생성
+ await db.insert(formEntries)
+ .values({
+ formCode,
+ contractItemId: packageId,
+ data: newTagData,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ });
+ }
+ }
+
+ // 진행 상황 보고
+ if (progressCallback) progressCallback(100);
+
+ // 최종 결과 반환
+ return {
+ processedCount,
+ excludedCount,
+ totalEntries: tagEntries.length,
+ errors: errors.length > 0 ? errors : undefined
+ };
+ } catch (error: any) {
+ console.error("Tag import error:", error);
+ throw error;
+ }
+}
+
+/**
+ * SEDP API에서 태그 데이터 가져오기
+ *
+ * @param projectCode 프로젝트 코드
+ * @param formCode 양식 코드
+ * @returns API 응답 데이터
+ */
+async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Promise<any> {
+ try {
+ // Get the token
+ const apiKey = await getSEDPToken();
+
+ // Define the API base URL
+ const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api';
+
+ // Make the API call
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/Data/GetPubData`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ REG_TYPE_ID: formCode,
+ ContainDeleted: false
+ })
+ }
+ );
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`);
+ }
+
+ const data = await response.json();
+ return data;
+ } catch (error: any) {
+ console.error('Error calling SEDP API:', error);
+ throw new Error(`Failed to fetch data from SEDP API: ${error.message || 'Unknown error'}`);
+ }
+} \ No newline at end of file
diff --git a/lib/sedp/get-tags.ts b/lib/sedp/get-tags.ts
new file mode 100644
index 00000000..7c5661c3
--- /dev/null
+++ b/lib/sedp/get-tags.ts
@@ -0,0 +1,263 @@
+// lib/sedp/get-tag.ts
+import db from "@/db/db";
+import {
+ contractItems,
+ tags,
+ forms,
+ items,
+ tagTypeClassFormMappings,
+ projects,
+ tagTypes,
+ tagClasses
+} from "@/db/schema";
+import { eq, and, like } from "drizzle-orm";
+import { getSEDPToken } from "./sedp-token";
+
+/**
+ * 태그 가져오기 서비스 함수
+ * contractItemId(packageId)를 기반으로 외부 시스템에서 태그 데이터를 가져와 DB에 저장
+ *
+ * @param packageId 계약 아이템 ID (contractItemId)
+ * @param progressCallback 진행 상황을 보고하기 위한 콜백 함수
+ * @returns 처리 결과 정보 (처리된 태그 수, 오류 목록 등)
+ */
+// 함수 반환 타입 업데이트
+export async function importTagsFromSEDP(
+ packageId: number,
+ progressCallback?: (progress: number) => void
+): Promise<{
+ processedCount: number;
+ excludedCount: number;
+ totalEntries: number;
+ errors?: string[];
+}> {
+ try {
+ // 진행 상황 보고
+ if (progressCallback) progressCallback(5);
+
+ // Step 1: Get the contract item to find relevant data
+ const contractItem = await db.query.contractItems.findFirst({
+ where: eq(contractItems.id, packageId)
+ });
+
+ if (!contractItem) {
+ throw new Error(`Contract item with ID ${packageId} not found`);
+ }
+
+ // 진행 상황 보고
+ if (progressCallback) progressCallback(5);
+
+ // Step 1-2: Get the item using itemId from contractItem
+ const item = await db.query.items.findFirst({
+ where: eq(items.id, contractItem.itemId)
+ });
+
+ if (!item) {
+ throw new Error(`Item with ID ${contractItem.itemId} not found`);
+ }
+
+ const itemCode = item.itemCode;
+
+ // 진행 상황 보고
+ if (progressCallback) progressCallback(10);
+
+ // Step 2: Find the mapping entry with the item code in remark field
+ // 더 유연한 검색 패턴 사용 (%itemCode%)
+ const mapping = await db.query.tagTypeClassFormMappings.findFirst({
+ where: like(tagTypeClassFormMappings.remark, `%${itemCode}%`)
+ });
+
+ if (!mapping) {
+ throw new Error(`No mapping found for item code ${itemCode}`);
+ }
+
+ // 진행 상황 보고
+ if (progressCallback) progressCallback(15);
+
+ // Step 3: Get the project code
+ const project = await db.query.projects.findFirst({
+ where: eq(projects.id, mapping.projectId)
+ });
+
+ if (!project) {
+ throw new Error(`Project with ID ${mapping.projectId} not found`);
+ }
+
+ const projectCode = project.code;
+ const formCode = mapping.formCode;
+
+ // 진행 상황 보고
+ if (progressCallback) progressCallback(20);
+
+ // Step 4: Find the form ID
+ const form = await db.query.forms.findFirst({
+ where: and(
+ eq(forms.contractItemId, packageId),
+ eq(forms.formCode, formCode)
+ )
+ });
+
+ let formId = form?.id;
+
+ // If form doesn't exist, create it
+ if (!form) {
+ const insertResult = await db.insert(forms).values({
+ contractItemId: packageId,
+ formCode: formCode,
+ formName: mapping.formName
+ }).returning({ id: forms.id });
+
+ if (insertResult.length === 0) {
+ throw new Error('Failed to create form record');
+ }
+
+ formId = insertResult[0].id;
+ }
+
+ // 진행 상황 보고
+ if (progressCallback) progressCallback(30);
+
+ // Step 5: Call the external API to get tag data
+ const tagData = await fetchTagDataFromSEDP(projectCode, formCode);
+
+ // 진행 상황 보고
+ if (progressCallback) progressCallback(50);
+
+ // Step 6: Process the data and insert into the tags table
+ let processedCount = 0;
+ let excludedCount = 0;
+ const errors: string[] = [];
+
+ // Get the first key from the response as the table name
+ const tableName = Object.keys(tagData)[0];
+ const tagEntries = tagData[tableName];
+
+ if (!Array.isArray(tagEntries) || tagEntries.length === 0) {
+ throw new Error('No tag data found in the API response');
+ }
+
+ const totalEntries = tagEntries.length;
+
+ // Process each tag entry
+ for (let i = 0; i < tagEntries.length; i++) {
+ try {
+ const entry = tagEntries[i];
+
+ // TAG_TYPE_ID가 null이거나 빈 문자열인 경우 제외
+ if (entry.TAG_TYPE_ID === null || entry.TAG_TYPE_ID === "") {
+ excludedCount++;
+
+ // 주기적으로 진행 상황 보고 (건너뛰어도 진행률은 업데이트)
+ if (progressCallback && (i % 10 === 0 || i === tagEntries.length - 1)) {
+ progressCallback(Math.floor(50 + (i / tagEntries.length) * 50));
+ }
+
+ continue; // 이 항목은 건너뜀
+ }
+
+ // Get tag type description
+ const tagType = await db.query.tagTypes.findFirst({
+ where: and(
+ eq(tagTypes.code, entry.TAG_TYPE_ID),
+ eq(tagTypes.projectId, mapping.projectId)
+ )
+ });
+
+ // Get tag class label
+ const tagClass = await db.query.tagClasses.findFirst({
+ where: and(
+ eq(tagClasses.code, entry.CLS_ID),
+ eq(tagClasses.projectId, mapping.projectId)
+ )
+ });
+
+ // Insert or update the tag
+ await db.insert(tags).values({
+ contractItemId: packageId,
+ formId: formId,
+ tagNo: entry.TAG_NO,
+ tagType: tagType?.description || entry.TAG_TYPE_ID,
+ class: tagClass?.label || entry.CLS_ID,
+ description: entry.TAG_DESC
+ }).onConflictDoUpdate({
+ target: [tags.contractItemId, tags.tagNo],
+ set: {
+ formId: formId,
+ tagType: tagType?.description || entry.TAG_TYPE_ID,
+ class: tagClass?.label || entry.CLS_ID,
+ description: entry.TAG_DESC,
+ updatedAt: new Date()
+ }
+ });
+
+ processedCount++;
+
+ // 주기적으로 진행 상황 보고
+ if (progressCallback && (i % 10 === 0 || i === tagEntries.length - 1)) {
+ progressCallback(Math.floor(50 + (i / tagEntries.length) * 50));
+ }
+ } catch (error: any) {
+ console.error(`Error processing tag entry:`, error);
+ errors.push(error.message || 'Unknown error');
+ }
+ }
+
+ // 최종 결과 반환
+ return {
+ processedCount,
+ excludedCount,
+ totalEntries,
+ errors: errors.length > 0 ? errors : undefined
+ };
+ } catch (error: any) {
+ console.error("Tag import error:", error);
+ throw error;
+ }
+}
+
+/**
+ * SEDP API에서 태그 데이터 가져오기
+ *
+ * @param projectCode 프로젝트 코드
+ * @param formCode 양식 코드
+ * @returns API 응답 데이터
+ */
+async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Promise<any> {
+ try {
+ // Get the token
+ const apiKey = await getSEDPToken();
+
+ // Define the API base URL
+ const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api';
+
+ // Make the API call
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/Data/GetPubData`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ REG_TYPE_ID: formCode,
+ ContainDeleted: false
+ })
+ }
+ );
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`);
+ }
+
+ const data = await response.json();
+ return data;
+ } catch (error: any) {
+ console.error('Error calling SEDP API:', error);
+ throw new Error(`Failed to fetch data from SEDP API: ${error.message || 'Unknown error'}`);
+ }
+} \ No newline at end of file
diff --git a/lib/sedp/sync-form.ts b/lib/sedp/sync-form.ts
index b9e6fa90..a3caa809 100644
--- a/lib/sedp/sync-form.ts
+++ b/lib/sedp/sync-form.ts
@@ -1,13 +1,42 @@
// src/lib/cron/syncTagFormMappings.ts
import db from "@/db/db";
-import { projects, tagTypes, tagClasses, tagTypeClassFormMappings, formMetas } from '@/db/schema';
-import { eq, and, inArray } from 'drizzle-orm';
+import { projects, tagTypes, tagClasses, tagTypeClassFormMappings, formMetas, forms, contractItems, items } from '@/db/schema';
+import { eq, and, inArray, ilike } from 'drizzle-orm';
import { getSEDPToken } from "./sedp-token";
// 환경 변수
const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/dev/api';
// 인터페이스 정의
+interface TagTypeClassFormMapping {
+ projectId: number;
+ tagTypeLabel: string;
+ classLabel: string;
+ formCode: string;
+ formName: string;
+ remark: string | null;
+ ep: string;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+interface FormMeta {
+ projectId: number;
+ formCode: string;
+ formName: string;
+ columns: string; // JSON 문자열
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+interface FormRecord {
+ contractItemId: number;
+ formCode: string;
+ formName: string;
+ eng: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+}
interface Register {
PROJ_NO: string;
TYPE_ID: string;
@@ -137,6 +166,87 @@ interface FormColumn {
options?: string[];
uom?: string;
uomId?: string;
+ shi?: Boolean;
+}
+
+// 아이템 코드 추출 함수
+function extractItemCodes(remark: string | null): string[] {
+ if (!remark) return [];
+
+ // 검색용으로만 소문자로 변환
+ const remarkLower = remark.toLowerCase();
+
+ // 'vd_' 접두사 확인
+ const hasVD_ = remarkLower.includes("vd_");
+
+ if (!hasVD_) return [];
+
+ let vdPart = "";
+
+ // 'vd_'가 있으면 원본 문자열에서 추출 (소문자 버전이 아님)
+ if (hasVD_) {
+ const vdIndex = remarkLower.indexOf("vd_");
+ vdPart = remark.substring(vdIndex + 3); // 원본 문자열에서 추출
+ }
+
+ if (!vdPart) return [];
+
+ // 쉼표로 구분된 여러 itemCode 처리
+ return vdPart.split(",").map(code => code.trim());
+}
+
+async function getDefaulTAttributes(): Promise<string[]> {
+ try {
+ const apiKey = await getSEDPToken();
+
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/Dictionary/GetByKey`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ },
+ body: JSON.stringify({
+ Key: "DefaultAttributesToCompare",
+ })
+ }
+ );
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ console.warn(`디폴트 속성 찾을 수 없음`);
+ return [];
+ }
+ throw new Error(`코드 리스트 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ // 안전하게 JSON 파싱
+ try {
+ const data = await response.json();
+ // 데이터가 배열인지 확인하고 문자열 배열로 변환
+ if (Array.isArray(data)) {
+ return data as string[];
+ } else {
+ console.warn('응답이 배열 형식이 아닙니다');
+ return [];
+ }
+ } catch (parseError) {
+ console.error(`디폴트 속성 응답 파싱 실패:`, parseError);
+ // 응답 내용 로깅
+ try {
+ const text = await response.clone().text();
+ console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`);
+ } catch (textError) {
+ console.error('응답 내용 로깅 실패:', textError);
+ }
+ return [];
+ }
+ } catch (error) {
+ console.error(`디폴트 어트리뷰트 가져오기 실패:`, error);
+ throw error;
+ }
}
// 레지스터 데이터 가져오기
@@ -144,7 +254,7 @@ async function getRegisters(projectCode: string): Promise<Register[]> {
try {
// 토큰(API 키) 가져오기
const apiKey = await getSEDPToken();
-
+
const response = await fetch(
`${SEDP_API_BASE_URL}/Register/Get`,
{
@@ -156,36 +266,123 @@ async function getRegisters(projectCode: string): Promise<Register[]> {
'ProjectNo': projectCode
},
body: JSON.stringify({
- ContainDeleted: true
+ ProjectNo: projectCode,
+ ContainDeleted: false
})
}
);
-
+
if (!response.ok) {
throw new Error(`레지스터 요청 실패: ${response.status} ${response.statusText}`);
}
-
- const data = await response.json();
-
- // 결과가 배열인지 확인
- if (Array.isArray(data)) {
- return data;
- } else {
- // 단일 객체인 경우 배열로 변환
- return [data];
+
+ // 안전하게 JSON 파싱
+ let data;
+ try {
+ data = await response.json();
+ } catch (parseError) {
+ console.error(`프로젝트 ${projectCode}의 레지스터 응답 파싱 실패:`, parseError);
+ // 응답 내용 로깅
+ const text = await response.clone().text();
+ console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`);
+ throw new Error(`레지스터 응답 파싱 실패: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
}
+
+ // 결과를 배열로 변환 (단일 객체인 경우 배열로 래핑)
+ let registers: Register[] = Array.isArray(data) ? data : [data];
+
+ // MAP_CLS_ID가 비어있지 않고 REMARK가 vd, VD, vD, Vd 중 하나인 레지스터만 필터링
+ registers = registers.filter(register => {
+ // 삭제된 레지스터 제외
+ if (register.DELETED) return false;
+
+ // MAP_CLS_ID 배열이 존재하고 요소가 하나 이상 있는지 확인
+ const hasValidMapClsId = Array.isArray(register.MAP_CLS_ID) && register.MAP_CLS_ID.length > 0;
+
+ // REMARK가 'vd_' 또는 'vd' 포함 확인 (대소문자 구분 없이)
+ const remarkLower = register.REMARK && register.REMARK.toLowerCase();
+ const hasValidRemark = remarkLower && (remarkLower.includes('vd'));
+
+ // 두 조건 모두 충족해야 함
+ return hasValidMapClsId && hasValidRemark;
+ });
+
+ console.log(`프로젝트 ${projectCode}에서 ${registers.length}개의 유효한 레지스터를 가져왔습니다.`);
+ return registers;
} catch (error) {
console.error(`프로젝트 ${projectCode}의 레지스터 가져오기 실패:`, error);
throw error;
}
}
-// 특정 속성 가져오기
-async function getAttributeById(projectCode: string, attributeId: string): Promise<Attribute | null> {
+// 프로젝트의 모든 속성을 가져와 맵으로 반환
+async function getAttributes(projectCode: string): Promise<Map<string, Attribute>> {
try {
// 토큰(API 키) 가져오기
const apiKey = await getSEDPToken();
-
+
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/Attributes/Get`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ ContainDeleted: false
+ })
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`속성 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ // 안전하게 JSON 파싱
+ try {
+ const data = await response.json();
+
+ // 데이터가 배열인지 확인
+ const attributes: Attribute[] = Array.isArray(data) ? data : [data];
+
+ // ATT_ID로 효율적인 조회를 위한 맵 생성
+ const attributeMap = new Map<string, Attribute>();
+ for (const attribute of attributes) {
+ if (!attribute.DELETED) {
+ attributeMap.set(attribute.ATT_ID, attribute);
+ }
+ }
+
+ console.log(`프로젝트 ${projectCode}에서 ${attributeMap.size}개의 속성을 가져왔습니다`);
+ return attributeMap;
+
+ } catch (parseError) {
+ console.error(`프로젝트 ${projectCode}의 속성 응답 파싱 실패:`, parseError);
+ // 응답 내용 로깅
+ try {
+ const text = await response.clone().text();
+ console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`);
+ } catch (textError) {
+ console.error('응답 내용 로깅 실패:', textError);
+ }
+ return new Map();
+ }
+ } catch (error) {
+ console.error(`프로젝트 ${projectCode}의 속성 가져오기 실패:`, error);
+ return new Map();
+ }
+}
+
+// 특정 속성 가져오기 (하위 호환성을 위해 유지)
+async function getAttributeById(projectCode: string, attributeId: string, register: string): Promise<Attribute | null> {
+ try {
+ // 토큰(API 키) 가져오기
+ const apiKey = await getSEDPToken();
+
const response = await fetch(
`${SEDP_API_BASE_URL}/Attributes/GetByID`,
{
@@ -197,11 +394,13 @@ async function getAttributeById(projectCode: string, attributeId: string): Promi
'ProjectNo': projectCode
},
body: JSON.stringify({
- ATT_ID: attributeId
+ ProjectNo: projectCode,
+ ATT_ID: attributeId,
+ ContainDeleted: false
})
}
);
-
+
if (!response.ok) {
if (response.status === 404) {
console.warn(`속성 ID ${attributeId}를 찾을 수 없음`);
@@ -209,20 +408,96 @@ async function getAttributeById(projectCode: string, attributeId: string): Promi
}
throw new Error(`속성 요청 실패: ${response.status} ${response.statusText}`);
}
-
- return response.json();
+
+ // 안전하게 JSON 파싱
+ try {
+ const data = await response.json();
+ return data;
+ } catch (parseError) {
+ console.error(`속성 ID ${attributeId} ${register} ${projectCode} 응답 파싱 실패:`, parseError);
+ // 응답 내용 로깅
+ try {
+ const text = await response.clone().text();
+ console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`);
+ } catch (textError) {
+ console.error('응답 내용 로깅 실패:', textError);
+ }
+ return null;
+ }
} catch (error) {
console.error(`속성 ID ${attributeId} 가져오기 실패:`, error);
return null;
}
}
-// 특정 코드 리스트 가져오기
+// 프로젝트의 모든 코드 리스트를 가져와 맵으로 반환
+async function getCodeLists(projectCode: string): Promise<Map<string, CodeList>> {
+ try {
+ // 토큰(API 키) 가져오기
+ const apiKey = await getSEDPToken();
+
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/CodeList/Get`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ ContainDeleted: false
+ })
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`코드 리스트 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ // 안전하게 JSON 파싱
+ try {
+ const data = await response.json();
+
+ // 데이터가 배열인지 확인
+ const codeLists: CodeList[] = Array.isArray(data) ? data : [data];
+
+ // CL_ID로 효율적인 조회를 위한 맵 생성
+ const codeListMap = new Map<string, CodeList>();
+ for (const codeList of codeLists) {
+ if (!codeList.DELETED) {
+ codeListMap.set(codeList.CL_ID, codeList);
+ }
+ }
+
+ console.log(`프로젝트 ${projectCode}에서 ${codeListMap.size}개의 코드 리스트를 가져왔습니다`);
+ return codeListMap;
+
+ } catch (parseError) {
+ console.error(`프로젝트 ${projectCode}의 코드 리스트 응답 파싱 실패:`, parseError);
+ // 응답 내용 로깅
+ try {
+ const text = await response.clone().text();
+ console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`);
+ } catch (textError) {
+ console.error('응답 내용 로깅 실패:', textError);
+ }
+ return new Map();
+ }
+ } catch (error) {
+ console.error(`프로젝트 ${projectCode}의 코드 리스트 가져오기 실패:`, error);
+ return new Map();
+ }
+}
+
+// 특정 코드 리스트 가져오기 (하위 호환성을 위해 유지)
async function getCodeListById(projectCode: string, codeListId: string): Promise<CodeList | null> {
try {
// 토큰(API 키) 가져오기
const apiKey = await getSEDPToken();
-
+
const response = await fetch(
`${SEDP_API_BASE_URL}/CodeList/GetByID`,
{
@@ -234,11 +509,13 @@ async function getCodeListById(projectCode: string, codeListId: string): Promise
'ProjectNo': projectCode
},
body: JSON.stringify({
- CL_ID: codeListId
+ ProjectNo: projectCode,
+ CL_ID: codeListId,
+ ContainDeleted: false
})
}
);
-
+
if (!response.ok) {
if (response.status === 404) {
console.warn(`코드 리스트 ID ${codeListId}를 찾을 수 없음`);
@@ -246,20 +523,96 @@ async function getCodeListById(projectCode: string, codeListId: string): Promise
}
throw new Error(`코드 리스트 요청 실패: ${response.status} ${response.statusText}`);
}
-
- return response.json();
+
+ // 안전하게 JSON 파싱
+ try {
+ const data = await response.json();
+ return data;
+ } catch (parseError) {
+ console.error(`코드 리스트 ID ${codeListId} 응답 파싱 실패:`, parseError);
+ // 응답 내용 로깅
+ try {
+ const text = await response.clone().text();
+ console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`);
+ } catch (textError) {
+ console.error('응답 내용 로깅 실패:', textError);
+ }
+ return null;
+ }
} catch (error) {
console.error(`코드 리스트 ID ${codeListId} 가져오기 실패:`, error);
return null;
}
}
-// UOM 가져오기
+// 프로젝트의 모든 UOM을 가져와 맵으로 반환
+async function getUOMs(projectCode: string): Promise<Map<string, UOM>> {
+ try {
+ // 토큰(API 키) 가져오기
+ const apiKey = await getSEDPToken();
+
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/UOM/Get`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ ContainDeleted: false
+ })
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`UOM 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ // 안전하게 JSON 파싱
+ try {
+ const data = await response.json();
+
+ // 데이터가 배열인지 확인
+ const uoms: UOM[] = Array.isArray(data) ? data : [data];
+
+ // UOM_ID로 효율적인 조회를 위한 맵 생성
+ const uomMap = new Map<string, UOM>();
+ for (const uom of uoms) {
+ if (!uom.DELETED) {
+ uomMap.set(uom.UOM_ID, uom);
+ }
+ }
+
+ console.log(`프로젝트 ${projectCode}에서 ${uomMap.size}개의 UOM을 가져왔습니다`);
+ return uomMap;
+
+ } catch (parseError) {
+ console.error(`프로젝트 ${projectCode}의 UOM 응답 파싱 실패:`, parseError);
+ // 응답 내용 로깅
+ try {
+ const text = await response.clone().text();
+ console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`);
+ } catch (textError) {
+ console.error('응답 내용 로깅 실패:', textError);
+ }
+ return new Map();
+ }
+ } catch (error) {
+ console.error(`프로젝트 ${projectCode}의 UOM 가져오기 실패:`, error);
+ return new Map();
+ }
+}
+
+// UOM 가져오기 (하위 호환성을 위해 유지)
async function getUomById(projectCode: string, uomId: string): Promise<UOM | null> {
try {
// 토큰(API 키) 가져오기
const apiKey = await getSEDPToken();
-
+
const response = await fetch(
`${SEDP_API_BASE_URL}/UOM/GetByID`,
{
@@ -271,11 +624,13 @@ async function getUomById(projectCode: string, uomId: string): Promise<UOM | nul
'ProjectNo': projectCode
},
body: JSON.stringify({
- UOM_ID: uomId
+ UOMID: uomId, // API 명세서에 따라 UOMID 사용
+ ProjectNo: projectCode,
+ ContainDeleted: false
})
}
);
-
+
if (!response.ok) {
if (response.status === 404) {
console.warn(`UOM ID ${uomId}를 찾을 수 없음`);
@@ -283,90 +638,215 @@ async function getUomById(projectCode: string, uomId: string): Promise<UOM | nul
}
throw new Error(`UOM 요청 실패: ${response.status} ${response.statusText}`);
}
-
- return response.json();
+
+ // 안전하게 JSON 파싱
+ try {
+ const data = await response.json();
+ return data;
+ } catch (parseError) {
+ console.error(`UOM ID ${uomId} 응답 파싱 실패:`, parseError);
+ // 응답 내용 로깅
+ try {
+ const text = await response.clone().text();
+ console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`);
+ } catch (textError) {
+ console.error('응답 내용 로깅 실패:', textError);
+ }
+ return null;
+ }
} catch (error) {
console.error(`UOM ID ${uomId} 가져오기 실패:`, error);
return null;
}
}
+// contractItemId 조회 함수
+async function getContractItemsByItemCodes(itemCodes: string[]): Promise<Map<string, number>> {
+ try {
+ if (!itemCodes.length) return new Map();
+
+ // 먼저 itemCodes에 해당하는 item 레코드를 조회
+ const itemRecords = await db.select({
+ id: items.id,
+ itemCode: items.itemCode
+ })
+ .from(items)
+ .where(inArray(items.itemCode, itemCodes));
+
+ if (!itemRecords.length) {
+ console.log(`No items found for itemCodes: ${itemCodes.join(', ')}`);
+ return new Map();
+ }
+
+ // item ID 목록 추출
+ const itemIds = itemRecords.map(item => item.id);
+
+ // contractItems 조회
+ const contractItemRecords = await db.select({
+ id: contractItems.id,
+ itemId: contractItems.itemId
+ })
+ .from(contractItems)
+ .where(inArray(contractItems.itemId, itemIds));
+
+ // itemCode와 contractItemId의 매핑 생성
+ const itemCodeToContractItemId = new Map<string, number>();
+
+ for (const item of itemRecords) {
+ // itemCode가 null이 아닌 경우에만 처리
+ if (item.itemCode) {
+ const matchedContractItems = contractItemRecords.filter(ci => ci.itemId === item.id);
+ if (matchedContractItems.length > 0) {
+ // 일치하는 첫 번째 contractItem 사용
+ itemCodeToContractItemId.set(item.itemCode, matchedContractItems[0].id);
+ }
+ }
+ }
+
+ return itemCodeToContractItemId;
+ } catch (error) {
+ console.error('ContractItems 조회 중 오류 발생:', error);
+ return new Map();
+ }
+}
+
// 데이터베이스에 태그 타입 클래스 폼 매핑 및 폼 메타 저장
async function saveFormMappingsAndMetas(
- projectId: number,
+ projectId: number,
projectCode: string,
registers: Register[]
): Promise<number> {
try {
- // 프로젝트와 관련된 태그 타입 및 클래스 가져오기
+ // 프로젝트의 태그 타입과 클래스 가져오기
const tagTypeRecords = await db.select()
.from(tagTypes)
.where(eq(tagTypes.projectId, projectId));
-
+
const tagClassRecords = await db.select()
.from(tagClasses)
.where(eq(tagClasses.projectId, projectId));
-
- // 태그 타입과 클래스를 매핑
+
+ // 태그 타입과 클래스 매핑
const tagTypeMap = new Map(tagTypeRecords.map(type => [type.code, type]));
const tagClassMap = new Map(tagClassRecords.map(cls => [cls.code, cls]));
+
+ // 모든 속성, 코드 리스트, UOM을 한 번에 가져와 반복 API 호출 방지
+ const attributeMap = await getAttributes(projectCode);
+ const codeListMap = await getCodeLists(projectCode);
+ const uomMap = await getUOMs(projectCode);
- // 저장할 매핑 목록과 폼 메타 정보
- const mappingsToSave = [];
- const formMetasToSave = [];
+ // 기본 속성 가져오기
+ const defaultAttributes = await getDefaulTAttributes();
+
+ // 모든 register에서 itemCode를 추출하여 한 번에 조회
+ const allItemCodes: string[] = [];
+ registers.forEach(register => {
+ if (register.REMARK) {
+ const itemCodes = extractItemCodes(register.REMARK);
+ allItemCodes.push(...itemCodes);
+ }
+ });
+
+ // 중복 제거
+ const uniqueItemCodes = [...new Set(allItemCodes)];
- // 각 레지스터 처리
+ // 모든 itemCode에 대한 contractItemId 조회
+ const itemCodeToContractItemId = await getContractItemsByItemCodes(uniqueItemCodes);
+
+ console.log(`${uniqueItemCodes.length}개의 고유 itemCode 중 ${itemCodeToContractItemId.size}개의 contractItem을 찾았습니다`);
+
+ // 저장할 데이터 준비
+ const mappingsToSave: TagTypeClassFormMapping[] = [];
+ const formMetasToSave: FormMeta[] = [];
+ const formsToSave: FormRecord[] = [];
+
+ // 폼이 있는 contractItemId 트래킹
+ const contractItemIdsWithForms = new Set<number>();
+
+ // 각 register 처리
for (const register of registers) {
- // 삭제된 레지스터는 건너뜀
+ // 삭제된 register 건너뛰기
if (register.DELETED) continue;
-
- // 폼 메타 데이터를 위한 컬럼 정보 구성
+
+ // REMARK에서 itemCodes 추출
+ const itemCodes = extractItemCodes(register.REMARK || '');
+ if (!itemCodes.length) {
+ console.log(`Register ${register.TYPE_ID} (${register.DESC})의 REMARK에 유효한 itemCode가 없습니다`);
+ continue;
+ }
+
+ // 폼 메타용 columns 구성
const columns: FormColumn[] = [];
-
- // 각 속성 정보 수집
+
for (const linkAtt of register.LNK_ATT) {
- // 속성 가져오기
- const attribute = await getAttributeById(projectCode, linkAtt.ATT_ID);
-
- if (!attribute) continue;
-
- // 기본 컬럼 정보
+ let attribute = null;
+
+ // 기본 속성인지 확인
+ if (defaultAttributes && defaultAttributes.includes(linkAtt.ATT_ID)) {
+ // 기본 속성에 대한 기본 attribute 객체 생성
+ attribute = {
+ DESC: linkAtt.ATT_ID,
+ VAL_TYPE: 'STRING'
+ };
+ } else {
+ // 맵에서 속성 조회
+ attribute = attributeMap.get(linkAtt.ATT_ID);
+
+ // 속성을 찾지 못한 경우 다음으로 넘어감
+ if (!attribute) continue;
+ }
+
+ // 컬럼 정보 생성
const column: FormColumn = {
key: linkAtt.ATT_ID,
- label: linkAtt.CPY_DESC,
- type: attribute.VAL_TYPE || 'STRING'
+ label: attribute.DESC,
+ type: (attribute.VAL_TYPE === 'LIST' || attribute.VAL_TYPE === 'DYNAMICLIST')
+ ? 'LIST'
+ : (attribute.VAL_TYPE || 'STRING'),
+ shi: attribute.REMARK?.toLocaleLowerCase() === "shi"
};
-
- // 리스트 타입인 경우 옵션 추가
- if ((attribute.VAL_TYPE === 'LIST' || attribute.VAL_TYPE === 'DYNAMICLIST') && attribute.CL_ID) {
- const codeList = await getCodeListById(projectCode, attribute.CL_ID);
+
+ // 리스트 타입에 대한 옵션 추가 (기본 속성이 아닌 경우)
+ if (!defaultAttributes.includes(linkAtt.ATT_ID) &&
+ (attribute.VAL_TYPE === 'LIST' || attribute.VAL_TYPE === 'DYNAMICLIST') &&
+ attribute.CL_ID) {
+ // 맵에서 코드 리스트 조회
+ const codeList = codeListMap.get(attribute.CL_ID);
+
if (codeList && codeList.VALUES) {
- // 유효한 옵션만 필터링
- const options = codeList.VALUES
- .filter(value => value.USE_YN)
- .map(value => value.DESC);
-
+ const options = [...new Set(
+ codeList.VALUES
+ .filter(value => value.USE_YN)
+ .map(value => value.VALUE)
+ )];
+
if (options.length > 0) {
column.options = options;
}
}
}
-
+
// UOM 정보 추가
if (linkAtt.UOM_ID) {
- const uom = await getUomById(projectCode, linkAtt.UOM_ID);
-
+ const uom = uomMap.get(linkAtt.UOM_ID);
+
if (uom) {
column.uom = uom.SYMBOL;
column.uomId = uom.UOM_ID;
}
}
-
+
columns.push(column);
}
-
- // 폼 메타 정보 저장
+
+ // 컬럼이 없으면 건너뛰기
+ if (columns.length === 0) {
+ console.log(`폼 ${register.TYPE_ID} (${register.DESC})에 컬럼이 없어 건너뜁니다`);
+ continue;
+ }
+
+ // 폼 메타 데이터 준비
formMetasToSave.push({
projectId,
formCode: register.TYPE_ID,
@@ -375,25 +855,24 @@ async function saveFormMappingsAndMetas(
createdAt: new Date(),
updatedAt: new Date()
});
-
- // 관련된 클래스 매핑 처리
+
+ // 클래스 매핑 처리
for (const classId of register.MAP_CLS_ID) {
- // 해당 클래스와 태그 타입 확인
const tagClass = tagClassMap.get(classId);
-
+
if (!tagClass) {
- console.warn(`클래스 ID ${classId}를 프로젝트 ID ${projectId}에서 찾을 수 없음`);
+ console.warn(`프로젝트 ID ${projectId}에서 클래스 ID ${classId}를 찾을 수 없습니다`);
continue;
}
-
+
const tagTypeCode = tagClass.tagTypeCode;
const tagType = tagTypeMap.get(tagTypeCode);
-
+
if (!tagType) {
- console.warn(`태그 타입 ${tagTypeCode}를 프로젝트 ID ${projectId}에서 찾을 수 없음`);
+ console.warn(`프로젝트 ID ${projectId}에서 태그 타입 ${tagTypeCode}를 찾을 수 없습니다`);
continue;
}
-
+
// 매핑 정보 저장
mappingsToSave.push({
projectId,
@@ -401,32 +880,71 @@ async function saveFormMappingsAndMetas(
classLabel: tagClass.label,
formCode: register.TYPE_ID,
formName: register.DESC,
+ remark: register.REMARK,
+ ep: register.EP_ID,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ });
+ }
+
+ // 폼 레코드 준비
+ for (const itemCode of itemCodes) {
+ const contractItemId = itemCodeToContractItemId.get(itemCode);
+
+ if (!contractItemId) {
+ console.warn(`itemCode: ${itemCode}에 대한 contractItemId를 찾을 수 없습니다`);
+ continue;
+ }
+
+ // 폼이 있는 contractItemId 추적
+ contractItemIdsWithForms.add(contractItemId);
+
+ formsToSave.push({
+ contractItemId,
+ formCode: register.TYPE_ID,
+ formName: register.DESC,
+ eng: true,
createdAt: new Date(),
updatedAt: new Date()
});
}
}
-
- // 기존 데이터 삭제 후 새로 저장
- await db.delete(tagTypeClassFormMappings).where(eq(tagTypeClassFormMappings.projectId, projectId));
- await db.delete(formMetas).where(eq(formMetas.projectId, projectId));
-
+
+ // 트랜잭션으로 모든 작업 처리
let totalSaved = 0;
-
- // 매핑 정보 저장
- if (mappingsToSave.length > 0) {
- await db.insert(tagTypeClassFormMappings).values(mappingsToSave);
- totalSaved += mappingsToSave.length;
- console.log(`프로젝트 ID ${projectId}에 ${mappingsToSave.length}개의 태그 타입-클래스-폼 매핑 저장 완료`);
- }
-
- // 폼 메타 정보 저장
- if (formMetasToSave.length > 0) {
- await db.insert(formMetas).values(formMetasToSave);
- totalSaved += formMetasToSave.length;
- console.log(`프로젝트 ID ${projectId}에 ${formMetasToSave.length}개의 폼 메타 정보 저장 완료`);
- }
-
+
+ await db.transaction(async (tx) => {
+ // 기존 데이터 삭제
+ await tx.delete(tagTypeClassFormMappings).where(eq(tagTypeClassFormMappings.projectId, projectId));
+ await tx.delete(formMetas).where(eq(formMetas.projectId, projectId));
+
+ // 해당 contractItemId에 대한 기존 폼 삭제
+ if (contractItemIdsWithForms.size > 0) {
+ await tx.delete(forms).where(inArray(forms.contractItemId, [...contractItemIdsWithForms]));
+ }
+
+ // 매핑 저장
+ if (mappingsToSave.length > 0) {
+ await tx.insert(tagTypeClassFormMappings).values(mappingsToSave);
+ totalSaved += mappingsToSave.length;
+ console.log(`프로젝트 ID ${projectId}에 대해 ${mappingsToSave.length}개의 태그 타입-클래스-폼 매핑을 저장했습니다`);
+ }
+
+ // 폼 메타 저장
+ if (formMetasToSave.length > 0) {
+ await tx.insert(formMetas).values(formMetasToSave);
+ totalSaved += formMetasToSave.length;
+ console.log(`프로젝트 ID ${projectId}에 대해 ${formMetasToSave.length}개의 폼 메타 레코드를 저장했습니다`);
+ }
+
+ // 폼 레코드 저장
+ if (formsToSave.length > 0) {
+ await tx.insert(forms).values(formsToSave);
+ totalSaved += formsToSave.length;
+ console.log(`프로젝트 ID ${projectId}에 대해 ${formsToSave.length}개의 폼 레코드를 저장했습니다`);
+ }
+ });
+
return totalSaved;
} catch (error) {
console.error(`폼 매핑 및 메타 저장 실패 (프로젝트 ID: ${projectId}):`, error);
@@ -438,39 +956,39 @@ async function saveFormMappingsAndMetas(
export async function syncTagFormMappings() {
try {
console.log('태그 폼 매핑 동기화 시작:', new Date().toISOString());
-
+
// 모든 프로젝트 가져오기
const allProjects = await db.select().from(projects);
-
+
// 각 프로젝트에 대해 폼 매핑 동기화
const results = await Promise.allSettled(
allProjects.map(async (project: Project) => {
try {
// 레지스터 데이터 가져오기
const registers = await getRegisters(project.code);
-
+
// 데이터베이스에 저장
const count = await saveFormMappingsAndMetas(project.id, project.code, registers);
- return {
- project: project.code,
- success: true,
- count
+ return {
+ project: project.code,
+ success: true,
+ count
} as SyncResult;
} catch (error) {
console.error(`프로젝트 ${project.code} 폼 매핑 동기화 실패:`, error);
- return {
- project: project.code,
- success: false,
- error: error instanceof Error ? error.message : String(error)
+ return {
+ project: project.code,
+ success: false,
+ error: error instanceof Error ? error.message : String(error)
} as SyncResult;
}
})
);
-
+
// 결과 처리를 위한 배열 준비
const successfulResults: SyncResult[] = [];
const failedResults: SyncResult[] = [];
-
+
// 결과 분류
results.forEach((result) => {
if (result.status === 'fulfilled') {
@@ -488,19 +1006,19 @@ export async function syncTagFormMappings() {
});
}
});
-
+
const successCount = successfulResults.length;
const failCount = failedResults.length;
-
+
// 이제 안전하게 count 속성에 접근 가능
- const totalItems = successfulResults.reduce((sum, result) =>
+ const totalItems = successfulResults.reduce((sum, result) =>
sum + (result.count || 0), 0
);
-
+
console.log(`태그 폼 매핑 동기화 완료: ${successCount}개 프로젝트 성공 (총 ${totalItems}개 항목), ${failCount}개 프로젝트 실패`);
-
- return {
- success: successCount,
+
+ return {
+ success: successCount,
failed: failCount,
items: totalItems,
timestamp: new Date().toISOString()
diff --git a/lib/sedp/sync-object-class.ts b/lib/sedp/sync-object-class.ts
index 1cf0c23b..0a76c592 100644
--- a/lib/sedp/sync-object-class.ts
+++ b/lib/sedp/sync-object-class.ts
@@ -40,7 +40,12 @@ interface SyncResult {
count?: number;
error?: string;
}
-
+interface TagType {
+ TYPE_ID: string;
+ DESC: string;
+ PROJ_NO: string;
+ // 기타 필드들...
+}
// 오브젝트 클래스 데이터 가져오기
async function getObjectClasses(projectCode: string, token:string): Promise<ObjectClass[]> {
try {
@@ -55,7 +60,8 @@ async function getObjectClasses(projectCode: string, token:string): Promise<Obje
'ProjectNo': projectCode
},
body: JSON.stringify({
- ContainDeleted: true
+ ProjectNo:projectCode,
+ ContainDeleted: false
})
}
);
@@ -95,11 +101,171 @@ async function verifyTagTypes(projectId: number, tagTypeCodes: string[]): Promis
}
}
-// 데이터베이스에 오브젝트 클래스 저장 (upsert 사용)
-async function saveObjectClassesToDatabase(projectId: number, classes: ObjectClass[]): Promise<number> {
+async function saveTagTypesToDatabase(allTagTypes: TagType[], projectCode: string): Promise<void> {
+ try {
+ if (allTagTypes.length === 0) {
+ console.log(`프로젝트 ${projectCode}에 저장할 태그 타입이 없습니다.`);
+ return;
+ }
+
+ // 프로젝트 코드로 프로젝트 ID 조회
+ const projectResult = await db.select({ id: projects.id })
+ .from(projects)
+ .where(eq(projects.code, projectCode))
+ .limit(1);
+
+ if (projectResult.length === 0) {
+ throw new Error(`프로젝트 코드 ${projectCode}에 해당하는 프로젝트를 찾을 수 없습니다.`);
+ }
+
+ const projectId = projectResult[0].id;
+
+ // 현재 프로젝트의 모든 태그 타입 조회
+ const existingTagTypes = await db.select()
+ .from(tagTypes)
+ .where(eq(tagTypes.projectId, projectId));
+
+ // 코드 기준으로 맵 생성
+ const existingTagTypeMap = new Map(
+ existingTagTypes.map(type => [type.code, type])
+ );
+
+ // API에 있는 코드 목록
+ const apiTagTypeCodes = new Set(allTagTypes.map(type => type.TYPE_ID));
+
+ // 삭제할 코드 목록
+ const codesToDelete = existingTagTypes
+ .map(type => type.code)
+ .filter(code => !apiTagTypeCodes.has(code));
+
+ // 새로 추가할 항목
+ const toInsert = [];
+
+ // 업데이트할 항목
+ const toUpdate = [];
+
+ // 태그 타입 데이터 처리
+ for (const tagType of allTagTypes) {
+ // 데이터베이스 레코드 준비
+ const record = {
+ code: tagType.TYPE_ID,
+ projectId: projectId,
+ description: tagType.DESC,
+ updatedAt: new Date()
+ };
+
+ // 이미 존재하는 코드인지 확인
+ if (existingTagTypeMap.has(tagType.TYPE_ID)) {
+ // 업데이트 항목에 추가
+ toUpdate.push(record);
+ } else {
+ // 새로 추가할 항목에 추가 (createdAt 필드 추가)
+ toInsert.push({
+ ...record,
+ createdAt: new Date()
+ });
+ }
+ }
+
+ // 트랜잭션 실행
+
+ // 1. 새 항목 삽입
+ if (toInsert.length > 0) {
+ await db.insert(tagTypes).values(toInsert);
+ console.log(`프로젝트 ID ${projectId}에 ${toInsert.length}개의 새 태그 타입 추가 완료`);
+ }
+
+ // 2. 기존 항목 업데이트
+ for (const item of toUpdate) {
+ await db.update(tagTypes)
+ .set({
+ description: item.description,
+ updatedAt: item.updatedAt
+ })
+ .where(
+ and(
+ eq(tagTypes.code, item.code),
+ eq(tagTypes.projectId, item.projectId)
+ )
+ );
+ }
+
+ if (toUpdate.length > 0) {
+ console.log(`프로젝트 ID ${projectId}의 ${toUpdate.length}개 태그 타입 업데이트 완료`);
+ }
+
+ // 3. 더 이상 존재하지 않는 항목 삭제
+ if (codesToDelete.length > 0) {
+ for (const code of codesToDelete) {
+ await db.delete(tagTypes)
+ .where(
+ and(
+ eq(tagTypes.code, code),
+ eq(tagTypes.projectId, projectId)
+ )
+ );
+ }
+ console.log(`프로젝트 ID ${projectId}에서 ${codesToDelete.length}개의 태그 타입 삭제 완료`);
+ }
+
+ console.log(`프로젝트 ${projectCode}(ID: ${projectId})의 태그 타입 동기화 완료`);
+ } catch (error) {
+ console.error(`태그 타입 저장 실패 (프로젝트: ${projectCode}):`, error);
+ throw error;
+ }
+}
+
+async function getAllTagTypes(projectCode: string, token: string): Promise<TagType[]> {
+ try {
+ console.log(`프로젝트 ${projectCode}의 모든 태그 타입 가져오기 시작`);
+
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/TagType/Get`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': token,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ ContainDeleted: false
+ })
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`태그 타입 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+
+ // 결과가 배열인지 확인
+ if (Array.isArray(data)) {
+ return data;
+ } else {
+ // 단일 객체인 경우 배열로 변환
+ return [data];
+ }
+ } catch (error) {
+ console.error('태그 타입 목록 가져오기 실패:', error);
+ throw error;
+ }
+}
+
+// 4. 기존 함수 수정 - saveObjectClassesToDatabase
+async function saveObjectClassesToDatabase(
+ projectId: number,
+ classes: ObjectClass[],
+ projectCode: string,
+ token: string,
+ skipTagTypeSync: boolean = false // 태그 타입 동기화를 건너뛸지 여부
+): Promise<number> {
try {
// null이 아닌 TAG_TYPE_ID만 필터링
- const validClasses = classes.filter(cls => cls.TAG_TYPE_ID !== null);
+ const validClasses = classes.filter(cls => cls.TAG_TYPE_ID !== null && cls.TAG_TYPE_ID !== "") ;
if (validClasses.length === 0) {
console.log(`프로젝트 ID ${projectId}에 저장할 유효한 오브젝트 클래스가 없습니다.`);
@@ -109,6 +275,25 @@ async function saveObjectClassesToDatabase(projectId: number, classes: ObjectCla
// 모든 태그 타입 ID 목록 추출
const tagTypeCodes = validClasses.map(cls => cls.TAG_TYPE_ID!);
+ // skipTagTypeSync가 true인 경우 태그 타입 동기화 단계 건너뜀
+ if (!skipTagTypeSync) {
+ // 태그 타입이 없는 경우를 대비해 태그 타입 정보 먼저 가져와서 저장
+ console.log(`프로젝트 ID ${projectId}의 태그 타입 동기화 시작...`);
+
+ try {
+ // 프로젝트의 모든 태그 타입 가져오기
+ const allTagTypes = await getAllTagTypes(projectCode, token);
+
+ // 태그 타입 저장
+ await saveTagTypesToDatabase(allTagTypes, projectCode);
+ } catch (error) {
+ console.error(`프로젝트 ${projectCode}의 태그 타입 동기화 실패:`, error);
+ // 에러가 발생해도 계속 진행
+ }
+
+ console.log(`프로젝트 ID ${projectId}의 태그 타입 동기화 완료`);
+ }
+
// 존재하는 태그 타입 확인
const existingTagTypeCodes = await verifyTagTypes(projectId, tagTypeCodes);
@@ -122,6 +307,7 @@ async function saveObjectClassesToDatabase(projectId: number, classes: ObjectCla
return 0;
}
+ // 이하 기존 코드와 동일
// 현재 프로젝트의 오브젝트 클래스 코드 가져오기
const existingClasses = await db.select()
.from(tagClasses)
@@ -223,7 +409,7 @@ async function saveObjectClassesToDatabase(projectId: number, classes: ObjectCla
}
}
-// 메인 동기화 함수
+// 5. 메인 동기화 함수 수정
export async function syncObjectClasses() {
try {
console.log('오브젝트 클래스 동기화 시작:', new Date().toISOString());
@@ -234,15 +420,55 @@ export async function syncObjectClasses() {
// 2. 모든 프로젝트 가져오기
const allProjects = await db.select().from(projects);
- // 3. 각 프로젝트에 대해 오브젝트 클래스 동기화
+ // 3. 모든 프로젝트에 대해 먼저 태그 타입 동기화 (바로 이 부분이 추가됨)
+ console.log('모든 프로젝트의 태그 타입 동기화 시작...');
+
+ const tagTypeResults = await Promise.allSettled(
+ allProjects.map(async (project: Project) => {
+ try {
+ console.log(`프로젝트 ${project.code}의 태그 타입 동기화 시작...`);
+ // 프로젝트의 모든 태그 타입 가져오기
+ const allTagTypes = await getAllTagTypes(project.code, token);
+
+ // 태그 타입 저장
+ await saveTagTypesToDatabase(allTagTypes, project.code);
+ console.log(`프로젝트 ${project.code}의 태그 타입 동기화 완료`);
+
+ return {
+ project: project.code,
+ success: true,
+ count: allTagTypes.length
+ };
+ } catch (error) {
+ console.error(`프로젝트 ${project.code}의 태그 타입 동기화 실패:`, error);
+ return {
+ project: project.code,
+ success: false,
+ error: error instanceof Error ? error.message : String(error)
+ };
+ }
+ })
+ );
+
+ // 태그 타입 동기화 결과 집계
+ const tagTypeSuccessCount = tagTypeResults.filter(
+ result => result.status === 'fulfilled' && result.value.success
+ ).length;
+
+ const tagTypeFailCount = tagTypeResults.length - tagTypeSuccessCount;
+
+ console.log(`모든 프로젝트의 태그 타입 동기화 완료: ${tagTypeSuccessCount}개 성공, ${tagTypeFailCount}개 실패`);
+
+ // 4. 각 프로젝트에 대해 오브젝트 클래스 동기화 (태그 타입 동기화는 건너뜀)
const results = await Promise.allSettled(
allProjects.map(async (project: Project) => {
try {
// 오브젝트 클래스 데이터 가져오기
const objectClasses = await getObjectClasses(project.code, token);
- // 데이터베이스에 저장
- const count = await saveObjectClassesToDatabase(project.id, objectClasses);
+ // 데이터베이스에 저장 (skipTagTypeSync를 true로 설정하여 태그 타입 동기화 건너뜀)
+ const count = await saveObjectClassesToDatabase(project.id, objectClasses, project.code, token, true);
+
return {
project: project.code,
success: true,
@@ -291,10 +517,17 @@ export async function syncObjectClasses() {
console.log(`오브젝트 클래스 동기화 완료: ${successCount}개 프로젝트 성공 (총 ${totalItems}개 항목), ${failCount}개 프로젝트 실패`);
+ // 전체 결과에 태그 타입 동기화 결과도 포함
return {
- success: successCount,
- failed: failCount,
- items: totalItems,
+ tagTypeSync: {
+ success: tagTypeSuccessCount,
+ failed: tagTypeFailCount
+ },
+ objectClassSync: {
+ success: successCount,
+ failed: failCount,
+ items: totalItems
+ },
timestamp: new Date().toISOString()
};
} catch (error) {
diff --git a/lib/sedp/sync-projects.ts b/lib/sedp/sync-projects.ts
index 1094b55f..0f5ed2a8 100644
--- a/lib/sedp/sync-projects.ts
+++ b/lib/sedp/sync-projects.ts
@@ -38,15 +38,12 @@ async function getProjects(): Promise<Project[]> {
const response = await fetch(
`${SEDP_API_BASE_URL}/Project/Get`,
{
- method: 'POST',
+ method: 'GET',
headers: {
'Content-Type': 'application/json',
'accept': '*/*',
'ApiKey': apiKey
- },
- body: JSON.stringify({
- ContainDeleted: true
- })
+ }
}
);
diff --git a/lib/sedp/sync-tag-types.ts b/lib/sedp/sync-tag-types.ts
index 2d19fc19..8233badd 100644
--- a/lib/sedp/sync-tag-types.ts
+++ b/lib/sedp/sync-tag-types.ts
@@ -118,7 +118,7 @@ async function getTagTypes(projectCode: string, token: string): Promise<TagType[
},
body: JSON.stringify({
ProjectNo: projectCode,
- ContainDeleted: true
+ ContainDeleted: false
})
}
);
@@ -149,7 +149,7 @@ async function getAttributes(projectCode: string, token: string): Promise<Attrib
},
body: JSON.stringify({
ProjectNo: projectCode,
- ContainDeleted: true
+ ContainDeleted: false
})
}
);
@@ -170,7 +170,7 @@ async function getAttributes(projectCode: string, token: string): Promise<Attrib
async function getCodeList(projectCode: string, codeListId: string, token: string): Promise<CodeList | null> {
try {
const response = await fetch(
- `${SEDP_API_BASE_URL}/CodeList/Get`,
+ `${SEDP_API_BASE_URL}/CodeList/GetByID`,
{
method: 'POST',
headers: {
@@ -182,7 +182,7 @@ async function getCodeList(projectCode: string, codeListId: string, token: strin
body: JSON.stringify({
ProjectNo: projectCode,
CL_ID: codeListId,
- ContainDeleted: true
+ ContainDeleted: false
})
}
);
@@ -299,34 +299,64 @@ async function processAndSaveTagSubfields(
// 1. 새 서브필드 삽입
if (toInsert.length > 0) {
- await db.insert(tagSubfields).values(toInsert);
- totalChanged += toInsert.length;
- console.log(`프로젝트 ID ${projectId}에 ${toInsert.length}개의 새 태그 서브필드 추가 완료`);
+ // 중복 제거를 위한 Map 생성 (마지막 항목만 유지)
+ const uniqueInsertMap = new Map();
+
+ for (const item of toInsert) {
+ const compositeKey = `${item.projectId}:${item.tagTypeCode}:${item.attributesId}`;
+ uniqueInsertMap.set(compositeKey, item);
+ }
+
+ // 중복이 제거된 배열 생성
+ const deduplicatedInserts = Array.from(uniqueInsertMap.values());
+
+ // 중복 제거된 항목만 삽입
+ await db.insert(tagSubfields).values(deduplicatedInserts);
+
+ // 중복 제거 전후 개수 로그
+ console.log(`프로젝트 ID ${projectId}에 ${deduplicatedInserts.length}개의 새 태그 서브필드 추가 완료 (중복 제거 전: ${toInsert.length}개)`);
+ totalChanged += deduplicatedInserts.length;
}
- // 2. 기존 서브필드 업데이트
- for (const item of toUpdate) {
- await db.update(tagSubfields)
- .set({
- attributesDescription: item.attributesDescription,
- expression: item.expression,
- delimiter: item.delimiter,
- sortOrder: item.sortOrder,
- updatedAt: item.updatedAt
- })
- .where(
- and(
- eq(tagSubfields.projectId, item.projectId),
- eq(tagSubfields.tagTypeCode, item.tagTypeCode),
- eq(tagSubfields.attributesId, item.attributesId)
- )
- );
- totalChanged += 1;
- }
- if (toUpdate.length > 0) {
- console.log(`프로젝트 ID ${projectId}의 ${toUpdate.length}개 태그 서브필드 업데이트 완료`);
- }
+ // 2. 기존 서브필드 업데이트
+if (toUpdate.length > 0) {
+ // 중복 제거를 위한 Map 생성 (마지막 항목만 유지)
+ const uniqueUpdateMap = new Map();
+
+ for (const item of toUpdate) {
+ const compositeKey = `${item.projectId}:${item.tagTypeCode}:${item.attributesId}`;
+ uniqueUpdateMap.set(compositeKey, item);
+ }
+
+ // 중복이 제거된 배열 생성
+ const deduplicatedUpdates = Array.from(uniqueUpdateMap.values());
+
+ // 중복 제거 전후 개수 로그
+ console.log(`프로젝트 ID ${projectId}의 ${deduplicatedUpdates.length}개 태그 서브필드 업데이트 시작 (중복 제거 전: ${toUpdate.length}개)`);
+
+ // 각 항목 개별 업데이트
+ for (const item of deduplicatedUpdates) {
+ await db.update(tagSubfields)
+ .set({
+ attributesDescription: item.attributesDescription,
+ expression: item.expression,
+ delimiter: item.delimiter,
+ sortOrder: item.sortOrder,
+ updatedAt: item.updatedAt
+ })
+ .where(
+ and(
+ eq(tagSubfields.projectId, item.projectId),
+ eq(tagSubfields.tagTypeCode, item.tagTypeCode),
+ eq(tagSubfields.attributesId, item.attributesId)
+ )
+ );
+ totalChanged += 1;
+ }
+
+ console.log(`프로젝트 ID ${projectId}의 ${deduplicatedUpdates.length}개 태그 서브필드 업데이트 완료`);
+}
// 3. 더 이상 존재하지 않는 서브필드 삭제
if (keysToDelete.length > 0) {
diff --git a/lib/tag-numbering/service.ts b/lib/tag-numbering/service.ts
index 6041f07c..3256b185 100644
--- a/lib/tag-numbering/service.ts
+++ b/lib/tag-numbering/service.ts
@@ -86,13 +86,13 @@ export async function getTagNumbering(input: GetTagNumberigSchema) {
- export const fetchTagSubfieldOptions = (async (attributesId: string): Promise<TagSubfieldOption[]> => {
+ export const fetchTagSubfieldOptions = (async (attributesId: string,projectId:number ): Promise<TagSubfieldOption[]> => {
try {
// (A) findMany -> 스키마 제네릭 누락 에러 발생 → 대신 select().from().where() 사용
const rows = await db
.select()
.from(tagSubfieldOptions)
- .where(eq(tagSubfieldOptions.attributesId, attributesId))
+ .where(and(eq(tagSubfieldOptions.attributesId, attributesId),eq(tagSubfieldOptions.projectId, projectId)))
.orderBy(asc(tagSubfieldOptions.code))
// rows는 TagSubfieldOption[] 형태
diff --git a/lib/tag-numbering/table/meta-sheet.tsx b/lib/tag-numbering/table/meta-sheet.tsx
index 4221837c..fd14e117 100644
--- a/lib/tag-numbering/table/meta-sheet.tsx
+++ b/lib/tag-numbering/table/meta-sheet.tsx
@@ -64,7 +64,7 @@ export function ViewTagOptions({
setLoading(true)
try {
// 서버 액션 호출 - attributesId와 일치하는 모든 옵션 가져오기
- const optionsData = await fetchTagSubfieldOptions(tagSubfield.attributesId)
+ const optionsData = await fetchTagSubfieldOptions(tagSubfield.attributesId, tagSubfield.projectId)
setOptions(optionsData || [])
} catch (error) {
console.error("Error fetching tag options:", error)
diff --git a/lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx b/lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx
index 7a14817f..9200e81b 100644
--- a/lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx
+++ b/lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx
@@ -23,7 +23,7 @@ export function TagNumberingTableToolbarActions({ table }: ItemsTableToolbarActi
setIsLoading(true)
// API 엔드포인트 호출
- const response = await fetch('/api/cron/object-classes')
+ const response = await fetch('/api/cron/tag-types')
if (!response.ok) {
const errorData = await response.json()
diff --git a/lib/tag-numbering/table/tagNumbering-table.tsx b/lib/tag-numbering/table/tagNumbering-table.tsx
index 6ca46e05..847b3eeb 100644
--- a/lib/tag-numbering/table/tagNumbering-table.tsx
+++ b/lib/tag-numbering/table/tagNumbering-table.tsx
@@ -132,17 +132,27 @@ export function TagNumberingTable({ promises }: ItemsTableProps) {
shallow: false,
clearOnDefault: true,
})
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+
+ const handleCompactChange = React.useCallback((compact: boolean) => {
+ setIsCompact(compact)
+ }, [])
+
return (
<>
<DataTable
table={table}
+ compact={isCompact}
>
<DataTableAdvancedToolbar
table={table}
filterFields={advancedFilterFields}
shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="tagNumberingTableCompact"
+ onCompactChange={handleCompactChange}
>
<TagNumberingTableToolbarActions table={table} />
</DataTableAdvancedToolbar>
diff --git a/lib/tags/service.ts b/lib/tags/service.ts
index 8477b1fb..b02f5dc2 100644
--- a/lib/tags/service.ts
+++ b/lib/tags/service.ts
@@ -7,7 +7,7 @@ import { createTagSchema, GetTagsSchema, updateTagSchema, UpdateTagSchema, type
import { revalidateTag, unstable_noStore } from "next/cache";
import { filterColumns } from "@/lib/filter-columns";
import { unstable_cache } from "@/lib/unstable-cache";
-import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, sql, ne ,count,isNull} from "drizzle-orm";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, sql, ne, count, isNull } from "drizzle-orm";
import { countTags, insertTag, selectTags } from "./repository";
import { getErrorMessage } from "../handle-error";
import { getFormMappingsByTagType } from './form-mapping-service';
@@ -29,7 +29,7 @@ export async function getTags(input: GetTagsSchema, packagesId: number) {
try {
const offset = (input.page - 1) * input.perPage;
- // (1) advancedWhere
+ // (1) advancedWhere
const advancedWhere = filterColumns({
table: tags,
filters: input.filters,
@@ -110,14 +110,14 @@ export async function createTag(
return await db.transaction(async (tx) => {
// 1) 선택된 contractItem의 contractId 가져오기
const contractItemResult = await tx
- .select({
- contractId: contractItems.contractId,
- projectId: contracts.projectId // projectId 추가
- })
- .from(contractItems)
- .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인
- .where(eq(contractItems.id, selectedPackageId))
- .limit(1)
+ .select({
+ contractId: contractItems.contractId,
+ projectId: contracts.projectId // projectId 추가
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인
+ .where(eq(contractItems.id, selectedPackageId))
+ .limit(1)
if (contractItemResult.length === 0) {
return { error: "Contract item not found" }
@@ -160,7 +160,7 @@ export async function createTag(
projectId
)
}
-
+
// 4) 이 태그 타입에 대한 주요 폼(첫 번째 폼)을 찾거나 생성
let primaryFormId: number | null = null
@@ -199,6 +199,7 @@ export async function createTag(
contractItemId: selectedPackageId,
formCode: formMapping.formCode,
formName: formMapping.formName,
+ im: true
})
.returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName })
@@ -253,6 +254,125 @@ export async function createTag(
}
}
+export async function createTagInForm(
+ formData: CreateTagSchema,
+ selectedPackageId: number | null,
+ formCode: string
+) {
+ if (!selectedPackageId) {
+ return { error: "No selectedPackageId provided" }
+ }
+
+ // Validate formData
+ const validated = createTagSchema.safeParse(formData)
+ if (!validated.success) {
+ return { error: validated.error.flatten().formErrors.join(", ") }
+ }
+
+ // React 서버 액션에서 매 요청마다 실행
+ unstable_noStore()
+
+ try {
+ // 하나의 트랜잭션에서 모든 작업 수행
+ return await db.transaction(async (tx) => {
+ // 1) 선택된 contractItem의 contractId 가져오기
+ const contractItemResult = await tx
+ .select({
+ contractId: contractItems.contractId,
+ projectId: contracts.projectId // projectId 추가
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인
+ .where(eq(contractItems.id, selectedPackageId))
+ .limit(1)
+
+ if (contractItemResult.length === 0) {
+ return { error: "Contract item not found" }
+ }
+
+ const contractId = contractItemResult[0].contractId
+ const projectId = contractItemResult[0].projectId
+
+ // 2) 해당 계약 내에서 같은 tagNo를 가진 태그가 있는지 확인
+ const duplicateCheck = await tx
+ .select({ count: sql<number>`count(*)` })
+ .from(tags)
+ .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id))
+ .where(
+ and(
+ eq(contractItems.contractId, contractId),
+ eq(tags.tagNo, validated.data.tagNo)
+ )
+ )
+
+ if (duplicateCheck[0].count > 0) {
+ return {
+ error: `태그 번호 "${validated.data.tagNo}"는 이미 이 계약 내에 존재합니다.`,
+ }
+ }
+
+ const form = await db.query.forms.findFirst({
+ where: eq(forms.formCode, formCode)
+ });
+
+ if (form?.id) {
+ // 5) 새 Tag 생성 (같은 트랜잭션 `tx` 사용)
+ const [newTag] = await insertTag(tx, {
+ contractItemId: selectedPackageId,
+ formId: form.id,
+ tagNo: validated.data.tagNo,
+ class: validated.data.class,
+ tagType: validated.data.tagType,
+ description: validated.data.description ?? null,
+ })
+
+ let updatedData: Array<{
+ TAG_NO: string;
+ TAG_DESC?: string;
+ }> = [];
+
+ updatedData.push({
+ TAG_NO: validated.data.tagNo,
+ TAG_DESC: validated.data.description ?? null,
+ });
+
+ const entry = await db.query.formEntries.findFirst({
+ where: and(
+ eq(formEntries.formCode, formCode),
+ eq(formEntries.contractItemId, selectedPackageId),
+ )
+ });
+
+ if (entry && entry.id && updatedData.length > 0) {
+ await db
+ .update(formEntries)
+ .set({ data: updatedData })
+ .where(eq(formEntries.id, entry.id));
+ }
+
+ console.log(`tags-${selectedPackageId}`, "create", newTag)
+
+ }
+
+ // 6) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시)
+ revalidateTag(`tags-${selectedPackageId}`)
+ revalidateTag(`forms-${selectedPackageId}`)
+ revalidateTag("tags")
+
+ // 7) 성공 시 반환
+ return {
+ success: true,
+ data: null
+ }
+ })
+ } catch (err: any) {
+ console.log("createTag in Form error:", err)
+
+ console.error("createTag in Form error:", err)
+ return { error: getErrorMessage(err) }
+ }
+}
+
export async function updateTag(
formData: UpdateTagSchema & { id: number },
selectedPackageId: number | null
@@ -292,14 +412,14 @@ export async function updateTag(
// 2) 선택된 contractItem의 contractId 가져오기
const contractItemResult = await tx
- .select({
- contractId: contractItems.contractId,
- projectId: contracts.projectId // projectId 추가
- })
- .from(contractItems)
- .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인
- .where(eq(contractItems.id, selectedPackageId))
- .limit(1)
+ .select({
+ contractId: contractItems.contractId,
+ projectId: contracts.projectId // projectId 추가
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인
+ .where(eq(contractItems.id, selectedPackageId))
+ .limit(1)
if (contractItemResult.length === 0) {
return { error: "Contract item not found" }
@@ -330,8 +450,8 @@ export async function updateTag(
}
// 4) 태그 타입이나 클래스가 변경되었는지 확인
- const isTagTypeOrClassChanged =
- originalTag.tagType !== validated.data.tagType ||
+ const isTagTypeOrClassChanged =
+ originalTag.tagType !== validated.data.tagType ||
originalTag.class !== validated.data.class
let primaryFormId = originalTag.formId
@@ -459,17 +579,17 @@ export async function bulkCreateTags(
selectedPackageId: number
) {
unstable_noStore();
-
+
if (!tagsfromExcel.length) {
return { error: "No tags provided" };
}
-
+
try {
// 단일 트랜잭션으로 모든 작업 처리
return await db.transaction(async (tx) => {
// 1. 컨트랙트 ID 및 프로젝트 ID 조회 (한 번만)
const contractItemResult = await tx
- .select({
+ .select({
contractId: contractItems.contractId,
projectId: contracts.projectId // projectId 추가
})
@@ -477,14 +597,14 @@ export async function bulkCreateTags(
.innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인
.where(eq(contractItems.id, selectedPackageId))
.limit(1);
-
+
if (contractItemResult.length === 0) {
return { error: "Contract item not found" };
}
-
+
const contractId = contractItemResult[0].contractId;
const projectId = contractItemResult[0].projectId; // projectId 추출
-
+
// 2. 모든 태그 번호 중복 검사 (한 번에)
const tagNos = tagsfromExcel.map(tag => tag.tagNo);
const duplicateCheck = await tx
@@ -495,24 +615,24 @@ export async function bulkCreateTags(
eq(contractItems.contractId, contractId),
inArray(tags.tagNo, tagNos)
));
-
+
if (duplicateCheck.length > 0) {
return {
error: `태그 번호 "${duplicateCheck.map(d => d.tagNo).join(', ')}"는 이미 존재합니다.`
};
}
-
+
// 3. 태그별 폼 정보 처리 및 태그 생성
const createdTags = [];
const allFormsInfo = []; // 모든 태그에 대한 폼 정보 저장
// 태그 유형별 폼 매핑 캐싱 (성능 최적화)
const formMappingsCache = new Map();
-
+
for (const tagData of tagsfromExcel) {
// 캐시 키 생성 (tagType + class)
const cacheKey = `${tagData.tagType}|${tagData.class || 'NONE'}`;
-
+
// 폼 매핑 가져오기 (캐시 사용)
let formMappings;
if (formMappingsCache.has(cacheKey)) {
@@ -526,11 +646,11 @@ export async function bulkCreateTags(
);
formMappingsCache.set(cacheKey, formMappings);
}
-
+
// 폼 처리 로직
let primaryFormId: number | null = null;
const createdOrExistingForms: CreatedOrExistingForm[] = [];
-
+
if (formMappings && formMappings.length > 0) {
for (const formMapping of formMappings) {
// 해당 폼이 이미 존재하는지 확인
@@ -590,7 +710,7 @@ export async function bulkCreateTags(
projectId
);
}
-
+
// 태그 생성
const [newTag] = await insertTag(tx, {
contractItemId: selectedPackageId,
@@ -600,9 +720,9 @@ export async function bulkCreateTags(
tagType: tagData.tagType,
description: tagData.description || null,
});
-
+
createdTags.push(newTag);
-
+
// 해당 태그의 폼 정보 저장
allFormsInfo.push({
tagNo: tagData.tagNo,
@@ -610,12 +730,12 @@ export async function bulkCreateTags(
primaryFormId,
});
}
-
+
// 4. 캐시 무효화 (한 번만)
revalidateTag(`tags-${selectedPackageId}`);
revalidateTag(`forms-${selectedPackageId}`);
revalidateTag("tags");
-
+
return {
success: true,
data: {
@@ -644,33 +764,33 @@ function removeTagFromDataJson(
tagNo: string
): any {
// data 구조가 어떻게 생겼는지에 따라 로직이 달라집니다.
- // 예: data 배열 안에 { tagNumber: string, ... } 형태로 여러 객체가 있다고 가정
+ // 예: data 배열 안에 { TAG_NO: string, ... } 형태로 여러 객체가 있다고 가정
if (!Array.isArray(dataJson)) return dataJson
- return dataJson.filter((entry) => entry.tagNumber !== tagNo)
+ return dataJson.filter((entry) => entry.TAG_NO !== tagNo)
}
export async function removeTags(input: RemoveTagsInput) {
unstable_noStore() // React 서버 액션 무상태 함수
-
+
const { ids, selectedPackageId } = input
-
+
try {
await db.transaction(async (tx) => {
const packageInfo = await tx
- .select({
- projectId: contracts.projectId
- })
- .from(contractItems)
- .innerJoin(contracts, eq(contractItems.contractId, contracts.id))
- .where(eq(contractItems.id, selectedPackageId))
- .limit(1);
-
- if (packageInfo.length === 0) {
- throw new Error(`Contract item with ID ${selectedPackageId} not found`);
- }
-
- const projectId = packageInfo[0].projectId;
+ .select({
+ projectId: contracts.projectId
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id))
+ .where(eq(contractItems.id, selectedPackageId))
+ .limit(1);
+
+ if (packageInfo.length === 0) {
+ throw new Error(`Contract item with ID ${selectedPackageId} not found`);
+ }
+
+ const projectId = packageInfo[0].projectId;
// 1) 삭제 대상 tag들을 미리 조회
const tagsToDelete = await tx
@@ -682,7 +802,7 @@ export async function removeTags(input: RemoveTagsInput) {
})
.from(tags)
.where(inArray(tags.id, ids))
-
+
// 2) 태그 타입과 클래스의 고유 조합 추출
const uniqueTypeClassCombinations = [...new Set(
tagsToDelete.map(tag => `${tag.tagType}|${tag.class || ''}`)
@@ -690,7 +810,7 @@ export async function removeTags(input: RemoveTagsInput) {
const [tagType, classValue] = combo.split('|');
return { tagType, class: classValue || undefined };
});
-
+
// 3) 각 태그 타입/클래스 조합에 대해 처리
for (const { tagType, class: classValue } of uniqueTypeClassCombinations) {
// 3-1) 삭제 중인 태그들 외에, 동일한 태그 타입/클래스를 가진 다른 태그가 있는지 확인
@@ -705,18 +825,18 @@ export async function removeTags(input: RemoveTagsInput) {
eq(tags.contractItemId, selectedPackageId) // 같은 contractItemId 내에서만 확인
)
)
-
+
// 3-2) 이 태그 타입/클래스에 연결된 폼 매핑 가져오기
- const formMappings = await getFormMappingsByTagType(tagType,projectId,classValue);
-
+ const formMappings = await getFormMappingsByTagType(tagType, projectId, classValue);
+
if (!formMappings.length) continue;
-
+
// 3-3) 이 태그 타입/클래스와 관련된 태그 번호 추출
const relevantTagNos = tagsToDelete
- .filter(tag => tag.tagType === tagType &&
- (classValue ? tag.class === classValue : !tag.class))
+ .filter(tag => tag.tagType === tagType &&
+ (classValue ? tag.class === classValue : !tag.class))
.map(tag => tag.tagNo);
-
+
// 3-4) 각 폼 코드에 대해 처리
for (const formMapping of formMappings) {
// 다른 태그가 없다면 폼 삭제
@@ -730,7 +850,7 @@ export async function removeTags(input: RemoveTagsInput) {
eq(forms.formCode, formMapping.formCode)
)
)
-
+
// formEntries 테이블에서도 해당 formCode 관련 데이터 삭제
await tx
.delete(formEntries)
@@ -740,7 +860,7 @@ export async function removeTags(input: RemoveTagsInput) {
eq(formEntries.formCode, formMapping.formCode)
)
)
- }
+ }
// 다른 태그가 있다면 formEntries 데이터에서 해당 태그 정보만 제거
else if (relevantTagNos.length > 0) {
const formEntryRecords = await tx
@@ -755,16 +875,16 @@ export async function removeTags(input: RemoveTagsInput) {
eq(formEntries.formCode, formMapping.formCode)
)
)
-
+
// 각 formEntry에 대해 처리
for (const entry of formEntryRecords) {
let updatedJson = entry.data;
-
+
// 각 tagNo에 대해 JSON 데이터에서 제거
for (const tagNo of relevantTagNos) {
updatedJson = removeTagFromDataJson(updatedJson, tagNo);
}
-
+
// 변경이 있다면 업데이트
await tx
.update(formEntries)
@@ -774,15 +894,15 @@ export async function removeTags(input: RemoveTagsInput) {
}
}
}
-
+
// 4) 마지막으로 tags 테이블에서 태그들 삭제
await tx.delete(tags).where(inArray(tags.id, ids))
})
-
+
// 5) 캐시 무효화
revalidateTag(`tags-${selectedPackageId}`)
revalidateTag(`forms-${selectedPackageId}`)
-
+
return { data: null, error: null }
} catch (err) {
return { data: null, error: getErrorMessage(err) }
@@ -802,7 +922,28 @@ export interface ClassOption {
* Class 옵션 목록을 가져오는 함수
* 이제 각 클래스는 연결된 tagTypeCode와 tagTypeDescription을 포함
*/
-export async function getClassOptions(){
+export async function getClassOptions(packageId?: number) {
+ if (!packageId) {
+ throw new Error("패키지 ID가 필요합니다");
+ }
+
+ // First, get the projectId from the contract associated with the package
+ const packageInfo = await db
+ .select({
+ projectId: contracts.projectId
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contracts.id, contractItems.contractId))
+ .where(eq(contractItems.id, packageId))
+ .limit(1);
+
+ if (!packageInfo.length) {
+ throw new Error("패키지를 찾을 수 없거나 연결된 프로젝트가 없습니다");
+ }
+
+ const projectId = packageInfo[0].projectId;
+
+ // Now get the tag classes filtered by projectId
const rows = await db
.select({
id: tagClasses.id,
@@ -812,16 +953,19 @@ export async function getClassOptions(){
tagTypeDescription: tagTypes.description,
})
.from(tagClasses)
- .leftJoin(tagTypes, eq(tagTypes.code, tagClasses.tagTypeCode))
+ .leftJoin(tagTypes, and(
+ eq(tagTypes.code, tagClasses.tagTypeCode),
+ eq(tagTypes.projectId, tagClasses.projectId)
+ ))
+ .where(eq(tagClasses.projectId, projectId));
return rows.map((row) => ({
code: row.code,
label: row.label,
tagTypeCode: row.tagTypeCode,
tagTypeDescription: row.tagTypeDescription ?? "",
- }))
+ }));
}
-
interface SubFieldDef {
name: string
label: string
@@ -856,7 +1000,7 @@ export async function getSubfieldsByTagType(tagTypeCode: string, selectedPackage
.where(
and(
eq(tagSubfields.tagTypeCode, tagTypeCode),
- eq(tagSubfields.projectId, projectId)
+ eq(tagSubfields.projectId, projectId)
)
)
.orderBy(asc(tagSubfields.sortOrder));
@@ -866,7 +1010,7 @@ export async function getSubfieldsByTagType(tagTypeCode: string, selectedPackage
for (const sf of rows) {
// projectId가 필요한 경우 getSubfieldType과 getSubfieldOptions 함수에도 전달
const subfieldType = await getSubfieldType(sf.attributesId, projectId);
-
+
const subfieldOptions = subfieldType === "select"
? await getSubfieldOptions(sf.attributesId, projectId)
: [];
@@ -889,11 +1033,11 @@ export async function getSubfieldsByTagType(tagTypeCode: string, selectedPackage
}
-async function getSubfieldType(attributesId: string, projectId:number): Promise<"select" | "text"> {
+async function getSubfieldType(attributesId: string, projectId: number): Promise<"select" | "text"> {
const optRows = await db
.select()
.from(tagSubfieldOptions)
- .where(and(eq(tagSubfieldOptions.attributesId, attributesId),eq(tagSubfieldOptions.projectId,projectId)))
+ .where(and(eq(tagSubfieldOptions.attributesId, attributesId), eq(tagSubfieldOptions.projectId, projectId)))
return optRows.length > 0 ? "select" : "text"
}
@@ -917,7 +1061,7 @@ export interface SubfieldOption {
/**
* SubField의 옵션 목록을 가져오는 보조 함수
*/
-async function getSubfieldOptions(attributesId: string, projectId:number): Promise<SubfieldOption[]> {
+async function getSubfieldOptions(attributesId: string, projectId: number): Promise<SubfieldOption[]> {
try {
const rows = await db
.select({
@@ -927,8 +1071,8 @@ async function getSubfieldOptions(attributesId: string, projectId:number): Promi
.from(tagSubfieldOptions)
.where(
and(
- eq(tagSubfieldOptions.attributesId, attributesId),
- eq(tagSubfieldOptions.projectId, projectId),
+ eq(tagSubfieldOptions.attributesId, attributesId),
+ eq(tagSubfieldOptions.projectId, projectId),
)
)
@@ -989,4 +1133,31 @@ export async function getTagTypes(): Promise<{ options: TagTypeOption[] }> {
export interface TagTypeOption {
id: string; // tagTypes.code 값
label: string; // tagTypes.description 값
+}
+
+export async function getProjectIdFromContractItemId(contractItemId: number): Promise<number | null> {
+ try {
+ // First get the contractId from contractItems
+ const contractItem = await db.query.contractItems.findFirst({
+ where: eq(contractItems.id, contractItemId),
+ columns: {
+ contractId: true
+ }
+ });
+
+ if (!contractItem) return null;
+
+ // Then get the projectId from contracts
+ const contract = await db.query.contracts.findFirst({
+ where: eq(contracts.id, contractItem.contractId),
+ columns: {
+ projectId: true
+ }
+ });
+
+ return contract?.projectId || null;
+ } catch (error) {
+ console.error("Error fetching projectId:", error);
+ return null;
+ }
} \ No newline at end of file
diff --git a/lib/tags/table/add-tag-dialog.tsx b/lib/tags/table/add-tag-dialog.tsx
index 8efb6b02..73df5aef 100644
--- a/lib/tags/table/add-tag-dialog.tsx
+++ b/lib/tags/table/add-tag-dialog.tsx
@@ -116,23 +116,25 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
// ---------------
// Load Class Options
// ---------------
- React.useEffect(() => {
- const loadClassOptions = async () => {
- setIsLoadingClasses(true)
- try {
- const result = await getClassOptions()
- setClassOptions(result)
- } catch (err) {
- toast.error("클래스 옵션을 불러오는데 실패했습니다.")
- } finally {
- setIsLoadingClasses(false)
- }
+// In the AddTagDialog component
+React.useEffect(() => {
+ const loadClassOptions = async () => {
+ setIsLoadingClasses(true)
+ try {
+ // Pass selectedPackageId to the function
+ const result = await getClassOptions(selectedPackageId)
+ setClassOptions(result)
+ } catch (err) {
+ toast.error("클래스 옵션을 불러오는데 실패했습니다.")
+ } finally {
+ setIsLoadingClasses(false)
}
+ }
- if (open) {
- loadClassOptions()
- }
- }, [open])
+ if (open) {
+ loadClassOptions()
+ }
+}, [open, selectedPackageId]) // Add selectedPackageId to the dependency array
// ---------------
// react-hook-form with fieldArray support for multiple rows
diff --git a/lib/tags/table/tag-table.tsx b/lib/tags/table/tag-table.tsx
index 5c8c048f..62f0a7c5 100644
--- a/lib/tags/table/tag-table.tsx
+++ b/lib/tags/table/tag-table.tsx
@@ -31,9 +31,6 @@ export function TagsTable({ promises, selectedPackageId }: TagsTableProps) {
// 1) 데이터를 가져옴 (server component -> use(...) pattern)
const [{ data, pageCount }] = React.use(promises)
-
-
-
const [rowAction, setRowAction] = React.useState<DataTableRowAction<Tag> | null>(null)
const columns = React.useMemo(
@@ -87,7 +84,7 @@ export function TagsTable({ promises, selectedPackageId }: TagsTableProps) {
enablePinning: true,
enableAdvancedFilter: true,
initialState: {
- sorting: [{ id: "createdAt", desc: true }],
+ // sorting: [{ id: "createdAt", desc: true }],
columnPinning: { right: ["actions"] },
},
getRowId: (originalRow) => String(originalRow.id),
@@ -97,16 +94,29 @@ export function TagsTable({ promises, selectedPackageId }: TagsTableProps) {
})
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+
+
+ const handleCompactChange = React.useCallback((compact: boolean) => {
+ setIsCompact(compact)
+ }, [])
+
+
return (
<>
<DataTable
table={table}
+ compact={isCompact}
+
floatingBar={<TagsTableFloatingBar table={table} selectedPackageId={selectedPackageId}/>}
>
<DataTableAdvancedToolbar
table={table}
filterFields={advancedFilterFields}
shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="tagTableCompact"
+ onCompactChange={handleCompactChange}
>
{/*
4) ToolbarActions에 tableData, setTableData 넘겨서
diff --git a/lib/tags/table/tags-export.tsx b/lib/tags/table/tags-export.tsx
index 4afbac6c..fa85148d 100644
--- a/lib/tags/table/tags-export.tsx
+++ b/lib/tags/table/tags-export.tsx
@@ -15,6 +15,7 @@ import { getClassOptions } from "../service"
*/
export async function exportTagsToExcel(
table: Table<Tag>,
+ selectedPackageId: number,
{
filename = "Tags",
excludeColumns = ["select", "actions", "createdAt", "updatedAt"],
@@ -26,6 +27,8 @@ export async function exportTagsToExcel(
} = {}
) {
try {
+
+
// 1. 테이블에서 컬럼 정보 가져오기
const allTableColumns = table.getAllLeafColumns()
@@ -39,7 +42,7 @@ export async function exportTagsToExcel(
const worksheet = workbook.addWorksheet("Tags")
// 3. Tag Class 옵션 가져오기
- const classOptions = await getClassOptions()
+ const classOptions = await getClassOptions(selectedPackageId)
// 4. 유효성 검사 시트 생성
const validationSheet = workbook.addWorksheet("ValidationData")
diff --git a/lib/tags/table/tags-table-toolbar-actions.tsx b/lib/tags/table/tags-table-toolbar-actions.tsx
index 497b2278..c6d13247 100644
--- a/lib/tags/table/tags-table-toolbar-actions.tsx
+++ b/lib/tags/table/tags-table-toolbar-actions.tsx
@@ -7,13 +7,14 @@ import ExcelJS from "exceljs"
import { saveAs } from "file-saver"
import { Button } from "@/components/ui/button"
-import { Download, Upload, Loader2 } from "lucide-react"
+import { Download, Upload, Loader2, RefreshCcw } from "lucide-react"
import { Tag, TagSubfields } from "@/db/schema/vendorData"
import { exportTagsToExcel } from "./tags-export"
import { AddTagDialog } from "./add-tag-dialog"
import { fetchTagSubfieldOptions, getTagNumberingRules, } from "@/lib/tag-numbering/service"
-import { bulkCreateTags, getClassOptions, getSubfieldsByTagType } from "../service"
+import { bulkCreateTags, getClassOptions, getProjectIdFromContractItemId, getSubfieldsByTagType } from "../service"
import { DeleteTagsDialog } from "./delete-tags-dialog"
+import { useRouter } from "next/navigation" // Add this import
// 태그 번호 검증을 위한 인터페이스
interface TagNumberingRule {
@@ -68,10 +69,16 @@ export function TagsTableToolbarActions({
selectedPackageId,
tableData,
}: TagsTableToolbarActionsProps) {
+ const router = useRouter() // Add this line
+
const [isPending, setIsPending] = React.useState(false)
const [isExporting, setIsExporting] = React.useState(false)
const fileInputRef = React.useRef<HTMLInputElement>(null)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [syncId, setSyncId] = React.useState<string | null>(null)
+ const pollingRef = React.useRef<NodeJS.Timeout | null>(null)
+
// 태그 타입별 넘버링 룰 캐시
const [tagNumberingRules, setTagNumberingRules] = React.useState<Record<string, TagNumberingRule[]>>({})
const [tagOptionsCache, setTagOptionsCache] = React.useState<Record<string, TagOption[]>>({})
@@ -84,7 +91,7 @@ export function TagsTableToolbarActions({
React.useEffect(() => {
const loadClassOptions = async () => {
try {
- const options = await getClassOptions()
+ const options = await getClassOptions(selectedPackageId)
setClassOptions(options)
} catch (error) {
console.error("Failed to load class options:", error)
@@ -92,7 +99,7 @@ export function TagsTableToolbarActions({
}
loadClassOptions()
- }, [])
+ }, [selectedPackageId])
// 숨겨진 <input>을 클릭
function handleImportClick() {
@@ -123,28 +130,53 @@ export function TagsTableToolbarActions({
}
}, [tagNumberingRules])
+ const [projectId, setProjectId] = React.useState<number | null>(null);
+
+ // Add useEffect to fetch projectId when selectedPackageId changes
+ React.useEffect(() => {
+ const fetchProjectId = async () => {
+ if (selectedPackageId) {
+ try {
+ const pid = await getProjectIdFromContractItemId(selectedPackageId);
+ setProjectId(pid);
+ } catch (error) {
+ console.error("Failed to fetch project ID:", error);
+ toast.error("Failed to load project data");
+ }
+ }
+ };
+
+ fetchProjectId();
+ }, [selectedPackageId]);
+
// 특정 attributesId에 대한 옵션 가져오기
const fetchOptions = React.useCallback(async (attributesId: string): Promise<TagOption[]> => {
- // 이미 캐시에 있으면 캐시된 값 사용
+ // Cache check remains the same
if (tagOptionsCache[attributesId]) {
- return tagOptionsCache[attributesId]
+ return tagOptionsCache[attributesId];
}
-
+
try {
- const options = await fetchTagSubfieldOptions(attributesId)
-
- // 캐시에 저장
+ // Only pass projectId if it's not null
+ let options: TagOption[];
+ if (projectId !== null) {
+ options = await fetchTagSubfieldOptions(attributesId, projectId);
+ } else {
+ options = []
+ }
+
+ // Update cache
setTagOptionsCache(prev => ({
...prev,
[attributesId]: options
- }))
-
- return options
+ }));
+
+ return options;
} catch (error) {
- console.error(`Error fetching options for ${attributesId}:`, error)
- return []
+ console.error(`Error fetching options for ${attributesId}:`, error);
+ return [];
}
- }, [tagOptionsCache])
+ }, [tagOptionsCache, projectId]);
// 클래스 라벨로 태그 타입 코드 찾기
const getTagTypeCodeByClassLabel = React.useCallback((classLabel: string): string | null => {
@@ -527,7 +559,7 @@ export function TagsTableToolbarActions({
setIsExporting(true)
// 유효성 검사가 포함된 새로운 엑셀 내보내기 함수 호출
- await exportTagsToExcel(table, {
+ await exportTagsToExcel(table,selectedPackageId, {
filename: `Tags_${selectedPackageId}`,
excludeColumns: ["select", "actions", "createdAt", "updatedAt"],
})
@@ -541,6 +573,105 @@ export function TagsTableToolbarActions({
}
}
+ const startGetTags = async () => {
+ try {
+ setIsLoading(true)
+
+ // API 엔드포인트 호출 - 작업 시작만 요청
+ const response = await fetch('/api/cron/tags/start', {
+ method: 'POST',
+ body: JSON.stringify({ packageId: selectedPackageId })
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.error || 'Failed to start tag import')
+ }
+
+ const data = await response.json()
+
+ // 작업 ID 저장
+ if (data.syncId) {
+ setSyncId(data.syncId)
+ toast.info('Tag import started. This may take a while...')
+
+ // 상태 확인을 위한 폴링 시작
+ startPolling(data.syncId)
+ } else {
+ throw new Error('No import ID returned from server')
+ }
+ } catch (error) {
+ console.error('Error starting tag import:', error)
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : 'An error occurred while starting tag import'
+ )
+ setIsLoading(false)
+ }
+ }
+
+ const startPolling = (id: string) => {
+ // 이전 폴링이 있다면 제거
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current)
+ }
+
+ // 5초마다 상태 확인
+ pollingRef.current = setInterval(async () => {
+ try {
+ const response = await fetch(`/api/cron/tags/status?id=${id}`)
+
+ if (!response.ok) {
+ throw new Error('Failed to get tag import status')
+ }
+
+ const data = await response.json()
+
+ if (data.status === 'completed') {
+ // 폴링 중지
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current)
+ pollingRef.current = null
+ }
+
+ router.refresh()
+
+ // 상태 초기화
+ setIsLoading(false)
+ setSyncId(null)
+
+ // 성공 메시지 표시
+ toast.success(
+ `Tags imported successfully! ${data.result?.processedCount || 0} items processed.`
+ )
+
+ // 테이블 데이터 업데이트
+ table.resetRowSelection()
+ } else if (data.status === 'failed') {
+ // 에러 처리
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current)
+ pollingRef.current = null
+ }
+
+ setIsLoading(false)
+ setSyncId(null)
+ toast.error(data.error || 'Import failed')
+ } else if (data.status === 'processing') {
+ // 진행 상태 업데이트 (선택적)
+ if (data.progress) {
+ toast.info(`Import in progress: ${data.progress}%`, {
+ id: `import-progress-${id}`,
+ })
+ }
+ }
+ } catch (error) {
+ console.error('Error checking importing status:', error)
+ }
+ }, 5000) // 5초마다 체크
+ }
+
return (
<div className="flex items-center gap-2">
@@ -553,7 +684,18 @@ export function TagsTableToolbarActions({
selectedPackageId={selectedPackageId}
/>
) : null}
-
+ <Button
+ variant="samsung"
+ size="sm"
+ className="gap-2"
+ onClick={startGetTags}
+ disabled={isLoading}
+ >
+ <RefreshCcw className={`size-4 ${isLoading ? 'animate-spin' : ''}`} aria-hidden="true" />
+ <span className="hidden sm:inline">
+ {isLoading ? 'Syncing...' : 'Get Tags'}
+ </span>
+ </Button>
<AddTagDialog selectedPackageId={selectedPackageId} />
diff --git a/lib/tags/table/update-tag-sheet.tsx b/lib/tags/table/update-tag-sheet.tsx
index 7d213fc3..613abaa9 100644
--- a/lib/tags/table/update-tag-sheet.tsx
+++ b/lib/tags/table/update-tag-sheet.tsx
@@ -102,7 +102,6 @@ export function UpdateTagSheet({ tag, selectedPackageId, ...props }: UpdateTagSh
const fieldIdsRef = React.useRef<Record<string, string>>({})
const classOptionIdsRef = React.useRef<Record<string, string>>({})
- console.log(tag)
// Load class options when sheet opens
React.useEffect(() => {
@@ -111,7 +110,7 @@ export function UpdateTagSheet({ tag, selectedPackageId, ...props }: UpdateTagSh
setIsLoadingClasses(true)
try {
- const result = await getClassOptions()
+ const result = await getClassOptions(selectedPackageId)
setClassOptions(result)
} catch (err) {
toast.error("클래스 옵션을 불러오는데 실패했습니다.")
diff --git a/lib/tasks/service.ts b/lib/tasks/service.ts
index c31ecd4b..0530ab85 100644
--- a/lib/tasks/service.ts
+++ b/lib/tasks/service.ts
@@ -1,4 +1,3 @@
-// src/lib/tasks/service.ts
"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
import { revalidateTag, unstable_noStore } from "next/cache";
diff --git a/lib/tbe/table/comments-sheet.tsx b/lib/tbe/table/comments-sheet.tsx
index 7fcde35d..0952209d 100644
--- a/lib/tbe/table/comments-sheet.tsx
+++ b/lib/tbe/table/comments-sheet.tsx
@@ -4,7 +4,7 @@ import * as React from "react"
import { useForm, useFieldArray } from "react-hook-form"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
-import { Loader, Download, X } from "lucide-react"
+import { Loader, Download, X ,Loader2} from "lucide-react"
import prettyBytes from "pretty-bytes"
import { toast } from "sonner"
@@ -50,7 +50,6 @@ import {
// DB 스키마에서 필요한 타입들을 가져온다고 가정
// (실제 프로젝트에 맞춰 import를 수정하세요.)
-import { RfqWithAll } from "@/db/schema/rfq"
import { formatDate } from "@/lib/utils"
import { createRfqCommentWithAttachments } from "@/lib/rfqs/service"
@@ -77,6 +76,7 @@ interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
currentUserId: number
rfqId:number
vendorId:number
+ isLoading?: boolean // New prop
/** 댓글 저장 후 갱신용 콜백 (옵션) */
onCommentsUpdated?: (comments: TbeComment[]) => void
}
@@ -96,6 +96,7 @@ export function CommentSheet({
initialComments = [],
currentUserId,
onCommentsUpdated,
+ isLoading = false,
...props
}: CommentSheetProps) {
const [comments, setComments] = React.useState<TbeComment[]>(initialComments)
@@ -125,6 +126,16 @@ export function CommentSheet({
// 간단히 테이블 하나로 표현
// 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음
function renderExistingComments() {
+
+ if (isLoading) {
+ return (
+ <div className="flex justify-center items-center h-32">
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
+ </div>
+ )
+ }
+
if (comments.length === 0) {
return <p className="text-sm text-muted-foreground">No comments yet</p>
}
diff --git a/lib/tbe/table/invite-vendors-dialog.tsx b/lib/tbe/table/invite-vendors-dialog.tsx
index 87467e57..59535278 100644
--- a/lib/tbe/table/invite-vendors-dialog.tsx
+++ b/lib/tbe/table/invite-vendors-dialog.tsx
@@ -39,6 +39,7 @@ interface InviteVendorsDialogProps
rfqId: number
showTrigger?: boolean
onSuccess?: () => void
+ hasMultipleRfqIds?: boolean
}
export function InviteVendorsDialog({
@@ -46,6 +47,7 @@ export function InviteVendorsDialog({
rfqId,
showTrigger = true,
onSuccess,
+ hasMultipleRfqIds,
...props
}: InviteVendorsDialogProps) {
const [isInvitePending, startInviteTransition] = React.useTransition()
@@ -105,10 +107,14 @@ export function InviteVendorsDialog({
/>
</div>
)
-
+ if (hasMultipleRfqIds) {
+ toast.error("동일한 RFQ에 대해 선택해주세요");
+ return;
+ }
// Desktop Dialog
if (isDesktop) {
return (
+
<Dialog {...props}>
{showTrigger ? (
<DialogTrigger asChild>
diff --git a/lib/tbe/table/tbe-result-dialog.tsx b/lib/tbe/table/tbe-result-dialog.tsx
new file mode 100644
index 00000000..59e2f49b
--- /dev/null
+++ b/lib/tbe/table/tbe-result-dialog.tsx
@@ -0,0 +1,208 @@
+"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Textarea } from "@/components/ui/textarea"
+import { Label } from "@/components/ui/label"
+import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
+import { getErrorMessage } from "@/lib/handle-error"
+import { saveTbeResult } from "@/lib/rfqs/service"
+
+// Define the props for the TbeResultDialog component
+interface TbeResultDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ tbe: VendorWithTbeFields | null
+ onRefresh?: () => void
+}
+
+// Define TBE result options
+const TBE_RESULT_OPTIONS = [
+ { value: "pass", label: "Pass", badgeVariant: "default" },
+ { value: "non-pass", label: "Non-Pass", badgeVariant: "destructive" },
+ { value: "conditional pass", label: "Conditional Pass", badgeVariant: "secondary" },
+] as const
+
+type TbeResultOption = typeof TBE_RESULT_OPTIONS[number]["value"]
+
+export function TbeResultDialog({
+ open,
+ onOpenChange,
+ tbe,
+ onRefresh,
+}: TbeResultDialogProps) {
+ // Initialize state for form inputs
+ const [result, setResult] = React.useState<TbeResultOption | "">("")
+ const [note, setNote] = React.useState("")
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ // Update form values when the tbe prop changes
+ React.useEffect(() => {
+ if (tbe) {
+ setResult((tbe.tbeResult as TbeResultOption) || "")
+ setNote(tbe.tbeNote || "")
+ }
+ }, [tbe])
+
+ // Reset form when dialog closes
+ React.useEffect(() => {
+ if (!open) {
+ // Small delay to avoid visual glitches when dialog is closing
+ const timer = setTimeout(() => {
+ if (!tbe) {
+ setResult("")
+ setNote("")
+ }
+ }, 300)
+ return () => clearTimeout(timer)
+ }
+ }, [open, tbe])
+
+ // Handle form submission with server action
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!tbe || !result) return
+
+ setIsSubmitting(true)
+
+ try {
+ // Call the server action to save the TBE result
+ const response = await saveTbeResult({
+ id: tbe.tbeId ?? 0, // This is the id in the rfq_evaluations table
+ vendorId: tbe.vendorId, // This is the vendorId in the rfq_evaluations table
+ result: result, // The selected evaluation result
+ notes: note, // The evaluation notes
+ })
+
+ if (!response.success) {
+ throw new Error(response.message || "Failed to save TBE result")
+ }
+
+ // Show success toast
+ toast.success("TBE result saved successfully")
+
+ // Close the dialog
+ onOpenChange(false)
+
+ // Refresh the data if refresh callback is provided
+ if (onRefresh) {
+ onRefresh()
+ }
+ } catch (error) {
+ // Show error toast
+ toast.error(`Failed to save: ${getErrorMessage(error)}`)
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // Find the selected result option
+ const selectedOption = TBE_RESULT_OPTIONS.find(option => option.value === result)
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle className="text-xl font-semibold">
+ {tbe?.tbeResult ? "Edit TBE Result" : "Enter TBE Result"}
+ </DialogTitle>
+ {tbe && (
+ <DialogDescription className="text-sm text-muted-foreground mt-1">
+ <div className="flex flex-col gap-1">
+ <span>
+ <strong>Vendor:</strong> {tbe.vendorName}
+ </span>
+ <span>
+ <strong>RFQ Code:</strong> {tbe.rfqCode}
+ </span>
+ {tbe.email && (
+ <span>
+ <strong>Email:</strong> {tbe.email}
+ </span>
+ )}
+ </div>
+ </DialogDescription>
+ )}
+ </DialogHeader>
+
+ <form onSubmit={handleSubmit} className="space-y-6 py-2">
+ <div className="space-y-2">
+ <Label htmlFor="tbe-result" className="text-sm font-medium">
+ Evaluation Result
+ </Label>
+ <Select
+ value={result}
+ onValueChange={(value) => setResult(value as TbeResultOption)}
+ required
+ >
+ <SelectTrigger id="tbe-result" className="w-full">
+ <SelectValue placeholder="Select a result" />
+ </SelectTrigger>
+ <SelectContent>
+ {TBE_RESULT_OPTIONS.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ <div className="flex items-center">
+ <Badge variant={option.badgeVariant as any} className="mr-2">
+ {option.label}
+ </Badge>
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="tbe-note" className="text-sm font-medium">
+ Evaluation Note
+ </Label>
+ <Textarea
+ id="tbe-note"
+ placeholder="Enter evaluation notes..."
+ value={note}
+ onChange={(e) => setNote(e.target.value)}
+ className="min-h-[120px] resize-y"
+ />
+ </div>
+
+ <DialogFooter className="gap-2 sm:gap-0">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={!result || isSubmitting}
+ className="min-w-[100px]"
+ >
+ {isSubmitting ? "Saving..." : "Save"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/tbe/table/tbe-table-columns.tsx b/lib/tbe/table/tbe-table-columns.tsx
index 3b62fe06..8f0de88c 100644
--- a/lib/tbe/table/tbe-table-columns.tsx
+++ b/lib/tbe/table/tbe-table-columns.tsx
@@ -37,6 +37,8 @@ interface GetColumnsProps {
router: NextRouter
openCommentSheet: (vendorId: number, rfqId: number) => void
openFilesDialog: (tbeId: number, vendorId: number, rfqId: number) => void
+ openVendorContactsDialog: (vendorId: number, vendor: VendorWithTbeFields) => void // 수정된 시그니처
+
}
@@ -47,7 +49,8 @@ export function getColumns({
setRowAction,
router,
openCommentSheet,
- openFilesDialog
+ openFilesDialog,
+ openVendorContactsDialog
}: GetColumnsProps): ColumnDef<VendorWithTbeFields>[] {
// ----------------------------------------------------------------
// 1) Select 컬럼 (체크박스)
@@ -105,6 +108,84 @@ export function getColumns({
cell: ({ row, getValue }) => {
// 1) 필드값 가져오기
const val = getValue()
+
+ if (cfg.id === "vendorName") {
+ const vendor = row.original;
+ const vendorId = vendor.vendorId;
+
+ // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링
+ const handleVendorNameClick = () => {
+ if (vendorId) {
+ openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달
+ } else {
+ toast.error("협력업체 ID를 찾을 수 없습니다.");
+ }
+ };
+
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left font-normal justify-start hover:underline"
+ onClick={handleVendorNameClick}
+ >
+ {val as string}
+ </Button>
+ );
+ }
+ if (cfg.id === "tbeResult") {
+ const vendor = row.original;
+ const tbeResult = vendor.tbeResult;
+ const filesCount = vendor.files?.length ?? 0;
+
+ // Only show button or link if there are files
+ if (filesCount > 0) {
+ // Function to handle clicking on the result
+ const handleTbeResultClick = () => {
+ setRowAction({ row, type: "tbeResult" });
+ };
+
+ if (!tbeResult) {
+ // No result yet, but files exist - show "결과 입력" button
+ return (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleTbeResultClick}
+ >
+ 결과 입력
+ </Button>
+ );
+ } else {
+ // Result exists - show as a hyperlink
+ let badgeVariant: "default" | "outline" | "destructive" | "secondary" = "outline";
+
+ // Set badge variant based on result
+ if (tbeResult === "pass") {
+ badgeVariant = "default";
+ } else if (tbeResult === "non-pass") {
+ badgeVariant = "destructive";
+ } else if (tbeResult === "conditional pass") {
+ badgeVariant = "secondary";
+ }
+
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto underline"
+ onClick={handleTbeResultClick}
+ >
+ <Badge variant={badgeVariant}>
+ {tbeResult}
+ </Badge>
+ </Button>
+ );
+ }
+ }
+
+ // No files available, return empty cell
+ return null;
+ }
+
if (cfg.id === "vendorStatus") {
const statusVal = row.original.vendorStatus
@@ -222,13 +303,27 @@ const commentsColumn: ColumnDef<VendorWithTbeFields> = {
}
return (
- <Button variant="ghost" size="sm" className="h-8 w-8 p-0 group relative" onClick={handleClick}>
- <MessageSquare className="h-4 w-4" />
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ commCount > 0 ? `View ${commCount} comments` : "No comments"
+ }
+ >
+ <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
{commCount > 0 && (
- <Badge variant="secondary" className="absolute -top-1 -right-1 h-4 min-w-[1rem] text-[0.625rem] p-0 flex items-center justify-center">
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
+ >
{commCount}
</Badge>
)}
+ <span className="sr-only">
+ {commCount > 0 ? `${commCount} Comments` : "No Comments"}
+ </span>
</Button>
)
},
diff --git a/lib/tbe/table/tbe-table-toolbar-actions.tsx b/lib/tbe/table/tbe-table-toolbar-actions.tsx
index 6a336135..cf6a041e 100644
--- a/lib/tbe/table/tbe-table-toolbar-actions.tsx
+++ b/lib/tbe/table/tbe-table-toolbar-actions.tsx
@@ -28,19 +28,31 @@ export function VendorsTableToolbarActions({ table,rfqId }: VendorsTableToolbarA
fileInputRef.current?.click()
}
+ // 선택된 행이 있는 경우 rfqId 확인
+ const uniqueRfqIds = table.getFilteredSelectedRowModel().rows.length > 0
+ ? [...new Set(table.getFilteredSelectedRowModel().rows.map(row => row.original.rfqId))]
+ : [];
+
+ const hasMultipleRfqIds = uniqueRfqIds.length > 1;
+
+ const invitationPossibeVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ .filter(vendor => vendor.technicalResponseStatus === null);
+ }, [table.getFilteredSelectedRowModel().rows]);
+
return (
<div className="flex items-center gap-2">
- {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ {invitationPossibeVendors.length > 0 && (
<InviteVendorsDialog
- vendors={table
- .getFilteredSelectedRowModel()
- .rows.map((row) => row.original)}
- rfqId = {rfqId}
+ vendors={invitationPossibeVendors}
+ rfqId={rfqId}
onSuccess={() => table.toggleAllRowsSelected(false)}
+ hasMultipleRfqIds={hasMultipleRfqIds}
/>
- ) : null}
-
-
+ )}
<Button
variant="outline"
size="sm"
diff --git a/lib/tbe/table/tbe-table.tsx b/lib/tbe/table/tbe-table.tsx
index e67b1d3d..83d601b3 100644
--- a/lib/tbe/table/tbe-table.tsx
+++ b/lib/tbe/table/tbe-table.tsx
@@ -15,12 +15,14 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv
import { useFeatureFlags } from "./feature-flags-provider"
import { getColumns } from "./tbe-table-columns"
import { Vendor, vendors } from "@/db/schema/vendors"
-import { InviteVendorsDialog } from "./invite-vendors-dialog"
import { CommentSheet, TbeComment } from "./comments-sheet"
import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
import { TBEFileDialog } from "./file-dialog"
import { fetchRfqAttachmentsbyCommentId, getAllTBE } from "@/lib/rfqs/service"
import { VendorsTableToolbarActions } from "./tbe-table-toolbar-actions"
+import { TbeResultDialog } from "./tbe-result-dialog"
+import { toast } from "sonner"
+import { VendorContactsDialog } from "./vendor-contact-dialog"
interface VendorsTableProps {
promises: Promise<[
@@ -39,6 +41,8 @@ export function AllTbeTable({ promises }: VendorsTableProps) {
// 댓글 시트 관련 state
const [initialComments, setInitialComments] = React.useState<TbeComment[]>([])
+ const [isLoadingComments, setIsLoadingComments] = React.useState(false)
+
const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
const [selectedVendorIdForComments, setSelectedVendorIdForComments] = React.useState<number | null>(null)
const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
@@ -49,6 +53,10 @@ export function AllTbeTable({ promises }: VendorsTableProps) {
const [selectedTbeIdForFiles, setSelectedTbeIdForFiles] = React.useState<number | null>(null)
const [selectedRfqIdForFiles, setSelectedRfqIdForFiles] = React.useState<number | null>(null)
+ const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false)
+ const [selectedVendor, setSelectedVendor] = React.useState<VendorWithTbeFields | null>(null)
+ const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null)
+
// 테이블 리프레시용
const handleRefresh = React.useCallback(() => {
router.refresh();
@@ -81,25 +89,33 @@ export function AllTbeTable({ promises }: VendorsTableProps) {
// -----------------------------------------------------------
async function openCommentSheet(vendorId: number, rfqId: number) {
setInitialComments([])
-
+ setIsLoadingComments(true)
const comments = rowAction?.row.original.comments
- if (comments && comments.length > 0) {
- const commentWithAttachments: TbeComment[] = await Promise.all(
- comments.map(async (c) => {
- const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
- return {
- ...c,
- commentedBy: 1, // DB나 API 응답에 있다고 가정
- attachments,
- }
- })
- )
- setInitialComments(commentWithAttachments)
+ try {
+ if (comments && comments.length > 0) {
+ const commentWithAttachments: TbeComment[] = await Promise.all(
+ comments.map(async (c) => {
+ const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
+ return {
+ ...c,
+ commentedBy: 1, // DB나 API 응답에 있다고 가정
+ attachments,
+ }
+ })
+ )
+ setInitialComments(commentWithAttachments)
+ }
+
+ setSelectedVendorIdForComments(vendorId)
+ setSelectedRfqIdForComments(rfqId)
+ setCommentSheetOpen(true)
+ } catch (error) {
+ console.error("Error loading comments:", error)
+ toast.error("Failed to load comments")
+ } finally {
+ // End loading regardless of success/failure
+ setIsLoadingComments(false)
}
-
- setSelectedVendorIdForComments(vendorId)
- setSelectedRfqIdForComments(rfqId)
- setCommentSheetOpen(true)
}
// -----------------------------------------------------------
@@ -112,6 +128,13 @@ export function AllTbeTable({ promises }: VendorsTableProps) {
setIsFileDialogOpen(true)
}
+ const openVendorContactsDialog = (vendorId: number, vendor: VendorWithTbeFields) => {
+ setSelectedVendorId(vendorId)
+ setSelectedVendor(vendor)
+ setIsContactDialogOpen(true)
+ }
+
+
// -----------------------------------------------------------
// 테이블 컬럼
// -----------------------------------------------------------
@@ -122,6 +145,7 @@ export function AllTbeTable({ promises }: VendorsTableProps) {
router,
openCommentSheet, // 필요하면 직접 호출 가능
openFilesDialog,
+ openVendorContactsDialog,
}),
[setRowAction, router]
)
@@ -161,7 +185,7 @@ export function AllTbeTable({ promises }: VendorsTableProps) {
enableAdvancedFilter: true,
initialState: {
sorting: [{ id: "rfqVendorUpdated", desc: true }],
- columnPinning: { right: ["actions"] },
+ columnPinning: { right: ["files", "comments"] },
},
getRowId: (originalRow) => (`${originalRow.id}${originalRow.rfqId}`),
shallow: false,
@@ -176,7 +200,7 @@ export function AllTbeTable({ promises }: VendorsTableProps) {
filterFields={advancedFilterFields}
shallow={false}
>
- <VendorsTableToolbarActions table={table} rfqId={selectedRfqIdForFiles ?? 0} />
+ <VendorsTableToolbarActions table={table} rfqId={selectedRfqIdForFiles ?? 0} />
</DataTableAdvancedToolbar>
</DataTable>
@@ -186,7 +210,8 @@ export function AllTbeTable({ promises }: VendorsTableProps) {
open={commentSheetOpen}
onOpenChange={setCommentSheetOpen}
vendorId={selectedVendorIdForComments ?? 0}
- rfqId={selectedRfqIdForComments ?? 0} // ← 여기!
+ rfqId={selectedRfqIdForComments ?? 0}
+ isLoading={isLoadingComments}
initialComments={initialComments}
/>
@@ -199,6 +224,20 @@ export function AllTbeTable({ promises }: VendorsTableProps) {
rfqId={selectedRfqIdForFiles ?? 0} // ← 여기!
onRefresh={handleRefresh}
/>
+
+ <TbeResultDialog
+ open={rowAction?.type === "tbeResult"}
+ onOpenChange={() => setRowAction(null)}
+ tbe={rowAction?.row.original ?? null}
+ />
+
+ <VendorContactsDialog
+ isOpen={isContactDialogOpen}
+ onOpenChange={setIsContactDialogOpen}
+ vendorId={selectedVendorId}
+ vendor={selectedVendor}
+ />
+
</>
)
} \ No newline at end of file
diff --git a/lib/tbe/table/vendor-contact-dialog.tsx b/lib/tbe/table/vendor-contact-dialog.tsx
new file mode 100644
index 00000000..6c96d2ef
--- /dev/null
+++ b/lib/tbe/table/vendor-contact-dialog.tsx
@@ -0,0 +1,71 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
+import { VendorContactsTable } from "@/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table"
+
+interface VendorContactsDialogProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ vendorId: number | null
+ vendor: VendorWithTbeFields | null
+}
+
+export function VendorContactsDialog({
+ isOpen,
+ onOpenChange,
+ vendorId,
+ vendor,
+}: VendorContactsDialogProps) {
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}>
+ <DialogHeader>
+ <div className="flex flex-col space-y-2">
+ <DialogTitle>협력업체 연락처</DialogTitle>
+ {vendor && (
+ <div className="flex flex-col space-y-1 mt-2">
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium text-foreground">{vendor.vendorName}</span>
+ {vendor.vendorCode && (
+ <span className="ml-2 text-xs text-muted-foreground">({vendor.vendorCode})</span>
+ )}
+ </div>
+ <div className="flex items-center">
+ {vendor.vendorStatus && (
+ <Badge variant="outline" className="mr-2">
+ {vendor.vendorStatus}
+ </Badge>
+ )}
+ {vendor.rfqVendorStatus && (
+ <Badge
+ variant={
+ vendor.rfqVendorStatus === "INVITED" ? "default" :
+ vendor.rfqVendorStatus === "DECLINED" ? "destructive" :
+ vendor.rfqVendorStatus === "ACCEPTED" ? "secondary" : "outline"
+ }
+ >
+ {vendor.rfqVendorStatus}
+ </Badge>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </DialogHeader>
+ {vendorId && (
+ <div className="py-4">
+ <VendorContactsTable vendorId={vendorId} />
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/users/repository.ts b/lib/users/repository.ts
index 78d1668b..3a404bde 100644
--- a/lib/users/repository.ts
+++ b/lib/users/repository.ts
@@ -111,8 +111,6 @@ export const getOtpByEmailAndCode = async (
code: string
): Promise<Otp | null> => {
- console.log(email, code, "db")
-
const [otp] = await db
.select()
.from(otps)
@@ -123,6 +121,20 @@ export const getOtpByEmailAndCode = async (
return otp ?? null;
};
+export const getOtpByEmail = async (
+ email: string,
+): Promise<User | null> => {
+
+ const [user] = await db
+ .select()
+ .from(users)
+ .where(
+ eq(users.email, email)
+ );
+
+ return user ?? null;
+};
+
export async function findAllRoles(): Promise<Role[]> {
return db.select().from(roles).where(eq(roles.domain ,'evcp')).orderBy(asc(roles.name));
} \ No newline at end of file
diff --git a/lib/users/send-otp.ts b/lib/users/send-otp.ts
index 55c08eaf..ecaf19a5 100644
--- a/lib/users/send-otp.ts
+++ b/lib/users/send-otp.ts
@@ -27,48 +27,49 @@ export async function sendOtpAction(email: string, lng: string) {
};
}
+ /////테스트 임시
// OTP 및 만료 시간 생성
- const otp = Math.floor(100000 + Math.random() * 900000).toString();
- const expires = new Date(Date.now() + 10 * 60 * 1000); // 10분 후 만료
- const token = jwt.sign(
- {
- email,
- otp,
- exp: Math.floor(expires.getTime() / 1000),
- },
- process.env.JWT_SECRET!
- );
+ // const otp = Math.floor(100000 + Math.random() * 900000).toString();
+ // const expires = new Date(Date.now() + 10 * 60 * 1000); // 10분 후 만료
+ // const token = jwt.sign(
+ // {
+ // email,
+ // otp,
+ // exp: Math.floor(expires.getTime() / 1000),
+ // },
+ // process.env.JWT_SECRET!
+ // );
- // DB에 OTP 추가
- await addNewOtp(email, otp, new Date(), token, expires);
+ // // DB에 OTP 추가
+ // await addNewOtp(email, otp, new Date(), token, expires);
- // 이메일에서 사용할 URL 구성
- const verificationUrl = `http://${host}/ko/login?token=${token}`;
+ // // 이메일에서 사용할 URL 구성
+ // const verificationUrl = `http://${host}/ko/login?token=${token}`;
- // IP 정보로부터 지역 조회 (ip-api 사용)
- const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() || '';
- let location = '';
- try {
- const response = await fetch(`http://ip-api.com/json/${ip}?fields=country,city`);
- const data = await response.json();
- location = data.city && data.country ? `${data.city}, ${data.country}` : '';
- } catch (error) {
- // 위치 조회 실패 시 무시
- }
+ // // IP 정보로부터 지역 조회 (ip-api 사용)
+ // const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() || '';
+ // let location = '';
+ // try {
+ // const response = await fetch(`http://ip-api.com/json/${ip}?fields=country,city`);
+ // const data = await response.json();
+ // location = data.city && data.country ? `${data.city}, ${data.country}` : '';
+ // } catch (error) {
+ // // 위치 조회 실패 시 무시
+ // }
- // OTP 이메일 발송
- await sendEmail({
- to: email,
- subject: `${otp} - SHI eVCP Sign-in Verification`,
- template: 'otp',
- context: {
- name: user.name,
- otp,
- verificationUrl,
- location,
- language: lng,
- },
- });
+ // // OTP 이메일 발송
+ // await sendEmail({
+ // to: email,
+ // subject: `${otp} - SHI eVCP Sign-in Verification`,
+ // template: 'otp',
+ // context: {
+ // name: user.name,
+ // otp,
+ // verificationUrl,
+ // location,
+ // language: lng,
+ // },
+ // });
// 클라이언트로 반환할 수 있는 값
return {
diff --git a/lib/users/service.ts b/lib/users/service.ts
index ae97beed..8b2c927e 100644
--- a/lib/users/service.ts
+++ b/lib/users/service.ts
@@ -202,6 +202,32 @@ export async function findEmailandOtp(email: string, code: string) {
}
}
+export async function findEmailTemp(email: string) {
+ try {
+
+ // 2) 사용자 정보 추가로 조회
+ const userRecord: User | null = await getUserByEmail(email)
+ if (!userRecord) {
+ return null
+ }
+
+ // 3) 필요한 형태로 "통합된 객체"를 반환
+ return {
+ email: userRecord.email,
+ name: userRecord.name, // DB 에서 가져온 실제 이름
+ id: userRecord.id, // user id
+ imageUrl:userRecord.imageUrl,
+ companyId:userRecord.companyId,
+ domain:userRecord.domain
+ // 기타 필요한 필드...
+ }
+
+ } catch (error) {
+ // 에러 처리
+ throw new Error('Failed to fetch user & otp')
+ }
+}
+
export async function updateUserProfileImage(formData: FormData) {
// 1) FormData에서 데이터 꺼내기
const file = formData.get("file") as File | null
diff --git a/lib/users/verifyOtp.ts b/lib/users/verifyOtp.ts
index aa759338..84919024 100644
--- a/lib/users/verifyOtp.ts
+++ b/lib/users/verifyOtp.ts
@@ -1,5 +1,5 @@
// lib/users/verifyOtp.ts
-import { findEmailandOtp } from '@/lib/users/service'
+import { findEmailTemp, findEmailandOtp } from '@/lib/users/service'
// "email과 code가 맞으면 유저 정보, 아니면 null" 형태로 작성
export async function verifyOtp(email: string, code: string) {
@@ -27,6 +27,27 @@ export async function verifyOtp(email: string, code: string) {
}
}
+export async function verifyOtpTemp(email: string) {
+ // DB에서 email과 code가 맞는지, 만료 안됐는지 검증
+ const otpRecord = await findEmailTemp(email)
+ if (!otpRecord) {
+ return null
+ }
+
+
+ // 여기서 otpRecord에 유저 정보가 있다고 가정
+ // 예: otpRecord.userId, otpRecord.userName, otpRecord.email 등
+ // 실제 DB 설계에 맞춰 필드명을 조정하세요.
+ return {
+ email: otpRecord.email,
+ name: otpRecord.name,
+ id: otpRecord.id,
+ imageUrl: otpRecord.imageUrl,
+ companyId: otpRecord.companyId,
+ domain: otpRecord.domain,
+ }
+}
+
export async function verifyExternalCredentials(username: string, password: string) {
// DB에서 email과 code가 맞는지, 만료 안됐는지 검증
diff --git a/lib/vendor-candidates/service.ts b/lib/vendor-candidates/service.ts
index 68971f18..bfeb3090 100644
--- a/lib/vendor-candidates/service.ts
+++ b/lib/vendor-candidates/service.ts
@@ -1,6 +1,5 @@
"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
-import { vendorCandidates} from "@/db/schema/vendors"
import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm";
import { revalidateTag, unstable_noStore } from "next/cache";
import { filterColumns } from "@/lib/filter-columns";
@@ -10,16 +9,20 @@ import db from "@/db/db";
import { sendEmail } from "../mail/sendEmail";
import { CreateVendorCandidateSchema, createVendorCandidateSchema, GetVendorsCandidateSchema, RemoveCandidatesInput, removeCandidatesSchema, updateVendorCandidateSchema, UpdateVendorCandidateSchema } from "./validations";
import { PgTransaction } from "drizzle-orm/pg-core";
+import { users, vendorCandidateLogs, vendorCandidates, vendorCandidatesWithVendorInfo } from "@/db/schema";
+import { headers } from 'next/headers';
export async function getVendorCandidates(input: GetVendorsCandidateSchema) {
return unstable_cache(
async () => {
try {
const offset = (input.page - 1) * input.perPage
+ const fromDate = input.from ? new Date(input.from) : undefined;
+ const toDate = input.to ? new Date(input.to) : undefined;
// 1) Advanced filters
const advancedWhere = filterColumns({
- table: vendorCandidates,
+ table: vendorCandidatesWithVendorInfo,
filters: input.filters,
joinOperator: input.joinOperator,
})
@@ -29,12 +32,16 @@ export async function getVendorCandidates(input: GetVendorsCandidateSchema) {
if (input.search) {
const s = `%${input.search}%`
globalWhere = or(
- ilike(vendorCandidates.companyName, s),
- ilike(vendorCandidates.contactEmail, s),
- ilike(vendorCandidates.contactPhone, s),
- ilike(vendorCandidates.country, s),
- ilike(vendorCandidates.source, s),
- ilike(vendorCandidates.status, s),
+ ilike(vendorCandidatesWithVendorInfo.companyName, s),
+ ilike(vendorCandidatesWithVendorInfo.contactEmail, s),
+ ilike(vendorCandidatesWithVendorInfo.contactPhone, s),
+ ilike(vendorCandidatesWithVendorInfo.country, s),
+ ilike(vendorCandidatesWithVendorInfo.source, s),
+ ilike(vendorCandidatesWithVendorInfo.status, s),
+ ilike(vendorCandidatesWithVendorInfo.taxId, s),
+ ilike(vendorCandidatesWithVendorInfo.items, s),
+ ilike(vendorCandidatesWithVendorInfo.remark, s),
+ ilike(vendorCandidatesWithVendorInfo.address, s),
// etc.
)
}
@@ -44,6 +51,8 @@ export async function getVendorCandidates(input: GetVendorsCandidateSchema) {
const finalWhere = and(
advancedWhere,
globalWhere,
+ fromDate ? gte(vendorCandidatesWithVendorInfo.createdAt, fromDate) : undefined,
+ toDate ? lte(vendorCandidatesWithVendorInfo.createdAt, toDate) : undefined
)
@@ -53,17 +62,17 @@ export async function getVendorCandidates(input: GetVendorsCandidateSchema) {
input.sort && input.sort.length > 0
? input.sort.map((item) =>
item.desc
- ? desc(vendorCandidates[item.id])
- : asc(vendorCandidates[item.id])
+ ? desc(vendorCandidatesWithVendorInfo[item.id])
+ : asc(vendorCandidatesWithVendorInfo[item.id])
)
- : [desc(vendorCandidates.createdAt)]
+ : [desc(vendorCandidatesWithVendorInfo.createdAt)]
// 6) Query & count
const { data, total } = await db.transaction(async (tx) => {
// a) Select from the view
const candidatesData = await tx
.select()
- .from(vendorCandidates)
+ .from(vendorCandidatesWithVendorInfo)
.where(finalWhere)
.orderBy(...orderBy)
.offset(offset)
@@ -72,7 +81,7 @@ export async function getVendorCandidates(input: GetVendorsCandidateSchema) {
// b) Count total
const resCount = await tx
.select({ count: count() })
- .from(vendorCandidates)
+ .from(vendorCandidatesWithVendorInfo)
.where(finalWhere)
return { data: candidatesData, total: resCount[0]?.count }
@@ -98,30 +107,48 @@ export async function getVendorCandidates(input: GetVendorsCandidateSchema) {
)()
}
-export async function createVendorCandidate(input: CreateVendorCandidateSchema) {
+export async function createVendorCandidate(input: CreateVendorCandidateSchema, userId: number) {
try {
// Validate input
const validated = createVendorCandidateSchema.parse(input);
- // Insert into database
- const [newCandidate] = await db
- .insert(vendorCandidates)
- .values({
- companyName: validated.companyName,
- contactEmail: validated.contactEmail,
- contactPhone: validated.contactPhone || null,
- country: validated.country || null,
- source: validated.source || null,
- status: validated.status,
- createdAt: new Date(),
- updatedAt: new Date(),
- })
- .returning();
+ // 트랜잭션으로 데이터 삽입과 로그 기록을 원자적으로 처리
+ const result = await db.transaction(async (tx) => {
+ // Insert into database
+ const [newCandidate] = await tx
+ .insert(vendorCandidates)
+ .values({
+ companyName: validated.companyName,
+ contactEmail: validated.contactEmail,
+ contactPhone: validated.contactPhone || null,
+ taxId: validated.taxId || "",
+ address: validated.address || null,
+ country: validated.country || null,
+ source: validated.source || null,
+ status: validated.status || "COLLECTED",
+ remark: validated.remark || null,
+ items: validated.items || "", // items가 필수 필드이므로 빈 문자열이라도 제공
+ vendorId: validated.vendorId || null,
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ // 로그에 기록
+ await tx.insert(vendorCandidateLogs).values({
+ vendorCandidateId: newCandidate.id,
+ userId: userId,
+ action: "create",
+ newStatus: newCandidate.status,
+ comment: `Created new vendor candidate: ${newCandidate.companyName}`
+ });
+
+ return newCandidate;
+ });
// Invalidate cache
revalidateTag("vendor-candidates");
- return { success: true, data: newCandidate };
+ return { success: true, data: result };
} catch (error) {
console.error("Failed to create vendor candidate:", error);
return { success: false, error: getErrorMessage(error) };
@@ -187,60 +214,107 @@ export async function getVendorCandidateCounts() {
/**
* Update a vendor candidate
*/
-export async function updateVendorCandidate(input: UpdateVendorCandidateSchema) {
+export async function updateVendorCandidate(input: UpdateVendorCandidateSchema, userId: number) {
try {
// Validate input
const validated = updateVendorCandidateSchema.parse(input);
// Prepare update data (excluding id)
const { id, ...updateData } = validated;
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
+ const baseUrl = `http://${host}`
// Add updatedAt timestamp
const dataToUpdate = {
...updateData,
updatedAt: new Date(),
};
-
- // Update database
- const [updatedCandidate] = await db
- .update(vendorCandidates)
- .set(dataToUpdate)
- .where(eq(vendorCandidates.id, id))
- .returning();
-
- // If status was updated to "INVITED", send email
- if (validated.status === "INVITED" && updatedCandidate.contactEmail) {
- await sendEmail({
- to: updatedCandidate.contactEmail,
- subject: "Invitation to Register as a Vendor",
- template: "vendor-invitation",
- context: {
- companyName: updatedCandidate.companyName,
- language: "en",
- registrationLink: `${process.env.NEXT_PUBLIC_APP_URL}/en/partners`,
- }
+
+ const result = await db.transaction(async (tx) => {
+ // 현재 데이터 조회 (상태 변경 감지를 위해)
+ const [existingCandidate] = await tx
+ .select()
+ .from(vendorCandidates)
+ .where(eq(vendorCandidates.id, id));
+
+ if (!existingCandidate) {
+ throw new Error("Vendor candidate not found");
+ }
+
+ // Update database
+ const [updatedCandidate] = await tx
+ .update(vendorCandidates)
+ .set(dataToUpdate)
+ .where(eq(vendorCandidates.id, id))
+ .returning();
+
+ // 로그 작성
+ const statusChanged =
+ updateData.status &&
+ existingCandidate.status !== updateData.status;
+
+ await tx.insert(vendorCandidateLogs).values({
+ vendorCandidateId: id,
+ userId: userId,
+ action: statusChanged ? "status_change" : "update",
+ oldStatus: statusChanged ? existingCandidate.status : undefined,
+ newStatus: statusChanged ? updateData.status : undefined,
+ comment: statusChanged
+ ? `Status changed from ${existingCandidate.status} to ${updateData.status}`
+ : `Updated vendor candidate: ${existingCandidate.companyName}`
});
- }
+
+
+
+ // If status was updated to "INVITED", send email
+ if (statusChanged && updateData.status === "INVITED" && updatedCandidate.contactEmail) {
+ await sendEmail({
+ to: updatedCandidate.contactEmail,
+ subject: "Invitation to Register as a Vendor",
+ template: "vendor-invitation",
+ context: {
+ companyName: updatedCandidate.companyName,
+ language: "en",
+ registrationLink: `${baseUrl}/en/partners`,
+ }
+ });
+
+ // 이메일 전송 로그
+ await tx.insert(vendorCandidateLogs).values({
+ vendorCandidateId: id,
+ userId: userId,
+ action: "invite_sent",
+ comment: `Invitation email sent to ${updatedCandidate.contactEmail}`
+ });
+ }
+
+ return updatedCandidate;
+ });
+
// Invalidate cache
revalidateTag("vendor-candidates");
-
- return { success: true, data: updatedCandidate };
+
+ return { success: true, data: result };
} catch (error) {
console.error("Failed to update vendor candidate:", error);
return { success: false, error: getErrorMessage(error) };
}
}
-/**
- * Update status of multiple vendor candidates at once
- */
export async function bulkUpdateVendorCandidateStatus({
ids,
- status
+ status,
+ userId,
+ comment
}: {
ids: number[],
- status: "COLLECTED" | "INVITED" | "DISCARDED"
+ status: "COLLECTED" | "INVITED" | "DISCARDED",
+ userId: number,
+ comment?: string
}) {
try {
// Validate inputs
@@ -252,50 +326,86 @@ export async function bulkUpdateVendorCandidateStatus({
return { success: false, error: "Invalid status" };
}
- // Get current data of candidates (needed for email sending)
- const candidatesBeforeUpdate = await db
- .select()
- .from(vendorCandidates)
- .where(inArray(vendorCandidates.id, ids));
-
- // Update all records
- const updatedCandidates = await db
- .update(vendorCandidates)
- .set({
- status,
- updatedAt: new Date(),
- })
- .where(inArray(vendorCandidates.id, ids))
- .returning();
-
- // If status is "INVITED", send emails to all updated candidates
- if (status === "INVITED") {
- const emailPromises = updatedCandidates
- .filter(candidate => candidate.contactEmail)
- .map(candidate =>
- sendEmail({
- to: candidate.contactEmail!,
- subject: "Invitation to Register as a Vendor",
- template: "vendor-invitation",
- context: {
- companyName: candidate.companyName,
- language: "en",
- registrationLink: `${process.env.NEXT_PUBLIC_APP_URL}/en/partners`,
- }
- })
- );
-
- // Wait for all emails to be sent
- await Promise.all(emailPromises);
- }
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ const baseUrl = `http://${host}`
+
+ const result = await db.transaction(async (tx) => {
+ // Get current data of candidates (needed for email sending and logging)
+ const candidatesBeforeUpdate = await tx
+ .select()
+ .from(vendorCandidates)
+ .where(inArray(vendorCandidates.id, ids));
+
+ // Update all records
+ const updatedCandidates = await tx
+ .update(vendorCandidates)
+ .set({
+ status,
+ updatedAt: new Date(),
+ })
+ .where(inArray(vendorCandidates.id, ids))
+ .returning();
+
+ // 각 후보자에 대한 로그 생성
+ const logPromises = candidatesBeforeUpdate.map(candidate => {
+ if (candidate.status === status) {
+ // 상태가 변경되지 않은 경우 로그 생성하지 않음
+ return Promise.resolve();
+ }
+
+ return tx.insert(vendorCandidateLogs).values({
+ vendorCandidateId: candidate.id,
+ userId: userId,
+ action: "status_change",
+ oldStatus: candidate.status,
+ newStatus: status,
+ comment: comment || `Bulk status update to ${status}`
+ });
+ });
+
+ await Promise.all(logPromises);
+
+ // If status is "INVITED", send emails to all updated candidates
+ if (status === "INVITED") {
+ const emailPromises = updatedCandidates
+ .filter(candidate => candidate.contactEmail)
+ .map(async (candidate) => {
+ await sendEmail({
+ to: candidate.contactEmail!,
+ subject: "Invitation to Register as a Vendor",
+ template: "vendor-invitation",
+ context: {
+ companyName: candidate.companyName,
+ language: "en",
+ registrationLink: `${baseUrl}/en/partners`,
+ }
+ });
+
+ // 이메일 발송 로그
+ await tx.insert(vendorCandidateLogs).values({
+ vendorCandidateId: candidate.id,
+ userId: userId,
+ action: "invite_sent",
+ comment: `Invitation email sent to ${candidate.contactEmail}`
+ });
+ });
+
+ // Wait for all emails to be sent
+ await Promise.all(emailPromises);
+ }
+
+ return updatedCandidates;
+ });
+
// Invalidate cache
revalidateTag("vendor-candidates");
-
- return {
- success: true,
- data: updatedCandidates,
- count: updatedCandidates.length
+
+ return {
+ success: true,
+ data: result,
+ count: result.length
};
} catch (error) {
console.error("Failed to bulk update vendor candidates:", error);
@@ -303,58 +413,111 @@ export async function bulkUpdateVendorCandidateStatus({
}
}
-
-
-
-/**
- * Remove multiple vendor candidates by their IDs
- */
-export async function removeCandidates(input: RemoveCandidatesInput) {
+// 4. 후보자 삭제 함수 업데이트
+export async function removeCandidates(input: RemoveCandidatesInput, userId: number) {
try {
// Validate input
const validated = removeCandidatesSchema.parse(input);
-
- // Get candidates before deletion (for logging purposes)
- const candidatesBeforeDelete = await db
- .select({
- id: vendorCandidates.id,
- companyName: vendorCandidates.companyName,
- })
- .from(vendorCandidates)
- .where(inArray(vendorCandidates.id, validated.ids));
-
- // Delete the candidates
- const deletedCandidates = await db
- .delete(vendorCandidates)
- .where(inArray(vendorCandidates.id, validated.ids))
- .returning({ id: vendorCandidates.id });
-
+
+ const result = await db.transaction(async (tx) => {
+ // Get candidates before deletion (for logging purposes)
+ const candidatesBeforeDelete = await tx
+ .select()
+ .from(vendorCandidates)
+ .where(inArray(vendorCandidates.id, validated.ids));
+
+ // 각 삭제될 후보자에 대한 로그 생성
+ for (const candidate of candidatesBeforeDelete) {
+ await tx.insert(vendorCandidateLogs).values({
+ vendorCandidateId: candidate.id,
+ userId: userId,
+ action: "delete",
+ oldStatus: candidate.status,
+ comment: `Deleted vendor candidate: ${candidate.companyName}`
+ });
+ }
+
+ // Delete the candidates
+ const deletedCandidates = await tx
+ .delete(vendorCandidates)
+ .where(inArray(vendorCandidates.id, validated.ids))
+ .returning({ id: vendorCandidates.id });
+
+ return {
+ deletedCandidates,
+ candidatesBeforeDelete
+ };
+ });
+
// If no candidates were deleted, return an error
- if (!deletedCandidates.length) {
+ if (!result.deletedCandidates.length) {
return {
success: false,
error: "No candidates were found with the provided IDs",
};
}
-
+
// Log deletion for audit purposes
console.log(
- `Deleted ${deletedCandidates.length} vendor candidates:`,
- candidatesBeforeDelete.map(c => `${c.id} (${c.companyName})`)
+ `Deleted ${result.deletedCandidates.length} vendor candidates:`,
+ result.candidatesBeforeDelete.map(c => `${c.id} (${c.companyName})`)
);
-
+
// Invalidate cache
revalidateTag("vendor-candidates");
revalidateTag("vendor-candidate-status-counts");
revalidateTag("vendor-candidate-total-count");
-
+
return {
success: true,
- count: deletedCandidates.length,
- deletedIds: deletedCandidates.map(c => c.id),
+ count: result.deletedCandidates.length,
+ deletedIds: result.deletedCandidates.map(c => c.id),
};
} catch (error) {
console.error("Failed to remove vendor candidates:", error);
return { success: false, error: getErrorMessage(error) };
}
+}
+
+export interface CandidateLogWithUser {
+ id: number
+ vendorCandidateId: number
+ userId: number
+ userName: string | null
+ userEmail: string | null
+ action: string
+ oldStatus: string | null
+ newStatus: string | null
+ comment: string | null
+ createdAt: Date
+}
+
+export async function getCandidateLogs(candidateId: number): Promise<CandidateLogWithUser[]> {
+ try {
+ const logs = await db
+ .select({
+ // vendor_candidate_logs 필드
+ id: vendorCandidateLogs.id,
+ vendorCandidateId: vendorCandidateLogs.vendorCandidateId,
+ userId: vendorCandidateLogs.userId,
+ action: vendorCandidateLogs.action,
+ oldStatus: vendorCandidateLogs.oldStatus,
+ newStatus: vendorCandidateLogs.newStatus,
+ comment: vendorCandidateLogs.comment,
+ createdAt: vendorCandidateLogs.createdAt,
+
+ // 조인한 users 테이블 필드
+ userName: users.name,
+ userEmail: users.email,
+ })
+ .from(vendorCandidateLogs)
+ .leftJoin(users, eq(vendorCandidateLogs.userId, users.id))
+ .where(eq(vendorCandidateLogs.vendorCandidateId, candidateId))
+ .orderBy(desc(vendorCandidateLogs.createdAt))
+
+ return logs
+ } catch (error) {
+ console.error("Failed to fetch candidate logs with user info:", error)
+ throw error
+ }
} \ No newline at end of file
diff --git a/lib/vendor-candidates/table/add-candidates-dialog.tsx b/lib/vendor-candidates/table/add-candidates-dialog.tsx
index db475064..733d3716 100644
--- a/lib/vendor-candidates/table/add-candidates-dialog.tsx
+++ b/lib/vendor-candidates/table/add-candidates-dialog.tsx
@@ -8,10 +8,13 @@ import i18nIsoCountries from "i18n-iso-countries"
import enLocale from "i18n-iso-countries/langs/en.json"
import koLocale from "i18n-iso-countries/langs/ko.json"
import { cn } from "@/lib/utils"
+import { useSession } from "next-auth/react" // next-auth 세션 훅 추가
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { useToast } from "@/hooks/use-toast"
import {
Popover,
PopoverContent,
@@ -36,19 +39,9 @@ import {
FormMessage,
} from "@/components/ui/form"
-// shadcn/ui Select
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
import { createVendorCandidateSchema, CreateVendorCandidateSchema } from "../validations"
import { createVendorCandidate } from "../service"
-import { vendorCandidates } from "@/db/schema/vendors"
// Register locales for countries
i18nIsoCountries.registerLocale(enLocale)
@@ -65,34 +58,65 @@ const countryArray = Object.entries(countryMap).map(([code, label]) => ({
export function AddCandidateDialog() {
const [open, setOpen] = React.useState(false)
const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const { toast } = useToast()
+ const { data: session, status } = useSession()
// react-hook-form 세팅
const form = useForm<CreateVendorCandidateSchema>({
resolver: zodResolver(createVendorCandidateSchema),
defaultValues: {
companyName: "",
- contactEmail: "",
+ contactEmail: "", // 이제 빈 문자열이 허용됨
contactPhone: "",
+ taxId: "",
+ address: "",
country: "",
source: "",
- status: "COLLECTED", // Default status set to COLLECTED
+ items: "",
+ remark: "",
+ status: "COLLECTED",
},
- })
+ });
async function onSubmit(data: CreateVendorCandidateSchema) {
setIsSubmitting(true)
try {
- const result = await createVendorCandidate(data)
+ // 세션 유효성 검사
+ if (!session || !session.user || !session.user.id) {
+ toast({
+ title: "인증 오류",
+ description: "로그인 정보를 찾을 수 없습니다. 다시 로그인해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+
+ // userId 추출 (세션 구조에 따라 조정 필요)
+ const userId = session.user.id
+
+ const result = await createVendorCandidate(data, Number(userId))
if (result.error) {
- alert(`에러: ${result.error}`)
+ toast({
+ title: "오류 발생",
+ description: result.error,
+ variant: "destructive",
+ })
return
}
// 성공 시 모달 닫고 폼 리셋
+ toast({
+ title: "등록 완료",
+ description: "협력업체 후보가 성공적으로 등록되었습니다.",
+ })
form.reset()
setOpen(false)
} catch (error) {
console.error("Failed to create vendor candidate:", error)
- alert("An unexpected error occurred")
+ toast({
+ title: "오류 발생",
+ description: "예상치 못한 오류가 발생했습니다.",
+ variant: "destructive",
+ })
} finally {
setIsSubmitting(false)
}
@@ -114,7 +138,7 @@ export function AddCandidateDialog() {
</Button>
</DialogTrigger>
- <DialogContent className="sm:max-w-[425px]">
+ <DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>Create New Vendor Candidate</DialogTitle>
<DialogDescription>
@@ -124,17 +148,15 @@ export function AddCandidateDialog() {
{/* shadcn/ui Form을 이용해 react-hook-form과 연결 */}
<Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)}>
- <div className="space-y-4 py-4">
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Company Name 필드 */}
<FormField
control={form.control}
name="companyName"
render={({ field }) => (
<FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- Company Name
- </FormLabel>
+ <FormLabel>Company Name <span className="text-red-500">*</span></FormLabel>
<FormControl>
<Input
placeholder="Enter company name"
@@ -147,15 +169,32 @@ export function AddCandidateDialog() {
)}
/>
+ {/* Tax ID 필드 (새로 추가) */}
+ <FormField
+ control={form.control}
+ name="taxId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Tax ID</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="Tax identification number"
+ {...field}
+ disabled={isSubmitting}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
{/* Contact Email 필드 */}
<FormField
control={form.control}
name="contactEmail"
render={({ field }) => (
<FormItem>
- <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
- Contact Email
- </FormLabel>
+ <FormLabel>Contact Email</FormLabel>
<FormControl>
<Input
placeholder="email@example.com"
@@ -188,6 +227,25 @@ export function AddCandidateDialog() {
)}
/>
+ {/* Address 필드 */}
+ <FormField
+ control={form.control}
+ name="address"
+ render={({ field }) => (
+ <FormItem className="col-span-full">
+ <FormLabel>Address</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="Company address"
+ {...field}
+ disabled={isSubmitting}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
{/* Country 필드 */}
<FormField
control={form.control}
@@ -260,7 +318,7 @@ export function AddCandidateDialog() {
name="source"
render={({ field }) => (
<FormItem>
- <FormLabel>Source</FormLabel>
+ <FormLabel>Source <span className="text-red-500">*</span></FormLabel>
<FormControl>
<Input
placeholder="Where this candidate was found"
@@ -273,37 +331,46 @@ export function AddCandidateDialog() {
)}
/>
- {/* Status 필드 */}
- {/* <FormField
+
+ {/* Items 필드 (새로 추가) */}
+ <FormField
control={form.control}
- name="status"
+ name="items"
render={({ field }) => (
- <FormItem>
- <FormLabel>Status</FormLabel>
- <Select
- onValueChange={field.onChange}
- defaultValue={field.value}
- disabled={isSubmitting}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="Select status" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectGroup>
- {vendorCandidates.status.enumValues.map((status) => (
- <SelectItem key={status} value={status}>
- {status}
- </SelectItem>
- ))}
- </SelectGroup>
- </SelectContent>
- </Select>
+ <FormItem className="col-span-full">
+ <FormLabel>Items <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="List of items or products this vendor provides"
+ className="min-h-[80px]"
+ {...field}
+ disabled={isSubmitting}
+ />
+ </FormControl>
<FormMessage />
</FormItem>
)}
- /> */}
+ />
+
+ {/* Remark 필드 (새로 추가) */}
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem className="col-span-full">
+ <FormLabel>Remarks</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Additional notes or comments"
+ className="min-h-[80px]"
+ {...field}
+ disabled={isSubmitting}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
</div>
<DialogFooter>
diff --git a/lib/vendor-candidates/table/candidates-table-columns.tsx b/lib/vendor-candidates/table/candidates-table-columns.tsx
index dc014d4e..113927cf 100644
--- a/lib/vendor-candidates/table/candidates-table-columns.tsx
+++ b/lib/vendor-candidates/table/candidates-table-columns.tsx
@@ -7,7 +7,7 @@ import { Ellipsis } from "lucide-react"
import { toast } from "sonner"
import { getErrorMessage } from "@/lib/handle-error"
-import { formatDate } from "@/lib/utils"
+import { formatDate, formatDateTime } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
@@ -24,24 +24,24 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
-import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
-import { VendorCandidates, vendorCandidates } from "@/db/schema/vendors"
import { getCandidateStatusIcon } from "@/lib/vendor-candidates/utils"
import { candidateColumnsConfig } from "@/config/candidatesColumnsConfig"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { VendorCandidatesWithVendorInfo } from "@/db/schema"
interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorCandidates> | null>>
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorCandidatesWithVendorInfo> | null>>
}
/**
* tanstack table 컬럼 정의 (중첩 헤더 버전)
*/
-export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorCandidates>[] {
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorCandidatesWithVendorInfo>[] {
// ----------------------------------------------------------------
// 1) select 컬럼 (체크박스)
// ----------------------------------------------------------------
- const selectColumn: ColumnDef<VendorCandidates> = {
+ const selectColumn: ColumnDef<VendorCandidatesWithVendorInfo> = {
id: "select",
header: ({ table }) => (
<Checkbox
@@ -70,48 +70,54 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorC
// ----------------------------------------------------------------
// 2) actions 컬럼 (Dropdown 메뉴)
// ----------------------------------------------------------------
- const actionsColumn: ColumnDef<VendorCandidates> = {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-40">
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
- >
- Edit
- </DropdownMenuItem>
-
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "delete" })}
- >
- Delete
- <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- }
+// "actions" 컬럼 예시
+const actionsColumn: ColumnDef<VendorCandidatesWithVendorInfo> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ 편집
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ 삭제
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+
+ {/* 여기서 Log 보기 액션 추가 */}
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "log" })}
+ >
+ 감사 로그 보기
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+}
+
// ----------------------------------------------------------------
// 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
// ----------------------------------------------------------------
- // 3-1) groupMap: { [groupName]: ColumnDef<VendorCandidates>[] }
- const groupMap: Record<string, ColumnDef<VendorCandidates>[]> = {}
+ // 3-1) groupMap: { [groupName]: ColumnDef<VendorCandidatesWithVendorInfo>[] }
+ const groupMap: Record<string, ColumnDef<VendorCandidatesWithVendorInfo>[]> = {}
candidateColumnsConfig.forEach((cfg) => {
// 만약 group가 없으면 "_noGroup" 처리
@@ -122,11 +128,11 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorC
}
// child column 정의
- const childCol: ColumnDef<VendorCandidates> = {
+ const childCol: ColumnDef<VendorCandidatesWithVendorInfo> = {
accessorKey: cfg.id,
enableResizing: true,
header: ({ column }) => (
- <DataTableColumnHeader column={column} title={cfg.label} />
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
),
meta: {
excelHeader: cfg.excelHeader,
@@ -148,9 +154,9 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorC
}
- if (cfg.id === "createdAt") {
+ if (cfg.id === "createdAt" ||cfg.id === "updatedAt" ) {
const dateVal = cell.getValue() as Date
- return formatDate(dateVal)
+ return formatDateTime(dateVal)
}
// code etc...
@@ -164,7 +170,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorC
// ----------------------------------------------------------------
// 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
// ----------------------------------------------------------------
- const nestedColumns: ColumnDef<VendorCandidates>[] = []
+ const nestedColumns: ColumnDef<VendorCandidatesWithVendorInfo>[] = []
// 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
// 여기서는 그냥 Object.entries 순서
diff --git a/lib/vendor-candidates/table/candidates-table-floating-bar.tsx b/lib/vendor-candidates/table/candidates-table-floating-bar.tsx
index 2696292d..baf4a583 100644
--- a/lib/vendor-candidates/table/candidates-table-floating-bar.tsx
+++ b/lib/vendor-candidates/table/candidates-table-floating-bar.tsx
@@ -30,37 +30,48 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { Kbd } from "@/components/kbd"
+import { useSession } from "next-auth/react" // next-auth 세션 훅
import { ActionConfirmDialog } from "@/components/ui/action-dialog"
-import { vendorCandidates, VendorCandidates } from "@/db/schema/vendors"
-import { bulkUpdateVendorCandidateStatus, removeCandidates, updateVendorCandidate } from "../service"
+import { VendorCandidatesWithVendorInfo, vendorCandidates } from "@/db/schema/vendors"
+import {
+ bulkUpdateVendorCandidateStatus,
+ removeCandidates,
+} from "../service"
+/**
+ * 테이블 상단/하단에 고정되는 Floating Bar
+ * 상태 일괄 변경, 초대, 삭제, Export 등을 수행
+ */
interface CandidatesTableFloatingBarProps {
- table: Table<VendorCandidates>
+ table: Table<VendorCandidatesWithVendorInfo>
}
-export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloatingBarProps) {
+export function VendorCandidateTableFloatingBar({
+ table,
+}: CandidatesTableFloatingBarProps) {
const rows = table.getFilteredSelectedRowModel().rows
+ const { data: session, status } = useSession()
+ // React 18의 startTransition 사용 (isPending으로 트랜지션 상태 확인)
const [isPending, startTransition] = React.useTransition()
const [action, setAction] = React.useState<
"update-status" | "export" | "delete" | "invite"
>()
const [popoverOpen, setPopoverOpen] = React.useState(false)
- // Clear selection on Escape key press
+ // ESC 키로 selection 해제
React.useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
table.toggleAllRowsSelected(false)
}
}
-
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [table])
- // 공용 confirm dialog state
+ // 공용 Confirm Dialog (ActionConfirmDialog) 제어
const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false)
const [confirmProps, setConfirmProps] = React.useState<{
title: string
@@ -69,25 +80,41 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati
}>({
title: "",
description: "",
- onConfirm: () => { },
+ onConfirm: () => {},
})
- // 1) "삭제" Confirm 열기
+ /**
+ * 1) 삭제 버튼 클릭 시 Confirm Dialog 열기
+ */
function handleDeleteConfirm() {
setAction("delete")
+
setConfirmProps({
- title: `Delete ${rows.length} user${rows.length > 1 ? "s" : ""}?`,
+ title: `Delete ${rows.length} candidate${
+ rows.length > 1 ? "s" : ""
+ }?`,
description: "This action cannot be undone.",
onConfirm: async () => {
startTransition(async () => {
- const { error } = await removeCandidates({
- ids: rows.map((row) => row.original.id),
- })
+ if (!session?.user?.id) {
+ toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.")
+ return
+ }
+ const userId = Number(session.user.id)
+
+ // removeCandidates 호출 시 userId를 넘긴다고 가정
+ const { error } = await removeCandidates(
+ {
+ ids: rows.map((row) => row.original.id),
+ },
+ userId
+ )
+
if (error) {
toast.error(error)
return
}
- toast.success("Users deleted")
+ toast.success("Candidates deleted successfully")
table.toggleAllRowsSelected(false)
setConfirmDialogOpen(false)
})
@@ -96,43 +123,71 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati
setConfirmDialogOpen(true)
}
- // 2) 상태 업데이트
- function handleSelectStatus(newStatus: VendorCandidates["status"]) {
+ /**
+ * 2) 선택된 후보들의 상태 일괄 업데이트
+ */
+ function handleSelectStatus(newStatus: VendorCandidatesWithVendorInfo["status"]) {
setAction("update-status")
setConfirmProps({
- title: `Update ${rows.length} candidate${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`,
+ title: `Update ${rows.length} candidate${
+ rows.length > 1 ? "s" : ""
+ } with status: ${newStatus}?`,
description: "This action will override their current status.",
onConfirm: async () => {
startTransition(async () => {
+ if (!session?.user?.id) {
+ toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.")
+ return
+ }
+ const userId = Number(session.user.id)
+
const { error } = await bulkUpdateVendorCandidateStatus({
ids: rows.map((row) => row.original.id),
status: newStatus,
+ userId,
+ comment: `Bulk status update to ${newStatus}`,
})
+
if (error) {
toast.error(error)
return
}
toast.success("Candidates updated")
setConfirmDialogOpen(false)
+ table.toggleAllRowsSelected(false)
})
},
})
setConfirmDialogOpen(true)
}
- // 3) 초대하기 (INVITED 상태로 바꾸고 이메일 전송)
+ /**
+ * 3) 초대하기 (status = "INVITED" + 이메일 발송)
+ */
function handleInvite() {
setAction("invite")
setConfirmProps({
- title: `Invite ${rows.length} candidate${rows.length > 1 ? "s" : ""}?`,
- description: "This will change their status to INVITED and send invitation emails.",
+ title: `Invite ${rows.length} candidate${
+ rows.length > 1 ? "s" : ""
+ }?`,
+ description:
+ "This will change their status to INVITED and send invitation emails.",
onConfirm: async () => {
startTransition(async () => {
+ if (!session?.user?.id) {
+ toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.")
+ return
+ }
+ const userId = Number(session.user.id)
+
const { error } = await bulkUpdateVendorCandidateStatus({
ids: rows.map((row) => row.original.id),
status: "INVITED",
+ userId,
+ comment: "Bulk invite action",
})
+
if (error) {
toast.error(error)
return
@@ -147,166 +202,168 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati
}
return (
- <Portal >
- <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}>
- <div className="w-full overflow-x-auto">
- <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow">
- <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1">
- <span className="whitespace-nowrap text-xs">
- {rows.length} selected
- </span>
- <Separator orientation="vertical" className="ml-2 mr-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="ghost"
- size="icon"
- className="size-5 hover:border"
- onClick={() => table.toggleAllRowsSelected(false)}
- >
- <X className="size-3.5 shrink-0" aria-hidden="true" />
- </Button>
- </TooltipTrigger>
- <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900">
- <p className="mr-2">Clear selection</p>
- <Kbd abbrTitle="Escape" variant="outline">
- Esc
- </Kbd>
- </TooltipContent>
- </Tooltip>
- </div>
- <Separator orientation="vertical" className="hidden h-5 sm:block" />
- <div className="flex items-center gap-1.5">
- {/* 초대하기 버튼 (새로 추가) */}
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="secondary"
- size="sm"
- className="h-7 border"
- onClick={handleInvite}
- disabled={isPending}
- >
- {isPending && action === "invite" ? (
- <Loader
- className="mr-1 size-3.5 animate-spin"
- aria-hidden="true"
- />
- ) : (
- <Mail className="mr-1 size-3.5" aria-hidden="true" />
- )}
- <span>Invite</span>
- </Button>
- </TooltipTrigger>
- <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
- <p>Send invitation emails</p>
- </TooltipContent>
- </Tooltip>
+ <>
+ {/* 선택된 row가 있을 때 표시되는 Floating Bar */}
+ <div className="flex justify-center w-full my-4">
+ <div className="flex items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow">
+ {/* 선택된 갯수 표시 + Clear selection 버튼 */}
+ <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1">
+ <span className="whitespace-nowrap text-xs">
+ {rows.length} selected
+ </span>
+ <Separator orientation="vertical" className="ml-2 mr-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-5 hover:border"
+ onClick={() => table.toggleAllRowsSelected(false)}
+ >
+ <X className="size-3.5 shrink-0" aria-hidden="true" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900">
+ <p className="mr-2">Clear selection</p>
+ <Kbd abbrTitle="Escape" variant="outline">
+ Esc
+ </Kbd>
+ </TooltipContent>
+ </Tooltip>
+ </div>
- <Select
- onValueChange={(value: VendorCandidates["status"]) => {
- handleSelectStatus(value)
- }}
- >
- <Tooltip>
- <SelectTrigger asChild>
- <TooltipTrigger asChild>
- <Button
- variant="secondary"
- size="icon"
- className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground"
- disabled={isPending}
- >
- {isPending && action === "update-status" ? (
- <Loader
- className="size-3.5 animate-spin"
- aria-hidden="true"
- />
- ) : (
- <CheckCircle2
- className="size-3.5"
- aria-hidden="true"
- />
- )}
- </Button>
- </TooltipTrigger>
- </SelectTrigger>
- <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
- <p>Update status</p>
- </TooltipContent>
- </Tooltip>
- <SelectContent align="center">
- <SelectGroup>
- {vendorCandidates.status.enumValues.map((status) => (
- <SelectItem
- key={status}
- value={status}
- className="capitalize"
- >
- {status}
- </SelectItem>
- ))}
- </SelectGroup>
- </SelectContent>
- </Select>
+ <Separator orientation="vertical" className="hidden h-5 sm:block" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="secondary"
- size="icon"
- className="size-7 border"
- onClick={() => {
- setAction("export")
+ {/* 우측 액션들: 초대, 상태변경, Export, 삭제 */}
+ <div className="flex items-center gap-1.5">
+ {/* 초대하기 */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="sm"
+ className="h-7 border"
+ onClick={handleInvite}
+ disabled={isPending}
+ >
+ {isPending && action === "invite" ? (
+ <Loader
+ className="mr-1 size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <Mail className="mr-1 size-3.5" aria-hidden="true" />
+ )}
+ <span>Invite</span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Send invitation emails</p>
+ </TooltipContent>
+ </Tooltip>
- startTransition(() => {
- exportTableToExcel(table, {
- excludeColumns: ["select", "actions"],
- onlySelected: true,
- })
- })
- }}
- disabled={isPending}
- >
- {isPending && action === "export" ? (
- <Loader
- className="size-3.5 animate-spin"
- aria-hidden="true"
- />
- ) : (
- <Download className="size-3.5" aria-hidden="true" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
- <p>Export candidates</p>
- </TooltipContent>
- </Tooltip>
-
+ {/* 상태 업데이트 (Select) */}
+ <Select
+ onValueChange={(value: VendorCandidatesWithVendorInfo["status"]) => {
+ handleSelectStatus(value)
+ }}
+ >
<Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="secondary"
- size="icon"
- className="size-7 border"
- onClick={handleDeleteConfirm}
- disabled={isPending}
- >
- {isPending && action === "delete" ? (
- <Loader
- className="size-3.5 animate-spin"
- aria-hidden="true"
- />
- ) : (
- <Trash2 className="size-3.5" aria-hidden="true" />
- )}
- </Button>
- </TooltipTrigger>
+ <SelectTrigger asChild>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="icon"
+ className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground"
+ disabled={isPending}
+ >
+ {isPending && action === "update-status" ? (
+ <Loader
+ className="size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <CheckCircle2 className="size-3.5" aria-hidden="true" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ </SelectTrigger>
<TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
- <p>Delete candidates</p>
+ <p>Update status</p>
</TooltipContent>
</Tooltip>
- </div>
+ <SelectContent align="center">
+ <SelectGroup>
+ {vendorCandidates.status.enumValues.map((status) => (
+ <SelectItem
+ key={status}
+ value={status}
+ className="capitalize"
+ >
+ {status}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+
+ {/* Export 버튼 */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="icon"
+ className="size-7 border"
+ onClick={() => {
+ setAction("export")
+ startTransition(() => {
+ exportTableToExcel(table, {
+ excludeColumns: ["select", "actions"],
+ onlySelected: true,
+ })
+ })
+ }}
+ disabled={isPending}
+ >
+ {isPending && action === "export" ? (
+ <Loader
+ className="size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <Download className="size-3.5" aria-hidden="true" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Export candidates</p>
+ </TooltipContent>
+ </Tooltip>
+
+ {/* 삭제 버튼 */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="icon"
+ className="size-7 border"
+ onClick={handleDeleteConfirm}
+ disabled={isPending}
+ >
+ {isPending && action === "delete" ? (
+ <Loader
+ className="size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <Trash2 className="size-3.5" aria-hidden="true" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Delete candidates</p>
+ </TooltipContent>
+ </Tooltip>
</div>
</div>
</div>
@@ -318,7 +375,10 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati
title={confirmProps.title}
description={confirmProps.description}
onConfirm={confirmProps.onConfirm}
- isLoading={isPending && (action === "delete" || action === "update-status" || action === "invite")}
+ isLoading={
+ isPending &&
+ (action === "delete" || action === "update-status" || action === "invite")
+ }
confirmLabel={
action === "delete"
? "Delete"
@@ -328,10 +388,8 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati
? "Invite"
: "Confirm"
}
- confirmVariant={
- action === "delete" ? "destructive" : "default"
- }
+ confirmVariant={action === "delete" ? "destructive" : "default"}
/>
- </Portal>
+ </>
)
-} \ No newline at end of file
+}
diff --git a/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx b/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx
index a2229a54..17462841 100644
--- a/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx
+++ b/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx
@@ -14,15 +14,15 @@ import {
} from "@/components/ui/dropdown-menu"
import { AddCandidateDialog } from "./add-candidates-dialog"
-import { VendorCandidates } from "@/db/schema/vendors"
import { DeleteCandidatesDialog } from "./delete-candidates-dialog"
import { InviteCandidatesDialog } from "./invite-candidates-dialog"
import { ImportVendorCandidatesButton } from "./import-button"
import { exportVendorCandidateTemplate } from "./excel-template-download"
+import { VendorCandidatesWithVendorInfo } from "@/db/schema/vendors"
interface CandidatesTableToolbarActionsProps {
- table: Table<VendorCandidates>
+ table: Table<VendorCandidatesWithVendorInfo>
}
export function CandidatesTableToolbarActions({ table }: CandidatesTableToolbarActionsProps) {
diff --git a/lib/vendor-candidates/table/candidates-table.tsx b/lib/vendor-candidates/table/candidates-table.tsx
index 2c01733c..e36649b5 100644
--- a/lib/vendor-candidates/table/candidates-table.tsx
+++ b/lib/vendor-candidates/table/candidates-table.tsx
@@ -11,17 +11,17 @@ import { toSentenceCase } from "@/lib/utils"
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 { DataTableToolbar } from "@/components/data-table/data-table-toolbar"
import { useFeatureFlags } from "./feature-flags-provider"
import { getVendorCandidateCounts, getVendorCandidates } from "../service"
-import { VendorCandidates, vendorCandidates } from "@/db/schema/vendors"
+import { vendorCandidates ,VendorCandidatesWithVendorInfo} from "@/db/schema/vendors"
import { VendorCandidateTableFloatingBar } from "./candidates-table-floating-bar"
import { getColumns } from "./candidates-table-columns"
import { CandidatesTableToolbarActions } from "./candidates-table-toolbar-actions"
import { DeleteCandidatesDialog } from "./delete-candidates-dialog"
import { UpdateCandidateSheet } from "./update-candidate-sheet"
import { getCandidateStatusIcon } from "@/lib/vendor-candidates/utils"
+import { ViewCandidateLogsDialog } from "./view-candidate_logs-dialog"
interface VendorCandidatesTableProps {
promises: Promise<
@@ -41,7 +41,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) {
const [rowAction, setRowAction] =
- React.useState<DataTableRowAction<VendorCandidates> | null>(null)
+ React.useState<DataTableRowAction<VendorCandidatesWithVendorInfo> | null>(null)
const columns = React.useMemo(
() => getColumns({ setRowAction }),
@@ -59,7 +59,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) {
* @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<VendorCandidates>[] = [
+ const filterFields: DataTableFilterField<VendorCandidatesWithVendorInfo>[] = [
{
id: "status",
@@ -83,7 +83,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) {
* 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<VendorCandidates>[] = [
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorCandidatesWithVendorInfo>[] = [
{
id: "companyName",
label: "Company Name",
@@ -109,7 +109,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) {
label: "Status",
type: "multi-select",
options: vendorCandidates.status.enumValues.map((status) => ({
- label: toSentenceCase(status),
+ label: (status),
value: status,
icon: getCandidateStatusIcon(status),
count: statusCounts[status],
@@ -118,7 +118,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) {
{
id: "createdAt",
- label: "Created at",
+ label: "수집일",
type: "date",
},
]
@@ -168,6 +168,11 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) {
showTrigger={false}
onSuccess={() => rowAction?.row.toggleSelected(false)}
/>
+ <ViewCandidateLogsDialog
+ open={rowAction?.type === "log"}
+ onOpenChange={() => setRowAction(null)}
+ candidateId={rowAction?.row.original?.id ?? null}
+ />
</>
)
}
diff --git a/lib/vendor-candidates/table/delete-candidates-dialog.tsx b/lib/vendor-candidates/table/delete-candidates-dialog.tsx
index e9fabf76..bc231109 100644
--- a/lib/vendor-candidates/table/delete-candidates-dialog.tsx
+++ b/lib/vendor-candidates/table/delete-candidates-dialog.tsx
@@ -28,12 +28,13 @@ import {
DrawerTrigger,
} from "@/components/ui/drawer"
-import { VendorCandidates } from "@/db/schema/vendors"
import { removeCandidates } from "../service"
+import { VendorCandidatesWithVendorInfo } from "@/db/schema"
+import { useSession } from "next-auth/react" // next-auth 세션 훅
interface DeleteCandidatesDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
- candidates: Row<VendorCandidates>["original"][]
+ candidates: Row<VendorCandidatesWithVendorInfo>["original"][]
showTrigger?: boolean
onSuccess?: () => void
}
@@ -46,12 +47,21 @@ export function DeleteCandidatesDialog({
}: DeleteCandidatesDialogProps) {
const [isDeletePending, startDeleteTransition] = React.useTransition()
const isDesktop = useMediaQuery("(min-width: 640px)")
+ const { data: session, status } = useSession()
function onDelete() {
startDeleteTransition(async () => {
+
+ if (!session?.user?.id) {
+ toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ const userId = Number(session.user.id)
+
const { error } = await removeCandidates({
ids: candidates.map((candidate) => candidate.id),
- })
+ }, userId)
if (error) {
toast.error(error)
diff --git a/lib/vendor-candidates/table/excel-template-download.tsx b/lib/vendor-candidates/table/excel-template-download.tsx
index b69ab821..673680db 100644
--- a/lib/vendor-candidates/table/excel-template-download.tsx
+++ b/lib/vendor-candidates/table/excel-template-download.tsx
@@ -16,10 +16,14 @@ export async function exportVendorCandidateTemplate() {
// Define the columns with expected headers
const columns = [
{ header: "Company Name", key: "companyName", width: 30 },
+ { header: "Tax ID", key: "taxId", width: 20 },
{ header: "Contact Email", key: "contactEmail", width: 30 },
{ header: "Contact Phone", key: "contactPhone", width: 20 },
+ { header: "Address", key: "address", width: 40 },
{ header: "Country", key: "country", width: 20 },
{ header: "Source", key: "source", width: 20 },
+ { header: "Items", key: "items", width: 40 },
+ { header: "Remark", key: "remark", width: 40 },
{ header: "Status", key: "status", width: 15 },
]
@@ -27,7 +31,7 @@ export async function exportVendorCandidateTemplate() {
worksheet.columns = columns
// Style the header row
- const headerRow = worksheet.getRow(1)
+ const headerRow = worksheet.getRow(2)
headerRow.font = { bold: true }
headerRow.alignment = { horizontal: "center" }
headerRow.eachCell((cell) => {
@@ -36,24 +40,39 @@ export async function exportVendorCandidateTemplate() {
pattern: "solid",
fgColor: { argb: "FFCCCCCC" },
}
+
+ // Mark required fields with a red asterisk
+ const requiredFields = ["Company Name", "Source", "Items"]
+ if (requiredFields.includes(cell.value as string)) {
+ cell.value = `${cell.value} *`
+ cell.font = { bold: true, color: { argb: "FFFF0000" } }
+ }
})
// Add example data rows
const exampleData = [
{
companyName: "ABC Corporation",
+ taxId: "123-45-6789",
contactEmail: "contact@abc.com",
contactPhone: "+1-123-456-7890",
+ address: "123 Business Ave, Suite 100, New York, NY 10001",
country: "US",
source: "Website",
+ items: "Electronic components, Circuit boards, Sensors",
+ remark: "Potential supplier for Project X",
status: "COLLECTED",
},
{
companyName: "XYZ Ltd.",
+ taxId: "GB987654321",
contactEmail: "info@xyz.com",
contactPhone: "+44-987-654-3210",
+ address: "45 Industrial Park, London, EC2A 4PX",
country: "GB",
source: "Referral",
+ items: "Steel components, Metal frames, Industrial hardware",
+ remark: "Met at trade show in March",
status: "COLLECTED",
},
]
@@ -65,8 +84,11 @@ export async function exportVendorCandidateTemplate() {
// Add data validation for Status column
const statusValues = ["COLLECTED", "INVITED", "DISCARDED"]
- for (let i = 2; i <= 100; i++) { // Apply to rows 2-100
- worksheet.getCell(`F${i}`).dataValidation = {
+ const statusColumn = columns.findIndex(col => col.key === "status") + 1
+ const statusColLetter = String.fromCharCode(64 + statusColumn)
+
+ for (let i = 4; i <= 100; i++) { // Apply to rows 4-100 (after example data)
+ worksheet.getCell(`${statusColLetter}${i}`).dataValidation = {
type: 'list',
allowBlank: true,
formulae: [`"${statusValues.join(',')}"`]
@@ -74,11 +96,23 @@ export async function exportVendorCandidateTemplate() {
}
// Add instructions row
- worksheet.insertRow(1, ["Please fill in the data below. Required fields: Company Name, Contact Email"])
- worksheet.mergeCells("A1:F1")
+ worksheet.insertRow(1, ["Please fill in the data below. Required fields are marked with an asterisk (*): Company Name, Source, Items"])
+ worksheet.mergeCells(`A1:${String.fromCharCode(64 + columns.length)}1`)
const instructionRow = worksheet.getRow(1)
instructionRow.font = { bold: true, color: { argb: "FF0000FF" } }
instructionRow.alignment = { horizontal: "center" }
+ instructionRow.height = 30
+
+ // Auto-width columns based on content
+ worksheet.columns.forEach(column => {
+ if (column.key) { // Check that column.key is defined
+ const dataMax = Math.max(...worksheet.getColumn(column.key).values
+ .filter(value => value !== null && value !== undefined)
+ .map(value => String(value).length)
+ )
+ column.width = Math.max(column.width || 10, dataMax + 2)
+ }
+ })
// Download the workbook
const buffer = await workbook.xlsx.writeBuffer()
diff --git a/lib/vendor-candidates/table/import-button.tsx b/lib/vendor-candidates/table/import-button.tsx
index 1a2a4f7c..b1dd43a9 100644
--- a/lib/vendor-candidates/table/import-button.tsx
+++ b/lib/vendor-candidates/table/import-button.tsx
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import { Upload, Loader } from 'lucide-react'
import { createVendorCandidate } from '../service'
import { Input } from '@/components/ui/input'
+import { useSession } from "next-auth/react" // next-auth 세션 훅 추가
interface ImportExcelProps {
onSuccess?: () => void
@@ -15,24 +16,25 @@ interface ImportExcelProps {
export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isImporting, setIsImporting] = React.useState(false)
+ const { data: session, status } = useSession()
// Helper function to get cell value as string
const getCellValueAsString = (cell: ExcelJS.Cell): string => {
if (!cell || cell.value === undefined || cell.value === null) return '';
-
+
if (typeof cell.value === 'string') return cell.value.trim();
if (typeof cell.value === 'number') return cell.value.toString();
-
+
// Handle rich text
if (typeof cell.value === 'object' && 'richText' in cell.value) {
return cell.value.richText.map((rt: any) => rt.text).join('');
}
-
+
// Handle dates
if (cell.value instanceof Date) {
return cell.value.toISOString().split('T')[0];
}
-
+
// Fallback
return String(cell.value);
}
@@ -42,55 +44,55 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) {
if (!file) return
setIsImporting(true)
-
+
try {
// Read the Excel file using ExcelJS
const data = await file.arrayBuffer()
const workbook = new ExcelJS.Workbook()
await workbook.xlsx.load(data)
-
+
// Get the first worksheet
const worksheet = workbook.getWorksheet(1)
if (!worksheet) {
toast.error("No worksheet found in the spreadsheet")
return
}
-
+
// Check if there's an instruction row
- const hasInstructionRow = worksheet.getRow(1).getCell(1).value !== null &&
- worksheet.getRow(1).getCell(2).value === null;
-
+ const hasInstructionRow = worksheet.getRow(1).getCell(1).value !== null &&
+ worksheet.getRow(1).getCell(2).value === null;
+
// Get header row index (row 2 if there's an instruction row, otherwise row 1)
const headerRowIndex = hasInstructionRow ? 2 : 1;
-
+
// Get column headers and their indices
const headerRow = worksheet.getRow(headerRowIndex);
const headers: Record<number, string> = {};
const columnIndices: Record<string, number> = {};
-
+
headerRow.eachCell((cell, colNumber) => {
const header = getCellValueAsString(cell);
headers[colNumber] = header;
columnIndices[header] = colNumber;
});
-
+
// Process data rows
const rows: any[] = [];
const startRow = headerRowIndex + 1;
-
+
for (let i = startRow; i <= worksheet.rowCount; i++) {
const row = worksheet.getRow(i);
-
+
// Skip empty rows
if (row.cellCount === 0) continue;
-
+
// Check if this is likely an example row
- const isExample = i === startRow && worksheet.getRow(i+1).values?.length === 0;
+ const isExample = i === startRow && worksheet.getRow(i + 1).values?.length === 0;
if (isExample) continue;
-
+
const rowData: Record<string, any> = {};
let hasData = false;
-
+
// Map the data using header indices
Object.entries(columnIndices).forEach(([header, colIndex]) => {
const value = getCellValueAsString(row.getCell(colIndex));
@@ -99,22 +101,22 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) {
hasData = true;
}
});
-
+
if (hasData) {
rows.push(rowData);
}
}
-
+
if (rows.length === 0) {
toast.error("No data found in the spreadsheet")
setIsImporting(false)
return
}
-
+
// Process each row
let successCount = 0;
let errorCount = 0;
-
+
// Create promises for all vendor candidate creation operations
const promises = rows.map(async (row) => {
try {
@@ -123,28 +125,40 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) {
companyName: String(row['Company Name'] || ''),
contactEmail: String(row['Contact Email'] || ''),
contactPhone: String(row['Contact Phone'] || ''),
+ taxId: String(row['Tax ID'] || ''),
+ address: String(row['Address'] || ''),
country: String(row['Country'] || ''),
source: String(row['Source'] || ''),
+ items: String(row['Items'] || ''),
+ remark: String(row['Remark'] || row['Remarks'] || ''),
// Default to COLLECTED if not specified
status: (row['Status'] || 'COLLECTED') as "COLLECTED" | "INVITED" | "DISCARDED"
};
-
+
// Validate required fields
- if (!candidateData.companyName || !candidateData.contactEmail) {
+ if (!candidateData.companyName || !candidateData.source ||
+ !candidateData.items) {
console.error("Missing required fields", candidateData);
errorCount++;
return null;
}
-
+
+ if (!session || !session.user || !session.user.id) {
+ toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ const userId = session.user.id
+
// Create the vendor candidate
- const result = await createVendorCandidate(candidateData);
-
+ const result = await createVendorCandidate(candidateData, Number(userId))
+
if (result.error) {
console.error(`Failed to import row: ${result.error}`, candidateData);
errorCount++;
return null;
}
-
+
successCount++;
return result.data;
} catch (error) {
@@ -153,10 +167,10 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) {
return null;
}
});
-
+
// Wait for all operations to complete
await Promise.all(promises);
-
+
// Show results
if (successCount > 0) {
toast.success(`Successfully imported ${successCount} vendor candidates`);
@@ -168,7 +182,7 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) {
} else if (errorCount > 0) {
toast.error(`Failed to import all ${errorCount} rows due to errors`);
}
-
+
} catch (error) {
console.error("Import error:", error);
toast.error("Error importing data. Please check file format.");
diff --git a/lib/vendor-candidates/table/invite-candidates-dialog.tsx b/lib/vendor-candidates/table/invite-candidates-dialog.tsx
index 366b6f45..45cf13c3 100644
--- a/lib/vendor-candidates/table/invite-candidates-dialog.tsx
+++ b/lib/vendor-candidates/table/invite-candidates-dialog.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import { type Row } from "@tanstack/react-table"
-import { Loader, Mail } from "lucide-react"
+import { Loader, Mail, AlertCircle, XCircle } from "lucide-react"
import { toast } from "sonner"
import { useMediaQuery } from "@/hooks/use-media-query"
@@ -27,9 +27,15 @@ import {
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
+import {
+ Alert,
+ AlertTitle,
+ AlertDescription
+} from "@/components/ui/alert"
import { VendorCandidates } from "@/db/schema/vendors"
import { bulkUpdateVendorCandidateStatus } from "../service"
+import { useSession } from "next-auth/react" // next-auth 세션 훅
interface InviteCandidatesDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -46,12 +52,35 @@ export function InviteCandidatesDialog({
}: InviteCandidatesDialogProps) {
const [isInvitePending, startInviteTransition] = React.useTransition()
const isDesktop = useMediaQuery("(min-width: 640px)")
+ const { data: session, status } = useSession()
+
+ // 후보자를 상태별로 분류
+ const discardedCandidates = candidates.filter(candidate => candidate.status === "DISCARDED")
+ const nonDiscardedCandidates = candidates.filter(candidate => candidate.status !== "DISCARDED")
+
+ // 이메일 유무에 따라 초대 가능한 후보자 분류 (DISCARDED가 아닌 후보자 중에서)
+ const candidatesWithEmail = nonDiscardedCandidates.filter(candidate => candidate.contactEmail)
+ const candidatesWithoutEmail = nonDiscardedCandidates.filter(candidate => !candidate.contactEmail)
+
+ // 각 카테고리 수
+ const invitableCount = candidatesWithEmail.length
+ const hasUninvitableCandidates = candidatesWithoutEmail.length > 0
+ const hasDiscardedCandidates = discardedCandidates.length > 0
function onInvite() {
startInviteTransition(async () => {
+ // 이메일이 있고 DISCARDED가 아닌 후보자만 상태 업데이트
+
+ if (!session?.user?.id) {
+ toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.")
+ return
+ }
+ const userId = Number(session.user.id)
const { error } = await bulkUpdateVendorCandidateStatus({
- ids: candidates.map((candidate) => candidate.id),
+ ids: candidatesWithEmail.map((candidate) => candidate.id),
status: "INVITED",
+ userId,
+ comment: "Bulk invite action",
})
if (error) {
@@ -60,11 +89,72 @@ export function InviteCandidatesDialog({
}
props.onOpenChange?.(false)
- toast.success("Invitation emails sent")
+
+ if (invitableCount === 0) {
+ toast.warning("No invitation sent - no eligible candidates with email addresses")
+ } else {
+ let skipMessage = ""
+
+ if (hasUninvitableCandidates && hasDiscardedCandidates) {
+ skipMessage = ` ${candidatesWithoutEmail.length} candidates without email and ${discardedCandidates.length} discarded candidates were skipped.`
+ } else if (hasUninvitableCandidates) {
+ skipMessage = ` ${candidatesWithoutEmail.length} candidates without email were skipped.`
+ } else if (hasDiscardedCandidates) {
+ skipMessage = ` ${discardedCandidates.length} discarded candidates were skipped.`
+ }
+
+ toast.success(`Invitation emails sent to ${invitableCount} candidates.${skipMessage}`)
+ }
+
onSuccess?.()
})
}
+ // 초대 버튼 비활성화 조건
+ const disableInviteButton = isInvitePending || invitableCount === 0
+
+ const DialogComponent = (
+ <>
+ <div className="space-y-4">
+ {/* 이메일 없는 후보자 알림 */}
+ {hasUninvitableCandidates && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertTitle>Missing Email Addresses</AlertTitle>
+ <AlertDescription>
+ {candidatesWithoutEmail.length} candidate{candidatesWithoutEmail.length > 1 ? 's' : ''} {candidatesWithoutEmail.length > 1 ? 'don\'t' : 'doesn\'t'} have email addresses and won't receive invitations.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 폐기된 후보자 알림 */}
+ {hasDiscardedCandidates && (
+ <Alert variant="destructive">
+ <XCircle className="h-4 w-4" />
+ <AlertTitle>Discarded Candidates</AlertTitle>
+ <AlertDescription>
+ {discardedCandidates.length} candidate{discardedCandidates.length > 1 ? 's have' : ' has'} been discarded and won't receive invitations.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ <DialogDescription>
+ {invitableCount > 0 ? (
+ <>
+ This will send invitation emails to{" "}
+ <span className="font-medium">{invitableCount}</span>
+ {invitableCount === 1 ? " candidate" : " candidates"} and change their status to INVITED.
+ </>
+ ) : (
+ <>
+ No candidates can be invited because none of the selected candidates have valid email addresses or they have been discarded.
+ </>
+ )}
+ </DialogDescription>
+ </div>
+ </>
+ )
+
if (isDesktop) {
return (
<Dialog {...props}>
@@ -79,12 +169,8 @@ export function InviteCandidatesDialog({
<DialogContent>
<DialogHeader>
<DialogTitle>Send invitations?</DialogTitle>
- <DialogDescription>
- This will send invitation emails to{" "}
- <span className="font-medium">{candidates.length}</span>
- {candidates.length === 1 ? " candidate" : " candidates"} and change their status to INVITED.
- </DialogDescription>
</DialogHeader>
+ {DialogComponent}
<DialogFooter className="gap-2 sm:space-x-0">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
@@ -93,7 +179,7 @@ export function InviteCandidatesDialog({
aria-label="Invite selected vendors"
variant="default"
onClick={onInvite}
- disabled={isInvitePending}
+ disabled={disableInviteButton}
>
{isInvitePending && (
<Loader
@@ -122,12 +208,8 @@ export function InviteCandidatesDialog({
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Send invitations?</DrawerTitle>
- <DrawerDescription>
- This will send invitation emails to{" "}
- <span className="font-medium">{candidates.length}</span>
- {candidates.length === 1 ? " candidate" : " candidates"} and change their status to INVITED.
- </DrawerDescription>
</DrawerHeader>
+ {DialogComponent}
<DrawerFooter className="gap-2 sm:space-x-0">
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
@@ -136,7 +218,7 @@ export function InviteCandidatesDialog({
aria-label="Invite selected vendors"
variant="default"
onClick={onInvite}
- disabled={isInvitePending}
+ disabled={disableInviteButton}
>
{isInvitePending && (
<Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
diff --git a/lib/vendor-candidates/table/update-candidate-sheet.tsx b/lib/vendor-candidates/table/update-candidate-sheet.tsx
index c475210b..3d278126 100644
--- a/lib/vendor-candidates/table/update-candidate-sheet.tsx
+++ b/lib/vendor-candidates/table/update-candidate-sheet.tsx
@@ -1,7 +1,6 @@
"use client"
import * as React from "react"
-import { vendorCandidates, VendorCandidates } from "@/db/schema/vendors"
import { zodResolver } from "@hookform/resolvers/zod"
import { Check, ChevronsUpDown, Loader } from "lucide-react"
import { useForm } from "react-hook-form"
@@ -38,6 +37,7 @@ import {
SheetTitle,
} from "@/components/ui/sheet"
import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
import {
Popover,
PopoverContent,
@@ -51,9 +51,11 @@ import {
CommandItem,
CommandList,
} from "@/components/ui/command"
+import { useSession } from "next-auth/react" // next-auth 세션 훅
import { updateVendorCandidateSchema, UpdateVendorCandidateSchema } from "../validations"
import { updateVendorCandidate } from "../service"
+import { vendorCandidates,VendorCandidatesWithVendorInfo} from "@/db/schema"
// Register locales for countries
i18nIsoCountries.registerLocale(enLocale)
@@ -69,47 +71,65 @@ const countryArray = Object.entries(countryMap).map(([code, label]) => ({
interface UpdateCandidateSheetProps
extends React.ComponentPropsWithRef<typeof Sheet> {
- candidate: VendorCandidates | null
+ candidate: VendorCandidatesWithVendorInfo | null
}
export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateSheetProps) {
const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const { data: session, status } = useSession()
// Set default values from candidate data when the component receives a new candidate
+
React.useEffect(() => {
if (candidate) {
form.reset({
id: candidate.id,
companyName: candidate.companyName,
- contactEmail: candidate.contactEmail,
+ taxId: candidate.taxId,
+ contactEmail: candidate.contactEmail || "", // null을 빈 문자열로 변환
contactPhone: candidate.contactPhone || "",
+ address: candidate.address || "",
country: candidate.country || "",
source: candidate.source || "",
+ items: candidate.items,
+ remark: candidate.remark || "",
status: candidate.status,
})
}
}, [candidate])
+
const form = useForm<UpdateVendorCandidateSchema>({
resolver: zodResolver(updateVendorCandidateSchema),
defaultValues: {
id: candidate?.id || 0,
companyName: candidate?.companyName || "",
+ taxId: candidate?.taxId || "",
contactEmail: candidate?.contactEmail || "",
contactPhone: candidate?.contactPhone || "",
+ address: candidate?.address || "",
country: candidate?.country || "",
source: candidate?.source || "",
+ items: candidate?.items || "",
+ remark: candidate?.remark || "",
status: candidate?.status || "COLLECTED",
},
})
function onSubmit(input: UpdateVendorCandidateSchema) {
startUpdateTransition(async () => {
+
+ if (!session?.user?.id) {
+ toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.")
+ return
+ }
+ const userId = Number(session.user.id)
+
if (!candidate) return
const { error } = await updateVendorCandidate({
...input,
- })
+ }, userId)
if (error) {
toast.error(error)
@@ -124,7 +144,7 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe
return (
<Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md overflow-y-auto">
<SheetHeader className="text-left">
<SheetTitle>Update Vendor Candidate</SheetTitle>
<SheetDescription>
@@ -142,7 +162,7 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe
name="companyName"
render={({ field }) => (
<FormItem>
- <FormLabel>Company Name</FormLabel>
+ <FormLabel>Company Name <span className="text-red-500">*</span></FormLabel>
<FormControl>
<Input
placeholder="Enter company name"
@@ -155,6 +175,25 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe
)}
/>
+ {/* Tax ID Field */}
+ <FormField
+ control={form.control}
+ name="taxId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Tax ID</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="Enter tax ID"
+ {...field}
+ disabled={isUpdatePending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
{/* Contact Email Field */}
<FormField
control={form.control}
@@ -194,6 +233,25 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe
)}
/>
+ {/* Address Field */}
+ <FormField
+ control={form.control}
+ name="address"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Address</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="Enter company address"
+ {...field}
+ disabled={isUpdatePending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
{/* Country Field */}
<FormField
control={form.control}
@@ -266,7 +324,7 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe
name="source"
render={({ field }) => (
<FormItem>
- <FormLabel>Source</FormLabel>
+ <FormLabel>Source <span className="text-red-500">*</span></FormLabel>
<FormControl>
<Input
placeholder="Where this candidate was found"
@@ -279,6 +337,46 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe
)}
/>
+ {/* Items Field */}
+ <FormField
+ control={form.control}
+ name="items"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Items <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="List of items or products this vendor provides"
+ className="min-h-[80px]"
+ {...field}
+ disabled={isUpdatePending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Remark Field */}
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Remark</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Additional notes or comments"
+ className="min-h-[80px]"
+ {...field}
+ disabled={isUpdatePending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
{/* Status Field */}
<FormField
control={form.control}
diff --git a/lib/vendor-candidates/table/view-candidate_logs-dialog.tsx b/lib/vendor-candidates/table/view-candidate_logs-dialog.tsx
new file mode 100644
index 00000000..6d119bf3
--- /dev/null
+++ b/lib/vendor-candidates/table/view-candidate_logs-dialog.tsx
@@ -0,0 +1,246 @@
+"use client"
+
+import * as React from "react"
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { formatDateTime } from "@/lib/utils"
+import { CandidateLogWithUser, getCandidateLogs } from "../service"
+import { useToast } from "@/hooks/use-toast"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Badge } from "@/components/ui/badge"
+import { Download, Search, User } from "lucide-react"
+
+interface ViewCandidateLogsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ candidateId: number | null
+}
+
+export function ViewCandidateLogsDialog({
+ open,
+ onOpenChange,
+ candidateId,
+}: ViewCandidateLogsDialogProps) {
+ const [logs, setLogs] = React.useState<CandidateLogWithUser[]>([])
+ const [filteredLogs, setFilteredLogs] = React.useState<CandidateLogWithUser[]>([])
+ const [loading, setLoading] = React.useState(false)
+ const [error, setError] = React.useState<string | null>(null)
+ const [searchQuery, setSearchQuery] = React.useState("")
+ const [actionFilter, setActionFilter] = React.useState<string>("all")
+ const { toast } = useToast()
+
+ // Get unique action types for filter dropdown
+ const actionTypes = React.useMemo(() => {
+ if (!logs.length) return []
+ return Array.from(new Set(logs.map(log => log.action)))
+ }, [logs])
+
+ // Fetch logs when dialog opens
+ React.useEffect(() => {
+ if (open && candidateId) {
+ setLoading(true)
+ setError(null)
+ getCandidateLogs(candidateId)
+ .then((res) => {
+ setLogs(res)
+ setFilteredLogs(res)
+ })
+ .catch((err) => {
+ console.error(err)
+ setError("Failed to load logs. Please try again.")
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to load candidate logs",
+ })
+ })
+ .finally(() => setLoading(false))
+ } else {
+ // Reset state when dialog closes
+ setSearchQuery("")
+ setActionFilter("all")
+ }
+ }, [open, candidateId, toast])
+
+ // Filter logs based on search query and action filter
+ React.useEffect(() => {
+ if (!logs.length) return
+
+ let result = [...logs]
+
+ // Apply action filter
+ if (actionFilter !== "all") {
+ result = result.filter(log => log.action === actionFilter)
+ }
+
+ // Apply search filter (case insensitive)
+ if (searchQuery) {
+ const query = searchQuery.toLowerCase()
+ result = result.filter(log =>
+ log.action.toLowerCase().includes(query) ||
+ (log.comment && log.comment.toLowerCase().includes(query)) ||
+ (log.oldStatus && log.oldStatus.toLowerCase().includes(query)) ||
+ (log.newStatus && log.newStatus.toLowerCase().includes(query)) ||
+ (log.userName && log.userName.toLowerCase().includes(query)) ||
+ (log.userEmail && log.userEmail.toLowerCase().includes(query))
+ )
+ }
+
+ setFilteredLogs(result)
+ }, [logs, searchQuery, actionFilter])
+
+ // Export logs as CSV
+ const exportLogs = () => {
+ if (!filteredLogs.length) return
+
+ const headers = ["Action", "Old Status", "New Status", "Comment", "User", "Email", "Date"]
+ const csvContent = [
+ headers.join(","),
+ ...filteredLogs.map(log => [
+ `"${log.action}"`,
+ `"${log.oldStatus || ''}"`,
+ `"${log.newStatus || ''}"`,
+ `"${log.comment?.replace(/"/g, '""') || ''}"`,
+ `"${log.userName || ''}"`,
+ `"${log.userEmail || ''}"`,
+ `"${formatDateTime(log.createdAt)}"`
+ ].join(","))
+ ].join("\n")
+
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.setAttribute("href", url)
+ link.setAttribute("download", `candidate-logs-${candidateId}-${new Date().toISOString().split('T')[0]}.csv`)
+ link.style.visibility = "hidden"
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ }
+
+ // Render status change with appropriate badge
+ const renderStatusChange = (oldStatus: string, newStatus: string) => {
+ return (
+ <div className="text-sm flex flex-wrap gap-2 items-center">
+ <strong>Status:</strong>
+ <Badge variant="outline" className="text-xs">{oldStatus}</Badge>
+ <span>→</span>
+ <Badge variant="outline" className="bg-primary/10 text-xs">{newStatus}</Badge>
+ </div>
+ )
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[700px]">
+ <DialogHeader>
+ <DialogTitle>Audit Logs</DialogTitle>
+ </DialogHeader>
+
+ {/* Filters and search */}
+ {/* Filters and search */}
+ <div className="flex items-center gap-2 mb-4">
+ <div className="relative flex-1">
+ <div className="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none">
+ <Search className="h-4 w-4 text-muted-foreground" />
+ </div>
+ <Input
+ placeholder="Search logs..."
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ className="pl-8"
+ />
+ </div>
+
+ <Select
+ value={actionFilter}
+ onValueChange={setActionFilter}
+ >
+ <SelectTrigger className="w-[180px]">
+ <SelectValue placeholder="Filter by action" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">All actions</SelectItem>
+ {actionTypes.map(action => (
+ <SelectItem key={action} value={action}>{action}</SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+
+ <Button
+ size="icon"
+ variant="outline"
+ onClick={exportLogs}
+ disabled={filteredLogs.length === 0}
+ title="Export to CSV"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </div>
+
+ <div className="space-y-2">
+ {loading && (
+ <div className="flex justify-center py-8">
+ <p className="text-muted-foreground">Loading logs...</p>
+ </div>
+ )}
+
+ {error && !loading && (
+ <div className="bg-destructive/10 text-destructive p-3 rounded-md">
+ {error}
+ </div>
+ )}
+
+ {!loading && !error && filteredLogs.length === 0 && (
+ <p className="text-muted-foreground text-center py-8">
+ {logs.length > 0 ? "No logs match your search criteria." : "No logs found for this candidate."}
+ </p>
+ )}
+
+ {!loading && !error && filteredLogs.length > 0 && (
+ <>
+ <div className="text-xs text-muted-foreground mb-2">
+ Showing {filteredLogs.length} {filteredLogs.length === 1 ? 'log' : 'logs'}
+ {filteredLogs.length !== logs.length && ` (filtered from ${logs.length})`}
+ </div>
+ <ScrollArea className="max-h-96 space-y-4 pr-4">
+ {filteredLogs.map((log) => (
+ <div key={log.id} className="rounded-md border p-4 mb-3 hover:bg-muted/50 transition-colors">
+ <div className="flex justify-between items-start mb-2">
+ <Badge className="text-xs">{log.action}</Badge>
+ <div className="text-xs text-muted-foreground">
+ {formatDateTime(log.createdAt)}
+ </div>
+ </div>
+
+ {log.oldStatus && log.newStatus && (
+ <div className="my-2">
+ {renderStatusChange(log.oldStatus, log.newStatus)}
+ </div>
+ )}
+
+ {log.comment && (
+ <div className="my-2 text-sm bg-muted/50 p-2 rounded-md">
+ <strong>Comment:</strong> {log.comment}
+ </div>
+ )}
+
+ {(log.userName || log.userEmail) && (
+ <div className="mt-3 pt-2 border-t flex items-center text-xs text-muted-foreground">
+ <User className="h-3 w-3 mr-1" />
+ {log.userName || "Unknown"}
+ {log.userEmail && <span className="ml-1">({log.userEmail})</span>}
+ </div>
+ )}
+ </div>
+ ))}
+ </ScrollArea>
+ </>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-candidates/validations.ts b/lib/vendor-candidates/validations.ts
index 0abb568e..f42d4d3f 100644
--- a/lib/vendor-candidates/validations.ts
+++ b/lib/vendor-candidates/validations.ts
@@ -1,4 +1,4 @@
-import { vendorCandidates } from "@/db/schema/vendors"
+import { vendorCandidates, vendorCandidatesWithVendorInfo } from "@/db/schema/vendors"
import {
createSearchParamsCache,
parseAsArrayOf,
@@ -12,13 +12,14 @@ import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
export const searchParamsCandidateCache = createSearchParamsCache({
// Common flags
flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
-
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
// Paging
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
// Sorting - adjusting for vendorInvestigationsView
- sort: getSortingStateParser<typeof vendorCandidates.$inferSelect>().withDefault([
+ sort: getSortingStateParser<typeof vendorCandidatesWithVendorInfo.$inferSelect>().withDefault([
{ id: "createdAt", desc: true },
]),
@@ -53,22 +54,45 @@ export type GetVendorsCandidateSchema = Awaited<ReturnType<typeof searchParamsCa
// Updated version of the updateVendorCandidateSchema
export const updateVendorCandidateSchema = z.object({
id: z.number(),
- companyName: z.string().min(1).max(255).optional(),
- contactEmail: z.string().email().max(255).optional(),
- contactPhone: z.string().max(50).optional(),
- country: z.string().max(100).optional(),
- source: z.string().max(100).optional(),
- status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]).optional(),
+ // 필수 필드
+ companyName: z.string().min(1, "회사명은 필수입니다").max(255),
+ // null을 명시적으로 처리
+ contactEmail: z.union([
+ z.string().email("유효한 이메일 형식이 아닙니다").max(255),
+ z.literal(''),
+ z.null()
+ ]).optional().transform(val => val === null ? '' : val),
+ contactPhone: z.union([z.string().max(50), z.literal(''), z.null()]).optional()
+ .transform(val => val === null ? '' : val),
+ country: z.union([z.string().max(100), z.literal(''), z.null()]).optional()
+ .transform(val => val === null ? '' : val),
+ // 필수 필드
+ source: z.string().min(1, "출처는 필수입니다").max(100),
+ address: z.union([z.string(), z.literal(''), z.null()]).optional()
+ .transform(val => val === null ? '' : val),
+ taxId: z.union([z.string(), z.literal(''), z.null()]).optional()
+ .transform(val => val === null ? '' : val),
+ // 필수 필드
+ items: z.string().min(1, "항목은 필수입니다"),
+ remark: z.union([z.string(), z.literal(''), z.null()]).optional()
+ .transform(val => val === null ? '' : val),
+ status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]),
updatedAt: z.date().optional().default(() => new Date()),
-});
+});;
// Create schema for vendor candidates
export const createVendorCandidateSchema = z.object({
- companyName: z.string().min(1).max(255),
- contactEmail: z.string().email().max(255),
+ companyName: z.string().min(1, "회사명은 필수입니다").max(255),
+ // 빈 문자열을 undefined로 변환하여 optional 처리
+ contactEmail: z.string().email("유효한 이메일 형식이 아닙니다").or(z.literal('')).optional(),
contactPhone: z.string().max(50).optional(),
country: z.string().max(100).optional(),
- source: z.string().max(100).optional(),
+ source: z.string().min(1, "출처는 필수입니다").max(100),
+ address: z.string().optional(),
+ taxId: z.string().optional(),
+ items: z.string().min(1, "항목은 필수입니다"),
+ remark: z.string().optional(),
+ vendorId: z.number().optional(),
status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]).default("COLLECTED"),
});
diff --git a/lib/vendor-document/service.ts b/lib/vendor-document/service.ts
index c0a30808..d81e703c 100644
--- a/lib/vendor-document/service.ts
+++ b/lib/vendor-document/service.ts
@@ -12,6 +12,7 @@ import { countVendorDocuments, selectVendorDocuments } from "./repository"
import path from "path";
import fs from "fs/promises";
import { v4 as uuidv4 } from "uuid"
+import { contractItems } from "@/db/schema"
/**
* 특정 vendorId에 속한 문서 목록 조회
@@ -342,4 +343,109 @@ export async function getStageNamesByDocumentId(documentId: number) {
console.error("Error fetching stage names:", error);
return []; // 오류 발생시 빈 배열 반환
}
+}
+
+
+// Define the return types
+export interface Document {
+ id: number;
+ docNumber: string;
+ title: string;
+}
+
+export interface IssueStage {
+ id: number;
+ stageName: string;
+}
+
+export interface Revision {
+ revision: string;
+}
+
+// Server Action: Fetch documents by packageId (contractItems.id)
+export async function fetchDocumentsByPackageId(packageId: number): Promise<Document[]> {
+ try {
+ // First, find the contractId from contractItems where id = packageId
+ const contractItemResult = await db.select({ contractId: contractItems.contractId })
+ .from(contractItems)
+ .where(eq(contractItems.id, packageId))
+ .limit(1);
+
+ if (!contractItemResult.length) {
+ return [];
+ }
+
+ const contractId = contractItemResult[0].contractId;
+
+ // Then, get documents associated with this contractId
+ const docsResult = await db.select({
+ id: documents.id,
+ docNumber: documents.docNumber,
+ title: documents.title,
+ })
+ .from(documents)
+ .where(eq(documents.contractId, contractId))
+ .orderBy(documents.docNumber);
+
+ return docsResult;
+ } catch (error) {
+ console.error("Error fetching documents:", error);
+ return [];
+ }
+}
+
+// Server Action: Fetch stages by documentId
+export async function fetchStagesByDocumentId(documentId: number): Promise<IssueStage[]> {
+ try {
+ const stagesResult = await db.select({
+ id: issueStages.id,
+ stageName: issueStages.stageName,
+ })
+ .from(issueStages)
+ .where(eq(issueStages.documentId, documentId))
+ .orderBy(issueStages.stageName);
+
+ return stagesResult;
+ } catch (error) {
+ console.error("Error fetching stages:", error);
+ return [];
+ }
+}
+
+// Server Action: Fetch revisions by documentId and stageName
+export async function fetchRevisionsByStageParams(
+ documentId: number,
+ stageName: string
+): Promise<Revision[]> {
+ try {
+ // First, find the issueStageId
+ const stageResult = await db.select({ id: issueStages.id })
+ .from(issueStages)
+ .where(
+ and(
+ eq(issueStages.documentId, documentId),
+ eq(issueStages.stageName, stageName)
+ )
+ )
+ .limit(1);
+
+ if (!stageResult.length) {
+ return [];
+ }
+
+ const issueStageId = stageResult[0].id;
+
+ // Then, get revisions for this stage
+ const revisionsResult = await db.select({
+ revision: revisions.revision,
+ })
+ .from(revisions)
+ .where(eq(revisions.issueStageId, issueStageId))
+ .orderBy(revisions.revision);
+
+ return revisionsResult;
+ } catch (error) {
+ console.error("Error fetching revisions:", error);
+ return [];
+ }
} \ No newline at end of file
diff --git a/lib/vendor-investigation/service.ts b/lib/vendor-investigation/service.ts
index b731a95c..e3d03cd4 100644
--- a/lib/vendor-investigation/service.ts
+++ b/lib/vendor-investigation/service.ts
@@ -12,6 +12,7 @@ import { sendEmail } from "../mail/sendEmail";
import fs from "fs"
import path from "path"
import { v4 as uuid } from "uuid"
+import { vendorsLogs } from "@/db/schema";
export async function getVendorsInvestigation(input: GetVendorsInvestigationSchema) {
return unstable_cache(
@@ -44,7 +45,7 @@ export async function getVendorsInvestigation(input: GetVendorsInvestigationSche
const finalWhere = and(
advancedWhere,
globalWhere,
- eq(vendorInvestigationsView.vendorStatus, "PQ_SUBMITTED")
+ // eq(vendorInvestigationsView.vendorStatus, "PQ_APPROVED")
)
@@ -82,6 +83,8 @@ export async function getVendorsInvestigation(input: GetVendorsInvestigationSche
// 7) Calculate pageCount
const pageCount = Math.ceil(total / input.perPage)
+ console.log(data,"data")
+
// Now 'data' already contains JSON arrays of contacts & items
// thanks to the subqueries in the view definition!
return { data, pageCount }
@@ -100,50 +103,84 @@ export async function getVendorsInvestigation(input: GetVendorsInvestigationSche
}
+/**
+ * Get existing investigations for a list of vendor IDs
+ *
+ * @param vendorIds Array of vendor IDs to check for existing investigations
+ * @returns Array of investigation data
+ */
+export async function getExistingInvestigationsForVendors(vendorIds: number[]) {
+ if (!vendorIds.length) return []
+
+ try {
+ // Query the vendorInvestigationsView using the vendorIds
+ const investigations = await db.query.vendorInvestigations.findMany({
+ where: inArray(vendorInvestigationsView.vendorId, vendorIds),
+ orderBy: [desc(vendorInvestigationsView.investigationCreatedAt)],
+ })
+
+ return investigations
+ } catch (error) {
+ console.error("Error fetching existing investigations:", error)
+ return []
+ }
+}
+
interface RequestInvestigateVendorsInput {
ids: number[]
}
export async function requestInvestigateVendors({
- ids,
-}: RequestInvestigateVendorsInput) {
+ ids, userId // userId를 추가
+}: RequestInvestigateVendorsInput & { userId: number }) {
try {
if (!ids || ids.length === 0) {
return { error: "No vendor IDs provided." }
}
- // 1. Create a new investigation row for each vendor
- // You could also check if an investigation already exists for each vendor
- // before inserting. For now, we’ll assume we always insert new ones.
- const newRecords = await db
- .insert(vendorInvestigations)
- .values(
- ids.map((vendorId) => ({
- vendorId
- }))
- )
- .returning()
-
- // 2. Optionally, send an email notification
- // Adjust recipient, subject, and body as needed.
+ const result = await db.transaction(async (tx) => {
+ // 1. Create a new investigation row for each vendor
+ const newRecords = await tx
+ .insert(vendorInvestigations)
+ .values(
+ ids.map((vendorId) => ({
+ vendorId
+ }))
+ )
+ .returning();
+
+ // 2. 각 벤더에 대해 로그 기록
+ await Promise.all(
+ ids.map(async (vendorId) => {
+ await tx.insert(vendorsLogs).values({
+ vendorId: vendorId,
+ userId: userId,
+ action: "investigation_requested",
+ comment: "Investigation requested for this vendor",
+ });
+ })
+ );
+
+ return newRecords;
+ });
+
+ // 3. 이메일 발송 (트랜잭션 외부에서 실행)
await sendEmail({
to: "dujin.kim@dtsolution.io",
subject: "New Vendor Investigation(s) Requested",
- // This template name could match a Handlebars file like: `investigation-request.hbs`
template: "investigation-request",
context: {
- // For example, if you're translating in Korean:
language: "ko",
- // Add any data you want to use within the template
vendorIds: ids,
notes: "Please initiate the planned investigations soon."
},
- })
+ });
- // 3. Optionally, revalidate any pages that might show updated data
- // revalidatePath("/your-vendors-page") // or wherever you list the vendors
+ // 4. 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-investigations");
- return { data: newRecords, error: null }
+ return { data: result, error: null }
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err)
return { error: errorMessage }
diff --git a/lib/vendor-investigation/table/contract-dialog.tsx b/lib/vendor-investigation/table/contract-dialog.tsx
new file mode 100644
index 00000000..28e6963b
--- /dev/null
+++ b/lib/vendor-investigation/table/contract-dialog.tsx
@@ -0,0 +1,85 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Avatar } from "@/components/ui/avatar"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { ContactItem } from "@/config/vendorInvestigationsColumnsConfig"
+
+interface ContactsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ investigationId: number | null
+ contacts: ContactItem[]
+}
+
+export function ContactsDialog({
+ open,
+ onOpenChange,
+ investigationId,
+ contacts,
+}: ContactsDialogProps) {
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle>Vendor Contacts</DialogTitle>
+ <DialogDescription>
+ {contacts.length > 0
+ ? `Showing ${contacts.length} contacts for investigation #${investigationId}`
+ : `No contacts found for investigation #${investigationId}`}
+ </DialogDescription>
+ </DialogHeader>
+ <ScrollArea className="max-h-[60vh] pr-4">
+ {contacts.length > 0 ? (
+ <div className="space-y-4">
+ {contacts.map((contact, index) => (
+ <div
+ key={index}
+ className="flex items-start gap-4 p-3 rounded-lg border"
+ >
+ <Avatar className="w-10 h-10">
+ <span>{contact.contactName?.charAt(0) || "C"}</span>
+ </Avatar>
+ <div className="flex-1 space-y-1">
+ <p className="font-medium">{contact.contactName || "Unnamed"}</p>
+ {contact.contactEmail && (
+ <p className="text-sm text-muted-foreground">
+ {contact.contactEmail}
+ </p>
+ )}
+ {contact.contactPhone && (
+ <p className="text-sm text-muted-foreground">
+ {contact.contactPhone}
+ </p>
+ )}
+ {contact.contactPosition && (
+ <p className="text-sm text-muted-foreground">
+ Position: {contact.contactPosition}
+ </p>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : (
+ <div className="text-center py-6 text-muted-foreground">
+ No contacts available
+ </div>
+ )}
+ </ScrollArea>
+ <DialogFooter>
+ <Button onClick={() => onOpenChange(false)}>Close</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-investigation/table/investigation-table.tsx b/lib/vendor-investigation/table/investigation-table.tsx
index fa4e2ab8..56aa7962 100644
--- a/lib/vendor-investigation/table/investigation-table.tsx
+++ b/lib/vendor-investigation/table/investigation-table.tsx
@@ -21,6 +21,8 @@ import {
PossibleItem
} from "@/config/vendorInvestigationsColumnsConfig"
import { UpdateVendorInvestigationSheet } from "./update-investigation-sheet"
+import { ItemsDrawer } from "./items-dialog"
+import { ContactsDialog } from "./contract-dialog"
interface VendorsTableProps {
promises: Promise<
@@ -71,18 +73,48 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) {
} as VendorInvestigationsViewWithContacts
})
}, [rawResponse.data])
+
+ console.log(transformedData)
const pageCount = rawResponse.pageCount
+ // Add state for row actions
const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorInvestigationsViewWithContacts> | null>(null)
+ // Add state for contacts dialog
+ const [contactsDialogOpen, setContactsDialogOpen] = React.useState(false)
+ const [selectedContacts, setSelectedContacts] = React.useState<ContactItem[]>([])
+ const [selectedContactInvestigationId, setSelectedContactInvestigationId] = React.useState<number | null>(null)
+
+ // Add state for items drawer
+ const [itemsDrawerOpen, setItemsDrawerOpen] = React.useState(false)
+ const [selectedItems, setSelectedItems] = React.useState<PossibleItem[]>([])
+ const [selectedItemInvestigationId, setSelectedItemInvestigationId] = React.useState<number | null>(null)
+
+ // Create handlers for opening the contacts dialog and items drawer
+ const openContactsModal = React.useCallback((investigationId: number, contacts: ContactItem[]) => {
+ setSelectedContactInvestigationId(investigationId)
+ setSelectedContacts(contacts || [])
+ setContactsDialogOpen(true)
+ }, [])
+
+ const openItemsDrawer = React.useCallback((investigationId: number, items: PossibleItem[]) => {
+ setSelectedItemInvestigationId(investigationId)
+ setSelectedItems(items || [])
+ setItemsDrawerOpen(true)
+ }, [])
+
// Get router
const router = useRouter()
- // Call getColumns() with router injection
+ // Call getColumns() with all required functions
const columns = React.useMemo(
- () => getColumns({ setRowAction }),
- [setRowAction, router]
+ () => getColumns({
+ setRowAction,
+ openContactsModal,
+ openItemsDrawer
+ }),
+ [setRowAction, openContactsModal, openItemsDrawer]
)
const filterFields: DataTableFilterField<VendorInvestigationsViewWithContacts>[] = [
@@ -123,11 +155,29 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) {
<VendorsTableToolbarActions table={table} />
</DataTableAdvancedToolbar>
</DataTable>
+
+ {/* Update Investigation Sheet */}
<UpdateVendorInvestigationSheet
open={rowAction?.type === "update"}
onOpenChange={() => setRowAction(null)}
investigation={rowAction?.row.original ?? null}
/>
+
+ {/* Contacts Dialog */}
+ <ContactsDialog
+ open={contactsDialogOpen}
+ onOpenChange={setContactsDialogOpen}
+ investigationId={selectedContactInvestigationId}
+ contacts={selectedContacts}
+ />
+
+ {/* Items Drawer */}
+ <ItemsDrawer
+ open={itemsDrawerOpen}
+ onOpenChange={setItemsDrawerOpen}
+ investigationId={selectedItemInvestigationId}
+ items={selectedItems}
+ />
</>
)
} \ No newline at end of file
diff --git a/lib/vendor-investigation/table/items-dialog.tsx b/lib/vendor-investigation/table/items-dialog.tsx
new file mode 100644
index 00000000..5d010ff4
--- /dev/null
+++ b/lib/vendor-investigation/table/items-dialog.tsx
@@ -0,0 +1,73 @@
+"use client"
+
+import * as React from "react"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetFooter,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { PossibleItem } from "@/config/vendorInvestigationsColumnsConfig"
+
+interface ItemsDrawerProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ investigationId: number | null
+ items: PossibleItem[]
+}
+
+export function ItemsDrawer({
+ open,
+ onOpenChange,
+ investigationId,
+ items,
+}: ItemsDrawerProps) {
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="sm:max-w-md">
+ <SheetHeader>
+ <SheetTitle>Possible Items</SheetTitle>
+ <SheetDescription>
+ {items.length > 0
+ ? `Showing ${items.length} items for investigation #${investigationId}`
+ : `No items found for investigation #${investigationId}`}
+ </SheetDescription>
+ </SheetHeader>
+ <ScrollArea className="max-h-[70vh] mt-6 pr-4">
+ {items.length > 0 ? (
+ <div className="space-y-4">
+ {items.map((item, index) => (
+ <div
+ key={index}
+ className="flex flex-col gap-2 p-3 rounded-lg border"
+ >
+ <div className="flex justify-between items-start">
+ <h4 className="font-medium">{item.itemName || "Unknown Item"}</h4>
+ {item.itemName && (
+ <span className="text-xs bg-muted px-2 py-1 rounded">
+ {item.itemCode}
+ </span>
+ )}
+ </div>
+
+
+ </div>
+ ))}
+ </div>
+ ) : (
+ <div className="text-center py-6 text-muted-foreground">
+ No items available
+ </div>
+ )}
+ </ScrollArea>
+ <SheetFooter className="mt-4">
+ <Button onClick={() => onOpenChange(false)}>Close</Button>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/service.ts b/lib/vendor-rfq-response/service.ts
index cba6c414..8f2954d7 100644
--- a/lib/vendor-rfq-response/service.ts
+++ b/lib/vendor-rfq-response/service.ts
@@ -1,10 +1,14 @@
-import { unstable_cache } from "next/cache";
+'use server'
+
+import { revalidateTag, unstable_cache } from "next/cache";
import db from "@/db/db";
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
-import { rfqAttachments, rfqComments, rfqItems } from "@/db/schema/rfq";
+import { rfqAttachments, rfqComments, rfqItems, vendorResponses } from "@/db/schema/rfq";
import { vendorResponsesView, vendorTechnicalResponses, vendorCommercialResponses, vendorResponseAttachments } from "@/db/schema/rfq";
import { items } from "@/db/schema/items";
import { GetRfqsForVendorsSchema } from "../rfqs/validations";
+import { ItemData } from "./vendor-cbe-table/rfq-items-table/rfq-items-table";
+import * as z from "zod"
@@ -27,7 +31,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v
);
}
- // 벤더 ID 필터링
+ // 협력업체 ID 필터링
const mainWhere = and(eq(vendorResponsesView.vendorId, vendorId), globalWhere);
// 정렬: 응답 시간순
@@ -75,7 +79,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v
.leftJoin(items, eq(rfqItems.itemCode, items.itemCode))
.where(inArray(rfqItems.rfqId, distinctRfqs));
- // 3-B) RFQ 첨부 파일 (벤더용)
+ // 3-B) RFQ 첨부 파일 (협력업체용)
const attachAll = await db
.select()
.from(rfqAttachments)
@@ -101,7 +105,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v
);
- // 3-E) 벤더 응답 상세 - 기술
+ // 3-E) 협력업체 응답 상세 - 기술
const technicalResponsesAll = await db
.select()
.from(vendorTechnicalResponses)
@@ -112,7 +116,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v
)
);
- // 3-F) 벤더 응답 상세 - 상업
+ // 3-F) 협력업체 응답 상세 - 상업
const commercialResponsesAll = await db
.select()
.from(vendorCommercialResponses)
@@ -123,7 +127,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v
)
);
- // 3-G) 벤더 응답 첨부 파일
+ // 3-G) 협력업체 응답 첨부 파일
const responseAttachmentsAll = await db
.select()
.from(vendorResponseAttachments)
@@ -257,7 +261,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v
projectCode: row.projectCode,
projectName: row.projectName,
- // 벤더 정보
+ // 협력업체 정보
vendorId: row.vendorId,
vendorName: row.vendorName,
vendorCode: row.vendorCode,
@@ -277,7 +281,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v
result: row.cbeResult,
} : null,
- // 벤더 응답 상세
+ // 협력업체 응답 상세
technicalResponse: techResponseByResponseId.get(row.responseId) || null,
commercialResponse: commResponseByResponseId.get(row.responseId) || null,
responseAttachments: respAttachByResponseId.get(row.responseId) || [],
@@ -298,4 +302,163 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v
tags: ["rfqs-vendor", `vendor-${vendorId}`],
}
)();
+}
+
+
+export async function getItemsByRfqId(rfqId: number): Promise<ResponseType> {
+ try {
+ if (!rfqId || isNaN(Number(rfqId))) {
+ return {
+ success: false,
+ error: "Invalid RFQ ID provided",
+ }
+ }
+
+ // Query the database to get all items for the given RFQ ID
+ const items = await db
+ .select()
+ .from(rfqItems)
+ .where(eq(rfqItems.rfqId, rfqId))
+ .orderBy(rfqItems.itemCode)
+
+
+ return {
+ success: true,
+ data: items as ItemData[],
+ }
+ } catch (error) {
+ console.error("Error fetching RFQ items:", error)
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error occurred when fetching RFQ items",
+ }
+ }
+}
+
+
+// Define the schema for validation
+const commercialResponseSchema = z.object({
+ responseId: z.number(),
+ vendorId: z.number(), // Added vendorId field
+ responseStatus: z.enum(["PENDING", "IN_PROGRESS", "SUBMITTED", "REJECTED", "ACCEPTED"]),
+ totalPrice: z.number().optional(),
+ currency: z.string().default("USD"),
+ paymentTerms: z.string().optional(),
+ incoterms: z.string().optional(),
+ deliveryPeriod: z.string().optional(),
+ warrantyPeriod: z.string().optional(),
+ validityPeriod: z.string().optional(),
+ priceBreakdown: z.string().optional(),
+ commercialNotes: z.string().optional(),
+})
+
+type CommercialResponseInput = z.infer<typeof commercialResponseSchema>
+
+interface ResponseType {
+ success: boolean
+ error?: string
+ data?: any
+}
+
+export async function updateCommercialResponse(input: CommercialResponseInput): Promise<ResponseType> {
+ try {
+ // Validate input data
+ const validated = commercialResponseSchema.parse(input)
+
+ // Check if a commercial response already exists for this responseId
+ const existingResponse = await db
+ .select()
+ .from(vendorCommercialResponses)
+ .where(eq(vendorCommercialResponses.responseId, validated.responseId))
+ .limit(1)
+
+ const now = new Date()
+
+ if (existingResponse.length > 0) {
+ // Update existing record
+ await db
+ .update(vendorCommercialResponses)
+ .set({
+ responseStatus: validated.responseStatus,
+ totalPrice: validated.totalPrice,
+ currency: validated.currency,
+ paymentTerms: validated.paymentTerms,
+ incoterms: validated.incoterms,
+ deliveryPeriod: validated.deliveryPeriod,
+ warrantyPeriod: validated.warrantyPeriod,
+ validityPeriod: validated.validityPeriod,
+ priceBreakdown: validated.priceBreakdown,
+ commercialNotes: validated.commercialNotes,
+ updatedAt: now,
+ })
+ .where(eq(vendorCommercialResponses.responseId, validated.responseId))
+
+ } else {
+ // Return error instead of creating a new record
+ return {
+ success: false,
+ error: "해당 응답 ID에 대한 상업 응답 정보를 찾을 수 없습니다."
+ }
+ }
+
+ // Also update the main vendor response status if submitted
+ if (validated.responseStatus === "SUBMITTED") {
+ // Get the vendor response
+ const vendorResponseResult = await db
+ .select()
+ .from(vendorResponses)
+ .where(eq(vendorResponses.id, validated.responseId))
+ .limit(1)
+
+ if (vendorResponseResult.length > 0) {
+ // Update the main response status to RESPONDED
+ await db
+ .update(vendorResponses)
+ .set({
+ responseStatus: "RESPONDED",
+ updatedAt: now,
+ })
+ .where(eq(vendorResponses.id, validated.responseId))
+ }
+ }
+
+ // Use vendorId for revalidateTag
+ revalidateTag(`cbe-vendor-${validated.vendorId}`)
+
+ return {
+ success: true,
+ data: { responseId: validated.responseId }
+ }
+
+ } catch (error) {
+ console.error("Error updating commercial response:", error)
+
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ error: "유효하지 않은 데이터가 제공되었습니다."
+ }
+ }
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error occurred"
+ }
+ }
+}
+// Helper function to get responseId from rfqId and vendorId
+export async function getCommercialResponseByResponseId(responseId: number): Promise<any | null> {
+ try {
+ const response = await db
+ .select()
+ .from(vendorCommercialResponses)
+ .where(eq(vendorCommercialResponses.responseId, responseId))
+ .limit(1)
+
+ return response.length > 0 ? response[0] : null
+ } catch (error) {
+ console.error("Error getting commercial response:", error)
+ return null
+ }
} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/types.ts b/lib/vendor-rfq-response/types.ts
index 5dadc89b..3f595ebb 100644
--- a/lib/vendor-rfq-response/types.ts
+++ b/lib/vendor-rfq-response/types.ts
@@ -50,7 +50,7 @@ export interface RfqResponse {
projectCode?: string | null;
projectName?: string | null;
- // 벤더 정보
+ // 협력업체 정보
vendorId: number;
vendorName: string;
vendorCode?: string | null;
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx
new file mode 100644
index 00000000..c7be0bf4
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx
@@ -0,0 +1,365 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Download, Loader2, MessageSquare, FileEdit } from "lucide-react"
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { useRouter } from "next/navigation"
+import { VendorWithCbeFields, vendorResponseCbeColumnsConfig } from "@/config/vendorCbeColumnsConfig"
+import { toast } from "sonner"
+
+
+type NextRouter = ReturnType<typeof useRouter>
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null>
+ >
+ router: NextRouter
+ openCommentSheet: (vendorId: number) => void
+ handleDownloadCbeFiles: (vendorId: number, rfqId: number) => void
+ loadingVendors: Record<string, boolean>
+ openVendorContactsDialog: (rfqId: number, rfq: VendorWithCbeFields) => void
+ // New prop for handling commercial response
+ openCommercialResponseSheet: (responseId: number, rfq: VendorWithCbeFields) => void
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ handleDownloadCbeFiles,
+ loadingVendors,
+ openVendorContactsDialog,
+ openCommercialResponseSheet
+}: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] {
+ // ----------------------------------------------------------------
+ // 1) Select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<VendorWithCbeFields> = {
+ 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"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) 그룹화(Nested) 컬럼 구성
+ // ----------------------------------------------------------------
+ const groupMap: Record<string, ColumnDef<VendorWithCbeFields>[]> = {}
+
+ vendorResponseCbeColumnsConfig.forEach((cfg) => {
+ const groupName = cfg.group || "_noGroup"
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // childCol: ColumnDef<VendorWithCbeFields>
+ const childCol: ColumnDef<VendorWithCbeFields> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ maxSize: 120,
+ // 셀 렌더링
+ cell: ({ row, getValue }) => {
+ // 1) 필드값 가져오기
+ const val = getValue()
+
+
+ if (cfg.id === "rfqCode") {
+ const rfq = row.original;
+ const rfqId = rfq.rfqId;
+
+ // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링
+ const handleVendorNameClick = () => {
+ if (rfqId) {
+ openVendorContactsDialog(rfqId, rfq); // vendor 전체 객체 전달
+ } else {
+ toast.error("협력업체 ID를 찾을 수 없습니다.");
+ }
+ };
+
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left font-normal justify-start hover:underline"
+ onClick={handleVendorNameClick}
+ >
+ {val as string}
+ </Button>
+ );
+ }
+
+ // Commercial Response Status에 배지 적용
+ if (cfg.id === "commercialResponseStatus") {
+ const status = val as string;
+
+ if (!status) return <span className="text-muted-foreground">-</span>;
+
+ let variant: "default" | "outline" | "secondary" | "destructive" = "outline";
+
+ switch (status) {
+ case "SUBMITTED":
+ variant = "default"; // Green
+ break;
+ case "IN_PROGRESS":
+ variant = "secondary"; // Orange/Yellow
+ break;
+ case "PENDING":
+ variant = "outline"; // Gray
+ break;
+ default:
+ variant = "outline";
+ }
+
+ return (
+ <Badge variant={variant} className="capitalize">
+ {status.toLowerCase().replace("_", " ")}
+ </Badge>
+ );
+ }
+
+ // 예) TBE Updated (날짜)
+ if (cfg.id === "respondedAt" || cfg.id === "rfqDueDate" ) {
+ const dateVal = val as Date | undefined
+ if (!dateVal) return null
+ return formatDate(dateVal)
+ }
+
+ // 그 외 필드는 기본 값 표시
+ return val ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // groupMap → nestedColumns
+ const nestedColumns: ColumnDef<VendorWithCbeFields>[] = []
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ nestedColumns.push(...colDefs)
+ } else {
+ nestedColumns.push({
+ id: groupName,
+ header: groupName,
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 3) Respond 컬럼 (새로 추가)
+ // ----------------------------------------------------------------
+ const respondColumn: ColumnDef<VendorWithCbeFields> = {
+ id: "respond",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Response" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const responseId = vendor.responseId
+
+ if (!responseId) {
+ return <div className="text-center text-muted-foreground">-</div>
+ }
+
+ const handleClick = () => {
+ openCommercialResponseSheet(responseId, vendor)
+ }
+
+ // Status에 따라 버튼 variant 변경
+ let variant: "default" | "outline" | "ghost" | "secondary" = "default"
+ let buttonText = "Respond"
+
+ if (vendor.commercialResponseStatus === "SUBMITTED") {
+ variant = "outline"
+ buttonText = "Update"
+ } else if (vendor.commercialResponseStatus === "IN_PROGRESS") {
+ variant = "secondary"
+ buttonText = "Continue"
+ }
+
+ return (
+ <Button
+ variant={variant}
+ size="sm"
+ // className="w-20"
+ onClick={handleClick}
+ >
+ <FileEdit className="h-3.5 w-3.5 mr-1" />
+ {buttonText}
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 200,
+ minSize: 115,
+ }
+
+ // ----------------------------------------------------------------
+ // 4) Comments 컬럼
+ // ----------------------------------------------------------------
+ const commentsColumn: ColumnDef<VendorWithCbeFields> = {
+ id: "comments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Comments" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const commCount = vendor.comments?.length ?? 0
+
+ function handleClick() {
+ // rowAction + openCommentSheet
+ setRowAction({ row, type: "comments" })
+ openCommentSheet(vendor.responseId ?? 0)
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ commCount > 0 ? `View ${commCount} comments` : "No comments"
+ }
+ >
+ <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {commCount > 0 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
+ >
+ {commCount}
+ </Badge>
+ )}
+ <span className="sr-only">
+ {commCount > 0 ? `${commCount} Comments` : "No Comments"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80
+ }
+
+ // ----------------------------------------------------------------
+ // 5) 파일 다운로드 컬럼 (개별 로딩 상태 적용)
+ // ----------------------------------------------------------------
+ const downloadColumn: ColumnDef<VendorWithCbeFields> = {
+ id: "attachDownload",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Attach Download" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const vendorId = vendor.vendorId
+ const rfqId = vendor.rfqId
+ const files = vendor.files?.length || 0
+
+ if (!vendorId || !rfqId) {
+ return <div className="text-center text-muted-foreground">-</div>
+ }
+
+ // 각 행별로 로딩 상태 확인 (vendorId_rfqId 형식의 키 사용)
+ const rowKey = `${vendorId}_${rfqId}`
+ const isRowLoading = loadingVendors[rowKey] === true
+
+ // 템플릿 파일이 없으면 다운로드 버튼 비활성화
+ const isDisabled = files <= 0 || isRowLoading
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={
+ isDisabled
+ ? undefined
+ : () => handleDownloadCbeFiles(vendorId, rfqId)
+ }
+ aria-label={
+ isRowLoading
+ ? "다운로드 중..."
+ : files > 0
+ ? `CBE 첨부 다운로드 (${files}개)`
+ : "다운로드할 파일 없음"
+ }
+ disabled={isDisabled}
+ >
+ {isRowLoading ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ )}
+
+ {/* 파일이 1개 이상인 경우 뱃지로 개수 표시 (로딩 중이 아닐 때만) */}
+ {!isRowLoading && files > 0 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
+ >
+ {files}
+ </Badge>
+ )}
+
+ <span className="sr-only">
+ {isRowLoading
+ ? "다운로드 중..."
+ : files > 0
+ ? `CBE 첨부 다운로드 (${files}개)`
+ : "다운로드할 파일 없음"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80,
+ }
+
+ // ----------------------------------------------------------------
+ // 6) 최종 컬럼 배열 (respondColumn 추가)
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ respondColumn, // 응답 컬럼 추가
+ downloadColumn,
+ commentsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx
new file mode 100644
index 00000000..8477f550
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx
@@ -0,0 +1,272 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+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 { getColumns } from "./cbe-table-columns"
+import {
+ fetchRfqAttachmentsbyCommentId,
+ getCBEbyVendorId,
+ getFileFromRfqAttachmentsbyid,
+ fetchCbeFiles
+} from "../../rfqs/service"
+import { useSession } from "next-auth/react"
+import { CbeComment, CommentSheet } from "./comments-sheet"
+import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { toast } from "sonner"
+import { RfqDeailDialog } from "./rfq-detail-dialog"
+import { CommercialResponseSheet } from "./respond-cbe-sheet"
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getCBEbyVendorId>>,
+ ]
+ >
+}
+
+export function CbeVendorTable({ promises }: VendorsTableProps) {
+ const { data: session } = useSession()
+ const userVendorId = session?.user?.companyId
+ const userId = Number(session?.user?.id)
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null)
+ const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null)
+
+ // 개별 협력업체별 로딩 상태를 관리하는 맵
+ const [loadingVendors, setLoadingVendors] = React.useState<Record<string, boolean>>({})
+
+ const router = useRouter()
+
+ // 코멘트 관련 상태
+ const [initialComments, setInitialComments] = React.useState<CbeComment[]>([])
+ const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
+ const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
+
+ // 상업 응답 관련 상태
+ const [commercialResponseSheetOpen, setCommercialResponseSheetOpen] = React.useState(false)
+ const [selectedResponseId, setSelectedResponseId] = React.useState<number | null>(null)
+ const [selectedRfq, setSelectedRfq] = React.useState<VendorWithCbeFields | null>(null)
+
+ // RFQ 상세 관련 상태
+ const [rfqDetailDialogOpen, setRfqDetailDialogOpen] = React.useState(false)
+ const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null)
+ const [selectedRfqDetail, setSelectedRfqDetail] = React.useState<VendorWithCbeFields | null>(null)
+
+ React.useEffect(() => {
+ if (rowAction?.type === "comments") {
+ // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행
+ openCommentSheet(Number(rowAction.row.original.responseId))
+ }
+ }, [rowAction])
+
+ async function openCommentSheet(responseId: number) {
+ setInitialComments([])
+
+ const comments = rowAction?.row.original.comments
+ const rfqId = rowAction?.row.original.rfqId
+
+ if (comments && comments.length > 0) {
+ const commentWithAttachments: CbeComment[] = await Promise.all(
+ comments.map(async (c) => {
+ // 서버 액션을 사용하여 코멘트 첨부 파일 가져오기
+ const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
+
+ return {
+ ...c,
+ commentedBy: userId, // DB나 API 응답에 있다고 가정
+ attachments,
+ }
+ })
+ )
+
+ setInitialComments(commentWithAttachments)
+ }
+
+ if(rfqId) {
+ setSelectedRfqIdForComments(rfqId)
+ }
+ setSelectedCbeId(responseId)
+ setCommentSheetOpen(true)
+ }
+
+ // 상업 응답 시트 열기
+ function openCommercialResponseSheet(responseId: number, rfq: VendorWithCbeFields) {
+ setSelectedResponseId(responseId)
+ setSelectedRfq(rfq)
+ setCommercialResponseSheetOpen(true)
+ }
+
+ // RFQ 상세 대화상자 열기
+ function openRfqDetailDialog(rfqId: number, rfq: VendorWithCbeFields) {
+ setSelectedRfqId(rfqId)
+ setSelectedRfqDetail(rfq)
+ setRfqDetailDialogOpen(true)
+ }
+
+ const handleDownloadCbeFiles = React.useCallback(
+ async (vendorId: number, rfqId: number) => {
+ // 고유 키 생성: vendorId_rfqId
+ const rowKey = `${vendorId}_${rfqId}`
+
+ // 해당 협력업체의 로딩 상태만 true로 설정
+ setLoadingVendors(prev => ({
+ ...prev,
+ [rowKey]: true
+ }))
+
+ try {
+ const { files, error } = await fetchCbeFiles(vendorId, rfqId);
+ if (error) {
+ toast.error(error);
+ return;
+ }
+ if (files.length === 0) {
+ toast.warning("다운로드할 CBE 파일이 없습니다");
+ return;
+ }
+ // 순차적으로 파일 다운로드
+ for (const file of files) {
+ await downloadFile(file.id);
+ }
+ toast.success(`${files.length}개의 CBE 파일이 다운로드되었습니다`);
+ } catch (error) {
+ toast.error("CBE 파일을 다운로드하는 데 실패했습니다");
+ console.error(error);
+ } finally {
+ // 해당 협력업체의 로딩 상태만 false로 되돌림
+ setLoadingVendors(prev => ({
+ ...prev,
+ [rowKey]: false
+ }))
+ }
+ },
+ []
+ );
+
+ const downloadFile = React.useCallback(async (fileId: number) => {
+ try {
+ const { file, error } = await getFileFromRfqAttachmentsbyid(fileId);
+ if (error || !file) {
+ throw new Error(error || "파일 정보를 가져오는 데 실패했습니다");
+ }
+
+ const link = document.createElement("a");
+ link.href = `/api/rfq-download?path=${encodeURIComponent(file.filePath)}`;
+ link.download = file.fileName;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ return true;
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ }, []);
+
+ // 응답 성공 후 데이터 갱신
+ const handleResponseSuccess = React.useCallback(() => {
+ // 필요한 경우 데이터 다시 가져오기
+ router.refresh()
+ }, [router]);
+
+ // getColumns() 호출 시 필요한 핸들러들 주입
+ const columns = React.useMemo(
+ () => getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ handleDownloadCbeFiles,
+ loadingVendors,
+ openVendorContactsDialog: openRfqDetailDialog,
+ openCommercialResponseSheet,
+ }),
+ [
+ setRowAction,
+ router,
+ openCommentSheet,
+ handleDownloadCbeFiles,
+ loadingVendors,
+ openRfqDetailDialog,
+ openCommercialResponseSheet
+ ]
+ );
+
+ // 필터 필드 정의
+ const filterFields: DataTableFilterField<VendorWithCbeFields>[] = []
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [
+
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "respondedAt", desc: true }],
+ columnPinning: { right: ["respond", "comments"] }, // respond 컬럼을 오른쪽에 고정
+ },
+ getRowId: (originalRow) => String(originalRow.responseId),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ />
+ </DataTable>
+
+ {/* 코멘트 시트 */}
+ {commentSheetOpen && selectedRfqIdForComments && selectedCbeId !== null && (
+ <CommentSheet
+ open={commentSheetOpen}
+ onOpenChange={setCommentSheetOpen}
+ rfqId={selectedRfqIdForComments}
+ initialComments={initialComments}
+ vendorId={userVendorId || 0}
+ currentUserId={userId || 0}
+ cbeId={selectedCbeId}
+ />
+ )}
+
+ {/* 상업 응답 시트 */}
+ {commercialResponseSheetOpen && selectedResponseId !== null && selectedRfq && (
+ <CommercialResponseSheet
+ open={commercialResponseSheetOpen}
+ onOpenChange={setCommercialResponseSheetOpen}
+ responseId={selectedResponseId}
+ rfq={selectedRfq}
+ onSuccess={handleResponseSuccess}
+ />
+ )}
+
+ {/* RFQ 상세 대화상자 */}
+ {rfqDetailDialogOpen && selectedRfqId !== null && (
+ <RfqDeailDialog
+ isOpen={rfqDetailDialogOpen}
+ onOpenChange={setRfqDetailDialogOpen}
+ rfqId={selectedRfqId}
+ rfq={selectedRfqDetail}
+ />
+ )}
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx
new file mode 100644
index 00000000..40d38145
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx
@@ -0,0 +1,323 @@
+"use client"
+
+import * as React from "react"
+import { useForm, useFieldArray } from "react-hook-form"
+import { z } from "zod"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Download, X, Loader2 } from "lucide-react"
+import prettyBytes from "pretty-bytes"
+import { toast } from "sonner"
+
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneUploadIcon,
+ DropzoneTitle,
+ DropzoneDescription,
+ DropzoneInput,
+} from "@/components/ui/dropzone"
+import {
+ Table,
+ TableHeader,
+ TableRow,
+ TableHead,
+ TableBody,
+ TableCell,
+} from "@/components/ui/table"
+
+import { formatDate } from "@/lib/utils"
+import { createRfqCommentWithAttachments } from "@/lib/rfqs/service"
+
+
+export interface CbeComment {
+ id: number
+ commentText: string
+ commentedBy?: number
+ commentedByEmail?: string
+ createdAt?: Date
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ }[]
+}
+
+// 1) props 정의
+interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ initialComments?: CbeComment[]
+ currentUserId: number
+ rfqId: number
+ tbeId?: number
+ cbeId?: number
+ vendorId: number
+ onCommentsUpdated?: (comments: CbeComment[]) => void
+ isLoading?: boolean // New prop
+}
+
+// 2) 폼 스키마
+const commentFormSchema = z.object({
+ commentText: z.string().min(1, "댓글을 입력하세요."),
+ newFiles: z.array(z.any()).optional(), // File[]
+})
+type CommentFormValues = z.infer<typeof commentFormSchema>
+
+const MAX_FILE_SIZE = 30e6 // 30MB
+
+export function CommentSheet({
+ rfqId,
+ vendorId,
+ initialComments = [],
+ currentUserId,
+ tbeId,
+ cbeId,
+ onCommentsUpdated,
+ isLoading = false, // Default to false
+ ...props
+}: CommentSheetProps) {
+
+
+ const [comments, setComments] = React.useState<CbeComment[]>(initialComments)
+ const [isPending, startTransition] = React.useTransition()
+
+ React.useEffect(() => {
+ setComments(initialComments)
+ }, [initialComments])
+
+ const form = useForm<CommentFormValues>({
+ resolver: zodResolver(commentFormSchema),
+ defaultValues: {
+ commentText: "",
+ newFiles: [],
+ },
+ })
+
+ const { fields: newFileFields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "newFiles",
+ })
+
+ // (A) 기존 코멘트 렌더링
+ function renderExistingComments() {
+
+ if (isLoading) {
+ return (
+ <div className="flex justify-center items-center h-32">
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
+ </div>
+ )
+ }
+
+ if (comments.length === 0) {
+ return <p className="text-sm text-muted-foreground">No comments yet</p>
+ }
+ return (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-1/2">Comment</TableHead>
+ <TableHead>Attachments</TableHead>
+ <TableHead>Created At</TableHead>
+ <TableHead>Created By</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {comments.map((c) => (
+ <TableRow key={c.id}>
+ <TableCell>{c.commentText}</TableCell>
+ <TableCell>
+ {!c.attachments?.length && (
+ <span className="text-sm text-muted-foreground">No files</span>
+ )}
+ {c.attachments?.length && (
+ <div className="flex flex-col gap-1">
+ {c.attachments.map((att) => (
+ <div key={att.id} className="flex items-center gap-2">
+ <a
+ href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`}
+ download
+ target="_blank"
+ rel="noreferrer"
+ className="inline-flex items-center gap-1 text-blue-600 underline"
+ >
+ <Download className="h-4 w-4" />
+ {att.fileName}
+ </a>
+ </div>
+ ))}
+ </div>
+ )}
+ </TableCell>
+ <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell>
+ <TableCell>{c.commentedByEmail ?? "-"}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )
+ }
+
+ // (B) 파일 드롭
+ function handleDropAccepted(files: File[]) {
+ append(files)
+ }
+
+ // (C) Submit
+ async function onSubmit(data: CommentFormValues) {
+ if (!rfqId) return
+ startTransition(async () => {
+ try {
+ const res = await createRfqCommentWithAttachments({
+ rfqId,
+ vendorId,
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ evaluationId: null,
+ cbeId: cbeId,
+ files: data.newFiles,
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create comment")
+ }
+
+ toast.success("Comment created")
+
+ // 임시로 새 코멘트 추가
+ const newComment: CbeComment = {
+ id: res.commentId, // 서버 응답
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ createdAt: res.createdAt,
+ attachments:
+ data.newFiles?.map((f) => ({
+ id: Math.floor(Math.random() * 1e6),
+ fileName: f.name,
+ filePath: "/uploads/" + f.name,
+ })) || [],
+ }
+ setComments((prev) => [...prev, newComment])
+ onCommentsUpdated?.([...comments, newComment])
+
+ form.reset()
+ } catch (err: any) {
+ console.error(err)
+ toast.error("Error: " + err.message)
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
+ <SheetHeader className="text-left">
+ <SheetTitle>Comments</SheetTitle>
+ <SheetDescription>
+ 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <FormField
+ control={form.control}
+ name="commentText"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>New Comment</FormLabel>
+ <FormControl>
+ <Textarea placeholder="Enter your comment..." {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={(rej) => {
+ toast.error("File rejected: " + (rej[0]?.file?.name || ""))
+ }}
+ >
+ {({ maxSize }) => (
+ <DropzoneZone className="flex justify-center">
+ <DropzoneInput />
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>Drop to attach files</DropzoneTitle>
+ <DropzoneDescription>
+ Max size: {prettyBytes(maxSize || 0)}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
+
+ {newFileFields.length > 0 && (
+ <div className="flex flex-col gap-2">
+ {newFileFields.map((field, idx) => {
+ const file = form.getValues(`newFiles.${idx}`)
+ if (!file) return null
+ return (
+ <div
+ key={field.id}
+ className="flex items-center justify-between border rounded p-2"
+ >
+ <span className="text-sm">
+ {file.name} ({prettyBytes(file.size)})
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ onClick={() => remove(idx)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ )
+ })}
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-4">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isPending}>
+ {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx b/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx
new file mode 100644
index 00000000..8cc4fa6f
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx
@@ -0,0 +1,427 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { z } from "zod"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Textarea } from "@/components/ui/textarea"
+import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { getCommercialResponseByResponseId, updateCommercialResponse } from "../service"
+
+// Define schema for form validation (client-side)
+const commercialResponseFormSchema = z.object({
+ responseStatus: z.enum(["PENDING", "IN_PROGRESS", "SUBMITTED", "REJECTED", "ACCEPTED"]),
+ totalPrice: z.coerce.number().optional(),
+ currency: z.string().default("USD"),
+ paymentTerms: z.string().optional(),
+ incoterms: z.string().optional(),
+ deliveryPeriod: z.string().optional(),
+ warrantyPeriod: z.string().optional(),
+ validityPeriod: z.string().optional(),
+ priceBreakdown: z.string().optional(),
+ commercialNotes: z.string().optional(),
+})
+
+type CommercialResponseFormInput = z.infer<typeof commercialResponseFormSchema>
+
+interface CommercialResponseSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ rfq: VendorWithCbeFields | null
+ responseId: number | null // This is the vendor_responses.id
+ onSuccess?: () => void
+}
+
+export function CommercialResponseSheet({
+ rfq,
+ responseId,
+ onSuccess,
+ ...props
+}: CommercialResponseSheetProps) {
+ const [isSubmitting, startSubmitTransition] = React.useTransition()
+ const [isLoading, setIsLoading] = React.useState(true)
+
+ const form = useForm<CommercialResponseFormInput>({
+ resolver: zodResolver(commercialResponseFormSchema),
+ defaultValues: {
+ responseStatus: "PENDING",
+ totalPrice: undefined,
+ currency: "USD",
+ paymentTerms: "",
+ incoterms: "",
+ deliveryPeriod: "",
+ warrantyPeriod: "",
+ validityPeriod: "",
+ priceBreakdown: "",
+ commercialNotes: "",
+ },
+ })
+
+ // Load existing commercial response data when sheet opens
+ React.useEffect(() => {
+ async function loadCommercialResponse() {
+ if (!responseId) return
+
+ setIsLoading(true)
+ try {
+ // Use the helper function to get existing data
+ const existingResponse = await getCommercialResponseByResponseId(responseId)
+
+ if (existingResponse) {
+ // If we found existing data, populate the form
+ form.reset({
+ responseStatus: existingResponse.responseStatus,
+ totalPrice: existingResponse.totalPrice,
+ currency: existingResponse.currency || "USD",
+ paymentTerms: existingResponse.paymentTerms || "",
+ incoterms: existingResponse.incoterms || "",
+ deliveryPeriod: existingResponse.deliveryPeriod || "",
+ warrantyPeriod: existingResponse.warrantyPeriod || "",
+ validityPeriod: existingResponse.validityPeriod || "",
+ priceBreakdown: existingResponse.priceBreakdown || "",
+ commercialNotes: existingResponse.commercialNotes || "",
+ })
+ } else if (rfq) {
+ // If no existing data but we have rfq data with some values already
+ form.reset({
+ responseStatus: rfq.commercialResponseStatus as any || "PENDING",
+ totalPrice: rfq.totalPrice || undefined,
+ currency: rfq.currency || "USD",
+ paymentTerms: rfq.paymentTerms || "",
+ incoterms: rfq.incoterms || "",
+ deliveryPeriod: rfq.deliveryPeriod || "",
+ warrantyPeriod: rfq.warrantyPeriod || "",
+ validityPeriod: rfq.validityPeriod || "",
+ priceBreakdown: "",
+ commercialNotes: "",
+ })
+ }
+ } catch (error) {
+ console.error("Failed to load commercial response data:", error)
+ toast.error("상업 응답 데이터를 불러오는데 실패했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadCommercialResponse()
+ }, [responseId, rfq, form])
+
+ function onSubmit(formData: CommercialResponseFormInput) {
+ if (!responseId) {
+ toast.error("응답 ID를 찾을 수 없습니다")
+ return
+ }
+
+ if (!rfq?.vendorId) {
+ toast.error("협력업체 ID를 찾을 수 없습니다")
+ return
+ }
+
+ startSubmitTransition(async () => {
+ try {
+ // Pass both responseId and vendorId to the server action
+ const result = await updateCommercialResponse({
+ responseId,
+ vendorId: rfq.vendorId, // Include vendorId for revalidateTag
+ ...formData,
+ })
+
+ if (!result.success) {
+ toast.error(result.error || "응답 제출 중 오류가 발생했습니다")
+ return
+ }
+
+ toast.success("Commercial response successfully submitted")
+ props.onOpenChange?.(false)
+
+ if (onSuccess) {
+ onSuccess()
+ }
+ } catch (error) {
+ console.error("Error submitting response:", error)
+ toast.error("응답 제출 중 오류가 발생했습니다")
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Commercial Response</SheetTitle>
+ <SheetDescription>
+ {rfq?.rfqCode && <span className="font-medium">{rfq.rfqCode}</span>}
+ <div className="mt-1">Please provide your commercial response for this RFQ</div>
+ </SheetDescription>
+ </SheetHeader>
+
+ {isLoading ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader className="h-8 w-8 animate-spin text-muted-foreground" />
+ </div>
+ ) : (
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4 overflow-y-auto max-h-[calc(100vh-200px)] pr-2"
+ >
+ <FormField
+ control={form.control}
+ name="responseStatus"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Response Status</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ >
+ <FormControl>
+ <SelectTrigger className="capitalize">
+ <SelectValue placeholder="Select response status" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="PENDING">Pending</SelectItem>
+ <SelectItem value="IN_PROGRESS">In Progress</SelectItem>
+ <SelectItem value="SUBMITTED">Submitted</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="totalPrice"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Total Price</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ placeholder="0.00"
+ {...field}
+ value={field.value || ''}
+ onChange={(e) => {
+ const value = e.target.value === '' ? undefined : parseFloat(e.target.value);
+ field.onChange(value);
+ }}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Currency</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Select currency" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="GBP">GBP</SelectItem>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* Other form fields remain the same */}
+ <FormField
+ control={form.control}
+ name="paymentTerms"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Payment Terms</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g. Net 30" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="incoterms"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Incoterms</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value || ''}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Select incoterms" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ <SelectItem value="EXW">EXW (Ex Works)</SelectItem>
+ <SelectItem value="FCA">FCA (Free Carrier)</SelectItem>
+ <SelectItem value="FOB">FOB (Free On Board)</SelectItem>
+ <SelectItem value="CIF">CIF (Cost, Insurance & Freight)</SelectItem>
+ <SelectItem value="DAP">DAP (Delivered At Place)</SelectItem>
+ <SelectItem value="DDP">DDP (Delivered Duty Paid)</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="deliveryPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Delivery Period</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g. 4-6 weeks" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="warrantyPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Warranty Period</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g. 12 months" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="validityPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Validity Period</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g. 30 days" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="priceBreakdown"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Price Breakdown (Optional)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Enter price breakdown details here"
+ className="min-h-[100px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="commercialNotes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Additional Notes (Optional)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Any additional comments or notes"
+ className="min-h-[100px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-4 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isSubmitting} type="submit">
+ {isSubmitting && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Submit Response
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ )}
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx
new file mode 100644
index 00000000..e9328641
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx
@@ -0,0 +1,89 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig"
+import { RfqItemsTable } from "./rfq-items-table/rfq-items-table"
+import { formatDateTime } from "@/lib/utils"
+import { CalendarClock } from "lucide-react"
+
+interface RfqDeailDialogProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ rfqId: number | null
+ rfq: VendorWithCbeFields | null
+}
+
+export function RfqDeailDialog({
+ isOpen,
+ onOpenChange,
+ rfqId,
+ rfq,
+}: RfqDeailDialogProps) {
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{ maxWidth: 1000, height: 480 }}>
+ <DialogHeader>
+ <div className="flex flex-col space-y-2">
+ <DialogTitle>프로젝트: {rfq && rfq.projectName}({rfq && rfq.projectCode}) / RFQ: {rfq && rfq.rfqCode} Detail</DialogTitle>
+ {rfq && (
+ <div className="flex flex-col space-y-3 mt-2">
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium text-foreground">{rfq.rfqDescription && rfq.rfqDescription}</span>
+ </div>
+
+ {/* 정보를 두 행으로 나누어 표시 */}
+ <div className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center">
+ {/* 첫 번째 행: 상태 배지 */}
+ <div className="flex items-center flex-wrap gap-2">
+ {rfq.rfqType && (
+ <Badge
+ variant={
+ rfq.rfqType === "BUDGETARY" ? "default" :
+ rfq.rfqType === "PURCHASE" ? "destructive" :
+ rfq.rfqType === "PURCHASE_BUDGETARY" ? "secondary" : "outline"
+ }
+ >
+ RFQ 유형: {rfq.rfqType}
+ </Badge>
+ )}
+
+
+ {rfq.vendorStatus && (
+ <Badge variant="outline">
+ RFQ 상태: {rfq.rfqStatus}
+ </Badge>
+ )}
+
+ </div>
+
+ {/* 두 번째 행: Due Date를 강조 표시 */}
+ {rfq.rfqDueDate && (
+ <div className="flex items-center">
+ <Badge variant="secondary" className="flex gap-1 text-xs py-1 px-3">
+ <CalendarClock className="h-3.5 w-3.5" />
+ <span className="font-semibold">Due Date:</span>
+ <span>{formatDateTime(rfq.rfqDueDate)}</span>
+ </Badge>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </DialogHeader>
+ {rfqId && (
+ <div className="py-4">
+ <RfqItemsTable rfqId={rfqId} />
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx
new file mode 100644
index 00000000..bf4ae709
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx
@@ -0,0 +1,62 @@
+"use client"
+// Because columns rely on React state/hooks for row actions
+
+import * as React from "react"
+import { ColumnDef, Row } from "@tanstack/react-table"
+import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"
+import { formatDate } from "@/lib/utils"
+import { Checkbox } from "@/components/ui/checkbox"
+import { ItemData } from "./rfq-items-table"
+
+
+/** getColumns: return array of ColumnDef for 'vendors' data */
+export function getColumns(): ColumnDef<ItemData>[] {
+ return [
+
+ // Vendor Name
+ {
+ accessorKey: "itemCode",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Item Code" />
+ ),
+ cell: ({ row }) => row.getValue("itemCode"),
+ },
+
+ // Vendor Code
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Description" />
+ ),
+ cell: ({ row }) => row.getValue("description"),
+ },
+
+ // Status
+ {
+ accessorKey: "quantity",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Quantity" />
+ ),
+ cell: ({ row }) => row.getValue("quantity"),
+ },
+
+
+ // Created At
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Created At" />
+ ),
+ cell: ({ cell }) => formatDate(cell.getValue() as Date),
+ },
+
+ // Updated At
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Updated At" />
+ ),
+ cell: ({ cell }) => formatDate(cell.getValue() as Date),
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx
new file mode 100644
index 00000000..c5c67e54
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx
@@ -0,0 +1,86 @@
+'use client'
+
+import * as React from "react"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { getColumns } from "./rfq-items-table-column"
+import { DataTableAdvancedFilterField } from "@/types/table"
+import { Loader2 } from "lucide-react"
+import { useToast } from "@/hooks/use-toast"
+import { getItemsByRfqId } from "../../service"
+
+export interface ItemData {
+ id: number
+ itemCode: string
+ description: string | null
+ quantity: number
+ uom: string | null
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface RFQItemsTableProps {
+ rfqId: number
+}
+
+export function RfqItemsTable({ rfqId }: RFQItemsTableProps) {
+ const { toast } = useToast()
+
+ const columns = React.useMemo(
+ () => getColumns(),
+ []
+ )
+
+ const [rfqItems, setRfqItems] = React.useState<ItemData[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ React.useEffect(() => {
+ async function loadItems() {
+ setIsLoading(true)
+ try {
+ // Use the correct function name (camelCase)
+ const result = await getItemsByRfqId(rfqId)
+ if (result.success && result.data) {
+ setRfqItems(result.data as ItemData[])
+ } else {
+ throw new Error(result.error || "Unknown error occurred")
+ }
+ } catch (error) {
+ console.error("RFQ 아이템 로드 오류:", error)
+ toast({
+ title: "Error",
+ description: "Failed to load RFQ items",
+ variant: "destructive",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+ loadItems()
+ }, [toast, rfqId])
+
+ const advancedFilterFields: DataTableAdvancedFilterField<ItemData>[] = [
+ { id: "itemCode", label: "Item Code", type: "text" },
+ { id: "description", label: "Description", type: "text" },
+ { id: "quantity", label: "Quantity", type: "number" },
+ { id: "uom", label: "UoM", type: "text" },
+ ]
+
+ // If loading, show a flex container that fills the parent and centers the spinner
+ if (isLoading) {
+ return (
+ <div className="flex h-full w-full items-center justify-center">
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+ </div>
+ )
+ }
+
+ // Otherwise, show the table
+ return (
+ <ClientDataTable
+ data={rfqItems}
+ columns={columns}
+ advancedFilterFields={advancedFilterFields}
+ >
+ </ClientDataTable>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx
index 1eee54f5..e0bf9727 100644
--- a/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx
+++ b/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx
@@ -4,7 +4,7 @@ import * as React from "react"
import { useForm, useFieldArray } from "react-hook-form"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
-import { Loader, Download, X } from "lucide-react"
+import { Loader, Download, X, Loader2 } from "lucide-react"
import prettyBytes from "pretty-bytes"
import { toast } from "sonner"
@@ -79,6 +79,8 @@ interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
vendorId:number
/** 댓글 저장 후 갱신용 콜백 (옵션) */
onCommentsUpdated?: (comments: TbeComment[]) => void
+ isLoading?: boolean // New prop
+
}
// 새 코멘트 작성 폼 스키마
@@ -96,6 +98,7 @@ export function CommentSheet({
initialComments = [],
currentUserId,
onCommentsUpdated,
+ isLoading = false, // Default to false
...props
}: CommentSheetProps) {
const [comments, setComments] = React.useState<TbeComment[]>(initialComments)
@@ -125,6 +128,15 @@ export function CommentSheet({
// 간단히 테이블 하나로 표현
// 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음
function renderExistingComments() {
+ if (isLoading) {
+ return (
+ <div className="flex justify-center items-center h-32">
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span>
+ </div>
+ )
+ }
+
if (comments.length === 0) {
return <p className="text-sm text-muted-foreground">No comments yet</p>
}
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx b/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx
new file mode 100644
index 00000000..2056a48f
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx
@@ -0,0 +1,86 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { formatDateTime } from "@/lib/utils"
+import { CalendarClock } from "lucide-react"
+import { RfqItemsTable } from "../vendor-cbe-table/rfq-items-table/rfq-items-table"
+import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig"
+
+interface RfqDeailDialogProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ rfqId: number | null
+ rfq: TbeVendorFields | null
+}
+
+export function RfqDeailDialog({
+ isOpen,
+ onOpenChange,
+ rfqId,
+ rfq,
+}: RfqDeailDialogProps) {
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}>
+ <DialogHeader>
+ <div className="flex flex-col space-y-2">
+ <DialogTitle>{rfq && rfq.rfqCode} Detail</DialogTitle>
+ {rfq && (
+ <div className="flex flex-col space-y-3 mt-2">
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium text-foreground">{rfq.rfqDescription && rfq.rfqDescription}</span>
+ </div>
+
+ {/* 정보를 두 행으로 나누어 표시 */}
+ <div className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center">
+ {/* 첫 번째 행: 상태 배지 */}
+ <div className="flex items-center flex-wrap gap-2">
+ {rfq.vendorStatus && (
+ <Badge variant="outline">
+ {rfq.rfqStatus}
+ </Badge>
+ )}
+ {rfq.rfqType && (
+ <Badge
+ variant={
+ rfq.rfqType === "BUDGETARY" ? "default" :
+ rfq.rfqType === "PURCHASE" ? "destructive" :
+ rfq.rfqType === "PURCHASE_BUDGETARY" ? "secondary" : "outline"
+ }
+ >
+ {rfq.rfqType}
+ </Badge>
+ )}
+ </div>
+
+ {/* 두 번째 행: Due Date를 강조 표시 */}
+ {rfq.rfqDueDate && (
+ <div className="flex items-center">
+ <Badge variant="secondary" className="flex gap-1 text-xs py-1 px-3">
+ <CalendarClock className="h-3.5 w-3.5" />
+ <span className="font-semibold">Due Date:</span>
+ <span>{formatDateTime(rfq.rfqDueDate)}</span>
+ </Badge>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </DialogHeader>
+ {rfqId && (
+ <div className="py-4">
+ <RfqItemsTable rfqId={rfqId} />
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx
index 7a95d7ed..f664d9a3 100644
--- a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx
+++ b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx
@@ -31,6 +31,8 @@ interface GetColumnsProps {
openCommentSheet: (vendorId: number) => void
handleDownloadTbeTemplate: (tbeId: number, vendorId: number, rfqId: number) => void
handleUploadTbeResponse: (tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => void
+ openVendorContactsDialog: (rfqId: number, rfq: TbeVendorFields) => void // 수정된 시그니처
+
}
/**
@@ -42,6 +44,7 @@ export function getColumns({
openCommentSheet,
handleDownloadTbeTemplate,
handleUploadTbeResponse,
+ openVendorContactsDialog
}: GetColumnsProps): ColumnDef<TbeVendorFields>[] {
// ----------------------------------------------------------------
// 1) Select 컬럼 (체크박스)
@@ -112,7 +115,30 @@ export function getColumns({
)
}
-
+
+ if (cfg.id === "rfqCode") {
+ const rfq = row.original;
+ const rfqId = rfq.rfqId;
+
+ // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링
+ const handleVendorNameClick = () => {
+ if (rfqId) {
+ openVendorContactsDialog(rfqId, rfq); // vendor 전체 객체 전달
+ } else {
+ toast.error("협력업체 ID를 찾을 수 없습니다.");
+ }
+ };
+
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left font-normal justify-start hover:underline"
+ onClick={handleVendorNameClick}
+ >
+ {val as string}
+ </Button>
+ );
+ }
if (cfg.id === "rfqVendorStatus") {
const statusVal = row.original.rfqVendorStatus
if (!statusVal) return null
@@ -173,21 +199,28 @@ export function getColumns({
}
return (
- <div>
- <Button
- variant="ghost"
- size="sm"
- className="h-8 w-8 p-0 group relative"
- onClick={handleClick}
- aria-label={commCount > 0 ? `View ${commCount} comments` : "Add comment"}
- >
- <div className="flex items-center justify-center relative">
- <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- </div>
- {commCount > 0 && <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full bg-red-500"></span>}
- <span className="sr-only">{commCount > 0 ? `${commCount} Comments` : "Add Comment"}</span>
- </Button>
- </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ commCount > 0 ? `View ${commCount} comments` : "No comments"
+ }
+ >
+ <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {commCount > 0 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"
+ >
+ {commCount}
+ </Badge>
+ )}
+ <span className="sr-only">
+ {commCount > 0 ? `${commCount} Comments` : "No Comments"}
+ </span>
+ </Button>
)
},
enableSorting: false,
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx
index 3450a643..13d5dc64 100644
--- a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx
+++ b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx
@@ -7,19 +7,17 @@ import type {
DataTableFilterField,
DataTableRowAction,
} from "@/types/table"
-
-import { toSentenceCase } from "@/lib/utils"
+import { toast } from "sonner"
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 { useFeatureFlags } from "./feature-flags-provider"
import { getColumns } from "./tbe-table-columns"
-import { Vendor, vendors } from "@/db/schema/vendors"
import { fetchRfqAttachmentsbyCommentId, getTBEforVendor } from "../../rfqs/service"
import { CommentSheet, TbeComment } from "./comments-sheet"
import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig"
import { useTbeFileHandlers } from "./tbeFileHandler"
import { useSession } from "next-auth/react"
+import { RfqDeailDialog } from "./rfq-detail-dialog"
interface VendorsTableProps {
promises: Promise<
@@ -30,7 +28,6 @@ interface VendorsTableProps {
}
export function TbeVendorTable({ promises }: VendorsTableProps) {
- const { featureFlags } = useFeatureFlags()
const { data: session } = useSession()
const userVendorId = session?.user?.companyId
const userId = Number(session?.user?.id)
@@ -43,8 +40,20 @@ export function TbeVendorTable({ promises }: VendorsTableProps) {
const router = useRouter()
const [initialComments, setInitialComments] = React.useState<TbeComment[]>([])
+ const [isLoadingComments, setIsLoadingComments] = React.useState(false)
+
const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
+ const [isRfqDetailDialogOpen, setIsRfqDetailDialogOpen] = React.useState(false)
+
+ const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null)
+ const [selectedRfq, setSelectedRfq] = React.useState<TbeVendorFields | null>(null)
+
+ const openVendorContactsDialog = (rfqId: number, rfq: TbeVendorFields) => {
+ setSelectedRfqId(rfqId)
+ setSelectedRfq(rfq)
+ setIsRfqDetailDialogOpen(true)
+ }
// TBE 파일 핸들러 훅 사용
const {
@@ -62,9 +71,11 @@ export function TbeVendorTable({ promises }: VendorsTableProps) {
async function openCommentSheet(vendorId: number) {
setInitialComments([])
+ setIsLoadingComments(true)
const comments = rowAction?.row.original.comments
+ try {
if (comments && comments.length > 0) {
const commentWithAttachments: TbeComment[] = await Promise.all(
comments.map(async (c) => {
@@ -73,18 +84,26 @@ export function TbeVendorTable({ promises }: VendorsTableProps) {
return {
...c,
- commentedBy: 1, // DB나 API 응답에 있다고 가정
+ commentedBy: userId, // DB나 API 응답에 있다고 가정
attachments,
}
})
)
-
+
setInitialComments(commentWithAttachments)
}
setSelectedRfqIdForComments(vendorId)
setCommentSheetOpen(true)
+
+ } catch (error) {
+ console.error("Error loading comments:", error)
+ toast.error("Failed to load comments")
+ } finally {
+ // End loading regardless of success/failure
+ setIsLoadingComments(false)
}
+}
// getColumns() 호출 시, 필요한 모든 핸들러 함수 주입
const columns = React.useMemo(
@@ -94,27 +113,25 @@ export function TbeVendorTable({ promises }: VendorsTableProps) {
openCommentSheet,
handleDownloadTbeTemplate,
handleUploadTbeResponse,
+ openVendorContactsDialog
}),
- [setRowAction, router, openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse]
+ [setRowAction, router, openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse, openVendorContactsDialog]
)
const filterFields: DataTableFilterField<TbeVendorFields>[] = []
const advancedFilterFields: DataTableAdvancedFilterField<TbeVendorFields>[] = [
- { id: "vendorName", label: "Vendor Name", type: "text" },
- { id: "vendorCode", label: "Vendor Code", type: "text" },
- { id: "email", label: "Email", type: "text" },
- { id: "country", label: "Country", type: "text" },
- {
- id: "vendorStatus",
- label: "Vendor Status",
- type: "multi-select",
- options: vendors.status.enumValues.map((status) => ({
- label: toSentenceCase(status),
- value: status,
- })),
- },
+ { id: "rfqCode", label: "RFQ Code", type: "text" },
+ { id: "projectCode", label: "Project Code", type: "text" },
+ { id: "projectName", label: "Project Name", type: "text" },
+ { id: "rfqCode", label: "RFQ Code", type: "text" },
+ { id: "tbeResult", label: "TBE Result", type: "text" },
+ { id: "tbeNote", label: "TBE Note", type: "text" },
+ { id: "rfqCode", label: "RFQ Code", type: "text" },
+ { id: "hasResponse", label: "Response?", type: "boolean" },
{ id: "rfqVendorUpdated", label: "Updated at", type: "date" },
+ { id: "dueDate", label: "Project Name", type: "date" },
+
]
const { table } = useDataTable({
@@ -150,11 +167,20 @@ export function TbeVendorTable({ promises }: VendorsTableProps) {
onOpenChange={setCommentSheetOpen}
rfqId={selectedRfqIdForComments}
initialComments={initialComments}
- vendorId={userVendorId||0}
- currentUserId={userId||0}
+ vendorId={userVendorId || 0}
+ currentUserId={userId || 0}
+ isLoading={isLoadingComments} // Pass the loading state
+
/>
)}
+ <RfqDeailDialog
+ isOpen={isRfqDetailDialogOpen}
+ onOpenChange={setIsRfqDetailDialogOpen}
+ rfqId={selectedRfqId}
+ rfq={selectedRfq}
+ />
+
{/* TBE 파일 다이얼로그 */}
<UploadDialog />
</>
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx
index 4efaee77..a0b6f805 100644
--- a/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx
+++ b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx
@@ -13,9 +13,9 @@ import {
import { Button } from "@/components/ui/button";
import {
fetchTbeTemplateFiles,
- getTbeTemplateFileInfo,
uploadTbeResponseFile,
getTbeSubmittedFiles,
+ getFileFromRfqAttachmentsbyid,
} from "../../rfqs/service";
import {
Dropzone,
@@ -118,7 +118,7 @@ export function useTbeFileHandlers() {
// 실제 다운로드 로직
const downloadFile = useCallback(async (fileId: number) => {
try {
- const { file, error } = await getTbeTemplateFileInfo(fileId);
+ const { file, error } = await getFileFromRfqAttachmentsbyid(fileId);
if (error || !file) {
throw new Error(error || "파일 정보를 가져오는 데 실패했습니다");
}
diff --git a/lib/vendor-type/repository.ts b/lib/vendor-type/repository.ts
new file mode 100644
index 00000000..7e0be35e
--- /dev/null
+++ b/lib/vendor-type/repository.ts
@@ -0,0 +1,130 @@
+import { vendorTypes } from "@/db/schema";
+import { SQL, eq, inArray, sql,asc, desc } from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+
+/**
+ * 협력업체 타입 조회 (복잡한 where + order + limit + offset 지원)
+ */
+export async function selectVendorTypes(
+ 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(vendorTypes)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+/**
+ * 전체 협력업체 타입 조회
+ */
+export async function findAllVendorTypes(tx: any) {
+ return tx.select()
+ .from(vendorTypes)
+ .orderBy(vendorTypes.nameKo);
+}
+
+/**
+ * 협력업체 타입 개수 카운트
+ */
+export async function countVendorTypes(tx: any, where?: SQL<unknown> | undefined) {
+ const result = await tx
+ .select({ count: sql`count(*)` })
+ .from(vendorTypes)
+ .where(where || undefined);
+
+ return Number(result[0]?.count || 0);
+}
+
+/**
+ * 협력업체 타입 추가
+ */
+export async function insertVendorType(
+ tx: any,
+ data: {
+ code: string;
+ nameKo: string;
+ nameEn: string;
+ }
+) {
+ const insertedRows = await tx
+ .insert(vendorTypes)
+ .values(data);
+
+ // 삽입된 데이터 가져오기
+ return tx.select()
+ .from(vendorTypes)
+ .where(eq(vendorTypes.code, data.code))
+ .limit(1);
+}
+
+/**
+ * 단일 협력업체 타입 업데이트
+ */
+export async function updateVendorType(
+ tx: any,
+ id: number,
+ data: Partial<{
+ code: string;
+ nameKo: string;
+ nameEn: string;
+ }>
+) {
+ await tx
+ .update(vendorTypes)
+ .set({
+ ...data,
+ updatedAt: new Date()
+ })
+ .where(eq(vendorTypes.id, id));
+
+ // 업데이트된 데이터 가져오기
+ return tx.select()
+ .from(vendorTypes)
+ .where(eq(vendorTypes.id, id))
+ .limit(1);
+}
+
+/**
+ * ID로 단일 협력업체 타입 삭제
+ */
+export async function deleteVendorTypeById(tx: any, id: number) {
+ // 삭제 전 데이터 가져오기 (필요한 경우)
+ const deletedRecord = await tx.select()
+ .from(vendorTypes)
+ .where(eq(vendorTypes.id, id))
+ .limit(1);
+
+ // 데이터 삭제
+ await tx
+ .delete(vendorTypes)
+ .where(eq(vendorTypes.id, id));
+
+ return deletedRecord;
+}
+
+/**
+ * 다수의 ID로 여러 협력업체 타입 삭제
+ */
+export async function deleteVendorTypesByIds(tx: any, ids: number[]) {
+ // 삭제 전 데이터 가져오기 (필요한 경우)
+ const deletedRecords = await tx.select()
+ .from(vendorTypes)
+ .where(inArray(vendorTypes.id, ids));
+
+ // 데이터 삭제
+ await tx
+ .delete(vendorTypes)
+ .where(inArray(vendorTypes.id, ids));
+
+ return deletedRecords;
+} \ No newline at end of file
diff --git a/lib/vendor-type/service.ts b/lib/vendor-type/service.ts
new file mode 100644
index 00000000..8624bb0e
--- /dev/null
+++ b/lib/vendor-type/service.ts
@@ -0,0 +1,239 @@
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { customAlphabet } from "nanoid";
+
+import { filterColumns } from "@/lib/filter-columns";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { getErrorMessage } from "@/lib/handle-error";
+
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm";
+import { CreateVendorTypeSchema, GetVendorTypesSchema, UpdateVendorTypeSchema } from "./validations";
+import {
+ countVendorTypes,
+ deleteVendorTypeById,
+ deleteVendorTypesByIds,
+ findAllVendorTypes,
+ insertVendorType,
+ selectVendorTypes,
+ updateVendorType
+} from "./repository";
+import { vendorTypes } from "@/db/schema";
+
+/* -----------------------------------------------------
+ 1) 조회 관련
+----------------------------------------------------- */
+
+/**
+ * 복잡한 조건으로 VendorType 목록을 조회 (+ pagination) 하고,
+ * 총 개수에 따라 pageCount를 계산해서 리턴.
+ * Next.js의 unstable_cache를 사용해 일정 시간 캐시.
+ */
+export async function getVendorTypes(input: GetVendorTypesSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: vendorTypes,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(vendorTypes.nameKo, s),
+ ilike(vendorTypes.nameEn, s),
+ ilike(vendorTypes.code, s)
+ );
+ }
+
+ const finalWhere = and(
+ advancedWhere,
+ globalWhere
+ );
+
+ // 아니면 ilike, inArray, gte 등으로 where 절 구성
+ const where = finalWhere;
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(vendorTypes[item.id]) : asc(vendorTypes[item.id])
+ )
+ : [asc(vendorTypes.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectVendorTypes(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countVendorTypes(tx, where);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ console.log(err, "err")
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: ["vendorTypes"], // revalidateTag("vendorTypes") 호출 시 무효화
+ }
+ )();
+}
+
+/* -----------------------------------------------------
+ 2) 생성(Create)
+----------------------------------------------------- */
+export interface VendorTypeCreateData {
+ code?: string;
+ nameKo: string;
+ nameEn: string;
+}
+
+/**
+ * VendorType 생성
+ */
+export async function createVendorType(input: VendorTypeCreateData) {
+ unstable_noStore(); // Next.js 서버 액션 캐싱 방지
+
+ try {
+ if (!input.nameKo || !input.nameEn) {
+ return {
+ success: false,
+ message: "한국어 이름과 영어 이름은 필수입니다",
+ data: null,
+ error: "필수 필드 누락"
+ };
+ }
+
+ // 코드가 없으면 자동 생성 (예: nameEn의 소문자화 + nanoid)
+ const code = input.code || `${input.nameEn.toLowerCase().replace(/\s+/g, '-')}-${customAlphabet('1234567890abcdef', 6)()}`;
+
+ // result 변수에 명시적으로 타입과 초기값 할당
+ let result: any[] = [];
+
+ // 트랜잭션 결과를 result에 할당
+ result = await db.transaction(async (tx) => {
+ // 기존 코드 확인 (code는 unique)
+ const existingVendorType = input.code ? await tx.query.vendorTypes.findFirst({
+ where: eq(vendorTypes.code, input.code),
+ }) : null;
+
+ let txResult;
+ if (existingVendorType) {
+ // 기존 vendorType 업데이트
+ txResult = await updateVendorType(tx, existingVendorType.id, {
+ nameKo: input.nameKo,
+ nameEn: input.nameEn,
+ });
+ } else {
+ // 새 vendorType 생성
+ txResult = await insertVendorType(tx, {
+ code,
+ nameKo: input.nameKo,
+ nameEn: input.nameEn,
+ });
+ }
+
+ return txResult;
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendorTypes");
+
+ return {
+ success: true,
+ data: result[0] || null,
+ error: null
+ };
+ } catch (err) {
+ console.error("협력업체 타입 생성/업데이트 오류:", err);
+
+ // 중복 키 오류 처리
+ if (err instanceof Error && err.message.includes("unique constraint")) {
+ return {
+ success: false,
+ message: "이미 존재하는 협력업체 타입 코드입니다",
+ data: null,
+ error: "중복 키 오류"
+ };
+ }
+
+ return {
+ success: false,
+ message: getErrorMessage(err),
+ data: null,
+ error: getErrorMessage(err)
+ };
+ }
+}
+
+/* -----------------------------------------------------
+ 3) 업데이트
+----------------------------------------------------- */
+
+/** 단건 업데이트 */
+export async function modifyVendorType(input: UpdateVendorTypeSchema & { id: number }) {
+ unstable_noStore();
+ try {
+ const data = await db.transaction(async (tx) => {
+ const [res] = await updateVendorType(tx, input.id, {
+ nameKo: input.nameKo,
+ nameEn: input.nameEn,
+ });
+ return res;
+ });
+
+ revalidateTag("vendorTypes");
+ return { data, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/** 단건 삭제 */
+export async function removeVendorType(input: { id: number }) {
+ unstable_noStore();
+ try {
+ await db.transaction(async (tx) => {
+ await deleteVendorTypeById(tx, input.id);
+ });
+
+ revalidateTag("vendorTypes");
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/** 복수 삭제 */
+export async function removeVendorTypes(input: { ids: number[] }) {
+ unstable_noStore();
+ try {
+ await db.transaction(async (tx) => {
+ await deleteVendorTypesByIds(tx, input.ids);
+ });
+
+ revalidateTag("vendorTypes");
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+} \ No newline at end of file
diff --git a/lib/vendor-type/table/add-vendorTypes-dialog.tsx b/lib/vendor-type/table/add-vendorTypes-dialog.tsx
new file mode 100644
index 00000000..74e1d10c
--- /dev/null
+++ b/lib/vendor-type/table/add-vendorTypes-dialog.tsx
@@ -0,0 +1,158 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { useToast } from "@/hooks/use-toast"
+// react-hook-form + shadcn/ui Form
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { createVendorType } from "../service"
+import { CreateVendorTypeSchema, createVendorTypeSchema } from "../validations"
+
+export function AddVendorTypeDialog() {
+ const [open, setOpen] = React.useState(false)
+ const { toast } = useToast()
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ // react-hook-form 세팅
+ const form = useForm<CreateVendorTypeSchema>({
+ resolver: zodResolver(createVendorTypeSchema),
+ defaultValues: {
+ nameKo: "",
+ nameEn: "",
+ },
+ mode: "onChange", // 입력값이 변경될 때마다 유효성 검사
+ })
+
+ // 폼 값 감시
+ const nameKo = form.watch("nameKo")
+ const nameEn = form.watch("nameEn")
+
+ // 두 필드가 모두 입력되었는지 확인
+ const isFormValid = nameKo.trim() !== "" && nameEn.trim() !== ""
+
+ async function onSubmit(data: CreateVendorTypeSchema) {
+ setIsSubmitting(true)
+ try {
+ const result = await createVendorType(data)
+ if (result.error) {
+ toast({
+ title: "오류 발생",
+ description: result.error,
+ variant: "destructive",
+ })
+ return
+ }
+
+ // 성공 시 모달 닫고 폼 리셋
+ toast({
+ title: "성공",
+ description: "협력업체 타입이 성공적으로 생성되었습니다.",
+ })
+ form.reset()
+ setOpen(false)
+ } catch (error) {
+ toast({
+ title: "오류 발생",
+ description: "협력업체 타입 생성 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ setOpen(nextOpen)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ {/* 모달을 열기 위한 버튼 */}
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ Add Vendor Type
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>새 협력업체 타입 생성</DialogTitle>
+ <DialogDescription>
+ 새 Vendor Type 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)}>
+ <div className="space-y-4 py-4">
+ <FormField
+ control={form.control}
+ name="nameKo"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>업체 유형(한글)<span className="text-red-500"> *</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 강재, 블록"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="nameEn"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>업체 유형(영문) <span className="text-red-500"> *</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="e.g. Steel, Block"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={isSubmitting || !isFormValid}
+ >
+ {isSubmitting ? "Creating..." : "Create"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-type/table/delete-vendorTypes-dialog.tsx b/lib/vendor-type/table/delete-vendorTypes-dialog.tsx
new file mode 100644
index 00000000..fa9376b6
--- /dev/null
+++ b/lib/vendor-type/table/delete-vendorTypes-dialog.tsx
@@ -0,0 +1,149 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { VendorTypes } from "@/db/schema"
+import { removeVendorTypes } from "../service"
+
+
+interface DeleteItemsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendorTypes: Row<VendorTypes>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteVendorTypesDialog({
+ vendorTypes,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteItemsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removeVendorTypes({
+ ids: vendorTypes.map((item) => item.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Tasks deleted")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({vendorTypes.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Are you absolutely sure?</DialogTitle>
+ <DialogDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{vendorTypes.length}</span>
+ {vendorTypes.length === 1 ? " task" : " tasks"} from our servers.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({vendorTypes.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Are you absolutely sure?</DrawerTitle>
+ <DrawerDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{vendorTypes.length}</span>
+ {vendorTypes.length === 1 ? " item" : " items"} from our servers.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Delete
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}
diff --git a/lib/rfqs/cbe-table/feature-flags-provider.tsx b/lib/vendor-type/table/feature-flags-provider.tsx
index 81131894..81131894 100644
--- a/lib/rfqs/cbe-table/feature-flags-provider.tsx
+++ b/lib/vendor-type/table/feature-flags-provider.tsx
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx b/lib/vendor-type/table/feature-flags.tsx
index 81131894..aaae6af2 100644
--- a/lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx
+++ b/lib/vendor-type/table/feature-flags.tsx
@@ -4,7 +4,6 @@ import * as React from "react"
import { useQueryState } from "nuqs"
import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
-import { cn } from "@/lib/utils"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import {
Tooltip,
@@ -14,33 +13,27 @@ import {
type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
-interface FeatureFlagsContextProps {
+interface TasksTableContextProps {
featureFlags: FeatureFlagValue[]
setFeatureFlags: (value: FeatureFlagValue[]) => void
}
-const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+const TasksTableContext = React.createContext<TasksTableContextProps>({
featureFlags: [],
setFeatureFlags: () => {},
})
-export function useFeatureFlags() {
- const context = React.useContext(FeatureFlagsContext)
+export function useTasksTable() {
+ const context = React.useContext(TasksTableContext)
if (!context) {
- throw new Error(
- "useFeatureFlags must be used within a FeatureFlagsProvider"
- )
+ throw new Error("useTasksTable must be used within a TasksTableProvider")
}
return context
}
-interface FeatureFlagsProviderProps {
- children: React.ReactNode
-}
-
-export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+export function TasksTableProvider({ children }: React.PropsWithChildren) {
const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
- "flags",
+ "featureFlags",
{
defaultValue: [],
parse: (value) => value.split(",") as FeatureFlagValue[],
@@ -48,12 +41,11 @@ export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
eq: (a, b) =>
a.length === b.length && a.every((value, index) => value === b[index]),
clearOnDefault: true,
- shallow: false,
}
)
return (
- <FeatureFlagsContext.Provider
+ <TasksTableContext.Provider
value={{
featureFlags,
setFeatureFlags: (value) => void setFeatureFlags(value),
@@ -66,24 +58,20 @@ export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
size="sm"
value={featureFlags}
onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
- className="w-fit gap-0"
+ className="w-fit"
>
- {dataTableConfig.featureFlags.map((flag, index) => (
+ {dataTableConfig.featureFlags.map((flag) => (
<Tooltip key={flag.value}>
<ToggleGroupItem
value={flag.value}
- className={cn(
- "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
- {
- "rounded-l-sm border-r-0": index === 0,
- "rounded-r-sm":
- index === dataTableConfig.featureFlags.length - 1,
- }
- )}
+ className="whitespace-nowrap px-3 text-xs"
asChild
>
<TooltipTrigger>
- <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ <flag.icon
+ className="mr-2 size-3.5 shrink-0"
+ aria-hidden="true"
+ />
{flag.label}
</TooltipTrigger>
</ToggleGroupItem>
@@ -103,6 +91,6 @@ export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
</ToggleGroup>
</div>
{children}
- </FeatureFlagsContext.Provider>
+ </TasksTableContext.Provider>
)
}
diff --git a/lib/vendor-type/table/import-excel-button.tsx b/lib/vendor-type/table/import-excel-button.tsx
new file mode 100644
index 00000000..bba9a117
--- /dev/null
+++ b/lib/vendor-type/table/import-excel-button.tsx
@@ -0,0 +1,265 @@
+"use client"
+
+import * as React from "react"
+import { Upload } from "lucide-react"
+import { toast } from "sonner"
+import * as ExcelJS from 'exceljs'
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Progress } from "@/components/ui/progress"
+import { processFileImport } from "./import-vendorTypes-handler" // 별도 파일로 분리
+
+interface ImportVendorTypeButtonProps {
+ onSuccess?: () => void
+}
+
+export function ImportVendorTypeButton({ onSuccess }: ImportVendorTypeButtonProps) {
+ const [open, setOpen] = React.useState(false)
+ const [file, setFile] = React.useState<File | null>(null)
+ const [isUploading, setIsUploading] = React.useState(false)
+ const [progress, setProgress] = React.useState(0)
+ const [error, setError] = React.useState<string | null>(null)
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일 선택 처리
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const selectedFile = e.target.files?.[0]
+ if (!selectedFile) return
+
+ if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) {
+ setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.")
+ return
+ }
+
+ setFile(selectedFile)
+ setError(null)
+ }
+
+ // 데이터 가져오기 처리
+ const handleImport = async () => {
+ if (!file) {
+ setError("가져올 파일을 선택해주세요.")
+ return
+ }
+
+ try {
+ setIsUploading(true)
+ setProgress(0)
+ setError(null)
+
+ // 파일을 ArrayBuffer로 읽기
+ const arrayBuffer = await file.arrayBuffer();
+
+ // ExcelJS 워크북 로드
+ const workbook = new ExcelJS.Workbook();
+ await workbook.xlsx.load(arrayBuffer);
+
+ // 첫 번째 워크시트 가져오기
+ const worksheet = workbook.worksheets[0];
+ if (!worksheet) {
+ throw new Error("Excel 파일에 워크시트가 없습니다.");
+ }
+
+ // 헤더 행 찾기
+ let headerRowIndex = 1;
+ let headerRow: ExcelJS.Row | undefined;
+ let headerValues: (string | null)[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ const values = row.values as (string | null)[];
+ if (!headerRow && values.some(v => v === "업체 유형(한글)" || v === "업체 유형 (한글)" ||v === "업체 유형(영어)" || v === "업체 유형 (영어)"||v === "업체 유형(영문)" || v === "업체 유형 (영문)")) {
+ headerRowIndex = rowNumber;
+ headerRow = row;
+ headerValues = [...values];
+ }
+ });
+
+ if (!headerRow) {
+ throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다.");
+ }
+
+ // 헤더를 기반으로 인덱스 매핑 생성
+ const headerMapping: Record<string, number> = {};
+ headerValues.forEach((value, index) => {
+ if (typeof value === 'string') {
+ headerMapping[value] = index;
+ }
+ });
+
+ // 필수 헤더 확인
+ const requiredHeaders = ["업체 유형(한글)", "업체 유형(영문)"];
+ const alternativeHeaders = {
+ "업체 유형(한글)": ["업체 유형 (한글)"],
+ "업체 유형(영문)": ["업체 유형(영어)", "업체 유형 (영어)", "업체 유형(영문)", "업체 유형 (영문)"],
+ };
+
+ // 헤더 매핑 확인 (대체 이름 포함)
+ const missingHeaders = requiredHeaders.filter(header => {
+ const alternatives = alternativeHeaders[header as keyof typeof alternativeHeaders] || [];
+ return !(header in headerMapping) &&
+ !alternatives.some(alt => alt in headerMapping);
+ });
+
+ if (missingHeaders.length > 0) {
+ throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`);
+ }
+
+ // 데이터 행 추출 (헤더 이후 행부터)
+ const dataRows: Record<string, any>[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > headerRowIndex) {
+ const rowData: Record<string, any> = {};
+ const values = row.values as (string | null | undefined)[];
+
+ // 헤더 매핑에 따라 데이터 추출
+ Object.entries(headerMapping).forEach(([header, index]) => {
+ rowData[header] = values[index] || "";
+ });
+
+ // 빈 행이 아닌 경우만 추가
+ if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) {
+ dataRows.push(rowData);
+ }
+ }
+ });
+
+ if (dataRows.length === 0) {
+ throw new Error("Excel 파일에 가져올 데이터가 없습니다.");
+ }
+
+ // 진행 상황 업데이트를 위한 콜백
+ const updateProgress = (current: number, total: number) => {
+ const percentage = Math.round((current / total) * 100);
+ setProgress(percentage);
+ };
+
+ // 실제 데이터 처리는 별도 함수에서 수행
+ const result = await processFileImport(
+ dataRows,
+ updateProgress
+ );
+
+ // 처리 완료
+ toast.success(`${result.successCount}개의 아이템이 성공적으로 가져와졌습니다.`);
+
+ if (result.errorCount > 0) {
+ toast.warning(`${result.errorCount}개의 항목은 처리할 수 없었습니다.`);
+ }
+
+ // 상태 초기화 및 다이얼로그 닫기
+ setFile(null);
+ setOpen(false);
+
+ // 성공 콜백 호출
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("Excel 파일 처리 중 오류 발생:", error);
+ setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다.");
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ // 다이얼로그 열기/닫기 핸들러
+ const handleOpenChange = (newOpen: boolean) => {
+ if (!newOpen) {
+ // 닫을 때 상태 초기화
+ setFile(null)
+ setError(null)
+ setProgress(0)
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ""
+ }
+ }
+ setOpen(newOpen)
+ }
+
+ return (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ onClick={() => setOpen(true)}
+ disabled={isUploading}
+ >
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>업체유형 가져오기</DialogTitle>
+ <DialogDescription>
+ 업체유형을 Excel 파일에서 가져옵니다.
+ <br />
+ 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 py-4">
+ <div className="flex items-center gap-4">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium"
+ accept=".xlsx,.xls"
+ onChange={handleFileChange}
+ disabled={isUploading}
+ />
+ </div>
+
+ {file && (
+ <div className="text-sm text-muted-foreground">
+ 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB)
+ </div>
+ )}
+
+ {isUploading && (
+ <div className="space-y-2">
+ <Progress value={progress} />
+ <p className="text-sm text-muted-foreground text-center">
+ {progress}% 완료
+ </p>
+ </div>
+ )}
+
+ {error && (
+ <div className="text-sm font-medium text-destructive">
+ {error}
+ </div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isUploading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleImport}
+ disabled={!file || isUploading}
+ >
+ {isUploading ? "처리 중..." : "가져오기"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-type/table/import-vendorTypes-handler.tsx b/lib/vendor-type/table/import-vendorTypes-handler.tsx
new file mode 100644
index 00000000..85e03e5e
--- /dev/null
+++ b/lib/vendor-type/table/import-vendorTypes-handler.tsx
@@ -0,0 +1,114 @@
+"use client"
+
+import { z } from "zod"
+import { createVendorType } from "../service";
+
+// 아이템 데이터 검증을 위한 Zod 스키마
+const itemSchema = z.object({
+ nameKo: z.string().min(1, "업체 유형(한글)은 필수입니다"),
+ nameEn: z.string().min(1, "업체 유형(영문)은 필수입니다"),
+});
+
+interface ProcessResult {
+ successCount: number;
+ errorCount: number;
+ errors?: Array<{ row: number; message: string }>;
+}
+
+/**
+ * Excel 파일에서 가져온 아이템 데이터 처리하는 함수
+ */
+export async function processFileImport(
+ jsonData: any[],
+ progressCallback?: (current: number, total: number) => void
+): Promise<ProcessResult> {
+ // 결과 카운터 초기화
+ let successCount = 0;
+ let errorCount = 0;
+ const errors: Array<{ row: number; message: string }> = [];
+
+ // 빈 행 등 필터링
+ const dataRows = jsonData.filter(row => {
+ // 빈 행 건너뛰기
+ if (Object.values(row).every(val => !val)) {
+ return false;
+ }
+ return true;
+ });
+
+ // 데이터 행이 없으면 빈 결과 반환
+ if (dataRows.length === 0) {
+ return { successCount: 0, errorCount: 0 };
+ }
+
+ // 각 행에 대해 처리
+ for (let i = 0; i < dataRows.length; i++) {
+ const row = dataRows[i];
+ const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작
+
+ // 진행 상황 콜백 호출
+ if (progressCallback) {
+ progressCallback(i + 1, dataRows.length);
+ }
+
+ try {
+ // 필드 매핑 (한글/영문 필드명 모두 지원)
+ const nameKo = row["업체 유형(한글)"] || row["업체 유형 (한글)"] || "";
+ const nameEn = row["업체 유형(영문)"] || row["업체 유형 (영문)"] || row["업체 유형(영어)"] || row["업체 유형 (영어)"] || "";
+
+ // 데이터 정제
+ const cleanedRow = {
+ nameKo: typeof nameKo === 'string' ? nameKo.trim() : String(nameKo).trim(),
+ nameEn: typeof nameEn === 'string' ? nameEn.trim() : String(nameEn).trim(),
+ };
+
+ // 데이터 유효성 검사
+ const validationResult = itemSchema.safeParse(cleanedRow);
+
+ if (!validationResult.success) {
+ const errorMessage = validationResult.error.errors.map(
+ err => `${err.path.join('.')}: ${err.message}`
+ ).join(', ');
+
+ errors.push({ row: rowIndex, message: errorMessage });
+ errorCount++;
+ continue;
+ }
+
+ // 아이템 생성 서버 액션 호출
+ const result = await createVendorType({
+ nameKo: cleanedRow.nameKo,
+ nameEn: cleanedRow.nameEn,
+ });
+
+ if (result.success || !result.error) {
+ successCount++;
+ } else {
+ errors.push({
+ row: rowIndex,
+ message: result.message || result.error || "알 수 없는 오류"
+ });
+ errorCount++;
+ }
+ } catch (error) {
+ console.error(`${rowIndex}행 처리 오류:`, error);
+ errors.push({
+ row: rowIndex,
+ message: error instanceof Error ? error.message : "알 수 없는 오류"
+ });
+ errorCount++;
+ }
+
+ // 비동기 작업 쓰로틀링
+ if (i % 5 === 0) {
+ await new Promise(resolve => setTimeout(resolve, 10));
+ }
+ }
+
+ // 처리 결과 반환
+ return {
+ successCount,
+ errorCount,
+ errors: errors.length > 0 ? errors : undefined
+ };
+} \ No newline at end of file
diff --git a/lib/vendor-type/table/update-vendorTypes-sheet.tsx b/lib/vendor-type/table/update-vendorTypes-sheet.tsx
new file mode 100644
index 00000000..d096a706
--- /dev/null
+++ b/lib/vendor-type/table/update-vendorTypes-sheet.tsx
@@ -0,0 +1,151 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+
+import { Input } from "@/components/ui/input"
+import { UpdateVendorTypeSchema, updateVendorTypeSchema } from "../validations"
+import { modifyVendorType } from "../service"
+import { VendorTypes } from "@/db/schema"
+
+interface UpdateTypeSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ vendorType: VendorTypes | null
+}
+
+export function UpdateTypeSheet({ vendorType, ...props }: UpdateTypeSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ const form = useForm<UpdateVendorTypeSchema>({
+ resolver: zodResolver(updateVendorTypeSchema),
+ defaultValues: {
+ nameKo: vendorType?.nameKo ?? "",
+ nameEn: vendorType?.nameEn ?? "",
+
+ },
+ })
+
+
+ React.useEffect(() => {
+ if (vendorType) {
+ form.reset({
+ nameKo: vendorType.nameKo ?? "",
+ nameEn: vendorType.nameEn ?? "",
+ });
+ }
+ }, [vendorType, form]);
+
+ function onSubmit(input: UpdateVendorTypeSchema) {
+ startUpdateTransition(async () => {
+ if (!vendorType) return
+
+ const { error } = await modifyVendorType({
+ id: vendorType.id,
+ ...input,
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("Item updated")
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Update vendorType</SheetTitle>
+ <SheetDescription>
+ Update the vendorType details and save the changes
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+
+ <FormField
+ control={form.control}
+ name="nameKo"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>업체 유형 (한글)</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="e.g."
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="nameEn"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>업체 유형 (영문)</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="e.g."
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+}
diff --git a/lib/vendor-type/table/vendorTypes-excel-template.tsx b/lib/vendor-type/table/vendorTypes-excel-template.tsx
new file mode 100644
index 00000000..a48e807e
--- /dev/null
+++ b/lib/vendor-type/table/vendorTypes-excel-template.tsx
@@ -0,0 +1,78 @@
+import * as ExcelJS from 'exceljs';
+import { saveAs } from "file-saver";
+
+/**
+ * 업체 유형 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드
+ */
+export async function exportVendorTypeTemplate() {
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+ workbook.creator = 'Item Management System';
+ workbook.created = new Date();
+
+ // 워크시트 생성
+ const worksheet = workbook.addWorksheet('업체 유형');
+
+ // 컬럼 헤더 정의 및 스타일 적용
+ worksheet.columns = [
+ { header: '업체 유형(한글)', key: 'nameKo', width: 50 },
+ { header: '업체 유형(영문)', key: 'nameEn', width: 50 },
+ ];
+
+ // 헤더 스타일 적용
+ const headerRow = worksheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ };
+ headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
+
+ // 테두리 스타일 적용
+ headerRow.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+
+ // 샘플 데이터 추가
+ const sampleData = [
+ { nameKo: 'ITEM001', nameEn: '샘플 업체 유형 1', },
+ { nameKo: 'ITEM002', nameEn: '샘플 업체 유형 2', }
+ ];
+
+ // 데이터 행 추가
+ sampleData.forEach(item => {
+ worksheet.addRow(item);
+ });
+
+ // 데이터 행 스타일 적용
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > 1) { // 헤더를 제외한 데이터 행
+ row.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+ }
+ });
+
+
+ try {
+ // 워크북을 Blob으로 변환
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
+ saveAs(blob, 'vendor-type-template.xlsx');
+ return true;
+ } catch (error) {
+ console.error('Excel 템플릿 생성 오류:', error);
+ throw error;
+ }
+} \ No newline at end of file
diff --git a/lib/vendor-type/table/vendorTypes-table-columns.tsx b/lib/vendor-type/table/vendorTypes-table-columns.tsx
new file mode 100644
index 00000000..b5cfca71
--- /dev/null
+++ b/lib/vendor-type/table/vendorTypes-table-columns.tsx
@@ -0,0 +1,179 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+import { toast } from "sonner"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { VendorTypes } from "@/db/schema"
+import { VendorTypesColumnsConfig } from "@/config/VendorTypesColumnsConfig"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorTypes> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorTypes>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<VendorTypes> = {
+ 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"
+ />
+ ),
+ size:40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<VendorTypes> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<VendorTypes>[] }
+ const groupMap: Record<string, ColumnDef<VendorTypes>[]> = {}
+
+ VendorTypesColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<VendorTypes> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+
+ if (cfg.id === "createdAt"||cfg.id === "updatedAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<VendorTypes>[] = []
+
+ // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
+ // 여기서는 그냥 Object.entries 순서
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없음 → 그냥 최상위 레벨 컬럼
+ nestedColumns.push(...colDefs)
+ } else {
+ // 상위 컬럼
+ nestedColumns.push({
+ id: groupName,
+ header: groupName, // "Basic Info", "Metadata" 등
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/vendor-type/table/vendorTypes-table-toolbar-actions.tsx b/lib/vendor-type/table/vendorTypes-table-toolbar-actions.tsx
new file mode 100644
index 00000000..de56c42f
--- /dev/null
+++ b/lib/vendor-type/table/vendorTypes-table-toolbar-actions.tsx
@@ -0,0 +1,162 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, FileDown } from "lucide-react"
+import * as ExcelJS from 'exceljs'
+import { saveAs } from "file-saver"
+
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { AddVendorTypeDialog } from "./add-vendorTypes-dialog"
+import { exportVendorTypeTemplate } from "./vendorTypes-excel-template"
+import { VendorTypes } from "@/db/schema"
+import { DeleteVendorTypesDialog } from "./delete-vendorTypes-dialog"
+import { ImportVendorTypeButton } from "./import-excel-button"
+
+interface ItemsTableToolbarActionsProps {
+ table: Table<VendorTypes>
+}
+
+export function ItemsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) {
+ const [refreshKey, setRefreshKey] = React.useState(0)
+
+ // 가져오기 성공 후 테이블 갱신
+ const handleImportSuccess = () => {
+ setRefreshKey(prev => prev + 1)
+ }
+
+ // Excel 내보내기 함수
+ const exportTableToExcel = async (
+ table: Table<any>,
+ options: {
+ filename: string;
+ excludeColumns?: string[];
+ sheetName?: string;
+ }
+ ) => {
+ const { filename, excludeColumns = [], sheetName = "업체 유형 목록" } = options;
+
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+ workbook.creator = 'vendorType Management System';
+ workbook.created = new Date();
+
+ // 워크시트 생성
+ const worksheet = workbook.addWorksheet(sheetName);
+
+ // 테이블 데이터 가져오기
+ const data = table.getFilteredRowModel().rows.map(row => row.original);
+
+ // 테이블 헤더 가져오기
+ const headers = table.getAllColumns()
+ .filter(column => !excludeColumns.includes(column.id))
+ .map(column => ({
+ key: column.id,
+ header: column.columnDef.header?.toString() || column.id
+ }));
+
+ // 컬럼 정의
+ worksheet.columns = headers.map(header => ({
+ header: header.header,
+ key: header.key,
+ width: 20 // 기본 너비
+ }));
+
+ // 스타일 적용
+ const headerRow = worksheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ };
+ headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
+
+ // 데이터 행 추가
+ data.forEach(item => {
+ const row: Record<string, any> = {};
+ headers.forEach(header => {
+ row[header.key] = item[header.key];
+ });
+ worksheet.addRow(row);
+ });
+
+ // 전체 셀에 테두리 추가
+ worksheet.eachRow((row, rowNumber) => {
+ row.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+ });
+
+ try {
+ // 워크북을 Blob으로 변환
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
+ saveAs(blob, `${filename}.xlsx`);
+ return true;
+ } catch (error) {
+ console.error("Excel 내보내기 오류:", error);
+ return false;
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteVendorTypesDialog
+ vendorTypes={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+ {/* 새 업체 유형 추가 다이얼로그 */}
+ <AddVendorTypeDialog />
+
+ {/* Import 버튼 */}
+ <ImportVendorTypeButton onSuccess={handleImportSuccess} />
+
+ {/* Export 드롭다운 메뉴 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "items",
+ excludeColumns: ["select", "actions"],
+ sheetName: "업체 유형 목록"
+ })
+ }
+ >
+ <FileDown className="mr-2 h-4 w-4" />
+ <span>현재 데이터 내보내기</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => exportVendorTypeTemplate()}>
+ <FileDown className="mr-2 h-4 w-4" />
+ <span>템플릿 다운로드</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-type/table/vendorTypes-table.tsx b/lib/vendor-type/table/vendorTypes-table.tsx
new file mode 100644
index 00000000..67c9d632
--- /dev/null
+++ b/lib/vendor-type/table/vendorTypes-table.tsx
@@ -0,0 +1,129 @@
+"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 { useFeatureFlags } from "./feature-flags-provider"
+
+import { getColumns } from "./vendorTypes-table-columns"
+import { ItemsTableToolbarActions } from "./vendorTypes-table-toolbar-actions"
+import { UpdateTypeSheet } from "./update-vendorTypes-sheet"
+import { getVendorTypes } from "../service"
+import { VendorTypes } from "@/db/schema"
+import { DeleteVendorTypesDialog } from "./delete-vendorTypes-dialog"
+
+interface ItemsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getVendorTypes>>,
+ ]
+ >
+}
+
+export function VendorTypesTable({ promises }: ItemsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+ console.log(data)
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<VendorTypes> | 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<VendorTypes>[] = [
+ ]
+
+ /**
+ * 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<VendorTypes>[] = [
+ {
+ id: "nameKo",
+ label: "업체 유형(한글)",
+ type: "text",
+ },
+ {
+ id: "nameEn",
+ label: "업체 유형(En)",
+ type: "text",
+ },
+ ]
+
+
+ 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}
+ >
+ <ItemsTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+
+ </DataTable>
+ <UpdateTypeSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ vendorType={rowAction?.row.original ?? null}
+ />
+ <DeleteVendorTypesDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ vendorTypes={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+ </>
+ )
+}
diff --git a/lib/vendor-type/validations.ts b/lib/vendor-type/validations.ts
new file mode 100644
index 00000000..146c404e
--- /dev/null
+++ b/lib/vendor-type/validations.ts
@@ -0,0 +1,46 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { VendorTypes } from "@/db/schema"
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<VendorTypes>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ nameKo: parseAsString.withDefault(""),
+ nameEn: parseAsString.withDefault(""),
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+export const createVendorTypeSchema = z.object({
+ nameKo: z.string(),
+ nameEn: z.string(),
+
+})
+
+export const updateVendorTypeSchema = z.object({
+ nameKo: z.string().optional(),
+ nameEn: z.string().optional(),
+})
+
+export type GetVendorTypesSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+export type CreateVendorTypeSchema = z.infer<typeof createVendorTypeSchema>
+export type UpdateVendorTypeSchema = z.infer<typeof updateVendorTypeSchema>
diff --git a/lib/vendors/repository.ts b/lib/vendors/repository.ts
index ff195932..1f59aac0 100644
--- a/lib/vendors/repository.ts
+++ b/lib/vendors/repository.ts
@@ -2,7 +2,7 @@
import { and, eq, inArray, count, gt, AnyColumn, SQLWrapper, SQL} from "drizzle-orm";
import { PgTransaction } from "drizzle-orm/pg-core";
-import { VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors";
+import { VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, vendorsWithTypesView, type Vendor } from "@/db/schema/vendors";
import db from '@/db/db';
import { items } from "@/db/schema/items";
import { rfqs,rfqItems, rfqEvaluations, vendorResponses } from "@/db/schema/rfq";
@@ -47,8 +47,33 @@ export async function countVendors(
}
+ export async function selectVendorsWithTypes (
+ tx: PgTransaction<any, any, any>,
+ { where, orderBy, offset, limit }: SelectVendorsOptions
+) {
+ return tx
+ .select()
+ .from(vendorsWithTypesView)
+ .where(where ?? undefined)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset ?? 0)
+ .limit(limit ?? 20);
+}
+
/**
- * 3) INSERT (단일 벤더 생성)
+ * 2) COUNT
+ */
+export async function countVendorsWithTypes(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+ ) {
+ const res = await tx.select({ count: count() }).from(vendorsWithTypesView).where(where);
+ return res[0]?.count ?? 0;
+ }
+
+
+/**
+ * 3) INSERT (단일 협력업체 생성)
* - id/createdAt/updatedAt은 DB default 사용
* - 반환값은 "생성된 레코드" 배열 ([newVendor])
*/
@@ -60,7 +85,7 @@ export async function insertVendor(
}
/**
- * 4) UPDATE (단일 벤더)
+ * 4) UPDATE (단일 협력업체)
*/
export async function updateVendor(
tx: PgTransaction<any, any, any>,
@@ -75,7 +100,7 @@ export async function updateVendor(
}
/**
- * 5) UPDATE (복수 벤더)
+ * 5) UPDATE (복수 협력업체)
* - 여러 개의 id를 받아 일괄 업데이트
*/
export async function updateVendors(
@@ -280,3 +305,4 @@ export async function countRfqHistory(
return count;
}
+
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts
index 8f095c0e..87a8336d 100644
--- a/lib/vendors/service.ts
+++ b/lib/vendors/service.ts
@@ -2,12 +2,13 @@
import { revalidateTag, unstable_noStore } from "next/cache";
import db from "@/db/db";
-import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorInvestigations, vendorInvestigationsView, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors";
+import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorInvestigations, vendorInvestigationsView, vendorItemsView, vendorPossibleItems, vendors, vendorsWithTypesView, vendorTypes, type Vendor } from "@/db/schema/vendors";
import logger from '@/lib/logger';
import { filterColumns } from "@/lib/filter-columns";
import { unstable_cache } from "@/lib/unstable-cache";
import { getErrorMessage } from "@/lib/handle-error";
+import { headers } from 'next/headers';
import {
selectVendors,
@@ -24,7 +25,10 @@ import {
countVendorItems,
insertVendorItem,
countRfqHistory,
- selectRfqHistory
+ selectRfqHistory,
+ selectVendorsWithTypes,
+ countVendorsWithTypes,
+
} from "./repository";
import type {
@@ -51,7 +55,8 @@ import { items } from "@/db/schema/items";
import { users } from "@/db/schema/users";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
-import { projects, vendorProjectPQs } from "@/db/schema";
+import { contracts, contractsDetailView, projects, vendorProjectPQs, vendorsLogs } from "@/db/schema";
+import { Hospital } from "lucide-react";
/* -----------------------------------------------------
@@ -68,61 +73,63 @@ export async function getVendors(input: GetVendorsSchema) {
async () => {
try {
const offset = (input.page - 1) * input.perPage;
-
- // 1) 고급 필터
+
+ // 1) 고급 필터 - vendors 대신 vendorsWithTypesView 사용
const advancedWhere = filterColumns({
- table: vendors,
+ table: vendorsWithTypesView,
filters: input.filters,
joinOperator: input.joinOperator,
});
-
+
// 2) 글로벌 검색
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.status, s)
+ ilike(vendorsWithTypesView.vendorName, s),
+ ilike(vendorsWithTypesView.vendorCode, s),
+ ilike(vendorsWithTypesView.email, s),
+ ilike(vendorsWithTypesView.status, s),
+ // 추가: 업체 유형 검색
+ ilike(vendorsWithTypesView.vendorTypeName, s)
);
}
-
+
// 최종 where 결합
const finalWhere = and(advancedWhere, globalWhere);
-
+
// 간단 검색 (advancedTable=false) 시 예시
const simpleWhere = and(
input.vendorName
- ? ilike(vendors.vendorName, `%${input.vendorName}%`)
+ ? ilike(vendorsWithTypesView.vendorName, `%${input.vendorName}%`)
: undefined,
- input.status ? ilike(vendors.status, input.status) : undefined,
+ input.status ? ilike(vendorsWithTypesView.status, input.status) : undefined,
input.country
- ? ilike(vendors.country, `%${input.country}%`)
+ ? ilike(vendorsWithTypesView.country, `%${input.country}%`)
: undefined
);
-
+
// 실제 사용될 where
const where = finalWhere;
-
+
// 정렬
const orderBy =
input.sort.length > 0
? input.sort.map((item) =>
- item.desc ? desc(vendors[item.id]) : asc(vendors[item.id])
+ item.desc ? desc(vendorsWithTypesView[item.id]) : asc(vendorsWithTypesView[item.id])
)
- : [asc(vendors.createdAt)];
-
+ : [asc(vendorsWithTypesView.createdAt)];
+
// 트랜잭션 내에서 데이터 조회
const { data, total } = await db.transaction(async (tx) => {
- // 1) vendor 목록 조회
- const vendorsData = await selectVendors(tx, {
+ // 1) vendor 목록 조회 (view 사용)
+ const vendorsData = await selectVendorsWithTypes(tx, {
where,
orderBy,
offset,
limit: input.perPage,
});
-
+
// 2) 각 vendor의 attachments 조회
const vendorsWithAttachments = await Promise.all(
vendorsData.map(async (vendor) => {
@@ -134,7 +141,7 @@ export async function getVendors(input: GetVendorsSchema) {
})
.from(vendorAttachments)
.where(eq(vendorAttachments.vendorId, vendor.id));
-
+
return {
...vendor,
hasAttachments: attachments.length > 0,
@@ -142,17 +149,18 @@ export async function getVendors(input: GetVendorsSchema) {
};
})
);
-
+
// 3) 전체 개수
- const total = await countVendors(tx, where);
+ const total = await countVendorsWithTypes(tx, where);
return { data: vendorsWithAttachments, total };
});
-
+
// 페이지 수
const pageCount = Math.ceil(total / input.perPage);
-
+
return { data, pageCount };
} catch (err) {
+ console.error("Error fetching vendors:", err);
// 에러 발생 시
return { data: [], pageCount: 0 };
}
@@ -165,7 +173,6 @@ export async function getVendors(input: GetVendorsSchema) {
)();
}
-
export async function getVendorStatusCounts() {
return unstable_cache(
async () => {
@@ -252,34 +259,58 @@ async function storeVendorFiles(
}
}
+
+export async function getVendorTypes() {
+ unstable_noStore(); // Next.js server action caching prevention
+
+ try {
+ const types = await db
+ .select({
+ id: vendorTypes.id,
+ code: vendorTypes.code,
+ nameKo: vendorTypes.nameKo,
+ nameEn: vendorTypes.nameEn,
+ })
+ .from(vendorTypes)
+ .orderBy(vendorTypes.nameKo);
+
+ return { data: types, error: null };
+ } catch (error) {
+ return { data: null, error: getErrorMessage(error) };
+ }
+}
+
export type CreateVendorData = {
vendorName: string
+ vendorTypeId: number
vendorCode?: string
+ items?: string
website?: string
taxId: string
address?: string
email: string
phone?: string
-
+
representativeName?: string
representativeBirth?: string
representativeEmail?: string
representativePhone?: string
-
+
creditAgency?: string
creditRating?: string
cashFlowRating?: string
corporateRegistrationNumber?: string
-
+
country?: string
status?: "PENDING_REVIEW" | "IN_REVIEW" | "IN_PQ" | "PQ_FAILED" | "APPROVED" | "ACTIVE" | "INACTIVE" | "BLACKLISTED" | "PQ_SUBMITTED"
}
+// Updated createVendor function with taxId duplicate check
export async function createVendor(params: {
vendorData: CreateVendorData
// 기존의 일반 첨부파일
files?: File[]
-
+
// 신용평가 / 현금흐름 등급 첨부
creditRatingFiles?: File[]
cashFlowRatingFiles?: File[]
@@ -292,17 +323,17 @@ export async function createVendor(params: {
}[]
}) {
unstable_noStore() // Next.js 서버 액션 캐싱 방지
-
+
try {
const { vendorData, files = [], creditRatingFiles = [], cashFlowRatingFiles = [], contacts } = params
-
+
// 이메일 중복 검사 - 이미 users 테이블에 존재하는지 확인
const existingUser = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, vendorData.email))
.limit(1);
-
+
// 이미 사용자가 존재하면 에러 반환
if (existingUser.length > 0) {
return {
@@ -310,7 +341,22 @@ export async function createVendor(params: {
error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)`
};
}
-
+
+ // taxId 중복 검사 추가
+ const existingVendor = await db
+ .select({ id: vendors.id })
+ .from(vendors)
+ .where(eq(vendors.taxId, vendorData.taxId))
+ .limit(1);
+
+ // 이미 동일한 taxId를 가진 업체가 존재하면 에러 반환
+ if (existingVendor.length > 0) {
+ return {
+ data: null,
+ error: `이미 등록된 사업자등록번호입니다. (Tax ID ${vendorData.taxId} already exists in the system)`
+ };
+ }
+
await db.transaction(async (tx) => {
// 1) Insert the vendor (확장 필드도 함께)
const [newVendor] = await insertVendor(tx, {
@@ -323,36 +369,38 @@ export async function createVendor(params: {
website: vendorData.website || null,
status: vendorData.status ?? "PENDING_REVIEW",
taxId: vendorData.taxId,
-
+ vendorTypeId: vendorData.vendorTypeId,
+ items: vendorData.items || null,
+
// 대표자 정보
representativeName: vendorData.representativeName || null,
representativeBirth: vendorData.representativeBirth || null,
representativeEmail: vendorData.representativeEmail || null,
representativePhone: vendorData.representativePhone || null,
corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null,
-
+
// 신용/현금흐름
creditAgency: vendorData.creditAgency || null,
creditRating: vendorData.creditRating || null,
cashFlowRating: vendorData.cashFlowRating || null,
})
-
+
// 2) If there are attached files, store them
// (2-1) 일반 첨부
if (files.length > 0) {
await storeVendorFiles(tx, newVendor.id, files, "GENERAL")
}
-
+
// (2-2) 신용평가 파일
if (creditRatingFiles.length > 0) {
await storeVendorFiles(tx, newVendor.id, creditRatingFiles, "CREDIT_RATING")
}
-
+
// (2-3) 현금흐름 파일
if (cashFlowRatingFiles.length > 0) {
await storeVendorFiles(tx, newVendor.id, cashFlowRatingFiles, "CASH_FLOW_RATING")
}
-
+
for (const contact of contacts) {
await tx.insert(vendorContacts).values({
vendorId: newVendor.id,
@@ -364,7 +412,7 @@ export async function createVendor(params: {
})
}
})
-
+
revalidateTag("vendors")
return { data: null, error: null }
} catch (error) {
@@ -377,12 +425,26 @@ export async function createVendor(params: {
/** 단건 업데이트 */
export async function modifyVendor(
- input: UpdateVendorSchema & { id: string }
+ input: UpdateVendorSchema & { id: string; userId: number; comment:string; } // userId 추가
) {
unstable_noStore();
try {
const updated = await db.transaction(async (tx) => {
- // 특정 ID 벤더를 업데이트
+ // 1. 업데이트 전에 기존 벤더 정보를 가져옴
+ const existingVendor = await tx.query.vendors.findFirst({
+ where: eq(vendors.id, parseInt(input.id)),
+ columns: {
+ status: true, // 상태 변경 로깅에 필요한 현재 상태만 가져옴
+ },
+ });
+
+ if (!existingVendor) {
+ throw new Error(`Vendor with ID ${input.id} not found`);
+ }
+
+ const oldStatus = existingVendor.status;
+
+ // 2. 벤더 정보 업데이트
const [res] = await updateVendor(tx, input.id, {
vendorName: input.vendorName,
vendorCode: input.vendorCode,
@@ -391,8 +453,32 @@ export async function modifyVendor(
phone: input.phone,
email: input.email,
website: input.website,
+ creditAgency: input.creditAgency,
+ creditRating: input.creditRating,
+ cashFlowRating: input.cashFlowRating,
status: input.status,
});
+
+ // 3. 상태가 변경되었다면 로그 기록
+ if (oldStatus !== input.status) {
+ await tx.insert(vendorsLogs).values({
+ vendorId: parseInt(input.id),
+ userId: input.userId,
+ action: "status_change",
+ oldStatus,
+ newStatus: input.status,
+ comment: input.comment || `Status changed from ${oldStatus} to ${input.status}`,
+ });
+ } else if (input.comment) {
+ // 상태 변경이 없더라도 코멘트가 있으면 로그 기록
+ await tx.insert(vendorsLogs).values({
+ vendorId: parseInt(input.id),
+ userId: input.userId,
+ action: "vendor_updated",
+ comment: input.comment,
+ });
+ }
+
return res;
});
@@ -414,7 +500,7 @@ export async function modifyVendors(input: {
unstable_noStore();
try {
const data = await db.transaction(async (tx) => {
- // 여러 벤더 일괄 업데이트
+ // 여러 협력업체 일괄 업데이트
const [updated] = await updateVendors(tx, input.ids, {
// 예: 상태만 일괄 변경
status: input.status,
@@ -560,7 +646,7 @@ export async function createVendorContact(input: CreateVendorContactSchema) {
return newContact;
});
- // 캐시 무효화 (벤더 연락처 목록 등)
+ // 캐시 무효화 (협력업체 연락처 목록 등)
revalidateTag(`vendor-contacts-${input.vendorId}`);
return { data: null, error: null };
@@ -723,7 +809,7 @@ export async function createVendorItem(input: CreateVendorItemSchema) {
return newContact;
});
- // 캐시 무효화 (벤더 연락처 목록 등)
+ // 캐시 무효화 (협력업체 연락처 목록 등)
revalidateTag(`vendor-items-${input.vendorId}`);
return { data: null, error: null };
@@ -885,98 +971,55 @@ interface CreateCompanyInput {
/**
- * 벤더 첨부파일 다운로드를 위한 서버 액션
- * @param vendorId 벤더 ID
+ * 협력업체 첨부파일 다운로드를 위한 서버 액션
+ * @param vendorId 협력업체 ID
* @param fileId 특정 파일 ID (단일 파일 다운로드시)
* @returns 다운로드할 수 있는 임시 URL
*/
-export async function downloadVendorAttachments(vendorId: number, fileId?: number) {
+export async function downloadVendorAttachments(vendorId:number, fileId?:number) {
try {
- // 벤더 정보 조회
- const vendor = await db.query.vendors.findFirst({
- where: eq(vendors.id, vendorId)
+ // API 경로 생성 (단일 파일 또는 모든 파일)
+ const url = fileId
+ ? `/api/vendors/attachments/download?id=${fileId}&vendorId=${vendorId}`
+ : `/api/vendors/attachments/download-all?vendorId=${vendorId}`;
+
+ // fetch 요청 (기본적으로 Blob으로 응답 받기)
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
});
-
- if (!vendor) {
- throw new Error(`벤더 정보를 찾을 수 없습니다. (ID: ${vendorId})`);
- }
-
- // 첨부파일 조회 (특정 파일 또는 모든 파일)
- const attachments = fileId
- ? await db.select()
- .from(vendorAttachments)
- .where(eq(vendorAttachments.id, fileId))
- : await db.select()
- .from(vendorAttachments)
- .where(eq(vendorAttachments.vendorId, vendorId));
-
- if (!attachments.length) {
- throw new Error('다운로드할 첨부파일이 없습니다.');
+
+ if (!response.ok) {
+ throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
}
-
- // 업로드 기본 경로
- const basePath = process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads');
-
- // 단일 파일인 경우 직접 URL 반환
- if (attachments.length === 1) {
- const attachment = attachments[0];
- const filePath = `/api/vendors/attachments/download?id=${attachment.id}`;
- return { url: filePath, fileName: attachment.fileName };
+
+ // 파일명 가져오기 (Content-Disposition 헤더에서)
+ const contentDisposition = response.headers.get('content-disposition');
+ let fileName = fileId ? `file-${fileId}.zip` : `vendor-${vendorId}-files.zip`;
+
+ if (contentDisposition) {
+ const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition);
+ if (matches && matches[1]) {
+ fileName = matches[1].replace(/['"]/g, '');
+ }
}
-
- // 다중 파일: 임시 ZIP 생성 후 URL 반환
- // 임시 디렉토리 생성
- const tempDir = path.join(process.cwd(), 'tmp');
- await fsPromises.mkdir(tempDir, { recursive: true });
-
- // 고유 ID로 임시 ZIP 파일명 생성
- const tempId = randomUUID();
- const zipFileName = `${vendor.vendorName || `vendor-${vendorId}`}-attachments-${tempId}.zip`;
- const zipFilePath = path.join(tempDir, zipFileName);
-
- // JSZip을 사용하여 ZIP 파일 생성
- const zip = new JSZip();
-
- // 파일 읽기 및 추가 작업을 병렬로 처리
- await Promise.all(
- attachments.map(async (attachment) => {
- const filePath = path.join(basePath, attachment.filePath);
-
- try {
- // 파일 존재 확인 (fsPromises.access 사용)
- try {
- await fsPromises.access(filePath, fs.constants.F_OK);
- } catch (e) {
- console.warn(`파일이 존재하지 않습니다: ${filePath}`);
- return; // 파일이 없으면 건너뜀
- }
-
- // 파일 읽기 (fsPromises.readFile 사용)
- const fileData = await fsPromises.readFile(filePath);
-
- // ZIP에 파일 추가
- zip.file(attachment.fileName, fileData);
- } catch (error) {
- console.warn(`파일을 처리할 수 없습니다: ${filePath}`, error);
- // 오류가 있더라도 계속 진행
- }
- })
- );
-
- // ZIP 생성 및 저장
- const zipContent = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 9 } });
- await fsPromises.writeFile(zipFilePath, zipContent);
-
- // 임시 ZIP 파일에 접근할 수 있는 URL 생성
- const downloadUrl = `/api/vendors/attachments/download-temp?file=${encodeURIComponent(zipFileName)}`;
-
- return {
- url: downloadUrl,
- fileName: `${vendor.vendorName || `vendor-${vendorId}`}-attachments.zip`
+
+ // Blob으로 응답 변환
+ const blob = await response.blob();
+
+ // Blob URL 생성
+ const blobUrl = window.URL.createObjectURL(blob);
+
+ return {
+ url: blobUrl,
+ fileName,
+ blob
};
} catch (error) {
- console.error('첨부파일 다운로드 서버 액션 오류:', error);
- throw new Error('첨부파일 다운로드 준비 중 오류가 발생했습니다.');
+ console.error('Download API error:', error);
+ throw error;
}
}
@@ -1016,13 +1059,22 @@ interface ApproveVendorsInput {
/**
* 선택된 벤더의 상태를 IN_REVIEW로 변경하고 이메일 알림을 발송하는 서버 액션
*/
-export async function approveVendors(input: ApproveVendorsInput) {
+export async function approveVendors(input: ApproveVendorsInput & { userId: number }) {
unstable_noStore();
try {
- // 트랜잭션 내에서 벤더 상태 업데이트, 유저 생성 및 이메일 발송
+ // 트랜잭션 내에서 협력업체 상태 업데이트, 유저 생성 및 이메일 발송
const result = await db.transaction(async (tx) => {
- // 1. 벤더 상태 업데이트
+ // 0. 업데이트 전 협력업체 상태 조회
+ const vendorsBeforeUpdate = await tx
+ .select({
+ id: vendors.id,
+ status: vendors.status,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, input.ids));
+
+ // 1. 협력업체 상태 업데이트
const [updated] = await tx
.update(vendors)
.set({
@@ -1032,7 +1084,7 @@ export async function approveVendors(input: ApproveVendorsInput) {
.where(inArray(vendors.id, input.ids))
.returning();
- // 2. 업데이트된 벤더 정보 조회
+ // 2. 업데이트된 협력업체 정보 조회
const updatedVendors = await tx
.select({
id: vendors.id,
@@ -1067,18 +1119,35 @@ export async function approveVendors(input: ApproveVendorsInput) {
})
);
- // 4. 각 벤더에게 이메일 발송
+ // 4. 로그 기록
+ await Promise.all(
+ vendorsBeforeUpdate.map(async (vendorBefore) => {
+ await tx.insert(vendorsLogs).values({
+ vendorId: vendorBefore.id,
+ userId: input.userId,
+ action: "status_change",
+ oldStatus: vendorBefore.status,
+ newStatus: "IN_REVIEW",
+ comment: "Vendor approved for review",
+ });
+ })
+ );
+
+ // 5. 각 벤더에게 이메일 발송
await Promise.all(
updatedVendors.map(async (vendor) => {
if (!vendor.email) return; // 이메일이 없으면 스킵
try {
- const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
+ const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기
const subject =
"[eVCP] Admin Account Created";
- const loginUrl = "http://3.36.56.124:3000/en/login";
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ const baseUrl = `http://${host}`
+ const loginUrl = `${baseUrl}/en/login`;
await sendEmail({
to: vendor.email,
@@ -1112,7 +1181,7 @@ export async function approveVendors(input: ApproveVendorsInput) {
}
}
-export async function requestPQVendors(input: ApproveVendorsInput) {
+export async function requestPQVendors(input: ApproveVendorsInput & { userId: number }) {
unstable_noStore();
try {
@@ -1134,9 +1203,18 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
}
}
- // 트랜잭션 내에서 벤더 상태 업데이트 및 이메일 발송
+ // 트랜잭션 내에서 협력업체 상태 업데이트 및 이메일 발송
const result = await db.transaction(async (tx) => {
- // 1. 벤더 상태 업데이트
+ // 0. 업데이트 전 협력업체 상태 조회
+ const vendorsBeforeUpdate = await tx
+ .select({
+ id: vendors.id,
+ status: vendors.status,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, input.ids));
+
+ // 1. 협력업체 상태 업데이트
const [updated] = await tx
.update(vendors)
.set({
@@ -1146,7 +1224,7 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
.where(inArray(vendors.id, input.ids))
.returning();
- // 2. 업데이트된 벤더 정보 조회
+ // 2. 업데이트된 협력업체 정보 조회
const updatedVendors = await tx
.select({
id: vendors.id,
@@ -1169,14 +1247,33 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
await tx.insert(vendorProjectPQs).values(vendorProjectPQsData);
}
-
- // 4. 각 벤더에게 이메일 발송
+
+ // 4. 로그 기록
+ await Promise.all(
+ vendorsBeforeUpdate.map(async (vendorBefore) => {
+ await tx.insert(vendorsLogs).values({
+ vendorId: vendorBefore.id,
+ userId: input.userId,
+ action: "status_change",
+ oldStatus: vendorBefore.status,
+ newStatus: "IN_PQ",
+ comment: input.projectId
+ ? `Project PQ requested (Project: ${projectInfo?.projectCode || input.projectId})`
+ : "PQ requested",
+ });
+ })
+ );
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
+ // 5. 각 벤더에게 이메일 발송
await Promise.all(
updatedVendors.map(async (vendor) => {
if (!vendor.email) return; // 이메일이 없으면 스킵
try {
- const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
+ const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기
// 프로젝트 PQ인지 일반 PQ인지에 따라 제목 변경
const subject = input.projectId
@@ -1184,7 +1281,7 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
: "[eVCP] You are invited to submit PQ";
// 로그인 URL에 프로젝트 ID 추가 (프로젝트 PQ인 경우)
- const baseLoginUrl = "http://3.36.56.124:3000/en/login";
+ const baseLoginUrl = `${host}/partners/pq`;
const loginUrl = input.projectId
? `${baseLoginUrl}?projectId=${input.projectId}`
: baseLoginUrl;
@@ -1192,7 +1289,8 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
await sendEmail({
to: vendor.email,
subject,
- template: input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용
+ template:input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용
+ // template: "vendor-pq-status", // 프로젝트별 템플릿 사용
context: {
vendorName: vendor.vendorName,
loginUrl,
@@ -1225,21 +1323,20 @@ export async function requestPQVendors(input: ApproveVendorsInput) {
return { data: null, error: getErrorMessage(err) };
}
}
-
interface SendVendorsInput {
ids: number[];
}
/**
- * APPROVED 상태인 벤더 정보를 기간계 시스템에 전송하고 벤더 코드를 업데이트하는 서버 액션
+ * APPROVED 상태인 협력업체 정보를 기간계 시스템에 전송하고 협력업체 코드를 업데이트하는 서버 액션
*/
-export async function sendVendors(input: SendVendorsInput) {
+export async function sendVendors(input: SendVendorsInput & { userId: number }) {
unstable_noStore();
try {
// 트랜잭션 내에서 진행
const result = await db.transaction(async (tx) => {
- // 1. 선택된 벤더 중 APPROVED 상태인 벤더만 필터링
+ // 1. 선택된 협력업체 중 APPROVED 상태인 벤더만 필터링
const approvedVendors = await db.query.vendors.findMany({
where: and(
inArray(vendors.id, input.ids),
@@ -1255,16 +1352,16 @@ export async function sendVendors(input: SendVendorsInput) {
// 2. 각 벤더에 대해 처리
for (const vendor of approvedVendors) {
- // 2-1. 벤더 연락처 정보 조회
+ // 2-1. 협력업체 연락처 정보 조회
const contacts = await db.query.vendorContacts.findMany({
where: eq(vendorContacts.vendorId, vendor.id)
});
- // 2-2. 벤더 가능 아이템 조회
+ // 2-2. 협력업체 가능 아이템 조회
const possibleItems = await db.query.vendorPossibleItems.findMany({
where: eq(vendorPossibleItems.vendorId, vendor.id)
});
- // 2-3. 벤더 첨부파일 조회
+ // 2-3. 협력업체 첨부파일 조회
const attachments = await db.query.vendorAttachments.findMany({
where: eq(vendorAttachments.vendorId, vendor.id),
columns: {
@@ -1274,8 +1371,7 @@ export async function sendVendors(input: SendVendorsInput) {
}
});
-
- // 2-4. 벤더 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용)
+ // 2-4. 협력업체 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용)
const vendorData = {
id: vendor.id,
vendorName: vendor.vendorName,
@@ -1311,7 +1407,7 @@ export async function sendVendors(input: SendVendorsInput) {
throw new Error(`Invalid response from ERP system for vendor ${vendor.id}`);
}
- // 2-5. 벤더 코드 및 상태 업데이트
+ // 2-5. 협력업체 코드 및 상태 업데이트
const vendorCode = responseData.vendorCode;
const [updated] = await tx
@@ -1324,16 +1420,27 @@ export async function sendVendors(input: SendVendorsInput) {
.where(eq(vendors.id, vendor.id))
.returning();
- // 2-6. 벤더에게 알림 이메일 발송
+ // 2-6. 로그 기록
+ await tx.insert(vendorsLogs).values({
+ vendorId: vendor.id,
+ userId: input.userId,
+ action: "status_change",
+ oldStatus: "APPROVED",
+ newStatus: "ACTIVE",
+ comment: `Sent to ERP system. Vendor code assigned: ${vendorCode}`,
+ });
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
+ // 2-7. 벤더에게 알림 이메일 발송
if (vendor.email) {
- const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
+ const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기
const subject =
"[eVCP] Vendor Registration Completed";
- const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000'
-
- const portalUrl = `${baseUrl}/en/partners`;
+ const portalUrl = `http://${host}/en/partners`;
await sendEmail({
to: vendor.email,
@@ -1355,12 +1462,20 @@ export async function sendVendors(input: SendVendorsInput) {
message: "Successfully sent to ERP system",
});
} catch (vendorError) {
- // 개별 벤더 처리 오류 기록
+ // 개별 협력업체 처리 오류 기록
results.push({
id: vendor.id,
success: false,
error: getErrorMessage(vendorError),
});
+
+ // 에러가 발생해도 로그는 기록
+ await tx.insert(vendorsLogs).values({
+ vendorId: vendor.id,
+ userId: input.userId,
+ action: "erp_send_failed",
+ comment: `Failed to send to ERP: ${getErrorMessage(vendorError)}`,
+ });
}
}
@@ -1387,55 +1502,68 @@ export async function sendVendors(input: SendVendorsInput) {
}
}
-
interface RequestInfoProps {
ids: number[];
+ userId: number; // 추가: 어떤 사용자가 요청했는지 로깅하기 위함
}
-export async function requestInfo({ ids }: RequestInfoProps) {
+export async function requestInfo({ ids, userId }: RequestInfoProps) {
try {
- // 1. 벤더 정보 가져오기
- const vendorList = await db.query.vendors.findMany({
- where: inArray(vendors.id, ids),
- });
-
- if (!vendorList.length) {
- return { error: "벤더 정보를 찾을 수 없습니다." };
- }
+ return await db.transaction(async (tx) => {
+ // 1. 협력업체 정보 가져오기
+ const vendorList = await tx.query.vendors.findMany({
+ where: inArray(vendors.id, ids),
+ });
- // 2. 각 벤더에게 이메일 보내기
- for (const vendor of vendorList) {
- // 이메일이 없는 경우 스킵
- if (!vendor.email) continue;
+ if (!vendorList.length) {
+ return { error: "협력업체 정보를 찾을 수 없습니다." };
+ }
- // 벤더 정보 페이지 URL 생성
- const vendorInfoUrl = `${process.env.NEXT_PUBLIC_APP_URL}/partners/info?vendorId=${vendor.id}`;
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
+ // 2. 각 벤더에 대한 로그 기록 및 이메일 발송
+ for (const vendor of vendorList) {
+ // 로그 기록
+ await tx.insert(vendorsLogs).values({
+ vendorId: vendor.id,
+ userId: userId,
+ action: "info_requested",
+ comment: "추가 정보 요청됨",
+ });
- // 벤더에게 이메일 보내기
- await sendEmail({
- to: vendor.email,
- subject: "[EVCP] 추가 정보 요청 / Additional Information Request",
- template: "vendor-additional-info",
- context: {
- vendorName: vendor.vendorName,
- vendorInfoUrl: vendorInfoUrl,
- language: "ko", // 기본 언어 설정, 벤더의 선호 언어가 있다면 그것을 사용할 수 있음
- },
- });
- }
+ // 이메일이 없는 경우 스킵
+ if (!vendor.email) continue;
+
+ // 협력업체 정보 페이지 URL 생성
+ const vendorInfoUrl = `http://${host}/partners/info?vendorId=${vendor.id}`;
+
+ // 벤더에게 이메일 보내기
+ await sendEmail({
+ to: vendor.email,
+ subject: "[EVCP] 추가 정보 요청 / Additional Information Request",
+ template: "vendor-additional-info",
+ context: {
+ vendorName: vendor.vendorName,
+ vendorInfoUrl: vendorInfoUrl,
+ language: "ko", // 기본 언어 설정, 벤더의 선호 언어가 있다면 그것을 사용할 수 있음
+ },
+ });
+ }
- // 3. 성공적으로 처리됨
- return { success: true };
+ // 3. 성공적으로 처리됨
+ return { success: true };
+ });
} catch (error) {
- console.error("벤더 정보 요청 중 오류 발생:", error);
- return { error: "벤더 정보 요청 중 오류가 발생했습니다. 다시 시도해 주세요." };
+ console.error("협력업체 정보 요청 중 오류 발생:", error);
+ return { error: "협력업체 정보 요청 중 오류가 발생했습니다. 다시 시도해 주세요." };
}
}
export async function getVendorDetailById(id: number) {
try {
- // View를 통해 벤더 정보 조회
+ // View를 통해 협력업체 정보 조회
const vendor = await db
.select()
.from(vendorDetailView)
@@ -1496,7 +1624,7 @@ export type ContactInfo = {
}
/**
- * 벤더 정보를 업데이트하는 함수
+ * 협력업체 정보를 업데이트하는 함수
*/
export async function updateVendorInfo(params: {
vendorData: UpdateVendorInfoData
@@ -1533,7 +1661,7 @@ export async function updateVendorInfo(params: {
// 트랜잭션으로 업데이트 수행
await db.transaction(async (tx) => {
- // 1. 벤더 정보 업데이트
+ // 1. 협력업체 정보 업데이트
await tx.update(vendors).set({
vendorName: vendorData.vendorName,
address: vendorData.address || null,
@@ -1672,7 +1800,7 @@ export async function updateVendorInfo(params: {
return {
data: {
success: true,
- message: '벤더 정보가 성공적으로 업데이트되었습니다.',
+ message: '협력업체 정보가 성공적으로 업데이트되었습니다.',
vendorId: vendorData.id
},
error: null
@@ -1681,4 +1809,205 @@ export async function updateVendorInfo(params: {
console.error("Vendor info update error:", error);
return { data: null, error: getErrorMessage(error) }
}
+}
+
+
+
+export interface VendorsLogWithUser {
+ id: number
+ vendorCandidateId: number
+ userId: number
+ userName: string | null
+ userEmail: string | null
+ action: string
+ oldStatus: string | null
+ newStatus: string | null
+ comment: string | null
+ createdAt: Date
+}
+
+export async function getVendorLogs(vendorId: number): Promise<VendorsLogWithUser[]> {
+ try {
+ const logs = await db
+ .select({
+ id: vendorsLogs.id,
+ vendorCandidateId: vendorsLogs.vendorId,
+ userId: vendorsLogs.userId,
+ action: vendorsLogs.action,
+ oldStatus: vendorsLogs.oldStatus,
+ newStatus: vendorsLogs.newStatus,
+ comment: vendorsLogs.comment,
+ createdAt: vendorsLogs.createdAt,
+
+ // 조인한 users 테이블 필드
+ userName: users.name,
+ userEmail: users.email,
+ })
+ .from(vendorsLogs)
+ .leftJoin(users, eq(vendorsLogs.userId, users.id))
+ .where(eq(vendorsLogs.vendorId, vendorId))
+ .orderBy(desc(vendorsLogs.createdAt))
+
+ return logs
+ } catch (error) {
+ console.error("Failed to fetch candidate logs with user info:", error)
+ throw error
+ }
+}
+
+
+
+/**
+ * 엑셀 내보내기용 벤더 연락처 목록 조회
+ * - 페이지네이션 없이 모든 연락처 반환
+ */
+export async function exportVendorContacts(vendorId: number) {
+ try {
+ const contacts = await db
+ .select()
+ .from(vendorContacts)
+ .where(eq(vendorContacts.vendorId, vendorId))
+ .orderBy(vendorContacts.isPrimary, vendorContacts.contactName);
+
+ return contacts;
+ } catch (error) {
+ console.error("Failed to export vendor contacts:", error);
+ return [];
+ }
+}
+
+/**
+ * 엑셀 내보내기용 벤더 아이템 목록 조회
+ * - 페이지네이션 없이 모든 아이템 정보 반환
+ */
+export async function exportVendorItems(vendorId: number) {
+ try {
+ const vendorItems = await db
+ .select({
+ id: vendorItemsView.vendorItemId,
+ vendorId: vendorItemsView.vendorId,
+ itemName: vendorItemsView.itemName,
+ itemCode: vendorItemsView.itemCode,
+ description: vendorItemsView.description,
+ createdAt: vendorItemsView.createdAt,
+ updatedAt: vendorItemsView.updatedAt,
+ })
+ .from(vendorItemsView)
+ .where(eq(vendorItemsView.vendorId, vendorId))
+ .orderBy(vendorItemsView.itemName);
+
+ return vendorItems;
+ } catch (error) {
+ console.error("Failed to export vendor items:", error);
+ return [];
+ }
+}
+
+/**
+ * 엑셀 내보내기용 벤더 RFQ 목록 조회
+ * - 페이지네이션 없이 모든 RFQ 정보 반환
+ */
+export async function exportVendorRFQs(vendorId: number) {
+ try {
+ const rfqs = await db
+ .select()
+ .from(vendorRfqView)
+ .where(eq(vendorRfqView.vendorId, vendorId))
+ .orderBy(vendorRfqView.rfqVendorUpdated);
+
+ return rfqs;
+ } catch (error) {
+ console.error("Failed to export vendor RFQs:", error);
+ return [];
+ }
+}
+
+/**
+ * 엑셀 내보내기용 벤더 계약 목록 조회
+ * - 페이지네이션 없이 모든 계약 정보 반환
+ */
+export async function exportVendorContracts(vendorId: number) {
+ try {
+ const contracts = await db
+ .select()
+ .from(contractsDetailView)
+ .where(eq(contractsDetailView.vendorId, vendorId))
+ .orderBy(contractsDetailView.createdAt);
+
+ return contracts;
+ } catch (error) {
+ console.error("Failed to export vendor contracts:", error);
+ return [];
+ }
+}
+
+/**
+ * 엑셀 내보내기용 벤더 정보 조회
+ * - 페이지네이션 없이 모든 벤더 정보 반환
+ */
+export async function exportVendorDetails(vendorIds: number[]) {
+ try {
+ if (!vendorIds.length) return [];
+
+ // 벤더 기본 정보 조회
+ const vendorsData = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ taxId: vendors.taxId,
+ address: vendors.address,
+ country: vendors.country,
+ phone: vendors.phone,
+ email: vendors.email,
+ website: vendors.website,
+ status: vendors.status,
+ representativeName: vendors.representativeName,
+ representativeBirth: vendors.representativeBirth,
+ representativeEmail: vendors.representativeEmail,
+ representativePhone: vendors.representativePhone,
+ corporateRegistrationNumber: vendors.corporateRegistrationNumber,
+ creditAgency: vendors.creditAgency,
+ creditRating: vendors.creditRating,
+ cashFlowRating: vendors.cashFlowRating,
+ createdAt: vendors.createdAt,
+ updatedAt: vendors.updatedAt,
+ })
+ .from(vendors)
+ .where(
+ vendorIds.length === 1
+ ? eq(vendors.id, vendorIds[0])
+ : inArray(vendors.id, vendorIds)
+ );
+
+ // 벤더별 상세 정보를 포함하여 반환
+ const vendorsWithDetails = await Promise.all(
+ vendorsData.map(async (vendor) => {
+ // 연락처 조회
+ const contacts = await exportVendorContacts(vendor.id);
+
+ // 아이템 조회
+ const items = await exportVendorItems(vendor.id);
+
+ // RFQ 조회
+ const rfqs = await exportVendorRFQs(vendor.id);
+
+ // 계약 조회
+ const contracts = await exportVendorContracts(vendor.id);
+
+ return {
+ ...vendor,
+ vendorContacts: contacts,
+ vendorItems: items,
+ vendorRfqs: rfqs,
+ vendorContracts: contracts,
+ };
+ })
+ );
+
+ return vendorsWithDetails;
+ } catch (error) {
+ console.error("Failed to export vendor details:", error);
+ return [];
+ }
} \ No newline at end of file
diff --git a/lib/vendors/table/approve-vendor-dialog.tsx b/lib/vendors/table/approve-vendor-dialog.tsx
index 253c2830..9c175dc5 100644
--- a/lib/vendors/table/approve-vendor-dialog.tsx
+++ b/lib/vendors/table/approve-vendor-dialog.tsx
@@ -29,6 +29,7 @@ import {
} from "@/components/ui/drawer"
import { Vendor } from "@/db/schema/vendors"
import { approveVendors } from "../service"
+import { useSession } from "next-auth/react"
interface ApprovalVendorDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -45,11 +46,19 @@ export function ApproveVendorsDialog({
}: ApprovalVendorDialogProps) {
const [isApprovePending, startApproveTransition] = React.useTransition()
const isDesktop = useMediaQuery("(min-width: 640px)")
+ const { data: session } = useSession()
function onApprove() {
+ if (!session?.user?.id) {
+ toast.error("사용자 인증 정보를 찾을 수 없습니다.")
+ return
+ }
+
startApproveTransition(async () => {
const { error } = await approveVendors({
ids: vendors.map((vendor) => vendor.id),
+ userId: Number(session.user.id)
+
})
if (error) {
@@ -70,7 +79,7 @@ export function ApproveVendorsDialog({
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Check className="size-4" aria-hidden="true" />
- Approve ({vendors.length})
+ 가입 Approve ({vendors.length})
</Button>
</DialogTrigger>
) : null}
diff --git a/lib/vendors/table/request-additional-Info-dialog.tsx b/lib/vendors/table/request-additional-Info-dialog.tsx
index 872162dd..2e39a527 100644
--- a/lib/vendors/table/request-additional-Info-dialog.tsx
+++ b/lib/vendors/table/request-additional-Info-dialog.tsx
@@ -29,6 +29,7 @@ import {
} from "@/components/ui/drawer"
import { Vendor } from "@/db/schema/vendors"
import { requestInfo } from "../service"
+import { useSession } from "next-auth/react"
interface RequestInfoDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -45,11 +46,18 @@ export function RequestInfoDialog({
}: RequestInfoDialogProps) {
const [isRequestPending, startRequestTransition] = React.useTransition()
const isDesktop = useMediaQuery("(min-width: 640px)")
+ const { data: session } = useSession()
function onApprove() {
+ if (!session?.user?.id) {
+ toast.error("사용자 인증 정보를 찾을 수 없습니다.")
+ return
+ }
startRequestTransition(async () => {
const { error, success } = await requestInfo({
ids: vendors.map((vendor) => vendor.id),
+ userId: Number(session.user.id)
+
})
if (error) {
@@ -58,7 +66,7 @@ export function RequestInfoDialog({
}
props.onOpenChange?.(false)
- toast.success("추가 정보 요청이 성공적으로 벤더에게 발송되었습니다.")
+ toast.success("추가 정보 요청이 성공적으로 협력업체에게 발송되었습니다.")
onSuccess?.()
})
}
@@ -76,12 +84,12 @@ export function RequestInfoDialog({
) : null}
<DialogContent>
<DialogHeader>
- <DialogTitle>벤더 추가 정보 요청 확인</DialogTitle>
+ <DialogTitle>협력업체 추가 정보 요청 확인</DialogTitle>
<DialogDescription>
<span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 추가 정보를 요청하시겠습니까?
+ {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 추가 정보를 요청하시겠습니까?
<br /><br />
- 요청시 벤더에게 이메일이 발송되며, 벤더는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은
+ 요청시 협력업체에게 이메일이 발송되며, 협력업체는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은
추가 정보를 입력하게 됩니다.
</DialogDescription>
</DialogHeader>
@@ -121,12 +129,12 @@ export function RequestInfoDialog({
) : null}
<DrawerContent>
<DrawerHeader>
- <DrawerTitle>벤더 추가 정보 요청 확인</DrawerTitle>
+ <DrawerTitle>협력업체 추가 정보 요청 확인</DrawerTitle>
<DrawerDescription>
<span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 추가 정보를 요청하시겠습니까?
+ {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 추가 정보를 요청하시겠습니까?
<br /><br />
- 요청시 벤더에게 이메일이 발송되며, 벤더는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은
+ 요청시 협력업체에게 이메일이 발송되며, 협력업체는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은
추가 정보를 입력하게 됩니다.
</DrawerDescription>
</DrawerHeader>
diff --git a/lib/vendors/table/request-basicContract-dialog.tsx b/lib/vendors/table/request-basicContract-dialog.tsx
new file mode 100644
index 00000000..8d05fbbe
--- /dev/null
+++ b/lib/vendors/table/request-basicContract-dialog.tsx
@@ -0,0 +1,548 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Send, AlertCircle, Clock, RefreshCw } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
+import { Vendor } from "@/db/schema/vendors"
+import { useSession } from "next-auth/react"
+import { getAllTemplates } from "@/lib/basic-contract/service"
+import { useState, useEffect } from "react"
+import { requestBasicContractInfo } from "@/lib/basic-contract/service"
+import { checkContractRequestStatus } from "@/lib/basic-contract/service"
+import { BasicContractTemplate } from "@/db/schema"
+
+interface RequestInfoDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<Vendor>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+// 계약 요청 상태 인터페이스
+interface VendorTemplateStatus {
+ vendorId: number;
+ vendorName: string;
+ templateId: number;
+ templateName: string;
+ status: string;
+ createdAt: Date;
+ completedAt?: Date; // 계약 체결 날짜 추가
+ isExpired: boolean; // 요청 만료 (30일)
+ isUpdated: boolean; // 템플릿 업데이트 여부
+ isContractExpired: boolean; // 계약 유효기간 만료 여부 (1년) 추가
+}
+export function RequestContractDialog({
+ vendors,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: RequestInfoDialogProps) {
+ const [isRequestPending, startRequestTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+ const { data: session } = useSession()
+ const [templates, setTemplates] = useState<BasicContractTemplate[]>([])
+ const [selectedTemplateIds, setSelectedTemplateIds] = useState<number[]>([])
+ const [isLoading, setIsLoading] = useState(false)
+ const [statusLoading, setStatusLoading] = useState(false)
+ const [statusData, setStatusData] = useState<VendorTemplateStatus[]>([])
+ const [forceResend, setForceResend] = useState<Set<string>>(new Set())
+
+ // 템플릿 및 상태 로드
+ useEffect(() => {
+ loadTemplatesAndStatus();
+ }, [vendors]);
+
+ // 템플릿과 현재 요청 상태를 로드하는 함수
+ const loadTemplatesAndStatus = async () => {
+ console.log("loadTemplatesAndStatus")
+ setIsLoading(true);
+ setStatusLoading(true);
+
+ try {
+ // 1. 템플릿 로드
+ const allTemplates = await getAllTemplates();
+ const activeTemplates = allTemplates.filter(t => t.status === 'ACTIVE');
+ setTemplates(activeTemplates);
+
+ // 기본 템플릿 선택 설정
+ const allActiveTemplateIds = activeTemplates.map(t => t.id);
+ setSelectedTemplateIds(allActiveTemplateIds);
+
+ // 2. 현재 계약 요청 상태 확인
+ if (vendors.length > 0 && allActiveTemplateIds.length > 0) {
+ const vendorIds = vendors.map(v => v.id);
+ const { data } = await checkContractRequestStatus(vendorIds, allActiveTemplateIds);
+ setStatusData(data || []);
+ }
+ } catch (error) {
+ console.error("데이터 로딩 오류:", error);
+ toast.error("템플릿 또는 상태 정보를 불러오는데 실패했습니다.");
+ } finally {
+ setIsLoading(false);
+ setStatusLoading(false);
+ }
+ };
+
+ // 체크박스 상태 변경 핸들러
+ const handleTemplateToggle = (templateId: number, checked: boolean) => {
+ if (checked) {
+ setSelectedTemplateIds(prev => [...prev, templateId]);
+ } else {
+ setSelectedTemplateIds(prev => prev.filter(id => id !== templateId));
+ }
+ };
+
+ // 강제 재전송 토글
+ const toggleForceResend = (vendorId: number, templateId: number) => {
+ const key = `${vendorId}-${templateId}`;
+ const newForceResend = new Set(forceResend);
+
+ if (newForceResend.has(key)) {
+ newForceResend.delete(key);
+ } else {
+ newForceResend.add(key);
+ }
+
+ setForceResend(newForceResend);
+ };
+
+ const renderStatusBadge = (vendorId: number, templateId: number) => {
+ const status = statusData.find(
+ s => s.vendorId === vendorId && s.templateId === templateId
+ );
+
+ if (!status || status.status === "NONE") return null;
+
+ // 상태에 따른 배지 스타일 설정
+ let badgeVariant = "outline";
+ let badgeLabel = "";
+ let icon = null;
+ let tooltip = "";
+
+ switch (status.status) {
+ case "PENDING":
+ badgeVariant = "secondary";
+ badgeLabel = "대기중";
+
+ if (status.isExpired) {
+ icon = <Clock className="h-3 w-3 mr-1" />;
+ tooltip = "요청이 만료되었습니다. 재전송이 필요합니다.";
+ } else if (status.isUpdated) {
+ icon = <RefreshCw className="h-3 w-3 mr-1" />;
+ tooltip = "템플릿이 업데이트되었습니다. 재전송이 필요합니다.";
+ } else {
+ tooltip = "서명 요청이 진행 중입니다.";
+ }
+ break;
+
+ case "COMPLETED":
+ // 계약 유효기간 만료 확인
+ if (status.isContractExpired) {
+ badgeVariant = "warning"; // 경고 스타일 적용
+ badgeLabel = "재계약 필요";
+ icon = <Clock className="h-3 w-3 mr-1" />;
+ tooltip = "계약 유효기간이 만료되었습니다. 재계약이 필요합니다.";
+ } else {
+ badgeVariant = "success";
+ badgeLabel = "완료됨";
+ tooltip = "이미 서명이 완료되었습니다.";
+ }
+ break;
+
+ case "REJECTED":
+ badgeVariant = "destructive";
+ badgeLabel = "거부됨";
+ tooltip = "협력업체가 서명을 거부했습니다.";
+ break;
+ }
+
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Badge variant={badgeVariant as any} className="ml-2 text-xs">
+ {icon}
+ {badgeLabel}
+ </Badge>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{tooltip}</p>
+
+ {/* 재전송 조건에 계약 유효기간 만료 추가 */}
+ {(status.isExpired || status.isUpdated || status.status === "REJECTED" ||
+ (status.status === "COMPLETED" && status.isContractExpired)) && (
+ <p className="text-xs mt-1">
+ <Button
+ variant="link"
+ size="sm"
+ className="h-4 p-0"
+ onClick={() => toggleForceResend(vendorId, templateId)}
+ >
+ {forceResend.has(`${vendorId}-${templateId}`) ? "재전송 취소" : "재전송 하기"}
+ </Button>
+ </p>
+ )}
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ );
+ };
+
+ // 유효한 요청인지 확인 함수 개선
+ const isValidRequest = (vendorId: number, templateId: number) => {
+ const status = statusData.find(
+ s => s.vendorId === vendorId && s.templateId === templateId
+ );
+
+ if (!status || status.status === "NONE") return true;
+
+ // 만료되었거나 템플릿이 업데이트되었거나 거부된 경우 재전송 가능
+ // 계약 유효기간 만료도 조건에 추가
+ if (status.isExpired ||
+ status.isUpdated ||
+ status.status === "REJECTED" ||
+ (status.status === "COMPLETED" && status.isContractExpired)) {
+ return forceResend.has(`${vendorId}-${templateId}`);
+ }
+
+ // PENDING(비만료) 또는 COMPLETED(유효기간 내)는 재전송 불가
+ return false;
+ };
+
+
+ // 요청 발송 처리
+ function onApprove() {
+ if (!session?.user?.id) {
+ toast.error("사용자 인증 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ if (selectedTemplateIds.length === 0) {
+ toast.error("최소 하나 이상의 계약서 템플릿을 선택해주세요.")
+ return
+ }
+
+ // 모든 협력업체-템플릿 조합 생성
+ const validRequests: { vendorId: number, templateId: number }[] = [];
+ const skippedRequests: { vendorId: number, templateId: number, reason: string }[] = [];
+
+ vendors.forEach(vendor => {
+ selectedTemplateIds.forEach(templateId => {
+ if (isValidRequest(vendor.id, templateId)) {
+ validRequests.push({
+ vendorId: vendor.id,
+ templateId
+ });
+ } else {
+ // 유효하지 않은 요청은 건너뜀
+ const status = statusData.find(
+ s => s.vendorId === vendor.id && s.templateId === templateId
+ );
+
+ let reason = "알 수 없음";
+ if (status) {
+ if (status.status === "PENDING") reason = "이미 대기 중";
+ if (status.status === "COMPLETED") reason = "이미 완료됨";
+ }
+
+ skippedRequests.push({
+ vendorId: vendor.id,
+ templateId,
+ reason
+ });
+ }
+ });
+ });
+
+ if (validRequests.length === 0) {
+ toast.error("전송 가능한 요청이 없습니다. 재전송이 필요한 항목을 '재전송 하기' 버튼으로 활성화하세요.");
+ return;
+ }
+
+ startRequestTransition(async () => {
+ // 유효한 요청만 처리
+ const requests = validRequests.map(req =>
+ requestBasicContractInfo({
+ vendorIds: [req.vendorId],
+ requestedBy: Number(session.user.id),
+ templateId: req.templateId
+ })
+ );
+
+ try {
+ const results = await Promise.all(requests);
+
+ // 오류 확인
+ const errors = results.filter(r => r.error);
+ if (errors.length > 0) {
+ toast.error(`${errors.length}개의 요청에서 오류가 발생했습니다.`);
+ return;
+ }
+
+ // 상태 메시지 생성
+ let successMessage = "기본계약서 서명 요청이 성공적으로 발송되었습니다.";
+ if (skippedRequests.length > 0) {
+ successMessage += ` (${skippedRequests.length}개 요청 건너뜀)`;
+ }
+
+ props.onOpenChange?.(false);
+ toast.success(successMessage);
+ onSuccess?.();
+ } catch (error) {
+ console.error("요청 처리 중 오류:", error);
+ toast.error("서명 요청 처리 중 오류가 발생했습니다.");
+ }
+ });
+ }
+
+ // 선택된 템플릿 수 표시
+ const selectedCount = selectedTemplateIds.length;
+ const totalCount = templates.length;
+
+ // UI 렌더링
+ const renderTemplateList = () => (
+ <div className="space-y-3">
+ {templates.map((template) => (
+ <div key={template.id} className="pb-2 border-b last:border-b-0">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id={`template-${template.id}`}
+ checked={selectedTemplateIds.includes(template.id)}
+ onCheckedChange={(checked) => handleTemplateToggle(template.id, checked as boolean)}
+ />
+ <label
+ htmlFor={`template-${template.id}`}
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
+ >
+ {template.templateName}
+ </label>
+
+ {/* 상태 배지를 템플릿 이름 옆에 나란히 배치 */}
+ {vendors.length === 1 && renderStatusBadge(vendors[0].id, template.id)}
+ </div>
+
+
+ {vendors.length === 1 && (() => {
+ const status = statusData.find(
+ s => s.vendorId === vendors[0].id && s.templateId === template.id
+ );
+
+ // 계약 유효기간 만료 조건 추가
+ if (status && (status.isExpired ||
+ status.isUpdated ||
+ status.status === "REJECTED" ||
+ (status.status === "COMPLETED" && status.isContractExpired))) {
+ const key = `${vendors[0].id}-${template.id}`;
+
+ // 계약 유효기간 만료인 경우 다른 텍스트 표시
+ const buttonText = status.status === "COMPLETED" && status.isContractExpired
+ ? (forceResend.has(key) ? "재계약 취소" : "재계약하기")
+ : (forceResend.has(key) ? "재전송 취소" : "재전송하기");
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-7 px-2 text-xs"
+ onClick={() => toggleForceResend(vendors[0].id, template.id)}
+ >
+ {buttonText}
+ </Button>
+ );
+ }
+ return null;
+ })()}
+
+ </div>
+
+ {/* 추가 정보 표시 (파일명 등) */}
+ <div className="mt-1 pl-6 text-xs text-muted-foreground">
+ {template.fileName}
+ </div>
+ </div>
+ ))}
+ </div>
+ );
+
+ // 내용 영역 렌더링
+ const renderContentArea = () => (
+ <div className="space-y-4">
+ <div className="space-y-2">
+ <div className="flex justify-between items-center">
+ <h3 className="text-sm font-medium">계약서 템플릿 선택</h3>
+ <span className="text-xs text-muted-foreground">
+ {selectedCount}/{totalCount} 선택됨
+ </span>
+ </div>
+
+ {isLoading ? (
+ <div className="flex items-center justify-center py-4">
+ <Loader className="size-4 animate-spin mr-2" />
+ <span>템플릿 로딩 중...</span>
+ </div>
+ ) : templates.length === 0 ? (
+ <div className="text-sm text-muted-foreground p-2 border rounded-md">
+ 활성 상태의 템플릿이 없습니다. 템플릿을 먼저 등록해주세요.
+ </div>
+ ) : (
+ // ScrollArea 대신 네이티브 스크롤 사용
+ <div className="border rounded-md p-3 overflow-y-auto h-[200px]">
+ {renderTemplateList()}
+ </div>
+ )}
+ </div>
+
+ {statusLoading && (
+ <div className="flex items-center text-sm text-muted-foreground">
+ <Loader className="size-3 animate-spin mr-2" />
+ <span>계약 상태 확인 중...</span>
+ </div>
+ )}
+
+ {/* 선택된 템플릿 정보 (ScrollArea 대신 네이티브 스크롤 사용) */}
+ {selectedTemplateIds.length > 0 && (
+ <div className="space-y-2 text-sm">
+ <h3 className="font-medium">선택된 템플릿 정보</h3>
+ <div className="overflow-y-auto max-h-[150px] border rounded-md p-2">
+ <div className="space-y-2">
+ {selectedTemplateIds.map(id => {
+ const template = templates.find(t => t.id === id);
+ if (!template) return null;
+
+ return (
+ <div key={id} className="p-2 border rounded-md bg-muted/50">
+ <p><span className="font-medium">이름:</span> {template.templateName}</p>
+ <p><span className="font-medium">파일:</span> {template.fileName}</p>
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ </div>
+ )}
+
+ <div className="text-sm text-muted-foreground mt-4">
+ 요청시 협력업체에게 이메일이 발송되며, 협력업체는 별도 페이지에서 기본계약서와 기타 관련 서류들에 대해서 서명을 하게 됩니다.
+ </div>
+ </div>
+ );
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Send className="size-4" aria-hidden="true" />
+ 기본계약서 서명 요청 ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>협력업체 기본계약서 요청 확인</DialogTitle>
+ <DialogDescription>
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 기본계약서 서명을 요청하시겠습니까?
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="py-4">
+ {renderContentArea()}
+ </div>
+
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="Send request to selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isRequestPending || isLoading || selectedTemplateIds.length === 0}
+ >
+ {isRequestPending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 요청 발송
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Send className="size-4" aria-hidden="true" />
+ 기본계약서 서명 요청 ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>협력업체 기본계약서 요청 확인</DrawerTitle>
+ <DrawerDescription>
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 기본계약서 서명을 요청하시겠습니까?
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="px-4">
+ {renderContentArea()}
+ </div>
+
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Send request to selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isRequestPending || isLoading || selectedTemplateIds.length === 0}
+ >
+ {isRequestPending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 요청 발송
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ );
+} \ No newline at end of file
diff --git a/lib/vendors/table/request-project-pq-dialog.tsx b/lib/vendors/table/request-project-pq-dialog.tsx
index c590d7ec..a9fe0e1a 100644
--- a/lib/vendors/table/request-project-pq-dialog.tsx
+++ b/lib/vendors/table/request-project-pq-dialog.tsx
@@ -44,6 +44,7 @@ import { Label } from "@/components/ui/label"
import { Vendor } from "@/db/schema/vendors"
import { requestPQVendors } from "../service"
import { getProjects, type Project } from "@/lib/rfqs/service"
+import { useSession } from "next-auth/react"
interface RequestProjectPQDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -63,6 +64,7 @@ export function RequestProjectPQDialog({
const [projects, setProjects] = React.useState<Project[]>([])
const [selectedProjectId, setSelectedProjectId] = React.useState<number | null>(null)
const [isLoadingProjects, setIsLoadingProjects] = React.useState(false)
+ const { data: session } = useSession()
// 프로젝트 목록 로드
React.useEffect(() => {
@@ -95,15 +97,23 @@ export function RequestProjectPQDialog({
}
function onApprove() {
+
if (!selectedProjectId) {
toast.error("프로젝트를 선택해주세요.")
return
}
+ if (!session?.user?.id) {
+ toast.error("사용자 인증 정보를 찾을 수 없습니다.")
+ return
+ }
+
startApproveTransition(async () => {
const { error } = await requestPQVendors({
ids: vendors.map((vendor) => vendor.id),
projectId: selectedProjectId,
+ userId: Number(session.user.id)
+
})
if (error) {
@@ -113,7 +123,7 @@ export function RequestProjectPQDialog({
props.onOpenChange?.(false)
- toast.success(`벤더에게 프로젝트 PQ가 성공적으로 요청되었습니다.`)
+ toast.success(`협력업체에게 프로젝트 PQ가 성공적으로 요청되었습니다.`)
onSuccess?.()
})
}
@@ -165,8 +175,8 @@ export function RequestProjectPQDialog({
<DialogTitle>프로젝트 PQ 요청 확인</DialogTitle>
<DialogDescription>
<span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 프로젝트 PQ 제출을 요청하시겠습니까?
- 요청을 보내면 벤더에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다.
+ {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 프로젝트 PQ 제출을 요청하시겠습니까?
+ 요청을 보내면 협력업체에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다.
</DialogDescription>
</DialogHeader>
@@ -177,7 +187,7 @@ export function RequestProjectPQDialog({
<Button variant="outline">취소</Button>
</DialogClose>
<Button
- aria-label="선택한 벤더에게 요청하기"
+ aria-label="선택한 협력업체에게 요청하기"
variant="default"
onClick={onApprove}
disabled={isApprovePending || !selectedProjectId}
@@ -211,8 +221,8 @@ export function RequestProjectPQDialog({
<DrawerTitle>프로젝트 PQ 요청 확인</DrawerTitle>
<DrawerDescription>
<span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 프로젝트 PQ 제출을 요청하시겠습니까?
- 요청을 보내면 벤더에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다.
+ {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 프로젝트 PQ 제출을 요청하시겠습니까?
+ 요청을 보내면 협력업체에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다.
</DrawerDescription>
</DrawerHeader>
@@ -225,7 +235,7 @@ export function RequestProjectPQDialog({
<Button variant="outline">취소</Button>
</DrawerClose>
<Button
- aria-label="선택한 벤더에게 요청하기"
+ aria-label="선택한 협력업체에게 요청하기"
variant="default"
onClick={onApprove}
disabled={isApprovePending || !selectedProjectId}
diff --git a/lib/vendors/table/request-vendor-investigate-dialog.tsx b/lib/vendors/table/request-vendor-investigate-dialog.tsx
index 0309ee4a..b3deafce 100644
--- a/lib/vendors/table/request-vendor-investigate-dialog.tsx
+++ b/lib/vendors/table/request-vendor-investigate-dialog.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import { type Row } from "@tanstack/react-table"
-import { Loader, Check, SendHorizonal } from "lucide-react"
+import { Loader, Check, SendHorizonal, AlertCircle, AlertTriangle } from "lucide-react"
import { toast } from "sonner"
import { useMediaQuery } from "@/hooks/use-media-query"
@@ -27,8 +27,29 @@ import {
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion"
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+
import { Vendor } from "@/db/schema/vendors"
-import { requestInvestigateVendors } from "@/lib/vendor-investigation/service"
+import { requestInvestigateVendors, getExistingInvestigationsForVendors } from "@/lib/vendor-investigation/service"
+import { useSession } from "next-auth/react"
+import { formatDate } from "@/lib/utils"
interface ApprovalVendorDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -37,21 +58,98 @@ interface ApprovalVendorDialogProps
onSuccess?: () => void
}
+// Helper function to get status badge variant and text
+function getStatusBadge(status: string) {
+ switch (status) {
+ case "REQUESTED":
+ return { variant: "secondary", text: "Requested" }
+ case "SCHEDULED":
+ return { variant: "warning", text: "Scheduled" }
+ case "IN_PROGRESS":
+ return { variant: "default", text: "In Progress" }
+ case "COMPLETED":
+ return { variant: "success", text: "Completed" }
+ case "CANCELLED":
+ return { variant: "destructive", text: "Cancelled" }
+ default:
+ return { variant: "outline", text: status }
+ }
+}
+
export function RequestVendorsInvestigateDialog({
vendors,
showTrigger = true,
onSuccess,
...props
}: ApprovalVendorDialogProps) {
-
- console.log(vendors)
const [isApprovePending, startApproveTransition] = React.useTransition()
+ const [isLoading, setIsLoading] = React.useState(true)
+ const [existingInvestigations, setExistingInvestigations] = React.useState<any[]>([])
const isDesktop = useMediaQuery("(min-width: 640px)")
+ const { data: session } = useSession()
+
+ // Fetch existing investigations when dialog opens
+ React.useEffect(() => {
+ if (vendors.length > 0) {
+ setIsLoading(true)
+ const fetchExistingInvestigations = async () => {
+ try {
+ const vendorIds = vendors.map(vendor => vendor.id)
+ const result = await getExistingInvestigationsForVendors(vendorIds)
+ setExistingInvestigations(result)
+ } catch (error) {
+ console.error("Failed to fetch existing investigations:", error)
+ toast.error("Failed to fetch existing investigations")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ fetchExistingInvestigations()
+ }
+ }, [vendors])
+
+ // Group vendors by investigation status
+ const vendorsWithInvestigations = React.useMemo(() => {
+ if (!existingInvestigations.length) return { withInvestigations: [], withoutInvestigations: vendors }
+
+ const vendorMap = new Map(vendors.map(v => [v.id, v]))
+ const withInvestigations: Array<{ vendor: typeof vendors[0], investigation: any }> = []
+
+ // Find vendors with existing investigations
+ existingInvestigations.forEach(inv => {
+ const vendor = vendorMap.get(inv.vendorId)
+ if (vendor) {
+ withInvestigations.push({ vendor, investigation: inv })
+ vendorMap.delete(inv.vendorId)
+ }
+ })
+
+ // Remaining vendors don't have investigations
+ const withoutInvestigations = Array.from(vendorMap.values())
+
+ return { withInvestigations, withoutInvestigations }
+ }, [vendors, existingInvestigations])
function onApprove() {
+ if (!session?.user?.id) {
+ toast.error("사용자 인증 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ // Only request investigations for vendors without existing ones
+ const vendorsToRequest = vendorsWithInvestigations.withoutInvestigations
+
+ if (vendorsToRequest.length === 0) {
+ toast.info("모든 선택된 업체에 이미 실사 요청이 있습니다.")
+ props.onOpenChange?.(false)
+ return
+ }
+
startApproveTransition(async () => {
const { error } = await requestInvestigateVendors({
- ids: vendors.map((vendor) => vendor.id),
+ ids: vendorsToRequest.map((vendor) => vendor.id),
+ userId: Number(session.user.id)
})
if (error) {
@@ -60,11 +158,102 @@ export function RequestVendorsInvestigateDialog({
}
props.onOpenChange?.(false)
- toast.success("Vendor Investigation successfully sent to 벤더실사담당자")
+ toast.success(`${vendorsToRequest.length}개 업체에 대한 실사 요청을 보냈습니다.`)
onSuccess?.()
})
}
+ const renderContent = () => {
+ return (
+ <>
+ <div className="space-y-4">
+ {isLoading ? (
+ <div className="flex items-center justify-center py-4">
+ <Loader className="size-6 animate-spin text-muted-foreground" />
+ </div>
+ ) : (
+ <>
+ {vendorsWithInvestigations.withInvestigations.length > 0 && (
+ <Alert>
+ <AlertTriangle className="h-4 w-4" />
+ <AlertTitle>기존 실사 요청 정보가 있습니다</AlertTitle>
+ <AlertDescription>
+ 선택한 {vendors.length}개 업체 중 {vendorsWithInvestigations.withInvestigations.length}개 업체에 대한
+ 기존 실사 요청이 있습니다. 새로운 요청은 기존 데이터가 없는 업체에만 적용됩니다.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {vendorsWithInvestigations.withInvestigations.length > 0 && (
+ <Accordion type="single" collapsible className="w-full">
+ <AccordionItem value="existing-investigations">
+ <AccordionTrigger className="font-medium">
+ 기존 실사 요청 ({vendorsWithInvestigations.withInvestigations.length})
+ </AccordionTrigger>
+ <AccordionContent>
+ <ScrollArea className="max-h-[200px]">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>업체명</TableHead>
+ <TableHead>상태</TableHead>
+ <TableHead>요청일</TableHead>
+ <TableHead>예정 일정</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {vendorsWithInvestigations.withInvestigations.map(({ vendor, investigation }) => {
+ const status = getStatusBadge(investigation.investigationStatus)
+ return (
+ <TableRow key={investigation.investigationId}>
+ <TableCell className="font-medium">{vendor.vendorName}</TableCell>
+ <TableCell>
+ <Badge variant={status.variant as any}>{status.text}</Badge>
+ </TableCell>
+ <TableCell>{formatDate(investigation.createdAt)}</TableCell>
+ <TableCell>
+ {investigation.scheduledStartAt
+ ? formatDate(investigation.scheduledStartAt)
+ : "미정"}
+ </TableCell>
+ </TableRow>
+ )
+ })}
+ </TableBody>
+ </Table>
+ </ScrollArea>
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
+ )}
+
+ <div>
+ <h3 className="text-sm font-medium mb-2">
+ 새로운 실사가 요청될 업체 ({vendorsWithInvestigations.withoutInvestigations.length})
+ </h3>
+ {vendorsWithInvestigations.withoutInvestigations.length > 0 ? (
+ <ScrollArea className="max-h-[200px]">
+ <ul className="space-y-1">
+ {vendorsWithInvestigations.withoutInvestigations.map((vendor) => (
+ <li key={vendor.id} className="text-sm py-1 px-2 border-b">
+ {vendor.vendorName} ({vendor.vendorCode || "코드 없음"})
+ </li>
+ ))}
+ </ul>
+ </ScrollArea>
+ ) : (
+ <p className="text-sm text-muted-foreground py-2">
+ 모든 선택된 업체에 이미 실사 요청이 있습니다.
+ </p>
+ )}
+ </div>
+ </>
+ )}
+ </div>
+ </>
+ )
+ }
+
if (isDesktop) {
return (
<Dialog {...props}>
@@ -72,29 +261,30 @@ export function RequestVendorsInvestigateDialog({
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<SendHorizonal className="size-4" aria-hidden="true" />
- Vendor Investigation Request ({vendors.length})
+ 실사 요청 ({vendors.length})
</Button>
</DialogTrigger>
) : null}
- <DialogContent>
+ <DialogContent className="max-w-md">
<DialogHeader>
- <DialogTitle>Confirm Vendor Investigation Requst</DialogTitle>
+ <DialogTitle>Confirm Vendor Investigation Request</DialogTitle>
<DialogDescription>
- Are you sure you want to request{" "}
- <span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? " vendor" : " vendors"}?
- After sent, 벤더실사담당자 will be notified and can manage it.
+ 선택한 {vendors.length}개 업체에 대한 실사 요청을 확인합니다.
+ 요청 후 협력업체실사담당자에게 알림이 전송됩니다.
</DialogDescription>
</DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
+
+ {renderContent()}
+
+ <DialogFooter className="gap-2 sm:space-x-0 mt-4">
<DialogClose asChild>
- <Button variant="outline">Cancel</Button>
+ <Button variant="outline">취소</Button>
</DialogClose>
<Button
aria-label="Request selected vendors"
variant="default"
onClick={onApprove}
- disabled={isApprovePending}
+ disabled={isApprovePending || isLoading || vendorsWithInvestigations.withoutInvestigations.length === 0}
>
{isApprovePending && (
<Loader
@@ -102,7 +292,7 @@ export function RequestVendorsInvestigateDialog({
aria-hidden="true"
/>
)}
- Request
+ 요청하기
</Button>
</DialogFooter>
</DialogContent>
@@ -124,26 +314,29 @@ export function RequestVendorsInvestigateDialog({
<DrawerHeader>
<DrawerTitle>Confirm Vendor Investigation</DrawerTitle>
<DrawerDescription>
- Are you sure you want to request{" "}
- <span className="font-medium">{vendors.length}</span>
- {vendors.length === 1 ? " vendor" : " vendors"}?
- After sent, 벤더실사담당자 will be notified and can manage it.
+ 선택한 {vendors.length}개 업체에 대한 실사 요청을 확인합니다.
+ 요청 후 협력업체실사담당자에게 알림이 전송됩니다.
</DrawerDescription>
</DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
+
+ <div className="px-4">
+ {renderContent()}
+ </div>
+
+ <DrawerFooter className="gap-2 sm:space-x-0 mt-4">
<DrawerClose asChild>
- <Button variant="outline">Cancel</Button>
+ <Button variant="outline">취소</Button>
</DrawerClose>
<Button
aria-label="Request selected vendors"
variant="default"
onClick={onApprove}
- disabled={isApprovePending}
+ disabled={isApprovePending || isLoading || vendorsWithInvestigations.withoutInvestigations.length === 0}
>
{isApprovePending && (
<Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
)}
- Request
+ 요청하기
</Button>
</DrawerFooter>
</DrawerContent>
diff --git a/lib/vendors/table/request-vendor-pg-dialog.tsx b/lib/vendors/table/request-vendor-pg-dialog.tsx
index de23ad9b..4bc4e909 100644
--- a/lib/vendors/table/request-vendor-pg-dialog.tsx
+++ b/lib/vendors/table/request-vendor-pg-dialog.tsx
@@ -29,6 +29,7 @@ import {
} from "@/components/ui/drawer"
import { Vendor } from "@/db/schema/vendors"
import { requestPQVendors } from "../service"
+import { useSession } from "next-auth/react"
interface ApprovalVendorDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -45,11 +46,21 @@ export function RequestPQVendorsDialog({
}: ApprovalVendorDialogProps) {
const [isApprovePending, startApproveTransition] = React.useTransition()
const isDesktop = useMediaQuery("(min-width: 640px)")
+ const { data: session } = useSession()
function onApprove() {
+
+ if (!session?.user?.id) {
+ toast.error("사용자 인증 정보를 찾을 수 없습니다.")
+ return
+ }
+
+
startApproveTransition(async () => {
const { error } = await requestPQVendors({
ids: vendors.map((vendor) => vendor.id),
+ userId: Number(session.user.id)
+
})
if (error) {
diff --git a/lib/vendors/table/update-vendor-sheet.tsx b/lib/vendors/table/update-vendor-sheet.tsx
index e65c4b1c..08994b6a 100644
--- a/lib/vendors/table/update-vendor-sheet.tsx
+++ b/lib/vendors/table/update-vendor-sheet.tsx
@@ -3,7 +3,25 @@
import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
-import { Loader } from "lucide-react"
+import {
+ Loader,
+ Activity,
+ AlertCircle,
+ AlertTriangle,
+ ClipboardList,
+ FilePenLine,
+ XCircle,
+ ClipboardCheck,
+ FileCheck2,
+ FileX2,
+ BadgeCheck,
+ CheckCircle2,
+ Circle as CircleIcon,
+ User,
+ Building,
+ AlignLeft,
+ Calendar
+} from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
@@ -14,6 +32,7 @@ import {
FormItem,
FormLabel,
FormMessage,
+ FormDescription
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
@@ -33,27 +52,143 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
+import { Separator } from "@/components/ui/separator"
+import { useSession } from "next-auth/react" // Import useSession
-import { Vendor } from "@/db/schema/vendors"
+import { VendorWithType, vendors } from "@/db/schema/vendors"
import { updateVendorSchema, type UpdateVendorSchema } from "../validations"
import { modifyVendor } from "../service"
-// 예: import { modifyVendor } from "@/lib/vendors/service"
interface UpdateVendorSheetProps
extends React.ComponentPropsWithRef<typeof Sheet> {
- vendor: Vendor | null
+ vendor: VendorWithType | null
+}
+type StatusType = (typeof vendors.status.enumValues)[number];
+
+type StatusConfig = {
+ Icon: React.ElementType;
+ className: string;
+ label: string;
+};
+
+// 상태 표시 유틸리티 함수
+const getStatusConfig = (status: StatusType): StatusConfig => {
+ switch(status) {
+ case "PENDING_REVIEW":
+ return {
+ Icon: ClipboardList,
+ className: "text-yellow-600",
+ label: "가입 신청 중"
+ };
+ case "IN_REVIEW":
+ return {
+ Icon: FilePenLine,
+ className: "text-blue-600",
+ label: "심사 중"
+ };
+ case "REJECTED":
+ return {
+ Icon: XCircle,
+ className: "text-red-600",
+ label: "심사 거부됨"
+ };
+ case "IN_PQ":
+ return {
+ Icon: ClipboardCheck,
+ className: "text-purple-600",
+ label: "PQ 진행 중"
+ };
+ case "PQ_SUBMITTED":
+ return {
+ Icon: FileCheck2,
+ className: "text-indigo-600",
+ label: "PQ 제출"
+ };
+ case "PQ_FAILED":
+ return {
+ Icon: FileX2,
+ className: "text-red-600",
+ label: "PQ 실패"
+ };
+ case "PQ_APPROVED":
+ return {
+ Icon: BadgeCheck,
+ className: "text-green-600",
+ label: "PQ 통과"
+ };
+ case "APPROVED":
+ return {
+ Icon: CheckCircle2,
+ className: "text-green-600",
+ label: "승인됨"
+ };
+ case "READY_TO_SEND":
+ return {
+ Icon: CheckCircle2,
+ className: "text-emerald-600",
+ label: "MDG 송부대기"
+ };
+ case "ACTIVE":
+ return {
+ Icon: Activity,
+ className: "text-emerald-600",
+ label: "활성 상태"
+ };
+ case "INACTIVE":
+ return {
+ Icon: AlertCircle,
+ className: "text-gray-600",
+ label: "비활성 상태"
+ };
+ case "BLACKLISTED":
+ return {
+ Icon: AlertTriangle,
+ className: "text-slate-800",
+ label: "거래 금지"
+ };
+ default:
+ return {
+ Icon: CircleIcon,
+ className: "text-gray-600",
+ label: status
+ };
+ }
+};
+
+// 신용평가기관 목록
+const creditAgencies = [
+ { value: "NICE", label: "NICE평가정보" },
+ { value: "KIS", label: "KIS (한국신용평가)" },
+ { value: "KED", label: "KED (한국기업데이터)" },
+ { value: "SCI", label: "SCI평가정보" },
+]
+
+// 신용등급 스케일
+const creditRatingScaleMap: Record<string, string[]> = {
+ NICE: ["AAA", "AA", "A", "BBB", "BB", "B", "C", "D"],
+ KIS: ["AAA", "AA+", "AA", "A+", "A", "BBB+", "BBB", "BB", "B", "C"],
+ KED: ["AAA", "AA", "A", "BBB", "BB", "B", "CCC", "CC", "C", "D"],
+ SCI: ["AAA", "AA+", "AA", "AA-", "A+", "A", "A-", "BBB+", "BBB-", "B"],
+}
+
+// 현금흐름등급 스케일
+const cashFlowRatingScaleMap: Record<string, string[]> = {
+ NICE: ["우수", "양호", "보통", "미흡", "불량"],
+ KIS: ["A+", "A", "B+", "B", "C", "D"],
+ KED: ["1등급", "2등급", "3등급", "4등급", "5등급"],
+ SCI: ["Level 1", "Level 2", "Level 3", "Level 4"],
}
// 폼 컴포넌트
export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) {
const [isPending, startTransition] = React.useTransition()
+ const [selectedAgency, setSelectedAgency] = React.useState<string>(vendor?.creditAgency || "NICE")
- console.log(vendor)
-
- // RHF + Zod
+ // 폼 정의 - UpdateVendorSchema 타입을 직접 사용
const form = useForm<UpdateVendorSchema>({
resolver: zodResolver(updateVendorSchema),
defaultValues: {
+ // 업체 기본 정보
vendorName: vendor?.vendorName ?? "",
vendorCode: vendor?.vendorCode ?? "",
address: vendor?.address ?? "",
@@ -61,7 +196,18 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps)
phone: vendor?.phone ?? "",
email: vendor?.email ?? "",
website: vendor?.website ?? "",
+ creditRating: vendor?.creditRating ?? "",
+ cashFlowRating: vendor?.cashFlowRating ?? "",
status: vendor?.status ?? "ACTIVE",
+ vendorTypeId: vendor?.vendorTypeId ?? undefined,
+
+ // 구매담당자 정보 (기본값은 비어있음)
+ buyerName: "",
+ buyerDepartment: "",
+ contractStartDate: undefined,
+ contractEndDate: undefined,
+ internalNotes: "",
+ // evaluationScore: "",
},
})
@@ -75,191 +221,439 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps)
phone: vendor?.phone ?? "",
email: vendor?.email ?? "",
website: vendor?.website ?? "",
+ creditRating: vendor?.creditRating ?? "",
+ cashFlowRating: vendor?.cashFlowRating ?? "",
status: vendor?.status ?? "ACTIVE",
+ vendorTypeId: vendor?.vendorTypeId ?? undefined,
+
+ // 구매담당자 필드는 유지
+ buyerName: form.getValues("buyerName"),
+ buyerDepartment: form.getValues("buyerDepartment"),
+ contractStartDate: form.getValues("contractStartDate"),
+ contractEndDate: form.getValues("contractEndDate"),
+ internalNotes: form.getValues("internalNotes"),
+ // evaluationScore: form.getValues("evaluationScore"),
});
}
}, [vendor, form]);
- console.log(form.getValues())
+ // 신용평가기관 변경 시 등급 필드를 초기화하는 효과
+ React.useEffect(() => {
+ // 선택된 평가기관에 따라 현재 선택된 등급이 유효한지 확인
+ const currentCreditRating = form.getValues("creditRating");
+ const currentCashFlowRating = form.getValues("cashFlowRating");
+
+ // 선택된 기관에 따른 유효한 등급 목록
+ const validCreditRatings = creditRatingScaleMap[selectedAgency] || [];
+ const validCashFlowRatings = cashFlowRatingScaleMap[selectedAgency] || [];
+
+ // 현재 등급이 유효하지 않으면 초기화
+ if (currentCreditRating && !validCreditRatings.includes(currentCreditRating)) {
+ form.setValue("creditRating", "");
+ }
+
+ if (currentCashFlowRating && !validCashFlowRatings.includes(currentCashFlowRating)) {
+ form.setValue("cashFlowRating", "");
+ }
+
+ // 신용평가기관 필드 업데이트
+ if(selectedAgency){
+ form.setValue("creditAgency", selectedAgency as "NICE" | "KIS" | "KED" | "SCI");
+ }
+
+ }, [selectedAgency, form]);
+
// 제출 핸들러
async function onSubmit(data: UpdateVendorSchema) {
if (!vendor) return
+ const { data: session } = useSession()
- startTransition(async () => {
- // 서버 액션 or API
- // const { error } = await modifyVendor({ id: vendor.id, ...data })
- // 여기선 간단 예시
- try {
- // 예시:
- const { error } = await modifyVendor({ id: String(vendor.id), ...data })
- if (error) throw new Error(error)
-
- toast.success("Vendor updated!")
- form.reset()
- props.onOpenChange?.(false)
- } catch (err: any) {
- toast.error(String(err))
- }
- })
- }
+ if (!session?.user?.id) {
+ toast.error("사용자 인증 정보를 찾을 수 없습니다.")
+ return
+ }
+ startTransition(async () => {
+ try {
+ // Add status change comment if status has changed
+ const oldStatus = vendor.status ?? "ACTIVE" // Default to ACTIVE if undefined
+ const newStatus = data.status ?? "ACTIVE" // Default to ACTIVE if undefined
+
+ const statusComment =
+ oldStatus !== newStatus
+ ? `상태 변경: ${getStatusConfig(oldStatus).label} → ${getStatusConfig(newStatus).label}`
+ : "" // Empty string instead of undefined
+
+ // 업체 정보 업데이트 - userId와 상태 변경 코멘트 추가
+ const { error } = await modifyVendor({
+ id: String(vendor.id),
+ userId: Number(session.user.id), // Add user ID from session
+ comment: statusComment, // Add comment for status changes
+ ...data // 모든 데이터 전달 - 서비스 함수에서 필요한 필드만 처리
+ })
+
+ if (error) throw new Error(error)
+
+ toast.success("업체 정보가 업데이트되었습니다!")
+ form.reset()
+ props.onOpenChange?.(false)
+ } catch (err: any) {
+ toast.error(String(err))
+ }
+ })
+}
return (
<Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetContent className="flex flex-col gap-6 sm:max-w-lg overflow-y-auto">
<SheetHeader className="text-left">
- <SheetTitle>Update Vendor</SheetTitle>
+ <SheetTitle>업체 정보 수정</SheetTitle>
<SheetDescription>
- Update the vendor details and save the changes
+ 업체 세부 정보를 수정하고 변경 사항을 저장하세요
</SheetDescription>
</SheetHeader>
<Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
- {/* vendorName */}
- <FormField
- control={form.control}
- name="vendorName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Vendor Name</FormLabel>
- <FormControl>
- <Input placeholder="Vendor Name" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
+ {/* 업체 기본 정보 섹션 */}
+ <div className="space-y-4">
+ <div className="flex items-center">
+ <Building className="mr-2 h-5 w-5 text-muted-foreground" />
+ <h3 className="text-sm font-medium">업체 기본 정보</h3>
+ </div>
+ <FormDescription>
+ 업체가 제공한 기본 정보입니다. 필요시 수정하세요.
+ </FormDescription>
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
+ {/* vendorName */}
+ <FormField
+ control={form.control}
+ name="vendorName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>업체명</FormLabel>
+ <FormControl>
+ <Input placeholder="업체명 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* vendorCode */}
+ <FormField
+ control={form.control}
+ name="vendorCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>업체 코드</FormLabel>
+ <FormControl>
+ <Input placeholder="예: ABC123" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* address */}
+ <FormField
+ control={form.control}
+ name="address"
+ render={({ field }) => (
+ <FormItem className="md:col-span-2">
+ <FormLabel>주소</FormLabel>
+ <FormControl>
+ <Input placeholder="주소 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* country */}
+ <FormField
+ control={form.control}
+ name="country"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>국가</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 대한민국" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* phone */}
+ <FormField
+ control={form.control}
+ name="phone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 010-1234-5678" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* email */}
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>이메일</FormLabel>
+ <FormControl>
+ <Input placeholder="예: info@company.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* website */}
+ <FormField
+ control={form.control}
+ name="website"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>웹사이트</FormLabel>
+ <FormControl>
+ <Input placeholder="예: https://www.company.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
- {/* vendorCode */}
- <FormField
- control={form.control}
- name="vendorCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Vendor Code</FormLabel>
- <FormControl>
- <Input placeholder="Code123" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ {/* status with icons */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => {
+ // 현재 선택된 상태의 구성 정보 가져오기
+ const selectedConfig = getStatusConfig(field.value ?? "ACTIVE");
+ const SelectedIcon = selectedConfig?.Icon || CircleIcon;
- {/* address */}
- <FormField
- control={form.control}
- name="address"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Address</FormLabel>
- <FormControl>
- <Input placeholder="123 Main St" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ return (
+ <FormItem>
+ <FormLabel>업체승인상태</FormLabel>
+ <FormControl>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue>
+ {field.value && (
+ <div className="flex items-center">
+ <SelectedIcon className={`mr-2 h-4 w-4 ${selectedConfig.className}`} />
+ <span>{selectedConfig.label}</span>
+ </div>
+ )}
+ </SelectValue>
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {vendors.status.enumValues.map((status) => {
+ const config = getStatusConfig(status);
+ const StatusIcon = config.Icon;
+ return (
+ <SelectItem key={status} value={status}>
+ <div className="flex items-center">
+ <StatusIcon className={`mr-2 h-4 w-4 ${config.className}`} />
+ <span>{config.label}</span>
+ </div>
+ </SelectItem>
+ );
+ })}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
- {/* country */}
- <FormField
- control={form.control}
- name="country"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Country</FormLabel>
- <FormControl>
- <Input placeholder="USA" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+
+ {/* 신용평가기관 선택 */}
+ <FormField
+ control={form.control}
+ name="creditAgency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>신용평가기관</FormLabel>
+ <FormControl>
+ <Select
+ value={field.value || ""}
+ onValueChange={(value) => {
+ field.onChange(value);
+ setSelectedAgency(value);
+ }}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue placeholder="평가기관 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {creditAgencies.map((agency) => (
+ <SelectItem key={agency.value} value={agency.value}>
+ {agency.label}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 평가년도 - 나중에 추가 가능 */}
+
+ {/* 신용등급 - 선택된 기관에 따라 옵션 변경 */}
+ <FormField
+ control={form.control}
+ name="creditRating"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>신용등급</FormLabel>
+ <FormControl>
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ disabled={!selectedAgency}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue placeholder="신용등급 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {(creditRatingScaleMap[selectedAgency] || []).map((rating) => (
+ <SelectItem key={rating} value={rating}>
+ {rating}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 현금흐름등급 - 선택된 기관에 따라 옵션 변경 */}
+ <FormField
+ control={form.control}
+ name="cashFlowRating"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>현금흐름등급</FormLabel>
+ <FormControl>
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ disabled={!selectedAgency}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue placeholder="현금흐름등급 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {(cashFlowRatingScaleMap[selectedAgency] || []).map((rating) => (
+ <SelectItem key={rating} value={rating}>
+ {rating}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
- {/* phone */}
- <FormField
- control={form.control}
- name="phone"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Phone</FormLabel>
- <FormControl>
- <Input placeholder="+1 555-1234" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ </div>
+ </div>
- {/* email */}
- <FormField
- control={form.control}
- name="email"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Email</FormLabel>
- <FormControl>
- <Input placeholder="vendor@example.com" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ {/* 구분선 */}
+ <Separator className="my-2" />
- {/* website */}
- <FormField
- control={form.control}
- name="website"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Website</FormLabel>
- <FormControl>
- <Input placeholder="https://www.vendor.com" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ {/* 구매담당자 입력 섹션 */}
+ <div className="space-y-4 bg-slate-50 p-4 rounded-md border border-slate-200">
+ <div className="flex items-center">
+ <User className="mr-2 h-5 w-5 text-blue-600" />
+ <h3 className="text-sm font-medium text-blue-800">구매담당자 정보</h3>
+ </div>
+ <FormDescription>
+ 구매담당자가 관리하는 추가 정보입니다. 이 정보는 내부용으로만 사용됩니다.
+ </FormDescription>
+
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
+ {/* 여기에 구매담당자 필드 추가 */}
+ <FormField
+ control={form.control}
+ name="buyerName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>담당자 이름</FormLabel>
+ <FormControl>
+ <Input placeholder="담당자 이름" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="buyerDepartment"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>담당 부서</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 구매부" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
- {/* status */}
- <FormField
- control={form.control}
- name="status"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Status</FormLabel>
- <FormControl>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- >
- <SelectTrigger className="capitalize">
- <SelectValue placeholder="Select a status" />
- </SelectTrigger>
- <SelectContent>
- <SelectGroup>
- {/* enum ["ACTIVE","INACTIVE","BLACKLISTED"] */}
- <SelectItem value="ACTIVE">ACTIVE</SelectItem>
- <SelectItem value="INACTIVE">INACTIVE</SelectItem>
- <SelectItem value="BLACKLISTED">BLACKLISTED</SelectItem>
- </SelectGroup>
- </SelectContent>
- </Select>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+
+ <FormField
+ control={form.control}
+ name="internalNotes"
+ render={({ field }) => (
+ <FormItem className="md:col-span-2">
+ <FormLabel>내부 메모</FormLabel>
+ <FormControl>
+ <Input placeholder="내부 참고사항을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
<SheetFooter className="gap-2 pt-2 sm:space-x-0">
<SheetClose asChild>
<Button type="button" variant="outline">
- Cancel
+ 취소
</Button>
</SheetClose>
<Button disabled={isPending}>
{isPending && (
<Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
)}
- Save
+ 저장
</Button>
</SheetFooter>
</form>
diff --git a/lib/vendors/table/vendor-all-export.ts b/lib/vendors/table/vendor-all-export.ts
new file mode 100644
index 00000000..cef801fd
--- /dev/null
+++ b/lib/vendors/table/vendor-all-export.ts
@@ -0,0 +1,486 @@
+// /lib/vendor-export.ts
+import ExcelJS from "exceljs"
+import { VendorWithType } from "@/db/schema/vendors"
+import { exportVendorDetails } from "../service";
+
+// 연락처 인터페이스 정의
+interface VendorContact {
+ contactName: string;
+ contactPosition?: string | null;
+ contactEmail: string;
+ contactPhone?: string | null;
+ isPrimary: boolean;
+}
+
+// 아이템 인터페이스 정의
+interface VendorItem {
+ itemCode: string;
+ itemName: string;
+ description?: string | null;
+ createdAt?: Date | string;
+}
+
+// RFQ 인터페이스 정의
+interface VendorRFQ {
+ rfqNumber: string;
+ title: string;
+ status: string;
+ requestDate?: Date | string | null;
+ dueDate?: Date | string | null;
+ description?: string | null;
+}
+
+// 계약 인터페이스 정의
+interface VendorContract {
+ projectCode: string;
+ projectName: string;
+ contractNo: string;
+ contractName: string;
+ status: string;
+ paymentTerms: string;
+ deliveryTerms: string;
+ deliveryDate: Date | string;
+ deliveryLocation: string;
+ startDate?: Date | string | null;
+ endDate?: Date | string | null;
+ currency: string;
+ totalAmount?: number | null;
+}
+
+// 서비스에서 반환하는 실제 데이터 구조
+interface VendorData {
+ id: number;
+ vendorName: string;
+ vendorCode: string | null;
+ taxId: string;
+ address: string | null;
+ country: string | null;
+ phone: string | null;
+ email: string | null;
+ website: string | null;
+ status: string;
+ representativeName: string | null;
+ representativeBirth: string | null;
+ representativeEmail: string | null;
+ representativePhone: string | null;
+ corporateRegistrationNumber: string | null;
+ creditAgency: string | null;
+ creditRating: string | null;
+ cashFlowRating: string | null;
+// items: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+ vendorContacts: VendorContact[];
+ vendorItems: VendorItem[];
+ vendorRfqs: VendorRFQ[];
+ vendorContracts: VendorContract[];
+}
+
+/**
+ * 선택된 벤더의 모든 관련 정보를 통합 시트 형식으로 엑셀로 내보내는 함수
+ * - 기본정보 시트
+ * - 연락처 시트
+ * - 아이템 시트
+ * - RFQ 시트
+ * - 계약 시트
+ * 각 시트에는 식별을 위한 벤더 코드, 벤더명, 세금ID가 포함됨
+ */
+export async function exportVendorsWithRelatedData(
+ vendors: VendorWithType[],
+ filename = "vendors-detailed"
+): Promise<void> {
+ if (!vendors.length) return;
+
+ // 선택된 벤더 ID 목록
+ const vendorIds = vendors.map(vendor => vendor.id);
+
+ try {
+ // 서버로부터 모든 관련 데이터 가져오기
+ const vendorsWithDetails = await exportVendorDetails(vendorIds);
+
+ if (!vendorsWithDetails.length) {
+ throw new Error("내보내기 데이터를 가져오는 중 오류가 발생했습니다.");
+ }
+
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+
+ // 데이터 타입 확인 (서비스에서 반환하는 실제 데이터 형태)
+ const vendorData = vendorsWithDetails as unknown as any[];
+
+ // ===== 1. 기본 정보 시트 =====
+ createBasicInfoSheet(workbook, vendorData);
+
+ // ===== 2. 연락처 시트 =====
+ createContactsSheet(workbook, vendorData);
+
+ // ===== 3. 아이템 시트 =====
+ createItemsSheet(workbook, vendorData);
+
+ // ===== 4. RFQ 시트 =====
+ createRFQsSheet(workbook, vendorData);
+
+ // ===== 5. 계약 시트 =====
+ createContractsSheet(workbook, vendorData);
+
+ // 파일 다운로드
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `${filename}-${new Date().toISOString().split("T")[0]}.xlsx`;
+ link.click();
+ URL.revokeObjectURL(url);
+
+ return;
+ } catch (error) {
+ console.error("Export error:", error);
+ throw error;
+ }
+}
+
+// 기본 정보 시트 생성 함수
+function createBasicInfoSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: VendorData[]
+): void {
+ const basicInfoSheet = workbook.addWorksheet("기본정보");
+
+ // 기본 정보 시트 헤더 설정
+ basicInfoSheet.columns = [
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ { header: "국가", key: "country", width: 10 },
+ { header: "상태", key: "status", width: 15 },
+ { header: "이메일", key: "email", width: 20 },
+ { header: "전화번호", key: "phone", width: 15 },
+ { header: "웹사이트", key: "website", width: 20 },
+ { header: "주소", key: "address", width: 30 },
+ { header: "대표자명", key: "representativeName", width: 15 },
+ { header: "신용등급", key: "creditRating", width: 10 },
+ { header: "현금흐름등급", key: "cashFlowRating", width: 10 },
+ { header: "생성일", key: "createdAt", width: 15 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(basicInfoSheet);
+
+ // 벤더 데이터 추가
+ vendors.forEach((vendor: VendorData) => {
+ basicInfoSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ country: vendor.country,
+ status: getStatusText(vendor.status), // 상태 코드를 읽기 쉬운 텍스트로 변환
+ email: vendor.email,
+ phone: vendor.phone,
+ website: vendor.website,
+ address: vendor.address,
+ representativeName: vendor.representativeName,
+ creditRating: vendor.creditRating,
+ cashFlowRating: vendor.cashFlowRating,
+ createdAt: vendor.createdAt ? formatDate(vendor.createdAt) : "",
+ });
+ });
+}
+
+// 연락처 시트 생성 함수
+function createContactsSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: VendorData[]
+): void {
+ const contactsSheet = workbook.addWorksheet("연락처");
+
+ contactsSheet.columns = [
+ // 벤더 식별 정보
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ // 연락처 정보
+ { header: "이름", key: "contactName", width: 15 },
+ { header: "직책", key: "contactPosition", width: 15 },
+ { header: "이메일", key: "contactEmail", width: 25 },
+ { header: "전화번호", key: "contactPhone", width: 15 },
+ { header: "주요 연락처", key: "isPrimary", width: 10 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(contactsSheet);
+
+ // 벤더별 연락처 데이터 추가
+ vendors.forEach((vendor: VendorData) => {
+ if (vendor.vendorContacts && vendor.vendorContacts.length > 0) {
+ vendor.vendorContacts.forEach((contact: VendorContact) => {
+ contactsSheet.addRow({
+ // 벤더 식별 정보
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ // 연락처 정보
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || "",
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || "",
+ isPrimary: contact.isPrimary ? "예" : "아니오",
+ });
+ });
+ } else {
+ // 연락처가 없는 경우에도 벤더 정보만 추가
+ contactsSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ contactName: "",
+ contactPosition: "",
+ contactEmail: "",
+ contactPhone: "",
+ isPrimary: "",
+ });
+ }
+ });
+}
+
+// 아이템 시트 생성 함수
+function createItemsSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: VendorData[]
+): void {
+ const itemsSheet = workbook.addWorksheet("아이템");
+
+ itemsSheet.columns = [
+ // 벤더 식별 정보
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ // 아이템 정보
+ { header: "아이템 코드", key: "itemCode", width: 15 },
+ { header: "아이템명", key: "itemName", width: 25 },
+ { header: "설명", key: "description", width: 30 },
+ { header: "등록일", key: "createdAt", width: 15 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(itemsSheet);
+
+ // 벤더별 아이템 데이터 추가
+ vendors.forEach((vendor: VendorData) => {
+ if (vendor.vendorItems && vendor.vendorItems.length > 0) {
+ vendor.vendorItems.forEach((item: VendorItem) => {
+ itemsSheet.addRow({
+ // 벤더 식별 정보
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ // 아이템 정보
+ itemCode: item.itemCode,
+ itemName: item.itemName,
+ description: item.description || "",
+ createdAt: item.createdAt ? formatDate(item.createdAt) : "",
+ });
+ });
+ } else {
+ // 아이템이 없는 경우에도 벤더 정보만 추가
+ itemsSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ itemCode: "",
+ itemName: "",
+ description: "",
+ createdAt: "",
+ });
+ }
+ });
+}
+
+// RFQ 시트 생성 함수
+function createRFQsSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: VendorData[]
+): void {
+ const rfqsSheet = workbook.addWorksheet("RFQ");
+
+ rfqsSheet.columns = [
+ // 벤더 식별 정보
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ // RFQ 정보
+ { header: "RFQ 번호", key: "rfqNumber", width: 15 },
+ { header: "제목", key: "title", width: 25 },
+ { header: "상태", key: "status", width: 15 },
+ { header: "요청일", key: "requestDate", width: 15 },
+ { header: "마감일", key: "dueDate", width: 15 },
+ { header: "설명", key: "description", width: 30 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(rfqsSheet);
+
+ // 벤더별 RFQ 데이터 추가
+ vendors.forEach((vendor: VendorData) => {
+ if (vendor.vendorRfqs && vendor.vendorRfqs.length > 0) {
+ vendor.vendorRfqs.forEach((rfq: VendorRFQ) => {
+ rfqsSheet.addRow({
+ // 벤더 식별 정보
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ // RFQ 정보
+ rfqNumber: rfq.rfqNumber,
+ title: rfq.title,
+ status: rfq.status,
+ requestDate: rfq.requestDate ? formatDate(rfq.requestDate) : "",
+ dueDate: rfq.dueDate ? formatDate(rfq.dueDate) : "",
+ description: rfq.description || "",
+ });
+ });
+ } else {
+ // RFQ가 없는 경우에도 벤더 정보만 추가
+ rfqsSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ rfqNumber: "",
+ title: "",
+ status: "",
+ requestDate: "",
+ dueDate: "",
+ description: "",
+ });
+ }
+ });
+}
+
+// 계약 시트 생성 함수
+function createContractsSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: VendorData[]
+): void {
+ const contractsSheet = workbook.addWorksheet("계약");
+
+ contractsSheet.columns = [
+ // 벤더 식별 정보
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ // 계약 정보
+ { header: "프로젝트 코드", key: "projectCode", width: 15 },
+ { header: "프로젝트명", key: "projectName", width: 20 },
+ { header: "계약 번호", key: "contractNo", width: 15 },
+ { header: "계약명", key: "contractName", width: 25 },
+ { header: "상태", key: "status", width: 15 },
+ { header: "지급 조건", key: "paymentTerms", width: 15 },
+ { header: "납품 조건", key: "deliveryTerms", width: 15 },
+ { header: "납품 일자", key: "deliveryDate", width: 15 },
+ { header: "납품 위치", key: "deliveryLocation", width: 20 },
+ { header: "계약 시작일", key: "startDate", width: 15 },
+ { header: "계약 종료일", key: "endDate", width: 15 },
+ { header: "통화", key: "currency", width: 10 },
+ { header: "총액", key: "totalAmount", width: 15 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(contractsSheet);
+
+ // 벤더별 계약 데이터 추가
+ vendors.forEach((vendor: VendorData) => {
+ if (vendor.vendorContracts && vendor.vendorContracts.length > 0) {
+ vendor.vendorContracts.forEach((contract: VendorContract) => {
+ contractsSheet.addRow({
+ // 벤더 식별 정보
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ // 계약 정보
+ projectCode: contract.projectCode,
+ projectName: contract.projectName,
+ contractNo: contract.contractNo,
+ contractName: contract.contractName,
+ status: contract.status,
+ paymentTerms: contract.paymentTerms,
+ deliveryTerms: contract.deliveryTerms,
+ deliveryDate: contract.deliveryDate ? formatDate(contract.deliveryDate) : "",
+ deliveryLocation: contract.deliveryLocation,
+ startDate: contract.startDate ? formatDate(contract.startDate) : "",
+ endDate: contract.endDate ? formatDate(contract.endDate) : "",
+ currency: contract.currency,
+ totalAmount: contract.totalAmount ? formatAmount(contract.totalAmount) : "",
+ });
+ });
+ } else {
+ // 계약이 없는 경우에도 벤더 정보만 추가
+ contractsSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ projectCode: "",
+ projectName: "",
+ contractNo: "",
+ contractName: "",
+ status: "",
+ paymentTerms: "",
+ deliveryTerms: "",
+ deliveryDate: "",
+ deliveryLocation: "",
+ startDate: "",
+ endDate: "",
+ currency: "",
+ totalAmount: "",
+ });
+ }
+ });
+}
+
+// 헤더 스타일 적용 함수
+function applyHeaderStyle(sheet: ExcelJS.Worksheet): void {
+ const headerRow = sheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.alignment = { horizontal: "center" };
+ headerRow.eachCell((cell: ExcelJS.Cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ };
+ });
+}
+
+// 날짜 포맷 함수
+function formatDate(date: Date | string): string {
+ if (!date) return "";
+ if (typeof date === 'string') {
+ date = new Date(date);
+ }
+ return date.toISOString().split('T')[0];
+}
+
+// 금액 포맷 함수
+function formatAmount(amount: number): string {
+ return amount.toLocaleString();
+}
+
+// 상태 코드를 읽기 쉬운 텍스트로 변환하는 함수
+function getStatusText(status: string): string {
+ const statusMap: Record<string, string> = {
+ "PENDING_REVIEW": "검토 대기중",
+ "IN_REVIEW": "검토 중",
+ "REJECTED": "거부됨",
+ "IN_PQ": "PQ 진행 중",
+ "PQ_SUBMITTED": "PQ 제출됨",
+ "PQ_FAILED": "PQ 실패",
+ "PQ_APPROVED": "PQ 승인됨",
+ "APPROVED": "승인됨",
+ "READY_TO_SEND": "발송 준비됨",
+ "ACTIVE": "활성",
+ "INACTIVE": "비활성",
+ "BLACKLISTED": "거래 금지"
+ };
+
+ return statusMap[status] || status;
+} \ No newline at end of file
diff --git a/lib/vendors/table/vendors-table-columns.tsx b/lib/vendors/table/vendors-table-columns.tsx
index 77750c47..c768b587 100644
--- a/lib/vendors/table/vendors-table-columns.tsx
+++ b/lib/vendors/table/vendors-table-columns.tsx
@@ -27,30 +27,41 @@ import {
import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
import { useRouter } from "next/navigation"
-import { Vendor, vendors, VendorWithAttachments } from "@/db/schema/vendors"
+import { VendorWithType, vendors, VendorWithAttachments } from "@/db/schema/vendors"
import { modifyVendor } from "../service"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { vendorColumnsConfig } from "@/config/vendorColumnsConfig"
import { Separator } from "@/components/ui/separator"
import { AttachmentsButton } from "./attachmentButton"
+import { getVendorStatusIcon } from "../utils"
+// 타입 정의 추가
+type StatusType = (typeof vendors.status.enumValues)[number];
+type BadgeVariantType = "default" | "secondary" | "destructive" | "outline";
+type StatusConfig = {
+ variant: BadgeVariantType;
+ className: string;
+};
+type StatusDisplayMap = {
+ [key in StatusType]: string;
+};
type NextRouter = ReturnType<typeof useRouter>;
-
interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Vendor> | null>>;
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorWithType> | null>>;
router: NextRouter;
+ userId: number;
}
/**
* tanstack table 컬럼 정의 (중첩 헤더 버전)
*/
-export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<Vendor>[] {
+export function getColumns({ setRowAction, router, userId }: GetColumnsProps): ColumnDef<VendorWithType>[] {
// ----------------------------------------------------------------
// 1) select 컬럼 (체크박스)
// ----------------------------------------------------------------
- const selectColumn: ColumnDef<Vendor> = {
+ const selectColumn: ColumnDef<VendorWithType> = {
id: "select",
header: ({ table }) => (
<Checkbox
@@ -79,102 +90,103 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
// ----------------------------------------------------------------
// 2) actions 컬럼 (Dropdown 메뉴)
// ----------------------------------------------------------------
-// ----------------------------------------------------------------
-// 2) actions 컬럼 (Dropdown 메뉴)
-// ----------------------------------------------------------------
-const actionsColumn: ColumnDef<Vendor> = {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
- const isApproved = row.original.status === "APPROVED";
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-56">
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
- >
- Edit
- </DropdownMenuItem>
- <DropdownMenuItem
- onSelect={() => {
- // 1) 만약 rowAction을 열고 싶다면
- // setRowAction({ row, type: "update" })
-
- // 2) 자세히 보기 페이지로 클라이언트 라우팅
- router.push(`/evcp/vendors/${row.original.id}/info`);
- }}
- >
- Details
- </DropdownMenuItem>
-
- {/* APPROVED 상태일 때만 추가 정보 기입 메뉴 표시 */}
- {isApproved && (
+ const actionsColumn: ColumnDef<VendorWithType> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const isApproved = row.original.status === "PQ_APPROVED";
+ const afterApproved = row.original.status === "ACTIVE";
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ {(isApproved ||afterApproved) && (
<DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "requestInfo" })}
- className="text-blue-600 font-medium"
+ onSelect={() => setRowAction({ row, type: "update" })}
>
- 추가 정보 기입
+ 레코드 편집
</DropdownMenuItem>
- )}
-
- <Separator />
- <DropdownMenuSub>
- <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger>
- <DropdownMenuSubContent>
- <DropdownMenuRadioGroup
- value={row.original.status}
- onValueChange={(value) => {
- startUpdateTransition(() => {
- toast.promise(
- modifyVendor({
- id: String(row.original.id),
- status: value as Vendor["status"],
- }),
- {
- loading: "Updating...",
- success: "Label updated",
- error: (err) => getErrorMessage(err),
- }
- )
- })
- }}
- >
- {vendors.status.enumValues.map((status) => (
- <DropdownMenuRadioItem
- key={status}
- value={status}
- className="capitalize"
- disabled={isUpdatePending}
- >
- {status}
- </DropdownMenuRadioItem>
- ))}
- </DropdownMenuRadioGroup>
- </DropdownMenuSubContent>
- </DropdownMenuSub>
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
-}
+ )}
+
+ <DropdownMenuItem
+ onSelect={() => {
+ // 1) 만약 rowAction을 열고 싶다면
+ // setRowAction({ row, type: "update" })
+
+ // 2) 자세히 보기 페이지로 클라이언트 라우팅
+ router.push(`/evcp/vendors/${row.original.id}/info`);
+ }}
+ >
+ 상세보기
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "log" })}
+ >
+ 감사 로그 보기
+ </DropdownMenuItem>
+
+ <Separator />
+ <DropdownMenuSub>
+ <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger>
+ <DropdownMenuSubContent>
+ <DropdownMenuRadioGroup
+ value={row.original.status}
+ onValueChange={(value) => {
+ startUpdateTransition(() => {
+ toast.promise(
+ modifyVendor({
+ id: String(row.original.id),
+ status: value as VendorWithType["status"],
+ userId,
+ vendorName: row.original.vendorName, // Required field from UpdateVendorSchema
+ comment: `Status changed to ${value}`
+ }),
+ {
+ loading: "Updating...",
+ success: "Label updated",
+ error: (err) => getErrorMessage(err),
+ }
+ )
+ })
+ }}
+ >
+ {vendors.status.enumValues.map((status) => (
+ <DropdownMenuRadioItem
+ key={status}
+ value={status}
+ className="capitalize"
+ disabled={isUpdatePending}
+ >
+ {status}
+ </DropdownMenuRadioItem>
+ ))}
+ </DropdownMenuRadioGroup>
+ </DropdownMenuSubContent>
+ </DropdownMenuSub>
+
+
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
// ----------------------------------------------------------------
// 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
// ----------------------------------------------------------------
- // 3-1) groupMap: { [groupName]: ColumnDef<Vendor>[] }
- const groupMap: Record<string, ColumnDef<Vendor>[]> = {}
+ // 3-1) groupMap: { [groupName]: ColumnDef<VendorWithType>[] }
+ const groupMap: Record<string, ColumnDef<VendorWithType>[]> = {}
vendorColumnsConfig.forEach((cfg) => {
// 만약 group가 없으면 "_noGroup" 처리
@@ -185,7 +197,7 @@ const actionsColumn: ColumnDef<Vendor> = {
}
// child column 정의
- const childCol: ColumnDef<Vendor> = {
+ const childCol: ColumnDef<VendorWithType> = {
accessorKey: cfg.id,
enableResizing: true,
header: ({ column }) => (
@@ -197,20 +209,158 @@ const actionsColumn: ColumnDef<Vendor> = {
type: cfg.type,
},
cell: ({ row, cell }) => {
+ // Status 컬럼 렌더링 개선 - 아이콘과 더 선명한 배경색 사용
+ if (cfg.id === "status") {
+ const statusVal = row.original.status as StatusType;
+ if (!statusVal) return null;
+ // Status badge variant mapping - 더 뚜렷한 색상으로 변경
+ const getStatusConfig = (status: StatusType): StatusConfig & { iconColor: string } => {
+ switch (status) {
+ case "PENDING_REVIEW":
+ return {
+ variant: "outline",
+ className: "bg-yellow-100 text-yellow-800 border-yellow-300",
+ iconColor: "text-yellow-600"
+ };
+ case "IN_REVIEW":
+ return {
+ variant: "outline",
+ className: "bg-blue-100 text-blue-800 border-blue-300",
+ iconColor: "text-blue-600"
+ };
+ case "REJECTED":
+ return {
+ variant: "outline",
+ className: "bg-red-100 text-red-800 border-red-300",
+ iconColor: "text-red-600"
+ };
+ case "IN_PQ":
+ return {
+ variant: "outline",
+ className: "bg-purple-100 text-purple-800 border-purple-300",
+ iconColor: "text-purple-600"
+ };
+ case "PQ_SUBMITTED":
+ return {
+ variant: "outline",
+ className: "bg-indigo-100 text-indigo-800 border-indigo-300",
+ iconColor: "text-indigo-600"
+ };
+ case "PQ_FAILED":
+ return {
+ variant: "outline",
+ className: "bg-red-100 text-red-800 border-red-300",
+ iconColor: "text-red-600"
+ };
+ case "PQ_APPROVED":
+ return {
+ variant: "outline",
+ className: "bg-green-100 text-green-800 border-green-300",
+ iconColor: "text-green-600"
+ };
+ case "APPROVED":
+ return {
+ variant: "outline",
+ className: "bg-green-100 text-green-800 border-green-300",
+ iconColor: "text-green-600"
+ };
+ case "READY_TO_SEND":
+ return {
+ variant: "outline",
+ className: "bg-emerald-100 text-emerald-800 border-emerald-300",
+ iconColor: "text-emerald-600"
+ };
+ case "ACTIVE":
+ return {
+ variant: "outline",
+ className: "bg-emerald-100 text-emerald-800 border-emerald-300 font-semibold",
+ iconColor: "text-emerald-600"
+ };
+ case "INACTIVE":
+ return {
+ variant: "outline",
+ className: "bg-gray-100 text-gray-800 border-gray-300",
+ iconColor: "text-gray-600"
+ };
+ case "BLACKLISTED":
+ return {
+ variant: "outline",
+ className: "bg-slate-800 text-white border-slate-900",
+ iconColor: "text-white"
+ };
+ default:
+ return {
+ variant: "outline",
+ className: "bg-gray-100 text-gray-800 border-gray-300",
+ iconColor: "text-gray-600"
+ };
+ }
+ };
+
+ // Translate status for display
+ const getStatusDisplay = (status: StatusType): string => {
+ const statusMap: StatusDisplayMap = {
+ "PENDING_REVIEW": "가입 신청 중",
+ "IN_REVIEW": "심사 중",
+ "REJECTED": "심사 거부됨",
+ "IN_PQ": "PQ 진행 중",
+ "PQ_SUBMITTED": "PQ 제출",
+ "PQ_FAILED": "PQ 실패",
+ "PQ_APPROVED": "PQ 통과",
+ "APPROVED": "승인됨",
+ "READY_TO_SEND": "MDG 송부대기",
+ "ACTIVE": "활성 상태",
+ "INACTIVE": "비활성 상태",
+ "BLACKLISTED": "거래 금지"
+ };
+
+ return statusMap[status] || status;
+ };
+
+ const config = getStatusConfig(statusVal);
+ const displayText = getStatusDisplay(statusVal);
+ const StatusIcon = getVendorStatusIcon(statusVal);
- if (cfg.id === "status") {
- const statusVal = row.original.status
- if (!statusVal) return null
- // const Icon = getStatusIcon(statusVal)
return (
- <div className="flex w-[6.25rem] items-center">
- {/* <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> */}
- <span className="capitalize">{statusVal}</span>
- </div>
- )
+ <Badge variant={config.variant} className={`flex items-center px-2 py-1 ${config.className}`}>
+ <StatusIcon className={`mr-1 h-3.5 w-3.5 ${config.iconColor}`} />
+ <span>{displayText}</span>
+ </Badge>
+ );
}
+ // 업체 유형 컬럼 처리
+ if (cfg.id === "vendorTypeName") {
+ const typeVal = row.original.vendorTypeName as string | null;
+ return typeVal ? (
+ <span className="text-sm font-medium">
+ {typeVal}
+ </span>
+ ) : (
+ <span className="text-sm text-gray-400">미지정</span>
+ );
+ }
+
+ // 업체 분류 컬럼 처리 (별도로 표시하고 싶은 경우)
+ if (cfg.id === "vendorCategory") {
+ const categoryVal = row.original.vendorCategory as string | null;
+ if (!categoryVal) return null;
+
+ let badgeClass = "";
+
+ if (categoryVal === "정규업체") {
+ badgeClass = "bg-green-50 text-green-700 border-green-200";
+ } else if (categoryVal === "잠재업체") {
+ badgeClass = "bg-blue-50 text-blue-700 border-blue-200";
+ }
+
+ return (
+ <Badge variant="outline" className={badgeClass}>
+ {categoryVal}
+ </Badge>
+ );
+ }
if (cfg.id === "createdAt") {
const dateVal = cell.getValue() as Date
@@ -222,10 +372,10 @@ const actionsColumn: ColumnDef<Vendor> = {
return formatDate(dateVal)
}
-
// code etc...
return row.getValue(cfg.id) ?? ""
},
+ minSize: 150
}
groupMap[groupName].push(childCol)
@@ -234,7 +384,7 @@ const actionsColumn: ColumnDef<Vendor> = {
// ----------------------------------------------------------------
// 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
// ----------------------------------------------------------------
- const nestedColumns: ColumnDef<Vendor>[] = []
+ const nestedColumns: ColumnDef<VendorWithType>[] = []
// 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
// 여기서는 그냥 Object.entries 순서
@@ -252,34 +402,35 @@ const actionsColumn: ColumnDef<Vendor> = {
}
})
- const attachmentsColumn: ColumnDef<VendorWithAttachments> = {
+ // attachments 컬럼 타입 문제 해결을 위한 타입 단언
+ const attachmentsColumn: ColumnDef<VendorWithType> = {
id: "attachments",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="" />
),
cell: ({ row }) => {
// hasAttachments 및 attachmentsList 속성이 추가되었다고 가정
- const hasAttachments = row.original.hasAttachments;
- const attachmentsList = row.original.attachmentsList || [];
-
- if(hasAttachments){
+ const hasAttachments = (row.original as VendorWithAttachments).hasAttachments;
+ const attachmentsList = (row.original as VendorWithAttachments).attachmentsList || [];
- // 서버 액션을 사용하는 컴포넌트로 교체
- return (
- <AttachmentsButton
- vendorId={row.original.id}
- hasAttachments={hasAttachments}
- attachmentsList={attachmentsList}
- />
- );}{
- return null
+ if (hasAttachments) {
+ // 서버 액션을 사용하는 컴포넌트로 교체
+ return (
+ <AttachmentsButton
+ vendorId={row.original.id}
+ hasAttachments={hasAttachments}
+ attachmentsList={attachmentsList}
+ />
+ );
+ } else {
+ return null;
}
},
enableSorting: false,
enableHiding: false,
minSize: 45,
};
-
+
// ----------------------------------------------------------------
// 4) 최종 컬럼 배열: select, nestedColumns, actions
diff --git a/lib/vendors/table/vendors-table-toolbar-actions.tsx b/lib/vendors/table/vendors-table-toolbar-actions.tsx
index 3cb2c552..1c788911 100644
--- a/lib/vendors/table/vendors-table-toolbar-actions.tsx
+++ b/lib/vendors/table/vendors-table-toolbar-actions.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Download, Upload, Check, BuildingIcon } from "lucide-react"
+import { Download, FileSpreadsheet, Upload, Check, BuildingIcon, FileText } from "lucide-react"
import { toast } from "sonner"
import { exportTableToExcel } from "@/lib/export"
@@ -11,25 +11,29 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
-import { Vendor } from "@/db/schema/vendors"
+import { VendorWithType } from "@/db/schema/vendors"
import { ApproveVendorsDialog } from "./approve-vendor-dialog"
import { RequestPQVendorsDialog } from "./request-vendor-pg-dialog"
import { RequestProjectPQDialog } from "./request-project-pq-dialog"
import { SendVendorsDialog } from "./send-vendor-dialog"
import { RequestVendorsInvestigateDialog } from "./request-vendor-investigate-dialog"
import { RequestInfoDialog } from "./request-additional-Info-dialog"
+import { RequestContractDialog } from "./request-basicContract-dialog"
+import { exportVendorsWithRelatedData } from "./vendor-all-export"
interface VendorsTableToolbarActionsProps {
- table: Table<Vendor>
+ table: Table<VendorWithType>
}
export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) {
+ const [isExporting, setIsExporting] = React.useState(false);
// 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
const fileInputRef = React.useRef<HTMLInputElement>(null)
- // 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링
+ // 선택된 협력업체 중 PENDING_REVIEW 상태인 협력업체만 필터링
const pendingReviewVendors = React.useMemo(() => {
return table
.getFilteredSelectedRowModel()
@@ -38,7 +42,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
.filter(vendor => vendor.status === "PENDING_REVIEW");
}, [table.getFilteredSelectedRowModel().rows]);
- // 선택된 벤더 중 IN_REVIEW 상태인 벤더만 필터링
+ // 선택된 협력업체 중 IN_REVIEW 상태인 협력업체만 필터링
const inReviewVendors = React.useMemo(() => {
return table
.getFilteredSelectedRowModel()
@@ -71,7 +75,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
.filter(vendor => vendor.status === "PQ_APPROVED");
}, [table.getFilteredSelectedRowModel().rows]);
- // 프로젝트 PQ를 보낼 수 있는 벤더 상태 필터링
+ // 프로젝트 PQ를 보낼 수 있는 협력업체 상태 필터링
const projectPQEligibleVendors = React.useMemo(() => {
return table
.getFilteredSelectedRowModel()
@@ -81,10 +85,66 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
["PENDING_REVIEW", "IN_REVIEW", "IN_PQ", "PQ_APPROVED", "APPROVED", "READY_TO_SEND", "ACTIVE"].includes(vendor.status)
);
}, [table.getFilteredSelectedRowModel().rows]);
+
+ // 선택된 모든 벤더 가져오기
+ const selectedVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original);
+ }, [table.getFilteredSelectedRowModel().rows]);
+
+ // 테이블의 모든 벤더 가져오기 (필터링된 결과)
+ const allFilteredVendors = React.useMemo(() => {
+ return table
+ .getFilteredRowModel()
+ .rows
+ .map(row => row.original);
+ }, [table.getFilteredRowModel().rows]);
+
+ // 선택된 벤더 통합 내보내기 함수 실행
+ const handleSelectedExport = async () => {
+ if (selectedVendors.length === 0) {
+ toast.warning("내보낼 협력업체를 선택해주세요.");
+ return;
+ }
+
+ try {
+ setIsExporting(true);
+ toast.info(`선택된 ${selectedVendors.length}개 업체의 정보를 내보내는 중입니다...`);
+ await exportVendorsWithRelatedData(selectedVendors, "selected-vendors-detailed");
+ toast.success(`${selectedVendors.length}개 업체 정보 내보내기가 완료되었습니다.`);
+ } catch (error) {
+ console.error("상세 정보 내보내기 오류:", error);
+ toast.error("상세 정보 내보내기 중 오류가 발생했습니다.");
+ } finally {
+ setIsExporting(false);
+ }
+ };
+
+ // 모든 벤더 통합 내보내기 함수 실행
+ const handleAllFilteredExport = async () => {
+ if (allFilteredVendors.length === 0) {
+ toast.warning("내보낼 협력업체가 없습니다.");
+ return;
+ }
+
+ try {
+ setIsExporting(true);
+ toast.info(`총 ${allFilteredVendors.length}개 업체의 정보를 내보내는 중입니다...`);
+ await exportVendorsWithRelatedData(allFilteredVendors, "all-vendors-detailed");
+ toast.success(`${allFilteredVendors.length}개 업체 정보 내보내기가 완료되었습니다.`);
+ } catch (error) {
+ console.error("상세 정보 내보내기 오류:", error);
+ toast.error("상세 정보 내보내기 중 오류가 발생했습니다.");
+ } finally {
+ setIsExporting(false);
+ }
+ };
return (
<div className="flex items-center gap-2">
- {/* 승인 다이얼로그: PENDING_REVIEW 상태인 벤더가 있을 때만 표시 */}
+ {/* 승인 다이얼로그: PENDING_REVIEW 상태인 협력업체가 있을 때만 표시 */}
{pendingReviewVendors.length > 0 && (
<ApproveVendorsDialog
vendors={pendingReviewVendors}
@@ -92,7 +152,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
/>
)}
- {/* 일반 PQ 요청: IN_REVIEW 상태인 벤더가 있을 때만 표시 */}
+ {/* 일반 PQ 요청: IN_REVIEW 상태인 협력업체가 있을 때만 표시 */}
{inReviewVendors.length > 0 && (
<RequestPQVendorsDialog
vendors={inReviewVendors}
@@ -100,7 +160,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
/>
)}
- {/* 프로젝트 PQ 요청: 적격 상태의 벤더가 있을 때만 표시 */}
+ {/* 프로젝트 PQ 요청: 적격 상태의 협력업체가 있을 때만 표시 */}
{projectPQEligibleVendors.length > 0 && (
<RequestProjectPQDialog
vendors={projectPQEligibleVendors}
@@ -109,13 +169,13 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
)}
{approvedVendors.length > 0 && (
- <RequestInfoDialog
+ <RequestContractDialog
vendors={approvedVendors}
onSuccess={() => table.toggleAllRowsSelected(false)}
/>
)}
- {sendVendors.length > 0 && (
+ {pqApprovedVendors.length > 0 && (
<RequestInfoDialog
vendors={sendVendors}
onSuccess={() => table.toggleAllRowsSelected(false)}
@@ -129,21 +189,63 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
/>
)}
- {/** 4) Export 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() =>
- exportTableToExcel(table, {
- filename: "vendors",
- excludeColumns: ["select", "actions"],
- })
- }
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
- </Button>
+ {/* Export 드롭다운 메뉴로 변경 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ disabled={isExporting}
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ {isExporting ? "내보내는 중..." : "Export"}
+ </span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ {/* 기본 내보내기 - 현재 테이블에 보이는 데이터만 */}
+ <DropdownMenuItem
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "vendors",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ disabled={isExporting}
+ >
+ <FileText className="mr-2 size-4" />
+ <span>현재 테이블 데이터 내보내기</span>
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+
+ {/* 선택된 벤더만 상세 내보내기 */}
+ <DropdownMenuItem
+ onClick={handleSelectedExport}
+ disabled={selectedVendors.length === 0 || isExporting}
+ >
+ <FileSpreadsheet className="mr-2 size-4" />
+ <span>선택한 업체 상세 정보 내보내기</span>
+ {selectedVendors.length > 0 && (
+ <span className="ml-1 text-xs text-muted-foreground">({selectedVendors.length}개)</span>
+ )}
+ </DropdownMenuItem>
+
+ {/* 모든 필터링된 벤더 상세 내보내기 */}
+ <DropdownMenuItem
+ onClick={handleAllFilteredExport}
+ disabled={allFilteredVendors.length === 0 || isExporting}
+ >
+ <Download className="mr-2 size-4" />
+ <span>모든 업체 상세 정보 내보내기</span>
+ {allFilteredVendors.length > 0 && (
+ <span className="ml-1 text-xs text-muted-foreground">({allFilteredVendors.length}개)</span>
+ )}
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
</div>
)
} \ No newline at end of file
diff --git a/lib/vendors/table/vendors-table.tsx b/lib/vendors/table/vendors-table.tsx
index 36fd45bd..02768f32 100644
--- a/lib/vendors/table/vendors-table.tsx
+++ b/lib/vendors/table/vendors-table.tsx
@@ -8,19 +8,18 @@ import type {
DataTableRowAction,
} from "@/types/table"
-import { toSentenceCase } from "@/lib/utils"
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 { useFeatureFlags } from "./feature-flags-provider"
import { getColumns } from "./vendors-table-columns"
import { getVendors, getVendorStatusCounts } from "../service"
-import { Vendor, vendors } from "@/db/schema/vendors"
+import { VendorWithType, vendors } from "@/db/schema/vendors"
import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions"
-import { VendorsTableFloatingBar } from "./vendors-table-floating-bar"
-import { UpdateTaskSheet } from "@/lib/tasks/table/update-task-sheet"
import { UpdateVendorSheet } from "./update-vendor-sheet"
import { getVendorStatusIcon } from "@/lib/vendors/utils"
+import { ViewVendorLogsDialog } from "./view-vendors_logs-dialog"
+import { useSession } from "next-auth/react"
interface VendorsTableProps {
promises: Promise<
@@ -32,58 +31,83 @@ interface VendorsTableProps {
}
export function VendorsTable({ promises }: VendorsTableProps) {
- const { featureFlags } = useFeatureFlags()
-
+ const { data: session } = useSession()
+ const userId = Number(session?.user.id)
+
// Suspense로 받아온 데이터
const [{ data, pageCount }, statusCounts] = React.use(promises)
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<Vendor> | null>(null)
-
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithType> | null>(null)
+
// **router** 획득
const router = useRouter()
-
+
// getColumns() 호출 시, router를 주입
const columns = React.useMemo(
- () => getColumns({ setRowAction, router }),
- [setRowAction, router]
+ () => getColumns({ setRowAction, router , userId}),
+ [setRowAction, router, userId]
)
-
- const filterFields: DataTableFilterField<Vendor>[] = [
+
+ // 상태 한글 변환 유틸리티 함수
+ const getStatusDisplay = (status: string): string => {
+ const statusMap: Record<string, string> = {
+ "PENDING_REVIEW": "가입 신청 중",
+ "IN_REVIEW": "심사 중",
+ "REJECTED": "심사 거부됨",
+ "IN_PQ": "PQ 진행 중",
+ "PQ_SUBMITTED": "PQ 제출",
+ "PQ_FAILED": "PQ 실패",
+ "PQ_APPROVED": "PQ 통과",
+ "APPROVED": "승인됨",
+ "READY_TO_SEND": "MDG 송부대기",
+ "ACTIVE": "활성 상태",
+ "INACTIVE": "비활성 상태",
+ "BLACKLISTED": "거래 금지"
+ };
+
+ return statusMap[status] || status;
+ };
+
+ const filterFields: DataTableFilterField<VendorWithType>[] = [
{
id: "status",
- label: "Status",
+ label: "상태",
options: vendors.status.enumValues.map((status) => ({
- label: toSentenceCase(status),
+ label: getStatusDisplay(status),
value: status,
count: statusCounts[status],
})),
},
-
- { id: "vendorCode", label: "Vendor Code" },
-
+
+ { id: "vendorCode", label: "업체 코드" },
]
-
- const advancedFilterFields: DataTableAdvancedFilterField<Vendor>[] = [
- { id: "vendorName", label: "Vendor Name", type: "text" },
- { id: "vendorCode", label: "Vendor Code", type: "text" },
- { id: "email", label: "Email", type: "text" },
- { id: "country", label: "Country", type: "text" },
+
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorWithType>[] = [
+ { id: "vendorName", label: "업체명", type: "text" },
+ { id: "vendorCode", label: "업체코드", type: "text" },
+ { id: "email", label: "이메일", type: "text" },
+ { id: "country", label: "국가", type: "text" },
+ { id: "vendorTypeName", label: "업체 유형", type: "text" },
+ { id: "vendorCategory", label: "업체 분류", type: "select", options: [
+ { label: "정규업체", value: "정규업체" },
+ { label: "잠재업체", value: "잠재업체" },
+ ]},
{
id: "status",
- label: "Status",
+ label: "업체승인상태",
type: "multi-select",
options: vendors.status.enumValues.map((status) => ({
- label: (status),
+ label: getStatusDisplay(status),
value: status,
count: statusCounts[status],
icon: getVendorStatusIcon(status),
-
})),
},
- { id: "createdAt", label: "Created at", type: "date" },
- { id: "updatedAt", label: "Updated at", type: "date" },
+ { id: "createdAt", label: "등록일", type: "date" },
+ { id: "updatedAt", label: "수정일", type: "date" },
]
-
+
const { table } = useDataTable({
data,
columns,
@@ -100,16 +124,25 @@ export function VendorsTable({ promises }: VendorsTableProps) {
clearOnDefault: true,
})
+ const handleCompactChange = React.useCallback((compact: boolean) => {
+ setIsCompact(compact)
+ }, [])
+
+
return (
<>
<DataTable
table={table}
+ compact={isCompact}
// floatingBar={<VendorsTableFloatingBar table={table} />}
>
<DataTableAdvancedToolbar
table={table}
filterFields={advancedFilterFields}
shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="vendorsTableCompact"
+ onCompactChange={handleCompactChange}
>
<VendorsTableToolbarActions table={table} />
</DataTableAdvancedToolbar>
@@ -119,6 +152,12 @@ export function VendorsTable({ promises }: VendorsTableProps) {
onOpenChange={() => setRowAction(null)}
vendor={rowAction?.row.original ?? null}
/>
+
+ <ViewVendorLogsDialog
+ open={rowAction?.type === "log"}
+ onOpenChange={() => setRowAction(null)}
+ vendorId={rowAction?.row.original?.id ?? null}
+ />
</>
)
} \ No newline at end of file
diff --git a/lib/vendors/table/view-vendors_logs-dialog.tsx b/lib/vendors/table/view-vendors_logs-dialog.tsx
new file mode 100644
index 00000000..7402ae55
--- /dev/null
+++ b/lib/vendors/table/view-vendors_logs-dialog.tsx
@@ -0,0 +1,244 @@
+"use client"
+
+import * as React from "react"
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { formatDateTime } from "@/lib/utils"
+import { useToast } from "@/hooks/use-toast"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Badge } from "@/components/ui/badge"
+import { Download, Search, User } from "lucide-react"
+import { VendorsLogWithUser, getVendorLogs } from "../service"
+
+interface VendorLogsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendorId: number | null
+}
+
+export function ViewVendorLogsDialog({
+ open,
+ onOpenChange,
+ vendorId,
+}: VendorLogsDialogProps) {
+ const [logs, setLogs] = React.useState<VendorsLogWithUser[]>([])
+ const [filteredLogs, setFilteredLogs] = React.useState<VendorsLogWithUser[]>([])
+ const [loading, setLoading] = React.useState(false)
+ const [error, setError] = React.useState<string | null>(null)
+ const [searchQuery, setSearchQuery] = React.useState("")
+ const [actionFilter, setActionFilter] = React.useState<string>("all")
+ const { toast } = useToast()
+
+ // Get unique action types for filter dropdown
+ const actionTypes = React.useMemo(() => {
+ if (!logs.length) return []
+ return Array.from(new Set(logs.map(log => log.action)))
+ }, [logs])
+
+ // Fetch logs when dialog opens
+ React.useEffect(() => {
+ if (open && vendorId) {
+ setLoading(true)
+ setError(null)
+ getVendorLogs(vendorId)
+ .then((res) => {
+ setLogs(res)
+ setFilteredLogs(res)
+ })
+ .catch((err) => {
+ console.error(err)
+ setError("Failed to load logs. Please try again.")
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to load candidate logs",
+ })
+ })
+ .finally(() => setLoading(false))
+ } else {
+ // Reset state when dialog closes
+ setSearchQuery("")
+ setActionFilter("all")
+ }
+ }, [open, vendorId, toast])
+
+ // Filter logs based on search query and action filter
+ React.useEffect(() => {
+ if (!logs.length) return
+
+ let result = [...logs]
+
+ // Apply action filter
+ if (actionFilter !== "all") {
+ result = result.filter(log => log.action === actionFilter)
+ }
+
+ // Apply search filter (case insensitive)
+ if (searchQuery) {
+ const query = searchQuery.toLowerCase()
+ result = result.filter(log =>
+ log.action.toLowerCase().includes(query) ||
+ (log.comment && log.comment.toLowerCase().includes(query)) ||
+ (log.oldStatus && log.oldStatus.toLowerCase().includes(query)) ||
+ (log.newStatus && log.newStatus.toLowerCase().includes(query)) ||
+ (log.userName && log.userName.toLowerCase().includes(query)) ||
+ (log.userEmail && log.userEmail.toLowerCase().includes(query))
+ )
+ }
+
+ setFilteredLogs(result)
+ }, [logs, searchQuery, actionFilter])
+
+ // Export logs as CSV
+ const exportLogs = () => {
+ if (!filteredLogs.length) return
+
+ const headers = ["Action", "Old Status", "New Status", "Comment", "User", "Email", "Date"]
+ const csvContent = [
+ headers.join(","),
+ ...filteredLogs.map(log => [
+ `"${log.action}"`,
+ `"${log.oldStatus || ''}"`,
+ `"${log.newStatus || ''}"`,
+ `"${log.comment?.replace(/"/g, '""') || ''}"`,
+ `"${log.userName || ''}"`,
+ `"${log.userEmail || ''}"`,
+ `"${formatDateTime(log.createdAt)}"`
+ ].join(","))
+ ].join("\n")
+
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.setAttribute("href", url)
+ link.setAttribute("download", `vendor-logs-${vendorId}-${new Date().toISOString().split('T')[0]}.csv`)
+ link.style.visibility = "hidden"
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ }
+
+ // Render status change with appropriate badge
+ const renderStatusChange = (oldStatus: string, newStatus: string) => {
+ return (
+ <div className="text-sm flex flex-wrap gap-2 items-center">
+ <strong>Status:</strong>
+ <Badge variant="outline" className="text-xs">{oldStatus}</Badge>
+ <span>→</span>
+ <Badge variant="outline" className="bg-primary/10 text-xs">{newStatus}</Badge>
+ </div>
+ )
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[700px]">
+ <DialogHeader>
+ <DialogTitle>Audit Logs</DialogTitle>
+ </DialogHeader>
+
+ {/* Filters and search */}
+ <div className="flex items-center gap-2 mb-4">
+ <div className="relative flex-1">
+ <div className="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none">
+ <Search className="h-4 w-4 text-muted-foreground" />
+ </div>
+ <Input
+ placeholder="Search logs..."
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ className="pl-8"
+ />
+ </div>
+
+ <Select
+ value={actionFilter}
+ onValueChange={setActionFilter}
+ >
+ <SelectTrigger className="w-[180px]">
+ <SelectValue placeholder="Filter by action" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">All actions</SelectItem>
+ {actionTypes.map(action => (
+ <SelectItem key={action} value={action}>{action}</SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+
+ <Button
+ size="icon"
+ variant="outline"
+ onClick={exportLogs}
+ disabled={filteredLogs.length === 0}
+ title="Export to CSV"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </div>
+
+ <div className="space-y-2">
+ {loading && (
+ <div className="flex justify-center py-8">
+ <p className="text-muted-foreground">Loading logs...</p>
+ </div>
+ )}
+
+ {error && !loading && (
+ <div className="bg-destructive/10 text-destructive p-3 rounded-md">
+ {error}
+ </div>
+ )}
+
+ {!loading && !error && filteredLogs.length === 0 && (
+ <p className="text-muted-foreground text-center py-8">
+ {logs.length > 0 ? "No logs match your search criteria." : "No logs found for this candidate."}
+ </p>
+ )}
+
+ {!loading && !error && filteredLogs.length > 0 && (
+ <>
+ <div className="text-xs text-muted-foreground mb-2">
+ Showing {filteredLogs.length} {filteredLogs.length === 1 ? 'log' : 'logs'}
+ {filteredLogs.length !== logs.length && ` (filtered from ${logs.length})`}
+ </div>
+ <div className="max-h-96 space-y-4 pr-4 overflow-y-auto">
+ {filteredLogs.map((log) => (
+ <div key={log.id} className="rounded-md border p-4 mb-3 hover:bg-muted/50 transition-colors">
+ <div className="flex justify-between items-start mb-2">
+ <Badge className="text-xs">{log.action}</Badge>
+ <div className="text-xs text-muted-foreground">
+ {formatDateTime(log.createdAt)}
+ </div>
+ </div>
+
+ {log.oldStatus && log.newStatus && (
+ <div className="my-2">
+ {renderStatusChange(log.oldStatus, log.newStatus)}
+ </div>
+ )}
+
+ {log.comment && (
+ <div className="my-2 text-sm bg-muted/50 p-2 rounded-md">
+ <strong>Comment:</strong> {log.comment}
+ </div>
+ )}
+
+ {(log.userName || log.userEmail) && (
+ <div className="mt-3 pt-2 border-t flex items-center text-xs text-muted-foreground">
+ <User className="h-3 w-3 mr-1" />
+ {log.userName || "Unknown"}
+ {log.userEmail && <span className="ml-1">({log.userEmail})</span>}
+ </div>
+ )}
+ </div>
+ ))}
+ </div>
+ </>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts
index 1c08f8ff..e01fa8b9 100644
--- a/lib/vendors/validations.ts
+++ b/lib/vendors/validations.ts
@@ -8,7 +8,7 @@ import {
import * as z from "zod"
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { Vendor, VendorContact, vendorInvestigationsView, VendorItemsView, vendors } from "@/db/schema/vendors";
+import { Vendor, VendorContact, vendorInvestigationsView, VendorItemsView, vendors, VendorWithType } from "@/db/schema/vendors";
import { rfqs } from "@/db/schema/rfq"
@@ -24,7 +24,7 @@ export const searchParamsCache = createSearchParamsCache({
perPage: parseAsInteger.withDefault(10),
// 정렬 (vendors 테이블에 맞춰 Vendor 타입 지정)
- sort: getSortingStateParser<Vendor>().withDefault([
+ sort: getSortingStateParser<VendorWithType>().withDefault([
{ id: "createdAt", desc: true }, // createdAt 기준 내림차순
]),
@@ -36,12 +36,12 @@ export const searchParamsCache = createSearchParamsCache({
search: parseAsString.withDefault(""),
// -----------------------------------------------------------------
- // 여기부터는 "벤더"에 특화된 검색 필드 예시
+ // 여기부터는 "협력업체"에 특화된 검색 필드 예시
// -----------------------------------------------------------------
// 상태 (ACTIVE, INACTIVE, BLACKLISTED 등) 중에서 선택
status: parseAsStringEnum(["ACTIVE", "INACTIVE", "BLACKLISTED"]),
- // 벤더명 검색
+ // 협력업체명 검색
vendorName: parseAsString.withDefault(""),
// 국가 검색
@@ -114,21 +114,32 @@ export const searchParamsItemCache = createSearchParamsCache({
description: parseAsString.withDefault(""),
});
+const creditAgencyEnum = z.enum(["NICE", "KIS", "KED", "SCI"]);
+export type CreditAgencyType = z.infer<typeof creditAgencyEnum>;
+
export const updateVendorSchema = z.object({
- vendorName: z.string().min(1, "Vendor name is required").max(255, "Max length 255").optional(),
- vendorCode: z.string().max(100, "Max length 100").optional(),
+ vendorName: z.string().min(1, "업체명은 필수 입력사항입니다"),
+ vendorCode: z.string().optional(),
address: z.string().optional(),
- country: z.string().max(100, "Max length 100").optional(),
- phone: z.string().max(50, "Max length 50").optional(),
- email: z.string().email("Invalid email").max(255).optional(),
- website: z.string().url("Invalid URL").max(255).optional(),
-
- // status는 특정 값만 허용하도록 enum 사용 예시
- // 필요 시 'SUSPENDED', 'BLACKLISTED' 등 추가하거나 제거 가능
- status: z.enum(vendors.status.enumValues)
- .optional()
- .default("ACTIVE"),
+ country: z.string().optional(),
+ phone: z.string().optional(),
+ email: z.string().email("유효한 이메일 주소를 입력해주세요").optional(),
+ website: z.string().url("유효한 URL을 입력해주세요").optional(),
+ status: z.enum(vendors.status.enumValues).optional(),
+ vendorTypeId: z.number().optional(),
+
+ // Optional fields for buyer information
+ buyerName: z.string().optional(),
+ buyerDepartment: z.string().optional(),
+ contractStartDate: z.date().optional(),
+ contractEndDate: z.date().optional(),
+ internalNotes: z.string().optional(),
+ creditRating: z.string().optional(),
+ cashFlowRating: z.string().optional(),
+ creditAgency: creditAgencyEnum.optional(),
+
+ // evaluationScore: z.string().optional(),
});
@@ -151,9 +162,9 @@ export const createVendorSchema = z
.string()
.min(1, "Vendor name is required")
.max(255, "Max length 255"),
- email: z.string().email("Invalid email").max(255),
- taxId: z.string().max(100, "Max length 100"),
+ vendorTypeId: z.number({ required_error: "업체유형을 선택해주세요" }),
+ email: z.string().email("Invalid email").max(255),
// 나머지 optional
vendorCode: z.string().max(100, "Max length 100").optional(),
address: z.string().optional(),
@@ -163,8 +174,6 @@ export const createVendorSchema = z
phone: z.string().max(50, "Max length 50").optional(),
website: z.string().url("Invalid URL").max(255).optional(),
- creditRatingAttachment: z.any().optional(), // 신용평가 첨부
- cashFlowRatingAttachment: z.any().optional(), // 현금흐름 첨부
attachedFiles: z.any()
.refine(
val => {
@@ -183,10 +192,9 @@ export const createVendorSchema = z
representativeEmail: z.union([z.string().email("Invalid email").max(255), z.literal("")]).optional(),
representativePhone: z.union([z.string().max(50), z.literal("")]).optional(),
corporateRegistrationNumber: z.union([z.string().max(100), z.literal("")]).optional(),
+ taxId: z.string().min(1, { message: "사업자등록번호를 입력해주세요" }),
- creditAgency: z.string().max(50).optional(),
- creditRating: z.string().max(50).optional(),
- cashFlowRating: z.string().max(50).optional(),
+ items: z.string().min(1, { message: "공급품목을 입력해주세요" }),
contacts: z
.array(contactSchema)
@@ -233,28 +241,7 @@ export const createVendorSchema = z
})
}
- // 2) 신용/현금흐름 등급도 필수라면
- if (!data.creditAgency) {
- ctx.addIssue({
- code: "custom",
- path: ["creditAgency"],
- message: "신용평가사 선택은 한국(KR) 업체일 경우 필수입니다.",
- })
- }
- if (!data.creditRating) {
- ctx.addIssue({
- code: "custom",
- path: ["creditRating"],
- message: "신용평가등급은 한국(KR) 업체일 경우 필수입니다.",
- })
- }
- if (!data.cashFlowRating) {
- ctx.addIssue({
- code: "custom",
- path: ["cashFlowRating"],
- message: "현금흐름등급은 한국(KR) 업체일 경우 필수입니다.",
- })
- }
+
}
}
)
@@ -349,7 +336,7 @@ export const updateVendorInfoSchema = z.object({
phone: z.string().optional(),
email: z.string().email("유효한 이메일을 입력해 주세요."),
website: z.string().optional(),
-
+
// 한국 사업자 정보 (KR일 경우 필수 항목들)
representativeName: z.string().optional(),
representativeBirth: z.string().optional(),