summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/[lng]/evcp/(evcp)/avl/[id]/page.tsx106
-rw-r--r--app/[lng]/evcp/(evcp)/avl/avl-page-client.tsx107
-rw-r--r--app/[lng]/evcp/(evcp)/avl/components/avl-registration-area.tsx792
-rw-r--r--app/[lng]/evcp/(evcp)/avl/page.tsx49
4 files changed, 1054 insertions, 0 deletions
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<SearchParams>
+}
+
+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 (
+ <div className="h-screen flex flex-col">
+ {/* 메인 콘텐츠 영역 */}
+ <div className="flex-1 overflow-hidden">
+ <div className="h-full p-4 md:p-6">
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={20}
+ searchableColumnCount={1}
+ filterableColumnCount={5}
+ cellWidths={[
+ "50px", "60px", "120px", "120px", "150px", "130px", "130px",
+ "140px", "140px", "100px", "100px", "100px", "100px", "120px",
+ "140px", "150px", "80px", "100px", "80px", "140px", "120px",
+ "160px", "100px", "120px", "120px", "130px", "120px", "130px", "200px"
+ ]}
+ shrinkZero
+ />
+ }
+ >
+ <AvlDetailTableWrapper
+ promises={promises}
+ avlListId={Number(id)}
+ avlListInfo={avlListInfo}
+ />
+ </React.Suspense>
+ </div>
+ </div>
+ </div>
+ )
+}
+
+// 실제 데이터를 받아서 AvlDetailTable에 전달하는 컴포넌트
+function AvlDetailTableWrapper({
+ promises,
+ avlListId,
+ avlListInfo
+}: {
+ promises: Promise<any>
+ avlListId: number
+ avlListInfo: any
+}) {
+ const [{ data, pageCount }] = React.use(promises)
+
+ // AVL 타입 결정
+ const avlType = avlListInfo.isTemplate ? '선종별표준AVL' : '프로젝트AVL'
+
+ // 선주명 추출 (프로젝트 정보에서)
+ const shipOwnerName = avlListInfo.shipOwnerName || undefined
+
+ return (
+ <AvlDetailTable
+ data={data}
+ pageCount={pageCount}
+ avlListId={avlListId}
+ avlType={avlType}
+ projectCode={avlListInfo.projectCode}
+ shipOwnerName={shipOwnerName}
+ businessType={avlListInfo.constructionSector || '조선'}
+ />
+ )
+}
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<AvlListItem[]>(initialData)
+ const [isLoading, setIsLoading] = useState(false)
+ const [registrationMode, setRegistrationMode] = useState<'standard' | 'project' | null>(null)
+ const [selectedAvlRow, setSelectedAvlRow] = useState<AvlListItem | null>(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 (
+ <div className="h-screen flex flex-col">
+ <div className="flex-1 overflow-hidden">
+ <ResizablePanelGroup direction="vertical" className="h-full">
+ {/* 상단 패널: AVL 목록 */}
+ <ResizablePanel defaultSize={40} minSize={20}>
+ <div className="h-full p-4">
+ <AvlTable
+ data={avlListData}
+ onRefresh={handleRefresh}
+ isLoading={isLoading}
+ onRegistrationModeChange={handleRegistrationModeChange}
+ onRowSelect={handleRowSelect}
+ />
+ </div>
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ {/* 하단 패널: AVL 등록 */}
+ <ResizablePanel defaultSize={60} minSize={30}>
+ <div className="h-full p-4 overflow-x-auto overflow-y-hidden">
+ <AvlRegistrationArea disabled={registrationMode === null} />
+ </div>
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ </div>
+ </div>
+ )
+}
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<ProjectAvlItem>[] = [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => 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 (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={handleRowSelection}
+ aria-label="Select row"
+ />
+ )
+ },
+ 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<StandardAvlItem>[] = [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => 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 (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={handleRowSelection}
+ aria-label="Select row"
+ />
+ )
+ },
+ 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<VendorPoolItem>[] = [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => 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 (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={handleRowSelection}
+ aria-label="Select row"
+ />
+ )
+ },
+ 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 (
+ <div className="flex flex-col">
+ <div className="mb-2">
+ <div className="flex items-center justify-between mb-2">
+ <h4 className="font-medium">프로젝트 AVL</h4>
+ <div className="flex gap-1">
+ <Button variant="outline" size="sm">
+ 행 추가
+ </Button>
+ <Button variant="outline" size="sm">
+ 파일 업로드
+ </Button>
+ <Button variant="outline" size="sm">
+ 자동 매핑
+ </Button>
+ <Button variant="outline" size="sm">
+ 강제 매핑
+ </Button>
+ <Button variant="outline" size="sm">
+ 항목 삭제
+ </Button>
+ </div>
+ </div>
+ </div>
+ <DataTable table={table} />
+ </div>
+ )
+}
+
+// 선종별 표준 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 (
+ <div className="flex flex-col">
+ <div className="mb-2">
+ <div className="flex items-center justify-between mb-2">
+ <h4 className="font-medium">선종별 표준 AVL</h4>
+ <div className="flex gap-1">
+ <Button variant="outline" size="sm">
+ 신규업체 추가
+ </Button>
+ <Button variant="outline" size="sm">
+ 파일 업로드
+ </Button>
+ <Button variant="outline" size="sm">
+ 일괄입력
+ </Button>
+ <Button variant="outline" size="sm">
+ 항목삭제
+ </Button>
+ </div>
+ </div>
+ </div>
+ <DataTable table={table} />
+ </div>
+ )
+}
+
+// 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 (
+ <div className="flex flex-col">
+ <div className="mb-2">
+ <div className="flex items-center justify-between mb-2">
+ <h4 className="font-medium">Vendor Pool</h4>
+ <div className="flex gap-1">
+ <Button variant="outline" size="sm">
+ 신규업체 추가
+ </Button>
+ </div>
+ </div>
+ </div>
+ <DataTable table={table} />
+ </div>
+ )
+}
+
+// 선택된 테이블 타입
+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 (
+ <Card className="h-full min-w-full overflow-visible">
+ {/* 고정 헤더 영역 */}
+ <div className="sticky top-0 z-10 p-4 border-b">
+ <div className="flex items-center justify-between">
+ <h3 className="text-lg font-semibold">AVL 등록</h3>
+ <div className="flex gap-2">
+ <Button variant="outline" size="sm">
+ 저장
+ </Button>
+ <Button variant="outline" size="sm">
+ 최종 확정
+ </Button>
+ <Button variant="outline" size="sm">
+ AVL 불러오기
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ {/* 스크롤되는 콘텐츠 영역 */}
+ <div className="overflow-x-auto overflow-y-hidden">
+ <div className="grid grid-cols-[2.2fr_2fr_2.5fr] gap-0 min-w-[1200px] w-fit">
+ {/* 프로젝트 AVL 테이블 - 9개 컬럼 */}
+ <div className="p-4 border-r relative">
+ <ProjectAvlTable
+ onSelectionChange={handleProjectSelection}
+ resetCounter={resetCounters.project}
+ />
+
+ {/* 이동 버튼들 - 첫 번째 border 위에 오버레이 */}
+ <div className="absolute right-0 top-1/2 transform -translate-y-1/2 translate-x-1/2 z-10">
+ <div className="flex flex-col gap-2 bg-background border rounded-md p-1 shadow-sm">
+
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="왼쪽으로 이동"
+ disabled={selectedTable !== 'standard' || selectedRowCount === 0}
+ >
+ <ChevronLeft className="w-4 h-4" />
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="오른쪽으로 이동"
+ disabled={selectedTable !== 'project' || selectedRowCount === 0}
+ >
+ <ChevronRight className="w-4 h-4" />
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="오른쪽으로 이동"
+ disabled={selectedTable !== 'project' || selectedRowCount === 0}
+ >
+ <ChevronsRight className="w-4 h-4" />
+ </Button>
+
+ </div>
+ </div>
+ </div>
+
+ {/* 선종별 표준 AVL 테이블 - 8개 컬럼 */}
+ <div className="p-4 border-r relative">
+ <StandardAvlTable
+ onSelectionChange={handleStandardSelection}
+ resetCounter={resetCounters.standard}
+ />
+
+ {/* 이동 버튼들 - 두 번째 border 위에 오버레이 */}
+ <div className="absolute right-0 top-1/2 transform -translate-y-1/2 translate-x-1/2 z-10">
+ <div className="flex flex-col gap-2 bg-background border rounded-md p-1 shadow-sm">
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="왼쪽으로 이동"
+ disabled={selectedTable !== 'standard' || selectedRowCount === 0}
+ >
+ <ChevronLeft className="w-4 h-4" />
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="오른쪽으로 이동"
+ disabled={selectedTable !== 'standard' || selectedRowCount === 0}
+ >
+ <ChevronRight className="w-4 h-4" />
+ </Button>
+
+ </div>
+ </div>
+ </div>
+
+ {/* Vendor Pool 테이블 - 10개 컬럼 */}
+ <div className="p-4 relative">
+ <VendorPoolTable
+ onSelectionChange={handleVendorSelection}
+ resetCounter={resetCounters.vendor}
+ />
+
+ {/* 이동 버튼들 - 세 번째 테이블의 왼쪽 border 위에 오버레이 */}
+ <div className="absolute left-0 top-1/2 transform -translate-y-1/2 -translate-x-1/2 z-10">
+ <div className="flex flex-col gap-2 bg-background border rounded-md p-1 shadow-sm">
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="왼쪽으로 이동"
+ disabled={selectedTable !== 'vendor' || selectedRowCount === 0}
+ >
+ <ChevronsLeft className="w-4 h-4" />
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="왼쪽으로 이동"
+ disabled={selectedTable !== 'vendor' || selectedRowCount === 0}
+ >
+ <ChevronLeft className="w-4 h-4" />
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="오른쪽으로 이동"
+ disabled={selectedTable !== 'standard' || selectedRowCount === 0}
+ >
+ <ChevronRight className="w-4 h-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </Card>
+ )
+}
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<SearchParams>
+}
+
+// 서버에서 초기 데이터 로드
+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 <AvlPageClient initialData={initialData} />
+}