summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-03-25 15:55:45 +0900
committerjoonhoekim <26rote@gmail.com>2025-03-25 15:55:45 +0900
commit1a2241c40e10193c5ff7008a7b7b36cc1d855d96 (patch)
tree8a5587f10ca55b162d7e3254cb088b323a34c41b /lib
initial commit
Diffstat (limited to 'lib')
-rw-r--r--lib/admin-users/repository.ts171
-rw-r--r--lib/admin-users/service.ts531
-rw-r--r--lib/admin-users/table/add-ausers-dialog.tsx348
-rw-r--r--lib/admin-users/table/ausers-table-columns.tsx228
-rw-r--r--lib/admin-users/table/ausers-table-floating-bar.tsx389
-rw-r--r--lib/admin-users/table/ausers-table-toolbar-actions.tsx118
-rw-r--r--lib/admin-users/table/ausers-table.tsx180
-rw-r--r--lib/admin-users/table/delete-ausers-dialog.tsx149
-rw-r--r--lib/admin-users/table/update-auser-sheet.tsx225
-rw-r--r--lib/admin-users/validations.ts65
-rw-r--r--lib/compose-refs.ts38
-rw-r--r--lib/constants.ts3
-rw-r--r--lib/data-table.ts181
-rw-r--r--lib/docuSign/docuSignFns.ts383
-rw-r--r--lib/docuSign/jwtConfig/README.md54
-rw-r--r--lib/docuSign/jwtConfig/jwtConfig.json6
-rw-r--r--lib/docuSign/jwtConfig/private.key29
-rw-r--r--lib/docuSign/types.ts37
-rw-r--r--lib/downloadFile.ts81
-rw-r--r--lib/equip-class/repository.ts45
-rw-r--r--lib/equip-class/service.ts85
-rw-r--r--lib/equip-class/table/equipClass-table-columns.tsx99
-rw-r--r--lib/equip-class/table/equipClass-table-toolbar-actions.tsx53
-rw-r--r--lib/equip-class/table/equipClass-table.tsx133
-rw-r--r--lib/equip-class/table/feature-flags-provider.tsx108
-rw-r--r--lib/equip-class/validation.ts34
-rw-r--r--lib/export.ts198
-rw-r--r--lib/export_all.ts251
-rw-r--r--lib/filter-columns.ts193
-rw-r--r--lib/fonts.ts5
-rw-r--r--lib/form-list/repository.ts46
-rw-r--r--lib/form-list/service.ts84
-rw-r--r--lib/form-list/table/feature-flags-provider.tsx108
-rw-r--r--lib/form-list/table/formLists-table-columns.tsx132
-rw-r--r--lib/form-list/table/formLists-table-toolbar-actions.tsx53
-rw-r--r--lib/form-list/table/formLists-table.tsx151
-rw-r--r--lib/form-list/table/meta-sheet.tsx245
-rw-r--r--lib/form-list/validation.ts36
-rw-r--r--lib/forms/services.ts645
-rw-r--r--lib/handle-error.ts22
-rw-r--r--lib/id.ts43
-rw-r--r--lib/items/repository.ts125
-rw-r--r--lib/items/service.ts201
-rw-r--r--lib/items/table/add-items-dialog.tsx156
-rw-r--r--lib/items/table/delete-items-dialog.tsx149
-rw-r--r--lib/items/table/feature-flags-provider.tsx108
-rw-r--r--lib/items/table/feature-flags.tsx96
-rw-r--r--lib/items/table/items-table-columns.tsx183
-rw-r--r--lib/items/table/items-table-toolbar-actions.tsx67
-rw-r--r--lib/items/table/items-table.tsx139
-rw-r--r--lib/items/table/update-item-sheet.tsx178
-rw-r--r--lib/items/validations.ts47
-rw-r--r--lib/logger.ts26
-rw-r--r--lib/mail/mailer.ts31
-rw-r--r--lib/mail/sendEmail.ts36
-rw-r--r--lib/mail/templates/admin-created.hbs78
-rw-r--r--lib/mail/templates/admin-email-changed.hbs90
-rw-r--r--lib/mail/templates/otp.hbs77
-rw-r--r--lib/mail/templates/rfq-invite.hbs116
-rw-r--r--lib/mail/templates/vendor-active.hbs51
-rw-r--r--lib/mail/templates/vendor-pq-comment.hbs128
-rw-r--r--lib/mail/templates/vendor-pq-status.hbs48
-rw-r--r--lib/parsers.ts94
-rw-r--r--lib/po/repository.ts44
-rw-r--r--lib/po/service.ts431
-rw-r--r--lib/po/service_r1.ts282
-rw-r--r--lib/po/table/feature-flags-provider.tsx108
-rw-r--r--lib/po/table/po-table-columns.tsx155
-rw-r--r--lib/po/table/po-table-toolbar-actions.tsx53
-rw-r--r--lib/po/table/po-table.tsx164
-rw-r--r--lib/po/table/sign-request-dialog.tsx410
-rw-r--r--lib/po/validations.ts67
-rw-r--r--lib/pq/pq-review-table/feature-flags-provider.tsx108
-rw-r--r--lib/pq/pq-review-table/vendors-table-columns.tsx212
-rw-r--r--lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx41
-rw-r--r--lib/pq/pq-review-table/vendors-table.tsx97
-rw-r--r--lib/pq/repository.ts44
-rw-r--r--lib/pq/service.ts987
-rw-r--r--lib/pq/table/add-pq-dialog.tsx299
-rw-r--r--lib/pq/table/delete-pqs-dialog.tsx149
-rw-r--r--lib/pq/table/pq-table-column.tsx185
-rw-r--r--lib/pq/table/pq-table-toolbar-actions.tsx55
-rw-r--r--lib/pq/table/pq-table.tsx125
-rw-r--r--lib/pq/table/update-pq-sheet.tsx272
-rw-r--r--lib/pq/validations.ts36
-rw-r--r--lib/rfqs/cbe-table/cbe-table-columns.tsx227
-rw-r--r--lib/rfqs/cbe-table/cbe-table.tsx161
-rw-r--r--lib/rfqs/cbe-table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs/repository.ts232
-rw-r--r--lib/rfqs/service.ts2783
-rw-r--r--lib/rfqs/table/BudgetaryRfqSelector.tsx261
-rw-r--r--lib/rfqs/table/ItemsDialog.tsx744
-rw-r--r--lib/rfqs/table/add-rfq-dialog.tsx349
-rw-r--r--lib/rfqs/table/attachment-rfq-sheet.tsx430
-rw-r--r--lib/rfqs/table/delete-rfqs-dialog.tsx149
-rw-r--r--lib/rfqs/table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs/table/feature-flags.tsx96
-rw-r--r--lib/rfqs/table/rfqs-table-columns.tsx315
-rw-r--r--lib/rfqs/table/rfqs-table-floating-bar.tsx338
-rw-r--r--lib/rfqs/table/rfqs-table-toolbar-actions.tsx55
-rw-r--r--lib/rfqs/table/rfqs-table.tsx264
-rw-r--r--lib/rfqs/table/update-rfq-sheet.tsx283
-rw-r--r--lib/rfqs/tbe-table/comments-sheet.tsx334
-rw-r--r--lib/rfqs/tbe-table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs/tbe-table/file-dialog.tsx141
-rw-r--r--lib/rfqs/tbe-table/invite-vendors-dialog.tsx203
-rw-r--r--lib/rfqs/tbe-table/tbe-table-columns.tsx307
-rw-r--r--lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx60
-rw-r--r--lib/rfqs/tbe-table/tbe-table.tsx190
-rw-r--r--lib/rfqs/validations.ts274
-rw-r--r--lib/rfqs/vendor-table/add-vendor-dialog.tsx37
-rw-r--r--lib/rfqs/vendor-table/comments-sheet.tsx303
-rw-r--r--lib/rfqs/vendor-table/feature-flags-provider.tsx108
-rw-r--r--lib/rfqs/vendor-table/invite-vendors-dialog.tsx177
-rw-r--r--lib/rfqs/vendor-table/vendor-list/vendor-list-table-column.tsx154
-rw-r--r--lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx142
-rw-r--r--lib/rfqs/vendor-table/vendors-table-columns.tsx264
-rw-r--r--lib/rfqs/vendor-table/vendors-table-floating-bar.tsx137
-rw-r--r--lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx84
-rw-r--r--lib/rfqs/vendor-table/vendors-table.tsx181
-rw-r--r--lib/roles/repository.ts94
-rw-r--r--lib/roles/services.ts300
-rw-r--r--lib/roles/table/add-role-dialog.tsx308
-rw-r--r--lib/roles/table/assign-roles-sheet.tsx87
-rw-r--r--lib/roles/table/delete-roles-dialog.tsx149
-rw-r--r--lib/roles/table/role-table-toolbar-actions.tsx101
-rw-r--r--lib/roles/table/roles-table-columns.tsx223
-rw-r--r--lib/roles/table/roles-table.tsx169
-rw-r--r--lib/roles/table/update-roles-sheet.tsx331
-rw-r--r--lib/roles/userTable/assginedUsers-table-columns.tsx164
-rw-r--r--lib/roles/userTable/assignedUsers-table.tsx159
-rw-r--r--lib/roles/validations.ts80
-rw-r--r--lib/storage.ts44
-rw-r--r--lib/tag-numbering/repository.ts45
-rw-r--r--lib/tag-numbering/service.ts123
-rw-r--r--lib/tag-numbering/table/feature-flags-provider.tsx108
-rw-r--r--lib/tag-numbering/table/meta-sheet.tsx226
-rw-r--r--lib/tag-numbering/table/tagNumbering-table-columns.tsx131
-rw-r--r--lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx53
-rw-r--r--lib/tag-numbering/table/tagNumbering-table.tsx151
-rw-r--r--lib/tag-numbering/validation.ts39
-rw-r--r--lib/tags/form-mapping-service.ts65
-rw-r--r--lib/tags/repository.ts71
-rw-r--r--lib/tags/service.ts796
-rw-r--r--lib/tags/table/add-tag-dialog copy.tsx637
-rw-r--r--lib/tags/table/add-tag-dialog.tsx893
-rw-r--r--lib/tags/table/delete-tags-dialog.tsx151
-rw-r--r--lib/tags/table/feature-flags-provider.tsx108
-rw-r--r--lib/tags/table/tag-table-column.tsx164
-rw-r--r--lib/tags/table/tag-table.tsx141
-rw-r--r--lib/tags/table/tags-export.tsx155
-rw-r--r--lib/tags/table/tags-table-floating-bar.tsx220
-rw-r--r--lib/tags/table/tags-table-toolbar-actions.tsx598
-rw-r--r--lib/tags/table/update-tag-sheet.tsx548
-rw-r--r--lib/tags/validations.ts68
-rw-r--r--lib/tasks/repository.ts166
-rw-r--r--lib/tasks/service.ts561
-rw-r--r--lib/tasks/table/add-tasks-dialog.tsx227
-rw-r--r--lib/tasks/table/delete-tasks-dialog.tsx149
-rw-r--r--lib/tasks/table/feature-flags-provider.tsx108
-rw-r--r--lib/tasks/table/feature-flags.tsx96
-rw-r--r--lib/tasks/table/tasks-table-columns.tsx262
-rw-r--r--lib/tasks/table/tasks-table-floating-bar.tsx354
-rw-r--r--lib/tasks/table/tasks-table-toolbar-actions.tsx117
-rw-r--r--lib/tasks/table/tasks-table.tsx197
-rw-r--r--lib/tasks/table/update-task-sheet.tsx230
-rw-r--r--lib/tasks/utils.ts80
-rw-r--r--lib/tasks/validations.ts50
-rw-r--r--lib/tbe/service.ts0
-rw-r--r--lib/tbe/table/comments-sheet.tsx334
-rw-r--r--lib/tbe/table/feature-flags-provider.tsx108
-rw-r--r--lib/tbe/table/file-dialog.tsx141
-rw-r--r--lib/tbe/table/invite-vendors-dialog.tsx203
-rw-r--r--lib/tbe/table/tbe-table-columns.tsx249
-rw-r--r--lib/tbe/table/tbe-table-toolbar-actions.tsx60
-rw-r--r--lib/tbe/table/tbe-table.tsx204
-rw-r--r--lib/unstable-cache.ts19
-rw-r--r--lib/users/repository.ts128
-rw-r--r--lib/users/send-otp.ts71
-rw-r--r--lib/users/service.ts413
-rw-r--r--lib/users/table/assign-roles-dialog.tsx194
-rw-r--r--lib/users/table/users-table-columns.tsx154
-rw-r--r--lib/users/table/users-table-toolbar-actions.tsx61
-rw-r--r--lib/users/table/users-table.tsx150
-rw-r--r--lib/users/verifyOtp.ts28
-rw-r--r--lib/users/verifyToken.ts38
-rw-r--r--lib/utils.ts75
-rw-r--r--lib/vendor-data/services.ts99
-rw-r--r--lib/vendor-document-list/repository.ts44
-rw-r--r--lib/vendor-document-list/service.ts284
-rw-r--r--lib/vendor-document-list/table/add-doc-dialog.tsx299
-rw-r--r--lib/vendor-document-list/table/delete-docs-dialog.tsx231
-rw-r--r--lib/vendor-document-list/table/doc-table-column.tsx202
-rw-r--r--lib/vendor-document-list/table/doc-table-toolbar-actions.tsx66
-rw-r--r--lib/vendor-document-list/table/doc-table.tsx110
-rw-r--r--lib/vendor-document-list/table/update-doc-sheet.tsx267
-rw-r--r--lib/vendor-document-list/validations.ts33
-rw-r--r--lib/vendor-document/repository.ts44
-rw-r--r--lib/vendor-document/service.ts346
-rw-r--r--lib/vendor-document/table/doc-table-column.tsx150
-rw-r--r--lib/vendor-document/table/doc-table-toolbar-actions.tsx57
-rw-r--r--lib/vendor-document/table/doc-table.tsx124
-rw-r--r--lib/vendor-document/validations.ts33
-rw-r--r--lib/vendor-rfq-response/service.ts301
-rw-r--r--lib/vendor-rfq-response/types.ts76
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx125
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx106
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx415
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx108
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx421
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx40
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx270
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx334
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx108
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx317
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx162
-rw-r--r--lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx355
-rw-r--r--lib/vendors/contacts-table/add-contact-dialog.tsx175
-rw-r--r--lib/vendors/contacts-table/contact-table-columns.tsx195
-rw-r--r--lib/vendors/contacts-table/contact-table-toolbar-actions.tsx106
-rw-r--r--lib/vendors/contacts-table/contact-table.tsx87
-rw-r--r--lib/vendors/contacts-table/feature-flags-provider.tsx108
-rw-r--r--lib/vendors/items-table/add-item-dialog.tsx289
-rw-r--r--lib/vendors/items-table/feature-flags-provider.tsx108
-rw-r--r--lib/vendors/items-table/item-table-columns.tsx197
-rw-r--r--lib/vendors/items-table/item-table-toolbar-actions.tsx106
-rw-r--r--lib/vendors/items-table/item-table.tsx85
-rw-r--r--lib/vendors/repository.ts282
-rw-r--r--lib/vendors/rfq-history-table/feature-flags-provider.tsx108
-rw-r--r--lib/vendors/rfq-history-table/rfq-history-table-columns.tsx223
-rw-r--r--lib/vendors/rfq-history-table/rfq-history-table-toolbar-actions.tsx136
-rw-r--r--lib/vendors/rfq-history-table/rfq-history-table.tsx156
-rw-r--r--lib/vendors/rfq-history-table/rfq-items-table-dialog.tsx98
-rw-r--r--lib/vendors/service.ts1345
-rw-r--r--lib/vendors/table/approve-vendor-dialog.tsx150
-rw-r--r--lib/vendors/table/attachmentButton.tsx69
-rw-r--r--lib/vendors/table/feature-flags-provider.tsx108
-rw-r--r--lib/vendors/table/request-vendor-pg-dialog.tsx150
-rw-r--r--lib/vendors/table/send-vendor-dialog.tsx150
-rw-r--r--lib/vendors/table/update-vendor-sheet.tsx270
-rw-r--r--lib/vendors/table/vendors-table-columns.tsx279
-rw-r--r--lib/vendors/table/vendors-table-floating-bar.tsx241
-rw-r--r--lib/vendors/table/vendors-table-toolbar-actions.tsx97
-rw-r--r--lib/vendors/table/vendors-table.tsx121
-rw-r--r--lib/vendors/validations.ts341
245 files changed, 46874 insertions, 0 deletions
diff --git a/lib/admin-users/repository.ts b/lib/admin-users/repository.ts
new file mode 100644
index 00000000..aff2da28
--- /dev/null
+++ b/lib/admin-users/repository.ts
@@ -0,0 +1,171 @@
+import db from "@/db/db";
+import { users, userRoles,userView,roles, type User, type UserRole, type UserView, Role } from "@/db/schema/users";
+import { companies, type Company } from "@/db/schema/companies";
+import {
+ eq,
+ inArray,
+ asc,
+ desc,
+ and,
+ count,
+ gt,
+ sql,
+ SQL,
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+import { Vendor, vendors } from "@/db/schema/vendors";
+
+// ============================================================
+// 타입
+// ============================================================
+
+export type NewUser = typeof users.$inferInsert; // User insert 시 필요한 타입
+export type NewUserRole = typeof userRoles.$inferInsert; // UserRole insert 시 필요한 타입
+export type NewCompany = typeof companies.$inferInsert; // Company insert 시 필요한 타입
+
+
+
+export async function selectUsersWithCompanyAndRoles(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]
+ offset?: number
+ limit?: number
+ }
+) {
+ const { where, orderBy, offset = 0, limit = 10 } = params
+
+ // 1) 쿼리 빌더 생성
+ const queryBuilder = tx
+ .select()
+ .from(userView)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit)
+
+ const rows = await queryBuilder
+ return rows
+}
+
+
+/** 총 개수 count */
+export async function countUsers(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(userView).where(where);
+ return res[0]?.count ?? 0;
+}
+
+export async function groupByCompany(
+ tx: PgTransaction<any, any, any>,
+) {
+ return tx
+ .select({
+ companyId: users.companyId,
+ count: count(),
+ })
+ .from(users)
+ .groupBy(users.companyId)
+ .having(gt(count(), 0));
+}
+
+export async function groupByRole(tx: PgTransaction<any, any, any>) {
+ return tx
+ .select({
+ roleId: userRoles.roleId,
+ count: sql<number>`COUNT(*)`.as("count"),
+ })
+ .from(users)
+ .leftJoin(userRoles, eq(userRoles.userId, users.id))
+ .leftJoin(roles, eq(roles.id, userRoles.roleId))
+ .groupBy(userRoles.roleId, roles.id, roles.name)
+ .having(gt(sql<number>`COUNT(*)` /* 또는 count()와 동일 */, 0));
+}
+
+export async function insertUser(
+ tx: PgTransaction<any, any, any>,
+ data: NewUser
+) {
+ return tx.insert(users).values(data).returning();
+}
+
+export async function insertUserRole(
+ tx: PgTransaction<any, any, any>,
+ data: NewUserRole
+) {
+ return tx.insert(userRoles).values(data).returning();
+}
+
+export async function updateUser(
+ tx: PgTransaction<any, any, any>,
+ userId: number,
+ data: Partial<User>
+) {
+ return tx
+ .update(users)
+ .set(data)
+ .where(eq(users.id, userId))
+ .returning();
+}
+
+/** 복수 업데이트 */
+export async function updateUsers(
+ tx: PgTransaction<any, any, any>,
+ids: number[],
+data: Partial<User>
+) {
+return tx
+ .update(users)
+ .set(data)
+ .where(inArray(users.id, ids))
+ .returning({ companyId: users.companyId });
+}
+
+export async function deleteRolesByUserId(
+ tx: PgTransaction<any, any, any>,
+ userId: number
+) {
+ return tx.delete(userRoles).where(eq(userRoles.userId, userId));
+}
+
+
+export async function deleteRolesByUserIds(
+ tx: PgTransaction<any, any, any>,
+ ids: number[]
+) {
+ return tx.delete(userRoles).where(inArray(userRoles.userId, ids));
+}
+
+export async function deleteUserById(
+ tx: PgTransaction<any, any, any>,
+ userId: number
+) {
+ return tx.delete(users).where(eq(users.id, userId));
+}
+
+
+export async function deleteUsersByIds(
+ tx: PgTransaction<any, any, any>,
+ ids: number[]
+) {
+ return tx.delete(users).where(inArray(users.id, ids));
+}
+
+export async function findAllCompanies(): Promise<Vendor[]> {
+ return db.select().from(vendors).orderBy(asc(vendors.vendorName));
+}
+
+export async function findAllRoles(): Promise<Role[]> {
+ return db.select().from(roles).where(eq(roles.domain ,'partners')).orderBy(asc(roles.name));
+}
+
+export const getUserById = async (id: number): Promise<UserView | null> => {
+ const userFouned = await db.select().from(userView).where(eq(userView.user_id, id)).execute();
+ if (userFouned.length === 0) return null;
+
+ const user = userFouned[0];
+ return user
+};
diff --git a/lib/admin-users/service.ts b/lib/admin-users/service.ts
new file mode 100644
index 00000000..5d738d38
--- /dev/null
+++ b/lib/admin-users/service.ts
@@ -0,0 +1,531 @@
+"use server";
+
+import { revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+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 {
+ selectUsersWithCompanyAndRoles,
+ countUsers,
+ insertUser,
+ insertUserRole,
+ updateUser, deleteRolesByUserId, deleteRolesByUserIds,
+ deleteUserById,
+ deleteUsersByIds,
+ groupByCompany,
+ groupByRole,
+ findAllCompanies, getUserById, updateUsers,
+ findAllRoles
+} from "./repository";
+
+import { filterColumns } from "@/lib/filter-columns";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { getErrorMessage } from "@/lib/handle-error";
+
+// types
+import type { CreateUserSchema, UpdateUserSchema, GetUsersSchema } from "./validations";
+
+import { sendEmail } from "@/lib//mail/sendEmail";
+import { Vendor } from "@/db/schema/vendors";
+
+/**
+ * 복잡한 조건으로 User 목록을 조회 (+ pagination) 하고,
+ * 총 개수에 따라 pageCount를 계산해서 리턴.
+ * Next.js의 unstable_cache를 사용해 일정 시간 캐시.
+ */
+
+
+export async function getUsers(input: GetUsersSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // (1) advancedWhere
+ const advancedWhere = filterColumns({
+ table: userView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // (2) globalWhere
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(userView.user_name, s),
+ ilike(userView.user_email, s),
+ ilike(userView.company_name, s)
+ );
+ }
+
+ // (3) 디폴트 domainWhere = eq(userView.domain, "partners")
+ // 다만, 사용자가 이미 domain 필터를 줬다면 적용 X
+ let domainWhere;
+ const hasDomainFilter = input.filters?.some((f) => f.id === "user_domain");
+ if (!hasDomainFilter) {
+ domainWhere = eq(userView.user_domain, "partners");
+ }
+
+ // (4) 최종 where
+ const finalWhere = and(advancedWhere, globalWhere, domainWhere);
+
+ // (5) 정렬
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(userView[item.id]) : asc(userView[item.id])
+ )
+ : [desc(users.createdAt)];
+
+ // ...
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectUsersWithCompanyAndRoles(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countUsers(tx, finalWhere);
+ 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: ["users"],
+ }
+ )();
+}
+
+export async function findUserById(id: number) {
+ try {
+ logger.info({ id }, 'Fetching user by ID');
+ const user = await getUserById(id);
+ if (!user) {
+ logger.warn({ id }, 'User not found');
+ } else {
+ logger.debug({ user }, 'User fetched successfully');
+ }
+ return user;
+ } catch (error) {
+ logger.error({ error }, 'Error fetching user by ID');
+ throw new Error('Failed to fetch user');
+ }
+};
+
+/**
+ * User 생성
+ * 필요 시 companyId, roles, etc. 함께 처리
+ */
+// export async function createUser(input: CreateUserSchema & { language?: string }) {
+// unstable_noStore(); // 캐싱 방지(Next.js 서버 액션용)
+// try {
+// const userLang = input.language || "en"; // 클라이언트가 안 주면 기본 "en"
+// // 예시 subject 분기
+// const subject =
+// userLang === "ko"
+// ? "[eVCP] 어드민 계정이 생성되었습니다."
+// : "[eVCP] Admin Account Created";
+
+// const loginUrl =
+// userLang === "ko"
+// ? "http://3.36.56.124:3000/ko/login"
+// : "http://3.36.56.124:3000/en/login";
+
+// // 실제 sendEmail
+// await sendEmail({
+// to: input.email,
+// subject,
+// template: "admin-created",
+// context: {
+// name: input.name,
+// loginUrl, // 위에서 분기한 URL
+// language: userLang, // 템플릿에서 {{t ... lng=language}} 처럼 쓸 수도
+// },
+// });
+
+// await db.transaction(async (tx) => {
+// // insertUser는 단건 생성
+// const [newUser] = await insertUser(tx, {
+// name: input.name,
+// email: input.email,
+// domain: input.domain,
+// companyId: input.companyId ?? null,
+// // 기타 필요한 필드
+// });
+
+// // 만약 roles를 함께 생성하려면,
+// await insertUserRole(tx, { userId: newUser.id, roleId: Number(r) });
+// }
+// });
+
+// // 캐시 무효화
+// revalidateTag("users");
+// revalidateTag("user-company-counts");
+
+
+
+// return { data: null, error: null };
+// } catch (err) {
+// return { data: null, error: getErrorMessage(err) };
+// }
+// }
+
+export async function createAdminUser(input: CreateUserSchema & { language?: string }) {
+ unstable_noStore(); // Next.js 캐싱 방지
+
+ try {
+ // 예) 관리자 메일 알림 로직
+ // roles에 'Vendor Admin'을 넣을 거라면, 사실상 input.roles.includes("admin") 체크 대신
+ // 아래에서 직접 메일 보내도 됨. 질문 예시대로 유지하겠습니다.
+ const userLang = input.language || "en";
+ const subject = userLang === "ko"
+ ? "[eVCP] 어드민 계정이 생성되었습니다."
+ : "[eVCP] Admin Account Created";
+
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000'
+
+ const loginUrl = userLang === "ko"
+ ? `${baseUrl}/ko/partners`
+ : `${baseUrl}/en/partners`;
+
+ await sendEmail({
+ to: input.email,
+ subject,
+ template: "admin-created", // 예: nodemailer + handlebars 등
+ context: {
+ name: input.name,
+ loginUrl,
+ language: userLang,
+ },
+ });
+
+ // 트랜잭션 시작
+ await db.transaction(async (tx) => {
+ // 1. 먼저 roles 테이블에서 name = "Vendor Admin" AND domain = input.domain 인 것을 찾는다.
+ let [vendorAdminRole] = await tx
+ .select()
+ .from(roles)
+ .where(
+ and(
+ eq(roles.name, "Vendor Admin"),
+ eq(roles.domain, input.domain),
+ eq(roles.companyId, input.companyId as number),
+ )
+ )
+ .limit(1);
+
+ // 2. 만약 없다면, 새롭게 생성한다.
+ if (!vendorAdminRole) {
+ // companyId나 description 등은 필요에 따라 조정
+ const insertedRoles = await tx
+ .insert(roles)
+ .values({
+ name: "Vendor Admin",
+ domain: input.domain,
+ companyId: input.companyId ?? null,
+ description: "Auto created Vendor Admin role",
+ })
+ .returning();
+ vendorAdminRole = insertedRoles[0]; // 방금 insert한 row
+ }
+
+ // 3. 유저 생성
+ const [newUser] = await insertUser(tx, {
+ name: input.name,
+ email: input.email,
+ domain: input.domain,
+ companyId: input.companyId ?? null,
+ // 기타 필요한 필드 추가
+ });
+
+ // 4. Vendor Admin role을 user_roles 에 할당 (반복문 없이 단일 insert)
+ await insertUserRole(tx, {
+ userId: newUser.id,
+ roleId: vendorAdminRole.id, // Number()로 캐스팅할 필요 없이 정수로 관리한다고 가정
+ });
+ });
+
+ // 캐시 무효화
+ revalidateTag("users");
+ revalidateTag("user-company-counts");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 회사별 유저 개수 groupBy
+ */
+export async function getUserCountGroupByCompany() {
+ return unstable_cache(
+ async () => {
+ try {
+ // 예: { [companyId: number]: number }
+ const result = await db.transaction(async (tx) => {
+ const rows = await groupByCompany(tx);
+ // groupByCompany(tx): SELECT companyId, COUNT(*) FROM users GROUP BY companyId HAVING COUNT(*) > 0
+ // 예: [{ companyId: 1, count: 10 }, { companyId: 2, count: 3 }, ...]
+
+ // reduce해서 {1: 10, 2: 3, ...} 형태로 만들거나 그대로 반환할 수 있음
+ const obj: Record<number, number> = {};
+ for (const row of rows) {
+ if (row.companyId !== null) {
+ obj[row.companyId] = row.count;
+ } else {
+ // companyId가 null인 유저 수
+ obj[-1] = (obj[-1] ?? 0) + row.count;
+ }
+ }
+ return obj;
+ });
+ return result;
+ } catch (err) {
+ return {};
+ }
+ },
+ ["user-company-counts"],
+ {
+ revalidate: 3600,
+ }
+ )();
+}
+
+/**
+ * 롤별 유저 개수 groupBy
+ */
+export async function getUserCountGroupByRole() {
+ return unstable_cache(
+ async () => {
+ try {
+ const result = await db.transaction(async (tx) => {
+ const rows = await groupByRole(tx);
+
+ const obj: Record<number, number> = {};
+ for (const row of rows) {
+ if (row.roleId !== null) {
+ obj[row.roleId] = row.count;
+ } else {
+ // roleId가 null인 유저 수
+ obj[-1] = (obj[-1] ?? 0) + row.count;
+ }
+ }
+ return obj;
+ });
+
+ // 여기서 result를 반환해 줘야 함!
+ return result;
+ } catch (err) {
+ console.error("getUserCountGroupByRole error:", err);
+ return {};
+ }
+ },
+ ["user-role-counts"],
+ {
+ revalidate: 3600,
+ }
+ )();
+}
+/**
+ * 단건 업데이트
+ */
+export async function modifiUser(input: UpdateUserSchema & { id: number } & { language?: string }) {
+ unstable_noStore();
+
+ try {
+
+ const oldUser = await getUserById(input.id)
+ const oldEmail = oldUser?.user_email ?? null;
+
+ const data = await db.transaction(async (tx) => {
+ // 1) 먼저 User 테이블 업데이트
+ const [res] = await updateUser(tx, input.id, {
+ name: input.name,
+ companyId: input.companyId,
+ email: input.email,
+ });
+
+ // 2) roles가 함께 왔다면, 기존 roles 삭제 → 새 roles 삽입
+ if (input.roles) {
+ // 기존 roles 삭제
+ await deleteRolesByUserId(tx, input.id);
+
+ // 새 roles 삽입
+ for (const r of input.roles) {
+ await insertUserRole(tx, {
+ userId: input.id,
+ roleId: Number(r),
+ });
+ }
+ }
+
+ return res;
+ });
+
+ // 3) 캐시 무효화
+ revalidateTag("users");
+
+ // 4) 이메일이 변경되었고, roles 중에 "admin"이 있다면 → 메일 발송
+ const isEmailChanged = oldEmail && input.email && oldEmail !== input.email;
+ const hasAdminRole = input.roles?.includes("admin") ?? false;
+
+ if (isEmailChanged && hasAdminRole && input.email) {
+ await sendEmail({
+ to: input.email,
+ subject: "[EVCP] Admin Email Changed",
+ template: "admin-email-changed",
+ context: {
+ name: input.name,
+ oldEmail,
+ newEmail: input.email,
+ language: input.language ?? "en",
+ },
+ });
+ }
+
+ // 예: companyId 변경 시 회사별 count도 다시 계산해야 하는 경우
+ if (data.companyId === input.companyId) {
+ revalidateTag("user-company-counts");
+ }
+
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+
+/** 복수 업데이트 */
+export async function modifiUsers(input: {
+ ids: number[]; // 업데이트 대상 유저 ID 배열
+ companyId?: User["companyId"]; // 회사 ID (있으면 업데이트)
+ roles?: UserView["roles"]; // 새 roles 배열 (있으면 업데이트)
+}) {
+ unstable_noStore() // Next.js 서버 액션 캐싱 방지
+
+ try {
+ await db.transaction(async (tx) => {
+ // 1) 회사 정보 업데이트
+ if (typeof input.companyId !== "undefined") {
+ // companyId가 주어졌으면, 해당 사용자들의 companyId 변경
+ await updateUsers(tx, input.ids, { companyId: input.companyId })
+ }
+
+ // 2) roles 업데이트
+ // (있으면 기존 roles 삭제 → 새 roles 삽입)
+ if (Array.isArray(input.roles)) {
+ // (a) 기존 roles 전부 삭제
+ await deleteRolesByUserIds(tx, input.ids)
+
+ // (b) 새 roles 삽입
+ for (const userId of input.ids) {
+ for (const r of input.roles) {
+ await insertUserRole(tx, {
+ userId,
+ roleId: Number(r),
+ })
+ }
+ }
+ }
+ })
+
+ // 캐시 무효화
+ revalidateTag("users")
+
+ return { data: null, error: null }
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) }
+ }
+}
+/**
+ * 단건 삭제
+ */
+export async function removeUser(input: { id: number }) {
+ unstable_noStore();
+
+ try {
+ await db.transaction(async (tx) => {
+ // 유저 삭제
+ await deleteRolesByUserId(tx, input.id);
+ await deleteUserById(tx, input.id);
+ // roles, otps 등도 함께 삭제해야 하면 여기서 처리
+ });
+
+ // 캐시 무효화
+ revalidateTag("users");
+ revalidateTag("user-company-counts");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 복수 삭제
+ */
+export async function removeUsers(input: { ids: number[] }) {
+ unstable_noStore();
+
+ try {
+ await db.transaction(async (tx) => {
+ // user_roles도 있으면 먼저 삭제해야 할 수 있음
+ await deleteRolesByUserIds(tx, input.ids);
+ await deleteUsersByIds(tx, input.ids);
+ });
+
+ revalidateTag("users");
+ revalidateTag("user-company-counts");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+export async function getAllCompanies(): Promise<Vendor[]> {
+ try {
+ return await findAllCompanies(); // Company[]
+ } catch (err) {
+ throw new Error("Failed to get companies");
+ }
+}
+
+export async function getAllRoles(): Promise<Role[]> {
+ try {
+ return await findAllRoles();
+ } catch (err) {
+ throw new Error("Failed to get roles");
+ }
+}
+
+/**
+ * 이미 해당 이메일이 users 테이블에 존재하는지 확인하는 함수
+ * @param email 확인할 이메일
+ * @returns boolean - 존재하면 true, 없으면 false
+ */
+export async function checkEmailExists(email: string): Promise<boolean> {
+ const result = await db
+ .select({ id: users.id }) // 굳이 모든 컬럼 필요 없으니 id만
+ .from(users)
+ .where(eq(users.email, email))
+ .limit(1);
+
+ return result.length > 0; // 1건 이상 있으면 true
+}
diff --git a/lib/admin-users/table/add-ausers-dialog.tsx b/lib/admin-users/table/add-ausers-dialog.tsx
new file mode 100644
index 00000000..dd29c190
--- /dev/null
+++ b/lib/admin-users/table/add-ausers-dialog.tsx
@@ -0,0 +1,348 @@
+"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"
+
+// react-hook-form + shadcn/ui Form
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Role, userRoles } from "@/db/schema/users"
+import { createUserSchema, type CreateUserSchema } from "@/lib/admin-users/validations"
+import { createAdminUser, getAllCompanies, getAllRoles } from "@/lib/admin-users/service"
+import { type Company } from "@/db/schema/companies"
+import { MultiSelect } from "@/components/ui/multi-select"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandEmpty,
+} from "@/components/ui/command"
+import { Check, ChevronsUpDown, Loader } from "lucide-react"
+import { cn } from "@/lib/utils"
+import { toast } from "sonner"
+import { Vendor } from "@/db/schema/vendors"
+
+const languageOptions = [
+ { value: "ko", label: "한국어" },
+ { value: "en", label: "English" },
+]
+
+
+export function AddUserDialog() {
+ const [open, setOpen] = React.useState(false)
+ const [companies, setCompanies] = React.useState<Vendor[]>([]) // 회사 목록
+ const [roles, setRoles] = React.useState<Role[]>([])
+ const [isAddPending, startAddTransition] = React.useTransition()
+
+
+
+ React.useEffect(() => {
+ // 회사 목록 불러오기 (예시)
+ getAllCompanies().then((res) => {
+ setCompanies(res)
+ })
+
+ getAllRoles().then((res) => {
+ setRoles(res)
+ })
+ }, [])
+
+ // react-hook-form 세팅
+ const form = useForm<CreateUserSchema & { language?: string }>({
+ resolver: zodResolver(createUserSchema),
+ defaultValues: {
+ name: "",
+ email: "",
+ companyId: null,
+ language:'en',
+ // roles는 array<string>, 여기서는 단일 선택 시 [role]로 담음
+ roles: [],
+ domain:'partners'
+ // domain, etc. 필요하다면 추가
+ },
+ })
+
+
+ async function onSubmit(data: CreateUserSchema & { language?: string }) {
+ data.domain = "partners"
+
+ // 만약 단일 Select로 role을 정했다면, data.roles = ["manager"] 이런 식
+ startAddTransition(async ()=> {
+ const result = await createAdminUser(data)
+ if (result.error) {
+ toast.error(`에러: ${result.error}`)
+ return
+ }
+ form.reset()
+ setOpen(false)
+ toast.success("User added")
+ })
+
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ setOpen(nextOpen)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ {/* 모달을 열기 위한 버튼 */}
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ Add User
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Create New User</DialogTitle>
+ <DialogDescription>
+ 새 User 정보를 입력하고 <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="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>User Name</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="e.g. dujin"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 이메일 */}
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Email</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="e.g. user@example.com"
+ type="email"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 회사 선택 (companyId) */}
+ <FormField
+ control={form.control}
+ name="companyId"
+ render={({ field }) => {
+ // 현재 선택된 회사 ID (number) → 문자열
+ const valueString = field.value ? String(field.value) : ""
+
+
+ // 현재 선택된 회사
+ const selectedCompany = companies.find(
+ (c) => String(c.id) === valueString
+ )
+
+ const selectedCompanyLabel = selectedCompany && `${selectedCompany.vendorName} ${selectedCompany.taxId}`
+
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+
+ return (
+ <FormItem>
+ <FormLabel>Company</FormLabel>
+ <FormControl>
+ <Popover
+ open={popoverOpen}
+ onOpenChange={setPopoverOpen}
+ modal={true}
+ >
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={popoverOpen}
+ className="w-full justify-between"
+ >
+ {selectedCompany
+ ? `${selectedCompany.vendorName} ${selectedCompany.taxId}`
+ : "Select company..."}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+
+ <PopoverContent className="w-full p-0">
+ <Command>
+ <CommandInput
+ placeholder="Search company..."
+ className="h-9"
+
+ />
+ <CommandList>
+ <CommandEmpty>No company found.</CommandEmpty>
+ <CommandGroup>
+ {companies.map((comp) => {
+ // string(comp.id)
+ const compIdStr = String(comp.id)
+ const label = `${comp.vendorName}${comp.taxId}`
+ const label2 = `${comp.vendorName} ${comp.taxId}`
+ return (
+ <CommandItem
+ key={comp.id}
+ value={label2}
+ onSelect={() => {
+ // 회사 ID를 number로
+ field.onChange(Number(comp.id))
+ setPopoverOpen(false)
+
+ }}
+ >
+ {label2}
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4",
+ selectedCompanyLabel === label2
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }}
+ />
+ {/* Role (Vendor Admin) - 읽기 전용 */}
+ <FormField
+ control={form.control}
+ name="roles" // 실제 필드: z.array(z.string())
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Role</FormLabel>
+ {/* UI에선 그냥 Vendor Admin이라고 표시만 (disabled) */}
+ <FormControl>
+ <Input
+ readOnly
+ disabled
+ value="Vendor Admin"
+ className="bg-gray-50 text-gray-500"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* language Select */}
+ <FormField
+ control={form.control}
+ name="language"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Language</FormLabel>
+ <FormControl>
+ <Select
+ onValueChange={field.onChange}
+ // 'value'로 현재 값 연결. defaultValue 대신 Controlled 컴포넌트로
+ value={field.value}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select language" />
+ </SelectTrigger>
+ <SelectContent>
+ {languageOptions.map((v, index) => (
+ <SelectItem key={index} value={v.value}>
+ {v.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isAddPending}
+ >
+ {isAddPending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Cancel
+ </Button>
+ <Button type="submit" disabled={form.formState.isSubmitting || isAddPending}>
+ {isAddPending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Create
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/admin-users/table/ausers-table-columns.tsx b/lib/admin-users/table/ausers-table-columns.tsx
new file mode 100644
index 00000000..38281c7e
--- /dev/null
+++ b/lib/admin-users/table/ausers-table-columns.tsx
@@ -0,0 +1,228 @@
+"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 { userRoles, type UserView } from "@/db/schema/users"
+
+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 { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import { UserWithCompanyAndRoles } from "@/types/user"
+import { getErrorMessage } from "@/lib/handle-error"
+
+import { modifiUser } from "@/lib/admin-users/service"
+import { toast } from "sonner"
+
+import { userColumnsConfig } from "@/config/userColumnsConfig"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { MultiSelect } from "@/components/ui/multi-select"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<UserView> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<UserView>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<UserView> = {
+ 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<UserView> = {
+ 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>
+
+ {/* <DropdownMenuSub>
+ <DropdownMenuSubTrigger>Roles</DropdownMenuSubTrigger>
+ <DropdownMenuSubContent>
+ <MultiSelect
+ defaultValue={row.original.roles}
+ options={userRoles.role.enumValues.map((role) => ({
+ value: role,
+ label: role,
+ }))}
+ value={row.original.roles}
+ onValueChange={(value) => {
+ startUpdateTransition(() => {
+
+ toast.promise(
+ modifiUser({
+ id: row.original.user_id,
+ roles: value as ("admin"|"normal")[],
+ }),
+ {
+ loading: "Updating...",
+ success: "Roles updated",
+ error: (err) => getErrorMessage(err),
+ }
+ );
+ });
+ }}
+
+ />
+ </DropdownMenuSubContent>
+ </DropdownMenuSub> */}
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<User>[] }
+ const groupMap: Record<string, ColumnDef<UserView>[]> = {}
+
+ userColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<UserView> = {
+ 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 === "created_at") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ if (cfg.id === "roles") {
+ const roleValues = row.original.roles;
+ return (
+ <div className="flex flex-wrap gap-1">
+ {roleValues.map((v) => (
+ <Badge key={v} variant="outline">
+ {v}
+ </Badge>
+ ))}
+ </div>
+ );
+ }
+
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<UserView>[] = []
+
+ // 순서를 고정하고 싶다면 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/admin-users/table/ausers-table-floating-bar.tsx b/lib/admin-users/table/ausers-table-floating-bar.tsx
new file mode 100644
index 00000000..ae950252
--- /dev/null
+++ b/lib/admin-users/table/ausers-table-floating-bar.tsx
@@ -0,0 +1,389 @@
+"use client"
+
+import * as React from "react"
+import { userRoles, users, UserView, type User } from "@/db/schema/users"
+import { SelectTrigger } from "@radix-ui/react-select"
+import { type Table } from "@tanstack/react-table"
+import {
+ ArrowUp,
+ CheckCircle2,
+ Download,
+ Loader,
+ Trash2,
+ X, Check
+} from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { Portal } from "@/components/ui/portal"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+} from "@/components/ui/select"
+import { Separator } from "@/components/ui/separator"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { Kbd } from "@/components/kbd"
+
+import { modifiUsers, getAllCompanies, removeUsers } from "@/lib//admin-users/service"
+import { type Company } from "@/db/schema/companies"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandEmpty,
+} from "@/components/ui/command"
+import { cn } from "@/lib/utils"
+import { MultiSelect } from "@/components/ui/multi-select"
+import { ActionConfirmDialog } from "@/components/ui/action-dialog"
+
+interface AusersTableFloatingBarProps {
+ table: Table<UserView>
+}
+
+
+export function AusersTableFloatingBar({ table }: AusersTableFloatingBarProps) {
+ const rows = table.getFilteredSelectedRowModel().rows
+
+ const [isPending, startTransition] = React.useTransition()
+ const [action, setAction] = React.useState<
+ "update-company" | "update-roles" | "export" | "delete"
+ >()
+ const [companies, setCompanies] = React.useState<Company[]>([]) // 회사 목록
+
+ // Clear selection on Escape key press
+ React.useEffect(() => {
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ table.toggleAllRowsSelected(false)
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [table])
+
+ React.useEffect(() => {
+ // 회사 목록 불러오기 (예시)
+ getAllCompanies().then((res) => {
+ setCompanies(res)
+ })
+ }, [])
+
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+ const [rolesPopoverOpen, setRolesPopoverOpen] = React.useState(false)
+
+
+ // 공용 confirm dialog state
+ const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false)
+ const [confirmProps, setConfirmProps] = React.useState<{
+ title: string
+ description?: string
+ onConfirm: () => Promise<void> | void
+ }>({
+ title: "",
+ description: "",
+ onConfirm: () => { },
+ })
+
+ // 1) "삭제" Confirm 열기
+ function handleDeleteConfirm() {
+ setAction("delete")
+ setConfirmProps({
+ title: `Delete ${rows.length} user${rows.length > 1 ? "s" : ""}?`,
+ description: "This action cannot be undone.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ const { error } = await removeUsers({
+ ids: rows.map((row) => row.original.user_id),
+ })
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("Users deleted")
+ table.toggleAllRowsSelected(false)
+ setConfirmDialogOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+ // 2) "회사 업데이트"에서 회사 선택 시 → Confirm Dialog
+ function handleSelectCompany(comp: Company) {
+ setAction("update-company")
+ setPopoverOpen(false)
+
+ // Confirm Dialog에 전달할 내용
+ setConfirmProps({
+ title: `Update ${rows.length} user${rows.length > 1 ? "s" : ""} to "${comp.name}"?`,
+ description: `TaxID: ${comp.taxID}. This action will overwrite their current company.`,
+ onConfirm: async () => {
+ startTransition(async () => {
+ const { error } = await modifiUsers({
+ ids: rows.map((row) => row.original.user_id),
+ companyId: comp.id,
+ })
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("Users updated")
+ setConfirmDialogOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+ // 3) "역할 업데이트" MultiSelect 후 → Confirm Dialog
+ function handleSelectRoles(newRoles: string[]) {
+ setAction("update-roles")
+ setRolesPopoverOpen(false)
+
+ setConfirmProps({
+ title: `Update ${rows.length} user${rows.length > 1 ? "s" : ""} with roles: ${newRoles.join(", ")}?`,
+ description: "This action will override their current roles.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ const { error } = await modifiUsers({
+ ids: rows.map((row) => row.original.user_id),
+ roles: newRoles as ("admin" | "normal")[],
+ })
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("Users updated")
+ setConfirmDialogOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+ 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">
+ <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
+
+ <Tooltip>
+ <PopoverTrigger 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-company" ? (
+ <Loader
+ className="size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <CheckCircle2
+ className="size-3.5"
+ aria-hidden="true"
+ />
+ )}
+ </Button>
+ </TooltipTrigger>
+ </PopoverTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Update company</p>
+ </TooltipContent>
+ </Tooltip>
+
+ <PopoverContent className="w-80 p-0">
+ <Command>
+ <CommandInput placeholder="Search company..." className="h-9" />
+ <CommandList>
+ <CommandEmpty>No company found.</CommandEmpty>
+ <CommandGroup>
+ {companies.map((comp) => {
+ const label = `${comp.name} (${comp.taxID})`
+ return (
+ <CommandItem
+ key={comp.id}
+ value={label}
+ onSelect={() => handleSelectCompany(comp)}
+ >
+ {label}
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+
+ <Popover open={rolesPopoverOpen} onOpenChange={setRolesPopoverOpen}>
+
+ <Tooltip>
+ <PopoverTrigger 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-roles" ? (
+ <Loader
+ className="size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <ArrowUp className="size-3.5" aria-hidden="true" />
+
+ )}
+ </Button>
+ </TooltipTrigger>
+ </PopoverTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Update roles</p>
+ </TooltipContent>
+ </Tooltip>
+ <PopoverContent>
+ <MultiSelect
+ defaultValue={["999999999"]}
+ options={[
+ /* ... */
+ { value: "999999999", label: "admin" }
+ ]}
+ onValueChange={(newRoles) => {
+ handleSelectRoles(newRoles)
+ }}
+ />
+ </PopoverContent>
+
+ </Popover>
+
+
+ <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 users</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 users</p>
+ </TooltipContent>
+ </Tooltip>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 공용 Confirm Dialog */}
+ <ActionConfirmDialog
+ open={confirmDialogOpen}
+ onOpenChange={setConfirmDialogOpen}
+ title={confirmProps.title}
+ description={confirmProps.description}
+ onConfirm={confirmProps.onConfirm}
+ isLoading={isPending && (action === "delete" || action === "update-company" || action === "update-roles")}
+ confirmLabel={
+ action === "delete"
+ ? "Delete"
+ : action === "update-company" || action === "update-roles"
+ ? "Update"
+ : "Confirm"
+ }
+ confirmVariant={
+ action === "delete" ? "destructive" : "default"
+ }
+ />
+ </Portal>
+ )
+}
diff --git a/lib/admin-users/table/ausers-table-toolbar-actions.tsx b/lib/admin-users/table/ausers-table-toolbar-actions.tsx
new file mode 100644
index 00000000..5472c3ac
--- /dev/null
+++ b/lib/admin-users/table/ausers-table-toolbar-actions.tsx
@@ -0,0 +1,118 @@
+"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 { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+
+// 삭제, 추가 다이얼로그
+import { DeleteUsersDialog } from "./delete-ausers-dialog"
+import { AddUserDialog } from "./add-ausers-dialog"
+
+// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import
+import { importTasksExcel } from "@/lib/tasks/service" // 예시
+import { type UserView } from "@/db/schema/users"
+
+interface AdmUserTableToolbarActionsProps {
+ table: Table<UserView>
+}
+
+export function AdmUserTableToolbarActions({ table }: AdmUserTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일이 선택되었을 때 처리
+ async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록)
+ event.target.value = ""
+
+ // 서버 액션 or API 호출
+ try {
+ // 예: 서버 액션 호출
+ const { errorFile, errorMessage } = await importTasksExcel(file)
+
+ if (errorMessage) {
+ toast.error(errorMessage)
+ }
+ if (errorFile) {
+ // 에러 엑셀을 다운로드
+ const url = URL.createObjectURL(errorFile)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "errors.xlsx"
+ link.click()
+ URL.revokeObjectURL(url)
+ } else {
+ // 성공
+ toast.success("Import success")
+ // 필요 시 revalidateTag("tasks") 등
+ }
+
+ } catch (err) {
+ toast.error("파일 업로드 중 오류가 발생했습니다.")
+
+ }
+ }
+
+ function handleImportClick() {
+ // 숨겨진 <input type="file" /> 요소를 클릭
+ fileInputRef.current?.click()
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteUsersDialog
+ users={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+ {/** 2) 새 Task 추가 다이얼로그 */}
+ <AddUserDialog />
+
+ {/** 3) Import 버튼 (파일 업로드) */}
+ <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}>
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+ {/*
+ 실제로는 숨겨진 input과 연결:
+ - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용
+ */}
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".xlsx,.xls"
+ className="hidden"
+ onChange={onFileChange}
+ />
+
+ {/** 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>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/admin-users/table/ausers-table.tsx b/lib/admin-users/table/ausers-table.tsx
new file mode 100644
index 00000000..ed575e75
--- /dev/null
+++ b/lib/admin-users/table/ausers-table.tsx
@@ -0,0 +1,180 @@
+"use client"
+
+import * as React from "react"
+import { userRoles , type UserView} from "@/db/schema/users"
+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 { DataTableToolbar } from "@/components/data-table/data-table-toolbar"
+
+import type {
+ getUserCountGroupByCompany,
+ getUserCountGroupByRole,
+ getUsers, getAllCompanies,
+ getAllRoles
+} from "@/lib//admin-users/service"
+import { getColumns } from "./ausers-table-columns"
+import { AdmUserTableToolbarActions } from "./ausers-table-toolbar-actions"
+import { DeleteUsersDialog } from "./delete-ausers-dialog"
+import { AusersTableFloatingBar } from "./ausers-table-floating-bar"
+import { UpdateAuserSheet } from "./update-auser-sheet"
+
+interface UsersTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getUsers>>,
+ Record<number, number>,
+ Record<number, number>,
+ Awaited<ReturnType<typeof getAllCompanies>>,
+ Awaited<ReturnType<typeof getAllRoles>>
+ ]
+ >
+}
+type RoleCounts = Record<string, number>
+
+export function AdmUserTable({ promises }: UsersTableProps) {
+
+ const [{ data, pageCount }, companyCounts,roleCountsRaw, companies, roles] =
+ React.use(promises)
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<UserView> | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ const roleCounts = roleCountsRaw as RoleCounts
+
+
+ /**
+ * 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<UserView>[] = [
+ {
+ id: "user_email",
+ label: "Email",
+ placeholder: "Filter email...",
+ },
+
+ ]
+
+ /**
+ * 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<UserView>[] = [
+ {
+ id: "user_name",
+ label: "User Name",
+ type: "text",
+ },
+ {
+ id: "user_email",
+ label: "Email",
+ type: "text",
+ },
+ {
+ id: "company_name",
+ label: "Company",
+ type: "multi-select",
+ options: companies.map((comp) => ({
+ label: comp.vendorName,
+ value: comp.vendorName,
+ count: companyCounts[comp.id]
+ })),
+ },
+
+ {
+ id: "roles",
+ label: "Roles",
+ type: "multi-select",
+ options: roles.map((role) => {
+ return {
+ label: toSentenceCase(role.name),
+ value: role.id,
+ count: roleCounts[role.id], // 이 값이 undefined인지 확인
+ };
+ }),
+ },
+ {
+ id: "created_at",
+ label: "Created at",
+ type: "date",
+ },
+ ]
+
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "created_at", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => `${originalRow.user_id}`,
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ floatingBar={<AusersTableFloatingBar table={table}/>}
+
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <AdmUserTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+
+ </DataTable>
+
+ <DeleteUsersDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ users={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+
+ <UpdateAuserSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ user={rowAction?.row.original ?? null}
+ />
+
+ </>
+ )
+}
diff --git a/lib/admin-users/table/delete-ausers-dialog.tsx b/lib/admin-users/table/delete-ausers-dialog.tsx
new file mode 100644
index 00000000..0699bb95
--- /dev/null
+++ b/lib/admin-users/table/delete-ausers-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 { removeUsers } from "@/lib//admin-users/service"
+import { type UserView } from "@/db/schema/users"
+
+interface DeleteUsersDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ users: Row<UserView>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteUsersDialog({
+ users,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteUsersDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removeUsers({
+ ids: users.map((user) => Number(user.user_id)),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Users 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 ({users.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">{users.length}</span>
+ {users.length === 1 ? " user" : " users"} 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 ({users.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">{users.length}</span>
+ {users.length === 1 ? " user" : " users"} 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/admin-users/table/update-auser-sheet.tsx b/lib/admin-users/table/update-auser-sheet.tsx
new file mode 100644
index 00000000..ddf1f932
--- /dev/null
+++ b/lib/admin-users/table/update-auser-sheet.tsx
@@ -0,0 +1,225 @@
+"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 {
+ 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 { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+ SelectValue,
+ SelectGroup,
+} from "@/components/ui/select"
+// import your MultiSelect or other role selection
+import { MultiSelect } from "@/components/ui/multi-select"
+
+import { userRoles, type UserView } from "@/db/schema/users"
+import { updateUserSchema, type UpdateUserSchema } from "@/lib/admin-users/validations"
+import { modifiUser } from "@/lib/admin-users/service"
+
+export interface UpdateAuserSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ user: UserView | null
+}
+
+const languageOptions = [
+ { value: "ko", label: "한국어" },
+ { value: "en", label: "English" },
+]
+
+
+export function UpdateAuserSheet({ user, ...props }: UpdateAuserSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ // 1) RHF 설정
+ const form = useForm<UpdateUserSchema & { language?: string }>({
+ resolver: zodResolver(updateUserSchema),
+ defaultValues: {
+ name: user?.user_name ?? "",
+ email: user?.user_email ?? "",
+ companyId: user?.company_id ?? null,
+ roles: user?.roles ?? [],
+ language:'en',
+ },
+ })
+
+ // 2) user prop 바뀔 때마다 form.reset
+ React.useEffect(() => {
+ if (user) {
+ form.reset({
+ name: user.user_name,
+ email: user.user_email,
+ companyId: user.company_id,
+ roles: user.roles,
+ })
+ }
+ }, [user, form])
+
+
+ // 3) onSubmit
+ async function onSubmit(input: UpdateUserSchema & { language?: string }) {
+ startUpdateTransition(async () => {
+ if (!user) return
+
+ const { error } = await modifiUser({
+ id: user.user_id, // user.userId
+ ...input,
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ // 성공 시
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("User updated successfully!")
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Update user</SheetTitle>
+ <SheetDescription>
+ Update the user details and save the changes
+ </SheetDescription>
+ </SheetHeader>
+
+ {/* 4) RHF Form */}
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ {/* name */}
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>User Name</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g. dujin" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* email */}
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Email</FormLabel>
+ <FormControl>
+ <Input type="email" placeholder="user@example.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* roles */}
+ <FormField
+ control={form.control}
+ name="roles"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Roles</FormLabel>
+ <FormControl>
+ <MultiSelect
+ // 예: userRoles.role.enumValues = ["admin","normal"]
+ defaultValue={form?.getValues().roles}
+ options={[
+ { value: "999999999", label: "admin" }
+ ]}
+ value={field.value}
+ onValueChange={(vals) => field.onChange(vals)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="language"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Language</FormLabel>
+ <FormControl>
+ <Select
+ onValueChange={field.onChange}
+ // 'value'로 현재 값 연결. defaultValue 대신 Controlled 컴포넌트로
+ value={field.value}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select language" />
+ </SelectTrigger>
+ <SelectContent>
+ {languageOptions.map((v, index) => (
+ <SelectItem key={index} value={v.value}>
+ {v.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 5) Footer: Cancel, Save */}
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+
+ <Button type="submit" disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/admin-users/validations.ts b/lib/admin-users/validations.ts
new file mode 100644
index 00000000..e505067d
--- /dev/null
+++ b/lib/admin-users/validations.ts
@@ -0,0 +1,65 @@
+import { userRoles, users, type UserView } from "@/db/schema/users";
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { checkEmailExists } from "./service";
+
+
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<UserView>().withDefault([
+ { id: "created_at", desc: true },
+ ]),
+ email: parseAsString.withDefault(""),
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+export const createUserSchema = z.object({
+ email: z
+ .string()
+ .email()
+ .refine(
+ async (email) => {
+ // 1) DB 조회해서 이미 같은 email이 있으면 false 반환
+ const isUsed = await checkEmailExists(email);
+ return !isUsed;
+ },
+ {
+ message: "This email is already in use",
+ }
+ ),
+ name: z.string().min(1), // 최소 길이 1
+ domain: z.enum(users.domain.enumValues), // "evcp" | "partners"
+ companyId: z.number().nullable().optional(), // number | null | undefined
+ roles:z.array(z.string()).min(1, "At least one role must be selected"),
+ language: z.enum(["ko", "en"]).optional(),
+
+});
+
+export const updateUserSchema = z.object({
+ name: z.string().optional(),
+ email: z.string().email().optional(),
+ domain: z.enum(users.domain.enumValues).optional(),
+ companyId: z.number().nullable().optional(),
+ roles: z.array(z.string()).optional(),
+ language: z.enum(["ko", "en"]).optional(),
+
+});
+export type GetUsersSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+export type CreateUserSchema = z.infer<typeof createUserSchema>
+export type UpdateUserSchema = z.infer<typeof updateUserSchema>
diff --git a/lib/compose-refs.ts b/lib/compose-refs.ts
new file mode 100644
index 00000000..bed48a40
--- /dev/null
+++ b/lib/compose-refs.ts
@@ -0,0 +1,38 @@
+/**
+ * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/composeRefs.tsx
+ */
+
+import * as React from "react"
+
+type PossibleRef<T> = React.Ref<T> | undefined
+
+/**
+ * Set a given ref to a given value
+ * This utility takes care of different types of refs: callback refs and RefObject(s)
+ */
+function setRef<T>(ref: PossibleRef<T>, value: T) {
+ if (typeof ref === "function") {
+ ref(value)
+ } else if (ref !== null && ref !== undefined) {
+ ;(ref as React.MutableRefObject<T>).current = value
+ }
+}
+
+/**
+ * A utility to compose multiple refs together
+ * Accepts callback refs and RefObject(s)
+ */
+function composeRefs<T>(...refs: PossibleRef<T>[]) {
+ return (node: T) => refs.forEach((ref) => setRef(ref, node))
+}
+
+/**
+ * A custom hook that composes multiple refs
+ * Accepts callback refs and RefObject(s)
+ */
+function useComposedRefs<T>(...refs: PossibleRef<T>[]) {
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ return React.useCallback(composeRefs(...refs), refs)
+}
+
+export { composeRefs, useComposedRefs }
diff --git a/lib/constants.ts b/lib/constants.ts
new file mode 100644
index 00000000..c95834ad
--- /dev/null
+++ b/lib/constants.ts
@@ -0,0 +1,3 @@
+export const unknownError = "An unknown error occurred. Please try again later."
+
+export const databasePrefix = "shadcn"
diff --git a/lib/data-table.ts b/lib/data-table.ts
new file mode 100644
index 00000000..4fed7b9b
--- /dev/null
+++ b/lib/data-table.ts
@@ -0,0 +1,181 @@
+import type { ColumnType, Filter, FilterOperator, } from "@/types/table"
+import { type Column } from "@tanstack/react-table"
+
+import { dataTableConfig } from "@/config/data-table"
+import { FilterFn, Row } from "@tanstack/react-table"
+
+/**
+ * Generate common pinning styles for a table column.
+ *
+ * This function calculates and returns CSS properties for pinned columns in a data table.
+ * It handles both left and right pinning, applying appropriate styles for positioning,
+ * shadows, and z-index. The function also considers whether the column is the last left-pinned
+ * or first right-pinned column to apply specific shadow effects.
+ *
+ * @param options - The options for generating pinning styles.
+ * @param options.column - The column object for which to generate styles.
+ * @param options.withBorder - Whether to show a box shadow between pinned and scrollable columns.
+ * @returns A React.CSSProperties object containing the calculated styles.
+ */
+export function getCommonPinningStyles<TData>({
+ column,
+ withBorder = false,
+}: {
+ column: Column<TData>
+ /**
+ * Show box shadow between pinned and scrollable columns.
+ * @default false
+ */
+ withBorder?: boolean
+}): React.CSSProperties {
+ const isPinned = column.getIsPinned()
+ const isLastLeftPinnedColumn =
+ isPinned === "left" && column.getIsLastColumn("left")
+ const isFirstRightPinnedColumn =
+ isPinned === "right" && column.getIsFirstColumn("right")
+
+ return {
+ boxShadow: withBorder
+ ? isLastLeftPinnedColumn
+ ? "-4px 0 4px -4px hsl(var(--border)) inset"
+ : isFirstRightPinnedColumn
+ ? "4px 0 4px -4px hsl(var(--border)) inset"
+ : undefined
+ : undefined,
+ left: isPinned === "left" ? `${column.getStart("left")}px` : undefined,
+ right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined,
+ opacity: isPinned ? 0.97 : 1,
+ position: isPinned ? "sticky" : "relative",
+ background: isPinned ? "hsl(var(--background))" : "hsl(var(--background))",
+ width: column.getSize(),
+ zIndex: isPinned ? 1 : 0,
+ }
+}
+
+/**
+ * Determine the default filter operator for a given column type.
+ *
+ * This function returns the most appropriate default filter operator based on the
+ * column's data type. For text columns, it returns 'iLike' (case-insensitive like),
+ * while for all other types, it returns 'eq' (equality).
+ *
+ * @param columnType - The type of the column (e.g., 'text', 'number', 'date', etc.).
+ * @returns The default FilterOperator for the given column type.
+ */
+export function getDefaultFilterOperator(
+ columnType: ColumnType
+): FilterOperator {
+ if (columnType === "text") {
+ return "iLike"
+ }
+
+ return "eq"
+}
+
+/**
+ * Retrieve the list of applicable filter operators for a given column type.
+ *
+ * This function returns an array of filter operators that are relevant and applicable
+ * to the specified column type. It uses a predefined mapping of column types to
+ * operator lists, falling back to text operators if an unknown column type is provided.
+ *
+ * @param columnType - The type of the column for which to get filter operators.
+ * @returns An array of objects, each containing a label and value for a filter operator.
+ */
+export function getFilterOperators(columnType: ColumnType) {
+ const operatorMap: Record<
+ ColumnType,
+ { label: string; value: FilterOperator }[]
+ > = {
+ text: dataTableConfig.textOperators,
+ number: dataTableConfig.numericOperators,
+ select: dataTableConfig.selectOperators,
+ "multi-select": dataTableConfig.selectOperators,
+ boolean: dataTableConfig.booleanOperators,
+ date: dataTableConfig.dateOperators,
+ }
+
+ return operatorMap[columnType] ?? dataTableConfig.textOperators
+}
+
+/**
+ * Filters out invalid or empty filters from an array of filters.
+ *
+ * This function processes an array of filters and returns a new array
+ * containing only the valid filters. A filter is considered valid if:
+ * - It has an 'isEmpty' or 'isNotEmpty' operator, or
+ * - Its value is not empty (for array values, at least one element must be present;
+ * for other types, the value must not be an empty string, null, or undefined)
+ *
+ * @param filters - An array of Filter objects to be validated.
+ * @returns A new array containing only the valid filters.
+ */
+export function getValidFilters<TData>(
+ filters: Filter<TData>[]
+): Filter<TData>[] {
+ return filters?.filter(
+ (filter) =>
+ filter.operator === "isEmpty" ||
+ filter.operator === "isNotEmpty" ||
+ (Array.isArray(filter.value)
+ ? filter.value.length > 0
+ : filter.value !== "" &&
+ filter.value !== null &&
+ filter.value !== undefined)
+ )
+}
+
+interface NumericFilterValue {
+ operator: string
+ inputValue?: number
+}
+
+
+export const numericFilter: FilterFn<any> = (
+ row: Row<any>,
+ columnId: string,
+ filterValue: NumericFilterValue
+) => {
+ const rowValue = row.getValue(columnId)
+
+ // handle "isEmpty" / "isNotEmpty"
+ if (filterValue.operator === "isEmpty") {
+ return rowValue == null || rowValue === ""
+ } else if (filterValue.operator === "isNotEmpty") {
+ return !(rowValue == null || rowValue === "")
+ }
+
+ // parse rowValue → numeric
+ const numericRowVal =
+ typeof rowValue === "number" ? rowValue : parseFloat(String(rowValue))
+
+ if (isNaN(numericRowVal)) {
+ // rowValue not a number
+ return false
+ }
+
+ // parse filterValue.inputValue
+ const filterNum = filterValue.inputValue
+ if (filterNum == null || isNaN(filterNum)) {
+ // if user didn’t actually type a number, match everything or nothing (your choice)
+ return true
+ }
+
+ // compare based on operator
+ switch (filterValue.operator) {
+ case "eq":
+ return numericRowVal === filterNum
+ case "ne":
+ return numericRowVal !== filterNum
+ case "lt":
+ return numericRowVal < filterNum
+ case "lte":
+ return numericRowVal <= filterNum
+ case "gt":
+ return numericRowVal > filterNum
+ case "gte":
+ return numericRowVal >= filterNum
+ default:
+ return true
+ }
+} \ No newline at end of file
diff --git a/lib/docuSign/docuSignFns.ts b/lib/docuSign/docuSignFns.ts
new file mode 100644
index 00000000..87977a0b
--- /dev/null
+++ b/lib/docuSign/docuSignFns.ts
@@ -0,0 +1,383 @@
+"use server";
+
+import docusign from "docusign-esign";
+import fs from "fs";
+import path from "path";
+import jwtConfig from "./jwtConfig/jwtConfig.json";
+import dayjs from "dayjs";
+import { ContractInfo, ContractorInfo } from "./types";
+
+const SCOPES = ["signature", "impersonation"];
+
+//DocuSign 인증 정보
+async function authenticate(): Promise<
+ | undefined
+ | {
+ accessToken: string;
+ apiAccountId: string;
+ basePath: string;
+ }
+> {
+ const jwtLifeSec = 10 * 60;
+ const dsApi = new docusign.ApiClient();
+ dsApi.setOAuthBasePath(jwtConfig.dsOauthServer.replace("https://", ""));
+ const privateKeyPath = path.resolve(
+ process.cwd(),
+ jwtConfig.privateKeyLocation
+ );
+
+ let rsaKey: Buffer = fs.readFileSync(privateKeyPath);
+
+ try {
+ const results = await dsApi.requestJWTUserToken(
+ jwtConfig.dsJWTClientId,
+ jwtConfig.impersonatedUserGuid,
+ SCOPES,
+ rsaKey,
+ jwtLifeSec
+ );
+ const accessToken = results.body.access_token;
+
+ const userInfoResults = await dsApi.getUserInfo(accessToken);
+ let userInfo = userInfoResults.accounts.find(
+ (account: Partial<{ isDefault: string }>) => account.isDefault === "true"
+ );
+
+ return {
+ accessToken: results.body.access_token,
+ apiAccountId: userInfo.accountId,
+ basePath: `${userInfo.baseUri}/restapi`,
+ };
+ } catch (e) {
+ console.error("❌ 인증 실패:", e);
+ }
+}
+
+async function getSignerId(
+ basePath: string,
+ accountId: string,
+ accessToken: string,
+ envelopeId: string,
+ roleName: string
+): Promise<string | null> {
+ const apiClient = new docusign.ApiClient();
+ apiClient.setBasePath(basePath);
+ apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken);
+
+ const envelopesApi = new docusign.EnvelopesApi(apiClient);
+
+ try {
+ const recipients = await envelopesApi.listRecipients(accountId, envelopeId);
+
+ const singers = recipients?.signers ?? [];
+
+ // 🔹 특정 서명자(Role Name 기준)의 Recipient ID 찾기
+ const signer = singers.find((s) => s.roleName === roleName);
+ if (!signer) {
+ console.error("❌ 해당 Role Name을 가진 서명자를 찾을 수 없습니다.");
+ return null;
+ }
+
+ return signer.recipientId as string;
+ } catch (error) {
+ console.error("❌ 서명자 ID 조회 실패:", error);
+ return null;
+ }
+}
+
+//계약서 서명 요청
+export async function requestContractSign(
+ contractTemplateId: string,
+ contractInfo: ContractInfo[],
+ subcontractorinfo: ContractorInfo,
+ contractorInfo: ContractorInfo,
+ ccInfo: ContractorInfo[],
+ brandId: string | undefined = undefined
+): Promise<
+ Partial<{
+ result: boolean;
+ envelopeId: string;
+ error: any;
+ }>
+> {
+ let accountInfo = await authenticate();
+ if (accountInfo) {
+ const { accessToken, basePath, apiAccountId } = accountInfo;
+ const {
+ email: subEmail,
+ name: subConName,
+ roleName: subRoleName,
+ } = subcontractorinfo;
+
+ const {
+ email: conEmail,
+ name: conName,
+ roleName: roleName,
+ } = contractorInfo;
+
+ const apiClient = new docusign.ApiClient();
+ apiClient.setBasePath(basePath);
+ apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken);
+ const envelopesApi = new docusign.EnvelopesApi(apiClient);
+
+ const signer1: docusign.TemplateRole = {
+ email: subEmail,
+ name: subConName,
+ roleName: subRoleName,
+ };
+
+ const signer1Tabs: docusign.Tabs = {
+ textTabs: [
+ ...contractInfo.map((c): docusign.Text => {
+ const textField: docusign.Text = {
+ tabLabel: c.tabLabel,
+ value: c.value,
+ locked: "true",
+ };
+ return textField;
+ }),
+ ],
+ };
+
+ const signer2: docusign.TemplateRole = {
+ email: conEmail,
+ name: conName,
+ roleName: roleName,
+ };
+
+ const signer2Tabs: docusign.Tabs = {
+ dateSignedTabs: [
+ {
+ tabLabel: "contract_complete_date",
+ },
+ ],
+ };
+
+ signer1.tabs = signer1Tabs;
+ signer2.tabs = signer2Tabs;
+
+ const envelopeDefinition: docusign.EnvelopeDefinition = {
+ templateId: contractTemplateId,
+ templateRoles: [signer1, signer2, ...ccInfo], // 두 명의 서명자 추가
+ status: "sent", // 즉시 발송
+ };
+
+ if (brandId) {
+ envelopeDefinition.brandId = brandId;
+ }
+
+ try {
+ let envelopeSummary = await envelopesApi.createEnvelope(apiAccountId, {
+ envelopeDefinition,
+ });
+
+ // console.log("✅ 서명 요청 완료, Envelope ID:", envelopeSummary);
+ return {
+ result: true,
+ envelopeId: envelopeSummary.envelopeId,
+ };
+ } catch (error) {
+ console.dir(error);
+ return {
+ result: false,
+ error,
+ };
+ }
+ } else {
+ return {
+ result: false,
+ };
+ }
+}
+
+//서명된 계약서 다운로드
+export async function downloadContractFile(envelopeId: string): Promise<
+ Partial<{
+ result: boolean;
+ fileName: string;
+ buffer: Buffer;
+ envelopeId: string;
+ error: any;
+ }>
+> {
+ let accountInfo = await authenticate();
+
+ if (accountInfo) {
+ const { accessToken, apiAccountId, basePath } = accountInfo;
+
+ const apiClient = new docusign.ApiClient();
+ apiClient.setBasePath(basePath);
+ apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken);
+
+ const envelopesApi = new docusign.EnvelopesApi(apiClient);
+
+ try {
+ //Document ID 등 파일 정보를 호출
+ const response = await envelopesApi.listDocuments(
+ apiAccountId,
+ envelopeId,
+ null
+ );
+
+ const { envelopeDocuments } = response || { envelopeDocuments: [] };
+
+ if (Array.isArray(envelopeDocuments) && envelopeDocuments.length > 0) {
+ const { documentId, name } = envelopeDocuments[0] as {
+ documentId: string;
+ name: string;
+ };
+
+ //Document Buffer 호출
+ const downloadFile = await envelopesApi.getDocument(
+ apiAccountId,
+ envelopeId,
+ documentId,
+ {}
+ );
+
+ if (documentId && documentId !== "certificate") {
+ const bufferData: Buffer = downloadFile as unknown as Buffer;
+ return {
+ result: true,
+ fileName: name,
+ buffer: bufferData,
+ envelopeId,
+ };
+ }
+ }
+
+ return {
+ result: false,
+ };
+ } catch (error) {
+ return {
+ result: false,
+ error,
+ };
+ }
+ } else {
+ return {
+ result: false,
+ };
+ }
+}
+
+//최종 서명 날짜 찾기
+export async function findContractCompleteTime(
+ envelopeId: string,
+ lastSignerRoleName: string
+): Promise<{
+ completedDateTime: string;
+ year: string;
+ month: string;
+ day: string;
+ time: string;
+} | null> {
+ let accountInfo = await authenticate();
+
+ if (!accountInfo) {
+ console.error("❌ 인증 실패: API 요청을 중단합니다.");
+ return null;
+ }
+
+ const { accessToken, apiAccountId: accountId, basePath } = accountInfo;
+
+ const apiClient = new docusign.ApiClient();
+ apiClient.setBasePath(basePath);
+ apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken);
+
+ const envelopesApi = new docusign.EnvelopesApi(apiClient);
+
+ try {
+ const envelope = await envelopesApi.getEnvelope(accountId, envelopeId);
+ if (!envelope.completedDateTime) {
+ console.error("❌ 서명 완료 날짜가 없습니다.");
+ return null;
+ }
+
+ // 🔹 `SIGNER_ID` 가져오기
+ const signerId = await getSignerId(
+ basePath,
+ accountId,
+ accessToken,
+ envelopeId,
+ lastSignerRoleName
+ );
+ if (!signerId) {
+ console.error("❌ 서명자 ID를 찾을 수 없습니다.");
+ return null;
+ }
+
+ const completedDate = dayjs(envelope.completedDateTime);
+ const year = completedDate.format("YYYY").toString();
+ const month = completedDate.format("MM").toString();
+ const day = completedDate.format("DD").toString();
+ const time = completedDate.format("HH:mm").toString();
+
+ return {
+ completedDateTime: envelope.completedDateTime,
+ year,
+ month,
+ day,
+ time,
+ };
+ } catch (error) {
+ console.error("❌ 서명 완료 후 날짜 추가 실패:", error);
+ return null;
+ }
+}
+
+export async function getRecipients(
+ envelopeId: string,
+ recipientId: string
+): Promise<{ result: boolean; message?: string }> {
+ try {
+ let accountInfo = await authenticate();
+
+ if (!accountInfo) {
+ console.error("❌ 인증 실패: API 요청을 중단합니다.");
+ return {
+ result: false,
+ message: "인증 실패: API 요청을 중단합니다.",
+ };
+ }
+
+ const { accessToken, apiAccountId: accountId, basePath } = accountInfo;
+
+ const apiClient = new docusign.ApiClient();
+ apiClient.setBasePath(basePath);
+ apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken);
+
+ const envelopesApi = new docusign.EnvelopesApi(apiClient);
+
+ const response = await envelopesApi.listRecipients(accountId, envelopeId);
+
+ const singers: { [key: string]: any }[] = response?.signers ?? [];
+
+ // 🔹 특정 서명자(Role Name 기준)의 Recipient ID 찾기
+ const signer = singers.find((s) => s.recipientId === recipientId);
+ if (!signer) {
+ console.error("❌ 해당 Role Name을 가진 서명자를 찾을 수 없습니다.");
+ return {
+ result: false,
+ message: "해당 Recipient id를 가진 서명자를 찾을 수 없습니다.",
+ };
+ }
+
+ const { autoRespondedReason, status } = signer;
+
+ if (autoRespondedReason || status === "status") {
+ return {
+ result: false,
+ message: autoRespondedReason,
+ };
+ }
+
+ return {
+ result: true,
+ };
+ } catch (error) {
+ console.error("Error retrieving recipients:", error);
+ return { result: false, message: (error as Error).message };
+ }
+}
diff --git a/lib/docuSign/jwtConfig/README.md b/lib/docuSign/jwtConfig/README.md
new file mode 100644
index 00000000..7c997d07
--- /dev/null
+++ b/lib/docuSign/jwtConfig/README.md
@@ -0,0 +1,54 @@
+# DocuSign
+
+## DocuSign Contract Template
+
+### DocuSign Delveloper Account
+
+1. ID: kiman.kim@dtsolution.co.kr
+2. PW: rlaks!153
+
+### jwtConfig.json
+
+1. DocuSign Developer 로그인
+2. DocuSign Developer Admin 메뉴 이동
+3. DocuSign 좌측 메뉴 바에서 INTERGRATIONS > Apps and Keys 이동
+
+```jwtConfig.json
+{
+ //Add App and Intergraion Key 시 private.key 파일 생성 (처음 key를 만들때만 저장 가능함.)
+ "privateKeyLocation": private.key 파일 경로,
+ "dsJWTClientId": Apps and Intergration Keys 내 Intergration Kzey,
+ "impersonatedUserGuid": My Account Information 내 User ID,
+ //개발환경: https://account-d.docusign.com
+ //운영환경: https://account.docusign.com
+ "dsOauthServer": "https://account-d.docusign.com"
+}
+```
+
+### DocuSign Web Hook
+
+1. DocuSign Developer 로그인
+2. DocuSign Developer Admin 메뉴 이동
+3. DocuSign 좌측 메뉴 바에서 INTERGRATIONS > Connect 이동
+4. Add Configuration > Custom
+5. Web Hook Url 입력
+6. Trigger Events
+ 6.1. Envelope Signed/Completed - Check
+ 6.2. Envelope Declined - Check
+ 6.3. Recipient Sent - Check
+ 6.4. Recipient Delivered - Check
+ 6.5. Recipient Signed/Completed - Check
+ 6.6. Recipient Declined - Check
+
+### DocuSign Mail Sender Info Change
+
+1. DocuSign Developer 로그인
+2. 우측 상단 유저 아이콘 클릭 후 Manage Profile Menu로 이동
+3. My Profile에서 Name 변경
+
+### DocuSign Mail Templete Change
+
+1. DocuSign Developer 로그인
+2. DocuSign Developer Admin 메뉴 이동
+3. DocuSign 좌측 메뉴 바에서 ACCOUNT > Brands 이동
+4. 사용하고자 하는 Brand 제작 후 BrandId 사용
diff --git a/lib/docuSign/jwtConfig/jwtConfig.json b/lib/docuSign/jwtConfig/jwtConfig.json
new file mode 100644
index 00000000..756ca9dd
--- /dev/null
+++ b/lib/docuSign/jwtConfig/jwtConfig.json
@@ -0,0 +1,6 @@
+{
+ "dsJWTClientId": "4ecf089f-9134-4c6c-9657-d8f8c41b5965",
+ "impersonatedUserGuid": "de8ef3a2-9498-4855-a571-249a774a3905",
+ "privateKeyLocation": "./lib/docuSign/jwtConfig/private.key",
+ "dsOauthServer": "https://account-d.docusign.com"
+}
diff --git a/lib/docuSign/jwtConfig/private.key b/lib/docuSign/jwtConfig/private.key
new file mode 100644
index 00000000..73c4291a
--- /dev/null
+++ b/lib/docuSign/jwtConfig/private.key
@@ -0,0 +1,29 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEowIBAAKCAQEAnnjspqTabuuPLPi9Iga8U/chJRNmyr1PTbJC/Il0jse4ps/C
+KGQdmVOsDzPW//dopMLVc5OmJ7I3y7lw2+TuJ0G7Ip7s6epV2dzqH9aA/yvHDwvj
+2W9ZRH8pNx5AjNDCscwBF3NCK8CoGqK3+ukvuErVK8XQHnzOtAF2uyd2JLodT0fE
+I+uyvIL1E5pzU5zHzxHoWCsrjKAVaHhWUiTP0migFYrMBMVWC30slvhrNg1qc4uT
+Of3rkOGAUK+MFqCbaUm4qKBest9hDgSSw1h8Wv3cKD90KlRgZRSLSRxFwxzhj0ft
+1ip+JIc8dLcax1+xhX0dKBW2GARchojxEAzhDQIDAQABAoIBABvVuyF5JsnhU7xv
+M09Q9g7cg0SfIAi/0DhiNYxke2Xh1D/ukZilHyLRlND1xs+ebhG0jCf5GO/ziIPe
+3mEtWJxqGfvWhOAAUlSKTlBJzc4kKxpsOPj16yzSFhPxmx5ww6XVoqJzEv4a4JwP
+FTg78a8R69f8rpXQT8FD2Y49e+2uwVZVJfCjyaLcS2jh0wfaf7YiztSfyeAZNU2z
+YIL05wDm6Kw8fsdgZ5tF+tEEx0xBelNh+g4fNVVYdQmUhTM0GHePH5KvLc7LQyxD
+z/8ymU5fxikJGFmSS4ncI8ZpmCjV36tkUfZ03n5fW+76Q+gncc+ZKtXRZLgqBdsK
+q9ZDTuECgYEAzXMpmOnZh6Mzw6js5WZ2jSw1vuHjEDBOxpKon9UXZD5wZh9bcuxr
+ARQy+9/UETppumIW8L+zpmrpZISyriywEkleIjQhDqA9HJGR1lSukhMTyt4bj6ER
+f3uyJUzFun5c/QTJEBEJneTFY/Zc4pB+KIdTf3EosVGbtBfkfUXvyyECgYEAxXbA
+lg6gmo7ZpGZuPdhMrGiSI8rmGsvIo8Bw7jqdb6E/ksl5nBIxsLcM2lJw8Qe/fvei
+g+4Zmc5NOzyOKO1L84ekOC6jfvnGR2jzS2hF/qcNLUEEOKEyzBeniWrAqt80fgeK
+cH3zSAXCyLaGJPfdPPqEDYtVBN+zTwNJvHDK5G0CgYEAq1Lcnlpr/vL2iLQGkKno
+NINocjw2OFrAZlEIcvik4AA9hLuja+uAs86fUXDujEtUvYtsq+iArEc9R4hs5Ff5
+n9Y0vHsSEftH2tn9bmkBhmiIOcUL4LMlP1TsUrR5srILYycpb891YIjUni5keL6b
+pbprw7uefneaSw0dieXXOGECgYBrnmsb3WD+m3hWt1TB9A7lsCBlzYFXfVUemhVy
+YRPI8TL6xz+2JdxbGYixvFi9pKFji4dRLAVb5CoHbNt1xs6sLXL9A74rx+mepb5j
+jLMJNPZjgZnRW1maDhJLPJlBB2FOhsGWya47xJgCWCgIIea8AzTRROzTOTA6keov
+/7E0iQKBgFUWjpHIC0wkBFQFAV1uji3P0Bp6/hCOq9hZNxiaS41AlrhrPDRcIqss
+rMrW0Wf0OGDv0+aQXdMkk+nKBjQO3uS6EIj2oDUY/hTFXAKqvDPbHEx3rbtR7NdJ
+Sx9/raUX3YoYSNbPwwKcIWiHVnqY/hI8zIb+RFZgwt+mEoLS9/a2
+-----END RSA PRIVATE KEY-----
+
+
diff --git a/lib/docuSign/types.ts b/lib/docuSign/types.ts
new file mode 100644
index 00000000..450199ce
--- /dev/null
+++ b/lib/docuSign/types.ts
@@ -0,0 +1,37 @@
+export interface ContractInfo {
+ tabLabel: string;
+ value: string;
+}
+
+export interface ContractorInfo {
+ email: string;
+ name: string;
+ roleName: string;
+}
+
+export type poTabLabes =
+ | "po_no"
+ | "vendor_name"
+ | "po_date"
+ | "project_name"
+ | "vendor_location"
+ | "shi_email"
+ | "vendor_email"
+ | "po_desc"
+ | "qty"
+ | "unit_price"
+ | "total"
+ | "grand_total_amount"
+ | "tax_rate"
+ | "tax_total"
+ | "payment_amount"
+ | "remark";
+
+type ContentMap<T extends string> = {
+ [K in T]: {
+ tabLabel: K;
+ value: string;
+ };
+};
+
+export type POContent = ContentMap<poTabLabes>[poTabLabes][];
diff --git a/lib/downloadFile.ts b/lib/downloadFile.ts
new file mode 100644
index 00000000..e2777976
--- /dev/null
+++ b/lib/downloadFile.ts
@@ -0,0 +1,81 @@
+'use server'
+
+import fs from 'fs/promises'
+import path from 'path'
+import { NextResponse } from 'next/server'
+
+/**
+ * 첨부 파일 다운로드를 위한 서버 액션
+ *
+ * @param filePath 파일의 상대 경로
+ * @returns 파일 내용(Base64 인코딩) 및 메타데이터를 포함한 객체
+ */
+export async function downloadFileAction(filePath: string) {
+ try {
+ // 보안: 파일 경로가 uploads 디렉토리 내에 있는지 확인
+ if (!filePath.startsWith('/uploads/') && !filePath.startsWith('uploads/')) {
+ return {
+ ok: false,
+ error: 'Invalid file path. Only files in the uploads directory can be downloaded.'
+ };
+ }
+
+ // 실제 서버 파일 시스템에서의 전체 경로 계산
+ // 참고: process.cwd()는 현재 실행 중인 프로세스의 작업 디렉토리를 반환합니다.
+ // 환경에 따라 public 폴더나 다른 위치를 기준으로 할 수도 있습니다.
+ const normalizedPath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
+ const fullPath = path.join(process.cwd(), 'public', normalizedPath);
+
+ // 파일 존재 여부 확인
+ try {
+ await fs.access(fullPath);
+ } catch {
+ return { ok: false, error: 'File not found' };
+ }
+
+ // 파일 읽기
+ const fileBuffer = await fs.readFile(fullPath);
+
+ // 파일 통계 정보 가져오기
+ const stats = await fs.stat(fullPath);
+
+ // MIME 타입 추측
+ const extension = path.extname(fullPath).toLowerCase();
+ let mimeType = 'application/octet-stream'; // 기본값
+
+ // 일반적인 파일 타입에 대한 MIME 타입 매핑
+ const mimeTypes = {
+ '.pdf': 'application/pdf',
+ '.doc': 'application/msword',
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ '.xls': 'application/vnd.ms-excel',
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.txt': 'text/plain',
+ };
+
+ if (extension in mimeTypes) {
+ mimeType = mimeTypes[extension];
+ }
+
+ // Base64로 인코딩하여 반환
+ return {
+ ok: true,
+ data: {
+ content: fileBuffer.toString('base64'),
+ fileName: path.basename(fullPath),
+ size: stats.size,
+ mimeType,
+ },
+ };
+ } catch (error) {
+ console.error('Download error:', error);
+ return {
+ ok: false,
+ error: error instanceof Error ? error.message : 'An unknown error occurred'
+ };
+ }
+} \ No newline at end of file
diff --git a/lib/equip-class/repository.ts b/lib/equip-class/repository.ts
new file mode 100644
index 00000000..ddf98dd2
--- /dev/null
+++ b/lib/equip-class/repository.ts
@@ -0,0 +1,45 @@
+import db from "@/db/db";
+import { Item, items } from "@/db/schema/items";
+import { tagClasses } from "@/db/schema/vendorData";
+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 selectTagClassLists(
+ 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(tagClasses)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+ }
+ /** 총 개수 count */
+ export async function countTagClassLists(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+ ) {
+ const res = await tx.select({ count: count() }).from(tagClasses).where(where);
+ return res[0]?.count ?? 0;
+ } \ No newline at end of file
diff --git a/lib/equip-class/service.ts b/lib/equip-class/service.ts
new file mode 100644
index 00000000..c35f4fbe
--- /dev/null
+++ b/lib/equip-class/service.ts
@@ -0,0 +1,85 @@
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { tagClasses } from "@/db/schema/vendorData";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm";
+import { GetTagClassesSchema } from "./validation";
+import { countTagClassLists, selectTagClassLists } from "./repository";
+
+export async function getTagClassists(input: GetTagClassesSchema) {
+
+ 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: tagClasses,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(tagClasses.code, s), ilike(tagClasses.label, s)
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ const conditions = [];
+ if (advancedWhere) conditions.push(advancedWhere);
+ if (globalWhere) conditions.push(globalWhere);
+
+ let finalWhere;
+ if (conditions.length > 0) {
+ finalWhere = conditions.length > 1 ? and(...conditions) : conditions[0];
+ }
+
+ // 아니면 ilike, inArray, gte 등으로 where 절 구성
+ const where = finalWhere
+
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(tagClasses[item.id]) : asc(tagClasses[item.id])
+ )
+ : [asc(tagClasses.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectTagClassLists(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countTagClassLists(tx, where);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: ["equip-class"], // revalidateTag("items") 호출 시 무효화
+ }
+ )();
+ } \ No newline at end of file
diff --git a/lib/equip-class/table/equipClass-table-columns.tsx b/lib/equip-class/table/equipClass-table-columns.tsx
new file mode 100644
index 00000000..1255abf3
--- /dev/null
+++ b/lib/equip-class/table/equipClass-table-columns.tsx
@@ -0,0 +1,99 @@
+"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 { TagClasses } from "@/db/schema/vendorData"
+import { equipclassColumnsConfig } from "@/config/equipClassColumnsConfig"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TagClasses> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TagClasses>[] {
+
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<TagClasses>[] }
+ const groupMap: Record<string, ColumnDef<TagClasses>[]> = {}
+
+ equipclassColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<TagClasses> = {
+ 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<TagClasses>[] = []
+
+ // 순서를 고정하고 싶다면 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/equip-class/table/equipClass-table-toolbar-actions.tsx b/lib/equip-class/table/equipClass-table-toolbar-actions.tsx
new file mode 100644
index 00000000..5e03d800
--- /dev/null
+++ b/lib/equip-class/table/equipClass-table-toolbar-actions.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, RefreshCcw, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { TagClasses } from "@/db/schema/vendorData"
+
+
+
+interface ItemsTableToolbarActionsProps {
+ table: Table<TagClasses>
+}
+
+export function EquipClassTableToolbarActions({ table }: ItemsTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="samsung"
+ size="sm"
+ className="gap-2"
+ >
+ <RefreshCcw className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Get Equip Class</span>
+ </Button>
+
+ {/** 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>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/equip-class/table/equipClass-table.tsx b/lib/equip-class/table/equipClass-table.tsx
new file mode 100644
index 00000000..56fd42aa
--- /dev/null
+++ b/lib/equip-class/table/equipClass-table.tsx
@@ -0,0 +1,133 @@
+"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 { TagClasses } from "@/db/schema/vendorData"
+import { getTagClassists } from "../service"
+import { EquipClassTableToolbarActions } from "./equipClass-table-toolbar-actions"
+import { getColumns } from "./equipClass-table-columns"
+
+interface ItemsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getTagClassists>>,
+ ]
+ >
+}
+
+export function EquipClassTable({ promises }: ItemsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+
+console.log(data)
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<TagClasses> | 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<TagClasses>[] = [
+
+ ]
+
+ /**
+ * 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<TagClasses>[] = [
+ {
+ id: "code",
+ label: "Code",
+ type: "text",
+ // group: "Basic Info",
+ },
+ {
+ id: "label",
+ label: "Label",
+ type: "text",
+ // group: "Basic Info",
+ },
+
+
+ {
+ 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.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <EquipClassTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+
+ </DataTable>
+
+ </>
+ )
+}
diff --git a/lib/equip-class/table/feature-flags-provider.tsx b/lib/equip-class/table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/equip-class/table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/equip-class/validation.ts b/lib/equip-class/validation.ts
new file mode 100644
index 00000000..48698ac4
--- /dev/null
+++ b/lib/equip-class/validation.ts
@@ -0,0 +1,34 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { TagClasses } from "@/db/schema/vendorData";
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<TagClasses>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ code: parseAsString.withDefault(""),
+ label: parseAsString.withDefault(""),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+
+
+export type GetTagClassesSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
diff --git a/lib/export.ts b/lib/export.ts
new file mode 100644
index 00000000..d910ef6a
--- /dev/null
+++ b/lib/export.ts
@@ -0,0 +1,198 @@
+import { type Table } from "@tanstack/react-table"
+import ExcelJS from "exceljs"
+
+/**
+ * `exportTableToExcel`:
+ * - filename: 다운로드할 엑셀 파일 이름(확장자 제외)
+ * - onlySelected: 선택된 행만 내보낼지 여부
+ * - excludeColumns: 제외할 column id들의 배열 (e.g. ["select", "actions"])
+ * - useGroupHeader: 그룹화 헤더를 사용할지 여부 (기본 false)
+ */
+export async function exportTableToExcel<TData>(
+ table: Table<TData>,
+ {
+ filename = "table",
+ onlySelected = false,
+ excludeColumns = [],
+ useGroupHeader = true,
+ }: {
+ filename?: string
+ onlySelected?: boolean
+ excludeColumns?: string[]
+ useGroupHeader?: boolean
+ } = {}
+): Promise<void> {
+ // 1) tanstack에서 실제 사용 중인 leaf columns 가져오기
+ const allColumns = table.getAllLeafColumns()
+
+ // 2) excludeColumns 목록에 들어있는 col.id 제거
+ const columns = allColumns.filter(
+ (col) => !excludeColumns.includes(col.id)
+ )
+
+ let sheetData: any[][]
+
+ if (useGroupHeader) {
+ // ────────────── 2줄 헤더 (row1 = 그룹명, row2 = 컬럼헤더) ──────────────
+ const row1: string[] = []
+ const row2: string[] = []
+
+ columns.forEach((col) => {
+ // group
+ const maybeGroup = (col.columnDef.meta as any)?.group
+ row1.push(maybeGroup ?? "")
+
+ // excelHeader
+ const maybeExcelHeader = (col.columnDef.meta as any)?.excelHeader
+ if (typeof maybeExcelHeader === "string") {
+ row2.push(maybeExcelHeader)
+ } else {
+ row2.push(col.id)
+ }
+ })
+
+ // 데이터
+ const rowModel = onlySelected
+ ? table.getFilteredSelectedRowModel()
+ : table.getRowModel()
+
+ const dataRows = rowModel.rows.map((row) =>
+ columns.map((col) => {
+ const val = row.getValue(col.id)
+ if (val == null) return ""
+ return typeof val === "object" ? JSON.stringify(val) : val
+ })
+ )
+
+ // 최종 sheetData: [ [그룹들...], [헤더들...], ...데이터들 ]
+ sheetData = [row1, row2, ...dataRows]
+ } else {
+ // ────────────── 기존 1줄 헤더 ──────────────
+ const headerRow = columns.map((col) => {
+ const maybeExcelHeader = (col.columnDef.meta as any)?.excelHeader
+ return typeof maybeExcelHeader === "string" ? maybeExcelHeader : col.id
+ })
+
+ // 데이터
+ const rowModel = onlySelected
+ ? table.getFilteredSelectedRowModel()
+ : table.getRowModel()
+
+ const dataRows = rowModel.rows.map((row) =>
+ columns.map((col) => {
+ const val = row.getValue(col.id)
+ if (val == null) return ""
+ return typeof val === "object" ? JSON.stringify(val) : val
+ })
+ )
+
+ sheetData = [headerRow, ...dataRows]
+ }
+
+ // ────────────── ExcelJS 워크북/시트 생성 ──────────────
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Sheet1")
+
+ // (추가) 칼럼별 최대 길이 추적
+ const maxColumnLengths = columns.map(() => 0)
+ sheetData.forEach((row) => {
+ row.forEach((cellValue, colIdx) => {
+ const cellText = cellValue?.toString() ?? ""
+ if (cellText.length > maxColumnLengths[colIdx]) {
+ maxColumnLengths[colIdx] = cellText.length
+ }
+ })
+ })
+
+ // 시트에 데이터 추가 + 헤더 스타일
+ sheetData.forEach((arr, idx) => {
+ const row = worksheet.addRow(arr)
+
+ // 헤더 스타일 적용
+ if (useGroupHeader) {
+ // 2줄 헤더
+ if (idx < 2) {
+ row.font = { bold: true }
+ row.alignment = { horizontal: "center" }
+ row.eachCell((cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ }
+ })
+ }
+ } else {
+ // 1줄 헤더
+ if (idx === 0) {
+ row.font = { bold: true }
+ row.alignment = { horizontal: "center" }
+ row.eachCell((cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ }
+ })
+ }
+ }
+ })
+
+ // ────────────── (핵심) 그룹 헤더 병합 로직 ──────────────
+ if (useGroupHeader) {
+ // row1 (인덱스 1) = 그룹명 행
+ // row2 (인덱스 2) = 실제 컬럼 헤더 행
+ const groupRowIndex = 1
+ const groupRow = worksheet.getRow(groupRowIndex)
+
+ // 같은 값이 연속되는 열을 병합
+ let start = 1 // 시작 열 인덱스 (1-based)
+ let prevValue = groupRow.getCell(start).value
+
+ for (let c = 2; c <= columns.length; c++) {
+ const cellVal = groupRow.getCell(c).value
+ if (cellVal !== prevValue) {
+ // 이전 그룹명이 빈 문자열이 아니면 병합
+ if (prevValue && prevValue.toString().trim() !== "") {
+ worksheet.mergeCells(
+ groupRowIndex,
+ start,
+ groupRowIndex,
+ c - 1
+ )
+ }
+ // 다음 구간 시작
+ start = c
+ prevValue = cellVal
+ }
+ }
+
+ // 마지막 구간까지 병합
+ if (prevValue && prevValue.toString().trim() !== "") {
+ worksheet.mergeCells(
+ groupRowIndex,
+ start,
+ groupRowIndex,
+ columns.length
+ )
+ }
+ }
+
+ // ────────────── (추가) 칼럼 너비 자동 조정 ──────────────
+ maxColumnLengths.forEach((len, idx) => {
+ // 최소 너비 10, +2 여백
+ worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10)
+ })
+
+ // ────────────── 최종 파일 다운로드 ──────────────
+ 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}.xlsx`
+ link.click()
+ URL.revokeObjectURL(url)
+} \ No newline at end of file
diff --git a/lib/export_all.ts b/lib/export_all.ts
new file mode 100644
index 00000000..6f925fbc
--- /dev/null
+++ b/lib/export_all.ts
@@ -0,0 +1,251 @@
+import { type Table } from "@tanstack/react-table"
+import ExcelJS from "exceljs"
+
+/**
+ * `exportTableToExcel`:
+ * - filename: 다운로드할 엑셀 파일 이름(확장자 제외)
+ * - onlySelected: 선택된 행만 내보낼지 여부
+ * - excludeColumns: 제외할 column id들의 배열 (e.g. ["select", "actions"])
+ * - useGroupHeader: 그룹화 헤더를 사용할지 여부 (기본 false)
+ * - allPages: true일 경우, 페이징 상관없이 모든 행을 내보냄
+ *
+ * 추가:
+ * - i18n: (key: string) => string | undefined
+ * => excelHeader나 group 값이 'myKey'처럼 i18n 키라면 이 함수를 통해 번역 문자열 반환
+ * - customHeaders: { [colId: string]: string }
+ * => 특정 col.id에 대해 강제로 헤더를 지정하고 싶을 때 사용
+ */
+export async function exportTableToExcel<TData>(
+ table: Table<TData>,
+ {
+ filename = "table",
+ onlySelected = false,
+ excludeColumns = [],
+ useGroupHeader = true,
+ allPages = false,
+ /** 아래 2개가 새로 추가된 옵션 */
+ i18n,
+ customHeaders = {},
+ }: {
+ filename?: string
+ onlySelected?: boolean
+ excludeColumns?: string[]
+ useGroupHeader?: boolean
+ allPages?: boolean
+ /** excelHeader나 group 값이 i18n 키일 경우, 해당 함수를 통해 번역 */
+ i18n?: (key: string) => string
+ /** 특정 col.id에 대한 강제 헤더 지정 */
+ customHeaders?: Record<string, string>
+ } = {}
+): Promise<void> {
+ // 1) tanstack에서 실제 사용 중인 leaf columns 가져오기
+ const allColumns = table.getAllLeafColumns()
+
+ // 2) excludeColumns 목록에 들어있는 col.id 제거
+ const columns = allColumns.filter(
+ (col) => !excludeColumns.includes(col.id)
+ )
+
+ // 실제로 기록할 sheetData(배열 형식)
+ let sheetData: any[][]
+
+ // ────────────── 2줄 헤더 (group + excelHeader) vs 1줄 헤더 ──────────────
+ if (useGroupHeader) {
+ // 2줄 헤더 (row1 = 그룹명, row2 = 컬럼헤더)
+ const row1: string[] = []
+ const row2: string[] = []
+
+ columns.forEach((col) => {
+ const meta = col.columnDef.meta as any
+ // 1) group (그룹헤더)
+ const groupKey = meta?.group
+ let groupLabel = groupKey ?? ""
+ if (groupLabel && i18n) {
+ // groupKey가 i18n 키라면 번역 적용
+ const maybeTranslated = i18n(groupLabel)
+ if (maybeTranslated) {
+ groupLabel = maybeTranslated
+ }
+ }
+ row1.push(groupLabel)
+
+ // 2) excelHeader (실제 컬럼 헤더)
+ // (a) customHeaders[col.id]가 우선
+ if (customHeaders[col.id]) {
+ row2.push(customHeaders[col.id])
+ } else {
+ // (b) meta?.excelHeader가 있으면 그것을 사용
+ const maybeExcelHeader = meta?.excelHeader
+ if (typeof maybeExcelHeader === "string") {
+ // i18n 함수가 있다면 i18n 키로 가정하고 번역 시도
+ if (i18n) {
+ const maybeTranslated = i18n(maybeExcelHeader)
+ row2.push(maybeTranslated || maybeExcelHeader)
+ } else {
+ row2.push(maybeExcelHeader)
+ }
+ } else {
+ // 모두 없으면 col.id 사용
+ row2.push(col.id)
+ }
+ }
+ })
+
+ // ─────────────────────────────────────────────────
+ // 필요한 데이터 행 추출
+ const rowModel = onlySelected
+ ? table.getFilteredSelectedRowModel()
+ : allPages
+ ? table.getPrePaginationRowModel()
+ : table.getRowModel()
+
+ const dataRows = rowModel.rows.map((row) =>
+ columns.map((col) => {
+ const val = row.getValue(col.id)
+ if (val == null) return ""
+ return typeof val === "object" ? JSON.stringify(val) : val
+ })
+ )
+
+ // 최종 sheetData: [ [그룹들...], [헤더들...], ...데이터들 ]
+ sheetData = [row1, row2, ...dataRows]
+
+ } else {
+ // ────────────── 기존 1줄 헤더 ──────────────
+ const headerRow = columns.map((col) => {
+ const meta = col.columnDef.meta as any
+
+ // 1) customHeaders[col.id]가 우선
+ if (customHeaders[col.id]) {
+ return customHeaders[col.id]
+ }
+
+ // 2) meta?.excelHeader가 문자열이면
+ if (typeof meta?.excelHeader === "string") {
+ if (i18n) {
+ const maybeTranslated = i18n(meta.excelHeader)
+ return maybeTranslated || meta.excelHeader
+ } else {
+ return meta.excelHeader
+ }
+ }
+
+ // 3) 모든 것이 없으면 col.id
+ return col.id
+ })
+
+ // 데이터
+ const rowModel = onlySelected
+ ? table.getFilteredSelectedRowModel()
+ : allPages
+ ? table.getPrePaginationRowModel()
+ : table.getRowModel()
+
+ const dataRows = rowModel.rows.map((row) =>
+ columns.map((col) => {
+ const val = row.getValue(col.id)
+ if (val == null) return ""
+ return typeof val === "object" ? JSON.stringify(val) : val
+ })
+ )
+
+ sheetData = [headerRow, ...dataRows]
+ }
+
+ // ────────────── ExcelJS 워크북/시트 생성 ──────────────
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Sheet1")
+
+ // (추가) 칼럼별 최대 길이 추적
+ const maxColumnLengths = columns.map(() => 0)
+ sheetData.forEach((row) => {
+ row.forEach((cellValue, colIdx) => {
+ const cellText = cellValue?.toString() ?? ""
+ if (cellText.length > maxColumnLengths[colIdx]) {
+ maxColumnLengths[colIdx] = cellText.length
+ }
+ })
+ })
+
+ // 시트에 데이터 추가 + 헤더 스타일
+ sheetData.forEach((arr, idx) => {
+ const row = worksheet.addRow(arr)
+
+ // 헤더 스타일 적용
+ if (useGroupHeader) {
+ // 2줄 헤더
+ if (idx < 2) {
+ row.font = { bold: true }
+ row.alignment = { horizontal: "center" }
+ row.eachCell((cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ }
+ })
+ }
+ } else {
+ // 1줄 헤더
+ if (idx === 0) {
+ row.font = { bold: true }
+ row.alignment = { horizontal: "center" }
+ row.eachCell((cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ }
+ })
+ }
+ }
+ })
+
+ // ────────────── (핵심) 그룹 헤더 병합 로직 ──────────────
+ if (useGroupHeader) {
+ // row1 (인덱스 1) = 그룹명 행
+ // row2 (인덱스 2) = 실제 컬럼 헤더 행
+ const groupRowIndex = 1
+ const groupRow = worksheet.getRow(groupRowIndex)
+
+ // 같은 값이 연속되는 열을 병합
+ let start = 1 // 시작 열 인덱스 (1-based)
+ let prevValue = groupRow.getCell(start).value
+
+ for (let c = 2; c <= columns.length; c++) {
+ const cellVal = groupRow.getCell(c).value
+ if (cellVal !== prevValue) {
+ // 이전 그룹명이 빈 문자열이 아니면 병합
+ if (prevValue && prevValue.toString().trim() !== "") {
+ worksheet.mergeCells(groupRowIndex, start, groupRowIndex, c - 1)
+ }
+ // 다음 구간 시작
+ start = c
+ prevValue = cellVal
+ }
+ }
+
+ // 마지막 구간까지 병합
+ if (prevValue && prevValue.toString().trim() !== "") {
+ worksheet.mergeCells(groupRowIndex, start, groupRowIndex, columns.length)
+ }
+ }
+
+ // ────────────── (추가) 칼럼 너비 자동 조정 ──────────────
+ maxColumnLengths.forEach((len, idx) => {
+ // 최소 너비 10, +2 여백
+ worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10)
+ })
+
+ // ────────────── 최종 파일 다운로드 ──────────────
+ 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}.xlsx`
+ link.click()
+ URL.revokeObjectURL(url)
+} \ No newline at end of file
diff --git a/lib/filter-columns.ts b/lib/filter-columns.ts
new file mode 100644
index 00000000..4b995925
--- /dev/null
+++ b/lib/filter-columns.ts
@@ -0,0 +1,193 @@
+import { isEmpty, isNotEmpty } from "@/db/utils"
+import type { Filter, JoinOperator } from "@/types/table"
+import { addDays, endOfDay, startOfDay } from "date-fns"
+import {
+ and,
+ eq,
+ gt,
+ gte,
+ ilike,
+ inArray,
+ lt,
+ lte,
+ ne,
+ notIlike,
+ notInArray,
+ or,
+ type AnyColumn,
+ type SQL,
+ type Table,
+} from "drizzle-orm"
+import type { PgTable, PgView } from "drizzle-orm/pg-core"
+
+type TableOrView = PgTable | PgView<any>
+
+/**
+ * Construct SQL conditions based on the provided filters for a specific table.
+ *
+ * This function takes a table and an array of filters, and returns a SQL
+ * expression that represents the logical combination of these conditions. The conditions
+ * are combined using the specified join operator (either 'AND' or 'OR'), which is determined
+ * by the first filter's joinOperator property.
+ *
+ * Each filter can specify various operators (e.g., equality, inequality,
+ * comparison for numbers and dates, etc.) and the function will generate the appropriate
+ * SQL expressions based on the filter's type and value.
+ *
+ * @param table - The table to apply the filters on.
+ * @param filters - An array of filters to be applied to the table.
+ * @param joinOperator - The join operator to use for combining the filters.
+ * @returns A SQL expression representing the combined filters, or undefined if no valid
+ * filters are found.
+ */
+
+export function filterColumns<T extends TableOrView>({
+ table,
+ filters,
+ joinOperator,
+}: {
+ table: T
+ filters: Filter<T>[]
+ joinOperator: JoinOperator
+}): SQL | undefined {
+
+ const joinFn = joinOperator === "and" ? and : or
+
+ const conditions = filters.map((filter) => {
+ const column = getColumn(table, filter.id)
+
+ switch (filter.operator) {
+ case "eq":
+ if (Array.isArray(filter.value)) {
+ return inArray(column, filter.value)
+ } else if (
+ column.dataType === "boolean" &&
+ typeof filter.value === "string"
+ ) {
+ return eq(column, filter.value === "true")
+ } else if (filter.type === "date") {
+ const date = new Date(filter.value)
+ const start = startOfDay(date)
+ const end = endOfDay(date)
+ return and(gte(column, start), lte(column, end))
+ } else {
+ return eq(column, filter.value)
+ }
+ case "ne":
+ if (Array.isArray(filter.value)) {
+ return notInArray(column, filter.value)
+ } else if (column.dataType === "boolean") {
+ return ne(column, filter.value === "true")
+ } else if (filter.type === "date") {
+ const date = new Date(filter.value)
+ const start = startOfDay(date)
+ const end = endOfDay(date)
+ return or(lt(column, start), gt(column, end))
+ } else {
+ return ne(column, filter.value)
+ }
+ case "iLike":
+ return filter.type === "text" && typeof filter.value === "string"
+ ? ilike(column, `%${filter.value}%`)
+ : undefined
+ case "notILike":
+ return filter.type === "text" && typeof filter.value === "string"
+ ? notIlike(column, `%${filter.value}%`)
+ : undefined
+ case "lt":
+ return filter.type === "number"
+ ? lt(column, filter.value)
+ : filter.type === "date" && typeof filter.value === "string"
+ ? lt(column, endOfDay(new Date(filter.value)))
+ : undefined
+ case "lte":
+ return filter.type === "number"
+ ? lte(column, filter.value)
+ : filter.type === "date" && typeof filter.value === "string"
+ ? lte(column, endOfDay(new Date(filter.value)))
+ : undefined
+ case "gt":
+ return filter.type === "number"
+ ? gt(column, filter.value)
+ : filter.type === "date" && typeof filter.value === "string"
+ ? gt(column, startOfDay(new Date(filter.value)))
+ : undefined
+ case "gte":
+ return filter.type === "number"
+ ? gte(column, filter.value)
+ : filter.type === "date" && typeof filter.value === "string"
+ ? gte(column, startOfDay(new Date(filter.value)))
+ : undefined
+ case "isBetween":
+ return filter.type === "date" &&
+ Array.isArray(filter.value) &&
+ filter.value.length === 2
+ ? and(
+ filter.value[0]
+ ? gte(column, startOfDay(new Date(filter.value[0])))
+ : undefined,
+ filter.value[1]
+ ? lte(column, endOfDay(new Date(filter.value[1])))
+ : undefined
+ )
+ : undefined
+ case "isRelativeToToday":
+ if (filter.type === "date" && typeof filter.value === "string") {
+ const today = new Date()
+ const [amount, unit] = filter.value.split(" ") ?? []
+ let startDate: Date
+ let endDate: Date
+
+ if (!amount || !unit) return undefined
+
+ switch (unit) {
+ case "days":
+ startDate = startOfDay(addDays(today, parseInt(amount)))
+ endDate = endOfDay(startDate)
+ break
+ case "weeks":
+ startDate = startOfDay(addDays(today, parseInt(amount) * 7))
+ endDate = endOfDay(addDays(startDate, 6))
+ break
+ case "months":
+ startDate = startOfDay(addDays(today, parseInt(amount) * 30))
+ endDate = endOfDay(addDays(startDate, 29))
+ break
+ default:
+ return undefined
+ }
+
+ return and(gte(column, startDate), lte(column, endDate))
+ }
+ return undefined
+ case "isEmpty":
+ return isEmpty(column)
+ case "isNotEmpty":
+ return isNotEmpty(column)
+
+ default:
+ throw new Error(`Unsupported operator: ${filter.operator}`)
+ }
+ })
+
+ const validConditions = conditions.filter(
+ (condition) => condition !== undefined
+ )
+
+
+ return validConditions.length > 0 ? joinFn(...validConditions) : undefined
+}
+
+/**
+ * Get table column.
+ * @param table The table to get the column from.
+ * @param columnKey The key of the column to retrieve from the table.
+ * @returns The column corresponding to the provided key.
+ */
+
+export function getColumn<T extends TableOrView>(
+ table: T,
+ columnKey: keyof T
+): AnyColumn {
+ return table[columnKey] as AnyColumn
+} \ No newline at end of file
diff --git a/lib/fonts.ts b/lib/fonts.ts
new file mode 100644
index 00000000..c5e8958d
--- /dev/null
+++ b/lib/fonts.ts
@@ -0,0 +1,5 @@
+import { GeistMono } from "geist/font/mono"
+import { GeistSans } from "geist/font/sans"
+
+export const fontSans = GeistSans
+export const fontMono = GeistMono
diff --git a/lib/form-list/repository.ts b/lib/form-list/repository.ts
new file mode 100644
index 00000000..ced320db
--- /dev/null
+++ b/lib/form-list/repository.ts
@@ -0,0 +1,46 @@
+import db from "@/db/db";
+import { Item, items } from "@/db/schema/items";
+import { tagTypeClassFormMappings } from "@/db/schema/vendorData";
+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 selectFormLists(
+ 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(tagTypeClassFormMappings)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+ }
+ /** 총 개수 count */
+ export async function countFormLists(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+ ) {
+ const res = await tx.select({ count: count() }).from(tagTypeClassFormMappings).where(where);
+ return res[0]?.count ?? 0;
+ }
+ \ No newline at end of file
diff --git a/lib/form-list/service.ts b/lib/form-list/service.ts
new file mode 100644
index 00000000..64156cf4
--- /dev/null
+++ b/lib/form-list/service.ts
@@ -0,0 +1,84 @@
+"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 { GetFormListsSchema } from "./validation";
+import { filterColumns } from "@/lib/filter-columns";
+import { tagTypeClassFormMappings } from "@/db/schema/vendorData";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm";
+import { countFormLists, selectFormLists } from "./repository";
+
+export async function getFormLists(input: GetFormListsSchema) {
+
+ 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: tagTypeClassFormMappings,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(tagTypeClassFormMappings.formCode, s), ilike(tagTypeClassFormMappings.formName, s)
+ , ilike(tagTypeClassFormMappings.tagTypeLabel, s) , ilike(tagTypeClassFormMappings.classLabel, 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(tagTypeClassFormMappings[item.id]) : asc(tagTypeClassFormMappings[item.id])
+ )
+ : [asc(tagTypeClassFormMappings.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectFormLists(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countFormLists(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: ["form-lists"], // revalidateTag("items") 호출 시 무효화
+ }
+ )();
+ } \ No newline at end of file
diff --git a/lib/form-list/table/feature-flags-provider.tsx b/lib/form-list/table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/form-list/table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/form-list/table/formLists-table-columns.tsx b/lib/form-list/table/formLists-table-columns.tsx
new file mode 100644
index 00000000..f638c4df
--- /dev/null
+++ b/lib/form-list/table/formLists-table-columns.tsx
@@ -0,0 +1,132 @@
+"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 { TagTypeClassFormMappings } from "@/db/schema/vendorData"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TagTypeClassFormMappings> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TagTypeClassFormMappings>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (단일 버튼 - Meta Info 바로 보기)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<TagTypeClassFormMappings> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => setRowAction({ row, type: "items" })}
+ >
+ <InfoIcon className="h-4 w-4" aria-hidden="true" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ View Meta Info
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<TagTypeClassFormMappings>[] }
+ const groupMap: Record<string, ColumnDef<TagTypeClassFormMappings>[]> = {}
+
+ formListsColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<TagTypeClassFormMappings> = {
+ 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<TagTypeClassFormMappings>[] = []
+
+ // 순서를 고정하고 싶다면 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,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/form-list/table/formLists-table-toolbar-actions.tsx b/lib/form-list/table/formLists-table-toolbar-actions.tsx
new file mode 100644
index 00000000..346a3980
--- /dev/null
+++ b/lib/form-list/table/formLists-table-toolbar-actions.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, RefreshCcw, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { TagTypeClassFormMappings } from "@/db/schema/vendorData"
+
+
+
+interface ItemsTableToolbarActionsProps {
+ table: Table<TagTypeClassFormMappings>
+}
+
+export function FormListsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="samsung"
+ size="sm"
+ className="gap-2"
+ >
+ <RefreshCcw className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Get Forms</span>
+ </Button>
+
+ {/** 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>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/form-list/table/formLists-table.tsx b/lib/form-list/table/formLists-table.tsx
new file mode 100644
index 00000000..be252655
--- /dev/null
+++ b/lib/form-list/table/formLists-table.tsx
@@ -0,0 +1,151 @@
+"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 { TagTypeClassFormMappings } from "@/db/schema/vendorData"
+import { getFormLists } from "../service"
+import { getColumns } from "./formLists-table-columns"
+import { FormListsTableToolbarActions } from "./formLists-table-toolbar-actions"
+import { ViewMetas } from "./meta-sheet"
+
+interface ItemsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getFormLists>>,
+ ]
+ >
+}
+
+export function FormListsTable({ promises }: ItemsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<TagTypeClassFormMappings> | 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<TagTypeClassFormMappings>[] = [
+
+
+ ]
+
+ /**
+ * 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<TagTypeClassFormMappings>[] = [
+ {
+ id: "formCode",
+ label: "Form Code",
+ type: "text",
+
+ },
+ {
+ id: "formName",
+ label: "Form Name",
+ type: "text",
+
+ },
+ {
+ id: "tagTypeLabel",
+ label: "Tag Type",
+ type: "text",
+
+ },
+ {
+ id: "classLabel",
+ label: "Class",
+ type: "text",
+
+ },
+
+ {
+ id: "createdAt",
+ label: "Created At",
+ type: "date",
+
+ },
+ {
+ id: "updatedAt",
+ label: "Updated At",
+ 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}
+ shallow={false}
+ >
+ <FormListsTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+
+ </DataTable>
+ <ViewMetas
+ open={rowAction?.type === "items"}
+ onOpenChange={() => setRowAction(null)}
+ form={rowAction?.row.original ?? null}
+ />
+
+ </>
+ )
+}
diff --git a/lib/form-list/table/meta-sheet.tsx b/lib/form-list/table/meta-sheet.tsx
new file mode 100644
index 00000000..155e4f5a
--- /dev/null
+++ b/lib/form-list/table/meta-sheet.tsx
@@ -0,0 +1,245 @@
+"use client"
+
+import * as React from "react"
+import { useMemo } from "react"
+import { Badge } from "@/components/ui/badge"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle
+} from "@/components/ui/sheet"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow
+} from "@/components/ui/table"
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger
+} from "@/components/ui/tabs"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle
+} from "@/components/ui/card"
+import type { TagTypeClassFormMappings } from "@/db/schema/vendorData" // or your actual type
+import { fetchFormMetadata, FormColumn } from "@/lib/forms/services"
+
+
+interface ViewMetasProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ form: TagTypeClassFormMappings | null
+}
+
+export function ViewMetas({ open, onOpenChange, form }: ViewMetasProps) {
+ // metadata & loading
+ const [metadata, setMetadata] = React.useState<{
+ formName: string
+ formCode: string
+ columns: FormColumn[]
+ } | null>(null)
+ const [loading, setLoading] = React.useState(false)
+
+ // Group columns by type for better organization
+ const groupedColumns = useMemo(() => {
+ if (!metadata?.columns) return {}
+
+ return metadata.columns.reduce((acc, column) => {
+ const type = column.type
+ if (!acc[type]) {
+ acc[type] = []
+ }
+ acc[type].push(column)
+ return acc
+ }, {} as Record<string, FormColumn[]>)
+ }, [metadata])
+
+ // Types for the tabs
+ const columnTypes = useMemo(() => {
+ return Object.keys(groupedColumns)
+ }, [groupedColumns])
+
+ // Fetch metadata when form changes and dialog is opened
+ React.useEffect(() => {
+ async function fetchMeta() {
+ if (!form || !open) return
+
+ setLoading(true)
+ try {
+ // 서버 액션 호출
+ const metaData = await fetchFormMetadata(form.formCode)
+ if (metaData) {
+ setMetadata(metaData)
+ } else {
+ setMetadata(null)
+ }
+ } catch (error) {
+ console.error("Error fetching form metadata:", error)
+ setMetadata(null)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchMeta()
+ }, [form, open])
+
+ if (!form) return null
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="sm:max-w-xl md:max-w-3xl lg:max-w-4xl xl:max-w-5xl overflow-y-auto">
+
+ <SheetHeader className="mb-4">
+ <SheetTitle>Form Metadata</SheetTitle>
+ <SheetDescription>
+ </SheetDescription>
+ {loading ? (
+ <div className="text-muted-foreground">Loading metadata...</div>
+ ) : metadata ? (
+ <div className="flex flex-col gap-1">
+ <div className="flex gap-2 items-center">
+ <span className="font-semibold">Form Code:</span>
+ <Badge variant="outline">{metadata.formCode}</Badge>
+ </div>
+ <div className="flex gap-2 items-center">
+ <span className="font-semibold">Form Name:</span>
+ <span>{metadata.formName}</span>
+ </div>
+ </div>
+ ) : (
+ <div className="text-sm text-muted-foreground">
+ No metadata found for form code: {form.formCode}
+ </div>
+ )}
+
+ </SheetHeader>
+
+ {loading ? (
+ <div className="flex items-center justify-center h-40">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
+ </div>
+ ) : metadata ? (
+ <Tabs defaultValue="all" className="mt-4">
+ <TabsList className="mb-4 flex-wrap">
+ <TabsTrigger value="all">All ({metadata.columns.length})</TabsTrigger>
+ {columnTypes.map((type) => (
+ <TabsTrigger key={type} value={type}>
+ {type} ({groupedColumns[type].length})
+ </TabsTrigger>
+ ))}
+ </TabsList>
+
+ <TabsContent value="all">
+ <Card>
+ <CardHeader>
+ <CardTitle>All Fields</CardTitle>
+ <CardDescription>All form fields and their properties</CardDescription>
+ </CardHeader>
+ <CardContent>
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>Key</TableHead>
+ <TableHead>Label</TableHead>
+ <TableHead>Type</TableHead>
+ <TableHead>Options</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {metadata.columns.map((column) => (
+ <TableRow key={column.key}>
+ <TableCell className="font-mono text-sm">{column.key}</TableCell>
+ <TableCell>{column.label}</TableCell>
+ <TableCell>
+ <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>
+ ) : (
+ "-"
+ )}
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {columnTypes.map((type) => (
+ <TabsContent key={type} value={type}>
+ <Card>
+ <CardHeader>
+ <CardTitle>{type.charAt(0).toUpperCase() + type.slice(1)} Fields</CardTitle>
+ <CardDescription>Fields with type "{type}"</CardDescription>
+ </CardHeader>
+ <CardContent>
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>Key</TableHead>
+ <TableHead>Label</TableHead>
+ {type === "select" && <TableHead>Options</TableHead>}
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {groupedColumns[type].map((column) => (
+ <TableRow key={column.key}>
+ <TableCell className="font-mono text-sm">{column.key}</TableCell>
+ <TableCell>{column.label}</TableCell>
+ {type === "select" && (
+ <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>
+ ) : (
+ "-"
+ )}
+ </TableCell>
+ )}
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ ))}
+ </Tabs>
+ ) : (
+ <div className="text-center py-8">
+ <div className="text-lg font-medium">No metadata found</div>
+ <p className="text-muted-foreground mt-2">
+ Could not find metadata for form code: {form.formCode}
+ </p>
+ </div>
+ )}
+
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/form-list/validation.ts b/lib/form-list/validation.ts
new file mode 100644
index 00000000..c8baf960
--- /dev/null
+++ b/lib/form-list/validation.ts
@@ -0,0 +1,36 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { TagTypeClassFormMappings } from "@/db/schema/vendorData";
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<TagTypeClassFormMappings>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ tagTypeLabel: parseAsString.withDefault(""),
+ classLabel: parseAsString.withDefault(""),
+ formCode: parseAsString.withDefault(""),
+ formName: parseAsString.withDefault(""),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+
+
+export type GetFormListsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
diff --git a/lib/forms/services.ts b/lib/forms/services.ts
new file mode 100644
index 00000000..e5fc8666
--- /dev/null
+++ b/lib/forms/services.ts
@@ -0,0 +1,645 @@
+// lib/forms/services.ts
+"use server"
+
+import db from "@/db/db";
+import { formEntries, formMetas, forms, tags, tagTypeClassFormMappings } from "@/db/schema/vendorData"
+import { eq, and, desc, sql, DrizzleError, or } 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";
+
+export interface FormInfo {
+ id: number
+ formCode: string
+ formName: string
+ // tagType: string
+}
+
+export async function getFormsByContractItemId(contractItemId: number | null) {
+ // 유효성 검사
+ if (!contractItemId || contractItemId <= 0) {
+ console.warn(`Invalid contractItemId: ${contractItemId}`);
+ return { forms: [] };
+ }
+
+ // 고유 캐시 키
+ const cacheKey = `forms-${contractItemId}`;
+
+ try {
+ return unstable_cache(
+ async () => {
+ console.log(`[Forms Service] Fetching forms for contractItemId: ${contractItemId}`);
+
+ 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));
+
+ console.log(`[Forms Service] Found ${formRecords.length} forms for contractItemId: ${contractItemId}`);
+
+ // 결과가 배열인지 확인
+ 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}`);
+ throw error; // 캐시 함수에서 에러를 던져 캐싱이 발생하지 않도록 함
+ }
+ },
+ [cacheKey],
+ {
+ // 캐시 시간 단축
+ revalidate: 60, // 1분으로 줄임
+ tags: [cacheKey]
+ }
+ )();
+ } catch (error) {
+ getErrorMessage(`Cache operation failed for contractItemId ${contractItemId}: ${error}`);
+
+ // 캐시 문제 시 직접 쿼리 시도
+ try {
+ console.log(`[Forms Service] Fallback: Direct query for contractItemId: ${contractItemId}`);
+
+ const formRecords = await db
+ .select({
+ id: forms.id,
+ formCode: forms.formCode,
+ formName: forms.formName,
+ // tagType: forms.tagType,
+ })
+ .from(forms)
+ .where(eq(forms.contractItemId, contractItemId));
+
+ return { forms: formRecords };
+ } catch (dbError) {
+ getErrorMessage(`Fallback query failed for contractItemId ${contractItemId}:${dbError}`);
+ return { forms: [] };
+ }
+ }
+}
+
+/**
+ * 폼 캐시를 갱신하는 서버 액션
+ */
+export async function revalidateForms(contractItemId: number) {
+ if (!contractItemId) return;
+
+ const cacheKey = `forms-${contractItemId}`;
+ console.log(`[Forms Service] Invalidating cache for ${cacheKey}`);
+
+ try {
+ revalidateTag(cacheKey);
+ console.log(`[Forms Service] Cache invalidated for ${cacheKey}`);
+ } catch (error) {
+ getErrorMessage(`Failed to invalidate cache for ${cacheKey}: ${error}`);
+ }
+}
+
+/**
+ * "가장 최신 1개 row"를 가져오고,
+ * data가 배열이면 그 배열을 반환,
+ * 그리고 이 로직 전체를 unstable_cache로 감싸 캐싱.
+ */
+export async function getFormData(formCode: string, contractItemId: number) {
+ // 고유 캐시 키 (formCode + contractItemId)
+ const cacheKey = `form-data-${formCode}-${contractItemId}`
+
+ try {
+ // 1) unstable_cache로 전체 로직을 감싼다
+ const result = await unstable_cache(
+ async () => {
+ // --- 기존 로직 시작 ---
+ // (1) form_metas 조회 (가정상 1개만 존재)
+ const metaRows = await db
+ .select()
+ .from(formMetas)
+ .where(eq(formMetas.formCode, formCode))
+ .orderBy(desc(formMetas.updatedAt))
+ .limit(1)
+
+ const meta = metaRows[0] ?? null
+ if (!meta) {
+ return { columns: null, data: [] }
+ }
+ // (2) form_entries에서 (formCode, contractItemId)에 해당하는 "가장 최신" 한 행
+ const entryRows = await db
+ .select()
+ .from(formEntries)
+ .where(and(eq(formEntries.formCode, formCode), eq(formEntries.contractItemId, contractItemId)))
+ .orderBy(desc(formEntries.updatedAt))
+ .limit(1)
+
+ const entry = entryRows[0] ?? null
+
+ // columns: DB에 저장된 JSON (DataTableColumnJSON[])
+ const columns = meta.columns as DataTableColumnJSON[]
+
+ columns.forEach((col) => {
+ // 이미 displayLabel이 있으면 그대로 두고,
+ // 없으면 uom이 있으면 "label (uom)" 형태,
+ // 둘 다 없으면 label만 쓴다.
+ if (!col.displayLabel) {
+ if (col.uom) {
+ col.displayLabel = `${col.label} (${col.uom})`
+ } else {
+ col.displayLabel = col.label
+ }
+ }
+ })
+
+ // data: 만약 entry가 없거나, data가 아닌 형태면 빈 배열
+ let data: Array<Record<string, any>> = []
+ if (entry) {
+ if (Array.isArray(entry.data)) {
+ data = entry.data
+ } else {
+ console.warn("formEntries data was not an array. Using empty array.")
+ }
+ }
+
+ return { columns, data }
+ // --- 기존 로직 끝 ---
+ },
+ [cacheKey], // 캐시 키 의존성
+ {
+ revalidate: 60, // 1분 캐시
+ tags: [cacheKey], // 캐시 태그
+ }
+ )()
+
+ return result
+ } catch (cacheError) {
+ console.error(`[getFormData] Cache operation failed:`, cacheError)
+
+ // --- fallback: 캐시 문제 시 직접 쿼리 시도 ---
+ try {
+ console.log(`[getFormData] Fallback DB query for (${formCode}, ${contractItemId})`)
+
+ // (1) form_metas
+ const metaRows = await db
+ .select()
+ .from(formMetas)
+ .where(eq(formMetas.formCode, formCode))
+ .orderBy(desc(formMetas.updatedAt))
+ .limit(1)
+
+ const meta = metaRows[0] ?? null
+ if (!meta) {
+ return { columns: null, data: [] }
+ }
+
+ // (2) form_entries
+ const entryRows = await db
+ .select()
+ .from(formEntries)
+ .where(and(eq(formEntries.formCode, formCode), eq(formEntries.contractItemId, contractItemId)))
+ .orderBy(desc(formEntries.updatedAt))
+ .limit(1)
+
+ const entry = entryRows[0] ?? null
+
+ const columns = meta.columns as DataTableColumnJSON[]
+
+ columns.forEach((col) => {
+ // 이미 displayLabel이 있으면 그대로 두고,
+ // 없으면 uom이 있으면 "label (uom)" 형태,
+ // 둘 다 없으면 label만 쓴다.
+ if (!col.displayLabel) {
+ if (col.uom) {
+ col.displayLabel = `${col.label} (${col.uom})`
+ } else {
+ col.displayLabel = col.label
+ }
+ }
+ })
+
+ let data: Array<Record<string, any>> = []
+ if (entry) {
+ if (Array.isArray(entry.data)) {
+ data = entry.data
+ } else {
+ console.warn("formEntries data was not an array. Using empty array (fallback).")
+ }
+ }
+
+ return { columns, data }
+ } catch (dbError) {
+ console.error(`[getFormData] Fallback DB query failed:`, dbError)
+ return { columns: null, data: [] }
+ }
+ }
+}
+
+// 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, formCode: string) {
+ // (1) Ensure there's a row in `forms` matching (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}`
+ )
+ }
+
+ // (2) Get all mappings from `tagTypeClassFormMappings` for this formCode.
+ const formMappings = await db
+ .select()
+ .from(tagTypeClassFormMappings)
+ .where(eq(tagTypeClassFormMappings.formCode, formCode))
+
+ // If no mappings are found, there's nothing to sync.
+ if (formMappings.length === 0) {
+ console.log(`No mappings found for formCode=${formCode}`)
+ return { createdCount: 0, updatedCount: 0, deletedCount: 0 }
+ }
+
+ // Build a dynamic OR clause to match (tagType, class) pairs from the mappings.
+ const orConditions = formMappings.map((m) =>
+ and(eq(tags.tagType, m.tagTypeLabel), eq(tags.class, m.classLabel))
+ )
+
+ // (3) Fetch all matching `tags` for the contractItemId + any of the (tagType, class) pairs.
+ const tagRows = await db
+ .select()
+ .from(tags)
+ .where(and(eq(tags.contractItemId, contractItemId), or(...orConditions)))
+
+ // (4) Fetch (or create) a single `formEntries` row for (contractItemId, formCode).
+ let [entry] = await db
+ .select()
+ .from(formEntries)
+ .where(
+ and(
+ eq(formEntries.contractItemId, contractItemId),
+ eq(formEntries.formCode, formCode)
+ )
+ )
+ .limit(1)
+
+ if (!entry) {
+ const [inserted] = await db
+ .insert(formEntries)
+ .values({
+ contractItemId,
+ formCode,
+ data: [], // Initialize with empty array
+ })
+ .returning()
+ entry = inserted
+ }
+
+ // entry.data는 [{ tagNumber: string, tagDescription?: string }, ...] 형태라고 가정
+ const existingData = entry.data as Array<{
+ tagNumber: string
+ tagDescription?: string
+ }>
+
+ // Create a Set of valid tagNumbers from tagRows for efficient lookup
+ const validTagNumbers = new Set(tagRows.map(tag => tag.tagNo))
+
+ // Copy existing data to work with
+ let updatedData: Array<{
+ tagNumber: string
+ tagDescription?: string
+ }> = []
+
+ let createdCount = 0
+ let updatedCount = 0
+ let deletedCount = 0
+
+ // First, filter out items that should be deleted (not in validTagNumbers)
+ for (const item of existingData) {
+ if (validTagNumbers.has(item.tagNumber)) {
+ updatedData.push(item)
+ } else {
+ deletedCount++
+ }
+ }
+
+ // (5) For each tagRow, if it's missing in updatedData, push it in.
+ // 이미 있는 경우에도 description이 달라지면 업데이트할 수 있음.
+ for (const tagRow of tagRows) {
+ const { tagNo, description } = tagRow
+
+ // 5-1. 기존 데이터에서 tagNumber 매칭
+ const existingIndex = updatedData.findIndex(
+ (item) => item.tagNumber === tagNo
+ )
+
+ // 5-2. 없다면 새로 추가
+ if (existingIndex === -1) {
+ updatedData.push({
+ tagNumber: tagNo,
+ tagDescription: description ?? "",
+ })
+ createdCount++
+ } else {
+ // 5-3. 이미 있으면, description이 다를 때만 업데이트(선택 사항)
+ const existingItem = updatedData[existingIndex]
+ if (existingItem.tagDescription !== description) {
+ updatedData[existingIndex] = {
+ ...existingItem,
+ tagDescription: description ?? "",
+ }
+ updatedCount++
+ }
+ }
+ }
+
+ // (6) 실제로 추가되거나 수정되거나 삭제된 게 있다면 DB에 반영
+ if (createdCount > 0 || updatedCount > 0 || deletedCount > 0) {
+ await db
+ .update(formEntries)
+ .set({ data: updatedData })
+ .where(eq(formEntries.id, entry.id))
+ }
+
+ // 캐시 무효화 등 후처리
+ revalidateTag(`form-data-${formCode}-${contractItemId}`)
+
+ return { createdCount, updatedCount, deletedCount }
+}
+
+/**
+ * updateFormDataInDB:
+ * (formCode, contractItemId)에 해당하는 "단 하나의" formEntries row를 가져와,
+ * data: [{ tagNumber, ...}, ...] 배열에서 tagNumber 매칭되는 항목을 업데이트
+ * 업데이트 후, revalidateTag()로 캐시 무효화.
+ */
+type UpdateResponse = {
+ success: boolean
+ message: string
+ data?: any
+}
+
+export async function updateFormDataInDB(
+ formCode: string,
+ contractItemId: number,
+ newData: Record<string, any>
+): Promise<UpdateResponse> {
+ try {
+ // 1) tagNumber로 식별
+ const tagNumber = newData.tagNumber
+ if (!tagNumber) {
+ return {
+ success: false,
+ message: "tagNumber는 필수 항목입니다."
+ }
+ }
+
+ // 2) row 찾기 (단 하나)
+ const entries = await db
+ .select()
+ .from(formEntries)
+ .where(
+ and(
+ eq(formEntries.formCode, formCode),
+ eq(formEntries.contractItemId, contractItemId)
+ )
+ )
+ .limit(1)
+
+ if (!entries || entries.length === 0) {
+ return {
+ success: false,
+ message: `폼 데이터를 찾을 수 없습니다. (formCode=${formCode}, contractItemId=${contractItemId})`
+ }
+ }
+
+ const entry = entries[0]
+
+ // 3) data가 배열인지 확인
+ if (!entry.data) {
+ return {
+ success: false,
+ message: "폼 데이터가 없습니다."
+ }
+ }
+
+ const dataArray = entry.data as Array<Record<string, any>>
+ if (!Array.isArray(dataArray)) {
+ return {
+ success: false,
+ message: "폼 데이터가 올바른 형식이 아닙니다. 배열 형식이어야 합니다."
+ }
+ }
+
+ // 4) tagNumber = newData.tagNumber 항목 찾기
+ const idx = dataArray.findIndex((item) => item.tagNumber === tagNumber)
+ if (idx < 0) {
+ return {
+ success: false,
+ message: `태그 번호 "${tagNumber}"를 가진 항목을 찾을 수 없습니다.`
+ }
+ }
+
+ // 5) 병합
+ const oldItem = dataArray[idx]
+ const updatedItem = {
+ ...oldItem,
+ ...newData,
+ tagNumber: oldItem.tagNumber, // tagNumber 변경 불가 시 유지
+ }
+
+ const updatedArray = [...dataArray]
+ updatedArray[idx] = updatedItem
+
+ // 6) DB UPDATE
+ try {
+ await db
+ .update(formEntries)
+ .set({
+ data: updatedArray,
+ updatedAt: new Date() // 업데이트 시간도 갱신
+ })
+ .where(eq(formEntries.id, entry.id))
+ } catch (dbError) {
+ console.error("Database update error:", dbError)
+
+ if (dbError instanceof DrizzleError) {
+ return {
+ success: false,
+ message: `데이터베이스 업데이트 오류: ${dbError.message}`
+ }
+ }
+
+ return {
+ success: false,
+ message: "데이터베이스 업데이트 중 오류가 발생했습니다."
+ }
+ }
+
+ // 7) Cache 무효화
+ try {
+ // 캐시 태그를 form-data-${formCode}-${contractItemId} 형태로 가정
+ const cacheTag = `form-data-${formCode}-${contractItemId}`
+ revalidateTag(cacheTag)
+ } catch (cacheError) {
+ console.warn("Cache revalidation warning:", cacheError)
+ // 캐시 무효화는 실패해도 업데이트 자체는 성공했으므로 경고만 로그로 남김
+ }
+
+ return {
+ success: true,
+ message: '데이터가 성공적으로 업데이트되었습니다.',
+ data: {
+ tagNumber,
+ updatedFields: Object.keys(newData).filter(key => key !== 'tagNumber')
+ }
+ }
+ } catch (error) {
+ // 예상치 못한 오류 처리
+ console.error("Unexpected error in updateFormDataInDB:", error)
+ return {
+ success: false,
+ message: error instanceof Error
+ ? `예상치 못한 오류가 발생했습니다: ${error.message}`
+ : "알 수 없는 오류가 발생했습니다."
+ }
+ }
+}
+
+// FormColumn Type (동일)
+export interface FormColumn {
+ key: string
+ type: string
+ label: string
+ options?: string[]
+}
+
+interface MetadataResult {
+ formName: string
+ formCode: string
+ columns: FormColumn[]
+}
+
+/**
+ * 서버 액션:
+ * 주어진 formCode에 해당하는 form_metas 레코드 1개를 찾아서
+ * { formName, formCode, columns } 형태로 반환.
+ * 없으면 null.
+ */
+export async function fetchFormMetadata(formCode: string): Promise<MetadataResult | null> {
+ try {
+ // 기존 방식: select().from().where()
+ const rows = await db
+ .select()
+ .from(formMetas)
+ .where(eq(formMetas.formCode, formCode))
+ .limit(1)
+
+ // rows는 배열
+ const metaData = rows[0]
+ if (!metaData) return null
+
+ return {
+ formCode: metaData.formCode,
+ formName: metaData.formName,
+ columns: metaData.columns as FormColumn[]
+ }
+ } catch (err) {
+ console.error("Error in fetchFormMetadata:", err)
+ return null
+ }
+} \ No newline at end of file
diff --git a/lib/handle-error.ts b/lib/handle-error.ts
new file mode 100644
index 00000000..1f608723
--- /dev/null
+++ b/lib/handle-error.ts
@@ -0,0 +1,22 @@
+import { toast } from "sonner"
+import { z } from "zod"
+
+export function getErrorMessage(err: unknown) {
+ const unknownError = "Something went wrong, please try again later."
+
+ if (err instanceof z.ZodError) {
+ const errors = err.issues.map((issue) => {
+ return issue.message
+ })
+ return errors.join("\n")
+ } else if (err instanceof Error) {
+ return err.message
+ } else {
+ return unknownError
+ }
+}
+
+export function showErrorToast(err: unknown) {
+ const errorMessage = getErrorMessage(err)
+ return toast.error(errorMessage)
+}
diff --git a/lib/id.ts b/lib/id.ts
new file mode 100644
index 00000000..e6e44cbd
--- /dev/null
+++ b/lib/id.ts
@@ -0,0 +1,43 @@
+import { customAlphabet } from "nanoid"
+
+const prefixes = {
+ task: "tsk",
+}
+
+interface GenerateIdOptions {
+ /**
+ * The length of the generated ID.
+ * @default 12
+ * @example 12 => "abc123def456"
+ * */
+ length?: number
+ /**
+ * The separator to use between the prefix and the generated ID.
+ * @default "_"
+ * @example "_" => "str_abc123"
+ * */
+ separator?: string
+}
+
+/**
+ * Generates a unique ID with optional prefix and configuration.
+ * @param prefixOrOptions The prefix string or options object
+ * @param options The options for generating the ID
+ */
+export function generateId(
+ prefixOrOptions?: keyof typeof prefixes | GenerateIdOptions,
+ options: GenerateIdOptions = {}
+) {
+ if (typeof prefixOrOptions === "object") {
+ options = prefixOrOptions
+ prefixOrOptions = undefined
+ }
+
+ const { length = 12, separator = "_" } = options
+ const id = customAlphabet(
+ "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
+ length
+ )()
+
+ return prefixOrOptions ? `${prefixes[prefixOrOptions]}${separator}${id}` : id
+}
diff --git a/lib/items/repository.ts b/lib/items/repository.ts
new file mode 100644
index 00000000..550e6b1d
--- /dev/null
+++ b/lib/items/repository.ts
@@ -0,0 +1,125 @@
+// src/lib/items/repository.ts
+import db from "@/db/db";
+import { Item, items } from "@/db/schema/items";
+import {
+ eq,
+ inArray,
+ not,
+ asc,
+ desc,
+ and,
+ ilike,
+ gte,
+ lte,
+ count,
+ gt,
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+export type NewItem = typeof items.$inferInsert
+
+/**
+ * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시
+ * - 트랜잭션(tx)을 받아서 사용하도록 구현
+ */
+export async function selectItems(
+ 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(items)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+/** 총 개수 count */
+export async function countItems(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(items).where(where);
+ return res[0]?.count ?? 0;
+}
+
+/** 단건 Insert 예시 */
+export async function insertItem(
+ tx: PgTransaction<any, any, any>,
+ data: NewItem // DB와 동일한 insert 가능한 타입
+) {
+ // returning() 사용 시 배열로 돌아오므로 [0]만 리턴
+ return tx
+ .insert(items)
+ .values(data)
+ .returning({ id: items.id, createdAt: items.createdAt });
+}
+
+/** 복수 Insert 예시 */
+export async function insertItems(
+ tx: PgTransaction<any, any, any>,
+ data: Item[]
+) {
+ return tx.insert(items).values(data).onConflictDoNothing();
+}
+
+
+
+/** 단건 삭제 */
+export async function deleteItemById(
+ tx: PgTransaction<any, any, any>,
+ itemId: number
+) {
+ return tx.delete(items).where(eq(items.id, itemId));
+}
+
+/** 복수 삭제 */
+export async function deleteItemsByIds(
+ tx: PgTransaction<any, any, any>,
+ ids: number[]
+) {
+ return tx.delete(items).where(inArray(items.id, ids));
+}
+
+/** 전체 삭제 */
+export async function deleteAllItems(
+ tx: PgTransaction<any, any, any>,
+) {
+ return tx.delete(items);
+}
+
+/** 단건 업데이트 */
+export async function updateItem(
+ tx: PgTransaction<any, any, any>,
+ itemId: number,
+ data: Partial<Item>
+) {
+ return tx
+ .update(items)
+ .set(data)
+ .where(eq(items.id, itemId))
+ .returning({ id: items.id, createdAt: items.createdAt });
+}
+
+/** 복수 업데이트 */
+export async function updateItems(
+ tx: PgTransaction<any, any, any>,
+ ids: number[],
+ data: Partial<Item>
+) {
+ return tx
+ .update(items)
+ .set(data)
+ .where(inArray(items.id, ids))
+ .returning({ id: items.id, createdAt: items.createdAt });
+}
+
+export async function findAllItems(): Promise<Item[]> {
+ return db.select().from(items).orderBy(asc(items.itemCode));
+}
diff --git a/lib/items/service.ts b/lib/items/service.ts
new file mode 100644
index 00000000..ef14a5f0
--- /dev/null
+++ b/lib/items/service.ts
@@ -0,0 +1,201 @@
+// src/lib/items/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 } 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";
+
+
+/* -----------------------------------------------------
+ 1) 조회 관련
+----------------------------------------------------- */
+
+/**
+ * 복잡한 조건으로 Item 목록을 조회 (+ pagination) 하고,
+ * 총 개수에 따라 pageCount를 계산해서 리턴.
+ * Next.js의 unstable_cache를 사용해 일정 시간 캐시.
+ */
+export async function getItems(input: GetItemsSchema) {
+
+ 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: items,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(items.itemCode, s), ilike(items.itemName, s)
+ , ilike(items.description, 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(items[item.id]) : asc(items[item.id])
+ )
+ : [asc(items.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectItems(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countItems(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: ["items"], // revalidateTag("items") 호출 시 무효화
+ }
+ )();
+}
+
+
+/* -----------------------------------------------------
+ 2) 생성(Create)
+----------------------------------------------------- */
+
+
+/**
+ * Item 생성 후, (가장 오래된 Item 1개) 삭제로
+ * 전체 Item 개수를 고정
+ */
+export async function createItem(input: CreateItemSchema) {
+ 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;
+
+ });
+
+ // 캐시 무효화
+ revalidateTag("items");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/* -----------------------------------------------------
+ 3) 업데이트
+----------------------------------------------------- */
+
+/** 단건 업데이트 */
+export async function modifyItem(input: UpdateItemSchema & { id: number }) {
+ unstable_noStore();
+ try {
+ const data = await db.transaction(async (tx) => {
+ const [res] = await updateItem(tx, input.id, {
+ itemCode: input.itemCode,
+ itemName: input.itemName,
+ description: input.description,
+ });
+ return res;
+ });
+
+ revalidateTag("items");
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+
+
+/** 단건 삭제 */
+export async function removeItem(input: { id: number }) {
+ unstable_noStore();
+ try {
+ await db.transaction(async (tx) => {
+ // 삭제
+ await deleteItemById(tx, input.id);
+ // 바로 새 Item 생성
+ });
+
+ revalidateTag("items");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/** 복수 삭제 */
+export async function removeItems(input: { ids: number[] }) {
+ unstable_noStore();
+ try {
+ await db.transaction(async (tx) => {
+ // 삭제
+ await deleteItemsByIds(tx, input.ids);
+ });
+
+ revalidateTag("items");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+export async function getAllItems(): Promise<Item[]> {
+ try {
+ return await findAllItems();
+ } catch (err) {
+ throw new Error("Failed to get roles");
+ }
+}
diff --git a/lib/items/table/add-items-dialog.tsx b/lib/items/table/add-items-dialog.tsx
new file mode 100644
index 00000000..2224444c
--- /dev/null
+++ b/lib/items/table/add-items-dialog.tsx
@@ -0,0 +1,156 @@
+"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"
+// react-hook-form + shadcn/ui Form
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+// shadcn/ui Select
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { createItemSchema, CreateItemSchema } from "../validations"
+import { createItem } from "../service"
+import { Textarea } from "@/components/ui/textarea"
+
+
+
+export function AddItemDialog() {
+ const [open, setOpen] = React.useState(false)
+
+ // react-hook-form 세팅
+ const form = useForm<CreateItemSchema>({
+ resolver: zodResolver(createItemSchema),
+ defaultValues: {
+ itemCode: "",
+ itemName: "",
+ description: "",
+ },
+ })
+
+ async function onSubmit(data: CreateItemSchema) {
+ const result = await createItem(data)
+ if (result.error) {
+ alert(`에러: ${result.error}`)
+ return
+ }
+ // 성공 시 모달 닫고 폼 리셋
+ form.reset()
+ setOpen(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 Item
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Create New Item</DialogTitle>
+ <DialogDescription>
+ 새 Item 정보를 입력하고 <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="itemCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Item Code</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="e.g."
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="itemName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Item Name</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="e.g."
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Description</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="e.g."
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ >
+ Cancel
+ </Button>
+ <Button type="submit" disabled={form.formState.isSubmitting}>
+ Create
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/items/table/delete-items-dialog.tsx b/lib/items/table/delete-items-dialog.tsx
new file mode 100644
index 00000000..25ae265b
--- /dev/null
+++ b/lib/items/table/delete-items-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 { Item } from "@/db/schema/items"
+import { removeItems } from "../service"
+
+interface DeleteItemsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ items: Row<Item>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteItemsDialog({
+ items,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteItemsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removeItems({
+ ids: items.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 ({items.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">{items.length}</span>
+ {items.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 ({items.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">{items.length}</span>
+ {items.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/items/table/feature-flags-provider.tsx b/lib/items/table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/items/table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/items/table/feature-flags.tsx b/lib/items/table/feature-flags.tsx
new file mode 100644
index 00000000..aaae6af2
--- /dev/null
+++ b/lib/items/table/feature-flags.tsx
@@ -0,0 +1,96 @@
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface TasksTableContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const TasksTableContext = React.createContext<TasksTableContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useTasksTable() {
+ const context = React.useContext(TasksTableContext)
+ if (!context) {
+ throw new Error("useTasksTable must be used within a TasksTableProvider")
+ }
+ return context
+}
+
+export function TasksTableProvider({ children }: React.PropsWithChildren) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "featureFlags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ }
+ )
+
+ return (
+ <TasksTableContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit"
+ >
+ {dataTableConfig.featureFlags.map((flag) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className="whitespace-nowrap px-3 text-xs"
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon
+ className="mr-2 size-3.5 shrink-0"
+ aria-hidden="true"
+ />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </TasksTableContext.Provider>
+ )
+}
diff --git a/lib/items/table/items-table-columns.tsx b/lib/items/table/items-table-columns.tsx
new file mode 100644
index 00000000..60043e8e
--- /dev/null
+++ b/lib/items/table/items-table-columns.tsx
@@ -0,0 +1,183 @@
+"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 { 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"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Item> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Item>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<Item> = {
+ 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<Item> = {
+ 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<Item>[] }
+ const groupMap: Record<string, ColumnDef<Item>[]> = {}
+
+ itemsColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<Item> = {
+ 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<Item>[] = []
+
+ // 순서를 고정하고 싶다면 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/items/table/items-table-toolbar-actions.tsx b/lib/items/table/items-table-toolbar-actions.tsx
new file mode 100644
index 00000000..3444daab
--- /dev/null
+++ b/lib/items/table/items-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"
+
+
+// 만약 서버 액션이나 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"
+
+interface ItemsTableToolbarActionsProps {
+ table: Table<Item>
+}
+
+export function ItemsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+
+
+ function handleImportClick() {
+ // 숨겨진 <input type="file" /> 요소를 클릭
+ fileInputRef.current?.click()
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteItemsDialog
+ items={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+ {/** 2) 새 Task 추가 다이얼로그 */}
+ <AddItemDialog />
+
+
+
+ {/** 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>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/items/table/items-table.tsx b/lib/items/table/items-table.tsx
new file mode 100644
index 00000000..bbbafc2f
--- /dev/null
+++ b/lib/items/table/items-table.tsx
@@ -0,0 +1,139 @@
+"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 { getItems } from "../service"
+import { Item } from "@/db/schema/items"
+import { getColumns } from "./items-table-columns"
+import { ItemsTableToolbarActions } from "./items-table-toolbar-actions"
+import { UpdateItemSheet } from "./update-item-sheet"
+import { DeleteItemsDialog } from "./delete-items-dialog"
+
+interface ItemsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getItems>>,
+ ]
+ >
+}
+
+export function ItemsTable({ promises }: ItemsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+ console.log(data)
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<Item> | 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<Item>[] = [
+ {
+ id: "itemCode",
+ label: "Item Code",
+ },
+ ]
+
+ /**
+ * 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<Item>[] = [
+ {
+ id: "itemCode",
+ label: "Item Code",
+ type: "text",
+ },
+ {
+ id: "itemName",
+ label: "Item Name",
+ type: "text",
+ }, {
+ id: "description",
+ label: "Description",
+ 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>
+ <UpdateItemSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ item={rowAction?.row.original ?? null}
+ />
+ <DeleteItemsDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ items={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+ </>
+ )
+}
diff --git a/lib/items/table/update-item-sheet.tsx b/lib/items/table/update-item-sheet.tsx
new file mode 100644
index 00000000..4bcdbfcb
--- /dev/null
+++ b/lib/items/table/update-item-sheet.tsx
@@ -0,0 +1,178 @@
+"use client"
+
+import * as React from "react"
+import { tasks, type Task } from "@/db/schema/tasks"
+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 {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Textarea } from "@/components/ui/textarea"
+
+import { modifiTask } from "@/lib//tasks/service"
+import { updateTaskSchema, type UpdateTaskSchema } from "@/lib/tasks/validations"
+import { Item } from "@/db/schema/items"
+import { updateItemSchema, UpdateItemSchema } from "../validations"
+import { modifyItem } from "../service"
+import { Input } from "@/components/ui/input"
+
+interface UpdateItemSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ item: Item | null
+}
+
+export function UpdateItemSheet({ item, ...props }: UpdateItemSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ console.log(item)
+ const form = useForm<UpdateItemSchema>({
+ resolver: zodResolver(updateItemSchema),
+ defaultValues: {
+ itemCode: item?.itemCode ?? "",
+ itemName: item?.itemName ?? "",
+ description: item?.description ?? "",
+
+ },
+ })
+
+
+ React.useEffect(() => {
+ if (item) {
+ form.reset({
+ itemCode: item.itemCode ?? "",
+ itemName: item.itemName ?? "",
+ description: item.description ?? "",
+ });
+ }
+ }, [item, form]);
+
+ function onSubmit(input: UpdateItemSchema) {
+ startUpdateTransition(async () => {
+ if (!item) return
+
+ const { error } = await modifyItem({
+ id: item.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 item</SheetTitle>
+ <SheetDescription>
+ Update the item 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="itemCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Item Code</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g." {...field} />
+
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="itemName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Item Name</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="e.g."
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Description</FormLabel>
+ <FormControl>
+ <Textarea
+ 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/items/validations.ts b/lib/items/validations.ts
new file mode 100644
index 00000000..d299959c
--- /dev/null
+++ b/lib/items/validations.ts
@@ -0,0 +1,47 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { Item } from "@/db/schema/items";
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<Item>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ itemCode: parseAsString.withDefault(""),
+ itemName: parseAsString.withDefault(""),
+ description: parseAsString.withDefault(""),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+export const createItemSchema = z.object({
+ itemCode: z.string(),
+ itemName: z.string(),
+ description: z.string(),
+})
+
+export const updateItemSchema = z.object({
+ itemCode: z.string().optional(),
+ itemName: z.string().optional(),
+ description: z.string().optional(),
+})
+
+export type GetItemsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+export type CreateItemSchema = z.infer<typeof createItemSchema>
+export type UpdateItemSchema = z.infer<typeof updateItemSchema>
diff --git a/lib/logger.ts b/lib/logger.ts
new file mode 100644
index 00000000..f49f52c2
--- /dev/null
+++ b/lib/logger.ts
@@ -0,0 +1,26 @@
+// lib/logger.ts
+import pino from 'pino';
+import fs from 'fs';
+
+// 로그 디렉토리 생성
+const logDirectory = './logs';
+if (!fs.existsSync(logDirectory)) {
+ fs.mkdirSync(logDirectory);
+}
+
+// 파일 스트림 설정
+const fileStream = fs.createWriteStream('./logs/app.log', { flags: 'a' });
+
+// Pino의 multistream을 사용한 멀티 스트림 설정
+const streams: pino.StreamEntry[] = [
+ { level: 'info', stream: fileStream }, // 파일에 로그 기록
+ process.env.NODE_ENV !== 'production' ? { level: 'debug', stream: pino.destination(1) } : null, // 콘솔에 로그 기록 (개발 환경)
+].filter(Boolean) as pino.StreamEntry[];
+
+// Pino 로거 생성
+const logger = pino({
+ timestamp: pino.stdTimeFunctions.isoTime,
+ level: process.env.LOG_LEVEL || 'info',
+}, pino.multistream(streams));
+
+export default logger; \ No newline at end of file
diff --git a/lib/mail/mailer.ts b/lib/mail/mailer.ts
new file mode 100644
index 00000000..e0a90f1e
--- /dev/null
+++ b/lib/mail/mailer.ts
@@ -0,0 +1,31 @@
+import nodemailer from 'nodemailer';
+import handlebars from 'handlebars';
+import fs from 'fs';
+import path from 'path';
+import i18next from 'i18next';
+
+// Nodemailer Transporter 생성
+const transporter = nodemailer.createTransport({
+ host: process.env.Email_Host,
+ port: 465,
+ secure: true,
+ auth: {
+ user: process.env.Email_User_Name,
+ pass: process.env.Email_Password,
+ },
+});
+
+// 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);
+}
+
+handlebars.registerHelper('t', function(key: string, options: any) {
+ // options.hash에는 Handlebars에서 넘긴 named parameter들(location=location 등)이 들어있음
+ return i18next.t(key, options.hash || {});
+ });
+
+export { transporter, loadTemplate }; \ No newline at end of file
diff --git a/lib/mail/sendEmail.ts b/lib/mail/sendEmail.ts
new file mode 100644
index 00000000..48cc1fbc
--- /dev/null
+++ b/lib/mail/sendEmail.ts
@@ -0,0 +1,36 @@
+import { useTranslation } from '@/i18n';
+import { transporter, loadTemplate } from './mailer';
+import handlebars from 'handlebars';
+
+interface SendEmailOptions {
+ to: string;
+ subject: string;
+ template: string; // 템플릿 파일명(확장자 제외)
+ context: Record<string, any>; // 템플릿에 주입할 데이터
+ attachments?: { // NodeMailer "Attachment" 타입
+ filename?: string
+ path?: string
+ content?: Buffer | string
+ // ...
+ }[]
+}
+
+export async function sendEmail({ to, subject, template, context, attachments = []}: SendEmailOptions) {
+ const { t, i18n } = await useTranslation(context.language ?? "en", "translation");
+
+ handlebars.registerHelper("t", function (key: string, options: any) {
+ // 여기서 i18n은 로컬 인스턴스
+ return i18n.t(key, options.hash || {});
+ });
+
+ const html = loadTemplate(template, context);
+
+ await transporter.sendMail({
+ from: 'EVCP" <dujin.kim@dtsolution.co.kr>',
+ to,
+ subject,
+ html,
+ attachments
+ });
+}
+
diff --git a/lib/mail/templates/admin-created.hbs b/lib/mail/templates/admin-created.hbs
new file mode 100644
index 00000000..7be7f15d
--- /dev/null
+++ b/lib/mail/templates/admin-created.hbs
@@ -0,0 +1,78 @@
+<!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>
+
+ <h1>{{t "adminCreated.title" lng=language}}</h1>
+
+ <p>
+ {{t "adminCreated.greeting" lng=language}}, <strong>{{name}}</strong>.
+ </p>
+
+ <p>
+ {{t "adminCreated.body1" lng=language}}
+ </p>
+
+ <p>
+ <a class="btn" href="{{loginUrl}}" target="_blank">{{t "adminCreated.loginCTA" lng=language}}</a>
+ </p>
+
+ <p>
+ {{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
diff --git a/lib/mail/templates/admin-email-changed.hbs b/lib/mail/templates/admin-email-changed.hbs
new file mode 100644
index 00000000..7b8ca473
--- /dev/null
+++ b/lib/mail/templates/admin-email-changed.hbs
@@ -0,0 +1,90 @@
+<!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>
+
+ <!-- 메일 제목 -->
+ <h1>{{t "adminEmailChanged.title" lng=language}}</h1>
+
+ <!-- 인사말 -->
+ <p>
+ {{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>
+ <a class="btn" href="{{loginUrl}}" target="_blank">
+ {{t "adminEmailChanged.loginCTA" lng=language}}
+ </a>
+ </p>
+
+ <!-- 도움 요청 문구 -->
+ <p>
+ {{t "adminEmailChanged.supportMsg" lng=language}}
+ </p>
+
+ <!-- 푸터 -->
+ <div class="footer">
+ <p>
+ {{t "adminEmailChanged.footerDisclaimer" lng=language}}
+ </p>
+ </div>
+ </div>
+ </body>
+</html> \ No newline at end of file
diff --git a/lib/mail/templates/otp.hbs b/lib/mail/templates/otp.hbs
new file mode 100644
index 00000000..adeda416
--- /dev/null
+++ b/lib/mail/templates/otp.hbs
@@ -0,0 +1,77 @@
+<!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>
+
+ <p>
+ {{t "receivedSignInAttempt" location=location}}
+ </p>
+
+ <p>
+ {{t "enterCodeInstruction"}}
+ </p>
+
+ <p class="code">{{otp}}</p>
+
+ <p>
+ <a href="{{verificationUrl}}">{{verificationUrl}}</a>
+ </p>
+
+
+ <div class="footer">
+ {{t "securityWarning"}}
+ </div>
+ </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
new file mode 100644
index 00000000..25bd96eb
--- /dev/null
+++ b/lib/mail/templates/rfq-invite.hbs
@@ -0,0 +1,116 @@
+<!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>
+
+ <!-- 메인 타이틀: RFQ 초대 -->
+ <h1>
+ {{t "rfqInvite.heading" lng=language}}
+ #{{rfqCode}}
+ </h1>
+
+ <!-- 벤더에게 인사말 -->
+ <p>
+ {{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>
+ {{t "rfqInvite.itemListTitle" lng=language}}
+ </p>
+ <ul>
+ {{#each items}}
+ <li>
+ <strong>{{this.itemCode}}</strong>
+ ({{this.quantity}} {{this.uom}})
+ - {{this.description}}
+ </li>
+ {{/each}}
+ </ul>
+
+ <!-- 로그인/접속 안내 -->
+ <p>
+ {{t "rfqInvite.moreDetail" lng=language}}
+ </p>
+ <a class="btn" href="{{loginUrl}}" target="_blank">
+ {{t "rfqInvite.viewButton" lng=language}}
+ </a>
+
+ <!-- 기타 안내 문구 -->
+ <p>
+ {{t "rfqInvite.supportMsg" lng=language}}
+ </p>
+
+ <!-- 푸터 -->
+ <div class="footer">
+ <p>
+ {{t "rfqInvite.footerDisclaimer" lng=language}}
+ </p>
+ </div>
+ </div>
+ </body>
+</html> \ No newline at end of file
diff --git a/lib/mail/templates/vendor-active.hbs b/lib/mail/templates/vendor-active.hbs
new file mode 100644
index 00000000..6458e2fb
--- /dev/null
+++ b/lib/mail/templates/vendor-active.hbs
@@ -0,0 +1,51 @@
+<!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
diff --git a/lib/mail/templates/vendor-pq-comment.hbs b/lib/mail/templates/vendor-pq-comment.hbs
new file mode 100644
index 00000000..b60deedc
--- /dev/null
+++ b/lib/mail/templates/vendor-pq-comment.hbs
@@ -0,0 +1,128 @@
+<!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>
+ </div>
+ </div>
+</body>
+</html> \ No newline at end of file
diff --git a/lib/mail/templates/vendor-pq-status.hbs b/lib/mail/templates/vendor-pq-status.hbs
new file mode 100644
index 00000000..541a6137
--- /dev/null
+++ b/lib/mail/templates/vendor-pq-status.hbs
@@ -0,0 +1,48 @@
+<!-- file: templates/vendor-pq-status.hbs -->
+
+<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
diff --git a/lib/parsers.ts b/lib/parsers.ts
new file mode 100644
index 00000000..20f3107b
--- /dev/null
+++ b/lib/parsers.ts
@@ -0,0 +1,94 @@
+import type { ExtendedSortingState, Filter } from "@/types/table"
+import { type Row } from "@tanstack/react-table"
+import { createParser } from "nuqs/server"
+import { z } from "zod"
+
+import { dataTableConfig } from "@/config/data-table"
+
+export const sortingItemSchema = z.object({
+ id: z.string(),
+ desc: z.boolean(),
+})
+
+/**
+ * Creates a parser for TanStack Table sorting state.
+ * @param originalRow The original row data to validate sorting keys against.
+ * @returns A parser for TanStack Table sorting state.
+ */
+export const getSortingStateParser = <TData>(
+ originalRow?: Row<TData>["original"]
+) => {
+ const validKeys = originalRow ? new Set(Object.keys(originalRow)) : null
+
+ return createParser<ExtendedSortingState<TData>>({
+ parse: (value) => {
+ try {
+ const parsed = JSON.parse(value)
+ const result = z.array(sortingItemSchema).safeParse(parsed)
+
+ if (!result.success) return null
+
+ if (validKeys && result.data.some((item) => !validKeys.has(item.id))) {
+ return null
+ }
+
+ return result.data as ExtendedSortingState<TData>
+ } catch {
+ return null
+ }
+ },
+ serialize: (value) => JSON.stringify(value),
+ eq: (a, b) =>
+ a.length === b.length &&
+ a.every(
+ (item, index) =>
+ item.id === b[index]?.id && item.desc === b[index]?.desc
+ ),
+ })
+}
+
+export const filterSchema = z.object({
+ id: z.string(),
+ value: z.union([z.string(), z.array(z.string())]),
+ type: z.enum(dataTableConfig.columnTypes),
+ operator: z.enum(dataTableConfig.globalOperators),
+ rowId: z.string(),
+})
+
+/**
+ * Create a parser for data table filters.
+ * @param originalRow The original row data to create the parser for.
+ * @returns A parser for data table filters state.
+ */
+export const getFiltersStateParser = <T>(originalRow?: Row<T>["original"]) => {
+ const validKeys = originalRow ? new Set(Object.keys(originalRow)) : null
+
+ return createParser<Filter<T>[]>({
+ parse: (value) => {
+ try {
+ const parsed = JSON.parse(value)
+ const result = z.array(filterSchema).safeParse(parsed)
+
+ if (!result.success) return null
+
+ if (validKeys && result.data.some((item) => !validKeys.has(item.id))) {
+ return null
+ }
+
+ return result.data as Filter<T>[]
+ } catch {
+ return null
+ }
+ },
+ serialize: (value) => JSON.stringify(value),
+ eq: (a, b) =>
+ a.length === b.length &&
+ a.every(
+ (filter, index) =>
+ filter.id === b[index]?.id &&
+ filter.value === b[index]?.value &&
+ filter.type === b[index]?.type &&
+ filter.operator === b[index]?.operator
+ ),
+ })
+}
diff --git a/lib/po/repository.ts b/lib/po/repository.ts
new file mode 100644
index 00000000..78d90ba7
--- /dev/null
+++ b/lib/po/repository.ts
@@ -0,0 +1,44 @@
+import db from "@/db/db";
+import { contractsDetailView } from "@/db/schema/contract";
+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 selectPos(
+ 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(contractsDetailView)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+/** 총 개수 count */
+export async function countPos(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(contractsDetailView).where(where);
+ return res[0]?.count ?? 0;
+}
diff --git a/lib/po/service.ts b/lib/po/service.ts
new file mode 100644
index 00000000..dc398201
--- /dev/null
+++ b/lib/po/service.ts
@@ -0,0 +1,431 @@
+"use server";
+
+import { headers } from "next/headers";
+import db from "@/db/db";
+import { GetPOSchema } from "./validations";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { filterColumns } from "@/lib/filter-columns";
+import {
+ asc,
+ desc,
+ ilike,
+ inArray,
+ and,
+ gte,
+ lte,
+ not,
+ or,
+ eq,
+ count,
+} from "drizzle-orm";
+import { countPos, selectPos } from "./repository";
+
+import {
+ contractEnvelopes,
+ contractsDetailView,
+ contractSigners,
+ contracts,
+} from "@/db/schema/contract";
+import { vendors, vendorContacts } from "@/db/schema/vendors";
+import { revalidatePath } from "next/cache";
+import * as z from "zod";
+import { POContent } from "@/lib/docuSign/types";
+
+/**
+ * PQ 목록 조회
+ */
+export async function getPOs(input: GetPOSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 1. Try a simple query first to make sure the view works at all
+ try {
+ const testQuery = await db
+ .select({ count: count() })
+ .from(contractsDetailView);
+ console.log("Test query result:", testQuery);
+ } catch (testErr) {
+ console.error("Test query failed:", testErr);
+ }
+
+ // 2. Build where clause with more careful handling
+ let advancedWhere;
+ try {
+ advancedWhere = filterColumns({
+ table: contractsDetailView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+ console.log("Advanced where clause built successfully");
+ } catch (whereErr) {
+ console.error("Error building advanced where:", whereErr);
+ advancedWhere = undefined;
+ }
+
+ let globalWhere;
+ if (input.search) {
+ try {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(contractsDetailView.contractNo, s),
+ ilike(contractsDetailView.contractName, s)
+ );
+ console.log("Global where clause built successfully");
+ } catch (searchErr) {
+ console.error("Error building search where:", searchErr);
+ globalWhere = undefined;
+ }
+ }
+
+ // 3. Combine where clauses safely
+ let finalWhere;
+ if (advancedWhere && globalWhere) {
+ finalWhere = and(advancedWhere, globalWhere);
+ } else {
+ finalWhere = advancedWhere || globalWhere;
+ }
+
+ // 4. Build order by
+ let orderBy;
+ try {
+ orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc
+ ? desc(contractsDetailView[item.id])
+ : asc(contractsDetailView[item.id])
+ )
+ : [asc(contractsDetailView.createdAt)];
+ } catch (orderErr) {
+ console.error("Error building order by:", orderErr);
+ orderBy = [asc(contractsDetailView.createdAt)];
+ }
+
+ // 5. Execute queries with proper error handling
+ let data = [];
+ let total = 0;
+
+ try {
+ // Try without transaction first for better error visibility
+ const queryBuilder = db.select().from(contractsDetailView);
+
+ // Add where clause if it exists
+ if (finalWhere) {
+ queryBuilder.where(finalWhere);
+ }
+
+ // Add ordering
+ queryBuilder.orderBy(...orderBy);
+
+ // Add pagination
+ queryBuilder.offset(offset).limit(input.perPage);
+
+ // Execute query
+ data = await queryBuilder;
+
+ // Get total count
+ const countBuilder = db
+ .select({ count: count() })
+ .from(contractsDetailView);
+
+ if (finalWhere) {
+ countBuilder.where(finalWhere);
+ }
+
+ const countResult = await countBuilder;
+ total = countResult[0]?.count || 0;
+ } catch (queryErr) {
+ console.error("Query execution failed:", queryErr);
+ throw queryErr; // Rethrow to be caught by the outer try/catch
+ }
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // More detailed error logging
+ console.error("Error in getPOs:", err);
+ if (err instanceof Error) {
+ console.error("Error message:", err.message);
+ console.error("Error stack:", err.stack);
+ }
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 3600,
+ tags: [`po`],
+ }
+ )();
+}
+
+// Schema for a single signer
+const signerSchema = z.object({
+ signerEmail: z.string().email(),
+ signerName: z.string().min(1),
+ signerPosition: z.string(),
+ signerType: z.enum(["REQUESTER", "VENDOR"]),
+ vendorContactId: z.number().optional(),
+});
+
+// Schema for the entire request
+const signatureRequestSchema = z.object({
+ contractId: z.number(),
+ signers: z.array(signerSchema).min(1, "At least one signer is required"),
+});
+
+/**
+ * Server action to request electronic signatures for a contract from multiple parties
+ */
+export async function requestSignatures(
+ input: z.infer<typeof signatureRequestSchema>
+): Promise<{ success: boolean; message: string }> {
+ try {
+ // Validate the input
+ const validatedData = signatureRequestSchema.parse(input);
+
+ const headersList = await headers();
+ const host = headersList.get("host");
+ const proto = headersList.get("x-forwarded-proto") || "http"; // 기본값은 http
+ const origin = `${proto}://${host}`;
+
+ // Use a transaction to ensure data consistency
+ return await db.transaction(async (tx) => {
+ // Get contract details using standard select
+ const [contract] = await tx
+ .select()
+ .from(contracts)
+ .where(eq(contracts.id, validatedData.contractId))
+ .limit(1);
+
+ if (!contract) {
+ throw new Error(
+ `Contract with ID ${validatedData.contractId} not found`
+ );
+ }
+
+ // Generate unique envelope ID
+ // const envelopeId = `env-${Date.now()}-${Math.floor(
+ // Math.random() * 1000
+ // )}`;
+
+ // Get contract number or fallback
+ const contractNo =
+ contract.contractNo || `contract-${validatedData.contractId}`;
+
+ const signer = validatedData.signers.find(
+ (c) => c.signerType === "REQUESTER"
+ );
+
+ const vendor = validatedData.signers.find(
+ (c) => c.signerType === "VENDOR"
+ );
+
+ if (!vendor || !signer) {
+ return {
+ success: true,
+ message: `협력업체 서명자를 확인할 수 없습니다.`,
+ };
+ }
+
+ const { vendorContactId } = vendor;
+
+ if (!vendorContactId) {
+ return {
+ success: true,
+ message: `계약 번호를 확인할 수 없습니다.`,
+ };
+ }
+
+ const [vendorInfoData] = await tx
+ .select({
+ vendorContract: vendorContacts,
+ vendorInfo: vendors,
+ })
+ .from(vendorContacts)
+ .leftJoin(vendors, eq(vendorContacts.vendorId, vendors.id))
+ .where(eq(vendorContacts.id, vendorContactId))
+ .limit(1);
+
+ const { vendorContract, vendorInfo } = vendorInfoData;
+
+ const docuSignTempId = "73b04617-477c-4ec8-8a32-c8da701f6b0c";
+
+ const { totalAmount = "0", tax = "0" } = contract;
+
+ const totalAmountNum = Number(totalAmount);
+ const taxNum = Number(tax);
+ const taxRate = ((taxNum / totalAmountNum) * 100).toFixed(2);
+
+ const contractInfo: POContent = [
+ { tabLabel: "po_no", value: contractNo },
+ { tabLabel: "vendor_name", value: vendorInfo?.vendorName ?? "" },
+ { tabLabel: "po_date", value: contract?.startDate ?? "" },
+ { tabLabel: "project_name", value: contract.contractName },
+ { tabLabel: "vendor_location", value: vendorInfo?.address ?? "" },
+ { tabLabel: "shi_email", value: signer.signerEmail },
+ { tabLabel: "vendor_email", value: vendorContract.contactEmail },
+ { tabLabel: "po_desc", value: contract.contractName },
+ { tabLabel: "qty", value: "1" },
+ { tabLabel: "unit_price", value: totalAmountNum.toLocaleString() },
+ { tabLabel: "total", value: totalAmountNum.toLocaleString() },
+ {
+ tabLabel: "grand_total_amount",
+ value: totalAmountNum.toLocaleString(),
+ },
+ { tabLabel: "tax_rate", value: taxRate },
+ { tabLabel: "tax_total", value: taxNum.toLocaleString() },
+ {
+ tabLabel: "payment_amount",
+ value: (totalAmountNum + taxNum).toLocaleString(),
+ },
+ {
+ tabLabel: "remark",
+ value: `결제 조건: ${contract.paymentTerms}
+납품 조건: ${contract.deliveryTerms}
+납품 기한: ${contract.deliveryDate}
+납품 장소: ${contract.deliveryLocation}
+계약 종료일/유효 기간: ${contract.endDate}
+Remarks:${contract.remarks}`,
+ },
+ ];
+
+ const sendDocuSign = await fetch(`${origin}/api/po/sendDocuSign`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json", // ✅ 이거 꼭 있어야 함!
+ },
+ body: JSON.stringify({
+ docuSignTempId,
+ contractInfo,
+ contractorInfo: {
+ email: "dts@dtsolution.co.kr",
+ name: "삼성중공업",
+ roleName: "shi",
+ },
+ subcontractorinfo: {
+ email: vendorContract.contactEmail,
+ name: vendorInfo?.vendorName,
+ roleName: "vendor",
+ },
+ ccInfo: [
+ // {
+ // email: "kiman.kim@dtsolution.io",
+ // name: "김기만",
+ // roleName: "cc",
+ // },
+ ],
+ }),
+ }).then((data) => data.json());
+
+ const { success: sendDocuSignResult, envelopeId } = sendDocuSign;
+
+ if (!sendDocuSignResult) {
+ return {
+ success: false,
+ message: "DocuSign 전자 서명 발송에 실패하였습니다.",
+ };
+ }
+
+ // Create a single envelope for all signers
+ const [newEnvelope] = await tx
+ .insert(contractEnvelopes)
+ .values({
+ contractId: validatedData.contractId,
+ envelopeId: envelopeId,
+ envelopeStatus: "sent",
+ fileName: `${contractNo}-signature.pdf`, // Required field
+ filePath: `/contracts/${validatedData.contractId}/signatures`, // Required field
+ // Add any other required fields based on your schema
+ })
+ .returning();
+
+ // // Check for duplicate emails
+ const signerEmails = new Set();
+ for (const signer of validatedData.signers) {
+ if (signerEmails.has(signer.signerEmail)) {
+ throw new Error(`Duplicate signer email: ${signer.signerEmail}`);
+ }
+ signerEmails.add(signer.signerEmail);
+ }
+
+ // Create signer records for each signer
+ for (const signer of validatedData.signers) {
+ await tx.insert(contractSigners).values({
+ envelopeId: newEnvelope.id,
+ signerEmail: signer.signerEmail,
+ signerName: signer.signerName,
+ signerPosition: signer.signerPosition,
+ signerStatus: "sent",
+ signerType: signer.signerType,
+ // Only include vendorContactId if it's provided and the signer is a vendor
+ ...(signer.vendorContactId && signer.signerType === "VENDOR"
+ ? { vendorContactId: signer.vendorContactId }
+ : {}),
+ });
+ }
+
+ // Update contract status to indicate pending signatures
+ await tx
+ .update(contracts)
+ .set({ status: "PENDING_SIGNATURE" })
+ .where(eq(contracts.id, validatedData.contractId));
+
+ // In a real implementation, you would send the envelope to DocuSign or similar service
+ // For example:
+ // const docusignResult = await docusignClient.createEnvelope({
+ // recipients: validatedData.signers.map(signer => ({
+ // email: signer.signerEmail,
+ // name: signer.signerName,
+ // recipientType: signer.signerType === "REQUESTER" ? "signer" : "cc",
+ // routingOrder: signer.signerType === "REQUESTER" ? 1 : 2,
+ // })),
+ // documentId: `contract-${validatedData.contractId}`,
+ // // other DocuSign-specific parameters
+ // });
+
+ // Revalidate the path to refresh the data
+ revalidatePath("/po");
+
+ // Return success response
+ return {
+ success: true,
+ message: `Signature requests sent to ${validatedData.signers.length} recipient(s)`,
+ };
+ });
+ } catch (error) {
+ console.error("Error requesting electronic signatures:", error);
+ return {
+ success: false,
+ message:
+ error instanceof Error
+ ? error.message
+ : "Failed to send signature requests",
+ };
+ }
+}
+
+export async function getVendorContacts(vendorId: number) {
+ try {
+ const contacts = await db
+ .select({
+ id: vendorContacts.id,
+ contactName: vendorContacts.contactName,
+ contactEmail: vendorContacts.contactEmail,
+ contactPosition: vendorContacts.contactPosition,
+ contactPhone: vendorContacts.contactPhone,
+ isPrimary: vendorContacts.isPrimary,
+ })
+ .from(vendorContacts)
+ .where(eq(vendorContacts.vendorId, vendorId))
+ .orderBy(vendorContacts.isPrimary, vendorContacts.contactName);
+
+ return contacts;
+ } catch (error) {
+ console.error("Error fetching vendor contacts:", error);
+ throw new Error("Failed to fetch vendor contacts");
+ }
+}
diff --git a/lib/po/service_r1.ts b/lib/po/service_r1.ts
new file mode 100644
index 00000000..64af73c4
--- /dev/null
+++ b/lib/po/service_r1.ts
@@ -0,0 +1,282 @@
+"use server"
+
+import db from "@/db/db"
+import { GetPOSchema } from "./validations"
+import { unstable_cache } from "@/lib/unstable-cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, count} from "drizzle-orm";
+import { countPos, selectPos } from "./repository";
+
+import { contractEnvelopes, contractsDetailView, contractSigners,contracts } from "@/db/schema/contract";
+import { revalidatePath } from "next/cache";
+import * as z from "zod"
+import { vendorContacts } from "@/db/schema/vendors";
+
+/**
+ * PQ 목록 조회
+ */
+export async function getPOs(input: GetPOSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 1. Try a simple query first to make sure the view works at all
+ try {
+ const testQuery = await db.select({ count: count() })
+ .from(contractsDetailView);
+ console.log("Test query result:", testQuery);
+ } catch (testErr) {
+ console.error("Test query failed:", testErr);
+ }
+
+ // 2. Build where clause with more careful handling
+ let advancedWhere;
+ try {
+ advancedWhere = filterColumns({
+ table: contractsDetailView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+ console.log("Advanced where clause built successfully");
+ } catch (whereErr) {
+ console.error("Error building advanced where:", whereErr);
+ advancedWhere = undefined;
+ }
+
+ let globalWhere;
+ if (input.search) {
+ try {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(contractsDetailView.contractNo, s),
+ ilike(contractsDetailView.contractName, s),
+ );
+ console.log("Global where clause built successfully");
+ } catch (searchErr) {
+ console.error("Error building search where:", searchErr);
+ globalWhere = undefined;
+ }
+ }
+
+ // 3. Combine where clauses safely
+ let finalWhere;
+ if (advancedWhere && globalWhere) {
+ finalWhere = and(advancedWhere, globalWhere);
+ } else {
+ finalWhere = advancedWhere || globalWhere;
+ }
+
+
+ // 4. Build order by
+ let orderBy;
+ try {
+ orderBy = input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(contractsDetailView[item.id]) : asc(contractsDetailView[item.id])
+ )
+ : [asc(contractsDetailView.createdAt)];
+ } catch (orderErr) {
+ console.error("Error building order by:", orderErr);
+ orderBy = [asc(contractsDetailView.createdAt)];
+ }
+
+ // 5. Execute queries with proper error handling
+ let data = [];
+ let total = 0;
+
+ try {
+ // Try without transaction first for better error visibility
+ const queryBuilder = db.select()
+ .from(contractsDetailView);
+
+ // Add where clause if it exists
+ if (finalWhere) {
+ queryBuilder.where(finalWhere);
+ }
+
+ // Add ordering
+ queryBuilder.orderBy(...orderBy);
+
+ // Add pagination
+ queryBuilder.offset(offset).limit(input.perPage);
+
+ // Execute query
+ data = await queryBuilder;
+
+ // Get total count
+ const countBuilder = db.select({ count: count() })
+ .from(contractsDetailView);
+
+ if (finalWhere) {
+ countBuilder.where(finalWhere);
+ }
+
+ const countResult = await countBuilder;
+ total = countResult[0]?.count || 0;
+
+ } catch (queryErr) {
+ console.error("Query execution failed:", queryErr);
+ throw queryErr; // Rethrow to be caught by the outer try/catch
+ }
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // More detailed error logging
+ console.error("Error in getPOs:", err);
+ if (err instanceof Error) {
+ console.error("Error message:", err.message);
+ console.error("Error stack:", err.stack);
+ }
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 3600,
+ tags: [`po`],
+ }
+ )();
+}
+
+// Schema for a single signer
+const signerSchema = z.object({
+ signerEmail: z.string().email(),
+ signerName: z.string().min(1),
+ signerPosition: z.string(),
+ signerType: z.enum(["REQUESTER", "VENDOR"]),
+ vendorContactId: z.number().optional(),
+ });
+
+ // Schema for the entire request
+ const signatureRequestSchema = z.object({
+ contractId: z.number(),
+ signers: z.array(signerSchema).min(1, "At least one signer is required")
+ });
+
+ /**
+ * Server action to request electronic signatures for a contract from multiple parties
+ */
+ export async function requestSignatures(
+ input: z.infer<typeof signatureRequestSchema>
+ ): Promise<{ success: boolean; message: string }> {
+ try {
+ // Validate the input
+ const validatedData = signatureRequestSchema.parse(input);
+
+ // Use a transaction to ensure data consistency
+ return await db.transaction(async (tx) => {
+ // Get contract details using standard select
+ const [contract] = await tx
+ .select()
+ .from(contracts)
+ .where(eq(contracts.id, validatedData.contractId))
+ .limit(1);
+
+ if (!contract) {
+ throw new Error(`Contract with ID ${validatedData.contractId} not found`);
+ }
+
+ // Generate unique envelope ID
+ const envelopeId = `env-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
+
+ // Get contract number or fallback
+ const contractNo = contract.contractNo || `contract-${validatedData.contractId}`;
+
+ // Create a single envelope for all signers
+ const [newEnvelope] = await tx.insert(contractEnvelopes)
+ .values({
+ contractId: validatedData.contractId,
+ envelopeId: envelopeId,
+ envelopeStatus: "sent",
+ fileName: `${contractNo}-signature.pdf`, // Required field
+ filePath: `/contracts/${validatedData.contractId}/signatures/${envelopeId}.pdf`, // Required field
+ // Add any other required fields based on your schema
+ })
+ .returning();
+
+ // Check for duplicate emails
+ const signerEmails = new Set();
+ for (const signer of validatedData.signers) {
+ if (signerEmails.has(signer.signerEmail)) {
+ throw new Error(`Duplicate signer email: ${signer.signerEmail}`);
+ }
+ signerEmails.add(signer.signerEmail);
+ }
+
+ // Create signer records for each signer
+ for (const signer of validatedData.signers) {
+ await tx.insert(contractSigners)
+ .values({
+ envelopeId: newEnvelope.id,
+ signerEmail: signer.signerEmail,
+ signerName: signer.signerName,
+ signerPosition: signer.signerPosition,
+ signerStatus: "sent",
+ signerType: signer.signerType,
+ // Only include vendorContactId if it's provided and the signer is a vendor
+ ...(signer.vendorContactId && signer.signerType === "VENDOR"
+ ? { vendorContactId: signer.vendorContactId }
+ : {})
+ });
+ }
+
+ // Update contract status to indicate pending signatures
+ await tx.update(contracts)
+ .set({ status: "PENDING_SIGNATURE" })
+ .where(eq(contracts.id, validatedData.contractId));
+
+ // In a real implementation, you would send the envelope to DocuSign or similar service
+ // For example:
+ // const docusignResult = await docusignClient.createEnvelope({
+ // recipients: validatedData.signers.map(signer => ({
+ // email: signer.signerEmail,
+ // name: signer.signerName,
+ // recipientType: signer.signerType === "REQUESTER" ? "signer" : "cc",
+ // routingOrder: signer.signerType === "REQUESTER" ? 1 : 2,
+ // })),
+ // documentId: `contract-${validatedData.contractId}`,
+ // // other DocuSign-specific parameters
+ // });
+
+ // Revalidate the path to refresh the data
+ revalidatePath("/po");
+
+ // Return success response
+ return {
+ success: true,
+ message: `Signature requests sent to ${validatedData.signers.length} recipient(s)`
+ };
+ });
+ } catch (error) {
+ console.error("Error requesting electronic signatures:", error);
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : "Failed to send signature requests"
+ };
+ }
+ }
+
+ export async function getVendorContacts(vendorId: number) {
+ try {
+ const contacts = await db
+ .select({
+ id: vendorContacts.id,
+ contactName: vendorContacts.contactName,
+ contactEmail: vendorContacts.contactEmail,
+ contactPosition: vendorContacts.contactPosition,
+ contactPhone: vendorContacts.contactPhone,
+ isPrimary: vendorContacts.isPrimary,
+ })
+ .from(vendorContacts)
+ .where(eq(vendorContacts.vendorId, vendorId))
+ .orderBy(vendorContacts.isPrimary, vendorContacts.contactName);
+
+ return contacts;
+ } catch (error) {
+ console.error("Error fetching vendor contacts:", error);
+ throw new Error("Failed to fetch vendor contacts");
+ }
+ } \ No newline at end of file
diff --git a/lib/po/table/feature-flags-provider.tsx b/lib/po/table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/po/table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/po/table/po-table-columns.tsx b/lib/po/table/po-table-columns.tsx
new file mode 100644
index 00000000..c2c01136
--- /dev/null
+++ b/lib/po/table/po-table-columns.tsx
@@ -0,0 +1,155 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { InfoIcon, PenIcon } from "lucide-react"
+
+import { formatDate } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { poColumnsConfig } from "@/config/poColumnsConfig"
+import { ContractDetail } from "@/db/schema/contract"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ContractDetail> | null>>
+}
+
+/**
+ * tanstack table column definitions with nested headers
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ContractDetail>[] {
+ // ----------------------------------------------------------------
+ // 1) select column (checkbox) - if needed
+ // ----------------------------------------------------------------
+
+ // ----------------------------------------------------------------
+ // 2) actions column (buttons for item info and signature request)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<ContractDetail> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ // Check if this contract already has a signature envelope
+ const hasSignature = row.original.hasSignature;
+
+ return (
+ <div className="flex items-center space-x-1">
+ {/* Item Info Button */}
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => setRowAction({ row, type: "items" })}
+ >
+ <InfoIcon className="h-4 w-4" aria-hidden="true" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ View Item Info
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+
+ {/* Signature Request Button - only show if no signature exists */}
+ {!hasSignature && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => setRowAction({ row, type: "signature" })}
+ >
+ <PenIcon className="h-4 w-4" aria-hidden="true" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ Request Electronic Signature
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+ </div>
+ );
+ },
+ size: 80, // Increased width to accommodate both buttons
+ };
+
+ // ----------------------------------------------------------------
+ // 3) Regular columns grouped by group name
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<ContractDetail>[] }
+ const groupMap: Record<string, ColumnDef<ContractDetail>[]> = {};
+
+ poColumnsConfig.forEach((cfg) => {
+ // Use "_noGroup" if no group is specified
+ const groupName = cfg.group || "_noGroup";
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = [];
+ }
+
+ // Child column definition
+ const childCol: ColumnDef<ContractDetail> = {
+ 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) Create actual parent columns (groups) from the groupMap
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<ContractDetail>[] = [];
+
+ // Order can be fixed by pre-defining group order or sorting
+ // Here we just use Object.entries order
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // No group → Add as top-level columns
+ nestedColumns.push(...colDefs);
+ } else {
+ // Parent column
+ nestedColumns.push({
+ id: groupName,
+ header: groupName, // "Basic Info", "Metadata", etc.
+ columns: colDefs,
+ });
+ }
+ });
+
+ // ----------------------------------------------------------------
+ // 4) Final column array: nestedColumns + actionsColumn
+ // ----------------------------------------------------------------
+ return [
+ ...nestedColumns,
+ actionsColumn,
+ ];
+} \ No newline at end of file
diff --git a/lib/po/table/po-table-toolbar-actions.tsx b/lib/po/table/po-table-toolbar-actions.tsx
new file mode 100644
index 00000000..e6c8e79a
--- /dev/null
+++ b/lib/po/table/po-table-toolbar-actions.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, RefreshCcw, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { ContractDetail } from "@/db/schema/contract"
+
+
+
+interface ItemsTableToolbarActionsProps {
+ table: Table<ContractDetail>
+}
+
+export function PoTableToolbarActions({ table }: ItemsTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="samsung"
+ size="sm"
+ className="gap-2"
+ >
+ <RefreshCcw className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Get POs</span>
+ </Button>
+
+ {/** 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>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/po/table/po-table.tsx b/lib/po/table/po-table.tsx
new file mode 100644
index 00000000..49fbdda4
--- /dev/null
+++ b/lib/po/table/po-table.tsx
@@ -0,0 +1,164 @@
+"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 { toast } from "sonner"
+
+import { getPOs, requestSignatures } from "../service"
+import { getColumns } from "./po-table-columns"
+import { ContractDetail } from "@/db/schema/contract"
+import { PoTableToolbarActions } from "./po-table-toolbar-actions"
+import { SignatureRequestModal } from "./sign-request-dialog"
+
+interface ItemsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getPOs>>,
+ ]
+ >
+}
+
+// Interface for signing party
+interface SigningParty {
+ signerEmail: string;
+ signerName: string;
+ signerPosition: string;
+ signerType: "REQUESTER" | "VENDOR";
+ vendorContactId?: number;
+}
+
+export function PoListsTable({ promises }: ItemsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<ContractDetail> | null>(null)
+
+ // State for signature request modal
+ const [signatureModalOpen, setSignatureModalOpen] = React.useState(false)
+ const [selectedContract, setSelectedContract] = React.useState<ContractDetail | null>(null)
+
+ // Handle row actions
+ React.useEffect(() => {
+ if (!rowAction) return
+
+ if (rowAction.type === "signature") {
+ // Open signature request modal with the selected contract
+ setSelectedContract(rowAction.row.original)
+ setSignatureModalOpen(true)
+ setRowAction(null)
+ } else if (rowAction.type === "items") {
+ // Existing handler for "items" action type
+ // Your existing code here
+ setRowAction(null)
+ }
+ }, [rowAction])
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // Updated handler to work with multiple signers
+ const handleSignatureRequest = async (
+ values: { signers: SigningParty[] },
+ contractId: number
+ ): Promise<void> => {
+ try {
+ const result = await requestSignatures({
+ contractId,
+ signers: values.signers
+ });
+
+ // Handle the result
+ if (result.success) {
+ toast.success(result.message || "Signature requests sent successfully");
+ } else {
+ toast.error(result.message || "Failed to send signature requests");
+ }
+ } catch (error) {
+ console.error("Error sending signature requests:", error);
+ toast.error("An error occurred while sending the signature requests");
+ }
+ }
+
+ const filterFields: DataTableFilterField<ContractDetail>[] = [
+ // Your existing filter fields
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<ContractDetail>[] = [
+ {
+ id: "contractNo",
+ label: "Contract No",
+ type: "text",
+ },
+ {
+ id: "contractName",
+ label: "Contract Name",
+ type: "text",
+ },
+ {
+ id: "createdAt",
+ label: "Created At",
+ type: "date",
+ },
+ {
+ id: "updatedAt",
+ label: "Updated At",
+ 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}
+ shallow={false}
+ >
+ <PoTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* Enhanced Dual Signature Request Modal */}
+ {selectedContract && (
+ <SignatureRequestModal
+ contract={selectedContract}
+ open={signatureModalOpen}
+ onOpenChange={setSignatureModalOpen}
+ onSubmit={handleSignatureRequest}
+ />
+ )}
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/po/table/sign-request-dialog.tsx b/lib/po/table/sign-request-dialog.tsx
new file mode 100644
index 00000000..f70e5e33
--- /dev/null
+++ b/lib/po/table/sign-request-dialog.tsx
@@ -0,0 +1,410 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import { z } from "zod"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { toast } from "sonner"
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { ContractDetail } from "@/db/schema/contract"
+import { getVendorContacts } from "../service"
+
+// Type for vendor contact
+interface VendorContact {
+ id: number
+ contactName: string
+ contactEmail: string
+ contactPosition: string | null
+ isPrimary: boolean
+}
+
+// Form schema for signature request
+const signatureRequestSchema = z.object({
+ // Requester signer information
+ includeRequesterSigner: z.boolean().default(true),
+ requesterEmail: z.string().email("Please enter a valid email address").optional(),
+ requesterName: z.string().min(1, "Please enter the signer's name").optional(),
+ requesterPosition: z.string().optional(),
+
+ // Vendor signer information
+ includeVendorSigner: z.boolean().default(true),
+ vendorContactId: z.number().optional(),
+}).refine(data => data.includeRequesterSigner || data.includeVendorSigner, {
+ message: "At least one signer must be included",
+ path: ["includeRequesterSigner"]
+}).refine(data => !data.includeRequesterSigner || (data.requesterEmail && data.requesterName), {
+ message: "Requester email and name are required",
+ path: ["requesterEmail"]
+}).refine(data => !data.includeVendorSigner || data.vendorContactId, {
+ message: "Please select a vendor contact",
+ path: ["vendorContactId"]
+});
+
+type SignatureRequestFormValues = z.infer<typeof signatureRequestSchema>
+
+// Interface for signing parties
+interface SigningParty {
+ signerEmail: string;
+ signerName: string;
+ signerPosition: string;
+ signerType: "REQUESTER" | "VENDOR";
+ vendorContactId?: number;
+}
+
+// Updated interface to accept multiple signers
+interface SignatureRequestModalProps {
+ contract: ContractDetail
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSubmit: (
+ values: {
+ signers: SigningParty[]
+ },
+ contractId: number
+ ) => Promise<{ success: boolean; message: string } | void>
+}
+
+export function SignatureRequestModal({
+ contract,
+ open,
+ onOpenChange,
+ onSubmit,
+}: SignatureRequestModalProps) {
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [vendorContacts, setVendorContacts] = useState<VendorContact[]>([])
+ const [selectedVendorContact, setSelectedVendorContact] = useState<VendorContact | null>(null)
+
+ const form = useForm<SignatureRequestFormValues>({
+ resolver: zodResolver(signatureRequestSchema),
+ defaultValues: {
+ includeRequesterSigner: true,
+ requesterEmail: "",
+ requesterName: "",
+ requesterPosition: "",
+ includeVendorSigner: true,
+ vendorContactId: undefined,
+ },
+ })
+
+ // Load vendor contacts when the modal opens
+ useEffect(() => {
+ if (open && contract?.vendorId) {
+ const loadVendorContacts = async () => {
+ try {
+ const contacts = await getVendorContacts(contract.vendorId);
+ setVendorContacts(contacts);
+
+ // Auto-select primary contact if available
+ const primaryContact = contacts.find(c => c.isPrimary);
+ if (primaryContact) {
+ handleVendorContactSelect(primaryContact.id.toString());
+ }
+ } catch (error) {
+ console.error("Error loading vendor contacts:", error);
+ toast.error("Failed to load vendor contacts");
+ }
+ };
+
+ loadVendorContacts();
+ }
+ }, [open, contract]);
+
+ // Handle selection of a vendor contact
+ const handleVendorContactSelect = (contactId: string) => {
+ const id = Number(contactId);
+ form.setValue("vendorContactId", id);
+
+ // Find the selected contact to show details
+ const contact = vendorContacts.find(c => c.id === id);
+ if (contact) {
+ setSelectedVendorContact(contact);
+ }
+ };
+
+ async function handleSubmit(values: SignatureRequestFormValues) {
+ setIsSubmitting(true);
+
+ try {
+ const signers: SigningParty[] = [];
+
+ // Add requester signer if included
+ if (values.includeRequesterSigner && values.requesterEmail && values.requesterName) {
+ signers.push({
+ signerEmail: values.requesterEmail,
+ signerName: values.requesterName,
+ signerPosition: values.requesterPosition || "",
+ signerType: "REQUESTER"
+ });
+ }
+
+ // Add vendor signer if included
+ if (values.includeVendorSigner && values.vendorContactId && selectedVendorContact) {
+ signers.push({
+ signerEmail: selectedVendorContact.contactEmail,
+ signerName: selectedVendorContact.contactName,
+ signerPosition: selectedVendorContact.contactPosition || "",
+ vendorContactId: values.vendorContactId,
+ signerType: "VENDOR"
+ });
+ }
+
+ if (signers.length === 0) {
+ throw new Error("At least one signer must be included");
+ }
+
+ const result = await onSubmit({ signers }, contract.id);
+
+ // Handle the result if it exists
+ if (result && typeof result === 'object') {
+ if (result.success) {
+ toast.success(result.message || "Signature requests sent successfully");
+ } else {
+ toast.error(result.message || "Failed to send signature requests");
+ }
+ } else {
+ // If no result is returned, assume success
+ toast.success("Electronic signature requests sent successfully");
+ }
+
+ form.reset();
+ onOpenChange(false);
+ } catch (error) {
+ console.error("Error sending signature requests:", error);
+ toast.error(error instanceof Error ? error.message : "Failed to send signature requests. Please try again.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <DialogTitle>Request Electronic Signatures</DialogTitle>
+ <DialogDescription>
+ Send signature requests for contract: {contract?.contractName || ""}
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
+ <Accordion type="multiple" defaultValue={["requester", "vendor"]} className="w-full">
+ {/* Requester Signature Section */}
+ <AccordionItem value="requester">
+ <div className="flex items-center space-x-2">
+ <FormField
+ control={form.control}
+ name="includeRequesterSigner"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center space-x-3 space-y-0 my-2">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <AccordionTrigger className="hover:no-underline ml-2">
+ <div className="text-sm font-medium">Requester Signature</div>
+ </AccordionTrigger>
+ </FormItem>
+ )}
+ />
+ </div>
+ <AccordionContent>
+ {form.watch("includeRequesterSigner") && (
+ <Card className="border-none shadow-none">
+ <CardContent className="p-0 space-y-4">
+ <FormField
+ control={form.control}
+ name="requesterEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Signer Email</FormLabel>
+ <FormControl>
+ <Input placeholder="email@example.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="requesterName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Signer Name</FormLabel>
+ <FormControl>
+ <Input placeholder="Full Name" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="requesterPosition"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Signer Position</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g. CEO, Manager" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+ )}
+ </AccordionContent>
+ </AccordionItem>
+
+ {/* Vendor Signature Section */}
+ <AccordionItem value="vendor">
+ <div className="flex items-center space-x-2">
+ <FormField
+ control={form.control}
+ name="includeVendorSigner"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center space-x-3 space-y-0 my-2">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <AccordionTrigger className="hover:no-underline ml-2">
+ <div className="text-sm font-medium">Vendor Signature</div>
+ </AccordionTrigger>
+ </FormItem>
+ )}
+ />
+ </div>
+ <AccordionContent>
+ {form.watch("includeVendorSigner") && (
+ <Card className="border-none shadow-none">
+ <CardContent className="p-0 space-y-4">
+ <FormField
+ control={form.control}
+ name="vendorContactId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Select Vendor Contact</FormLabel>
+ <Select
+ onValueChange={handleVendorContactSelect}
+ defaultValue={field.value?.toString()}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Select a contact" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {vendorContacts.length > 0 ? (
+ vendorContacts.map((contact) => (
+ <SelectItem
+ key={contact.id}
+ value={contact.id.toString()}
+ >
+ {contact.contactName} {contact.isPrimary ? "(Primary)" : ""}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="none" disabled>
+ No contacts available
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Display selected contact info (read-only) */}
+ {selectedVendorContact && (
+ <>
+ <FormItem className="pb-2">
+ <FormLabel>Contact Email</FormLabel>
+ <div className="p-2 border rounded-md bg-muted">
+ {selectedVendorContact.contactEmail}
+ </div>
+ </FormItem>
+
+ <FormItem className="pb-2">
+ <FormLabel>Contact Name</FormLabel>
+ <div className="p-2 border rounded-md bg-muted">
+ {selectedVendorContact.contactName}
+ </div>
+ </FormItem>
+
+ <FormItem className="pb-2">
+ <FormLabel>Contact Position</FormLabel>
+ <div className="p-2 border rounded-md bg-muted">
+ {selectedVendorContact.contactPosition || "N/A"}
+ </div>
+ </FormItem>
+ </>
+ )}
+ </CardContent>
+ </Card>
+ )}
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
+
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+ Cancel
+ </Button>
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? "Sending..." : "Send Requests"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/po/validations.ts b/lib/po/validations.ts
new file mode 100644
index 00000000..c96d7277
--- /dev/null
+++ b/lib/po/validations.ts
@@ -0,0 +1,67 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { ContractDetail } from "@/db/schema/contract"
+
+// nuqs/server 에 parseAsBoolean, parseAsNumber 등이 없다면
+// 숫자/불리언으로 처리해야 할 필드도 우선 parseAsString / parseAsStringEnum 으로 받습니다.
+// 실제 사용 시에는 후속 로직에서 변환(예: parseFloat 등)하세요.
+
+export const searchParamsCache = createSearchParamsCache({
+ // UI 모드나 플래그 관련
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬 (createdAt 기준 내림차순)
+ sort: getSortingStateParser<ContractDetail>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ projectCode: parseAsString.withDefault(""),
+ projectName: parseAsString.withDefault(""),
+
+ // 기존 필드
+ contractNo: parseAsString.withDefault(""),
+ contractName: parseAsString.withDefault(""),
+ status: parseAsString.withDefault(""),
+ startDate: parseAsString.withDefault(""), // 문자열 "YYYY-MM-DD" 형태
+ endDate: parseAsString.withDefault(""), // 마찬가지
+
+ // 추가된 PO 관련 필드
+ paymentTerms: parseAsString.withDefault(""),
+ deliveryTerms: parseAsString.withDefault(""),
+ deliveryDate: parseAsString.withDefault(""), // "YYYY-MM-DD"
+ deliveryLocation: parseAsString.withDefault(""),
+
+ // 금액 관련 (문자열로 받고 후처리에서 parseFloat 권장)
+ currency: parseAsString.withDefault("KRW"),
+ totalAmount: parseAsString.withDefault(""),
+ discount: parseAsString.withDefault(""),
+ tax: parseAsString.withDefault(""),
+ shippingFee: parseAsString.withDefault(""),
+ netTotal: parseAsString.withDefault(""),
+
+ // 부분 납품/결제 허용 여부 (문자열 "true"/"false")
+ partialShippingAllowed: parseAsStringEnum(["true", "false"]).withDefault("false"),
+ partialPaymentAllowed: parseAsStringEnum(["true", "false"]).withDefault("false"),
+
+ remarks: parseAsString.withDefault(""),
+ version: parseAsString.withDefault(""),
+
+ // 고급 필터(Advanced) & 검색
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+})
+
+// 최종 타입
+export type GetPOSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> \ No newline at end of file
diff --git a/lib/pq/pq-review-table/feature-flags-provider.tsx b/lib/pq/pq-review-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/pq/pq-review-table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/pq/pq-review-table/vendors-table-columns.tsx b/lib/pq/pq-review-table/vendors-table-columns.tsx
new file mode 100644
index 00000000..8673443f
--- /dev/null
+++ b/lib/pq/pq-review-table/vendors-table-columns.tsx
@@ -0,0 +1,212 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis, PaperclipIcon } 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 { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import { useRouter } from "next/navigation"
+
+import { Vendor, vendors, VendorWithAttachments } from "@/db/schema/vendors"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { vendorColumnsConfig } from "@/config/vendorColumnsConfig"
+import { Separator } from "@/components/ui/separator"
+
+
+type NextRouter = ReturnType<typeof useRouter>;
+
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Vendor> | null>>;
+ router: NextRouter;
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<Vendor>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<Vendor> = {
+ 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<Vendor> = {
+ 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={() => {
+ // 1) 만약 rowAction을 열고 싶다면
+ // setRowAction({ row, type: "update" })
+
+ // 2) 자세히 보기 페이지로 클라이언트 라우팅
+ router.push(`/evcp/pq/${row.original.id}`);
+ }}
+ >
+ Details
+ </DropdownMenuItem>
+
+
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<Vendor>[] }
+ const groupMap: Record<string, ColumnDef<Vendor>[]> = {}
+
+ vendorColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<Vendor> = {
+ 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 === "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>
+ )
+ }
+
+
+ if (cfg.id === "createdAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ if (cfg.id === "updatedAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+
+ // code etc...
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<Vendor>[] = []
+
+ // 순서를 고정하고 싶다면 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/pq/pq-review-table/vendors-table-toolbar-actions.tsx b/lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx
new file mode 100644
index 00000000..98fef170
--- /dev/null
+++ b/lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx
@@ -0,0 +1,41 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload, Check } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { Vendor } from "@/db/schema/vendors"
+
+interface VendorsTableToolbarActionsProps {
+ table: Table<Vendor>
+}
+
+export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+
+
+ return (
+ <div className="flex items-center gap-2">
+
+
+ {/** 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>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/pq-review-table/vendors-table.tsx b/lib/pq/pq-review-table/vendors-table.tsx
new file mode 100644
index 00000000..7eb8f7de
--- /dev/null
+++ b/lib/pq/pq-review-table/vendors-table.tsx
@@ -0,0 +1,97 @@
+"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 { useFeatureFlags } from "./feature-flags-provider"
+import { getColumns } from "./vendors-table-columns"
+import { Vendor, vendors } from "@/db/schema/vendors"
+import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions"
+import { getVendorsInPQ } from "../service"
+
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getVendorsInPQ>>,
+ ]
+ >
+}
+
+export function VendorsPQReviewTable({ promises }: VendorsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<Vendor> | null>(null)
+
+ // **router** 획득
+ const router = useRouter()
+
+ // getColumns() 호출 시, router를 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, router }),
+ [setRowAction, router]
+ )
+
+ const filterFields: DataTableFilterField<Vendor>[] = [
+
+
+ { id: "vendorCode", label: "Vendor Code" },
+
+ ]
+
+ 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" },
+
+ { id: "createdAt", label: "Created at", type: "date" },
+ { id: "updatedAt", label: "Updated at", 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}
+ // floatingBar={<VendorsTableFloatingBar table={table} />}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <VendorsTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/repository.ts b/lib/pq/repository.ts
new file mode 100644
index 00000000..95daf9a3
--- /dev/null
+++ b/lib/pq/repository.ts
@@ -0,0 +1,44 @@
+import db from "@/db/db";
+import { pqCriterias } from "@/db/schema/pq";
+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 selectPqs(
+ 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(pqCriterias)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+/** 총 개수 count */
+export async function countPqs(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(pqCriterias).where(where);
+ return res[0]?.count ?? 0;
+}
diff --git a/lib/pq/service.ts b/lib/pq/service.ts
new file mode 100644
index 00000000..a1373dae
--- /dev/null
+++ b/lib/pq/service.ts
@@ -0,0 +1,987 @@
+"use server"
+
+import db from "@/db/db"
+import { GetPQSchema } from "./validations"
+import { unstable_cache } from "@/lib/unstable-cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { getErrorMessage } from "@/lib/handle-error";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, count} from "drizzle-orm";
+import { z } from "zod"
+import { revalidateTag, unstable_noStore, revalidatePath} from "next/cache";
+import { pqCriterias, vendorCriteriaAttachments, vendorPqCriteriaAnswers, vendorPqReviewLogs } from "@/db/schema/pq"
+import { countPqs, selectPqs } from "./repository";
+import { sendEmail } from "../mail/sendEmail";
+import { vendorAttachments, vendors } from "@/db/schema/vendors";
+import path from 'path';
+import fs from 'fs/promises';
+import { randomUUID } from 'crypto';
+import { writeFile, mkdir } from 'fs/promises';
+import { GetVendorsSchema } from "../vendors/validations";
+import { countVendors, selectVendors } from "../vendors/repository";
+
+/**
+ * PQ 목록 조회
+ */
+export async function getPQs(input: GetPQSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: pqCriterias,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(pqCriterias.code, s), ilike(pqCriterias.groupName, s), ilike(pqCriterias.remarks, s), ilike(pqCriterias.checkPoint, s), ilike(pqCriterias.description, s)
+ )
+ }
+
+ const finalWhere = and(advancedWhere, globalWhere);
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(pqCriterias[item.id]) : asc(pqCriterias[item.id])
+ )
+ : [asc(pqCriterias.createdAt)];
+
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectPqs(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countPqs(tx, finalWhere);
+ 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: [`pq`],
+ }
+ )();
+}
+
+// PQ 생성을 위한 입력 스키마 정의
+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(),
+ groupName: z.string().optional()
+});
+
+export type CreatePqInputType = z.infer<typeof createPqSchema>;
+
+/**
+ * PQ 기준 생성
+ */
+export async function createPq(input: CreatePqInputType) {
+ try {
+ // 입력 유효성 검증
+ const validatedData = createPqSchema.parse(input);
+
+ // 트랜잭션 사용하여 PQ 기준 생성
+ return await db.transaction(async (tx) => {
+ // PQ 기준 생성
+ const [newPqCriteria] = await tx
+ .insert(pqCriterias)
+ .values({
+ code: validatedData.code,
+ checkPoint: validatedData.checkPoint,
+ description: validatedData.description || null,
+ remarks: validatedData.remarks || null,
+ groupName: validatedData.groupName || null,
+ })
+ .returning({ id: pqCriterias.id });
+
+ // 성공 결과 반환
+ return {
+ success: true,
+ pqId: newPqCriteria.id,
+ message: "PQ criteria created successfully"
+ };
+ });
+ } catch (error) {
+ console.error("Error creating PQ criteria:", error);
+
+ // Zod 유효성 검사 에러 처리
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ message: "Validation failed",
+ errors: error.errors
+ };
+ }
+
+ // 기타 에러 처리
+ return {
+ success: false,
+ message: "Failed to create PQ criteria"
+ };
+ }
+}
+
+// PQ 캐시 무효화 함수
+export async function invalidatePqCache() {
+ revalidatePath(`/evcp/pq-criteria`);
+ revalidateTag(`pq`);
+}
+
+// PQ 삭제를 위한 스키마 정의
+const removePqsSchema = z.object({
+ ids: z.array(z.number()).min(1, "At least one PQ ID is required")
+});
+
+export type RemovePqsInputType = z.infer<typeof removePqsSchema>;
+
+/**
+ * PQ 기준 삭제
+ */
+export async function removePqs(input: RemovePqsInputType) {
+ try {
+ // 입력 유효성 검증
+ const validatedData = removePqsSchema.parse(input);
+
+ // 트랜잭션 사용하여 PQ 기준 삭제
+ await db.transaction(async (tx) => {
+ // PQ 기준 테이블에서 삭제
+ await tx
+ .delete(pqCriterias)
+ .where(inArray(pqCriterias.id, validatedData.ids));
+ });
+
+ // 캐시 무효화
+ await invalidatePqCache();
+
+ return { success: true };
+ } catch (error) {
+ console.error("Error removing PQ criteria:", error);
+
+ // Zod 유효성 검사 에러 처리
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ error: "Validation failed: " + error.errors.map(e => e.message).join(', ')
+ };
+ }
+
+ // 기타 에러 처리
+ return {
+ success: false,
+ error: "Failed to remove PQ criteria"
+ };
+ }
+}
+
+// PQ 수정을 위한 스키마 정의
+const modifyPqSchema = z.object({
+ id: z.number().positive("ID is required"),
+ code: z.string().min(1, "Code is required"),
+ checkPoint: z.string().min(1, "Check point is required"),
+ groupName: z.string().min(1, "Group is required"),
+ description: z.string().optional(),
+ remarks: z.string().optional()
+});
+
+export type ModifyPqInputType = z.infer<typeof modifyPqSchema>;
+
+
+export async function modifyPq(input: ModifyPqInputType) {
+ try {
+ // 입력 유효성 검증
+ const validatedData = modifyPqSchema.parse(input);
+
+ // 트랜잭션 사용하여 PQ 기준 수정
+ return await db.transaction(async (tx) => {
+ // PQ 기준 수정
+ await tx
+ .update(pqCriterias)
+ .set({
+ code: validatedData.code,
+ checkPoint: validatedData.checkPoint,
+ description: validatedData.description || null,
+ remarks: validatedData.remarks || null,
+ groupName: validatedData.groupName,
+ updatedAt: new Date(),
+ })
+ .where(eq(pqCriterias.id, validatedData.id));
+
+ // 성공 결과 반환
+ return {
+ success: true,
+ message: "PQ criteria updated successfully"
+ };
+ });
+ } catch (error) {
+ console.error("Error updating PQ criteria:", error);
+
+ // Zod 유효성 검사 에러 처리
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ error: "Validation failed: " + error.errors.map(e => e.message).join(', ')
+ };
+ }
+
+ // 기타 에러 처리
+ return {
+ success: false,
+ error: "Failed to update PQ criteria"
+ };
+ } finally {
+ // 캐시 무효화
+ revalidatePath(`/partners/pq`);
+ revalidateTag(`pq`);
+ }
+}
+
+export interface PQAttachment {
+ attachId: number
+ fileName: string
+ filePath: string
+ fileSize?: number
+}
+
+export interface PQItem {
+ answerId: number | null; // null도 허용하도록 변경
+ criteriaId: number
+ code: string
+ checkPoint: string
+ description: string | null
+ answer: string // or null
+ attachments: PQAttachment[]
+}
+
+export interface PQGroupData {
+ groupName: string
+ items: PQItem[]
+}
+
+
+export async function getPQDataByVendorId(vendorId: number): Promise<PQGroupData[]> {
+ // 1) Query: pqCriterias
+ // LEFT JOIN vendorPqCriteriaAnswers (to get `answer`)
+ // LEFT JOIN vendorCriteriaAttachments (to get each attachment row)
+ const rows = await db
+ .select({
+ criteriaId: pqCriterias.id,
+ groupName: pqCriterias.groupName,
+ code: pqCriterias.code,
+ checkPoint: pqCriterias.checkPoint,
+ description: pqCriterias.description,
+
+ // From vendorPqCriteriaAnswers
+ answer: vendorPqCriteriaAnswers.answer, // can be null if no row exists
+ answerId: vendorPqCriteriaAnswers.id, // internal PK if needed
+
+ // From vendorCriteriaAttachments
+ attachId: vendorCriteriaAttachments.id,
+ fileName: vendorCriteriaAttachments.fileName,
+ filePath: vendorCriteriaAttachments.filePath,
+ fileSize: vendorCriteriaAttachments.fileSize,
+ })
+ .from(pqCriterias)
+ .leftJoin(
+ vendorPqCriteriaAnswers,
+ and(
+ eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId),
+ eq(vendorPqCriteriaAnswers.vendorId, vendorId)
+ )
+ )
+ .leftJoin(
+ vendorCriteriaAttachments,
+ eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId)
+ )
+ .orderBy(pqCriterias.groupName, pqCriterias.code)
+
+ // 2) Group by groupName => each group has a map of criteriaId => PQItem
+ // so we can gather attachments properly.
+ const groupMap = new Map<string, Record<number, PQItem>>()
+
+ for (const row of rows) {
+ const g = row.groupName || "Others"
+
+ // Ensure we have an object for this group
+ if (!groupMap.has(g)) {
+ groupMap.set(g, {})
+ }
+
+ const groupItems = groupMap.get(g)!
+ // If we haven't seen this criteriaId yet, create a PQItem
+ if (!groupItems[row.criteriaId]) {
+ groupItems[row.criteriaId] = {
+ answerId: row.answerId,
+ criteriaId: row.criteriaId,
+ code: row.code,
+ checkPoint: row.checkPoint,
+ description: row.description,
+ answer: row.answer || "", // if row.answer is null, just empty string
+ attachments: [],
+ }
+ }
+
+ // If there's an attachment row (attachId not null), push it onto `attachments`
+ if (row.attachId) {
+ groupItems[row.criteriaId].attachments.push({
+ attachId: row.attachId,
+ fileName: row.fileName || "",
+ filePath: row.filePath || "",
+ fileSize: row.fileSize || undefined,
+ })
+ }
+ }
+
+ // 3) Convert groupMap into an array of { groupName, items[] }
+ const data: PQGroupData[] = []
+ for (const [groupName, itemsMap] of groupMap.entries()) {
+ // Convert the itemsMap (key=criteriaId => PQItem) into an array
+ const items = Object.values(itemsMap)
+ data.push({ groupName, items })
+ }
+
+ return data
+}
+
+
+interface PQAttachmentInput {
+ fileName: string // original user-friendly file name
+ url: string // the UUID-based path stored on server
+ size?: number // optional file size
+}
+
+interface SavePQAnswer {
+ criteriaId: number
+ answer: string
+ attachments: PQAttachmentInput[]
+}
+
+interface SavePQInput {
+ vendorId: number
+ answers: SavePQAnswer[]
+}
+
+/**
+ * 여러 항목을 한 번에 Upsert
+ */
+export async function savePQAnswersAction(input: SavePQInput) {
+ const { vendorId, answers } = input
+
+ try {
+ for (const ans of answers) {
+ // 1) Check if a row already exists for (vendorId, criteriaId)
+ const existing = await db
+ .select()
+ .from(vendorPqCriteriaAnswers)
+ .where(
+ and(
+ eq(vendorPqCriteriaAnswers.vendorId, vendorId),
+ eq(vendorPqCriteriaAnswers.criteriaId, ans.criteriaId)
+ )
+ )
+
+ let answerId: number
+
+ // 2) If it exists, update the row; otherwise insert
+ if (existing.length === 0) {
+ // Insert new
+ const inserted = await db
+ .insert(vendorPqCriteriaAnswers)
+ .values({
+ vendorId,
+ criteriaId: ans.criteriaId,
+ answer: ans.answer,
+ // no attachmentPaths column anymore
+ })
+ .returning({ id: vendorPqCriteriaAnswers.id })
+
+ answerId = inserted[0].id
+ } else {
+ // Update existing
+ answerId = existing[0].id
+
+ await db
+ .update(vendorPqCriteriaAnswers)
+ .set({
+ answer: ans.answer,
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorPqCriteriaAnswers.id, answerId))
+ }
+
+ // 3) Now manage attachments in vendorCriteriaAttachments
+ // We'll do a "diff": remove old ones not in the new list, insert new ones not in DB.
+
+ // 3a) Load old attachments from DB
+ const oldAttachments = await db
+ .select({
+ id: vendorCriteriaAttachments.id,
+ filePath: vendorCriteriaAttachments.filePath,
+ })
+ .from(vendorCriteriaAttachments)
+ .where(eq(vendorCriteriaAttachments.vendorCriteriaAnswerId, answerId))
+
+ // 3b) Gather the new filePaths (urls) from the client
+ const newPaths = ans.attachments.map(a => a.url)
+
+ // 3c) Find attachments to remove
+ const toRemove = oldAttachments.filter(old => !newPaths.includes(old.filePath))
+ if (toRemove.length > 0) {
+ const removeIds = toRemove.map(r => r.id)
+ await db
+ .delete(vendorCriteriaAttachments)
+ .where(inArray(vendorCriteriaAttachments.id, removeIds))
+ }
+
+ // 3d) Insert new attachments that aren’t in DB
+ const oldPaths = oldAttachments.map(o => o.filePath)
+ const toAdd = ans.attachments.filter(a => !oldPaths.includes(a.url))
+
+ for (const attach of toAdd) {
+ await db.insert(vendorCriteriaAttachments).values({
+ vendorCriteriaAnswerId: answerId,
+ fileName: attach.fileName, // original filename
+ filePath: attach.url, // random/UUID path on server
+ fileSize: attach.size ?? null,
+ // fileType if you have it, etc.
+ })
+ }
+ }
+
+ return { ok: true }
+ } catch (error) {
+ console.error("savePQAnswersAction error:", error)
+ return { ok: false, error: String(error) }
+ }
+}
+
+
+
+/**
+ * PQ 제출 서버 액션 - 벤더 상태를 PQ_SUBMITTED로 업데이트
+ * @param vendorId 벤더 ID
+ */
+export async function submitPQAction(vendorId: number) {
+ unstable_noStore();
+
+ try {
+ // 1. 모든 PQ 항목에 대한 응답이 있는지 검증
+ const pqCriteriaCount = await db
+ .select({ count: count() })
+ .from(vendorPqCriteriaAnswers)
+ .where(eq(vendorPqCriteriaAnswers.vendorId, vendorId));
+
+ const totalPqCriteriaCount = pqCriteriaCount[0]?.count || 0;
+
+ // 응답 데이터 검증
+ if (totalPqCriteriaCount === 0) {
+ return { ok: false, error: "No PQ answers found" };
+ }
+
+ // 2. 벤더 정보 조회
+ const vendor = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ status: vendors.status,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .then(rows => rows[0]);
+
+ if (!vendor) {
+ return { ok: false, error: "Vendor not found" };
+ }
+
+ // 3. 벤더 상태가 제출 가능한 상태인지 확인
+ const allowedStatuses = ["IN_PQ", "PENDING_REVIEW", "IN_REVIEW", "REJECTED", "PQ_FAILED"];
+ if (!allowedStatuses.includes(vendor.status)) {
+ return {
+ ok: false,
+ error: `Cannot submit PQ in current status: ${vendor.status}`
+ };
+ }
+
+ // 4. 벤더 상태 업데이트
+ await db
+ .update(vendors)
+ .set({
+ status: "PQ_SUBMITTED",
+ updatedAt: new Date(),
+ })
+ .where(eq(vendors.id, vendorId));
+
+ // 5. 관리자에게 이메일 알림 발송
+ if (process.env.ADMIN_EMAIL) {
+ try {
+ await sendEmail({
+ to: process.env.ADMIN_EMAIL,
+ subject: `[eVCP] PQ Submitted: ${vendor.vendorName}`,
+ template: "pq-submitted-admin",
+ context: {
+ vendorName: vendor.vendorName,
+ vendorId: vendor.id,
+ submittedDate: new Date().toLocaleString(),
+ adminUrl: `${process.env.NEXT_PUBLIC_APP_URL}/admin/vendors/${vendorId}/pq`,
+ }
+ });
+ } catch (emailError) {
+ console.error("Failed to send admin notification:", emailError);
+ // 이메일 실패는 전체 프로세스를 중단하지 않음
+ }
+ }
+
+ // 6. 벤더에게 확인 이메일 발송
+ if (vendor.email) {
+ try {
+ await sendEmail({
+ to: vendor.email,
+ subject: "[eVCP] PQ Submission Confirmation",
+ template: "pq-submitted-vendor",
+ context: {
+ vendorName: vendor.vendorName,
+ submittedDate: new Date().toLocaleString(),
+ portalUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
+ }
+ });
+ } catch (emailError) {
+ console.error("Failed to send vendor confirmation:", emailError);
+ // 이메일 실패는 전체 프로세스를 중단하지 않음
+ }
+ }
+
+ // 7. 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+
+ return { ok: true };
+ } catch (error) {
+ console.error("PQ submit error:", error);
+ return { ok: false, error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * 향상된 파일 업로드 서버 액션
+ * - 직접 파일 처리 (file 객체로 받음)
+ * - 디렉토리 자동 생성
+ * - 중복 방지를 위한 UUID 적용
+ */
+export async function uploadFileAction(file: File) {
+ unstable_noStore();
+
+ try {
+ // 파일 유효성 검사
+ if (!file || file.size === 0) {
+ throw new Error("Invalid file");
+ }
+
+ const maxSize = 6e8;
+ if (file.size > maxSize) {
+ throw new Error(`File size exceeds limit (${Math.round(maxSize / 1024 / 1024)}MB)`);
+ }
+
+ // 파일 확장자 가져오기
+ const originalFilename = file.name;
+ const fileExt = path.extname(originalFilename);
+ const fileNameWithoutExt = path.basename(originalFilename, fileExt);
+
+ // 저장 경로 설정
+ const uploadDir = process.env.UPLOAD_DIR
+ ? process.env.UPLOAD_DIR
+ : path.join(process.cwd(), "public", "uploads")
+ const datePrefix = new Date().toISOString().slice(0, 10).replace(/-/g, ''); // YYYYMMDD
+ const targetDir = path.join(uploadDir, 'pq', datePrefix);
+
+ // UUID로 고유 파일명 생성
+ const uuid = randomUUID();
+ const sanitizedFilename = fileNameWithoutExt
+ .replace(/[^a-zA-Z0-9-_]/g, '_') // 안전한 문자만 허용
+ .slice(0, 50); // 이름 길이 제한
+
+ const filename = `${sanitizedFilename}-${uuid}${fileExt}`;
+ const filePath = path.join(targetDir, filename);
+ const relativeFilePath = path.join('pq', datePrefix, filename);
+
+ // 디렉토리 생성 (없는 경우)
+ try {
+ await mkdir(targetDir, { recursive: true });
+ } catch (err) {
+ console.error("Error creating directory:", err);
+ throw new Error("Failed to create upload directory");
+ }
+
+ // 파일 저장
+ const buffer = await file.arrayBuffer();
+ await writeFile(filePath, Buffer.from(buffer));
+
+ // 상대 경로를 반환 (DB에 저장하기 용이함)
+ const publicUrl = `/uploads/${relativeFilePath.replace(/\\/g, '/')}`;
+
+ return {
+ fileName: originalFilename,
+ url: publicUrl,
+ size: file.size,
+ };
+ } catch (error) {
+ console.error("File upload error:", error);
+ throw new Error(`Upload failed: ${getErrorMessage(error)}`);
+ }
+}
+
+/**
+ * 여러 파일 일괄 업로드
+ */
+export async function uploadMultipleFilesAction(files: File[]) {
+ unstable_noStore();
+
+ try {
+ const results = [];
+
+ for (const file of files) {
+ try {
+ const result = await uploadFileAction(file);
+ results.push({
+ success: true,
+ ...result
+ });
+ } catch (error) {
+ results.push({
+ success: false,
+ fileName: file.name,
+ error: getErrorMessage(error)
+ });
+ }
+ }
+
+ return {
+ ok: true,
+ results
+ };
+ } catch (error) {
+ console.error("Batch upload error:", error);
+ return {
+ ok: false,
+ error: getErrorMessage(error)
+ };
+ }
+}
+
+export async function getVendorsInPQ(input: GetVendorsSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 1) 고급 필터
+ const advancedWhere = filterColumns({
+ table: vendors,
+ 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)
+ );
+ }
+
+ // 최종 where 결합
+ const finalWhere = and(advancedWhere, globalWhere, eq(vendors.status ,"PQ_SUBMITTED"));
+
+ // 간단 검색 (advancedTable=false) 시 예시
+ const simpleWhere = and(
+ input.vendorName
+ ? ilike(vendors.vendorName, `%${input.vendorName}%`)
+ : undefined,
+ input.status ? ilike(vendors.status, input.status) : undefined,
+ input.country
+ ? ilike(vendors.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])
+ )
+ : [asc(vendors.createdAt)];
+
+ // 트랜잭션 내에서 데이터 조회
+ const { data, total } = await db.transaction(async (tx) => {
+ // 1) vendor 목록 조회
+ const vendorsData = await selectVendors(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ // 2) 각 vendor의 attachments 조회
+ const vendorsWithAttachments = await Promise.all(
+ vendorsData.map(async (vendor) => {
+ const attachments = await tx
+ .select({
+ id: vendorAttachments.id,
+ fileName: vendorAttachments.fileName,
+ filePath: vendorAttachments.filePath,
+ })
+ .from(vendorAttachments)
+ .where(eq(vendorAttachments.vendorId, vendor.id));
+
+ return {
+ ...vendor,
+ hasAttachments: attachments.length > 0,
+ attachmentsList: attachments,
+ };
+ })
+ );
+
+ // 3) 전체 개수
+ const total = await countVendors(tx, where);
+ return { data: vendorsWithAttachments, total };
+ });
+
+ // 페이지 수
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: ["vendors-in-pq"], // revalidateTag("vendors") 호출 시 무효화
+ }
+ )();
+}
+
+
+export type VendorStatus =
+ | "PENDING_REVIEW"
+ | "IN_REVIEW"
+ | "REJECTED"
+ | "IN_PQ"
+ | "PQ_SUBMITTED"
+ | "PQ_FAILED"
+ | "APPROVED"
+ | "ACTIVE"
+ | "INACTIVE"
+ | "BLACKLISTED"
+
+ export async function updateVendorStatusAction(
+ vendorId: number,
+ newStatus: VendorStatus
+ ) {
+ try {
+ // 1) Update DB
+ await db.update(vendors)
+ .set({ status: newStatus })
+ .where(eq(vendors.id, vendorId))
+
+ // 2) Load vendor’s email & name
+ const vendor = await db.select().from(vendors).where(eq(vendors.id, vendorId)).then(r => r[0])
+ if (!vendor) {
+ return { ok: false, error: "Vendor not found" }
+ }
+
+ // 3) Send email
+ await sendEmail({
+ to: vendor.email || "",
+ subject: `Your PQ Status is now ${newStatus}`,
+ template: "vendor-pq-status", // matches .hbs file
+ context: {
+ name: vendor.vendorName,
+ status: newStatus,
+ loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/pq`, // etc.
+ },
+ })
+ revalidateTag("vendors")
+ revalidateTag("vendors-in-pq")
+ return { ok: true }
+ } catch (error) {
+ console.error("updateVendorStatusAction error:", error)
+ return { ok: false, error: String(error) }
+ }
+ }
+// 코멘트 타입 정의
+interface ItemComment {
+ answerId: number;
+ checkPoint: string; // 체크포인트 정보 추가
+ code: string; // 코드 정보 추가
+ comment: string;
+}
+
+/**
+ * PQ 변경 요청 처리 서버 액션
+ *
+ * @param vendorId 벤더 ID
+ * @param comment 항목별 코멘트 배열 (answerId, checkPoint, code, comment로 구성)
+ * @param generalComment 전체 PQ에 대한 일반 코멘트 (선택사항)
+ */
+export async function requestPqChangesAction({
+ vendorId,
+ comment,
+ generalComment,
+}: {
+ vendorId: number;
+ comment: ItemComment[];
+ generalComment?: string;
+}) {
+ try {
+ // 1) 벤더 상태 업데이트
+ await db.update(vendors)
+ .set({
+ status: "IN_PQ", // 변경 요청 상태로 설정
+ updatedAt: new Date(),
+ })
+ .where(eq(vendors.id, vendorId));
+
+ // 2) 벤더 정보 가져오기
+ const vendor = await db.select()
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .then(r => r[0]);
+
+ if (!vendor) {
+ return { ok: false, error: "Vendor not found" };
+ }
+
+ // 3) 각 항목별 코멘트 저장
+ const currentDate = new Date();
+ const reviewerId = 1; // 관리자 ID (실제 구현에서는 세션에서 가져옵니다)
+ const reviewerName = "AdminUser"; // 실제 구현에서는 세션에서 가져옵니다
+
+ // 병렬로 모든 코멘트 저장
+ if (comment && comment.length > 0) {
+ const insertPromises = comment.map(item =>
+ db.insert(vendorPqReviewLogs)
+ .values({
+ vendorPqCriteriaAnswerId: item.answerId,
+ // reviewerId: reviewerId,
+ reviewerName: reviewerName,
+ reviewerComment: item.comment,
+ createdAt: currentDate,
+ // 추가 메타데이터 필드가 있다면 저장
+ // 이런 메타데이터는 DB 스키마에 해당 필드가 있어야 함
+ // meta: JSON.stringify({ checkPoint: item.checkPoint, code: item.code })
+ })
+ );
+
+ // 모든 삽입 기다리기
+ await Promise.all(insertPromises);
+ }
+
+ // 4) 변경 요청 이메일 보내기
+ // 코멘트 목록 준비
+ const commentItems = comment.map(item => ({
+ id: item.answerId,
+ code: item.code,
+ checkPoint: item.checkPoint,
+ text: item.comment
+ }));
+
+ await sendEmail({
+ to: vendor.email || "",
+ subject: `[IMPORTANT] Your PQ submission requires changes`,
+ template: "vendor-pq-comment", // matches .hbs file
+ context: {
+ name: vendor.vendorName,
+ vendorCode: vendor.vendorCode,
+ loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/pq`,
+ comments: commentItems,
+ generalComment: generalComment || "",
+ hasGeneralComment: !!generalComment,
+ commentCount: commentItems.length,
+ },
+ });
+
+ revalidateTag("vendors")
+ revalidateTag("vendors-in-pq")
+
+ return { ok: true };
+ } catch (error) {
+ console.error("requestPqChangesAction error:", error);
+ return { ok: false, error: String(error) };
+ }
+}
+interface AddReviewCommentInput {
+ answerId: number // vendorPqCriteriaAnswers.id
+ comment: string
+ reviewerName?: string
+}
+
+export async function addReviewCommentAction(input: AddReviewCommentInput) {
+ try {
+ // 1) Check that the answer row actually exists
+ const existing = await db
+ .select({ id: vendorPqCriteriaAnswers.id })
+ .from(vendorPqCriteriaAnswers)
+ .where(eq(vendorPqCriteriaAnswers.id, input.answerId))
+
+ if (existing.length === 0) {
+ return { ok: false, error: "Item not found" }
+ }
+
+ // 2) Insert the log
+ await db.insert(vendorPqReviewLogs).values({
+ vendorPqCriteriaAnswerId: input.answerId,
+ reviewerComment: input.comment,
+ reviewerName: input.reviewerName ?? "AdminUser",
+ })
+
+ return { ok: true }
+ } catch (error) {
+ console.error("addReviewCommentAction error:", error)
+ return { ok: false, error: String(error) }
+ }
+}
+
+interface GetItemReviewLogsInput {
+ answerId: number
+}
+
+export async function getItemReviewLogsAction(input: GetItemReviewLogsInput) {
+ try {
+
+ const logs = await db
+ .select()
+ .from(vendorPqReviewLogs)
+ .where(eq(vendorPqReviewLogs.vendorPqCriteriaAnswerId, input.answerId))
+ .orderBy(desc(vendorPqReviewLogs.createdAt));
+
+ return { ok: true, data: logs };
+ } catch (error) {
+ console.error("getItemReviewLogsAction error:", error);
+ return { ok: false, error: String(error) };
+ }
+} \ No newline at end of file
diff --git a/lib/pq/table/add-pq-dialog.tsx b/lib/pq/table/add-pq-dialog.tsx
new file mode 100644
index 00000000..8164dbaf
--- /dev/null
+++ b/lib/pq/table/add-pq-dialog.tsx
@@ -0,0 +1,299 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { Plus } from "lucide-react"
+import { useRouter } from "next/navigation"
+
+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 {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { useToast } from "@/hooks/use-toast"
+import { createPq, invalidatePqCache } from "../service"
+
+// PQ 생성을 위한 Zod 스키마 정의
+const createPqSchema = z.object({
+ code: z.string().min(1, "Code is required"),
+ checkPoint: z.string().min(1, "Check point is required"),
+ groupName: z.string().min(1, "Group is required"),
+ description: z.string().optional(),
+ remarks: z.string().optional()
+});
+
+type CreatePqInputType = z.infer<typeof createPqSchema>;
+
+// 그룹 이름 옵션
+const groupOptions = [
+ "GENERAL",
+ "Quality Management System",
+ "Workshop & Environment",
+ "Warranty",
+];
+
+// 설명 예시 텍스트
+const descriptionExample = `Address :
+Tel. / Fax :
+e-mail :`;
+
+export function AddPqDialog() {
+ const [open, setOpen] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const router = useRouter()
+ const { toast } = useToast()
+
+ // react-hook-form 설정
+ const form = useForm<CreatePqInputType>({
+ resolver: zodResolver(createPqSchema),
+ defaultValues: {
+ code: "",
+ checkPoint: "",
+ groupName: groupOptions[0],
+ description: "",
+ remarks: ""
+ },
+ })
+
+ // 예시 텍스트를 description 필드에 채우는 함수
+ const fillExampleText = () => {
+ form.setValue("description", descriptionExample);
+ };
+
+ async function onSubmit(data: CreatePqInputType) {
+ try {
+ setIsSubmitting(true)
+
+ // 서버 액션 호출
+ const result = await createPq(data)
+
+ if (!result.success) {
+ toast({
+ title: "Error",
+ description: result.message || "Failed to create PQ criteria",
+ variant: "destructive",
+ })
+ return
+ }
+
+ await invalidatePqCache();
+
+ // 성공 시 처리
+ toast({
+ title: "Success",
+ description: "PQ criteria created successfully",
+ })
+
+ // 모달 닫고 폼 리셋
+ form.reset()
+ setOpen(false)
+
+ // 페이지 새로고침
+ router.refresh()
+
+ } catch (error) {
+ console.error('Error creating PQ criteria:', error)
+ toast({
+ title: "Error",
+ description: "An unexpected error occurred",
+ 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">
+ <Plus className="size-4" />
+ Add PQ
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="sm:max-w-[550px]">
+ <DialogHeader>
+ <DialogTitle>Create New PQ Criteria</DialogTitle>
+ <DialogDescription>
+ 새 PQ 기준 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 py-2">
+ {/* Code 필드 */}
+ <FormField
+ control={form.control}
+ name="code"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Code <span className="text-destructive">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 1-1, A.2.3"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ PQ 항목의 고유 코드를 입력하세요 (예: "1-1", "A.2.3")
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Check Point 필드 */}
+ <FormField
+ control={form.control}
+ name="checkPoint"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Check Point <span className="text-destructive">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="검증 항목을 입력하세요"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Group Name 필드 (Select) */}
+ <FormField
+ control={form.control}
+ name="groupName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Group <span className="text-destructive">*</span></FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ value={field.value}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="그룹을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {groupOptions.map((group) => (
+ <SelectItem key={group} value={group}>
+ {group}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormDescription>
+ PQ 항목의 분류 그룹을 선택하세요
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Description 필드 - 예시 템플릿 버튼 추가 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <div className="flex items-center justify-between">
+ <FormLabel>Description</FormLabel>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={fillExampleText}
+ >
+ 예시 채우기
+ </Button>
+ </div>
+ <FormControl>
+ <Textarea
+ placeholder={`줄바꿈을 포함한 상세 설명을 입력하세요\n예:\n${descriptionExample}`}
+ className="min-h-[120px] font-mono"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormDescription>
+ 줄바꿈이 필요한 경우 Enter 키를 누르세요. 입력한 대로 저장됩니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Remarks 필드 */}
+ <FormField
+ control={form.control}
+ name="remarks"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Remarks</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="비고 사항을 입력하세요"
+ className="min-h-[80px]"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ form.reset();
+ setOpen(false);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={isSubmitting}
+ >
+ {isSubmitting ? "Creating..." : "Create"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/table/delete-pqs-dialog.tsx b/lib/pq/table/delete-pqs-dialog.tsx
new file mode 100644
index 00000000..c6a2ce82
--- /dev/null
+++ b/lib/pq/table/delete-pqs-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 { PqCriterias } from "@/db/schema/pq"
+import { removePqs } from "../service"
+
+
+interface DeleteTasksDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ pqs: Row<PqCriterias>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeletePqsDialog({
+ pqs,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteTasksDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removePqs({
+ ids: pqs.map((pq) => pq.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 ({pqs.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">{pqs.length}</span>
+ {pqs.length === 1 ? " PQ" : " PQs"} 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 ({pqs.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">{pqs.length}</span>
+ {pqs.length === 1 ? " task" : " pqs"} 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/pq/table/pq-table-column.tsx b/lib/pq/table/pq-table-column.tsx
new file mode 100644
index 00000000..7efed645
--- /dev/null
+++ b/lib/pq/table/pq-table-column.tsx
@@ -0,0 +1,185 @@
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { Checkbox } from "@/components/ui/checkbox"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { DataTableRowAction } from "@/types/table"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Button } from "@/components/ui/button"
+import { Ellipsis } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+import { PqCriterias } from "@/db/schema/pq"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PqCriterias> | null>>
+}
+
+export function getColumns({
+ setRowAction,
+}: GetColumnsProps): ColumnDef<PqCriterias>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size:40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ {
+ accessorKey: "groupName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Group Name" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("groupName")}</div>,
+ meta: {
+ excelHeader: "Group Name"
+ },
+ enableResizing: true,
+ minSize: 60,
+ size: 100,
+ },
+ {
+ accessorKey: "code",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Code" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("code")}</div>,
+ meta: {
+ excelHeader: "Code"
+ },
+ enableResizing: true,
+ minSize: 50,
+ size: 100,
+ },
+ {
+ accessorKey: "checkPoint",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Check Point" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("checkPoint")}</div>,
+ meta: {
+ excelHeader: "Check Point"
+ },
+ enableResizing: true,
+ minSize: 180,
+ size: 180,
+ },
+
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Description" />
+ ),
+ cell: ({ row }) => {
+ const text = row.getValue("description") as string
+ return (
+ <div style={{ whiteSpace: "pre-wrap" }}>
+ {text}
+ </div>
+ )
+ },
+ meta: {
+ excelHeader: "Description"
+ },
+ enableResizing: true,
+ minSize: 180,
+ size: 180,
+ },
+
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Created At" />
+ ),
+ cell: ({ cell }) => formatDateTime(cell.getValue() as Date),
+ meta: {
+ excelHeader: "created At"
+ },
+ enableResizing: true,
+ minSize: 180,
+ size: 180,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Updated At" />
+ ),
+ cell: ({ cell }) => formatDateTime(cell.getValue() as Date),
+ meta: {
+ excelHeader: "updated At"
+ },
+ enableResizing: true,
+ minSize: 180,
+ size: 180,
+ },
+ {
+ 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-7 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,
+ }
+ ]
+} \ No newline at end of file
diff --git a/lib/pq/table/pq-table-toolbar-actions.tsx b/lib/pq/table/pq-table-toolbar-actions.tsx
new file mode 100644
index 00000000..1d151520
--- /dev/null
+++ b/lib/pq/table/pq-table-toolbar-actions.tsx
@@ -0,0 +1,55 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Send, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { DeletePqsDialog } from "./delete-pqs-dialog"
+import { AddPqDialog } from "./add-pq-dialog"
+import { PqCriterias } from "@/db/schema/pq"
+
+
+interface DocTableToolbarActionsProps {
+ table: Table<PqCriterias>
+}
+
+export function PqTableToolbarActions({ table}: DocTableToolbarActionsProps) {
+
+
+ return (
+ <div className="flex items-center gap-2">
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeletePqsDialog
+ pqs={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+
+ <AddPqDialog />
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "Document-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/pq/table/pq-table.tsx b/lib/pq/table/pq-table.tsx
new file mode 100644
index 00000000..73876c72
--- /dev/null
+++ b/lib/pq/table/pq-table.tsx
@@ -0,0 +1,125 @@
+"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 { getPQs } from "../service"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { PqCriterias } from "@/db/schema/pq"
+import { DeletePqsDialog } from "./delete-pqs-dialog"
+import { PqTableToolbarActions } from "./pq-table-toolbar-actions"
+import { getColumns } from "./pq-table-column"
+import { UpdatePqSheet } from "./update-pq-sheet"
+
+interface DocumentListTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getPQs>>]>
+}
+
+export function PqsTable({
+ promises,
+}: DocumentListTableProps) {
+ // 1) 데이터를 가져옴 (server component -> use(...) pattern)
+ const [{ data, pageCount }] = React.use(promises)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<PqCriterias> | null>(null)
+
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // Filter fields
+ const filterFields: DataTableFilterField<PqCriterias>[] = []
+
+ const advancedFilterFields: DataTableAdvancedFilterField<PqCriterias>[] = [
+ {
+ id: "code",
+ label: "Code",
+ type: "text",
+ },
+ {
+ id: "checkPoint",
+ label: "Check Point",
+ type: "text",
+ },
+ {
+ id: "description",
+ label: "Description",
+ type: "text",
+ },
+ {
+ id: "remarks",
+ label: "Remarks",
+ type: "text",
+ },
+ {
+ id: "groupName",
+ label: "Group Name",
+ type: "text",
+ },
+ {
+ id: "createdAt",
+ label: "Created at",
+ type: "date",
+ },
+ {
+ id: "updatedAt",
+ label: "Updated at",
+ type: "date",
+ },
+ ]
+
+ // useDataTable 훅으로 react-table 구성
+ const { table } = useDataTable({
+ data: data, // <-- 여기서 tableData 사용
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ // grouping:['groupName']
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ columnResizeMode: "onEnd",
+
+ })
+ return (
+ <>
+ <DataTable table={table} >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <PqTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <UpdatePqSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ pq={rowAction?.row.original ?? null}
+ />
+
+ <DeletePqsDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ pqs={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/table/update-pq-sheet.tsx b/lib/pq/table/update-pq-sheet.tsx
new file mode 100644
index 00000000..3bac3558
--- /dev/null
+++ b/lib/pq/table/update-pq-sheet.tsx
@@ -0,0 +1,272 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader, Save } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { z } from "zod"
+import { useRouter } from "next/navigation"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} 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 { Textarea } from "@/components/ui/textarea"
+
+import { modifyPq } from "../service"
+
+// PQ 수정을 위한 Zod 스키마 정의
+const updatePqSchema = z.object({
+ code: z.string().min(1, "Code is required"),
+ checkPoint: z.string().min(1, "Check point is required"),
+ groupName: z.string().min(1, "Group is required"),
+ description: z.string().optional(),
+ remarks: z.string().optional()
+});
+
+type UpdatePqSchema = z.infer<typeof updatePqSchema>;
+
+// 그룹 이름 옵션
+const groupOptions = [
+ "GENERAL",
+ "Quality Management System",
+ "Organization",
+ "Resource Management",
+ "Other"
+];
+
+interface UpdatePqSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ pq: {
+ id: number;
+ code: string;
+ checkPoint: string;
+ description: string | null;
+ remarks: string | null;
+ groupName: string | null;
+ } | null
+}
+
+export function UpdatePqSheet({ pq, ...props }: UpdatePqSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const router = useRouter()
+
+ const form = useForm<UpdatePqSchema>({
+ resolver: zodResolver(updatePqSchema),
+ defaultValues: {
+ code: pq?.code ?? "",
+ checkPoint: pq?.checkPoint ?? "",
+ groupName: pq?.groupName ?? groupOptions[0],
+ description: pq?.description ?? "",
+ remarks: pq?.remarks ?? "",
+ },
+ })
+
+ // 폼 초기화 (pq가 변경될 때)
+ React.useEffect(() => {
+ if (pq) {
+ form.reset({
+ code: pq.code,
+ checkPoint: pq.checkPoint,
+ groupName: pq.groupName ?? groupOptions[0],
+ description: pq.description ?? "",
+ remarks: pq.remarks ?? "",
+ });
+ }
+ }, [pq, form]);
+
+ function onSubmit(input: UpdatePqSchema) {
+ startUpdateTransition(async () => {
+ if (!pq) return
+
+ const result = await modifyPq({
+ id: pq.id,
+ ...input,
+ })
+
+ if (!result.success && 'error' in result) {
+ toast.error(result.error)
+ } else {
+ toast.error("Failed to update PQ criteria")
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("PQ criteria updated successfully")
+ router.refresh()
+ })
+ }
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Update PQ Criteria</SheetTitle>
+ <SheetDescription>
+ Update the PQ criteria details and save the changes
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ {/* Code 필드 */}
+ <FormField
+ control={form.control}
+ name="code"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Code <span className="text-destructive">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 1-1, A.2.3"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Check Point 필드 */}
+ <FormField
+ control={form.control}
+ name="checkPoint"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Check Point <span className="text-destructive">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="검증 항목을 입력하세요"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Group Name 필드 (Select) */}
+ <FormField
+ control={form.control}
+ name="groupName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Group <span className="text-destructive">*</span></FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ value={field.value}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="그룹을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {groupOptions.map((group) => (
+ <SelectItem key={group} value={group}>
+ {group}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Description 필드 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Description</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="상세 설명을 입력하세요"
+ className="min-h-[120px] whitespace-pre-wrap"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormDescription>
+ 줄바꿈이 필요한 경우 Enter 키를 누르세요. 입력한 대로 저장됩니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Remarks 필드 */}
+ <FormField
+ control={form.control}
+ name="remarks"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Remarks</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="비고 사항을 입력하세요"
+ className="min-h-[80px]"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => form.reset()}
+ >
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ <Save className="mr-2 size-4" /> Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/validations.ts b/lib/pq/validations.ts
new file mode 100644
index 00000000..27e065ba
--- /dev/null
+++ b/lib/pq/validations.ts
@@ -0,0 +1,36 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { PqCriterias } from "@/db/schema/pq"
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<PqCriterias>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ code: parseAsString.withDefault(""),
+ checkPoint: parseAsString.withDefault(""),
+ description: parseAsString.withDefault(""),
+ remarks: parseAsString.withDefault(""),
+ groupName: parseAsString.withDefault(""),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+
+export type GetPQSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
diff --git a/lib/rfqs/cbe-table/cbe-table-columns.tsx b/lib/rfqs/cbe-table/cbe-table-columns.tsx
new file mode 100644
index 00000000..325b0465
--- /dev/null
+++ b/lib/rfqs/cbe-table/cbe-table-columns.tsx
@@ -0,0 +1,227 @@
+"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: (vendorId: number) => void
+ openFilesDialog: (cbeId:number , vendorId: number) => void
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ openFilesDialog
+}: 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<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,
+ },
+ // 셀 렌더링
+ cell: ({ row, getValue }) => {
+ // 1) 필드값 가져오기
+ const val = getValue()
+
+ 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 === "rfqVendorStatus") {
+ const statusVal = row.original.rfqVendorStatus
+ 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>
+ )
+ }
+
+ // 예) TBE Updated (날짜)
+ if (cfg.id === "cbeUpdated") {
+ 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) 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.cbeId ?? 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>
+ )
+ },
+ enableSorting: false,
+ maxSize:80
+}
+
+
+
+
+// ----------------------------------------------------------------
+// 5) 최종 컬럼 배열 - Update to include the files column
+// ----------------------------------------------------------------
+return [
+ selectColumn,
+ ...nestedColumns,
+ commentsColumn,
+ // actionsColumn,
+]
+
+} \ No newline at end of file
diff --git a/lib/rfqs/cbe-table/cbe-table.tsx b/lib/rfqs/cbe-table/cbe-table.tsx
new file mode 100644
index 00000000..243b91ed
--- /dev/null
+++ b/lib/rfqs/cbe-table/cbe-table.tsx
@@ -0,0 +1,161 @@
+"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 { 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"
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getCBE>>,
+ ]
+ >
+ rfqId: number
+}
+
+
+export function CbeTable({ promises, rfqId }: VendorsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+
+ console.log(data, "data")
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null)
+
+ // **router** 획득
+ const router = useRouter()
+
+ 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)
+
+ // Add handleRefresh function
+ const handleRefresh = React.useCallback(() => {
+ router.refresh();
+ }, [router]);
+
+ 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);
+ }
+ }, [rowAction])
+
+ async function openCommentSheet(vendorId: number) {
+ setInitialComments([])
+
+ 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,
+ }
+ })
+ )
+ // 3) state에 저장 -> CommentSheet에서 initialComments로 사용
+ setInitialComments(commentWithAttachments)
+ }
+
+ setSelectedRfqIdForComments(vendorId)
+ setCommentSheetOpen(true)
+ }
+
+ const openFilesDialog = (cbeId: number, vendorId: number) => {
+ setSelectedTbeId(cbeId)
+ setSelectedVendorId(vendorId)
+ setIsFileDialogOpen(true)
+ }
+
+
+ // getColumns() 호출 시, router를 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog }),
+ [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: "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" },
+ ]
+
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "rfqVendorUpdated", 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}
+ >
+ {/* <VendorsTableToolbarActions table={table} rfqId={rfqId} /> */}
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/cbe-table/feature-flags-provider.tsx b/lib/rfqs/cbe-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/rfqs/cbe-table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/rfqs/repository.ts b/lib/rfqs/repository.ts
new file mode 100644
index 00000000..ad44cf07
--- /dev/null
+++ b/lib/rfqs/repository.ts
@@ -0,0 +1,232 @@
+// 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 { users } from "@/db/schema/users";
+import {
+ eq,
+ inArray,
+ not,
+ asc,
+ desc,
+ and,
+ ilike,
+ gte,
+ lte,
+ count,
+ gt, sql
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+import { RfqType } from "./validations";
+export type NewRfq = typeof rfqs.$inferInsert
+export type NewRfqItem = typeof rfqItems.$inferInsert
+
+/**
+ * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시
+ * - 트랜잭션(tx)을 받아서 사용하도록 구현
+ */
+export async function selectRfqs(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ }
+) {
+ const { where, orderBy, offset = 0, limit = 10 } = params;
+
+ return tx
+ .select({
+ rfqId: rfqsView.id,
+ id: rfqsView.id,
+ rfqCode: rfqsView.rfqCode,
+ description: rfqsView.description,
+ projectCode: rfqsView.projectCode,
+ projectName: rfqsView.projectName,
+ dueDate: rfqsView.dueDate,
+ status: rfqsView.status,
+ // createdBy → user 이메일
+ createdBy: rfqsView.createdBy, // still the numeric user ID
+ createdByEmail: rfqsView.userEmail, // string
+
+ createdAt: rfqsView.createdAt,
+ updatedAt: rfqsView.updatedAt,
+ // ====================
+ // 1) itemCount via subselect
+ // ====================
+ itemCount:rfqsView.itemCount,
+ attachCount: rfqsView.attachmentCount,
+
+ // user info
+ // userId: users.id,
+ userEmail: rfqsView.userEmail,
+ userName: rfqsView.userName,
+ })
+ .from(rfqsView)
+ .where(where ?? undefined)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+/** 총 개수 count */
+export async function countRfqs(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(rfqsView).where(where);
+ return res[0]?.count ?? 0;
+}
+
+/** 단건 Insert 예시 */
+export async function insertRfq(
+ tx: PgTransaction<any, any, any>,
+ data: NewRfq // DB와 동일한 insert 가능한 타입
+) {
+ // returning() 사용 시 배열로 돌아오므로 [0]만 리턴
+ return tx
+ .insert(rfqs)
+ .values(data)
+ .returning({ id: rfqs.id, createdAt: rfqs.createdAt });
+}
+
+/** 복수 Insert 예시 */
+export async function insertRfqs(
+ tx: PgTransaction<any, any, any>,
+ data: Rfq[]
+) {
+ return tx.insert(rfqs).values(data).onConflictDoNothing();
+}
+
+/** 단건 삭제 */
+export async function deleteRfqById(
+ tx: PgTransaction<any, any, any>,
+ rfqId: number
+) {
+ return tx.delete(rfqs).where(eq(rfqs.id, rfqId));
+}
+
+/** 복수 삭제 */
+export async function deleteRfqsByIds(
+ tx: PgTransaction<any, any, any>,
+ ids: number[]
+) {
+ return tx.delete(rfqs).where(inArray(rfqs.id, ids));
+}
+
+/** 전체 삭제 */
+export async function deleteAllRfqs(
+ tx: PgTransaction<any, any, any>,
+) {
+ return tx.delete(rfqs);
+}
+
+/** 단건 업데이트 */
+export async function updateRfq(
+ tx: PgTransaction<any, any, any>,
+ rfqId: number,
+ data: Partial<Rfq>
+) {
+ return tx
+ .update(rfqs)
+ .set(data)
+ .where(eq(rfqs.id, rfqId))
+ .returning({ status: rfqs.status });
+}
+
+// /** 복수 업데이트 */
+export async function updateRfqs(
+ tx: PgTransaction<any, any, any>,
+ ids: number[],
+ data: Partial<Rfq>
+) {
+ return tx
+ .update(rfqs)
+ .set(data)
+ .where(inArray(rfqs.id, ids))
+ .returning({ status: rfqs.status, dueDate: rfqs.dueDate });
+}
+
+
+// 모든 task 조회
+export const getAllRfqs = async (): Promise<Rfq[]> => {
+ const datas = await db.select().from(rfqs).execute();
+ return datas
+};
+
+
+export async function groupByStatus(
+ tx: PgTransaction<any, any, any>,
+ rfqType: RfqType = RfqType.PURCHASE
+) {
+ return tx
+ .select({
+ status: rfqs.status,
+ count: count(),
+ })
+ .from(rfqs)
+ .where(eq(rfqs.rfqType, rfqType)) // rfqType으로 필터링 추가
+ .groupBy(rfqs.status)
+ .having(gt(count(), 0));
+}
+
+export async function insertRfqItem(
+ tx: PgTransaction<any, any, any>,
+ data: NewRfqItem
+) {
+ return tx.insert(rfqItems).values(data).returning();
+}
+
+export const getRfqById = async (id: number): Promise<RfqWithItems | null> => {
+ // 1) RFQ 단건 조회
+ const rfqsRes = await db
+ .select()
+ .from(rfqs)
+ .where(eq(rfqs.id, id))
+ .limit(1);
+
+ if (rfqsRes.length === 0) return null;
+ const rfqRow = rfqsRes[0];
+
+ // 2) 해당 RFQ 아이템 목록 조회
+ const itemsRes = await db
+ .select()
+ .from(rfqItems)
+ .where(eq(rfqItems.rfqId, id));
+
+ // itemsRes: RfqItem[]
+
+ // 3) RfqWithItems 형태로 반환
+ const result: RfqWithItems = {
+ ...rfqRow,
+ lines: itemsRes,
+ };
+
+ return result;
+};
+
+/** 단건 업데이트 */
+export async function updateRfqVendor(
+ tx: PgTransaction<any, any, any>,
+ rfqVendorId: number,
+ data: Partial<VendorResponse>
+) {
+ return tx
+ .update(vendorResponses)
+ .set(data)
+ .where(eq(vendorResponses.id, rfqVendorId))
+ .returning({ status: vendorResponses.responseStatus });
+}
+
+/** 복수 업데이트 */
+export async function updateRfqVendors(
+ tx: PgTransaction<any, any, any>,
+ ids: number[],
+ data: Partial<VendorResponse>
+) {
+ return tx
+ .update(vendorResponses)
+ .set(data)
+ .where(inArray(vendorResponses.id, ids))
+ .returning({ status: vendorResponses.responseStatus });
+}
diff --git a/lib/rfqs/service.ts b/lib/rfqs/service.ts
new file mode 100644
index 00000000..b1e02cd0
--- /dev/null
+++ b/lib/rfqs/service.ts
@@ -0,0 +1,2783 @@
+// src/lib/tasks/service.ts
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { revalidatePath, revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+
+import { filterColumns } from "@/lib/filter-columns";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { getErrorMessage } from "@/lib/handle-error";
+
+import { GetRfqsSchema, CreateRfqSchema, UpdateRfqSchema, CreateRfqItemSchema, GetMatchedVendorsSchema, GetRfqsForVendorsSchema, UpdateRfqVendorSchema, GetTBESchema, RfqType, GetCBESchema } 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";
+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 { 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 { sendEmail } from "../mail/sendEmail";
+import { projects } from "@/db/schema/projects";
+import { items } from "@/db/schema/items";
+import * as z from "zod"
+
+
+interface InviteVendorsInput {
+ rfqId: number
+ vendorIds: number[]
+ rfqType: RfqType
+}
+
+/* -----------------------------------------------------
+ 1) 조회 관련
+----------------------------------------------------- */
+
+/**
+ * 복잡한 조건으로 Rfq 목록을 조회 (+ pagination) 하고,
+ * 총 개수에 따라 pageCount를 계산해서 리턴.
+ * Next.js의 unstable_cache를 사용해 일정 시간 캐시.
+ */
+export async function getRfqs(input: GetRfqsSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+ // const advancedTable = input.flags.includes("advancedTable");
+ const advancedTable = true;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: rfqsView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(rfqsView.rfqCode, s), ilike(rfqsView.projectCode, s)
+ , ilike(rfqsView.projectName, s), ilike(rfqsView.dueDate, s), ilike(rfqsView.status, s)
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ let rfqTypeWhere;
+ if (input.rfqType) {
+ rfqTypeWhere = eq(rfqsView.rfqType, input.rfqType);
+ }
+
+ let whereConditions = [];
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+ if (rfqTypeWhere) whereConditions.push(rfqTypeWhere);
+
+ // 조건이 있을 때만 and() 사용
+ const finalWhere = whereConditions.length > 0
+ ? and(...whereConditions)
+ : undefined;
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(rfqsView[item.id]) : asc(rfqsView[item.id])
+ )
+ : [asc(rfqsView.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectRfqs(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countRfqs(tx, finalWhere);
+ return { data, total };
+ });
+
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+
+ return { data, pageCount };
+ } catch (err) {
+ console.error("getRfqs 에러:", err); // 자세한 에러 로깅
+
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 3600,
+ tags: [`rfqs-${input.rfqType}`],
+ }
+ )();
+}
+
+/** Status별 개수 */
+export async function getRfqStatusCounts(rfqType: RfqType = RfqType.PURCHASE) {
+ return unstable_cache(
+ async () => {
+ try {
+ const initial: Record<Rfq["status"], number> = {
+ DRAFT: 0,
+ PUBLISHED: 0,
+ EVALUATION: 0,
+ AWARDED: 0,
+ };
+
+ const result = await db.transaction(async (tx) => {
+ // rfqType을 기준으로 필터링 추가
+ const rows = await groupByStatus(tx, rfqType);
+ return rows.reduce<Record<Rfq["status"], number>>((acc, { status, count }) => {
+ acc[status] = count;
+ return acc;
+ }, initial);
+ });
+
+ return result;
+ } catch (err) {
+ return {} as Record<Rfq["status"], number>;
+ }
+ },
+ [`rfq-status-counts-${rfqType}`], // 캐싱 키에 rfqType 추가
+ {
+ revalidate: 3600,
+ }
+ )();
+}
+
+
+
+/* -----------------------------------------------------
+ 2) 생성(Create)
+----------------------------------------------------- */
+
+/**
+ * Rfq 생성 후, (가장 오래된 Rfq 1개) 삭제로
+ * 전체 Rfq 개수를 고정
+ */
+export async function createRfq(input: CreateRfqSchema) {
+
+ console.log(input.createdBy, "input.createdBy")
+
+ unstable_noStore(); // Next.js 서버 액션 캐싱 방지
+ try {
+ await db.transaction(async (tx) => {
+ // 새 Rfq 생성
+ const [newTask] = await insertRfq(tx, {
+ rfqCode: input.rfqCode,
+ projectId: input.projectId || null,
+ description: input.description || null,
+ dueDate: input.dueDate,
+ status: input.status,
+ rfqType: input.rfqType, // rfqType 추가
+ createdBy: input.createdBy,
+ });
+ return newTask;
+ });
+
+ // 캐시 무효화
+ revalidateTag(`rfqs-${input.rfqType}`);
+ revalidateTag(`rfq-status-counts-${input.rfqType}`);
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/* -----------------------------------------------------
+ 3) 업데이트
+----------------------------------------------------- */
+
+/** 단건 업데이트 */
+export async function modifyRfq(input: UpdateRfqSchema & { id: number }) {
+ unstable_noStore();
+ try {
+ const data = await db.transaction(async (tx) => {
+ const [res] = await updateRfq(tx, input.id, {
+ rfqCode: input.rfqCode,
+ projectId: input.projectId || null,
+ dueDate: input.dueDate,
+ status: input.status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED",
+ createdBy: input.createdBy,
+ });
+ return res;
+ });
+
+ revalidateTag("rfqs");
+ if (data.status === input.status) {
+ revalidateTag("rfqs-status-counts");
+ }
+
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+export async function modifyRfqs(input: {
+ ids: number[];
+ status?: Rfq["status"];
+ dueDate?: Date
+}) {
+ unstable_noStore();
+ try {
+ const data = await db.transaction(async (tx) => {
+ const [res] = await updateRfqs(tx, input.ids, {
+ status: input.status,
+ dueDate: input.dueDate,
+ });
+ return res;
+ });
+
+ revalidateTag("rfqs");
+ if (data.status === input.status) {
+ revalidateTag("rfq-status-counts");
+ }
+
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+
+/* -----------------------------------------------------
+ 4) 삭제
+----------------------------------------------------- */
+
+/** 단건 삭제 */
+export async function removeRfq(input: { id: number }) {
+ unstable_noStore();
+ try {
+ await db.transaction(async (tx) => {
+ // 삭제
+ await deleteRfqById(tx, input.id);
+ // 바로 새 Rfq 생성
+ });
+
+ revalidateTag("rfqs");
+ revalidateTag("rfq-status-counts");
+
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/** 복수 삭제 */
+export async function removeRfqs(input: { ids: number[] }) {
+ unstable_noStore();
+ try {
+ await db.transaction(async (tx) => {
+ // 삭제
+ await deleteRfqsByIds(tx, input.ids);
+ });
+
+ revalidateTag("rfqs");
+ revalidateTag("rfq-status-counts");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+// 삭제를 위한 입력 스키마
+const deleteRfqItemSchema = z.object({
+ id: z.number().int(),
+ rfqId: z.number().int(),
+ rfqType: z.nativeEnum(RfqType).default(RfqType.PURCHASE),
+});
+
+type DeleteRfqItemSchema = z.infer<typeof deleteRfqItemSchema>;
+
+/**
+ * RFQ 아이템 삭제 함수
+ */
+export async function deleteRfqItem(input: DeleteRfqItemSchema) {
+ unstable_noStore(); // Next.js 서버 액션 캐싱 방지
+
+ try {
+ // 삭제 작업 수행
+ await db
+ .delete(rfqItems)
+ .where(
+ and(
+ eq(rfqItems.id, input.id),
+ eq(rfqItems.rfqId, input.rfqId)
+ )
+ );
+
+ console.log(`Deleted RFQ item: ${input.id} for RFQ ${input.rfqId}`);
+
+ // 캐시 무효화
+ revalidateTag("rfq-items");
+ revalidateTag(`rfqs-${input.rfqType}`);
+ revalidateTag(`rfq-${input.rfqId}`);
+
+ return { data: null, error: null };
+ } catch (err) {
+ console.error("Error in deleteRfqItem:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+// createRfqItem 함수 수정 (id 파라미터 추가)
+export async function createRfqItem(input: CreateRfqItemSchema & { id?: number }) {
+ unstable_noStore();
+
+ try {
+ // DB 트랜잭션
+ await db.transaction(async (tx) => {
+ // id가 전달되었으면 해당 id로 업데이트, 그렇지 않으면 기존 로직대로 진행
+ if (input.id) {
+ // 기존 아이템 업데이트
+ await tx
+ .update(rfqItems)
+ .set({
+ description: input.description ?? null,
+ quantity: input.quantity ?? 1,
+ uom: input.uom ?? "",
+ updatedAt: new Date(),
+ })
+ .where(eq(rfqItems.id, input.id));
+
+ console.log(`Updated RFQ item with id: ${input.id}`);
+ } else {
+ // 기존 로직: 같은 itemCode로 이미 존재하는지 확인 후 업데이트/생성
+ const existingItems = await tx
+ .select()
+ .from(rfqItems)
+ .where(
+ and(
+ eq(rfqItems.rfqId, input.rfqId),
+ eq(rfqItems.itemCode, input.itemCode)
+ )
+ );
+
+ if (existingItems.length > 0) {
+ // 이미 존재하는 경우 업데이트
+ const existingItem = existingItems[0];
+ await tx
+ .update(rfqItems)
+ .set({
+ description: input.description ?? null,
+ quantity: input.quantity ?? 1,
+ uom: input.uom ?? "",
+ updatedAt: new Date(),
+ })
+ .where(eq(rfqItems.id, existingItem.id));
+
+ console.log(`Updated existing RFQ item: ${existingItem.id} for RFQ ${input.rfqId}, Item ${input.itemCode}`);
+ } else {
+ // 존재하지 않는 경우 새로 생성
+ const [newItem] = await insertRfqItem(tx, {
+ rfqId: input.rfqId,
+ itemCode: input.itemCode,
+ description: input.description ?? null,
+ quantity: input.quantity ?? 1,
+ uom: input.uom ?? "",
+ });
+
+ console.log(`Created new RFQ item for RFQ ${input.rfqId}, Item ${input.itemCode}`);
+ }
+ }
+ });
+
+ // 캐시 무효화
+ revalidateTag("rfq-items");
+ revalidateTag(`rfqs-${input.rfqType}`);
+ revalidateTag(`rfq-${input.rfqId}`);
+
+ return { data: null, error: null };
+ } catch (err) {
+ console.error("Error in createRfqItem:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+/**
+ * 서버 액션: 파일 첨부/삭제 처리
+ * @param rfqId RFQ ID
+ * @param removedExistingIds 기존 첨부 중 삭제된 record ID 배열
+ * @param newFiles 새로 업로드된 파일 (File[]) - Next.js server action에서
+ * @param vendorId (optional) 업로더가 vendor인지 구분
+ */
+export async function processRfqAttachments(args: {
+ rfqId: number;
+ removedExistingIds?: number[];
+ newFiles?: File[];
+ vendorId?: number | null;
+ rfqType?: RfqType | null;
+}) {
+ const { rfqId, removedExistingIds = [], newFiles = [], vendorId = null } = args;
+
+ try {
+ // 1) 삭제된 기존 첨부: DB + 파일시스템에서 제거
+ if (removedExistingIds.length > 0) {
+ // 1-1) DB에서 filePath 조회
+ const rows = await db
+ .select({
+ id: rfqAttachments.id,
+ filePath: rfqAttachments.filePath
+ })
+ .from(rfqAttachments)
+ .where(inArray(rfqAttachments.id, removedExistingIds));
+
+ // 1-2) DB 삭제
+ await db
+ .delete(rfqAttachments)
+ .where(inArray(rfqAttachments.id, removedExistingIds));
+
+ // 1-3) 파일 삭제
+ for (const row of rows) {
+ // filePath: 예) "/rfq/123/...xyz"
+ const absolutePath = path.join(
+ process.cwd(),
+ "public",
+ row.filePath.replace(/^\/+/, "") // 슬래시 제거
+ );
+ try {
+ await fs.unlink(absolutePath);
+ } catch (err) {
+ console.error("File remove error:", err);
+ }
+ }
+ }
+
+ // 2) 새 파일 업로드
+ if (newFiles.length > 0) {
+ const rfqDir = path.join("public", "rfq", String(rfqId));
+ // 폴더 없으면 생성
+ await fs.mkdir(rfqDir, { recursive: true });
+
+ for (const file of newFiles) {
+ // 2-1) File -> Buffer
+ const ab = await file.arrayBuffer();
+ const buffer = Buffer.from(ab);
+
+ // 2-2) 고유 파일명
+ const uniqueName = `${randomUUID()}-${file.name}`;
+ // 예) "rfq/123/xxx"
+ const relativePath = path.join("rfq", String(rfqId), uniqueName);
+ const absolutePath = path.join("public", relativePath);
+
+ // 2-3) 파일 저장
+ await fs.writeFile(absolutePath, buffer);
+
+ // 2-4) DB Insert
+ await db.insert(rfqAttachments).values({
+ rfqId,
+ vendorId,
+ fileName: file.name,
+ filePath: "/" + relativePath.replace(/\\/g, "/"),
+ // (Windows 경로 대비)
+ });
+ }
+ }
+
+ const [countRow] = await db
+ .select({ cnt: sql<number>`count(*)`.as("cnt") })
+ .from(rfqAttachments)
+ .where(eq(rfqAttachments.rfqId, rfqId));
+
+ const newCount = countRow?.cnt ?? 0;
+
+ // 3) revalidateTag 등 캐시 무효화
+ revalidateTag("rfq-attachments");
+ revalidateTag(`rfqs-${args.rfqType}`)
+
+ return { ok: true, updatedItemCount: newCount };
+ } catch (error) {
+ console.error("processRfqAttachments error:", error);
+ return { ok: false, error: String(error) };
+ }
+}
+
+
+
+export async function fetchRfqAttachments(rfqId: number) {
+ // DB select
+ const rows = await db
+ .select()
+ .from(rfqAttachments)
+ .where(eq(rfqAttachments.rfqId, rfqId))
+
+ // rows: { id, fileName, filePath, createdAt, vendorId, ... }
+ // 필요 없는 필드는 omit하거나 transform 가능
+ return rows.map((row) => ({
+ id: row.id,
+ fileName: row.fileName,
+ filePath: row.filePath,
+ createdAt: row.createdAt, // or string
+ vendorId: row.vendorId,
+ size: undefined, // size를 DB에 저장하지 않았다면
+ }))
+}
+
+export async function fetchRfqItems(rfqId: number) {
+ // DB select
+ const rows = await db
+ .select()
+ .from(rfqItems)
+ .where(eq(rfqItems.rfqId, rfqId))
+
+ // rows: { id, fileName, filePath, createdAt, vendorId, ... }
+ // 필요 없는 필드는 omit하거나 transform 가능
+ return rows.map((row) => ({
+ // id: row.id,
+ itemCode: row.itemCode,
+ description: row.description,
+ quantity: row.quantity,
+ uom: row.uom,
+ }))
+}
+
+export const findRfqById = async (id: number): Promise<RfqWithItems | null> => {
+ try {
+ logger.info({ id }, 'Fetching user by ID');
+ const rfq = await getRfqById(id);
+ if (!rfq) {
+ logger.warn({ id }, 'User not found');
+ } else {
+ logger.debug({ rfq }, 'User fetched successfully');
+ }
+ return rfq;
+ } catch (error) {
+ logger.error({ error }, 'Error fetching user by ID');
+ throw new Error('Failed to fetch user');
+ }
+};
+
+export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: number) {
+ return unstable_cache(
+ async () => {
+ // ─────────────────────────────────────────────────────
+ // 1) rfq_items에서 distinct itemCode
+ // ─────────────────────────────────────────────────────
+ const itemRows = await db
+ .select({ code: rfqItems.itemCode })
+ .from(rfqItems)
+ .where(eq(rfqItems.rfqId, rfqId))
+ .groupBy(rfqItems.itemCode)
+
+ const itemCodes = itemRows.map((r) => r.code)
+ const itemCount = itemCodes.length
+ if (itemCount === 0) {
+ return { data: [], pageCount: 0 }
+ }
+
+ // ─────────────────────────────────────────────────────
+ // 2) vendorPossibleItems에서 모든 itemCodes를 보유한 vendor
+ // ─────────────────────────────────────────────────────
+ const inList = itemCodes.map((c) => `'${c}'`).join(",")
+ const sqlVendorIds = await db.execute(
+ sql`
+ SELECT vpi.vendor_id AS "vendorId"
+ FROM ${vendorPossibleItems} vpi
+ WHERE vpi.item_code IN (${sql.raw(inList)})
+ GROUP BY vpi.vendor_id
+ HAVING COUNT(DISTINCT vpi.item_code) = ${itemCount}
+ `
+ )
+ const vendorIdList = sqlVendorIds.rows.map((row: any) => +row.vendorId)
+ if (vendorIdList.length === 0) {
+ return { data: [], pageCount: 0 }
+ }
+
+ console.log(vendorIdList,"vendorIdList")
+
+ // ─────────────────────────────────────────────────────
+ // 3) 필터/검색/정렬
+ // ─────────────────────────────────────────────────────
+ const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10)
+ const limit = input.perPage ?? 10
+
+ // (가) 커스텀 필터
+ // 여기서는 "뷰(vendorRfqView)"의 컬럼들에 대해 필터합니다.
+ const advancedWhere = filterColumns({
+ // 테이블이 아니라 "뷰"를 넘길 수도 있고,
+ // 혹은 columns 객체(연결된 모든 컬럼)로 넘겨도 됩니다.
+ table: vendorRfqView,
+ filters: input.filters ?? [],
+ joinOperator: input.joinOperator ?? "and",
+ })
+
+ // (나) 글로벌 검색
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ sql`${vendorRfqView.vendorName} ILIKE ${s}`,
+ sql`${vendorRfqView.vendorCode} ILIKE ${s}`,
+ sql`${vendorRfqView.email} ILIKE ${s}`
+ )
+ }
+
+ // (다) 최종 where
+ // vendorId가 vendorIdList 내에 있어야 하고,
+ // 특정 rfqId(뷰에 담긴 값)도 일치해야 함.
+ const finalWhere = and(
+ inArray(vendorRfqView.vendorId, vendorIdList),
+ eq(vendorRfqView.rfqId, rfqId),
+ advancedWhere,
+ globalWhere
+ )
+
+ // (라) 정렬
+ const orderBy = input.sort?.length
+ ? input.sort.map((s) => {
+ // "column id" -> vendorRfqView.* 중 하나
+ const col = (vendorRfqView as any)[s.id]
+ return s.desc ? desc(col) : asc(col)
+ })
+ : [asc(vendorRfqView.vendorId)]
+
+ // ─────────────────────────────────────────────────────
+ // 4) View에서 데이터 SELECT
+ // ─────────────────────────────────────────────────────
+ const [rows, total] = await db.transaction(async (tx) => {
+ const data = await tx
+ .select({
+ id: vendorRfqView.vendorId,
+ vendorID: vendorRfqView.vendorId,
+ vendorName: vendorRfqView.vendorName,
+ vendorCode: vendorRfqView.vendorCode,
+ address: vendorRfqView.address,
+ country: vendorRfqView.country,
+ email: vendorRfqView.email,
+ website: vendorRfqView.website,
+ vendorStatus: vendorRfqView.vendorStatus,
+ // rfqVendorStatus와 rfqVendorUpdated는 나중에 정확한 데이터로 교체할 예정
+ rfqVendorStatus: vendorRfqView.rfqVendorStatus,
+ rfqVendorUpdated: vendorRfqView.rfqVendorUpdated,
+ })
+ .from(vendorRfqView)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(limit)
+
+ // 총 개수
+ const [{ count }] = await tx
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(vendorRfqView)
+ .where(finalWhere)
+
+ return [data, Number(count)]
+ })
+
+
+ console.log(rows)
+ console.log(total)
+ // ─────────────────────────────────────────────────────
+ // 4-1) 정확한 rfqVendorStatus와 rfqVendorUpdated 조회
+ // ─────────────────────────────────────────────────────
+ const distinctVendorIds = [...new Set(rows.map((r) => r.id))]
+
+ // vendorResponses 테이블에서 정확한 상태와 업데이트 시간 조회
+ const vendorStatuses = await db
+ .select({
+ vendorId: vendorResponses.vendorId,
+ status: vendorResponses.responseStatus,
+ updatedAt: vendorResponses.updatedAt
+ })
+ .from(vendorResponses)
+ .where(
+ and(
+ inArray(vendorResponses.vendorId, distinctVendorIds),
+ eq(vendorResponses.rfqId, rfqId)
+ )
+ )
+
+ // vendorId별 상태정보 맵 생성
+ const statusMap = new Map<number, { status: string, updatedAt: Date }>()
+ for (const vs of vendorStatuses) {
+ statusMap.set(vs.vendorId, {
+ status: vs.status,
+ updatedAt: vs.updatedAt
+ })
+ }
+
+ // 정확한 상태 정보로 업데이트된 rows 생성
+ const updatedRows = rows.map(row => ({
+ ...row,
+ rfqVendorStatus: statusMap.get(row.id)?.status || null,
+ rfqVendorUpdated: statusMap.get(row.id)?.updatedAt || null
+ }))
+
+ // ─────────────────────────────────────────────────────
+ // 5) 코멘트 조회: 기존과 동일
+ // ─────────────────────────────────────────────────────
+ const commAll = await db
+ .select()
+ .from(rfqComments)
+ .where(
+ and(
+ inArray(rfqComments.vendorId, distinctVendorIds),
+ eq(rfqComments.rfqId, rfqId)
+ )
+ )
+
+ const commByVendorId = new Map<number, any[]>()
+ for (const c of commAll) {
+ const vid = c.vendorId!
+ if (!commByVendorId.has(vid)) {
+ commByVendorId.set(vid, [])
+ }
+ commByVendorId.get(vid)!.push({
+ id: c.id,
+ commentText: c.commentText,
+ vendorId: c.vendorId,
+ evaluationId: c.evaluationId,
+ createdAt: c.createdAt,
+ commentedBy: c.commentedBy,
+ })
+ }
+
+ // ─────────────────────────────────────────────────────
+ // 6) rows에 comments 병합
+ // ─────────────────────────────────────────────────────
+ const final = updatedRows.map((row) => ({
+ ...row,
+ comments: commByVendorId.get(row.id) ?? [],
+ }))
+
+ // ─────────────────────────────────────────────────────
+ // 7) 반환
+ // ─────────────────────────────────────────────────────
+ const pageCount = Math.ceil(total / limit)
+ return { data: final, pageCount }
+ },
+ [JSON.stringify({ input, rfqId })],
+ { revalidate: 3600, tags: ["rfq-vendors"] }
+ )()
+}
+
+export async function inviteVendors(input: InviteVendorsInput) {
+ unstable_noStore() // 서버 액션 캐싱 방지
+ try {
+ const { rfqId, vendorIds } = input
+ if (!rfqId || !Array.isArray(vendorIds) || vendorIds.length === 0) {
+ throw new Error("Invalid input")
+ }
+
+ // DB 데이터 준비 및 첨부파일 처리를 위한 트랜잭션
+ const rfqData = await db.transaction(async (tx) => {
+ // 2-A) RFQ 기본 정보 조회
+ const [rfqRow] = await tx
+ .select({
+ rfqCode: rfqsView.rfqCode,
+ description: rfqsView.description,
+ projectCode: rfqsView.projectCode,
+ projectName: rfqsView.projectName,
+ dueDate: rfqsView.dueDate,
+ createdBy: rfqsView.createdBy,
+ })
+ .from(rfqsView)
+ .where(eq(rfqsView.id, rfqId))
+
+ if (!rfqRow) {
+ throw new Error(`RFQ #${rfqId} not found`)
+ }
+
+ // 2-B) 아이템 목록 조회
+ const items = await tx
+ .select({
+ itemCode: rfqItems.itemCode,
+ description: rfqItems.description,
+ quantity: rfqItems.quantity,
+ uom: rfqItems.uom,
+ })
+ .from(rfqItems)
+ .where(eq(rfqItems.rfqId, rfqId))
+
+ // 2-C) 첨부파일 목록 조회
+ const attachRows = await tx
+ .select({
+ id: rfqAttachments.id,
+ fileName: rfqAttachments.fileName,
+ filePath: rfqAttachments.filePath,
+ })
+ .from(rfqAttachments)
+ .where(
+ and(
+ eq(rfqAttachments.rfqId, rfqId),
+ isNull(rfqAttachments.vendorId),
+ isNull(rfqAttachments.evaluationId)
+ )
+ )
+
+ const vendorRows = await tx
+ .select({ id: vendors.id, email: vendors.email })
+ .from(vendors)
+ .where(inArray(vendors.id, vendorIds))
+
+ // NodeMailer attachments 형식 맞추기
+ const attachments = []
+ for (const att of attachRows) {
+ const absolutePath = path.join(process.cwd(), "public", att.filePath.replace(/^\/+/, ""))
+ attachments.push({
+ path: absolutePath,
+ filename: att.fileName,
+ })
+ }
+
+ return { rfqRow, items, vendorRows, attachments }
+ })
+
+ 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 emailErrors = []
+
+ // 각 벤더에 대해 처리
+ for (const v of vendorRows) {
+ if (!v.email) {
+ continue // 이메일 없는 벤더 무시
+ }
+
+ try {
+ // DB 업데이트: 각 벤더 상태 별도 트랜잭션
+ await db.transaction(async (tx) => {
+ // rfq_vendors upsert
+ const existing = await tx
+ .select()
+ .from(vendorResponses)
+ .where(and(eq(vendorResponses.rfqId, rfqId), eq(vendorResponses.vendorId, v.id)))
+
+ if (existing.length > 0) {
+ await tx
+ .update(vendorResponses)
+ .set({
+ responseStatus: "INVITED",
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorResponses.id, existing[0].id))
+ } else {
+ await tx.insert(vendorResponses).values({
+ rfqId,
+ vendorId: v.id,
+ responseStatus: "INVITED",
+ })
+ }
+ })
+
+ // 이메일 발송 (트랜잭션 외부)
+ await sendEmail({
+ to: v.email,
+ subject: `[RFQ ${rfqRow.rfqCode}] You are invited from Samgsung Heavy Industries!`,
+ 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,
+ })
+ } catch (err) {
+ // 개별 벤더 처리 실패 로깅
+ console.error(`Failed to process vendor ${v.id}: ${getErrorMessage(err)}`)
+ emailErrors.push({ vendorId: v.id, error: getErrorMessage(err) })
+ // 계속 진행 (다른 벤더 처리)
+ }
+ }
+
+ // 최종적으로 RFQ 상태 업데이트 (별도 트랜잭션)
+ try {
+ await db.transaction(async (tx) => {
+ await tx
+ .update(rfqs)
+ .set({
+ status: "PUBLISHED",
+ updatedAt: new Date(),
+ })
+ .where(eq(rfqs.id, rfqId))
+
+ console.log(`Updated RFQ #${rfqId} status to PUBLISHED`)
+ })
+
+ // 캐시 무효화
+ revalidateTag("rfq-vendors")
+ revalidateTag("cbe-vendors")
+ revalidateTag("rfqs")
+ revalidateTag(`rfqs-${input.rfqType}`)
+ revalidateTag(`rfq-${rfqId}`)
+
+ // 이메일 오류가 있었는지 확인
+ if (emailErrors.length > 0) {
+ return {
+ error: `일부 벤더에게 이메일 발송 실패 (${emailErrors.length}/${vendorRows.length}), RFQ 상태는 업데이트됨`,
+ emailErrors
+ }
+ }
+
+ return { error: null }
+ } catch (err) {
+ return { error: `RFQ 상태 업데이트 실패: ${getErrorMessage(err)}` }
+ }
+ } catch (err) {
+ return { error: getErrorMessage(err) }
+ }
+}
+
+
+/**
+ * TBE용 평가 데이터 목록 조회
+ */
+export async function getTBE(input: GetTBESchema, rfqId: number) {
+ return unstable_cache(
+ async () => {
+ // 1) 페이징
+ const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10)
+ const limit = input.perPage ?? 10
+
+ // 2) 고급 필터
+ const advancedWhere = filterColumns({
+ table: vendorTbeView,
+ filters: input.filters ?? [],
+ joinOperator: input.joinOperator ?? "and",
+ })
+
+ // 3) 글로벌 검색
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ sql`${vendorTbeView.vendorName} ILIKE ${s}`,
+ sql`${vendorTbeView.vendorCode} ILIKE ${s}`,
+ sql`${vendorTbeView.email} ILIKE ${s}`
+ )
+ }
+
+ // 4) REJECTED 아니거나 NULL
+ const notRejected = or(
+ ne(vendorTbeView.rfqVendorStatus, "REJECTED"),
+ isNull(vendorTbeView.rfqVendorStatus)
+ )
+
+ // 5) finalWhere
+ const finalWhere = and(
+ eq(vendorTbeView.rfqId, rfqId),
+ notRejected,
+ advancedWhere,
+ globalWhere
+ )
+
+ // 6) 정렬
+ const orderBy = input.sort?.length
+ ? input.sort.map((s) => {
+ const col = (vendorTbeView as any)[s.id]
+ return s.desc ? desc(col) : asc(col)
+ })
+ : [asc(vendorTbeView.vendorId)]
+
+ // 7) 메인 SELECT
+ const [rows, total] = await db.transaction(async (tx) => {
+ const data = await tx
+ .select({
+ // 원하는 컬럼들
+ id: vendorTbeView.vendorId,
+ tbeId: vendorTbeView.tbeId,
+ vendorId: vendorTbeView.vendorId,
+ vendorName: vendorTbeView.vendorName,
+ vendorCode: vendorTbeView.vendorCode,
+ address: vendorTbeView.address,
+ country: vendorTbeView.country,
+ email: vendorTbeView.email,
+ website: vendorTbeView.website,
+ vendorStatus: vendorTbeView.vendorStatus,
+
+ rfqId: vendorTbeView.rfqId,
+ rfqCode: vendorTbeView.rfqCode,
+ projectCode: vendorTbeView.projectCode,
+ projectName: vendorTbeView.projectName,
+ description: vendorTbeView.description,
+ dueDate: vendorTbeView.dueDate,
+
+ rfqVendorStatus: vendorTbeView.rfqVendorStatus,
+ rfqVendorUpdated: vendorTbeView.rfqVendorUpdated,
+
+ tbeResult: vendorTbeView.tbeResult,
+ tbeNote: vendorTbeView.tbeNote,
+ tbeUpdated: vendorTbeView.tbeUpdated,
+ })
+ .from(vendorTbeView)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(limit)
+
+ const [{ count }] = await tx
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(vendorTbeView)
+ .where(finalWhere)
+
+ return [data, Number(count)]
+ })
+
+ if (!rows.length) {
+ return { data: [], pageCount: 0 }
+ }
+
+ // 8) Comments 조회
+ const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))]
+
+ const commAll = await db
+ .select({
+ id: rfqComments.id,
+ commentText: rfqComments.commentText,
+ vendorId: rfqComments.vendorId,
+ evaluationId: rfqComments.evaluationId,
+ createdAt: rfqComments.createdAt,
+ commentedBy: rfqComments.commentedBy,
+ evalType: rfqEvaluations.evalType,
+ })
+ .from(rfqComments)
+ .innerJoin(
+ rfqEvaluations,
+ and(
+ eq(rfqEvaluations.id, rfqComments.evaluationId),
+ eq(rfqEvaluations.evalType, "TBE")
+ )
+ )
+ .where(
+ and(
+ isNotNull(rfqComments.evaluationId),
+ eq(rfqComments.rfqId, rfqId),
+ inArray(rfqComments.vendorId, distinctVendorIds)
+ )
+ )
+
+ // 8-A) vendorId -> comments grouping
+ const commByVendorId = new Map<number, any[]>()
+ for (const c of commAll) {
+ const vid = c.vendorId!
+ if (!commByVendorId.has(vid)) {
+ commByVendorId.set(vid, [])
+ }
+ commByVendorId.get(vid)!.push({
+ id: c.id,
+ commentText: c.commentText,
+ vendorId: c.vendorId,
+ evaluationId: c.evaluationId,
+ createdAt: c.createdAt,
+ commentedBy: c.commentedBy,
+ })
+ }
+
+ // 9) TBE 파일 조회 - vendorResponseAttachments로 대체
+ // Step 1: Get vendorResponses for the rfqId and vendorIds
+ const responsesAll = await db
+ .select({
+ id: vendorResponses.id,
+ vendorId: vendorResponses.vendorId
+ })
+ .from(vendorResponses)
+ .where(
+ and(
+ eq(vendorResponses.rfqId, rfqId),
+ inArray(vendorResponses.vendorId, distinctVendorIds)
+ )
+ );
+
+ // Group responses by vendorId for later lookup
+ const responsesByVendorId = new Map<number, number[]>();
+ for (const resp of responsesAll) {
+ if (!responsesByVendorId.has(resp.vendorId)) {
+ responsesByVendorId.set(resp.vendorId, []);
+ }
+ responsesByVendorId.get(resp.vendorId)!.push(resp.id);
+ }
+
+ // Step 2: Get all responseIds
+ const allResponseIds = responsesAll.map(r => r.id);
+
+ // Step 3: Get technicalResponses for these responseIds
+ const technicalResponsesAll = await db
+ .select({
+ id: vendorTechnicalResponses.id,
+ responseId: vendorTechnicalResponses.responseId
+ })
+ .from(vendorTechnicalResponses)
+ .where(inArray(vendorTechnicalResponses.responseId, allResponseIds));
+
+ // Create mapping from responseId to technicalResponseIds
+ const technicalResponseIdsByResponseId = new Map<number, number[]>();
+ for (const tr of technicalResponsesAll) {
+ if (!technicalResponseIdsByResponseId.has(tr.responseId)) {
+ technicalResponseIdsByResponseId.set(tr.responseId, []);
+ }
+ technicalResponseIdsByResponseId.get(tr.responseId)!.push(tr.id);
+ }
+
+ // Step 4: Get all technicalResponseIds
+ const allTechnicalResponseIds = technicalResponsesAll.map(tr => tr.id);
+
+ // Step 5: Get attachments for these technicalResponseIds
+ const filesAll = await db
+ .select({
+ id: vendorResponseAttachments.id,
+ fileName: vendorResponseAttachments.fileName,
+ filePath: vendorResponseAttachments.filePath,
+ technicalResponseId: vendorResponseAttachments.technicalResponseId,
+ fileType: vendorResponseAttachments.fileType,
+ attachmentType: vendorResponseAttachments.attachmentType,
+ description: vendorResponseAttachments.description,
+ uploadedAt: vendorResponseAttachments.uploadedAt,
+ uploadedBy: vendorResponseAttachments.uploadedBy
+ })
+ .from(vendorResponseAttachments)
+ .where(
+ and(
+ inArray(vendorResponseAttachments.technicalResponseId, allTechnicalResponseIds),
+ isNotNull(vendorResponseAttachments.technicalResponseId)
+ )
+ );
+
+ // Step 6: Create mapping from technicalResponseId to attachments
+ const filesByTechnicalResponseId = new Map<number, any[]>();
+ for (const file of filesAll) {
+ // Skip if technicalResponseId is null (should never happen due to our filter above)
+ if (file.technicalResponseId === null) continue;
+
+ if (!filesByTechnicalResponseId.has(file.technicalResponseId)) {
+ filesByTechnicalResponseId.set(file.technicalResponseId, []);
+ }
+ filesByTechnicalResponseId.get(file.technicalResponseId)!.push({
+ id: file.id,
+ fileName: file.fileName,
+ filePath: file.filePath,
+ fileType: file.fileType,
+ attachmentType: file.attachmentType,
+ description: file.description,
+ uploadedAt: file.uploadedAt,
+ uploadedBy: file.uploadedBy
+ });
+ }
+
+ // Step 7: Create the final filesByVendorId map
+ const filesByVendorId = new Map<number, any[]>();
+ for (const [vendorId, responseIds] of responsesByVendorId.entries()) {
+ filesByVendorId.set(vendorId, []);
+
+ for (const responseId of responseIds) {
+ const technicalResponseIds = technicalResponseIdsByResponseId.get(responseId) || [];
+
+ for (const technicalResponseId of technicalResponseIds) {
+ const files = filesByTechnicalResponseId.get(technicalResponseId) || [];
+ filesByVendorId.get(vendorId)!.push(...files);
+ }
+ }
+ }
+
+ // 10) 최종 합치기
+ const final = rows.map((row) => ({
+ ...row,
+ dueDate: row.dueDate ? new Date(row.dueDate) : null,
+ comments: commByVendorId.get(row.vendorId) ?? [],
+ files: filesByVendorId.get(row.vendorId) ?? [],
+ }))
+
+ const pageCount = Math.ceil(total / limit)
+ return { data: final, pageCount }
+ },
+ [JSON.stringify({ input, rfqId })],
+ {
+ revalidate: 3600,
+ tags: ["tbe-vendors"],
+ }
+ )()
+}
+
+export async function getTBEforVendor(input: GetTBESchema, 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: vendorTbeView,
+ filters: input.filters ?? [],
+ joinOperator: input.joinOperator ?? "and",
+ })
+
+ // 3) 글로벌 검색
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ sql`${vendorTbeView.vendorName} ILIKE ${s}`,
+ sql`${vendorTbeView.vendorCode} ILIKE ${s}`,
+ sql`${vendorTbeView.email} ILIKE ${s}`
+ )
+ }
+
+ // 4) REJECTED 아니거나 NULL
+ const notRejected = or(
+ ne(vendorTbeView.rfqVendorStatus, "REJECTED"),
+ isNull(vendorTbeView.rfqVendorStatus)
+ )
+
+ // 5) finalWhere
+ const finalWhere = and(
+ isNotNull(vendorTbeView.tbeId),
+ eq(vendorTbeView.vendorId, vendorId),
+
+ notRejected,
+ advancedWhere,
+ globalWhere
+ )
+
+ // 6) 정렬
+ const orderBy = input.sort?.length
+ ? input.sort.map((s) => {
+ const col = (vendorTbeView as any)[s.id]
+ return s.desc ? desc(col) : asc(col)
+ })
+ : [asc(vendorTbeView.vendorId)]
+
+ // 7) 메인 SELECT
+ const [rows, total] = await db.transaction(async (tx) => {
+ const data = await tx
+ .select({
+ // 원하는 컬럼들
+ id: vendorTbeView.vendorId,
+ tbeId: vendorTbeView.tbeId,
+ vendorId: vendorTbeView.vendorId,
+ vendorName: vendorTbeView.vendorName,
+ vendorCode: vendorTbeView.vendorCode,
+ address: vendorTbeView.address,
+ country: vendorTbeView.country,
+ email: vendorTbeView.email,
+ website: vendorTbeView.website,
+ vendorStatus: vendorTbeView.vendorStatus,
+
+ rfqId: vendorTbeView.rfqId,
+ rfqCode: vendorTbeView.rfqCode,
+ projectCode: vendorTbeView.projectCode,
+ projectName: vendorTbeView.projectName,
+ description: vendorTbeView.description,
+ dueDate: vendorTbeView.dueDate,
+
+ vendorResponseId: vendorTbeView.vendorResponseId,
+ rfqVendorStatus: vendorTbeView.rfqVendorStatus,
+ rfqVendorUpdated: vendorTbeView.rfqVendorUpdated,
+
+ tbeResult: vendorTbeView.tbeResult,
+ tbeNote: vendorTbeView.tbeNote,
+ tbeUpdated: vendorTbeView.tbeUpdated,
+ })
+ .from(vendorTbeView)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(limit)
+
+ const [{ count }] = await tx
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(vendorTbeView)
+ .where(finalWhere)
+
+ return [data, Number(count)]
+ })
+
+ if (!rows.length) {
+ return { data: [], pageCount: 0 }
+ }
+
+ // 8) Comments 조회
+ // - evaluationId != null && evalType = "TBE"
+ // - => leftJoin(rfqEvaluations) or innerJoin
+ const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))]
+ const distinctTbeIds = [...new Set(rows.map((r) => r.tbeId).filter(Boolean))]
+
+ // (A) 조인 방식
+ const commAll = await db
+ .select({
+ id: rfqComments.id,
+ commentText: rfqComments.commentText,
+ vendorId: rfqComments.vendorId,
+ evaluationId: rfqComments.evaluationId,
+ createdAt: rfqComments.createdAt,
+ commentedBy: rfqComments.commentedBy,
+ evalType: rfqEvaluations.evalType, // (optional)
+ })
+ .from(rfqComments)
+ // evalType = 'TBE'
+ .innerJoin(
+ rfqEvaluations,
+ and(
+ eq(rfqEvaluations.id, rfqComments.evaluationId),
+ eq(rfqEvaluations.evalType, "TBE") // ★ TBE만
+ )
+ )
+ .where(
+ and(
+ isNotNull(rfqComments.evaluationId),
+ inArray(rfqComments.vendorId, distinctVendorIds)
+ )
+ )
+
+ // 8-A) vendorId -> comments grouping
+ const commByVendorId = new Map<number, any[]>()
+ for (const c of commAll) {
+ const vid = c.vendorId!
+ if (!commByVendorId.has(vid)) {
+ commByVendorId.set(vid, [])
+ }
+ commByVendorId.get(vid)!.push({
+ id: c.id,
+ commentText: c.commentText,
+ vendorId: c.vendorId,
+ evaluationId: c.evaluationId,
+ createdAt: c.createdAt,
+ commentedBy: c.commentedBy,
+ })
+ }
+
+ // 9) TBE 템플릿 파일 수 조회
+ const templateFiles = await db
+ .select({
+ tbeId: rfqAttachments.evaluationId,
+ fileCount: sql<number>`count(*)`.as("file_count"),
+ })
+ .from(rfqAttachments)
+ .where(
+ and(
+ inArray(rfqAttachments.evaluationId, distinctTbeIds),
+ isNull(rfqAttachments.vendorId),
+ isNull(rfqAttachments.commentId)
+ )
+ )
+ .groupBy(rfqAttachments.evaluationId)
+
+ // tbeId -> fileCount 매핑 - null 체크 추가
+ const templateFileCountMap = new Map<number, number>()
+ for (const tf of templateFiles) {
+ if (tf.tbeId !== null) {
+ templateFileCountMap.set(tf.tbeId, Number(tf.fileCount))
+ }
+ }
+
+ // 10) TBE 응답 파일 확인 (각 tbeId + vendorId 조합에 대해)
+ const tbeResponseFiles = await db
+ .select({
+ tbeId: rfqAttachments.evaluationId,
+ vendorId: rfqAttachments.vendorId,
+ responseFileCount: sql<number>`count(*)`.as("response_file_count"),
+ })
+ .from(rfqAttachments)
+ .where(
+ and(
+ inArray(rfqAttachments.evaluationId, distinctTbeIds),
+ inArray(rfqAttachments.vendorId, distinctVendorIds),
+ isNull(rfqAttachments.commentId)
+ )
+ )
+ .groupBy(rfqAttachments.evaluationId, rfqAttachments.vendorId)
+
+ // tbeId_vendorId -> hasResponse 매핑 - null 체크 추가
+ const tbeResponseMap = new Map<string, number>()
+ for (const rf of tbeResponseFiles) {
+ if (rf.tbeId !== null && rf.vendorId !== null) {
+ const key = `${rf.tbeId}_${rf.vendorId}`
+ tbeResponseMap.set(key, Number(rf.responseFileCount))
+ }
+ }
+
+ // 11) 최종 합치기
+ const final = rows.map((row) => {
+ const tbeId = row.tbeId
+ const vendorId = row.vendorId
+
+ // 템플릿 파일 수
+ const templateFileCount = tbeId !== null ? templateFileCountMap.get(tbeId) || 0 : 0
+
+ // 응답 파일 여부
+ const responseKey = tbeId !== null ? `${tbeId}_${vendorId}` : ""
+ const responseFileCount = responseKey ? tbeResponseMap.get(responseKey) || 0 : 0
+
+ return {
+ ...row,
+ dueDate: row.dueDate ? new Date(row.dueDate) : null,
+ comments: commByVendorId.get(row.vendorId) ?? [],
+ templateFileCount, // 추가: 템플릿 파일 수
+ hasResponse: responseFileCount > 0, // 추가: 응답 파일 제출 여부
+ }
+ })
+
+ const pageCount = Math.ceil(total / limit)
+ return { data: final, pageCount }
+ },
+ [JSON.stringify(input), String(vendorId)], // 캐싱 키에 packagesId 추가
+ {
+ revalidate: 3600,
+ tags: [`tbe-vendors-${vendorId}`],
+ }
+ )()
+}
+
+export async function inviteTbeVendorsAction(formData: FormData) {
+ // 캐싱 방지
+ unstable_noStore()
+
+ try {
+ // 1) FormData에서 기본 필드 추출
+ const rfqId = Number(formData.get("rfqId"))
+ 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) {
+ throw new Error("Invalid input or no files attached.")
+ }
+
+ // /public/rfq/[rfqId] 경로
+ const uploadDir = path.join(process.cwd(), "public", "rfq", String(rfqId))
+
+
+ // DB 트랜잭션
+ await db.transaction(async (tx) => {
+ // (A) RFQ 기본 정보 조회
+ const [rfqRow] = await tx
+ .select({
+ rfqCode: vendorResponsesView.rfqCode,
+ description: vendorResponsesView.rfqDescription,
+ projectCode: vendorResponsesView.projectCode,
+ projectName: vendorResponsesView.projectName,
+ dueDate: vendorResponsesView.rfqDueDate,
+ createdBy: vendorResponsesView.rfqCreatedBy,
+ })
+ .from(vendorResponsesView)
+ .where(eq(vendorResponsesView.rfqId, rfqId))
+
+ if (!rfqRow) {
+ throw new Error(`RFQ #${rfqId} not found`)
+ }
+
+ // (B) RFQ 아이템 목록
+ const items = await tx
+ .select({
+ itemCode: rfqItems.itemCode,
+ description: rfqItems.description,
+ quantity: rfqItems.quantity,
+ uom: rfqItems.uom,
+ })
+ .from(rfqItems)
+ .where(eq(rfqItems.rfqId, rfqId))
+
+ // (C) 대상 벤더들
+ const vendorRows = await tx
+ .select({ id: vendors.id, email: vendors.email })
+ .from(vendors)
+ .where(sql`${vendors.id} in (${vendorIds})`)
+
+ // (D) 모든 TBE 파일 저장 & 이후 벤더 초대 처리
+ // 파일은 한 번만 저장해도 되지만, 각 벤더별로 따로 저장/첨부가 필요하다면 루프를 돌려도 됨.
+ // 여기서는 "모든 파일"을 RFQ-DIR에 저장 + "각 벤더"에는 동일 파일 목록을 첨부한다는 예시.
+ const savedFiles = []
+ for (const file of tbeFiles) {
+ const originalName = file.name || "tbe-sheet.xlsx"
+ const savePath = path.join(uploadDir, originalName)
+
+ // 파일 ArrayBuffer → Buffer 변환 후 저장
+ const arrayBuffer = await file.arrayBuffer()
+ fs.writeFile(savePath, Buffer.from(arrayBuffer))
+
+ // 저장 경로 & 파일명 기록
+ savedFiles.push({
+ fileName: originalName,
+ filePath: `/rfq/${rfqId}/${originalName}`, // public 이하 경로
+ absolutePath: savePath,
+ })
+ }
+
+ // (E) 각 벤더별로 TBE 평가 레코드, 초대 처리, 메일 발송
+ for (const v of vendorRows) {
+ if (!v.email) {
+ // 이메일 없는 경우 로직 (스킵 or throw)
+ continue
+ }
+
+ // 1) TBE 평가 레코드 생성
+ const [evalRow] = await tx
+ .insert(rfqEvaluations)
+ .values({
+ rfqId,
+ vendorId: v.id,
+ evalType: "TBE",
+ })
+ .returning({ id: rfqEvaluations.id })
+
+ // 2) rfqAttachments에 저장한 파일들을 기록
+ for (const sf of savedFiles) {
+ await tx.insert(rfqAttachments).values({
+ rfqId,
+ // vendorId: v.id,
+ evaluationId: evalRow.id,
+ fileName: sf.fileName,
+ filePath: sf.filePath,
+ })
+ }
+
+ // 4) 메일 발송
+ 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,
+ })),
+ })
+ }
+
+ // 5) 캐시 무효화
+ revalidateTag("tbe-vendors")
+ })
+
+ // 성공
+ return { error: null }
+ } catch (err) {
+ console.error("[inviteTbeVendorsAction] Error:", err)
+ return { error: getErrorMessage(err) }
+ }
+}
+////partners
+
+
+export async function modifyRfqVendor(input: UpdateRfqVendorSchema) {
+ unstable_noStore();
+ try {
+ const data = await db.transaction(async (tx) => {
+ const [res] = await updateRfqVendor(tx, input.id, {
+ responseStatus: input.status,
+ });
+ return res;
+ });
+
+ revalidateTag("rfqs-vendor");
+ revalidateTag("rfq-vendors");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+export async function createRfqCommentWithAttachments(params: {
+ rfqId: number
+ vendorId?: number | null
+ commentText: string
+ commentedBy: number
+ evaluationId?: number | null
+ files?: File[]
+}) {
+ const { rfqId, vendorId, commentText, commentedBy, evaluationId, files } = params
+
+
+ // 1) 새로운 코멘트 생성
+ const [insertedComment] = await db
+ .insert(rfqComments)
+ .values({
+ rfqId,
+ vendorId: vendorId || null,
+ commentText,
+ commentedBy,
+ evaluationId: evaluationId || null,
+ })
+ .returning({ id: rfqComments.id, createdAt: rfqComments.createdAt }) // id만 반환하도록
+
+ if (!insertedComment) {
+ throw new Error("Failed to create comment")
+ }
+
+ // 2) 첨부파일 처리 (S3 업로드 등은 프로젝트 상황에 따라)
+ if (files && files.length > 0) {
+
+ const rfqDir = path.join(process.cwd(), "public", "rfq", String(rfqId));
+ // 폴더 없으면 생성
+ await fs.mkdir(rfqDir, { recursive: true });
+
+ for (const file of files) {
+ const ab = await file.arrayBuffer();
+ const buffer = Buffer.from(ab);
+
+ // 2-2) 고유 파일명
+ const uniqueName = `${randomUUID()}-${file.name}`;
+ // 예) "rfq/123/xxx"
+ const relativePath = path.join("rfq", String(rfqId), uniqueName);
+ const absolutePath = path.join(process.cwd(), "public", relativePath);
+
+ // 2-3) 파일 저장
+ await fs.writeFile(absolutePath, buffer);
+
+ // DB에 첨부파일 row 생성
+ await db.insert(rfqAttachments).values({
+ rfqId,
+ vendorId: vendorId || null,
+ evaluationId: evaluationId || null,
+ commentId: insertedComment.id, // 새 코멘트와 연결
+ fileName: file.name,
+ filePath: "/" + relativePath.replace(/\\/g, "/"),
+ })
+ }
+ }
+
+ revalidateTag("rfq-vendors");
+
+ return { ok: true, commentId: insertedComment.id, createdAt: insertedComment.createdAt }
+}
+
+export async function fetchRfqAttachmentsbyCommentId(commentId: number) {
+ // DB select
+ const rows = await db
+ .select()
+ .from(rfqAttachments)
+ .where(eq(rfqAttachments.commentId, commentId))
+
+ // rows: { id, fileName, filePath, createdAt, vendorId, ... }
+ // 필요 없는 필드는 omit하거나 transform 가능
+ return rows.map((row) => ({
+ id: row.id,
+ fileName: row.fileName,
+ filePath: row.filePath,
+ createdAt: row.createdAt, // or string
+ vendorId: row.vendorId,
+ evaluationId: row.evaluationId,
+ size: undefined, // size를 DB에 저장하지 않았다면
+ }))
+}
+
+export async function updateRfqComment(params: {
+ commentId: number
+ commentText: string
+}) {
+ const { commentId, commentText } = params
+
+ // 예: 간단한 길이 체크 등 유효성 검사
+ if (!commentText || commentText.trim().length === 0) {
+ throw new Error("Comment text must not be empty.")
+ }
+
+ // DB 업데이트
+ const updatedRows = await db
+ .update(rfqComments)
+ .set({ commentText }) // 필요한 컬럼만 set
+ .where(eq(rfqComments.id, commentId))
+ .returning({ id: rfqComments.id })
+
+ // 혹은 returning 전체(row)를 받아서 확인할 수도 있음
+ if (updatedRows.length === 0) {
+ // 해당 id가 없으면 예외
+ throw new Error("Comment not found or already deleted.")
+ }
+ revalidateTag("rfq-vendors");
+ return { ok: true }
+}
+
+export type Project = {
+ id: number;
+ projectCode: string;
+ projectName: string;
+}
+
+export async function getProjects(): Promise<Project[]> {
+ try {
+ // 트랜잭션을 사용하여 프로젝트 데이터 조회
+ const projectList = await db.transaction(async (tx) => {
+ // 모든 프로젝트 조회
+ const results = await tx
+ .select({
+ id: projects.id,
+ projectCode: projects.code, // 테이블의 실제 컬럼명에 맞게 조정
+ projectName: projects.name, // 테이블의 실제 컬럼명에 맞게 조정
+ })
+ .from(projects)
+ .orderBy(projects.code);
+
+ return results;
+ });
+
+ return projectList;
+ } catch (error) {
+ console.error("프로젝트 목록 가져오기 실패:", error);
+ return []; // 오류 발생 시 빈 배열 반환
+ }
+}
+
+
+// 반환 타입 명시적 정의 - rfqCode가 null일 수 있음을 반영
+export interface BudgetaryRfq {
+ id: number;
+ rfqCode: string | null; // null 허용으로 변경
+ description: string | null;
+ projectId: number | null;
+ projectCode: string | null;
+ projectName: string | null;
+}
+
+interface GetBudgetaryRfqsParams {
+ search?: string;
+ projectId?: number;
+ limit?: number;
+ offset?: number;
+}
+
+type GetBudgetaryRfqsResponse =
+ | { rfqs: BudgetaryRfq[]; totalCount: number; error?: never }
+ | { error: string; rfqs?: never; totalCount: number }
+/**
+ * Budgetary 타입의 RFQ 목록을 가져오는 서버 액션
+ * Purchase RFQ 생성 시 부모 RFQ로 선택할 수 있도록 함
+ * 페이징 및 필터링 기능 포함
+ */
+export async function getBudgetaryRfqs(params: GetBudgetaryRfqsParams = {}): Promise<GetBudgetaryRfqsResponse> {
+ const { search, projectId, limit = 50, offset = 0 } = params;
+ const cacheKey = `budgetary-rfqs-${JSON.stringify(params)}`;
+ return unstable_cache(
+ async () => {
+ try {
+
+ const baseCondition = eq(rfqs.rfqType, RfqType.BUDGETARY);
+
+ let where1
+ // 검색어 조건 추가 (있을 경우)
+ if (search && search.trim()) {
+ const searchTerm = `%${search.trim()}%`;
+ const searchCondition = or(
+ ilike(rfqs.rfqCode, searchTerm),
+ ilike(rfqs.description, searchTerm),
+ ilike(projects.code, searchTerm),
+ ilike(projects.name, searchTerm)
+ );
+ where1 = searchCondition
+ }
+
+ let where2
+ // 프로젝트 ID 조건 추가 (있을 경우)
+ if (projectId) {
+ where2 = eq(rfqs.projectId, projectId);
+ }
+
+ const finalWhere = and(where1, where2, baseCondition)
+
+ // 총 개수 조회
+ const [countResult] = await db
+ .select({ count: count() })
+ .from(rfqs)
+ .leftJoin(projects, eq(rfqs.projectId, projects.id))
+ .where(finalWhere);
+
+ // 실제 데이터 조회
+ const budgetaryRfqs = await db
+ .select({
+ id: rfqs.id,
+ rfqCode: rfqs.rfqCode,
+ description: rfqs.description,
+ projectId: rfqs.projectId,
+ projectCode: projects.code,
+ projectName: projects.name,
+ })
+ .from(rfqs)
+ .leftJoin(projects, eq(rfqs.projectId, projects.id))
+ .where(finalWhere)
+ .orderBy(desc(rfqs.createdAt))
+ .limit(limit)
+ .offset(offset);
+
+ return {
+ rfqs: budgetaryRfqs as BudgetaryRfq[], // 타입 단언으로 호환성 보장
+ totalCount: Number(countResult?.count) || 0
+ };
+ } catch (error) {
+ console.error("Error fetching budgetary RFQs:", error);
+ return {
+ error: "Failed to fetch budgetary RFQs",
+ totalCount: 0
+ };
+ }
+ },
+ [cacheKey],
+ {
+ revalidate: 60, // 1분 캐시
+ tags: ["rfqs-budgetary"],
+ }
+ )();
+}
+
+export async function getAllVendors() {
+ // Adjust the query as needed (add WHERE, ORDER, etc.)
+ const allVendors = await db.select().from(vendors)
+ return allVendors
+}
+
+/**
+ * Server action to associate items from an RFQ with a vendor
+ *
+ * @param rfqId - The ID of the RFQ containing items to associate
+ * @param vendorId - The ID of the vendor to associate items with
+ * @returns Object indicating success or failure
+ */
+export async function addItemToVendors(rfqId: number, vendorIds: number[]) {
+ try {
+ // Input validation
+ if (!vendorIds.length) {
+ return {
+ success: false,
+ error: "No vendors selected"
+ };
+ }
+
+ // 1. Find all itemCodes associated with the given rfqId using select
+ const rfqItemResults = await db
+ .select({ itemCode: rfqItems.itemCode })
+ .from(rfqItems)
+ .where(eq(rfqItems.rfqId, rfqId));
+
+ // Extract itemCodes
+ const itemCodes = rfqItemResults.map(item => item.itemCode);
+
+ if (itemCodes.length === 0) {
+ return {
+ success: false,
+ error: "No items found for this RFQ"
+ };
+ }
+
+ // 2. Find existing vendor-item combinations to avoid duplicates
+ const existingCombinations = await db
+ .select({
+ vendorId: vendorPossibleItems.vendorId,
+ itemCode: vendorPossibleItems.itemCode
+ })
+ .from(vendorPossibleItems)
+ .where(
+ and(
+ inArray(vendorPossibleItems.vendorId, vendorIds),
+ inArray(vendorPossibleItems.itemCode, itemCodes)
+ )
+ );
+
+ // Create a Set of existing combinations for easy lookups
+ const existingSet = new Set();
+ existingCombinations.forEach(combo => {
+ existingSet.add(`${combo.vendorId}-${combo.itemCode}`);
+ });
+
+ // 3. Prepare records to insert (only non-existing combinations)
+ const recordsToInsert = [];
+
+ for (const vendorId of vendorIds) {
+ for (const itemCode of itemCodes) {
+ const key = `${vendorId}-${itemCode}`;
+ if (!existingSet.has(key)) {
+ recordsToInsert.push({
+ vendorId,
+ itemCode,
+ // createdAt and updatedAt will be set by defaultNow()
+ });
+ }
+ }
+ }
+
+ // 4. Bulk insert if there are records to insert
+ let insertedCount = 0;
+ if (recordsToInsert.length > 0) {
+ const result = await db.insert(vendorPossibleItems).values(recordsToInsert);
+ insertedCount = recordsToInsert.length;
+ }
+
+ // 5. Revalidate to refresh data
+ revalidateTag("rfq-vendors");
+
+ // 6. Return success with counts
+ return {
+ success: true,
+ insertedCount,
+ totalPossibleItems: vendorIds.length * itemCodes.length,
+ vendorCount: vendorIds.length,
+ itemCount: itemCodes.length
+ };
+ } catch (error) {
+ console.error("Error adding items to vendors:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "Unknown error"
+ };
+ }
+}
+
+/**
+ * 특정 평가에 대한 TBE 템플릿 파일 목록 조회
+ * evaluationId가 일치하고 vendorId가 null인 파일 목록
+ */
+export async function fetchTbeTemplateFiles(evaluationId: number) {
+
+ console.log(evaluationId, "evaluationId")
+ try {
+ const files = await db
+ .select({
+ id: rfqAttachments.id,
+ fileName: rfqAttachments.fileName,
+ filePath: rfqAttachments.filePath,
+ createdAt: rfqAttachments.createdAt,
+ })
+ .from(rfqAttachments)
+ .where(
+ and(
+ isNull(rfqAttachments.commentId),
+ isNull(rfqAttachments.vendorId),
+ eq(rfqAttachments.evaluationId, evaluationId),
+ // eq(rfqAttachments.vendorId, vendorId),
+
+ )
+ )
+
+ return { files, error: null }
+ } catch (error) {
+ console.error("Error fetching TBE template files:", error)
+ return {
+ files: [],
+ error: "템플릿 파일을 가져오는 중 오류가 발생했습니다."
+ }
+ }
+}
+
+/**
+ * 특정 TBE 템플릿 파일 다운로드를 위한 정보 조회
+ */
+export async function getTbeTemplateFileInfo(fileId: number) {
+ try {
+ const file = await db
+ .select({
+ fileName: rfqAttachments.fileName,
+ filePath: rfqAttachments.filePath,
+ })
+ .from(rfqAttachments)
+ .where(eq(rfqAttachments.id, fileId))
+ .limit(1)
+
+ if (!file.length) {
+ return { file: null, error: "파일을 찾을 수 없습니다." }
+ }
+
+ return { file: file[0], error: null }
+ } catch (error) {
+ console.error("Error getting TBE template file info:", error)
+ return {
+ file: null,
+ error: "파일 정보를 가져오는 중 오류가 발생했습니다."
+ }
+ }
+}
+
+/**
+ * TBE 응답 파일 업로드 처리
+ */
+export async function uploadTbeResponseFile(formData: FormData) {
+ try {
+ const file = formData.get("file") as File
+ const rfqId = parseInt(formData.get("rfqId") as string)
+ const vendorId = parseInt(formData.get("vendorId") as string)
+ const evaluationId = parseInt(formData.get("evaluationId") as string)
+ const vendorResponseId = parseInt(formData.get("vendorResponseId") as string)
+
+ if (!file || !rfqId || !vendorId || !evaluationId) {
+ return {
+ success: false,
+ error: "필수 필드가 누락되었습니다."
+ }
+ }
+
+ // 타임스탬프 기반 고유 파일명 생성
+ const timestamp = Date.now()
+ const originalName = file.name
+ const fileExtension = originalName.split(".").pop()
+ const fileName = `${originalName.split(".")[0]}-${timestamp}.${fileExtension}`
+
+ // 업로드 디렉토리 및 경로 정의
+ const uploadDir = join(process.cwd(), "rfq", "tbe-responses")
+
+ // 디렉토리가 없으면 생성
+ try {
+ await mkdir(uploadDir, { recursive: true })
+ } catch (error) {
+ // 이미 존재하면 무시
+ }
+
+ const filePath = join(uploadDir, fileName)
+
+ // 파일을 버퍼로 변환
+ const bytes = await file.arrayBuffer()
+ const buffer = Buffer.from(bytes)
+
+ // 파일을 서버에 저장
+ await writeFile(filePath, buffer)
+
+ // 먼저 vendorTechnicalResponses 테이블에 엔트리 생성
+ const technicalResponse = await db.insert(vendorTechnicalResponses)
+ .values({
+ responseId: vendorResponseId,
+ summary: "TBE 응답 파일 업로드", // 필요에 따라 수정
+ notes: `파일명: ${originalName}`,
+ })
+ .returning({ id: vendorTechnicalResponses.id });
+
+ // 생성된 기술 응답 ID 가져오기
+ const technicalResponseId = technicalResponse[0].id;
+
+ // 파일 정보를 데이터베이스에 저장
+ const dbFilePath = `/rfq/tbe-responses/${fileName}`
+
+ // vendorResponseAttachments 테이블 스키마에 맞게 데이터 삽입
+ await db.insert(vendorResponseAttachments)
+ .values({
+ // 오류 메시지를 기반으로 올바른 필드 이름 사용
+ // 테이블 스키마에 정의된 필드만 포함해야 함
+ responseId: vendorResponseId,
+ technicalResponseId: technicalResponseId,
+ // vendorId와 evaluationId 필드가 테이블에 있다면 포함, 없다면 제거
+ // vendorId: vendorId,
+ // evaluationId: evaluationId,
+ fileName: originalName,
+ filePath: dbFilePath,
+ uploadedAt: new Date(),
+ });
+
+ // 경로 재검증 (캐시된 데이터 새로고침)
+ revalidatePath(`/rfq/${rfqId}/tbe`)
+ revalidateTag(`tbe-vendors-${vendorId}`)
+
+ return {
+ success: true,
+ message: "파일이 성공적으로 업로드되었습니다."
+ }
+ } catch (error) {
+ console.error("Error uploading file:", error)
+ return {
+ success: false,
+ error: "파일 업로드에 실패했습니다."
+ }
+ }
+}
+
+export async function getTbeSubmittedFiles(responseId: number) {
+ try {
+ // First, get the technical response IDs where vendorResponseId matches responseId
+ const technicalResponses = await db
+ .select({
+ id: vendorTechnicalResponses.id,
+ })
+ .from(vendorTechnicalResponses)
+ .where(
+ eq(vendorTechnicalResponses.responseId, responseId)
+ )
+
+ if (technicalResponses.length === 0) {
+ return { files: [], error: null }
+ }
+
+ // Extract the IDs from the result
+ const technicalResponseIds = technicalResponses.map(tr => tr.id)
+
+ // Then get attachments where technicalResponseId matches any of the IDs we found
+ const files = await db
+ .select({
+ id: vendorResponseAttachments.id,
+ fileName: vendorResponseAttachments.fileName,
+ filePath: vendorResponseAttachments.filePath,
+ uploadedAt: vendorResponseAttachments.uploadedAt,
+ fileType: vendorResponseAttachments.fileType,
+ attachmentType: vendorResponseAttachments.attachmentType,
+ description: vendorResponseAttachments.description,
+ })
+ .from(vendorResponseAttachments)
+ .where(
+ inArray(vendorResponseAttachments.technicalResponseId, technicalResponseIds)
+ )
+ .orderBy(vendorResponseAttachments.uploadedAt)
+
+ return { files, error: null }
+ } catch (error) {
+ return { files: [], error: 'Failed to fetch TBE submitted files' }
+ }
+}
+
+
+
+export async function getTbeFilesForVendor(rfqId: number, vendorId: number) {
+ try {
+ // Step 1: Get responseId from vendor_responses table
+ const response = await db
+ .select({
+ id: vendorResponses.id,
+ })
+ .from(vendorResponses)
+ .where(
+ and(
+ eq(vendorResponses.rfqId, rfqId),
+ eq(vendorResponses.vendorId, vendorId)
+ )
+ )
+ .limit(1);
+
+ if (!response || response.length === 0) {
+ return { files: [], error: 'No vendor response found' };
+ }
+
+ const responseId = response[0].id;
+
+ // Step 2: Get the technical response IDs
+ const technicalResponses = await db
+ .select({
+ id: vendorTechnicalResponses.id,
+ })
+ .from(vendorTechnicalResponses)
+ .where(
+ eq(vendorTechnicalResponses.responseId, responseId)
+ );
+
+ if (technicalResponses.length === 0) {
+ return { files: [], error: null };
+ }
+
+ // Extract the IDs from the result
+ const technicalResponseIds = technicalResponses.map(tr => tr.id);
+
+ // Step 3: Get attachments where technicalResponseId matches any of the IDs
+ const files = await db
+ .select({
+ id: vendorResponseAttachments.id,
+ fileName: vendorResponseAttachments.fileName,
+ filePath: vendorResponseAttachments.filePath,
+ uploadedAt: vendorResponseAttachments.uploadedAt,
+ fileType: vendorResponseAttachments.fileType,
+ attachmentType: vendorResponseAttachments.attachmentType,
+ description: vendorResponseAttachments.description,
+ })
+ .from(vendorResponseAttachments)
+ .where(
+ inArray(vendorResponseAttachments.technicalResponseId, technicalResponseIds)
+ )
+ .orderBy(vendorResponseAttachments.uploadedAt);
+
+ return { files, error: null };
+ } catch (error) {
+ return { files: [], error: 'Failed to fetch vendor files' };
+ }
+}
+
+export async function getAllTBE(input: GetTBESchema) {
+ return unstable_cache(
+ async () => {
+ // 1) 페이징
+ const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10)
+ const limit = input.perPage ?? 10
+
+ // 2) 고급 필터
+ const advancedWhere = filterColumns({
+ table: vendorTbeView,
+ filters: input.filters ?? [],
+ joinOperator: input.joinOperator ?? "and",
+ })
+
+ // 3) 글로벌 검색
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ sql`${vendorTbeView.vendorName} ILIKE ${s}`,
+ sql`${vendorTbeView.vendorCode} ILIKE ${s}`,
+ sql`${vendorTbeView.email} ILIKE ${s}`,
+ sql`${vendorTbeView.rfqCode} ILIKE ${s}`,
+ sql`${vendorTbeView.projectCode} ILIKE ${s}`,
+ sql`${vendorTbeView.projectName} ILIKE ${s}`
+ )
+ }
+
+ // 4) REJECTED 아니거나 NULL
+ const notRejected = or(
+ ne(vendorTbeView.rfqVendorStatus, "REJECTED"),
+ isNull(vendorTbeView.rfqVendorStatus)
+ )
+
+ // 5) rfqType 필터 추가
+ const rfqTypeFilter = input.rfqType ? eq(vendorTbeView.rfqType, input.rfqType) : undefined
+
+ // 6) finalWhere - rfqType 필터 추가
+ const finalWhere = and(
+ notRejected,
+ advancedWhere,
+ globalWhere,
+ rfqTypeFilter // 새로 추가된 rfqType 필터
+ )
+
+ // 6) 정렬
+ const orderBy = input.sort?.length
+ ? input.sort.map((s) => {
+ const col = (vendorTbeView as any)[s.id]
+ return s.desc ? desc(col) : asc(col)
+ })
+ : [desc(vendorTbeView.rfqId), asc(vendorTbeView.vendorId)] // Default sort by newest RFQ first
+
+ // 7) 메인 SELECT
+ const [rows, total] = await db.transaction(async (tx) => {
+ const data = await tx
+ .select({
+ // 원하는 컬럼들
+ id: vendorTbeView.vendorId,
+ tbeId: vendorTbeView.tbeId,
+ vendorId: vendorTbeView.vendorId,
+ vendorName: vendorTbeView.vendorName,
+ vendorCode: vendorTbeView.vendorCode,
+ address: vendorTbeView.address,
+ country: vendorTbeView.country,
+ email: vendorTbeView.email,
+ website: vendorTbeView.website,
+ vendorStatus: vendorTbeView.vendorStatus,
+
+ rfqId: vendorTbeView.rfqId,
+ rfqCode: vendorTbeView.rfqCode,
+ projectCode: vendorTbeView.projectCode,
+ projectName: vendorTbeView.projectName,
+ description: vendorTbeView.description,
+ dueDate: vendorTbeView.dueDate,
+
+ rfqVendorStatus: vendorTbeView.rfqVendorStatus,
+ rfqVendorUpdated: vendorTbeView.rfqVendorUpdated,
+
+ tbeResult: vendorTbeView.tbeResult,
+ tbeNote: vendorTbeView.tbeNote,
+ tbeUpdated: vendorTbeView.tbeUpdated,
+ })
+ .from(vendorTbeView)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(limit)
+
+ const [{ count }] = await tx
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(vendorTbeView)
+ .where(finalWhere)
+
+ return [data, Number(count)]
+ })
+
+ if (!rows.length) {
+ return { data: [], pageCount: 0 }
+ }
+
+ // 8) Get distinct rfqIds and vendorIds - filter out nulls
+ 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[];
+
+ // 9) Comments 조회
+ const commentsConditions = [isNotNull(rfqComments.evaluationId)];
+
+ // 배열이 비어있지 않을 때만 조건 추가
+ if (distinctRfqIds.length > 0) {
+ commentsConditions.push(inArray(rfqComments.rfqId, distinctRfqIds));
+ }
+
+ if (distinctVendorIds.length > 0) {
+ commentsConditions.push(inArray(rfqComments.vendorId, distinctVendorIds));
+ }
+
+ const commAll = await db
+ .select({
+ id: rfqComments.id,
+ commentText: rfqComments.commentText,
+ vendorId: rfqComments.vendorId,
+ rfqId: rfqComments.rfqId,
+ evaluationId: rfqComments.evaluationId,
+ createdAt: rfqComments.createdAt,
+ commentedBy: rfqComments.commentedBy,
+ evalType: rfqEvaluations.evalType,
+ })
+ .from(rfqComments)
+ .innerJoin(
+ rfqEvaluations,
+ and(
+ eq(rfqEvaluations.id, rfqComments.evaluationId),
+ eq(rfqEvaluations.evalType, "TBE")
+ )
+ )
+ .where(and(...commentsConditions));
+
+ // 9-A) Create a composite key (rfqId-vendorId) -> comments mapping
+ const commByCompositeKey = new Map<string, any[]>()
+ for (const c of commAll) {
+ if (!c.rfqId || !c.vendorId) continue;
+
+ const compositeKey = `${c.rfqId}-${c.vendorId}`;
+ if (!commByCompositeKey.has(compositeKey)) {
+ commByCompositeKey.set(compositeKey, [])
+ }
+ commByCompositeKey.get(compositeKey)!.push({
+ id: c.id,
+ commentText: c.commentText,
+ vendorId: c.vendorId,
+ evaluationId: c.evaluationId,
+ createdAt: c.createdAt,
+ commentedBy: c.commentedBy,
+ })
+ }
+
+ // 10) Responses 조회
+ const responsesAll = await db
+ .select({
+ id: vendorResponses.id,
+ rfqId: vendorResponses.rfqId,
+ vendorId: vendorResponses.vendorId
+ })
+ .from(vendorResponses)
+ .where(
+ and(
+ inArray(vendorResponses.rfqId, distinctRfqIds),
+ inArray(vendorResponses.vendorId, distinctVendorIds)
+ )
+ );
+
+ // Group responses by rfqId-vendorId composite key
+ const responsesByCompositeKey = new Map<string, number[]>();
+ for (const resp of responsesAll) {
+ const compositeKey = `${resp.rfqId}-${resp.vendorId}`;
+ if (!responsesByCompositeKey.has(compositeKey)) {
+ responsesByCompositeKey.set(compositeKey, []);
+ }
+ responsesByCompositeKey.get(compositeKey)!.push(resp.id);
+ }
+
+ // Get all responseIds
+ const allResponseIds = responsesAll.map(r => r.id);
+
+ // 11) Get technicalResponses for these responseIds
+ const technicalResponsesAll = await db
+ .select({
+ id: vendorTechnicalResponses.id,
+ responseId: vendorTechnicalResponses.responseId
+ })
+ .from(vendorTechnicalResponses)
+ .where(inArray(vendorTechnicalResponses.responseId, allResponseIds));
+
+ // Create mapping from responseId to technicalResponseIds
+ const technicalResponseIdsByResponseId = new Map<number, number[]>();
+ for (const tr of technicalResponsesAll) {
+ if (!technicalResponseIdsByResponseId.has(tr.responseId)) {
+ technicalResponseIdsByResponseId.set(tr.responseId, []);
+ }
+ technicalResponseIdsByResponseId.get(tr.responseId)!.push(tr.id);
+ }
+
+ // Get all technicalResponseIds
+ const allTechnicalResponseIds = technicalResponsesAll.map(tr => tr.id);
+
+ // 12) Get attachments for these technicalResponseIds
+ const filesAll = await db
+ .select({
+ id: vendorResponseAttachments.id,
+ fileName: vendorResponseAttachments.fileName,
+ filePath: vendorResponseAttachments.filePath,
+ technicalResponseId: vendorResponseAttachments.technicalResponseId,
+ fileType: vendorResponseAttachments.fileType,
+ attachmentType: vendorResponseAttachments.attachmentType,
+ description: vendorResponseAttachments.description,
+ uploadedAt: vendorResponseAttachments.uploadedAt,
+ uploadedBy: vendorResponseAttachments.uploadedBy
+ })
+ .from(vendorResponseAttachments)
+ .where(
+ and(
+ inArray(vendorResponseAttachments.technicalResponseId, allTechnicalResponseIds),
+ isNotNull(vendorResponseAttachments.technicalResponseId)
+ )
+ );
+
+ // Create mapping from technicalResponseId to attachments
+ const filesByTechnicalResponseId = new Map<number, any[]>();
+ for (const file of filesAll) {
+ if (file.technicalResponseId === null) continue;
+
+ if (!filesByTechnicalResponseId.has(file.technicalResponseId)) {
+ filesByTechnicalResponseId.set(file.technicalResponseId, []);
+ }
+ filesByTechnicalResponseId.get(file.technicalResponseId)!.push({
+ id: file.id,
+ fileName: file.fileName,
+ filePath: file.filePath,
+ fileType: file.fileType,
+ attachmentType: file.attachmentType,
+ description: file.description,
+ uploadedAt: file.uploadedAt,
+ uploadedBy: file.uploadedBy
+ });
+ }
+
+ // 13) Create the final filesByCompositeKey map
+ const filesByCompositeKey = new Map<string, any[]>();
+
+ for (const [compositeKey, responseIds] of responsesByCompositeKey.entries()) {
+ filesByCompositeKey.set(compositeKey, []);
+
+ for (const responseId of responseIds) {
+ const technicalResponseIds = technicalResponseIdsByResponseId.get(responseId) || [];
+
+ for (const technicalResponseId of technicalResponseIds) {
+ const files = filesByTechnicalResponseId.get(technicalResponseId) || [];
+ filesByCompositeKey.get(compositeKey)!.push(...files);
+ }
+ }
+ }
+
+ // 14) 최종 합치기
+ const final = rows.map((row) => {
+ const compositeKey = `${row.rfqId}-${row.vendorId}`;
+
+ return {
+ ...row,
+ dueDate: row.dueDate ? new Date(row.dueDate) : null,
+ comments: commByCompositeKey.get(compositeKey) ?? [],
+ files: filesByCompositeKey.get(compositeKey) ?? [],
+ };
+ })
+
+ const pageCount = Math.ceil(total / limit)
+ return { data: final, pageCount }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 3600,
+ tags: ["all-tbe-vendors"],
+ }
+ )()
+}
+
+
+
+
+
+export async function getCBE(input: GetCBESchema, rfqId: number) {
+ return unstable_cache(
+ async () => {
+ // [1] 페이징
+ const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10);
+ const limit = input.perPage ?? 10;
+
+ // [2] 고급 필터
+ const advancedWhere = filterColumns({
+ table: vendorCbeView,
+ filters: input.filters ?? [],
+ joinOperator: input.joinOperator ?? "and",
+ });
+
+ // [3] 글로벌 검색
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ sql`${vendorCbeView.vendorName} ILIKE ${s}`,
+ sql`${vendorCbeView.vendorCode} ILIKE ${s}`,
+ sql`${vendorCbeView.email} ILIKE ${s}`
+ );
+ }
+
+ // [4] REJECTED 아니거나 NULL
+ const notRejected = or(
+ ne(vendorCbeView.rfqVendorStatus, "REJECTED"),
+ isNull(vendorCbeView.rfqVendorStatus)
+ );
+
+ // [5] 최종 where
+ const finalWhere = and(
+ eq(vendorCbeView.rfqId, rfqId),
+ notRejected,
+ advancedWhere,
+ globalWhere
+ );
+
+ // [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)];
+
+ // [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,
+ })
+ .from(vendorCbeView)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(limit);
+
+ const [{ count }] = await tx
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(vendorCbeView)
+ .where(finalWhere);
+
+ return [data, Number(count)];
+ });
+
+ if (!rows.length) {
+ return { data: [], pageCount: 0 };
+ }
+
+ // [8] Comments 조회
+ // TBE 에서는 rfqComments + rfqEvaluations(evalType="TBE") 를 조인했지만,
+ // CBE는 cbeEvaluations 또는 evalType="CBE"를 기준으로 바꾸면 됩니다.
+ // 만약 cbeEvaluations.id 를 evaluationId 로 참조한다면 아래와 같이 innerJoin:
+ const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))];
+
+ const commAll = await db
+ .select({
+ id: rfqComments.id,
+ commentText: rfqComments.commentText,
+ vendorId: rfqComments.vendorId,
+ evaluationId: rfqComments.evaluationId,
+ createdAt: rfqComments.createdAt,
+ commentedBy: rfqComments.commentedBy,
+ // cbeEvaluations에는 evalType 컬럼이 별도로 없을 수도 있음(프로젝트 구조에 맞게 수정)
+ // evalType: cbeEvaluations.evalType,
+ })
+ .from(rfqComments)
+ .innerJoin(
+ cbeEvaluations,
+ eq(cbeEvaluations.id, rfqComments.evaluationId)
+ )
+ .where(
+ and(
+ isNotNull(rfqComments.evaluationId),
+ 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, []);
+ }
+ commByVendorId.get(vid)!.push({
+ id: c.id,
+ commentText: c.commentText,
+ vendorId: c.vendorId,
+ evaluationId: c.evaluationId,
+ createdAt: c.createdAt,
+ commentedBy: c.commentedBy,
+ });
+ }
+
+ // [9] CBE 파일 조회 (프로젝트에 따라 구조가 달라질 수 있음)
+ // - TBE는 vendorTechnicalResponses 기준
+ // - CBE는 vendorCommercialResponses(가정) 등이 있을 수 있음
+ // - 여기서는 예시로 "동일한 vendorResponses + vendorResponseAttachments" 라고 가정
+ // Step 1: vendorResponses 가져오기 (rfqId + vendorIds)
+ const responsesAll = await db
+ .select({
+ id: vendorResponses.id,
+ vendorId: vendorResponses.vendorId,
+ })
+ .from(vendorResponses)
+ .where(
+ and(
+ eq(vendorResponses.rfqId, rfqId),
+ inArray(vendorResponses.vendorId, distinctVendorIds)
+ )
+ );
+
+ // Group responses by vendorId
+ 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
+ .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, allCommercialResponseIds),
+ isNotNull(vendorResponseAttachments.responseId)
+ )
+ );
+
+ // Step 4: responseId -> files
+ const filesByResponseId = new Map<number, any[]>();
+ for (const file of filesAll) {
+ const rid = file.responseId!;
+ if (!filesByResponseId.has(rid)) {
+ filesByResponseId.set(rid, []);
+ }
+ filesByResponseId.get(rid)!.push({
+ id: file.id,
+ fileName: file.fileName,
+ filePath: file.filePath,
+ fileType: file.fileType,
+ attachmentType: file.attachmentType,
+ description: file.description,
+ uploadedAt: file.uploadedAt,
+ uploadedBy: file.uploadedBy,
+ });
+ }
+
+ // Step 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);
+ }
+ }
+
+ // [10] 최종 데이터 합치기
+ const final = rows.map((row) => ({
+ ...row,
+ dueDate: row.dueDate ? new Date(row.dueDate) : null,
+ comments: commByVendorId.get(row.vendorId) ?? [],
+ files: filesByVendorId.get(row.vendorId) ?? [],
+ }));
+
+ const pageCount = Math.ceil(total / limit);
+ return { data: final, pageCount };
+ },
+ // 캐싱 키 & 옵션
+ [JSON.stringify({ input, rfqId })],
+ {
+ revalidate: 3600,
+ tags: ["cbe-vendors"],
+ }
+ )();
+} \ No newline at end of file
diff --git a/lib/rfqs/table/BudgetaryRfqSelector.tsx b/lib/rfqs/table/BudgetaryRfqSelector.tsx
new file mode 100644
index 00000000..cea53c1d
--- /dev/null
+++ b/lib/rfqs/table/BudgetaryRfqSelector.tsx
@@ -0,0 +1,261 @@
+"use client"
+
+import * as React from "react"
+import { Check, ChevronsUpDown, Loader } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"
+import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
+import { cn } from "@/lib/utils"
+import { useDebounce } from "@/hooks/use-debounce"
+import { getBudgetaryRfqs, type BudgetaryRfq } from "../service"
+
+interface BudgetaryRfqSelectorProps {
+ selectedRfqId?: number;
+ onRfqSelect: (rfq: BudgetaryRfq | null) => void;
+ placeholder?: string;
+}
+
+export function BudgetaryRfqSelector({
+ selectedRfqId,
+ onRfqSelect,
+ placeholder = "Budgetary RFQ 선택..."
+}: BudgetaryRfqSelectorProps) {
+ const [searchTerm, setSearchTerm] = React.useState("");
+ const debouncedSearchTerm = useDebounce(searchTerm, 300);
+
+ const [open, setOpen] = React.useState(false);
+ const [loading, setLoading] = React.useState(false);
+ const [budgetaryRfqs, setBudgetaryRfqs] = React.useState<BudgetaryRfq[]>([]);
+ const [selectedRfq, setSelectedRfq] = React.useState<BudgetaryRfq | null>(null);
+ const [page, setPage] = React.useState(1);
+ const [hasMore, setHasMore] = React.useState(true);
+ const [totalCount, setTotalCount] = React.useState(0);
+
+ const listRef = React.useRef<HTMLDivElement>(null);
+
+ // 초기 선택된 RFQ가 있을 경우 로드
+ React.useEffect(() => {
+ if (selectedRfqId && open) {
+ const loadSelectedRfq = async () => {
+ try {
+ const result = await getBudgetaryRfqs({
+ limit: 1,
+ // null을 undefined로 변환하여 타입 오류 해결
+ projectId: selectedRfq?.projectId ?? undefined
+ });
+
+ if ('rfqs' in result && result.rfqs) {
+ // 옵셔널 체이닝 또는 조건부 검사로 undefined 체크
+ const foundRfq = result.rfqs.find(rfq => rfq.id === selectedRfqId);
+ if (foundRfq) {
+ setSelectedRfq(foundRfq);
+ }
+ }
+ } catch (error) {
+ console.error("선택된 RFQ 로드 오류:", error);
+ }
+ };
+
+ if (!selectedRfq || selectedRfq.id !== selectedRfqId) {
+ loadSelectedRfq();
+ }
+ }
+ }, [selectedRfqId, open, selectedRfq]);
+
+ // 검색어 변경 시 데이터 리셋 및 재로드
+ React.useEffect(() => {
+ if (open) {
+ setPage(1);
+ setHasMore(true);
+ setBudgetaryRfqs([]);
+ loadBudgetaryRfqs(1, true);
+ }
+ }, [debouncedSearchTerm, open]);
+
+ // 데이터 로드 함수
+ const loadBudgetaryRfqs = async (pageToLoad: number, reset = false) => {
+ if (!open) return;
+
+ setLoading(true);
+ try {
+ const limit = 20; // 한 번에 로드할 항목 수
+ const result = await getBudgetaryRfqs({
+ search: debouncedSearchTerm,
+ limit,
+ offset: (pageToLoad - 1) * limit,
+ });
+
+ if ('rfqs' in result && result.rfqs) {
+ if (reset) {
+ setBudgetaryRfqs(result.rfqs);
+ } else {
+ setBudgetaryRfqs(prev => [...prev, ...result.rfqs]);
+ }
+
+ setTotalCount(result.totalCount);
+ setHasMore(result.rfqs.length === limit && (pageToLoad * limit) < result.totalCount);
+ setPage(pageToLoad);
+ }
+ } catch (error) {
+ console.error("Budgetary RFQs 로드 오류:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 무한 스크롤 처리
+ const handleScroll = () => {
+ if (listRef.current) {
+ const { scrollTop, scrollHeight, clientHeight } = listRef.current;
+
+ // 스크롤이 90% 이상 내려갔을 때 다음 페이지 로드
+ if (scrollTop + clientHeight >= scrollHeight * 0.9 && !loading && hasMore) {
+ loadBudgetaryRfqs(page + 1);
+ }
+ }
+ };
+
+ // RFQ를 프로젝트별로 그룹화하는 함수
+ const groupRfqsByProject = (rfqs: BudgetaryRfq[]) => {
+ const groups: Record<string, {
+ projectId: number | null;
+ projectCode: string | null;
+ projectName: string | null;
+ rfqs: BudgetaryRfq[];
+ }> = {};
+
+ // 'No Project' 그룹 기본 생성
+ groups['no-project'] = {
+ projectId: null,
+ projectCode: null,
+ projectName: null,
+ rfqs: []
+ };
+
+ // 프로젝트별로 RFQ 그룹화
+ rfqs.forEach(rfq => {
+ const key = rfq.projectId ? `project-${rfq.projectId}` : 'no-project';
+
+ if (!groups[key] && rfq.projectId) {
+ groups[key] = {
+ projectId: rfq.projectId,
+ projectCode: rfq.projectCode,
+ projectName: rfq.projectName,
+ rfqs: []
+ };
+ }
+
+ groups[key].rfqs.push(rfq);
+ });
+
+ // 필터링된 결과가 있는 그룹만 남기기
+ return Object.values(groups).filter(group => group.rfqs.length > 0);
+ };
+
+ // 그룹화된 RFQ 목록
+ const groupedRfqs = React.useMemo(() => {
+ return groupRfqsByProject(budgetaryRfqs);
+ }, [budgetaryRfqs]);
+
+ // RFQ 선택 처리
+ const handleRfqSelect = (rfq: BudgetaryRfq | null) => {
+ setSelectedRfq(rfq);
+ onRfqSelect(rfq);
+ setOpen(false);
+ };
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={open}
+ className="w-full justify-between"
+ >
+ {selectedRfq
+ ? `${selectedRfq.rfqCode || ""} - ${selectedRfq.description || ""}`
+ : placeholder}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="Budgetary RFQ 코드/설명/프로젝트 검색..."
+ value={searchTerm}
+ onValueChange={setSearchTerm}
+ />
+ <CommandList
+ className="max-h-[300px]"
+ ref={listRef}
+ onScroll={handleScroll}
+ >
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+
+ <CommandGroup>
+ <CommandItem
+ value="none"
+ onSelect={() => handleRfqSelect(null)}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ !selectedRfq
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ <span className="font-medium">선택 안함</span>
+ </CommandItem>
+ </CommandGroup>
+
+ {groupedRfqs.map((group, index) => (
+ <CommandGroup
+ key={`group-${group.projectId || index}`}
+ heading={
+ group.projectId
+ ? `${group.projectCode || ""} - ${group.projectName || ""}`
+ : "프로젝트 없음"
+ }
+ >
+ {group.rfqs.map((rfq) => (
+ <CommandItem
+ key={rfq.id}
+ value={`${rfq.rfqCode || ""} ${rfq.description || ""}`}
+ onSelect={() => handleRfqSelect(rfq)}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ selectedRfq?.id === rfq.id
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ <span className="font-medium">{rfq.rfqCode || ""}</span>
+ <span className="ml-2 text-gray-500 truncate">
+ - {rfq.description || ""}
+ </span>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ ))}
+
+ {loading && (
+ <div className="py-2 text-center">
+ <Loader className="h-4 w-4 animate-spin mx-auto" />
+ </div>
+ )}
+
+ {!loading && !hasMore && budgetaryRfqs.length > 0 && (
+ <div className="py-2 text-center text-sm text-muted-foreground">
+ 총 {totalCount}개 중 {budgetaryRfqs.length}개 표시됨
+ </div>
+ )}
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ );
+} \ No newline at end of file
diff --git a/lib/rfqs/table/ItemsDialog.tsx b/lib/rfqs/table/ItemsDialog.tsx
new file mode 100644
index 00000000..f1dbf90e
--- /dev/null
+++ b/lib/rfqs/table/ItemsDialog.tsx
@@ -0,0 +1,744 @@
+"use client"
+
+import * as React from "react"
+import { useForm, useFieldArray, useWatch } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage
+} from "@/components/ui/form"
+import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandItem,
+ CommandGroup,
+ CommandEmpty
+} from "@/components/ui/command"
+import { Check, ChevronsUpDown, Plus, Trash2, Save, X, AlertCircle, Eye } from "lucide-react"
+import { toast } from "sonner"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { Badge } from "@/components/ui/badge"
+
+import { createRfqItem, deleteRfqItem } from "../service"
+import { RfqWithItemCount } from "@/db/schema/rfq"
+import { RfqType } from "../validations"
+
+// Zod 스키마 - 수량은 string으로 받아서 나중에 변환
+const itemSchema = z.object({
+ id: z.number().optional(),
+ itemCode: z.string().nonempty({ message: "아이템 코드를 선택해주세요" }),
+ description: z.string().optional(),
+ quantity: z.coerce.number().min(1, { message: "최소 수량은 1입니다" }).default(1),
+ uom: z.string().default("each"),
+});
+
+const itemsFormSchema = z.object({
+ rfqId: z.number().int(),
+ items: z.array(itemSchema).min(1, { message: "최소 1개 이상의 아이템을 추가해주세요" }),
+});
+
+type ItemsFormSchema = z.infer<typeof itemsFormSchema>;
+
+interface RfqsItemsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ rfq: RfqWithItemCount | null;
+ defaultItems?: {
+ id?: number;
+ itemCode: string;
+ quantity?: number | null;
+ description?: string | null;
+ uom?: string | null;
+ }[];
+ itemsList: { code: string | null; name: string }[];
+ rfqType?: RfqType;
+}
+
+export function RfqsItemsDialog({
+ open,
+ onOpenChange,
+ rfq,
+ defaultItems = [],
+ itemsList,
+ rfqType
+}: RfqsItemsDialogProps) {
+ const rfqId = rfq?.rfqId ?? 0;
+
+ // 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능
+ const isEditable = rfq?.status === "DRAFT";
+
+ // 초기 아이템 ID 목록을 추적하기 위한 상태 추가
+ const [initialItemIds, setInitialItemIds] = React.useState<(number | undefined)[]>([]);
+
+ // 삭제된 아이템 ID를 저장하는 상태 추가
+ const [deletedItemIds, setDeletedItemIds] = React.useState<number[]>([]);
+
+ // 1) form
+ const form = useForm<ItemsFormSchema>({
+ resolver: zodResolver(itemsFormSchema),
+ defaultValues: {
+ rfqId,
+ items: defaultItems.length > 0 ? defaultItems.map((it) => ({
+ id: it.id,
+ quantity: it.quantity ?? 1,
+ uom: it.uom ?? "each",
+ itemCode: it.itemCode ?? "",
+ description: it.description ?? "",
+ })) : [{ itemCode: "", description: "", quantity: 1, uom: "each" }],
+ },
+ mode: "onChange", // 입력 필드가 변경될 때마다 유효성 검사
+ });
+
+ // 다이얼로그가 열릴 때마다 폼 초기화 및 초기 아이템 ID 저장
+ React.useEffect(() => {
+ if (open) {
+ const initialItems = defaultItems.length > 0
+ ? defaultItems.map((it) => ({
+ id: it.id,
+ quantity: it.quantity ?? 1,
+ uom: it.uom ?? "each",
+ itemCode: it.itemCode ?? "",
+ description: it.description ?? "",
+ }))
+ : [{ itemCode: "", description: "", quantity: 1, uom: "each" }];
+
+ form.reset({
+ rfqId,
+ items: initialItems,
+ });
+
+ // 초기 아이템 ID 목록 저장
+ setInitialItemIds(defaultItems.map(item => item.id));
+
+ // 삭제된 아이템 목록 초기화
+ setDeletedItemIds([]);
+ setHasUnsavedChanges(false);
+ }
+ }, [open, defaultItems, rfqId, form]);
+
+ // 새로운 요소에 대한 ref 배열
+ const inputRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
+ const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false);
+ const [isExitDialogOpen, setIsExitDialogOpen] = React.useState(false);
+
+ // 폼 변경 감지 - 편집 가능한 경우에만 변경 감지
+ React.useEffect(() => {
+ if (!isEditable) return;
+
+ const subscription = form.watch(() => {
+ setHasUnsavedChanges(true);
+ });
+ return () => subscription.unsubscribe();
+ }, [form, isEditable]);
+
+ // 2) field array
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "items",
+ });
+
+ // 3) watch items array
+ const watchItems = form.watch("items");
+
+ // 4) Add item row with auto-focus
+ function handleAddItem() {
+ if (!isEditable) return;
+
+ // 명시적으로 숫자 타입으로 지정
+ append({
+ itemCode: "",
+ description: "",
+ quantity: 1,
+ uom: "each"
+ });
+ setHasUnsavedChanges(true);
+
+ // 다음 렌더링 사이클에서 새로 추가된 항목에 포커스
+ setTimeout(() => {
+ const newIndex = fields.length;
+ const button = inputRefs.current[newIndex];
+ if (button) {
+ button.click();
+ }
+ }, 100);
+ }
+
+ // 항목 직접 삭제 - 기존 ID가 있을 경우 삭제 목록에 추가
+ const handleRemoveItem = (index: number) => {
+ if (!isEditable) return;
+
+ const itemToRemove = form.getValues().items[index];
+
+ // 기존 ID가 있는 아이템이라면 삭제 목록에 추가
+ if (itemToRemove.id !== undefined) {
+ setDeletedItemIds(prev => [...prev, itemToRemove.id as number]);
+ }
+
+ remove(index);
+ setHasUnsavedChanges(true);
+
+ // 포커스 처리: 다음 항목이 있으면 다음 항목으로, 없으면 마지막 항목으로
+ setTimeout(() => {
+ const nextIndex = Math.min(index, fields.length - 1);
+ if (nextIndex >= 0 && inputRefs.current[nextIndex]) {
+ inputRefs.current[nextIndex]?.click();
+ }
+ }, 50);
+ };
+
+ // 다이얼로그 닫기 전 확인
+ const handleDialogClose = (open: boolean) => {
+ if (!open && hasUnsavedChanges && isEditable) {
+ setIsExitDialogOpen(true);
+ } else {
+ onOpenChange(open);
+ }
+ };
+
+ // 필드 포커스 유틸리티 함수
+ const focusField = (selector: string) => {
+ if (!isEditable) return;
+
+ setTimeout(() => {
+ const element = document.querySelector(selector) as HTMLInputElement | null;
+ if (element) {
+ element.focus();
+ }
+ }, 10);
+ };
+
+ // 5) Submit - 업데이트된 제출 로직 (생성/수정 + 삭제 처리)
+ async function onSubmit(data: ItemsFormSchema) {
+ if (!isEditable) return;
+
+ try {
+ setIsSubmitting(true);
+
+ // 각 아이템이 유효한지 확인
+ const anyInvalidItems = data.items.some(item => !item.itemCode || item.quantity < 1);
+
+ if (anyInvalidItems) {
+ toast.error("유효하지 않은 아이템이 있습니다. 모든 필드를 확인해주세요.");
+ setIsSubmitting(false);
+ return;
+ }
+
+ // 1. 삭제 처리 - 삭제된 아이템 ID가 있으면 삭제 요청
+ const deletePromises = deletedItemIds.map(id =>
+ deleteRfqItem({
+ id: id,
+ rfqId: rfqId,
+ rfqType: rfqType ?? RfqType.PURCHASE
+ })
+ );
+
+ // 2. 생성/수정 처리 - 폼에 남아있는 아이템들
+ const upsertPromises = data.items.map((item) =>
+ createRfqItem({
+ rfqId: rfqId,
+ itemCode: item.itemCode,
+ description: item.description,
+ // 명시적으로 숫자로 변환
+ quantity: Number(item.quantity),
+ uom: item.uom,
+ rfqType: rfqType ?? RfqType.PURCHASE,
+ id: item.id // 기존 ID가 있으면 업데이트, 없으면 생성
+ })
+ );
+
+ // 모든 요청 병렬 처리
+ await Promise.all([...deletePromises, ...upsertPromises]);
+
+ toast.success("RFQ 아이템이 성공적으로 저장되었습니다!");
+ setHasUnsavedChanges(false);
+ onOpenChange(false);
+ } catch (err) {
+ toast.error(`오류가 발생했습니다: ${String(err)}`);
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ // 단축키 처리 - 편집 가능한 경우에만 단축키 활성화
+ React.useEffect(() => {
+ if (!isEditable) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // Alt+N: 새 항목 추가
+ if (e.altKey && e.key === 'n') {
+ e.preventDefault();
+ handleAddItem();
+ }
+ // Ctrl+S: 저장
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') {
+ e.preventDefault();
+ form.handleSubmit(onSubmit)();
+ }
+ // Esc: 포커스된 팝오버 닫기
+ if (e.key === 'Escape') {
+ document.querySelectorAll('[role="combobox"][aria-expanded="true"]').forEach(
+ (el) => (el as HTMLButtonElement).click()
+ );
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [form, isEditable]);
+
+ return (
+ <>
+ <Dialog open={open} onOpenChange={handleDialogClose}>
+ <DialogContent className="max-w-none w-[1200px]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ {isEditable ? "RFQ 아이템 관리" : "RFQ 아이템 조회"}
+ <Badge variant="outline" className="ml-2">
+ {rfq?.rfqCode || `RFQ #${rfqId}`}
+ </Badge>
+ {rfqType && (
+ <Badge variant={rfqType === RfqType.PURCHASE ? "default" : "secondary"} className="ml-1">
+ {rfqType === RfqType.PURCHASE ? "구매 RFQ" : "예산 RFQ"}
+ </Badge>
+ )}
+ {rfq?.status && (
+ <Badge
+ variant={rfq.status === "DRAFT" ? "outline" : "secondary"}
+ className="ml-1"
+ >
+ {rfq.status}
+ </Badge>
+ )}
+ </DialogTitle>
+ <DialogDescription>
+ {isEditable
+ ? (rfq?.description || '아이템을 각 행에 하나씩 추가할 수 있습니다.')
+ : '드래프트 상태가 아닌 RFQ는 아이템을 편집할 수 없습니다.'}
+ </DialogDescription>
+ </DialogHeader>
+ <div className="overflow-x-auto w-full">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)}>
+ <div className="space-y-4">
+ {/* 헤더 행 (라벨) */}
+ <div className="flex items-center gap-2 border-b pb-2 font-medium text-sm">
+ <div className="w-[250px] pl-3">아이템</div>
+ <div className="w-[400px] pl-2">설명</div>
+ <div className="w-[80px] pl-2 text-center">수량</div>
+ <div className="w-[80px] pl-2 text-center">단위</div>
+ {isEditable && <div className="w-[42px]"></div>}
+ </div>
+
+ {/* 아이템 행들 */}
+ <div className="max-h-[50vh] overflow-y-auto pr-1 space-y-3">
+ {fields.map((field, index) => {
+ // 현재 row의 itemCode
+ const codeValue = watchItems[index]?.itemCode || "";
+ // "이미" 사용된 코드를 모두 구함
+ const usedCodes = watchItems
+ .map((it, i) => i === index ? null : it.itemCode)
+ .filter(Boolean) as string[];
+
+ // itemsList에서 "현재 선택한 code"만 예외적으로 허용하고,
+ // 다른 행에서 이미 사용한 code는 제거
+ const filteredItems = (itemsList || [])
+ .filter((it) => {
+ if (!it.code) return false;
+ if (it.code === codeValue) return true;
+ return !usedCodes.includes(it.code);
+ })
+ .map((it) => ({
+ code: it.code ?? "", // fallback
+ name: it.name,
+ }));
+
+ // 선택된 아이템 찾기
+ const selected = filteredItems.find(it => it.code === codeValue);
+
+ return (
+ <div key={field.id} className="flex items-center gap-2 group hover:bg-gray-50 p-1 rounded-md transition-colors">
+ {/* -- itemCode + Popover(Select) -- */}
+ {isEditable ? (
+ <FormField
+ control={form.control}
+ name={`items.${index}.itemCode`}
+ render={({ field }) => {
+ const [popoverOpen, setPopoverOpen] = React.useState(false);
+ const selected = filteredItems.find(it => it.code === field.value);
+
+ return (
+ <FormItem className="flex items-center gap-2 w-[250px]">
+ <FormControl>
+ <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ // 컴포넌트에 ref 전달
+ ref={el => {
+ inputRefs.current[index] = el;
+ }}
+ variant="outline"
+ role="combobox"
+ aria-expanded={popoverOpen}
+ className="w-full justify-between"
+ data-error={!!form.formState.errors.items?.[index]?.itemCode}
+ data-state={selected ? "filled" : "empty"}
+ >
+ {selected ? `${selected.code} - ${selected.name}` : "아이템 선택..."}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="아이템 검색..." className="h-9" autoFocus />
+ <CommandList>
+ <CommandEmpty>아이템을 찾을 수 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {filteredItems.map((it) => {
+ const label = `${it.code} - ${it.name}`;
+ return (
+ <CommandItem
+ key={it.code}
+ value={label}
+ onSelect={() => {
+ field.onChange(it.code);
+ setPopoverOpen(false);
+ // 자동으로 다음 필드로 포커스 이동
+ focusField(`input[name="items.${index}.description"]`);
+ }}
+ >
+ {label}
+ <Check
+ className={
+ "ml-auto h-4 w-4" +
+ (it.code === field.value ? " opacity-100" : " opacity-0")
+ }
+ />
+ </CommandItem>
+ );
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </FormControl>
+ {form.formState.errors.items?.[index]?.itemCode && (
+ <AlertCircle className="h-4 w-4 text-destructive" />
+ )}
+ </FormItem>
+ );
+ }}
+ />
+ ) : (
+ <div className="flex items-center w-[250px] pl-3">
+ {selected ? `${selected.code} - ${selected.name}` : codeValue}
+ </div>
+ )}
+
+ {/* ID 필드 추가 (숨김) */}
+ <FormField
+ control={form.control}
+ name={`items.${index}.id`}
+ render={({ field }) => (
+ <input type="hidden" {...field} />
+ )}
+ />
+
+ {/* description */}
+ {isEditable ? (
+ <FormField
+ control={form.control}
+ name={`items.${index}.description`}
+ render={({ field }) => (
+ <FormItem className="w-[400px]">
+ <FormControl>
+ <Input
+ className="w-full"
+ placeholder="아이템 상세 정보"
+ {...field}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ focusField(`input[name="items.${index}.quantity"]`);
+ }
+ }}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ ) : (
+ <div className="w-[400px] pl-2">
+ {watchItems[index]?.description || ""}
+ </div>
+ )}
+
+ {/* quantity */}
+ {isEditable ? (
+ <FormField
+ control={form.control}
+ name={`items.${index}.quantity`}
+ render={({ field }) => (
+ <FormItem className="w-[80px] relative">
+ <FormControl>
+ <Input
+ type="number"
+ className="w-full text-center"
+ min="1"
+ {...field}
+ // 값 변경 핸들러 개선
+ onChange={(e) => {
+ const value = e.target.value === '' ? 1 : parseInt(e.target.value, 10);
+ field.onChange(isNaN(value) ? 1 : value);
+ }}
+ // 최소값 보장 (빈 문자열 방지)
+ onBlur={(e) => {
+ if (e.target.value === '' || parseInt(e.target.value, 10) < 1) {
+ field.onChange(1);
+ }
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ focusField(`input[name="items.${index}.uom"]`);
+ }
+ }}
+ />
+ </FormControl>
+ {form.formState.errors.items?.[index]?.quantity && (
+ <AlertCircle className="h-4 w-4 text-destructive absolute right-2 top-2" />
+ )}
+ </FormItem>
+ )}
+ />
+ ) : (
+ <div className="w-[80px] text-center">
+ {watchItems[index]?.quantity}
+ </div>
+ )}
+
+ {/* uom */}
+ {isEditable ? (
+ <FormField
+ control={form.control}
+ name={`items.${index}.uom`}
+ render={({ field }) => (
+ <FormItem className="w-[80px]">
+ <FormControl>
+ <Input
+ placeholder="each"
+ className="w-full text-center"
+ {...field}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ // 마지막 행이면 새로운 행 추가
+ if (index === fields.length - 1) {
+ handleAddItem();
+ } else {
+ // 아니면 다음 행의 아이템 선택으로 이동
+ const button = inputRefs.current[index + 1];
+ if (button) {
+ setTimeout(() => button.click(), 10);
+ }
+ }
+ }
+ }}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ ) : (
+ <div className="w-[80px] text-center">
+ {watchItems[index]?.uom || "each"}
+ </div>
+ )}
+
+ {/* remove row - 편집 모드에서만 표시 */}
+ {isEditable && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ onClick={() => handleRemoveItem(index)}
+ className="group-hover:opacity-100 transition-opacity"
+ aria-label="아이템 삭제"
+ >
+ <Trash2 className="h-4 w-4 text-destructive" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>아이템 삭제</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+ </div>
+ );
+ })}
+ </div>
+
+ <div className="flex justify-between items-center pt-2 border-t">
+ <div className="flex items-center gap-2">
+ {isEditable ? (
+ <>
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button type="button" variant="outline" onClick={handleAddItem} className="gap-1">
+ <Plus className="h-4 w-4" />
+ 아이템 추가
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="bottom">
+ <p>단축키: Alt+N</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ <span className="text-sm text-muted-foreground">
+ {fields.length}개 아이템
+ </span>
+ {deletedItemIds.length > 0 && (
+ <span className="text-sm text-destructive">
+ ({deletedItemIds.length}개 아이템 삭제 예정)
+ </span>
+ )}
+ </>
+ ) : (
+ <span className="text-sm text-muted-foreground">
+ {fields.length}개 아이템
+ </span>
+ )}
+ </div>
+
+ {isEditable && (
+ <div className="text-xs text-muted-foreground">
+ <span className="inline-flex items-center gap-1 mr-2">
+ <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Tab</kbd>
+ <span>필드 간 이동</span>
+ </span>
+ <span className="inline-flex items-center gap-1">
+ <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Enter</kbd>
+ <span>다음 필드로 이동</span>
+ </span>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter className="mt-6 gap-2">
+ {isEditable ? (
+ <>
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button type="button" variant="outline" onClick={() => handleDialogClose(false)}>
+ <X className="mr-2 h-4 w-4" />
+ 취소
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>변경사항을 저장하지 않고 나가기</TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ type="submit"
+ disabled={isSubmitting || (!form.formState.isDirty && deletedItemIds.length === 0) || !form.formState.isValid}
+ >
+ {isSubmitting ? (
+ <>처리 중...</>
+ ) : (
+ <>
+ <Save className="mr-2 h-4 w-4" />
+ 저장
+ </>
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>단축키: Ctrl+S</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </>
+ ) : (
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+ <X className="mr-2 h-4 w-4" />
+ 닫기
+ </Button>
+ )}
+ </DialogFooter>
+ </form>
+ </Form>
+ </div>
+ </DialogContent>
+ </Dialog>
+
+ {/* 저장하지 않고 나가기 확인 다이얼로그 - 편집 모드에서만 활성화 */}
+ {isEditable && (
+ <AlertDialog open={isExitDialogOpen} onOpenChange={setIsExitDialogOpen}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>저장되지 않은 변경사항</AlertDialogTitle>
+ <AlertDialogDescription>
+ 저장되지 않은 변경사항이 있습니다. 그래도 나가시겠습니까?
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>취소</AlertDialogCancel>
+ <AlertDialogAction onClick={() => {
+ setIsExitDialogOpen(false);
+ onOpenChange(false);
+ }}>
+ 저장하지 않고 나가기
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ )}
+ </>
+ );
+} \ No newline at end of file
diff --git a/lib/rfqs/table/add-rfq-dialog.tsx b/lib/rfqs/table/add-rfq-dialog.tsx
new file mode 100644
index 00000000..1d824bc0
--- /dev/null
+++ b/lib/rfqs/table/add-rfq-dialog.tsx
@@ -0,0 +1,349 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Check, ChevronsUpDown } from "lucide-react"
+import { toast } from "sonner"
+
+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 { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
+import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"
+
+import { useSession } from "next-auth/react"
+import { createRfqSchema, type CreateRfqSchema, RfqType } from "../validations"
+import { createRfq, getBudgetaryRfqs } from "../service"
+import { ProjectSelector } from "@/components/ProjectSelector"
+import { type Project } from "../service"
+import { cn } from "@/lib/utils"
+import { BudgetaryRfqSelector } from "./BudgetaryRfqSelector"
+import { type BudgetaryRfq as ServiceBudgetaryRfq } from "../service";
+
+// 부모 RFQ 정보 타입 정의
+interface BudgetaryRfq {
+ id: number;
+ rfqCode: string;
+ description: string | null;
+}
+
+interface AddRfqDialogProps {
+ rfqType?: RfqType;
+}
+
+export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const { data: session, status } = useSession()
+ const [budgetaryRfqs, setBudgetaryRfqs] = React.useState<BudgetaryRfq[]>([])
+ const [isLoadingBudgetary, setIsLoadingBudgetary] = React.useState(false)
+ const [budgetarySearchOpen, setBudgetarySearchOpen] = React.useState(false)
+ const [budgetarySearchTerm, setBudgetarySearchTerm] = React.useState("")
+ const [selectedBudgetaryRfq, setSelectedBudgetaryRfq] = React.useState<BudgetaryRfq | null>(null)
+
+ // 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 = () => {
+ return rfqType === RfqType.PURCHASE
+ ? "Purchase RFQ"
+ : "Budgetary RFQ";
+ };
+
+ // RHF + Zod
+ const form = useForm<CreateRfqSchema>({
+ resolver: zodResolver(createRfqSchema),
+ defaultValues: {
+ rfqCode: "",
+ description: "",
+ projectId: undefined,
+ parentRfqId: undefined,
+ dueDate: new Date(),
+ status: "DRAFT",
+ rfqType: rfqType,
+ // Don't set createdBy yet - we'll set it when the form is submitted
+ createdBy: undefined,
+ },
+ });
+
+ // Update form values when session loads
+ React.useEffect(() => {
+ if (status === "authenticated" && userId) {
+ form.setValue("createdBy", userId);
+ }
+ }, [status, userId, form]);
+
+ // Budgetary RFQ 목록 로드 (Purchase RFQ 생성 시만)
+ React.useEffect(() => {
+ if (rfqType === RfqType.PURCHASE && open) {
+ const loadBudgetaryRfqs = async () => {
+ setIsLoadingBudgetary(true);
+ try {
+ const result = await getBudgetaryRfqs();
+ if ('rfqs' in result) {
+ setBudgetaryRfqs(result.rfqs as unknown as BudgetaryRfq[]);
+ } else if ('error' in result) {
+ console.error("Budgetary RFQs 로드 오류:", result.error);
+ }
+ } catch (error) {
+ console.error("Budgetary RFQs 로드 오류:", error);
+ } finally {
+ setIsLoadingBudgetary(false);
+ }
+ };
+
+ loadBudgetaryRfqs();
+ }
+ }, [rfqType, open]);
+
+ // 검색어로 필터링된 Budgetary RFQ 목록
+ const filteredBudgetaryRfqs = React.useMemo(() => {
+ if (!budgetarySearchTerm.trim()) return budgetaryRfqs;
+
+ const lowerSearch = budgetarySearchTerm.toLowerCase();
+ return budgetaryRfqs.filter(
+ rfq =>
+ rfq.rfqCode.toLowerCase().includes(lowerSearch) ||
+ (rfq.description && rfq.description.toLowerCase().includes(lowerSearch))
+ );
+ }, [budgetaryRfqs, budgetarySearchTerm]);
+
+ // 프로젝트 선택 처리
+ const handleProjectSelect = (project: Project) => {
+ form.setValue("projectId", project.id);
+ };
+
+ // Budgetary RFQ 선택 처리
+ const handleBudgetaryRfqSelect = (rfq: BudgetaryRfq) => {
+ setSelectedBudgetaryRfq(rfq);
+ form.setValue("parentRfqId", rfq.id);
+ setBudgetarySearchOpen(false);
+ };
+
+ async function onSubmit(data: CreateRfqSchema) {
+ // Check if user is authenticated before submitting
+ if (status !== "authenticated" || !userId) {
+ toast.error("사용자 인증이 필요합니다. 다시 로그인해주세요.");
+ return;
+ }
+
+ // Make sure createdBy is set with the current user ID
+ const submitData = {
+ ...data,
+ createdBy: userId
+ };
+
+ console.log("Submitting form data:", submitData);
+
+ const result = await createRfq(submitData);
+ if (result.error) {
+ toast.error(`에러: ${result.error}`);
+ return;
+ }
+
+ toast.success("RFQ가 성공적으로 생성되었습니다.");
+ form.reset();
+ setSelectedBudgetaryRfq(null);
+ setOpen(false);
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset();
+ setSelectedBudgetaryRfq(null);
+ }
+ setOpen(nextOpen);
+ }
+
+ // Return a message or disabled state if user is not authenticated
+ if (status === "loading") {
+ return <Button variant="outline" size="sm" disabled>Loading...</Button>;
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ {/* 모달을 열기 위한 버튼 */}
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ Add {getTitle()}
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Create New {getTitle()}</DialogTitle>
+ <DialogDescription>
+ 새 {getTitle()} 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)}>
+ <div className="space-y-4 py-4">
+ {/* rfqType - hidden field */}
+ <FormField
+ control={form.control}
+ name="rfqType"
+ render={({ field }) => (
+ <input type="hidden" {...field} />
+ )}
+ />
+
+ {/* Project Selector */}
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Project</FormLabel>
+ <FormControl>
+ <ProjectSelector
+ selectedProjectId={field.value}
+ onProjectSelect={handleProjectSelect}
+ placeholder="프로젝트 선택..."
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Budgetary RFQ Selector - 구매용 RFQ 생성 시에만 표시 */}
+ {rfqType === RfqType.PURCHASE && (
+ <FormField
+ control={form.control}
+ name="parentRfqId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Budgetary RFQ (Optional)</FormLabel>
+ <FormControl>
+ <BudgetaryRfqSelector
+ selectedRfqId={field.value as number | undefined}
+ onRfqSelect={(rfq) => {
+ setSelectedBudgetaryRfq(rfq as any);
+ form.setValue("parentRfqId", rfq?.id);
+ }}
+ placeholder="Budgetary RFQ 선택..."
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ {/* rfqCode */}
+ <FormField
+ control={form.control}
+ name="rfqCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Code</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g. RFQ-2025-001" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* description */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Description</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g. 설명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* dueDate */}
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Due Date</FormLabel>
+ <FormControl>
+ <Input
+ type="date"
+ value={field.value ? field.value.toISOString().slice(0, 10) : ""}
+ onChange={(e) => {
+ const val = e.target.value
+ if (val) {
+ field.onChange(new Date(val + "T00:00:00"))
+ }
+ }}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* status (Read-only) */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Status</FormLabel>
+ <FormControl>
+ <Input
+ disabled
+ className="capitalize"
+ {...field}
+ onChange={() => {}} // Prevent changes
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={form.formState.isSubmitting || status !== "authenticated"}
+ >
+ Create
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/table/attachment-rfq-sheet.tsx b/lib/rfqs/table/attachment-rfq-sheet.tsx
new file mode 100644
index 00000000..57a170e1
--- /dev/null
+++ b/lib/rfqs/table/attachment-rfq-sheet.tsx
@@ -0,0 +1,430 @@
+"use client"
+
+import * as React from "react"
+import { z } from "zod"
+import { useForm, useFieldArray } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+ SheetFooter,
+ SheetClose,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription
+} from "@/components/ui/form"
+import { Trash2, Plus, Loader, Download, X, Eye, AlertCircle } from "lucide-react"
+import { useToast } from "@/hooks/use-toast"
+import { Badge } from "@/components/ui/badge"
+
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone"
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+ FileListSize,
+} from "@/components/ui/file-list"
+
+import prettyBytes from "pretty-bytes"
+import { processRfqAttachments } from "../service"
+import { format } from "path"
+import { formatDate } from "@/lib/utils"
+import { RfqType } from "../validations"
+import { RfqWithItemCount } from "@/db/schema/rfq"
+
+const MAX_FILE_SIZE = 6e8 // 600MB
+
+/** 기존 첨부 파일 정보 */
+interface ExistingAttachment {
+ id: number
+ fileName: string
+ filePath: string
+ createdAt?: Date // or Date
+ vendorId?: number | null
+ size?: number
+}
+
+/** 새로 업로드할 파일 */
+const newUploadSchema = z.object({
+ fileObj: z.any().optional(), // 실제 File
+})
+
+/** 기존 첨부 (react-hook-form에서 관리) */
+const existingAttachSchema = z.object({
+ id: z.number(),
+ fileName: z.string(),
+ filePath: z.string(),
+ vendorId: z.number().nullable().optional(),
+ createdAt: z.custom<Date>().optional(), // or use z.any().optional()
+ size: z.number().optional(),
+})
+
+/** RHF 폼 전체 스키마 */
+const attachmentsFormSchema = z.object({
+ rfqId: z.number().int(),
+ existing: z.array(existingAttachSchema),
+ newUploads: z.array(newUploadSchema),
+})
+
+type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema>
+
+interface RfqAttachmentsSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ defaultAttachments?: ExistingAttachment[]
+ rfqType?: RfqType
+ rfq: RfqWithItemCount | null
+ /** 업로드/삭제 후 상위 테이블에 itemCount 등을 업데이트하기 위한 콜백 */
+ onAttachmentsUpdated?: (rfqId: number, newItemCount: number) => void
+}
+
+/**
+ * RfqAttachmentsSheet:
+ * - 기존 첨부 목록 (다운로드 + 삭제)
+ * - 새 파일 Dropzone
+ * - Save 시 processRfqAttachments(server action)
+ */
+export function RfqAttachmentsSheet({
+ defaultAttachments = [],
+ onAttachmentsUpdated,
+ rfq,
+ rfqType,
+ ...props
+}: RfqAttachmentsSheetProps) {
+ const { toast } = useToast()
+ const [isPending, startUpdate] = React.useTransition()
+ const rfqId = rfq?.rfqId ?? 0;
+
+ // 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능
+ const isEditable = rfq?.status === "DRAFT";
+
+ // React Hook Form
+ const form = useForm<AttachmentsFormValues>({
+ resolver: zodResolver(attachmentsFormSchema),
+ defaultValues: {
+ rfqId,
+ existing: [],
+ newUploads: [],
+ },
+ })
+
+ const { reset, control, handleSubmit } = form
+
+ // defaultAttachments가 바뀔 때마다, RHF 상태를 reset
+ React.useEffect(() => {
+ reset({
+ rfqId,
+ existing: defaultAttachments.map((att) => ({
+ ...att,
+ vendorId: att.vendorId ?? null,
+ size: att.size ?? undefined,
+ })),
+ newUploads: [],
+ })
+ }, [rfqId, defaultAttachments, reset])
+
+ // Field Arrays
+ const {
+ fields: existingFields,
+ remove: removeExisting,
+ } = useFieldArray({ control, name: "existing" })
+
+ const {
+ fields: newUploadFields,
+ append: appendNewUpload,
+ remove: removeNewUpload,
+ } = useFieldArray({ control, name: "newUploads" })
+
+ // 기존 첨부 항목 중 삭제된 것 찾기
+ function findRemovedExistingIds(data: AttachmentsFormValues): number[] {
+ const finalIds = data.existing.map((att) => att.id)
+ const originalIds = defaultAttachments.map((att) => att.id)
+ return originalIds.filter((id) => !finalIds.includes(id))
+ }
+
+ async function onSubmit(data: AttachmentsFormValues) {
+ // 편집 불가능한 상태에서는 제출 방지
+ if (!isEditable) return;
+
+ startUpdate(async () => {
+ try {
+ const removedExistingIds = findRemovedExistingIds(data)
+ const newFiles = data.newUploads
+ .map((it) => it.fileObj)
+ .filter((f): f is File => !!f)
+
+ // 서버 액션
+ const res = await processRfqAttachments({
+ rfqId,
+ removedExistingIds,
+ newFiles,
+ vendorId: null, // vendor ID if needed
+ rfqType
+ })
+
+ if (!res.ok) throw new Error(res.error ?? "Unknown error")
+
+ const newCount = res.updatedItemCount ?? 0
+
+ toast({
+ variant: "default",
+ title: "Success",
+ description: "File(s) updated",
+ })
+
+ // 상위 테이블 등에 itemCount 업데이트
+ onAttachmentsUpdated?.(rfqId, newCount)
+
+ // 모달 닫기
+ props.onOpenChange?.(false)
+ } catch (err) {
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: String(err),
+ })
+ }
+ })
+ }
+
+ /** 기존 첨부 - X 버튼 */
+ function handleRemoveExisting(idx: number) {
+ // 편집 불가능한 상태에서는 삭제 방지
+ if (!isEditable) return;
+ removeExisting(idx)
+ }
+
+ /** 드롭존에서 파일 받기 */
+ function handleDropAccepted(acceptedFiles: File[]) {
+ // 편집 불가능한 상태에서는 파일 추가 방지
+ if (!isEditable) return;
+ const mapped = acceptedFiles.map((file) => ({ fileObj: file }))
+ appendNewUpload(mapped)
+ }
+
+ /** 드롭존에서 파일 거부(에러) */
+ function handleDropRejected(fileRejections: any[]) {
+ // 편집 불가능한 상태에서는 무시
+ if (!isEditable) return;
+
+ fileRejections.forEach((rej) => {
+ toast({
+ variant: "destructive",
+ title: "File Error",
+ description: rej.file.name + " not accepted",
+ })
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-sm">
+ <SheetHeader>
+ <SheetTitle className="flex items-center gap-2">
+ {isEditable ? "Manage Attachments" : "View Attachments"}
+ {rfq?.status && (
+ <Badge
+ variant={rfq.status === "DRAFT" ? "outline" : "secondary"}
+ className="ml-1"
+ >
+ {rfq.status}
+ </Badge>
+ )}
+ </SheetTitle>
+ <SheetDescription>
+ {`RFQ ${rfq?.rfqCode} - `}
+ {isEditable ? '파일 첨부/삭제' : '첨부 파일 보기'}
+ {!isEditable && (
+ <div className="mt-1 text-xs flex items-center gap-1 text-amber-600">
+ <AlertCircle className="h-3 w-3" />
+ <span>드래프트 상태가 아닌 RFQ는 첨부파일을 수정할 수 없습니다.</span>
+ </div>
+ )}
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ {/* 1) 기존 첨부 목록 */}
+ <div className="space-y-2">
+ <p className="font-semibold text-sm">Existing Attachments</p>
+ {existingFields.length === 0 && (
+ <p className="text-sm text-muted-foreground">No existing attachments</p>
+ )}
+ {existingFields.map((field, index) => {
+ const vendorLabel = field.vendorId ? "(Vendor)" : "(Internal)"
+ return (
+ <div
+ key={field.id}
+ className="flex items-center justify-between rounded border p-2"
+ >
+ <div className="flex flex-col text-sm">
+ <span className="font-medium">
+ {field.fileName} {vendorLabel}
+ </span>
+ {field.size && (
+ <span className="text-xs text-muted-foreground">
+ {Math.round(field.size / 1024)} KB
+ </span>
+ )}
+ {field.createdAt && (
+ <span className="text-xs text-muted-foreground">
+ Created at {formatDate(field.createdAt)}
+ </span>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ {/* 1) Download button (if filePath) */}
+ {field.filePath && (
+ <a
+ href={`/api/rfq-download?path=${encodeURIComponent(field.filePath)}`}
+ download={field.fileName}
+ className="text-sm"
+ >
+ <Button variant="ghost" size="icon" type="button">
+ <Download className="h-4 w-4" />
+ </Button>
+ </a>
+ )}
+ {/* 2) Remove button - 편집 가능할 때만 표시 */}
+ {isEditable && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ onClick={() => handleRemoveExisting(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ )
+ })}
+ </div>
+
+ {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */}
+ {isEditable ? (
+ <>
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ >
+ {({ maxSize }) => (
+ <FormField
+ control={control}
+ name="newUploads" // not actually used for storing each file detail
+ render={() => (
+ <FormItem>
+ <FormLabel>Drop Files Here</FormLabel>
+ <DropzoneZone className="flex justify-center">
+ <FormControl>
+ <DropzoneInput />
+ </FormControl>
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>Drop to upload</DropzoneTitle>
+ <DropzoneDescription>
+ Max size: {maxSize ? prettyBytes(maxSize) : "??? MB"}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ <FormDescription>Alternatively, click browse.</FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+ </Dropzone>
+
+ {/* newUpload fields -> FileList */}
+ {newUploadFields.length > 0 && (
+ <div className="grid gap-4">
+ <h6 className="font-semibold leading-none tracking-tight">
+ {`Files (${newUploadFields.length})`}
+ </h6>
+ <FileList>
+ {newUploadFields.map((field, idx) => {
+ const fileObj = form.getValues(`newUploads.${idx}.fileObj`)
+ if (!fileObj) return null
+
+ const fileName = fileObj.name
+ const fileSize = fileObj.size
+ return (
+ <FileListItem key={field.id}>
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{fileName}</FileListName>
+ <FileListDescription>
+ {`${prettyBytes(fileSize)}`}
+ </FileListDescription>
+ </FileListInfo>
+ <FileListAction onClick={() => removeNewUpload(idx)}>
+ <X />
+ <span className="sr-only">Remove</span>
+ </FileListAction>
+ </FileListHeader>
+ </FileListItem>
+ )
+ })}
+ </FileList>
+ </div>
+ )}
+ </>
+ ) : (
+ <div className="p-3 bg-muted rounded-md flex items-center justify-center">
+ <div className="text-center text-sm text-muted-foreground">
+ <Eye className="h-4 w-4 mx-auto mb-2" />
+ <p>보기 모드에서는 파일 첨부를 할 수 없습니다.</p>
+ </div>
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ {isEditable ? "Cancel" : "Close"}
+ </Button>
+ </SheetClose>
+ {isEditable && (
+ <Button
+ type="submit"
+ disabled={isPending || (form.getValues().newUploads.length === 0 && defaultAttachments.length === form.getValues().existing.length)}
+ >
+ {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/rfqs/table/delete-rfqs-dialog.tsx b/lib/rfqs/table/delete-rfqs-dialog.tsx
new file mode 100644
index 00000000..09596bc7
--- /dev/null
+++ b/lib/rfqs/table/delete-rfqs-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 { Rfq, RfqWithItemCount } from "@/db/schema/rfq"
+import { removeRfqs } from "../service"
+
+interface DeleteRfqsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ rfqs: Row<RfqWithItemCount>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteRfqsDialog({
+ rfqs,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteRfqsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removeRfqs({
+ ids: rfqs.map((rfq) => rfq.rfqId),
+ })
+
+ 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 ({rfqs.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">{rfqs.length}</span>
+ {rfqs.length === 1 ? " task" : " rfqs"} 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 ({rfqs.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">{rfqs.length}</span>
+ {rfqs.length === 1 ? " task" : " rfqs"} 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/table/feature-flags-provider.tsx b/lib/rfqs/table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/rfqs/table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/rfqs/table/feature-flags.tsx b/lib/rfqs/table/feature-flags.tsx
new file mode 100644
index 00000000..aaae6af2
--- /dev/null
+++ b/lib/rfqs/table/feature-flags.tsx
@@ -0,0 +1,96 @@
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface TasksTableContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const TasksTableContext = React.createContext<TasksTableContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useTasksTable() {
+ const context = React.useContext(TasksTableContext)
+ if (!context) {
+ throw new Error("useTasksTable must be used within a TasksTableProvider")
+ }
+ return context
+}
+
+export function TasksTableProvider({ children }: React.PropsWithChildren) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "featureFlags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ }
+ )
+
+ return (
+ <TasksTableContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit"
+ >
+ {dataTableConfig.featureFlags.map((flag) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className="whitespace-nowrap px-3 text-xs"
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon
+ className="mr-2 size-3.5 shrink-0"
+ aria-hidden="true"
+ />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </TasksTableContext.Provider>
+ )
+}
diff --git a/lib/rfqs/table/rfqs-table-columns.tsx b/lib/rfqs/table/rfqs-table-columns.tsx
new file mode 100644
index 00000000..98df3bc8
--- /dev/null
+++ b/lib/rfqs/table/rfqs-table-columns.tsx
@@ -0,0 +1,315 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis, Paperclip, Package } from "lucide-react"
+import { toast } from "sonner"
+
+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 { getRFQStatusIcon } from "@/lib/tasks/utils"
+import { rfqsColumnsConfig } from "@/config/rfqsColumnsConfig"
+import { RfqWithItemCount } from "@/db/schema/rfq"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { useRouter } from "next/navigation"
+import { RfqType } from "../validations"
+
+type NextRouter = ReturnType<typeof useRouter>;
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<RfqWithItemCount> | null>
+ >
+ openItemsModal: (rfqId: number) => void
+ openAttachmentsSheet: (rfqId: number) => void
+ router: NextRouter
+ rfqType?: RfqType
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({
+ setRowAction,
+ openItemsModal,
+ openAttachmentsSheet,
+ router,
+ rfqType,
+}: GetColumnsProps): ColumnDef<RfqWithItemCount>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<RfqWithItemCount> = {
+ 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<RfqWithItemCount> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ // Proceed 버튼 클릭 시 호출되는 함수
+ const handleProceed = () => {
+ const rfq = row.original
+ const itemCount = Number(rfq.itemCount || 0)
+ const attachCount = Number(rfq.attachCount || 0)
+
+ // 아이템과 첨부파일이 모두 0보다 커야 진행 가능
+ if (itemCount > 0 && attachCount > 0) {
+ router.push(
+ rfqType === RfqType.PURCHASE
+ ? `/evcp/rfq/${rfq.rfqId}`
+ : `/evcp/budgetary/${rfq.rfqId}`
+ )
+ } else {
+ // 조건을 충족하지 않는 경우 토스트 알림 표시
+ if (itemCount === 0 && attachCount === 0) {
+ toast.error("아이템과 첨부파일을 먼저 추가해주세요.")
+ } else if (itemCount === 0) {
+ toast.error("아이템을 먼저 추가해주세요.")
+ } else {
+ toast.error("첨부파일을 먼저 추가해주세요.")
+ }
+ }
+ }
+
+ 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={handleProceed}>
+ {row.original.status ==="DRAFT"?"Proceed":"View Detail"}
+ <DropdownMenuShortcut>↵</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) itemsColumn (아이템 개수 표시: 아이콘 + Badge)
+ // ----------------------------------------------------------------
+ const itemsColumn: ColumnDef<RfqWithItemCount> = {
+ id: "items",
+ header: "Items",
+ cell: ({ row }) => {
+ const rfq = row.original
+ const itemCount = rfq.itemCount || 0
+
+ const handleClick = () => {
+ openItemsModal(rfq.rfqId)
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ itemCount > 0 ? `View ${itemCount} items` : "Add items"
+ }
+ >
+ <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {itemCount > 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"
+ >
+ {itemCount}
+ </Badge>
+ )}
+ <span className="sr-only">
+ {itemCount > 0 ? `${itemCount} Items` : "Add Items"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ size: 60,
+ }
+
+ // ----------------------------------------------------------------
+ // 4) attachmentsColumn (첨부파일 개수 표시: 아이콘 + Badge)
+ // ----------------------------------------------------------------
+ const attachmentsColumn: ColumnDef<RfqWithItemCount> = {
+ id: "attachments",
+ header: "Attachments",
+ cell: ({ row }) => {
+ const fileCount = row.original.attachCount ?? 0
+
+ const handleClick = () => {
+ openAttachmentsSheet(row.original.rfqId)
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ fileCount > 0 ? `View ${fileCount} files` : "Add files"
+ }
+ >
+ <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {fileCount > 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"
+ >
+ {fileCount}
+ </Badge>
+ )}
+ <span className="sr-only">
+ {fileCount > 0 ? `${fileCount} Files` : "Add Files"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ size: 60,
+ }
+
+ // ----------------------------------------------------------------
+ // 5) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ const groupMap: Record<string, ColumnDef<RfqWithItemCount>[]> = {}
+
+ rfqsColumnsConfig.forEach((cfg) => {
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<RfqWithItemCount> = {
+ 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 === "status") {
+ const statusVal = row.original.status
+ if (!statusVal) return null
+ const Icon = getRFQStatusIcon(
+ statusVal as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED"
+ )
+ 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>
+ )
+ }
+
+ 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)
+ })
+
+ // groupMap -> nestedColumns
+ const nestedColumns: ColumnDef<RfqWithItemCount>[] = []
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ nestedColumns.push(...colDefs)
+ } else {
+ nestedColumns.push({
+ id: groupName,
+ header: groupName,
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 6) 최종 컬럼 배열
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ attachmentsColumn, // 첨부파일
+ actionsColumn,
+ itemsColumn, // 아이템
+ ]
+} \ No newline at end of file
diff --git a/lib/rfqs/table/rfqs-table-floating-bar.tsx b/lib/rfqs/table/rfqs-table-floating-bar.tsx
new file mode 100644
index 00000000..daef7e0b
--- /dev/null
+++ b/lib/rfqs/table/rfqs-table-floating-bar.tsx
@@ -0,0 +1,338 @@
+"use client"
+
+import * as React from "react"
+import { Table } from "@tanstack/react-table"
+import { toast } from "sonner"
+import { Calendar, type CalendarProps } from "@/components/ui/calendar"
+import { Button } from "@/components/ui/button"
+import { Portal } from "@/components/ui/portal"
+import {
+ Select,
+ SelectTrigger,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectValue,
+} from "@/components/ui/select"
+import { Separator } from "@/components/ui/separator"
+import {
+ Tooltip,
+ TooltipTrigger,
+ TooltipContent,
+} from "@/components/ui/tooltip"
+import { Kbd } from "@/components/kbd"
+import { ActionConfirmDialog } from "@/components/ui/action-dialog"
+
+import { ArrowUp, CheckCircle2, Download, Loader, Trash2, X, CalendarIcon } from "lucide-react"
+
+import { exportTableToExcel } from "@/lib/export"
+
+import { RfqWithItemCount, rfqs } from "@/db/schema/rfq"
+import { modifyRfqs, removeRfqs } from "../service"
+
+interface RfqsTableFloatingBarProps {
+ table: Table<RfqWithItemCount>
+}
+
+/**
+ * 추가된 로직:
+ * - 달력(캘린더) 아이콘 버튼
+ * - 눌렀을 때 Popover로 Calendar 표시
+ * - 날짜 선택 시 Confirm 다이얼로그 → modifyRfqs({ dueDate })
+ */
+export function RfqsTableFloatingBar({ table }: RfqsTableFloatingBarProps) {
+ const rows = table.getFilteredSelectedRowModel().rows
+ const [isPending, startTransition] = React.useTransition()
+ const [action, setAction] = React.useState<"update-status" | "export" | "delete" | "update-dueDate">()
+ const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false)
+
+ const [confirmProps, setConfirmProps] = React.useState<{
+ title: string
+ description?: string
+ onConfirm: () => Promise<void> | void
+ }>({
+ title: "",
+ description: "",
+ onConfirm: () => {},
+ })
+
+ // 캘린더 Popover 열림 여부
+ const [calendarOpen, setCalendarOpen] = React.useState(false)
+ const [selectedDate, setSelectedDate] = React.useState<Date | null>(null)
+
+ // Clear selection on Escape key press
+ React.useEffect(() => {
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ table.toggleAllRowsSelected(false)
+ }
+ }
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [table])
+
+ function handleDeleteConfirm() {
+ setAction("delete")
+ setConfirmProps({
+ title: `Delete ${rows.length} RFQ${rows.length > 1 ? "s" : ""}?`,
+ description: "This action cannot be undone.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ const { error } = await removeRfqs({
+ ids: rows.map((row) => row.original.rfqId),
+ })
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("RFQs deleted")
+ table.toggleAllRowsSelected(false)
+ setConfirmDialogOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+ function handleSelectStatus(newStatus: RfqWithItemCount["status"]) {
+ setAction("update-status")
+ setConfirmProps({
+ title: `Update ${rows.length} RFQ${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`,
+ description: "This action will override their current status.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ const { error } = await modifyRfqs({
+ ids: rows.map((row) => row.original.rfqId),
+ status: newStatus as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED",
+ })
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("RFQs updated")
+ setConfirmDialogOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+ // 1) 달력에서 날짜를 선택했을 때 → Confirm 다이얼로그
+ function handleDueDateSelect(newDate: Date) {
+ setAction("update-dueDate")
+
+ setConfirmProps({
+ title: `Update ${rows.length} RFQ${rows.length > 1 ? "s" : ""} Due Date to ${newDate.toDateString()}?`,
+ description: "This action will override their current due date.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ const { error } = await modifyRfqs({
+ ids: rows.map((r) => r.original.rfqId),
+ dueDate: newDate,
+ })
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("Due date updated")
+ setConfirmDialogOpen(false)
+ setCalendarOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+ // 2) Export
+ function handleExport() {
+ setAction("export")
+ startTransition(() => {
+ exportTableToExcel(table, {
+ excludeColumns: ["select", "actions"],
+ onlySelected: true,
+ })
+ })
+ }
+
+ // Floating bar UI
+ return (
+ <Portal>
+ <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5">
+ <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">
+ {/* Selection Info + Clear */}
+ <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">
+ {/* 1) Status Update */}
+ <Select
+ onValueChange={(value: RfqWithItemCount["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>
+ {rfqs.status.enumValues.map((status) => (
+ <SelectItem key={status} value={status} className="capitalize">
+ {status}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+
+ {/* 2) Due Date Update: Calendar Popover */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="icon"
+ className="size-7 border"
+ disabled={isPending}
+ onClick={() => setCalendarOpen((open) => !open)}
+ >
+ {isPending && action === "update-dueDate" ? (
+ <Loader className="size-3.5 animate-spin" aria-hidden="true" />
+ ) : (
+ <CalendarIcon className="size-3.5" aria-hidden="true" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Update Due Date</p>
+ </TooltipContent>
+ </Tooltip>
+
+ {/* Calendar Popover (간단 구현) */}
+ {calendarOpen && (
+ <div className="absolute bottom-16 z-50 rounded-md border bg-background p-2 shadow">
+ <Calendar
+ mode="single"
+ selected={selectedDate || new Date()}
+ onSelect={(date) => {
+ if (date) {
+ setSelectedDate(date)
+ handleDueDateSelect(date)
+ }
+ }}
+ initialFocus
+ />
+ </div>
+ )}
+
+ {/* 3) Export */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="icon"
+ className="size-7 border"
+ onClick={handleExport}
+ 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 tasks</p>
+ </TooltipContent>
+ </Tooltip>
+
+ {/* 4) Delete */}
+ <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 tasks</p>
+ </TooltipContent>
+ </Tooltip>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 공용 Confirm Dialog */}
+ <ActionConfirmDialog
+ open={confirmDialogOpen}
+ onOpenChange={setConfirmDialogOpen}
+ title={confirmProps.title}
+ description={confirmProps.description}
+ onConfirm={confirmProps.onConfirm}
+ isLoading={
+ isPending && (action === "delete" || action === "update-status" || action === "update-dueDate")
+ }
+ confirmLabel={
+ action === "delete"
+ ? "Delete"
+ : action === "update-status"
+ ? "Update"
+ : action === "update-dueDate"
+ ? "Update"
+ : "Confirm"
+ }
+ confirmVariant={action === "delete" ? "destructive" : "default"}
+ />
+ </Portal>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/table/rfqs-table-toolbar-actions.tsx b/lib/rfqs/table/rfqs-table-toolbar-actions.tsx
new file mode 100644
index 00000000..6402e625
--- /dev/null
+++ b/lib/rfqs/table/rfqs-table-toolbar-actions.tsx
@@ -0,0 +1,55 @@
+"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 { RfqWithItemCount } from "@/db/schema/rfq"
+import { DeleteRfqsDialog } from "./delete-rfqs-dialog"
+import { AddRfqDialog } from "./add-rfq-dialog"
+import { RfqType } from "../validations"
+
+
+interface RfqsTableToolbarActionsProps {
+ table: Table<RfqWithItemCount>
+ rfqType?: RfqType;
+}
+
+export function RfqsTableToolbarActions({ table , rfqType = RfqType.PURCHASE}: RfqsTableToolbarActionsProps) {
+ return (
+ <div className="flex items-center gap-2">
+ {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteRfqsDialog
+ rfqs={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+ {/** 2) 새 Task 추가 다이얼로그 */}
+ <AddRfqDialog rfqType={rfqType} />
+
+
+ {/** 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>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/table/rfqs-table.tsx b/lib/rfqs/table/rfqs-table.tsx
new file mode 100644
index 00000000..db5c31e7
--- /dev/null
+++ b/lib/rfqs/table/rfqs-table.tsx
@@ -0,0 +1,264 @@
+"use client"
+
+import * as React from "react"
+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 { getRFQStatusIcon } from "@/lib/tasks/utils"
+import { useFeatureFlags } from "./feature-flags-provider"
+import { getColumns } from "./rfqs-table-columns"
+import { fetchRfqAttachments, fetchRfqItems, getRfqs, getRfqStatusCounts } from "../service"
+import { RfqItem, RfqWithItemCount, rfqs } from "@/db/schema/rfq"
+import { RfqsTableFloatingBar } from "./rfqs-table-floating-bar"
+import { UpdateRfqSheet } from "./update-rfq-sheet"
+import { DeleteRfqsDialog } from "./delete-rfqs-dialog"
+import { RfqsTableToolbarActions } from "./rfqs-table-toolbar-actions"
+import { RfqsItemsDialog } from "./ItemsDialog"
+import { getAllItems } from "@/lib/items/service"
+import { RfqAttachmentsSheet } from "./attachment-rfq-sheet"
+import { useRouter } from "next/navigation"
+import { RfqType } from "../validations"
+
+interface RfqsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getRfqs>>,
+ Awaited<ReturnType<typeof getRfqStatusCounts>>,
+ Awaited<ReturnType<typeof getAllItems>>,
+ ]
+ >;
+ rfqType?: RfqType; // rfqType props 추가
+}
+
+export interface ExistingAttachment {
+ id: number;
+ fileName: string;
+ filePath: string;
+ createdAt?: Date;
+ vendorId?: number | null;
+ size?: number;
+}
+
+export interface ExistingItem {
+ id?: number;
+ itemCode: string;
+ description: string | null;
+ quantity: number | null;
+ uom: string | null;
+}
+
+export function RfqsTable({ promises, rfqType = RfqType.PURCHASE }: RfqsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ const [{ data, pageCount }, statusCounts, items] = React.use(promises)
+ const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
+ const [selectedRfqIdForAttachments, setSelectedRfqIdForAttachments] = React.useState<number | null>(null)
+ const [attachDefault, setAttachDefault] = React.useState<ExistingAttachment[]>([])
+ const [itemsDefault, setItemsDefault] = React.useState<ExistingItem[]>([])
+
+ const router = useRouter()
+
+ const itemsList = items?.map((v) => ({
+ code: v.itemCode ?? "",
+ name: v.itemName ?? "",
+ }));
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<RfqWithItemCount> | null>(null)
+
+ const [rowData, setRowData] = React.useState<RfqWithItemCount[]>(() => data)
+
+ const [itemsModalOpen, setItemsModalOpen] = React.useState(false);
+ const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null);
+
+
+ const selectedRfq = React.useMemo(() => {
+ return rowData.find(row => row.rfqId === selectedRfqId) || null;
+ }, [rowData, selectedRfqId]);
+
+ // rfqType에 따른 제목 계산
+ const getRfqTypeTitle = () => {
+ return rfqType === RfqType.PURCHASE ? "Purchase RFQ" : "Budgetary RFQ";
+ };
+
+ async function openItemsModal(rfqId: number) {
+ const itemList = await fetchRfqItems(rfqId)
+ setItemsDefault(itemList)
+ setSelectedRfqId(rfqId);
+ setItemsModalOpen(true);
+ }
+
+ async function openAttachmentsSheet(rfqId: number) {
+ // 4.1) Fetch current attachments from server (server action)
+ const list = await fetchRfqAttachments(rfqId) // returns ExistingAttachment[]
+ setAttachDefault(list)
+ setSelectedRfqIdForAttachments(rfqId)
+ setAttachmentsOpen(true)
+ setSelectedRfqId(rfqId);
+ }
+
+ function handleAttachmentsUpdated(rfqId: number, newCount: number, newList?: ExistingAttachment[]) {
+ // 5.1) update rowData itemCount
+ setRowData(prev =>
+ prev.map(r =>
+ r.rfqId === rfqId
+ ? { ...r, itemCount: newCount }
+ : r
+ )
+ )
+ // 5.2) if newList is provided, store it
+ if (newList) {
+ setAttachDefault(newList)
+ }
+ }
+
+ const columns = React.useMemo(() => getColumns({
+ setRowAction, router,
+ // we pass openItemsModal as a prop so the itemsColumn can call it
+ openItemsModal,
+ openAttachmentsSheet,
+ rfqType
+ }), [setRowAction, router, rfqType]);
+
+ /**
+ * This component can render either a faceted filter or a search filter based on the `options` prop.
+ */
+ const filterFields: DataTableFilterField<RfqWithItemCount>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ Code",
+ placeholder: "Filter RFQ Code...",
+ },
+ {
+ id: "status",
+ label: "Status",
+ options: rfqs.status.enumValues?.map((status) => {
+ // 명시적으로 status를 허용된 리터럴 타입으로 변환
+ const s = status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED";
+ return {
+ label: toSentenceCase(s),
+ value: s,
+ icon: getRFQStatusIcon(s),
+ count: statusCounts[s],
+ };
+ }),
+
+ }
+ ]
+
+ /**
+ * Advanced filter fields for the data table.
+ */
+ const advancedFilterFields: DataTableAdvancedFilterField<RfqWithItemCount>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ Code",
+ type: "text",
+ },
+ {
+ id: "description",
+ label: "Description",
+ type: "text",
+ },
+ {
+ id: "projectCode",
+ label: "Project Code",
+ type: "text",
+ },
+ {
+ id: "dueDate",
+ label: "Due Date",
+ type: "date",
+ },
+ {
+ id: "status",
+ label: "Status",
+ type: "multi-select",
+ options: rfqs.status.enumValues?.map((status) => {
+ // 명시적으로 status를 허용된 리터럴 타입으로 변환
+ const s = status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED";
+ return {
+ label: toSentenceCase(s),
+ value: s,
+ icon: getRFQStatusIcon(s),
+ count: statusCounts[s],
+ };
+ }),
+
+ },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.rfqId),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ floatingBar={<RfqsTableFloatingBar table={table} />}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <RfqsTableToolbarActions table={table} rfqType={rfqType} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <UpdateRfqSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ rfq={rowAction?.row.original ?? null}
+ rfqType={rfqType}
+ />
+
+ <DeleteRfqsDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ rfqs={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+
+ <RfqsItemsDialog
+ open={itemsModalOpen}
+ onOpenChange={setItemsModalOpen}
+ rfq={selectedRfq ?? null}
+ itemsList={itemsList}
+ defaultItems={itemsDefault}
+ rfqType={rfqType}
+ />
+
+ <RfqAttachmentsSheet
+ open={attachmentsOpen}
+ onOpenChange={setAttachmentsOpen}
+ defaultAttachments={attachDefault}
+ rfqType={rfqType}
+ rfq={selectedRfq ?? null}
+ onAttachmentsUpdated={handleAttachmentsUpdated}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/table/update-rfq-sheet.tsx b/lib/rfqs/table/update-rfq-sheet.tsx
new file mode 100644
index 00000000..769f25e7
--- /dev/null
+++ b/lib/rfqs/table/update-rfq-sheet.tsx
@@ -0,0 +1,283 @@
+"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 { useSession } from "next-auth/react"
+
+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 {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Input } from "@/components/ui/input"
+
+import { Rfq, RfqWithItemCount } from "@/db/schema/rfq"
+import { RfqType, updateRfqSchema, type UpdateRfqSchema } from "../validations"
+import { modifyRfq } from "../service"
+import { ProjectSelector } from "@/components/ProjectSelector"
+import { type Project } from "../service"
+import { BudgetaryRfqSelector } from "./BudgetaryRfqSelector"
+
+interface UpdateRfqSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ rfq: RfqWithItemCount | null
+ rfqType?: RfqType;
+}
+
+
+interface BudgetaryRfq {
+ id: number;
+ rfqCode: string;
+ description: string | null;
+}
+
+
+export function UpdateRfqSheet({ rfq,rfqType = RfqType.PURCHASE , ...props }: UpdateRfqSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const { data: session } = useSession()
+ const userId = Number(session?.user?.id || 1)
+ const [selectedBudgetaryRfq, setSelectedBudgetaryRfq] = React.useState<BudgetaryRfq | null>(null)
+
+ // RHF setup
+ const form = useForm<UpdateRfqSchema>({
+ resolver: zodResolver(updateRfqSchema),
+ defaultValues: {
+ id: rfq?.rfqId ?? 0, // PK
+ rfqCode: rfq?.rfqCode ?? "",
+ description: rfq?.description ?? "",
+ projectId: rfq?.projectId, // 프로젝트 ID
+ dueDate: rfq?.dueDate ?? undefined, // null을 undefined로 변환
+ status: rfq?.status ?? "DRAFT",
+ createdBy: rfq?.createdBy ?? userId,
+ },
+ });
+
+ // 프로젝트 선택 처리
+ const handleProjectSelect = (project: Project) => {
+ form.setValue("projectId", project.id);
+ };
+
+ async function onSubmit(input: UpdateRfqSchema) {
+ startUpdateTransition(async () => {
+ if (!rfq) return
+
+ const { error } = await modifyRfq({
+ ...input,
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false) // close the sheet
+ toast.success("RFQ updated!")
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Update RFQ</SheetTitle>
+ <SheetDescription>
+ Update the RFQ details and save the changes
+ </SheetDescription>
+ </SheetHeader>
+
+ {/* RHF Form */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+
+ {/* Hidden or code-based id field */}
+ <FormField
+ control={form.control}
+ name="id"
+ render={({ field }) => (
+ <input type="hidden" {...field} />
+ )}
+ />
+
+ {/* Project Selector - 재사용 컴포넌트 사용 */}
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Project</FormLabel>
+ <FormControl>
+ <ProjectSelector
+ selectedProjectId={field.value}
+ onProjectSelect={handleProjectSelect}
+ placeholder="프로젝트 선택..."
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Budgetary RFQ Selector - 구매용 RFQ 생성 시에만 표시 */}
+ {rfqType === RfqType.PURCHASE && (
+ <FormField
+ control={form.control}
+ name="parentRfqId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Budgetary RFQ (Optional)</FormLabel>
+ <FormControl>
+ <BudgetaryRfqSelector
+ selectedRfqId={field.value as number | undefined}
+ onRfqSelect={(rfq) => {
+ setSelectedBudgetaryRfq(rfq as any);
+ form.setValue("parentRfqId", rfq?.id);
+ }}
+ placeholder="Budgetary RFQ 선택..."
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+
+ {/* rfqCode */}
+ <FormField
+ control={form.control}
+ name="rfqCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Code</FormLabel>
+ <FormControl>
+ <Input placeholder="e.g. RFQ-2025-001" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* description */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Description</FormLabel>
+ <FormControl>
+ <Input placeholder="Description" {...field} value={field.value || ""} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+
+
+ {/* dueDate (type="date") */}
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Due Date</FormLabel>
+ <FormControl>
+ <Input
+ type="date"
+ // convert Date -> yyyy-mm-dd
+ value={field.value ? field.value.toISOString().slice(0, 10) : ""}
+ onChange={(e) => {
+ const val = e.target.value
+ field.onChange(val ? new Date(val + "T00:00:00") : undefined)
+ }}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* status (Select) */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Status</FormLabel>
+ <FormControl>
+ <Select
+ onValueChange={field.onChange}
+ value={field.value ?? "DRAFT"}
+ >
+ <SelectTrigger className="capitalize">
+ <SelectValue placeholder="Select status" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].map((item) => (
+ <SelectItem key={item} value={item} className="capitalize">
+ {item}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* createdBy (hidden or read-only) */}
+ <FormField
+ control={form.control}
+ name="createdBy"
+ render={({ field }) => (
+ <input type="hidden" {...field} />
+ )}
+ />
+
+ <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>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/tbe-table/comments-sheet.tsx b/lib/rfqs/tbe-table/comments-sheet.tsx
new file mode 100644
index 00000000..bea1fc8e
--- /dev/null
+++ b/lib/rfqs/tbe-table/comments-sheet.tsx
@@ -0,0 +1,334 @@
+"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 } 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 { 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
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ }[]
+}
+
+interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ /** 코멘트를 작성할 RFQ 정보 */
+ /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */
+ initialComments?: TbeComment[]
+
+ /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */
+ currentUserId: number
+ rfqId:number
+ vendorId:number
+ /** 댓글 저장 후 갱신용 콜백 (옵션) */
+ onCommentsUpdated?: (comments: TbeComment[]) => void
+}
+
+// 새 코멘트 작성 폼 스키마
+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,
+ onCommentsUpdated,
+ ...props
+}: CommentSheetProps) {
+ 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: []
+ }
+ })
+
+ // formFieldArray 예시 (파일 목록)
+ const { fields: newFileFields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "newFiles"
+ })
+
+ // 1) 기존 코멘트 + 첨부 보여주기
+ // 간단히 테이블 하나로 표현
+ // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음
+ function renderExistingComments() {
+ 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 || c.attachments.length === 0) && (
+ <span className="text-sm text-muted-foreground">No files</span>
+ )}
+ {c.attachments && c.attachments.length > 0 && (
+ <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}
+ 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.commentedBy ?? "-"}
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )
+ }
+
+ // 2) 새 파일 Drop
+ function handleDropAccepted(files: File[]) {
+ // 드롭된 File[]을 RHF field array에 추가
+ const toAppend = files.map((f) => f)
+ append(toAppend)
+ }
+
+
+ // 3) 저장(Submit)
+ async function onSubmit(data: CommentFormValues) {
+
+ if (!rfqId) return
+ startTransition(async () => {
+ try {
+ // 서버 액션 호출
+ const res = await createRfqCommentWithAttachments({
+ rfqId: rfqId,
+ vendorId: vendorId, // 필요시 세팅
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ evaluationId: null, // 필요시 세팅
+ files: data.newFiles
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create comment")
+ }
+
+ toast.success("Comment created")
+
+ // 새 코멘트를 다시 불러오거나,
+ // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트
+ const newComment: TbeComment = {
+ id: res.commentId, // 서버에서 반환된 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,
+ })) || [])
+ }
+ 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/rfqs/tbe-table/feature-flags-provider.tsx b/lib/rfqs/tbe-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/rfqs/tbe-table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/rfqs/tbe-table/file-dialog.tsx b/lib/rfqs/tbe-table/file-dialog.tsx
new file mode 100644
index 00000000..1d1a65ea
--- /dev/null
+++ b/lib/rfqs/tbe-table/file-dialog.tsx
@@ -0,0 +1,141 @@
+"use client"
+
+import * as React from "react"
+import { Download, X } from "lucide-react"
+import { toast } from "sonner"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDateTime } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+
+import {
+ FileList,
+ FileListItem,
+ FileListIcon,
+ FileListInfo,
+ FileListName,
+ FileListDescription,
+ FileListAction,
+} from "@/components/ui/file-list"
+import { getTbeFilesForVendor, getTbeSubmittedFiles } from "../service"
+
+interface TBEFileDialogProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ tbeId: number
+ vendorId: number
+ rfqId: number
+ onRefresh?: () => void
+}
+
+export function TBEFileDialog({
+ isOpen,
+ onOpenChange,
+ vendorId,
+ rfqId,
+ onRefresh,
+}: TBEFileDialogProps) {
+ const [submittedFiles, setSubmittedFiles] = React.useState<any[]>([])
+ const [isFetchingFiles, setIsFetchingFiles] = React.useState(false)
+
+
+ // Fetch submitted files when dialog opens
+ React.useEffect(() => {
+ if (isOpen && rfqId && vendorId) {
+ fetchSubmittedFiles()
+ }
+ }, [isOpen, rfqId, vendorId])
+
+ // Fetch submitted files using the service function
+ const fetchSubmittedFiles = async () => {
+ if (!rfqId || !vendorId) return
+
+ setIsFetchingFiles(true)
+ try {
+ const { files, error } = await getTbeFilesForVendor(rfqId, vendorId)
+
+ if (error) {
+ throw new Error(error)
+ }
+
+ setSubmittedFiles(files)
+ } catch (error) {
+ toast.error("Failed to load files: " + getErrorMessage(error))
+ } finally {
+ setIsFetchingFiles(false)
+ }
+ }
+
+ // Download submitted file
+ const downloadSubmittedFile = async (file: any) => {
+ try {
+ const response = await fetch(`/api/file/${file.id}/download`)
+ if (!response.ok) {
+ throw new Error("Failed to download file")
+ }
+
+ const blob = await response.blob()
+ const url = window.URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = url
+ a.download = file.fileName
+ document.body.appendChild(a)
+ a.click()
+ window.URL.revokeObjectURL(url)
+ document.body.removeChild(a)
+ } catch (error) {
+ toast.error("Failed to download file: " + getErrorMessage(error))
+ }
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-lg">
+ <DialogHeader>
+ <DialogTitle>TBE 응답 파일</DialogTitle>
+ <DialogDescription>제출된 파일 목록을 확인하고 다운로드하세요.</DialogDescription>
+ </DialogHeader>
+
+ {/* 제출된 파일 목록 */}
+ {isFetchingFiles ? (
+ <div className="flex justify-center items-center py-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
+ </div>
+ ) : submittedFiles.length > 0 ? (
+ <div className="grid gap-2">
+ <FileList>
+ {submittedFiles.map((file) => (
+ <FileListItem key={file.id} className="flex items-center justify-between gap-3">
+ <div className="flex items-center gap-3 flex-1">
+ <FileListIcon className="flex-shrink-0" />
+ <FileListInfo className="flex-1 min-w-0">
+ <FileListName className="text-sm font-medium truncate">{file.fileName}</FileListName>
+ <FileListDescription className="text-xs text-muted-foreground">
+ {file.uploadedAt ? formatDateTime(file.uploadedAt) : ""}
+ </FileListDescription>
+ </FileListInfo>
+ </div>
+ <FileListAction className="flex-shrink-0 ml-2">
+ <Button variant="ghost" size="icon" onClick={() => downloadSubmittedFile(file)}>
+ <Download className="h-4 w-4" />
+ <span className="sr-only">파일 다운로드</span>
+ </Button>
+ </FileListAction>
+ </FileListItem>
+ ))}
+ </FileList>
+ </div>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">제출된 파일이 없습니다.</div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/tbe-table/invite-vendors-dialog.tsx b/lib/rfqs/tbe-table/invite-vendors-dialog.tsx
new file mode 100644
index 00000000..e38e0ede
--- /dev/null
+++ b/lib/rfqs/tbe-table/invite-vendors-dialog.tsx
@@ -0,0 +1,203 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Send } 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 { Input } from "@/components/ui/input"
+
+import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
+import { inviteTbeVendorsAction } from "../service"
+
+interface InviteVendorsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<VendorWithTbeFields>["original"][]
+ rfqId: number
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function InviteVendorsDialog({
+ vendors,
+ rfqId,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: InviteVendorsDialogProps) {
+ const [isInvitePending, startInviteTransition] = React.useTransition()
+
+
+ // multiple 파일을 받을 state
+ const [files, setFiles] = React.useState<FileList | null>(null)
+
+ // 미디어쿼리 (desktop 여부)
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onInvite() {
+ startInviteTransition(async () => {
+ // 파일이 선택되지 않았다면 에러
+ if (!files || files.length === 0) {
+ toast.error("Please attach TBE files before inviting.")
+ return
+ }
+
+ // FormData 생성
+ const formData = new FormData()
+ formData.append("rfqId", String(rfqId))
+ vendors.forEach((vendor) => {
+ formData.append("vendorIds[]", String(vendor.id))
+ })
+
+ // multiple 파일
+ for (let i = 0; i < files.length; i++) {
+ formData.append("tbeFiles", files[i]) // key는 동일하게 "tbeFiles"
+ }
+
+ // 서버 액션 호출
+ const { error } = await inviteTbeVendorsAction(formData)
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ // 성공
+ props.onOpenChange?.(false)
+ toast.success("Vendors invited with TBE!")
+ onSuccess?.()
+ })
+ }
+
+ // 파일 선택 UI
+ const fileInput = (
+ <div className="mb-4">
+ <label className="mb-2 block font-medium">TBE Sheets</label>
+ <Input
+ type="file"
+ multiple
+ onChange={(e) => {
+ setFiles(e.target.files)
+ }}
+ />
+ </div>
+ )
+
+ // Desktop Dialog
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Send className="mr-2 size-4" aria-hidden="true" />
+ Invite ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Are you absolutely sure?</DialogTitle>
+ <DialogDescription>
+ This action cannot be undone. This will permanently invite{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 파일 첨부 */}
+ {fileInput}
+
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Invite selected rows"
+ variant="destructive"
+ onClick={onInvite}
+ // 파일이 없거나 초대 진행중이면 비활성화
+ disabled={isInvitePending || !files || files.length === 0}
+ >
+ {isInvitePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Invite
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ // Mobile Drawer
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Send className="mr-2 size-4" aria-hidden="true" />
+ Invite ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : 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>
+ </DrawerHeader>
+
+ {/* 파일 첨부 */}
+ {fileInput}
+
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Invite selected rows"
+ variant="destructive"
+ onClick={onInvite}
+ // 파일이 없거나 초대 진행중이면 비활성화
+ disabled={isInvitePending || !files || files.length === 0}
+ >
+ {isInvitePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Invite
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ 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
new file mode 100644
index 00000000..29fbd5cd
--- /dev/null
+++ b/lib/rfqs/tbe-table/tbe-table-columns.tsx
@@ -0,0 +1,307 @@
+"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 {
+ VendorTbeColumnConfig,
+ vendorTbeColumnsConfig,
+ VendorWithTbeFields,
+} from "@/config/vendorTbeColumnsConfig"
+
+type NextRouter = ReturnType<typeof useRouter>
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<VendorWithTbeFields> | null>
+ >
+ router: NextRouter
+ openCommentSheet: (vendorId: number) => void
+ openFilesDialog: (tbeId:number , vendorId: number) => void
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ openFilesDialog
+}: GetColumnsProps): ColumnDef<VendorWithTbeFields>[] {
+ // ----------------------------------------------------------------
+ // 1) Select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<VendorWithTbeFields> = {
+ 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<VendorWithTbeFields>[]> = {}
+
+ vendorTbeColumnsConfig.forEach((cfg) => {
+ const groupName = cfg.group || "_noGroup"
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // childCol: ColumnDef<VendorWithTbeFields>
+ const childCol: ColumnDef<VendorWithTbeFields> = {
+ 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 === "vendorStatus") {
+ const statusVal = row.original.vendorStatus
+ if (!statusVal) return null
+ // const Icon = getStatusIcon(statusVal)
+ return (
+ <Badge variant="outline">
+ {statusVal}
+ </Badge>
+ )
+ }
+
+
+ if (cfg.id === "rfqVendorStatus") {
+ const statusVal = row.original.rfqVendorStatus
+ 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>
+ )
+ }
+
+ // 예) TBE Updated (날짜)
+ if (cfg.id === "tbeUpdated") {
+ const dateVal = val as Date | undefined
+ if (!dateVal) return null
+ return formatDate(dateVal)
+ }
+
+ // 그 외 필드는 기본 값 표시
+ return val ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // groupMap → nestedColumns
+ const nestedColumns: ColumnDef<VendorWithTbeFields>[] = []
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ nestedColumns.push(...colDefs)
+ } else {
+ nestedColumns.push({
+ id: groupName,
+ header: groupName,
+ columns: colDefs,
+ })
+ }
+ })
+
+// ----------------------------------------------------------------
+// 3) Comments 컬럼
+// ----------------------------------------------------------------
+const commentsColumn: ColumnDef<VendorWithTbeFields> = {
+ 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.tbeId ?? 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>
+ )
+ },
+ enableSorting: false,
+ maxSize:80
+}
+
+ // ----------------------------------------------------------------
+ // 4) Actions 컬럼 (예: 초대하기 버튼)
+ // ----------------------------------------------------------------
+ // const actionsColumn: ColumnDef<VendorWithTbeFields> = {
+ // id: "actions",
+ // cell: ({ row }) => {
+ // const status = row.original.tbeResult
+ // // 예: 만약 tbeResult가 없을 때만 초대하기 버튼 표시
+ // if (status) {
+ // return null
+ // }
+
+ // return (
+ // <Button
+ // onClick={() => setRowAction({ row, type: "invite" })}
+ // size="sm"
+ // variant="outline"
+ // >
+ // 발행하기
+ // </Button>
+ // )
+ // },
+ // size: 80,
+ // enableSorting: false,
+ // enableHiding: false,
+ // }
+// ----------------------------------------------------------------
+// 3) Files Column - Add before Comments column
+// ----------------------------------------------------------------
+const filesColumn: ColumnDef<VendorWithTbeFields> = {
+ id: "files",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Response Files" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ // We'll assume that files count will be populated from the backend
+ // You'll need to modify your getTBE function to include files
+ const filesCount = vendor.files?.length ?? 0
+
+ function handleClick() {
+ // Open files dialog
+ setRowAction({ row, type: "files" })
+ openFilesDialog(vendor.tbeId ?? 0, vendor.vendorId ?? 0)
+ }
+
+ return (
+ <div className="flex items-center justify-center">
+<Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={filesCount > 0 ? `View ${filesCount} files` : "Upload file"}
+>
+ {/* 아이콘: 중앙 정렬을 위해 Button 자체가 flex container */}
+ <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+
+ {/* 파일 개수가 1개 이상이면 뱃지 표시 */}
+ {filesCount > 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"
+ >
+ {filesCount}
+ </Badge>
+ )}
+
+ <span className="sr-only">
+ {filesCount > 0 ? `${filesCount} Files` : "Upload File"}
+ </span>
+</Button>
+ </div>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80
+}
+
+// ----------------------------------------------------------------
+// 5) 최종 컬럼 배열 - Update to include the files column
+// ----------------------------------------------------------------
+return [
+ selectColumn,
+ ...nestedColumns,
+ filesColumn, // Add the files column before comments
+ commentsColumn,
+ // actionsColumn,
+]
+
+} \ No newline at end of file
diff --git a/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx b/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx
new file mode 100644
index 00000000..6a336135
--- /dev/null
+++ b/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx
@@ -0,0 +1,60 @@
+"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 { InviteVendorsDialog } from "./invite-vendors-dialog"
+import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
+
+interface VendorsTableToolbarActionsProps {
+ table: Table<VendorWithTbeFields>
+ rfqId: number
+}
+
+export function VendorsTableToolbarActions({ table,rfqId }: VendorsTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일이 선택되었을 때 처리
+
+ function handleImportClick() {
+ // 숨겨진 <input type="file" /> 요소를 클릭
+ fileInputRef.current?.click()
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <InviteVendorsDialog
+ vendors={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ rfqId = {rfqId}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+
+ <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/tbe-table/tbe-table.tsx b/lib/rfqs/tbe-table/tbe-table.tsx
new file mode 100644
index 00000000..c385ca0b
--- /dev/null
+++ b/lib/rfqs/tbe-table/tbe-table.tsx
@@ -0,0 +1,190 @@
+"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 { useFeatureFlags } from "./feature-flags-provider"
+import { getColumns } from "./tbe-table-columns"
+import { Vendor, vendors } from "@/db/schema/vendors"
+import { VendorsTableToolbarActions } from "./tbe-table-toolbar-actions"
+import { fetchRfqAttachmentsbyCommentId, getTBE } from "../service"
+import { InviteVendorsDialog } from "./invite-vendors-dialog"
+import { CommentSheet, TbeComment } from "./comments-sheet"
+import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
+import { TBEFileDialog } from "./file-dialog"
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getTBE>>,
+ ]
+ >
+ rfqId: number
+}
+
+
+export function TbeTable({ promises, rfqId }: VendorsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+
+ console.log(data)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithTbeFields> | null>(null)
+
+ // **router** 획득
+ const router = useRouter()
+
+ 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")
+
+ // Add handleRefresh function
+ const handleRefresh = React.useCallback(() => {
+ router.refresh();
+ }, [router]);
+
+ 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 tbeId = rowAction.row.original.tbeId ?? 0;
+ openFilesDialog(tbeId, vendorId);
+ }
+ }, [rowAction])
+
+ async function openCommentSheet(vendorId: number) {
+ setInitialComments([])
+
+ 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,
+ }
+ })
+ )
+ // 3) state에 저장 -> CommentSheet에서 initialComments로 사용
+ setInitialComments(commentWithAttachments)
+ }
+
+ setSelectedRfqIdForComments(vendorId)
+ setCommentSheetOpen(true)
+ }
+
+ const openFilesDialog = (tbeId: number, vendorId: number) => {
+ setSelectedTbeId(tbeId)
+ setSelectedVendorId(vendorId)
+ setIsFileDialogOpen(true)
+ }
+
+
+ // getColumns() 호출 시, router를 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog }),
+ [setRowAction, router]
+ )
+
+ const filterFields: DataTableFilterField<VendorWithTbeFields>[] = [
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorWithTbeFields>[] = [
+ { 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" },
+ ]
+
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "rfqVendorUpdated", 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}
+ >
+ <VendorsTableToolbarActions table={table} rfqId={rfqId} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ <InviteVendorsDialog
+ vendors={rowAction?.row.original ? [rowAction?.row.original] : []}
+ onOpenChange={() => setRowAction(null)}
+ rfqId={rfqId}
+ open={rowAction?.type === "invite"}
+ showTrigger={false}
+ />
+ <CommentSheet
+ currentUserId={1}
+ open={commentSheetOpen}
+ onOpenChange={setCommentSheetOpen}
+ rfqId={rfqId}
+ vendorId={selectedRfqIdForComments ?? 0}
+ initialComments={initialComments}
+ />
+
+ <TBEFileDialog
+ isOpen={isFileDialogOpen}
+ onOpenChange={setIsFileDialogOpen}
+ tbeId={selectedTbeId ?? 0}
+ vendorId={selectedVendorId ?? 0}
+ rfqId={rfqId} // Use the prop directly instead of data[0]?.rfqId
+ onRefresh={handleRefresh}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/validations.ts b/lib/rfqs/validations.ts
new file mode 100644
index 00000000..369e426c
--- /dev/null
+++ b/lib/rfqs/validations.ts
@@ -0,0 +1,274 @@
+import { createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} 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 { Vendor, vendors } from "@/db/schema/vendors";
+
+export const RfqType = {
+ PURCHASE: "PURCHASE",
+ BUDGETARY: "BUDGETARY"
+} as const;
+
+export type RfqType = typeof RfqType[keyof typeof RfqType];
+
+// =======================
+// 1) SearchParams (목록 필터링/정렬)
+// =======================
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<RfqsView>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 간단 검색 필드
+ rfqCode: parseAsString.withDefault(""),
+ projectCode: parseAsString.withDefault(""),
+ projectName: parseAsString.withDefault(""),
+ dueDate: parseAsString.withDefault(""),
+
+ // 상태 - 여러 개일 수 있다고 가정
+ status: parseAsArrayOf(z.enum(rfqs.status.enumValues)).withDefault([]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+ rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY"]).withDefault("PURCHASE"),
+
+});
+
+export type GetRfqsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
+
+
+export const searchParamsMatchedVCache = 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<VendorRfqViewBase>().withDefault([
+ { id: "rfqVendorUpdated", desc: true },
+ ]),
+
+ // 4) 간단 검색 필드
+ vendorName: parseAsString.withDefault(""),
+ vendorCode: parseAsString.withDefault(""),
+ country: parseAsString.withDefault(""),
+ email: parseAsString.withDefault(""),
+ website: parseAsString.withDefault(""),
+
+ // 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED"
+ // rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리
+ vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]),
+
+ // 6) 고급 필터 (nuqs - filterColumns)
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 7) 글로벌 검색어
+ search: parseAsString.withDefault(""),
+})
+export type GetMatchedVendorsSchema = Awaited<ReturnType<typeof searchParamsMatchedVCache.parse>>;
+
+export const searchParamsTBECache = 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<VendorTbeView>().withDefault([
+ { id: "tbeUpdated", desc: true },
+ ]),
+
+ // 4) 간단 검색 필드
+ vendorName: parseAsString.withDefault(""),
+ vendorCode: parseAsString.withDefault(""),
+ country: parseAsString.withDefault(""),
+ email: parseAsString.withDefault(""),
+ website: parseAsString.withDefault(""),
+
+ tbeResult: parseAsString.withDefault(""),
+ tbeNote: parseAsString.withDefault(""),
+ tbeUpdated: parseAsString.withDefault(""),
+ rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY"]).withDefault("PURCHASE"),
+
+ // 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED"
+ // rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리
+ vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]),
+
+ // 6) 고급 필터 (nuqs - filterColumns)
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 7) 글로벌 검색어
+ search: parseAsString.withDefault(""),
+})
+export type GetTBESchema = Awaited<ReturnType<typeof searchParamsTBECache.parse>>;
+
+// =======================
+// 2) Create RFQ Schema
+// =======================
+export const createRfqSchema = z.object({
+ rfqCode: z.string().min(3, "RFQ 코드는 최소 3글자 이상이어야 합니다"),
+ description: z.string().optional(),
+ projectId: z.number().nullable().optional(), // 프로젝트 ID (선택적)
+ parentRfqId: z.number().nullable().optional(), // 부모 RFQ ID (선택적)
+ dueDate: z.date(),
+ status: z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]),
+ rfqType: z.enum([RfqType.PURCHASE, RfqType.BUDGETARY]).default(RfqType.PURCHASE),
+ createdBy: z.number(),
+});
+
+export type CreateRfqSchema = z.infer<typeof createRfqSchema>;
+
+export const createRfqItemSchema = z.object({
+ rfqId: z.number().int().min(1, "Invalid RFQ ID"),
+ itemCode: z.string().min(1),
+ itemName: z.string().optional(),
+ description: z.string().optional(),
+ quantity: z.number().min(1).optional(),
+ uom: z.string().optional(),
+ rfqType: z.string().default("PURCHASE"), // rfqType 필드 추가
+
+});
+
+export type CreateRfqItemSchema = z.infer<typeof createRfqItemSchema>;
+
+// =======================
+// 3) Update RFQ Schema
+// (현재 코드엔 updateTaskSchema라고 되어 있는데,
+// RFQ 업데이트이므로 'updateRfqSchema'라 명명하는 게 자연스러움)
+// =======================
+export const updateRfqSchema = z.object({
+ // PK id -> 실제로는 URL params로 받을 수도 있지만,
+ // 여기서는 body에서 받는다고 가정
+ id: z.number().int().min(1, "Invalid ID"),
+
+ // 업데이트 시 대부분 optional
+ rfqCode: z.string().max(50).optional(),
+ projectId: z.number().nullable().optional(), // null 값도 허용
+ description: z.string().optional(),
+ parentRfqId: z.number().nullable().optional(), // 부모 RFQ ID (선택적)
+ dueDate: z.preprocess(
+ // null이나 빈 문자열을 undefined로 변환
+ (val) => (val === null || val === '') ? undefined : val,
+ z.date().optional()
+ ),
+ status: z.union([
+ z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]),
+ z.string().refine(
+ (val) => ["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].includes(val),
+ { message: "Invalid status value" }
+ )
+ ]).optional(),
+ createdBy: z.number().int().min(1).optional(),
+});
+export type UpdateRfqSchema = z.infer<typeof updateRfqSchema>;
+
+export const searchParamsRfqsForVendorsCache = createSearchParamsCache({
+ // 1) 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // 2) 페이지네이션
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 3) 정렬 (rfqs 테이블)
+ sort: getSortingStateParser<Rfq>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 4) 간단 검색 필드 (예: rfqCode, projectName, projectCode 등)
+ rfqCode: parseAsString.withDefault(""),
+ projectCode: parseAsString.withDefault(""),
+ projectName: parseAsString.withDefault(""),
+
+ // 5) 상태 배열 (rfqs.status.enumValues: "DRAFT" | "PUBLISHED" | ...)
+ status: parseAsArrayOf(z.enum(rfqs.status.enumValues)).withDefault([]),
+
+ // 6) 고급 필터 (nuqs filterColumns)
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 7) 글로벌 검색어
+ search: parseAsString.withDefault(""),
+})
+
+/**
+ * 최종 타입
+ * `Awaited<ReturnType<...parse>>` 형태로
+ * Next.js 13 서버 액션이나 클라이언트에서 사용 가능
+ */
+export type GetRfqsForVendorsSchema = Awaited<ReturnType<typeof searchParamsRfqsForVendorsCache.parse>>
+
+export const updateRfqVendorSchema = z.object({
+ id: z.number().int().min(1, "Invalid ID"), // rfq_vendors.id
+ status: z.enum(["INVITED","ACCEPTED","DECLINED","REVIEWING", "RESPONDED"])
+})
+
+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 },
+ ]),
+
+ // 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"]).withDefault("PURCHASE"),
+
+
+ totalCost: parseAsInteger.withDefault(0),
+ currency: parseAsString.withDefault(""),
+ paymentTerms: parseAsString.withDefault(""),
+ incoterms: parseAsString.withDefault(""),
+ deliverySchedule: parseAsString.withDefault(""),
+
+ // 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED"
+ // rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리
+ vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]),
+
+ // 6) 고급 필터 (nuqs - filterColumns)
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 7) 글로벌 검색어
+ search: parseAsString.withDefault(""),
+})
+export type GetCBESchema = Awaited<ReturnType<typeof searchParamsCBECache.parse>>;
diff --git a/lib/rfqs/vendor-table/add-vendor-dialog.tsx b/lib/rfqs/vendor-table/add-vendor-dialog.tsx
new file mode 100644
index 00000000..8ec5b9f4
--- /dev/null
+++ b/lib/rfqs/vendor-table/add-vendor-dialog.tsx
@@ -0,0 +1,37 @@
+"use client"
+
+import * as React from "react"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { VendorsListTable } from "./vendor-list/vendor-list-table"
+
+interface VendorsListTableProps {
+ rfqId: number // so we know which RFQ to insert into
+ }
+
+
+/**
+ * A dialog that contains a client-side table or infinite scroll
+ * for "all vendors," allowing the user to select vendors and add them to the RFQ.
+ */
+export function AddVendorDialog({ rfqId }: VendorsListTableProps) {
+ const [open, setOpen] = React.useState(false)
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button size="sm">
+ Add Vendor
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1600, height:680}}>
+ <DialogHeader>
+ <DialogTitle>Add Vendor to RFQ</DialogTitle>
+ </DialogHeader>
+
+ <VendorsListTable rfqId={rfqId}/>
+
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/vendor-table/comments-sheet.tsx b/lib/rfqs/vendor-table/comments-sheet.tsx
new file mode 100644
index 00000000..644869c6
--- /dev/null
+++ b/lib/rfqs/vendor-table/comments-sheet.tsx
@@ -0,0 +1,303 @@
+"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 } 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 MatchedVendorComment {
+ id: number
+ commentText: string
+ commentedBy?: number
+ createdAt?: Date
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ }[]
+}
+
+// 1) props 정의
+interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ initialComments?: MatchedVendorComment[]
+ currentUserId: number
+ rfqId: number
+ vendorId: number
+ onCommentsUpdated?: (comments: MatchedVendorComment[]) => void
+}
+
+// 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,
+ onCommentsUpdated,
+ ...props
+}: CommentSheetProps) {
+ const [comments, setComments] = React.useState<MatchedVendorComment[]>(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 (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={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.commentedBy ?? "-"}</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,
+ files: data.newFiles,
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create comment")
+ }
+
+ toast.success("Comment created")
+
+ // 임시로 새 코멘트 추가
+ const newComment: MatchedVendorComment = {
+ 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 && <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/rfqs/vendor-table/feature-flags-provider.tsx b/lib/rfqs/vendor-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/rfqs/vendor-table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/rfqs/vendor-table/invite-vendors-dialog.tsx b/lib/rfqs/vendor-table/invite-vendors-dialog.tsx
new file mode 100644
index 00000000..23853e2f
--- /dev/null
+++ b/lib/rfqs/vendor-table/invite-vendors-dialog.tsx
@@ -0,0 +1,177 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Send, Trash, AlertTriangle } 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 { Alert, AlertDescription } from "@/components/ui/alert"
+
+import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig"
+import { inviteVendors } from "../service"
+import { RfqType } from "@/lib/rfqs/validations"
+
+interface DeleteTasksDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<MatchedVendorRow>["original"][]
+ rfqId:number
+ rfqType: RfqType
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function InviteVendorsDialog({
+ vendors,
+ rfqId,
+ rfqType,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteTasksDialogProps) {
+ const [isInvitePending, startInviteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startInviteTransition(async () => {
+ const { error } = await inviteVendors({
+ rfqId,
+ vendorIds: vendors.map((vendor) => Number(vendor.id)),
+ rfqType
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Vendor invited")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Send className="mr-2 size-4" aria-hidden="true" />
+ Invite ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Are you absolutely sure?</DialogTitle>
+ <DialogDescription>
+ This action cannot be undone. This will permanently invite{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 편집 제한 경고 메시지 */}
+ <Alert variant="destructive" className="mt-4">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription className="font-medium">
+ 한 업체라도 초대를 하고 나면 아이템 편집과 RFQ 문서 첨부 편집은 불가능합니다.
+ </AlertDescription>
+ </Alert>
+
+ <DialogFooter className="gap-2 sm:space-x-0 mt-6">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Invite selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isInvitePending}
+ >
+ {isInvitePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Invite
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Invite ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : 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"} from our servers.
+ </DrawerDescription>
+ </DrawerHeader>
+
+ {/* 편집 제한 경고 메시지 (모바일용) */}
+ <div className="px-4">
+ <Alert variant="destructive">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription className="font-medium">
+ 한 업체라도 초대를 하고 나면 아이템 편집과 RFQ 문서 첨부 편집은 불가능합니다.
+ </AlertDescription>
+ </Alert>
+ </div>
+
+ <DrawerFooter className="gap-2 sm:space-x-0 mt-4">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isInvitePending}
+ >
+ {isInvitePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Invite
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/vendor-table/vendor-list/vendor-list-table-column.tsx b/lib/rfqs/vendor-table/vendor-list/vendor-list-table-column.tsx
new file mode 100644
index 00000000..bfcbe75b
--- /dev/null
+++ b/lib/rfqs/vendor-table/vendor-list/vendor-list-table-column.tsx
@@ -0,0 +1,154 @@
+"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 { VendorData } from "./vendor-list-table"
+import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"
+import { formatDate } from "@/lib/utils"
+import { Checkbox } from "@/components/ui/checkbox"
+
+export interface DataTableRowAction<TData> {
+ row: Row<TData>
+ type: "open" | "update" | "delete"
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorData> | null>>
+ setSelectedVendorIds: React.Dispatch<React.SetStateAction<number[]>> // Changed to array
+}
+
+/** getColumns: return array of ColumnDef for 'vendors' data */
+export function getColumns({
+ setRowAction,
+ setSelectedVendorIds, // Changed parameter name
+}: GetColumnsProps): ColumnDef<VendorData>[] {
+ return [
+ // MULTIPLE SELECT COLUMN
+ {
+ id: "select",
+ enableSorting: false,
+ enableHiding: false,
+ size: 40,
+ // Add checkbox in header for select all functionality
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getFilteredSelectedRowModel().rows.length > 0 &&
+ table.getFilteredSelectedRowModel().rows.length === table.getFilteredRowModel().rows.length
+ }
+ onCheckedChange={(checked) => {
+ table.toggleAllRowsSelected(!!checked)
+
+ // Update selectedVendorIds based on all rows selection
+ if (checked) {
+ const allIds = table.getFilteredRowModel().rows.map(row => row.original.id)
+ setSelectedVendorIds(allIds)
+ } else {
+ setSelectedVendorIds([])
+ }
+ }}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row }) => {
+ const isSelected = row.getIsSelected()
+
+ return (
+ <Checkbox
+ checked={isSelected}
+ onCheckedChange={(checked) => {
+ row.toggleSelected(!!checked)
+
+ // Update the selectedVendorIds state by adding or removing this ID
+ setSelectedVendorIds(prevIds => {
+ if (checked) {
+ // Add this ID if it doesn't exist
+ return prevIds.includes(row.original.id)
+ ? prevIds
+ : [...prevIds, row.original.id]
+ } else {
+ // Remove this ID
+ return prevIds.filter(id => id !== row.original.id)
+ }
+ })
+ }}
+ aria-label="Select row"
+ />
+ )
+ },
+ },
+
+ // Vendor Name
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Vendor Name" />
+ ),
+ cell: ({ row }) => row.getValue("vendorName"),
+ },
+
+ // Vendor Code
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Vendor Code" />
+ ),
+ cell: ({ row }) => row.getValue("vendorCode"),
+ },
+
+ // Status
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Status" />
+ ),
+ cell: ({ row }) => row.getValue("status"),
+ },
+
+ // Country
+ {
+ accessorKey: "country",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Country" />
+ ),
+ cell: ({ row }) => row.getValue("country"),
+ },
+
+ // Email
+ {
+ accessorKey: "email",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Email" />
+ ),
+ cell: ({ row }) => row.getValue("email"),
+ },
+
+ // Phone
+ {
+ accessorKey: "phone",
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple column={column} title="Phone" />
+ ),
+ cell: ({ row }) => row.getValue("phone"),
+ },
+
+ // 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/vendor-table/vendor-list/vendor-list-table.tsx b/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx
new file mode 100644
index 00000000..c436eebd
--- /dev/null
+++ b/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx
@@ -0,0 +1,142 @@
+"use client"
+
+import * as React from "react"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { DataTableRowAction, getColumns } from "./vendor-list-table-column"
+import { DataTableAdvancedFilterField } from "@/types/table"
+import { addItemToVendors, getAllVendors } from "../../service"
+import { Loader2, Plus } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { useToast } from "@/hooks/use-toast"
+
+export 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
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface VendorsListTableProps {
+ rfqId: number
+}
+
+export function VendorsListTable({ rfqId }: VendorsListTableProps) {
+ const { toast } = useToast()
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<VendorData> | null>(null)
+
+ // Changed to array for multiple selection
+ const [selectedVendorIds, setSelectedVendorIds] = React.useState<number[]>([])
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, setSelectedVendorIds }),
+ [setRowAction, setSelectedVendorIds]
+ )
+
+ const [vendors, setVendors] = React.useState<VendorData[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ React.useEffect(() => {
+ async function loadAllVendors() {
+ setIsLoading(true)
+ try {
+ const allVendors = await getAllVendors()
+ setVendors(allVendors)
+ } catch (error) {
+ console.error("벤더 목록 로드 오류:", error)
+ toast({
+ title: "Error",
+ description: "Failed to load vendors",
+ variant: "destructive",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+ loadAllVendors()
+ }, [toast])
+
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = []
+
+ async function handleAddVendors() {
+ if (selectedVendorIds.length === 0) return // Safety check
+
+ setIsSubmitting(true)
+ try {
+ // Update to use the multiple vendor service
+ const result = await addItemToVendors(rfqId, selectedVendorIds)
+
+ if (result.success) {
+ toast({
+ title: "Success",
+ description: `Added items to ${selectedVendorIds.length} vendors`,
+ })
+ // Reset selection after successful addition
+ setSelectedVendorIds([])
+ } else {
+ toast({
+ title: "Error",
+ description: result.error || "Failed to add items to vendors",
+ variant: "destructive",
+ })
+ }
+ } catch (err) {
+ console.error("Failed to add vendors:", err)
+ toast({
+ title: "Error",
+ description: "An unexpected error occurred",
+ variant: "destructive",
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // 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={vendors}
+ columns={columns}
+ advancedFilterFields={advancedFilterFields}
+ >
+ <div className="flex items-center gap-2">
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleAddVendors}
+ disabled={selectedVendorIds.length === 0 || isSubmitting}
+ >
+ {isSubmitting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Adding...
+ </>
+ ) : (
+ <>
+ <Plus className="mr-2 h-4 w-4" />
+ Add Vendors ({selectedVendorIds.length})
+ </>
+ )}
+ </Button>
+ </div>
+ </ClientDataTable>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/vendor-table/vendors-table-columns.tsx b/lib/rfqs/vendor-table/vendors-table-columns.tsx
new file mode 100644
index 00000000..1220cb9d
--- /dev/null
+++ b/lib/rfqs/vendor-table/vendors-table-columns.tsx
@@ -0,0 +1,264 @@
+"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 { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import { useRouter } from "next/navigation"
+
+import { vendors } from "@/db/schema/vendors"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { vendorColumnsConfig } from "@/config/vendorColumnsConfig"
+import { Separator } from "@/components/ui/separator"
+import { MatchedVendorRow, vendorRfqColumnsConfig } from "@/config/vendorRfbColumnsConfig"
+
+
+type NextRouter = ReturnType<typeof useRouter>;
+
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<MatchedVendorRow> | null>>;
+ router: NextRouter;
+ openCommentSheet: (rfqId: number) => void;
+
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction, router, openCommentSheet }: GetColumnsProps): ColumnDef<MatchedVendorRow>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<MatchedVendorRow> = {
+ 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 메뉴)
+ // ----------------------------------------------------------------
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<MatchedVendorRow>[] }
+ const groupMap: Record<string, ColumnDef<MatchedVendorRow>[]> = {}
+
+ vendorRfqColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<MatchedVendorRow> = {
+ 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 === "vendorStatus") {
+ const statusVal = row.original.vendorStatus
+ if (!statusVal) return null
+ // const Icon = getStatusIcon(statusVal)
+ return (
+ <Badge variant="outline">
+ {statusVal}
+ </Badge>
+ )
+ }
+
+ if (cfg.id === "rfqVendorStatus") {
+ const statusVal = row.original.rfqVendorStatus
+ if (!statusVal) return null
+ // const Icon = getStatusIcon(statusVal)
+ const variant = statusVal === "INVITED" ? "default" : statusVal === "REJECTED" ? "destructive" : statusVal === "ACCEPTED" ? "secondary" : "outline"
+ return (
+ <Badge variant={variant}>
+ {statusVal}
+ </Badge>
+ )
+ }
+
+
+ if (cfg.id === "rfqVendorUpdated") {
+ const dateVal = cell.getValue() as Date
+ if (!dateVal) return null
+ return formatDate(dateVal)
+ }
+
+
+ // code etc...
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ const actionsColumn: ColumnDef<MatchedVendorRow> = {
+ id: "actions",
+ // header: "Actions",
+ cell: ({ row }) => {
+ const rfq = row.original
+ const commCount = rfq.comments?.length ?? 0
+ const status = row.original.rfqVendorStatus
+
+ // 공통 코멘트 핸들러
+ function handleCommentClick() {
+ setRowAction({ row, type: "comments" })
+ openCommentSheet(Number(row.original.id))
+ }
+
+ 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">
+
+ {/* 기존 기능: status가 INVITED일 때만 표시 */}
+ {(!status || status === 'INVITED') && (
+ <DropdownMenuItem onSelect={() => setRowAction({ row, type: "invite" })}>
+ 발행하기
+ </DropdownMenuItem>
+ )}
+ {/* 두 기능 사이 구분선 */}
+ <DropdownMenuSeparator />
+ {/* 코멘트 메뉴 항목 */}
+ <DropdownMenuItem onSelect={handleCommentClick}>
+ {commCount > 0 ? `${commCount} Comments` : "Add Comment"}
+ </DropdownMenuItem>
+
+
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+
+ // const commentsColumn: ColumnDef<MatchedVendorRow> = {
+ // id: "comments",
+ // header: "Comments",
+ // cell: ({ row }) => {
+ // const rfq = row.original
+ // const commCount = rfq.comments?.length ?? 0
+
+ // // 공통 클릭 핸들러
+ // function handleClick() {
+ // setRowAction({ row, type: "comments" })
+ // openCommentSheet(Number(row.original.id))
+ // }
+
+ // return commCount > 0 ? (
+ // <a
+ // href="#"
+ // onClick={(e) => {
+ // e.preventDefault()
+ // handleClick()
+ // }}
+ // >
+ // {commCount} Comments
+ // </a>
+ // ) : (
+ // <Button size="sm" variant="outline" onClick={handleClick}>
+ // Add Comment
+ // </Button>
+ // )
+ // },
+ // }
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<MatchedVendorRow>[] = []
+
+ // 순서를 고정하고 싶다면 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,
+ // commentsColumn,
+ actionsColumn
+
+ ]
+} \ No newline at end of file
diff --git a/lib/rfqs/vendor-table/vendors-table-floating-bar.tsx b/lib/rfqs/vendor-table/vendors-table-floating-bar.tsx
new file mode 100644
index 00000000..9b32cf5f
--- /dev/null
+++ b/lib/rfqs/vendor-table/vendors-table-floating-bar.tsx
@@ -0,0 +1,137 @@
+"use client"
+
+import * as React from "react"
+import { SelectTrigger } from "@radix-ui/react-select"
+import { type Table } from "@tanstack/react-table"
+import {
+ ArrowUp,
+ CheckCircle2,
+ Download,
+ Loader,
+ Trash2,
+ X,
+} from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { Portal } from "@/components/ui/portal"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+} from "@/components/ui/select"
+import { Separator } from "@/components/ui/separator"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { Kbd } from "@/components/kbd"
+
+import { ActionConfirmDialog } from "@/components/ui/action-dialog"
+import { vendors } from "@/db/schema/vendors"
+import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig"
+
+interface VendorsTableFloatingBarProps {
+ table: Table<MatchedVendorRow>
+}
+
+
+export function VendorsTableFloatingBar({ table }: VendorsTableFloatingBarProps) {
+ const rows = table.getFilteredSelectedRowModel().rows
+
+ const [isPending, startTransition] = React.useTransition()
+ const [action, setAction] = React.useState<
+ "update-status" | "export" | "delete"
+ >()
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+ // Clear selection on Escape key press
+ 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
+ const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false)
+ const [confirmProps, setConfirmProps] = React.useState<{
+ title: string
+ description?: string
+ onConfirm: () => Promise<void> | void
+ }>({
+ title: "",
+ description: "",
+ onConfirm: () => { },
+ })
+
+
+
+
+
+ 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>
+
+ </div>
+ </div>
+ </div>
+
+
+ {/* 공용 Confirm Dialog */}
+ <ActionConfirmDialog
+ open={confirmDialogOpen}
+ onOpenChange={setConfirmDialogOpen}
+ title={confirmProps.title}
+ description={confirmProps.description}
+ onConfirm={confirmProps.onConfirm}
+ isLoading={isPending && (action === "delete" || action === "update-status")}
+ confirmLabel={
+ action === "delete"
+ ? "Delete"
+ : action === "update-status"
+ ? "Update"
+ : "Confirm"
+ }
+ confirmVariant={
+ action === "delete" ? "destructive" : "default"
+ }
+ />
+ </Portal>
+ )
+}
diff --git a/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx b/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx
new file mode 100644
index 00000000..abb34f85
--- /dev/null
+++ b/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx
@@ -0,0 +1,84 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+
+import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig"
+import { InviteVendorsDialog } from "./invite-vendors-dialog"
+import { AddVendorDialog } from "./add-vendor-dialog"
+import { Button } from "@/components/ui/button"
+import { useToast } from "@/hooks/use-toast"
+
+interface VendorsTableToolbarActionsProps {
+ table: Table<MatchedVendorRow>
+ rfqId: number
+}
+
+export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbarActionsProps) {
+ const { toast } = useToast()
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 선택된 모든 행
+ 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() {
+ fileInputRef.current?.click()
+ }
+
+ function handleInviteClick() {
+ // 조건에 맞지 않는 벤더가 있다면 토스트 메시지 표시
+ if (ineligibleCount > 0) {
+ toast({
+ title: "일부 벤더만 초대됩니다",
+ description: `선택한 ${selectedRows.length}개 중 ${eligibleVendors.length}개만 초대 가능합니다. 나머지 ${ineligibleCount}개는 초대 불가능한 상태입니다.`,
+ // variant: "warning",
+ })
+ }
+ }
+
+ // 다이얼로그 표시 여부 - 적합한 벤더가 1개 이상 있으면 표시
+ const showInviteDialog = eligibleVendors.length > 0
+
+ return (
+ <div className="flex items-center gap-2">
+ {selectedRows.length > 0 && (
+ <>
+ {showInviteDialog ? (
+ <InviteVendorsDialog
+ vendors={eligibleVendors}
+ rfqId={rfqId}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ onOpenChange={(open) => {
+ // 다이얼로그가 열릴 때만 경고 표시
+ if (open && ineligibleCount > 0) {
+ handleInviteClick()
+ }
+ }}
+ />
+ ) : (
+ <Button
+ variant="default"
+ size="sm"
+ disabled={true}
+ title="선택된 벤더 중 초대 가능한 벤더가 없습니다"
+ >
+ 초대 불가
+ </Button>
+ )}
+ </>
+ )}
+
+ <AddVendorDialog rfqId={rfqId} />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/rfqs/vendor-table/vendors-table.tsx b/lib/rfqs/vendor-table/vendors-table.tsx
new file mode 100644
index 00000000..838342bf
--- /dev/null
+++ b/lib/rfqs/vendor-table/vendors-table.tsx
@@ -0,0 +1,181 @@
+"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 { useFeatureFlags } from "./feature-flags-provider"
+import { getColumns } from "./vendors-table-columns"
+import { vendors } from "@/db/schema/vendors"
+import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions"
+import { VendorsTableFloatingBar } from "./vendors-table-floating-bar"
+import { fetchRfqAttachmentsbyCommentId, getMatchedVendors } from "../service"
+import { InviteVendorsDialog } from "./invite-vendors-dialog"
+import { CommentSheet, MatchedVendorComment } from "./comments-sheet"
+import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig"
+import { RfqType } from "@/lib/rfqs/validations"
+
+interface VendorsTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getMatchedVendors>>]>
+ rfqId: number
+ rfqType: RfqType
+}
+
+export function MatchedVendorsTable({ promises, rfqId, rfqType}: VendorsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ // 1) Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+ // data는 MatchedVendorRow[] 형태 (getMatchedVendors에서 반환)
+
+ console.log(data)
+
+ // 2) Row 액션 상태
+ const [rowAction, setRowAction] = React.useState<
+ DataTableRowAction<MatchedVendorRow> | null
+ >(null)
+
+ // **router** 획득
+ const router = useRouter()
+
+ // 3) CommentSheet 에 넣을 상태
+ // => “댓글”은 MatchedVendorComment[] 로 관리해야 함
+ const [initialComments, setInitialComments] = React.useState<
+ MatchedVendorComment[]
+ >([])
+ const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
+ const [selectedVendorIdForComments, setSelectedVendorIdForComments] =
+ React.useState<number | null>(null)
+
+ // 4) rowAction이 바뀌면, type이 "comments"인지 확인 후 open
+ React.useEffect(() => {
+ if (rowAction?.type === "comments") {
+ openCommentSheet(rowAction.row.original.id)
+ }
+ }, [rowAction])
+
+ // 5) 댓글 시트 오픈 함수
+ async function openCommentSheet(vendorId: number) {
+ setInitialComments([])
+
+ // (a) 현재 Row의 comments 불러옴
+ const comments = rowAction?.row.original.comments
+ if (comments && comments.length > 0) {
+ // (b) 각 comment마다 첨부파일 fetch
+ const commentWithAttachments: MatchedVendorComment[] = await Promise.all(
+ comments.map(async (c) => {
+ const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
+ return {
+ ...c,
+ attachments,
+ }
+ })
+ )
+ setInitialComments(commentWithAttachments)
+ }
+
+ // (c) vendorId state
+ setSelectedVendorIdForComments(vendorId)
+ setCommentSheetOpen(true)
+ }
+
+ // 6) 컬럼 정의 (memo)
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, router, openCommentSheet }),
+ [setRowAction, router]
+ )
+
+ // 7) 필터 정의
+ const filterFields: DataTableFilterField<MatchedVendorRow>[] = []
+
+ const advancedFilterFields: DataTableAdvancedFilterField<MatchedVendorRow>[] = [
+ { 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: "rfqVendorStatus",
+ label: "RFQ Status",
+ type: "multi-select",
+ options: ["INVITED", "ACCEPTED", "REJECTED", "QUOTED"].map((s) => ({
+ label: s,
+ value: s,
+ })),
+ },
+ { id: "rfqVendorUpdated", label: "Updated at", type: "date" },
+ ]
+
+ // 8) 테이블 생성
+ const { table } = useDataTable({
+ data, // MatchedVendorRow[]
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "rfqVendorUpdated", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ // 행의 고유 ID
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <VendorsTableToolbarActions table={table} rfqId={rfqId} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 초대 다이얼로그 */}
+ <InviteVendorsDialog
+ vendors={rowAction?.row.original ? [rowAction?.row.original] : []}
+ onOpenChange={() => setRowAction(null)}
+ rfqId={rfqId}
+ open={rowAction?.type === "invite"}
+ showTrigger={false}
+ rfqType={rfqType}
+ />
+
+ {/* 댓글 시트 */}
+ <CommentSheet
+ open={commentSheetOpen}
+ onOpenChange={setCommentSheetOpen}
+ initialComments={initialComments}
+ rfqId={rfqId}
+ vendorId={selectedVendorIdForComments ?? 0}
+ currentUserId={1}
+ onCommentsUpdated={(updatedComments) => {
+ // Row 의 comments 필드도 업데이트
+ if (!rowAction?.row) return
+ rowAction.row.original.comments = updatedComments
+ }}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/roles/repository.ts b/lib/roles/repository.ts
new file mode 100644
index 00000000..99ffdf29
--- /dev/null
+++ b/lib/roles/repository.ts
@@ -0,0 +1,94 @@
+// repository.ts
+import { sql, and, eq, inArray ,desc,asc} from "drizzle-orm";
+import type { PgTransaction } from "drizzle-orm/pg-core";
+import { roles, users, userRoles, Role, roleView, RoleView } from "@/db/schema/users"; // 수정
+import db from "@/db/db";
+import { companies } from "@/db/schema/companies";
+
+export type NewRole = typeof roles.$inferInsert; // User insert 시 필요한 타입
+
+
+// (A) SELECT roles + userCount
+export async function selectRolesWithUserCount(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]
+ offset?: number,
+ limit?: number,
+ }
+) {
+
+ const { where, orderBy, offset = 0, limit = 10 } = params
+
+ const query = tx
+ .select()
+ .from(roleView)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit)
+
+ const rows = await query
+ return rows
+}
+// (B) countRoles
+export async function countRoles(
+tx: PgTransaction<any, any, any>,
+ where?: ReturnType<typeof and>
+) {
+ // COUNT(*) from roles
+ const [{ count }] = await tx
+ .select({ count: sql<number>`COUNT(*)`.as("count") })
+ .from(roles)
+ .where(where ?? undefined);
+
+ return count; // number
+}
+
+export async function insertRole(
+ tx: PgTransaction<any, any, any>,
+ data: NewRole
+) {
+ return tx.insert(roles).values(data).returning();
+}
+
+export const getRoleById = async (id: number): Promise<Role | null> => {
+ const roleFouned = await db.select().from(roles).where(eq(roles.id, id)).execute();
+ if (roleFouned.length === 0) return null;
+
+ const role = roleFouned[0];
+ return role
+};
+
+
+export async function updateRole(
+ tx: PgTransaction<any, any, any>,
+ roleId: number,
+ data: Partial<Role>
+) {
+ return tx
+ .update(roles)
+ .set(data)
+ .where(eq(roles.id, roleId))
+ .returning();
+}
+
+
+export async function deleteRolesByIds(
+ tx: PgTransaction<any, any, any>,
+ ids: number[]
+) {
+ return tx.delete(roles).where(inArray(roles.id, ids));
+}
+
+export async function deleteUserRolesByIds(
+ tx: PgTransaction<any, any, any>,
+ ids: number[]
+) {
+ return tx.delete(userRoles).where(inArray(userRoles.roleId, ids));
+}
+
+export async function findAllRoleView(domain?: "evcp" | "partners"): Promise<RoleView[]> {
+ return db.select().from(roleView).where(eq(roleView.domain,domain)).orderBy(asc(roleView.name));
+} \ No newline at end of file
diff --git a/lib/roles/services.ts b/lib/roles/services.ts
new file mode 100644
index 00000000..1a91d4fa
--- /dev/null
+++ b/lib/roles/services.ts
@@ -0,0 +1,300 @@
+"use server";
+
+import { revalidateTag, unstable_cache, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { permissions, Role, rolePermissions, roles, RoleView, roleView, userRoles } from "@/db/schema/users";
+import { and, or, asc, desc, ilike, eq, inArray } from "drizzle-orm";
+import { filterColumns } from "@/lib/filter-columns";
+import {
+ selectRolesWithUserCount,
+ countRoles,
+ insertRole,
+ getRoleById,
+ updateRole,
+ deleteRolesByIds,
+ deleteUserRolesByIds,
+ findAllRoleView,
+} from "./repository";
+import { CreateRoleSchema, GetRolesSchema, UpdateRoleSchema } from "./validations";
+import { getErrorMessage } from "@/lib/handle-error";
+
+interface UpsertPermissionsInput {
+ roleIds: number[];
+ permissionKeys: string[];
+ itemTitle?: string;
+}
+
+export async function getRolesWithCount(input: GetRolesSchema) {
+ // unstable_cache: 특정 키와 함께 캐싱
+ return unstable_cache(
+ async () => {
+ try {
+ // 1) pagination
+ const offset = (input.page - 1) * input.perPage;
+
+ // 2) advanced filter
+ const advancedWhere = filterColumns({
+ table: roleView, // 또는 roleView
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // 3) 글로벌 검색
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ // 예: roles.name 에 ilike 검색
+ globalWhere = or(ilike(roles.name, s));
+ }
+
+ // 4) 최종 where
+ const finalWhere = and(advancedWhere, globalWhere);
+
+ // (5) 정렬
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(roleView[item.id]) : asc(roleView[item.id])
+ )
+ : [desc(roleView.created_at)];
+
+
+ // 6) 트랜잭션 + Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ // 실제 SELECT
+ const data = await selectRolesWithUserCount(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ // 전체 개수
+ const total = await countRoles(tx, finalWhere);
+
+ return { data, total };
+ });
+
+ // 7) pageCount
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러시 기본값
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: ["roles"], // revalidateTag("roles")로 무효화
+ }
+ )();
+}
+
+export async function createRole(input: CreateRoleSchema) {
+ unstable_noStore(); // 캐싱 방지(Next.js 서버 액션용)
+ try {
+
+ await db.transaction(async (tx) => {
+ const [newRole] = await insertRole(tx, {
+ name: input.name,
+ domain: input.domain,
+ description: input.description ?? "",
+ companyId: input.domain === "partners" ? input.companyId ?? null : null,
+ });
+ });
+
+ revalidateTag("roles");
+
+ return { data: null, error: null };
+
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+
+export async function modifiRole(input: UpdateRoleSchema & { id: number }) {
+ unstable_noStore();
+
+ try {
+
+ const data = await db.transaction(async (tx) => {
+ // 1) 먼저 User 테이블 업데이트
+ const [res] = await updateRole(tx, input.id, {
+ name: input.name,
+ description: input.description,
+ domain: input.domain
+ });
+
+ return res;
+ });
+
+ // 3) 캐시 무효화
+ revalidateTag("roles");
+
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+export async function removeRoles(input: { ids: number[] }) {
+ unstable_noStore();
+
+ try {
+ await db.transaction(async (tx) => {
+ // user_roles도 있으면 먼저 삭제해야 할 수 있음
+
+ await deleteUserRolesByIds(tx, input.ids);
+ await deleteRolesByIds(tx, input.ids);
+
+ });
+
+ revalidateTag("roles");
+ revalidateTag("user-role-counts");
+ revalidateTag("users");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+
+
+export async function assignRolesToUsers(roleIds: number[], userIds: number[]) {
+ // Next.js 서버 액션에서 캐싱 방지
+ unstable_noStore()
+
+ try {
+ await db.transaction(async (tx) => {
+ // 1) 기존 userRoles 삭제: userIds, roleIds에 해당하는 레코드만
+ await tx
+ .delete(userRoles)
+ .where(
+ and(
+ inArray(userRoles.roleId, roleIds),
+ inArray(userRoles.userId, userIds)
+ )
+ )
+
+ // 2) 새로 삽입
+ if (roleIds.length > 0 && userIds.length > 0) {
+ const newRows = []
+ for (const rid of roleIds) {
+ for (const uid of userIds) {
+ newRows.push({ roleId: rid, userId: uid })
+ }
+ }
+ await tx.insert(userRoles).values(newRows)
+ }
+ })
+
+ // 캐시 무효화
+ revalidateTag("users")
+ revalidateTag("roles")
+
+ return { data: null, error: null }
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) }
+ }
+}
+
+export async function getAllRoleView(domain?: "evcp" | "partners"): Promise<RoleView[]> {
+ try {
+ return await findAllRoleView(domain)
+ } catch (err) {
+ throw new Error("Failed to get roles")
+ }
+}
+
+export async function upsertPermissions(input: UpsertPermissionsInput) {
+ unstable_noStore();
+ try {
+ const { roleIds, permissionKeys, itemTitle } = input;
+ if (!roleIds.length || !permissionKeys.length) {
+ return; // nothing to do
+ }
+
+ const roleIdNums = roleIds
+
+ await db.transaction(async (tx) => {
+ for (const permKey of permissionKeys) {
+ // A) Check if permissionKey exists in "permissions" table
+ const [existingPerm] = await tx
+ .select({ id: permissions.id })
+ .from(permissions)
+ .where(eq(permissions.permissionKey, permKey))
+ .limit(1);
+
+ let permissionId: number;
+ if (!existingPerm) {
+ // Insert new permission
+ // description를 어떻게 만들지는 자유: itemTitle + permKey 등
+ const [inserted] = await tx
+ .insert(permissions)
+ .values({
+ permissionKey: permKey,
+ description: itemTitle ? `Menu: ${itemTitle} perm: ${permKey}` : permKey,
+ })
+ .returning({ id: permissions.id });
+
+ permissionId = inserted.id;
+ } else {
+ permissionId = existingPerm.id;
+ }
+
+ // B) now link (roleId, permissionId) in role_permissions
+ for (const rId of roleIdNums) {
+ // check if already exists
+ const [rp] = await tx
+ .select({ p: rolePermissions.permissionId })
+ .from(rolePermissions)
+ .where(and(eq(rolePermissions.roleId, rId), eq(rolePermissions.permissionId, permissionId)))
+ .limit(1);
+
+ if (!rp) {
+ // insert
+ await tx.insert(rolePermissions).values({
+ roleId: rId,
+ permissionId,
+ });
+ }
+ // if rp exists, skip
+ }
+ }
+ });
+
+ return { data: null, error: null };
+
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+
+export async function getMenuPermissions(
+ itemKey: string
+): Promise<{ roleId: number; permKey: string }[]> {
+ // itemKey = "alert-dialog"
+ // permKey = "alert-dialog.create", "alert-dialog.viewOwn", ...
+ const pattern = `${itemKey}.%`
+
+ // SELECT rp.role_id, p.permission_key
+ // FROM role_permissions rp
+ // JOIN permissions p ON p.id = rp.permissionId
+ // WHERE p.permission_key LIKE 'alert-dialog.%'
+ const rows = await db
+ .select({
+ roleId: rolePermissions.roleId,
+ permKey: permissions.permissionKey,
+ })
+ .from(rolePermissions)
+ .innerJoin(permissions, eq(permissions.id, rolePermissions.permissionId))
+ .where(ilike(permissions.permissionKey, pattern));
+
+ return rows;
+} \ No newline at end of file
diff --git a/lib/roles/table/add-role-dialog.tsx b/lib/roles/table/add-role-dialog.tsx
new file mode 100644
index 00000000..365daf29
--- /dev/null
+++ b/lib/roles/table/add-role-dialog.tsx
@@ -0,0 +1,308 @@
+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 {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Check, ChevronsUpDown, Loader } from "lucide-react"
+import { cn } from "@/lib/utils"
+import { toast } from "sonner"
+
+import { createRoleSchema, type CreateRoleSchema } from "../validations"
+import { createRole } from "../services"
+import { Textarea } from "@/components/ui/textarea"
+import { Company } from "@/db/schema/companies"
+import { getAllCompanies } from "@/lib/admin-users/service"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandEmpty,
+} from "@/components/ui/command"
+
+
+
+const domainOptions = [
+ { value: "partners", label: "협력업체" },
+ { value: "evcp", label: "삼성중공업" },
+]
+
+export function AddRoleDialog() {
+ const [open, setOpen] = React.useState(false)
+ const [isAddPending, startAddTransition] = React.useTransition()
+ const [companies, setCompanies] = React.useState<Company[]>([]) // 회사 목록
+
+ React.useEffect(() => {
+ getAllCompanies().then((res) => {
+ setCompanies(res)
+ })
+ }, [])
+
+ // react-hook-form 세팅
+ const form = useForm<CreateRoleSchema>({
+ resolver: zodResolver(createRoleSchema),
+ defaultValues: {
+ name: "",
+ domain: "evcp", // 기본값
+ description: "",
+ // companyId: null, // optional
+ },
+ })
+
+ async function onSubmit(data: CreateRoleSchema) {
+ startAddTransition(async () => {
+ const result = await createRole(data)
+ if (result.error) {
+ toast.error(`에러: ${result.error}`)
+ return
+ }
+ form.reset()
+ setOpen(false)
+ toast.success("Role added")
+ })
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ setOpen(nextOpen)
+ }
+
+ // domain이 partners일 경우 companyId 입력 필드 보이게
+ const selectedDomain = form.watch("domain")
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ Add Role
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Create New Role</DialogTitle>
+ <DialogDescription>
+ 새 Role 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)}>
+ <div className="space-y-4 py-4">
+ {/* 1) Role Name */}
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Role Name</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="e.g. admin"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 2) Description */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Role Description</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Describe role"
+ className="resize-none"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 3) Domain Select */}
+ <FormField
+ control={form.control}
+ name="domain"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Domain</FormLabel>
+ <FormControl>
+ <Select
+ // domain이 바뀔 때마다 form state에도 반영
+ onValueChange={field.onChange}
+ value={field.value}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select Domain" />
+ </SelectTrigger>
+ <SelectContent>
+ {domainOptions.map((v, index) => (
+ <SelectItem key={index} value={v.value}>
+ {v.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 4) companyId => domain이 partners인 경우만 노출 */}
+ {selectedDomain === "partners" && (
+ <FormField
+ control={form.control}
+ name="companyId"
+ render={({ field }) => {
+ // 현재 선택된 회사 ID (number) → 문자열
+ const valueString = field.value ? String(field.value) : ""
+
+
+ // 현재 선택된 회사
+ const selectedCompany = companies.find(
+ (c) => String(c.id) === valueString
+ )
+
+ const selectedCompanyLabel = selectedCompany && `${selectedCompany.name} ${selectedCompany.taxID}`
+
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+
+ return (
+ <FormItem>
+ <FormLabel>Company</FormLabel>
+ <FormControl>
+ <Popover
+ open={popoverOpen}
+ onOpenChange={setPopoverOpen}
+ modal={true}
+ >
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={popoverOpen}
+ className="w-full justify-between"
+ >
+ {selectedCompany
+ ? `${selectedCompany.name} ${selectedCompany.taxID}`
+ : "Select company..."}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+
+ <PopoverContent className="w-full p-0">
+ <Command>
+ <CommandInput
+ placeholder="Search company..."
+ className="h-9"
+
+ />
+ <CommandList>
+ <CommandEmpty>No company found.</CommandEmpty>
+ <CommandGroup>
+ {companies.map((comp) => {
+ // string(comp.id)
+ const compIdStr = String(comp.id)
+ const label = `${comp.name}${comp.taxID}`
+ const label2 = `${comp.name} ${comp.taxID}`
+ return (
+ <CommandItem
+ key={comp.id}
+ value={label2}
+ onSelect={() => {
+ // 회사 ID를 number로
+ field.onChange(Number(comp.id))
+ setPopoverOpen(false)
+
+ }}
+ >
+ {label2}
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4",
+ selectedCompanyLabel === label2
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }}
+ />
+ )}
+ </div>
+
+ {/* Footer */}
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isAddPending}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={form.formState.isSubmitting || isAddPending}
+ >
+ {isAddPending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Create
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/roles/table/assign-roles-sheet.tsx b/lib/roles/table/assign-roles-sheet.tsx
new file mode 100644
index 00000000..11c6a1ff
--- /dev/null
+++ b/lib/roles/table/assign-roles-sheet.tsx
@@ -0,0 +1,87 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import { Loader } from "lucide-react"
+import { AssginedUserTable } from "../userTable/assignedUsers-table"
+import { assignUsersToRole } from "@/lib/users/service"
+import { RoleView } from "@/db/schema/users"
+
+export interface UpdateRoleSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ role: RoleView | null
+
+ // ★ 새로 추가: 테이블에 필요한 데이터 로딩 promise
+ assignedTablePromises: Promise<[
+ { data: any[]; pageCount: number }
+
+ ]>
+}
+
+export function AssignRolesSheet({ role, assignedTablePromises, ...props }: UpdateRoleSheetProps) {
+
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const [selectedUserIds, setSelectedUserIds] = React.useState<number[]>([])
+
+ // 2) 자식에서 호출될 콜백
+ function handleSelectedChange(ids: number[]) {
+ setSelectedUserIds(ids)
+ }
+
+ async function handleAssign() {
+ startUpdateTransition(async () => {
+ if (!role) return
+ const { error } = await assignUsersToRole(role.id, selectedUserIds)
+ if (error) {
+ toast.error(error)
+ return
+ }
+ props.onOpenChange?.(false)
+ toast.success(`Assigned ${selectedUserIds.length} users!`)
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>"{role?.name}"에 유저를 할당하세요</SheetTitle>
+ <SheetDescription>
+ 현재 {role?.name}에는 {role?.user_count}명이 할당되어있습니다. 이 롤은 다음과 같습니다.<br/> {role?.description}
+ </SheetDescription>
+ </SheetHeader>
+
+ <AssginedUserTable promises={assignedTablePromises} onSelectedChange={handleSelectedChange} />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+
+ {/* <Button disabled={isUpdatePending} onClick={onSubmitAssignUsers}> */}
+ <Button disabled={isUpdatePending} onClick={handleAssign}>
+ {isUpdatePending && (
+ <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
+ )}
+ Assign
+ </Button>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/roles/table/delete-roles-dialog.tsx b/lib/roles/table/delete-roles-dialog.tsx
new file mode 100644
index 00000000..269bc7c3
--- /dev/null
+++ b/lib/roles/table/delete-roles-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 { removeRoles } from "../services"
+import { RoleView } from "@/db/schema/users"
+
+interface DeleteRolesDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ roles: Row<RoleView>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteRolesDialog({
+ roles,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteRolesDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removeRoles({
+ ids: roles.map((role) => Number(role.id)),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Users 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 ({roles.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">{roles.length}</span>
+ {roles.length === 1 ? " role" : " roles"} 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 ({roles.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">{roles.length}</span>
+ {roles.length === 1 ? " role" : " roles"} 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/roles/table/role-table-toolbar-actions.tsx b/lib/roles/table/role-table-toolbar-actions.tsx
new file mode 100644
index 00000000..66e279d6
--- /dev/null
+++ b/lib/roles/table/role-table-toolbar-actions.tsx
@@ -0,0 +1,101 @@
+"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"
+
+// 삭제, 추가 다이얼로그
+
+// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import
+import { importTasksExcel } from "@/lib/tasks/service" // 예시
+import { AddRoleDialog } from "./add-role-dialog"
+import { DeleteRolesDialog } from "./delete-roles-dialog"
+import { RoleView } from "@/db/schema/users"
+
+interface RoleTableToolbarActionsProps {
+ table: Table<RoleView>
+}
+
+export function RoleTableToolbarActions({ table }: RoleTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일이 선택되었을 때 처리
+ async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록)
+ event.target.value = ""
+
+ // 서버 액션 or API 호출
+ try {
+ // 예: 서버 액션 호출
+ const { errorFile, errorMessage } = await importTasksExcel(file)
+
+ if (errorMessage) {
+ toast.error(errorMessage)
+ }
+ if (errorFile) {
+ // 에러 엑셀을 다운로드
+ const url = URL.createObjectURL(errorFile)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "errors.xlsx"
+ link.click()
+ URL.revokeObjectURL(url)
+ } else {
+ // 성공
+ toast.success("Import success")
+ // 필요 시 revalidateTag("tasks") 등
+ }
+
+ } catch (err) {
+ toast.error("파일 업로드 중 오류가 발생했습니다.")
+
+ }
+ }
+
+ function handleImportClick() {
+ // 숨겨진 <input type="file" /> 요소를 클릭
+ fileInputRef.current?.click()
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteRolesDialog
+ roles={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+ <AddRoleDialog />
+
+
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "roles",
+ 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/roles/table/roles-table-columns.tsx b/lib/roles/table/roles-table-columns.tsx
new file mode 100644
index 00000000..3a491585
--- /dev/null
+++ b/lib/roles/table/roles-table-columns.tsx
@@ -0,0 +1,223 @@
+"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 { 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 { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import { UserWithCompanyAndRoles } from "@/types/user"
+import { getErrorMessage } from "@/lib/handle-error"
+
+import { modifiUser } from "@/lib/admin-users/service"
+import { toast } from "sonner"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { MultiSelect } from "@/components/ui/multi-select"
+import { roleColumnsConfig } from "@/config/roleColumnsConfig"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
+import { RoleView } from "@/db/schema/users"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RoleView> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<RoleView>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<RoleView> = {
+ 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<RoleView> = {
+ 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: "user" })}
+ >
+ User Assignment
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<User>[] }
+ const groupMap: Record<string, ColumnDef<RoleView>[]> = {}
+
+ roleColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<RoleView> = {
+ 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 === "created_at") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ if (cfg.id === "domain") {
+ const dateVal = cell.getValue() as string
+ return (
+ <div className="flex w-[6.25rem] items-center">
+ {dateVal === "evcp"?"삼성중공업":"협력업체"}
+ </div>)
+ }
+
+ if (cfg.id === "user_count") {
+ const dateVal = cell.getValue() as number
+ return (
+ <div className="flex w-[3.25rem] items-center">
+ {dateVal}
+ </div>)
+ }
+
+ if (cfg.id === "description") {
+ const val = cell.getValue() as string;
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="line-clamp-2 w-[400px]">
+ {val}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ {val}
+ </TooltipContent>
+ </Tooltip>
+ );
+ }
+
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<RoleView>[] = []
+
+ // 순서를 고정하고 싶다면 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/roles/table/roles-table.tsx b/lib/roles/table/roles-table.tsx
new file mode 100644
index 00000000..cd7c2a3b
--- /dev/null
+++ b/lib/roles/table/roles-table.tsx
@@ -0,0 +1,169 @@
+"use client"
+
+import * as React from "react"
+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 { DataTableToolbar } from "@/components/data-table/data-table-toolbar"
+
+
+import { getRolesWithCount } from "@/lib/roles/services"
+import { getColumns } from "./roles-table-columns"
+import { RoleTableToolbarActions } from "./role-table-toolbar-actions"
+import { UpdateRolesSheet } from "./update-roles-sheet"
+import { AssignRolesSheet } from "./assign-roles-sheet"
+import { getUsersAll } from "@/lib/users/service"
+import { DeleteRolesDialog } from "./delete-roles-dialog"
+import { RoleView } from "@/db/schema/users"
+
+
+interface RolesTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getRolesWithCount>>,
+ ]
+ >
+ promises2: Promise<
+ [
+ Awaited<ReturnType<typeof getUsersAll>>,
+ ]
+>
+}
+
+export function RolesTable({ promises ,promises2 }: RolesTableProps) {
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<RoleView> | 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<RoleView>[] = [
+ {
+ id: "name",
+ label: "Role Name",
+ placeholder: "Filter role name...",
+ },
+
+ ]
+
+ /**
+ * 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<RoleView>[] = [
+ {
+ id: "name",
+ label: "Role Name",
+ type: "text",
+ },
+
+ {
+ id: "domain",
+ label: "룰 도메인",
+ type: "text",
+ },
+
+ {
+ id: "company_name",
+ label: "회사명",
+ type: "text",
+ },
+
+ {
+ id: "created_at",
+ label: "Created at",
+ type: "date",
+ },
+ ]
+
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "created_at", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => `${originalRow.id}`,
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <RoleTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+
+ </DataTable>
+
+ <UpdateRolesSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ role={rowAction?.row.original ?? null}
+ />
+
+ <AssignRolesSheet
+ open={rowAction?.type === "user"}
+ onOpenChange={() => setRowAction(null)}
+ role={rowAction?.row.original ?? null}
+ assignedTablePromises={promises2}
+ />
+
+ <DeleteRolesDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ roles={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+
+
+
+
+ </>
+ )
+}
diff --git a/lib/roles/table/update-roles-sheet.tsx b/lib/roles/table/update-roles-sheet.tsx
new file mode 100644
index 00000000..cbe20352
--- /dev/null
+++ b/lib/roles/table/update-roles-sheet.tsx
@@ -0,0 +1,331 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+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 { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+ SelectValue,
+ SelectGroup,
+} from "@/components/ui/select"
+// import your MultiSelect or other role selection
+import { RoleView, userRoles, type UserView } from "@/db/schema/users"
+import { getAllCompanies, modifiUser } from "@/lib/admin-users/service"
+import { modifiRole } from "../services"
+import { updateRoleSchema, UpdateRoleSchema } from "../validations"
+import { Check, ChevronsUpDown, Loader } from "lucide-react"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandEmpty,
+} from "@/components/ui/command"
+import { Company } from "@/db/schema/companies"
+import { cn } from "@/lib/utils"
+
+export interface UpdateRoleSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ role: RoleView | null
+}
+
+const domainOptions = [
+ { value: "partners", label: "협력업체" },
+ { value: "evcp", label: "삼성중공업" },
+]
+
+
+
+export function UpdateRolesSheet({ role, ...props }: UpdateRoleSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const [companies, setCompanies] = React.useState<Company[]>([]) // 회사 목록
+
+ React.useEffect(() => {
+ getAllCompanies().then((res) => {
+ setCompanies(res)
+ })
+ }, [])
+
+
+ // 1) RHF 설정
+ const form = useForm<UpdateRoleSchema>({
+ resolver: zodResolver(updateRoleSchema),
+ defaultValues: {
+ name: role?.name ?? "",
+ description: role?.description ?? "",
+ domain: (role?.domain === "evcp" || role?.domain === "partners")
+ ? role?.domain
+ : undefined,
+ },
+ })
+
+ // 2) user prop 바뀔 때마다 form.reset
+ React.useEffect(() => {
+ if (role) {
+ form.reset({
+ name: role.name,
+ description: role.description,
+ domain: role.domain as "evcp" | "partners" | undefined,
+ })
+ }
+ }, [role, form])
+
+ const selectedDomain = form.watch("domain")
+
+
+ // 3) onSubmit
+ async function onSubmit(input: UpdateRoleSchema) {
+ startUpdateTransition(async () => {
+ if (!role) return
+
+ const { error } = await modifiRole({
+ id: role.id, // user.userId
+ ...input,
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ // 성공 시
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("User updated successfully!")
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Update user</SheetTitle>
+ <SheetDescription>
+ Update the user details and save the changes
+ </SheetDescription>
+ </SheetHeader>
+
+ {/* 4) RHF Form */}
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ {/* name */}
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Role Name</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="e.g. admin"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Role Description</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Describe role"
+ className="resize-none"
+ {...field}
+ />
+ </FormControl>
+ {/* <FormDescription>
+ You can <span>@mention</span> other users and organizations to
+ link to them.
+ </FormDescription> */}
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+
+ {/* language Select */}
+ <FormField
+ control={form.control}
+ name="domain"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Domain</FormLabel>
+ <FormControl>
+ <Select
+ onValueChange={field.onChange}
+ // 'value'로 현재 값 연결. defaultValue 대신 Controlled 컴포넌트로
+ value={field.value}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select Domain" />
+ </SelectTrigger>
+ <SelectContent>
+ {domainOptions.map((v, index) => (
+ <SelectItem key={index} value={v.value}>
+ {v.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {selectedDomain === "partners" && (
+ <FormField
+ control={form.control}
+ name="company_id"
+ render={({ field }) => {
+ // 현재 선택된 회사 ID (number) → 문자열
+ const valueString = field.value ? String(field.value) : ""
+
+
+ // 현재 선택된 회사
+ const selectedCompany = companies.find(
+ (c) => String(c.id) === valueString
+ )
+
+ const selectedCompanyLabel = selectedCompany && `${selectedCompany.name} ${selectedCompany.taxID}`
+
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+
+ return (
+ <FormItem>
+ <FormLabel>Company</FormLabel>
+ <FormControl>
+ <Popover
+ open={popoverOpen}
+ onOpenChange={setPopoverOpen}
+ modal={true}
+ >
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={popoverOpen}
+ className="w-full justify-between"
+ >
+ {selectedCompany
+ ? `${selectedCompany.name} ${selectedCompany.taxID}`
+ : "Select company..."}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+
+ <PopoverContent className="w-full p-0" side="bottom" >
+ <Command>
+ <CommandInput
+ placeholder="Search company..."
+ className="h-9"
+
+ />
+ <CommandList>
+ <CommandEmpty>No company found.</CommandEmpty>
+ <CommandGroup>
+ {companies.map((comp) => {
+ // string(comp.id)
+ const compIdStr = String(comp.id)
+ const label = `${comp.name}${comp.taxID}`
+ const label2 = `${comp.name} ${comp.taxID}`
+ return (
+ <CommandItem
+ key={comp.id}
+ value={label2}
+ onSelect={() => {
+ // 회사 ID를 number로
+ field.onChange(Number(comp.id))
+ setPopoverOpen(false)
+
+ }}
+ >
+ {label2}
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4",
+ selectedCompanyLabel === label2
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }}
+ />
+ )}
+
+ {/* 5) Footer: Cancel, Save */}
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+
+ <Button type="submit" disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/roles/userTable/assginedUsers-table-columns.tsx b/lib/roles/userTable/assginedUsers-table-columns.tsx
new file mode 100644
index 00000000..b317a465
--- /dev/null
+++ b/lib/roles/userTable/assginedUsers-table-columns.tsx
@@ -0,0 +1,164 @@
+"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 { userRoles, type UserView } from "@/db/schema/users"
+
+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 { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import { UserWithCompanyAndRoles } from "@/types/user"
+import { getErrorMessage } from "@/lib/handle-error"
+
+import { modifiUser } from "@/lib/admin-users/service"
+import { toast } from "sonner"
+
+import { euserColumnsConfig } from "@/config/euserColumnsConfig"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { MultiSelect } from "@/components/ui/multi-select"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<UserView> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<UserView>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<UserView> = {
+ 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 메뉴)
+ // ----------------------------------------------------------------
+
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<User>[] }
+ const groupMap: Record<string, ColumnDef<UserView>[]> = {}
+
+ euserColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<UserView> = {
+ 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 === "created_at") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ if (cfg.id === "roles") {
+ const roleValues = row.original.roles;
+ return (
+ <div className="flex flex-wrap gap-1">
+ {roleValues.map((v) => (
+ v === null?"":
+
+ <Badge key={v} variant="outline">
+ {v}
+ </Badge>
+
+ ))}
+ </div>
+ );
+ }
+
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<UserView>[] = []
+
+ // 순서를 고정하고 싶다면 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,
+
+ ]
+} \ No newline at end of file
diff --git a/lib/roles/userTable/assignedUsers-table.tsx b/lib/roles/userTable/assignedUsers-table.tsx
new file mode 100644
index 00000000..5ac52f13
--- /dev/null
+++ b/lib/roles/userTable/assignedUsers-table.tsx
@@ -0,0 +1,159 @@
+"use client"
+
+import * as React from "react"
+import { userRoles , type UserView} from "@/db/schema/users"
+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 { DataTableToolbar } from "@/components/data-table/data-table-toolbar"
+
+import type {
+ getAllRoles, getUsersAll, getUsersEVCP
+} from "@/lib//users/service"
+import { getColumns } from "./assginedUsers-table-columns"
+
+
+
+interface UsersTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getUsersAll>>
+
+ ]
+ >
+ onSelectedChange:any
+}
+
+export function AssginedUserTable({ promises ,onSelectedChange}: UsersTableProps) {
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<UserView> | 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<UserView>[] = [
+ {
+ id: "user_email",
+ label: "Email",
+ placeholder: "Filter email...",
+ },
+
+ ]
+
+ /**
+ * 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<UserView>[] = [
+ {
+ id: "user_name",
+ label: "User Name",
+ type: "text",
+ },
+ {
+ id: "user_email",
+ label: "Email",
+ type: "text",
+ },
+
+
+ {
+ id: "created_at",
+ label: "Created at",
+ type: "date",
+ },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "created_at", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => `${originalRow.user_id}`,
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ const rowSelection = table.getState().rowSelection
+
+ function shallowEqual(arrA: number[], arrB: number[]): boolean {
+ if (arrA.length !== arrB.length) return false
+ for (let i = 0; i < arrA.length; i++) {
+ if (arrA[i] !== arrB[i]) return false
+ }
+ return true
+ }
+ const previousUserIdsRef = React.useRef<number[]>([])
+
+ React.useEffect(() => {
+ // 선택 상태가 바뀌었을 때만 실행
+ if (!onSelectedChange) return
+
+ const rows = table.getSelectedRowModel().rows
+ const newUserIds = rows.map((r) => r.original.user_id)
+
+ // 이전/새 userIds 비교
+ if (!shallowEqual(previousUserIdsRef.current, newUserIds)) {
+ previousUserIdsRef.current = newUserIds
+ onSelectedChange(newUserIds)
+ }
+ }, [rowSelection, onSelectedChange])
+
+ return (
+ <>
+ <DataTable
+ table={table}
+
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ </DataTableAdvancedToolbar>
+
+ </DataTable>
+
+
+ </>
+ )
+}
diff --git a/lib/roles/validations.ts b/lib/roles/validations.ts
new file mode 100644
index 00000000..10cfe33b
--- /dev/null
+++ b/lib/roles/validations.ts
@@ -0,0 +1,80 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { RoleView, users } from "@/db/schema/users";
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<RoleView>().withDefault([
+ { id: "created_at", desc: true },
+ ]),
+ name: parseAsString.withDefault(""),
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+ })
+
+ export const createRoleSchema = z.object({
+ name: z.string().min(1),
+ description: z.string().min(1),
+ companyId:z
+ .number()
+ .int()
+ .positive()
+ .nullish(), // number | nullish
+ domain: z.enum(users.domain.enumValues), // "evcp" | "partners"
+ });
+
+ export const createRoleAssignmentSchema = z.object({
+ evcpRoles:z.array(z.string()),
+
+ });
+
+
+
+ export const updateRoleSchema = z.object({
+ name: z.string().min(1),
+ description: z.string().min(1),
+ domain: z.enum(users.domain.enumValues), // "evcp" | "partners"
+ company_id: z
+ .number()
+ .int()
+ .positive()
+ .nullish(), // number | nullish
+ }).superRefine((data, ctx) => {
+ // domain이 partners 이면 companyId는 필수
+ if (data.domain === "partners" && !data.company_id) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["company_id"],
+ message: "협력업체(domain=partners)일 경우 companyId는 필수입니다.",
+ })
+ }
+
+ // domain이 evcp 이면 companyId는 null이어야 한다면(정책상)
+ if (data.domain === "evcp" && data.company_id) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["company_id"],
+ message: "domain=evcp이면 companyId를 입력할 수 없습니다.",
+ })
+ }
+ })
+
+// TypeScript에서 사용할 타입
+export type GetRolesSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+export type CreateRoleSchema = z.infer<typeof createRoleSchema>
+export type UpdateRoleSchema = z.infer<typeof updateRoleSchema>
+export type CreateRoleAssignmentSchema = z.infer<typeof createRoleAssignmentSchema> \ No newline at end of file
diff --git a/lib/storage.ts b/lib/storage.ts
new file mode 100644
index 00000000..ead937aa
--- /dev/null
+++ b/lib/storage.ts
@@ -0,0 +1,44 @@
+import fs from "fs/promises"
+import path from "path"
+import crypto from "crypto"
+
+/**
+ * 주어진 File을 해시된 파일명으로 로컬에 저장하고,
+ * 저장된 파일의 메타데이터를 반환하는 공용 함수
+ */
+export async function saveDocument(
+ file: File,
+ directory: string = "./public"
+) {
+ // 확장자 추출
+ const originalName = file.name
+ const ext = path.extname(originalName) || ""
+
+ // 해시 파일명 생성
+ const randomHash = crypto.randomBytes(20).toString("hex")
+ const hashedFileName = randomHash + ext
+
+ // 파일 저장
+ await storeFile(file, hashedFileName, directory)
+
+ // 필요한 메타데이터 반환 (원본 이름, 해시 파일명 등)
+ return {
+ originalName,
+ hashedFileName,
+ ext,
+ // 필요하면 file.size, file.type 등도 포함 가능
+ }
+}
+
+/**
+ * 실제 파일 쓰기 (로컬이든, S3든 자유롭게 교체 가능)
+ */
+async function storeFile(file: File, hashedFileName: string, directory: string) {
+ const arrayBuffer = await file.arrayBuffer()
+ const buffer = Buffer.from(arrayBuffer)
+
+ const filePath = path.join(directory, hashedFileName)
+
+ // 만약 기존 파일에 추가가 아니라 새 파일 생성이라면 writeFile 사용
+ await fs.writeFile(filePath, buffer)
+} \ No newline at end of file
diff --git a/lib/tag-numbering/repository.ts b/lib/tag-numbering/repository.ts
new file mode 100644
index 00000000..6ebf84db
--- /dev/null
+++ b/lib/tag-numbering/repository.ts
@@ -0,0 +1,45 @@
+import db from "@/db/db";
+import { viewTagSubfields } from "@/db/schema/vendorData";
+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 selectTagNumbering(
+ 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(viewTagSubfields)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+ }
+ /** 총 개수 count */
+ export async function countTagNumbering(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+ ) {
+ const res = await tx.select({ count: count() }).from(viewTagSubfields).where(where);
+ return res[0]?.count ?? 0;
+ }
+ \ No newline at end of file
diff --git a/lib/tag-numbering/service.ts b/lib/tag-numbering/service.ts
new file mode 100644
index 00000000..9b1c1172
--- /dev/null
+++ b/lib/tag-numbering/service.ts
@@ -0,0 +1,123 @@
+"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 { GetTagNumberigSchema } from "./validation";
+import { filterColumns } from "@/lib/filter-columns";
+import { TagSubfieldOption, tagSubfieldOptions, ViewTagSubfields, viewTagSubfields } from "@/db/schema/vendorData";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm";
+import { countTagNumbering, selectTagNumbering } from "./repository";
+
+export async function getTagNumbering(input: GetTagNumberigSchema) {
+
+ 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: viewTagSubfields,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(viewTagSubfields.tagTypeCode, s), ilike(viewTagSubfields.tagTypeDescription, s)
+ , ilike(viewTagSubfields.attributesId, s) , ilike(viewTagSubfields.attributesDescription, s), ilike(viewTagSubfields.expression, 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(viewTagSubfields[item.id]) : asc(viewTagSubfields[item.id])
+ )
+ : [asc(viewTagSubfields.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectTagNumbering(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countTagNumbering(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: ["tag-numbering"], // revalidateTag("items") 호출 시 무효화
+ }
+ )();
+ }
+
+
+
+ export const fetchTagSubfieldOptions = (async (attributesId: string): Promise<TagSubfieldOption[]> => {
+ try {
+ // (A) findMany -> 스키마 제네릭 누락 에러 발생 → 대신 select().from().where() 사용
+ const rows = await db
+ .select()
+ .from(tagSubfieldOptions)
+ .where(eq(tagSubfieldOptions.attributesId, attributesId))
+ .orderBy(asc(tagSubfieldOptions.code))
+
+ // rows는 TagSubfieldOption[] 형태
+ return rows
+ } catch (error) {
+ console.error("Error fetching tag subfield options:", error)
+ return []
+ }
+ })
+
+ export const getTagNumberingRules = (async (tagType: string): Promise<ViewTagSubfields[]> => {
+ try {
+ if (!tagType) {
+ return []
+ }
+
+ // 기존 findMany 대신 select().from().where() + orderBy
+ const rules = await db
+ .select()
+ .from(viewTagSubfields)
+ .where(eq(viewTagSubfields.tagTypeDescription, tagType))
+ .orderBy(asc(viewTagSubfields.sortOrder))
+
+ return rules
+ } catch (error) {
+ console.error("Error fetching tag numbering rules:", error)
+ return []
+ }
+ }) \ No newline at end of file
diff --git a/lib/tag-numbering/table/feature-flags-provider.tsx b/lib/tag-numbering/table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/tag-numbering/table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/tag-numbering/table/meta-sheet.tsx b/lib/tag-numbering/table/meta-sheet.tsx
new file mode 100644
index 00000000..4221837c
--- /dev/null
+++ b/lib/tag-numbering/table/meta-sheet.tsx
@@ -0,0 +1,226 @@
+"use client"
+
+import * as React from "react"
+import { useEffect, useState } from "react"
+import { Copy } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle
+} from "@/components/ui/sheet"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow
+} from "@/components/ui/table"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+ CardFooter
+} from "@/components/ui/card"
+import { Separator } from "@/components/ui/separator"
+import { ViewTagSubfields } from "@/db/schema/vendorData"
+import { fetchTagSubfieldOptions } from "../service"
+
+interface TagOption {
+ id: number
+ attributesId: string
+ code: string
+ label: string
+ createdAt?: Date
+ updatedAt?: Date
+}
+
+interface ViewTagOptionsProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ tagSubfield: ViewTagSubfields | null
+}
+
+export function ViewTagOptions({
+ open,
+ onOpenChange,
+ tagSubfield
+}: ViewTagOptionsProps) {
+ const [options, setOptions] = useState<TagOption[]>([])
+ const [loading, setLoading] = useState(false)
+ const [copied, setCopied] = useState<string | null>(null)
+
+ // 옵션 데이터 가져오기
+ useEffect(() => {
+ async function fetchOptions() {
+ if (!tagSubfield || !open) return
+
+ setLoading(true)
+ try {
+ // 서버 액션 호출 - attributesId와 일치하는 모든 옵션 가져오기
+ const optionsData = await fetchTagSubfieldOptions(tagSubfield.attributesId)
+ setOptions(optionsData || [])
+ } catch (error) {
+ console.error("Error fetching tag options:", error)
+ setOptions([])
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchOptions()
+ }, [tagSubfield, open])
+
+ // 코드 복사 기능
+ const copyToClipboard = (text: string, type: string) => {
+ navigator.clipboard.writeText(text).then(() => {
+ setCopied(type)
+ setTimeout(() => setCopied(null), 2000)
+ })
+ }
+
+ if (!tagSubfield) return null
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="sm:max-w-xl md:max-w-3xl lg:max-w-4xl xl:max-w-5xl overflow-y-auto">
+
+ <SheetHeader className="mb-6">
+ <SheetTitle className="text-xl flex items-center gap-2">
+ Field Options
+ <Badge variant="outline" className="ml-2">
+ {options.length} options
+ </Badge>
+ </SheetTitle>
+ <SheetDescription className="mb-4">
+ Field information and available options
+ </SheetDescription>
+
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <span className="text-sm font-medium">Attributes ID:</span>
+ <div className="flex items-center gap-1">
+ <Badge variant="secondary">
+ {tagSubfield.attributesId}
+ </Badge>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-6 w-6"
+ onClick={() => copyToClipboard(tagSubfield.attributesId, 'attributesId')}
+ >
+ <Copy className="h-3 w-3" />
+ </Button>
+ {copied === 'attributesId' && (
+ <span className="text-xs text-green-600">Copied</span>
+ )}
+ </div>
+ </div>
+ <div className="flex items-center justify-between">
+ <span className="text-sm font-medium">Tag Type:</span>
+ <Badge>{tagSubfield.tagTypeCode}</Badge>
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <span className="text-sm font-medium">Description:</span>
+ <span className="text-sm">{tagSubfield.attributesDescription}</span>
+ </div>
+ <div className="flex items-center justify-between">
+ <span className="text-sm font-medium">Expression:</span>
+ <code className="bg-muted px-2 py-1 rounded text-xs">
+ {tagSubfield.expression || 'N/A'}
+ </code>
+ </div>
+ </div>
+ </div>
+ {tagSubfield.tagTypeDescription && (
+ <div className="mt-4 text-sm bg-muted p-2 rounded">
+ <span className="font-medium">Type Description: </span>
+ {tagSubfield.tagTypeDescription}
+ </div>
+ )}
+
+ </SheetHeader>
+
+ <Separator className="my-4" />
+
+ {loading ? (
+ <div className="flex items-center justify-center h-40">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
+ </div>
+ ) : options.length > 0 ? (
+ <Card>
+ <CardHeader>
+ <CardTitle>Available Options</CardTitle>
+ <CardDescription>
+ All available options for field {tagSubfield.attributesId}
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-24">Code</TableHead>
+ <TableHead>Label</TableHead>
+ <TableHead className="text-right">Actions</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {options.map((option) => (
+ <TableRow key={option.id}>
+ <TableCell className="font-mono">
+ {option.code}
+ </TableCell>
+ <TableCell>{option.label}</TableCell>
+ <TableCell className="text-right">
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0"
+ onClick={() => copyToClipboard(`${option.code} - ${option.label}`, `option-${option.id}`)}
+ >
+ <Copy className="h-4 w-4" />
+ {copied === `option-${option.id}` && (
+ <span className="absolute -top-2 -right-2 text-xs text-green-600 bg-white px-1 rounded-sm">
+ Copied
+ </span>
+ )}
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </CardContent>
+ <CardFooter className="flex justify-between text-sm text-muted-foreground">
+ <div>
+ {options.length} options found for {tagSubfield.attributesId}
+ </div>
+ {tagSubfield.delimiter && (
+ <div>
+ Delimiter: <code className="bg-muted px-2 py-1 rounded text-xs">{tagSubfield.delimiter}</code>
+ </div>
+ )}
+ </CardFooter>
+ </Card>
+ ) : (
+ <div className="text-center py-8">
+ <div className="text-lg font-medium">No options found</div>
+ <p className="text-muted-foreground mt-2">
+ This field ({tagSubfield.attributesId}) has no defined options.
+ </p>
+ </div>
+ )}
+
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/tag-numbering/table/tagNumbering-table-columns.tsx b/lib/tag-numbering/table/tagNumbering-table-columns.tsx
new file mode 100644
index 00000000..6e9b8191
--- /dev/null
+++ b/lib/tag-numbering/table/tagNumbering-table-columns.tsx
@@ -0,0 +1,131 @@
+"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 {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { ViewTagSubfields } from "@/db/schema/vendorData"
+import { tagNumberingColumnsConfig } from "@/config/tagNumberingColumnsConfig"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ViewTagSubfields> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ViewTagSubfields>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (단일 버튼 - Meta Info 바로 보기)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<ViewTagSubfields> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => setRowAction({ row, type: "items" })}
+ >
+ <InfoIcon className="h-4 w-4" aria-hidden="true" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ View Option Info.
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<ViewTagSubfields>[] }
+ const groupMap: Record<string, ColumnDef<ViewTagSubfields>[]> = {}
+
+ tagNumberingColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<ViewTagSubfields> = {
+ 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<ViewTagSubfields>[] = []
+
+ // 순서를 고정하고 싶다면 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,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx b/lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx
new file mode 100644
index 00000000..1a7af254
--- /dev/null
+++ b/lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, RefreshCcw, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { ViewTagSubfields } from "@/db/schema/vendorData"
+
+
+
+interface ItemsTableToolbarActionsProps {
+ table: Table<ViewTagSubfields>
+}
+
+export function TagNumberingTableToolbarActions({ table }: ItemsTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="samsung"
+ size="sm"
+ className="gap-2"
+ >
+ <RefreshCcw className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Get Tag Numbering</span>
+ </Button>
+
+ {/** 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>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/tag-numbering/table/tagNumbering-table.tsx b/lib/tag-numbering/table/tagNumbering-table.tsx
new file mode 100644
index 00000000..7997aad9
--- /dev/null
+++ b/lib/tag-numbering/table/tagNumbering-table.tsx
@@ -0,0 +1,151 @@
+"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 { ViewTagSubfields } from "@/db/schema/vendorData"
+import { getTagNumbering } from "../service"
+import { getColumns } from "./tagNumbering-table-columns"
+import { TagNumberingTableToolbarActions } from "./tagNumbering-table-toolbar-actions"
+import { ViewTagOptions } from "./meta-sheet"
+
+interface ItemsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getTagNumbering>>,
+ ]
+ >
+}
+
+export function TagNumberingTable({ promises }: ItemsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<ViewTagSubfields> | 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<ViewTagSubfields>[] = [
+
+ ]
+
+ /**
+ * 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<ViewTagSubfields>[] = [
+ {
+ id: "tagTypeCode",
+ label: "Tag Type Code",
+ type: "text",
+ },
+ {
+ id: "tagTypeDescription",
+ label: "Tag Type Description",
+ type: "text",
+ },
+
+ {
+ id: "attributesId",
+ label: "Attributes Id",
+ type: "text",
+ },
+
+ {
+ id: "attributesDescription",
+ label: "Attributes Description",
+ type: "text",
+ },
+ {
+ id: "expression",
+ label: "expression",
+ type: "text",
+ },
+ {
+ id: "createdAt",
+ label: "Created At",
+ type: "date",
+ },
+ {
+ id: "updatedAt",
+ label: "Updated At",
+ 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}
+ shallow={false}
+ >
+ <TagNumberingTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+
+ </DataTable>
+
+ <ViewTagOptions
+ open={rowAction?.type === "items"}
+ onOpenChange={() => setRowAction(null)}
+ tagSubfield={rowAction?.row.original ?? null}
+ />
+
+ </>
+ )
+}
diff --git a/lib/tag-numbering/validation.ts b/lib/tag-numbering/validation.ts
new file mode 100644
index 00000000..36199f24
--- /dev/null
+++ b/lib/tag-numbering/validation.ts
@@ -0,0 +1,39 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { ViewTagSubfields } from "@/db/schema/vendorData";
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<ViewTagSubfields>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ tagTypeCode: parseAsString.withDefault(""),
+ tagTypeDescription: parseAsString.withDefault(""),
+ attributesId: parseAsString.withDefault(""),
+ attributesDescription: parseAsString.withDefault(""),
+ expression: parseAsString.withDefault(""),
+ delimiter: parseAsString.withDefault(""),
+ sortOrder: parseAsString.withDefault(""),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+
+
+export type GetTagNumberigSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
diff --git a/lib/tags/form-mapping-service.ts b/lib/tags/form-mapping-service.ts
new file mode 100644
index 00000000..4b772ab6
--- /dev/null
+++ b/lib/tags/form-mapping-service.ts
@@ -0,0 +1,65 @@
+"use server"
+
+import db from "@/db/db"
+import { tagTypeClassFormMappings } from "@/db/schema/vendorData";
+import { eq, and } from "drizzle-orm"
+
+// 폼 정보 인터페이스 (동일)
+export interface FormMapping {
+ formCode: string;
+ formName: string;
+}
+
+/**
+ * 주어진 tagType, classCode로 DB를 조회하여
+ * 1) 특정 classCode 매핑 => 존재하면 반환
+ * 2) 없으면 DEFAULT 매핑 => 없으면 빈 배열
+ */
+export async function getFormMappingsByTagType(
+ tagType: string,
+ classCode?: string
+): Promise<FormMapping[]> {
+
+ console.log(`DB-based getFormMappingsByTagType => tagType="${tagType}", class="${classCode ?? "NONE"}"`);
+
+ // 1) classCode가 있으면 시도
+ if (classCode) {
+ const specificRows = await db
+ .select({
+ formCode: tagTypeClassFormMappings.formCode,
+ formName: tagTypeClassFormMappings.formName,
+ })
+ .from(tagTypeClassFormMappings)
+ .where(and(
+ eq(tagTypeClassFormMappings.tagTypeLabel, tagType),
+ eq(tagTypeClassFormMappings.classLabel, classCode)
+ ))
+
+ if (specificRows.length > 0) {
+ console.log("Found specific mapping rows:", specificRows.length);
+ return specificRows;
+ }
+ }
+
+ // 2) fallback => DEFAULT
+ console.log(`Falling back to DEFAULT for tagType="${tagType}"`);
+ const defaultRows = await db
+ .select({
+ formCode: tagTypeClassFormMappings.formCode,
+ formName: tagTypeClassFormMappings.formName,
+ })
+ .from(tagTypeClassFormMappings)
+ .where(and(
+ eq(tagTypeClassFormMappings.tagTypeLabel, tagType),
+ eq(tagTypeClassFormMappings.classLabel, "DEFAULT")
+ ))
+
+ if (defaultRows.length > 0) {
+ console.log("Using DEFAULT mapping rows:", defaultRows.length);
+ return defaultRows;
+ }
+
+ // 3) 아무것도 없으면 빈 배열
+ console.log(`No mappings found at all for tagType="${tagType}"`);
+ return [];
+} \ No newline at end of file
diff --git a/lib/tags/repository.ts b/lib/tags/repository.ts
new file mode 100644
index 00000000..b5d48335
--- /dev/null
+++ b/lib/tags/repository.ts
@@ -0,0 +1,71 @@
+import db from "@/db/db";
+import { NewTag, tags } from "@/db/schema/vendorData";
+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 selectTags(
+ 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(tags)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+/** 총 개수 count */
+export async function countTags(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(tags).where(where);
+ return res[0]?.count ?? 0;
+}
+
+export async function insertTag(
+ tx: PgTransaction<any, any, any>,
+ data: NewTag // DB와 동일한 insert 가능한 타입
+) {
+ // returning() 사용 시 배열로 돌아오므로 [0]만 리턴
+ return tx
+ .insert(tags)
+ .values(data)
+ .returning({ id: tags.id, createdAt: tags.createdAt });
+}
+
+/** 단건 삭제 */
+export async function deleteTagById(
+ tx: PgTransaction<any, any, any>,
+ tagId: number
+) {
+ return tx.delete(tags).where(eq(tags.id, tagId));
+}
+
+/** 복수 삭제 */
+export async function deleteTagsByIds(
+ tx: PgTransaction<any, any, any>,
+ ids: number[]
+) {
+ return tx.delete(tags).where(inArray(tags.id, ids));
+}
diff --git a/lib/tags/service.ts b/lib/tags/service.ts
new file mode 100644
index 00000000..efba2fd5
--- /dev/null
+++ b/lib/tags/service.ts
@@ -0,0 +1,796 @@
+"use server"
+
+import db from "@/db/db"
+import { formEntries, forms, tagClasses, tags, tagSubfieldOptions, tagSubfields, tagTypes } from "@/db/schema/vendorData"
+// import { eq } from "drizzle-orm"
+import { createTagSchema, GetTagsSchema, updateTagSchema, UpdateTagSchema, type CreateTagSchema } from "./validations"
+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 } from "drizzle-orm";
+import { countTags, deleteTagById, deleteTagsByIds, insertTag, selectTags } from "./repository";
+import { getErrorMessage } from "../handle-error";
+import { getFormMappingsByTagType } from './form-mapping-service';
+import { contractItems } from "@/db/schema/contract";
+
+
+// 폼 결과를 위한 인터페이스 정의
+interface CreatedOrExistingForm {
+ id: number;
+ formCode: string;
+ formName: string;
+ isNewlyCreated: boolean;
+}
+
+export async function getTags(input: GetTagsSchema, packagesId: number) {
+
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // (1) advancedWhere
+ const advancedWhere = filterColumns({
+ table: tags,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // (2) globalWhere
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(tags.tagNo, s),
+ ilike(tags.tagType, s),
+ ilike(tags.description, s)
+ );
+ }
+ // (4) 최종 where
+ const finalWhere = and(advancedWhere, globalWhere, eq(tags.contractItemId, packagesId));
+
+ // (5) 정렬
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(tags[item.id]) : asc(tags[item.id])
+ )
+ : [asc(tags.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectTags(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countTags(tx, finalWhere);
+
+
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input), String(packagesId)], // 캐싱 키에 packagesId 추가
+ {
+ revalidate: 3600,
+ tags: [`tags-${packagesId}`], // 패키지별 태그 사용
+ }
+ )();
+}
+
+export async function createTag(
+ formData: CreateTagSchema,
+ selectedPackageId: number | null
+) {
+ 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 })
+ .from(contractItems)
+ .where(eq(contractItems.id, selectedPackageId))
+ .limit(1)
+
+ if (contractItemResult.length === 0) {
+ return { error: "Contract item not found" }
+ }
+
+ const contractId = contractItemResult[0].contractId
+
+ // 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}"는 이미 이 계약 내에 존재합니다.`,
+ }
+ }
+
+ // 3) 태그 타입에 따른 폼 정보 가져오기
+ const formMappings = await getFormMappingsByTagType(
+ validated.data.tagType,
+ validated.data.class
+ )
+
+ // 폼 매핑이 없으면 로그만 남기고 진행
+ if (!formMappings || formMappings.length === 0) {
+ console.log(
+ "No form mappings found for tag type:",
+ validated.data.tagType
+ )
+ }
+
+ // 4) 이 태그 타입에 대한 주요 폼(첫 번째 폼)을 찾거나 생성
+ let primaryFormId: number | null = null
+ const createdOrExistingForms: CreatedOrExistingForm[] = []
+
+ if (formMappings && formMappings.length > 0) {
+ for (const formMapping of formMappings) {
+ // 4-1) 이미 존재하는 폼인지 확인
+ const existingForm = await tx
+ .select({ id: forms.id })
+ .from(forms)
+ .where(
+ and(
+ eq(forms.contractItemId, selectedPackageId),
+ eq(forms.formCode, formMapping.formCode)
+ )
+ )
+ .limit(1)
+
+ let formId: number
+ if (existingForm.length > 0) {
+ // 이미 존재하면 해당 ID 사용
+ formId = existingForm[0].id
+ createdOrExistingForms.push({
+ id: formId,
+ formCode: formMapping.formCode,
+ formName: formMapping.formName,
+ isNewlyCreated: false,
+ })
+ } else {
+ // 존재하지 않으면 새로 생성
+ const insertResult = await tx
+ .insert(forms)
+ .values({
+ contractItemId: selectedPackageId,
+ formCode: formMapping.formCode,
+ formName: formMapping.formName,
+ })
+ .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName })
+
+ console.log("insertResult:", insertResult)
+ formId = insertResult[0].id
+ createdOrExistingForms.push({
+ id: formId,
+ formCode: insertResult[0].formCode,
+ formName: insertResult[0].formName,
+ isNewlyCreated: true,
+ })
+ }
+
+ // 첫 번째 폼을 "주요 폼"으로 설정하여 태그 생성 시 사용
+ if (primaryFormId === null) {
+ primaryFormId = formId
+ }
+ }
+ }
+
+ // 5) 새 Tag 생성 (같은 트랜잭션 `tx` 사용)
+ const [newTag] = await insertTag(tx, {
+ contractItemId: selectedPackageId,
+ formId: primaryFormId,
+ tagNo: validated.data.tagNo,
+ class: validated.data.class,
+ tagType: validated.data.tagType,
+ description: validated.data.description ?? null,
+ })
+
+ console.log(`tags-${selectedPackageId}`, "create", newTag)
+
+ // 6) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시)
+ revalidateTag(`tags-${selectedPackageId}`)
+ revalidateTag(`forms-${selectedPackageId}`)
+ revalidateTag("tags")
+
+ // 7) 성공 시 반환
+ return {
+ success: true,
+ data: {
+ forms: createdOrExistingForms,
+ primaryFormId,
+ },
+ }
+ })
+ } catch (err: any) {
+ console.error("createTag error:", err)
+ return { error: getErrorMessage(err) }
+ }
+}
+
+export async function updateTag(
+ formData: UpdateTagSchema & { id: number },
+ selectedPackageId: number | null
+) {
+ if (!selectedPackageId) {
+ return { error: "No selectedPackageId provided" }
+ }
+
+ if (!formData.id) {
+ return { error: "No tag ID provided" }
+ }
+
+ // Validate formData
+ const validated = updateTagSchema.safeParse(formData)
+ if (!validated.success) {
+ return { error: validated.error.flatten().formErrors.join(", ") }
+ }
+
+ // React 서버 액션에서 매 요청마다 실행
+ unstable_noStore()
+
+ try {
+ // 하나의 트랜잭션에서 모든 작업 수행
+ return await db.transaction(async (tx) => {
+ // 1) 기존 태그 존재 여부 확인
+ const existingTag = await tx
+ .select()
+ .from(tags)
+ .where(eq(tags.id, formData.id))
+ .limit(1)
+
+ if (existingTag.length === 0) {
+ return { error: "태그를 찾을 수 없습니다." }
+ }
+
+ const originalTag = existingTag[0]
+
+ // 2) 선택된 contractItem의 contractId 가져오기
+ const contractItemResult = await tx
+ .select({ contractId: contractItems.contractId })
+ .from(contractItems)
+ .where(eq(contractItems.id, selectedPackageId))
+ .limit(1)
+
+ if (contractItemResult.length === 0) {
+ return { error: "Contract item not found" }
+ }
+
+ const contractId = contractItemResult[0].contractId
+
+ // 3) 태그 번호가 변경되었고, 해당 계약 내에서 같은 tagNo를 가진 다른 태그가 있는지 확인
+ if (originalTag.tagNo !== validated.data.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),
+ ne(tags.id, formData.id) // 자기 자신은 제외
+ )
+ )
+
+ if (duplicateCheck[0].count > 0) {
+ return {
+ error: `태그 번호 "${validated.data.tagNo}"는 이미 이 계약 내에 존재합니다.`,
+ }
+ }
+ }
+
+ // 4) 태그 타입이나 클래스가 변경되었는지 확인
+ const isTagTypeOrClassChanged =
+ originalTag.tagType !== validated.data.tagType ||
+ originalTag.class !== validated.data.class
+
+ let primaryFormId = originalTag.formId
+
+ // 태그 타입이나 클래스가 변경되었다면 연관된 폼 업데이트
+ if (isTagTypeOrClassChanged) {
+ // 4-1) 태그 타입에 따른 폼 정보 가져오기
+ const formMappings = await getFormMappingsByTagType(
+ validated.data.tagType,
+ validated.data.class
+ )
+
+ // 폼 매핑이 없으면 로그만 남기고 진행
+ if (!formMappings || formMappings.length === 0) {
+ console.log(
+ "No form mappings found for tag type:",
+ validated.data.tagType
+ )
+ }
+
+ // 4-2) 이 태그 타입에 대한 주요 폼(첫 번째 폼)을 찾거나 생성
+ const createdOrExistingForms: CreatedOrExistingForm[] = []
+
+ if (formMappings && formMappings.length > 0) {
+ for (const formMapping of formMappings) {
+ // 이미 존재하는 폼인지 확인
+ const existingForm = await tx
+ .select({ id: forms.id })
+ .from(forms)
+ .where(
+ and(
+ eq(forms.contractItemId, selectedPackageId),
+ eq(forms.formCode, formMapping.formCode)
+ )
+ )
+ .limit(1)
+
+ let formId: number
+ if (existingForm.length > 0) {
+ // 이미 존재하면 해당 ID 사용
+ formId = existingForm[0].id
+ createdOrExistingForms.push({
+ id: formId,
+ formCode: formMapping.formCode,
+ formName: formMapping.formName,
+ isNewlyCreated: false,
+ })
+ } else {
+ // 존재하지 않으면 새로 생성
+ const insertResult = await tx
+ .insert(forms)
+ .values({
+ contractItemId: selectedPackageId,
+ formCode: formMapping.formCode,
+ formName: formMapping.formName,
+ })
+ .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName })
+
+ formId = insertResult[0].id
+ createdOrExistingForms.push({
+ id: formId,
+ formCode: insertResult[0].formCode,
+ formName: insertResult[0].formName,
+ isNewlyCreated: true,
+ })
+ }
+
+ // 첫 번째 폼을 "주요 폼"으로 설정하여 태그 업데이트 시 사용
+ if (createdOrExistingForms.length === 1) {
+ primaryFormId = formId
+ }
+ }
+ }
+ }
+
+ // 5) 태그 업데이트
+ const [updatedTag] = await tx
+ .update(tags)
+ .set({
+ contractItemId: selectedPackageId,
+ formId: primaryFormId,
+ tagNo: validated.data.tagNo,
+ class: validated.data.class,
+ tagType: validated.data.tagType,
+ description: validated.data.description ?? null,
+ updatedAt: new Date(),
+ })
+ .where(eq(tags.id, formData.id))
+ .returning()
+
+ // 6) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시)
+ revalidateTag(`tags-${selectedPackageId}`)
+ revalidateTag(`forms-${selectedPackageId}`)
+ revalidateTag("tags")
+
+ // 7) 성공 시 반환
+ return {
+ success: true,
+ data: {
+ tag: updatedTag,
+ formUpdated: isTagTypeOrClassChanged
+ },
+ }
+ })
+ } catch (err: any) {
+ console.error("updateTag error:", err)
+ return { error: getErrorMessage(err) }
+ }
+}
+
+export interface TagInputData {
+ tagNo: string;
+ class: string;
+ tagType: string;
+ description?: string | null;
+ formId?: number | null;
+ [key: string]: any;
+}
+// 새로운 서버 액션
+export async function bulkCreateTags(
+ tagsfromExcel: TagInputData[],
+ selectedPackageId: number
+) {
+ unstable_noStore();
+
+ if (!tagsfromExcel.length) {
+ return { error: "No tags provided" };
+ }
+
+ try {
+ // 단일 트랜잭션으로 모든 작업 처리
+ return await db.transaction(async (tx) => {
+ // 1. 컨트랙트 ID 조회 (한 번만)
+ const contractItemResult = await tx
+ .select({ contractId: contractItems.contractId })
+ .from(contractItems)
+ .where(eq(contractItems.id, selectedPackageId))
+ .limit(1);
+
+ if (contractItemResult.length === 0) {
+ return { error: "Contract item not found" };
+ }
+
+ const contractId = contractItemResult[0].contractId;
+
+ // 2. 모든 태그 번호 중복 검사 (한 번에)
+ const tagNos = tagsfromExcel.map(tag => tag.tagNo);
+ const duplicateCheck = await tx
+ .select({ tagNo: tags.tagNo })
+ .from(tags)
+ .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id))
+ .where(and(
+ eq(contractItems.contractId, contractId),
+ inArray(tags.tagNo, tagNos)
+ ));
+
+ if (duplicateCheck.length > 0) {
+ return {
+ error: `태그 번호 "${duplicateCheck.map(d => d.tagNo).join(', ')}"는 이미 존재합니다.`
+ };
+ }
+
+ // 3. 태그별 폼 정보 처리 및 태그 생성
+ const createdTags = [];
+
+ for (const tagData of tagsfromExcel) {
+ // 각 태그 유형에 대한 폼 처리 (createTag 함수와 유사한 로직)
+ const formMappings = await getFormMappingsByTagType(tagData.tagType, tagData.class);
+ let primaryFormId = null;
+
+ // 폼 처리 로직 (생략...)
+
+ // 태그 생성
+ const [newTag] = await insertTag(tx, {
+ contractItemId: selectedPackageId,
+ formId: primaryFormId,
+ tagNo: tagData.tagNo,
+ class: tagData.class,
+ tagType: tagData.tagType,
+ description: tagData.description || null,
+ });
+
+ createdTags.push(newTag);
+ }
+
+ // 4. 캐시 무효화 (한 번만)
+ revalidateTag(`tags-${selectedPackageId}`);
+ revalidateTag(`forms-${selectedPackageId}`);
+ revalidateTag("tags");
+
+ return {
+ success: true,
+ data: {
+ createdCount: createdTags.length,
+ tags: createdTags
+ }
+ };
+ });
+ } catch (err: any) {
+ console.error("bulkCreateTags error:", err);
+ return { error: err.message || "Failed to create tags" };
+ }
+}
+
+
+/** 복수 삭제 */
+interface RemoveTagsInput {
+ ids: number[];
+ selectedPackageId: number;
+}
+
+
+// formEntries의 data JSON에서 tagNo가 일치하는 객체를 제거해주는 예시 함수
+function removeTagFromDataJson(
+ dataJson: any,
+ tagNo: string
+): any {
+ // data 구조가 어떻게 생겼는지에 따라 로직이 달라집니다.
+ // 예: data 배열 안에 { tagNumber: string, ... } 형태로 여러 객체가 있다고 가정
+ if (!Array.isArray(dataJson)) return dataJson
+ return dataJson.filter((entry) => entry.tagNumber !== tagNo)
+}
+
+export async function removeTags(input: RemoveTagsInput) {
+ unstable_noStore() // React 서버 액션 무상태 함수
+
+ const { ids, selectedPackageId } = input
+
+ try {
+ await db.transaction(async (tx) => {
+ // 1) 삭제 대상 tag들을 미리 조회 (tagNo, tagType, class 등을 얻기 위함)
+ const tagsToDelete = await tx
+ .select({
+ id: tags.id,
+ tagNo: tags.tagNo,
+ tagType: tags.tagType,
+ class: tags.class,
+ })
+ .from(tags)
+ .where(inArray(tags.id, ids))
+
+ // 2) 각 tag마다 관련된 formCode를 찾고, forms & formEntries 처리를 수행
+ for (const tagInfo of tagsToDelete) {
+ const { tagNo, tagType, class: tagClass } = tagInfo
+
+ // 2-1) tagTypeClassFormMappings(혹은 대응되는 로직)에서 formCode 목록 가져오기
+ const formMappings = await getFormMappingsByTagType(tagType, tagClass)
+ if (!formMappings) continue
+
+ // 2-2) 얻어온 formCode 리스트를 순회하면서, forms 테이블과 formEntries 테이블 처리
+ for (const fm of formMappings) {
+ // (A) forms 테이블 삭제
+ // - 조건: contractItemId=selectedPackageId, formCode=fm.formCode
+ await tx
+ .delete(forms)
+ .where(
+ and(
+ eq(forms.contractItemId, selectedPackageId),
+ eq(forms.formCode, fm.formCode)
+ )
+ )
+
+ // (B) formEntries 테이블 JSON에서 tagNo 제거 → 업데이트
+ // - 예: formEntries 안에 (id, contractItemId, formCode, data(=json)) 칼럼 존재 가정
+ const formEntryRecords = await tx
+ .select({
+ id: formEntries.id,
+ data: formEntries.data,
+ })
+ .from(formEntries)
+ .where(
+ and(
+ eq(formEntries.contractItemId, selectedPackageId),
+ eq(formEntries.formCode, fm.formCode)
+ )
+ )
+
+ // 여러 formEntries 레코드가 있을 수도 있어서 모두 처리
+ for (const entry of formEntryRecords) {
+ const updatedJson = removeTagFromDataJson(entry.data, tagNo)
+
+ // 변경이 있다면 업데이트
+ await tx
+ .update(formEntries)
+ .set({ data: updatedJson })
+ .where(eq(formEntries.id, entry.id))
+ }
+ }
+ }
+
+ // 3) 마지막으로 실제로 tags 테이블에서 Tag들을 삭제
+ // (Tag → forms → formEntries 순서대로 처리)
+ await tx.delete(tags).where(inArray(tags.id, ids))
+ })
+
+ // 4) 캐시 무효화
+ // revalidateTag("tags")
+ revalidateTag(`tags-${selectedPackageId}`)
+ revalidateTag(`forms-${selectedPackageId}`)
+
+ return { data: null, error: null }
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) }
+ }
+}
+
+// Updated service functions to support the new schema
+
+// 업데이트된 ClassOption 타입
+export interface ClassOption {
+ code: string;
+ label: string;
+ tagTypeCode: string; // 클래스와 연결된 태그 타입 코드
+ tagTypeDescription?: string; // 태그 타입의 설명 (선택적)
+}
+
+/**
+ * Class 옵션 목록을 가져오는 함수
+ * 이제 각 클래스는 연결된 tagTypeCode와 tagTypeDescription을 포함
+ */
+export async function getClassOptions(){
+ const rows = await db
+ .select({
+ id: tagClasses.id,
+ code: tagClasses.code,
+ label: tagClasses.label,
+ tagTypeCode: tagClasses.tagTypeCode,
+ tagTypeDescription: tagTypes.description,
+ })
+ .from(tagClasses)
+ .leftJoin(tagTypes, eq(tagTypes.code, tagClasses.tagTypeCode))
+
+ return rows.map((row) => ({
+ code: row.code,
+ label: row.label,
+ tagTypeCode: row.tagTypeCode,
+ tagTypeDescription: row.tagTypeDescription ?? "",
+ }))
+}
+
+interface SubFieldDef {
+ name: string
+ label: string
+ type: "select" | "text"
+ options: { value: string; label: string }[]
+ expression: string | null
+ delimiter: string | null
+}
+
+export async function getSubfieldsByTagType(tagTypeCode: string) {
+ try {
+ const rows = await db
+ .select()
+ .from(tagSubfields)
+ .where(eq(tagSubfields.tagTypeCode, tagTypeCode))
+ .orderBy(asc(tagSubfields.sortOrder))
+
+ // 각 row -> SubFieldDef
+ const formattedSubFields: SubFieldDef[] = []
+ for (const sf of rows) {
+ const subfieldType = await getSubfieldType(sf.attributesId)
+ const subfieldOptions = subfieldType === "select"
+ ? await getSubfieldOptions(sf.attributesId)
+ : []
+
+ formattedSubFields.push({
+ name: sf.attributesId.toLowerCase(),
+ label: sf.attributesDescription,
+ type: subfieldType,
+ options: subfieldOptions,
+ expression: sf.expression,
+ delimiter: sf.delimiter,
+ })
+ }
+
+ return { subFields: formattedSubFields }
+ } catch (error) {
+ console.error("Error fetching subfields by tag type:", error)
+ throw new Error("Failed to fetch subfields")
+ }
+}
+
+
+async function getSubfieldType(attributesId: string): Promise<"select" | "text"> {
+ const optRows = await db
+ .select()
+ .from(tagSubfieldOptions)
+ .where(eq(tagSubfieldOptions.attributesId, attributesId))
+
+ return optRows.length > 0 ? "select" : "text"
+}
+
+export interface SubfieldOption {
+ /**
+ * 옵션의 실제 값 (데이터베이스에 저장될 값)
+ * 예: "PM", "AA", "VB", "01" 등
+ */
+ value: string;
+
+ /**
+ * 옵션의 표시 레이블 (사용자에게 보여질 텍스트)
+ * 예: "Pump", "Pneumatic Motor", "Ball Valve" 등
+ */
+ label: string;
+}
+
+
+
+/**
+ * SubField의 옵션 목록을 가져오는 보조 함수
+ */
+async function getSubfieldOptions(attributesId: string): Promise<SubfieldOption[]> {
+ try {
+ const rows = await db
+ .select({
+ code: tagSubfieldOptions.code,
+ label: tagSubfieldOptions.label
+ })
+ .from(tagSubfieldOptions)
+ .where(eq(tagSubfieldOptions.attributesId, attributesId))
+
+ return rows.map((row) => ({
+ value: row.code,
+ label: row.label
+ }))
+ } catch (error) {
+ console.error(`Error fetching options for attribute ${attributesId}:`, error)
+ return []
+ }
+}
+
+
+/**
+ * Tag Type 목록을 가져오는 함수
+ * 이제 tagTypes 테이블에서 직접 데이터를 가져옴
+ */
+export async function getTagTypes(): Promise<{ options: TagTypeOption[] }> {
+ return unstable_cache(
+ async () => {
+ console.log(`[Server] Fetching tag types from tagTypes table`)
+
+ try {
+ // 이제 tagSubfields가 아닌 tagTypes 테이블에서 직접 조회
+ const result = await db
+ .select({
+ code: tagTypes.code,
+ description: tagTypes.description,
+ })
+ .from(tagTypes)
+ .orderBy(tagTypes.description);
+
+ // TagTypeOption 형식으로 변환
+ const tagTypeOptions: TagTypeOption[] = result.map(item => ({
+ id: item.code, // id 필드에 code 값 할당
+ label: item.description, // label 필드에 description 값 할당
+ }));
+
+ console.log(`[Server] Found ${tagTypeOptions.length} tag types`)
+ return { options: tagTypeOptions };
+ } catch (error) {
+ console.error('[Server] Error fetching tag types:', error)
+ return { options: [] }
+ }
+ },
+ ['tag-types-list'],
+ {
+ revalidate: 3600, // 1시간 캐시
+ tags: ['tag-types']
+ }
+ )()
+}
+
+/**
+ * TagTypeOption 인터페이스 정의
+ */
+export interface TagTypeOption {
+ id: string; // tagTypes.code 값
+ label: string; // tagTypes.description 값
+} \ No newline at end of file
diff --git a/lib/tags/table/add-tag-dialog copy.tsx b/lib/tags/table/add-tag-dialog copy.tsx
new file mode 100644
index 00000000..e9f84933
--- /dev/null
+++ b/lib/tags/table/add-tag-dialog copy.tsx
@@ -0,0 +1,637 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation" // <-- 1) Import router from App Router
+import { useForm, useWatch } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { toast } from "sonner"
+import { Loader2, ChevronsUpDown, Check } from "lucide-react"
+
+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 {
+ Form,
+ FormField,
+ FormItem,
+ FormControl,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandEmpty,
+} from "@/components/ui/command"
+import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
+import { cn } from "@/lib/utils"
+
+import type { CreateTagSchema } from "@/lib/tags/validations"
+import { createTagSchema } from "@/lib/tags/validations"
+import {
+ createTag,
+ getSubfieldsByTagType,
+ getClassOptions,
+ type ClassOption,
+ TagTypeOption,
+} from "@/lib/tags/service"
+
+// SubFieldDef for clarity
+interface SubFieldDef {
+ name: string
+ label: string
+ type: "select" | "text"
+ options?: { value: string; label: string }[]
+ expression?: string
+ delimiter?: string
+}
+
+// 클래스 옵션 인터페이스
+interface UpdatedClassOption extends ClassOption {
+ tagTypeCode: string
+ tagTypeDescription?: string
+}
+
+interface AddTagDialogProps {
+ selectedPackageId: number | null
+}
+
+export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
+ const router = useRouter() // <-- 2) Use the router hook
+
+ const [open, setOpen] = React.useState(false)
+ const [tagTypeList, setTagTypeList] = React.useState<TagTypeOption[]>([])
+ const [selectedTagTypeCode, setSelectedTagTypeCode] = React.useState<string | null>(null)
+ const [subFields, setSubFields] = React.useState<SubFieldDef[]>([])
+ const [classOptions, setClassOptions] = React.useState<UpdatedClassOption[]>([])
+ const [classSearchTerm, setClassSearchTerm] = React.useState("")
+ const [isLoadingClasses, setIsLoadingClasses] = React.useState(false)
+ const [isLoadingSubFields, setIsLoadingSubFields] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ // ID management
+ const selectIdRef = React.useRef(0)
+ const getUniqueSelectId = React.useCallback(() => `select-${selectIdRef.current++}`, [])
+ const fieldIdsRef = React.useRef<Record<string, string>>({})
+ const classOptionIdsRef = React.useRef<Record<string, string>>({})
+
+ // ---------------
+ // Load Class Options
+ // ---------------
+ React.useEffect(() => {
+ const loadClassOptions = async () => {
+ setIsLoadingClasses(true)
+ try {
+ const result = await getClassOptions()
+ setClassOptions(result)
+ } catch (err) {
+ toast.error("Failed to load class options")
+ } finally {
+ setIsLoadingClasses(false)
+ }
+ }
+
+ if (open) {
+ loadClassOptions()
+ }
+ }, [open])
+
+ // ---------------
+ // react-hook-form
+ // ---------------
+ const form = useForm<CreateTagSchema>({
+ resolver: zodResolver(createTagSchema),
+ defaultValues: {
+ tagType: "",
+ tagNo: "",
+ description: "",
+ functionCode: "",
+ seqNumber: "",
+ valveAcronym: "",
+ processUnit: "",
+ class: "",
+ },
+ })
+
+ // watch
+ const { tagNo, ...fieldsToWatch } = useWatch({
+ control: form.control,
+ })
+
+ // ---------------
+ // Load subfields by TagType code
+ // ---------------
+ async function loadSubFieldsByTagTypeCode(tagTypeCode: string) {
+ setIsLoadingSubFields(true)
+ try {
+ const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode)
+ const formattedSubFields: SubFieldDef[] = apiSubFields.map(field => ({
+ name: field.name,
+ label: field.label,
+ type: field.type,
+ options: field.options || [],
+ expression: field.expression ?? undefined,
+ delimiter: field.delimiter ?? undefined,
+ }))
+ setSubFields(formattedSubFields)
+ selectIdRef.current = 0
+ return true
+ } catch (err) {
+ toast.error("Failed to load subfields")
+ setSubFields([])
+ return false
+ } finally {
+ setIsLoadingSubFields(false)
+ }
+ }
+
+ // ---------------
+ // Handle class selection
+ // ---------------
+ async function handleSelectClass(classOption: UpdatedClassOption) {
+ form.setValue("class", classOption.label)
+ if (classOption.tagTypeCode) {
+ setSelectedTagTypeCode(classOption.tagTypeCode)
+ // If you have tagTypeList, you can find the label
+ const tagType = tagTypeList.find(t => t.id === classOption.tagTypeCode)
+ if (tagType) {
+ form.setValue("tagType", tagType.label)
+ } else if (classOption.tagTypeDescription) {
+ form.setValue("tagType", classOption.tagTypeDescription)
+ }
+ await loadSubFieldsByTagTypeCode(classOption.tagTypeCode)
+ }
+ }
+
+ // ---------------
+ // Render subfields
+ // ---------------
+ function renderSubFields() {
+ if (isLoadingSubFields) {
+ return (
+ <div className="flex justify-center items-center py-8">
+ <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
+ <span className="ml-3 text-muted-foreground">Loading fields...</span>
+ </div>
+ )
+ }
+ if (subFields.length === 0 && selectedTagTypeCode) {
+ return (
+ <div className="py-4 text-center text-muted-foreground">
+ No fields available for this tag type.
+ </div>
+ )
+ }
+ if (subFields.length === 0) {
+ return null
+ }
+
+ return subFields.map((sf, index) => {
+ if (!fieldIdsRef.current[`${sf.name}-${index}`]) {
+ fieldIdsRef.current[`${sf.name}-${index}`] =
+ `field-${sf.name}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
+ }
+ const fieldId = fieldIdsRef.current[`${sf.name}-${index}`]
+ const selectId = getUniqueSelectId()
+
+ return (
+ <FormField
+ key={fieldId}
+ control={form.control}
+ name={sf.name as keyof CreateTagSchema}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{sf.label}</FormLabel>
+ <FormControl>
+ {sf.type === "select" ? (
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue
+ placeholder={`Select ${sf.label}`}
+ className={
+ !field.value ? "text-muted-foreground text-opacity-60" : ""
+ }
+ />
+ </SelectTrigger>
+ <SelectContent
+ align="start"
+ side="bottom"
+ style={{ width: 400, maxWidth: 400 }}
+ sideOffset={4}
+ id={selectId}
+ >
+ {sf.options?.map((opt, optIndex) => {
+ const optionKey = `${fieldId}-option-${opt.value}-${optIndex}`
+ return (
+ <SelectItem
+ key={optionKey}
+ value={opt.value}
+ className="multi-line-select-item pr-6"
+ title={opt.label}
+ >
+ {opt.label}
+ </SelectItem>
+ )
+ })}
+ </SelectContent>
+ </Select>
+ ) : (
+ <Input
+ placeholder={`Enter ${sf.label}`}
+ {...field}
+ className={
+ !field.value
+ ? "placeholder:text-muted-foreground placeholder:text-opacity-60"
+ : ""
+ }
+ />
+ )}
+ </FormControl>
+ <FormMessage>
+ {sf.expression && (
+ <span
+ className="text-xs text-muted-foreground truncate block"
+ title={sf.expression}
+ >
+ 형식: {sf.expression}
+ </span>
+ )}
+ </FormMessage>
+ </FormItem>
+ )}
+ />
+ )
+ })
+ }
+
+ // ---------------
+ // Build TagNo from subfields automatically
+ // ---------------
+ React.useEffect(() => {
+ if (subFields.length === 0) {
+ form.setValue("tagNo", "", { shouldDirty: false })
+ }
+
+ const subscription = form.watch((value, { name }) => {
+ if (!name || name === "tagNo" || subFields.length === 0) {
+ return
+ }
+ let combined = ""
+ subFields.forEach((sf, idx) => {
+ const fieldValue = form.getValues(sf.name as keyof CreateTagSchema) || ""
+ combined += fieldValue
+ if (fieldValue && idx < subFields.length - 1 && sf.delimiter) {
+ combined += sf.delimiter
+ }
+ })
+ const currentTagNo = form.getValues("tagNo")
+ if (currentTagNo !== combined) {
+ form.setValue("tagNo", combined, {
+ shouldDirty: false,
+ shouldTouch: false,
+ shouldValidate: false,
+ })
+ }
+ })
+
+ return () => subscription.unsubscribe()
+ }, [subFields, form])
+
+ // ---------------
+ // Basic validation for TagNo
+ // ---------------
+ const isTagNoValid = React.useMemo(() => {
+ const val = form.getValues("tagNo")
+ return val && val.trim() !== "" && !val.includes("??")
+ }, [fieldsToWatch])
+
+ // ---------------
+ // Submit handler
+ // ---------------
+ async function onSubmit(data: CreateTagSchema) {
+ if (!selectedPackageId) {
+ toast.error("No selectedPackageId.")
+ return
+ }
+ setIsSubmitting(true)
+ try {
+ const res = await createTag(data, selectedPackageId)
+ if ("error" in res) {
+ toast.error(`Error: ${res.error}`)
+ return
+ }
+
+ toast.success("Tag created successfully!")
+
+ // 3) Refresh or navigate after creation:
+ // Option A: If you just want to refresh the same route:
+ router.refresh()
+
+ // Option B: If you want to go to /partners/vendor-data/tag/{selectedPackageId}
+ // router.push(`/partners/vendor-data/tag/${selectedPackageId}?r=${Date.now()}`)
+
+ // (If you want to reset the form dialog or close it, do that too)
+ form.reset()
+ setOpen(false)
+ } catch (err) {
+ toast.error("Failed to create tag.")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // ---------------
+ // Render Class field
+ // ---------------
+ function renderClassField(field: any) {
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+ const buttonId = React.useMemo(
+ () => `class-button-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ []
+ )
+ const popoverContentId = React.useMemo(
+ () => `class-popover-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ []
+ )
+ const commandId = React.useMemo(
+ () => `class-command-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ []
+ )
+
+ return (
+ <FormItem>
+ <FormLabel>Class</FormLabel>
+ <FormControl>
+ <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ key={buttonId}
+ type="button"
+ variant="outline"
+ className="w-full justify-between"
+ disabled={isLoadingClasses}
+ >
+ {isLoadingClasses ? (
+ <>
+ <span>Loading classes...</span>
+ <Loader2 className="ml-2 h-4 w-4 animate-spin" />
+ </>
+ ) : (
+ <>
+ <span className="truncate">
+ {field.value || "Select Class..."}
+ </span>
+ <ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" />
+ </>
+ )}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent key={popoverContentId} className="w-full p-0">
+ <Command key={commandId}>
+ <CommandInput
+ key={`${commandId}-input`}
+ placeholder="Search Class..."
+ value={classSearchTerm}
+ onValueChange={setClassSearchTerm}
+ />
+ <CommandList key={`${commandId}-list`}>
+ <CommandEmpty key={`${commandId}-empty`}>No class found.</CommandEmpty>
+ <CommandGroup key={`${commandId}-group`}>
+ {classOptions.map((opt) => {
+ if (!classOptionIdsRef.current[opt.code]) {
+ classOptionIdsRef.current[opt.code] =
+ `class-${opt.code}-${Date.now()}-${Math.random()
+ .toString(36)
+ .slice(2, 9)}`
+ }
+ const optionId = classOptionIdsRef.current[opt.code]
+
+ return (
+ <CommandItem
+ key={optionId}
+ onSelect={() => {
+ field.onChange(opt.label)
+ setPopoverOpen(false)
+ handleSelectClass(opt)
+ }}
+ value={opt.label}
+ className="truncate"
+ title={opt.label}
+ >
+ <span className="truncate">{opt.label}</span>
+ <Check
+ key={`${optionId}-check`}
+ className={cn(
+ "ml-auto h-4 w-4 flex-shrink-0",
+ field.value === opt.label ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }
+
+ // ---------------
+ // Render TagType field (readonly after class selection)
+ // ---------------
+ function renderTagTypeField(field: any) {
+ const isReadOnly = !!selectedTagTypeCode
+ const inputId = React.useMemo(
+ () =>
+ `tag-type-input-${isReadOnly ? "readonly" : "editable"}-${Date.now()}-${Math.random()
+ .toString(36)
+ .slice(2, 9)}`,
+ [isReadOnly]
+ )
+
+ return (
+ <FormItem>
+ <FormLabel>Tag Type</FormLabel>
+ <FormControl>
+ {isReadOnly ? (
+ <Input
+ key={`tag-type-readonly-${inputId}`}
+ {...field}
+ readOnly
+ className="bg-muted"
+ />
+ ) : (
+ <Input
+ key={`tag-type-placeholder-${inputId}`}
+ {...field}
+ readOnly
+ placeholder="Tag Type is determined by selected Class"
+ className="bg-muted"
+ />
+ )}
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }
+
+ // ---------------
+ // Reset IDs/states when dialog closes
+ // ---------------
+ React.useEffect(() => {
+ if (!open) {
+ fieldIdsRef.current = {}
+ classOptionIdsRef.current = {}
+ selectIdRef.current = 0
+ }
+ }, [open])
+
+ return (
+ <Dialog
+ open={open}
+ onOpenChange={(o) => {
+ if (!o) {
+ form.reset()
+ setSelectedTagTypeCode(null)
+ setSubFields([])
+ }
+ setOpen(o)
+ }}
+ >
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ Add Tag
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="max-h-[80vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>Add New Tag</DialogTitle>
+ <DialogDescription>
+ Choose a Class, and the Tag Type and subfields will be automatically loaded.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="max-h-[70vh] flex flex-col"
+ >
+ <div className="flex-1 overflow-auto px-4 space-y-4">
+ {/* Class */}
+ <FormField
+ key="class-field"
+ control={form.control}
+ name="class"
+ render={({ field }) => renderClassField(field)}
+ />
+
+ {/* TagType (read-only) */}
+ <FormField
+ key="tag-type-field"
+ control={form.control}
+ name="tagType"
+ render={({ field }) => renderTagTypeField(field)}
+ />
+
+ {/* SubFields */}
+ <div className="flex-1 overflow-auto px-2 py-2 space-y-4 max-h-[300px]">
+ {renderSubFields()}
+ </div>
+
+ {/* TagNo (read-only) */}
+ <FormField
+ key="tag-no-field"
+ control={form.control}
+ name="tagNo"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Tag No</FormLabel>
+ <FormControl>
+ <Input
+ {...field}
+ readOnly
+ className="bg-muted truncate"
+ title={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Description */}
+ <FormField
+ key="description-field"
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Description</FormLabel>
+ <FormControl>
+ <Input
+ {...field}
+ placeholder="Enter description..."
+ className="truncate"
+ title={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* Footer */}
+ <DialogFooter className="bg-background z-10 pt-4 px-4 py-4">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ form.reset()
+ setOpen(false)
+ setSubFields([])
+ setSelectedTagTypeCode(null)
+ }}
+ disabled={isSubmitting || isLoadingSubFields}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={isSubmitting || isLoadingSubFields || !isTagNoValid}
+ >
+ {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ Create
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/tags/table/add-tag-dialog.tsx b/lib/tags/table/add-tag-dialog.tsx
new file mode 100644
index 00000000..3814761d
--- /dev/null
+++ b/lib/tags/table/add-tag-dialog.tsx
@@ -0,0 +1,893 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { useForm, useWatch, useFieldArray } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { toast } from "sonner"
+import { Loader2, ChevronsUpDown, Check, Plus, Trash2, Copy } from "lucide-react"
+
+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 {
+ Form,
+ FormField,
+ FormItem,
+ FormControl,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandEmpty,
+} from "@/components/ui/command"
+import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
+import { cn } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+
+import type { CreateTagSchema } from "@/lib/tags/validations"
+import { createTagSchema } from "@/lib/tags/validations"
+import {
+ createTag,
+ getSubfieldsByTagType,
+ getClassOptions,
+ type ClassOption,
+ TagTypeOption,
+} from "@/lib/tags/service"
+
+// Updated to support multiple rows
+interface MultiTagFormValues {
+ class: string;
+ tagType: string;
+ rows: Array<{
+ [key: string]: string;
+ tagNo: string;
+ description: string;
+ }>;
+}
+
+// SubFieldDef for clarity
+interface SubFieldDef {
+ name: string
+ label: string
+ type: "select" | "text"
+ options?: { value: string; label: string }[]
+ expression?: string
+ delimiter?: string
+}
+
+// 클래스 옵션 인터페이스
+interface UpdatedClassOption extends ClassOption {
+ tagTypeCode: string
+ tagTypeDescription?: string
+}
+
+interface AddTagDialogProps {
+ selectedPackageId: number | null
+}
+
+export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
+ const router = useRouter()
+
+ const [open, setOpen] = React.useState(false)
+ const [tagTypeList, setTagTypeList] = React.useState<TagTypeOption[]>([])
+ const [selectedTagTypeCode, setSelectedTagTypeCode] = React.useState<string | null>(null)
+ const [subFields, setSubFields] = React.useState<SubFieldDef[]>([])
+ const [classOptions, setClassOptions] = React.useState<UpdatedClassOption[]>([])
+ const [classSearchTerm, setClassSearchTerm] = React.useState("")
+ const [isLoadingClasses, setIsLoadingClasses] = React.useState(false)
+ const [isLoadingSubFields, setIsLoadingSubFields] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ // ID management
+ const selectIdRef = React.useRef(0)
+ const getUniqueSelectId = React.useCallback(() => `select-${selectIdRef.current++}`, [])
+ const fieldIdsRef = React.useRef<Record<string, string>>({})
+ const classOptionIdsRef = React.useRef<Record<string, string>>({})
+
+ console.log(subFields)
+
+ // ---------------
+ // Load Class Options
+ // ---------------
+ React.useEffect(() => {
+ const loadClassOptions = async () => {
+ setIsLoadingClasses(true)
+ try {
+ const result = await getClassOptions()
+ setClassOptions(result)
+ } catch (err) {
+ toast.error("클래스 옵션을 불러오는데 실패했습니다.")
+ } finally {
+ setIsLoadingClasses(false)
+ }
+ }
+
+ if (open) {
+ loadClassOptions()
+ }
+ }, [open])
+
+ // ---------------
+ // react-hook-form with fieldArray support for multiple rows
+ // ---------------
+ const form = useForm<MultiTagFormValues>({
+ defaultValues: {
+ tagType: "",
+ class: "",
+ rows: [{
+ tagNo: "",
+ description: ""
+ }]
+ },
+ })
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "rows"
+ })
+
+ // ---------------
+ // Load subfields by TagType code
+ // ---------------
+ async function loadSubFieldsByTagTypeCode(tagTypeCode: string) {
+ setIsLoadingSubFields(true)
+ try {
+ const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode)
+ const formattedSubFields: SubFieldDef[] = apiSubFields.map(field => ({
+ name: field.name,
+ label: field.label,
+ type: field.type,
+ options: field.options || [],
+ expression: field.expression ?? undefined,
+ delimiter: field.delimiter ?? undefined,
+ }))
+ setSubFields(formattedSubFields)
+
+ // Initialize the rows with these subfields
+ const currentRows = form.getValues("rows");
+ const updatedRows = currentRows.map(row => {
+ const newRow = { ...row };
+ formattedSubFields.forEach(field => {
+ if (!newRow[field.name]) {
+ newRow[field.name] = "";
+ }
+ });
+ return newRow;
+ });
+
+ form.setValue("rows", updatedRows);
+ return true
+ } catch (err) {
+ toast.error("서브필드를 불러오는데 실패했습니다.")
+ setSubFields([])
+ return false
+ } finally {
+ setIsLoadingSubFields(false)
+ }
+ }
+
+ // ---------------
+ // Handle class selection
+ // ---------------
+ async function handleSelectClass(classOption: UpdatedClassOption) {
+ form.setValue("class", classOption.label)
+ if (classOption.tagTypeCode) {
+ setSelectedTagTypeCode(classOption.tagTypeCode)
+ // If you have tagTypeList, you can find the label
+ const tagType = tagTypeList.find(t => t.id === classOption.tagTypeCode)
+ if (tagType) {
+ form.setValue("tagType", tagType.label)
+ } else if (classOption.tagTypeDescription) {
+ form.setValue("tagType", classOption.tagTypeDescription)
+ }
+ await loadSubFieldsByTagTypeCode(classOption.tagTypeCode)
+ }
+ }
+
+ // ---------------
+ // Build TagNo from subfields automatically for each row
+ // ---------------
+ React.useEffect(() => {
+ if (subFields.length === 0) {
+ return;
+ }
+
+ const subscription = form.watch((value) => {
+ if (!value.rows || subFields.length === 0) {
+ return;
+ }
+
+ const rows = [...value.rows];
+ rows.forEach((row, rowIndex) => {
+ if (!row) return;
+
+ let combined = "";
+ subFields.forEach((sf, idx) => {
+ const fieldValue = row[sf.name] || "";
+ combined += fieldValue;
+ if (fieldValue && idx < subFields.length - 1 && sf.delimiter) {
+ combined += sf.delimiter;
+ }
+ });
+
+ const currentTagNo = form.getValues(`rows.${rowIndex}.tagNo`);
+ if (currentTagNo !== combined) {
+ form.setValue(`rows.${rowIndex}.tagNo`, combined, {
+ shouldDirty: true, // Changed from false to true
+ shouldTouch: true, // Changed from false to true
+ shouldValidate: true, // Changed from false to true
+ });
+ }
+ });
+ });
+
+ return () => subscription.unsubscribe();
+ }, [subFields, form]);
+ // ---------------
+ // Check if tag numbers are valid
+ // ---------------
+ const areAllTagNosValid = React.useMemo(() => {
+ const rows = form.getValues("rows");
+ return rows.every(row => {
+ const tagNo = row.tagNo;
+ return tagNo && tagNo.trim() !== "" && !tagNo.includes("??");
+ });
+ }, [form.watch()]); // Watch the entire form to catch all changes
+ // ---------------
+ // Submit handler for multiple tags
+ // ---------------
+ async function onSubmit(data: MultiTagFormValues) {
+ if (!selectedPackageId) {
+ toast.error("No selectedPackageId.");
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ const successfulTags = [];
+ const failedTags = [];
+
+ // Process each row
+ for (const row of data.rows) {
+ // Create tag data from the row and shared class/tagType
+ const tagData: CreateTagSchema = {
+ tagType: data.tagType,
+ class: data.class,
+ tagNo: row.tagNo,
+ description: row.description,
+ ...Object.fromEntries(
+ subFields.map(field => [field.name, row[field.name] || ""])
+ ),
+ // Add any required default fields from the original form
+ functionCode: row.functionCode || "",
+ seqNumber: row.seqNumber || "",
+ valveAcronym: row.valveAcronym || "",
+ processUnit: row.processUnit || "",
+ };
+
+ try {
+ const res = await createTag(tagData, selectedPackageId);
+ if ("error" in res) {
+ failedTags.push({ tag: row.tagNo, error: res.error });
+ } else {
+ successfulTags.push(row.tagNo);
+ }
+ } catch (err) {
+ failedTags.push({ tag: row.tagNo, error: "Unknown error" });
+ }
+ }
+
+ // Show results to the user
+ if (successfulTags.length > 0) {
+ toast.success(`${successfulTags.length}개의 태그가 성공적으로 생성되었습니다!`);
+ }
+
+ if (failedTags.length > 0) {
+ toast.error(`${failedTags.length}개의 태그 생성에 실패했습니다.`);
+ console.error("Failed tags:", failedTags);
+ }
+
+ // Refresh the page
+ router.refresh();
+
+ // Reset the form and close dialog if all successful
+ if (failedTags.length === 0) {
+ form.reset();
+ setOpen(false);
+ }
+ } catch (err) {
+ toast.error("태그 생성 처리에 실패했습니다.");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ // ---------------
+ // Add a new row
+ // ---------------
+ function addRow() {
+ // Create a properly typed row with index signature to allow dynamic properties
+ const newRow: {
+ tagNo: string;
+ description: string;
+ [key: string]: string; // This allows any string key with string values
+ } = {
+ tagNo: "",
+ description: ""
+ };
+
+ // Add all subfields with empty values
+ subFields.forEach(field => {
+ newRow[field.name] = "";
+ });
+
+ append(newRow);
+
+ // Force form validation after row is added
+ setTimeout(() => form.trigger(), 0);
+ }
+
+ // ---------------
+ // Duplicate row
+ // ---------------
+ function duplicateRow(index: number) {
+ const rowToDuplicate = form.getValues(`rows.${index}`);
+ // Use proper typing with index signature
+ const newRow: {
+ tagNo: string;
+ description: string;
+ [key: string]: string;
+ } = { ...rowToDuplicate };
+
+ // Clear the tagNo field as it will be auto-generated
+ newRow.tagNo = "";
+ append(newRow);
+
+ // Force form validation after row is duplicated
+ setTimeout(() => form.trigger(), 0);
+ }
+
+ // ---------------
+ // Render Class field
+ // ---------------
+ function renderClassField(field: any) {
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+ const buttonId = React.useMemo(
+ () => `class-button-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ []
+ )
+ const popoverContentId = React.useMemo(
+ () => `class-popover-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ []
+ )
+ const commandId = React.useMemo(
+ () => `class-command-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ []
+ )
+
+ return (
+ <FormItem className="w-1/2">
+ <FormLabel>Class</FormLabel>
+ <FormControl>
+ <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ key={buttonId}
+ type="button"
+ variant="outline"
+ className="w-full justify-between relative h-9"
+ disabled={isLoadingClasses}
+ >
+ {isLoadingClasses ? (
+ <>
+ <span>클래스 로딩 중...</span>
+ <Loader2 className="ml-2 h-4 w-4 animate-spin" />
+ </>
+ ) : (
+ <>
+ <span className="truncate mr-1 flex-grow text-left">
+ {field.value || "클래스 선택..."}
+ </span>
+ <ChevronsUpDown className="h-4 w-4 opacity-50 flex-shrink-0" />
+ </>
+ )}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent key={popoverContentId} className="w-[300px] p-0">
+ <Command key={commandId}>
+ <CommandInput
+ key={`${commandId}-input`}
+ placeholder="클래스 검색..."
+ value={classSearchTerm}
+ onValueChange={setClassSearchTerm}
+ />
+ <CommandList key={`${commandId}-list`} className="max-h-[300px]">
+ <CommandEmpty key={`${commandId}-empty`}>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup key={`${commandId}-group`}>
+ {classOptions.map((opt, optIndex) => {
+ if (!classOptionIdsRef.current[opt.code]) {
+ classOptionIdsRef.current[opt.code] =
+ `class-${opt.code}-${Date.now()}-${Math.random()
+ .toString(36)
+ .slice(2, 9)}`
+ }
+ const optionId = classOptionIdsRef.current[opt.code]
+
+ return (
+ <CommandItem
+ key={`${optionId}-${optIndex}`}
+ onSelect={() => {
+ field.onChange(opt.label)
+ setPopoverOpen(false)
+ handleSelectClass(opt)
+ }}
+ value={opt.label}
+ className="truncate"
+ title={opt.label}
+ >
+ <span className="truncate">{opt.label}</span>
+ <Check
+ key={`${optionId}-check`}
+ className={cn(
+ "ml-auto h-4 w-4 flex-shrink-0",
+ field.value === opt.label ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }
+
+ // ---------------
+ // Render TagType field (readonly after class selection)
+ // ---------------
+ function renderTagTypeField(field: any) {
+ const isReadOnly = !!selectedTagTypeCode
+ const inputId = React.useMemo(
+ () =>
+ `tag-type-input-${isReadOnly ? "readonly" : "editable"}-${Date.now()}-${Math.random()
+ .toString(36)
+ .slice(2, 9)}`,
+ [isReadOnly]
+ )
+
+ return (
+ <FormItem className="w-1/2">
+ <FormLabel>Tag Type</FormLabel>
+ <FormControl>
+ {isReadOnly ? (
+ <div className="relative">
+ <Input
+ key={`tag-type-readonly-${inputId}`}
+ {...field}
+ readOnly
+ className="h-9 bg-muted"
+ />
+ </div>
+ ) : (
+ <Input
+ key={`tag-type-placeholder-${inputId}`}
+ {...field}
+ readOnly
+ placeholder="클래스 선택시 자동으로 결정됩니다"
+ className="h-9 bg-muted"
+ />
+ )}
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }
+
+ // ---------------
+ // Render the table of subfields
+ // ---------------
+ function renderTagTable() {
+ if (isLoadingSubFields) {
+ return (
+ <div className="flex justify-center items-center py-8">
+ <Loader2 className="h-8 w-8 animate-spin text-primary" />
+ <div className="ml-3 text-muted-foreground">필드 로딩 중...</div>
+ </div>
+ )
+ }
+
+ if (subFields.length === 0 && selectedTagTypeCode) {
+ return (
+ <div className="py-4 text-center text-muted-foreground">
+ 이 태그 유형에 대한 필드가 없습니다.
+ </div>
+ )
+ }
+
+ if (subFields.length === 0) {
+ return (
+ <div className="py-4 text-center text-muted-foreground">
+ 태그 데이터를 입력하려면 먼저 상단에서 클래스를 선택하세요.
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-4">
+ {/* 헤더 */}
+ <div className="flex justify-between items-center">
+ <h3 className="text-sm font-medium">태그 항목 ({fields.length}개)</h3>
+ {!areAllTagNosValid && (
+ <Badge variant="destructive" className="ml-2">
+ 유효하지 않은 태그 존재
+ </Badge>
+ )}
+ </div>
+
+ {/* 테이블 컨테이너 - 가로/세로 스크롤 모두 적용 */}
+ <div className="border rounded-md overflow-auto" style={{ maxHeight: '400px', maxWidth: '100%' }}>
+ <div className="min-w-full overflow-x-auto">
+ <Table className="w-full table-fixed">
+ <TableHeader className="sticky top-0 bg-muted z-10">
+ <TableRow>
+ <TableHead className="w-10 text-center">#</TableHead>
+ <TableHead className="w-[120px]">
+ <div className="font-medium">Tag No</div>
+ </TableHead>
+ <TableHead className="w-[180px]">
+ <div className="font-medium">Description</div>
+ </TableHead>
+
+ {/* Subfields */}
+ {subFields.map((field, fieldIndex) => (
+ <TableHead
+ key={`header-${field.name}-${fieldIndex}`}
+ className="w-[120px]"
+ >
+ <div className="flex flex-col">
+ <div className="font-medium" title={field.label}>
+ {field.label}
+ </div>
+ {field.expression && (
+ <div className="text-[10px] text-muted-foreground truncate" title={field.expression}>
+ {field.expression}
+ </div>
+ )}
+ </div>
+ </TableHead>
+ ))}
+
+ <TableHead className="w-[100px] text-center sticky right-0 bg-muted">Actions</TableHead>
+ </TableRow>
+ </TableHeader>
+
+ <TableBody>
+ {fields.map((item, rowIndex) => (
+ <TableRow
+ key={`row-${item.id}-${rowIndex}`}
+ className={rowIndex % 2 === 0 ? "bg-background" : "bg-muted/20"}
+ >
+ {/* Row number */}
+ <TableCell className="text-center text-muted-foreground font-mono">
+ {rowIndex + 1}
+ </TableCell>
+
+ {/* Tag No cell */}
+ <TableCell className="p-1">
+ <FormField
+ control={form.control}
+ name={`rows.${rowIndex}.tagNo`}
+ render={({ field }) => (
+ <FormItem className="m-0 space-y-0">
+ <FormControl>
+ <div className="relative">
+ <Input
+ {...field}
+ readOnly
+ className={cn(
+ "bg-muted h-8 w-full font-mono text-sm",
+ field.value?.includes("??") && "border-red-500 bg-red-50"
+ )}
+ title={field.value || ""}
+ />
+ {field.value?.includes("??") && (
+ <div className="absolute right-2 top-1/2 transform -translate-y-1/2">
+ <Badge variant="destructive" className="text-xs">
+ !
+ </Badge>
+ </div>
+ )}
+ </div>
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </TableCell>
+
+ {/* Description cell */}
+ <TableCell className="p-1">
+ <FormField
+ control={form.control}
+ name={`rows.${rowIndex}.description`}
+ render={({ field }) => (
+ <FormItem className="m-0 space-y-0">
+ <FormControl>
+ <Input
+ {...field}
+ className="h-8 w-full"
+ placeholder="항목 이름 입력"
+ title={field.value || ""}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </TableCell>
+
+ {/* Subfield cells */}
+ {subFields.map((sf, sfIndex) => (
+ <TableCell
+ key={`cell-${item.id}-${rowIndex}-${sf.name}-${sfIndex}`}
+ className="p-1"
+ >
+ <FormField
+ control={form.control}
+ name={`rows.${rowIndex}.${sf.name}`}
+ render={({ field }) => (
+ <FormItem className="m-0 space-y-0">
+ <FormControl>
+ {sf.type === "select" ? (
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ >
+ <SelectTrigger
+ className="w-full h-8 truncate"
+ title={field.value || ""}
+ >
+ <SelectValue placeholder={`선택...`} className="truncate" />
+ </SelectTrigger>
+ <SelectContent
+ align="start"
+ side="bottom"
+ className="max-h-[200px]"
+ style={{ minWidth: "250px", maxWidth: "350px" }}
+ >
+ {sf.options?.map((opt, index) => (
+ <SelectItem
+ key={`${rowIndex}-${sf.name}-${opt.value}-${index}`}
+ value={opt.value}
+ title={opt.label}
+ className="whitespace-normal py-2 break-words"
+ >
+ {opt.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ ) : (
+ <Input
+ {...field}
+ className="h-8 w-full"
+ placeholder={`입력...`}
+ title={field.value || ""}
+ />
+ )}
+ </FormControl>
+ {/* <FormMessage>{sf.expression}</FormMessage> */}
+ </FormItem>
+
+ )}
+ />
+ </TableCell>
+ ))}
+
+ {/* Actions cell */}
+ <TableCell className="p-1 sticky right-0 bg-white shadow-[-4px_0_4px_rgba(0,0,0,0.05)]">
+ <div className="flex justify-center space-x-1">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-7 w-7"
+ onClick={() => duplicateRow(rowIndex)}
+ >
+ <Copy className="h-3.5 w-3.5 text-muted-foreground" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="left">
+ <p>행 복제</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className={cn(
+ "h-7 w-7",
+ fields.length <= 1 && "opacity-50"
+ )}
+ onClick={() => fields.length > 1 && remove(rowIndex)}
+ disabled={fields.length <= 1}
+ >
+ <Trash2 className="h-3.5 w-3.5 text-red-500" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="left">
+ <p>행 삭제</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+
+ {/* 행 추가 버튼 */}
+ <Button
+ type="button"
+ variant="outline"
+ className="w-full border-dashed"
+ onClick={addRow}
+ disabled={!selectedTagTypeCode || isLoadingSubFields}
+ >
+ <Plus className="h-4 w-4 mr-2" />
+ 새 행 추가
+ </Button>
+ </div>
+ </div>
+ );
+ }
+
+ // ---------------
+ // Reset IDs/states when dialog closes
+ // ---------------
+ React.useEffect(() => {
+ if (!open) {
+ fieldIdsRef.current = {}
+ classOptionIdsRef.current = {}
+ selectIdRef.current = 0
+ }
+ }, [open])
+
+ return (
+ <Dialog
+ open={open}
+ onOpenChange={(o) => {
+ if (!o) {
+ form.reset({
+ tagType: "",
+ class: "",
+ rows: [{ tagNo: "", description: "" }]
+ });
+ setSelectedTagTypeCode(null);
+ setSubFields([]);
+ }
+ setOpen(o);
+ }}
+ >
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ 태그 추가
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="max-h-[90vh] max-w-[95vw]" style={{ width: 1500 }}>
+ <DialogHeader>
+ <DialogTitle>새 태그 추가</DialogTitle>
+ <DialogDescription>
+ 클래스를 선택하여 태그 유형과 하위 필드를 로드한 다음, 여러 행을 추가하여 여러 태그를 생성하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="space-y-6"
+ >
+ {/* 클래스 및 태그 유형 선택 */}
+ <div className="flex gap-4">
+ <FormField
+ key="class-field"
+ control={form.control}
+ name="class"
+ render={({ field }) => renderClassField(field)}
+ />
+
+ <FormField
+ key="tag-type-field"
+ control={form.control}
+ name="tagType"
+ render={({ field }) => renderTagTypeField(field)}
+ />
+ </div>
+
+ {/* 태그 테이블 */}
+ {renderTagTable()}
+
+ {/* 버튼 */}
+ <DialogFooter>
+ <div className="flex items-center gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ form.reset({
+ tagType: "",
+ class: "",
+ rows: [{ tagNo: "", description: "" }]
+ });
+ setOpen(false);
+ setSubFields([]);
+ setSelectedTagTypeCode(null);
+ }}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isSubmitting || !areAllTagNosValid || fields.length < 1}
+ >
+ {isSubmitting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 처리 중...
+ </>
+ ) : (
+ `${fields.length}개 태그 생성`
+ )}
+ </Button>
+ </div>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/tags/table/delete-tags-dialog.tsx b/lib/tags/table/delete-tags-dialog.tsx
new file mode 100644
index 00000000..6a024cda
--- /dev/null
+++ b/lib/tags/table/delete-tags-dialog.tsx
@@ -0,0 +1,151 @@
+"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 { removeTags } from "@/lib//tags/service"
+import { Tag } from "@/db/schema/vendorData"
+
+interface DeleteTasksDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ tags: Row<Tag>["original"][]
+ showTrigger?: boolean
+ selectedPackageId: number
+ onSuccess?: () => void
+}
+
+export function DeleteTagsDialog({
+ tags,
+ showTrigger = true,
+ onSuccess,
+ selectedPackageId,
+ ...props
+}: DeleteTasksDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removeTags({
+ ids: tags.map((tag) => tag.id),selectedPackageId
+ })
+
+ 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="size-4" aria-hidden="true" />
+ Delete ({tags.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">{tags.length}</span>
+ {tags.length === 1 ? " tag" : " tags"} 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 ({tags.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">{tags.length}</span>
+ {tags.length === 1 ? " tag" : " tags"} 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/tags/table/feature-flags-provider.tsx b/lib/tags/table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/tags/table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/tags/table/tag-table-column.tsx b/lib/tags/table/tag-table-column.tsx
new file mode 100644
index 00000000..47746000
--- /dev/null
+++ b/lib/tags/table/tag-table-column.tsx
@@ -0,0 +1,164 @@
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+import { formatDate } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Ellipsis } from "lucide-react"
+// 기존 헤더 컴포넌트 사용 (리사이저가 내장된 헤더는 따로 구현할 예정)
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { Tag } from "@/db/schema/vendorData"
+import { DataTableRowAction } from "@/types/table"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Tag> | null>>
+}
+
+export function getColumns({
+ setRowAction,
+}: GetColumnsProps): ColumnDef<Tag>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ enableResizing: false, // 체크박스 열은 리사이징 비활성화
+ size: 40,
+ minSize: 40,
+ maxSize: 40,
+ },
+
+ {
+ accessorKey: "tagNo",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Tag No." />
+ ),
+ cell: ({ row }) => <div>{row.getValue("tagNo")}</div>,
+ meta: {
+ excelHeader: "Tag No"
+ },
+ enableResizing: true, // 리사이징 활성화
+ minSize: 100, // 최소 너비
+ size: 160, // 기본 너비
+ },
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Tag Description" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("description")}</div>,
+ meta: {
+ excelHeader: "Tag Descripiton"
+ },
+ enableResizing: true,
+ minSize: 150,
+ size: 240,
+ },
+ {
+ accessorKey: "class",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Tag Class" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("class")}</div>,
+ meta: {
+ excelHeader: "Tag Class"
+ },
+ enableResizing: true,
+ minSize: 100,
+ size: 150,
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Created At" />
+ ),
+ cell: ({ cell }) => formatDate(cell.getValue() as Date),
+ meta: {
+ excelHeader: "created At"
+ },
+ enableResizing: true,
+ minSize: 120,
+ size: 180,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Updated At" />
+ ),
+ cell: ({ cell }) => formatDate(cell.getValue() as Date),
+ meta: {
+ excelHeader: "updated At"
+ },
+ enableResizing: true,
+ minSize: 120,
+ size: 180,
+ },
+ {
+ id: "actions",
+ 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-6" 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>
+ )
+ },
+ enableResizing: false, // 액션 열은 리사이징 비활성화
+ size: 40,
+ minSize: 40,
+ maxSize: 40,
+ enableHiding: false,
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/tags/table/tag-table.tsx b/lib/tags/table/tag-table.tsx
new file mode 100644
index 00000000..5c8c048f
--- /dev/null
+++ b/lib/tags/table/tag-table.tsx
@@ -0,0 +1,141 @@
+"use client"
+
+import * as React from "react"
+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 "./tag-table-column"
+import { Tag } from "@/db/schema/vendorData"
+import { DeleteTagsDialog } from "./delete-tags-dialog"
+import { TagsTableToolbarActions } from "./tags-table-toolbar-actions"
+import { TagsTableFloatingBar } from "./tags-table-floating-bar"
+import { getTags } from "../service"
+import { UpdateTagSheet } from "./update-tag-sheet"
+
+// 여기서 받은 `promises`로부터 태그 목록을 가져와 상태를 세팅
+// 예: "selectedPackageId"는 props로 전달
+interface TagsTableProps {
+ promises: Promise< [ Awaited<ReturnType<typeof getTags>> ] >
+ selectedPackageId: number
+}
+
+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(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // Filter fields
+ const filterFields: DataTableFilterField<Tag>[] = [
+ {
+ id: "tagNo",
+ label: "Tag Number",
+ placeholder: "Filter Tag Number...",
+ },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<Tag>[] = [
+ {
+ id: "tagNo",
+ label: "Tag No",
+ type: "text",
+ },
+ {
+ id: "tagType",
+ label: "Tag Type",
+ type: "text",
+ },
+ {
+ id: "description",
+ label: "Description",
+ type: "text",
+ },
+ {
+ id: "createdAt",
+ label: "Created at",
+ type: "date",
+ },
+ {
+ id: "updatedAt",
+ label: "Updated at",
+ type: "date",
+ },
+ ]
+
+ // 3) useDataTable 훅으로 react-table 구성
+ const { table } = useDataTable({
+ data: data, // <-- 여기서 tableData 사용
+ 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,
+ columnResizeMode: "onEnd",
+
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ floatingBar={<TagsTableFloatingBar table={table} selectedPackageId={selectedPackageId}/>}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ {/*
+ 4) ToolbarActions에 tableData, setTableData 넘겨서
+ import 시 상태 병합
+ */}
+ <TagsTableToolbarActions
+ table={table}
+ selectedPackageId={selectedPackageId}
+ tableData={data} // <-- pass current data
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <UpdateTagSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ tag={rowAction?.row.original ?? null}
+ selectedPackageId={selectedPackageId}
+ />
+
+
+ <DeleteTagsDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ tags={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ selectedPackageId={selectedPackageId}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/tags/table/tags-export.tsx b/lib/tags/table/tags-export.tsx
new file mode 100644
index 00000000..4afbac6c
--- /dev/null
+++ b/lib/tags/table/tags-export.tsx
@@ -0,0 +1,155 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { toast } from "sonner"
+import ExcelJS from "exceljs"
+import { saveAs } from "file-saver"
+import { Tag } from "@/db/schema/vendorData"
+import { getClassOptions } from "../service"
+
+/**
+ * 태그 데이터를 엑셀로 내보내는 함수 (유효성 검사 포함)
+ * - 별도의 ValidationData 시트에 Tag Class 옵션 데이터를 포함
+ * - Tag Class 열에 데이터 유효성 검사(드롭다운)을 적용
+ */
+export async function exportTagsToExcel(
+ table: Table<Tag>,
+ {
+ filename = "Tags",
+ excludeColumns = ["select", "actions", "createdAt", "updatedAt"],
+ maxRows = 5000, // 데이터 유효성 검사를 적용할 최대 행 수
+ }: {
+ filename?: string
+ excludeColumns?: string[]
+ maxRows?: number
+ } = {}
+) {
+ try {
+ // 1. 테이블에서 컬럼 정보 가져오기
+ const allTableColumns = table.getAllLeafColumns()
+
+ // 제외할 컬럼 필터링
+ const tableColumns = allTableColumns.filter(
+ (col) => !excludeColumns.includes(col.id)
+ )
+
+ // 2. 워크북 및 워크시트 생성
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Tags")
+
+ // 3. Tag Class 옵션 가져오기
+ const classOptions = await getClassOptions()
+
+ // 4. 유효성 검사 시트 생성
+ const validationSheet = workbook.addWorksheet("ValidationData")
+ validationSheet.state = 'hidden' // 시트 숨김 처리
+
+ // 4.1. Tag Class 유효성 검사 데이터 추가
+ validationSheet.getColumn(1).values = ["Tag Class", ...classOptions.map(opt => opt.label)]
+
+ // 5. 메인 시트에 헤더 추가
+ const headers = tableColumns.map((col) => {
+ const meta = col.columnDef.meta as any
+ // meta에 excelHeader가 있으면 사용
+ if (meta?.excelHeader) {
+ return meta.excelHeader
+ }
+ // 없으면 컬럼 ID 사용
+ return col.id
+ })
+
+ worksheet.addRow(headers)
+
+ // 6. 헤더 스타일 적용
+ const headerRow = worksheet.getRow(1)
+ headerRow.font = { bold: true }
+ headerRow.alignment = { horizontal: 'center' }
+ headerRow.eachCell((cell) => {
+ cell.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFCCCCCC' }
+ }
+ })
+
+ // 7. 데이터 행 추가
+ const rowModel = table.getPrePaginationRowModel()
+
+ rowModel.rows.forEach((row) => {
+ const rowData = tableColumns.map((col) => {
+ const value = row.getValue(col.id)
+
+ // 날짜 형식 처리
+ if (value instanceof Date) {
+ return new Date(value).toISOString().split('T')[0]
+ }
+
+ // value가 null/undefined면 빈 문자열, 객체면 JSON 문자열, 그 외에는 그대로 반환
+ if (value == null) return ""
+ return typeof value === "object" ? JSON.stringify(value) : value
+ })
+
+ worksheet.addRow(rowData)
+ })
+
+ // 8. Tag Class 열에 데이터 유효성 검사 적용
+ const classColIndex = headers.findIndex(header => header === "Tag Class")
+
+ if (classColIndex !== -1) {
+ const colLetter = worksheet.getColumn(classColIndex + 1).letter
+
+ // 데이터 유효성 검사 설정
+ const validation = {
+ type: 'list' as const,
+ allowBlank: true,
+ formulae: [`ValidationData!$A$2:$A$${classOptions.length + 1}`],
+ showErrorMessage: true,
+ errorStyle: 'warning' as const,
+ errorTitle: '유효하지 않은 클래스',
+ error: '목록에서 클래스를 선택해주세요.'
+ }
+
+ // 모든 데이터 행 + 추가 행(최대 maxRows까지)에 유효성 검사 적용
+ for (let rowIdx = 2; rowIdx <= maxRows; rowIdx++) {
+ worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation
+ }
+ }
+
+ // 9. 컬럼 너비 자동 조정
+ tableColumns.forEach((col, index) => {
+ const column = worksheet.getColumn(index + 1)
+ const headerLength = headers[index]?.length || 10
+
+ // 데이터 기반 최대 길이 계산
+ let maxLength = headerLength
+ rowModel.rows.forEach((row) => {
+ const value = row.getValue(col.id)
+ if (value != null) {
+ const valueLength = String(value).length
+ if (valueLength > maxLength) {
+ maxLength = valueLength
+ }
+ }
+ })
+
+ // 너비 설정 (최소 10, 최대 50)
+ column.width = Math.min(Math.max(maxLength + 2, 10), 50)
+ })
+
+ // 10. 파일 다운로드
+ const buffer = await workbook.xlsx.writeBuffer()
+ saveAs(
+ new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ }),
+ `${filename}_${new Date().toISOString().split('T')[0]}.xlsx`
+ )
+
+ return true
+ } catch (error) {
+ console.error("Excel export error:", error)
+ toast.error("Excel 내보내기 중 오류가 발생했습니다.")
+ return false
+ }
+} \ No newline at end of file
diff --git a/lib/tags/table/tags-table-floating-bar.tsx b/lib/tags/table/tags-table-floating-bar.tsx
new file mode 100644
index 00000000..8d55b7ac
--- /dev/null
+++ b/lib/tags/table/tags-table-floating-bar.tsx
@@ -0,0 +1,220 @@
+"use client"
+
+import * as React from "react"
+import { SelectTrigger } from "@radix-ui/react-select"
+import { type Table } from "@tanstack/react-table"
+import {
+ ArrowUp,
+ CheckCircle2,
+ Download,
+ Loader,
+ Trash2,
+ X,
+} from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { Portal } from "@/components/ui/portal"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+} from "@/components/ui/select"
+import { Separator } from "@/components/ui/separator"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { Kbd } from "@/components/kbd"
+
+import { removeTags } from "@/lib//tags/service"
+import { ActionConfirmDialog } from "@/components/ui/action-dialog"
+import { Tag } from "@/db/schema/vendorData"
+
+interface TagsTableFloatingBarProps {
+ table: Table<Tag>
+ selectedPackageId: number
+
+}
+
+
+export function TagsTableFloatingBar({ table, selectedPackageId }: TagsTableFloatingBarProps) {
+ const rows = table.getFilteredSelectedRowModel().rows
+
+ const [isPending, startTransition] = React.useTransition()
+ const [action, setAction] = React.useState<
+ "update-status" | "update-priority" | "export" | "delete"
+ >()
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+ // Clear selection on Escape key press
+ 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
+ const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false)
+ const [confirmProps, setConfirmProps] = React.useState<{
+ title: string
+ description?: string
+ onConfirm: () => Promise<void> | void
+ }>({
+ title: "",
+ description: "",
+ onConfirm: () => { },
+ })
+
+ // 1) "삭제" Confirm 열기
+ function handleDeleteConfirm() {
+ setAction("delete")
+ setConfirmProps({
+ title: `Delete ${rows.length} tag${rows.length > 1 ? "s" : ""}?`,
+ description: "This action cannot be undone.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ const { error } = await removeTags({
+ ids: rows.map((row) => row.original.id),
+ selectedPackageId
+ })
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("Tags deleted")
+ table.toggleAllRowsSelected(false)
+ setConfirmDialogOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+
+
+ 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="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 tasks</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 tasks</p>
+ </TooltipContent>
+ </Tooltip>
+ </div>
+ </div>
+ </div>
+ </div>
+
+
+ {/* 공용 Confirm Dialog */}
+ <ActionConfirmDialog
+ open={confirmDialogOpen}
+ onOpenChange={setConfirmDialogOpen}
+ title={confirmProps.title}
+ description={confirmProps.description}
+ onConfirm={confirmProps.onConfirm}
+ isLoading={isPending && (action === "delete" || action === "update-priority" || action === "update-status")}
+ confirmLabel={
+ action === "delete"
+ ? "Delete"
+ : action === "update-priority" || action === "update-status"
+ ? "Update"
+ : "Confirm"
+ }
+ confirmVariant={
+ action === "delete" ? "destructive" : "default"
+ }
+ />
+ </Portal>
+ )
+}
diff --git a/lib/tags/table/tags-table-toolbar-actions.tsx b/lib/tags/table/tags-table-toolbar-actions.tsx
new file mode 100644
index 00000000..8d53d3f3
--- /dev/null
+++ b/lib/tags/table/tags-table-toolbar-actions.tsx
@@ -0,0 +1,598 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { toast } from "sonner"
+import ExcelJS from "exceljs"
+import { saveAs } from "file-saver"
+
+import { Button } from "@/components/ui/button"
+import { Download, Upload, Loader2 } 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 { DeleteTagsDialog } from "./delete-tags-dialog"
+
+// 태그 번호 검증을 위한 인터페이스
+interface TagNumberingRule {
+ attributesId: string;
+ attributesDescription: string;
+ expression: string | null;
+ delimiter: string | null;
+ sortOrder: number;
+}
+
+interface TagOption {
+ code: string;
+ label: string;
+}
+
+interface ClassOption {
+ code: string;
+ label: string;
+ tagTypeCode: string;
+ tagTypeDescription: string;
+}
+
+// 서브필드 정의
+interface SubFieldDef {
+ name: string;
+ label: string;
+ type: "select" | "text";
+ options?: { value: string; label: string }[];
+ expression?: string;
+ delimiter?: string;
+}
+
+interface TagsTableToolbarActionsProps {
+ /** react-table 객체 */
+ table: Table<Tag>
+ /** 현재 선택된 패키지 ID */
+ selectedPackageId: number
+ /** 현재 태그 목록(상태) */
+ tableData: Tag[]
+ /** 태그 목록을 갱신하는 setState */
+}
+
+/**
+ * TagsTableToolbarActions:
+ * - Import 버튼 -> Excel 파일 파싱 & 유효성 검사 (Class 기반 검증 추가)
+ * - 에러 발생 시: state는 그대로 두고, 오류가 적힌 엑셀만 재다운로드
+ * - 정상인 경우: tableData에 병합
+ * - Export 버튼 -> 유효성 검사가 포함된 Excel 내보내기
+ */
+export function TagsTableToolbarActions({
+ table,
+ selectedPackageId,
+ tableData,
+}: TagsTableToolbarActionsProps) {
+ const [isPending, setIsPending] = React.useState(false)
+ const [isExporting, setIsExporting] = React.useState(false)
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 태그 타입별 넘버링 룰 캐시
+ const [tagNumberingRules, setTagNumberingRules] = React.useState<Record<string, TagNumberingRule[]>>({})
+ const [tagOptionsCache, setTagOptionsCache] = React.useState<Record<string, TagOption[]>>({})
+
+ // 클래스 옵션 및 서브필드 캐시
+ const [classOptions, setClassOptions] = React.useState<ClassOption[]>([])
+ const [subfieldCache, setSubfieldCache] = React.useState<Record<string, SubFieldDef[]>>({})
+
+ // 컴포넌트 마운트 시 클래스 옵션 로드
+ React.useEffect(() => {
+ const loadClassOptions = async () => {
+ try {
+ const options = await getClassOptions()
+ setClassOptions(options)
+ } catch (error) {
+ console.error("Failed to load class options:", error)
+ }
+ }
+
+ loadClassOptions()
+ }, [])
+
+ // 숨겨진 <input>을 클릭
+ function handleImportClick() {
+ fileInputRef.current?.click()
+ }
+
+ // 태그 넘버링 룰 가져오기
+ const fetchTagNumberingRules = React.useCallback(async (tagType: string): Promise<TagNumberingRule[]> => {
+ // 이미 캐시에 있으면 캐시된 값 사용
+ if (tagNumberingRules[tagType]) {
+ return tagNumberingRules[tagType]
+ }
+
+ try {
+ // 서버 액션 직접 호출
+ const rules = await getTagNumberingRules(tagType)
+
+ // 캐시에 저장
+ setTagNumberingRules(prev => ({
+ ...prev,
+ [tagType]: rules
+ }))
+
+ return rules
+ } catch (error) {
+ console.error(`Error fetching rules for ${tagType}:`, error)
+ return []
+ }
+ }, [tagNumberingRules])
+
+ // 특정 attributesId에 대한 옵션 가져오기
+ const fetchOptions = React.useCallback(async (attributesId: string): Promise<TagOption[]> => {
+ // 이미 캐시에 있으면 캐시된 값 사용
+ if (tagOptionsCache[attributesId]) {
+ return tagOptionsCache[attributesId]
+ }
+
+ try {
+ const options = await fetchTagSubfieldOptions(attributesId)
+
+ // 캐시에 저장
+ setTagOptionsCache(prev => ({
+ ...prev,
+ [attributesId]: options
+ }))
+
+ return options
+ } catch (error) {
+ console.error(`Error fetching options for ${attributesId}:`, error)
+ return []
+ }
+ }, [tagOptionsCache])
+
+ // 클래스 라벨로 태그 타입 코드 찾기
+ const getTagTypeCodeByClassLabel = React.useCallback((classLabel: string): string | null => {
+ const classOption = classOptions.find(opt => opt.label === classLabel)
+ return classOption?.tagTypeCode || null
+ }, [classOptions])
+
+ // 태그 타입에 따른 서브필드 가져오기
+ const fetchSubfieldsByTagType = React.useCallback(async (tagTypeCode: string): Promise<SubFieldDef[]> => {
+ // 이미 캐시에 있으면 캐시된 값 사용
+ if (subfieldCache[tagTypeCode]) {
+ return subfieldCache[tagTypeCode]
+ }
+
+ try {
+ const { subFields } = await getSubfieldsByTagType(tagTypeCode)
+
+ // API 응답을 SubFieldDef 형식으로 변환
+ const formattedSubFields: SubFieldDef[] = subFields.map(field => ({
+ name: field.name,
+ label: field.label,
+ type: field.type,
+ options: field.options || [],
+ expression: field.expression ?? undefined,
+ delimiter: field.delimiter ?? undefined,
+ }))
+
+ // 캐시에 저장
+ setSubfieldCache(prev => ({
+ ...prev,
+ [tagTypeCode]: formattedSubFields
+ }))
+
+ return formattedSubFields
+ } catch (error) {
+ console.error(`Error fetching subfields for tagType ${tagTypeCode}:`, error)
+ return []
+ }
+ }, [subfieldCache])
+
+ // Class 기반 태그 번호 형식 검증
+ const validateTagNumberByClass = React.useCallback(async (
+ tagNo: string,
+ classLabel: string
+ ): Promise<string> => {
+ if (!tagNo) return "Tag number is empty."
+ if (!classLabel) return "Class is empty."
+
+ try {
+ // 1. 클래스 라벨로 태그 타입 코드 찾기
+ const tagTypeCode = getTagTypeCodeByClassLabel(classLabel)
+ if (!tagTypeCode) {
+ return `No tag type found for class '${classLabel}'.`
+ }
+
+ // 2. 태그 타입 코드로 서브필드 가져오기
+ const subfields = await fetchSubfieldsByTagType(tagTypeCode)
+ if (!subfields || subfields.length === 0) {
+ return `No subfields found for tag type code '${tagTypeCode}'.`
+ }
+
+ // 3. 태그 번호를 파트별로 분석
+ let remainingTagNo = tagNo
+ let currentPosition = 0
+
+ for (const field of subfields) {
+ // 구분자 확인
+ const delimiter = field.delimiter || ""
+
+ // 다음 구분자 위치 또는 문자열 끝
+ let nextDelimiterPos
+ if (delimiter && remainingTagNo.includes(delimiter)) {
+ nextDelimiterPos = remainingTagNo.indexOf(delimiter)
+ } else {
+ nextDelimiterPos = remainingTagNo.length
+ }
+
+ // 현재 파트 추출
+ const part = remainingTagNo.substring(0, nextDelimiterPos)
+
+ // 비어있으면 오류
+ if (!part) {
+ return `Empty part for field '${field.label}'.`
+ }
+
+ // 정규식 검증
+ if (field.expression) {
+ const regex = new RegExp(`^${field.expression}$`)
+ if (!regex.test(part)) {
+ return `Part '${part}' for field '${field.label}' does not match the pattern '${field.expression}'.`
+ }
+ }
+
+ // 선택 옵션 검증
+ if (field.type === "select" && field.options && field.options.length > 0) {
+ const validValues = field.options.map(opt => opt.value)
+ if (!validValues.includes(part)) {
+ return `'${part}' is not a valid value for field '${field.label}'. Valid options: ${validValues.join(", ")}.`
+ }
+ }
+
+ // 남은 문자열 업데이트
+ if (delimiter && nextDelimiterPos < remainingTagNo.length) {
+ remainingTagNo = remainingTagNo.substring(nextDelimiterPos + delimiter.length)
+ } else {
+ remainingTagNo = ""
+ break
+ }
+ }
+
+ // 문자열이 남아있으면 오류
+ if (remainingTagNo) {
+ return `Tag number has extra parts: '${remainingTagNo}'.`
+ }
+
+ return "" // 오류 없음
+ } catch (error) {
+ console.error("Error validating tag number by class:", error)
+ return "Error validating tag number format."
+ }
+ }, [getTagTypeCodeByClassLabel, fetchSubfieldsByTagType])
+
+ // 기존 태그 번호 검증 함수 (기존 코드를 유지)
+ const validateTagNumber = React.useCallback(async (tagNo: string, tagType: string): Promise<string> => {
+ if (!tagNo) return "Tag number is empty."
+ if (!tagType) return "Tag type is empty."
+
+ try {
+ // 1. 태그 타입에 대한 넘버링 룰 가져오기
+ const rules = await fetchTagNumberingRules(tagType)
+ if (!rules || rules.length === 0) {
+ return `No numbering rules found for tag type '${tagType}'.`
+ }
+
+ // 2. 정렬된 룰 (sortOrder 기준)
+ const sortedRules = [...rules].sort((a, b) => a.sortOrder - b.sortOrder)
+
+ // 3. 태그 번호를 파트로 분리
+ let remainingTagNo = tagNo
+ let currentPosition = 0
+
+ for (const rule of sortedRules) {
+ // 마지막 룰이 아니고 구분자가 있으면
+ const delimiter = rule.delimiter || ""
+
+ // 다음 구분자 위치 찾기 또는 문자열 끝
+ let nextDelimiterPos
+ if (delimiter && remainingTagNo.includes(delimiter)) {
+ nextDelimiterPos = remainingTagNo.indexOf(delimiter)
+ } else {
+ nextDelimiterPos = remainingTagNo.length
+ }
+
+ // 현재 파트 추출
+ const part = remainingTagNo.substring(0, nextDelimiterPos)
+
+ // 표현식이 있으면 검증
+ if (rule.expression) {
+ const regex = new RegExp(`^${rule.expression}$`)
+ if (!regex.test(part)) {
+ return `Part '${part}' does not match the pattern '${rule.expression}' for ${rule.attributesDescription}.`
+ }
+ }
+
+ // 옵션이 있는 경우 유효한 코드인지 확인
+ const options = await fetchOptions(rule.attributesId)
+ if (options.length > 0) {
+ const isValidCode = options.some(opt => opt.code === part)
+ if (!isValidCode) {
+ return `'${part}' is not a valid code for ${rule.attributesDescription}. Valid options: ${options.map(o => o.code).join(', ')}.`
+ }
+ }
+
+ // 남은 문자열 업데이트
+ if (delimiter && nextDelimiterPos < remainingTagNo.length) {
+ remainingTagNo = remainingTagNo.substring(nextDelimiterPos + delimiter.length)
+ } else {
+ remainingTagNo = ""
+ break
+ }
+
+ // 모든 룰을 처리했는데 문자열이 남아있으면 오류
+ if (remainingTagNo && rule === sortedRules[sortedRules.length - 1]) {
+ return `Tag number has extra parts: '${remainingTagNo}'.`
+ }
+ }
+
+ // 문자열이 남아있으면 오류
+ if (remainingTagNo) {
+ return `Tag number has unprocessed parts: '${remainingTagNo}'.`
+ }
+
+ return "" // 오류 없음
+ } catch (error) {
+ console.error("Error validating tag number:", error)
+ return "Error validating tag number."
+ }
+ }, [fetchTagNumberingRules, fetchOptions])
+
+ /**
+ * 개선된 handleFileChange 함수
+ * 1) ExcelJS로 파일 파싱
+ * 2) 헤더 -> meta.excelHeader 매핑
+ * 3) 각 행 유효성 검사 (Class 기반 검증 추가)
+ * 4) 에러 행 있으면 → 오류 메시지 기록 + 재다운로드 (상태 변경 안 함)
+ * 5) 정상 행만 importedRows 로 → 병합
+ */
+ async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
+ const file = e.target.files?.[0]
+ if (!file) return
+
+ // 파일 input 초기화
+ e.target.value = ""
+ setIsPending(true)
+
+ try {
+ // 1) Workbook 로드
+ const workbook = new ExcelJS.Workbook()
+ const arrayBuffer = await file.arrayBuffer()
+ await workbook.xlsx.load(arrayBuffer)
+
+ // 첫 번째 시트 사용
+ const worksheet = workbook.worksheets[0]
+
+ // (A) 마지막 열에 "Error" 헤더
+ const lastColIndex = worksheet.columnCount + 1
+ worksheet.getRow(1).getCell(lastColIndex).value = "Error"
+
+ // (B) 엑셀 헤더 (Row1)
+ const headerRowValues = worksheet.getRow(1).values as ExcelJS.CellValue[]
+
+ // (C) excelHeader -> accessor 매핑
+ const excelHeaderToAccessor: Record<string, string> = {}
+ for (const col of table.getAllColumns()) {
+ const meta = col.columnDef.meta as { excelHeader?: string } | undefined
+ if (meta?.excelHeader) {
+ const accessor = col.id as string
+ excelHeaderToAccessor[meta.excelHeader] = accessor
+ }
+ }
+
+ // (D) accessor -> column index
+ const accessorIndexMap: Record<string, number> = {}
+ for (let i = 1; i < headerRowValues.length; i++) {
+ const cellVal = String(headerRowValues[i] ?? "").trim()
+ if (!cellVal) continue
+ const accessor = excelHeaderToAccessor[cellVal]
+ if (accessor) {
+ accessorIndexMap[accessor] = i
+ }
+ }
+
+ let errorCount = 0
+ const importedRows: Tag[] = []
+ const fileTagNos = new Set<string>() // 파일 내 태그번호 중복 체크용
+ const lastRow = worksheet.lastRow?.number || 1
+
+ // 2) 각 데이터 행 파싱
+ for (let rowNum = 2; rowNum <= lastRow; rowNum++) {
+ const row = worksheet.getRow(rowNum)
+ const rowVals = row.values as ExcelJS.CellValue[]
+ if (!rowVals || rowVals.length <= 1) continue // 빈 행 스킵
+
+ let errorMsg = ""
+
+ // 필요한 accessorIndex
+ const tagNoIndex = accessorIndexMap["tagNo"]
+ const classIndex = accessorIndexMap["class"]
+
+ // 엑셀에서 값 읽기
+ const tagNo = tagNoIndex ? String(rowVals[tagNoIndex] ?? "").trim() : ""
+ const classVal = classIndex ? String(rowVals[classIndex] ?? "").trim() : ""
+
+ // A. 필수값 검사
+ if (!tagNo) {
+ errorMsg += `Tag No is empty. `
+ }
+ if (!classVal) {
+ errorMsg += `Class is empty. `
+ }
+
+ // B. 중복 검사
+ if (tagNo) {
+ // 이미 tableData 내 존재 여부
+ const dup = tableData.find(
+ (t) => t.contractItemId === selectedPackageId && t.tagNo === tagNo
+ )
+ if (dup) {
+ errorMsg += `TagNo '${tagNo}' already exists. `
+ }
+
+ // 이번 엑셀 파일 내 중복
+ if (fileTagNos.has(tagNo)) {
+ errorMsg += `TagNo '${tagNo}' is duplicated within this file. `
+ } else {
+ fileTagNos.add(tagNo)
+ }
+ }
+
+ // C. Class 기반 형식 검증
+ if (tagNo && classVal && !errorMsg) {
+ // classVal 로부터 태그타입 코드 획득
+ const tagTypeCode = getTagTypeCodeByClassLabel(classVal)
+
+ if (!tagTypeCode) {
+ errorMsg += `No tag type code found for class '${classVal}'. `
+ } else {
+ // validateTagNumberByClass( ) 안에서
+ // → tagTypeCode로 서브필드 조회, 정규식 검증 등 처리
+ const classValidationError = await validateTagNumberByClass(tagNo, classVal)
+ if (classValidationError) {
+ errorMsg += classValidationError + " "
+ }
+ }
+ }
+
+ // D. 에러 처리
+ if (errorMsg) {
+ row.getCell(lastColIndex).value = errorMsg.trim()
+ errorCount++
+ } else {
+ // 최종 태그 타입 결정 (DB에 저장할 때 'tagType' 컬럼을 무엇으로 쓸지 결정)
+ // 예: DB에서 tagType을 "CV" 같은 코드로 저장하려면
+ // const finalTagType = getTagTypeCodeByClassLabel(classVal) ?? ""
+ // 혹은 "Control Valve" 같은 description을 쓰려면 classOptions에서 찾아볼 수도 있음
+ const finalTagType = getTagTypeCodeByClassLabel(classVal) ?? ""
+
+ // 정상 행을 importedRows에 추가
+ importedRows.push({
+ id: 0, // 임시
+ contractItemId: selectedPackageId,
+ formId: null,
+ tagNo,
+ tagType: finalTagType, // ← 코드로 저장할지, Description으로 저장할지 결정
+ class: classVal,
+ description: String(rowVals[accessorIndexMap["description"] ?? 0] ?? "").trim(),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ }
+ }
+
+ // (E) 오류 행이 있으면 → 수정된 엑셀 재다운로드 & 종료
+ if (errorCount > 0) {
+ const outBuf = await workbook.xlsx.writeBuffer()
+ const errorFile = new Blob([outBuf])
+ const url = URL.createObjectURL(errorFile)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "tag_import_errors.xlsx"
+ link.click()
+ URL.revokeObjectURL(url)
+
+ toast.error(`There are ${errorCount} error row(s). Please see downloaded file.`)
+ return
+ }
+
+ // 정상 행이 있으면 태그 생성 요청
+ if (importedRows.length > 0) {
+ const result = await bulkCreateTags(importedRows, selectedPackageId);
+ if ("error" in result) {
+ toast.error(result.error);
+ } else {
+ toast.success(`${result.data.createdCount}개의 태그가 성공적으로 생성되었습니다.`);
+ }
+ }
+
+ toast.success(`Imported ${importedRows.length} tags successfully!`)
+
+ } catch (err) {
+ console.error(err)
+ toast.error("파일 업로드 중 오류가 발생했습니다.")
+ } finally {
+ setIsPending(false)
+ }
+ }
+ // 새 Export 함수 - 유효성 검사 시트를 포함한 엑셀 내보내기
+ async function handleExport() {
+ try {
+ setIsExporting(true)
+
+ // 유효성 검사가 포함된 새로운 엑셀 내보내기 함수 호출
+ await exportTagsToExcel(table, {
+ filename: `Tags_${selectedPackageId}`,
+ excludeColumns: ["select", "actions", "createdAt", "updatedAt"],
+ })
+
+ toast.success("태그 목록이 성공적으로 내보내졌습니다.")
+ } catch (error) {
+ console.error("Export error:", error)
+ toast.error("태그 목록 내보내기 중 오류가 발생했습니다.")
+ } finally {
+ setIsExporting(false)
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteTagsDialog
+ tags={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ selectedPackageId={selectedPackageId}
+ />
+ ) : null}
+
+
+ <AddTagDialog selectedPackageId={selectedPackageId} />
+
+ {/* Import */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleImportClick}
+ disabled={isPending || isExporting}
+ >
+ {isPending ? (
+ <Loader2 className="size-4 mr-2 animate-spin" aria-hidden="true" />
+ ) : (
+ <Upload className="size-4 mr-2" aria-hidden="true" />
+ )}
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".xlsx,.xls"
+ className="hidden"
+ onChange={handleFileChange}
+ />
+
+ {/* Export */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ disabled={isPending || isExporting}
+ >
+ {isExporting ? (
+ <Loader2 className="size-4 mr-2 animate-spin" aria-hidden="true" />
+ ) : (
+ <Download className="size-4 mr-2" aria-hidden="true" />
+ )}
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/tags/table/update-tag-sheet.tsx b/lib/tags/table/update-tag-sheet.tsx
new file mode 100644
index 00000000..27a1bdcb
--- /dev/null
+++ b/lib/tags/table/update-tag-sheet.tsx
@@ -0,0 +1,548 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader2, Check, ChevronsUpDown } 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 { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import { Badge } from "@/components/ui/badge"
+import { cn } from "@/lib/utils"
+
+import { Tag } from "@/db/schema/vendorData"
+import { updateTag, getSubfieldsByTagType, getClassOptions, TagTypeOption } from "@/lib/tags/service"
+
+// SubFieldDef 인터페이스
+interface SubFieldDef {
+ name: string
+ label: string
+ type: "select" | "text"
+ options?: { value: string; label: string }[]
+ expression?: string
+ delimiter?: string
+}
+
+// 클래스 옵션 인터페이스
+interface UpdatedClassOption {
+ code: string
+ label: string
+ tagTypeCode: string
+ tagTypeDescription?: string
+}
+
+// UpdateTagSchema 정의
+const updateTagSchema = z.object({
+ class: z.string().min(1, "Class is required"),
+ tagType: z.string().min(1, "Tag Type is required"),
+ tagNo: z.string().min(1, "Tag Number is required"),
+ description: z.string().optional(),
+ // 추가 필드들은 동적으로 처리됨
+})
+
+// TypeScript 타입 정의
+type UpdateTagSchema = z.infer<typeof updateTagSchema> & Record<string, string>
+
+interface UpdateTagSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ tag: Tag | null
+ selectedPackageId: number
+}
+
+export function UpdateTagSheet({ tag, selectedPackageId, ...props }: UpdateTagSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const [tagTypeList, setTagTypeList] = React.useState<TagTypeOption[]>([])
+ const [selectedTagTypeCode, setSelectedTagTypeCode] = React.useState<string | null>(null)
+ const [subFields, setSubFields] = React.useState<SubFieldDef[]>([])
+ const [classOptions, setClassOptions] = React.useState<UpdatedClassOption[]>([])
+ const [classSearchTerm, setClassSearchTerm] = React.useState("")
+ const [isLoadingClasses, setIsLoadingClasses] = React.useState(false)
+ const [isLoadingSubFields, setIsLoadingSubFields] = React.useState(false)
+
+ // ID management for popover elements
+ const selectIdRef = React.useRef(0)
+ 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(() => {
+ const loadClassOptions = async () => {
+ if (!props.open || !tag) return
+
+ setIsLoadingClasses(true)
+ try {
+ const result = await getClassOptions()
+ setClassOptions(result)
+ } catch (err) {
+ toast.error("클래스 옵션을 불러오는데 실패했습니다.")
+ } finally {
+ setIsLoadingClasses(false)
+ }
+ }
+
+ loadClassOptions()
+ }, [props.open, tag])
+
+ // Form setup
+ const form = useForm<UpdateTagSchema>({
+ resolver: zodResolver(updateTagSchema),
+ defaultValues: {
+ class: "",
+ tagType: "",
+ tagNo: "",
+ description: "",
+ },
+ })
+
+ // Load tag data into form when tag changes
+ React.useEffect(() => {
+ if (!tag) return
+
+ // 필요한 필드만 선택적으로 추출
+ const formValues = {
+ tagNo: tag.tagNo,
+ tagType: tag.tagType,
+ class: tag.class,
+ description: tag.description || ""
+ // 참고: 실제 태그 데이터에는 서브필드(functionCode, seqNumber 등)가 없음
+ };
+
+ // 폼 초기화
+ form.reset(formValues)
+
+ // 태그 타입 코드 설정 (추가 필드 로딩을 위해)
+ if (tag.tagType) {
+ // 해당 태그 타입에 맞는 클래스 옵션을 찾아서 태그 타입 코드 설정
+ const foundClass = classOptions.find(opt => opt.label === tag.class)
+ if (foundClass?.tagTypeCode) {
+ setSelectedTagTypeCode(foundClass.tagTypeCode)
+ loadSubFieldsByTagTypeCode(foundClass.tagTypeCode)
+ }
+ }
+ }, [tag, classOptions, form])
+
+ // Load subfields by tag type code
+ async function loadSubFieldsByTagTypeCode(tagTypeCode: string) {
+ setIsLoadingSubFields(true)
+ try {
+ const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode)
+ const formattedSubFields: SubFieldDef[] = apiSubFields.map(field => ({
+ name: field.name,
+ label: field.label,
+ type: field.type,
+ options: field.options || [],
+ expression: field.expression ?? undefined,
+ delimiter: field.delimiter ?? undefined,
+ }))
+ setSubFields(formattedSubFields)
+ return true
+ } catch (err) {
+ toast.error("서브필드를 불러오는데 실패했습니다.")
+ setSubFields([])
+ return false
+ } finally {
+ setIsLoadingSubFields(false)
+ }
+ }
+
+ // Handle class selection
+ async function handleSelectClass(classOption: UpdatedClassOption) {
+ form.setValue("class", classOption.label, { shouldValidate: true })
+
+ if (classOption.tagTypeCode) {
+ setSelectedTagTypeCode(classOption.tagTypeCode)
+
+ // Set tag type
+ const tagType = tagTypeList.find(t => t.id === classOption.tagTypeCode)
+ if (tagType) {
+ form.setValue("tagType", tagType.label, { shouldValidate: true })
+ } else if (classOption.tagTypeDescription) {
+ form.setValue("tagType", classOption.tagTypeDescription, { shouldValidate: true })
+ }
+
+ await loadSubFieldsByTagTypeCode(classOption.tagTypeCode)
+ }
+ }
+
+ // Form submission handler
+ function onSubmit(data: UpdateTagSchema) {
+ startUpdateTransition(async () => {
+ if (!tag) return
+
+ try {
+ // 기본 필드와 서브필드 데이터 결합
+ const tagData = {
+ id: tag.id,
+ tagType: data.tagType,
+ class: data.class,
+ tagNo: data.tagNo,
+ description: data.description,
+ ...Object.fromEntries(
+ subFields.map(field => [field.name, data[field.name] || ""])
+ ),
+ }
+
+ const result = await updateTag(tagData, selectedPackageId)
+
+ if ("error" in result) {
+ toast.error(result.error)
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("태그가 성공적으로 업데이트되었습니다")
+ } catch (error) {
+ console.error("Error updating tag:", error)
+ toast.error("태그 업데이트 중 오류가 발생했습니다")
+ }
+ })
+ }
+
+ // Render class field
+ function renderClassField(field: any) {
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+ const buttonId = React.useMemo(
+ () => `class-button-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ []
+ )
+ const popoverContentId = React.useMemo(
+ () => `class-popover-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ []
+ )
+ const commandId = React.useMemo(
+ () => `class-command-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ []
+ )
+
+ return (
+ <FormItem>
+ <FormLabel>Class</FormLabel>
+ <FormControl>
+ <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ key={buttonId}
+ type="button"
+ variant="outline"
+ className="w-full justify-between relative h-9"
+ disabled={isLoadingClasses}
+ >
+ {isLoadingClasses ? (
+ <>
+ <span>클래스 로딩 중...</span>
+ <Loader2 className="ml-2 h-4 w-4 animate-spin" />
+ </>
+ ) : (
+ <>
+ <span className="truncate mr-1 flex-grow text-left">
+ {field.value || "클래스 선택..."}
+ </span>
+ <ChevronsUpDown className="h-4 w-4 opacity-50 flex-shrink-0" />
+ </>
+ )}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent key={popoverContentId} className="w-[300px] p-0">
+ <Command key={commandId}>
+ <CommandInput
+ key={`${commandId}-input`}
+ placeholder="클래스 검색..."
+ value={classSearchTerm}
+ onValueChange={setClassSearchTerm}
+ />
+ <CommandList key={`${commandId}-list`} className="max-h-[300px]">
+ <CommandEmpty key={`${commandId}-empty`}>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup key={`${commandId}-group`}>
+ {classOptions.map((opt, optIndex) => {
+ if (!classOptionIdsRef.current[opt.code]) {
+ classOptionIdsRef.current[opt.code] =
+ `class-${opt.code}-${Date.now()}-${Math.random()
+ .toString(36)
+ .slice(2, 9)}`
+ }
+ const optionId = classOptionIdsRef.current[opt.code]
+
+ return (
+ <CommandItem
+ key={`${optionId}-${optIndex}`}
+ onSelect={() => {
+ field.onChange(opt.label)
+ setPopoverOpen(false)
+ handleSelectClass(opt)
+ }}
+ value={opt.label}
+ className="truncate"
+ title={opt.label}
+ >
+ <span className="truncate">{opt.label}</span>
+ <Check
+ key={`${optionId}-check`}
+ className={cn(
+ "ml-auto h-4 w-4 flex-shrink-0",
+ field.value === opt.label ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }
+
+ // Render TagType field (readonly)
+ function renderTagTypeField(field: any) {
+ return (
+ <FormItem>
+ <FormLabel>Tag Type</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ {...field}
+ readOnly
+ className="h-9 bg-muted"
+ />
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }
+
+ // Render Tag Number field (readonly)
+ function renderTagNoField(field: any) {
+ return (
+ <FormItem>
+ <FormLabel>Tag Number</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ {...field}
+ readOnly
+ className="h-9 bg-muted font-mono"
+ />
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }
+
+ // Render form fields for each subfield
+ function renderSubFields() {
+ if (isLoadingSubFields) {
+ return (
+ <div className="flex justify-center items-center py-4">
+ <Loader2 className="h-6 w-6 animate-spin text-primary" />
+ <div className="ml-3 text-muted-foreground">필드 로딩 중...</div>
+ </div>
+ )
+ }
+
+ if (subFields.length === 0) {
+ return null
+ }
+
+ return (
+ <div className="space-y-4">
+ <div className="text-sm font-medium text-muted-foreground">추가 필드</div>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {subFields.map((sf, index) => (
+ <FormField
+ key={`subfield-${sf.name}-${index}`}
+ control={form.control}
+ name={sf.name}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{sf.label}</FormLabel>
+ <FormControl>
+ {sf.type === "select" ? (
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ >
+ <SelectTrigger className="w-full h-9">
+ <SelectValue placeholder={`${sf.label} 선택...`} />
+ </SelectTrigger>
+ <SelectContent
+ align="start"
+ side="bottom"
+ className="max-h-[250px]"
+ style={{ minWidth: "250px", maxWidth: "350px" }}
+ >
+ {sf.options?.map((opt, optIndex) => (
+ <SelectItem
+ key={`${sf.name}-${opt.value}-${optIndex}`}
+ value={opt.value}
+ title={opt.label}
+ className="whitespace-normal py-2 break-words"
+ >
+ {opt.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ ) : (
+ <Input
+ {...field}
+ className="h-9"
+ placeholder={`${sf.label} 입력...`}
+ />
+ )}
+ </FormControl>
+ {sf.expression && (
+ <p className="text-xs text-muted-foreground mt-1" title={sf.expression}>
+ {sf.expression}
+ </p>
+ )}
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ ))}
+ </div>
+ </div>
+ )
+ }
+
+ // 컴포넌트 렌더링
+ return (
+ <Sheet {...props}>
+ {/* <SheetContent className="flex flex-col gap-0 sm:max-w-md overflow-y-auto"> */}
+ <SheetContent className="flex flex-col gap-6 sm:max-w-lg overflow-y-auto">
+ <SheetHeader className="text-left">
+ <SheetTitle>태그 수정</SheetTitle>
+ <SheetDescription>
+ 태그 정보를 업데이트하고 변경 사항을 저장하세요
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="flex-1 overflow-y-auto py-4">
+ <Form {...form}>
+ <form
+ id="update-tag-form"
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="space-y-6"
+ >
+ {/* 기본 태그 정보 */}
+ <div className="space-y-4">
+ {/* Class */}
+ <FormField
+ control={form.control}
+ name="class"
+ render={({ field }) => renderClassField(field)}
+ />
+
+ {/* Tag Type */}
+ <FormField
+ control={form.control}
+ name="tagType"
+ render={({ field }) => renderTagTypeField(field)}
+ />
+
+ {/* Tag Number */}
+ <FormField
+ control={form.control}
+ name="tagNo"
+ render={({ field }) => renderTagNoField(field)}
+ />
+
+ {/* Description */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Description</FormLabel>
+ <FormControl>
+ <Input
+ {...field}
+ placeholder="태그 설명 입력..."
+ className="h-9"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 서브필드 */}
+ {renderSubFields()}
+ </form>
+ </Form>
+ </div>
+
+ <SheetFooter className="pt-2">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button
+ type="submit"
+ form="update-tag-form"
+ disabled={isUpdatePending || isLoadingSubFields}
+ >
+ {isUpdatePending ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
+ 저장 중...
+ </>
+ ) : (
+ "저장"
+ )}
+ </Button>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/tags/validations.ts b/lib/tags/validations.ts
new file mode 100644
index 00000000..65e64f04
--- /dev/null
+++ b/lib/tags/validations.ts
@@ -0,0 +1,68 @@
+// /lib/tags/validations.ts
+import { z } from "zod"
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { Tag } from "@/db/schema/vendorData"
+
+export const createTagSchema = z.object({
+ tagNo: z.string().min(1, "Tag No is required"),
+ tagType: z.string().min(1, "Tag Type is required"),
+ class: z.string().min(1, "Equipment Class is required"),
+ description: z.string().min(1, "Description is required"), // 필수 필드로 변경
+
+ // optional sub-fields for dynamic numbering
+ functionCode: z.string().optional(),
+ seqNumber: z.string().optional(),
+ valveAcronym: z.string().optional(),
+ processUnit: z.string().optional(),
+
+ // If you also want contractItemId:
+ // contractItemId: z.number(),
+})
+
+export const updateTagSchema = z.object({
+ id: z.number().optional(), // 업데이트 과정에서 별도 검증
+ tagNo: z.string().min(1, "Tag Number is required"),
+ class: z.string().min(1, "Class is required"),
+ tagType: z.string().min(1, "Tag Type is required"),
+ description: z.string().optional(),
+ // 추가 필드들은 동적으로 추가될 수 있음
+ functionCode: z.string().optional(),
+ seqNumber: z.string().optional(),
+ valveAcronym: z.string().optional(),
+ processUnit: z.string().optional(),
+ // 기타 필드들은 필요에 따라 추가
+})
+
+export type UpdateTagSchema = z.infer<typeof updateTagSchema>
+
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<Tag>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ tagNo: parseAsString.withDefault(""),
+ tagType: parseAsString.withDefault(""),
+ description: parseAsString.withDefault(""),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+export type CreateTagSchema = z.infer<typeof createTagSchema>
+export type GetTagsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+
diff --git a/lib/tasks/repository.ts b/lib/tasks/repository.ts
new file mode 100644
index 00000000..2e71ee20
--- /dev/null
+++ b/lib/tasks/repository.ts
@@ -0,0 +1,166 @@
+// src/lib/tasks/repository.ts
+import db from "@/db/db";
+import { tasks, type Task } from "@/db/schema/tasks";
+import {
+ eq,
+ inArray,
+ not,
+ asc,
+ desc,
+ and,
+ ilike,
+ gte,
+ lte,
+ count,
+ gt,
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+export type NewTask = typeof tasks.$inferInsert
+
+/**
+ * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시
+ * - 트랜잭션(tx)을 받아서 사용하도록 구현
+ */
+export async function selectTasks(
+ 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(tasks)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+ }
+/** 총 개수 count */
+export async function countTasks(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(tasks).where(where);
+ return res[0]?.count ?? 0;
+}
+
+/** 단건 Insert 예시 */
+export async function insertTask(
+ tx: PgTransaction<any, any, any>,
+ data: NewTask // DB와 동일한 insert 가능한 타입
+) {
+ // returning() 사용 시 배열로 돌아오므로 [0]만 리턴
+ return tx
+ .insert(tasks)
+ .values(data)
+ .returning({ id: tasks.id, createdAt: tasks.createdAt });
+}
+
+/** 복수 Insert 예시 */
+export async function insertTasks(
+ tx: PgTransaction<any, any, any>,
+ data: Task[]
+) {
+ return tx.insert(tasks).values(data).onConflictDoNothing();
+}
+
+/** (방금 생성된 Task를 제외한) 가장 오래된 Task 하나 조회 */
+export async function selectOldestTaskExcept(
+ tx: PgTransaction<any, any, any>,
+ excludeId: string
+) {
+ return tx
+ .select({ id: tasks.id, createdAt: tasks.createdAt })
+ .from(tasks)
+ .where(not(eq(tasks.id, excludeId)))
+ .orderBy(asc(tasks.createdAt))
+ .limit(1);
+}
+
+/** 단건 삭제 */
+export async function deleteTaskById(
+ tx: PgTransaction<any, any, any>,
+ taskId: string
+) {
+ return tx.delete(tasks).where(eq(tasks.id, taskId));
+}
+
+/** 복수 삭제 */
+export async function deleteTasksByIds(
+ tx: PgTransaction<any, any, any>,
+ ids: string[]
+) {
+ return tx.delete(tasks).where(inArray(tasks.id, ids));
+}
+
+/** 전체 삭제 */
+export async function deleteAllTasks(
+ tx: PgTransaction<any, any, any>,
+) {
+ return tx.delete(tasks);
+}
+
+/** 단건 업데이트 */
+export async function updateTask(
+ tx: PgTransaction<any, any, any>,
+ taskId: string,
+ data: Partial<Task>
+) {
+ return tx
+ .update(tasks)
+ .set(data)
+ .where(eq(tasks.id, taskId))
+ .returning({ status: tasks.status, priority: tasks.priority });
+}
+
+/** 복수 업데이트 */
+export async function updateTasks(
+ tx: PgTransaction<any, any, any>,
+ ids: string[],
+ data: Partial<Task>
+) {
+ return tx
+ .update(tasks)
+ .set(data)
+ .where(inArray(tasks.id, ids))
+ .returning({ status: tasks.status, priority: tasks.priority });
+}
+
+/** status 기준 groupBy */
+export async function groupByStatus(
+ tx: PgTransaction<any, any, any>,
+) {
+ return tx
+ .select({
+ status: tasks.status,
+ count: count(),
+ })
+ .from(tasks)
+ .groupBy(tasks.status)
+ .having(gt(count(), 0));
+}
+
+/** priority 기준 groupBy */
+export async function groupByPriority(
+ tx: PgTransaction<any, any, any>,
+) {
+ return tx
+ .select({
+ priority: tasks.priority,
+ count: count(),
+ })
+ .from(tasks)
+ .groupBy(tasks.priority)
+ .having(gt(count(), 0));
+}
+
+// 모든 task 조회
+export const getAllTasks = async (): Promise<Task[]> => {
+ const users = await db.select().from(tasks).execute();
+ return users
+};
diff --git a/lib/tasks/service.ts b/lib/tasks/service.ts
new file mode 100644
index 00000000..c31ecd4b
--- /dev/null
+++ b/lib/tasks/service.ts
@@ -0,0 +1,561 @@
+// src/lib/tasks/service.ts
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { tasks, type Task } from "@/db/schema/tasks";
+import { customAlphabet } from "nanoid";
+
+import { filterColumns } from "@/lib/filter-columns";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { getErrorMessage } from "@/lib/handle-error";
+
+import type { CreateTaskSchema, UpdateTaskSchema, GetTasksSchema } from "./validations";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm";
+
+// 레포지토리 함수들
+import {
+ selectTasks,
+ countTasks,
+ insertTask,
+ insertTasks,
+ selectOldestTaskExcept,
+ deleteTaskById,
+ deleteTasksByIds,
+ deleteAllTasks,
+ updateTask,
+ updateTasks,
+ groupByStatus,
+ groupByPriority,
+ getAllTasks,
+} from "./repository";
+
+import ExcelJS from "exceljs"
+import { tasksColumnsConfig, type TaskColumnConfig } from "@/config/tasksColumnsConfig"
+
+interface ImportResult {
+ errorFile: File | null
+ errorMessage: string | null
+ successMessage?: string
+}
+/* -----------------------------------------------------
+ 1) 조회 관련
+----------------------------------------------------- */
+
+/**
+ * 복잡한 조건으로 Task 목록을 조회 (+ pagination) 하고,
+ * 총 개수에 따라 pageCount를 계산해서 리턴.
+ * Next.js의 unstable_cache를 사용해 일정 시간 캐시.
+ */
+export async function getTasks(input: GetTasksSchema) {
+
+ 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;
+ // const advancedTable = input.flags.includes("advancedTable");
+ const advancedTable = true;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: tasks,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(tasks.title, s), ilike(tasks.code, s)
+ , ilike(tasks.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 = advancedTable
+ ? finalWhere
+ : and(
+ input.title ? ilike(tasks.title, `%${input.title}%`) : undefined,
+ input.status.length > 0 ? inArray(tasks.status, input.status) : undefined,
+ input.priority.length > 0 ? inArray(tasks.priority, input.priority) : undefined,
+ fromDate ? gte(tasks.createdAt, fromDate) : undefined,
+ toDate ? lte(tasks.createdAt, toDate) : undefined
+ );
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(tasks[item.id]) : asc(tasks[item.id])
+ )
+ : [asc(tasks.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectTasks(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countTasks(tx, where);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+// console.log("===> advancedWhere:", advancedWhere);
+// console.log("===> globalWhere:", globalWhere);
+// console.log("===> finalWhere:", finalWhere);
+// console.log("===> offset:", offset, " limit:", input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: ["tasks"], // revalidateTag("tasks") 호출 시 무효화
+ }
+ )();
+}
+
+
+/** Status별 개수 */
+export async function getTaskStatusCounts() {
+ return unstable_cache(
+ async () => {
+ try {
+
+ const initial: Record<Task["status"], number> = {
+ todo: 0,
+ "in-progress": 0,
+ done: 0,
+ canceled: 0,
+ };
+
+
+ const result = await db.transaction(async (tx) => {
+ const rows = await groupByStatus(tx);
+ return rows.reduce<Record<Task["status"], number>>((acc, { status, count }) => {
+ acc[status] = count;
+ return acc;
+ }, initial);
+ });
+
+ return result;
+ } catch (err) {
+ return {} as Record<Task["status"], number>;
+ }
+ },
+ ["task-status-counts"], // 캐싱 키
+ {
+ revalidate: 3600,
+ }
+ )();
+}
+
+/** Priority별 개수 */
+export async function getTaskPriorityCounts() {
+ return unstable_cache(
+ async () => {
+ try {
+
+ const initial: Record<Task["priority"], number> = {
+ low: 0,
+ medium: 0,
+ high: 0,
+ };
+
+ const result = await db.transaction(async (tx) => {
+ const rows = await groupByPriority(tx);
+ return rows.reduce<Record<Task["priority"], number>>((acc, { priority, count }) => {
+ acc[priority] = count;
+ return acc;
+ }, initial);
+ });
+
+ return result;
+ } catch (err) {
+ return {} as Record<Task["priority"], number>;
+ }
+ },
+ ["task-priority-counts"],
+ {
+ revalidate: 3600,
+ }
+ )();
+}
+
+/* -----------------------------------------------------
+ 2) 생성(Create)
+----------------------------------------------------- */
+
+
+/**
+ * Task 생성 후, (가장 오래된 Task 1개) 삭제로
+ * 전체 Task 개수를 고정
+ */
+export async function createTask(input: CreateTaskSchema) {
+ unstable_noStore(); // Next.js 서버 액션 캐싱 방지
+ try {
+ await db.transaction(async (tx) => {
+ // 새 Task 생성
+ const [newTask] = await insertTask(tx, {
+ title: input.title,
+ status: input.status,
+ label: input.label,
+ priority: input.priority,
+ });
+ return newTask;
+
+ });
+
+ console.log("tasks")
+
+ // 캐시 무효화
+ revalidateTag("tasks");
+ revalidateTag("task-status-counts");
+ revalidateTag("task-priority-counts");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/* -----------------------------------------------------
+ 3) 업데이트
+----------------------------------------------------- */
+
+/** 단건 업데이트 */
+export async function modifiTask(input: UpdateTaskSchema & { id: string }) {
+ unstable_noStore();
+ try {
+ const data = await db.transaction(async (tx) => {
+ const [res] = await updateTask(tx, input.id, {
+ title: input.title,
+ label: input.label,
+ status: input.status,
+ priority: input.priority,
+ });
+ return res;
+ });
+
+ revalidateTag("tasks");
+ if (data.status === input.status) {
+ revalidateTag("task-status-counts");
+ }
+ if (data.priority === input.priority) {
+ revalidateTag("task-priority-counts");
+ }
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/** 복수 업데이트 */
+export async function modifiTasks(input: {
+ ids: string[];
+ label?: Task["label"];
+ status?: Task["status"];
+ priority?: Task["priority"];
+}) {
+ unstable_noStore();
+ try {
+ const data = await db.transaction(async (tx) => {
+ const [res] = await updateTasks(tx, input.ids, {
+ label: input.label,
+ status: input.status,
+ priority: input.priority,
+ });
+ return res;
+ });
+
+ revalidateTag("tasks");
+ if (data.status === input.status) {
+ revalidateTag("task-status-counts");
+ }
+ if (data.priority === input.priority) {
+ revalidateTag("task-priority-counts");
+ }
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/* -----------------------------------------------------
+ 4) 삭제
+----------------------------------------------------- */
+
+/** 단건 삭제 */
+export async function removeTask(input: { id: string }) {
+ unstable_noStore();
+ try {
+ await db.transaction(async (tx) => {
+ // 삭제
+ await deleteTaskById(tx, input.id);
+ // 바로 새 Task 생성
+ });
+
+ revalidateTag("tasks");
+ revalidateTag("task-status-counts");
+ revalidateTag("task-priority-counts");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/** 복수 삭제 */
+export async function removeTasks(input: { ids: string[] }) {
+ unstable_noStore();
+ try {
+ await db.transaction(async (tx) => {
+ // 삭제
+ await deleteTasksByIds(tx, input.ids);
+ });
+
+ revalidateTag("tasks");
+ revalidateTag("task-status-counts");
+ revalidateTag("task-priority-counts");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 1) 그룹 헤더(2줄 헤더)인지, 1줄 헤더인지 판별
+ * - Row1이 `group` 값들로 이루어져 있고,
+ * - Row2가 `excelHeader` 값들과 매칭되는지
+ */
+function detectHasGroupHeader(
+ worksheet: ExcelJS.Worksheet,
+ config: TaskColumnConfig[]
+): boolean {
+ // 전체 group 목록
+ const groupSet = new Set(
+ config.filter((c) => c.group).map((c) => c.group!.trim())
+ )
+ // 전체 excelHeader 목록
+ const headerSet = new Set(
+ config.filter((c) => c.excelHeader).map((c) => c.excelHeader!.trim())
+ )
+
+
+ // row1이 전부(또는 대부분) groupSet에 속하면 => 그룹 헤더일 가능성 높음
+ // row1Values = (index 0은 비어있을 수 있으므로) 안전하게 string 변환 후 trim
+ const row1Values = (worksheet.getRow(1)?.values ?? []) as (string | null | undefined)[]
+ const row2Values = (worksheet.getRow(2)?.values ?? []) as (string | null | undefined)[]
+
+ // row1Values가 전부 groupSet 내에 있거나 빈 문자열이면, "이건 그룹 헤더"
+ const row1IsMostlyGroup = row1Values.every((val) => {
+ if (!val) {
+ return true
+ }
+ return groupSet.has(val.trim())
+ })
+ // row2 중에 headerSet에 포함되는 값이 몇 개나 되는가?
+ // 즉, row2가 실제로 excelHeader로 구성되어 있으면 -> 2줄 헤더 가능성
+ const row2HeaderCount = row2Values.filter((val) => {
+ // val이 string인지 확인
+ if (typeof val === "string") {
+ return headerSet.has(val.trim())
+ }
+ // null/undefined(또는 숫자, 객체 등)이면 필터링 제외
+ return false
+ }).length
+
+ // (단순 로직) row1이 그룹 같고, row2가 적어도 1개 이상 excelHeader 매칭 => 2줄 헤더
+ // 프로젝트에 맞춰 좀 더 세밀하게 조건을 잡아도 됨.
+ if (row1IsMostlyGroup && row2HeaderCount > 0) {
+ return true
+ }
+ return false
+}
+
+export async function importTasksExcel(file: File): Promise<ImportResult> {
+ try {
+ // 1) 엑셀 로드
+ const buffer = await file.arrayBuffer()
+ const workbook = new ExcelJS.Workbook()
+ await workbook.xlsx.load(buffer)
+
+ // 첫 번째 시트만 사용
+ const worksheet = workbook.worksheets[0]
+ if (!worksheet) {
+ throw new Error("엑셀 파일에 시트가 없습니다.")
+ }
+
+ // 2) 그룹 헤더(2줄) or 일반 헤더(1줄) 판별
+ const hasGroupHeader = detectHasGroupHeader(worksheet, tasksColumnsConfig)
+ const headerRowIndex = hasGroupHeader ? 2 : 1
+ const dataStartRowIndex = hasGroupHeader ? 3 : 2
+
+ const headerRow = worksheet.getRow(headerRowIndex)
+ if (!headerRow) {
+ throw new Error("엑셀 헤더 행을 찾지 못했습니다.")
+ }
+
+ // 3) 엑셀 헤더(문자열) → 컬럼 인덱스(Map)
+ const columnIndexMap = new Map<string, number>()
+ headerRow.eachCell((cell, colIndex) => {
+ if (typeof cell.value === "string") {
+ columnIndexMap.set(cell.value.trim(), colIndex)
+ }
+ })
+
+ // 4) columnToFieldMap: "엑셀 열 인덱스" → "DB 필드(Task의 keyof)"
+ // 예) "Code" → "code", "Title" → "title", ...
+ const columnToFieldMap = new Map<number, keyof Task>()
+ tasksColumnsConfig.forEach((cfg) => {
+ if (!cfg.excelHeader) return
+ const colIndex = columnIndexMap.get(cfg.excelHeader.trim())
+ if (colIndex !== undefined) {
+ // 예: colIndex=1 -> cfg.id="code"
+ columnToFieldMap.set(colIndex, cfg.id)
+ }
+ })
+
+ // 5) 에러가 발생하면 표시할 용도
+ const errorRows: { rowIndex: number; message: string }[] = []
+
+ // 6) 엑셀에서 읽어온 행 데이터를 임시 보관
+ // "마지막 컬럼(D/d) → toDelete=true"
+ type ExcelRowData = {
+ rowIndex: number
+ fields: Partial<Task>
+ toDelete: boolean
+ }
+ const rowDataList: ExcelRowData[] = []
+
+ for (let r = dataStartRowIndex; r <= worksheet.rowCount; r++) {
+ const row = worksheet.getRow(r)
+ if (!row ) continue
+
+ // (6-1) 마지막 셀을 보고 DELETE 여부 판단
+ const lastCellValue = row.getCell(row.cellCount).value
+ const isDelete =
+ typeof lastCellValue === "string" &&
+ lastCellValue.toLowerCase() === "d"
+
+ // (6-2) 각 열 -> DB 필드 매핑
+ const fields = {} as Partial<Task>
+
+ columnToFieldMap.forEach((fieldId, colIdx) => {
+ const cellValue = row.getCell(colIdx).value
+ if (fieldId === "createdAt") {
+ fields.createdAt = undefined
+ } else {
+ fields[fieldId] = (cellValue ?? null) as any
+ }
+ })
+
+ rowDataList.push({
+ rowIndex: r,
+ fields,
+ toDelete: isDelete,
+ })
+ }
+
+ // (6-3) 혹시 이 시점에서 "필수 값이 누락됐다" 등의 검증을 하고 싶다면 errorRows.push(...)
+ // if (errorRows.length > 0) => 엑셀에 표시 후 리턴 (생략)
+
+ // 7) 현재 DB에 있는 "code" 목록을 가져온다
+ const existingCodes = await getAllTasks().then((rows) => rows.map((r) => r.code))
+ const existingCodeSet = new Set<string>(existingCodes.filter(Boolean))
+
+ // 8) CREATE/UPDATE/DELETE 목록 분리
+ const toCreate: Task[] = []
+ // (updateTasks 함수가 "ids: string[], data: Partial<Task>" 형태)
+ // - 여러 code를 한꺼번에 업데이트할 수도 있지만, 여기선 간단히 1code씩
+ const toUpdate: { codes: string[]; data: Partial<Task> }[] = []
+ const toDeleteCodes: string[] = []
+
+ for (const { rowIndex, fields, toDelete } of rowDataList) {
+ // code를 string으로 캐스팅
+ const code = fields.code ? String(fields.code).trim() : ""
+
+ if (toDelete) {
+ // DELETE
+ if (code && existingCodeSet.has(code)) {
+ toDeleteCodes.push(code)
+ }
+ // code가 없거나 DB에 없으면 무시
+ continue
+ }
+
+ // CREATE or UPDATE
+ if (!code) {
+
+ toCreate.push(fields as Task)
+ } else {
+ // code가 있고, DB에도 있으면 UPDATE
+ if (existingCodeSet.has(code)) {
+ toUpdate.push({ codes: [code], data: fields })
+ } else {
+ // code가 있지만 DB에 없으면 CREATE
+ toCreate.push(fields as Task)
+ }
+ }
+ }
+
+ // (선택) 에러가 있으면 여기서 다시 한 번 errorRows에 추가 후 반환 가능
+
+ // 9) 트랜잭션으로 처리
+ await db.transaction(async (tx) => {
+ // CREATE
+ if (toCreate.length > 0) {
+ await insertTasks(tx, toCreate)
+ }
+ // UPDATE
+ if (toUpdate.length > 0) {
+ for (const { codes, data } of toUpdate) {
+ await updateTasks(tx, codes, data)
+ }
+ }
+ // DELETE
+ if (toDeleteCodes.length > 0) {
+ await deleteTasksByIds(tx, toDeleteCodes)
+ }
+ })
+
+ // 10) 성공 메시지
+ const msg: string[] = []
+ if (toCreate.length > 0) msg.push(`${toCreate.length}건 생성`)
+ if (toUpdate.length > 0) msg.push(`${toUpdate.length}건 수정`)
+ if (toDeleteCodes.length > 0) msg.push(`${toDeleteCodes.length}건 삭제`)
+ const successMessage = msg.length > 0 ? msg.join(", ") : "No changes"
+
+ return {
+ errorFile: null,
+ errorMessage: null,
+ successMessage,
+ }
+ } catch (err: any) {
+ return {
+ errorFile: null,
+ errorMessage: err.message || "Import 중 오류가 발생했습니다.",
+ }
+ }
+} \ No newline at end of file
diff --git a/lib/tasks/table/add-tasks-dialog.tsx b/lib/tasks/table/add-tasks-dialog.tsx
new file mode 100644
index 00000000..18a9a4b2
--- /dev/null
+++ b/lib/tasks/table/add-tasks-dialog.tsx
@@ -0,0 +1,227 @@
+"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"
+// react-hook-form + shadcn/ui Form
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+// shadcn/ui Select
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+
+import { tasks } from "@/db/schema/tasks" // enumValues 가져올 DB 스키마
+import { createTaskSchema, type CreateTaskSchema } from "@/lib/tasks/validations"
+import { createTask } from "@/lib/tasks/service" // 서버 액션 혹은 API
+
+export function AddTaskDialog() {
+ const [open, setOpen] = React.useState(false)
+
+ // react-hook-form 세팅
+ const form = useForm<CreateTaskSchema>({
+ resolver: zodResolver(createTaskSchema),
+ defaultValues: {
+ title: "",
+ label: tasks.label.enumValues[0] ?? "", // enumValues 중 첫 번째를 기본값으로
+ status: tasks.status.enumValues[0] ?? "",
+ priority: tasks.priority.enumValues[0] ?? "",
+ },
+ })
+
+ async function onSubmit(data: CreateTaskSchema) {
+ const result = await createTask(data)
+ if (result.error) {
+ alert(`에러: ${result.error}`)
+ return
+ }
+ // 성공 시 모달 닫고 폼 리셋
+ form.reset()
+ setOpen(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 Task
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Create New Task</DialogTitle>
+ <DialogDescription>
+ 새 Task 정보를 입력하고 <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">
+ {/* Title 필드 */}
+ <FormField
+ control={form.control}
+ name="title"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Title</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="e.g. Fix the layout bug"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Label (Select) */}
+ <FormField
+ control={form.control}
+ name="label"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Label</FormLabel>
+ <FormControl>
+ <Select
+ onValueChange={field.onChange}
+ value={field.value}
+ >
+ <SelectTrigger className="capitalize">
+ <SelectValue placeholder="Select a label" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {tasks.label.enumValues.map((item) => (
+ <SelectItem
+ key={item}
+ value={item}
+ className="capitalize"
+ >
+ {item}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Status (Select) */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Status</FormLabel>
+ <FormControl>
+ <Select
+ onValueChange={field.onChange}
+ value={field.value}
+ >
+ <SelectTrigger className="capitalize">
+ <SelectValue placeholder="Select a status" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {tasks.status.enumValues.map((item) => (
+ <SelectItem
+ key={item}
+ value={item}
+ className="capitalize"
+ >
+ {item}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Priority (Select) */}
+ <FormField
+ control={form.control}
+ name="priority"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Priority</FormLabel>
+ <FormControl>
+ <Select
+ onValueChange={field.onChange}
+ value={field.value}
+ >
+ <SelectTrigger className="capitalize">
+ <SelectValue placeholder="Select a priority" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {tasks.priority.enumValues.map((item) => (
+ <SelectItem
+ key={item}
+ value={item}
+ className="capitalize"
+ >
+ {item}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ >
+ Cancel
+ </Button>
+ <Button type="submit" disabled={form.formState.isSubmitting}>
+ Create
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/tasks/table/delete-tasks-dialog.tsx b/lib/tasks/table/delete-tasks-dialog.tsx
new file mode 100644
index 00000000..c82c913e
--- /dev/null
+++ b/lib/tasks/table/delete-tasks-dialog.tsx
@@ -0,0 +1,149 @@
+"use client"
+
+import * as React from "react"
+import { type Task } from "@/db/schema/tasks"
+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 { removeTasks } from "@/lib//tasks/service"
+
+interface DeleteTasksDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ tasks: Row<Task>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteTasksDialog({
+ tasks,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteTasksDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removeTasks({
+ ids: tasks.map((task) => task.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 ({tasks.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">{tasks.length}</span>
+ {tasks.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 ({tasks.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">{tasks.length}</span>
+ {tasks.length === 1 ? " task" : " tasks"} 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/tasks/table/feature-flags-provider.tsx b/lib/tasks/table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/tasks/table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/tasks/table/feature-flags.tsx b/lib/tasks/table/feature-flags.tsx
new file mode 100644
index 00000000..aaae6af2
--- /dev/null
+++ b/lib/tasks/table/feature-flags.tsx
@@ -0,0 +1,96 @@
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface TasksTableContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const TasksTableContext = React.createContext<TasksTableContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useTasksTable() {
+ const context = React.useContext(TasksTableContext)
+ if (!context) {
+ throw new Error("useTasksTable must be used within a TasksTableProvider")
+ }
+ return context
+}
+
+export function TasksTableProvider({ children }: React.PropsWithChildren) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "featureFlags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ }
+ )
+
+ return (
+ <TasksTableContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit"
+ >
+ {dataTableConfig.featureFlags.map((flag) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className="whitespace-nowrap px-3 text-xs"
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon
+ className="mr-2 size-3.5 shrink-0"
+ aria-hidden="true"
+ />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </TasksTableContext.Provider>
+ )
+}
diff --git a/lib/tasks/table/tasks-table-columns.tsx b/lib/tasks/table/tasks-table-columns.tsx
new file mode 100644
index 00000000..3737c2e5
--- /dev/null
+++ b/lib/tasks/table/tasks-table-columns.tsx
@@ -0,0 +1,262 @@
+"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 { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+
+import { modifiTask } from "@/lib/tasks/service"
+import { getPriorityIcon, getStatusIcon } from "@/lib/tasks/utils"
+import { tasks } from "@/db/schema/tasks"
+import type { Task } from "@/db/schema/tasks"
+
+import { tasksColumnsConfig } from "@/config/tasksColumnsConfig"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Task> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Task>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<Task> = {
+ 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<Task> = {
+ 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>
+ <DropdownMenuSub>
+ <DropdownMenuSubTrigger>Labels</DropdownMenuSubTrigger>
+ <DropdownMenuSubContent>
+ <DropdownMenuRadioGroup
+ value={row.original.label}
+ onValueChange={(value) => {
+ startUpdateTransition(() => {
+ toast.promise(
+ modifiTask({
+ id: row.original.id,
+ label: value as Task["label"],
+ }),
+ {
+ loading: "Updating...",
+ success: "Label updated",
+ error: (err) => getErrorMessage(err),
+ }
+ )
+ })
+ }}
+ >
+ {tasks.label.enumValues.map((label) => (
+ <DropdownMenuRadioItem
+ key={label}
+ value={label}
+ className="capitalize"
+ disabled={isUpdatePending}
+ >
+ {label}
+ </DropdownMenuRadioItem>
+ ))}
+ </DropdownMenuRadioGroup>
+ </DropdownMenuSubContent>
+ </DropdownMenuSub>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<Task>[] }
+ const groupMap: Record<string, ColumnDef<Task>[]> = {}
+
+ tasksColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<Task> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+ // 예: cfg.id === "title" → custom rendering
+ if (cfg.id === "title") {
+ const labelVal = row.original.label
+ const labelExists = tasks.label.enumValues.includes(labelVal ?? "")
+ return (
+ <div className="flex space-x-2">
+ {labelExists && <Badge variant="outline">{labelVal}</Badge>}
+ <span className="max-w-[31.25rem] truncate font-medium">
+ {row.getValue("title")}
+ </span>
+ </div>
+ )
+ }
+
+ 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>
+ )
+ }
+
+ if (cfg.id === "priority") {
+ const priorityVal = row.original.priority
+ if (!priorityVal) return null
+ const Icon = getPriorityIcon(priorityVal)
+ return (
+ <div className="flex items-center">
+ <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" />
+ <span className="capitalize">{priorityVal}</span>
+ </div>
+ )
+ }
+
+ if (cfg.id === "archived") {
+ return (
+ <Badge variant="outline">
+ {row.original.archived ? "Yes" : "No"}
+ </Badge>
+ )
+ }
+
+ if (cfg.id === "createdAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ // code etc...
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<Task>[] = []
+
+ // 순서를 고정하고 싶다면 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/tasks/table/tasks-table-floating-bar.tsx b/lib/tasks/table/tasks-table-floating-bar.tsx
new file mode 100644
index 00000000..6d367f81
--- /dev/null
+++ b/lib/tasks/table/tasks-table-floating-bar.tsx
@@ -0,0 +1,354 @@
+"use client"
+
+import * as React from "react"
+import { tasks, type Task } from "@/db/schema/tasks"
+import { SelectTrigger } from "@radix-ui/react-select"
+import { type Table } from "@tanstack/react-table"
+import {
+ ArrowUp,
+ CheckCircle2,
+ Download,
+ Loader,
+ Trash2,
+ X,
+} from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { Portal } from "@/components/ui/portal"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+} from "@/components/ui/select"
+import { Separator } from "@/components/ui/separator"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { Kbd } from "@/components/kbd"
+
+import { removeTasks, modifiTasks } from "@/lib//tasks/service"
+import { DeleteTasksDialog } from "./delete-tasks-dialog"
+import { ActionConfirmDialog } from "@/components/ui/action-dialog"
+
+interface TasksTableFloatingBarProps {
+ table: Table<Task>
+}
+
+
+export function TasksTableFloatingBar({ table }: TasksTableFloatingBarProps) {
+ const rows = table.getFilteredSelectedRowModel().rows
+
+ const [isPending, startTransition] = React.useTransition()
+ const [action, setAction] = React.useState<
+ "update-status" | "update-priority" | "export" | "delete"
+ >()
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+ // Clear selection on Escape key press
+ 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
+ const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false)
+ const [confirmProps, setConfirmProps] = React.useState<{
+ title: string
+ description?: string
+ onConfirm: () => Promise<void> | void
+ }>({
+ title: "",
+ description: "",
+ onConfirm: () => { },
+ })
+
+ // 1) "삭제" Confirm 열기
+ function handleDeleteConfirm() {
+ setAction("delete")
+ setConfirmProps({
+ title: `Delete ${rows.length} user${rows.length > 1 ? "s" : ""}?`,
+ description: "This action cannot be undone.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ const { error } = await removeTasks({
+ ids: rows.map((row) => row.original.id),
+ })
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("Users deleted")
+ table.toggleAllRowsSelected(false)
+ setConfirmDialogOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+ // 2)
+ function handleSelectStatus(newStatus: Task["status"]) {
+ setAction("update-status")
+
+ setConfirmProps({
+ title: `Update ${rows.length} task${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`,
+ description: "This action will override their current status.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ const { error } = await modifiTasks({
+ ids: rows.map((row) => row.original.id),
+ status: newStatus,
+ })
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("Tasks updated")
+ setConfirmDialogOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+ // 3)
+ function handleSelectPriority(newPriority: Task["priority"]) {
+ setAction("update-priority")
+
+ setConfirmProps({
+ title: `Update ${rows.length} task${rows.length > 1 ? "s" : ""} with priority: ${newPriority}?`,
+ description: "This action will override their current priority.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ const { error } = await modifiTasks({
+ ids: rows.map((row) => row.original.id),
+ priority: newPriority,
+ })
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("Tasks updated")
+ setConfirmDialogOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+ 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">
+ <Select
+ onValueChange={(value: Task["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>
+ {tasks.status.enumValues.map((status) => (
+ <SelectItem
+ key={status}
+ value={status}
+ className="capitalize"
+ >
+ {status}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <Select
+ onValueChange={(value: Task["priority"]) => {
+ handleSelectPriority(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-priority" ? (
+ <Loader
+ className="size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <ArrowUp 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 priority</p>
+ </TooltipContent>
+ </Tooltip>
+ <SelectContent align="center">
+ <SelectGroup>
+ {tasks.priority.enumValues.map((priority) => (
+ <SelectItem
+ key={priority}
+ value={priority}
+ className="capitalize"
+ >
+ {priority}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <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 tasks</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 tasks</p>
+ </TooltipContent>
+ </Tooltip>
+ </div>
+ </div>
+ </div>
+ </div>
+
+
+ {/* 공용 Confirm Dialog */}
+ <ActionConfirmDialog
+ open={confirmDialogOpen}
+ onOpenChange={setConfirmDialogOpen}
+ title={confirmProps.title}
+ description={confirmProps.description}
+ onConfirm={confirmProps.onConfirm}
+ isLoading={isPending && (action === "delete" || action === "update-priority" || action === "update-status")}
+ confirmLabel={
+ action === "delete"
+ ? "Delete"
+ : action === "update-priority" || action === "update-status"
+ ? "Update"
+ : "Confirm"
+ }
+ confirmVariant={
+ action === "delete" ? "destructive" : "default"
+ }
+ />
+ </Portal>
+ )
+}
diff --git a/lib/tasks/table/tasks-table-toolbar-actions.tsx b/lib/tasks/table/tasks-table-toolbar-actions.tsx
new file mode 100644
index 00000000..8219b7b6
--- /dev/null
+++ b/lib/tasks/table/tasks-table-toolbar-actions.tsx
@@ -0,0 +1,117 @@
+"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 { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+
+// 삭제, 추가 다이얼로그
+import { DeleteTasksDialog } from "./delete-tasks-dialog"
+import { AddTaskDialog } from "./add-tasks-dialog"
+
+// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import
+import { importTasksExcel } from "@/lib/tasks/service" // 예시
+
+interface TasksTableToolbarActionsProps {
+ table: Table<Task>
+}
+
+export function TasksTableToolbarActions({ table }: TasksTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일이 선택되었을 때 처리
+ async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록)
+ event.target.value = ""
+
+ // 서버 액션 or API 호출
+ try {
+ // 예: 서버 액션 호출
+ const { errorFile, errorMessage } = await importTasksExcel(file)
+
+ if (errorMessage) {
+ toast.error(errorMessage)
+ }
+ if (errorFile) {
+ // 에러 엑셀을 다운로드
+ const url = URL.createObjectURL(errorFile)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "errors.xlsx"
+ link.click()
+ URL.revokeObjectURL(url)
+ } else {
+ // 성공
+ toast.success("Import success")
+ // 필요 시 revalidateTag("tasks") 등
+ }
+
+ } catch (err) {
+ toast.error("파일 업로드 중 오류가 발생했습니다.")
+
+ }
+ }
+
+ function handleImportClick() {
+ // 숨겨진 <input type="file" /> 요소를 클릭
+ fileInputRef.current?.click()
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteTasksDialog
+ tasks={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+ {/** 2) 새 Task 추가 다이얼로그 */}
+ <AddTaskDialog />
+
+ {/** 3) Import 버튼 (파일 업로드) */}
+ <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}>
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+ {/*
+ 실제로는 숨겨진 input과 연결:
+ - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용
+ */}
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".xlsx,.xls"
+ className="hidden"
+ onChange={onFileChange}
+ />
+
+ {/** 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>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/tasks/table/tasks-table.tsx b/lib/tasks/table/tasks-table.tsx
new file mode 100644
index 00000000..ab448a7b
--- /dev/null
+++ b/lib/tasks/table/tasks-table.tsx
@@ -0,0 +1,197 @@
+"use client"
+
+import * as React from "react"
+import { tasks, type Task } from "@/db/schema/tasks"
+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 { DataTableToolbar } from "@/components/data-table/data-table-toolbar"
+
+import type {
+ getTaskPriorityCounts,
+ getTasks,
+ getTaskStatusCounts,
+} from "@/lib//tasks/service"
+import { getPriorityIcon, getStatusIcon } from "@/lib/tasks/utils"
+import { DeleteTasksDialog } from "./delete-tasks-dialog"
+import { useFeatureFlags } from "./feature-flags-provider"
+import { getColumns } from "./tasks-table-columns"
+import { TasksTableFloatingBar } from "./tasks-table-floating-bar"
+import { TasksTableToolbarActions } from "./tasks-table-toolbar-actions"
+import { UpdateTaskSheet } from "./update-task-sheet"
+
+interface TasksTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getTasks>>,
+ Awaited<ReturnType<typeof getTaskStatusCounts>>,
+ Awaited<ReturnType<typeof getTaskPriorityCounts>>,
+ ]
+ >
+}
+
+export function TasksTable({ promises }: TasksTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ const [{ data, pageCount }, statusCounts, priorityCounts] =
+ React.use(promises)
+
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<Task> | 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<Task>[] = [
+ {
+ id: "title",
+ label: "Title",
+ placeholder: "Filter titles...",
+ },
+ {
+ id: "status",
+ label: "Status",
+ options: tasks.status.enumValues.map((status) => ({
+ label: toSentenceCase(status),
+ value: status,
+ icon: getStatusIcon(status),
+ count: statusCounts[status],
+ })),
+ },
+ {
+ id: "priority",
+ label: "Priority",
+ options: tasks.priority.enumValues.map((priority) => ({
+ label: toSentenceCase(priority),
+ value: priority,
+ icon: getPriorityIcon(priority),
+ count: priorityCounts[priority],
+ })),
+ },
+ ]
+
+ /**
+ * 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<Task>[] = [
+ {
+ id: "code",
+ label: "Task",
+ type: "text",
+ },
+ {
+ id: "title",
+ label: "Title",
+ type: "text",
+ },
+ {
+ id: "label",
+ label: "Label",
+ type: "text",
+ },
+ {
+ id: "status",
+ label: "Status",
+ type: "multi-select",
+ options: tasks.status.enumValues.map((status) => ({
+ label: toSentenceCase(status),
+ value: status,
+ icon: getStatusIcon(status),
+ count: statusCounts[status],
+ })),
+ },
+ {
+ id: "priority",
+ label: "Priority",
+ type: "multi-select",
+ options: tasks.priority.enumValues.map((priority) => ({
+ label: toSentenceCase(priority),
+ value: priority,
+ icon: getPriorityIcon(priority),
+ count: priorityCounts[priority],
+ })),
+ },
+ {
+ id: "createdAt",
+ label: "Created at",
+ type: "date",
+ },
+ ]
+
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => originalRow.id,
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ floatingBar={<TasksTableFloatingBar table={table} />}
+ >
+
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <TasksTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+
+ </DataTable>
+ <UpdateTaskSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ task={rowAction?.row.original ?? null}
+ />
+ <DeleteTasksDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ tasks={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+ </>
+ )
+}
diff --git a/lib/tasks/table/update-task-sheet.tsx b/lib/tasks/table/update-task-sheet.tsx
new file mode 100644
index 00000000..1f4f5aa8
--- /dev/null
+++ b/lib/tasks/table/update-task-sheet.tsx
@@ -0,0 +1,230 @@
+"use client"
+
+import * as React from "react"
+import { tasks, type Task } from "@/db/schema/tasks"
+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 {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Textarea } from "@/components/ui/textarea"
+
+import { modifiTask } from "@/lib//tasks/service"
+import { updateTaskSchema, type UpdateTaskSchema } from "@/lib/tasks/validations"
+
+interface UpdateTaskSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ task: Task | null
+}
+
+export function UpdateTaskSheet({ task, ...props }: UpdateTaskSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ const form = useForm<UpdateTaskSchema>({
+ resolver: zodResolver(updateTaskSchema),
+ defaultValues: {
+ title: task?.title ?? "",
+ label: task?.label,
+ status: task?.status,
+ priority: task?.priority,
+ },
+ })
+
+ function onSubmit(input: UpdateTaskSchema) {
+ startUpdateTransition(async () => {
+ if (!task) return
+
+ const { error } = await modifiTask({
+ id: task.id,
+ ...input,
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("Task updated")
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Update task</SheetTitle>
+ <SheetDescription>
+ Update the task 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="title"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Title</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Do a kickflip"
+ className="resize-none"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="label"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Label</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ >
+ <FormControl>
+ <SelectTrigger className="capitalize">
+ <SelectValue placeholder="Select a label" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ {tasks.label.enumValues.map((item) => (
+ <SelectItem
+ key={item}
+ value={item}
+ className="capitalize"
+ >
+ {item}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Status</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ >
+ <FormControl>
+ <SelectTrigger className="capitalize">
+ <SelectValue placeholder="Select a status" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ {tasks.status.enumValues.map((item) => (
+ <SelectItem
+ key={item}
+ value={item}
+ className="capitalize"
+ >
+ {item}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="priority"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Priority</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ >
+ <FormControl>
+ <SelectTrigger className="capitalize">
+ <SelectValue placeholder="Select a priority" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ {tasks.priority.enumValues.map((item) => (
+ <SelectItem
+ key={item}
+ value={item}
+ className="capitalize"
+ >
+ {item}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <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/tasks/utils.ts b/lib/tasks/utils.ts
new file mode 100644
index 00000000..ea4425de
--- /dev/null
+++ b/lib/tasks/utils.ts
@@ -0,0 +1,80 @@
+import { tasks, type Task } from "@/db/schema/tasks"
+import { faker } from "@faker-js/faker"
+import {
+ ArrowDownIcon,
+ ArrowRightIcon,
+ ArrowUpIcon,
+ AwardIcon,
+ CheckCircle2,
+ CircleHelp,
+ CircleIcon,
+ CircleX,
+ PencilIcon,
+ SearchIcon,
+ SendIcon,
+ Timer,
+} from "lucide-react"
+import { customAlphabet } from "nanoid"
+
+import { generateId } from "@/lib/id"
+import { Rfq } from "@/db/schema/rfq"
+
+export function generateRandomTask(): Task {
+ return {
+ id: generateId("task"),
+ code: `TASK-${customAlphabet("0123456789", 4)()}`,
+ title: faker.hacker
+ .phrase()
+ .replace(/^./, (letter) => letter.toUpperCase()),
+ status: faker.helpers.shuffle(tasks.status.enumValues)[0] ?? "todo",
+ label: faker.helpers.shuffle(tasks.label.enumValues)[0] ?? "bug",
+ priority: faker.helpers.shuffle(tasks.priority.enumValues)[0] ?? "low",
+ archived: faker.datatype.boolean({ probability: 0.2 }),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }
+}
+
+/**
+ * Returns the appropriate status icon based on the provided status.
+ * @param status - The status of the task.
+ * @returns A React component representing the status icon.
+ */
+export function getStatusIcon(status: Task["status"]) {
+ const statusIcons = {
+ canceled: CircleX,
+ done: CheckCircle2,
+ "in-progress": Timer,
+ todo: CircleHelp,
+ }
+
+ return statusIcons[status] || CircleIcon
+}
+
+export function getRFQStatusIcon(status: Rfq["status"]) {
+ const statusIcons = {
+ DRAFT: PencilIcon,
+ PUBLISHED: SendIcon,
+ EVALUATION: SearchIcon,
+ AWARDED: AwardIcon,
+ }
+
+
+
+ return statusIcons[status] || CircleIcon
+}
+
+/**
+ * Returns the appropriate priority icon based on the provided priority.
+ * @param priority - The priority of the task.
+ * @returns A React component representing the priority icon.
+ */
+export function getPriorityIcon(priority: Task["priority"]) {
+ const priorityIcons = {
+ high: ArrowUpIcon,
+ low: ArrowDownIcon,
+ medium: ArrowRightIcon,
+ }
+
+ return priorityIcons[priority] || CircleIcon
+}
diff --git a/lib/tasks/validations.ts b/lib/tasks/validations.ts
new file mode 100644
index 00000000..fea313f3
--- /dev/null
+++ b/lib/tasks/validations.ts
@@ -0,0 +1,50 @@
+import { tasks, type Task } from "@/db/schema/tasks";
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<Task>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ title: parseAsString.withDefault(""),
+ status: parseAsArrayOf(z.enum(tasks.status.enumValues)).withDefault([]),
+ priority: parseAsArrayOf(z.enum(tasks.priority.enumValues)).withDefault([]),
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+export const createTaskSchema = z.object({
+ title: z.string(),
+ label: z.enum(tasks.label.enumValues),
+ status: z.enum(tasks.status.enumValues),
+ priority: z.enum(tasks.priority.enumValues),
+})
+
+export const updateTaskSchema = z.object({
+ title: z.string().optional(),
+ label: z.enum(tasks.label.enumValues).optional(),
+ status: z.enum(tasks.status.enumValues).optional(),
+ priority: z.enum(tasks.priority.enumValues).optional(),
+})
+
+export type GetTasksSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+export type CreateTaskSchema = z.infer<typeof createTaskSchema>
+export type UpdateTaskSchema = z.infer<typeof updateTaskSchema>
diff --git a/lib/tbe/service.ts b/lib/tbe/service.ts
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/lib/tbe/service.ts
diff --git a/lib/tbe/table/comments-sheet.tsx b/lib/tbe/table/comments-sheet.tsx
new file mode 100644
index 00000000..7fcde35d
--- /dev/null
+++ b/lib/tbe/table/comments-sheet.tsx
@@ -0,0 +1,334 @@
+"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 } 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 { RfqWithAll } from "@/db/schema/rfq"
+import { formatDate } from "@/lib/utils"
+import { createRfqCommentWithAttachments } from "@/lib/rfqs/service"
+
+// 코멘트 + 첨부파일 구조 (단순 예시)
+// 실제 DB 스키마에 맞춰 조정
+export interface TbeComment {
+ id: number
+ commentText: string
+ commentedBy?: number
+ createdAt?: string | Date
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ }[]
+}
+
+interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ /** 코멘트를 작성할 RFQ 정보 */
+ /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */
+ initialComments?: TbeComment[]
+
+ /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */
+ currentUserId: number
+ rfqId:number
+ vendorId:number
+ /** 댓글 저장 후 갱신용 콜백 (옵션) */
+ onCommentsUpdated?: (comments: TbeComment[]) => void
+}
+
+// 새 코멘트 작성 폼 스키마
+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,
+ onCommentsUpdated,
+ ...props
+}: CommentSheetProps) {
+ 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: []
+ }
+ })
+
+ // formFieldArray 예시 (파일 목록)
+ const { fields: newFileFields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "newFiles"
+ })
+
+ // 1) 기존 코멘트 + 첨부 보여주기
+ // 간단히 테이블 하나로 표현
+ // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음
+ function renderExistingComments() {
+ 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 || c.attachments.length === 0) && (
+ <span className="text-sm text-muted-foreground">No files</span>
+ )}
+ {c.attachments && c.attachments.length > 0 && (
+ <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}
+ 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.commentedBy ?? "-"}
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )
+ }
+
+ // 2) 새 파일 Drop
+ function handleDropAccepted(files: File[]) {
+ // 드롭된 File[]을 RHF field array에 추가
+ const toAppend = files.map((f) => f)
+ append(toAppend)
+ }
+
+
+ // 3) 저장(Submit)
+ async function onSubmit(data: CommentFormValues) {
+
+ if (!rfqId) return
+ startTransition(async () => {
+ try {
+ // 서버 액션 호출
+ const res = await createRfqCommentWithAttachments({
+ rfqId: rfqId,
+ vendorId: vendorId, // 필요시 세팅
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ evaluationId: null, // 필요시 세팅
+ files: data.newFiles
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create comment")
+ }
+
+ toast.success("Comment created")
+
+ // 새 코멘트를 다시 불러오거나,
+ // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트
+ const newComment: TbeComment = {
+ id: res.commentId, // 서버에서 반환된 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,
+ })) || [])
+ }
+ 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/tbe/table/feature-flags-provider.tsx b/lib/tbe/table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/tbe/table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/tbe/table/file-dialog.tsx b/lib/tbe/table/file-dialog.tsx
new file mode 100644
index 00000000..b569f2b1
--- /dev/null
+++ b/lib/tbe/table/file-dialog.tsx
@@ -0,0 +1,141 @@
+"use client"
+
+import * as React from "react"
+import { Download, X } from "lucide-react"
+import { toast } from "sonner"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDateTime } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+
+import {
+ FileList,
+ FileListItem,
+ FileListIcon,
+ FileListInfo,
+ FileListName,
+ FileListDescription,
+ FileListAction,
+} from "@/components/ui/file-list"
+import { getTbeFilesForVendor } from "@/lib/rfqs/service"
+
+interface TBEFileDialogProps {
+ isOpen: boolean
+ onOpenChange: (open: boolean) => void
+ tbeId: number
+ vendorId: number
+ rfqId: number
+ onRefresh?: () => void
+}
+
+export function TBEFileDialog({
+ isOpen,
+ onOpenChange,
+ vendorId,
+ rfqId,
+ onRefresh,
+}: TBEFileDialogProps) {
+ const [submittedFiles, setSubmittedFiles] = React.useState<any[]>([])
+ const [isFetchingFiles, setIsFetchingFiles] = React.useState(false)
+
+
+ // Fetch submitted files when dialog opens
+ React.useEffect(() => {
+ if (isOpen && rfqId && vendorId) {
+ fetchSubmittedFiles()
+ }
+ }, [isOpen, rfqId, vendorId])
+
+ // Fetch submitted files using the service function
+ const fetchSubmittedFiles = async () => {
+ if (!rfqId || !vendorId) return
+
+ setIsFetchingFiles(true)
+ try {
+ const { files, error } = await getTbeFilesForVendor(rfqId, vendorId)
+
+ if (error) {
+ throw new Error(error)
+ }
+
+ setSubmittedFiles(files)
+ } catch (error) {
+ toast.error("Failed to load files: " + getErrorMessage(error))
+ } finally {
+ setIsFetchingFiles(false)
+ }
+ }
+
+ // Download submitted file
+ const downloadSubmittedFile = async (file: any) => {
+ try {
+ const response = await fetch(`/api/file/${file.id}/download`)
+ if (!response.ok) {
+ throw new Error("Failed to download file")
+ }
+
+ const blob = await response.blob()
+ const url = window.URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = url
+ a.download = file.fileName
+ document.body.appendChild(a)
+ a.click()
+ window.URL.revokeObjectURL(url)
+ document.body.removeChild(a)
+ } catch (error) {
+ toast.error("Failed to download file: " + getErrorMessage(error))
+ }
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-lg">
+ <DialogHeader>
+ <DialogTitle>TBE 응답 파일</DialogTitle>
+ <DialogDescription>제출된 파일 목록을 확인하고 다운로드하세요.</DialogDescription>
+ </DialogHeader>
+
+ {/* 제출된 파일 목록 */}
+ {isFetchingFiles ? (
+ <div className="flex justify-center items-center py-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
+ </div>
+ ) : submittedFiles.length > 0 ? (
+ <div className="grid gap-2">
+ <FileList>
+ {submittedFiles.map((file) => (
+ <FileListItem key={file.id} className="flex items-center justify-between gap-3">
+ <div className="flex items-center gap-3 flex-1">
+ <FileListIcon className="flex-shrink-0" />
+ <FileListInfo className="flex-1 min-w-0">
+ <FileListName className="text-sm font-medium truncate">{file.fileName}</FileListName>
+ <FileListDescription className="text-xs text-muted-foreground">
+ {file.uploadedAt ? formatDateTime(file.uploadedAt) : ""}
+ </FileListDescription>
+ </FileListInfo>
+ </div>
+ <FileListAction className="flex-shrink-0 ml-2">
+ <Button variant="ghost" size="icon" onClick={() => downloadSubmittedFile(file)}>
+ <Download className="h-4 w-4" />
+ <span className="sr-only">파일 다운로드</span>
+ </Button>
+ </FileListAction>
+ </FileListItem>
+ ))}
+ </FileList>
+ </div>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">제출된 파일이 없습니다.</div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/tbe/table/invite-vendors-dialog.tsx b/lib/tbe/table/invite-vendors-dialog.tsx
new file mode 100644
index 00000000..87467e57
--- /dev/null
+++ b/lib/tbe/table/invite-vendors-dialog.tsx
@@ -0,0 +1,203 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Send } 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 { Input } from "@/components/ui/input"
+
+import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
+import { inviteTbeVendorsAction } from "@/lib/rfqs/service"
+
+interface InviteVendorsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<VendorWithTbeFields>["original"][]
+ rfqId: number
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function InviteVendorsDialog({
+ vendors,
+ rfqId,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: InviteVendorsDialogProps) {
+ const [isInvitePending, startInviteTransition] = React.useTransition()
+
+
+ // multiple 파일을 받을 state
+ const [files, setFiles] = React.useState<FileList | null>(null)
+
+ // 미디어쿼리 (desktop 여부)
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onInvite() {
+ startInviteTransition(async () => {
+ // 파일이 선택되지 않았다면 에러
+ if (!files || files.length === 0) {
+ toast.error("Please attach TBE files before inviting.")
+ return
+ }
+
+ // FormData 생성
+ const formData = new FormData()
+ formData.append("rfqId", String(rfqId))
+ vendors.forEach((vendor) => {
+ formData.append("vendorIds[]", String(vendor.id))
+ })
+
+ // multiple 파일
+ for (let i = 0; i < files.length; i++) {
+ formData.append("tbeFiles", files[i]) // key는 동일하게 "tbeFiles"
+ }
+
+ // 서버 액션 호출
+ const { error } = await inviteTbeVendorsAction(formData)
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ // 성공
+ props.onOpenChange?.(false)
+ toast.success("Vendors invited with TBE!")
+ onSuccess?.()
+ })
+ }
+
+ // 파일 선택 UI
+ const fileInput = (
+ <div className="mb-4">
+ <label className="mb-2 block font-medium">TBE Sheets</label>
+ <Input
+ type="file"
+ multiple
+ onChange={(e) => {
+ setFiles(e.target.files)
+ }}
+ />
+ </div>
+ )
+
+ // Desktop Dialog
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Send className="mr-2 size-4" aria-hidden="true" />
+ Invite ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Are you absolutely sure?</DialogTitle>
+ <DialogDescription>
+ This action cannot be undone. This will permanently invite{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 파일 첨부 */}
+ {fileInput}
+
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Invite selected rows"
+ variant="destructive"
+ onClick={onInvite}
+ // 파일이 없거나 초대 진행중이면 비활성화
+ disabled={isInvitePending || !files || files.length === 0}
+ >
+ {isInvitePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Invite
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ // Mobile Drawer
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Send className="mr-2 size-4" aria-hidden="true" />
+ Invite ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : 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>
+ </DrawerHeader>
+
+ {/* 파일 첨부 */}
+ {fileInput}
+
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Invite selected rows"
+ variant="destructive"
+ onClick={onInvite}
+ // 파일이 없거나 초대 진행중이면 비활성화
+ disabled={isInvitePending || !files || files.length === 0}
+ >
+ {isInvitePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Invite
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/tbe/table/tbe-table-columns.tsx b/lib/tbe/table/tbe-table-columns.tsx
new file mode 100644
index 00000000..f2bc2ced
--- /dev/null
+++ b/lib/tbe/table/tbe-table-columns.tsx
@@ -0,0 +1,249 @@
+"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 {
+ VendorTbeColumnConfig,
+ vendorTbeColumnsConfig,
+ VendorWithTbeFields,
+} from "@/config/vendorTbeColumnsConfig"
+
+type NextRouter = ReturnType<typeof useRouter>
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorWithTbeFields> | null>>
+ router: NextRouter
+ openCommentSheet: (vendorId: number, rfqId: number) => void
+ openFilesDialog: (tbeId: number, vendorId: number, rfqId: number) => void
+}
+
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ openFilesDialog
+}: GetColumnsProps): ColumnDef<VendorWithTbeFields>[] {
+ // ----------------------------------------------------------------
+ // 1) Select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<VendorWithTbeFields> = {
+ 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<VendorWithTbeFields>[]> = {}
+
+ vendorTbeColumnsConfig.forEach((cfg) => {
+ const groupName = cfg.group || "_noGroup"
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // childCol: ColumnDef<VendorWithTbeFields>
+ const childCol: ColumnDef<VendorWithTbeFields> = {
+ 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 === "vendorStatus") {
+ const statusVal = row.original.vendorStatus
+ if (!statusVal) return null
+ // const Icon = getStatusIcon(statusVal)
+ return (
+ <Badge variant="outline">
+ {statusVal}
+ </Badge>
+ )
+ }
+
+
+ if (cfg.id === "rfqVendorStatus") {
+ const statusVal = row.original.rfqVendorStatus
+ 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>
+ )
+ }
+
+ // 예) TBE Updated (날짜)
+ if (cfg.id === "tbeUpdated") {
+ const dateVal = val as Date | undefined
+ if (!dateVal) return null
+ return formatDate(dateVal)
+ }
+
+ // 그 외 필드는 기본 값 표시
+ return val ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // groupMap → nestedColumns
+ const nestedColumns: ColumnDef<VendorWithTbeFields>[] = []
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ nestedColumns.push(...colDefs)
+ } else {
+ nestedColumns.push({
+ id: groupName,
+ header: groupName,
+ columns: colDefs,
+ })
+ }
+ })
+// 파일 칼럼
+const filesColumn: ColumnDef<VendorWithTbeFields> = {
+ id: "files",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Response Files" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const filesCount = vendor.files?.length ?? 0
+
+ function handleClick() {
+ // setRowAction으로 타입만 설정하고 끝내는 방법도 가능하지만
+ // 혹은 바로 openFilesDialog()를 호출해도 됨.
+ setRowAction({ row, type: "files" })
+ // 필요한 값을 직접 호출해서 넘겨줄 수도 있음.
+ openFilesDialog(
+ vendor.tbeId ?? 0,
+ vendor.vendorId ?? 0,
+ vendor.rfqId ?? 0,
+ )
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={filesCount > 0 ? `View ${filesCount} files` : "Upload file"}
+ >
+ <Download className="h-4 w-4" />
+ {filesCount > 0 && (
+ <Badge variant="secondary" className="absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center">
+ {filesCount}
+ </Badge>
+ )}
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80,
+}
+
+// 댓글 칼럼
+const commentsColumn: ColumnDef<VendorWithTbeFields> = {
+ 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.vendorId ?? 0,
+ vendor.rfqId ?? 0,
+ )
+ }
+
+ return (
+ <Button variant="ghost" size="sm" className="h-8 w-8 p-0 group relative" onClick={handleClick}>
+ <MessageSquare className="h-4 w-4" />
+ {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">
+ {commCount}
+ </Badge>
+ )}
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80,
+}
+// ----------------------------------------------------------------
+// 5) 최종 컬럼 배열 - Update to include the files column
+// ----------------------------------------------------------------
+return [
+ selectColumn,
+ ...nestedColumns,
+ filesColumn, // Add the files column before comments
+ commentsColumn,
+ // actionsColumn,
+]
+
+} \ No newline at end of file
diff --git a/lib/tbe/table/tbe-table-toolbar-actions.tsx b/lib/tbe/table/tbe-table-toolbar-actions.tsx
new file mode 100644
index 00000000..6a336135
--- /dev/null
+++ b/lib/tbe/table/tbe-table-toolbar-actions.tsx
@@ -0,0 +1,60 @@
+"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 { InviteVendorsDialog } from "./invite-vendors-dialog"
+import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig"
+
+interface VendorsTableToolbarActionsProps {
+ table: Table<VendorWithTbeFields>
+ rfqId: number
+}
+
+export function VendorsTableToolbarActions({ table,rfqId }: VendorsTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일이 선택되었을 때 처리
+
+ function handleImportClick() {
+ // 숨겨진 <input type="file" /> 요소를 클릭
+ fileInputRef.current?.click()
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <InviteVendorsDialog
+ vendors={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ rfqId = {rfqId}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+
+ <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/tbe/table/tbe-table.tsx b/lib/tbe/table/tbe-table.tsx
new file mode 100644
index 00000000..ed323800
--- /dev/null
+++ b/lib/tbe/table/tbe-table.tsx
@@ -0,0 +1,204 @@
+"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 { 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"
+
+interface VendorsTableProps {
+ promises: Promise<[
+ Awaited<ReturnType<typeof getAllTBE>>,
+ ]>
+}
+
+export function AllTbeTable({ promises }: VendorsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+ const router = useRouter()
+
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithTbeFields> | null>(null)
+
+ // 댓글 시트 관련 state
+ const [initialComments, setInitialComments] = React.useState<TbeComment[]>([])
+ const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
+ const [selectedVendorIdForComments, setSelectedVendorIdForComments] = React.useState<number | null>(null)
+ const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
+
+ // 파일 다이얼로그 관련 state
+ const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false)
+ const [selectedVendorIdForFiles, setSelectedVendorIdForFiles] = React.useState<number | null>(null)
+ const [selectedTbeIdForFiles, setSelectedTbeIdForFiles] = React.useState<number | null>(null)
+ const [selectedRfqIdForFiles, setSelectedRfqIdForFiles] = React.useState<number | null>(null)
+
+ // 테이블 리프레시용
+ const handleRefresh = React.useCallback(() => {
+ router.refresh();
+ }, [router]);
+
+ // -----------------------------------------------------------
+ // 특정 action이 설정될 때마다 실행되는 effect
+ // -----------------------------------------------------------
+ React.useEffect(() => {
+ if (!rowAction) return
+
+ if (rowAction.type === "comments") {
+ // rowAction가 새로 세팅되면 openCommentSheet 실행
+ // row.original에 rfqId가 있다고 가정
+ openCommentSheet(
+ rowAction.row.original.vendorId ?? 0,
+ rowAction.row.original.rfqId ?? 0,
+ )
+ } else if (rowAction.type === "files") {
+ openFilesDialog(
+ rowAction.row.original.tbeId ?? 0,
+ rowAction.row.original.vendorId ?? 0,
+ rowAction.row.original.rfqId ?? 0,
+ )
+ }
+ }, [rowAction])
+
+ // -----------------------------------------------------------
+ // 댓글 시트 열기
+ // -----------------------------------------------------------
+ async function openCommentSheet(vendorId: number, rfqId: number) {
+ setInitialComments([])
+
+ 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)
+ }
+
+ setSelectedVendorIdForComments(vendorId)
+ setSelectedRfqIdForComments(rfqId)
+ setCommentSheetOpen(true)
+ }
+
+ // -----------------------------------------------------------
+ // 파일 다이얼로그 열기
+ // -----------------------------------------------------------
+ const openFilesDialog = (tbeId: number, vendorId: number, rfqId: number) => {
+ setSelectedTbeIdForFiles(tbeId)
+ setSelectedVendorIdForFiles(vendorId)
+ setSelectedRfqIdForFiles(rfqId)
+ setIsFileDialogOpen(true)
+ }
+
+ // -----------------------------------------------------------
+ // 테이블 컬럼
+ // -----------------------------------------------------------
+ const columns = React.useMemo(
+ () =>
+ getColumns({
+ setRowAction,
+ router,
+ openCommentSheet, // 필요하면 직접 호출 가능
+ openFilesDialog,
+ }),
+ [setRowAction, router]
+ )
+
+ // -----------------------------------------------------------
+ // 필터 필드
+ // -----------------------------------------------------------
+ const filterFields: DataTableFilterField<VendorWithTbeFields>[] = [
+ // 예: 표준 필터
+ ]
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorWithTbeFields>[] = [
+ { 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" },
+ ]
+
+ // -----------------------------------------------------------
+ // 테이블 생성 훅
+ // -----------------------------------------------------------
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "rfqVendorUpdated", 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}
+ >
+ <VendorsTableToolbarActions table={table} rfqId={selectedRfqIdForFiles ?? 0} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 댓글 시트 */}
+ <CommentSheet
+ currentUserId={1}
+ open={commentSheetOpen}
+ onOpenChange={setCommentSheetOpen}
+ vendorId={selectedVendorIdForComments ?? 0}
+ rfqId={selectedRfqIdForComments ?? 0} // ← 여기!
+ initialComments={initialComments}
+ />
+
+ {/* 파일 업로드/다운로드 다이얼로그 */}
+ <TBEFileDialog
+ isOpen={isFileDialogOpen}
+ onOpenChange={setIsFileDialogOpen}
+ tbeId={selectedTbeIdForFiles ?? 0}
+ vendorId={selectedVendorIdForFiles ?? 0}
+ rfqId={selectedRfqIdForFiles ?? 0} // ← 여기!
+ onRefresh={handleRefresh}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/unstable-cache.ts b/lib/unstable-cache.ts
new file mode 100644
index 00000000..c17bb9d8
--- /dev/null
+++ b/lib/unstable-cache.ts
@@ -0,0 +1,19 @@
+/**
+ * @see https://github.com/ethanniser/NextMaster/blob/main/src/lib/unstable-cache.ts
+ */
+
+import { cache } from "react"
+import { unstable_cache as next_unstable_cache } from "next/cache"
+
+// next_unstable_cache doesn't handle deduplication, so we wrap it in React's cache
+export const unstable_cache = <Inputs extends unknown[], Output>(
+ cb: (...args: Inputs) => Promise<Output>,
+ keyParts: string[],
+ options?: {
+ /**
+ * The revalidation interval in seconds.
+ */
+ revalidate?: number | false
+ tags?: string[]
+ }
+) => cache(next_unstable_cache(cb, keyParts, options))
diff --git a/lib/users/repository.ts b/lib/users/repository.ts
new file mode 100644
index 00000000..78d1668b
--- /dev/null
+++ b/lib/users/repository.ts
@@ -0,0 +1,128 @@
+// lib/users/repository.ts
+import db from '@/db/db';
+import { users, otps, type User, Role, roles, userRoles } from '@/db/schema/users';
+import { Otp } from '@/types/user';
+import { eq,and ,asc} from 'drizzle-orm';
+
+// 모든 사용자 조회
+export const getAllUsers = async (): Promise<User[]> => {
+ const usersRes = await db.select().from(users).execute();
+ return usersRes
+};
+
+export async function getRoleAssignedUsers(roleId: number) {
+ const rows = await db
+ .select()
+ .from(userRoles)
+ .where(eq(userRoles.roleId, roleId))
+ return rows.map((r) => r.userId) // [1, 2, 5, ...]
+}
+
+// ID로 사용자 조회
+export const getUserById = async (id: number): Promise<User | null> => {
+ const usersRes = await db.select().from(users).where(eq(users.id, id)).execute();
+ if (usersRes.length === 0) return null;
+
+ const user = usersRes[0];
+ return user
+};
+
+// Email로 사용자 조회
+export const getUserByEmail = async (email: string): Promise<User | null> => {
+ const usersRes = await db.select().from(users).where(eq(users.email, email)).execute();
+ if (usersRes.length === 0) return null;
+
+ const user = usersRes[0];
+ return user
+};
+
+
+// 새 사용자 생성
+export const createUser = async (name: string, email: string): Promise<User> => {
+ const usersRes = await db.insert(users).values({ name, email }).returning();
+ const user = usersRes[0];
+ return user
+};
+
+// 사용자 업데이트
+export const updateUser = async (id: number, data: Partial<User>): Promise<User | null> => {
+ const usersRes = await db.update(users).set(data).where(eq(users.id, id)).returning();
+ if (usersRes.length === 0) return null;
+ const user = usersRes[0];
+ return user
+};
+
+// 사용자 삭제
+export const deleteUser = async (id: number): Promise<boolean> => {
+ const result = await db.delete(users).where(eq(users.id, id)).execute();
+ return (result.rowCount ?? 0) > 0; // null일 경우 0으로 처리
+};
+
+
+// 새 otp 생성
+export const createOtp = async ( email: string, code:string, createdAt:Date, otpToken:string, otpExpires:Date ): Promise<Otp> => {
+ const otp = await db.insert(otps).values({ email, code, createdAt, otpToken,otpExpires }).returning();
+ return otp[0]
+};
+
+
+export const findOtpByEmail = async (email: string): Promise<Otp | null> => {
+ const [otpRecord] = await db
+ .select()
+ .from(otps)
+ .where(eq(otps.email, email))
+
+ return otpRecord ?? null
+}
+
+export const updateOtp = async (
+ email: string,
+ code: string,
+ createdAt: Date,
+ otpToken: string,
+ otpExpires: Date
+): Promise<Otp> => {
+ const rows = await db
+ .update(otps)
+ .set({
+ code,
+ createdAt,
+ otpToken,
+ otpExpires,
+ })
+ .where(eq(otps.email, email))
+ .returning();
+
+ return rows[0];
+};
+
+// Email 및 토큰으로 opt 조회
+export const getOtpByEmailAndToken = async (email: string, token:string): Promise<Otp | null> => {
+ const opts = await db.select().from(otps).where(eq(otps.email, email)).execute();
+ if (opts.length === 0) return null;
+
+ const otp = opts[0];
+ return otp
+};
+
+
+export const getOtpByEmailAndCode = async (
+ email: string,
+ code: string
+): Promise<Otp | null> => {
+
+ console.log(email, code, "db")
+
+ const [otp] = await db
+ .select()
+ .from(otps)
+ .where(
+ and(eq(otps.email, email), eq(otps.code, code))
+ );
+
+ return otp ?? 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
new file mode 100644
index 00000000..c8cfb83d
--- /dev/null
+++ b/lib/users/send-otp.ts
@@ -0,0 +1,71 @@
+"use server";
+
+import { headers } from 'next/headers';
+import { sendEmail } from '@/lib/mail/sendEmail';
+import jwt from 'jsonwebtoken';
+import { findUserByEmail, addNewOtp } from '@/lib/users/service';
+
+
+export async function sendOtpAction(email: string, lng: string) {
+ // Next.js의 headers() API로 헤더 정보를 얻을 수 있습니다.
+ const headersList = await headers();
+
+ // 호스트 정보 (request.nextUrl.host 대체)
+ const host = headersList.get('host') || 'localhost:3000';
+
+ // 사용자 조회
+ const user = await findUserByEmail(email);
+
+ if (!user) {
+ // 서버 액션에서 에러 던지면, 클라이언트 컴포넌트에서 try-catch로 잡을 수 있습니다.
+ throw new Error('User does not exist');
+ }
+
+ // 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!
+ );
+
+ // DB에 OTP 추가
+ await addNewOtp(email, otp, new Date(), token, expires);
+
+ // 이메일에서 사용할 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) {
+ // 위치 조회 실패 시 무시
+ }
+
+ // OTP 이메일 발송
+ await sendEmail({
+ to: email,
+ subject: `${otp} - SHI eVCP Sign-in Verification`,
+ template: 'otp',
+ context: {
+ name: user.name,
+ otp,
+ verificationUrl,
+ location,
+ language: lng,
+ },
+ });
+
+ // 클라이언트로 반환할 수 있는 값
+ return {
+ success: true,
+ };
+} \ No newline at end of file
diff --git a/lib/users/service.ts b/lib/users/service.ts
new file mode 100644
index 00000000..ae97beed
--- /dev/null
+++ b/lib/users/service.ts
@@ -0,0 +1,413 @@
+// lib/users/service.ts
+"use server";
+
+import { Otp } from '@/types/user';
+import { getAllUsers, createUser, getUserById, updateUser, deleteUser, getUserByEmail, createOtp,getOtpByEmailAndToken, updateOtp, findOtpByEmail ,getOtpByEmailAndCode, findAllRoles, getRoleAssignedUsers} from './repository';
+import logger from '@/lib/logger';
+import { Role, userRoles, users, userView, type User } from '@/db/schema/users';
+import { saveDocument } from '../storage';
+import { GetUsersSchema } from '../admin-users/validations';
+import { revalidateTag, unstable_cache, unstable_noStore } from 'next/cache';
+import { filterColumns } from '../filter-columns';
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm";
+import { countUsers, selectUsersWithCompanyAndRoles } from '../admin-users/repository';
+import db from "@/db/db";
+import { getErrorMessage } from "@/lib/handle-error";
+
+interface AssignUsersArgs {
+ roleId: number
+ userIds: number[]
+}
+
+
+export const fetchAllUsers = async (): Promise<User[]> => {
+ try {
+ logger.info('Fetching all users');
+ const users = await getAllUsers();
+ logger.debug({ count: users.length }, 'Fetched users successfully');
+ return users;
+ } catch (error) {
+ logger.error({ error }, 'Error fetching all users');
+ throw new Error('Failed to fetch users');
+ }
+};
+
+
+export const fetchRoleAssignedUserID = async (roleId: number) => {
+ try {
+ logger.info('Fetching all users');
+ const users = await getRoleAssignedUsers(roleId);
+ logger.debug({ count: users.length }, 'Fetched users successfully');
+ return users;
+ } catch (error) {
+ logger.error({ error }, 'Error fetching all users');
+ throw new Error('Failed to fetch users');
+ }
+};
+
+
+export const addNewUser = async (name: string, email: string): Promise<User> => {
+ try {
+ logger.info({ name, email }, 'Creating a new user');
+ const user = await createUser(name, email);
+ logger.debug({ user }, 'User created successfully');
+ return user;
+ } catch (error) {
+ logger.error({ error }, 'Error creating a new user');
+ throw new Error('Failed to create user');
+ }
+};
+
+export const findUserById = async (id: number): Promise<User | null> => {
+ try {
+ logger.info({ id }, 'Fetching user by ID');
+ const user = await getUserById(id);
+ if (!user) {
+ logger.warn({ id }, 'User not found');
+ } else {
+ logger.debug({ user }, 'User fetched successfully');
+ }
+ return user;
+ } catch (error) {
+ logger.error({ error }, 'Error fetching user by ID');
+ throw new Error('Failed to fetch user');
+ }
+};
+
+export const findUserByEmail = async (email: string): Promise<User | null> => {
+ try {
+ logger.info({ email }, 'Fetching user by Email');
+ const user = await getUserByEmail(email);
+ if (!user) {
+ logger.warn({ email }, 'User not found');
+ } else {
+ logger.debug({ user }, 'User fetched successfully');
+ }
+ return user;
+ } catch (error) {
+ logger.error({ error }, 'Error fetching user by ID');
+ throw new Error('Failed to fetch user');
+ }
+};
+
+export const modifyUser = async (id: number, data: Partial<User>): Promise<User | null> => {
+ try {
+ logger.info({ id, data }, 'Updating user');
+ const user = await updateUser(id, data);
+ if (!user) {
+ logger.warn({ id }, 'User not found for update');
+ } else {
+ logger.debug({ user }, 'User updated successfully');
+ }
+ return user;
+ } catch (error) {
+ logger.error({ error }, 'Error updating user');
+ throw new Error('Failed to update user');
+ }
+};
+
+export const removeUser = async (id: number): Promise<boolean> => {
+ try {
+ logger.info({ id }, 'Deleting user');
+ const success = await deleteUser(id);
+ if (success) {
+ logger.debug({ id }, 'User deleted successfully');
+ } else {
+ logger.warn({ id }, 'User not found for deletion');
+ }
+ return success;
+ } catch (error) {
+ logger.error({ error }, 'Error deleting user');
+ throw new Error('Failed to delete user');
+ }
+};
+
+export const addNewOtp = async (
+ email: string,
+ code: string,
+ createdAt: Date,
+ otpToken: string,
+ otpExpires: Date
+): Promise<Otp> => {
+ try {
+ logger.info({ email }, 'Creating or updating an OTP record');
+
+ // 1) 먼저 email로 Otp가 있는지 조회
+ const existingOtp = await findOtpByEmail(email);
+
+ // 2) 이미 있으면 update
+ if (existingOtp) {
+ const otp = await updateOtp(email, code, createdAt, otpToken, otpExpires);
+ logger.debug({ otp }, 'OTP updated successfully');
+ return otp;
+ }
+ // 3) 없으면 새로 생성
+ else {
+ const otp = await createOtp(email, code, createdAt, otpToken, otpExpires);
+ logger.debug({ otp }, 'OTP created successfully');
+ return otp;
+ }
+ } catch (error) {
+ logger.error({ error }, 'Error creating or updating OTP');
+ throw new Error('Failed to create or update OTP');
+ }
+};
+
+export const findOtpByEmailandToken = async (email: string, otpToken: string): Promise<Otp | null> => {
+ try {
+ logger.info({ email }, 'Fetching otp by Email');
+ const otp = await getOtpByEmailAndToken(email, otpToken);
+ if (!otp) {
+ logger.warn({ email }, 'Otp not found');
+ } else {
+ logger.debug({ otp }, 'Otp fetched successfully');
+ }
+ return otp;
+ } catch (error) {
+ logger.error({ error }, 'Error fetching user by ID');
+ throw new Error('Failed to fetch user');
+ }
+};
+
+
+export async function findEmailandOtp(email: string, code: string) {
+ try {
+ // 1) otp 조회
+ const otpRecord: Otp | null = await getOtpByEmailAndCode(email, code)
+ if (!otpRecord) {
+ return null
+ }
+
+ // 2) 사용자 정보 추가로 조회
+ const userRecord: User | null = await getUserByEmail(email)
+ if (!userRecord) {
+ return null
+ }
+
+ // 3) 필요한 형태로 "통합된 객체"를 반환
+ return {
+ otpExpires: otpRecord.otpExpires,
+ 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
+ const userId = Number(formData.get("userId"))
+ const name = formData.get("name") as string
+ const email = formData.get("email") as string
+
+ // 2) 기본적인 유효성 검증
+ if (!file) {
+ throw new Error("No file found in the FormData.")
+ }
+ if (!userId) {
+ throw new Error("userId is required.")
+ }
+
+ try {
+ // 3) 파일 저장 (해시 생성)
+ const directory = './public/profiles'
+ const { hashedFileName } = await saveDocument(file, directory)
+
+ // 4) DB 업데이트
+ const imageUrl = hashedFileName
+ const data = { name, email, imageUrl }
+ const user = await updateUser(userId, data)
+ if (!user) {
+ // updateUser가 null을 반환하면, DB 업데이트 실패 혹은 해당 유저가 없음
+ throw new Error(`User with id=${userId} not found or update failed.`)
+ }
+
+ // 5) 성공 시 성공 정보 반환
+ return { success: true, user }
+ } catch (err: any) {
+ // DB 업데이트 중 발생하는 에러나 saveDocument 내부 에러 등을 처리
+ console.error("[updateUserProfileImage] Error:", err)
+ throw new Error(err.message ?? "Failed to update user profile.")
+ }
+}
+
+export async function getUsersEVCP(input: GetUsersSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // (1) advancedWhere
+ const advancedWhere = filterColumns({
+ table: userView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // (2) globalWhere
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(userView.user_name, s),
+ ilike(userView.user_email, s),
+ ilike(userView.company_name, s)
+ );
+ }
+
+ // (3) 디폴트 domainWhere = eq(userView.domain, "partners")
+ // 다만, 사용자가 이미 domain 필터를 줬다면 적용 X
+ let domainWhere;
+ const hasDomainFilter = input.filters?.some((f) => f.id === "user_domain");
+ if (!hasDomainFilter) {
+ domainWhere = eq(userView.user_domain, "evcp");
+ }
+
+ // (4) 최종 where
+ const finalWhere = and(advancedWhere, globalWhere, domainWhere);
+
+ // (5) 정렬
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(userView[item.id]) : asc(userView[item.id])
+ )
+ : [desc(users.createdAt)];
+
+ // ...
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectUsersWithCompanyAndRoles(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countUsers(tx, finalWhere);
+ 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: ["users"],
+ }
+ )();
+}
+
+export async function getAllRoles(): Promise<Role[]> {
+ try {
+ return await findAllRoles();
+ } catch (err) {
+ throw new Error("Failed to get roles");
+ }
+}
+
+
+export async function getUsersAll(input: GetUsersSchema, domain: string) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // (1) advancedWhere
+ const advancedWhere = filterColumns({
+ table: userView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // (2) globalWhere
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(userView.user_name, s),
+ ilike(userView.user_email, s),
+ ilike(userView.company_name, s)
+ );
+ }
+
+ // (3) domainWhere - 무조건 들어가야 하는 domain 조건
+ const domainWhere = eq(userView.user_domain, domain);
+
+ // (4) 최종 where
+ // domainWhere과 advancedWhere, globalWhere를 모두 and로 묶는다.
+ // (globalWhere가 존재하지 않을 수 있으니, and() 호출 시 undefined를 자동 무시할 수도 있음)
+ const finalWhere = and(domainWhere, advancedWhere, globalWhere);
+
+ // (5) 정렬
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(userView[item.id]) : asc(userView[item.id])
+ )
+ : [desc(users.createdAt)];
+
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectUsersWithCompanyAndRoles(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countUsers(tx, finalWhere);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+ return { data, pageCount };
+ } catch (err) {
+ return { data: [], pageCount: 0 };
+ }
+ },
+ // (6) 캐시 종속성 배열에 domain도 추가
+ [JSON.stringify(input), domain],
+ {
+ revalidate: 3600,
+ tags: ["users"],
+ }
+ )();
+}
+
+
+export async function assignUsersToRole(roleId: number, userIds: number[]) {
+ unstable_noStore(); // 캐싱 방지(Next.js 서버 액션용)
+ try{
+ await db.transaction(async (tx) => {
+ // 1) 기존 userRoles 레코드 삭제
+ await tx.delete(userRoles).where(eq(userRoles.roleId, roleId))
+
+ // 2) 새로 넣기
+ if (userIds.length > 0) {
+ await tx.insert(userRoles).values(
+ userIds.map((uid) => ({ userId: uid, roleId }))
+ )
+ }
+ })
+ revalidateTag("users");
+ revalidateTag("roles");
+
+ return { data: null, error: null };
+ } catch (err){
+ return { data: null, error: getErrorMessage(err) };
+
+ }
+
+}
diff --git a/lib/users/table/assign-roles-dialog.tsx b/lib/users/table/assign-roles-dialog.tsx
new file mode 100644
index 00000000..003f6500
--- /dev/null
+++ b/lib/users/table/assign-roles-dialog.tsx
@@ -0,0 +1,194 @@
+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 {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Check, ChevronsUpDown, Loader, UserRoundPlus } from "lucide-react"
+import { cn } from "@/lib/utils"
+import { toast } from "sonner"
+
+import { Textarea } from "@/components/ui/textarea"
+import { Company } from "@/db/schema/companies"
+import { getAllCompanies } from "@/lib/admin-users/service"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandEmpty,
+} from "@/components/ui/command"
+import { assignRolesToUsers, getAllRoleView } from "@/lib/roles/services"
+import { RoleView } from "@/db/schema/users"
+import { type UserView } from "@/db/schema/users"
+import { type Row } from "@tanstack/react-table"
+import { createRoleAssignmentSchema, CreateRoleAssignmentSchema, createRoleSchema, CreateRoleSchema } from "@/lib/roles/validations"
+import { MultiSelect } from "@/components/ui/multi-select"
+
+interface AssignRoleDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ users: Row<UserView>["original"][]
+
+}
+
+
+export function AssignRoleDialog({ users }: AssignRoleDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [isAddPending, startAddTransition] = React.useTransition()
+ const [roles, setRoles] = React.useState<RoleView[]>([]) // 회사 목록
+ const [loading, setLoading] = React.useState(false)
+
+ const partnersRoles = roles.filter(v => v.domain === "partners")
+ const evcpRoles = roles.filter(v => v.domain === "evcp")
+
+
+ React.useEffect(() => {
+ getAllRoleView("evcp").then((res) => {
+ setRoles(res)
+ })
+ }, [])
+
+
+ const form = useForm<CreateRoleAssignmentSchema>({
+ resolver: zodResolver(createRoleAssignmentSchema),
+ defaultValues: {
+ evcpRoles: [],
+ },
+ })
+
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ setOpen(nextOpen)
+ }
+
+ const evcpUsers = users.filter(v => v.user_domain === "evcp");
+
+
+ async function onSubmit(data: CreateRoleAssignmentSchema) {
+ console.log(data.evcpRoles.map((v)=>Number(v)))
+ startAddTransition(async () => {
+
+
+ // if(partnerUsers.length>0){
+ // const result = await assignRolesToUsers( partnerUsers.map(v=>v.user_id) ,data.partnersRoles)
+
+ // if (result.error) {
+ // toast.error(`에러: ${result.error}`)
+ // return
+ // }
+ // }
+
+ if (evcpUsers.length > 0) {
+ const result = await assignRolesToUsers( data.evcpRoles.map((v)=>Number(v)), evcpUsers.map(v => v.user_id))
+
+ if (result.error) {
+ toast.error(`에러: ${result.error}`)
+ return
+ }
+ }
+
+ form.reset()
+ setOpen(false)
+ toast.success("Role assgined")
+ })
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ <UserRoundPlus className="mr-2 size-4" aria-hidden="true" />
+ Assign Role ({users.length})
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Assign Roles to {evcpUsers.length} Users</DialogTitle>
+ <DialogDescription>
+ Role을 Multi-select 하시기 바랍니다.
+ </DialogDescription>
+ </DialogHeader>
+
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)}>
+ <div className="space-y-4 py-4">
+ {/* evcp 롤 선택 */}
+ {evcpUsers.length > 0 &&
+ <FormField
+ control={form.control}
+ name="evcpRoles"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>eVCP Role</FormLabel>
+ <FormControl>
+ <MultiSelect
+ options={evcpRoles.map((role) => ({ value: String(role.id), label: role.name }))}
+ onValueChange={(values) => {
+ field.onChange(values);
+ }}
+
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ }
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isAddPending}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={form.formState.isSubmitting || isAddPending}
+ >
+ {isAddPending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Assgin
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/users/table/users-table-columns.tsx b/lib/users/table/users-table-columns.tsx
new file mode 100644
index 00000000..c0eb9520
--- /dev/null
+++ b/lib/users/table/users-table-columns.tsx
@@ -0,0 +1,154 @@
+"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 { userRoles, type UserView } from "@/db/schema/users"
+
+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 { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import { UserWithCompanyAndRoles } from "@/types/user"
+import { getErrorMessage } from "@/lib/handle-error"
+
+import { modifiUser } from "@/lib/admin-users/service"
+import { toast } from "sonner"
+
+import { euserColumnsConfig } from "@/config/euserColumnsConfig"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { MultiSelect } from "@/components/ui/multi-select"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<UserView> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<UserView>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<UserView> = {
+ 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,
+ }
+
+
+
+ const groupMap: Record<string, ColumnDef<UserView>[]> = {}
+
+ euserColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<UserView> = {
+ 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 === "created_at") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ if (cfg.id === "roles") {
+ const roleValues = row.original.roles;
+ return (
+ <div className="flex flex-wrap gap-1">
+ {roleValues.map((v) => (
+ v === null?"":
+ <Badge key={v} variant="outline">
+ {v}
+ </Badge>
+ ))}
+ </div>
+ );
+ }
+
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<UserView>[] = []
+
+ // 순서를 고정하고 싶다면 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,
+ ]
+} \ No newline at end of file
diff --git a/lib/users/table/users-table-toolbar-actions.tsx b/lib/users/table/users-table-toolbar-actions.tsx
new file mode 100644
index 00000000..106953a6
--- /dev/null
+++ b/lib/users/table/users-table-toolbar-actions.tsx
@@ -0,0 +1,61 @@
+"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 { UserView } from "@/db/schema/users"
+import { DeleteUsersDialog } from "@/lib/admin-users/table/delete-ausers-dialog"
+import { AssignRoleDialog } from "./assign-roles-dialog"
+
+interface UsersTableToolbarActionsProps {
+ table: Table<UserView>
+}
+
+export function UsersTableToolbarActions({ table }: UsersTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+
+ function handleImportClick() {
+ // 숨겨진 <input type="file" /> 요소를 클릭
+ fileInputRef.current?.click()
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <AssignRoleDialog
+ users={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ />
+ ) : null}
+
+
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "roles",
+ 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/users/table/users-table.tsx b/lib/users/table/users-table.tsx
new file mode 100644
index 00000000..53cb961e
--- /dev/null
+++ b/lib/users/table/users-table.tsx
@@ -0,0 +1,150 @@
+"use client"
+
+import * as React from "react"
+import { userRoles , type UserView} from "@/db/schema/users"
+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 { DataTableToolbar } from "@/components/data-table/data-table-toolbar"
+
+import type {
+ getAllRoles, getUsersEVCP
+} from "@/lib//users/service"
+import { getColumns } from "./users-table-columns"
+import { UsersTableToolbarActions } from "./users-table-toolbar-actions"
+
+
+
+interface UsersTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getUsersEVCP>>,
+ Record<number, number>,
+ Awaited<ReturnType<typeof getAllRoles>>
+ ]
+ >
+}
+type RoleCounts = Record<string, number>
+
+export function UserTable({ promises }: UsersTableProps) {
+
+ const [{ data, pageCount }, roleCountsRaw, roles] =
+ React.use(promises)
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<UserView> | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ const roleCounts = roleCountsRaw as RoleCounts
+
+
+ /**
+ * 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<UserView>[] = [
+ {
+ id: "user_email",
+ label: "Email",
+ placeholder: "Filter email...",
+ },
+
+ ]
+
+ /**
+ * 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<UserView>[] = [
+ {
+ id: "user_name",
+ label: "User Name",
+ type: "text",
+ },
+ {
+ id: "user_email",
+ label: "Email",
+ type: "text",
+ },
+
+ {
+ id: "roles",
+ label: "Roles",
+ type: "multi-select",
+ options: roles.map((role) => {
+ return {
+ label: toSentenceCase(role.name),
+ value: role.id,
+ count: roleCounts[role.id], // 이 값이 undefined인지 확인
+ };
+ }),
+ },
+ {
+ id: "created_at",
+ label: "Created at",
+ type: "date",
+ },
+ ]
+
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "created_at", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => `${originalRow.user_id}`,
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <UsersTableToolbarActions table={table}/>
+ </DataTableAdvancedToolbar>
+
+ </DataTable>
+
+
+ </>
+ )
+}
diff --git a/lib/users/verifyOtp.ts b/lib/users/verifyOtp.ts
new file mode 100644
index 00000000..5de76f90
--- /dev/null
+++ b/lib/users/verifyOtp.ts
@@ -0,0 +1,28 @@
+// lib/users/verifyOtp.ts
+import { findEmailandOtp } from '@/lib/users/service'
+
+// "email과 code가 맞으면 유저 정보, 아니면 null" 형태로 작성
+export async function verifyOtp(email: string, code: string) {
+ // DB에서 email과 code가 맞는지, 만료 안됐는지 검증
+ const otpRecord = await findEmailandOtp(email, code)
+ if (!otpRecord) {
+ return null
+ }
+
+ // 만료 체크
+ if (otpRecord.otpExpires && otpRecord.otpExpires < new Date()) {
+ 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,
+ }
+} \ No newline at end of file
diff --git a/lib/users/verifyToken.ts b/lib/users/verifyToken.ts
new file mode 100644
index 00000000..745a1052
--- /dev/null
+++ b/lib/users/verifyToken.ts
@@ -0,0 +1,38 @@
+"use server";
+
+import jwt from 'jsonwebtoken';
+import { findOtpByEmailandToken } from '@/lib/users/service';
+
+export async function verifyTokenAction(token: string) {
+ if (!token) {
+ // 토큰이 없으면 바로 false 반환
+ return { valid: false };
+ }
+
+ try {
+ // 토큰 검증
+ const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { email: string; otp: string };
+ const { email } = decoded;
+
+ // DB에서 OTP 정보 조회
+ const otp = await findOtpByEmailandToken(email, token);
+ if (!otp) {
+ // 해당하는 OTP/토큰이 없으면 invalid
+ return { valid: false };
+ }
+
+ // 토큰 동일성 및 만료 확인
+ if (otp.otpToken !== token || (otp.otpExpires && otp.otpExpires < new Date())) {
+ return { valid: false };
+ }
+
+ // 여기까지 통과하면 valid
+ return {
+ valid: true,
+ email,
+ };
+ } catch (error) {
+ // JWT 검증 실패
+ return { valid: false };
+ }
+} \ No newline at end of file
diff --git a/lib/utils.ts b/lib/utils.ts
new file mode 100644
index 00000000..2eca9285
--- /dev/null
+++ b/lib/utils.ts
@@ -0,0 +1,75 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
+
+export function formatDate(
+ date: Date | string | number,
+ locale: string = "en-US",
+ opts: Intl.DateTimeFormatOptions = {},
+ includeTime: boolean = false
+) {
+ return new Intl.DateTimeFormat(locale, {
+ month: opts.month ?? "long",
+ day: opts.day ?? "numeric",
+ year: opts.year ?? "numeric",
+ // Add time options when includeTime is true
+ ...(includeTime && {
+ hour: opts.hour ?? "2-digit",
+ minute: opts.minute ?? "2-digit",
+ second: opts.second ?? "2-digit",
+ hour12: opts.hour12 ?? false, // Use 24-hour format by default
+ }),
+ ...opts, // This allows overriding any of the above defaults
+ }).format(new Date(date))
+}
+
+// Alternative: Create a separate function for date and time
+export function formatDateTime(
+ date: Date | string | number,
+ locale: string = "en-US",
+ opts: Intl.DateTimeFormatOptions = {}
+) {
+ return new Intl.DateTimeFormat(locale, {
+ month: opts.month ?? "long",
+ day: opts.day ?? "numeric",
+ year: opts.year ?? "numeric",
+ hour: opts.hour ?? "2-digit",
+ minute: opts.minute ?? "2-digit",
+ second: opts.second ?? "2-digit",
+ hour12: opts.hour12 ?? false,
+ ...opts,
+ }).format(new Date(date))
+}
+
+export function toSentenceCase(str: string) {
+ return str
+ .replace(/_/g, " ")
+ .replace(/([A-Z])/g, " $1")
+ .toLowerCase()
+ .replace(/^\w/, (c) => c.toUpperCase())
+ .replace(/\s+/g, " ")
+ .trim()
+}
+
+/**
+ * @see https://github.com/radix-ui/primitives/blob/main/packages/core/primitive/src/primitive.tsx
+ */
+export function composeEventHandlers<E>(
+ originalEventHandler?: (event: E) => void,
+ ourEventHandler?: (event: E) => void,
+ { checkForDefaultPrevented = true } = {}
+) {
+ return function handleEvent(event: E) {
+ originalEventHandler?.(event)
+
+ if (
+ checkForDefaultPrevented === false ||
+ !(event as unknown as Event).defaultPrevented
+ ) {
+ return ourEventHandler?.(event)
+ }
+ }
+}
diff --git a/lib/vendor-data/services.ts b/lib/vendor-data/services.ts
new file mode 100644
index 00000000..7f0c47c1
--- /dev/null
+++ b/lib/vendor-data/services.ts
@@ -0,0 +1,99 @@
+"use server";
+
+import db from "@/db/db"
+import { items } from "@/db/schema/items"
+import { projects } from "@/db/schema/projects"
+import { Tag, tags } from "@/db/schema/vendorData"
+import { eq } from "drizzle-orm"
+import { revalidateTag, unstable_noStore } from "next/cache";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { contractItems, contracts } from "@/db/schema/contract";
+
+// 스키마 import
+
+export interface ProjectWithContracts {
+ projectId: number
+ projectCode: string
+ projectName: string
+ projectType: string
+
+ contracts: {
+ contractId: number
+ contractNo: string
+ contractName: string
+ // contractName 등 필요한 필드 추가
+ packages: {
+ itemId: number
+ itemName: string
+ }[]
+ }[]
+}
+
+
+export async function getVendorProjectsAndContracts(
+ vendorId: number
+): Promise<ProjectWithContracts[]> {
+ const rows = await db
+ .select({
+ projectId: projects.id,
+ projectCode: projects.code,
+ projectName: projects.name,
+ projectType: projects.type,
+
+ contractId: contracts.id,
+ contractNo: contracts.contractNo,
+ contractName: contracts.contractName,
+
+ itemId: contractItems.id,
+ itemName: items.itemName,
+ })
+ .from(contracts)
+ .innerJoin(projects, eq(contracts.projectId, projects.id))
+ .innerJoin(contractItems, eq(contractItems.contractId, contracts.id))
+ .innerJoin(items, eq(contractItems.itemId, items.id))
+ .where(eq(contracts.vendorId, vendorId))
+
+ const projectMap = new Map<number, ProjectWithContracts>()
+
+ for (const row of rows) {
+ // 1) 프로젝트 그룹 찾기
+ let projectEntry = projectMap.get(row.projectId)
+ if (!projectEntry) {
+ // 새 프로젝트 항목 생성
+ projectEntry = {
+ projectId: row.projectId,
+ projectCode: row.projectCode,
+ projectName: row.projectName,
+ projectType: row.projectType,
+ contracts: [],
+ }
+ projectMap.set(row.projectId, projectEntry)
+ }
+
+ // 2) 프로젝트 안에서 계약(contractId) 찾기
+ let contractEntry = projectEntry.contracts.find(
+ (c) => c.contractId === row.contractId
+ )
+ if (!contractEntry) {
+ // 새 계약 항목
+ contractEntry = {
+ contractId: row.contractId,
+ contractNo: row.contractNo,
+ contractName: row.contractName,
+ packages: [],
+ }
+ projectEntry.contracts.push(contractEntry)
+ }
+
+ // 3) 계약의 packages 배열에 아이템 추가
+ contractEntry.packages.push({
+ itemId: row.itemId,
+ itemName: row.itemName,
+ })
+ }
+
+ return Array.from(projectMap.values())
+}
+
+
+// 1) 태그 조회
diff --git a/lib/vendor-document-list/repository.ts b/lib/vendor-document-list/repository.ts
new file mode 100644
index 00000000..43adf7ca
--- /dev/null
+++ b/lib/vendor-document-list/repository.ts
@@ -0,0 +1,44 @@
+import db from "@/db/db";
+import { documentStagesView } from "@/db/schema/vendorDocu";
+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 selectVendorDocuments(
+ 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(documentStagesView)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+/** 총 개수 count */
+export async function countVendorDocuments(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(documentStagesView).where(where);
+ return res[0]?.count ?? 0;
+}
diff --git a/lib/vendor-document-list/service.ts b/lib/vendor-document-list/service.ts
new file mode 100644
index 00000000..75c9b6cd
--- /dev/null
+++ b/lib/vendor-document-list/service.ts
@@ -0,0 +1,284 @@
+"use server"
+
+import { eq, SQL } from "drizzle-orm"
+import db from "@/db/db"
+import { documents, documentStagesView, issueStages } from "@/db/schema/vendorDocu"
+import { contracts } from "@/db/schema/vendorData"
+import { GetVendorDcoumentsSchema } from "./validations"
+import { unstable_cache } from "@/lib/unstable-cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { getErrorMessage } from "@/lib/handle-error";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm";
+import { countVendorDocuments, selectVendorDocuments } from "./repository"
+import path from "path";
+import fs from "fs/promises";
+import { v4 as uuidv4 } from "uuid"
+import { z } from "zod"
+import { revalidateTag, unstable_noStore ,revalidatePath} from "next/cache";
+
+/**
+ * 특정 vendorId에 속한 문서 목록 조회
+ */
+export async function getVendorDocuments(input: GetVendorDcoumentsSchema, id: number) {
+
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: documentStagesView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(documentStagesView.title, s), ilike(documentStagesView.docNumber, s)
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ const finalWhere = and(advancedWhere, globalWhere, eq(documentStagesView.contractId, id));
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(documentStagesView[item.id]) : asc(documentStagesView[item.id])
+ )
+ : [asc(documentStagesView.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectVendorDocuments(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countVendorDocuments(tx, finalWhere);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input), String(id)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: [`vendor-docuemnt-list-${id}`],
+ }
+ )();
+}
+
+
+// 입력 스키마 정의
+const createDocumentSchema = z.object({
+ docNumber: z.string().min(1, "Document number is required"),
+ title: z.string().min(1, "Title is required"),
+ status: z.string(),
+ stages: z.array(z.string()).min(1, "At least one stage is required"),
+ contractId: z.number().positive("Contract ID is required")
+});
+
+export type CreateDocumentInputType = z.infer<typeof createDocumentSchema>;
+
+export async function createDocument(input: CreateDocumentInputType) {
+ try {
+ // 입력 유효성 검증
+ const validatedData = createDocumentSchema.parse(input);
+
+ // 트랜잭션 사용하여 문서와 스테이지 동시 생성
+ return await db.transaction(async (tx) => {
+ // 1. 문서 생성
+ const [newDocument] = await tx
+ .insert(documents)
+ .values({
+ contractId: validatedData.contractId,
+ docNumber: validatedData.docNumber,
+ title: validatedData.title,
+ status: validatedData.status,
+ // issuedDate는 선택적으로 추가 가능
+ })
+ .returning({ id: documents.id });
+
+ // 2. 스테이지 생성 (문서 ID 연결)
+ const stageValues = validatedData.stages.map(stageName => ({
+ documentId: newDocument.id,
+ stageName: stageName,
+ // planDate, actualDate는 나중에 설정 가능
+ }));
+
+ // 스테이지 배열 삽입
+ await tx.insert(issueStages).values(stageValues);
+
+ // 성공 결과 반환
+ return {
+ success: true,
+ documentId: newDocument.id,
+ message: "Document and stages created successfully"
+ };
+ });
+ } catch (error) {
+ console.error("Error creating document:", error);
+
+ // Zod 유효성 검사 에러 처리
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ message: "Validation failed",
+ errors: error.errors
+ };
+ }
+
+ // 기타 에러 처리
+ return {
+ success: false,
+ message: "Failed to create document"
+ };
+ }
+}
+
+// 캐시 무효화 함수
+export async function invalidateDocumentCache(contractId: number) {
+ revalidatePath(`/partners/document-list/${contractId}`);
+ // 추가로 tag 기반 캐시도 무효화할 수 있음
+ revalidateTag(`vendor-docuemnt-list-${contractId}`);
+}
+
+const removeDocumentsSchema = z.object({
+ ids: z.array(z.number()).min(1, "At least one document ID is required")
+});
+export type RemoveDocumentsInputType = z.infer<typeof removeDocumentsSchema>;
+
+export async function removeDocuments(input: RemoveDocumentsInputType) {
+ try {
+ // 입력 유효성 검증
+ const validatedData = removeDocumentsSchema.parse(input);
+
+ // 먼저 삭제할 문서의 contractId를 일반 select 쿼리로 가져옴
+ const [result] = await db
+ .select({ contractId: documents.contractId })
+ .from(documents)
+ .where(eq(documents.id, validatedData.ids[0]))
+ .limit(1);
+
+ const contractId = result?.contractId;
+
+ // 트랜잭션 사용하여 문서 삭제
+ await db.transaction(async (tx) => {
+ // documents 테이블에서 삭제 (cascade 옵션으로 연결된 issueStages도 함께 삭제)
+ await tx
+ .delete(documents)
+ .where(inArray(documents.id, validatedData.ids));
+ });
+
+ // 캐시 무효화
+ if (contractId) {
+ await invalidateDocumentCache(contractId);
+ }
+
+ return { success: true };
+ } catch (error) {
+ console.error("Error removing documents:", error);
+
+ // Zod 유효성 검사 에러 처리
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ error: "Validation failed: " + error.errors.map(e => e.message).join(', ')
+ };
+ }
+
+ // 기타 에러 처리
+ return {
+ success: false,
+ error: "Failed to remove documents"
+ };
+ }
+}
+
+// 입력 스키마 정의
+const modifyDocumentSchema = z.object({
+ id: z.number().positive("Document ID is required"),
+ contractId: z.number().positive("Contract ID is required"),
+ docNumber: z.string().min(1, "Document number is required"),
+ title: z.string().min(1, "Title is required"),
+ status: z.string().min(1, "Status is required"),
+ description: z.string().optional(),
+ remarks: z.string().optional()
+});
+
+export type ModifyDocumentInputType = z.infer<typeof modifyDocumentSchema>;
+
+/**
+ * 문서 정보 수정 서버 액션
+ */
+export async function modifyDocument(input: ModifyDocumentInputType) {
+ try {
+ // 입력 유효성 검증
+ const validatedData = modifyDocumentSchema.parse(input);
+
+ // 업데이트할 문서 데이터 준비
+ const updateData = {
+ docNumber: validatedData.docNumber,
+ title: validatedData.title,
+ status: validatedData.status,
+ description: validatedData.description,
+ remarks: validatedData.remarks,
+ updatedAt: new Date() // 수정 시간 업데이트
+ };
+
+ // 트랜잭션 사용하여 문서 업데이트
+ const [updatedDocument] = await db.transaction(async (tx) => {
+ // documents 테이블 업데이트
+ return tx
+ .update(documents)
+ .set(updateData)
+ .where(eq(documents.id, validatedData.id))
+ .returning({ id: documents.id });
+ });
+
+ // 문서가 존재하지 않는 경우 처리
+ if (!updatedDocument) {
+ return {
+ success: false,
+ error: "Document not found"
+ };
+ }
+
+ // 캐시 무효화
+ await invalidateDocumentCache(validatedData.contractId);
+
+ // 성공 결과 반환
+ return {
+ success: true,
+ documentId: updatedDocument.id,
+ message: "Document updated successfully"
+ };
+
+ } catch (error) {
+ console.error("Error updating document:", error);
+
+ // Zod 유효성 검사 에러 처리
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ error: "Validation failed: " + error.errors.map(e => e.message).join(', ')
+ };
+ }
+
+ // 기타 에러 처리
+ return {
+ success: false,
+ error: getErrorMessage(error) || "Failed to update document"
+ };
+ }
+}
diff --git a/lib/vendor-document-list/table/add-doc-dialog.tsx b/lib/vendor-document-list/table/add-doc-dialog.tsx
new file mode 100644
index 00000000..b108721c
--- /dev/null
+++ b/lib/vendor-document-list/table/add-doc-dialog.tsx
@@ -0,0 +1,299 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { Plus, X } from "lucide-react"
+import { useRouter } from "next/navigation"
+
+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 {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { useToast } from "@/hooks/use-toast"
+import { createDocument, CreateDocumentInputType, invalidateDocumentCache } from "../service"
+
+// Zod 스키마 정의 - 빈 문자열 방지 로직 추가
+const createDocumentSchema = z.object({
+ docNumber: z.string().min(1, "Document number is required"),
+ title: z.string().min(1, "Title is required"),
+ stages: z.array(z.string().min(1, "Stage name cannot be empty"))
+ .min(1, "At least one stage is required")
+ .refine(stages => !stages.some(stage => stage.trim() === ""), {
+ message: "Stage names cannot be empty"
+ })
+});
+
+type CreateDocumentSchema = z.infer<typeof createDocumentSchema>;
+
+interface AddDocumentListDialogProps {
+ projectType: "ship" | "plant";
+ contractId: number;
+}
+
+export function AddDocumentListDialog({ projectType, contractId }: AddDocumentListDialogProps) {
+ const [open, setOpen] = React.useState(false);
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
+ const router = useRouter();
+ const { toast } = useToast()
+
+ // 기본 스테이지 설정
+ const defaultStages = projectType === "ship"
+ ? ["For Approval", "For Working"]
+ : [""];
+
+ // react-hook-form 설정
+ const form = useForm<CreateDocumentSchema>({
+ resolver: zodResolver(createDocumentSchema),
+ defaultValues: {
+ docNumber: "",
+ title: "",
+ stages: defaultStages
+ },
+ });
+
+ // 식물 유형일 때 단계 추가 기능
+ const addStage = () => {
+ const currentStages = form.getValues().stages;
+ form.setValue('stages', [...currentStages, ""], { shouldValidate: true });
+ };
+
+ // 식물 유형일 때 단계 제거 기능
+ const removeStage = (index: number) => {
+ const currentStages = form.getValues().stages;
+ const newStages = currentStages.filter((_, i) => i !== index);
+ form.setValue('stages', newStages, { shouldValidate: true });
+ };
+
+ async function onSubmit(data: CreateDocumentSchema) {
+ try {
+ setIsSubmitting(true);
+
+ // 빈 문자열 필터링 (추가 안전장치)
+ const filteredStages = data.stages.filter(stage => stage.trim() !== "");
+
+ if (filteredStages.length === 0) {
+ toast({
+ title: "Error",
+ description: "At least one valid stage name is required",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ // 서버 액션 호출 - status를 "pending"으로 설정
+ const result = await createDocument({
+ ...data,
+ stages: filteredStages, // 필터링된 단계 사용
+ status: "pending", // status 필드 추가
+ contractId, // 계약 ID 추가
+ } as CreateDocumentInputType);
+
+ if (result.success) {
+ // 성공 시 캐시 무효화
+ await invalidateDocumentCache(contractId);
+
+ // 토스트 메시지
+ toast({
+ title: "Success",
+ description: "Document created successfully",
+ variant: "default",
+ });
+
+ // 모달 닫기 및 폼 리셋
+ form.reset();
+ setOpen(false);
+
+ router.refresh();
+ } else {
+ // 실패 시 에러 토스트
+ toast({
+ title: "Error",
+ description: result.message || "Failed to create document",
+ variant: "destructive",
+ });
+ }
+ } catch (error) {
+ console.error('Error creating document:', error);
+ toast({
+ title: "Error",
+ description: "An unexpected error occurred",
+ variant: "destructive",
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ // 제출 전 유효성 검사
+ const validateBeforeSubmit = async () => {
+ // 빈 스테이지 검사
+ const stages = form.getValues().stages;
+ const hasEmptyStage = stages.some(stage => stage.trim() === "");
+
+ if (hasEmptyStage) {
+ form.setError("stages", {
+ type: "manual",
+ message: "Stage names cannot be empty"
+ });
+ return false;
+ }
+
+ return true;
+ };
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset({
+ docNumber: "",
+ title: "",
+ stages: defaultStages
+ });
+ }
+ setOpen(nextOpen);
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ {/* 모달을 열기 위한 버튼 */}
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ <Plus className="size-4 mr-1"/>
+ Add Document
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>Create New Document</DialogTitle>
+ <DialogDescription>
+ 새 문서 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit, async (errors) => {
+ // 추가 유효성 검사 수행
+ console.error("Form errors:", errors);
+ const stages = form.getValues().stages;
+ if (stages.some(stage => stage.trim() === "")) {
+ toast({
+ title: "Error",
+ description: "Stage names cannot be empty",
+ variant: "destructive",
+ });
+ }
+ })} className="space-y-4">
+ {/* 문서 번호 필드 */}
+ <FormField
+ control={form.control}
+ name="docNumber"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Document Number</FormLabel>
+ <FormControl>
+ <Input placeholder="Enter document number" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 문서 제목 필드 */}
+ <FormField
+ control={form.control}
+ name="title"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Title</FormLabel>
+ <FormControl>
+ <Input placeholder="Enter document title" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 스테이지 섹션 */}
+ <div>
+ <div className="flex items-center justify-between mb-2">
+ <FormLabel>Stages</FormLabel>
+ {projectType === "plant" && (
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={addStage}
+ className="h-8 px-2"
+ >
+ <Plus className="h-4 w-4 mr-1" /> Add Stage
+ </Button>
+ )}
+ </div>
+
+ {form.watch("stages").map((stage, index) => (
+ <div key={index} className="flex items-center gap-2 mb-2">
+ <FormField
+ control={form.control}
+ name={`stages.${index}`}
+ render={({ field }) => (
+ <FormItem className="flex-1">
+ <FormControl>
+ <Input
+ placeholder="Enter stage name"
+ {...field}
+ disabled={projectType === "ship"}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {projectType === "plant" && index > 0 && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeStage(index)}
+ className="h-8 w-8 p-0"
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ ))}
+ <FormMessage>
+ {form.formState.errors.stages?.message}
+ </FormMessage>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={isSubmitting || form.formState.isSubmitting}
+ >
+ {isSubmitting ? "Creating..." : "Create"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/table/delete-docs-dialog.tsx b/lib/vendor-document-list/table/delete-docs-dialog.tsx
new file mode 100644
index 00000000..8813c742
--- /dev/null
+++ b/lib/vendor-document-list/table/delete-docs-dialog.tsx
@@ -0,0 +1,231 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash, AlertCircle } 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 { Alert, AlertDescription } from "@/components/ui/alert"
+import { DocumentStagesView } from "@/db/schema/vendorDocu"
+import { removeDocuments } from "../service"
+
+interface DeleteDocumentsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ documents: Row<DocumentStagesView>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteDocumentsDialog({
+ documents,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteDocumentsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ // "pending" 상태인 문서만 필터링
+ const pendingDocuments = documents.filter(doc => doc.status === "pending")
+ const nonPendingDocuments = documents.filter(doc => doc.status !== "pending")
+
+ const hasMixedStatus = pendingDocuments.length > 0 && nonPendingDocuments.length > 0
+ const hasNoPendingDocuments = pendingDocuments.length === 0
+
+ function onDelete() {
+ // 삭제할 문서가 없으면 경고
+ if (pendingDocuments.length === 0) {
+ toast.error("No pending documents to delete")
+ props.onOpenChange?.(false)
+ return
+ }
+
+ startDeleteTransition(async () => {
+ // "pending" 상태인 문서 ID만 전달
+ const { success, error } = await removeDocuments({
+ ids: pendingDocuments.map((document) => document.documentId)
+ })
+
+ if (!success) {
+ toast.error(error || "Failed to delete documents")
+ return
+ }
+
+ props.onOpenChange?.(false)
+
+ // 적절한 성공 메시지 표시
+ if (hasMixedStatus) {
+ toast.success(`${pendingDocuments.length} pending document(s) deleted successfully. ${nonPendingDocuments.length} non-pending document(s) were not affected.`)
+ } else {
+ toast.success(`${pendingDocuments.length} document(s) deleted successfully`)
+ }
+
+ onSuccess?.()
+ })
+ }
+
+ // 선택된 문서 상태에 대한 알림 메시지 렌더링
+ const renderStatusAlert = () => {
+ if (hasNoPendingDocuments) {
+ return (
+ <Alert variant="destructive" className="mb-4">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ None of the selected documents are in "pending" status. Only pending documents can be deleted.
+ </AlertDescription>
+ </Alert>
+ )
+ }
+
+ if (hasMixedStatus) {
+ return (
+ <Alert className="mb-4">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ Only the {pendingDocuments.length} document(s) with "pending" status will be deleted.
+ {nonPendingDocuments.length} document(s) cannot be deleted because they are not in pending status.
+ </AlertDescription>
+ </Alert>
+ )
+ }
+
+ return null
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({documents.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Are you absolutely sure?</DialogTitle>
+ <DialogDescription>
+ This action cannot be undone. Only documents with "pending" status can be deleted.
+ </DialogDescription>
+ </DialogHeader>
+
+ {renderStatusAlert()}
+
+ <div>
+ {pendingDocuments.length > 0 && (
+ <p className="text-sm text-muted-foreground mb-2">
+ {pendingDocuments.length} pending document(s) will be deleted:
+ </p>
+ )}
+ {pendingDocuments.length > 0 && (
+ <ul className="text-sm list-disc pl-5 mb-4 max-h-40 overflow-y-auto">
+ {pendingDocuments.map(doc => (
+ <li key={doc.documentId} className="text-muted-foreground">{doc.docNumber} - {doc.title}</li>
+ ))}
+ </ul>
+ )}
+ </div>
+
+ <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 || pendingDocuments.length === 0}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Delete {pendingDocuments.length > 0 ? `(${pendingDocuments.length})` : ""}
+ </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 ({documents.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Are you absolutely sure?</DrawerTitle>
+ <DrawerDescription>
+ This action cannot be undone. Only documents with "pending" status can be deleted.
+ </DrawerDescription>
+ </DrawerHeader>
+
+ {renderStatusAlert()}
+
+ <div className="px-4">
+ {pendingDocuments.length > 0 && (
+ <p className="text-sm text-muted-foreground mb-2">
+ {pendingDocuments.length} pending document(s) will be deleted:
+ </p>
+ )}
+ {pendingDocuments.length > 0 && (
+ <ul className="text-sm list-disc pl-5 mb-4 max-h-40 overflow-y-auto">
+ {pendingDocuments.map(doc => (
+ <li key={doc.documentId} className="text-muted-foreground">{doc.docNumber} - {doc.title}</li>
+ ))}
+ </ul>
+ )}
+ </div>
+
+ <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 || pendingDocuments.length === 0}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Delete {pendingDocuments.length > 0 ? `(${pendingDocuments.length})` : ""}
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/table/doc-table-column.tsx b/lib/vendor-document-list/table/doc-table-column.tsx
new file mode 100644
index 00000000..30fb06b0
--- /dev/null
+++ b/lib/vendor-document-list/table/doc-table-column.tsx
@@ -0,0 +1,202 @@
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { Checkbox } from "@/components/ui/checkbox"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { DataTableRowAction } from "@/types/table"
+import { DocumentStagesView } from "@/db/schema/vendorDocu"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Button } from "@/components/ui/button"
+import { Ellipsis } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<DocumentStagesView> | null>>
+}
+
+export function getColumns({
+ setRowAction,
+}: GetColumnsProps): ColumnDef<DocumentStagesView>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size:40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "docNumber",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Doc Number" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("docNumber")}</div>,
+ meta: {
+ excelHeader: "Doc Number"
+ },
+ enableResizing: true,
+ minSize: 50,
+ size: 100,
+ },
+ {
+ accessorKey: "title",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Doc title" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("title")}</div>,
+ meta: {
+ excelHeader: "Doc title"
+ },
+ enableResizing: true,
+ minSize: 100,
+ size: 160,
+ },
+
+ {
+ accessorKey: "stageCount",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Stage Count" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("stageCount")}</div>,
+ meta: {
+ excelHeader: "Stage Count"
+ },
+ enableResizing: true,
+ minSize: 50,
+ size: 50,
+ },
+ {
+ accessorKey: "stageList",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Stage List" />
+ ),
+ cell: ({ row }) => {
+ const stageNames = row.getValue("stageList") as string[] | null
+
+ if (!stageNames || stageNames.length === 0) {
+ return <span className="text-sm text-muted-foreground italic">No stages</span>
+ }
+
+ return (
+ <div className="flex flex-wrap gap-2">
+ {stageNames.map((stageName, idx) => (
+ <Badge variant="secondary" key={idx}>
+ {stageName}
+ </Badge>
+ ))}
+ </div>
+ )
+ },
+ enableResizing: true,
+ minSize: 120,
+ size: 120,
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="status" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("status")}</div>,
+ meta: {
+ excelHeader: "status"
+ },
+ enableResizing: true,
+ minSize: 60,
+ size: 60,
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Created At" />
+ ),
+ cell: ({ cell }) => formatDateTime(cell.getValue() as Date),
+ meta: {
+ excelHeader: "created At"
+ },
+ enableResizing: true,
+ minSize: 120,
+ size: 120,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Updated At" />
+ ),
+ cell: ({ cell }) => formatDateTime(cell.getValue() as Date),
+ meta: {
+ excelHeader: "updated At"
+ },
+ enableResizing: true,
+ minSize: 120,
+ size: 120,
+ },
+ {
+ 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-7 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,
+ }
+ ]
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/table/doc-table-toolbar-actions.tsx b/lib/vendor-document-list/table/doc-table-toolbar-actions.tsx
new file mode 100644
index 00000000..a30384dd
--- /dev/null
+++ b/lib/vendor-document-list/table/doc-table-toolbar-actions.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Send, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { DocumentStagesView } from "@/db/schema/vendorDocu"
+import { AddDocumentListDialog } from "./add-doc-dialog"
+import { DeleteDocumentsDialog } from "./delete-docs-dialog"
+
+
+interface DocTableToolbarActionsProps {
+ table: Table<DocumentStagesView>
+ projectType: "ship" | "plant";
+ selectedPackageId: number
+}
+
+export function DocTableToolbarActions({ table, projectType, selectedPackageId }: DocTableToolbarActionsProps) {
+
+
+ return (
+ <div className="flex items-center gap-2">
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteDocumentsDialog
+ documents={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+
+ <AddDocumentListDialog projectType={projectType} contractId={selectedPackageId} />
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "Document-list",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+
+
+
+ <Button
+ variant="samsung"
+ size="sm"
+ className="gap-2"
+ >
+ <Send className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Send to SHI</span>
+ </Button>
+
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/table/doc-table.tsx b/lib/vendor-document-list/table/doc-table.tsx
new file mode 100644
index 00000000..f70ce365
--- /dev/null
+++ b/lib/vendor-document-list/table/doc-table.tsx
@@ -0,0 +1,110 @@
+"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 { getColumns } from "./doc-table-column"
+import { getVendorDocuments } from "../service"
+import { DocumentStagesView } from "@/db/schema/vendorDocu"
+import { useEffect } from "react"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { DocTableToolbarActions } from "./doc-table-toolbar-actions"
+import { DeleteDocumentsDialog } from "./delete-docs-dialog"
+
+interface DocumentListTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getVendorDocuments>>]>
+ selectedPackageId: number
+ projectType: "ship" | "plant";
+}
+
+export function DocumentsTable({
+ promises,
+ selectedPackageId,
+ projectType,
+}: DocumentListTableProps) {
+ // 1) 데이터를 가져옴 (server component -> use(...) pattern)
+ const [{ data, pageCount }] = React.use(promises)
+
+ console.log(data)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<DocumentStagesView> | null>(null)
+
+
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // Filter fields
+ const filterFields: DataTableFilterField<DocumentStagesView>[] = []
+
+ const advancedFilterFields: DataTableAdvancedFilterField<DocumentStagesView>[] = [
+ {
+ id: "docNumber",
+ label: "Doc Number",
+ type: "text",
+ },
+ {
+ id: "title",
+ label: "Doc Title",
+ type: "text",
+ },
+ {
+ id: "createdAt",
+ label: "Created at",
+ type: "date",
+ },
+ {
+ id: "updatedAt",
+ label: "Updated at",
+ type: "date",
+ },
+ ]
+
+ // useDataTable 훅으로 react-table 구성
+ const { table } = useDataTable({
+ data: data, // <-- 여기서 tableData 사용
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.documentId),
+ shallow: false,
+ clearOnDefault: true,
+ columnResizeMode: "onEnd",
+
+ })
+ return (
+ <>
+ <DataTable table={table} >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <DocTableToolbarActions table={table} projectType={projectType} selectedPackageId={selectedPackageId} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <DeleteDocumentsDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ documents={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/table/update-doc-sheet.tsx b/lib/vendor-document-list/table/update-doc-sheet.tsx
new file mode 100644
index 00000000..3e0ca225
--- /dev/null
+++ b/lib/vendor-document-list/table/update-doc-sheet.tsx
@@ -0,0 +1,267 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader, Save } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { z } from "zod"
+import { useRouter } from "next/navigation"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} 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 { Textarea } from "@/components/ui/textarea"
+import { modifyDocument } from "../service"
+
+// Document 수정을 위한 Zod 스키마 정의
+const updateDocumentSchema = z.object({
+ docNumber: z.string().min(1, "Document number is required"),
+ title: z.string().min(1, "Title is required"),
+ status: z.string().min(1, "Status is required"),
+ description: z.string().optional(),
+ remarks: z.string().optional()
+});
+
+type UpdateDocumentSchema = z.infer<typeof updateDocumentSchema>;
+
+// 상태 옵션 정의
+const statusOptions = [
+ "pending",
+ "in-progress",
+ "completed",
+ "rejected"
+];
+
+interface UpdateDocumentSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ document: {
+ id: number;
+ contractId: number;
+ docNumber: string;
+ title: string;
+ status: string;
+ description?: string | null;
+ remarks?: string | null;
+ } | null
+}
+
+export function UpdateDocumentSheet({ document, ...props }: UpdateDocumentSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const router = useRouter()
+
+ const form = useForm<UpdateDocumentSchema>({
+ resolver: zodResolver(updateDocumentSchema),
+ defaultValues: {
+ docNumber: "",
+ title: "",
+ status: "",
+ description: "",
+ remarks: "",
+ },
+ })
+
+ // 폼 초기화 (document가 변경될 때)
+ React.useEffect(() => {
+ if (document) {
+ form.reset({
+ docNumber: document.docNumber,
+ title: document.title,
+ status: document.status,
+ description: document.description ?? "",
+ remarks: document.remarks ?? "",
+ });
+ }
+ }, [document, form]);
+
+ function onSubmit(input: UpdateDocumentSchema) {
+ startUpdateTransition(async () => {
+ if (!document) return
+
+ const result = await modifyDocument({
+ id: document.id,
+ contractId: document.contractId,
+ ...input,
+ })
+
+ if (!result.success) {
+ if ('error' in result) {
+ toast.error(result.error)
+ } else {
+ toast.error("Failed to update document")
+ }
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("Document updated successfully")
+ router.refresh()
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Update Document</SheetTitle>
+ <SheetDescription>
+ Update the document 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="docNumber"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Document Number</FormLabel>
+ <FormControl>
+ <Input placeholder="Enter document number" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 문서 제목 필드 */}
+ <FormField
+ control={form.control}
+ name="title"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Title</FormLabel>
+ <FormControl>
+ <Input placeholder="Enter document title" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 상태 필드 */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Status</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ value={field.value}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Select status" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ {statusOptions.map((status) => (
+ <SelectItem key={status} value={status}>
+ {status.charAt(0).toUpperCase() + status.slice(1)}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 설명 필드 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Description</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Enter document description"
+ className="min-h-[80px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 비고 필드 */}
+ <FormField
+ control={form.control}
+ name="remarks"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Remarks</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Enter additional remarks"
+ className="min-h-[80px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => form.reset()}
+ >
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ <Save className="mr-2 size-4" /> Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/validations.ts b/lib/vendor-document-list/validations.ts
new file mode 100644
index 00000000..036cc6c6
--- /dev/null
+++ b/lib/vendor-document-list/validations.ts
@@ -0,0 +1,33 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { DocumentStagesView } from "@/db/schema/vendorDocu"
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<DocumentStagesView>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ title: parseAsString.withDefault(""),
+ docNumber: parseAsString.withDefault(""),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+
+export type GetVendorDcoumentsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
diff --git a/lib/vendor-document/repository.ts b/lib/vendor-document/repository.ts
new file mode 100644
index 00000000..79e0cf70
--- /dev/null
+++ b/lib/vendor-document/repository.ts
@@ -0,0 +1,44 @@
+import db from "@/db/db";
+import { vendorDocumentsView } from "@/db/schema/vendorDocu";
+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 selectVendorDocuments(
+ 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(vendorDocumentsView)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+/** 총 개수 count */
+export async function countVendorDocuments(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(vendorDocumentsView).where(where);
+ return res[0]?.count ?? 0;
+}
diff --git a/lib/vendor-document/service.ts b/lib/vendor-document/service.ts
new file mode 100644
index 00000000..b14a64e0
--- /dev/null
+++ b/lib/vendor-document/service.ts
@@ -0,0 +1,346 @@
+"use server"
+
+import { eq, SQL } from "drizzle-orm"
+import db from "@/db/db"
+import { documentAttachments, documents, issueStages, revisions, vendorDocumentsView } from "@/db/schema/vendorDocu"
+import { contracts } from "@/db/schema/vendorData"
+import { GetVendorDcoumentsSchema } from "./validations"
+import { unstable_cache } from "@/lib/unstable-cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { getErrorMessage } from "@/lib/handle-error";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or , isNotNull, isNull} from "drizzle-orm";
+import { countVendorDocuments, selectVendorDocuments } from "./repository"
+import path from "path";
+import fs from "fs/promises";
+import { v4 as uuidv4 } from "uuid"
+
+/**
+ * 특정 vendorId에 속한 문서 목록 조회
+ */
+export async function getVendorDocumentLists(input: GetVendorDcoumentsSchema, id: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: vendorDocumentsView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(vendorDocumentsView.title, s), ilike(vendorDocumentsView.docNumber, s)
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ const finalWhere = and(advancedWhere, globalWhere, eq(vendorDocumentsView.contractId, id));
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(vendorDocumentsView[item.id]) : asc(vendorDocumentsView[item.id])
+ )
+ : [asc(vendorDocumentsView.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectVendorDocuments(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countVendorDocuments(tx, finalWhere);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input), String(id)], // Include id in the cache key
+ {
+ revalidate: 3600,
+ tags: [`vendor-docuemnt-${id}`],
+ }
+ )();
+}
+
+
+// getDocumentVersionsByDocId 함수 수정 - 업로더 타입으로 필터링 추가
+export async function getDocumentVersionsByDocId(
+ docId: number,
+) {
+ // 모든 조건을 배열로 관리
+ const conditions: SQL<unknown>[] = [eq(issueStages.documentId, docId)];
+
+
+
+ // 쿼리 실행
+ const rows = await db
+ .select({
+ // stage 정보
+ stageId: issueStages.id,
+ stageName: issueStages.stageName,
+ planDate: issueStages.planDate,
+ actualDate: issueStages.actualDate,
+
+ // revision 정보
+ revisionId: revisions.id,
+ revision: revisions.revision,
+ uploaderType: revisions.uploaderType,
+ uploaderName: revisions.uploaderName,
+ comment: revisions.comment,
+ status: revisions.status,
+ approvedDate: revisions.approvedDate,
+
+ // attachment 정보
+ attachmentId: documentAttachments.id,
+ fileName: documentAttachments.fileName,
+ filePath: documentAttachments.filePath,
+ fileType: documentAttachments.fileType,
+ DocumentSubmitDate: revisions.createdAt,
+ })
+ .from(issueStages)
+ .leftJoin(revisions, eq(issueStages.id, revisions.issueStageId))
+ .leftJoin(documentAttachments, eq(revisions.id, documentAttachments.revisionId))
+ .where(and(...conditions))
+ .orderBy(issueStages.id, revisions.id, documentAttachments.id);
+
+ // 결과를 처리하여 프론트엔드 형식으로 변환
+ // 스테이지+리비전별로 그룹화
+ const stageRevMap = new Map();
+ // 리비전이 있는 스테이지 ID 추적
+ const stagesWithRevisions = new Set();
+
+ for (const row of rows) {
+ const stageId = row.stageId;
+
+
+ // 리비전이 있는 경우 처리
+ if (row.revisionId) {
+ // 리비전이 있는 스테이지 추적
+ stagesWithRevisions.add(stageId);
+
+ const key = `${stageId}-${row.revisionId}`;
+
+ if (!stageRevMap.has(key)) {
+ stageRevMap.set(key, {
+ id: row.revisionId,
+ stage: row.stageName,
+ revision: row.revision,
+ uploaderType: row.uploaderType,
+ uploaderName: row.uploaderName || null,
+ comment: row.comment || null,
+ status: row.status || null,
+ planDate: row.planDate,
+ actualDate: row.actualDate,
+ approvedDate: row.approvedDate,
+ DocumentSubmitDate: row.DocumentSubmitDate,
+ attachments: []
+ });
+ }
+
+ // attachmentId가 있는 경우에만 첨부파일 추가
+ if (row.attachmentId) {
+ stageRevMap.get(key).attachments.push({
+ id: row.attachmentId,
+ fileName: row.fileName,
+ filePath: row.filePath,
+ fileType: row.fileType
+ });
+ }
+ }
+ }
+
+
+ // 최종 결과 생성
+ const result = [
+ ...stageRevMap.values()
+ ];
+
+ // 스테이지 이름으로 정렬하고, 같은 스테이지 내에서는 리비전이 없는 항목이 먼저 오도록 정렬
+ result.sort((a, b) => {
+ if (a.stage !== b.stage) {
+ return a.stage.localeCompare(b.stage);
+ }
+
+ // 같은 스테이지 내에서는 리비전이 없는 항목이 먼저 오도록
+ if (a.revision === null) return -1;
+ if (b.revision === null) return 1;
+
+ // 두 항목 모두 리비전이 있는 경우 리비전 번호로 정렬
+ return a.revision - b.revision;
+ });
+
+ return result;
+}
+// createRevisionAction 함수 수정 - 확장된 업로더 타입 지원
+export async function createRevisionAction(formData: FormData) {
+
+ const stage = formData.get("stage") as string | null
+ const revision = formData.get("revision") as string | null
+ const docIdStr = formData.get("documentId") as string
+ const docId = parseInt(docIdStr, 10)
+ const customFileName = formData.get("customFileName") as string;
+
+ // 업로더 타입 추가 (기본값: "vendor")
+ const uploaderType = formData.get("uploaderType") as string || "vendor"
+ const uploaderName = formData.get("uploaderName") as string | null
+ const comment = formData.get("comment") as string | null
+
+ if (!docId || Number.isNaN(docId)) {
+ throw new Error("Invalid or missing documentId")
+ }
+ if (!stage || !revision) {
+ throw new Error("Missing stage/revision")
+ }
+
+ // 업로더 타입 검증
+ if (!['vendor', 'client', 'shi'].includes(uploaderType)) {
+ throw new Error(`Invalid uploaderType: ${uploaderType}. Must be one of: vendor, client, shi`);
+ }
+
+ // 트랜잭션 시작
+ return await db.transaction(async (tx) => {
+ // (1) issueStageId 찾기 (stageName + documentId)
+ let issueStageId: number;
+ const stageRecord = await tx
+ .select()
+ .from(issueStages)
+ .where(and(eq(issueStages.stageName, stage), eq(issueStages.documentId, docId)))
+ .limit(1)
+
+ if (!stageRecord.length) {
+ // Stage가 없으면 새로 생성
+ const [newStage] = await tx
+ .insert(issueStages)
+ .values({
+ documentId: docId,
+ stageName: stage,
+ updatedAt: new Date(),
+ })
+ .returning()
+
+ issueStageId = newStage.id
+ } else {
+ issueStageId = stageRecord[0].id
+ }
+
+ // (2) Revision 찾기 또는 생성 (issueStageId + revision 조합)
+ let revisionId: number;
+ const revisionRecord = await tx
+ .select()
+ .from(revisions)
+ .where(and(eq(revisions.issueStageId, issueStageId), eq(revisions.revision, revision)))
+ .limit(1)
+
+ // 기본 상태값 설정
+ let status = 'submitted';
+ if (uploaderType === 'client') status = 'reviewed';
+ if (uploaderType === 'shi') status = 'official';
+
+ if (!revisionRecord.length) {
+ // Revision이 없으면 새로 생성
+ const [newRevision] = await tx
+ .insert(revisions)
+ .values({
+ issueStageId,
+ revision,
+ uploaderType,
+ uploaderName: uploaderName || undefined,
+ comment: comment || undefined,
+ status,
+ updatedAt: new Date(),
+ })
+ .returning()
+
+ revisionId = newRevision.id
+ } else {
+ // 이미 존재하는 경우, 업로더 타입이 다르면 업데이트
+ if (revisionRecord[0].uploaderType !== uploaderType) {
+ await tx
+ .update(revisions)
+ .set({
+ uploaderType,
+ uploaderName: uploaderName || undefined,
+ comment: comment || undefined,
+ status,
+ updatedAt: new Date(),
+ })
+ .where(eq(revisions.id, revisionRecord[0].id))
+ }
+ revisionId = revisionRecord[0].id
+ }
+
+ // (3) 파일 처리
+ const file = formData.get("attachment") as File | null
+ let attachmentRecord: typeof documentAttachments.$inferSelect | null = null;
+
+ if (file && file.size > 0) {
+ const originalName = customFileName
+ const ext = path.extname(originalName)
+ const uniqueName = uuidv4() + ext
+ const baseDir = path.join(process.cwd(), "public", "documents")
+ const savePath = path.join(baseDir, uniqueName)
+
+ const arrayBuffer = await file.arrayBuffer()
+ const buffer = Buffer.from(arrayBuffer)
+ await fs.writeFile(savePath, buffer)
+
+ // 파일 정보를 documentAttachments 테이블에 저장
+ const result = await tx
+ .insert(documentAttachments)
+ .values({
+ revisionId,
+ fileName: originalName,
+ filePath: "/documents/" + uniqueName,
+ fileSize: file.size,
+ fileType: ext.replace('.', '').toLowerCase(),
+ updatedAt: new Date(),
+ })
+ .returning()
+
+ // 첫 번째 결과만 할당
+ attachmentRecord = result[0]
+ }
+
+ // (4) Documents 테이블의 updatedAt 갱신 (docId가 documents.id)
+ await tx
+ .update(documents)
+ .set({ updatedAt: new Date() })
+ .where(eq(documents.id, docId))
+
+ return attachmentRecord
+ })
+}
+
+
+export async function getStageNamesByDocumentId(documentId: number) {
+ try {
+ if (!documentId || Number.isNaN(documentId)) {
+ throw new Error("Invalid document ID");
+ }
+
+ const stageRecords = await db
+ .select({ stageName: issueStages.stageName })
+ .from(issueStages)
+ .where(eq(issueStages.documentId, documentId))
+ .orderBy(issueStages.stageName);
+
+ // stageName 배열로 변환
+ return stageRecords.map(record => record.stageName);
+ } catch (error) {
+ console.error("Error fetching stage names:", error);
+ return []; // 오류 발생시 빈 배열 반환
+ }
+} \ No newline at end of file
diff --git a/lib/vendor-document/table/doc-table-column.tsx b/lib/vendor-document/table/doc-table-column.tsx
new file mode 100644
index 00000000..e53b03b9
--- /dev/null
+++ b/lib/vendor-document/table/doc-table-column.tsx
@@ -0,0 +1,150 @@
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { Checkbox } from "@/components/ui/checkbox"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { DataTableRowAction } from "@/types/table"
+import { VendorDocumentsView } from "@/db/schema/vendorDocu"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorDocumentsView> | null>>
+}
+
+export function getColumns({
+ setRowAction,
+}: GetColumnsProps): ColumnDef<VendorDocumentsView>[] {
+ return [
+ {
+ id: "select",
+ // Remove the "Select all" checkbox in header since we're doing single-select
+ header: () => <span className="sr-only">Select</span>,
+ cell: ({ row, table }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => {
+ // If selecting this row
+ if (value) {
+ // First deselect all rows (to ensure single selection)
+ table.toggleAllRowsSelected(false)
+ // Then select just this row
+ row.toggleSelected(true)
+ // Trigger the same action that was in the "Select" button
+ setRowAction({ row, type: "select" })
+ } else {
+ // Just deselect this row
+ row.toggleSelected(false)
+ }
+ }}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ enableResizing: false,
+ size: 40,
+ minSize: 40,
+ maxSize: 40,
+ },
+
+ {
+ accessorKey: "docNumber",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Doc Number" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("docNumber")}</div>,
+ meta: {
+ excelHeader: "Doc Number"
+ },
+ enableResizing: true,
+ minSize: 100,
+ size: 160,
+ },
+ {
+ accessorKey: "title",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Doc title" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("title")}</div>,
+ meta: {
+ excelHeader: "Doc title"
+ },
+ enableResizing: true,
+ minSize: 100,
+ size: 160,
+ },
+ {
+ accessorKey: "latestStageName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Latest Stage Name" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("latestStageName")}</div>,
+ meta: {
+ excelHeader: "Latest Stage Name"
+ },
+ enableResizing: true,
+ minSize: 100,
+ size: 160,
+ },
+ {
+ accessorKey: "latestStagePlanDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Latest Stage Plan Date" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date) : "";
+ }, meta: {
+ excelHeader: "Latest Stage Plan Date"
+ },
+ enableResizing: true,
+ minSize: 100,
+ size: 160,
+ },
+ {
+ accessorKey: "latestStageActualDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Latest Stage Actual Date" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date) : "";
+ }, meta: {
+ excelHeader: "Latest Stage Actual Date"
+ },
+ enableResizing: true,
+ minSize: 100,
+ size: 160,
+ },
+ {
+ accessorKey: "latestRevision",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Latest Revision" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("latestRevision")}</div>,
+ meta: {
+ excelHeader: "Latest Revision"
+ },
+ enableResizing: true,
+ minSize: 100,
+ size: 160,
+ },
+
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Updated At" />
+ ),
+ cell: ({ cell }) => formatDateTime(cell.getValue() as Date),
+ meta: {
+ excelHeader: "updated At"
+ },
+ enableResizing: true,
+ minSize: 120,
+ size: 180,
+ },
+ // The "actions" column has been removed
+ ]
+} \ No newline at end of file
diff --git a/lib/vendor-document/table/doc-table-toolbar-actions.tsx b/lib/vendor-document/table/doc-table-toolbar-actions.tsx
new file mode 100644
index 00000000..cf4aa7c1
--- /dev/null
+++ b/lib/vendor-document/table/doc-table-toolbar-actions.tsx
@@ -0,0 +1,57 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Send, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { VendorDocumentsView } from "@/db/schema/vendorDocu"
+
+
+interface DocTableToolbarActionsProps {
+ table: Table<VendorDocumentsView>
+}
+
+export function DocTableToolbarActions({ table }: DocTableToolbarActionsProps) {
+
+
+ return (
+ <div className="flex items-center gap-2">
+ <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>
+
+
+ <Button
+ size="sm"
+ className="gap-2"
+ >
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Bulk File Upload</span>
+ </Button>
+
+ <Button
+ variant="samsung"
+ size="sm"
+ className="gap-2"
+ >
+ <Send className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Send to SHI</span>
+ </Button>
+
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-document/table/doc-table.tsx b/lib/vendor-document/table/doc-table.tsx
new file mode 100644
index 00000000..dfd906fa
--- /dev/null
+++ b/lib/vendor-document/table/doc-table.tsx
@@ -0,0 +1,124 @@
+"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 { getColumns } from "./doc-table-column"
+import { getVendorDocumentLists } from "../service"
+import { VendorDocumentsView } from "@/db/schema/vendorDocu"
+import { useEffect } from "react"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { DocTableToolbarActions } from "./doc-table-toolbar-actions"
+
+interface DocumentListTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getVendorDocumentLists>>]>
+ selectedPackageId: number
+ onSelectDocument?: (document: VendorDocumentsView | null) => void
+}
+
+export function DocumentListTable({
+ promises,
+ selectedPackageId,
+ onSelectDocument
+}: DocumentListTableProps) {
+ // 1) 데이터를 가져옴 (server component -> use(...) pattern)
+ const [{ data, pageCount }] = React.use(promises)
+
+ console.log(data)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorDocumentsView> | null>(null)
+
+ // 3) 행 액션 처리
+ useEffect(() => {
+ if (rowAction) {
+ // 액션 유형에 따라 처리
+ switch (rowAction.type) {
+ case "select":
+ // 선택된 문서 처리
+ if (onSelectDocument) {
+ onSelectDocument(rowAction.row.original)
+ }
+ break;
+ case "update":
+ // 업데이트 처리 로직
+ console.log("Update document:", rowAction.row.original)
+ break;
+ case "delete":
+ // 삭제 처리 로직
+ console.log("Delete document:", rowAction.row.original)
+ break;
+ }
+
+ // 액션 처리 후 rowAction 초기화
+ setRowAction(null)
+ }
+ }, [rowAction, onSelectDocument])
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // Filter fields
+ const filterFields: DataTableFilterField<VendorDocumentsView>[] = []
+
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorDocumentsView>[] = [
+ {
+ id: "docNumber",
+ label: "Doc Number",
+ type: "text",
+ },
+ {
+ id: "title",
+ label: "Doc Title",
+ type: "text",
+ },
+ {
+ id: "createdAt",
+ label: "Created at",
+ type: "date",
+ },
+ {
+ id: "updatedAt",
+ label: "Updated at",
+ type: "date",
+ },
+ ]
+
+ // useDataTable 훅으로 react-table 구성
+ const { table } = useDataTable({
+ data: data, // <-- 여기서 tableData 사용
+ 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,
+ columnResizeMode: "onEnd",
+
+ })
+ return (
+ <>
+ <DataTable table={table} >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <DocTableToolbarActions table={table}/>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-document/validations.ts b/lib/vendor-document/validations.ts
new file mode 100644
index 00000000..7b8bb5fb
--- /dev/null
+++ b/lib/vendor-document/validations.ts
@@ -0,0 +1,33 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { VendorDocumentsView, vendorDocumentsView } from "@/db/schema/vendorDocu"
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<VendorDocumentsView>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ title: parseAsString.withDefault(""),
+ docNumber: parseAsString.withDefault(""),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+
+export type GetVendorDcoumentsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
diff --git a/lib/vendor-rfq-response/service.ts b/lib/vendor-rfq-response/service.ts
new file mode 100644
index 00000000..cba6c414
--- /dev/null
+++ b/lib/vendor-rfq-response/service.ts
@@ -0,0 +1,301 @@
+import { 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 { vendorResponsesView, vendorTechnicalResponses, vendorCommercialResponses, vendorResponseAttachments } from "@/db/schema/rfq";
+import { items } from "@/db/schema/items";
+import { GetRfqsForVendorsSchema } from "../rfqs/validations";
+
+
+
+export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, vendorId: number) {
+ return unstable_cache(
+ async () => {
+ const offset = (input.page - 1) * input.perPage;
+ const limit = input.perPage;
+
+ // 1) 메인 쿼리: vendorResponsesView 사용
+ const { rows, total } = await db.transaction(async (tx) => {
+ // 검색 조건
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ sql`${vendorResponsesView.rfqCode} ILIKE ${s}`,
+ sql`${vendorResponsesView.projectName} ILIKE ${s}`,
+ sql`${vendorResponsesView.rfqDescription} ILIKE ${s}`
+ );
+ }
+
+ // 벤더 ID 필터링
+ const mainWhere = and(eq(vendorResponsesView.vendorId, vendorId), globalWhere);
+
+ // 정렬: 응답 시간순
+ const orderBy = [desc(vendorResponsesView.respondedAt)];
+
+ // (A) 데이터 조회
+ const data = await tx
+ .select()
+ .from(vendorResponsesView)
+ .where(mainWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(limit);
+
+ // (B) 전체 개수 카운트
+ const [{ count }] = await tx
+ .select({
+ count: sql<number>`count(*)`.as("count"),
+ })
+ .from(vendorResponsesView)
+ .where(mainWhere);
+
+ return { rows: data, total: Number(count) };
+ });
+
+ // 2) rfqId 고유 목록 추출
+ const distinctRfqs = [...new Set(rows.map((r) => r.rfqId))];
+ if (distinctRfqs.length === 0) {
+ return { data: [], pageCount: 0 };
+ }
+
+ // 3) 추가 데이터 조회
+ // 3-A) RFQ 아이템
+ const itemsAll = await db
+ .select({
+ id: rfqItems.id,
+ rfqId: rfqItems.rfqId,
+ itemCode: rfqItems.itemCode,
+ itemName: items.itemName,
+ quantity: rfqItems.quantity,
+ description: rfqItems.description,
+ uom: rfqItems.uom,
+ })
+ .from(rfqItems)
+ .leftJoin(items, eq(rfqItems.itemCode, items.itemCode))
+ .where(inArray(rfqItems.rfqId, distinctRfqs));
+
+ // 3-B) RFQ 첨부 파일 (벤더용)
+ const attachAll = await db
+ .select()
+ .from(rfqAttachments)
+ .where(
+ and(
+ inArray(rfqAttachments.rfqId, distinctRfqs),
+ isNull(rfqAttachments.vendorId)
+ )
+ );
+
+ // 3-C) RFQ 코멘트
+ const commAll = await db
+ .select()
+ .from(rfqComments)
+ .where(
+ and(
+ inArray(rfqComments.rfqId, distinctRfqs),
+ or(
+ isNull(rfqComments.vendorId),
+ eq(rfqComments.vendorId, vendorId)
+ )
+ )
+ );
+
+
+ // 3-E) 벤더 응답 상세 - 기술
+ const technicalResponsesAll = await db
+ .select()
+ .from(vendorTechnicalResponses)
+ .where(
+ inArray(
+ vendorTechnicalResponses.responseId,
+ rows.map((r) => r.responseId)
+ )
+ );
+
+ // 3-F) 벤더 응답 상세 - 상업
+ const commercialResponsesAll = await db
+ .select()
+ .from(vendorCommercialResponses)
+ .where(
+ inArray(
+ vendorCommercialResponses.responseId,
+ rows.map((r) => r.responseId)
+ )
+ );
+
+ // 3-G) 벤더 응답 첨부 파일
+ const responseAttachmentsAll = await db
+ .select()
+ .from(vendorResponseAttachments)
+ .where(
+ inArray(
+ vendorResponseAttachments.responseId,
+ rows.map((r) => r.responseId)
+ )
+ );
+
+ // 4) 데이터 그룹화
+ // RFQ 아이템 그룹화
+ const itemsByRfqId = new Map<number, any[]>();
+ for (const it of itemsAll) {
+ if (!itemsByRfqId.has(it.rfqId)) {
+ itemsByRfqId.set(it.rfqId, []);
+ }
+ itemsByRfqId.get(it.rfqId)!.push({
+ id: it.id,
+ itemCode: it.itemCode,
+ itemName: it.itemName,
+ quantity: it.quantity,
+ description: it.description,
+ uom: it.uom,
+ });
+ }
+
+ // RFQ 첨부 파일 그룹화
+ const attachByRfqId = new Map<number, any[]>();
+ for (const att of attachAll) {
+ const rid = att.rfqId!;
+ if (!attachByRfqId.has(rid)) {
+ attachByRfqId.set(rid, []);
+ }
+ attachByRfqId.get(rid)!.push({
+ id: att.id,
+ fileName: att.fileName,
+ filePath: att.filePath,
+ vendorId: att.vendorId,
+ evaluationId: att.evaluationId,
+ });
+ }
+
+ // RFQ 코멘트 그룹화
+ const commByRfqId = new Map<number, any[]>();
+ for (const c of commAll) {
+ const rid = c.rfqId!;
+ if (!commByRfqId.has(rid)) {
+ commByRfqId.set(rid, []);
+ }
+ commByRfqId.get(rid)!.push({
+ id: c.id,
+ commentText: c.commentText,
+ vendorId: c.vendorId,
+ evaluationId: c.evaluationId,
+ createdAt: c.createdAt,
+ });
+ }
+
+
+ // 기술 응답 그룹화
+ const techResponseByResponseId = new Map<number, any>();
+ for (const tr of technicalResponsesAll) {
+ techResponseByResponseId.set(tr.responseId, {
+ id: tr.id,
+ summary: tr.summary,
+ notes: tr.notes,
+ createdAt: tr.createdAt,
+ updatedAt: tr.updatedAt,
+ });
+ }
+
+ // 상업 응답 그룹화
+ const commResponseByResponseId = new Map<number, any>();
+ for (const cr of commercialResponsesAll) {
+ commResponseByResponseId.set(cr.responseId, {
+ id: cr.id,
+ totalPrice: cr.totalPrice,
+ currency: cr.currency,
+ paymentTerms: cr.paymentTerms,
+ incoterms: cr.incoterms,
+ deliveryPeriod: cr.deliveryPeriod,
+ warrantyPeriod: cr.warrantyPeriod,
+ validityPeriod: cr.validityPeriod,
+ priceBreakdown: cr.priceBreakdown,
+ commercialNotes: cr.commercialNotes,
+ createdAt: cr.createdAt,
+ updatedAt: cr.updatedAt,
+ });
+ }
+
+ // 응답 첨부 파일 그룹화
+ const respAttachByResponseId = new Map<number, any[]>();
+ for (const ra of responseAttachmentsAll) {
+ const rid = ra.responseId!;
+ if (!respAttachByResponseId.has(rid)) {
+ respAttachByResponseId.set(rid, []);
+ }
+ respAttachByResponseId.get(rid)!.push({
+ id: ra.id,
+ fileName: ra.fileName,
+ filePath: ra.filePath,
+ attachmentType: ra.attachmentType,
+ description: ra.description,
+ uploadedAt: ra.uploadedAt,
+ uploadedBy: ra.uploadedBy,
+ });
+ }
+
+ // 5) 최종 데이터 결합
+ const final = rows.map((row) => {
+ return {
+ // 응답 정보
+ responseId: row.responseId,
+ responseStatus: row.responseStatus,
+ respondedAt: row.respondedAt,
+
+ // RFQ 기본 정보
+ rfqId: row.rfqId,
+ rfqCode: row.rfqCode,
+ rfqDescription: row.rfqDescription,
+ rfqDueDate: row.rfqDueDate,
+ rfqStatus: row.rfqStatus,
+ rfqType: row.rfqType,
+ rfqCreatedAt: row.rfqCreatedAt,
+ rfqUpdatedAt: row.rfqUpdatedAt,
+ rfqCreatedBy: row.rfqCreatedBy,
+
+ // 프로젝트 정보
+ projectId: row.projectId,
+ projectCode: row.projectCode,
+ projectName: row.projectName,
+
+ // 벤더 정보
+ vendorId: row.vendorId,
+ vendorName: row.vendorName,
+ vendorCode: row.vendorCode,
+
+ // RFQ 관련 데이터
+ items: itemsByRfqId.get(row.rfqId) || [],
+ attachments: attachByRfqId.get(row.rfqId) || [],
+ comments: commByRfqId.get(row.rfqId) || [],
+
+ // 평가 정보
+ tbeEvaluation: row.tbeId ? {
+ id: row.tbeId,
+ result: row.tbeResult,
+ } : null,
+ cbeEvaluation: row.cbeId ? {
+ id: row.cbeId,
+ result: row.cbeResult,
+ } : null,
+
+ // 벤더 응답 상세
+ technicalResponse: techResponseByResponseId.get(row.responseId) || null,
+ commercialResponse: commResponseByResponseId.get(row.responseId) || null,
+ responseAttachments: respAttachByResponseId.get(row.responseId) || [],
+
+ // 응답 상태 표시
+ hasTechnicalResponse: row.hasTechnicalResponse,
+ hasCommercialResponse: row.hasCommercialResponse,
+ attachmentCount: row.attachmentCount || 0,
+ };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+ return { data: final, pageCount };
+ },
+ [JSON.stringify(input), `${vendorId}`],
+ {
+ revalidate: 600,
+ tags: ["rfqs-vendor", `vendor-${vendorId}`],
+ }
+ )();
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/types.ts b/lib/vendor-rfq-response/types.ts
new file mode 100644
index 00000000..5dadc89b
--- /dev/null
+++ b/lib/vendor-rfq-response/types.ts
@@ -0,0 +1,76 @@
+// RFQ 아이템 타입
+export interface RfqResponseItem {
+ id: number;
+ itemCode: string;
+ itemName: string;
+ quantity?: number;
+ uom?: string;
+ description?: string | null;
+}
+
+// RFQ 첨부 파일 타입
+export interface RfqResponseAttachment {
+ id: number;
+ fileName: string;
+ filePath: string;
+ vendorId?: number | null;
+ evaluationId?: number | null;
+}
+
+// RFQ 코멘트 타입
+export interface RfqResponseComment {
+ id: number;
+ commentText: string;
+ vendorId?: number | null;
+ evaluationId?: number | null;
+ createdAt: Date;
+ commentedBy?: number;
+}
+
+// 최종 RfqResponse 타입 - RFQ 참여 응답만 포함하도록 간소화
+export interface RfqResponse {
+ // 응답 정보
+ responseId: number;
+ responseStatus: "INVITED" | "ACCEPTED" | "DECLINED" | "REVIEWING" | "RESPONDED";
+ respondedAt: Date;
+
+ // RFQ 기본 정보
+ rfqId: number;
+ rfqCode: string;
+ rfqDescription?: string | null;
+ rfqDueDate?: Date | null;
+ rfqStatus: string;
+ rfqType?: string | null;
+ rfqCreatedAt: Date;
+ rfqUpdatedAt: Date;
+ rfqCreatedBy?: number | null;
+
+ // 프로젝트 정보
+ projectId?: number | null;
+ projectCode?: string | null;
+ projectName?: string | null;
+
+ // 벤더 정보
+ vendorId: number;
+ vendorName: string;
+ vendorCode?: string | null;
+
+ // RFQ 관련 데이터
+ items: RfqResponseItem[];
+ attachments: RfqResponseAttachment[];
+ comments: RfqResponseComment[];
+}
+
+// DataTable 등에서 사용할 수 있도록 id 필드를 추가한 확장 타입
+export interface RfqResponseWithId extends RfqResponse {
+ id: number; // rfqId와 동일하게 사용
+}
+
+// 페이지네이션 결과 타입
+export interface RfqResponsesResult {
+ data: RfqResponseWithId[];
+ pageCount: number;
+}
+
+// 이전 버전과의 호환성을 위한 RfqWithAll 타입 (이름만 유지)
+export type RfqWithAll = RfqResponseWithId; \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx b/lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx
new file mode 100644
index 00000000..504fc177
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx
@@ -0,0 +1,125 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableFooter,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { RfqWithAll } from "../types"
+/**
+ * 아이템 구조 예시
+ * - API 응답에서 quantity가 "string" 형태이므로,
+ * 숫자로 사용하실 거라면 parse 과정이 필요할 수 있습니다.
+ */
+export interface RfqItem {
+ id: number
+ itemCode: string
+ itemName: string
+ quantity: string
+ description: string
+ uom: string
+}
+
+/**
+ * 첨부파일 구조 예시
+ */
+export interface RfqAttachment {
+ id: number
+ fileName: string
+ filePath: string
+ vendorId: number | null
+ evaluationId: number | null
+}
+
+
+/**
+ * 다이얼로그 내에서만 사용할 단순 아이템 구조 (예: 임시/기본값 표출용)
+ */
+export interface DefaultItem {
+ id?: number
+ itemCode: string
+ description?: string | null
+ quantity?: number | null
+ uom?: string | null
+}
+
+/**
+ * RfqsItemsDialog 컴포넌트 Prop 타입
+ */
+export interface RfqsItemsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ rfq: RfqWithAll
+ defaultItems?: DefaultItem[]
+}
+
+export function RfqsItemsDialog({
+ open,
+ onOpenChange,
+ rfq,
+}: RfqsItemsDialogProps) {
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-none w-[1200px]">
+ <DialogHeader>
+ <DialogTitle>Items for RFQ {rfq?.rfqCode}</DialogTitle>
+ <DialogDescription>
+ Below is the list of items for this RFQ.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="overflow-x-auto w-full space-y-4">
+ {rfq && rfq.items.length === 0 && (
+ <p className="text-sm text-muted-foreground">No items found.</p>
+ )}
+ {rfq && rfq.items.length > 0 && (
+ <Table>
+ {/* 필요에 따라 TableCaption 등을 추가해도 좋습니다. */}
+ <TableHeader>
+ <TableRow>
+ <TableHead>Item Code</TableHead>
+ <TableHead>Item Code</TableHead>
+ <TableHead>Description</TableHead>
+ <TableHead>Qty</TableHead>
+ <TableHead>UoM</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {rfq.items.map((it, idx) => (
+ <TableRow key={it.id ?? idx}>
+ <TableCell>{it.itemCode || "No Code"}</TableCell>
+ <TableCell>{it.itemName || "No Name"}</TableCell>
+ <TableCell>{it.description || "-"}</TableCell>
+ <TableCell>{it.quantity ?? 1}</TableCell>
+ <TableCell>{it.uom ?? "each"}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )}
+ </div>
+
+ <DialogFooter className="mt-4">
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+ Close
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx b/lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx
new file mode 100644
index 00000000..6c51c12c
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx
@@ -0,0 +1,106 @@
+"use client"
+
+import * as React from "react"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+ SheetFooter,
+ SheetClose,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import { Download } from "lucide-react"
+import { formatDate } from "@/lib/utils"
+
+// 첨부파일 구조
+interface RfqAttachment {
+ id: number
+ fileName: string
+ filePath: string
+ createdAt?: Date // or Date
+ vendorId?: number | null
+ size?: number
+}
+
+// 컴포넌트 Prop
+interface RfqAttachmentsSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ rfqId: number
+ attachments?: RfqAttachment[]
+}
+
+/**
+ * RfqAttachmentsSheet:
+ * - 단순히 첨부파일 리스트 + 다운로드 버튼만
+ */
+export function RfqAttachmentsSheet({
+ rfqId,
+ attachments = [],
+ ...props
+}: RfqAttachmentsSheetProps) {
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-sm">
+ <SheetHeader>
+ <SheetTitle>Attachments</SheetTitle>
+ <SheetDescription>RFQ #{rfqId}에 대한 첨부파일 목록</SheetDescription>
+ </SheetHeader>
+
+ <div className="space-y-2">
+ {/* 첨부파일이 없을 경우 */}
+ {attachments.length === 0 && (
+ <p className="text-sm text-muted-foreground">
+ No attachments
+ </p>
+ )}
+
+ {/* 첨부파일 목록 */}
+ {attachments.map((att) => (
+ <div
+ key={att.id}
+ className="flex items-center justify-between rounded border p-2"
+ >
+ <div className="flex flex-col text-sm">
+ <span className="font-medium">{att.fileName}</span>
+ {att.size && (
+ <span className="text-xs text-muted-foreground">
+ {Math.round(att.size / 1024)} KB
+ </span>
+ )}
+ {att.createdAt && (
+ <span className="text-xs text-muted-foreground">
+ Created at {formatDate(att.createdAt)}
+ </span>
+ )}
+ </div>
+ {/* 파일 다운로드 버튼 */}
+ {att.filePath && (
+ <a
+ href={att.filePath}
+ download
+ target="_blank"
+ rel="noreferrer"
+ className="text-sm"
+ >
+ <Button variant="ghost" size="icon" type="button">
+ <Download className="h-4 w-4" />
+ </Button>
+ </a>
+ )}
+ </div>
+ ))}
+ </div>
+
+ <SheetFooter className="gap-2 pt-2">
+ {/* 닫기 버튼 */}
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Close
+ </Button>
+ </SheetClose>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx
new file mode 100644
index 00000000..d401f1cd
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx
@@ -0,0 +1,415 @@
+"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 } 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 { RfqWithAll } from "../types"
+
+import { createRfqCommentWithAttachments, updateRfqComment } from "../../rfqs/service"
+import { formatDate } from "@/lib/utils"
+
+// 코멘트 + 첨부파일 구조 (단순 예시)
+// 실제 DB 스키마에 맞춰 조정
+export interface RfqComment {
+ id: number
+ commentText: string
+ commentedBy?: number
+ createdAt?: Date
+ attachments?: {
+ id: number
+ fileName: string
+ filePath?: string
+ }[]
+}
+
+interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ /** 코멘트를 작성할 RFQ 정보 */
+ /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */
+ initialComments?: RfqComment[]
+
+ /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */
+ currentUserId: number
+ rfq:RfqWithAll
+ /** 댓글 저장 후 갱신용 콜백 (옵션) */
+ onCommentsUpdated?: (comments: RfqComment[]) => void
+}
+
+// 새 코멘트 작성 폼 스키마
+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({
+ rfq,
+ initialComments = [],
+ currentUserId,
+ onCommentsUpdated,
+ ...props
+}: CommentSheetProps) {
+ const [comments, setComments] = React.useState<RfqComment[]>(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"
+ })
+
+ // 1) 기존 코멘트 + 첨부 보여주기
+ // 간단히 테이블 하나로 표현
+ // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음
+ function renderExistingComments() {
+ // 1) 편집 상태 관리
+ const [editingId, setEditingId] = React.useState<number | null>(null)
+ const [editText, setEditText] = React.useState("")
+
+ // 2) Edit 시작 핸들러
+ function handleEditClick(c: RfqComment) {
+ setEditingId(c.id)
+ setEditText(c.commentText)
+ }
+
+ // 3) Save 핸들러
+ async function handleSave(commentId: number) {
+ try {
+ // (예시) 서버 액션 or API 요청
+ await updateRfqComment({ commentId, commentText: editText })
+
+ // 만약 단순 로컬 수정만 할 거라면,
+ // parent state의 comments를 갱신하는 로직 필요
+ setComments((prev) =>
+ prev.map((comment) =>
+ comment.id === commentId
+ ? { ...comment, commentText: editText }
+ : comment
+ )
+ )
+
+ toast.success("Comment updated.")
+ } catch (err) {
+ toast.error("Error updating comment.")
+ } finally {
+ // 편집 모드 종료
+ setEditingId(null)
+ setEditText("")
+ }
+ }
+
+ // 4) Cancel 핸들러
+ function handleCancel() {
+ setEditingId(null)
+ setEditText("")
+ }
+
+ // 만약 comments가 비어 있다면
+ if (comments.length === 0) {
+ return <p className="text-sm text-muted-foreground">No comments yet</p>
+ }
+
+ // 5) 테이블 렌더링
+ return (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-1/2">Comment</TableHead>
+ <TableHead>Attachments</TableHead>
+ <TableHead>Created At</TableHead>
+ <TableHead>Created By</TableHead>
+
+ {/* 추가된 Actions 컬럼 */}
+ <TableHead>Actions</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {comments.map((c) => (
+ <TableRow key={c.id}>
+ {/* 1) Comment 셀 */}
+ <TableCell>
+ {/* 현재 행이 editing 모드인지 체크 */}
+ {editingId === c.id ? (
+ // 편집 모드
+ <textarea
+ value={editText}
+ onChange={(e) => setEditText(e.target.value)}
+ className="w-full border p-1 rounded"
+ rows={3}
+ />
+ ) : (
+ // 일반 모드
+ c.commentText
+ )}
+ </TableCell>
+
+ {/* 2) Attachments 셀 (기존과 동일) */}
+ <TableCell>
+ {(!c.attachments || c.attachments.length === 0) && (
+ <span className="text-sm text-muted-foreground">No files</span>
+ )}
+ {c.attachments && c.attachments.length > 0 && (
+ <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}
+ 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>
+
+ {/* 3) Created At */}
+ <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell>
+
+ {/* 4) Created By */}
+ <TableCell>{c.commentedBy ?? "-"}</TableCell>
+
+ {/* 5) 새로 추가된 Actions */}
+ <TableCell>
+ {editingId === c.id ? (
+ // 편집 중일 때
+ <div className="flex gap-2">
+ <Button variant="outline" size="sm" onClick={() => handleSave(c.id)}>
+ Save
+ </Button>
+ <Button variant="ghost" size="sm" onClick={handleCancel}>
+ Cancel
+ </Button>
+ </div>
+ ) : (
+ // 일반 상태
+ <Button variant="outline" size="sm" onClick={() => handleEditClick(c)}>
+ Edit
+ </Button>
+ )}
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )
+ }
+
+ // 2) 새 파일 Drop
+ function handleDropAccepted(files: File[]) {
+ // 드롭된 File[]을 RHF field array에 추가
+ const toAppend = files.map((f) => f)
+ append(toAppend)
+ }
+
+
+ // 3) 저장(Submit)
+ async function onSubmit(data: CommentFormValues) {
+
+ if (!rfq) return
+ startTransition(async () => {
+ try {
+ // 서버 액션 호출
+ const res = await createRfqCommentWithAttachments({
+ rfqId: rfq.id,
+ vendorId: rfq.vendorId, // 필요시 세팅
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ evaluationId: null, // 필요시 세팅
+ files: data.newFiles
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create comment")
+ }
+
+ toast.success("Comment created")
+
+ // 새 코멘트를 다시 불러오거나,
+ // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트
+ const newComment: RfqComment = {
+ 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,
+ })) || [])
+ }
+ 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/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx b/lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx
new file mode 100644
index 00000000..ac8fa35e
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx
@@ -0,0 +1,421 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { ColumnDef } from "@tanstack/react-table"
+import {
+ Ellipsis,
+ MessageSquare,
+ Package,
+ Paperclip,
+} from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { Badge } from "@/components/ui/badge"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { modifyRfqVendor } from "../../rfqs/service"
+import type { RfqWithAll } from "../types"
+import type { DataTableRowAction } from "@/types/table"
+
+type NextRouter = ReturnType<typeof useRouter>
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<RfqWithAll> | null>
+ >
+ router: NextRouter
+ openAttachmentsSheet: (rfqId: number) => void
+ openCommentSheet: (rfqId: number) => void
+}
+
+/**
+ * tanstack table 컬럼 정의 (Nested Header)
+ */
+export function getColumns({
+ setRowAction,
+ router,
+ openAttachmentsSheet,
+ openCommentSheet,
+}: GetColumnsProps): ColumnDef<RfqWithAll>[] {
+ // 1) 체크박스(Select) 컬럼
+ const selectColumn: ColumnDef<RfqWithAll> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // 2) Actions (Dropdown)
+ const actionsColumn: ColumnDef<RfqWithAll> = {
+ id: "actions",
+ enableHiding: false,
+ cell: ({ row }) => {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="icon">
+ <Ellipsis className="h-4 w-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ <DropdownMenuSub>
+ <DropdownMenuSubTrigger>RFQ Response</DropdownMenuSubTrigger>
+ <DropdownMenuSubContent>
+ <DropdownMenuRadioGroup
+ value={row.original.responseStatus}
+ onValueChange={(value) => {
+ startUpdateTransition(async () => {
+ let newStatus:
+ | "ACCEPTED"
+ | "DECLINED"
+ | "REVIEWING"
+
+ switch (value) {
+ case "ACCEPTED":
+ newStatus = "ACCEPTED"
+ break
+ case "DECLINED":
+ newStatus = "DECLINED"
+ break
+ default:
+ newStatus = "REVIEWING"
+ }
+
+ await toast.promise(
+ modifyRfqVendor({
+ id: row.original.responseId,
+ status: newStatus,
+ }),
+ {
+ loading: "Updating response status...",
+ success: "Response status updated",
+ error: (err) => getErrorMessage(err),
+ }
+ )
+ })
+ }}
+ >
+ {[
+ { value: "ACCEPTED", label: "Accept RFQ" },
+ { value: "DECLINED", label: "Decline RFQ" },
+ ].map((rep) => (
+ <DropdownMenuRadioItem
+ key={rep.value}
+ value={rep.value}
+ className="capitalize"
+ disabled={isUpdatePending}
+ >
+ {rep.label}
+ </DropdownMenuRadioItem>
+ ))}
+ </DropdownMenuRadioGroup>
+ </DropdownMenuSubContent>
+ </DropdownMenuSub>
+ {/* <DropdownMenuItem
+ onClick={() => {
+ router.push(`/vendor/rfqs/${row.original.rfqId}`)
+ }}
+ >
+ View Details
+ </DropdownMenuItem> */}
+ {/* <DropdownMenuItem onClick={() => openAttachmentsSheet(row.original.rfqId)}>
+ View Attachments
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => openCommentSheet(row.original.rfqId)}>
+ View Comments
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "items" })}>
+ View Items
+ </DropdownMenuItem> */}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // 3) RFQ Code 컬럼
+ const rfqCodeColumn: ColumnDef<RfqWithAll> = {
+ id: "rfqCode",
+ accessorKey: "rfqCode",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ Code" />
+ ),
+ cell: ({ row }) => {
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto font-medium"
+ onClick={() => router.push(`/vendor/rfqs/${row.original.rfqId}`)}
+ >
+ {row.original.rfqCode}
+ </Button>
+ )
+ },
+ size: 150,
+ }
+
+ // 4) 응답 상태 컬럼
+ const responseStatusColumn: ColumnDef<RfqWithAll> = {
+ id: "responseStatus",
+ accessorKey: "responseStatus",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Response Status" />
+ ),
+ cell: ({ row }) => {
+ const status = row.original.responseStatus;
+ let variant: "default" | "secondary" | "destructive" | "outline";
+
+ switch (status) {
+ case "REVIEWING":
+ variant = "default";
+ break;
+ case "ACCEPTED":
+ variant = "secondary";
+ break;
+ case "DECLINED":
+ variant = "destructive";
+ break;
+ default:
+ variant = "outline";
+ }
+
+ return <Badge variant={variant}>{status}</Badge>;
+ },
+ size: 150,
+ }
+
+ // 5) 프로젝트 이름 컬럼
+ const projectNameColumn: ColumnDef<RfqWithAll> = {
+ id: "projectName",
+ accessorKey: "projectName",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Project" />
+ ),
+ cell: ({ row }) => row.original.projectName || "-",
+ size: 150,
+ }
+
+ // 6) RFQ Description 컬럼
+ const descriptionColumn: ColumnDef<RfqWithAll> = {
+ id: "rfqDescription",
+ accessorKey: "rfqDescription",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Description" />
+ ),
+ cell: ({ row }) => row.original.rfqDescription || "-",
+ size: 200,
+ }
+
+ // 7) Due Date 컬럼
+ const dueDateColumn: ColumnDef<RfqWithAll> = {
+ id: "rfqDueDate",
+ accessorKey: "rfqDueDate",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Due Date" />
+ ),
+ cell: ({ row }) => {
+ const date = row.original.rfqDueDate;
+ return date ? formatDate(date) : "-";
+ },
+ size: 120,
+ }
+
+ // 8) Last Updated 컬럼
+ const updatedAtColumn: ColumnDef<RfqWithAll> = {
+ id: "respondedAt",
+ accessorKey: "respondedAt",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Last Updated" />
+ ),
+ cell: ({ row }) => {
+ const date = row.original.respondedAt;
+ return date ? formatDateTime(date) : "-";
+ },
+ size: 150,
+ }
+
+ // 9) Items 컬럼 - 뱃지로 아이템 개수 표시
+ const itemsColumn: ColumnDef<RfqWithAll> = {
+ id: "items",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Items" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const count = rfq.items?.length ?? 0
+
+ function handleClick() {
+ setRowAction({ row, type: "items" })
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={count > 0 ? `View ${count} items` : "No items"}
+ >
+ <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {count > 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"
+ >
+ {count}
+ </Badge>
+ )}
+
+ <span className="sr-only">
+ {count > 0 ? `${count} Items` : "No Items"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80,
+ }
+
+ // 10) Attachments 컬럼 - 뱃지로 파일 개수 표시
+ const attachmentsColumn: ColumnDef<RfqWithAll> = {
+ id: "attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Attachments" />
+ ),
+ cell: ({ row }) => {
+ const attachCount = row.original.attachments?.length ?? 0
+
+ function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
+ e.preventDefault()
+ openAttachmentsSheet(row.original.rfqId)
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ attachCount > 0 ? `View ${attachCount} files` : "No files"
+ }
+ >
+ <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {attachCount > 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"
+ >
+ {attachCount}
+ </Badge>
+ )}
+ <span className="sr-only">
+ {attachCount > 0 ? `${attachCount} Files` : "No Files"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80,
+ }
+
+ // 11) Comments 컬럼 - 뱃지로 댓글 개수 표시
+ const commentsColumn: ColumnDef<RfqWithAll> = {
+ id: "comments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Comments" />
+ ),
+ cell: ({ row }) => {
+ const commCount = row.original.comments?.length ?? 0
+
+ function handleClick() {
+ setRowAction({ row, type: "comments" })
+ openCommentSheet(row.original.rfqId)
+ }
+
+ 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,
+ }
+
+ // 최종 컬럼 구성 - TBE/CBE 관련 컬럼 제외
+ return [
+ selectColumn,
+ rfqCodeColumn,
+ responseStatusColumn,
+ projectNameColumn,
+ descriptionColumn,
+ dueDateColumn,
+ itemsColumn,
+ attachmentsColumn,
+ commentsColumn,
+ updatedAtColumn,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx
new file mode 100644
index 00000000..1bae99ef
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx
@@ -0,0 +1,40 @@
+"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 { RfqWithAll } from "../types"
+
+
+interface RfqsTableToolbarActionsProps {
+ table: Table<RfqWithAll>
+}
+
+export function RfqsVendorTableToolbarActions({ table }: RfqsTableToolbarActionsProps) {
+
+
+ return (
+ <div className="flex items-center gap-2">
+
+ {/** 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>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx
new file mode 100644
index 00000000..337c2875
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx
@@ -0,0 +1,270 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { useRouter } from "next/navigation"
+
+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 "./rfqs-table-columns"
+import { RfqWithAll } from "../types"
+
+import {
+ fetchRfqAttachments,
+ fetchRfqAttachmentsbyCommentId,
+} from "../../rfqs/service"
+
+import { RfqsVendorTableToolbarActions } from "./rfqs-table-toolbar-actions"
+import { RfqsItemsDialog } from "./ItemsDialog"
+import { RfqAttachmentsSheet } from "./attachment-rfq-sheet"
+import { CommentSheet, RfqComment } from "./comments-sheet"
+import { getRfqResponsesForVendor } from "../service"
+
+interface RfqsTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getRfqResponsesForVendor>>]>
+}
+
+// 코멘트+첨부파일 구조 예시
+export interface RfqCommentWithAttachments extends RfqComment {
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ createdAt?: Date
+ vendorId?: number | null
+ size?: number
+ }[]
+}
+
+export interface ExistingAttachment {
+ id: number
+ fileName: string
+ filePath: string
+ createdAt?: Date
+ vendorId?: number | null
+ size?: number
+}
+
+export interface ExistingItem {
+ id?: number
+ itemCode: string
+ description: string | null
+ quantity: number | null
+ uom: string | null
+}
+
+export function RfqsVendorTable({ promises }: RfqsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ // 1) 테이블 데이터( RFQs )
+ const [{ data: responseData, pageCount }] = React.use(promises)
+
+ // 데이터를 RfqWithAll 타입으로 변환 (id 필드 추가)
+ const data: RfqWithAll[] = React.useMemo(() => {
+ return responseData.map(item => ({
+ ...item,
+ id: item.rfqId, // id 필드를 rfqId와 동일하게 설정
+ }));
+ }, [responseData]);
+
+ const router = useRouter()
+
+ // 2) 첨부파일 시트 + 관련 상태
+ const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
+ const [selectedRfqIdForAttachments, setSelectedRfqIdForAttachments] = React.useState<number | null>(null)
+ const [attachDefault, setAttachDefault] = React.useState<ExistingAttachment[]>([])
+
+ // 3) 코멘트 시트 + 관련 상태
+ const [initialComments, setInitialComments] = React.useState<RfqCommentWithAttachments[]>([])
+ const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
+ const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
+
+ // 4) rowAction으로 다양한 모달/시트 열기
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqWithAll> | null>(null)
+
+ // 열리고 닫힐 때마다, rowAction 등을 확인해서 시트 열기/닫기 처리
+ React.useEffect(() => {
+ if (rowAction?.type === "comments" && rowAction?.row.original) {
+ openCommentSheet(rowAction.row.original.id)
+ }
+ }, [rowAction])
+
+ /**
+ * (A) 코멘트 시트를 열기 전에,
+ * DB에서 (rfqId에 해당하는) 코멘트들 + 각 코멘트별 첨부파일을 조회.
+ */
+ const openCommentSheet = React.useCallback(async (rfqId: number) => {
+ setInitialComments([])
+
+ // 여기서 rowAction을 직접 참조하지 않고, 필요한 데이터만 파라미터로 받기
+ const comments = data.find(rfq => rfq.rfqId === rfqId)?.comments || []
+
+ if (comments && comments.length > 0) {
+ const commentWithAttachments = await Promise.all(
+ comments.map(async (c) => {
+ const attachments = await fetchRfqAttachmentsbyCommentId(c.id)
+ return {
+ ...c,
+ commentedBy: c.commentedBy || 1,
+ attachments,
+ }
+ })
+ )
+
+ setInitialComments(commentWithAttachments)
+ }
+
+ setSelectedRfqIdForComments(rfqId)
+ setCommentSheetOpen(true)
+ }, [data]) // data만 의존성으로 추가
+
+ /**
+ * (B) 첨부파일 시트 열기
+ */
+ const openAttachmentsSheet = React.useCallback(async (rfqId: number) => {
+ const list = await fetchRfqAttachments(rfqId)
+ setAttachDefault(list)
+ setSelectedRfqIdForAttachments(rfqId)
+ setAttachmentsOpen(true)
+ }, [])
+
+ // 5) DataTable 컬럼 세팅
+ const columns = React.useMemo(
+ () =>
+ getColumns({
+ setRowAction,
+ router,
+ openAttachmentsSheet,
+ openCommentSheet
+ }),
+ [setRowAction, router, openAttachmentsSheet, openCommentSheet]
+ )
+
+ /**
+ * 간단한 filterFields 예시
+ */
+ const filterFields: DataTableFilterField<RfqWithAll>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ Code",
+ placeholder: "Filter RFQ Code...",
+ },
+ {
+ id: "projectName",
+ label: "Project",
+ placeholder: "Filter Project...",
+ },
+ {
+ id: "rfqDescription",
+ label: "Description",
+ placeholder: "Filter Description...",
+ },
+ ]
+
+ /**
+ * Advanced filter fields 예시
+ */
+ const advancedFilterFields: DataTableAdvancedFilterField<RfqWithAll>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ Code",
+ type: "text",
+ },
+ {
+ id: "rfqDescription",
+ label: "Description",
+ type: "text",
+ },
+ {
+ id: "projectCode",
+ label: "Project Code",
+ type: "text",
+ },
+ {
+ id: "projectName",
+ label: "Project Name",
+ type: "text",
+ },
+ {
+ id: "rfqDueDate",
+ label: "Due Date",
+ type: "date",
+ },
+ {
+ id: "responseStatus",
+ label: "Response Status",
+ type: "select",
+ options: [
+ { label: "Reviewing", value: "REVIEWING" },
+ { label: "Accepted", value: "ACCEPTED" },
+ { label: "Declined", value: "DECLINED" },
+ ],
+ }
+ ]
+
+ // useDataTable() 훅 -> pagination, sorting 등 관리
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "respondedAt", 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}
+ >
+ <RfqsVendorTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 1) 아이템 목록 Dialog */}
+ {rowAction?.type === "items" && rowAction?.row.original && (
+ <RfqsItemsDialog
+ open={true}
+ onOpenChange={() => setRowAction(null)}
+ rfq={rowAction.row.original}
+ />
+ )}
+
+ {/* 2) 코멘트 시트 */}
+ {selectedRfqIdForComments && (
+ <CommentSheet
+ currentUserId={1}
+ open={commentSheetOpen}
+ onOpenChange={setCommentSheetOpen}
+ initialComments={initialComments}
+ rfq={data.find(item => item.rfqId === selectedRfqIdForComments)!}
+ />
+ )}
+
+ {/* 3) 첨부파일 시트 */}
+ <RfqAttachmentsSheet
+ open={attachmentsOpen}
+ onOpenChange={setAttachmentsOpen}
+ rfqId={selectedRfqIdForAttachments ?? 0}
+ attachments={attachDefault}
+ />
+ </>
+ )
+} \ 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
new file mode 100644
index 00000000..1eee54f5
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx
@@ -0,0 +1,334 @@
+"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 } 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 { RfqWithAll } from "@/db/schema/rfq"
+import { createRfqCommentWithAttachments } from "../../rfqs/service"
+import { formatDate } from "@/lib/utils"
+
+// 코멘트 + 첨부파일 구조 (단순 예시)
+// 실제 DB 스키마에 맞춰 조정
+export interface TbeComment {
+ id: number
+ commentText: string
+ commentedBy?: number
+ createdAt?: string | Date
+ attachments?: {
+ id: number
+ fileName: string
+ filePath: string
+ }[]
+}
+
+interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> {
+ /** 코멘트를 작성할 RFQ 정보 */
+ /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */
+ initialComments?: TbeComment[]
+
+ /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */
+ currentUserId: number
+ rfqId:number
+ vendorId:number
+ /** 댓글 저장 후 갱신용 콜백 (옵션) */
+ onCommentsUpdated?: (comments: TbeComment[]) => void
+}
+
+// 새 코멘트 작성 폼 스키마
+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,
+ onCommentsUpdated,
+ ...props
+}: CommentSheetProps) {
+ 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: []
+ }
+ })
+
+ // formFieldArray 예시 (파일 목록)
+ const { fields: newFileFields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "newFiles"
+ })
+
+ // 1) 기존 코멘트 + 첨부 보여주기
+ // 간단히 테이블 하나로 표현
+ // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음
+ function renderExistingComments() {
+ 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 || c.attachments.length === 0) && (
+ <span className="text-sm text-muted-foreground">No files</span>
+ )}
+ {c.attachments && c.attachments.length > 0 && (
+ <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}
+ 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.commentedBy ?? "-"}
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )
+ }
+
+ // 2) 새 파일 Drop
+ function handleDropAccepted(files: File[]) {
+ // 드롭된 File[]을 RHF field array에 추가
+ const toAppend = files.map((f) => f)
+ append(toAppend)
+ }
+
+
+ // 3) 저장(Submit)
+ async function onSubmit(data: CommentFormValues) {
+
+ if (!rfqId) return
+ startTransition(async () => {
+ try {
+ // 서버 액션 호출
+ const res = await createRfqCommentWithAttachments({
+ rfqId: rfqId,
+ vendorId: vendorId, // 필요시 세팅
+ commentText: data.commentText,
+ commentedBy: currentUserId,
+ evaluationId: null, // 필요시 세팅
+ files: data.newFiles
+ })
+
+ if (!res.ok) {
+ throw new Error("Failed to create comment")
+ }
+
+ toast.success("Comment created")
+
+ // 새 코멘트를 다시 불러오거나,
+ // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트
+ const newComment: TbeComment = {
+ id: res.commentId, // 서버에서 반환된 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,
+ })) || [])
+ }
+ 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/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx b/lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
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
new file mode 100644
index 00000000..34a53d17
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx
@@ -0,0 +1,317 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Download, MessageSquare, Upload } 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 { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { useRouter } from "next/navigation"
+
+import {
+ tbeVendorColumnsConfig,
+ VendorTbeColumnConfig,
+ vendorTbeColumnsConfig,
+ TbeVendorFields,
+} from "@/config/vendorTbeColumnsConfig"
+
+type NextRouter = ReturnType<typeof useRouter>
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<TbeVendorFields> | null>
+ >
+ router: NextRouter
+ openCommentSheet: (vendorId: number) => void
+ handleDownloadTbeTemplate: (tbeId: number, vendorId: number, rfqId: number) => void
+ handleUploadTbeResponse: (tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => void
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ handleDownloadTbeTemplate,
+ handleUploadTbeResponse,
+}: GetColumnsProps): ColumnDef<TbeVendorFields>[] {
+ // ----------------------------------------------------------------
+ // 1) Select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<TbeVendorFields> = {
+ 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<TbeVendorFields>[]> = {}
+
+ tbeVendorColumnsConfig.forEach((cfg) => {
+ const groupName = cfg.group || "_noGroup"
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // childCol: ColumnDef<TbeVendorFields>
+ const childCol: ColumnDef<TbeVendorFields> = {
+ 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 === "vendorStatus") {
+ const statusVal = row.original.vendorStatus
+ if (!statusVal) return null
+ // const Icon = getStatusIcon(statusVal)
+ return (
+ <Badge variant="outline">
+ {statusVal}
+ </Badge>
+ )
+ }
+
+
+ if (cfg.id === "rfqVendorStatus") {
+ const statusVal = row.original.rfqVendorStatus
+ if (!statusVal) return null
+ // const Icon = getStatusIcon(statusVal)
+ const variant = statusVal === "INVITED" ? "default" : statusVal === "REJECTED" ? "destructive" : statusVal === "ACCEPTED" ? "secondary" : "outline"
+ return (
+ <Badge variant={variant}>
+ {statusVal}
+ </Badge>
+ )
+ }
+
+ // 예) TBE Updated (날짜)
+ if (cfg.id === "tbeUpdated") {
+ const dateVal = val as Date | undefined
+ if (!dateVal) return null
+ return formatDate(dateVal)
+ }
+
+ // 그 외 필드는 기본 값 표시
+ return val ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // groupMap → nestedColumns
+ const nestedColumns: ColumnDef<TbeVendorFields>[] = []
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ nestedColumns.push(...colDefs)
+ } else {
+ nestedColumns.push({
+ id: groupName,
+ header: groupName,
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 3) Comments 컬럼
+ // ----------------------------------------------------------------
+ const commentsColumn: ColumnDef<TbeVendorFields> = {
+ 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.tbeId ?? 0)
+ }
+
+ 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>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80
+ }
+
+ // ----------------------------------------------------------------
+ // 4) TBE 다운로드 컬럼 - 템플릿 다운로드 기능
+ // ----------------------------------------------------------------
+ const tbeDownloadColumn: ColumnDef<TbeVendorFields> = {
+ id: "tbeDownload",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="TBE Sheets" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const tbeId = vendor.tbeId
+ const vendorId = vendor.vendorId
+ const rfqId = vendor.rfqId
+ const templateFileCount = vendor.templateFileCount || 0
+
+ if (!tbeId || !vendorId || !rfqId) {
+ return <div className="text-center text-muted-foreground">-</div>
+ }
+
+ // 템플릿 파일이 없으면 다운로드 버튼 비활성화
+ const isDisabled = templateFileCount <= 0
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={
+ isDisabled
+ ? undefined
+ : () => handleDownloadTbeTemplate(tbeId, vendorId, rfqId)
+ }
+ aria-label={
+ templateFileCount > 0
+ ? `TBE 템플릿 다운로드 (${templateFileCount}개)`
+ : "다운로드할 파일 없음"
+ }
+ disabled={isDisabled}
+ >
+ <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+
+ {/* 파일이 1개 이상인 경우 뱃지로 개수 표시 */}
+ {templateFileCount > 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"
+ >
+ {templateFileCount}
+ </Badge>
+ )}
+
+ <span className="sr-only">
+ {templateFileCount > 0
+ ? `TBE 템플릿 다운로드 (${templateFileCount}개)`
+ : "다운로드할 파일 없음"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80,
+ }
+ // ----------------------------------------------------------------
+ // 5) TBE 업로드 컬럼 - 응답 업로드 기능
+ // ----------------------------------------------------------------
+ const tbeUploadColumn: ColumnDef<TbeVendorFields> = {
+ id: "tbeUpload",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Upload Response" />
+ ),
+ cell: ({ row }) => {
+ const vendor = row.original
+ const tbeId = vendor.tbeId
+ const vendorId = vendor.vendorId
+ const rfqId = vendor.rfqId
+ const vendorResponseId = vendor.vendorResponseId
+ const status = vendor.rfqVendorStatus
+ const hasResponse = vendor.hasResponse || false
+
+
+ if (!tbeId || !vendorId || !rfqId || status === "REJECTED") {
+ return <div className="text-center text-muted-foreground">-</div>
+ }
+
+ return (
+ <div >
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0 group relative"
+ onClick={() => handleUploadTbeResponse(tbeId, vendorId, rfqId, vendorResponseId)}
+ aria-label={hasResponse ? "TBE 응답 확인" : "TBE 응답 업로드"}
+ >
+ <div className="flex items-center justify-center relative">
+ <Upload className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ </div>
+ {hasResponse && (
+ <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full" style={{ backgroundColor: '#10B981' }}></span>
+ )}
+ <span className="sr-only">
+ {"TBE 응답 업로드"}
+ </span>
+ </Button>
+ </div>
+ )
+ },
+ enableSorting: false,
+ maxSize: 80
+ }
+
+ // ----------------------------------------------------------------
+ // 6) 최종 컬럼 배열
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ commentsColumn,
+ tbeDownloadColumn,
+ tbeUploadColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx
new file mode 100644
index 00000000..3450a643
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx
@@ -0,0 +1,162 @@
+"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 { 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"
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getTBEforVendor>>,
+ ]
+ >
+}
+
+export function TbeVendorTable({ promises }: VendorsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+ 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<TbeVendorFields> | null>(null)
+
+
+ // router 획득
+ const router = useRouter()
+
+ const [initialComments, setInitialComments] = React.useState<TbeComment[]>([])
+ const [commentSheetOpen, setCommentSheetOpen] = React.useState(false)
+ const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null)
+
+ // TBE 파일 핸들러 훅 사용
+ const {
+ handleDownloadTbeTemplate,
+ handleUploadTbeResponse,
+ UploadDialog,
+ } = useTbeFileHandlers()
+
+ React.useEffect(() => {
+ if (rowAction?.type === "comments") {
+ // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행
+ openCommentSheet(Number(rowAction.row.original.id))
+ }
+ }, [rowAction])
+
+ async function openCommentSheet(vendorId: number) {
+ setInitialComments([])
+
+ 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)
+ }
+
+ setSelectedRfqIdForComments(vendorId)
+ setCommentSheetOpen(true)
+ }
+
+ // getColumns() 호출 시, 필요한 모든 핸들러 함수 주입
+ const columns = React.useMemo(
+ () => getColumns({
+ setRowAction,
+ router,
+ openCommentSheet,
+ handleDownloadTbeTemplate,
+ handleUploadTbeResponse,
+ }),
+ [setRowAction, router, openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse]
+ )
+
+ 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: "rfqVendorUpdated", label: "Updated at", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "rfqVendorUpdated", desc: true }],
+ columnPinning: { right: ["comments", "tbeDocuments"] }, // tbeDocuments 컬럼을 우측에 고정
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ />
+ </DataTable>
+
+ {/* 코멘트 시트 */}
+ {commentSheetOpen && selectedRfqIdForComments && (
+ <CommentSheet
+ open={commentSheetOpen}
+ onOpenChange={setCommentSheetOpen}
+ rfqId={selectedRfqIdForComments}
+ initialComments={initialComments}
+ vendorId={userVendorId||0}
+ currentUserId={userId||0}
+ />
+ )}
+
+ {/* TBE 파일 다이얼로그 */}
+ <UploadDialog />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx
new file mode 100644
index 00000000..3994b8eb
--- /dev/null
+++ b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx
@@ -0,0 +1,355 @@
+"use client";
+
+import { useCallback, useState, useEffect } from "react";
+import { toast } from "sonner";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import {
+ fetchTbeTemplateFiles,
+ getTbeTemplateFileInfo,
+ uploadTbeResponseFile,
+ getTbeSubmittedFiles,
+} from "../../rfqs/service";
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone";
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+ FileListSize,
+} from "@/components/ui/file-list";
+import { Download, X } from "lucide-react";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { formatDateTime } from "@/lib/utils";
+
+export function useTbeFileHandlers() {
+ // 모달 열림 여부, 현재 선택된 IDs
+ const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
+ const [currentTbeId, setCurrentTbeId] = useState<number | null>(null);
+ const [currentVendorId, setCurrentVendorId] = useState<number | null>(null);
+ const [currentRfqId, setCurrentRfqId] = useState<number | null>(null);
+ const [currentvendorResponseId, setCurrentvendorResponseId] = useState<number | null>(null);
+
+
+
+ // 로딩 상태들
+ const [isLoading, setIsLoading] = useState(false);
+ const [isFetchingFiles, setIsFetchingFiles] = useState(false);
+
+ // 업로드할 파일, 제출된 파일 목록
+ const [selectedFile, setSelectedFile] = useState<File | null>(null);
+ const [submittedFiles, setSubmittedFiles] = useState<
+ Array<{ id: number; fileName: string; filePath: string; uploadedAt: Date }>
+ >([]);
+
+ // ===================================
+ // 1) 제출된 파일 목록 가져오기
+ // ===================================
+ const fetchSubmittedFiles = useCallback(async (vendorResponseId: number) => {
+ if (!vendorResponseId ) return;
+
+ setIsFetchingFiles(true);
+ try {
+ const { files, error } = await getTbeSubmittedFiles(vendorResponseId);
+ if (error) {
+ console.error(error);
+ return;
+ }
+ setSubmittedFiles(files);
+ } catch (error) {
+ console.error("Failed to fetch submitted files:", error);
+ } finally {
+ setIsFetchingFiles(false);
+ }
+ }, []);
+
+ // ===================================
+ // 2) TBE 템플릿 다운로드
+ // ===================================
+ const handleDownloadTbeTemplate = useCallback(
+ async (tbeId: number, vendorId: number, rfqId: number) => {
+ setCurrentTbeId(tbeId);
+ setCurrentVendorId(vendorId);
+ setCurrentRfqId(rfqId);
+ setIsLoading(true);
+
+ try {
+ const { files, error } = await fetchTbeTemplateFiles(tbeId);
+ if (error) {
+ toast.error(error);
+ return;
+ }
+ if (files.length === 0) {
+ toast.warning("다운로드할 템플릿 파일이 없습니다");
+ return;
+ }
+ // 순차적으로 파일 다운로드
+ for (const file of files) {
+ await downloadFile(file.id);
+ }
+ toast.success("모든 템플릿 파일이 다운로드되었습니다");
+ } catch (error) {
+ toast.error("템플릿 파일을 다운로드하는 데 실패했습니다");
+ console.error(error);
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ []
+ );
+
+ // 실제 다운로드 로직
+ const downloadFile = useCallback(async (fileId: number) => {
+ try {
+ const { file, error } = await getTbeTemplateFileInfo(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;
+ }
+ }, []);
+
+ // ===================================
+ // 3) 제출된 파일 다운로드
+ // ===================================
+ const downloadSubmittedFile = useCallback((file: { id: number; fileName: string; filePath: string }) => {
+ try {
+ const link = document.createElement("a");
+ link.href = `/api/files/${file.filePath}`;
+ link.download = file.fileName;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ toast.success(`${file.fileName} 다운로드 시작`);
+ } catch (error) {
+ console.error("Failed to download file:", error);
+ toast.error("파일 다운로드에 실패했습니다");
+ }
+ }, []);
+
+ // ===================================
+ // 4) TBE 응답 업로드 모달 열기
+ // (이 시점에서는 데이터 fetch하지 않음)
+ // ===================================
+ const handleUploadTbeResponse = useCallback((tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => {
+ setCurrentTbeId(tbeId);
+ setCurrentVendorId(vendorId);
+ setCurrentRfqId(rfqId);
+ setCurrentvendorResponseId(vendorResponseId);
+ setIsUploadDialogOpen(true);
+ }, []);
+
+ // ===================================
+ // 5) Dialog 열고 닫힐 때 상태 초기화
+ // 열렸을 때 -> useEffect로 파일 목록 가져오기
+ // ===================================
+ useEffect(() => {
+ if (!isUploadDialogOpen) {
+ // 닫힐 때는 파일 상태들 초기화
+ setSelectedFile(null);
+ setSubmittedFiles([]);
+ }
+ }, [isUploadDialogOpen]);
+
+ useEffect(() => {
+ // Dialog가 열렸고, ID들이 유효하면
+ if (isUploadDialogOpen &&currentvendorResponseId) {
+ fetchSubmittedFiles(currentvendorResponseId);
+ }
+ }, [isUploadDialogOpen, currentvendorResponseId, fetchSubmittedFiles]);
+
+ // ===================================
+ // 6) 드롭존 파일 선택 & 제거
+ // ===================================
+ const handleFileDrop = useCallback((files: File[]) => {
+ if (files && files.length > 0) {
+ setSelectedFile(files[0]);
+ }
+ }, []);
+
+ const handleRemoveFile = useCallback(() => {
+ setSelectedFile(null);
+ }, []);
+
+ // ===================================
+ // 7) 응답 파일 업로드
+ // ===================================
+ const handleSubmitResponse = useCallback(async () => {
+ if (!selectedFile || !currentTbeId || !currentVendorId || !currentRfqId ||!currentvendorResponseId) {
+ toast.error("업로드할 파일을 선택해주세요");
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ // FormData 생성
+ const formData = new FormData();
+ formData.append("file", selectedFile);
+ formData.append("rfqId", currentRfqId.toString());
+ formData.append("vendorId", currentVendorId.toString());
+ formData.append("evaluationId", currentTbeId.toString());
+ formData.append("vendorResponseId", currentvendorResponseId.toString());
+
+ const result = await uploadTbeResponseFile(formData);
+ if (!result.success) {
+ throw new Error(result.error || "파일 업로드에 실패했습니다");
+ }
+
+ toast.success(result.message || "응답이 성공적으로 업로드되었습니다");
+
+ // 업로드 후 다시 제출된 파일 목록 가져오기
+ await fetchSubmittedFiles(currentvendorResponseId);
+
+ // 업로드 성공 시 선택 파일 초기화
+ setSelectedFile(null);
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "응답 업로드에 실패했습니다");
+ console.error(error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [selectedFile, currentTbeId, currentVendorId, currentRfqId, currentvendorResponseId,fetchSubmittedFiles]);
+
+ // ===================================
+ // 8) 실제 Dialog 컴포넌트
+ // ===================================
+ const UploadDialog = () => (
+ <Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}>
+ <DialogContent className="sm:max-w-lg">
+ <DialogHeader>
+ <DialogTitle>TBE 응답 파일</DialogTitle>
+ <DialogDescription>제출된 파일을 확인하거나 새 파일을 업로드하세요.</DialogDescription>
+ </DialogHeader>
+
+ <Tabs defaultValue="upload" className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="upload">새 파일 업로드</TabsTrigger>
+ <TabsTrigger
+ value="submitted"
+ disabled={submittedFiles.length === 0}
+ className={submittedFiles.length > 0 ? "relative" : ""}
+ >
+ 제출된 파일{" "}
+ {submittedFiles.length > 0 && (
+ <span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary w-4 h-4 text-[10px] text-primary-foreground">
+ {submittedFiles.length}
+ </span>
+ )}
+ </TabsTrigger>
+ </TabsList>
+
+ {/* 업로드 탭 */}
+ <TabsContent value="upload" className="pt-4">
+ <div className="grid gap-4">
+ {selectedFile ? (
+ <FileList>
+ <FileListItem>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{selectedFile.name}</FileListName>
+ <FileListSize>{selectedFile.size}</FileListSize>
+ </FileListInfo>
+ <FileListAction>
+ <Button variant="ghost" size="icon" onClick={handleRemoveFile}>
+ <X className="h-4 w-4" />
+ <span className="sr-only">파일 제거</span>
+ </Button>
+ </FileListAction>
+ </FileListItem>
+ </FileList>
+ ) : (
+ <Dropzone onDrop={handleFileDrop}>
+ <DropzoneInput className="sr-only" />
+ <DropzoneZone className="flex flex-col items-center justify-center gap-2 p-6">
+ <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" />
+ <DropzoneTitle>파일을 드래그하거나 클릭하여 업로드</DropzoneTitle>
+ <DropzoneDescription>TBE 응답 파일 (XLSX, XLS, DOCX, PDF 등)</DropzoneDescription>
+ </DropzoneZone>
+ </Dropzone>
+ )}
+
+ <DialogFooter className="mt-4">
+ <Button type="submit" onClick={handleSubmitResponse} disabled={!selectedFile || isLoading}>
+ {isLoading ? "업로드 중..." : "응답 업로드"}
+ </Button>
+ </DialogFooter>
+ </div>
+ </TabsContent>
+
+ {/* 제출된 파일 탭 */}
+ <TabsContent value="submitted" className="pt-4">
+ {isFetchingFiles ? (
+ <div className="flex justify-center items-center py-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
+ </div>
+ ) : submittedFiles.length > 0 ? (
+ <div className="grid gap-2">
+ <FileList>
+ {submittedFiles.map((file) => (
+ <FileListItem key={file.id} className="flex items-center justify-between gap-3">
+ <div className="flex items-center gap-3 flex-1">
+ <FileListIcon className="flex-shrink-0" />
+ <FileListInfo className="flex-1 min-w-0">
+ <FileListName className="text-sm font-medium truncate">{file.fileName}</FileListName>
+ <FileListDescription className="text-xs text-muted-foreground">
+ {file.uploadedAt ? formatDateTime(file.uploadedAt) : ""}
+ </FileListDescription>
+ </FileListInfo>
+ </div>
+ <FileListAction className="flex-shrink-0 ml-2">
+ <Button variant="ghost" size="icon" onClick={() => downloadSubmittedFile(file)}>
+ <Download className="h-4 w-4" />
+ <span className="sr-only">파일 다운로드</span>
+ </Button>
+ </FileListAction>
+ </FileListItem>
+ ))}
+ </FileList>
+ </div>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">제출된 파일이 없습니다.</div>
+ )}
+ </TabsContent>
+ </Tabs>
+ </DialogContent>
+ </Dialog>
+ );
+
+ // ===================================
+ // 9) Hooks 내보내기
+ // ===================================
+ return {
+ handleDownloadTbeTemplate,
+ handleUploadTbeResponse,
+ UploadDialog,
+ };
+} \ No newline at end of file
diff --git a/lib/vendors/contacts-table/add-contact-dialog.tsx b/lib/vendors/contacts-table/add-contact-dialog.tsx
new file mode 100644
index 00000000..5376583a
--- /dev/null
+++ b/lib/vendors/contacts-table/add-contact-dialog.tsx
@@ -0,0 +1,175 @@
+"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 {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+
+import {
+ createVendorContactSchema,
+ type CreateVendorContactSchema,
+} from "../validations"
+import { createVendorContact } from "../service"
+
+interface AddContactDialogProps {
+ vendorId: number
+}
+
+export function AddContactDialog({ vendorId }: AddContactDialogProps) {
+ const [open, setOpen] = React.useState(false)
+
+ // react-hook-form 세팅
+ const form = useForm<CreateVendorContactSchema>({
+ resolver: zodResolver(createVendorContactSchema),
+ defaultValues: {
+ // vendorId는 form에 표시할 필요가 없다면 hidden으로 관리하거나, submit 시 추가
+ vendorId,
+ contactName: "",
+ contactPosition: "",
+ contactEmail: "",
+ contactPhone: "",
+ isPrimary: false,
+ },
+ })
+
+ async function onSubmit(data: CreateVendorContactSchema) {
+ // 혹은 여기서 data.vendorId = vendorId; 해줘도 됨
+ const result = await createVendorContact(data)
+ if (result.error) {
+ alert(`에러: ${result.error}`)
+ return
+ }
+ // 성공 시 모달 닫고 폼 리셋
+ form.reset()
+ setOpen(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 Contact
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Create New Contact</DialogTitle>
+ <DialogDescription>
+ 새 Contact 정보를 입력하고 <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="contactName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Contact Name</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 홍길동" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactPosition"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Position / Title</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 과장" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Email</FormLabel>
+ <FormControl>
+ <Input placeholder="name@company.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactPhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Phone</FormLabel>
+ <FormControl>
+ <Input placeholder="010-1234-5678" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 단순 checkbox */}
+ <FormField
+ control={form.control}
+ name="isPrimary"
+ render={({ field }) => (
+ <FormItem>
+ <div className="flex items-center space-x-2 mt-2">
+ <input
+ type="checkbox"
+ checked={field.value}
+ onChange={(e) => field.onChange(e.target.checked)}
+ />
+ <FormLabel>Is Primary?</FormLabel>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={() => setOpen(false)}>
+ Cancel
+ </Button>
+ <Button type="submit" disabled={form.formState.isSubmitting}>
+ Create
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/contacts-table/contact-table-columns.tsx b/lib/vendors/contacts-table/contact-table-columns.tsx
new file mode 100644
index 00000000..f80fae33
--- /dev/null
+++ b/lib/vendors/contacts-table/contact-table-columns.tsx
@@ -0,0 +1,195 @@
+"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 { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+
+import { VendorContact, vendors } from "@/db/schema/vendors"
+import { modifyVendor } from "../service"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { vendorContactsColumnsConfig } from "@/config/vendorContactsColumnsConfig"
+
+
+
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorContact> | null>>;
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorContact>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<VendorContact> = {
+ 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<VendorContact> = {
+ 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<VendorContact>[] }
+ const groupMap: Record<string, ColumnDef<VendorContact>[]> = {}
+
+ vendorContactsColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<VendorContact> = {
+ 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") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ if (cfg.id === "updatedAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+
+ // code etc...
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<VendorContact>[] = []
+
+ // 순서를 고정하고 싶다면 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/vendors/contacts-table/contact-table-toolbar-actions.tsx b/lib/vendors/contacts-table/contact-table-toolbar-actions.tsx
new file mode 100644
index 00000000..8aef6953
--- /dev/null
+++ b/lib/vendors/contacts-table/contact-table-toolbar-actions.tsx
@@ -0,0 +1,106 @@
+"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"
+
+
+// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import
+import { importTasksExcel } from "@/lib/tasks/service" // 예시
+import { VendorContact } from "@/db/schema/vendors"
+import { AddContactDialog } from "./add-contact-dialog"
+
+interface VendorsTableToolbarActionsProps {
+ table: Table<VendorContact>
+ vendorId: number
+}
+
+export function VendorsTableToolbarActions({ table,vendorId }: VendorsTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일이 선택되었을 때 처리
+ async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록)
+ event.target.value = ""
+
+ // 서버 액션 or API 호출
+ try {
+ // 예: 서버 액션 호출
+ const { errorFile, errorMessage } = await importTasksExcel(file)
+
+ if (errorMessage) {
+ toast.error(errorMessage)
+ }
+ if (errorFile) {
+ // 에러 엑셀을 다운로드
+ const url = URL.createObjectURL(errorFile)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "errors.xlsx"
+ link.click()
+ URL.revokeObjectURL(url)
+ } else {
+ // 성공
+ toast.success("Import success")
+ // 필요 시 revalidateTag("tasks") 등
+ }
+
+ } catch (err) {
+ toast.error("파일 업로드 중 오류가 발생했습니다.")
+
+ }
+ }
+
+ function handleImportClick() {
+ // 숨겨진 <input type="file" /> 요소를 클릭
+ fileInputRef.current?.click()
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+
+ <AddContactDialog vendorId={vendorId}/>
+
+ {/** 3) Import 버튼 (파일 업로드) */}
+ <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}>
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+ {/*
+ 실제로는 숨겨진 input과 연결:
+ - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용
+ */}
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".xlsx,.xls"
+ className="hidden"
+ onChange={onFileChange}
+ />
+
+ {/** 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>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/contacts-table/contact-table.tsx b/lib/vendors/contacts-table/contact-table.tsx
new file mode 100644
index 00000000..2991187e
--- /dev/null
+++ b/lib/vendors/contacts-table/contact-table.tsx
@@ -0,0 +1,87 @@
+"use client"
+
+import * as React from "react"
+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 { useFeatureFlags } from "./feature-flags-provider"
+import { getColumns } from "./contact-table-columns"
+import { getVendorContacts, } from "../service"
+import { VendorContact, vendors } from "@/db/schema/vendors"
+import { VendorsTableToolbarActions } from "./contact-table-toolbar-actions"
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getVendorContacts>>,
+ ]
+ >,
+ vendorId:number
+}
+
+export function VendorContactsTable({ promises , vendorId}: VendorsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorContact> | null>(null)
+
+ // getColumns() 호출 시, router를 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ const filterFields: DataTableFilterField<VendorContact>[] = [
+
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorContact>[] = [
+ { id: "contactName", label: "Contact Name", type: "text" },
+ { id: "contactPosition", label: "Contact Position", type: "text" },
+ { id: "contactEmail", label: "Contact Email", type: "text" },
+ { id: "contactPhone", label: "Contact Phone", type: "text" },
+ { id: "createdAt", label: "Created at", type: "date" },
+ { id: "updatedAt", label: "Updated at", 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}
+ shallow={false}
+ >
+ <VendorsTableToolbarActions table={table} vendorId={vendorId} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/contacts-table/feature-flags-provider.tsx b/lib/vendors/contacts-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/vendors/contacts-table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/vendors/items-table/add-item-dialog.tsx b/lib/vendors/items-table/add-item-dialog.tsx
new file mode 100644
index 00000000..6bbcc436
--- /dev/null
+++ b/lib/vendors/items-table/add-item-dialog.tsx
@@ -0,0 +1,289 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Check, ChevronsUpDown } from "lucide-react"
+
+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 {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import { cn } from "@/lib/utils"
+
+import {
+ createVendorItemSchema,
+ type CreateVendorItemSchema,
+} from "../validations"
+
+import { createVendorItem, getItemsForVendor, ItemDropdownOption } from "../service"
+
+interface AddItemDialogProps {
+ vendorId: number
+}
+
+export function AddItemDialog({ vendorId }: AddItemDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [commandOpen, setCommandOpen] = React.useState(false)
+ const [items, setItems] = React.useState<ItemDropdownOption[]>([])
+ const [filteredItems, setFilteredItems] = React.useState<ItemDropdownOption[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [searchTerm, setSearchTerm] = React.useState("")
+
+ // 선택된 아이템의 정보를 보여주기 위한 상태
+ const [selectedItem, setSelectedItem] = React.useState<{
+ itemName: string;
+ description: string;
+ } | null>(null)
+
+ // react-hook-form 세팅 - 서버로 보낼 값은 vendorId와 itemCode만
+ const form = useForm<CreateVendorItemSchema>({
+ resolver: zodResolver(createVendorItemSchema),
+ defaultValues: {
+ vendorId,
+ itemCode: "",
+ },
+ })
+
+ console.log(vendorId)
+
+ // 아이템 목록 가져오기 (한 번만 호출)
+ const fetchItems = React.useCallback(async () => {
+ if (items.length > 0) return // 이미 로드된 경우 스킵
+
+ setIsLoading(true)
+ try {
+ const result = await getItemsForVendor(vendorId)
+ if (result.data) {
+ setItems(result.data)
+ setFilteredItems(result.data)
+ }
+ } catch (error) {
+ console.error("Failed to fetch items:", error)
+ } finally {
+ setIsLoading(false)
+ }
+ }, [items.length])
+
+ // 팝오버 열릴 때 아이템 목록 로드
+ React.useEffect(() => {
+ if (commandOpen) {
+ fetchItems()
+ }
+ }, [commandOpen, fetchItems])
+
+ // 클라이언트 사이드 필터링
+ React.useEffect(() => {
+ if (!items.length) return
+
+ if (!searchTerm.trim()) {
+ setFilteredItems(items)
+ return
+ }
+
+ const lowerSearch = searchTerm.toLowerCase()
+ const filtered = items.filter(item =>
+ item.itemCode.toLowerCase().includes(lowerSearch) ||
+ item.itemName.toLowerCase().includes(lowerSearch) ||
+ (item.description && item.description.toLowerCase().includes(lowerSearch))
+ )
+
+ setFilteredItems(filtered)
+ }, [searchTerm, items])
+
+ // 선택된 아이템 데이터로 폼 업데이트
+ const handleSelectItem = (item: ItemDropdownOption) => {
+ // 폼에는 itemCode만 설정
+ form.setValue("itemCode", item.itemCode)
+
+ // 나머지 정보는 표시용 상태에 저장
+ setSelectedItem({
+ itemName: item.itemName,
+ description: item.description || "",
+ })
+
+ setCommandOpen(false)
+ }
+
+ // 폼 제출 - itemCode만 서버로 전송
+ async function onSubmit(data: CreateVendorItemSchema) {
+ // 서버에는 vendorId와 itemCode만 전송됨
+ const result = await createVendorItem(data)
+ console.log(result)
+ if (result.error) {
+ alert(`에러: ${result.error}`)
+ return
+ }
+ // 성공 시 모달 닫고 폼 리셋
+ form.reset()
+ setSelectedItem(null)
+ setOpen(false)
+ }
+
+ // 모달 열림/닫힘 핸들
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ // 닫힐 때 폼 리셋
+ form.reset()
+ setSelectedItem(null)
+ }
+ setOpen(nextOpen)
+ }
+
+ // 현재 선택된 아이템 코드
+ const selectedItemCode = form.watch("itemCode")
+
+ // 선택된 아이템 코드가 있으면 상세 정보 표시를 위한 아이템 찾기
+ const displayItemCode = selectedItemCode || "아이템 선택..."
+ const displayItemName = selectedItem?.itemName || ""
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ {/* 모달 열기 버튼 */}
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ Add Item
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="max-h-[90vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle>Create New Item</DialogTitle>
+ <DialogDescription>
+ 아이템을 선택한 후 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* shadcn/ui Form + react-hook-form */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 overflow-hidden">
+ <div className="space-y-4 py-4 flex-1 overflow-y-auto">
+
+ {/* 아이템 선택 */}
+ <div>
+ <FormLabel className="text-sm font-medium">아이템 선택</FormLabel>
+ <Popover open={commandOpen} onOpenChange={setCommandOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={commandOpen}
+ className="w-full justify-between mt-1"
+ >
+ {selectedItemCode
+ ? `${selectedItemCode} - ${displayItemName}`
+ : "아이템 선택..."}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="아이템 코드/이름 검색..."
+ onValueChange={setSearchTerm}
+ />
+ <CommandList className="max-h-[200px]">
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+ {isLoading ? (
+ <div className="py-6 text-center text-sm">로딩 중...</div>
+ ) : (
+ <CommandGroup>
+ {filteredItems.map((item) => (
+ <CommandItem
+ key={item.itemCode}
+ value={`${item.itemCode} ${item.itemName}`}
+ onSelect={() => handleSelectItem(item)}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ selectedItemCode === item.itemCode
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ <span className="font-medium">{item.itemCode}</span>
+ <span className="ml-2 text-gray-500 truncate">- {item.itemName}</span>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ )}
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ {/* 아이템 정보 영역 - 선택된 경우에만 표시 */}
+ {selectedItem && (
+ <div className="rounded-md border p-3 mt-4 overflow-hidden">
+ <h3 className="font-medium text-sm mb-2">선택된 아이템 정보</h3>
+
+ {/* Item Code - readonly (hidden field) */}
+ <FormField
+ control={form.control}
+ name="itemCode"
+ render={({ field }) => (
+ <FormItem className="hidden">
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+
+ {/* Item Name (표시용) */}
+ <div className="mb-2">
+ <p className="text-xs font-medium text-gray-500">Item Name</p>
+ <p className="text-sm mt-0.5 break-words">{selectedItem.itemName}</p>
+ </div>
+
+ {/* Description (표시용) */}
+ {selectedItem.description && (
+ <div>
+ <p className="text-xs font-medium text-gray-500">Description</p>
+ <p className="text-sm mt-0.5 break-words max-h-20 overflow-y-auto">{selectedItem.description}</p>
+ </div>
+ )}
+ </div>
+ )}
+
+ </div>
+
+ <DialogFooter className="flex-shrink-0 pt-2">
+ <Button type="button" variant="outline" onClick={() => setOpen(false)}>
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={form.formState.isSubmitting || !selectedItemCode}
+ >
+ Create
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/items-table/feature-flags-provider.tsx b/lib/vendors/items-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/vendors/items-table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/vendors/items-table/item-table-columns.tsx b/lib/vendors/items-table/item-table-columns.tsx
new file mode 100644
index 00000000..b5d26434
--- /dev/null
+++ b/lib/vendors/items-table/item-table-columns.tsx
@@ -0,0 +1,197 @@
+"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 { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+
+import { VendorItemsView, vendors } from "@/db/schema/vendors"
+import { modifyVendor } from "../service"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { vendorItemsColumnsConfig } from "@/config/vendorItemsColumnsConfig"
+
+
+
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorItemsView> | null>>;
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorItemsView>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<VendorItemsView> = {
+ 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<VendorItemsView> = {
+ 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<VendorItemsView>[] }
+ const groupMap: Record<string, ColumnDef<VendorItemsView>[]> = {}
+
+ vendorItemsColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<VendorItemsView> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ minSize: cfg.minWidth,
+ size: cfg.defaultWidth,
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+
+
+ if (cfg.id === "createdAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ if (cfg.id === "updatedAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+
+ // code etc...
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<VendorItemsView>[] = []
+
+ // 순서를 고정하고 싶다면 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/vendors/items-table/item-table-toolbar-actions.tsx b/lib/vendors/items-table/item-table-toolbar-actions.tsx
new file mode 100644
index 00000000..f7bd2bf6
--- /dev/null
+++ b/lib/vendors/items-table/item-table-toolbar-actions.tsx
@@ -0,0 +1,106 @@
+"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"
+
+
+// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import
+import { importTasksExcel } from "@/lib/tasks/service" // 예시
+import { VendorItemsView } from "@/db/schema/vendors"
+import { AddItemDialog } from "./add-item-dialog"
+
+interface VendorsTableToolbarActionsProps {
+ table: Table<VendorItemsView>
+ vendorId: number
+}
+
+export function VendorsTableToolbarActions({ table,vendorId }: VendorsTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일이 선택되었을 때 처리
+ async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록)
+ event.target.value = ""
+
+ // 서버 액션 or API 호출
+ try {
+ // 예: 서버 액션 호출
+ const { errorFile, errorMessage } = await importTasksExcel(file)
+
+ if (errorMessage) {
+ toast.error(errorMessage)
+ }
+ if (errorFile) {
+ // 에러 엑셀을 다운로드
+ const url = URL.createObjectURL(errorFile)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "errors.xlsx"
+ link.click()
+ URL.revokeObjectURL(url)
+ } else {
+ // 성공
+ toast.success("Import success")
+ // 필요 시 revalidateTag("tasks") 등
+ }
+
+ } catch (err) {
+ toast.error("파일 업로드 중 오류가 발생했습니다.")
+
+ }
+ }
+
+ function handleImportClick() {
+ // 숨겨진 <input type="file" /> 요소를 클릭
+ fileInputRef.current?.click()
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+
+ <AddItemDialog vendorId={vendorId}/>
+
+ {/** 3) Import 버튼 (파일 업로드) */}
+ <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}>
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+ {/*
+ 실제로는 숨겨진 input과 연결:
+ - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용
+ */}
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".xlsx,.xls"
+ className="hidden"
+ onChange={onFileChange}
+ />
+
+ {/** 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>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/items-table/item-table.tsx b/lib/vendors/items-table/item-table.tsx
new file mode 100644
index 00000000..d8cd0ea2
--- /dev/null
+++ b/lib/vendors/items-table/item-table.tsx
@@ -0,0 +1,85 @@
+"use client"
+
+import * as React from "react"
+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 { useFeatureFlags } from "./feature-flags-provider"
+import { getColumns } from "./item-table-columns"
+import { getVendorItems, } from "../service"
+import { VendorItemsView, vendors } from "@/db/schema/vendors"
+import { VendorsTableToolbarActions } from "./item-table-toolbar-actions"
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getVendorItems>>,
+ ]
+ >,
+ vendorId:number
+}
+
+export function VendorItemsTable({ promises , vendorId}: VendorsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorItemsView> | null>(null)
+
+ // getColumns() 호출 시, router를 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ const filterFields: DataTableFilterField<VendorItemsView>[] = [
+
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorItemsView>[] = [
+ { id: "itemName", label: "Item Name", type: "text" },
+ { id: "itemCode", label: "Item Code", type: "text" },
+ { id: "description", label: "Description", type: "text" },
+ { id: "createdAt", label: "Created at", type: "date" },
+ { id: "updatedAt", label: "Updated at", 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.itemCode),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <VendorsTableToolbarActions table={table} vendorId={vendorId} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/repository.ts b/lib/vendors/repository.ts
new file mode 100644
index 00000000..ff195932
--- /dev/null
+++ b/lib/vendors/repository.ts
@@ -0,0 +1,282 @@
+// src/lib/vendors/repository.ts
+
+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 db from '@/db/db';
+import { items } from "@/db/schema/items";
+import { rfqs,rfqItems, rfqEvaluations, vendorResponses } from "@/db/schema/rfq";
+import { sql } from "drizzle-orm";
+
+interface SelectVendorsOptions {
+ where?: any;
+ orderBy?: any[];
+ offset?: number;
+ limit?: number;
+}
+export declare function asc(column: AnyColumn | SQLWrapper): SQL;
+export declare function desc(column: AnyColumn | SQLWrapper): SQL;
+export type NewVendorContact = typeof vendorContacts.$inferInsert
+export type NewVendorItem = typeof vendorPossibleItems.$inferInsert
+
+/**
+ * 1) SELECT (목록 조회)
+ */
+export async function selectVendors(
+ tx: PgTransaction<any, any, any>,
+ { where, orderBy, offset, limit }: SelectVendorsOptions
+) {
+ return tx
+ .select()
+ .from(vendors)
+ .where(where ?? undefined)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset ?? 0)
+ .limit(limit ?? 20);
+}
+
+/**
+ * 2) COUNT
+ */
+export async function countVendors(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+ ) {
+ const res = await tx.select({ count: count() }).from(vendors).where(where);
+ return res[0]?.count ?? 0;
+ }
+
+
+/**
+ * 3) INSERT (단일 벤더 생성)
+ * - id/createdAt/updatedAt은 DB default 사용
+ * - 반환값은 "생성된 레코드" 배열 ([newVendor])
+ */
+export async function insertVendor(
+ tx: PgTransaction<any, any, any>,
+ data: Omit<Vendor, "id" | "createdAt" | "updatedAt">
+) {
+ return tx.insert(vendors).values(data).returning();
+}
+
+/**
+ * 4) UPDATE (단일 벤더)
+ */
+export async function updateVendor(
+ tx: PgTransaction<any, any, any>,
+ id: string,
+ data: Partial<Vendor>
+) {
+ return tx
+ .update(vendors)
+ .set(data)
+ .where(eq(vendors.id, Number(id)))
+ .returning();
+}
+
+/**
+ * 5) UPDATE (복수 벤더)
+ * - 여러 개의 id를 받아 일괄 업데이트
+ */
+export async function updateVendors(
+ tx: PgTransaction<any, any, any>,
+ ids: string[],
+ data: Partial<Vendor>
+) {
+ const numericIds = ids.map((i) => Number(i));
+ return tx
+ .update(vendors)
+ .set(data)
+ .where(inArray(vendors.id, numericIds))
+ .returning();
+}
+
+/** status 기준 groupBy */
+export async function groupByStatus(
+ tx: PgTransaction<any, any, any>,
+) {
+ return tx
+ .select({
+ status: vendors.status,
+ count: count(),
+ })
+ .from(vendors)
+ .groupBy(vendors.status)
+ .having(gt(count(), 0));
+}
+
+
+// ID로 사용자 조회
+export const getVendorById = async (id: number): Promise<Vendor | null> => {
+ const vendorsRes = await db.select().from(vendors).where(eq(vendors.id, id)).execute();
+ if (vendorsRes.length === 0) return null;
+
+ const vendor = vendorsRes[0];
+ return vendor
+};
+
+export const getVendorContactsById = async (id: number): Promise<VendorContact | null> => {
+ const contactsRes = await db.select().from(vendorContacts).where(eq(vendorContacts.vendorId, id)).execute();
+ if (contactsRes.length === 0) return null;
+
+ const contact = contactsRes[0];
+ return contact
+};
+
+export async function selectVendorContacts(
+ 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(vendorContacts)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+
+export async function countVendorContacts(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(vendorContacts).where(where);
+ return res[0]?.count ?? 0;
+}
+
+export async function insertVendorContact(
+ tx: PgTransaction<any, any, any>,
+ data: NewVendorContact // DB와 동일한 insert 가능한 타입
+) {
+ // returning() 사용 시 배열로 돌아오므로 [0]만 리턴
+ return tx
+ .insert(vendorContacts)
+ .values(data)
+ .returning({ id: vendorContacts.id, createdAt: vendorContacts.createdAt });
+}
+
+
+export async function selectVendorItems(
+ 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({
+ // vendor_possible_items cols
+ vendorItemId: vendorItemsView.vendorItemId,
+ vendorId: vendorItemsView.vendorId,
+ itemCode: vendorItemsView.itemCode,
+ createdAt: vendorItemsView.createdAt,
+ updatedAt: vendorItemsView.updatedAt,
+ itemName: vendorItemsView.itemName,
+ description: vendorItemsView.description,
+ })
+ .from(vendorItemsView)
+ .where(where ?? undefined)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+
+export async function countVendorItems(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(vendorItemsView).where(where);
+ return res[0]?.count ?? 0;
+}
+
+export async function insertVendorItem(
+ tx: PgTransaction<any, any, any>,
+ data: NewVendorItem // DB와 동일한 insert 가능한 타입
+) {
+ // returning() 사용 시 배열로 돌아오므로 [0]만 리턴
+ return tx
+ .insert(vendorPossibleItems)
+ .values(data)
+ .returning({ id: vendorPossibleItems.id, createdAt: vendorPossibleItems.createdAt });
+}
+
+export async function selectRfqHistory(
+ tx: PgTransaction<any, any, any>,
+ { where, orderBy, offset, limit }: SelectVendorsOptions
+) {
+ return tx
+ .select({
+ // RFQ 기본 정보
+ id: rfqs.id,
+ rfqCode: rfqs.rfqCode,
+
+ description: rfqs.description,
+ dueDate: rfqs.dueDate,
+ status: rfqs.status,
+ createdAt: rfqs.createdAt,
+
+
+ // Item 정보 (집계)
+ itemCount: sql<number>`count(distinct ${rfqItems.id})::integer`,
+
+ // 평가 정보
+ tbeResult: sql<string>`
+ (select result from ${rfqEvaluations}
+ where rfq_id = ${rfqs.id}
+ and vendor_id = ${vendorResponses.vendorId}
+ and eval_type = 'TBE'
+ limit 1)`,
+ cbeResult: sql<string>`
+ (select result from ${rfqEvaluations}
+ where rfq_id = ${rfqs.id}
+ and vendor_id = ${vendorResponses.vendorId}
+ and eval_type = 'CBE'
+ limit 1)`
+ })
+ .from(rfqs)
+ .innerJoin(vendorResponses, eq(rfqs.id, vendorResponses.rfqId))
+
+ .leftJoin(rfqItems, eq(rfqs.id, rfqItems.rfqId))
+ .where(where ?? undefined)
+ .groupBy(
+ rfqs.id,
+ rfqs.rfqCode,
+
+ rfqs.description,
+ rfqs.dueDate,
+ rfqs.status,
+ rfqs.createdAt,
+
+ vendorResponses.vendorId,
+
+ )
+ .orderBy(...(orderBy ?? []))
+ .offset(offset ?? 0)
+ .limit(limit ?? 20);
+}
+
+export async function countRfqHistory(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const [{ count }] = await tx
+ .select({
+ count: sql<number>`count(distinct ${rfqs.id})::integer`,
+ })
+ .from(rfqs)
+ .innerJoin(vendorResponses, eq(rfqs.id, vendorResponses.rfqId))
+ .where(where ?? undefined);
+
+ return count;
+}
diff --git a/lib/vendors/rfq-history-table/feature-flags-provider.tsx b/lib/vendors/rfq-history-table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/vendors/rfq-history-table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/vendors/rfq-history-table/rfq-history-table-columns.tsx b/lib/vendors/rfq-history-table/rfq-history-table-columns.tsx
new file mode 100644
index 00000000..7e22e96a
--- /dev/null
+++ b/lib/vendors/rfq-history-table/rfq-history-table-columns.tsx
@@ -0,0 +1,223 @@
+"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 { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+
+import { VendorItem, vendors } from "@/db/schema/vendors"
+import { modifyVendor } from "../service"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { getRFQStatusIcon } from "@/lib/tasks/utils"
+import { rfqHistoryColumnsConfig } from "@/config/rfqHistoryColumnsConfig"
+
+export interface RfqHistoryRow {
+ id: number;
+ rfqCode: string | null;
+ projectCode: string | null;
+ projectName: string | null;
+ description: string | null;
+ dueDate: Date;
+ status: "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED";
+ vendorStatus: string;
+ totalAmount: number | null;
+ currency: string | null;
+ leadTime: string | null;
+ itemCount: number;
+ tbeResult: string | null;
+ cbeResult: string | null;
+ createdAt: Date;
+ items: {
+ rfqId: number;
+ id: number;
+ itemCode: string;
+ description: string | null;
+ quantity: number | null;
+ uom: string | null;
+ }[];
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RfqHistoryRow> | null>>;
+ openItemsModal: (rfqId: number) => void;
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction, openItemsModal }: GetColumnsProps): ColumnDef<RfqHistoryRow>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<RfqHistoryRow> = {
+ 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<RfqHistoryRow> = {
+ 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" })}
+ >
+ View Details
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들
+ // ----------------------------------------------------------------
+ const basicColumns: ColumnDef<RfqHistoryRow>[] = rfqHistoryColumnsConfig.map((cfg) => {
+ const column: ColumnDef<RfqHistoryRow> = {
+ accessorKey: cfg.id,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ size: cfg.size,
+ }
+
+ if (cfg.id === "description") {
+ column.cell = ({ row }) => {
+ const description = row.original.description
+ if (!description) return null
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <div className="break-words whitespace-normal line-clamp-2">
+ {description}
+ </div>
+ </TooltipTrigger>
+ <TooltipContent side="bottom" className="max-w-[400px] whitespace-pre-wrap break-words">
+ {description}
+ </TooltipContent>
+ </Tooltip>
+ )
+ }
+ }
+
+ if (cfg.id === "status") {
+ column.cell = ({ row }) => {
+ const statusVal = row.original.status
+ if (!statusVal) return null
+ const Icon = getRFQStatusIcon(statusVal)
+ return (
+ <div className="flex items-center">
+ <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" />
+ <span className="capitalize">{statusVal}</span>
+ </div>
+ )
+ }
+ }
+
+ if (cfg.id === "totalAmount") {
+ column.cell = ({ row }) => {
+ const amount = row.original.totalAmount
+ const currency = row.original.currency
+ if (!amount || !currency) return null
+ return (
+ <div className="whitespace-nowrap">
+ {`${currency} ${amount.toLocaleString()}`}
+ </div>
+ )
+ }
+ }
+
+ if (cfg.id === "dueDate" || cfg.id === "createdAt") {
+ column.cell = ({ row }) => (
+ <div className="whitespace-nowrap">
+ {formatDate(row.getValue(cfg.id))}
+ </div>
+ )
+ }
+
+ return column
+ })
+
+ const itemsColumn: ColumnDef<RfqHistoryRow> = {
+ id: "items",
+ header: "Items",
+ cell: ({ row }) => {
+ const rfq = row.original;
+ const count = rfq.itemCount || 0;
+ return (
+ <Button variant="ghost" onClick={() => openItemsModal(rfq.id)}>
+ {count === 0 ? "No Items" : `${count} Items`}
+ </Button>
+ )
+ },
+ }
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...basicColumns,
+ itemsColumn,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/vendors/rfq-history-table/rfq-history-table-toolbar-actions.tsx b/lib/vendors/rfq-history-table/rfq-history-table-toolbar-actions.tsx
new file mode 100644
index 00000000..46eaa6a6
--- /dev/null
+++ b/lib/vendors/rfq-history-table/rfq-history-table-toolbar-actions.tsx
@@ -0,0 +1,136 @@
+"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 { DataTableViewOptions } from "@/components/data-table/data-table-view-options"
+
+
+// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import
+import { importTasksExcel } from "@/lib/tasks/service" // 예시
+import { VendorItem } from "@/db/schema/vendors"
+// import { AddItemDialog } from "./add-item-dialog"
+
+interface RfqHistoryRow {
+ id: number;
+ rfqCode: string | null;
+ projectCode: string | null;
+ projectName: string | null;
+ description: string | null;
+ dueDate: Date;
+ status: "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED";
+ vendorStatus: string;
+ totalAmount: number | null;
+ currency: string | null;
+ leadTime: string | null;
+ itemCount: number;
+ tbeResult: string | null;
+ cbeResult: string | null;
+ createdAt: Date;
+ items: {
+ rfqId: number;
+ id: number;
+ itemCode: string;
+ description: string | null;
+ quantity: number | null;
+ uom: string | null;
+ }[];
+}
+
+interface RfqHistoryTableToolbarActionsProps {
+ table: Table<RfqHistoryRow>
+}
+
+export function RfqHistoryTableToolbarActions({
+ table,
+}: RfqHistoryTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일이 선택되었을 때 처리
+ async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록)
+ event.target.value = ""
+
+ // 서버 액션 or API 호출
+ try {
+ // 예: 서버 액션 호출
+ const { errorFile, errorMessage } = await importTasksExcel(file)
+
+ if (errorMessage) {
+ toast.error(errorMessage)
+ }
+ if (errorFile) {
+ // 에러 엑셀을 다운로드
+ const url = URL.createObjectURL(errorFile)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "errors.xlsx"
+ link.click()
+ URL.revokeObjectURL(url)
+ } else {
+ // 성공
+ toast.success("Import success")
+ // 필요 시 revalidateTag("tasks") 등
+ }
+
+ } catch (err) {
+ toast.error("파일 업로드 중 오류가 발생했습니다.")
+
+ }
+ }
+
+ // function handleImportClick() {
+ // // 숨겨진 <input type="file" /> 요소를 클릭
+ // fileInputRef.current?.click()
+ // }
+
+ return (
+ <div className="flex items-center gap-2">
+ <DataTableViewOptions table={table} />
+
+ {/* 조회만 하는 모듈 */}
+ {/* <AddItemDialog vendorId={vendorId}/> */}
+
+ {/** 3) Import 버튼 (파일 업로드) */}
+ {/* <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}>
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button> */}
+ {/*
+ 실제로는 숨겨진 input과 연결:
+ - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용
+ */}
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".xlsx,.xls"
+ className="hidden"
+ onChange={onFileChange}
+ />
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "rfq-history",
+ 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/vendors/rfq-history-table/rfq-history-table.tsx b/lib/vendors/rfq-history-table/rfq-history-table.tsx
new file mode 100644
index 00000000..71830303
--- /dev/null
+++ b/lib/vendors/rfq-history-table/rfq-history-table.tsx
@@ -0,0 +1,156 @@
+"use client"
+
+import * as React from "react"
+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 "./rfq-history-table-columns"
+import { getRfqHistory } from "../service"
+import { RfqHistoryTableToolbarActions } from "./rfq-history-table-toolbar-actions"
+import { RfqItemsTableDialog } from "./rfq-items-table-dialog"
+import { getRFQStatusIcon } from "@/lib/tasks/utils"
+import { TooltipProvider } from "@/components/ui/tooltip"
+
+export interface RfqHistoryRow {
+ id: number;
+ rfqCode: string | null;
+ projectCode: string | null;
+ projectName: string | null;
+ description: string | null;
+ dueDate: Date;
+ status: "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED";
+ vendorStatus: string;
+ totalAmount: number | null;
+ currency: string | null;
+ leadTime: string | null;
+ itemCount: number;
+ tbeResult: string | null;
+ cbeResult: string | null;
+ createdAt: Date;
+ items: {
+ rfqId: number;
+ id: number;
+ itemCode: string;
+ description: string | null;
+ quantity: number | null;
+ uom: string | null;
+ }[];
+}
+
+interface RfqHistoryTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getRfqHistory>>,
+ ]
+ >
+}
+
+export function VendorRfqHistoryTable({ promises }: RfqHistoryTableProps) {
+ const [{ data, pageCount }] = React.use(promises)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqHistoryRow> | null>(null)
+
+ const [itemsModalOpen, setItemsModalOpen] = React.useState(false);
+ const [selectedRfq, setSelectedRfq] = React.useState<RfqHistoryRow | null>(null);
+
+ const openItemsModal = React.useCallback((rfqId: number) => {
+ const rfq = data.find(r => r.id === rfqId);
+ if (rfq) {
+ setSelectedRfq(rfq);
+ setItemsModalOpen(true);
+ }
+ }, [data]);
+
+ const columns = React.useMemo(() => getColumns({
+ setRowAction,
+ openItemsModal,
+ }), [setRowAction, openItemsModal]);
+
+ const filterFields: DataTableFilterField<RfqHistoryRow>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ Code",
+ placeholder: "Filter RFQ Code...",
+ },
+ {
+ id: "status",
+ label: "Status",
+ options: ["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].map((status) => ({
+ label: toSentenceCase(status),
+ value: status,
+ icon: getRFQStatusIcon(status),
+ })),
+ },
+ {
+ id: "vendorStatus",
+ label: "Vendor Status",
+ placeholder: "Filter Vendor Status...",
+ }
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<RfqHistoryRow>[] = [
+ { id: "rfqCode", label: "RFQ Code", type: "text" },
+ { id: "projectCode", label: "Project Code", type: "text" },
+ { id: "projectName", label: "Project Name", type: "text" },
+ {
+ id: "status",
+ label: "RFQ Status",
+ type: "multi-select",
+ options: ["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].map((status) => ({
+ label: toSentenceCase(status),
+ value: status,
+ icon: getRFQStatusIcon(status),
+ })),
+ },
+ { id: "vendorStatus", label: "Vendor Status", type: "text" },
+ { id: "dueDate", label: "Due Date", type: "date" },
+ { id: "createdAt", label: "Created At", 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: true,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <TooltipProvider>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <RfqHistoryTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <RfqItemsTableDialog
+ open={itemsModalOpen}
+ onOpenChange={setItemsModalOpen}
+ items={selectedRfq?.items ?? []}
+ />
+ </TooltipProvider>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/rfq-history-table/rfq-items-table-dialog.tsx b/lib/vendors/rfq-history-table/rfq-items-table-dialog.tsx
new file mode 100644
index 00000000..49a5d890
--- /dev/null
+++ b/lib/vendors/rfq-history-table/rfq-items-table-dialog.tsx
@@ -0,0 +1,98 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { DataTable } from "@/components/data-table/data-table"
+import { useDataTable } from "@/hooks/use-data-table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+
+interface RfqItem {
+ id: number
+ itemCode: string
+ description: string | null
+ quantity: number | null
+ uom: string | null
+}
+
+interface RfqItemsTableDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ items: RfqItem[]
+}
+
+export function RfqItemsTableDialog({
+ open,
+ onOpenChange,
+ items,
+}: RfqItemsTableDialogProps) {
+ const columns = React.useMemo<ColumnDef<RfqItem>[]>(
+ () => [
+ {
+ accessorKey: "itemCode",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Item Code" />
+ ),
+ },
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Description" />
+ ),
+ cell: ({ row }) => row.getValue("description") || "-",
+ },
+ {
+ accessorKey: "quantity",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="Quantity" />
+ ),
+ cell: ({ row }) => {
+ const quantity = row.getValue("quantity") as number | null;
+ return (
+ <div className="text-center">
+ {quantity !== null ? quantity.toLocaleString() : "-"}
+ </div>
+ );
+ },
+ },
+ {
+ accessorKey: "uom",
+ header: ({ column }) => (
+ <DataTableColumnHeader column={column} title="UoM" />
+ ),
+ cell: ({ row }) => row.getValue("uom") || "-",
+ },
+ ],
+ []
+ )
+
+ const { table } = useDataTable({
+ data: items,
+ columns,
+ pageCount: 1,
+ enablePinning: false,
+ enableAdvancedFilter: false,
+ })
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-3xl">
+ <DialogHeader>
+ <DialogTitle>RFQ Items</DialogTitle>
+ <DialogDescription>
+ Items included in this RFQ
+ </DialogDescription>
+ </DialogHeader>
+ <div className="mt-4">
+ <DataTable table={table} />
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts
new file mode 100644
index 00000000..2da16888
--- /dev/null
+++ b/lib/vendors/service.ts
@@ -0,0 +1,1345 @@
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { vendorAttachments, VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, 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 {
+ selectVendors,
+ countVendors,
+ insertVendor,
+ updateVendor,
+ updateVendors, groupByStatus,
+ getVendorById,
+ getVendorContactsById,
+ selectVendorContacts,
+ countVendorContacts,
+ insertVendorContact,
+ selectVendorItems,
+ countVendorItems,
+ insertVendorItem,
+ countRfqHistory,
+ selectRfqHistory
+} from "./repository";
+
+import type {
+ CreateVendorSchema,
+ UpdateVendorSchema,
+ GetVendorsSchema,
+ GetVendorContactsSchema,
+ CreateVendorContactSchema,
+ GetVendorItemsSchema,
+ CreateVendorItemSchema,
+ GetRfqHistorySchema,
+} from "./validations";
+
+import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull } from "drizzle-orm";
+import { rfqItems, rfqs, vendorRfqView } from "@/db/schema/rfq";
+import path from "path";
+import fs from "fs/promises";
+import { randomUUID } from "crypto";
+import JSZip from 'jszip';
+import { promises as fsPromises } from 'fs';
+import { sendEmail } from "../mail/sendEmail";
+import { PgTransaction } from "drizzle-orm/pg-core";
+import { items } from "@/db/schema/items";
+import { id_ID } from "@faker-js/faker";
+import { users } from "@/db/schema/users";
+
+
+/* -----------------------------------------------------
+ 1) 조회 관련
+----------------------------------------------------- */
+
+/**
+ * 복잡한 조건으로 Vendor 목록을 조회 (+ pagination) 하고,
+ * 총 개수에 따라 pageCount를 계산해서 리턴.
+ * Next.js의 unstable_cache를 사용해 일정 시간 캐시.
+ */
+export async function getVendors(input: GetVendorsSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 1) 고급 필터
+ const advancedWhere = filterColumns({
+ table: vendors,
+ 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)
+ );
+ }
+
+ // 최종 where 결합
+ const finalWhere = and(advancedWhere, globalWhere);
+
+ // 간단 검색 (advancedTable=false) 시 예시
+ const simpleWhere = and(
+ input.vendorName
+ ? ilike(vendors.vendorName, `%${input.vendorName}%`)
+ : undefined,
+ input.status ? ilike(vendors.status, input.status) : undefined,
+ input.country
+ ? ilike(vendors.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])
+ )
+ : [asc(vendors.createdAt)];
+
+ // 트랜잭션 내에서 데이터 조회
+ const { data, total } = await db.transaction(async (tx) => {
+ // 1) vendor 목록 조회
+ const vendorsData = await selectVendors(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ // 2) 각 vendor의 attachments 조회
+ const vendorsWithAttachments = await Promise.all(
+ vendorsData.map(async (vendor) => {
+ const attachments = await tx
+ .select({
+ id: vendorAttachments.id,
+ fileName: vendorAttachments.fileName,
+ filePath: vendorAttachments.filePath,
+ })
+ .from(vendorAttachments)
+ .where(eq(vendorAttachments.vendorId, vendor.id));
+
+ return {
+ ...vendor,
+ hasAttachments: attachments.length > 0,
+ attachmentsList: attachments,
+ };
+ })
+ );
+
+ // 3) 전체 개수
+ const total = await countVendors(tx, where);
+ return { data: vendorsWithAttachments, total };
+ });
+
+ // 페이지 수
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: ["vendors"], // revalidateTag("vendors") 호출 시 무효화
+ }
+ )();
+}
+
+
+export async function getVendorStatusCounts() {
+ return unstable_cache(
+ async () => {
+ try {
+
+ const initial: Record<Vendor["status"], number> = {
+ ACTIVE: 0,
+ INACTIVE: 0,
+ BLACKLISTED: 0,
+ "PENDING_REVIEW": 0,
+ "IN_REVIEW": 0,
+ "REJECTED": 0,
+ "IN_PQ": 0,
+ "PQ_FAILED": 0,
+ "APPROVED": 0,
+ "PQ_SUBMITTED": 0
+ };
+
+
+ const result = await db.transaction(async (tx) => {
+ const rows = await groupByStatus(tx);
+ return rows.reduce<Record<Vendor["status"], number>>((acc, { status, count }) => {
+ acc[status] = count;
+ return acc;
+ }, initial);
+ });
+
+ return result;
+ } catch (err) {
+ return {} as Record<Vendor["status"], number>;
+ }
+ },
+ ["task-status-counts"], // 캐싱 키
+ {
+ revalidate: 3600,
+ }
+ )();
+}
+
+/* -----------------------------------------------------
+ 2) 생성(Create)
+----------------------------------------------------- */
+
+/**
+ * 신규 Vendor 생성
+ */
+
+async function storeVendorFiles(
+ tx: PgTransaction<any, any, any>,
+ vendorId: number,
+ files: File[],
+ attachmentType: string
+) {
+ const vendorDir = path.join(
+ process.cwd(),
+ "public",
+ "vendors",
+ String(vendorId)
+ )
+ await fs.mkdir(vendorDir, { recursive: true })
+
+ for (const file of files) {
+ // Convert file to buffer
+ const ab = await file.arrayBuffer()
+ const buffer = Buffer.from(ab)
+
+ // Generate a unique filename
+ const uniqueName = `${randomUUID()}-${file.name}`
+ const relativePath = path.join("vendors", String(vendorId), uniqueName)
+ const absolutePath = path.join(process.cwd(), "public", relativePath)
+
+ // Write to disk
+ await fs.writeFile(absolutePath, buffer)
+
+ // Insert attachment record
+ await tx.insert(vendorAttachments).values({
+ vendorId,
+ fileName: file.name,
+ filePath: "/" + relativePath.replace(/\\/g, "/"),
+ attachmentType, // "GENERAL", "CREDIT_RATING", "CASH_FLOW_RATING", ...
+ })
+ }
+}
+
+export type CreateVendorData = {
+ vendorName: string
+ vendorCode?: 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"
+}
+
+export async function createVendor(params: {
+ vendorData: CreateVendorData
+ // 기존의 일반 첨부파일
+ files?: File[]
+
+ // 신용평가 / 현금흐름 등급 첨부
+ creditRatingFiles?: File[]
+ cashFlowRatingFiles?: File[]
+ contacts: {
+ contactName: string
+ contactPosition?: string
+ contactEmail: string
+ contactPhone?: string
+ isPrimary?: boolean
+ }[]
+}) {
+ 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 {
+ data: null,
+ error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)`
+ };
+ }
+
+ await db.transaction(async (tx) => {
+ // 1) Insert the vendor (확장 필드도 함께)
+ const [newVendor] = await insertVendor(tx, {
+ vendorName: vendorData.vendorName,
+ vendorCode: vendorData.vendorCode || null,
+ address: vendorData.address || null,
+ country: vendorData.country || null,
+ phone: vendorData.phone || null,
+ email: vendorData.email,
+ website: vendorData.website || null,
+ status: vendorData.status ?? "PENDING_REVIEW",
+ taxId: vendorData.taxId,
+
+ // 대표자 정보
+ 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,
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || null,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || null,
+ isPrimary: contact.isPrimary ?? false,
+ })
+ }
+ })
+
+ revalidateTag("vendors")
+ return { data: null, error: null }
+ } catch (error) {
+ return { data: null, error: getErrorMessage(error) }
+ }
+}
+/* -----------------------------------------------------
+ 3) 업데이트 (단건/복수)
+----------------------------------------------------- */
+
+/** 단건 업데이트 */
+export async function modifyVendor(
+ input: UpdateVendorSchema & { id: string }
+) {
+ unstable_noStore();
+ try {
+ const updated = await db.transaction(async (tx) => {
+ // 특정 ID 벤더를 업데이트
+ const [res] = await updateVendor(tx, input.id, {
+ vendorName: input.vendorName,
+ vendorCode: input.vendorCode,
+ address: input.address,
+ country: input.country,
+ phone: input.phone,
+ email: input.email,
+ website: input.website,
+ status: input.status,
+ });
+ return res;
+ });
+
+ // 필요 시, status 변경 등에 따른 다른 캐시도 무효화
+ revalidateTag("vendors");
+ revalidateTag("rfq-vendors");
+
+ return { data: updated, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/** 복수 업데이트 */
+export async function modifyVendors(input: {
+ ids: string[];
+ status?: Vendor["status"];
+}) {
+ unstable_noStore();
+ try {
+ const data = await db.transaction(async (tx) => {
+ // 여러 벤더 일괄 업데이트
+ const [updated] = await updateVendors(tx, input.ids, {
+ // 예: 상태만 일괄 변경
+ status: input.status,
+ });
+ return updated;
+ });
+
+ revalidateTag("vendors");
+ if (data.status === input.status) {
+ revalidateTag("vendor-status-counts");
+ }
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+export const findVendorById = async (id: number): Promise<Vendor | null> => {
+ try {
+ logger.info({ id }, 'Fetching user by ID');
+ const vendor = await getVendorById(id);
+ if (!vendor) {
+ logger.warn({ id }, 'User not found');
+ } else {
+ logger.debug({ vendor }, 'User fetched successfully');
+ }
+ return vendor;
+ } catch (error) {
+ logger.error({ error }, 'Error fetching user by ID');
+ throw new Error('Failed to fetch user');
+ }
+};
+
+
+export const findVendorContactsById = async (id: number): Promise<VendorContact | null> => {
+ try {
+ logger.info({ id }, 'Fetching user by ID');
+ const vendor = await getVendorContactsById(id);
+ if (!vendor) {
+ logger.warn({ id }, 'User not found');
+ } else {
+ logger.debug({ vendor }, 'User fetched successfully');
+ }
+ return vendor;
+ } catch (error) {
+ logger.error({ error }, 'Error fetching user by ID');
+ throw new Error('Failed to fetch user');
+ }
+};
+
+
+export async function getVendorContacts(input: GetVendorContactsSchema, id: 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: vendorContacts,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(vendorContacts.contactName, s), ilike(vendorContacts.contactPosition, s)
+ , ilike(vendorContacts.contactEmail, s), ilike(vendorContacts.contactPhone, s)
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ const vendorWhere = eq(vendorContacts.vendorId, id)
+
+ const finalWhere = and(
+ // advancedWhere or your existing conditions
+ advancedWhere,
+ globalWhere,
+ vendorWhere
+ )
+
+
+ // 아니면 ilike, inArray, gte 등으로 where 절 구성
+ const where = finalWhere
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(vendorContacts[item.id]) : asc(vendorContacts[item.id])
+ )
+ : [asc(vendorContacts.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectVendorContacts(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countVendorContacts(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(id)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: [`vendor-contacts-${id}`], // revalidateTag("tasks") 호출 시 무효화
+ }
+ )();
+}
+
+export async function createVendorContact(input: CreateVendorContactSchema) {
+ unstable_noStore(); // Next.js 서버 액션 캐싱 방지
+ try {
+ await db.transaction(async (tx) => {
+ // DB Insert
+ const [newContact] = await insertVendorContact(tx, {
+ vendorId: input.vendorId,
+ contactName: input.contactName,
+ contactPosition: input.contactPosition || "",
+ contactEmail: input.contactEmail,
+ contactPhone: input.contactPhone || "",
+ isPrimary: input.isPrimary || false,
+ });
+ return newContact;
+ });
+
+ // 캐시 무효화 (벤더 연락처 목록 등)
+ revalidateTag(`vendor-contacts-${input.vendorId}`);
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+
+///item
+
+export async function getVendorItems(input: GetVendorItemsSchema, id: number) {
+ const cachedFunction = 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: vendorItemsView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(vendorItemsView.itemCode, s)
+ , ilike(vendorItemsView.description, s)
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ const vendorWhere = eq(vendorItemsView.vendorId, id)
+
+ const finalWhere = and(
+ // advancedWhere or your existing conditions
+ advancedWhere,
+ globalWhere,
+ vendorWhere
+ )
+
+
+ // 아니면 ilike, inArray, gte 등으로 where 절 구성
+ const where = finalWhere
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(vendorItemsView[item.id]) : asc(vendorItemsView[item.id])
+ )
+ : [asc(vendorItemsView.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectVendorItems(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countVendorItems(tx, where);
+ return { data, total };
+ });
+
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+
+ console.log(data)
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input), String(id)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: [`vendor-items-${id}`], // revalidateTag("tasks") 호출 시 무효화
+ }
+ );
+ return cachedFunction();
+}
+
+export interface ItemDropdownOption {
+ itemCode: string;
+ itemName: string;
+ description: string | null;
+}
+
+/**
+ * Vendor Item 추가 시 사용할 아이템 목록 조회 (전체 목록 반환)
+ * 아이템 코드, 이름, 설명만 간소화해서 반환
+ */
+export async function getItemsForVendor(vendorId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ // 해당 vendorId가 이미 가지고 있는 itemCode 목록을 서브쿼리로 구함
+ // 그 아이템코드를 제외(notIn)하여 모든 items 테이블에서 조회
+ const itemsData = await db
+ .select({
+ itemCode: items.itemCode,
+ itemName: items.itemName,
+ description: items.description,
+ })
+ .from(items)
+ .leftJoin(
+ vendorPossibleItems,
+ eq(items.itemCode, vendorPossibleItems.itemCode)
+ )
+ // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만
+ .where(
+ isNull(vendorPossibleItems.id) // 또는 isNull(vendorPossibleItems.itemCode)
+ )
+ .orderBy(asc(items.itemName))
+
+ return {
+ data: itemsData.map((item) => ({
+ itemCode: item.itemCode ?? "", // null이라면 ""로 치환
+ itemName: item.itemName,
+ description: item.description ?? "" // null이라면 ""로 치환
+ })),
+ error: null
+ }
+ } catch (err) {
+ console.error("Failed to fetch items for vendor dropdown:", err)
+ return {
+ data: [],
+ error: "아이템 목록을 불러오는데 실패했습니다.",
+ }
+ }
+ },
+ // 캐시 키를 vendorId 별로 달리 해야 한다.
+ ["items-for-vendor", String(vendorId)],
+ {
+ revalidate: 3600, // 1시간 캐싱
+ tags: ["items"], // revalidateTag("items") 호출 시 무효화
+ }
+ )()
+}
+
+export async function createVendorItem(input: CreateVendorItemSchema) {
+ unstable_noStore(); // Next.js 서버 액션 캐싱 방지
+ try {
+ await db.transaction(async (tx) => {
+ // DB Insert
+ const [newContact] = await insertVendorItem(tx, {
+ vendorId: input.vendorId,
+ itemCode: input.itemCode,
+
+ });
+ return newContact;
+ });
+
+ // 캐시 무효화 (벤더 연락처 목록 등)
+ revalidateTag(`vendor-items-${input.vendorId}`);
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+export async function getRfqHistory(input: GetRfqHistorySchema, vendorId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ logger.info({ vendorId, input }, "Starting getRfqHistory");
+
+ const offset = (input.page - 1) * input.perPage;
+
+ // 기본 where 조건 (vendorId)
+ const vendorWhere = eq(vendorRfqView.vendorId, vendorId);
+ logger.debug({ vendorWhere }, "Vendor where condition");
+
+ // 고급 필터링
+ const advancedWhere = filterColumns({
+ table: vendorRfqView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+ logger.debug({ advancedWhere }, "Advanced where condition");
+
+ // 글로벌 검색
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(vendorRfqView.rfqCode, s),
+ ilike(vendorRfqView.projectCode, s),
+ ilike(vendorRfqView.projectName, s)
+ );
+ logger.debug({ globalWhere, search: input.search }, "Global search condition");
+ }
+
+ const finalWhere = and(
+ advancedWhere,
+ globalWhere,
+ vendorWhere
+ );
+ logger.debug({ finalWhere }, "Final where condition");
+
+ // 정렬 조건
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(rfqs[item.id]) : asc(rfqs[item.id])
+ )
+ : [desc(rfqs.createdAt)];
+ logger.debug({ orderBy }, "Order by condition");
+
+ // 트랜잭션으로 데이터 조회
+ const { data, total } = await db.transaction(async (tx) => {
+ logger.debug("Starting transaction for RFQ history query");
+
+ const data = await selectRfqHistory(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ logger.debug({ dataLength: data.length }, "RFQ history data fetched");
+
+ // RFQ 아이템 정보 조회
+ const rfqIds = data.map(rfq => rfq.id);
+ const items = await tx
+ .select({
+ rfqId: rfqItems.rfqId,
+ id: rfqItems.id,
+ itemCode: rfqItems.itemCode,
+ description: rfqItems.description,
+ quantity: rfqItems.quantity,
+ uom: rfqItems.uom,
+ })
+ .from(rfqItems)
+ .where(inArray(rfqItems.rfqId, rfqIds));
+
+ // RFQ 데이터에 아이템 정보 추가
+ const dataWithItems = data.map(rfq => ({
+ ...rfq,
+ items: items.filter(item => item.rfqId === rfq.id),
+ }));
+
+ const total = await countRfqHistory(tx, finalWhere);
+ logger.debug({ total }, "RFQ history total count");
+
+ return { data: dataWithItems, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+ logger.info({
+ vendorId,
+ dataLength: data.length,
+ total,
+ pageCount
+ }, "RFQ history query completed");
+
+ return { data, pageCount };
+ } catch (err) {
+ logger.error({
+ err,
+ vendorId,
+ stack: err instanceof Error ? err.stack : undefined
+ }, 'Error fetching RFQ history');
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify({ input, vendorId })],
+ {
+ revalidate: 3600,
+ tags: ["rfq-history"],
+ }
+ )();
+}
+
+export async function checkJoinPortal(taxID: string) {
+ try {
+ // 이미 등록된 회사가 있는지 검색
+ const result = await db.select().from(vendors).where(eq(vendors.taxId, taxID)).limit(1)
+
+ if (result.length > 0) {
+ // 이미 가입되어 있음
+ // data에 예시로 vendorName이나 다른 정보를 담아 반환
+ return {
+ success: false,
+ data: result[0].vendorName ?? "Already joined",
+ }
+ }
+
+ // 미가입 → 가입 가능
+ return {
+ success: true,
+ }
+ } catch (err) {
+ console.error("checkJoinPortal error:", err)
+ // 서버 에러 시
+ return {
+ success: false,
+ data: "서버 에러가 발생했습니다.",
+ }
+ }
+}
+
+interface CreateCompanyInput {
+ vendorName: string
+ taxId: string
+ email: string
+ address: string
+ phone?: string
+ country?: string
+ // 필요한 필드 추가 가능 (vendorCode, website 등)
+}
+
+
+/**
+ * 벤더 첨부파일 다운로드를 위한 서버 액션
+ * @param vendorId 벤더 ID
+ * @param fileId 특정 파일 ID (단일 파일 다운로드시)
+ * @returns 다운로드할 수 있는 임시 URL
+ */
+export async function downloadVendorAttachments(vendorId: number, fileId?: number) {
+ try {
+ // 벤더 정보 조회
+ const vendor = await db.select()
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .limit(1)
+ .then(rows => rows[0]);
+
+ 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('다운로드할 첨부파일이 없습니다.');
+ }
+
+ // 업로드 기본 경로
+ 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 };
+ }
+
+ // 다중 파일: 임시 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`
+ };
+ } catch (error) {
+ console.error('첨부파일 다운로드 서버 액션 오류:', error);
+ throw new Error('첨부파일 다운로드 준비 중 오류가 발생했습니다.');
+ }
+}
+
+/**
+ * 임시 ZIP 파일 정리를 위한 서버 액션
+ * @param fileName 정리할 파일명
+ */
+export async function cleanupTempFiles(fileName: string) {
+ 'use server';
+
+ try {
+ const tempDir = path.join(process.cwd(), 'tmp');
+ const filePath = path.join(tempDir, fileName);
+
+ try {
+ // 파일 존재 확인
+ await fsPromises.access(filePath, fs.constants.F_OK);
+ // 파일 삭제
+ await fsPromises.unlink(filePath);
+ } catch {
+ // 파일이 없으면 무시
+ }
+
+ return { success: true };
+ } catch (error) {
+ console.error('임시 파일 정리 오류:', error);
+ return { success: false, error: '임시 파일 정리 중 오류가 발생했습니다.' };
+ }
+}
+
+
+interface ApproveVendorsInput {
+ ids: number[];
+}
+
+/**
+ * 선택된 벤더의 상태를 IN_REVIEW로 변경하고 이메일 알림을 발송하는 서버 액션
+ */
+export async function approveVendors(input: ApproveVendorsInput) {
+ unstable_noStore();
+
+ try {
+ // 트랜잭션 내에서 벤더 상태 업데이트, 유저 생성 및 이메일 발송
+ const result = await db.transaction(async (tx) => {
+ // 1. 벤더 상태 업데이트
+ const [updated] = await tx
+ .update(vendors)
+ .set({
+ status: "IN_REVIEW",
+ updatedAt: new Date()
+ })
+ .where(inArray(vendors.id, input.ids))
+ .returning();
+
+ // 2. 업데이트된 벤더 정보 조회
+ const updatedVendors = await tx
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, input.ids));
+
+ // 3. 각 벤더에 대한 유저 계정 생성
+ await Promise.all(
+ updatedVendors.map(async (vendor) => {
+ if (!vendor.email) return; // 이메일이 없으면 스킵
+
+ // 이미 존재하는 유저인지 확인
+ const existingUser = await tx
+ .select({ id: users.id })
+ .from(users)
+ .where(eq(users.email, vendor.email))
+ .limit(1);
+
+ // 유저가 존재하지 않는 경우에만 생성
+ if (existingUser.length === 0) {
+ await tx.insert(users).values({
+ name: vendor.vendorName,
+ email: vendor.email,
+ companyId: vendor.id,
+ domain: "partners", // 기본값으로 이미 설정되어 있지만 명시적으로 지정
+ });
+ }
+ })
+ );
+
+ // 4. 각 벤더에게 이메일 발송
+ await Promise.all(
+ updatedVendors.map(async (vendor) => {
+ if (!vendor.email) return; // 이메일이 없으면 스킵
+
+ try {
+ const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
+
+ const subject =
+ "[eVCP] Admin Account Created";
+
+ const loginUrl = "http://3.36.56.124:3000/en/login";
+
+ await sendEmail({
+ to: vendor.email,
+ subject,
+ template: "admin-created", // 이메일 템플릿 이름
+ context: {
+ vendorName: vendor.vendorName,
+ loginUrl,
+ language: userLang,
+ },
+ });
+ } catch (emailError) {
+ console.error(`Failed to send email to vendor ${vendor.id}:`, emailError);
+ // 이메일 전송 실패는 전체 트랜잭션을 실패시키지 않음
+ }
+ })
+ );
+
+ return updated;
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+ revalidateTag("users"); // 유저 캐시도 무효화
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("Error approving vendors:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+export async function requestPQVendors(input: ApproveVendorsInput) {
+ unstable_noStore();
+
+ try {
+ // 트랜잭션 내에서 벤더 상태 업데이트 및 이메일 발송
+ const result = await db.transaction(async (tx) => {
+ // 1. 벤더 상태 업데이트
+ const [updated] = await tx
+ .update(vendors)
+ .set({
+ status: "IN_PQ",
+ updatedAt: new Date()
+ })
+ .where(inArray(vendors.id, input.ids))
+ .returning();
+
+ // 2. 업데이트된 벤더 정보 조회
+ const updatedVendors = await tx
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ email: vendors.email,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, input.ids));
+
+ // 3. 각 벤더에게 이메일 발송
+ await Promise.all(
+ updatedVendors.map(async (vendor) => {
+ if (!vendor.email) return; // 이메일이 없으면 스킵
+
+ try {
+ const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기
+
+ const subject =
+ "[eVCP] You are invited to submit PQ";
+
+ const loginUrl = "http://3.36.56.124:3000/en/login";
+
+ await sendEmail({
+ to: vendor.email,
+ subject,
+ template: "pq", // 이메일 템플릿 이름
+ context: {
+ vendorName: vendor.vendorName,
+ loginUrl,
+ language: userLang,
+ },
+ });
+ } catch (emailError) {
+ console.error(`Failed to send email to vendor ${vendor.id}:`, emailError);
+ // 이메일 전송 실패는 전체 트랜잭션을 실패시키지 않음
+ }
+ })
+ );
+
+ return updated;
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("Error approving vendors:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+interface SendVendorsInput {
+ ids: number[];
+}
+
+/**
+ * APPROVED 상태인 벤더 정보를 기간계 시스템에 전송하고 벤더 코드를 업데이트하는 서버 액션
+ */
+export async function sendVendors(input: SendVendorsInput) {
+ unstable_noStore();
+
+ try {
+ // 트랜잭션 내에서 진행
+ const result = await db.transaction(async (tx) => {
+ // 1. 선택된 벤더 중 APPROVED 상태인 벤더만 필터링
+ const approvedVendors = await tx
+ .select()
+ .from(vendors)
+ .where(
+ and(
+ inArray(vendors.id, input.ids),
+ eq(vendors.status, "APPROVED")
+ )
+ );
+
+ if (!approvedVendors.length) {
+ throw new Error("No approved vendors found in the selection");
+ }
+
+ // 벤더별 처리 결과를 저장할 배열
+ const results = [];
+
+ // 2. 각 벤더에 대해 처리
+ for (const vendor of approvedVendors) {
+ // 2-1. 벤더 연락처 정보 조회
+ const contacts = await tx
+ .select()
+ .from(vendorContacts)
+ .where(eq(vendorContacts.vendorId, vendor.id));
+
+ // 2-2. 벤더 가능 아이템 조회
+ const possibleItems = await tx
+ .select()
+ .from(vendorPossibleItems)
+ .where(eq(vendorPossibleItems.vendorId, vendor.id));
+
+ // 2-3. 벤더 첨부파일 조회
+ const attachments = await tx
+ .select({
+ id: vendorAttachments.id,
+ fileName: vendorAttachments.fileName,
+ filePath: vendorAttachments.filePath,
+ })
+ .from(vendorAttachments)
+ .where(eq(vendorAttachments.vendorId, vendor.id));
+
+ // 2-4. 벤더 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용)
+ const vendorData = {
+ id: vendor.id,
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ address: vendor.address || "",
+ country: vendor.country || "",
+ phone: vendor.phone || "",
+ email: vendor.email || "",
+ website: vendor.website || "",
+ contacts,
+ possibleItems,
+ attachments,
+ };
+
+ try {
+ // 내부 API 호출 (기간계 시스템 연동 API)
+ const erpResponse = await fetch(`/api/erp/vendors`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(vendorData),
+ });
+
+ if (!erpResponse.ok) {
+ const errorData = await erpResponse.json();
+ throw new Error(`ERP system error for vendor ${vendor.id}: ${errorData.message || erpResponse.statusText}`);
+ }
+
+ const responseData = await erpResponse.json();
+
+ if (!responseData.success || !responseData.vendorCode) {
+ throw new Error(`Invalid response from ERP system for vendor ${vendor.id}`);
+ }
+
+ // 2-5. 벤더 코드 및 상태 업데이트
+ const vendorCode = responseData.vendorCode;
+
+ const [updated] = await tx
+ .update(vendors)
+ .set({
+ vendorCode,
+ status: "ACTIVE", // 상태를 ACTIVE로 변경
+ updatedAt: new Date(),
+ })
+ .where(eq(vendors.id, vendor.id))
+ .returning();
+
+ // 2-6. 벤더에게 알림 이메일 발송
+ if (vendor.email) {
+ 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`;
+
+ await sendEmail({
+ to: vendor.email,
+ subject,
+ template: "vendor-active",
+ context: {
+ vendorName: vendor.vendorName,
+ vendorCode,
+ portalUrl,
+ language: userLang,
+ },
+ });
+ }
+
+ results.push({
+ id: vendor.id,
+ success: true,
+ vendorCode,
+ message: "Successfully sent to ERP system",
+ });
+ } catch (vendorError) {
+ // 개별 벤더 처리 오류 기록
+ results.push({
+ id: vendor.id,
+ success: false,
+ error: getErrorMessage(vendorError),
+ });
+ }
+ }
+
+ // 3. 처리 결과 반환
+ const successCount = results.filter(r => r.success).length;
+ const failCount = results.filter(r => !r.success).length;
+
+ return {
+ totalProcessed: results.length,
+ successCount,
+ failCount,
+ results,
+ };
+ });
+
+ // 캐시 무효화
+ revalidateTag("vendors");
+ revalidateTag("vendor-status-counts");
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("Error sending vendors to ERP:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
diff --git a/lib/vendors/table/approve-vendor-dialog.tsx b/lib/vendors/table/approve-vendor-dialog.tsx
new file mode 100644
index 00000000..253c2830
--- /dev/null
+++ b/lib/vendors/table/approve-vendor-dialog.tsx
@@ -0,0 +1,150 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Check } 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 { Vendor } from "@/db/schema/vendors"
+import { approveVendors } from "../service"
+
+interface ApprovalVendorDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<Vendor>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function ApproveVendorsDialog({
+ vendors,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: ApprovalVendorDialogProps) {
+ const [isApprovePending, startApproveTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onApprove() {
+ startApproveTransition(async () => {
+ const { error } = await approveVendors({
+ ids: vendors.map((vendor) => vendor.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Vendors successfully approved for review")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Check className="size-4" aria-hidden="true" />
+ Approve ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Confirm Vendor Approval</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to approve{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}?
+ After approval, vendors will be notified and can login to submit PQ information.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Approve selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending}
+ >
+ {isApprovePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Approve
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Check className="size-4" aria-hidden="true" />
+ Approve ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Confirm Vendor Approval</DrawerTitle>
+ <DrawerDescription>
+ Are you sure you want to approve{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}?
+ After approval, vendors will be notified and can login to submit PQ information.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Approve selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending}
+ >
+ {isApprovePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Approve
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/table/attachmentButton.tsx b/lib/vendors/table/attachmentButton.tsx
new file mode 100644
index 00000000..a82f59e1
--- /dev/null
+++ b/lib/vendors/table/attachmentButton.tsx
@@ -0,0 +1,69 @@
+'use client';
+
+import React from 'react';
+import { Button } from '@/components/ui/button';
+import { PaperclipIcon } from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+import { toast } from 'sonner';
+import { type VendorAttach } from '@/db/schema/vendors';
+import { downloadVendorAttachments } from '../service';
+
+interface AttachmentsButtonProps {
+ vendorId: number;
+ hasAttachments: boolean;
+ attachmentsList?: VendorAttach[];
+}
+
+export function AttachmentsButton({ vendorId, hasAttachments, attachmentsList = [] }: AttachmentsButtonProps) {
+ if (!hasAttachments) return null;
+
+ const handleDownload = async () => {
+ try {
+ toast.loading('첨부파일을 준비하는 중...');
+
+ // 서버 액션 호출
+ const result = await downloadVendorAttachments(vendorId);
+
+ // 로딩 토스트 닫기
+ toast.dismiss();
+
+ if (!result || !result.url) {
+ toast.error('다운로드 준비 중 오류가 발생했습니다.');
+ return;
+ }
+
+ // 파일 다운로드 트리거
+ toast.success('첨부파일 다운로드가 시작되었습니다.');
+
+ // 다운로드 링크 열기
+ const a = document.createElement('a');
+ a.href = result.url;
+ a.download = result.fileName || '첨부파일.zip';
+ a.style.display = 'none';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+
+ } catch (error) {
+ toast.dismiss();
+ toast.error('첨부파일 다운로드에 실패했습니다.');
+ console.error('첨부파일 다운로드 오류:', error);
+ }
+ };
+
+ return (
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={handleDownload}
+ title={`${attachmentsList.length}개 파일 다운로드`}
+ >
+ <PaperclipIcon className="h-4 w-4" />
+ {attachmentsList.length > 1 && (
+ <Badge variant="outline" className="ml-1 h-5 min-w-5 px-1">
+ {attachmentsList.length}
+ </Badge>
+ )}
+ </Button>
+ );
+}
diff --git a/lib/vendors/table/feature-flags-provider.tsx b/lib/vendors/table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/vendors/table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+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,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <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,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/vendors/table/request-vendor-pg-dialog.tsx b/lib/vendors/table/request-vendor-pg-dialog.tsx
new file mode 100644
index 00000000..b417f846
--- /dev/null
+++ b/lib/vendors/table/request-vendor-pg-dialog.tsx
@@ -0,0 +1,150 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Check, SendHorizonal } 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 { Vendor } from "@/db/schema/vendors"
+import { requestPQVendors } from "../service"
+
+interface ApprovalVendorDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<Vendor>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function RequestPQVendorsDialog({
+ vendors,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: ApprovalVendorDialogProps) {
+ const [isApprovePending, startApproveTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onApprove() {
+ startApproveTransition(async () => {
+ const { error } = await requestPQVendors({
+ ids: vendors.map((vendor) => vendor.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("PQ successfully sent to vendors")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <SendHorizonal className="size-4" aria-hidden="true" />
+ Request ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Confirm Vendor PQ requst</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to request{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}?
+ After sent, vendors will be notified and can submit PQ information.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Request selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending}
+ >
+ {isApprovePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Request
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Check className="size-4" aria-hidden="true" />
+ Request ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Confirm Vendor Approval</DrawerTitle>
+ <DrawerDescription>
+ Are you sure you want to request{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}?
+ After sent, vendors will be notified and can submit PQ information.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Request selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending}
+ >
+ {isApprovePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Request
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/table/send-vendor-dialog.tsx b/lib/vendors/table/send-vendor-dialog.tsx
new file mode 100644
index 00000000..a34abb77
--- /dev/null
+++ b/lib/vendors/table/send-vendor-dialog.tsx
@@ -0,0 +1,150 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Check, Send } 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 { Vendor } from "@/db/schema/vendors"
+import { requestPQVendors, sendVendors } from "../service"
+
+interface ApprovalVendorDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<Vendor>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function SendVendorsDialog({
+ vendors,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: ApprovalVendorDialogProps) {
+ const [isApprovePending, startApproveTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onApprove() {
+ startApproveTransition(async () => {
+ const { error } = await sendVendors({
+ ids: vendors.map((vendor) => vendor.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("PQ successfully sent to vendors")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Send className="size-4" aria-hidden="true" />
+ Send ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Confirm to send Vendor Information</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to send{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}?
+ After vendor information is sent, vendor code will be generated.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Send selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending}
+ >
+ {isApprovePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Send
+ </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" />
+ Send ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Confirm to send Vendor Information</DrawerTitle>
+ <DrawerDescription>
+ Are you sure you want to send{" "}
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? " vendor" : " vendors"}?
+ After vendor information is sent, vendor code will be generated.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Send selected vendors"
+ variant="default"
+ onClick={onApprove}
+ disabled={isApprovePending}
+ >
+ {isApprovePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Send
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/table/update-vendor-sheet.tsx b/lib/vendors/table/update-vendor-sheet.tsx
new file mode 100644
index 00000000..e65c4b1c
--- /dev/null
+++ b/lib/vendors/table/update-vendor-sheet.tsx
@@ -0,0 +1,270 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { Loader } from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+
+import { Vendor } 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
+}
+
+// 폼 컴포넌트
+export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) {
+ const [isPending, startTransition] = React.useTransition()
+
+ console.log(vendor)
+
+ // RHF + Zod
+ const form = useForm<UpdateVendorSchema>({
+ resolver: zodResolver(updateVendorSchema),
+ defaultValues: {
+ vendorName: vendor?.vendorName ?? "",
+ vendorCode: vendor?.vendorCode ?? "",
+ address: vendor?.address ?? "",
+ country: vendor?.country ?? "",
+ phone: vendor?.phone ?? "",
+ email: vendor?.email ?? "",
+ website: vendor?.website ?? "",
+ status: vendor?.status ?? "ACTIVE",
+ },
+ })
+
+ React.useEffect(() => {
+ if (vendor) {
+ form.reset({
+ vendorName: vendor?.vendorName ?? "",
+ vendorCode: vendor?.vendorCode ?? "",
+ address: vendor?.address ?? "",
+ country: vendor?.country ?? "",
+ phone: vendor?.phone ?? "",
+ email: vendor?.email ?? "",
+ website: vendor?.website ?? "",
+ status: vendor?.status ?? "ACTIVE",
+ });
+ }
+ }, [vendor, form]);
+
+ console.log(form.getValues())
+ // 제출 핸들러
+ async function onSubmit(data: UpdateVendorSchema) {
+ if (!vendor) return
+
+ 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))
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Update Vendor</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>
+ )}
+ />
+
+ {/* vendorCode */}
+ <FormField
+ control={form.control}
+ name="vendorCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Vendor Code</FormLabel>
+ <FormControl>
+ <Input placeholder="Code123" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* address */}
+ <FormField
+ control={form.control}
+ name="address"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Address</FormLabel>
+ <FormControl>
+ <Input placeholder="123 Main St" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* country */}
+ <FormField
+ control={form.control}
+ name="country"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Country</FormLabel>
+ <FormControl>
+ <Input placeholder="USA" {...field} />
+ </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>
+ )}
+ />
+
+ {/* email */}
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Email</FormLabel>
+ <FormControl>
+ <Input placeholder="vendor@example.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* website */}
+ <FormField
+ control={form.control}
+ name="website"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Website</FormLabel>
+ <FormControl>
+ <Input placeholder="https://www.vendor.com" {...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>
+ )}
+ />
+
+ <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>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/table/vendors-table-columns.tsx b/lib/vendors/table/vendors-table-columns.tsx
new file mode 100644
index 00000000..c503e369
--- /dev/null
+++ b/lib/vendors/table/vendors-table-columns.tsx
@@ -0,0 +1,279 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis, PaperclipIcon } 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 { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import { useRouter } from "next/navigation"
+
+import { Vendor, 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"
+
+
+type NextRouter = ReturnType<typeof useRouter>;
+
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Vendor> | null>>;
+ router: NextRouter;
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<Vendor>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<Vendor> = {
+ 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<Vendor> = {
+ 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={() => {
+ // 1) 만약 rowAction을 열고 싶다면
+ // setRowAction({ row, type: "update" })
+
+ // 2) 자세히 보기 페이지로 클라이언트 라우팅
+ router.push(`/evcp/vendors/${row.original.id}/info`);
+ }}
+ >
+ Details
+ </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,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<Vendor>[] }
+ const groupMap: Record<string, ColumnDef<Vendor>[]> = {}
+
+ vendorColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<Vendor> = {
+ 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 === "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>
+ )
+ }
+
+
+ if (cfg.id === "createdAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ if (cfg.id === "updatedAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+
+ // code etc...
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<Vendor>[] = []
+
+ // 순서를 고정하고 싶다면 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,
+ })
+ }
+ })
+
+ const attachmentsColumn: ColumnDef<VendorWithAttachments> = {
+ id: "attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="" />
+ ),
+ cell: ({ row }) => {
+ // hasAttachments 및 attachmentsList 속성이 추가되었다고 가정
+ const hasAttachments = row.original.hasAttachments;
+ const attachmentsList = row.original.attachmentsList || [];
+
+ if(hasAttachments){
+
+ // 서버 액션을 사용하는 컴포넌트로 교체
+ return (
+ <AttachmentsButton
+ vendorId={row.original.id}
+ hasAttachments={hasAttachments}
+ attachmentsList={attachmentsList}
+ />
+ );}{
+ return null
+ }
+ },
+ enableSorting: false,
+ enableHiding: false,
+ minSize: 45,
+ };
+
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ attachmentsColumn,
+ ...nestedColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/vendors/table/vendors-table-floating-bar.tsx b/lib/vendors/table/vendors-table-floating-bar.tsx
new file mode 100644
index 00000000..791fb760
--- /dev/null
+++ b/lib/vendors/table/vendors-table-floating-bar.tsx
@@ -0,0 +1,241 @@
+"use client"
+
+import * as React from "react"
+import { SelectTrigger } from "@radix-ui/react-select"
+import { type Table } from "@tanstack/react-table"
+import {
+ ArrowUp,
+ CheckCircle2,
+ Download,
+ Loader,
+ Trash2,
+ X,
+} from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { Portal } from "@/components/ui/portal"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+} from "@/components/ui/select"
+import { Separator } from "@/components/ui/separator"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { Kbd } from "@/components/kbd"
+
+import { ActionConfirmDialog } from "@/components/ui/action-dialog"
+import { Vendor, vendors } from "@/db/schema/vendors"
+import { modifyVendors } from "../service"
+
+interface VendorsTableFloatingBarProps {
+ table: Table<Vendor>
+}
+
+
+export function VendorsTableFloatingBar({ table }: VendorsTableFloatingBarProps) {
+ const rows = table.getFilteredSelectedRowModel().rows
+
+ const [isPending, startTransition] = React.useTransition()
+ const [action, setAction] = React.useState<
+ "update-status" | "export" | "delete"
+ >()
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+ // Clear selection on Escape key press
+ 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
+ const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false)
+ const [confirmProps, setConfirmProps] = React.useState<{
+ title: string
+ description?: string
+ onConfirm: () => Promise<void> | void
+ }>({
+ title: "",
+ description: "",
+ onConfirm: () => { },
+ })
+
+
+ // 2)
+ function handleSelectStatus(newStatus: Vendor["status"]) {
+ setAction("update-status")
+
+ setConfirmProps({
+ title: `Update ${rows.length} vendor${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`,
+ description: "This action will override their current status.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ const { error } = await modifyVendors({
+ ids: rows.map((row) => String(row.original.id)),
+ status: newStatus,
+ })
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("Vendors updated")
+ setConfirmDialogOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+
+ 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">
+ <Select
+ onValueChange={(value: Vendor["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>
+ {vendors.status.enumValues.map((status) => (
+ <SelectItem
+ key={status}
+ value={status}
+ className="capitalize"
+ >
+ {status}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <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 vendors</p>
+ </TooltipContent>
+ </Tooltip>
+
+ </div>
+ </div>
+ </div>
+ </div>
+
+
+ {/* 공용 Confirm Dialog */}
+ <ActionConfirmDialog
+ open={confirmDialogOpen}
+ onOpenChange={setConfirmDialogOpen}
+ title={confirmProps.title}
+ description={confirmProps.description}
+ onConfirm={confirmProps.onConfirm}
+ isLoading={isPending && (action === "delete" || action === "update-status")}
+ confirmLabel={
+ action === "delete"
+ ? "Delete"
+ : action === "update-status"
+ ? "Update"
+ : "Confirm"
+ }
+ confirmVariant={
+ action === "delete" ? "destructive" : "default"
+ }
+ />
+ </Portal>
+ )
+}
diff --git a/lib/vendors/table/vendors-table-toolbar-actions.tsx b/lib/vendors/table/vendors-table-toolbar-actions.tsx
new file mode 100644
index 00000000..c0605191
--- /dev/null
+++ b/lib/vendors/table/vendors-table-toolbar-actions.tsx
@@ -0,0 +1,97 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload, Check } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { Vendor } from "@/db/schema/vendors"
+import { ApproveVendorsDialog } from "./approve-vendor-dialog"
+import { RequestPQVendorsDialog } from "./request-vendor-pg-dialog"
+import { SendVendorsDialog } from "./send-vendor-dialog"
+
+interface VendorsTableToolbarActionsProps {
+ table: Table<Vendor>
+}
+
+export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링
+ const pendingReviewVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ .filter(vendor => vendor.status === "PENDING_REVIEW");
+ }, [table.getFilteredSelectedRowModel().rows]);
+
+
+ // 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링
+ const inReviewVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ .filter(vendor => vendor.status === "IN_REVIEW");
+ }, [table.getFilteredSelectedRowModel().rows]);
+
+ const approvedVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ .filter(vendor => vendor.status === "APPROVED");
+ }, [table.getFilteredSelectedRowModel().rows]);
+
+
+
+ return (
+ <div className="flex items-center gap-2">
+
+
+
+ {/* 승인 다이얼로그: PENDING_REVIEW 상태인 벤더가 있을 때만 표시 */}
+ {pendingReviewVendors.length > 0 && (
+ <ApproveVendorsDialog
+ vendors={pendingReviewVendors}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ )}
+
+ {inReviewVendors.length > 0 && (
+ <RequestPQVendorsDialog
+ vendors={inReviewVendors}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ )}
+
+ {approvedVendors.length > 0 && (
+ <SendVendorsDialog
+ vendors={approvedVendors}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ )}
+
+
+ {/** 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>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/table/vendors-table.tsx b/lib/vendors/table/vendors-table.tsx
new file mode 100644
index 00000000..c04d57a9
--- /dev/null
+++ b/lib/vendors/table/vendors-table.tsx
@@ -0,0 +1,121 @@
+"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 { useFeatureFlags } from "./feature-flags-provider"
+import { getColumns } from "./vendors-table-columns"
+import { getVendors, getVendorStatusCounts } from "../service"
+import { Vendor, 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"
+
+interface VendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getVendors>>,
+ Awaited<ReturnType<typeof getVendorStatusCounts>>
+ ]
+ >
+}
+
+export function VendorsTable({ promises }: VendorsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }, statusCounts] = React.use(promises)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<Vendor> | null>(null)
+
+ // **router** 획득
+ const router = useRouter()
+
+ // getColumns() 호출 시, router를 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, router }),
+ [setRowAction, router]
+ )
+
+ const filterFields: DataTableFilterField<Vendor>[] = [
+ {
+ id: "status",
+ label: "Status",
+ options: vendors.status.enumValues.map((status) => ({
+ label: toSentenceCase(status),
+ value: status,
+ count: statusCounts[status],
+ })),
+ },
+
+ { id: "vendorCode", label: "Vendor Code" },
+
+ ]
+
+ 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" },
+ {
+ id: "status",
+ label: "Status",
+ type: "multi-select",
+ options: vendors.status.enumValues.map((status) => ({
+ label: toSentenceCase(status),
+ value: status,
+ count: statusCounts[status],
+ })),
+ },
+ { id: "createdAt", label: "Created at", type: "date" },
+ { id: "updatedAt", label: "Updated at", 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}
+ // floatingBar={<VendorsTableFloatingBar table={table} />}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <VendorsTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ <UpdateVendorSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ vendor={rowAction?.row.original ?? null}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts
new file mode 100644
index 00000000..14efc8dc
--- /dev/null
+++ b/lib/vendors/validations.ts
@@ -0,0 +1,341 @@
+import { tasks, type Task } from "@/db/schema/tasks";
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { Vendor, VendorContact, VendorItemsView, vendors } from "@/db/schema/vendors";
+import { rfqs } from "@/db/schema/rfq"
+
+
+export const searchParamsCache = createSearchParamsCache({
+
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬 (vendors 테이블에 맞춰 Vendor 타입 지정)
+ sort: getSortingStateParser<Vendor>().withDefault([
+ { id: "createdAt", desc: true }, // createdAt 기준 내림차순
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // -----------------------------------------------------------------
+ // 여기부터는 "벤더"에 특화된 검색 필드 예시
+ // -----------------------------------------------------------------
+ // 상태 (ACTIVE, INACTIVE, BLACKLISTED 등) 중에서 선택
+ status: parseAsStringEnum(["ACTIVE", "INACTIVE", "BLACKLISTED"]),
+
+ // 벤더명 검색
+ vendorName: parseAsString.withDefault(""),
+
+ // 국가 검색
+ country: parseAsString.withDefault(""),
+
+ // 예) 코드 검색
+ vendorCode: parseAsString.withDefault(""),
+
+ // 필요하다면 이메일 검색 / 웹사이트 검색 등 추가 가능
+ email: parseAsString.withDefault(""),
+ website: parseAsString.withDefault(""),
+});
+
+export const searchParamsContactCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬 (vendors 테이블에 맞춰 Vendor 타입 지정)
+ sort: getSortingStateParser<VendorContact>().withDefault([
+ { id: "createdAt", desc: true }, // createdAt 기준 내림차순
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+
+ contactName: parseAsString.withDefault(""),
+ contactPosition: parseAsString.withDefault(""),
+ contactEmail: parseAsString.withDefault(""),
+ contactPhone: parseAsString.withDefault(""),
+});
+
+
+
+export const searchParamsItemCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬 (vendors 테이블에 맞춰 Vendor 타입 지정)
+ sort: getSortingStateParser<VendorItemsView>().withDefault([
+ { id: "createdAt", desc: true }, // createdAt 기준 내림차순
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+
+ itemName: parseAsString.withDefault(""),
+ itemCode: parseAsString.withDefault(""),
+ description: parseAsString.withDefault(""),
+});
+
+
+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(),
+ 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"),
+});
+
+
+const contactSchema = z.object({
+ contactName: z
+ .string()
+ .min(1, "Contact name is required")
+ .max(255, "Max length 255"),
+ contactPosition: z.string().max(100).optional(),
+ contactEmail: z.string().email("Invalid email").max(255),
+ contactPhone: z.string().max(50).optional(),
+ isPrimary: z.boolean().default(false).optional()})
+
+const vendorStatusEnum = z.enum(vendors.status.enumValues)
+// CREATE 시: 일부 필드는 필수, 일부는 optional
+export const createVendorSchema = z
+ .object({
+
+ vendorName: 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"),
+
+ // 나머지 optional
+ vendorCode: z.string().max(100, "Max length 100").optional(),
+ address: z.string().optional(),
+ country: z.string()
+ .min(1, "국가 선택은 필수입니다.")
+ .max(100, "Max length 100"),
+ 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 => {
+ // Validate that files exist and there's at least one file
+ return val &&
+ (Array.isArray(val) ? val.length > 0 :
+ val instanceof FileList ? val.length > 0 :
+ val && typeof val === 'object' && 'length' in val && val.length > 0);
+ },
+ { message: "첨부 파일은 필수입니다." }
+ ),
+ status: vendorStatusEnum.default("PENDING_REVIEW"),
+
+ representativeName: z.union([z.string().max(255), z.literal("")]).optional(),
+ representativeBirth: z.union([z.string().max(20), z.literal("")]).optional(),
+ 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(),
+
+ creditAgency: z.string().max(50).optional(),
+ creditRating: z.string().max(50).optional(),
+ cashFlowRating: z.string().max(50).optional(),
+
+ contacts: z
+ .array(contactSchema)
+ .nonempty("At least one contact is required."),
+
+ // ... (기타 필드)
+ })
+ .superRefine((data, ctx) => {
+ if (data.country === "KR") {
+ // 1) 대표자 정보가 누락되면 각각 에러 발생
+ if (!data.representativeName) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["representativeName"],
+ message: "대표자 이름은 한국(KR) 업체일 경우 필수입니다.",
+ })
+ }
+ if (!data.representativeBirth) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["representativeBirth"],
+ message: "대표자 생년월일은 한국(KR) 업체일 경우 필수입니다.",
+ })
+ }
+ if (!data.representativeEmail) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["representativeEmail"],
+ message: "대표자 이메일은 한국(KR) 업체일 경우 필수입니다.",
+ })
+ }
+ if (!data.representativePhone) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["representativePhone"],
+ message: "대표자 전화번호는 한국(KR) 업체일 경우 필수입니다.",
+ })
+ }
+ if (!data.corporateRegistrationNumber) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["corporateRegistrationNumber"],
+ message: "법인등록번호는 한국(KR) 업체일 경우 필수입니다.",
+ })
+ }
+
+ // 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) 업체일 경우 필수입니다.",
+ })
+ }
+ }
+ }
+)
+
+export const createVendorContactSchema = z.object({
+ vendorId: z.number(),
+ contactName: z.string()
+ .min(1, "Contact name is required")
+ .max(255, "Max length 255"), // 신규 생성 시 반드시 입력
+ contactPosition: z.string().max(100, "Max length 100"),
+ contactEmail: z.string().email(),
+ contactPhone: z.string().max(50, "Max length 50").optional(),
+ isPrimary: z.boolean(),
+});
+
+
+export const updateVendorContactSchema = z.object({
+ contactName: z.string()
+ .min(1, "Contact name is required")
+ .max(255, "Max length 255"), // 신규 생성 시 반드시 입력
+ contactPosition: z.string().max(100, "Max length 100").optional(),
+ contactEmail: z.string().email().optional(),
+ contactPhone: z.string().max(50, "Max length 50").optional(),
+ isPrimary: z.boolean().optional(),
+});
+
+
+
+export const createVendorItemSchema = z.object({
+ vendorId: z.number(),
+ itemCode: z.string().max(100, "Max length 100"),
+
+});
+
+
+export const updateVendorItemSchema = z.object({
+ itemName: z.string().optional(),
+ itemCode: z.string().max(100, "Max length 100"),
+ description: z.string().optional()
+});
+
+export const searchParamsRfqHistoryCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬
+ sort: getSortingStateParser<typeof rfqs.$inferSelect>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // RFQ 특화 필터
+ rfqCode: parseAsString.withDefault(""),
+ projectCode: parseAsString.withDefault(""),
+ projectName: parseAsString.withDefault(""),
+ status: parseAsStringEnum(["DRAFT", "IN_PROGRESS", "COMPLETED", "CANCELLED"]),
+ vendorStatus: parseAsStringEnum(["INVITED", "ACCEPTED", "DECLINED", "SUBMITTED", "AWARDED", "REJECTED"]),
+ dueDate: parseAsString.withDefault(""),
+});
+
+export type GetVendorsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+export type GetVendorContactsSchema = Awaited<ReturnType<typeof searchParamsContactCache.parse>>
+export type GetVendorItemsSchema = Awaited<ReturnType<typeof searchParamsItemCache.parse>>
+
+export type UpdateVendorSchema = z.infer<typeof updateVendorSchema>
+export type CreateVendorSchema = z.infer<typeof createVendorSchema>
+export type CreateVendorContactSchema = z.infer<typeof createVendorContactSchema>
+export type UpdateVendorContactSchema = z.infer<typeof updateVendorContactSchema>
+export type CreateVendorItemSchema = z.infer<typeof createVendorItemSchema>
+export type UpdateVendorItemSchema = z.infer<typeof updateVendorItemSchema>
+export type GetRfqHistorySchema = Awaited<ReturnType<typeof searchParamsRfqHistoryCache.parse>>