From e7818a457371849e29519497ebf046f385f05ab6 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Mon, 15 Sep 2025 01:23:00 +0000 Subject: (김준회) AVL 기능 구현 1차 및 벤더풀 E/B 구분 개선 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[lng]/evcp/(evcp)/avl/[id]/page.tsx | 106 +++ app/[lng]/evcp/(evcp)/avl/avl-page-client.tsx | 107 +++ .../avl/components/avl-registration-area.tsx | 792 +++++++++++++++++++++ app/[lng]/evcp/(evcp)/avl/page.tsx | 49 ++ 4 files changed, 1054 insertions(+) create mode 100644 app/[lng]/evcp/(evcp)/avl/[id]/page.tsx create mode 100644 app/[lng]/evcp/(evcp)/avl/avl-page-client.tsx create mode 100644 app/[lng]/evcp/(evcp)/avl/components/avl-registration-area.tsx create mode 100644 app/[lng]/evcp/(evcp)/avl/page.tsx (limited to 'app') diff --git a/app/[lng]/evcp/(evcp)/avl/[id]/page.tsx b/app/[lng]/evcp/(evcp)/avl/[id]/page.tsx new file mode 100644 index 00000000..52ee7b7f --- /dev/null +++ b/app/[lng]/evcp/(evcp)/avl/[id]/page.tsx @@ -0,0 +1,106 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" +import { notFound } from "next/navigation" + +import { getValidFilters } from "@/lib/data-table" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { getAvlLists, getAvlDetail } from "@/lib/avl/service" +import { avlDetailSearchParamsCache } from "@/lib/avl/validations" +import { AvlDetailTable } from "@/lib/avl/table/avl-detail-table" +import { getAvlListById } from "@/lib/avl/service" + +interface AvlDetailPageProps { + params: Promise<{ id: string }> + searchParams: Promise +} + +export default async function AvlDetailPage(props: AvlDetailPageProps) { + const { id } = await props.params + const searchParams = await props.searchParams + const search = avlDetailSearchParamsCache.parse(searchParams) + + const validFilters = getValidFilters(search.filters) + + // ID 검증 + const numericId = Number(id) + if (isNaN(numericId) || numericId <= 0) { + notFound() + } + + // AVL 리스트 정보 조회 + const avlListInfo = await getAvlListById(numericId) + if (!avlListInfo) { + notFound() + } + + const promises = Promise.all([ + getAvlDetail({ + ...search, + filters: validFilters, + avlListId: numericId, + }), + ]) + + return ( +
+ {/* 메인 콘텐츠 영역 */} +
+
+ + } + > + + +
+
+
+ ) +} + +// 실제 데이터를 받아서 AvlDetailTable에 전달하는 컴포넌트 +function AvlDetailTableWrapper({ + promises, + avlListId, + avlListInfo +}: { + promises: Promise + avlListId: number + avlListInfo: any +}) { + const [{ data, pageCount }] = React.use(promises) + + // AVL 타입 결정 + const avlType = avlListInfo.isTemplate ? '선종별표준AVL' : '프로젝트AVL' + + // 선주명 추출 (프로젝트 정보에서) + const shipOwnerName = avlListInfo.shipOwnerName || undefined + + return ( + + ) +} diff --git a/app/[lng]/evcp/(evcp)/avl/avl-page-client.tsx b/app/[lng]/evcp/(evcp)/avl/avl-page-client.tsx new file mode 100644 index 00000000..a9d5713d --- /dev/null +++ b/app/[lng]/evcp/(evcp)/avl/avl-page-client.tsx @@ -0,0 +1,107 @@ +"use client" + +import { useEffect, useState } from "react" +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable" +import { AvlTable } from "@/lib/avl/table/avl-table" +import { AvlRegistrationArea } from "@/lib/avl/table/avl-registration-area" +import { getAvlLists } from "@/lib/avl/service" +import { AvlListItem } from "@/lib/avl/types" +import { toast } from "sonner" + +interface AvlPageClientProps { + initialData: AvlListItem[] +} + +export function AvlPageClient({ initialData }: AvlPageClientProps) { + const [avlListData, setAvlListData] = useState(initialData) + const [isLoading, setIsLoading] = useState(false) + const [registrationMode, setRegistrationMode] = useState<'standard' | 'project' | null>(null) + const [selectedAvlRow, setSelectedAvlRow] = useState(null) + + // 초기 데이터 설정 + useEffect(() => { + setAvlListData(initialData) + }, [initialData]) + + // AVL 리스트 데이터 로드 (클라이언트에서 추가 로드 시 사용) + const loadAvlListData = async () => { + try { + setIsLoading(true) + // 기본 파라미터로 전체 데이터 조회 + const result = await getAvlLists({ + page: 1, + perPage: 100, // 충분한 수량으로 조회 + sort: [{ id: "createdAt", desc: true }], + flags: [], + filters: [], + joinOperator: "and", + search: "", + isTemplate: "false", + constructionSector: "", + projectCode: "", + shipType: "", + avlKind: "", + htDivision: "H", + rev: "", + }) + + setAvlListData(result.data) + } catch (error) { + console.error("AVL 리스트 로드 실패:", error) + toast.error("AVL 리스트를 불러오는데 실패했습니다.") + setAvlListData([]) + } finally { + setIsLoading(false) + } + } + + // 리프레시 핸들러 + const handleRefresh = async () => { + await loadAvlListData() + toast.success("AVL 리스트가 새로고침되었습니다.") + } + + // 등록 모드 변경 핸들러 + const handleRegistrationModeChange = (mode: 'standard' | 'project') => { + setRegistrationMode(mode) + } + + // 행 선택 핸들러 + const handleRowSelect = (selectedRow: AvlListItem | null) => { + setSelectedAvlRow(selectedRow) + } + + return ( +
+
+ + {/* 상단 패널: AVL 목록 */} + +
+ +
+
+ + + + {/* 하단 패널: AVL 등록 */} + +
+ +
+
+
+
+
+ ) +} diff --git a/app/[lng]/evcp/(evcp)/avl/components/avl-registration-area.tsx b/app/[lng]/evcp/(evcp)/avl/components/avl-registration-area.tsx new file mode 100644 index 00000000..69b1d417 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/avl/components/avl-registration-area.tsx @@ -0,0 +1,792 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { Checkbox } from "@/components/ui/checkbox" +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react" +import { + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, +} from "@tanstack/react-table" +import { DataTable } from "@/components/data-table/data-table" + +// 프로젝트 AVL 데이터 타입 +type ProjectAvlItem = { + id: string + no: number + designCategory: string + customerAvlMaterialName: string + materialGroupInfo: string + avlVendorName: string + vendorInfo: string + ownerSuggestion: string + shiSuggestion: string +} + +// 선종별 표준 AVL 데이터 타입 +type StandardAvlItem = { + id: string + no: number + designCategory: string + avlVendorName: string + materialGroupInfo: string + vendorInfo: string + headquarterLocation: string + tier: string +} + +// Vendor Pool 데이터 타입 +type VendorPoolItem = { + id: string + no: number + designCategory: string + avlVendorName: string + materialGroupInfo: string + vendorInfo: string + vendorClassification: string + faStatus: string + recentQuoteNumber: string + recentOrderNumber: string +} + +// Mock 데이터들 +const mockProjectAvlData: ProjectAvlItem[] = [ + { + id: "p1", + no: 1, + designCategory: "엔진", + customerAvlMaterialName: "메인엔진", + materialGroupInfo: "엔진부품", + avlVendorName: "엔진업체A", + vendorInfo: "국내 엔진 전문업체", + ownerSuggestion: "승인", + shiSuggestion: "승인", + }, +] + +const mockStandardAvlData: StandardAvlItem[] = [ + { + id: "s1", + no: 1, + designCategory: "케이블", + avlVendorName: "케이블업체A", + materialGroupInfo: "전기부품", + vendorInfo: "국내 케이블 제조사", + headquarterLocation: "한국", + tier: "Tier 1", + }, +] + +const mockVendorPoolData: VendorPoolItem[] = [ + { + id: "v1", + no: 1, + designCategory: "펌프", + avlVendorName: "펌프업체A", + materialGroupInfo: "기계부품", + vendorInfo: "국제 펌프 제조사", + vendorClassification: "주요업체", + faStatus: "완료", + recentQuoteNumber: "Q2024001", + recentOrderNumber: "PO2024001", + }, +] + +// 프로젝트 AVL 테이블 컬럼 +const projectAvlColumns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row, table }) => { + // 프로젝트 AVL 테이블의 단일 선택 핸들러 + const handleRowSelection = (checked: boolean) => { + if (checked) { + // 다른 모든 행의 선택 해제 + table.getRowModel().rows.forEach(r => { + if (r !== row && r.getIsSelected()) { + r.toggleSelected(false) + } + }) + } + // 현재 행 선택/해제 + row.toggleSelected(checked) + } + + return ( + + ) + }, + enableSorting: false, + enableHiding: false, + size: 50, + }, + { + accessorKey: "no", + header: "No.", + size: 60, + }, + { + accessorKey: "designCategory", + header: "설계공종", + size: 120, + }, + { + accessorKey: "customerAvlMaterialName", + header: "고객사 AVL 자재명", + size: 150, + }, + { + accessorKey: "materialGroupInfo", + header: "자재그룹 정보", + size: 130, + }, + { + accessorKey: "avlVendorName", + header: "AVL 등재업체명", + size: 140, + }, + { + accessorKey: "vendorInfo", + header: "협력업체 정보", + size: 130, + }, + { + accessorKey: "ownerSuggestion", + header: "선주제안", + size: 100, + }, + { + accessorKey: "shiSuggestion", + header: "SHI 제안", + size: 100, + }, +] + +// 선종별 표준 AVL 테이블 컬럼 +const standardAvlColumns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row, table }) => { + // 선종별 표준 AVL 테이블의 단일 선택 핸들러 + const handleRowSelection = (checked: boolean) => { + if (checked) { + // 다른 모든 행의 선택 해제 + table.getRowModel().rows.forEach(r => { + if (r !== row && r.getIsSelected()) { + r.toggleSelected(false) + } + }) + } + // 현재 행 선택/해제 + row.toggleSelected(checked) + } + + return ( + + ) + }, + enableSorting: false, + enableHiding: false, + size: 50, + }, + { + accessorKey: "no", + header: "No.", + size: 60, + }, + { + accessorKey: "designCategory", + header: "설계공종", + size: 120, + }, + { + accessorKey: "avlVendorName", + header: "AVL 등재업체명", + size: 140, + }, + { + accessorKey: "materialGroupInfo", + header: "자재그룹 정보", + size: 130, + }, + { + accessorKey: "vendorInfo", + header: "협력업체 정보", + size: 130, + }, + { + accessorKey: "headquarterLocation", + header: "본사 위치 (국가)", + size: 140, + }, + { + accessorKey: "tier", + header: "등급 (Tier)", + size: 120, + }, +] + +// Vendor Pool 테이블 컬럼 +const vendorPoolColumns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row, table }) => { + // Vendor Pool 테이블의 단일 선택 핸들러 + const handleRowSelection = (checked: boolean) => { + if (checked) { + // 다른 모든 행의 선택 해제 + table.getRowModel().rows.forEach(r => { + if (r !== row && r.getIsSelected()) { + r.toggleSelected(false) + } + }) + } + // 현재 행 선택/해제 + row.toggleSelected(checked) + } + + return ( + + ) + }, + enableSorting: false, + enableHiding: false, + size: 50, + }, + { + accessorKey: "no", + header: "No.", + size: 60, + }, + { + accessorKey: "designCategory", + header: "설계공종", + size: 120, + }, + { + accessorKey: "avlVendorName", + header: "AVL 등재업체명", + size: 140, + }, + { + accessorKey: "materialGroupInfo", + header: "자재그룹 정보", + size: 130, + }, + { + accessorKey: "vendorInfo", + header: "협력업체 정보", + size: 130, + }, + { + accessorKey: "vendorClassification", + header: "업체분류", + size: 100, + }, + { + accessorKey: "faStatus", + header: "FA현황", + size: 100, + }, + { + accessorKey: "recentQuoteNumber", + header: "최근견적번호", + size: 130, + }, + { + accessorKey: "recentOrderNumber", + header: "최근발주번호", + size: 130, + }, +] + +// 프로젝트 AVL 테이블 컴포넌트 +function ProjectAvlTable({ + onSelectionChange, + resetCounter +}: { + onSelectionChange?: (count: number) => void + resetCounter?: number +}) { + const [data] = React.useState(() => [...mockProjectAvlData]) + + const table = useReactTable({ + data, + columns: projectAvlColumns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + initialState: { + pagination: { + pageSize: 10, + }, + }, + }) + + // 선택 상태 변경 시 콜백 호출 + React.useEffect(() => { + const selectedRows = table.getFilteredSelectedRowModel().rows + onSelectionChange?.(selectedRows.length) + }, [table.getFilteredSelectedRowModel().rows.length, onSelectionChange]) + + // 선택 해제 요청이 오면 모든 선택 해제 + React.useEffect(() => { + if (resetCounter && resetCounter > 0) { + table.toggleAllPageRowsSelected(false) + } + }, [resetCounter, table]) + + return ( +
+
+
+

