From 2ce5f9dfbb69f0898c42ab862db5ad142fa24943 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 14 Oct 2025 09:14:10 +0000 Subject: (최겸) 구매 입찰 1회성 품목 기준정보 개발(스키마, 테이블, CRUD, 페이지 등) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../evcp/(evcp)/(procurement)/p-items/page.tsx | 62 ++++ config/menuConfig.ts | 12 + config/procurementItemsColumnsConfig.ts | 87 +++++ db/schema/items.ts | 17 + i18n/locales/en/menu.json | 4 +- i18n/locales/ko/menu.json | 4 +- lib/procurement-items/repository.ts | 118 +++++++ lib/procurement-items/service.ts | 374 +++++++++++++++++++++ .../table/add-procurement-items-dialog.tsx | 197 +++++++++++ .../table/delete-procurement-items-dialog.tsx | 151 +++++++++ .../import-procurement-items-excel-button.tsx | 247 ++++++++++++++ .../table/procurement-items-excel-template.tsx | 101 ++++++ .../table/procurement-items-table-columns.tsx | 179 ++++++++++ .../procurement-items-table-toolbar-actions.tsx | 182 ++++++++++ .../table/procurement-items-table.tsx | 152 +++++++++ .../table/update-procurement-items-sheet.tsx | 221 ++++++++++++ lib/procurement-items/validations.ts | 57 ++++ lib/rfq-last/vendor/vendor-detail-dialog.tsx | 6 +- 18 files changed, 2166 insertions(+), 5 deletions(-) create mode 100644 app/[lng]/evcp/(evcp)/(procurement)/p-items/page.tsx create mode 100644 config/procurementItemsColumnsConfig.ts create mode 100644 lib/procurement-items/repository.ts create mode 100644 lib/procurement-items/service.ts create mode 100644 lib/procurement-items/table/add-procurement-items-dialog.tsx create mode 100644 lib/procurement-items/table/delete-procurement-items-dialog.tsx create mode 100644 lib/procurement-items/table/import-procurement-items-excel-button.tsx create mode 100644 lib/procurement-items/table/procurement-items-excel-template.tsx create mode 100644 lib/procurement-items/table/procurement-items-table-columns.tsx create mode 100644 lib/procurement-items/table/procurement-items-table-toolbar-actions.tsx create mode 100644 lib/procurement-items/table/procurement-items-table.tsx create mode 100644 lib/procurement-items/table/update-procurement-items-sheet.tsx create mode 100644 lib/procurement-items/validations.ts 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 +} + +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 ( + +
+
+
+
+

+ 1회성 품목 관리 +

+ +
+

+ 입찰에서 사용하는 1회성 품목을 등록하고 관리합니다. +

+
+
+
+ + + } + > + + +
+ ) +} 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, + params: { + where?: any; + orderBy?: (ReturnType | ReturnType)[]; + 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, + 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, + data: NewProcurementItem +) { + return tx + .insert(procurementItems) + .values(data) + .returning({ id: procurementItems.id, createdAt: procurementItems.createdAt }); +} + +/** 복수 Insert */ +export async function insertProcurementItems( + tx: PgTransaction, + data: ProcurementItem[] +) { + return tx.insert(procurementItems).values(data).onConflictDoNothing(); +} + +/** 단건 삭제 */ +export async function deleteProcurementItemById( + tx: PgTransaction, + itemId: number +) { + return tx.delete(procurementItems).where(eq(procurementItems.id, itemId)); +} + +/** 복수 삭제 */ +export async function deleteProcurementItemsByIds( + tx: PgTransaction, + ids: number[] +) { + return tx.delete(procurementItems).where(inArray(procurementItems.id, ids)); +} + +/** 전체 삭제 */ +export async function deleteAllProcurementItems( + tx: PgTransaction, +) { + return tx.delete(procurementItems); +} + +/** 단건 업데이트 */ +export async function updateProcurementItem( + tx: PgTransaction, + itemId: number, + data: Partial +) { + return tx + .update(procurementItems) + .set(data) + .where(eq(procurementItems.id, itemId)) + .returning({ id: procurementItems.id, createdAt: procurementItems.createdAt }); +} + +/** 복수 업데이트 */ +export async function updateProcurementItems( + tx: PgTransaction, + ids: number[], + data: Partial +) { + return tx + .update(procurementItems) + .set(data) + .where(inArray(procurementItems.id, ids)) + .returning({ id: procurementItems.id, createdAt: procurementItems.createdAt }); +} + +export async function findAllProcurementItems(): Promise { + 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 { + 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 { + 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({ + 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 ( + + + + 새 품목 추가 + + 새 품목 정보를 입력하고 추가 버튼을 누르세요. + + + +
+ + ( + + 품목코드 + + + + + + )} + /> + + ( + + 품목명 * + + + + + + )} + /> + + ( + + 재질 + + + + + + )} + /> + + ( + + 규격 + + + + + + )} + /> + + ( + + 단위 + + + + + + )} + /> + + ( + + 활성화여부 + + + + )} + /> + + + + + + + +
+
+ ) +} 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 { + 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 ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 실행 취소할 수 없습니다. 선택한{" "} + {procurementItems.length}개 품목을 + 서버에서 영구적으로 삭제합니다. + + + + + + + + + + + ) + } + + return ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 실행 취소할 수 없습니다. 선택한{" "} + {procurementItems.length}개 품목을 + 서버에서 영구적으로 삭제합니다. + + + + + + + + + + + ) +} 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(null) + const [isUploading, setIsUploading] = React.useState(false) + const [progress, setProgress] = React.useState(0) + const [error, setError] = React.useState(null) + const fileInputRef = React.useRef(null) + + // 파일 선택 처리 + const handleFileChange = (e: React.ChangeEvent) => { + 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 ( + <> + + + + + + 엑셀 파일에서 품목 가져오기 + + 템플릿을 다운로드하여 작성한 후 가져오기를 실행하세요. + + + +
+
+ + + {file && ( +

+ 선택된 파일: {file.name} +

+ )} +
+ + {isUploading && ( +
+ +

+ 처리 중... {progress}% +

+
+ )} + + {error && ( +

{error}

+ )} +
+ + + + + +
+
+ + ) +} 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 | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef = { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( + + + + + + setRowAction({ row, type: "update" })} + > + 수정 + + + + setRowAction({ row, type: "delete" })} + > + 삭제 + ⌘⌫ + + + + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef[] } + const groupMap: Record[]> = {} + + procurementItemsColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + + ), + 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 ( + + {value === 'Y' ? '활성' : '비활성'} + + ) + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef[] = [] + + // 순서를 고정하고 싶다면 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 +} + +export function ProcurementItemsTableToolbarActions({ + table +}: ProcurementItemsTableToolbarActionsProps) { + const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false) + const router = useRouter() + + // 가져오기 성공 후 테이블 갱신 + const handleImportSuccess = () => { + router.refresh() + } + + // Excel 내보내기 함수 + const exportTableToExcel = async ( + table: Table, + 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 ( + <> +
+ {/* 추가 버튼 */} + + + {/* 엑셀 내보내기 메뉴 */} + + + + + + + + 현재 목록 내보내기 + + + + 템플릿 다운로드 + + + + + {/* 엑셀 가져오기 버튼 */} + + + {/* 선택된 항목들 삭제 버튼 */} + {table.getFilteredSelectedRowModel().rows.length > 0 && ( + row.original)} + showTrigger={true} + onSuccess={() => { + table.toggleAllRowsSelected(false) + router.refresh() + }} + /> + )} +
+ + { + 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>, + ] + > +} + +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 | null>(null) + + const [isCompact, setIsCompact] = React.useState(false) + + const router = useRouter() + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // 필터 필드들 + const filterFields: DataTableFilterField[] = [ + { + id: "itemCode", + label: "품목코드", + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { + 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 ( + <> + + + + + + + setRowAction(null)} + procurementItem={rowAction?.row.original} + /> + + 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 { + procurementItem: ProcurementItem | null +} + +export function UpdateProcurementItemSheet({ + procurementItem, + ...props +}: UpdateProcurementItemSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + console.log(procurementItem) + const form = useForm({ + 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 ( + + + + 품목 수정 + + 품목 정보를 수정하고 저장 버튼을 누르세요. + + + +
+
+ + ( + + 품목코드 + + + + + + )} + /> + + ( + + 품목명 + + + + + + )} + /> + + ( + + 재질 + + + + + + )} + /> + + ( + + 규격 + + + + + + )} + /> + + ( + + 단위 + + + + + + )} + /> + + ( + + 활성화여부 + + + + )} + /> + + + + + + + + + +
+
+
+ ) +} 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().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> + +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 + +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 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({ 최종 수정일 {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 }) : "-"} @@ -302,14 +302,14 @@ export function VendorResponseDetailDialog({
최초 발송일시 - {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 })}
{data.lastEmailSentAt && data.emailResentCount > 1 && (
최근 재발송일시 - {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 })} 재발송 {data.emailResentCount - 1}회 -- cgit v1.2.3