diff options
Diffstat (limited to 'lib/material')
| -rw-r--r-- | lib/material/material-group-service.ts | 105 | ||||
| -rw-r--r-- | lib/material/services.ts | 224 | ||||
| -rw-r--r-- | lib/material/table/material-detail-dialog.tsx | 359 | ||||
| -rw-r--r-- | lib/material/table/material-table-columns.tsx | 183 | ||||
| -rw-r--r-- | lib/material/table/material-table.tsx | 199 | ||||
| -rw-r--r-- | lib/material/validations.ts | 31 |
6 files changed, 1101 insertions, 0 deletions
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>>; |