프로젝트 AVL

+
+ + + + + +
+
+
+ +
+ ) +} + +// 선종별 표준 AVL 테이블 컴포넌트 +function StandardAvlTable({ + onSelectionChange, + resetCounter +}: { + onSelectionChange?: (count: number) => void + resetCounter?: number +}) { + const [data] = React.useState(() => [...mockStandardAvlData]) + + const table = useReactTable({ + data, + columns: standardAvlColumns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + initialState: { + pagination: { + pageSize: 10, + }, + }, + }) + + // 선택 상태 변경 시 콜백 호출 + React.useEffect(() => { + onSelectionChange?.(table.getFilteredSelectedRowModel().rows.length) + }, [table.getFilteredSelectedRowModel().rows.length, onSelectionChange]) + + // 선택 해제 요청이 오면 모든 선택 해제 + React.useEffect(() => { + if (resetCounter && resetCounter > 0) { + table.toggleAllPageRowsSelected(false) + } + }, [resetCounter, table]) + + return ( +
+
+
+

선종별 표준 AVL

+
+ + + + +
+
+
+ +
+ ) +} + +// Vendor Pool 테이블 컴포넌트 +function VendorPoolTable({ + onSelectionChange, + resetCounter +}: { + onSelectionChange?: (count: number) => void + resetCounter?: number +}) { + const [data] = React.useState(() => [...mockVendorPoolData]) + + const table = useReactTable({ + data, + columns: vendorPoolColumns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + initialState: { + pagination: { + pageSize: 10, + }, + }, + }) + + // 선택 상태 변경 시 콜백 호출 + React.useEffect(() => { + onSelectionChange?.(table.getFilteredSelectedRowModel().rows.length) + }, [table.getFilteredSelectedRowModel().rows.length, onSelectionChange]) + + // 선택 해제 요청이 오면 모든 선택 해제 + React.useEffect(() => { + if (resetCounter && resetCounter > 0) { + table.toggleAllPageRowsSelected(false) + } + }, [resetCounter, table]) + + return ( +
+
+
+

