From f72142f6cc46c7be5bf90803d365c2ecd144c53d Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Mon, 1 Sep 2025 10:22:55 +0000 Subject: (김준회) MDG 자재마스터 정보 조회 기능 및 메뉴 추가, 회원가입시 공급품목 선택 기능 추가 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[lng]/evcp/(evcp)/material/page.tsx | 76 +++++ app/api/table/materials/infinite/route.ts | 93 ++++++ components/common/material/material-selector.tsx | 320 ++++++++++++++++++++ components/signup/join-form.tsx | 22 +- config/menuConfig.ts | 12 + db/schema/items.ts | 29 +- i18n/locales/en/menu.json | 4 +- i18n/locales/ko/menu.json | 2 + lib/material/material-group-service.ts | 105 +++++++ lib/material/services.ts | 224 ++++++++++++++ lib/material/table/material-detail-dialog.tsx | 359 +++++++++++++++++++++++ lib/material/table/material-table-columns.tsx | 183 ++++++++++++ lib/material/table/material-table.tsx | 199 +++++++++++++ lib/material/validations.ts | 31 ++ 14 files changed, 1651 insertions(+), 8 deletions(-) create mode 100644 app/[lng]/evcp/(evcp)/material/page.tsx create mode 100644 app/api/table/materials/infinite/route.ts create mode 100644 components/common/material/material-selector.tsx create mode 100644 lib/material/material-group-service.ts create mode 100644 lib/material/services.ts create mode 100644 lib/material/table/material-detail-dialog.tsx create mode 100644 lib/material/table/material-table-columns.tsx create mode 100644 lib/material/table/material-table.tsx create mode 100644 lib/material/validations.ts diff --git a/app/[lng]/evcp/(evcp)/material/page.tsx b/app/[lng]/evcp/(evcp)/material/page.tsx new file mode 100644 index 00000000..00983a3f --- /dev/null +++ b/app/[lng]/evcp/(evcp)/material/page.tsx @@ -0,0 +1,76 @@ +/** + * 자재마스터 테이블 + * MDG 자재마스터를 그대로 보여줄 것임 + * 수정/추가 기능은 불필요 + */ + +import * as React from "react" +import { type SearchParams } from "@/types/table" + +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { getMaterials } from "@/lib/material/services" +import { MaterialTable } from "@/lib/material/table/material-table" +import { InformationButton } from "@/components/information/information-button" +import { searchParamsCache } from "@/lib/material/validations" + +interface MaterialPageProps { + searchParams: Promise +} + +export default async function MaterialPage(props: MaterialPageProps) { + const searchParams = await props.searchParams + + // searchParamsCache를 사용해서 파라미터 파싱 + const search = searchParamsCache.parse(searchParams) + + // pageSize 기반으로 모드 자동 결정 + const isInfiniteMode = search.perPage >= 1_000_000 + + // 페이지네이션 모드일 때만 서버에서 데이터 가져오기 + // 무한 스크롤 모드에서는 클라이언트에서 SWR로 데이터 로드 + const promises = isInfiniteMode + ? undefined + : Promise.all([ + getMaterials(search as any), // 타입 캐스팅으로 임시 해결 + ]) + + return ( + +
+
+
+
+

+ 자재마스터 +

+ +
+

+ MDG로부터 수신된 자재마스터 정보입니다. +

+
+
+
+ + }> + {/* 추가 컴포넌트가 필요한 경우 여기에 */} + + + + } + > + + +
+ ) +} \ No newline at end of file diff --git a/app/api/table/materials/infinite/route.ts b/app/api/table/materials/infinite/route.ts new file mode 100644 index 00000000..c4be1d0e --- /dev/null +++ b/app/api/table/materials/infinite/route.ts @@ -0,0 +1,93 @@ +// app/api/table/materials/infinite/route.ts +import { NextRequest, NextResponse } from "next/server"; +import { getMaterialsInfinite, type GetMaterialsInfiniteInput } from "@/lib/material/services"; + +// URL 파라미터를 GetMaterialsInfiniteInput으로 변환하는 헬퍼 함수 +function parseUrlParamsToInfiniteInput(searchParams: URLSearchParams): GetMaterialsInfiniteInput { + + const cursor = searchParams.get("cursor") || undefined; + const limit = parseInt(searchParams.get("limit") || "50"); + + // 고급 필터링 관련 + const search = searchParams.get("search") || ""; + const joinOperator = searchParams.get("joinOperator") || "and"; + + // 필터 파라미터 파싱 + let filters: any[] = []; + const filtersParam = searchParams.get("filters"); + if (filtersParam) { + try { + filters = JSON.parse(filtersParam); + } catch (e) { + console.warn("Invalid filters parameter:", e); + filters = []; + } + } + + // 정렬 파라미터 파싱 + let sort: Array<{ id: string; desc: boolean }> = [{ id: "createdAt", desc: true }]; + const sortParam = searchParams.get("sort"); + if (sortParam) { + try { + sort = JSON.parse(sortParam); + } catch (e) { + console.warn("Invalid sort parameter:", e); + // 기본 정렬 + sort = [{ id: "createdAt", desc: true }]; + } + } else { + // 정렬이 없으면 기본 정렬 + sort = [{ id: "createdAt", desc: true }]; + } + + return { + // 무한 스크롤 관련 + limit, + + // 고급 필터링 + search, + filters, + joinOperator: joinOperator as "and" | "or", + + // 정렬 + sort, + }; +} + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + console.log("=== Materials Infinite API ==="); + console.log("Raw searchParams:", Object.fromEntries(searchParams.entries())); + + // URL 파라미터 파싱 + const input = parseUrlParamsToInfiniteInput(searchParams); + console.log("Parsed input:", input); + + // 데이터 조회 + const result = await getMaterialsInfinite(input); + console.log("Query result count:", result.data.length); + + // 응답 구성 + const response = { + items: result.data, + hasNextPage: false, // 무한 스크롤에서는 모든 데이터를 한번에 로드 + nextCursor: null, + total: result.data.length, + }; + + return NextResponse.json(response); + } catch (error) { + console.error("Materials infinite API error:", error); + return NextResponse.json( + { + error: "Internal server error", + items: [], + hasNextPage: false, + nextCursor: null, + total: 0, + }, + { status: 500 } + ); + } +} diff --git a/components/common/material/material-selector.tsx b/components/common/material/material-selector.tsx new file mode 100644 index 00000000..aa68d2b5 --- /dev/null +++ b/components/common/material/material-selector.tsx @@ -0,0 +1,320 @@ +"use client"; + +import React, { useState, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from "@/components/ui/command"; +import { Check, ChevronsUpDown, X, Search, ChevronLeft, ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useDebounce } from "@/hooks/use-debounce"; +import { searchMaterialsForSelector, MaterialSearchItem } from "@/lib/material/material-group-service"; + +interface MaterialSelectorProps { + selectedMaterials?: MaterialSearchItem[]; + onMaterialsChange?: (materials: MaterialSearchItem[]) => void; + singleSelect?: boolean; + placeholder?: string; + noValuePlaceHolder?: string; + disabled?: boolean; + maxSelections?: number; + className?: string; + closeOnSelect?: boolean; +} + +export function MaterialSelector({ + selectedMaterials = [], + onMaterialsChange, + singleSelect = false, + placeholder = "자재를 검색하세요...", + noValuePlaceHolder = "자재를 검색해주세요", + disabled = false, + maxSelections, + className, + closeOnSelect = true +}: MaterialSelectorProps) { + + const [open, setOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [searchError, setSearchError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [pagination, setPagination] = useState({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }); + + // Debounce 적용된 검색어 + const debouncedSearchQuery = useDebounce(searchQuery, 300); + + // 검색 실행 - useCallback으로 메모이제이션 + const performSearch = useCallback(async (query: string, page: number = 1) => { + setIsSearching(true); + setSearchError(null); + + try { + const result = await searchMaterialsForSelector(query, page, 10); + + if (result.success) { + setSearchResults(result.data); + setPagination(result.pagination); + setCurrentPage(page); + } else { + setSearchResults([]); + setSearchError("검색 중 오류가 발생했습니다."); + setPagination({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }); + } + } catch (err) { + console.error("자재 검색 실패:", err); + setSearchResults([]); + setSearchError("검색 중 오류가 발생했습니다."); + setPagination({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }); + } finally { + setIsSearching(false); + } + }, []); + + // Debounced 검색어 변경 시 검색 실행 (검색어가 있을 때만) + React.useEffect(() => { + if (debouncedSearchQuery.trim()) { + setCurrentPage(1); + performSearch(debouncedSearchQuery, 1); + } else { + // 검색어가 없으면 결과 초기화 + setSearchResults([]); + setPagination({ + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }); + } + }, [debouncedSearchQuery, performSearch]); + + // 페이지 변경 처리 - useCallback으로 메모이제이션 + const handlePageChange = useCallback((newPage: number) => { + if (newPage >= 1 && newPage <= pagination.pageCount) { + performSearch(debouncedSearchQuery, newPage); + } + }, [pagination.pageCount, performSearch, debouncedSearchQuery]); + + // 자재 선택 처리 - useCallback으로 메모이제이션 + const handleMaterialSelect = useCallback((material: MaterialSearchItem) => { + if (disabled) return; + + let newSelectedMaterials: MaterialSearchItem[]; + + if (singleSelect) { + newSelectedMaterials = [material]; + } else { + const isAlreadySelected = selectedMaterials.some( + (selected) => selected.materialGroupCode === material.materialGroupCode && + selected.materialName === material.materialName + ); + + if (isAlreadySelected) { + newSelectedMaterials = selectedMaterials.filter( + (selected) => !(selected.materialGroupCode === material.materialGroupCode && + selected.materialName === material.materialName) + ); + } else { + if (maxSelections && selectedMaterials.length >= maxSelections) { + return; // 최대 선택 수 초과 시 추가하지 않음 + } + newSelectedMaterials = [...selectedMaterials, material]; + } + } + + onMaterialsChange?.(newSelectedMaterials); + + if (closeOnSelect && singleSelect) { + setOpen(false); + } + }, [disabled, singleSelect, selectedMaterials, maxSelections, onMaterialsChange, closeOnSelect]); + + // 개별 자재 제거 + const handleRemoveMaterial = useCallback((materialToRemove: MaterialSearchItem) => { + if (disabled) return; + + const newSelectedMaterials = selectedMaterials.filter( + (material) => !(material.materialGroupCode === materialToRemove.materialGroupCode && + material.materialName === materialToRemove.materialName) + ); + onMaterialsChange?.(newSelectedMaterials); + }, [disabled, selectedMaterials, onMaterialsChange]); + + // 선택된 자재가 있는지 확인 + const isMaterialSelected = useCallback((material: MaterialSearchItem) => { + return selectedMaterials.some( + (selected) => selected.materialGroupCode === material.materialGroupCode && + selected.materialName === material.materialName + ); + }, [selectedMaterials]); + + return ( +
+ + + + + + + +
+ + setSearchQuery(e.target.value)} + className="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none border-0 focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50" + /> +
+ + + + {!searchQuery.trim() ? ( +
+ 자재를 검색하려면 검색어를 입력해주세요. +
+ ) : isSearching ? ( +
+ 검색 중... +
+ ) : searchError ? ( +
+ {searchError} +
+ ) : searchResults.length === 0 ? ( + 검색 결과가 없습니다. + ) : ( + + {searchResults.map((material) => ( + handleMaterialSelect(material)} + className="cursor-pointer" + > + +
+
{material.materialName}
+
+ 코드: {material.materialGroupCode} +
+
+
+ ))} +
+ )} +
+ + {/* 페이지네이션 */} + {searchResults.length > 0 && pagination.pageCount > 1 && ( +
+
+ 총 {pagination.total}개 중 {((pagination.page - 1) * pagination.perPage) + 1}- + {Math.min(pagination.page * pagination.perPage, pagination.total)}개 표시 +
+
+ + + {pagination.page} / {pagination.pageCount} + + +
+
+ )} +
+
+
+
+
+ ); +} diff --git a/components/signup/join-form.tsx b/components/signup/join-form.tsx index 9eda1a7d..4ee05c9b 100644 --- a/components/signup/join-form.tsx +++ b/components/signup/join-form.tsx @@ -71,6 +71,8 @@ import koLocale from "i18n-iso-countries/langs/ko.json"; import { getVendorTypes } from '@/lib/vendors/service'; import ConsentStep from './conset-step'; import { checkEmailExists } from '@/lib/vendor-users/service'; +import { MaterialSelector } from '@/components/common/material/material-selector'; +import { MaterialSearchItem } from '@/lib/material/material-group-service'; i18nIsoCountries.registerLocale(enLocale); i18nIsoCountries.registerLocale(koLocale); @@ -111,7 +113,7 @@ interface AccountData { interface VendorData { vendorName: string; vendorTypeId?: number; - items: string; + items: MaterialSearchItem[]; taxId: string; address: string; addressDetail: string; @@ -473,7 +475,7 @@ export default function JoinForm() { const [vendorData, setVendorData] = useState({ vendorName: "", vendorTypeId: undefined, - items: "", + items: [], taxId: defaultTaxId, address: "", addressDetail: "", @@ -944,6 +946,11 @@ function CompleteVendorForm({ onChange(prev => ({ ...prev, [field]: value })); }; + // 자재 변경 핸들러 + const handleMaterialsChange = (materials: MaterialSearchItem[]) => { + handleInputChange('items', materials); + }; + // 파일 업로드 핸들러들 const createFileUploadHandler = (setFiles: (files: File[]) => void, currentFiles: File[]) => ({ onDropAccepted: (acceptedFiles: File[]) => { @@ -1064,6 +1071,7 @@ function CompleteVendorForm({ }, vendor: { ...data, + items: JSON.stringify(data.items), // 자재 배열을 JSON 문자열로 변환 phone: normalizedVendorPhone, representativePhone: normalizedRepresentativePhone, contacts: normalizedContacts, @@ -1224,10 +1232,14 @@ function CompleteVendorForm({ - handleInputChange('items', e.target.value)} +

{t('supplyItemsHint')} diff --git a/config/menuConfig.ts b/config/menuConfig.ts index 120230bd..269207bc 100644 --- a/config/menuConfig.ts +++ b/config/menuConfig.ts @@ -48,6 +48,12 @@ export const mainNav: MenuSection[] = [ descriptionKey: "menu.master_data.package_numbers_desc", groupKey: "groups.basic_info" }, + { + titleKey: "menu.master_data.material_master", + href: "/evcp/material", + descriptionKey: "menu.master_data.material_master_desc", + groupKey: "groups.basic_info" + }, { titleKey: "menu.master_data.object_class", href: "/evcp/equip-class", @@ -463,6 +469,12 @@ export const procurementNav: MenuSection[] = [ descriptionKey: "menu.master_data.package_numbers_desc", groupKey: "groups.basic_info" }, + { + titleKey: "menu.master_data.material_master", + href: "/evcp/material", + descriptionKey: "menu.master_data.material_master_desc", + groupKey: "groups.basic_info" + }, { titleKey: "menu.master_data.incoterms", href: "/evcp/incoterms", diff --git a/db/schema/items.ts b/db/schema/items.ts index 102f897f..16338671 100644 --- a/db/schema/items.ts +++ b/db/schema/items.ts @@ -1,4 +1,6 @@ -import { pgTable, varchar, text, timestamp ,serial, integer, unique} from "drizzle-orm/pg-core" +import { pgTable, varchar, text, timestamp, serial, integer, unique, pgView } from "drizzle-orm/pg-core" +import { sql } from "drizzle-orm" +import { MATERIAL_MASTER_PART_MATL } from "./MDG/mdg" // 자재 아이템 정보 테이블 (items) - 기존 CMCTB_MAT_CLAS 테이블 매핑 (SOAP 연결 시 컬럼이 추가/삭제될 수 있음) export const items = pgTable("items", { @@ -84,4 +86,27 @@ export const itemOffshoreHull = pgTable("item_offshore_hull", { export type ItemOffshoreTop = typeof itemOffshoreTop.$inferSelect; export type ItemOffshoreHull = typeof itemOffshoreHull.$inferSelect; -//각 테이블별 컬럼 변경(itemid -> itemCode) \ No newline at end of file +//각 테이블별 컬럼 변경(itemid -> itemCode) + + +// 자재 검색용 뷰 - MDG 스키마의 MATERIAL_MASTER_PART_MATL 테이블에서 DISTINCT 조회 +export const materialSearchView = pgView("material_search_view").as((qb) => { + return qb + .select({ + materialGroupCode: MATERIAL_MASTER_PART_MATL.MATKL, + materialName: MATERIAL_MASTER_PART_MATL.ZZNAME, + displayText: sql`COALESCE(${MATERIAL_MASTER_PART_MATL.MATKL}, '') || ' - ' || COALESCE(${MATERIAL_MASTER_PART_MATL.ZZNAME}, '')`.as("display_text") + }) + .from(MATERIAL_MASTER_PART_MATL) + .where(sql` + ${MATERIAL_MASTER_PART_MATL.MATKL} IS NOT NULL + AND ${MATERIAL_MASTER_PART_MATL.MATKL} != '' + AND ${MATERIAL_MASTER_PART_MATL.ZZNAME} IS NOT NULL + AND ${MATERIAL_MASTER_PART_MATL.ZZNAME} != '' + `) + .groupBy(MATERIAL_MASTER_PART_MATL.MATKL, MATERIAL_MASTER_PART_MATL.ZZNAME) + .orderBy(MATERIAL_MASTER_PART_MATL.MATKL, MATERIAL_MASTER_PART_MATL.ZZNAME); +}); + +export type MaterialSearchView = typeof materialSearchView.$inferSelect; + diff --git a/i18n/locales/en/menu.json b/i18n/locales/en/menu.json index 3a1579d7..521bafd0 100644 --- a/i18n/locales/en/menu.json +++ b/i18n/locales/en/menu.json @@ -2,7 +2,7 @@ "branding": { "evcp_main": "eVCP", "evcp_partners": "eVCP Partners", - "evcp_procurement": "eVCP Procurement", + "evcp_procurement": "package_numberseVCP Procurement", "evcp_sales": "eVCP Technical Sales", "evcp_engineering": "eVCP Engineering" }, @@ -38,6 +38,8 @@ "projects": "Project List", "projects_desc": "Project list received from MDG (C)", "package_numbers": "Package Numbers", + "material_master": "Material Master", + "material_master_desc": "Material master list received from MDG", "package_numbers_desc": "Package number list used in quotation (before PR issuance), bidding (before PR issuance), design data and documents", "object_class": "Object Class List", "object_class_desc": "Object class list", diff --git a/i18n/locales/ko/menu.json b/i18n/locales/ko/menu.json index db7f9feb..e41d5727 100644 --- a/i18n/locales/ko/menu.json +++ b/i18n/locales/ko/menu.json @@ -37,6 +37,8 @@ "projects": "프로젝트 리스트", "projects_desc": "MDG에서 받은 프로젝트 리스트(C)", "package_numbers": "패키지 넘버", + "material_master": "자재 마스터", + "material_master_desc": "MDG에서 받은 자재 마스터 목록", "package_numbers_desc": "견적(PR 발행 전), 입찰(PR 발행 전), 설계 데이터 및 문서에서 사용되는 패키지 넘버 목록", "object_class": "객체 클래스 목록", "object_class_desc": "객체 클래스 목록", diff --git a/lib/material/material-group-service.ts b/lib/material/material-group-service.ts new file mode 100644 index 00000000..216cd0e6 --- /dev/null +++ b/lib/material/material-group-service.ts @@ -0,0 +1,105 @@ +"use server"; + +import { sql, SQL } from "drizzle-orm"; +import db from "@/db/db"; +import { materialSearchView } from "@/db/schema/items"; + +export interface MaterialSearchItem { + materialGroupCode: string; + materialName: string; + displayText: string; +} + +export interface MaterialSearchResult { + success: boolean; + data: MaterialSearchItem[]; + pagination: { + page: number; + perPage: number; + total: number; + pageCount: number; + hasNextPage: boolean; + hasPrevPage: boolean; + }; +} + +/** + * 자재 검색 함수 - material_search_view에서 검색 + */ +export async function searchMaterialsForSelector( + query: string, + page: number = 1, + perPage: number = 10 +): Promise { + try { + const offset = (page - 1) * perPage; + + // 검색 조건 + let searchWhere: SQL | undefined; + if (query.trim()) { + const searchPattern = `%${query.trim()}%`; + searchWhere = sql`( + ${materialSearchView.materialGroupCode} ILIKE ${searchPattern} OR + ${materialSearchView.materialName} ILIKE ${searchPattern} OR + ${materialSearchView.displayText} ILIKE ${searchPattern} + )`; + } + + const { data, total } = await db.transaction(async (tx) => { + // 데이터 조회 + const data = await tx + .select() + .from(materialSearchView) + .where(searchWhere) + .orderBy(materialSearchView.materialGroupCode, materialSearchView.materialName) + .limit(perPage) + .offset(offset); + + // 총 개수 조회 + const countResult = await tx + .select({ count: sql`count(*)` }) + .from(materialSearchView) + .where(searchWhere); + + const total = countResult[0]?.count || 0; + + return { + data: data.map((row) => ({ + materialGroupCode: row.materialGroupCode, + materialName: row.materialName, + displayText: row.displayText, + })), + total, + }; + }); + + const pageCount = Math.ceil(total / perPage); + + return { + success: true, + data, + pagination: { + page, + perPage, + total, + pageCount, + hasNextPage: page < pageCount, + hasPrevPage: page > 1, + }, + }; + } catch (error) { + console.error("자재 검색 오류:", error); + return { + success: false, + data: [], + pagination: { + page: 1, + perPage: 10, + total: 0, + pageCount: 0, + hasNextPage: false, + hasPrevPage: false, + }, + }; + } +} diff --git a/lib/material/services.ts b/lib/material/services.ts new file mode 100644 index 00000000..e050d8f8 --- /dev/null +++ b/lib/material/services.ts @@ -0,0 +1,224 @@ +'use server' + +import { and, asc, desc, ilike, or, sql, eq } from 'drizzle-orm'; +import db from '@/db/db'; +import { filterColumns } from "@/lib/filter-columns"; +import { + MATERIAL_MASTER_PART_MATL, + MATERIAL_MASTER_PART_MATL_CHARASGN, + MATERIAL_MASTER_PART_MATL_CLASSASGN, + MATERIAL_MASTER_PART_MATL_DESC, + MATERIAL_MASTER_PART_MATL_UNIT +} from "@/db/schema/MDG/mdg"; + +// 자재마스터 테이블의 컬럼 타입 정의 +type MaterialColumn = keyof typeof MATERIAL_MASTER_PART_MATL.$inferSelect; + +export interface GetMaterialsInput { + page: number; + perPage: number; + search?: string; + sort: Array<{ + id: MaterialColumn; + desc: boolean; + }>; + filters?: any[]; + joinOperator: 'and' | 'or'; +} + +/** + * 자재마스터 목록을 조회합니다. + * 필수 컬럼: MATKL(자재그룹), MATNR(자재코드), ZZNAME(자재명), ZZPJT(프로젝트), createdAt, updatedAt + */ +export async function getMaterials(input: GetMaterialsInput) { + const safePerPage = Math.min(input.perPage, 100); + + try { + const offset = (input.page - 1) * safePerPage; + + // 고급 필터링 + const advancedWhere = filterColumns({ + table: MATERIAL_MASTER_PART_MATL, + filters: (input.filters || []) as any, + joinOperator: input.joinOperator, + }); + + // 전역 검색 - 주요 컬럼들에 대해 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(MATERIAL_MASTER_PART_MATL.MATKL, s), // 자재그룹코드 + ilike(MATERIAL_MASTER_PART_MATL.MATNR, s), // 자재코드 + ilike(MATERIAL_MASTER_PART_MATL.ZZNAME, s), // 자재명 + ilike(MATERIAL_MASTER_PART_MATL.ZZPJT, s), // 프로젝트 + ); + } + + const finalWhere = and(advancedWhere, globalWhere); + + // 정렬 처리 - 타입 안전하게 처리 + const orderBy = input.sort.length > 0 + ? input.sort.map((item) => { + const column = MATERIAL_MASTER_PART_MATL[item.id]; + return item.desc ? desc(column) : asc(column); + }) + : [desc(MATERIAL_MASTER_PART_MATL.createdAt)]; + + // 데이터 조회 - 필요한 컬럼만 선택 + const { data, total } = await db.transaction(async (tx) => { + const data = await tx + .select({ + id: MATERIAL_MASTER_PART_MATL.id, + MATKL: MATERIAL_MASTER_PART_MATL.MATKL, // 자재그룹(=자재그룹코드) + MATNR: MATERIAL_MASTER_PART_MATL.MATNR, // 자재코드(=자재번호) + ZZNAME: MATERIAL_MASTER_PART_MATL.ZZNAME, // 자재명(=자재그룹명) + ZZPJT: MATERIAL_MASTER_PART_MATL.ZZPJT, // 프로젝트 + createdAt: MATERIAL_MASTER_PART_MATL.createdAt, + updatedAt: MATERIAL_MASTER_PART_MATL.updatedAt, + }) + .from(MATERIAL_MASTER_PART_MATL) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(safePerPage); + + const totalResult = await tx + .select({ + count: sql`count(*)` + }) + .from(MATERIAL_MASTER_PART_MATL) + .where(finalWhere); + + const total = Number(totalResult[0]?.count) || 0; + return { data, total }; + }); + + const pageCount = Math.ceil(total / safePerPage); + return { data, pageCount }; + } catch (err) { + console.error('Error in getMaterials:', err); + return { data: [], pageCount: 0 }; + } +} + +/** + * 무한 스크롤을 위한 자재마스터 조회 (페이지네이션 없음) + */ +export interface GetMaterialsInfiniteInput extends Omit { + limit?: number; // 무한 스크롤용 추가 옵션 +} + +export async function getMaterialsInfinite(input: GetMaterialsInfiniteInput) { + try { + // 고급 필터링 + const advancedWhere = filterColumns({ + table: MATERIAL_MASTER_PART_MATL, + filters: (input.filters || []) as any, + joinOperator: input.joinOperator || "and", + }); + + // 전역 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(MATERIAL_MASTER_PART_MATL.MATKL, s), + ilike(MATERIAL_MASTER_PART_MATL.MATNR, s), + ilike(MATERIAL_MASTER_PART_MATL.ZZNAME, s), + ilike(MATERIAL_MASTER_PART_MATL.ZZPJT, s), + ); + } + + const finalWhere = and(advancedWhere, globalWhere); + + // 정렬 처리 - 타입 안전하게 처리 + const orderBy = input.sort.length > 0 + ? input.sort.map((item) => { + const column = MATERIAL_MASTER_PART_MATL[item.id]; + return item.desc ? desc(column) : asc(column); + }) + : [desc(MATERIAL_MASTER_PART_MATL.createdAt)]; + + // 전체 데이터 조회 (클라이언트에서 가상화 처리) + const data = await db + .select({ + id: MATERIAL_MASTER_PART_MATL.id, + MATKL: MATERIAL_MASTER_PART_MATL.MATKL, + MATNR: MATERIAL_MASTER_PART_MATL.MATNR, + ZZNAME: MATERIAL_MASTER_PART_MATL.ZZNAME, + ZZPJT: MATERIAL_MASTER_PART_MATL.ZZPJT, + createdAt: MATERIAL_MASTER_PART_MATL.createdAt, + updatedAt: MATERIAL_MASTER_PART_MATL.updatedAt, + }) + .from(MATERIAL_MASTER_PART_MATL) + .where(finalWhere) + .orderBy(...orderBy); + + return { data }; + } catch (err) { + console.error('Error in getMaterialsInfinite:', err); + return { data: [] }; + } +} + +/** + * 자재마스터 상세 정보를 조회합니다 (모든 관련 테이블 포함) + */ +export async function getMaterialDetail(matnr: string) { + try { + // 메인 자재 정보 조회 + const material = await db + .select() + .from(MATERIAL_MASTER_PART_MATL) + .where(eq(MATERIAL_MASTER_PART_MATL.MATNR, matnr)) + .limit(1); + + if (material.length === 0) { + return null; + } + + // 관련 테이블들 조회 + const [ + characteristics, + classifications, + descriptions, + units + ] = await Promise.all([ + // CHARASGN - 특성 할당 + db + .select() + .from(MATERIAL_MASTER_PART_MATL_CHARASGN) + .where(eq(MATERIAL_MASTER_PART_MATL_CHARASGN.MATNR, matnr)), + + // CLASSASGN - 클래스 할당 + db + .select() + .from(MATERIAL_MASTER_PART_MATL_CLASSASGN) + .where(eq(MATERIAL_MASTER_PART_MATL_CLASSASGN.MATNR, matnr)), + + // DESC - 설명 + db + .select() + .from(MATERIAL_MASTER_PART_MATL_DESC) + .where(eq(MATERIAL_MASTER_PART_MATL_DESC.MATNR, matnr)), + + // UNIT - 단위 + db + .select() + .from(MATERIAL_MASTER_PART_MATL_UNIT) + .where(eq(MATERIAL_MASTER_PART_MATL_UNIT.MATNR, matnr)) + ]); + + return { + material: material[0], + characteristics, + classifications, + descriptions, + units + }; + } catch (err) { + console.error('Error in getMaterialDetail:', err); + return null; + } +} \ No newline at end of file diff --git a/lib/material/table/material-detail-dialog.tsx b/lib/material/table/material-detail-dialog.tsx new file mode 100644 index 00000000..aed0485c --- /dev/null +++ b/lib/material/table/material-detail-dialog.tsx @@ -0,0 +1,359 @@ +"use client" + +import * as React from "react" +import { getMaterialDetail } from "../services" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs" +import { Skeleton } from "@/components/ui/skeleton" +import { Badge } from "@/components/ui/badge" + +import { formatDate } from "@/lib/utils" + +interface MaterialDetailDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + matnr: string | null +} + +export function MaterialDetailDialog({ + open, + onOpenChange, + matnr, +}: MaterialDetailDialogProps) { + const [data, setData] = React.useState> | null>(null) + const [loading, setLoading] = React.useState(false) + + React.useEffect(() => { + if (open && matnr) { + setLoading(true) + getMaterialDetail(matnr) + .then(setData) + .finally(() => setLoading(false)) + } + }, [open, matnr]) + + if (!matnr) return null + + return ( +

+ + + 자재마스터 상세정보 + + 자재코드: {matnr} + + + + {loading ? ( +
+ + + +
+ ) : data ? ( + + + 기본정보 + 특성할당 + 클래스할당 + 설명 + 단위 + + +
+ + +
+
+

기본 자재 정보

+ + + + 자재코드 (MATNR) + {data.material.MATNR || "-"} + + + 자재명 (ZZNAME) + {data.material.ZZNAME || "-"} + + + 프로젝트 (ZZPJT) + {data.material.ZZPJT || "-"} + + + 구 자재번호 (BISMT) + {data.material.BISMT || "-"} + + + 자재그룹 (MATKL) + {data.material.MATKL || "-"} + + + 자재유형 (MTART) + {data.material.MTART || "-"} + + + 기본단위 (MEINS) + {data.material.MEINS || "-"} + + + 산업섹터 (MBRSH) + {data.material.MBRSH || "-"} + + + 사업부 (SPART) + {data.material.SPART || "-"} + + + 규격 (ZZSPEC) + {data.material.ZZSPEC || "-"} + + + 설명 (ZZDESC) + {data.material.ZZDESC || "-"} + + + PLM ID (ZZPLMID) + {data.material.ZZPLMID || "-"} + + + 삭제플래그 (LVORM) + + {data.material.LVORM ? ( + 삭제됨 + ) : ( + 활성 + )} + + + + 자재상태 (MSTAE) + {data.material.MSTAE || "-"} + + + 총 중량 (BRGEW) + {data.material.BRGEW || "-"} + + + 순 중량 (NTGEW) + {data.material.NTGEW || "-"} + + + 중량단위 (GEWEI) + {data.material.GEWEI || "-"} + + + 크기/치수 (GROES) + {data.material.GROES || "-"} + + + 생성일시 + {formatDate(data.material.createdAt, "KR")} + + + 수정일시 + {formatDate(data.material.updatedAt, "KR")} + + +
+
+
+
+ + +
+

특성 할당 정보

+ {data.characteristics.length === 0 && ( +

특성 할당 정보가 없습니다.

+ )} + + + + 특성명 (ATNAM) + 특성값 (ATWRT) + 특성내역 (ATBEZ) + 특성값내역 (ATWTB) + 클래스번호 (CLASS) + 클래스유형 (KLART) + 측정단위 (ATAWE) + + + + {data.characteristics.length > 0 ? ( + data.characteristics.map((char, index) => ( + + {char.ATNAM || "-"} + {char.ATWRT || "-"} + {char.ATBEZ || "-"} + {char.ATWTB || "-"} + {char.CLASS || "-"} + {char.KLART || "-"} + {char.ATAWE || "-"} + + )) + ) : ( + + + 데이터가 없습니다 + + + )} + +
+
+
+ + +
+

클래스 할당 정보

+ {data.classifications.length === 0 && ( +

클래스 할당 정보가 없습니다.

+ )} + + + + 클래스번호 (CLASS) + 클래스유형 (KLART) + 생성일시 + 수정일시 + + + + {data.classifications.length > 0 ? ( + data.classifications.map((cls, index) => ( + + {cls.CLASS || "-"} + {cls.KLART || "-"} + {formatDate(cls.createdAt, "KR")} + {formatDate(cls.updatedAt, "KR")} + + )) + ) : ( + + + 데이터가 없습니다 + + + )} + +
+
+
+ + +
+

자재 설명 정보

+ {data.descriptions.length === 0 && ( +

자재 설명 정보가 없습니다.

+ )} + + + + 자재설명 (MAKTX) + 언어 (SPRAS) + 생성일시 + 수정일시 + + + + {data.descriptions.length > 0 ? ( + data.descriptions.map((desc, index) => ( + + {desc.MAKTX || "-"} + {desc.SPRAS || "-"} + {formatDate(desc.createdAt, "KR")} + {formatDate(desc.updatedAt, "KR")} + + )) + ) : ( + + + 데이터가 없습니다 + + + )} + +
+
+
+ + +
+

단위 정보

+ {data.units.length === 0 && ( +

단위 정보가 없습니다.

+ )} + + + + 대체단위 (MEINH) + 분모 (UMREN) + 분자 (UMREZ) + 길이 (LAENG) + 폭 (BREIT) + 높이 (HOEHE) + 부피 (VOLUM) + 부피단위 (VOLEH) + 총중량 (BRGEW) + 중량단위 (GEWEI) + 치수단위 (MEABM) + + + + {data.units.length > 0 ? ( + data.units.map((unit, index) => ( + + {unit.MEINH || "-"} + {unit.UMREN || "-"} + {unit.UMREZ || "-"} + {unit.LAENG || "-"} + {unit.BREIT || "-"} + {unit.HOEHE || "-"} + {unit.VOLUM || "-"} + {unit.VOLEH || "-"} + {unit.BRGEW || "-"} + {unit.GEWEI || "-"} + {unit.MEABM || "-"} + + )) + ) : ( + + + 데이터가 없습니다 + + + )} + +
+
+
+
+
+ ) : ( +
+

데이터를 찾을 수 없습니다.

+
+ )} +
+
+ ) +} diff --git a/lib/material/table/material-table-columns.tsx b/lib/material/table/material-table-columns.tsx new file mode 100644 index 00000000..dd405770 --- /dev/null +++ b/lib/material/table/material-table-columns.tsx @@ -0,0 +1,183 @@ +"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" +import { type DataTableRowAction } from "@/types/table" + +import { formatDate } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { Button } from "@/components/ui/button" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" + +// Material 타입 정의 (서비스에서 반환되는 타입과 일치) +type Material = { + id: number; + MATKL: string | null; // 자재그룹 + MATNR: string | null; // 자재코드 + ZZNAME: string | null; // 자재명 + ZZPJT: string | null; // 프로젝트 + createdAt: Date; + updatedAt: Date; +} + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>> +} + +/** + * Material 테이블 컬럼 정의 + */ +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) 데이터 컬럼들 + // ---------------------------------------------------------------- + const dataColumns: ColumnDef[] = [ + { + accessorKey: "MATKL", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("MATKL") as string | null + return ( +
+ {value || "-"} +
+ ) + }, + enableSorting: true, + enableHiding: false, + }, + { + accessorKey: "MATNR", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("MATNR") as string | null + return ( +
+ {value || "-"} +
+ ) + }, + enableSorting: true, + enableHiding: false, + }, + { + accessorKey: "ZZNAME", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("ZZNAME") as string | null + return ( +
+ {value || "-"} +
+ ) + }, + enableSorting: true, + }, + { + accessorKey: "ZZPJT", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("ZZPJT") as string | null + return ( +
+ {value || "-"} +
+ ) + }, + enableSorting: true, + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("createdAt") as Date + return ( +
+ {formatDate(value, "KR")} +
+ ) + }, + enableSorting: true, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("updatedAt") as Date + return ( +
+ {formatDate(value, "KR")} +
+ ) + }, + enableSorting: true, + }, + ] + + // ---------------------------------------------------------------- + // 3) actions 컬럼 (상세보기) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( + + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열 + // ---------------------------------------------------------------- + return [ + selectColumn, + ...dataColumns, + actionsColumn, + ] +} diff --git a/lib/material/table/material-table.tsx b/lib/material/table/material-table.tsx new file mode 100644 index 00000000..6870a030 --- /dev/null +++ b/lib/material/table/material-table.tsx @@ -0,0 +1,199 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { InfiniteDataTable } from "@/components/data-table/infinite-data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { Button } from "@/components/ui/button" +import { Alert, AlertDescription } from "@/components/ui/alert" + +import { getMaterials } from "../services" +import { getColumns } from "./material-table-columns" +import { MaterialDetailDialog } from "./material-detail-dialog" +import { ViewModeToggle } from "@/components/data-table/view-mode-toggle" + +// Material 타입 정의 (서비스에서 반환되는 타입과 일치) +type Material = { + id: number; + MATKL: string | null; // 자재그룹(=자재그룹코드) + MATNR: string | null; // 자재코드 + ZZNAME: string | null; // 자재명 + ZZPJT: string | null; // 프로젝트 + createdAt: Date; + updatedAt: Date; +} + +interface MaterialTableProps { + promises?: Promise< + [ + Awaited>, + ] + > +} + +export function MaterialTable({ promises }: MaterialTableProps) { + // 페이지네이션 모드 데이터 + const paginationData = promises ? React.use(promises) : null + const [{ data = [], pageCount = 0 }] = paginationData || [{ data: [], pageCount: 0 }] + + console.log('MaterialTable data:', data.length, 'materials') + + const [rowAction, setRowAction] = + React.useState | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // 기존 필터 필드들 + const filterFields: DataTableFilterField[] = [ + { + id: "MATKL", + label: "자재그룹", + }, + { + id: "MATNR", + label: "자재코드", + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { + id: "MATKL", + label: "자재그룹", + type: "text", + }, + { + id: "MATNR", + label: "자재코드", + type: "text", + }, + { + id: "ZZNAME", + label: "자재명", + type: "text", + }, + { + id: "ZZPJT", + label: "프로젝트", + type: "text", + }, + ] + + // 확장된 useDataTable 훅 사용 (pageSize 기반 자동 전환) + const { + table, + infiniteScroll, + isInfiniteMode, + handlePageSizeChange, + } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { left: ["select"], right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + // 무한 스크롤 설정 + infiniteScrollConfig: { + apiEndpoint: "/api/table/materials/infinite", + tableName: "materials", + maxPageSize: 100, + }, + }) + + return ( +
+ + {/* 모드 토글 */} +
+ +
+ + {/* 에러 상태 (무한 스크롤 모드) */} + {isInfiniteMode && infiniteScroll?.error && ( + + + 데이터를 불러오는 중 오류가 발생했습니다. + + + + )} + + {/* 로딩 상태가 아닐 때만 테이블 렌더링 */} + {!(isInfiniteMode && infiniteScroll?.isLoading && infiniteScroll?.isEmpty) ? ( + <> + {/* 도구 모음 */} + + + {/* 테이블 렌더링 */} + {isInfiniteMode ? ( + // 무한 스크롤 모드: InfiniteDataTable 사용 + + ) : ( + // 페이지네이션 모드: DataTable 사용 + + )} + + ) : ( + /* 로딩 스켈레톤 (무한 스크롤 초기 로딩) */ +
+
+ 무한 스크롤 모드로 데이터를 로드하고 있습니다... +
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ ))} +
+ )} + + {/* 상세보기 다이얼로그 */} + setRowAction(null)} + matnr={rowAction?.row.original?.MATNR || null} + /> +
+ ) +} diff --git a/lib/material/validations.ts b/lib/material/validations.ts new file mode 100644 index 00000000..5831372a --- /dev/null +++ b/lib/material/validations.ts @@ -0,0 +1,31 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { MATERIAL_MASTER_PART_MATL } from "@/db/schema/MDG/mdg" + +// Material 검색 파라미터 캐시 정의 +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 }, + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}); + +// 타입 내보내기 +export type GetMaterialsSchema = Awaited>; -- cgit v1.2.3