diff options
| -rw-r--r-- | app/[lng]/evcp/(evcp)/material-groups/page.tsx | 76 | ||||
| -rw-r--r-- | app/api/table/material-groups/infinite/route.ts | 93 | ||||
| -rw-r--r-- | config/menuConfig.ts | 4 | ||||
| -rw-r--r-- | db/schema/items.ts | 2 | ||||
| -rw-r--r-- | i18n/locales/en/menu.json | 4 | ||||
| -rw-r--r-- | i18n/locales/ko/menu.json | 4 | ||||
| -rw-r--r-- | lib/material-groups/services.ts | 165 | ||||
| -rw-r--r-- | lib/material-groups/table/material-group-table-columns.tsx | 61 | ||||
| -rw-r--r-- | lib/material-groups/table/material-group-table.tsx | 178 | ||||
| -rw-r--r-- | lib/material-groups/validations.ts | 31 |
10 files changed, 611 insertions, 7 deletions
diff --git a/app/[lng]/evcp/(evcp)/material-groups/page.tsx b/app/[lng]/evcp/(evcp)/material-groups/page.tsx new file mode 100644 index 00000000..3acd11b9 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/material-groups/page.tsx @@ -0,0 +1,76 @@ +/** + * 자재그룹 테이블 + * materialSearchView를 사용하여 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 { getMaterialGroups } from "@/lib/material-groups/services" +import { MaterialGroupTable } from "@/lib/material-groups/table/material-group-table" +import { InformationButton } from "@/components/information/information-button" +import { searchParamsCache } from "@/lib/material-groups/validations" + +interface MaterialGroupPageProps { + searchParams: Promise<SearchParams> +} + +export default async function MaterialGroupPage(props: MaterialGroupPageProps) { + 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([ + getMaterialGroups(search), + ]) + + 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-groups" /> + </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={2} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["15rem", "25rem"]} + shrinkZero + /> + } + > + <MaterialGroupTable promises={promises} /> + </React.Suspense> + </Shell> + ) +} diff --git a/app/api/table/material-groups/infinite/route.ts b/app/api/table/material-groups/infinite/route.ts new file mode 100644 index 00000000..1201ab77 --- /dev/null +++ b/app/api/table/material-groups/infinite/route.ts @@ -0,0 +1,93 @@ +// app/api/table/material-groups/infinite/route.ts +import { NextRequest, NextResponse } from "next/server"; +import { getMaterialGroupsInfinite, type GetMaterialGroupsInfiniteInput } from "@/lib/material-groups/services"; + +// URL 파라미터를 GetMaterialGroupsInfiniteInput으로 변환하는 헬퍼 함수 +function parseUrlParamsToInfiniteInput(searchParams: URLSearchParams): GetMaterialGroupsInfiniteInput { + + 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: "materialGroupCode", desc: false }]; + const sortParam = searchParams.get("sort"); + if (sortParam) { + try { + sort = JSON.parse(sortParam); + } catch (e) { + console.warn("Invalid sort parameter:", e); + // 기본 정렬 + sort = [{ id: "materialGroupCode", desc: false }]; + } + } else { + // 정렬이 없으면 기본 정렬 + sort = [{ id: "materialGroupCode", desc: false }]; + } + + 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("=== Material Groups Infinite API ==="); + console.log("Raw searchParams:", Object.fromEntries(searchParams.entries())); + + // URL 파라미터 파싱 + const input = parseUrlParamsToInfiniteInput(searchParams); + console.log("Parsed input:", input); + + // 데이터 조회 + const result = await getMaterialGroupsInfinite(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("Material Groups infinite API error:", error); + return NextResponse.json( + { + error: "Internal server error", + items: [], + hasNextPage: false, + nextCursor: null, + total: 0, + }, + { status: 500 } + ); + } +} diff --git a/config/menuConfig.ts b/config/menuConfig.ts index 955b24c4..605941a5 100644 --- a/config/menuConfig.ts +++ b/config/menuConfig.ts @@ -50,7 +50,7 @@ export const mainNav: MenuSection[] = [ }, { titleKey: "menu.master_data.material_master", - href: "/evcp/materials", + href: "/evcp/material-groups", descriptionKey: "menu.master_data.material_master_desc", groupKey: "groups.basic_info" }, @@ -471,7 +471,7 @@ export const procurementNav: MenuSection[] = [ }, { titleKey: "menu.master_data.material_master", - href: "/evcp/materials", + href: "/evcp/material-groups", descriptionKey: "menu.master_data.material_master_desc", groupKey: "groups.basic_info" }, diff --git a/db/schema/items.ts b/db/schema/items.ts index 16338671..a7eb2a3f 100644 --- a/db/schema/items.ts +++ b/db/schema/items.ts @@ -89,7 +89,7 @@ export type ItemOffshoreHull = typeof itemOffshoreHull.$inferSelect; //각 테이블별 컬럼 변경(itemid -> itemCode) -// 자재 검색용 뷰 - MDG 스키마의 MATERIAL_MASTER_PART_MATL 테이블에서 DISTINCT 조회 +// 자재그룹 검색용 뷰 - MDG 스키마의 MATERIAL_MASTER_PART_MATL 테이블에서 DISTINCT 조회 export const materialSearchView = pgView("material_search_view").as((qb) => { return qb .select({ diff --git a/i18n/locales/en/menu.json b/i18n/locales/en/menu.json index 521bafd0..33ade381 100644 --- a/i18n/locales/en/menu.json +++ b/i18n/locales/en/menu.json @@ -38,8 +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", + "material_master": "Material Groups", + "material_master_desc": "Material groups 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 e41d5727..bb8e4c00 100644 --- a/i18n/locales/ko/menu.json +++ b/i18n/locales/ko/menu.json @@ -37,8 +37,8 @@ "projects": "프로젝트 리스트", "projects_desc": "MDG에서 받은 프로젝트 리스트(C)", "package_numbers": "패키지 넘버", - "material_master": "자재 마스터", - "material_master_desc": "MDG에서 받은 자재 마스터 목록", + "material_master": "자재 그룹", + "material_master_desc": "MDG에서 받은 자재 그룹 목록", "package_numbers_desc": "견적(PR 발행 전), 입찰(PR 발행 전), 설계 데이터 및 문서에서 사용되는 패키지 넘버 목록", "object_class": "객체 클래스 목록", "object_class_desc": "객체 클래스 목록", diff --git a/lib/material-groups/services.ts b/lib/material-groups/services.ts new file mode 100644 index 00000000..4b7e9dea --- /dev/null +++ b/lib/material-groups/services.ts @@ -0,0 +1,165 @@ +'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 { materialSearchView } from "@/db/schema/items"; + +// 자재그룹 뷰의 컬럼 타입 정의 +type MaterialGroupColumn = keyof typeof materialSearchView.$inferSelect; + +export interface GetMaterialGroupsInput { + page: number; + perPage: number; + search?: string; + sort: Array<{ + id: MaterialGroupColumn; + desc: boolean; + }>; + filters?: any[]; + joinOperator: 'and' | 'or'; +} + +/** + * 자재그룹 목록을 조회합니다. + * materialSearchView를 사용하여 MATKL(자재그룹코드)와 ZZNAME(자재그룹명)의 고유한 조합을 조회 + */ +export async function getMaterialGroups(input: GetMaterialGroupsInput) { + const safePerPage = Math.min(input.perPage, 100); + + try { + const offset = (input.page - 1) * safePerPage; + + // 고급 필터링 + const advancedWhere = filterColumns({ + table: materialSearchView, + filters: (input.filters || []) as any, + joinOperator: input.joinOperator, + }); + + // 전역 검색 - 주요 컬럼들에 대해 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(materialSearchView.materialGroupCode, s), // 자재그룹코드 + ilike(materialSearchView.materialName, s), // 자재그룹명 + ilike(materialSearchView.displayText, s), // 표시 텍스트 + ); + } + + const finalWhere = and(advancedWhere, globalWhere); + + // 정렬 처리 - 타입 안전하게 처리 + const orderBy = input.sort.length > 0 + ? input.sort.map((item) => { + const column = materialSearchView[item.id]; + return item.desc ? desc(column) : asc(column); + }) + : [asc(materialSearchView.materialGroupCode)]; + + // 데이터 조회 + const { data, total } = await db.transaction(async (tx) => { + const data = await tx + .select({ + materialGroupCode: materialSearchView.materialGroupCode, + materialName: materialSearchView.materialName, + displayText: materialSearchView.displayText, + }) + .from(materialSearchView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(safePerPage); + + const totalResult = await tx + .select({ + count: sql<number>`count(*)` + }) + .from(materialSearchView) + .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 getMaterialGroups:', err); + return { data: [], pageCount: 0 }; + } +} + +/** + * 무한 스크롤을 위한 자재그룹 조회 (페이지네이션 없음) + */ +export interface GetMaterialGroupsInfiniteInput extends Omit<GetMaterialGroupsInput, 'page' | 'perPage'> { + limit?: number; // 무한 스크롤용 추가 옵션 +} + +export async function getMaterialGroupsInfinite(input: GetMaterialGroupsInfiniteInput) { + try { + // 고급 필터링 + const advancedWhere = filterColumns({ + table: materialSearchView, + filters: (input.filters || []) as any, + joinOperator: input.joinOperator || "and", + }); + + // 전역 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(materialSearchView.materialGroupCode, s), + ilike(materialSearchView.materialName, s), + ilike(materialSearchView.displayText, s), + ); + } + + const finalWhere = and(advancedWhere, globalWhere); + + // 정렬 처리 - 타입 안전하게 처리 + const orderBy = input.sort.length > 0 + ? input.sort.map((item) => { + const column = materialSearchView[item.id]; + return item.desc ? desc(column) : asc(column); + }) + : [asc(materialSearchView.materialGroupCode)]; + + // 전체 데이터 조회 (클라이언트에서 가상화 처리) + const data = await db + .select({ + materialGroupCode: materialSearchView.materialGroupCode, + materialName: materialSearchView.materialName, + displayText: materialSearchView.displayText, + }) + .from(materialSearchView) + .where(finalWhere) + .orderBy(...orderBy); + + return { data }; + } catch (err) { + console.error('Error in getMaterialGroupsInfinite:', err); + return { data: [] }; + } +} + +/** + * 특정 자재그룹 상세 정보 조회 + */ +export async function getMaterialGroupDetail(materialGroupCode: string) { + try { + const materialGroup = await db + .select() + .from(materialSearchView) + .where(eq(materialSearchView.materialGroupCode, materialGroupCode)) + .limit(1); + + return materialGroup.length > 0 ? materialGroup[0] : null; + } catch (err) { + console.error('Error in getMaterialGroupDetail:', err); + return null; + } +} diff --git a/lib/material-groups/table/material-group-table-columns.tsx b/lib/material-groups/table/material-group-table-columns.tsx new file mode 100644 index 00000000..90df720d --- /dev/null +++ b/lib/material-groups/table/material-group-table-columns.tsx @@ -0,0 +1,61 @@ +"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" + +// MaterialGroup 타입 정의 (서비스에서 반환되는 타입과 일치) +type MaterialGroup = { + materialGroupCode: string | null; + materialName: string | null; + displayText: string | null; +} + +/** + * MaterialGroup 테이블 컬럼 정의 + */ +export function getColumns(): ColumnDef<MaterialGroup>[] { + // ---------------------------------------------------------------- + // 데이터 컬럼들 + // ---------------------------------------------------------------- + const dataColumns: ColumnDef<MaterialGroup>[] = [ + { + accessorKey: "materialGroupCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="자재그룹코드" /> + ), + cell: ({ row }) => { + const value = row.getValue("materialGroupCode") as string | null + return ( + <div className="font-medium"> + {value || "-"} + </div> + ) + }, + enableSorting: true, + enableHiding: false, + }, + { + accessorKey: "materialName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="자재그룹명" /> + ), + cell: ({ row }) => { + const value = row.getValue("materialName") as string | null + return ( + <div className="max-w-[300px] truncate"> + {value || "-"} + </div> + ) + }, + enableSorting: true, + enableHiding: false, + }, + ] + + // ---------------------------------------------------------------- + // 최종 컬럼 배열 + // ---------------------------------------------------------------- + return dataColumns +} diff --git a/lib/material-groups/table/material-group-table.tsx b/lib/material-groups/table/material-group-table.tsx new file mode 100644 index 00000000..21430c4f --- /dev/null +++ b/lib/material-groups/table/material-group-table.tsx @@ -0,0 +1,178 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, +} 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 { getMaterialGroups } from "../services" +import { getColumns } from "./material-group-table-columns" +import { ViewModeToggle } from "@/components/data-table/view-mode-toggle" + +// MaterialGroup 타입 정의 (서비스에서 반환되는 타입과 일치) +type MaterialGroup = { + materialGroupCode: string | null; + materialName: string | null; + displayText: string | null; +} + +interface MaterialGroupTableProps { + promises?: Promise< + [ + Awaited<ReturnType<typeof getMaterialGroups>>, + ] + > +} + +export function MaterialGroupTable({ promises }: MaterialGroupTableProps) { + // 페이지네이션 모드 데이터 + const paginationData = promises ? React.use(promises) : null + const [{ data = [], pageCount = 0 }] = paginationData || [{ data: [], pageCount: 0 }] + + console.log('MaterialGroupTable data:', data.length, 'material groups') + + const columns = React.useMemo( + () => getColumns(), + [] + ) + + // 기존 필터 필드들 + const filterFields: DataTableFilterField<MaterialGroup>[] = [ + { + id: "materialGroupCode", + label: "자재그룹코드", + }, + { + id: "materialName", + label: "자재그룹명", + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<MaterialGroup>[] = [ + { + id: "materialGroupCode", + label: "자재그룹코드", + type: "text", + }, + { + id: "materialName", + label: "자재그룹명", + type: "text", + }, + { + id: "displayText", + label: "표시텍스트", + type: "text", + }, + ] + + // 확장된 useDataTable 훅 사용 (pageSize 기반 자동 전환) + const { + table, + infiniteScroll, + isInfiniteMode, + handlePageSizeChange, + } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "materialGroupCode", desc: false }], + }, + getRowId: (originalRow) => String(originalRow.materialGroupCode || 'unknown'), + shallow: false, + clearOnDefault: true, + // 무한 스크롤 설정 + infiniteScrollConfig: { + apiEndpoint: "/api/table/material-groups/infinite", + tableName: "material-groups", + 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> + )} + + </div> + ) +} diff --git a/lib/material-groups/validations.ts b/lib/material-groups/validations.ts new file mode 100644 index 00000000..c379c833 --- /dev/null +++ b/lib/material-groups/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 { materialSearchView } from "@/db/schema/items" + +// MaterialGroup 검색 파라미터 캐시 정의 +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 확장된 타입으로 정렬 파서 사용 + sort: getSortingStateParser<typeof materialSearchView>().withDefault([ + { id: "materialGroupCode", desc: false }, + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}); + +// 타입 내보내기 +export type GetMaterialGroupsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>; |