Vendor Pool

+
+ +
+
+
+ +
+ ) +} + +// 선택된 테이블 타입 +type SelectedTable = 'project' | 'standard' | 'vendor' | null + +// 선택 상태 액션 타입 +type SelectionAction = + | { type: 'SELECT_PROJECT'; count: number } + | { type: 'SELECT_STANDARD'; count: number } + | { type: 'SELECT_VENDOR'; count: number } + | { type: 'CLEAR_SELECTION' } + +// 선택 상태 +interface SelectionState { + selectedTable: SelectedTable + selectedRowCount: number + resetCounters: { + project: number + standard: number + vendor: number + } +} + +// 선택 상태 리듀서 +const selectionReducer = (state: SelectionState, action: SelectionAction): SelectionState => { + switch (action.type) { + case 'SELECT_PROJECT': + if (action.count > 0) { + return { + selectedTable: 'project', + selectedRowCount: action.count, + resetCounters: { + ...state.resetCounters, + standard: state.selectedTable !== 'project' ? state.resetCounters.standard + 1 : state.resetCounters.standard, + vendor: state.selectedTable !== 'project' ? state.resetCounters.vendor + 1 : state.resetCounters.vendor, + } + } + } else if (state.selectedTable === 'project') { + return { + ...state, + selectedTable: null, + selectedRowCount: 0, + } + } + return state + + case 'SELECT_STANDARD': + if (action.count > 0) { + return { + selectedTable: 'standard', + selectedRowCount: action.count, + resetCounters: { + ...state.resetCounters, + project: state.selectedTable !== 'standard' ? state.resetCounters.project + 1 : state.resetCounters.project, + vendor: state.selectedTable !== 'standard' ? state.resetCounters.vendor + 1 : state.resetCounters.vendor, + } + } + } else if (state.selectedTable === 'standard') { + return { + ...state, + selectedTable: null, + selectedRowCount: 0, + } + } + return state + + case 'SELECT_VENDOR': + if (action.count > 0) { + return { + selectedTable: 'vendor', + selectedRowCount: action.count, + resetCounters: { + ...state.resetCounters, + project: state.selectedTable !== 'vendor' ? state.resetCounters.project + 1 : state.resetCounters.project, + standard: state.selectedTable !== 'vendor' ? state.resetCounters.standard + 1 : state.resetCounters.standard, + } + } + } else if (state.selectedTable === 'vendor') { + return { + ...state, + selectedTable: null, + selectedRowCount: 0, + } + } + return state + + default: + return state + } +} + +// AVL 등록 영역 컴포넌트 +export function AvlRegistrationArea() { + // 단일 선택 상태 관리 (useReducer 사용) + const [selectionState, dispatch] = React.useReducer(selectionReducer, { + selectedTable: null, + selectedRowCount: 0, + resetCounters: { + project: 0, + standard: 0, + vendor: 0, + }, + }) + + // 선택 핸들러들 + const handleProjectSelection = React.useCallback((count: number) => { + dispatch({ type: 'SELECT_PROJECT', count }) + }, []) + + const handleStandardSelection = React.useCallback((count: number) => { + dispatch({ type: 'SELECT_STANDARD', count }) + }, []) + + const handleVendorSelection = React.useCallback((count: number) => { + dispatch({ type: 'SELECT_VENDOR', count }) + }, []) + + const { selectedTable, selectedRowCount, resetCounters } = selectionState + + return ( + + {/* 고정 헤더 영역 */} +
+
+

