summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-10-14 09:14:10 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-10-14 09:14:10 +0000
commit2ce5f9dfbb69f0898c42ab862db5ad142fa24943 (patch)
tree64b2d54c5c56860ed36038867c570acd2abf35a3
parent6acb316af6041f093532a778f66960fc196e1547 (diff)
(최겸) 구매 입찰 1회성 품목 기준정보 개발(스키마, 테이블, CRUD, 페이지 등)
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/p-items/page.tsx62
-rw-r--r--config/menuConfig.ts12
-rw-r--r--config/procurementItemsColumnsConfig.ts87
-rw-r--r--db/schema/items.ts17
-rw-r--r--i18n/locales/en/menu.json4
-rw-r--r--i18n/locales/ko/menu.json4
-rw-r--r--lib/procurement-items/repository.ts118
-rw-r--r--lib/procurement-items/service.ts374
-rw-r--r--lib/procurement-items/table/add-procurement-items-dialog.tsx197
-rw-r--r--lib/procurement-items/table/delete-procurement-items-dialog.tsx151
-rw-r--r--lib/procurement-items/table/import-procurement-items-excel-button.tsx247
-rw-r--r--lib/procurement-items/table/procurement-items-excel-template.tsx101
-rw-r--r--lib/procurement-items/table/procurement-items-table-columns.tsx179
-rw-r--r--lib/procurement-items/table/procurement-items-table-toolbar-actions.tsx182
-rw-r--r--lib/procurement-items/table/procurement-items-table.tsx152
-rw-r--r--lib/procurement-items/table/update-procurement-items-sheet.tsx221
-rw-r--r--lib/procurement-items/validations.ts57
-rw-r--r--lib/rfq-last/vendor/vendor-detail-dialog.tsx6
18 files changed, 2166 insertions, 5 deletions
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/p-items/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/p-items/page.tsx
new file mode 100644
index 00000000..e3810b5b
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/(procurement)/p-items/page.tsx
@@ -0,0 +1,62 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { getProcurementItems } from "@/lib/procurement-items/service"
+import { ProcurementItemsTable } from "@/lib/procurement-items/table/procurement-items-table"
+import { searchParamsCache } from "@/lib/procurement-items/validations"
+import { InformationButton } from "@/components/information/information-button"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getProcurementItems({
+ ...search,
+ filters: validFilters,
+ }),
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">
+ 1회성 품목 관리
+ </h2>
+ <InformationButton pagePath="evcp/procurement-items" />
+ </div>
+ <p className="text-muted-foreground">
+ 입찰에서 사용하는 1회성 품목을 등록하고 관리합니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={8}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "20rem", "8rem", "12rem", "6rem", "8rem", "10rem", "10rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <ProcurementItemsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/config/menuConfig.ts b/config/menuConfig.ts
index 9dd649e2..4ac2bdc1 100644
--- a/config/menuConfig.ts
+++ b/config/menuConfig.ts
@@ -168,6 +168,12 @@ export const mainNav: MenuSection[] = [
descriptionKey: "menu.master_data.buyer_signaturee_desc",
groupKey: "groups.procurement_info"
},
+ {
+ titleKey: "menu.master_data.procurement_items",
+ href: "/evcp/p-items",
+ descriptionKey: "menu.master_data.procurement_items_desc",
+ groupKey: "groups.procurement_info"
+ },
],
},
{
@@ -595,6 +601,12 @@ export const procurementNav: MenuSection[] = [
descriptionKey: "menu.master_data.compliance_survey_desc",
groupKey: "groups.procurement_info"
},
+ {
+ titleKey: "menu.master_data.procurement_items",
+ href: "/evcp/p-items",
+ descriptionKey: "menu.master_data.procurement_items_desc",
+ groupKey: "groups.procurement_info"
+ },
],
},
{
diff --git a/config/procurementItemsColumnsConfig.ts b/config/procurementItemsColumnsConfig.ts
new file mode 100644
index 00000000..dd922f91
--- /dev/null
+++ b/config/procurementItemsColumnsConfig.ts
@@ -0,0 +1,87 @@
+import { ProcurementItem } from "@/db/schema/items"
+
+export interface ProcurementItemColumnConfig {
+ id: keyof ProcurementItem
+ label: string
+ group?: string
+ excelHeader?: string
+ type?: string
+ sortable?: boolean
+ filterable?: boolean
+ width?: number
+}
+
+export const procurementItemsColumnsConfig: ProcurementItemColumnConfig[] = [
+ {
+ id: "itemCode",
+ label: "품목코드",
+ excelHeader: "품목코드",
+ type: "text",
+ sortable: true,
+ filterable: true,
+ width: 150,
+ },
+ {
+ id: "itemName",
+ label: "품목명",
+ excelHeader: "품목명",
+ type: "text",
+ sortable: true,
+ filterable: true,
+ width: 250,
+ },
+ {
+ id: "material",
+ label: "재질",
+ excelHeader: "재질",
+ type: "text",
+ sortable: true,
+ filterable: true,
+ width: 120,
+ },
+ {
+ id: "specification",
+ label: "규격",
+ excelHeader: "규격",
+ type: "text",
+ sortable: true,
+ filterable: true,
+ width: 200,
+ },
+ {
+ id: "unit",
+ label: "단위",
+ excelHeader: "단위",
+ type: "text",
+ sortable: true,
+ filterable: true,
+ width: 80,
+ },
+ {
+ id: "isActive",
+ label: "활성화여부",
+ excelHeader: "활성화여부",
+ type: "text",
+ sortable: true,
+ filterable: true,
+ width: 100,
+ },
+ {
+ id: "createdBy",
+ label: "등록자",
+ excelHeader: "등록자",
+ type: "text",
+ sortable: true,
+ filterable: true,
+ width: 120,
+ },
+ {
+ id: "createdAt",
+ label: "등록일시",
+ excelHeader: "등록일시",
+ type: "date",
+ sortable: true,
+ filterable: true,
+ width: 130,
+ },
+]
diff --git a/db/schema/items.ts b/db/schema/items.ts
index d7640049..e9c10058 100644
--- a/db/schema/items.ts
+++ b/db/schema/items.ts
@@ -88,4 +88,21 @@ export type ItemOffshoreHull = typeof itemOffshoreHull.$inferSelect;
//각 테이블별 컬럼 변경(itemid -> itemCode)
+// 품목 관리 테이블 - 사용자 요구사항에 맞게 설계
+export const procurementItems = pgTable("procurement_items", {
+ id: serial("id").primaryKey(),
+ itemCode: varchar("item_code", { length: 100 }),
+ itemName: varchar("item_name", { length: 255 }).notNull(),
+ material: varchar("material", { length: 100 }), // 재질
+ specification: varchar("specification", { length: 255 }), // 규격
+ unit: varchar("unit", { length: 50 }), // 단위
+ isActive: varchar("is_active", { length: 1 }).default('Y').notNull(), // 활성화여부 (Y/N)
+ createdBy: varchar("created_by", { length: 100 }), // 등록자
+ createdAt: timestamp("created_at").defaultNow().notNull(), // 등록일시
+ updatedAt: timestamp("updated_at").defaultNow().notNull(), // 수정일시
+});
+
+export type ProcurementItem = typeof procurementItems.$inferSelect;
+export type ProcurementItemInsert = typeof procurementItems.$inferInsert;
+
diff --git a/i18n/locales/en/menu.json b/i18n/locales/en/menu.json
index 4c241c03..b0ddeacd 100644
--- a/i18n/locales/en/menu.json
+++ b/i18n/locales/en/menu.json
@@ -79,7 +79,9 @@
"general_contract_template_desc": "General contract standard template management",
"gtc":"General Terms and Conditions",
"buyer_signature":"Buyer Signature",
- "buyer_signaturee_desc":"Buyer Signature Management"
+ "buyer_signaturee_desc":"Buyer Signature Management",
+ "procurement_items": "One-time Items Management",
+ "procurement_items_desc": "Register and manage one-time items used in bidding"
},
"engineering_management": {
"title": "Engineering",
diff --git a/i18n/locales/ko/menu.json b/i18n/locales/ko/menu.json
index 7b563bcb..467123f2 100644
--- a/i18n/locales/ko/menu.json
+++ b/i18n/locales/ko/menu.json
@@ -78,7 +78,9 @@
"general_contract_template_desc": "일반계약 표준양식 템플릿 관리",
"gtc": "General Terms and Conditions",
"buyer_signature": "구매자 서명 관리",
- "buyer_signaturee_desc": "자동 전자서명을 위한 서명 업로드"
+ "buyer_signaturee_desc": "자동 전자서명을 위한 서명 업로드",
+ "procurement_items": "1회성 품목 관리",
+ "procurement_items_desc": "입찰에서 사용하는 1회성 품목을 등록하고 관리"
},
"engineering_management": {
"title": "설계",
diff --git a/lib/procurement-items/repository.ts b/lib/procurement-items/repository.ts
new file mode 100644
index 00000000..d660c81e
--- /dev/null
+++ b/lib/procurement-items/repository.ts
@@ -0,0 +1,118 @@
+// lib/procurement/items/repository.ts
+import db from "@/db/db";
+import { ProcurementItem, procurementItems } from "@/db/schema/items";
+import {
+ eq,
+ inArray,
+ asc,
+ desc,
+ count,
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+
+export type NewProcurementItem = typeof procurementItems.$inferInsert
+
+/**
+ * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수
+ * 트랜잭션(tx)을 받아서 사용하도록 구현
+ */
+export async function selectProcurementItems(
+ 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()
+ .from(procurementItems)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+
+/** 총 개수 count */
+export async function countProcurementItems(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(procurementItems).where(where);
+ return res[0]?.count ?? 0;
+}
+
+/** 단건 Insert */
+export async function insertProcurementItem(
+ tx: PgTransaction<any, any, any>,
+ data: NewProcurementItem
+) {
+ return tx
+ .insert(procurementItems)
+ .values(data)
+ .returning({ id: procurementItems.id, createdAt: procurementItems.createdAt });
+}
+
+/** 복수 Insert */
+export async function insertProcurementItems(
+ tx: PgTransaction<any, any, any>,
+ data: ProcurementItem[]
+) {
+ return tx.insert(procurementItems).values(data).onConflictDoNothing();
+}
+
+/** 단건 삭제 */
+export async function deleteProcurementItemById(
+ tx: PgTransaction<any, any, any>,
+ itemId: number
+) {
+ return tx.delete(procurementItems).where(eq(procurementItems.id, itemId));
+}
+
+/** 복수 삭제 */
+export async function deleteProcurementItemsByIds(
+ tx: PgTransaction<any, any, any>,
+ ids: number[]
+) {
+ return tx.delete(procurementItems).where(inArray(procurementItems.id, ids));
+}
+
+/** 전체 삭제 */
+export async function deleteAllProcurementItems(
+ tx: PgTransaction<any, any, any>,
+) {
+ return tx.delete(procurementItems);
+}
+
+/** 단건 업데이트 */
+export async function updateProcurementItem(
+ tx: PgTransaction<any, any, any>,
+ itemId: number,
+ data: Partial<ProcurementItem>
+) {
+ return tx
+ .update(procurementItems)
+ .set(data)
+ .where(eq(procurementItems.id, itemId))
+ .returning({ id: procurementItems.id, createdAt: procurementItems.createdAt });
+}
+
+/** 복수 업데이트 */
+export async function updateProcurementItems(
+ tx: PgTransaction<any, any, any>,
+ ids: number[],
+ data: Partial<ProcurementItem>
+) {
+ return tx
+ .update(procurementItems)
+ .set(data)
+ .where(inArray(procurementItems.id, ids))
+ .returning({ id: procurementItems.id, createdAt: procurementItems.createdAt });
+}
+
+export async function findAllProcurementItems(): Promise<ProcurementItem[]> {
+ return db.select().from(procurementItems).orderBy(asc(procurementItems.itemCode));
+}
diff --git a/lib/procurement-items/service.ts b/lib/procurement-items/service.ts
new file mode 100644
index 00000000..ee6df959
--- /dev/null
+++ b/lib/procurement-items/service.ts
@@ -0,0 +1,374 @@
+// lib/procurement/items/service.ts
+"use server"
+
+import { 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 { asc, desc, ilike, and, or, eq } from "drizzle-orm"
+import { GetProcurementItemsSchema, UpdateProcurementItemSchema, createProcurementItemSchema } from "./validations"
+import { ProcurementItem, procurementItems } from "@/db/schema/items"
+import {
+ countProcurementItems,
+ deleteProcurementItemById,
+ deleteProcurementItemsByIds,
+ findAllProcurementItems,
+ insertProcurementItem,
+ selectProcurementItems,
+ updateProcurementItem
+} from "./repository"
+
+/* -----------------------------------------------------
+ 1) 조회 관련
+----------------------------------------------------- */
+
+/**
+ * 복잡한 조건으로 품목 목록을 조회 (+ pagination) 하고,
+ * 총 개수에 따라 pageCount를 계산해서 리턴.
+ * Next.js의 unstable_cache를 사용해 일정 시간 캐시.
+ */
+export async function getProcurementItems(input: GetProcurementItemsSchema) {
+ const safePerPage = Math.min(input.perPage, 100)
+
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * safePerPage
+
+ const advancedWhere = filterColumns({
+ table: procurementItems,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ })
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ ilike(procurementItems.itemCode, s),
+ ilike(procurementItems.itemName, s),
+ ilike(procurementItems.material, s),
+ ilike(procurementItems.specification, s),
+ ilike(procurementItems.unit, s),
+ )
+ }
+
+ const finalWhere = and(advancedWhere, globalWhere)
+
+ const orderBy = input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(procurementItems[item.id]) : asc(procurementItems[item.id])
+ )
+ : [asc(procurementItems.createdAt)]
+
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectProcurementItems(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: safePerPage,
+ })
+
+ const total = await countProcurementItems(tx, finalWhere)
+ return { data, total }
+ })
+
+ const pageCount = Math.ceil(total / safePerPage)
+ return { data, pageCount }
+ } catch (err) {
+ console.error(err)
+ return { data: [], pageCount: 0 }
+ }
+ },
+ [JSON.stringify({...input, perPage: safePerPage})],
+ {
+ revalidate: 3600,
+ tags: ["procurement-items"],
+ }
+ )()
+}
+
+/* -----------------------------------------------------
+ 2) 생성(Create)
+----------------------------------------------------- */
+
+export interface ProcurementItemCreateData {
+ itemCode: string
+ itemName: string
+ material?: string | null
+ specification?: string | null
+ unit?: string | null
+ isActive?: string | null
+ createdBy?: string | null
+}
+
+/**
+ * 품목 생성
+ */
+export async function createProcurementItem(input: ProcurementItemCreateData) {
+ unstable_noStore()
+
+ try {
+ if (!input.itemCode || !input.itemName) {
+ return {
+ success: false,
+ message: "품목코드와 품목명은 필수입니다",
+ data: null,
+ error: "필수 필드 누락"
+ }
+ }
+
+ let result: any[] = []
+
+ result = await db.transaction(async (tx) => {
+ // 기존 품목 확인 (itemCode는 unique)
+ const existingItem = await tx.query.procurementItems.findFirst({
+ where: eq(procurementItems.itemCode, input.itemCode),
+ })
+
+ let txResult
+ if (existingItem) {
+ // 기존 품목 업데이트
+ txResult = await updateProcurementItem(tx, existingItem.id, {
+ itemName: input.itemName,
+ material: input.material,
+ specification: input.specification,
+ unit: input.unit,
+ isActive: input.isActive,
+ createdBy: input.createdBy,
+ })
+ } else {
+ // 새 품목 생성
+ txResult = await insertProcurementItem(tx, {
+ itemCode: input.itemCode,
+ itemName: input.itemName,
+ material: input.material,
+ specification: input.specification,
+ unit: input.unit,
+ isActive: input.isActive || 'Y',
+ createdBy: input.createdBy,
+ })
+ }
+
+ return txResult
+ })
+
+ // 캐시 무효화
+ revalidateTag("procurement-items")
+
+ return {
+ success: true,
+ data: result[0] || null,
+ error: null
+ }
+ } catch (err) {
+ console.error("품목 생성/업데이트 오류:", err)
+
+ // 중복 키 오류 처리
+ if (err instanceof Error && err.message.includes("unique constraint")) {
+ return {
+ success: false,
+ message: "이미 존재하는 품목코드입니다",
+ data: null,
+ error: "중복 키 오류"
+ }
+ }
+
+ return {
+ success: false,
+ message: getErrorMessage(err),
+ data: null,
+ error: getErrorMessage(err)
+ }
+ }
+}
+
+/* -----------------------------------------------------
+ 3) 업데이트
+----------------------------------------------------- */
+
+/** 단건 업데이트 */
+export async function modifyProcurementItem(input: UpdateProcurementItemSchema & { id: number }) {
+ unstable_noStore()
+ try {
+ await db.transaction(async (tx) => {
+ await updateProcurementItem(tx, input.id, {
+ itemCode: input.itemCode,
+ itemName: input.itemName,
+ material: input.material,
+ specification: input.specification,
+ unit: input.unit,
+ isActive: input.isActive,
+ })
+ })
+
+ revalidateTag("procurement-items")
+ return { data: null, error: null }
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) }
+ }
+}
+
+/** 단건 삭제 */
+export async function removeProcurementItem(input: { id: number }) {
+ unstable_noStore()
+ try {
+ await db.transaction(async (tx) => {
+ await deleteProcurementItemById(tx, input.id)
+ })
+
+ revalidateTag("procurement-items")
+ return { data: null, error: null }
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) }
+ }
+}
+
+/** 복수 삭제 */
+export async function removeProcurementItems(input: { ids: number[] }) {
+ unstable_noStore()
+ try {
+ await db.transaction(async (tx) => {
+ await deleteProcurementItemsByIds(tx, input.ids)
+ })
+
+ revalidateTag("procurement-items")
+ return { data: null, error: null }
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) }
+ }
+}
+
+export async function getAllProcurementItems(): Promise<ProcurementItem[]> {
+ try {
+ return await findAllProcurementItems()
+ } catch (err) {
+ throw new Error("Failed to get procurement items")
+ }
+}
+
+// 품목 검색 함수
+export async function searchProcurementItems(query: string): Promise<{ itemCode: string; itemName: string }[]> {
+ unstable_noStore()
+
+ try {
+ if (!query || query.trim().length < 1) {
+ return []
+ }
+
+ const searchQuery = `%${query.trim()}%`
+
+ const results = await db
+ .select({
+ itemCode: procurementItems.itemCode,
+ itemName: procurementItems.itemName,
+ })
+ .from(procurementItems)
+ .where(
+ and(
+ or(
+ ilike(procurementItems.itemCode, searchQuery),
+ ilike(procurementItems.itemName, searchQuery)
+ ),
+ // 활성화된 품목만
+ eq(procurementItems.isActive, 'Y')
+ )
+ )
+ .limit(20)
+ .orderBy(asc(procurementItems.itemCode))
+
+ return results
+ } catch (err) {
+ console.error("품목 검색 오류:", err)
+ return []
+ }
+}
+
+/* -----------------------------------------------------
+ 4) Excel Import
+----------------------------------------------------- */
+
+export interface ImportResult {
+ success: boolean
+ importedCount: number
+ errorCount: number
+ errors: string[]
+ message: string
+}
+
+/**
+ * Excel 파일에서 품목 데이터 대량 가져오기
+ */
+export async function importProcurementItemsFromExcel(excelData: any[]): Promise<ImportResult> {
+ unstable_noStore()
+
+ try {
+ if (!Array.isArray(excelData)) {
+ return {
+ success: false,
+ importedCount: 0,
+ errorCount: 0,
+ errors: ["데이터 배열이 필요합니다."],
+ message: "데이터 배열이 필요합니다."
+ }
+ }
+
+ let importedCount = 0
+ let errorCount = 0
+ const errors: string[] = []
+
+ // 배치 처리 (한 번에 너무 많은 데이터를 처리하지 않도록)
+ const batchSize = 50
+ for (let i = 0; i < excelData.length; i += batchSize) {
+ const batch = excelData.slice(i, i + batchSize)
+
+ for (const itemData of batch) {
+ try {
+ // 데이터 검증
+ const validatedData = createProcurementItemSchema.parse(itemData)
+
+ // 품목 생성 또는 업데이트
+ const result = await createProcurementItem({
+ itemCode: validatedData.itemCode,
+ itemName: validatedData.itemName,
+ material: validatedData.material || null,
+ specification: validatedData.specification || null,
+ unit: validatedData.unit || null,
+ isActive: validatedData.isActive || 'Y',
+ })
+
+ if (result.success) {
+ importedCount++
+ } else {
+ errorCount++
+ errors.push(`${validatedData.itemCode}: ${result.error}`)
+ }
+ } catch (validationError) {
+ errorCount++
+ errors.push(`${itemData.itemCode || '알 수 없음'}: 검증 오류`)
+ }
+ }
+ }
+
+ return {
+ success: true,
+ importedCount,
+ errorCount,
+ errors: errors.slice(0, 10), // 너무 많은 오류 메시지를 보내지 않도록 제한
+ message: `${importedCount}개 품목이 성공적으로 가져오기를 완료했습니다.${errorCount > 0 ? ` ${errorCount}개 오류 발생.` : ''}`
+ }
+
+ } catch (error) {
+ console.error("품목 가져오기 오류:", error)
+ return {
+ success: false,
+ importedCount: 0,
+ errorCount: 0,
+ errors: ["서버 내부 오류가 발생했습니다."],
+ message: "서버 내부 오류가 발생했습니다."
+ }
+ }
+}
diff --git a/lib/procurement-items/table/add-procurement-items-dialog.tsx b/lib/procurement-items/table/add-procurement-items-dialog.tsx
new file mode 100644
index 00000000..b2915dc2
--- /dev/null
+++ b/lib/procurement-items/table/add-procurement-items-dialog.tsx
@@ -0,0 +1,197 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/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 {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { createProcurementItemSchema, CreateProcurementItemSchema } from "../validations"
+import { createProcurementItem } from "../service"
+
+interface AddProcurementItemDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSuccess?: () => void
+}
+
+export function AddProcurementItemDialog({
+ open,
+ onOpenChange,
+ onSuccess
+}: AddProcurementItemDialogProps) {
+ // react-hook-form 세팅
+ const form = useForm<CreateProcurementItemSchema>({
+ resolver: zodResolver(createProcurementItemSchema),
+ defaultValues: {
+ itemCode: "",
+ itemName: "",
+ material: "",
+ specification: "",
+ unit: "",
+ isActive: "Y",
+ },
+ })
+
+ async function onSubmit(data: CreateProcurementItemSchema) {
+ const result = await createProcurementItem({
+ itemCode: data.itemCode,
+ itemName: data.itemName,
+ material: data.material ?? null,
+ specification: data.specification ?? null,
+ unit: data.unit ?? null,
+ isActive: data.isActive || 'Y',
+ })
+
+ if (result.error) {
+ alert(`에러: ${result.error}`)
+ return
+ }
+
+ // 성공 시 모달 닫고 폼 리셋
+ form.reset()
+ onOpenChange(false)
+ onSuccess?.()
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ onOpenChange(nextOpen)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>새 품목 추가</DialogTitle>
+ <DialogDescription>
+ 새 품목 정보를 입력하고 <b>추가</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="itemCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>품목코드</FormLabel>
+ <FormControl>
+ <Input placeholder="예: ITEM001" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="itemName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>품목명 *</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 강관" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="material"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>재질</FormLabel>
+ <FormControl>
+ <Input placeholder="예: SS400" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="specification"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>규격</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 50x50x2.3T" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="unit"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>단위</FormLabel>
+ <FormControl>
+ <Input placeholder="예: EA, M, KG" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="isActive"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>활성화여부</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="활성화 여부를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="Y">활성</SelectItem>
+ <SelectItem value="N">비활성</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button type="submit" disabled={form.formState.isSubmitting}>
+ {form.formState.isSubmitting ? "추가 중..." : "추가"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/procurement-items/table/delete-procurement-items-dialog.tsx b/lib/procurement-items/table/delete-procurement-items-dialog.tsx
new file mode 100644
index 00000000..a1262a03
--- /dev/null
+++ b/lib/procurement-items/table/delete-procurement-items-dialog.tsx
@@ -0,0 +1,151 @@
+"use client"
+
+import * as React from "react"
+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 { ProcurementItem } from "@/db/schema/items"
+import { removeProcurementItems } from "../service"
+
+interface DeleteProcurementItemsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ procurementItems: ProcurementItem[]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteProcurementItemsDialog({
+ procurementItems,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteProcurementItemsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removeProcurementItems({
+ ids: procurementItems.map((item) => item.id),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("품목이 성공적으로 삭제되었습니다.")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({procurementItems.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 실행 취소할 수 없습니다. 선택한{" "}
+ <span className="font-medium">{procurementItems.length}</span>개 품목을
+ 서버에서 영구적으로 삭제합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택된 품목 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({procurementItems.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 실행 취소할 수 없습니다. 선택한{" "}
+ <span className="font-medium">{procurementItems.length}</span>개 품목을
+ 서버에서 영구적으로 삭제합니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택된 품목 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}
diff --git a/lib/procurement-items/table/import-procurement-items-excel-button.tsx b/lib/procurement-items/table/import-procurement-items-excel-button.tsx
new file mode 100644
index 00000000..6a50909e
--- /dev/null
+++ b/lib/procurement-items/table/import-procurement-items-excel-button.tsx
@@ -0,0 +1,247 @@
+"use client"
+
+import * as React from "react"
+import { Upload } from "lucide-react"
+import { toast } from "sonner"
+import * as ExcelJS from 'exceljs'
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Progress } from "@/components/ui/progress"
+
+interface ImportProcurementItemButtonProps {
+ onImportSuccess?: () => void
+}
+
+export function ImportProcurementItemButton({ onImportSuccess }: ImportProcurementItemButtonProps) {
+ const [open, setOpen] = React.useState(false)
+ const [file, setFile] = React.useState<File | null>(null)
+ const [isUploading, setIsUploading] = React.useState(false)
+ const [progress, setProgress] = React.useState(0)
+ const [error, setError] = React.useState<string | null>(null)
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일 선택 처리
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const selectedFile = e.target.files?.[0]
+ if (!selectedFile) return
+
+ if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) {
+ setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.")
+ return
+ }
+
+ setFile(selectedFile)
+ setError(null)
+ }
+
+ // 데이터 가져오기 처리
+ const handleImport = async () => {
+ if (!file) {
+ setError("가져올 파일을 선택해주세요.")
+ return
+ }
+
+ try {
+ setIsUploading(true)
+ setProgress(0)
+ setError(null)
+
+ // 파일을 ArrayBuffer로 변환
+ const arrayBuffer = await file.arrayBuffer()
+
+ // ExcelJS 워크북 로드
+ const workbook = new ExcelJS.Workbook()
+ await workbook.xlsx.load(arrayBuffer)
+
+ // 첫 번째 워크시트 가져오기
+ const worksheet = workbook.worksheets[0]
+ if (!worksheet) {
+ throw new Error("Excel 파일에 워크시트가 없습니다.")
+ }
+
+ // 헤더 행 찾기
+ let headerRowIndex = 1
+ let headerRow: ExcelJS.Row | undefined
+ let headerValues: (string | null)[] = []
+
+ worksheet.eachRow((row, rowNumber) => {
+ const values = row.values as (string | null)[]
+ if (!headerRow && values.some(v => v === "품목코드" || v === "itemCode" || v === "item_code")) {
+ headerRowIndex = rowNumber
+ headerRow = row
+ headerValues = [...values]
+ }
+ })
+
+ if (!headerRow) {
+ throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다.")
+ }
+
+ // 컬럼 매핑
+ const columnMap: { [key: string]: number } = {}
+ headerValues.forEach((header, index) => {
+ if (header) {
+ const normalizedHeader = header.toString().toLowerCase()
+ if (normalizedHeader.includes("품목코드") || normalizedHeader.includes("itemcode") || normalizedHeader === "item_code") {
+ columnMap.itemCode = index
+ } else if (normalizedHeader.includes("품목명") || normalizedHeader.includes("itemname") || normalizedHeader === "item_name") {
+ columnMap.itemName = index
+ } else if (normalizedHeader.includes("재질") || normalizedHeader.includes("material")) {
+ columnMap.material = index
+ } else if (normalizedHeader.includes("규격") || normalizedHeader.includes("specification")) {
+ columnMap.specification = index
+ } else if (normalizedHeader.includes("단위") || normalizedHeader.includes("unit")) {
+ columnMap.unit = index
+ } else if (normalizedHeader.includes("활성화") || normalizedHeader.includes("isactive") || normalizedHeader === "is_active") {
+ columnMap.isActive = index
+ }
+ }
+ })
+
+ // 필수 컬럼 확인
+ if (!columnMap.itemCode || !columnMap.itemName) {
+ throw new Error("필수 컬럼(품목코드, 품목명)을 찾을 수 없습니다.")
+ }
+
+ // 데이터 행 처리
+ const importData: any[] = []
+ let successCount = 0
+ let errorCount = 0
+
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber <= headerRowIndex) return // 헤더 행 건너뜀
+
+ const values = row.values as (string | null | undefined)[]
+
+ const itemData = {
+ itemCode: values[columnMap.itemCode]?.toString().trim(),
+ itemName: values[columnMap.itemName]?.toString().trim(),
+ material: values[columnMap.material]?.toString().trim() || null,
+ specification: values[columnMap.specification]?.toString().trim() || null,
+ unit: values[columnMap.unit]?.toString().trim() || null,
+ isActive: values[columnMap.isActive]?.toString().trim() || 'Y',
+ }
+
+ // 필수 필드 검증
+ if (!itemData.itemCode || !itemData.itemName) {
+ errorCount++
+ return
+ }
+
+ importData.push(itemData)
+ })
+
+ if (importData.length === 0) {
+ throw new Error("가져올 데이터가 없습니다.")
+ }
+
+ setProgress(50)
+
+ // 실제 데이터 저장 처리 (서버 액션 호출)
+ const { importProcurementItemsFromExcel } = await import('../service')
+ const result = await importProcurementItemsFromExcel(importData)
+
+ if (!result.success) {
+ throw new Error(result.message || '가져오기에 실패했습니다.')
+ }
+
+ setProgress(100)
+
+ toast.success(`${result.importedCount}개 품목이 성공적으로 가져오기를 완료했습니다.`)
+
+ // 성공 콜백 호출
+ onImportSuccess?.()
+ setOpen(false)
+
+ } catch (error) {
+ console.error('가져오기 오류:', error)
+ setError(error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.')
+ toast.error('가져오기에 실패했습니다.')
+ } finally {
+ setIsUploading(false)
+ setProgress(0)
+ }
+ }
+
+ return (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setOpen(true)}
+ >
+ <Upload className="mr-2 h-4 w-4" />
+ 엑셀 가져오기
+ </Button>
+
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>엑셀 파일에서 품목 가져오기</DialogTitle>
+ <DialogDescription>
+ 템플릿을 다운로드하여 작성한 후 가져오기를 실행하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div>
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".xlsx,.xls"
+ onChange={handleFileChange}
+ className="hidden"
+ />
+ <Button
+ variant="outline"
+ onClick={() => fileInputRef.current?.click()}
+ disabled={isUploading}
+ className="w-full"
+ >
+ 파일 선택
+ </Button>
+ {file && (
+ <p className="mt-2 text-sm text-muted-foreground">
+ 선택된 파일: {file.name}
+ </p>
+ )}
+ </div>
+
+ {isUploading && (
+ <div className="space-y-2">
+ <Progress value={progress} className="w-full" />
+ <p className="text-sm text-muted-foreground">
+ 처리 중... {progress}%
+ </p>
+ </div>
+ )}
+
+ {error && (
+ <p className="text-sm text-destructive">{error}</p>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setOpen(false)}>
+ 취소
+ </Button>
+ <Button
+ onClick={handleImport}
+ disabled={!file || isUploading}
+ >
+ {isUploading ? "가져오는 중..." : "가져오기"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ )
+}
diff --git a/lib/procurement-items/table/procurement-items-excel-template.tsx b/lib/procurement-items/table/procurement-items-excel-template.tsx
new file mode 100644
index 00000000..d72af26a
--- /dev/null
+++ b/lib/procurement-items/table/procurement-items-excel-template.tsx
@@ -0,0 +1,101 @@
+import * as ExcelJS from 'exceljs';
+import { saveAs } from "file-saver";
+
+/**
+ * 품목 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드
+ */
+export async function exportProcurementItemTemplate() {
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+ workbook.creator = 'Procurement Item Management System';
+ workbook.created = new Date();
+
+ // 워크시트 생성
+ const worksheet = workbook.addWorksheet('품목');
+
+ // 컬럼 헤더 정의 및 스타일 적용
+ worksheet.columns = [
+ { header: '품목코드', key: 'itemCode', width: 15 },
+ { header: '품목명', key: 'itemName', width: 30 },
+ { header: '재질', key: 'material', width: 20 },
+ { header: '규격', key: 'specification', width: 25 },
+ { header: '단위', key: 'unit', width: 10 },
+ { header: '활성화여부', key: 'isActive', width: 12 },
+ ];
+
+ // 헤더 스타일 적용
+ const headerRow = worksheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ };
+ headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
+
+ // 테두리 스타일 적용
+ headerRow.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+
+ // 샘플 데이터 행 추가
+ const sampleRow = worksheet.addRow({
+ itemCode: 'ITEM001',
+ itemName: '강관',
+ material: 'SS400',
+ specification: '50x50x2.3T',
+ unit: 'EA',
+ isActive: 'Y'
+ });
+
+ // 샘플 행 스타일 적용
+ sampleRow.eachCell((cell) => {
+ cell.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFF5F5F5' }
+ };
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+
+ // 설명 행 추가
+ const descriptionRow = worksheet.addRow([
+ '※ 품목코드: 필수, 중복 불가',
+ '※ 품목명: 필수',
+ '※ 재질: 선택사항',
+ '※ 규격: 선택사항',
+ '※ 단위: 선택사항 (예: EA, M, KG)',
+ '※ 활성화여부: Y(활성) 또는 N(비활성)'
+ ]);
+
+ descriptionRow.font = { italic: true, color: { argb: 'FF666666' } };
+ descriptionRow.eachCell((cell) => {
+ cell.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFFFE0E0' }
+ };
+ });
+
+ // 워크시트 설정
+ worksheet.views = [
+ { state: 'frozen', ySplit: 1 } // 헤더 행 고정
+ ];
+
+ // 파일 저장
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+ });
+ saveAs(blob, 'procurement_items_template.xlsx');
+}
diff --git a/lib/procurement-items/table/procurement-items-table-columns.tsx b/lib/procurement-items/table/procurement-items-table-columns.tsx
new file mode 100644
index 00000000..b695767a
--- /dev/null
+++ b/lib/procurement-items/table/procurement-items-table-columns.tsx
@@ -0,0 +1,179 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+
+import { 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,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { procurementItemsColumnsConfig } from "@/config/procurementItemsColumnsConfig"
+import { ProcurementItem } from "@/db/schema/items"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ProcurementItem> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ProcurementItem>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<ProcurementItem> = {
+ 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<ProcurementItem> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ 수정
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ 삭제
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<ProcurementItem>[] }
+ const groupMap: Record<string, ColumnDef<ProcurementItem>[]> = {}
+
+ procurementItemsColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<ProcurementItem> = {
+ 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, "KR")
+ }
+
+ if (cfg.id === "isActive") {
+ const value = cell.getValue() as string
+ return (
+ <Badge variant={value === 'Y' ? 'default' : 'secondary'}>
+ {value === 'Y' ? '활성' : '비활성'}
+ </Badge>
+ )
+ }
+
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<ProcurementItem>[] = []
+
+ // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
+ // 여기서는 그냥 Object.entries 순서
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없음 → 그냥 최상위 레벨 컬럼
+ nestedColumns.push(...colDefs)
+ } else {
+ // 상위 컬럼
+ nestedColumns.push({
+ id: groupName,
+ header: groupName,
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ actionsColumn,
+ ]
+}
diff --git a/lib/procurement-items/table/procurement-items-table-toolbar-actions.tsx b/lib/procurement-items/table/procurement-items-table-toolbar-actions.tsx
new file mode 100644
index 00000000..f9bc8805
--- /dev/null
+++ b/lib/procurement-items/table/procurement-items-table-toolbar-actions.tsx
@@ -0,0 +1,182 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { type Table } from "@tanstack/react-table"
+import { Download, FileDown, Plus } from "lucide-react"
+import * as ExcelJS from 'exceljs'
+import { saveAs } from "file-saver"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { ProcurementItem } from "@/db/schema/items"
+import { DeleteProcurementItemsDialog } from "./delete-procurement-items-dialog"
+import { AddProcurementItemDialog } from "./add-procurement-items-dialog"
+import { exportProcurementItemTemplate } from "./procurement-items-excel-template"
+import { ImportProcurementItemButton } from "./import-procurement-items-excel-button"
+
+interface ProcurementItemsTableToolbarActionsProps {
+ table: Table<ProcurementItem>
+}
+
+export function ProcurementItemsTableToolbarActions({
+ table
+}: ProcurementItemsTableToolbarActionsProps) {
+ const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false)
+ const router = useRouter()
+
+ // 가져오기 성공 후 테이블 갱신
+ const handleImportSuccess = () => {
+ router.refresh()
+ }
+
+ // Excel 내보내기 함수
+ const exportTableToExcel = async (
+ table: Table<ProcurementItem>,
+ options: {
+ filename: string;
+ excludeColumns?: string[];
+ sheetName?: string;
+ }
+ ) => {
+ const { filename, excludeColumns = [], sheetName = "품목 목록" } = options;
+
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+ workbook.creator = 'Procurement Item Management System';
+ workbook.created = new Date();
+
+ // 워크시트 생성
+ const worksheet = workbook.addWorksheet(sheetName);
+
+ // 테이블 데이터 가져오기
+ const data = table.getFilteredRowModel().rows.map(row => row.original);
+
+ // 테이블 헤더 가져오기
+ const headers = table.getAllColumns()
+ .filter(column => !excludeColumns.includes(column.id))
+ .map(column => ({
+ key: column.id,
+ header: column.columnDef.meta?.excelHeader || column.id,
+ }));
+
+ // 헤더 행 추가
+ worksheet.addRow(headers.map(h => h.header));
+
+ // 데이터 행 추가
+ data.forEach((item) => {
+ const rowData = headers.map(header => {
+ const value = item[header.key];
+
+ // 날짜 처리
+ if (header.key === 'createdAt' || header.key === 'updatedAt') {
+ return value instanceof Date ? value.toLocaleDateString('ko-KR') : value;
+ }
+
+ // 활성화 여부 처리
+ if (header.key === 'isActive') {
+ return value === 'Y' ? '활성' : '비활성';
+ }
+
+ return value || '';
+ });
+ worksheet.addRow(rowData);
+ });
+
+ // 컬럼 너비 설정
+ worksheet.columns = headers.map(header => ({
+ key: header.key,
+ width: header.key === 'itemName' || header.key === 'specification' ? 30 : 15
+ }));
+
+ // 파일 저장
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+ });
+ saveAs(blob, `${filename}_${new Date().toISOString().split('T')[0]}.xlsx`);
+ };
+
+ // 테이블 내보내기
+ const handleExportToExcel = () => {
+ exportTableToExcel(table, {
+ filename: 'procurement_items',
+ excludeColumns: ['select', 'actions'],
+ sheetName: '품목 목록'
+ });
+ toast.success('엑셀 파일이 다운로드되었습니다.');
+ };
+
+ // 템플릿 다운로드
+ const handleDownloadTemplate = () => {
+ exportProcurementItemTemplate();
+ toast.success('엑셀 템플릿이 다운로드되었습니다.');
+ };
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+ {/* 추가 버튼 */}
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => setIsAddDialogOpen(true)}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 품목 추가
+ </Button>
+
+ {/* 엑셀 내보내기 메뉴 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Download className="mr-2 h-4 w-4" />
+ 내보내기
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={handleExportToExcel}>
+ <FileDown className="mr-2 h-4 w-4" />
+ 현재 목록 내보내기
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={handleDownloadTemplate}>
+ <FileDown className="mr-2 h-4 w-4" />
+ 템플릿 다운로드
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ {/* 엑셀 가져오기 버튼 */}
+ <ImportProcurementItemButton onImportSuccess={handleImportSuccess} />
+
+ {/* 선택된 항목들 삭제 버튼 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 && (
+ <DeleteProcurementItemsDialog
+ procurementItems={table.getFilteredSelectedRowModel().rows.map(row => row.original)}
+ showTrigger={true}
+ onSuccess={() => {
+ table.toggleAllRowsSelected(false)
+ router.refresh()
+ }}
+ />
+ )}
+ </div>
+
+ <AddProcurementItemDialog
+ open={isAddDialogOpen}
+ onOpenChange={setIsAddDialogOpen}
+ onSuccess={() => {
+ setIsAddDialogOpen(false)
+ router.refresh()
+ }}
+ />
+ </>
+ )
+}
diff --git a/lib/procurement-items/table/procurement-items-table.tsx b/lib/procurement-items/table/procurement-items-table.tsx
new file mode 100644
index 00000000..a504af40
--- /dev/null
+++ b/lib/procurement-items/table/procurement-items-table.tsx
@@ -0,0 +1,152 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+
+import { getProcurementItems } from "../service"
+import { ProcurementItem } from "@/db/schema/items"
+import { getColumns } from "./procurement-items-table-columns"
+import { ProcurementItemsTableToolbarActions } from "./procurement-items-table-toolbar-actions"
+import { UpdateProcurementItemSheet } from "./update-procurement-items-sheet"
+import { DeleteProcurementItemsDialog } from "./delete-procurement-items-dialog"
+
+interface ProcurementItemsTableProps {
+ promises?: Promise<
+ [
+ Awaited<ReturnType<typeof getProcurementItems>>,
+ ]
+ >
+}
+
+export function ProcurementItemsTable({ promises }: ProcurementItemsTableProps) {
+ // 페이지네이션 모드 데이터
+ const paginationData = promises ? React.use(promises) : null
+ const [{ data = [], pageCount = 0 }] = paginationData || [{ data: [], pageCount: 0 }]
+
+ console.log('ProcurementItemsTable data:', data.length, 'items')
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<ProcurementItem> | null>(null)
+
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+
+ const router = useRouter()
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // 필터 필드들
+ const filterFields: DataTableFilterField<ProcurementItem>[] = [
+ {
+ id: "itemCode",
+ label: "품목코드",
+ },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<ProcurementItem>[] = [
+ {
+ id: "itemCode",
+ label: "품목코드",
+ type: "text",
+ },
+ {
+ id: "itemName",
+ label: "품목명",
+ type: "text",
+ },
+ {
+ id: "material",
+ label: "재질",
+ type: "text",
+ },
+ {
+ id: "specification",
+ label: "규격",
+ type: "text",
+ },
+ {
+ id: "unit",
+ label: "단위",
+ type: "text",
+ },
+ {
+ id: "isActive",
+ label: "활성화여부",
+ type: "text",
+ },
+ ]
+
+ const handleCompactChange = (compact: boolean) => {
+ setIsCompact(compact)
+ }
+
+ // useDataTable 훅 사용
+ const {table} = useDataTable({
+ data: data,
+ pageCount: pageCount,
+ columns,
+ filterFields,
+ enableAdvancedFilter: true,
+ initialState: {
+ pageSize: 10,
+ sorting: [{ id: "createdAt", desc: true }],
+ columnVisibility: {
+ id: false,
+ updatedAt: false,
+ },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ compact={isCompact}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="procurementItemsTableCompact"
+ onCompactChange={handleCompactChange}
+ >
+ <ProcurementItemsTableToolbarActions
+ table={table}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <UpdateProcurementItemSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ procurementItem={rowAction?.row.original}
+ />
+
+ <DeleteProcurementItemsDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ procurementItems={rowAction?.row.original ? [rowAction.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => {
+ setRowAction(null)
+ router.refresh()
+ }}
+ />
+ </>
+ )
+}
diff --git a/lib/procurement-items/table/update-procurement-items-sheet.tsx b/lib/procurement-items/table/update-procurement-items-sheet.tsx
new file mode 100644
index 00000000..9cda28ae
--- /dev/null
+++ b/lib/procurement-items/table/update-procurement-items-sheet.tsx
@@ -0,0 +1,221 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+
+import { ProcurementItem } from "@/db/schema/items"
+import { updateProcurementItemSchema, UpdateProcurementItemSchema } from "../validations"
+import { modifyProcurementItem } from "../service"
+import { Input } from "@/components/ui/input"
+
+interface UpdateProcurementItemSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ procurementItem: ProcurementItem | null
+}
+
+export function UpdateProcurementItemSheet({
+ procurementItem,
+ ...props
+}: UpdateProcurementItemSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ console.log(procurementItem)
+ const form = useForm<UpdateProcurementItemSchema>({
+ resolver: zodResolver(updateProcurementItemSchema),
+ defaultValues: {
+ itemCode: procurementItem?.itemCode ?? "",
+ itemName: procurementItem?.itemName ?? "",
+ material: procurementItem?.material ?? "",
+ specification: procurementItem?.specification ?? "",
+ unit: procurementItem?.unit ?? "",
+ isActive: procurementItem?.isActive ?? "Y",
+ },
+ })
+
+ React.useEffect(() => {
+ if (procurementItem) {
+ form.reset({
+ itemCode: procurementItem.itemCode ?? "",
+ itemName: procurementItem.itemName ?? "",
+ material: procurementItem.material ?? "",
+ specification: procurementItem.specification ?? "",
+ unit: procurementItem.unit ?? "",
+ isActive: procurementItem.isActive ?? "Y",
+ })
+ }
+ }, [procurementItem, form])
+
+ function onSubmit(input: UpdateProcurementItemSchema) {
+ startUpdateTransition(async () => {
+ if (!procurementItem) return
+
+ const { error } = await modifyProcurementItem({
+ id: procurementItem.id,
+ ...input,
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ form.reset()
+ toast.success("품목이 성공적으로 수정되었습니다.")
+ props.onOpenChange?.(false)
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="w-[400px] sm:w-[540px]">
+ <SheetHeader>
+ <SheetTitle>품목 수정</SheetTitle>
+ <SheetDescription>
+ 품목 정보를 수정하고 <b>저장</b> 버튼을 누르세요.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="mt-6">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="itemCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>품목코드</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="itemName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>품목명</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="material"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>재질</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="specification"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>규격</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="unit"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>단위</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="isActive"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>활성화여부</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="Y">활성</SelectItem>
+ <SelectItem value="N">비활성</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter>
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader className="mr-2 size-4 animate-spin" />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </div>
+ </SheetContent>
+ </Sheet>
+ )
+}
diff --git a/lib/procurement-items/validations.ts b/lib/procurement-items/validations.ts
new file mode 100644
index 00000000..0a2b2105
--- /dev/null
+++ b/lib/procurement-items/validations.ts
@@ -0,0 +1,57 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { ProcurementItem } 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<ProcurementItem>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ itemCode: parseAsString.withDefault(""),
+ itemName: parseAsString.withDefault(""),
+ material: parseAsString.withDefault(""),
+ specification: parseAsString.withDefault(""),
+ unit: parseAsString.withDefault(""),
+ isActive: parseAsString.withDefault(""),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+})
+
+export type GetProcurementItemsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+
+export const createProcurementItemSchema = z.object({
+ itemCode: z.string(),
+ itemName: z.string().min(1, "품목명은 필수입니다"),
+ material: z.string().max(100).optional(),
+ specification: z.string().max(255).optional(),
+ unit: z.string().max(50).optional(),
+ isActive: z.string().max(1).default('Y').optional(),
+})
+
+export type CreateProcurementItemSchema = z.infer<typeof createProcurementItemSchema>
+
+export const updateProcurementItemSchema = z.object({
+ itemCode: z.string().optional(),
+ itemName: z.string().optional(),
+ material: z.string().max(100).optional(),
+ specification: z.string().max(255).optional(),
+ unit: z.string().max(50).optional(),
+ isActive: z.string().max(1).optional(),
+})
+
+export type UpdateProcurementItemSchema = z.infer<typeof updateProcurementItemSchema>
diff --git a/lib/rfq-last/vendor/vendor-detail-dialog.tsx b/lib/rfq-last/vendor/vendor-detail-dialog.tsx
index 7946e371..0eee1b8b 100644
--- a/lib/rfq-last/vendor/vendor-detail-dialog.tsx
+++ b/lib/rfq-last/vendor/vendor-detail-dialog.tsx
@@ -235,7 +235,7 @@ export function VendorResponseDetailDialog({
<span className="text-sm text-muted-foreground">최종 수정일</span>
<span className="text-sm">
{data.updatedAt
- ? format(new Date(data.updatedAt), "yyyy-MM-dd HH:mm", { locale: ko })
+ ? format(new Date(new Date(data.updatedAt).getTime() + 9 * 60 * 60 * 1000), "yyyy-MM-dd HH:mm", { locale: ko })
: "-"}
</span>
</div>
@@ -302,14 +302,14 @@ export function VendorResponseDetailDialog({
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">최초 발송일시</span>
<span className="text-sm">
- {format(new Date(data.emailSentAt), "yyyy-MM-dd HH:mm", { locale: ko })}
+ {format(new Date(new Date(data.emailSentAt).getTime() + 9 * 60 * 60 * 1000), "yyyy-MM-dd HH:mm", { locale: ko })}
</span>
</div>
{data.lastEmailSentAt && data.emailResentCount > 1 && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">최근 재발송일시</span>
<span className="text-sm">
- {format(new Date(data.lastEmailSentAt), "yyyy-MM-dd HH:mm", { locale: ko })}
+ {format(new Date(new Date(data.lastEmailSentAt).getTime() + 9 * 60 * 60 * 1000), "yyyy-MM-dd HH:mm", { locale: ko })}
<Badge variant="secondary" className="ml-2">
재발송 {data.emailResentCount - 1}회
</Badge>