summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/evcp/(evcp)/material/page.tsx76
-rw-r--r--app/api/table/materials/infinite/route.ts93
-rw-r--r--components/common/material/material-selector.tsx320
-rw-r--r--components/signup/join-form.tsx22
-rw-r--r--config/menuConfig.ts12
-rw-r--r--db/schema/items.ts29
-rw-r--r--i18n/locales/en/menu.json4
-rw-r--r--i18n/locales/ko/menu.json2
-rw-r--r--lib/material/material-group-service.ts105
-rw-r--r--lib/material/services.ts224
-rw-r--r--lib/material/table/material-detail-dialog.tsx359
-rw-r--r--lib/material/table/material-table-columns.tsx183
-rw-r--r--lib/material/table/material-table.tsx199
-rw-r--r--lib/material/validations.ts31
14 files changed, 1651 insertions, 8 deletions
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<SearchParams>
+}
+
+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 (
+ <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">
+ 자재마스터
+ </h2>
+ <InformationButton pagePath="evcp/material" />
+ </div>
+ <p className="text-muted-foreground">
+ MDG로부터 수신된 자재마스터 정보입니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* 추가 컴포넌트가 필요한 경우 여기에 */}
+ </React.Suspense>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={5}
+ searchableColumnCount={1}
+ filterableColumnCount={3}
+ cellWidths={["10rem", "15rem", "20rem", "12rem", "12rem", "12rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <MaterialTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+} \ 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<MaterialSearchItem[]>([]);
+ const [isSearching, setIsSearching] = useState(false);
+ const [searchError, setSearchError] = useState<string | null>(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 (
+ <div className={cn("w-full", className)}>
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={open}
+ className="w-full justify-between min-h-[2.5rem] h-auto"
+ disabled={disabled}
+ >
+ <div className="flex flex-wrap gap-1 flex-1 text-left">
+ {selectedMaterials.length === 0 ? (
+ <span className="text-muted-foreground">{noValuePlaceHolder}</span>
+ ) : (
+ selectedMaterials.map((material) => (
+ <Badge
+ key={`${material.materialGroupCode}-${material.materialName}`}
+ variant="secondary"
+ className="gap-1"
+ >
+ <span className="max-w-[200px] truncate">
+ {material.displayText}
+ </span>
+ {!disabled && (
+ <X
+ className="h-3 w-3 cursor-pointer hover:text-red-500"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleRemoveMaterial(material);
+ }}
+ />
+ )}
+ </Badge>
+ ))
+ )}
+ </div>
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+
+ <PopoverContent className="w-full p-0" align="start">
+ <Command>
+ <div className="flex items-center border-b px-3">
+ <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
+ <Input
+ placeholder={placeholder}
+ value={searchQuery}
+ onChange={(e) => 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"
+ />
+ </div>
+
+ <CommandList>
+ <ScrollArea className="h-64">
+ {!searchQuery.trim() ? (
+ <div className="p-4 text-center text-sm text-muted-foreground">
+ 자재를 검색하려면 검색어를 입력해주세요.
+ </div>
+ ) : isSearching ? (
+ <div className="p-4 text-center text-sm text-muted-foreground">
+ 검색 중...
+ </div>
+ ) : searchError ? (
+ <div className="p-4 text-center text-sm text-red-500">
+ {searchError}
+ </div>
+ ) : searchResults.length === 0 ? (
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ ) : (
+ <CommandGroup>
+ {searchResults.map((material) => (
+ <CommandItem
+ key={`${material.materialGroupCode}-${material.materialName}`}
+ onSelect={() => handleMaterialSelect(material)}
+ className="cursor-pointer"
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ isMaterialSelected(material) ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <div className="flex-1">
+ <div className="font-medium">{material.materialName}</div>
+ <div className="text-xs text-muted-foreground">
+ 코드: {material.materialGroupCode}
+ </div>
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ )}
+ </ScrollArea>
+
+ {/* 페이지네이션 */}
+ {searchResults.length > 0 && pagination.pageCount > 1 && (
+ <div className="flex items-center justify-between border-t px-3 py-2">
+ <div className="text-xs text-muted-foreground">
+ 총 {pagination.total}개 중 {((pagination.page - 1) * pagination.perPage) + 1}-
+ {Math.min(pagination.page * pagination.perPage, pagination.total)}개 표시
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handlePageChange(currentPage - 1)}
+ disabled={!pagination.hasPrevPage}
+ className="h-6 w-6 p-0"
+ >
+ <ChevronLeft className="h-3 w-3" />
+ </Button>
+ <span className="text-xs">
+ {pagination.page} / {pagination.pageCount}
+ </span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handlePageChange(currentPage + 1)}
+ disabled={!pagination.hasNextPage}
+ className="h-6 w-6 p-0"
+ >
+ <ChevronRight className="h-3 w-3" />
+ </Button>
+ </div>
+ </div>
+ )}
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </div>
+ );
+}
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<VendorData>({
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({
<label className="block text-sm font-medium mb-1">
{t('supplyItems')} <span className="text-red-500">*</span>
</label>
- <Input
- value={data.items}
- onChange={(e) => handleInputChange('items', e.target.value)}
+ <MaterialSelector
+ selectedMaterials={data.items}
+ onMaterialsChange={handleMaterialsChange}
+ placeholder="type material name or code..."
+ noValuePlaceHolder="type material name or code..."
disabled={isSubmitting}
+ singleSelect={false}
+ className="w-full"
/>
<p className="text-xs text-gray-500 mt-1">
{t('supplyItemsHint')}
diff --git a/config/menuConfig.ts b/config/menuConfig.ts
index 120230bd..269207bc 100644
--- a/config/menuConfig.ts
+++ b/config/menuConfig.ts
@@ -49,6 +49,12 @@ export const mainNav: MenuSection[] = [
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",
descriptionKey: "menu.master_data.object_class_desc",
@@ -464,6 +470,12 @@ export const procurementNav: MenuSection[] = [
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",
descriptionKey: "menu.master_data.incoterms_desc",
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<string>`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<MaterialSearchResult> {
+ try {
+ const offset = (page - 1) * perPage;
+
+ // 검색 조건
+ let searchWhere: SQL<unknown> | 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<number>`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<number>`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<GetMaterialsInput, 'page' | 'perPage'> {
+ 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<Awaited<ReturnType<typeof getMaterialDetail>> | 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 (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-6xl h-[90vh] flex flex-col">
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>자재마스터 상세정보</DialogTitle>
+ <DialogDescription>
+ 자재코드: {matnr}
+ </DialogDescription>
+ </DialogHeader>
+
+ {loading ? (
+ <div className="space-y-4 p-4">
+ <Skeleton className="h-8 w-full" />
+ <Skeleton className="h-32 w-full" />
+ <Skeleton className="h-32 w-full" />
+ </div>
+ ) : data ? (
+ <Tabs defaultValue="basic" className="w-full flex flex-col flex-1 min-h-0">
+ <TabsList className="grid w-full grid-cols-5 flex-shrink-0">
+ <TabsTrigger value="basic">기본정보</TabsTrigger>
+ <TabsTrigger value="characteristics">특성할당</TabsTrigger>
+ <TabsTrigger value="classifications">클래스할당</TabsTrigger>
+ <TabsTrigger value="descriptions">설명</TabsTrigger>
+ <TabsTrigger value="units">단위</TabsTrigger>
+ </TabsList>
+
+ <div className="flex-1 overflow-y-auto min-h-0 mt-4">
+
+ <TabsContent value="basic" className="mt-0">
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-semibold mb-3">기본 자재 정보</h3>
+ <Table>
+ <TableBody>
+ <TableRow>
+ <TableCell className="font-medium w-1/4">자재코드 (MATNR)</TableCell>
+ <TableCell>{data.material.MATNR || "-"}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">자재명 (ZZNAME)</TableCell>
+ <TableCell>{data.material.ZZNAME || "-"}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">프로젝트 (ZZPJT)</TableCell>
+ <TableCell>{data.material.ZZPJT || "-"}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">구 자재번호 (BISMT)</TableCell>
+ <TableCell>{data.material.BISMT || "-"}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">자재그룹 (MATKL)</TableCell>
+ <TableCell>{data.material.MATKL || "-"}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">자재유형 (MTART)</TableCell>
+ <TableCell>{data.material.MTART || "-"}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">기본단위 (MEINS)</TableCell>
+ <TableCell>{data.material.MEINS || "-"}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">산업섹터 (MBRSH)</TableCell>
+ <TableCell>{data.material.MBRSH || "-"}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">사업부 (SPART)</TableCell>
+ <TableCell>{data.material.SPART || "-"}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">규격 (ZZSPEC)</TableCell>
+ <TableCell>{data.material.ZZSPEC || "-"}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">설명 (ZZDESC)</TableCell>
+ <TableCell>{data.material.ZZDESC || "-"}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">PLM ID (ZZPLMID)</TableCell>
+ <TableCell>{data.material.ZZPLMID || "-"}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">삭제플래그 (LVORM)</TableCell>
+ <TableCell>
+ {data.material.LVORM ? (
+ <Badge variant="destructive">삭제됨</Badge>
+ ) : (
+ <Badge variant="secondary">활성</Badge>
+ )}
+ </TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">자재상태 (MSTAE)</TableCell>
+ <TableCell>{data.material.MSTAE || "-"}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">총 중량 (BRGEW)</TableCell>
+ <TableCell>{data.material.BRGEW || "-"}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">순 중량 (NTGEW)</TableCell>
+ <TableCell>{data.material.NTGEW || "-"}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">중량단위 (GEWEI)</TableCell>
+ <TableCell>{data.material.GEWEI || "-"}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">크기/치수 (GROES)</TableCell>
+ <TableCell>{data.material.GROES || "-"}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">생성일시</TableCell>
+ <TableCell>{formatDate(data.material.createdAt, "KR")}</TableCell>
+ </TableRow>
+ <TableRow>
+ <TableCell className="font-medium">수정일시</TableCell>
+ <TableCell>{formatDate(data.material.updatedAt, "KR")}</TableCell>
+ </TableRow>
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ </TabsContent>
+
+ <TabsContent value="characteristics" className="mt-0">
+ <div>
+ <h3 className="text-lg font-semibold mb-3">특성 할당 정보</h3>
+ {data.characteristics.length === 0 && (
+ <p className="text-muted-foreground mb-4">특성 할당 정보가 없습니다.</p>
+ )}
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>특성명 (ATNAM)</TableHead>
+ <TableHead>특성값 (ATWRT)</TableHead>
+ <TableHead>특성내역 (ATBEZ)</TableHead>
+ <TableHead>특성값내역 (ATWTB)</TableHead>
+ <TableHead>클래스번호 (CLASS)</TableHead>
+ <TableHead>클래스유형 (KLART)</TableHead>
+ <TableHead>측정단위 (ATAWE)</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {data.characteristics.length > 0 ? (
+ data.characteristics.map((char, index) => (
+ <TableRow key={index}>
+ <TableCell className="font-medium">{char.ATNAM || "-"}</TableCell>
+ <TableCell>{char.ATWRT || "-"}</TableCell>
+ <TableCell>{char.ATBEZ || "-"}</TableCell>
+ <TableCell>{char.ATWTB || "-"}</TableCell>
+ <TableCell>{char.CLASS || "-"}</TableCell>
+ <TableCell>{char.KLART || "-"}</TableCell>
+ <TableCell>{char.ATAWE || "-"}</TableCell>
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell colSpan={7} className="text-center text-muted-foreground">
+ 데이터가 없습니다
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ </TabsContent>
+
+ <TabsContent value="classifications" className="mt-0">
+ <div>
+ <h3 className="text-lg font-semibold mb-3">클래스 할당 정보</h3>
+ {data.classifications.length === 0 && (
+ <p className="text-muted-foreground mb-4">클래스 할당 정보가 없습니다.</p>
+ )}
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>클래스번호 (CLASS)</TableHead>
+ <TableHead>클래스유형 (KLART)</TableHead>
+ <TableHead>생성일시</TableHead>
+ <TableHead>수정일시</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {data.classifications.length > 0 ? (
+ data.classifications.map((cls, index) => (
+ <TableRow key={index}>
+ <TableCell className="font-medium">{cls.CLASS || "-"}</TableCell>
+ <TableCell>{cls.KLART || "-"}</TableCell>
+ <TableCell>{formatDate(cls.createdAt, "KR")}</TableCell>
+ <TableCell>{formatDate(cls.updatedAt, "KR")}</TableCell>
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell colSpan={4} className="text-center text-muted-foreground">
+ 데이터가 없습니다
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ </TabsContent>
+
+ <TabsContent value="descriptions" className="mt-0">
+ <div>
+ <h3 className="text-lg font-semibold mb-3">자재 설명 정보</h3>
+ {data.descriptions.length === 0 && (
+ <p className="text-muted-foreground mb-4">자재 설명 정보가 없습니다.</p>
+ )}
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>자재설명 (MAKTX)</TableHead>
+ <TableHead>언어 (SPRAS)</TableHead>
+ <TableHead>생성일시</TableHead>
+ <TableHead>수정일시</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {data.descriptions.length > 0 ? (
+ data.descriptions.map((desc, index) => (
+ <TableRow key={index}>
+ <TableCell className="font-medium">{desc.MAKTX || "-"}</TableCell>
+ <TableCell>{desc.SPRAS || "-"}</TableCell>
+ <TableCell>{formatDate(desc.createdAt, "KR")}</TableCell>
+ <TableCell>{formatDate(desc.updatedAt, "KR")}</TableCell>
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell colSpan={4} className="text-center text-muted-foreground">
+ 데이터가 없습니다
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ </TabsContent>
+
+ <TabsContent value="units" className="mt-0">
+ <div>
+ <h3 className="text-lg font-semibold mb-3">단위 정보</h3>
+ {data.units.length === 0 && (
+ <p className="text-muted-foreground mb-4">단위 정보가 없습니다.</p>
+ )}
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>대체단위 (MEINH)</TableHead>
+ <TableHead>분모 (UMREN)</TableHead>
+ <TableHead>분자 (UMREZ)</TableHead>
+ <TableHead>길이 (LAENG)</TableHead>
+ <TableHead>폭 (BREIT)</TableHead>
+ <TableHead>높이 (HOEHE)</TableHead>
+ <TableHead>부피 (VOLUM)</TableHead>
+ <TableHead>부피단위 (VOLEH)</TableHead>
+ <TableHead>총중량 (BRGEW)</TableHead>
+ <TableHead>중량단위 (GEWEI)</TableHead>
+ <TableHead>치수단위 (MEABM)</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {data.units.length > 0 ? (
+ data.units.map((unit, index) => (
+ <TableRow key={index}>
+ <TableCell className="font-medium">{unit.MEINH || "-"}</TableCell>
+ <TableCell>{unit.UMREN || "-"}</TableCell>
+ <TableCell>{unit.UMREZ || "-"}</TableCell>
+ <TableCell>{unit.LAENG || "-"}</TableCell>
+ <TableCell>{unit.BREIT || "-"}</TableCell>
+ <TableCell>{unit.HOEHE || "-"}</TableCell>
+ <TableCell>{unit.VOLUM || "-"}</TableCell>
+ <TableCell>{unit.VOLEH || "-"}</TableCell>
+ <TableCell>{unit.BRGEW || "-"}</TableCell>
+ <TableCell>{unit.GEWEI || "-"}</TableCell>
+ <TableCell>{unit.MEABM || "-"}</TableCell>
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell colSpan={11} className="text-center text-muted-foreground">
+ 데이터가 없습니다
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ </TabsContent>
+ </div>
+ </Tabs>
+ ) : (
+ <div className="text-center py-8">
+ <p className="text-muted-foreground">데이터를 찾을 수 없습니다.</p>
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
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<React.SetStateAction<DataTableRowAction<Material> | null>>
+}
+
+/**
+ * Material 테이블 컬럼 정의
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Material>[] {
+
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<Material> = {
+ 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) 데이터 컬럼들
+ // ----------------------------------------------------------------
+ const dataColumns: ColumnDef<Material>[] = [
+ {
+ accessorKey: "MATKL",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재그룹" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("MATKL") as string | null
+ return (
+ <div className="font-medium">
+ {value || "-"}
+ </div>
+ )
+ },
+ enableSorting: true,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "MATNR",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재코드" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("MATNR") as string | null
+ return (
+ <div className="font-medium">
+ {value || "-"}
+ </div>
+ )
+ },
+ enableSorting: true,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "ZZNAME",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재명" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("ZZNAME") as string | null
+ return (
+ <div className="max-w-[200px] truncate">
+ {value || "-"}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+ {
+ accessorKey: "ZZPJT",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("ZZPJT") as string | null
+ return (
+ <div className="max-w-[150px] truncate">
+ {value || "-"}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일시" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("createdAt") as Date
+ return (
+ <div className="text-sm text-muted-foreground">
+ {formatDate(value, "KR")}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일시" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("updatedAt") as Date
+ return (
+ <div className="text-sm text-muted-foreground">
+ {formatDate(value, "KR")}
+ </div>
+ )
+ },
+ enableSorting: true,
+ },
+ ]
+
+ // ----------------------------------------------------------------
+ // 3) actions 컬럼 (상세보기)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<Material> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <Button onClick={() => setRowAction({ row, type: "view" })}>
+ 상세
+ </Button>
+ )
+ },
+ 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<ReturnType<typeof getMaterials>>,
+ ]
+ >
+}
+
+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<DataTableRowAction<Material> | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // 기존 필터 필드들
+ const filterFields: DataTableFilterField<Material>[] = [
+ {
+ id: "MATKL",
+ label: "자재그룹",
+ },
+ {
+ id: "MATNR",
+ label: "자재코드",
+ },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<Material>[] = [
+ {
+ 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 (
+ <div className="w-full space-y-2.5 overflow-x-auto" style={{maxWidth:'100vw'}}>
+
+ {/* 모드 토글 */}
+ <div className="flex items-center justify-between">
+ <ViewModeToggle
+ isInfiniteMode={isInfiniteMode}
+ onSwitch={handlePageSizeChange}
+ />
+ </div>
+
+ {/* 에러 상태 (무한 스크롤 모드) */}
+ {isInfiniteMode && infiniteScroll?.error && (
+ <Alert variant="destructive">
+ <AlertDescription>
+ 데이터를 불러오는 중 오류가 발생했습니다.
+ <Button
+ variant="link"
+ size="sm"
+ onClick={() => infiniteScroll.reset()}
+ className="ml-2 p-0 h-auto"
+ >
+ 다시 시도
+ </Button>
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 로딩 상태가 아닐 때만 테이블 렌더링 */}
+ {!(isInfiniteMode && infiniteScroll?.isLoading && infiniteScroll?.isEmpty) ? (
+ <>
+ {/* 도구 모음 */}
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ />
+
+ {/* 테이블 렌더링 */}
+ {isInfiniteMode ? (
+ // 무한 스크롤 모드: InfiniteDataTable 사용
+ <InfiniteDataTable
+ table={table}
+ hasNextPage={infiniteScroll?.hasNextPage || false}
+ isLoadingMore={infiniteScroll?.isLoadingMore || false}
+ onLoadMore={infiniteScroll?.onLoadMore}
+ totalCount={infiniteScroll?.totalCount}
+ isEmpty={infiniteScroll?.isEmpty || false}
+ compact={false}
+ autoSizeColumns={true}
+ />
+ ) : (
+ // 페이지네이션 모드: DataTable 사용
+ <DataTable
+ table={table}
+ compact={false}
+ autoSizeColumns={true}
+ />
+ )}
+ </>
+ ) : (
+ /* 로딩 스켈레톤 (무한 스크롤 초기 로딩) */
+ <div className="space-y-3">
+ <div className="text-sm text-muted-foreground mb-4">
+ 무한 스크롤 모드로 데이터를 로드하고 있습니다...
+ </div>
+ {Array.from({ length: 10 }).map((_, i) => (
+ <div key={i} className="h-12 w-full bg-muted animate-pulse rounded" />
+ ))}
+ </div>
+ )}
+
+ {/* 상세보기 다이얼로그 */}
+ <MaterialDetailDialog
+ open={rowAction?.type === "view"}
+ onOpenChange={() => setRowAction(null)}
+ matnr={rowAction?.row.original?.MATNR || null}
+ />
+ </div>
+ )
+}
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<typeof MATERIAL_MASTER_PART_MATL>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+});
+
+// 타입 내보내기
+export type GetMaterialsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;