AVL 등록

+
+ + + +
+
+
+ + {/* 스크롤되는 콘텐츠 영역 */} +
+
+ {/* 프로젝트 AVL 테이블 - 9개 컬럼 */} +
+ + + {/* 이동 버튼들 - 첫 번째 border 위에 오버레이 */} +
+
+ + + + + + + +
+
+
+ + {/* 선종별 표준 AVL 테이블 - 8개 컬럼 */} +
+ + + {/* 이동 버튼들 - 두 번째 border 위에 오버레이 */} +
+
+ + + +
+
+
+ + {/* Vendor Pool 테이블 - 10개 컬럼 */} +
+ + + {/* 이동 버튼들 - 세 번째 테이블의 왼쪽 border 위에 오버레이 */} +
+
+ + + +
+
+
+
+
+
+ ) +} diff --git a/app/[lng]/evcp/(evcp)/avl/page.tsx b/app/[lng]/evcp/(evcp)/avl/page.tsx new file mode 100644 index 00000000..a5a5a170 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/avl/page.tsx @@ -0,0 +1,49 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" +import { getValidFilters } from "@/lib/data-table" +import { vendorPoSearchParamsCache } from "@/lib/po/vendor-table/validations" +import { getAvlLists } from "@/lib/avl/service" +import { AvlListItem } from "@/lib/avl/types" +import { AvlPageClient } from "./avl-page-client" + +interface AvlPageProps { + searchParams: Promise +} + +// 서버에서 초기 데이터 로드 +async function getInitialAvlData(searchParams: SearchParams) { + try { + const search = vendorPoSearchParamsCache.parse(searchParams) + const validFilters = getValidFilters(search.filters) + + // 기본 파라미터로 전체 데이터 조회 + const result = await getAvlLists({ + page: 1, + perPage: 100, // 충분한 수량으로 조회 + sort: [{ id: "createdAt", desc: true }], + flags: [], + filters: validFilters, + joinOperator: "and", + search: search.search || "", + isTemplate: "" as any, + constructionSector: "", + projectCode: "", + shipType: "", + avlKind: "", + htDivision: "" as any, + rev: "", + }) + + return result.data + } catch (error) { + console.error("AVL 초기 데이터 로드 실패:", error) + return [] + } +} + +export default async function AvlPage(props: AvlPageProps) { + const searchParams = await props.searchParams + const initialData = await getInitialAvlData(searchParams) + + return +} -- cgit v1.2.3