summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--_docker/docker-compose.yml2
-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
-rw-r--r--db/schema/avl/avl.ts17
-rw-r--r--lib/avl/avl-atoms.ts5
-rw-r--r--lib/avl/components/avl-history-modal.tsx297
-rw-r--r--lib/avl/components/project-field-components.tsx113
-rw-r--r--lib/avl/components/project-field-utils.ts45
-rw-r--r--lib/avl/service.ts2363
-rw-r--r--lib/avl/snapshot-utils.ts190
-rw-r--r--lib/avl/table/avl-detail-table.tsx116
-rw-r--r--lib/avl/table/avl-registration-area.tsx568
-rw-r--r--lib/avl/table/avl-table-columns.tsx353
-rw-r--r--lib/avl/table/avl-table.tsx554
-rw-r--r--lib/avl/table/avl-vendor-add-and-modify-dialog.tsx945
-rw-r--r--lib/avl/table/columns-detail.tsx290
-rw-r--r--lib/avl/table/project-avl-add-dialog.tsx779
-rw-r--r--lib/avl/table/project-avl-table-columns.tsx167
-rw-r--r--lib/avl/table/project-avl-table.tsx650
-rw-r--r--lib/avl/table/standard-avl-add-dialog.tsx960
-rw-r--r--lib/avl/table/standard-avl-table-columns.tsx91
-rw-r--r--lib/avl/table/standard-avl-table.tsx651
-rw-r--r--lib/avl/table/vendor-pool-table-columns.tsx96
-rw-r--r--lib/avl/table/vendor-pool-table.tsx301
-rw-r--r--lib/avl/types.ts163
-rw-r--r--lib/avl/validations.ts170
-rw-r--r--lib/bidding-projects/service.ts40
-rw-r--r--lib/projects/service.ts24
-rw-r--r--lib/vendor-pool/table/vendor-pool-table.tsx4
31 files changed, 11000 insertions, 8 deletions
diff --git a/_docker/docker-compose.yml b/_docker/docker-compose.yml
index 62b6bae5..bf5027ff 100644
--- a/_docker/docker-compose.yml
+++ b/_docker/docker-compose.yml
@@ -10,7 +10,7 @@ services:
ports:
- "5432:5432" # host:container, container는 항상 5432, host 측은 원하는 포트로 설정
volumes:
- - postgres_data:/var/lib/postgresql/data
+ - evcp_postgres_data:/var/lib/postgresql/data
restart: always
volumes:
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} />
+}
diff --git a/db/schema/avl/avl.ts b/db/schema/avl/avl.ts
index addbba94..d2aac795 100644
--- a/db/schema/avl/avl.ts
+++ b/db/schema/avl/avl.ts
@@ -1,4 +1,4 @@
-import { pgTable, text, boolean, integer, timestamp, varchar, decimal } from "drizzle-orm/pg-core";
+import { pgTable, boolean, integer, timestamp, varchar, decimal, json } from "drizzle-orm/pg-core";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
// AVL 리스트 테이블 (프로젝트 AVL 및 선종별 표준 AVL 리스트)
@@ -17,6 +17,9 @@ export const avlList = pgTable("avl_list", {
htDivision: varchar("ht_division", { length: 10 }), // H=Hull, T=Top, 공통
rev: integer("rev").default(1), // 리비전 정보
+ // 히스토리 관리
+ vendorInfoSnapshot: json("vendor_info_snapshot"), // AVL 생성 시점의 vendorInfo 현황 스냅샷
+
// 타임스탬프
createdAt: timestamp("created_at").defaultNow(), // 등재일
createdBy: varchar("created_by", { length: 50 }), // 등재자
@@ -29,10 +32,18 @@ export const avlVendorInfo = pgTable("avl_vendor_info", {
// 기본 식별자
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
+ isTemplate: boolean("is_template").default(false), // false: 프로젝트 AVL의 레코드임, true: 표준 AVL의 레코드임
+
+ // 표준 AVL용 필드들 (isTemplate=true일 경우)
+ constructionSector: varchar("construction_sector", { length: 10 }), // 공사부문 (표준 AVL일 경우)
+ shipType: varchar("ship_type", { length: 50 }), // 선종 (표준 AVL일 경우)
+ avlKind: varchar("avl_kind", { length: 50 }), // AVL 종류 (표준 AVL일 경우)
+ htDivision: varchar("ht_division", { length: 10 }), // H/T 구분 (표준 AVL일 경우)
+
projectCode: varchar("project_code", { length: 50 }), // 프로젝트코드 (프로젝트 AVL일 경우)
- // AVL 리스트와의 관계 (외래키)
- avlListId: integer("avl_list_id").references(() => avlList.id),
+ // AVL 리스트와의 관계 (자식이 먼저 생기므로.. 제약조건 없는 외래키)
+ avlListId: integer("avl_list_id"),
// 제안방향
ownerSuggestion: boolean("owner_suggestion").default(false), // 선주 제안사인 경우
diff --git a/lib/avl/avl-atoms.ts b/lib/avl/avl-atoms.ts
new file mode 100644
index 00000000..26836413
--- /dev/null
+++ b/lib/avl/avl-atoms.ts
@@ -0,0 +1,5 @@
+import { atom } from 'jotai';
+import type { AvlListItem } from '@/lib/avl/types';
+
+// AVL 페이지에서 선택된 AVL 레코드
+export const selectedAvlRecordAtom = atom<AvlListItem | null>(null);
diff --git a/lib/avl/components/avl-history-modal.tsx b/lib/avl/components/avl-history-modal.tsx
new file mode 100644
index 00000000..4f0c354b
--- /dev/null
+++ b/lib/avl/components/avl-history-modal.tsx
@@ -0,0 +1,297 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Calendar, Users, FileText, ChevronDown, ChevronRight } from "lucide-react"
+import type { AvlListItem } from "@/lib/avl/types"
+
+interface AvlHistoryModalProps {
+ isOpen: boolean
+ onClose: () => void
+ avlItem: AvlListItem | null
+ historyData?: AvlHistoryRecord[]
+ onLoadHistory?: (avlItem: AvlListItem) => Promise<AvlHistoryRecord[]>
+}
+
+export interface VendorSnapshot {
+ id: number
+ vendorName?: string
+ avlVendorName?: string
+ vendorCode?: string
+ disciplineName?: string
+ materialNameCustomerSide?: string
+ materialGroupCode?: string
+ materialGroupName?: string
+ tier?: string
+ hasAvl?: boolean
+ faTarget?: boolean
+ headquarterLocation?: string
+ ownerSuggestion?: boolean
+ shiSuggestion?: boolean
+ [key: string]: unknown // 다른 모든 속성들
+}
+
+export interface AvlHistoryRecord {
+ id: number
+ rev: number
+ createdAt: string
+ createdBy: string
+ vendorInfoSnapshot: VendorSnapshot[] // JSON 데이터
+ changeDescription?: string
+}
+
+// 스냅샷 테이블 컴포넌트
+interface SnapshotTableProps {
+ snapshot: VendorSnapshot[]
+ isOpen: boolean
+ onToggle: () => void
+}
+
+function SnapshotTable({ snapshot, isOpen, onToggle }: SnapshotTableProps) {
+ if (!snapshot || snapshot.length === 0) {
+ return (
+ <div className="text-sm text-muted-foreground">
+ 스냅샷 데이터가 없습니다.
+ </div>
+ )
+ }
+
+ return (
+ <Collapsible open={isOpen} onOpenChange={onToggle}>
+ <CollapsibleTrigger asChild>
+ <Button variant="outline" size="sm" className="w-full justify-between">
+ <span>벤더 상세 정보 ({snapshot.length}개)</span>
+ {isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
+ </Button>
+ </CollapsibleTrigger>
+ <CollapsibleContent className="mt-3">
+ <div className="border rounded-lg">
+ <div className="overflow-auto max-h-[400px]">
+ <Table>
+ <TableHeader className="sticky top-0 bg-background z-10">
+ <TableRow>
+ <TableHead className="w-[60px]">No.</TableHead>
+ <TableHead className="w-[100px]">설계공종</TableHead>
+ <TableHead>고객사 AVL 자재명</TableHead>
+ <TableHead className="w-[120px]">자재그룹 코드</TableHead>
+ <TableHead className="w-[130px]">자재그룹 명</TableHead>
+ <TableHead>AVL 등재업체명</TableHead>
+ <TableHead className="w-[120px]">협력업체 코드</TableHead>
+ <TableHead className="w-[130px]">협력업체 명</TableHead>
+ <TableHead className="w-[80px]">선주제안</TableHead>
+ <TableHead className="w-[80px]">SHI 제안</TableHead>
+ <TableHead className="w-[100px]">본사 위치</TableHead>
+ <TableHead className="w-[80px]">등급</TableHead>
+ <TableHead className="w-[60px]">AVL</TableHead>
+ <TableHead className="w-[80px]">FA대상</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {snapshot.map((item, index) => (
+ <TableRow key={item.id || index}>
+ <TableCell className="font-mono text-xs text-center">{index + 1}</TableCell>
+ <TableCell className="text-sm">{item.disciplineName || '-'}</TableCell>
+ <TableCell className="text-sm">{item.materialNameCustomerSide || '-'}</TableCell>
+ <TableCell className="font-mono text-xs">{item.materialGroupCode || '-'}</TableCell>
+ <TableCell className="text-sm">{item.materialGroupName || '-'}</TableCell>
+ <TableCell className="font-medium text-sm">{item.avlVendorName || '-'}</TableCell>
+ <TableCell className="font-mono text-xs">{item.vendorCode || '-'}</TableCell>
+ <TableCell className="font-medium text-sm">{item.vendorName || '-'}</TableCell>
+ <TableCell>
+ <Badge variant={item.ownerSuggestion ? "default" : "secondary"} className="text-xs">
+ {item.ownerSuggestion ? "예" : "아니오"}
+ </Badge>
+ </TableCell>
+ <TableCell>
+ <Badge variant={item.shiSuggestion ? "default" : "secondary"} className="text-xs">
+ {item.shiSuggestion ? "예" : "아니오"}
+ </Badge>
+ </TableCell>
+ <TableCell className="text-xs">{item.headquarterLocation || '-'}</TableCell>
+ <TableCell>
+ {item.tier ? (
+ <Badge variant="outline" className="text-xs">
+ {item.tier}
+ </Badge>
+ ) : '-'}
+ </TableCell>
+ <TableCell>
+ <Badge variant={item.hasAvl ? "default" : "secondary"} className="text-xs">
+ {item.hasAvl ? "Y" : "N"}
+ </Badge>
+ </TableCell>
+ <TableCell>
+ <Badge variant={item.faTarget ? "default" : "secondary"} className="text-xs">
+ {item.faTarget ? "Y" : "N"}
+ </Badge>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ </CollapsibleContent>
+ </Collapsible>
+ )
+}
+
+export function AvlHistoryModal({
+ isOpen,
+ onClose,
+ avlItem,
+ historyData,
+ onLoadHistory
+}: AvlHistoryModalProps) {
+ const [loading, setLoading] = React.useState(false)
+ const [history, setHistory] = React.useState<AvlHistoryRecord[]>([])
+ const [openSnapshots, setOpenSnapshots] = React.useState<Record<number, boolean>>({})
+
+ // 히스토리 데이터 로드
+ React.useEffect(() => {
+ if (isOpen && avlItem && onLoadHistory) {
+ setLoading(true)
+ onLoadHistory(avlItem)
+ .then(setHistory)
+ .catch(console.error)
+ .finally(() => setLoading(false))
+ } else if (historyData) {
+ setHistory(historyData)
+ }
+ }, [isOpen, avlItem, onLoadHistory, historyData])
+
+ // 스냅샷 테이블 토글 함수
+ const toggleSnapshot = (recordId: number) => {
+ setOpenSnapshots(prev => ({
+ ...prev,
+ [recordId]: !prev[recordId]
+ }))
+ }
+
+ if (!avlItem) return null
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onClose}>
+ <DialogContent className="max-w-7xl h-[90vh] flex flex-col">
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ AVL 리비전 히스토리
+ </DialogTitle>
+ <div className="text-sm text-muted-foreground">
+ {avlItem.isTemplate ? "표준 AVL" : "프로젝트 AVL"} - {avlItem.avlKind}
+ {avlItem.projectCode && ` (${avlItem.projectCode})`}
+ </div>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-auto min-h-0">
+ <div className="pr-4">
+ {loading ? (
+ <div className="flex items-center justify-center h-[300px]">
+ <div className="text-muted-foreground">히스토리를 불러오는 중...</div>
+ </div>
+ ) : history.length === 0 ? (
+ <div className="flex items-center justify-center h-[300px]">
+ <div className="text-muted-foreground">히스토리 데이터가 없습니다.</div>
+ </div>
+ ) : (
+ <div className="space-y-4 py-4">
+ {history.map((record, index) => (
+ <div
+ key={record.id}
+ className={`p-4 border rounded-lg ${
+ index === 0 ? "border-primary bg-primary/5" : "border-border"
+ }`}
+ >
+ {/* 리비전 헤더 */}
+ <div className="flex items-center justify-between mb-3">
+ <div className="flex items-center gap-2">
+ <Badge
+ variant={index === 0 ? "default" : "outline"}
+ className="font-mono"
+ >
+ Rev {record.rev}
+ </Badge>
+ {index === 0 && (
+ <Badge variant="secondary" className="text-xs">
+ 현재
+ </Badge>
+ )}
+ </div>
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
+ <div className="flex items-center gap-1">
+ <Calendar className="h-4 w-4" />
+ {new Date(record.createdAt).toLocaleDateString('ko-KR')}
+ </div>
+ </div>
+ </div>
+
+ {/* 변경 설명 */}
+ {record.changeDescription && (
+ <div className="mb-3 p-2 bg-muted/50 rounded text-sm">
+ {record.changeDescription}
+ </div>
+ )}
+
+ {/* Vendor Info 요약 */}
+ <div className="grid grid-cols-3 gap-4 text-sm">
+ <div className="text-center">
+ <div className="font-medium text-lg">
+ {record.vendorInfoSnapshot?.length || 0}
+ </div>
+ <div className="text-muted-foreground">총 협력업체</div>
+ </div>
+ <div className="text-center">
+ <div className="font-medium text-lg">
+ {record.vendorInfoSnapshot?.filter(v => v.hasAvl).length || 0}
+ </div>
+ <div className="text-muted-foreground">AVL 등재</div>
+ </div>
+ <div className="text-center">
+ <div className="font-medium text-lg">
+ {record.vendorInfoSnapshot?.filter(v => v.faTarget).length || 0}
+ </div>
+ <div className="text-muted-foreground">FA 대상</div>
+ </div>
+ </div>
+
+ {/* 스냅샷 테이블 */}
+ <div className="mt-3 pt-3 border-t">
+ <SnapshotTable
+ snapshot={record.vendorInfoSnapshot || []}
+ isOpen={openSnapshots[record.id] || false}
+ onToggle={() => toggleSnapshot(record.id)}
+ />
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <div className="flex justify-end pt-4 border-t flex-shrink-0 mt-4">
+ <Button variant="outline" onClick={onClose}>
+ 닫기
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/avl/components/project-field-components.tsx b/lib/avl/components/project-field-components.tsx
new file mode 100644
index 00000000..95505d08
--- /dev/null
+++ b/lib/avl/components/project-field-components.tsx
@@ -0,0 +1,113 @@
+"use client"
+
+import * as React from "react"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
+import {
+ ProjectSearchStatus,
+ getLabelStatusClassName,
+ getDisplayElementStatusClassName,
+ getInputStatusClassName
+} from "./project-field-utils"
+
+// 타입 재내보내기
+export type { ProjectSearchStatus } from "./project-field-utils"
+
+// 재사용 가능한 필드 컴포넌트들
+export interface ProjectInputFieldProps {
+ label: string
+ value: string
+ onChange: (value: string) => void
+ placeholder: string
+ status: ProjectSearchStatus
+ statusText?: string
+ minWidth?: string
+}
+
+export const ProjectInputField: React.FC<ProjectInputFieldProps> = ({
+ label,
+ value,
+ onChange,
+ placeholder,
+ status,
+ statusText,
+ minWidth = "250px"
+}) => (
+ <div className="space-y-2 min-w-[250px] flex-shrink-0" style={{ minWidth }}>
+ <label className={`text-sm font-medium ${getLabelStatusClassName(status)}`}>
+ {label}
+ {statusText && <span className="ml-1 text-xs">{statusText}</span>}
+ </label>
+ <Input
+ value={value}
+ onChange={(e) => onChange(e.target.value)}
+ placeholder={placeholder}
+ className={`h-8 text-sm ${getInputStatusClassName(status)}`}
+ />
+ </div>
+)
+
+export interface ProjectDisplayFieldProps {
+ label: string
+ value: string
+ status: ProjectSearchStatus
+ minWidth?: string
+ formatter?: (value: string) => string
+}
+
+export const ProjectDisplayField: React.FC<ProjectDisplayFieldProps> = ({
+ label,
+ value,
+ status,
+ minWidth = "120px",
+ formatter
+}) => {
+ const displayValue = status === 'searching' ? '조회 중...' : (formatter ? formatter(value) : (value || '-'))
+
+ return (
+ <div className="space-y-2 flex-shrink-0" style={{ minWidth }}>
+ <label className={`text-sm font-medium ${getLabelStatusClassName(status)}`}>
+ {label}
+ {status === 'searching' && <span className="ml-1 text-xs">(조회 중...)</span>}
+ </label>
+ <div className={`text-sm font-medium min-h-[32px] flex items-center border rounded-md px-3 bg-background ${getDisplayElementStatusClassName(status)}`}>
+ {displayValue}
+ </div>
+ </div>
+ )
+}
+
+export interface ProjectFileFieldProps {
+ label: string
+ originalFile: string
+ onFileUpload: (event: React.ChangeEvent<HTMLInputElement>) => void
+ minWidth?: string
+}
+
+export const ProjectFileField: React.FC<ProjectFileFieldProps> = ({
+ label,
+ originalFile,
+ onFileUpload,
+ minWidth = "200px"
+}) => (
+ <div className="space-y-2 flex-shrink-0" style={{ minWidth }}>
+ <label className="text-sm font-medium text-muted-foreground">{label}</label>
+ <div className="flex items-center gap-2 min-h-[32px]">
+ {originalFile ? (
+ <span className="text-sm text-blue-600">{originalFile}</span>
+ ) : (
+ <div className="relative">
+ <input
+ type="file"
+ onChange={onFileUpload}
+ className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
+ accept=".xlsx,.xls,.csv"
+ />
+ <Button variant="outline" size="sm" className="text-xs">
+ 파일 선택
+ </Button>
+ </div>
+ )}
+ </div>
+ </div>
+)
diff --git a/lib/avl/components/project-field-utils.ts b/lib/avl/components/project-field-utils.ts
new file mode 100644
index 00000000..d3d84295
--- /dev/null
+++ b/lib/avl/components/project-field-utils.ts
@@ -0,0 +1,45 @@
+// 프로젝트 검색 상태 타입
+export type ProjectSearchStatus = 'idle' | 'searching' | 'success-projects' | 'success-bidding' | 'error'
+
+// 프로젝트 상태에 따른 스타일링 유틸리티 함수들
+export const getLabelStatusClassName = (status: ProjectSearchStatus): string => {
+ switch (status) {
+ case 'error':
+ return 'text-red-600'
+ case 'success-projects':
+ case 'success-bidding':
+ return 'text-green-600'
+ case 'searching':
+ return 'text-blue-600'
+ default:
+ return 'text-muted-foreground'
+ }
+}
+
+export const getDisplayElementStatusClassName = (status: ProjectSearchStatus): string => {
+ switch (status) {
+ case 'error':
+ return 'border-red-300'
+ case 'success-projects':
+ case 'success-bidding':
+ return 'border-green-300'
+ case 'searching':
+ return 'border-blue-300'
+ default:
+ return 'border-input'
+ }
+}
+
+export const getInputStatusClassName = (status: ProjectSearchStatus): string => {
+ switch (status) {
+ case 'error':
+ return 'border-red-300 focus:border-red-500 focus:ring-red-500/20'
+ case 'success-projects':
+ case 'success-bidding':
+ return 'border-green-300 focus:border-green-500 focus:ring-green-500/20'
+ case 'searching':
+ return 'border-blue-300 focus:border-blue-500 focus:ring-blue-500/20'
+ default:
+ return ''
+ }
+}
diff --git a/lib/avl/service.ts b/lib/avl/service.ts
new file mode 100644
index 00000000..535a0169
--- /dev/null
+++ b/lib/avl/service.ts
@@ -0,0 +1,2363 @@
+"use server";
+
+import { GetAvlListSchema, GetAvlDetailSchema, GetProjectAvlSchema, GetStandardAvlSchema } from "./validations";
+import { AvlListItem, AvlDetailItem, CreateAvlListInput, UpdateAvlListInput, ActionResult, AvlVendorInfoInput } from "./types";
+import type { NewAvlVendorInfo } from "@/db/schema/avl/avl";
+import type { NewVendorPool } from "@/db/schema/avl/vendor-pool";
+import db from "@/db/db";
+import { avlList, avlVendorInfo } from "@/db/schema/avl/avl";
+import { vendorPool } from "@/db/schema/avl/vendor-pool";
+import { eq, and, or, ilike, count, desc, asc, sql, inArray } from "drizzle-orm";
+import { debugLog, debugError, debugSuccess, debugWarn } from "@/lib/debug-utils";
+import { revalidateTag } from "next/cache";
+import { createVendorInfoSnapshot } from "./snapshot-utils";
+
+/**
+ * AVL 리스트 조회
+ * avl_list 테이블에서 실제 데이터를 조회합니다.
+ */
+export const getAvlLists = async (input: GetAvlListSchema) => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ debugLog('AVL 리스트 조회 시작', { input, offset });
+
+ // 검색 조건 구성
+ const whereConditions: any[] = [];
+
+ // 검색어 기반 필터링
+ if (input.search) {
+ const searchTerm = `%${input.search}%`;
+ whereConditions.push(
+ or(
+ ilike(avlList.constructionSector, searchTerm),
+ ilike(avlList.projectCode, searchTerm),
+ ilike(avlList.shipType, searchTerm),
+ ilike(avlList.avlKind, searchTerm)
+ )
+ );
+ }
+
+ // 필터 조건 추가
+ if (input.isTemplate === "true") {
+ whereConditions.push(eq(avlList.isTemplate, true));
+ } else if (input.isTemplate === "false") {
+ whereConditions.push(eq(avlList.isTemplate, false));
+ }
+
+ if (input.constructionSector) {
+ whereConditions.push(ilike(avlList.constructionSector, `%${input.constructionSector}%`));
+ }
+ if (input.projectCode) {
+ whereConditions.push(ilike(avlList.projectCode, `%${input.projectCode}%`));
+ }
+ if (input.shipType) {
+ whereConditions.push(ilike(avlList.shipType, `%${input.shipType}%`));
+ }
+ if (input.avlKind) {
+ whereConditions.push(ilike(avlList.avlKind, `%${input.avlKind}%`));
+ }
+ if (input.htDivision) {
+ whereConditions.push(eq(avlList.htDivision, input.htDivision));
+ }
+ if (input.rev) {
+ whereConditions.push(eq(avlList.rev, parseInt(input.rev)));
+ }
+
+ // 정렬 조건 구성
+ const orderByConditions: any[] = [];
+ input.sort.forEach((sortItem) => {
+ const column = sortItem.id as keyof typeof avlList;
+
+ if (column && avlList[column]) {
+ if (sortItem.desc) {
+ orderByConditions.push(sql`${avlList[column]} desc`);
+ } else {
+ orderByConditions.push(sql`${avlList[column]} asc`);
+ }
+ }
+ });
+
+ // 기본 정렬 (등재일 내림차순)
+ if (orderByConditions.length === 0) {
+ orderByConditions.push(desc(avlList.createdAt));
+ }
+
+ // 총 개수 조회
+ const totalCount = await db
+ .select({ count: count() })
+ .from(avlList)
+ .where(and(...whereConditions));
+
+ // 데이터 조회
+ const data = await db
+ .select()
+ .from(avlList)
+ .where(and(...whereConditions))
+ .orderBy(...orderByConditions)
+ .limit(input.perPage)
+ .offset(offset);
+
+ // 데이터 변환 (timestamp -> string)
+ const transformedData: AvlListItem[] = data.map((item, index) => ({
+ ...item,
+ no: offset + index + 1,
+ selected: false,
+ createdAt: ((item as any).createdAt as Date)?.toISOString().split('T')[0] || '',
+ updatedAt: ((item as any).updatedAt as Date)?.toISOString().split('T')[0] || '',
+ // 추가 필드들 (실제로는 JOIN이나 별도 쿼리로 가져와야 함)
+ projectInfo: item.projectCode || '',
+ shipType: item.shipType || '',
+ avlType: item.avlKind || '',
+ htDivision: item.htDivision || '',
+ rev: item.rev || 1,
+ }));
+
+ const pageCount = Math.ceil(totalCount[0].count / input.perPage);
+
+ debugSuccess('AVL 리스트 조회 완료', { recordCount: transformedData.length, pageCount });
+
+ return {
+ data: transformedData,
+ pageCount
+ };
+ } catch (err) {
+ debugError('AVL 리스트 조회 실패', { error: err, input });
+ console.error("Error in getAvlLists:", err);
+ return { data: [], pageCount: 0 };
+ }
+};
+
+
+/**
+ * AVL 상세 정보 조회 (특정 AVL ID의 모든 vendor info)
+ */
+export const getAvlDetail = async (input: GetAvlDetailSchema & { avlListId: number }) => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ debugLog('AVL 상세 조회 시작', { input, offset });
+
+ // 검색 조건 구성
+ const whereConditions: any[] = [];
+
+ // AVL 리스트 ID 필터 (필수)
+ whereConditions.push(eq(avlVendorInfo.avlListId, input.avlListId));
+
+ // 검색어 기반 필터링
+ if (input.search) {
+ const searchTerm = `%${input.search}%`;
+ whereConditions.push(
+ or(
+ ilike(avlVendorInfo.disciplineName, searchTerm),
+ ilike(avlVendorInfo.materialNameCustomerSide, searchTerm),
+ ilike(avlVendorInfo.vendorName, searchTerm),
+ ilike(avlVendorInfo.avlVendorName, searchTerm)
+ )
+ );
+ }
+
+ // 필터 조건 추가
+ if (input.equipBulkDivision) {
+ whereConditions.push(eq(avlVendorInfo.equipBulkDivision, input.equipBulkDivision === "EQUIP" ? "E" : "B"));
+ }
+ if (input.disciplineCode) {
+ whereConditions.push(ilike(avlVendorInfo.disciplineCode, `%${input.disciplineCode}%`));
+ }
+ if (input.disciplineName) {
+ whereConditions.push(ilike(avlVendorInfo.disciplineName, `%${input.disciplineName}%`));
+ }
+ if (input.materialNameCustomerSide) {
+ whereConditions.push(ilike(avlVendorInfo.materialNameCustomerSide, `%${input.materialNameCustomerSide}%`));
+ }
+ if (input.packageCode) {
+ whereConditions.push(ilike(avlVendorInfo.packageCode, `%${input.packageCode}%`));
+ }
+ if (input.packageName) {
+ whereConditions.push(ilike(avlVendorInfo.packageName, `%${input.packageName}%`));
+ }
+ if (input.materialGroupCode) {
+ whereConditions.push(ilike(avlVendorInfo.materialGroupCode, `%${input.materialGroupCode}%`));
+ }
+ if (input.materialGroupName) {
+ whereConditions.push(ilike(avlVendorInfo.materialGroupName, `%${input.materialGroupName}%`));
+ }
+ if (input.vendorCode) {
+ whereConditions.push(ilike(avlVendorInfo.vendorCode, `%${input.vendorCode}%`));
+ }
+ if (input.vendorName) {
+ whereConditions.push(ilike(avlVendorInfo.vendorName, `%${input.vendorName}%`));
+ }
+ if (input.avlVendorName) {
+ whereConditions.push(ilike(avlVendorInfo.avlVendorName, `%${input.avlVendorName}%`));
+ }
+ if (input.tier) {
+ whereConditions.push(ilike(avlVendorInfo.tier, `%${input.tier}%`));
+ }
+ if (input.faTarget === "true") {
+ whereConditions.push(eq(avlVendorInfo.faTarget, true));
+ } else if (input.faTarget === "false") {
+ whereConditions.push(eq(avlVendorInfo.faTarget, false));
+ }
+ if (input.faStatus) {
+ whereConditions.push(ilike(avlVendorInfo.faStatus, `%${input.faStatus}%`));
+ }
+ if (input.isAgent === "true") {
+ whereConditions.push(eq(avlVendorInfo.isAgent, true));
+ } else if (input.isAgent === "false") {
+ whereConditions.push(eq(avlVendorInfo.isAgent, false));
+ }
+ if (input.contractSignerName) {
+ whereConditions.push(ilike(avlVendorInfo.contractSignerName, `%${input.contractSignerName}%`));
+ }
+ if (input.headquarterLocation) {
+ whereConditions.push(ilike(avlVendorInfo.headquarterLocation, `%${input.headquarterLocation}%`));
+ }
+ if (input.manufacturingLocation) {
+ whereConditions.push(ilike(avlVendorInfo.manufacturingLocation, `%${input.manufacturingLocation}%`));
+ }
+ if (input.hasAvl === "true") {
+ whereConditions.push(eq(avlVendorInfo.hasAvl, true));
+ } else if (input.hasAvl === "false") {
+ whereConditions.push(eq(avlVendorInfo.hasAvl, false));
+ }
+ if (input.isBlacklist === "true") {
+ whereConditions.push(eq(avlVendorInfo.isBlacklist, true));
+ } else if (input.isBlacklist === "false") {
+ whereConditions.push(eq(avlVendorInfo.isBlacklist, false));
+ }
+ if (input.isBcc === "true") {
+ whereConditions.push(eq(avlVendorInfo.isBcc, true));
+ } else if (input.isBcc === "false") {
+ whereConditions.push(eq(avlVendorInfo.isBcc, false));
+ }
+ if (input.techQuoteNumber) {
+ whereConditions.push(ilike(avlVendorInfo.techQuoteNumber, `%${input.techQuoteNumber}%`));
+ }
+ if (input.quoteCode) {
+ whereConditions.push(ilike(avlVendorInfo.quoteCode, `%${input.quoteCode}%`));
+ }
+ if (input.quoteCountry) {
+ whereConditions.push(ilike(avlVendorInfo.quoteCountry, `%${input.quoteCountry}%`));
+ }
+ if (input.remark) {
+ whereConditions.push(ilike(avlVendorInfo.remark, `%${input.remark}%`));
+ }
+
+ // 정렬 조건 구성
+ const orderByConditions: any[] = [];
+ input.sort.forEach((sortItem) => {
+ const column = sortItem.id as keyof typeof avlVendorInfo;
+
+ if (column && avlVendorInfo[column]) {
+ if (sortItem.desc) {
+ orderByConditions.push(sql`${avlVendorInfo[column]} desc`);
+ } else {
+ orderByConditions.push(sql`${avlVendorInfo[column]} asc`);
+ }
+ }
+ });
+
+ // 기본 정렬
+ if (orderByConditions.length === 0) {
+ orderByConditions.push(asc(avlVendorInfo.id));
+ }
+
+ // 총 개수 조회
+ const totalCount = await db
+ .select({ count: count() })
+ .from(avlVendorInfo)
+ .where(and(...whereConditions));
+
+ // 데이터 조회
+ const data = await db
+ .select()
+ .from(avlVendorInfo)
+ .where(and(...whereConditions))
+ .orderBy(...orderByConditions)
+ .limit(input.perPage)
+ .offset(offset);
+
+ // 데이터 변환 (timestamp -> string, DB 필드 -> UI 필드)
+ const transformedData: AvlDetailItem[] = data.map((item, index) => ({
+ ...(item as any),
+ no: offset + index + 1,
+ selected: false,
+ createdAt: ((item as any).createdAt as Date)?.toISOString().split('T')[0] || '',
+ updatedAt: ((item as any).updatedAt as Date)?.toISOString().split('T')[0] || '',
+ // UI 표시용 필드 변환
+ equipBulkDivision: item.equipBulkDivision === "E" ? "EQUIP" : "BULK",
+ faTarget: item.faTarget ?? false,
+ agentStatus: item.isAgent ? "예" : "아니오",
+ shiAvl: item.hasAvl ?? false,
+ shiBlacklist: item.isBlacklist ?? false,
+ shiBcc: item.isBcc ?? false,
+ salesQuoteNumber: item.techQuoteNumber || '',
+ quoteCode: item.quoteCode || '',
+ salesVendorInfo: item.quoteVendorName || '',
+ salesCountry: item.quoteCountry || '',
+ totalAmount: item.quoteTotalAmount ? item.quoteTotalAmount.toString() : '',
+ quoteReceivedDate: item.quoteReceivedDate || '',
+ recentQuoteDate: item.recentQuoteDate || '',
+ recentQuoteNumber: item.recentQuoteNumber || '',
+ recentOrderDate: item.recentOrderDate || '',
+ recentOrderNumber: item.recentOrderNumber || '',
+ remarks: item.remark || '',
+ }));
+
+ const pageCount = Math.ceil(totalCount[0].count / input.perPage);
+
+ debugSuccess('AVL 상세 조회 완료', { recordCount: transformedData.length, pageCount });
+
+ return {
+ data: transformedData,
+ pageCount
+ };
+ } catch (err) {
+ debugError('AVL 상세 조회 실패', { error: err, input });
+ console.error("Error in getAvlDetail:", err);
+ return { data: [], pageCount: 0 };
+ }
+};
+
+
+/**
+ * AVL 리스트 상세 정보 조회 (단일)
+ */
+export async function getAvlListById(id: number): Promise<AvlListItem | null> {
+ try {
+ const data = await db
+ .select()
+ .from(avlList)
+ .where(eq(avlList.id, id))
+ .limit(1);
+
+ if (data.length === 0) {
+ return null;
+ }
+
+ const item = data[0];
+
+ // 데이터 변환
+ const transformedData: AvlListItem = {
+ ...item,
+ no: 1,
+ selected: false,
+ createdAt: ((item as any).createdAt as Date)?.toISOString().split('T')[0] || '',
+ updatedAt: ((item as any).updatedAt as Date)?.toISOString().split('T')[0] || '',
+ projectInfo: item.projectCode || '',
+ shipType: item.shipType || '',
+ avlType: item.avlKind || '',
+ htDivision: item.htDivision || '',
+ rev: item.rev || 1,
+ };
+
+ return transformedData;
+ } catch (err) {
+ console.error("Error in getAvlListById:", err);
+ return null;
+ }
+}
+
+/**
+ * AVL Vendor Info 상세 정보 조회 (단일)
+ */
+export async function getAvlVendorInfoById(id: number): Promise<AvlDetailItem | null> {
+ try {
+ const data = await db
+ .select()
+ .from(avlVendorInfo)
+ .where(eq(avlVendorInfo.id, id))
+ .limit(1);
+
+ if (data.length === 0) {
+ return null;
+ }
+
+ const item = data[0];
+
+ // 데이터 변환
+ const transformedData: AvlDetailItem = {
+ ...(item as any),
+ no: 1,
+ selected: false,
+ createdAt: ((item as any).createdAt as Date)?.toISOString().split('T')[0] || '',
+ updatedAt: ((item as any).updatedAt as Date)?.toISOString().split('T')[0] || '',
+ equipBulkDivision: item.equipBulkDivision === "E" ? "EQUIP" : "BULK",
+ faTarget: item.faTarget ?? false,
+ agentStatus: item.isAgent ? "예" : "아니오",
+ shiAvl: item.hasAvl ?? false,
+ shiBlacklist: item.isBlacklist ?? false,
+ shiBcc: item.isBcc ?? false,
+ salesQuoteNumber: item.techQuoteNumber || '',
+ quoteCode: item.quoteCode || '',
+ salesVendorInfo: item.quoteVendorName || '',
+ salesCountry: item.quoteCountry || '',
+ totalAmount: item.quoteTotalAmount ? item.quoteTotalAmount.toString() : '',
+ quoteReceivedDate: item.quoteReceivedDate || '',
+ recentQuoteDate: item.recentQuoteDate || '',
+ recentQuoteNumber: item.recentQuoteNumber || '',
+ recentOrderDate: item.recentOrderDate || '',
+ recentOrderNumber: item.recentOrderNumber || '',
+ remarks: item.remark || '',
+ };
+
+ return transformedData;
+ } catch (err) {
+ console.error("Error in getAvlVendorInfoById:", err);
+ return null;
+ }
+}
+
+/**
+ * AVL 액션 처리
+ * 신규등록, 일괄입력, 저장 등의 액션을 처리
+ */
+export async function handleAvlAction(
+ action: string,
+ data?: any
+): Promise<ActionResult> {
+ try {
+ switch (action) {
+ case "new-registration":
+ return { success: true, message: "신규 AVL 등록 모드" };
+
+ case "standard-registration":
+ return { success: true, message: "표준 AVL 등재 모드" };
+
+ case "project-registration":
+ return { success: true, message: "프로젝트 AVL 등재 모드" };
+
+ case "bulk-import":
+ if (!data?.file) {
+ return { success: false, message: "업로드할 파일이 없습니다." };
+ }
+ console.log("일괄 입력 처리:", data.file);
+ return { success: true, message: "일괄 입력 처리가 시작되었습니다." };
+
+ case "save":
+ console.log("변경사항 저장:", data);
+ return { success: true, message: "변경사항이 저장되었습니다." };
+
+ case "edit":
+ if (!data?.id) {
+ return { success: false, message: "수정할 항목 ID가 없습니다." };
+ }
+ return { success: true, message: "수정 모달이 열렸습니다.", data: { id: data.id } };
+
+ case "delete":
+ if (!data?.id) {
+ return { success: false, message: "삭제할 항목 ID가 없습니다." };
+ }
+ // 실제 삭제 처리
+ const deleteResult = await deleteAvlList(data.id);
+ if (deleteResult) {
+ return { success: true, message: "항목이 삭제되었습니다.", data: { id: data.id } };
+ } else {
+ return { success: false, message: "항목 삭제에 실패했습니다." };
+ }
+
+ case "view-detail":
+ if (!data?.id) {
+ return { success: false, message: "조회할 항목 ID가 없습니다." };
+ }
+ return { success: true, message: "상세 정보가 조회되었습니다.", data: { id: data.id } };
+
+ default:
+ return { success: false, message: `알 수 없는 액션입니다: ${action}` };
+ }
+ } catch (err) {
+ console.error("Error in handleAvlAction:", err);
+ return { success: false, message: "액션 처리 중 오류가 발생했습니다." };
+ }
+}
+
+// 클라이언트에서 호출할 수 있는 서버 액션 래퍼들
+export async function createAvlListAction(data: CreateAvlListInput): Promise<AvlListItem | null> {
+ return await createAvlList(data);
+}
+
+export async function updateAvlListAction(id: number, data: UpdateAvlListInput): Promise<AvlListItem | null> {
+ return await updateAvlList(id, data);
+}
+
+export async function deleteAvlListAction(id: number): Promise<boolean> {
+ return await deleteAvlList(id);
+}
+
+export async function handleAvlActionAction(action: string, data?: any): Promise<ActionResult> {
+ return await handleAvlAction(action, data);
+}
+
+/**
+ * AVL 리스트 생성
+ */
+export async function createAvlList(data: CreateAvlListInput): Promise<AvlListItem | null> {
+ try {
+ debugLog('AVL 리스트 생성 시작', { inputData: data });
+
+ const currentTimestamp = new Date();
+
+ // 데이터베이스에 삽입할 데이터 준비
+ const insertData = {
+ isTemplate: data.isTemplate ?? false,
+ constructionSector: data.constructionSector,
+ projectCode: data.projectCode,
+ shipType: data.shipType,
+ avlKind: data.avlKind,
+ htDivision: data.htDivision,
+ rev: data.rev ?? 1,
+ vendorInfoSnapshot: data.vendorInfoSnapshot, // 스냅샷 데이터 추가
+ createdBy: data.createdBy || 'system',
+ updatedBy: data.updatedBy || 'system',
+ };
+
+ debugLog('DB INSERT 시작', {
+ table: 'avl_list',
+ data: insertData,
+ hasVendorSnapshot: !!insertData.vendorInfoSnapshot,
+ snapshotLength: insertData.vendorInfoSnapshot?.length
+ });
+
+ // 데이터베이스에 삽입
+ const result = await db
+ .insert(avlList)
+ .values(insertData)
+ .returning();
+
+ if (result.length === 0) {
+ debugError('DB 삽입 실패: 결과가 없음', { insertData });
+ throw new Error("Failed to create AVL list");
+ }
+
+ debugSuccess('DB INSERT 완료', {
+ table: 'avl_list',
+ result: result[0],
+ savedSnapshotLength: result[0].vendorInfoSnapshot?.length
+ });
+
+ const createdItem = result[0];
+
+ // 생성된 데이터를 AvlListItem 타입으로 변환
+ const transformedData: AvlListItem = {
+ ...createdItem,
+ no: 1,
+ selected: false,
+ createdAt: createdItem.createdAt ? createdItem.createdAt.toISOString().split('T')[0] : '',
+ updatedAt: createdItem.updatedAt ? createdItem.updatedAt.toISOString().split('T')[0] : '',
+ projectInfo: createdItem.projectCode || '',
+ shipType: createdItem.shipType || '',
+ avlType: createdItem.avlKind || '',
+ htDivision: createdItem.htDivision || '',
+ rev: createdItem.rev || 1,
+ vendorInfoSnapshot: createdItem.vendorInfoSnapshot, // 스냅샷 데이터 포함
+ };
+
+ debugSuccess('AVL 리스트 생성 완료', { result: transformedData });
+
+ // 캐시 무효화
+ revalidateTag('avl-list');
+
+ debugSuccess('AVL 캐시 무효화 완료', { tags: ['avl-list'] });
+
+ return transformedData;
+ } catch (err) {
+ debugError('AVL 리스트 생성 실패', { error: err, inputData: data });
+ console.error("Error in createAvlList:", err);
+ return null;
+ }
+}
+
+/**
+ * AVL 리스트 업데이트
+ */
+export async function updateAvlList(id: number, data: UpdateAvlListInput): Promise<AvlListItem | null> {
+ try {
+ debugLog('AVL 리스트 업데이트 시작', { id, updateData: data });
+
+ const currentTimestamp = new Date();
+
+ // 업데이트할 데이터 준비
+ const updateData: any = {};
+
+ if (data.isTemplate !== undefined) updateData.isTemplate = data.isTemplate;
+ if (data.constructionSector !== undefined) updateData.constructionSector = data.constructionSector;
+ if (data.projectCode !== undefined) updateData.projectCode = data.projectCode;
+ if (data.shipType !== undefined) updateData.shipType = data.shipType;
+ if (data.avlKind !== undefined) updateData.avlKind = data.avlKind;
+ if (data.htDivision !== undefined) updateData.htDivision = data.htDivision;
+ if (data.rev !== undefined) updateData.rev = data.rev;
+ if (data.createdBy !== undefined) updateData.createdBy = data.createdBy;
+ if (data.updatedBy !== undefined) updateData.updatedBy = data.updatedBy;
+
+ updateData.updatedAt = currentTimestamp;
+
+ // 업데이트할 데이터가 없는 경우
+ if (Object.keys(updateData).length <= 1) {
+ return await getAvlListById(id);
+ }
+
+ // 데이터베이스 업데이트
+ const result = await db
+ .update(avlList)
+ .set(updateData)
+ .where(eq(avlList.id, id))
+ .returning();
+
+ if (result.length === 0) {
+ throw new Error("AVL list not found or update failed");
+ }
+
+ const updatedItem = result[0];
+
+ // 업데이트된 데이터를 AvlListItem 타입으로 변환
+ const transformedData: AvlListItem = {
+ ...updatedItem,
+ no: 1,
+ selected: false,
+ createdAt: updatedItem.createdAt ? updatedItem.createdAt.toISOString().split('T')[0] : '',
+ updatedAt: updatedItem.updatedAt ? updatedItem.updatedAt.toISOString().split('T')[0] : '',
+ projectInfo: updatedItem.projectCode || '',
+ shipType: updatedItem.shipType || '',
+ avlType: updatedItem.avlKind || '',
+ htDivision: updatedItem.htDivision || '',
+ rev: updatedItem.rev || 1,
+ };
+
+ debugSuccess('AVL 리스트 업데이트 완료', { id, result: transformedData });
+
+ // 캐시 무효화
+ revalidateTag('avl-list');
+
+ return transformedData;
+ } catch (err) {
+ debugError('AVL 리스트 업데이트 실패', { error: err, id, updateData: data });
+ console.error("Error in updateAvlList:", err);
+ return null;
+ }
+}
+
+/**
+ * AVL 리스트 삭제
+ */
+export async function deleteAvlList(id: number): Promise<boolean> {
+ try {
+ debugLog('AVL 리스트 삭제 시작', { id });
+
+ // 데이터베이스에서 삭제
+ const result = await db
+ .delete(avlList)
+ .where(eq(avlList.id, id));
+
+ // 삭제 확인을 위한 재조회
+ const checkDeleted = await db
+ .select({ id: avlList.id })
+ .from(avlList)
+ .where(eq(avlList.id, id))
+ .limit(1);
+
+ const isDeleted = checkDeleted.length === 0;
+
+ if (isDeleted) {
+ debugSuccess('AVL 리스트 삭제 완료', { id });
+ revalidateTag('avl-list');
+ } else {
+ debugWarn('AVL 리스트 삭제 실패: 항목이 존재함', { id });
+ }
+
+ return isDeleted;
+ } catch (err) {
+ debugError('AVL 리스트 삭제 실패', { error: err, id });
+ console.error("Error in deleteAvlList:", err);
+ return false;
+ }
+}
+
+/**
+ * AVL Vendor Info 생성
+ */
+export async function createAvlVendorInfo(data: AvlVendorInfoInput): Promise<AvlDetailItem | null> {
+ try {
+ debugLog('AVL Vendor Info 생성 시작', { inputData: data });
+
+ // UI 필드를 DB 필드로 변환
+ const insertData: NewAvlVendorInfo = {
+ isTemplate: data.isTemplate ?? false, // AVL 타입 구분
+ constructionSector: data.constructionSector || null, // 표준 AVL용
+ shipType: data.shipType || null, // 표준 AVL용
+ avlKind: data.avlKind || null, // 표준 AVL용
+ htDivision: data.htDivision || null, // 표준 AVL용
+ projectCode: data.projectCode || null, // 프로젝트 코드 저장
+ avlListId: data.avlListId || null, // nullable - 나중에 프로젝트별로 묶어줄 때 설정
+ ownerSuggestion: data.ownerSuggestion ?? false,
+ shiSuggestion: data.shiSuggestion ?? false,
+ equipBulkDivision: data.equipBulkDivision === "EQUIP" ? "E" : "B",
+ disciplineCode: data.disciplineCode || null,
+ disciplineName: data.disciplineName,
+ materialNameCustomerSide: data.materialNameCustomerSide,
+ packageCode: data.packageCode || null,
+ packageName: data.packageName || null,
+ materialGroupCode: data.materialGroupCode || null,
+ materialGroupName: data.materialGroupName || null,
+ vendorId: data.vendorId || null,
+ vendorName: data.vendorName || null,
+ vendorCode: data.vendorCode || null,
+ avlVendorName: data.avlVendorName || null,
+ tier: data.tier || null,
+ faTarget: data.faTarget ?? false,
+ faStatus: data.faStatus || null,
+ isAgent: data.isAgent ?? false,
+ contractSignerId: data.contractSignerId || null,
+ contractSignerName: data.contractSignerName || null,
+ contractSignerCode: data.contractSignerCode || null,
+ headquarterLocation: data.headquarterLocation || null,
+ manufacturingLocation: data.manufacturingLocation || null,
+ hasAvl: data.shiAvl ?? false,
+ isBlacklist: data.shiBlacklist ?? false,
+ isBcc: data.shiBcc ?? false,
+ techQuoteNumber: data.salesQuoteNumber || null,
+ quoteCode: data.quoteCode || null,
+ quoteVendorId: data.quoteVendorId || null,
+ quoteVendorName: data.salesVendorInfo || null,
+ quoteVendorCode: data.quoteVendorCode || null,
+ quoteCountry: data.salesCountry || null,
+ quoteTotalAmount: data.totalAmount ? data.totalAmount.replace(/,/g, '') as any : null,
+ quoteReceivedDate: data.quoteReceivedDate || null,
+ recentQuoteDate: data.recentQuoteDate || null,
+ recentQuoteNumber: data.recentQuoteNumber || null,
+ recentOrderDate: data.recentOrderDate || null,
+ recentOrderNumber: data.recentOrderNumber || null,
+ remark: data.remarks || null,
+ };
+
+ debugLog('DB INSERT 시작', { table: 'avl_vendor_info', data: insertData });
+
+ // 데이터베이스에 삽입
+ const result = await db
+ .insert(avlVendorInfo)
+ .values(insertData as any)
+ .returning();
+
+ if (result.length === 0) {
+ debugError('DB 삽입 실패: 결과가 없음', { insertData });
+ throw new Error("Failed to create AVL vendor info");
+ }
+
+ debugSuccess('DB INSERT 완료', { table: 'avl_vendor_info', result: result[0] });
+
+ const createdItem = result[0];
+
+ // 생성된 데이터를 AvlDetailItem 타입으로 변환
+ const transformedData: AvlDetailItem = {
+ ...(createdItem as any),
+ no: 1,
+ selected: false,
+ createdAt: createdItem.createdAt ? createdItem.createdAt.toISOString().split('T')[0] : '',
+ updatedAt: createdItem.updatedAt ? createdItem.updatedAt.toISOString().split('T')[0] : '',
+ equipBulkDivision: createdItem.equipBulkDivision === "E" ? "EQUIP" : "BULK",
+ faTarget: createdItem.faTarget ?? false,
+ agentStatus: createdItem.isAgent ? "예" : "아니오",
+ shiAvl: createdItem.hasAvl ?? false,
+ shiBlacklist: createdItem.isBlacklist ?? false,
+ shiBcc: createdItem.isBcc ?? false,
+ salesQuoteNumber: createdItem.techQuoteNumber || '',
+ quoteCode: createdItem.quoteCode || '',
+ salesVendorInfo: createdItem.quoteVendorName || '',
+ salesCountry: createdItem.quoteCountry || '',
+ totalAmount: createdItem.quoteTotalAmount ? createdItem.quoteTotalAmount.toString() : '',
+ quoteReceivedDate: createdItem.quoteReceivedDate || '',
+ recentQuoteDate: createdItem.recentQuoteDate || '',
+ recentQuoteNumber: createdItem.recentQuoteNumber || '',
+ recentOrderDate: createdItem.recentOrderDate || '',
+ recentOrderNumber: createdItem.recentOrderNumber || '',
+ remarks: createdItem.remark || '',
+ };
+
+ debugSuccess('AVL Vendor Info 생성 완료', { result: transformedData });
+
+ // 캐시 무효화
+ revalidateTag('avl-detail');
+
+ return transformedData;
+ } catch (err) {
+ debugError('AVL Vendor Info 생성 실패', { error: err, inputData: data });
+ console.error("Error in createAvlVendorInfo:", err);
+ return null;
+ }
+}
+
+/**
+ * AVL Vendor Info 업데이트
+ */
+export async function updateAvlVendorInfo(id: number, data: Partial<AvlVendorInfoInput>): Promise<AvlDetailItem | null> {
+ try {
+ debugLog('AVL Vendor Info 업데이트 시작', { id, data });
+
+ // 간단한 필드 매핑
+ const updateData: any = { updatedAt: new Date() };
+
+ // ownerSuggestion과 shiSuggestion 추가
+ if (data.ownerSuggestion !== undefined) updateData.ownerSuggestion = data.ownerSuggestion;
+ if (data.shiSuggestion !== undefined) updateData.shiSuggestion = data.shiSuggestion;
+
+ if (data.equipBulkDivision !== undefined) updateData.equipBulkDivision = data.equipBulkDivision === "EQUIP" ? "E" : "B";
+ if (data.disciplineCode !== undefined) updateData.disciplineCode = data.disciplineCode;
+ if (data.disciplineName !== undefined) updateData.disciplineName = data.disciplineName;
+ if (data.materialNameCustomerSide !== undefined) updateData.materialNameCustomerSide = data.materialNameCustomerSide;
+ if (data.packageCode !== undefined) updateData.packageCode = data.packageCode;
+ if (data.packageName !== undefined) updateData.packageName = data.packageName;
+ if (data.materialGroupCode !== undefined) updateData.materialGroupCode = data.materialGroupCode;
+ if (data.materialGroupName !== undefined) updateData.materialGroupName = data.materialGroupName;
+ if (data.vendorId !== undefined) updateData.vendorId = data.vendorId;
+ if (data.vendorName !== undefined) updateData.vendorName = data.vendorName;
+ if (data.vendorCode !== undefined) updateData.vendorCode = data.vendorCode;
+ if (data.avlVendorName !== undefined) updateData.avlVendorName = data.avlVendorName;
+ if (data.tier !== undefined) updateData.tier = data.tier;
+ if (data.faTarget !== undefined) updateData.faTarget = data.faTarget;
+ if (data.faStatus !== undefined) updateData.faStatus = data.faStatus;
+ if (data.isAgent !== undefined) updateData.isAgent = data.isAgent;
+ if (data.contractSignerId !== undefined) updateData.contractSignerId = data.contractSignerId;
+ if (data.contractSignerName !== undefined) updateData.contractSignerName = data.contractSignerName;
+ if (data.contractSignerCode !== undefined) updateData.contractSignerCode = data.contractSignerCode;
+ if (data.headquarterLocation !== undefined) updateData.headquarterLocation = data.headquarterLocation;
+ if (data.manufacturingLocation !== undefined) updateData.manufacturingLocation = data.manufacturingLocation;
+ if (data.shiAvl !== undefined) updateData.hasAvl = data.shiAvl;
+ if (data.shiBlacklist !== undefined) updateData.isBlacklist = data.shiBlacklist;
+ if (data.shiBcc !== undefined) updateData.isBcc = data.shiBcc;
+ if (data.salesQuoteNumber !== undefined) updateData.techQuoteNumber = data.salesQuoteNumber;
+ if (data.quoteCode !== undefined) updateData.quoteCode = data.quoteCode;
+ if (data.quoteVendorId !== undefined) updateData.quoteVendorId = data.quoteVendorId;
+ if (data.quoteVendorCode !== undefined) updateData.quoteVendorCode = data.quoteVendorCode;
+ if (data.salesCountry !== undefined) updateData.quoteCountry = data.salesCountry;
+ if (data.quoteReceivedDate !== undefined) updateData.quoteReceivedDate = data.quoteReceivedDate;
+ if (data.recentQuoteDate !== undefined) updateData.recentQuoteDate = data.recentQuoteDate;
+ if (data.recentQuoteNumber !== undefined) updateData.recentQuoteNumber = data.recentQuoteNumber;
+ if (data.recentOrderDate !== undefined) updateData.recentOrderDate = data.recentOrderDate;
+ if (data.recentOrderNumber !== undefined) updateData.recentOrderNumber = data.recentOrderNumber;
+ if (data.remarks !== undefined) updateData.remark = data.remarks;
+
+ // 숫자 변환
+ if (data.totalAmount !== undefined) {
+ updateData.quoteTotalAmount = data.totalAmount ? parseFloat(data.totalAmount.replace(/,/g, '')) || null : null;
+ }
+
+ // 문자열 필드
+ if (data.salesVendorInfo !== undefined) updateData.quoteVendorName = data.salesVendorInfo;
+
+ debugLog('업데이트할 데이터', { updateData });
+
+ // 데이터베이스 업데이트
+ const result = await db
+ .update(avlVendorInfo)
+ .set(updateData)
+ .where(eq(avlVendorInfo.id, id))
+ .returning();
+
+ if (result.length === 0) {
+ throw new Error("AVL vendor info not found");
+ }
+
+ debugSuccess('AVL Vendor Info 업데이트 성공', { id });
+
+ revalidateTag('avl-detail');
+
+ // 업데이트된 데이터 조회해서 반환
+ return await getAvlVendorInfoById(id);
+ } catch (err) {
+ debugError('AVL Vendor Info 업데이트 실패', { id, error: err });
+ console.error("Error in updateAvlVendorInfo:", err);
+ return null;
+ }
+}
+
+/**
+ * AVL Vendor Info 삭제
+ */
+export async function deleteAvlVendorInfo(id: number): Promise<boolean> {
+ try {
+ debugLog('AVL Vendor Info 삭제 시작', { id });
+
+ // 데이터베이스에서 삭제
+ const result = await db
+ .delete(avlVendorInfo)
+ .where(eq(avlVendorInfo.id, id));
+
+ // 삭제 확인을 위한 재조회
+ const checkDeleted = await db
+ .select({ id: avlVendorInfo.id })
+ .from(avlVendorInfo)
+ .where(eq(avlVendorInfo.id, id))
+ .limit(1);
+
+ const isDeleted = checkDeleted.length === 0;
+
+ if (isDeleted) {
+ debugSuccess('AVL Vendor Info 삭제 완료', { id });
+ revalidateTag('avl-detail');
+ } else {
+ debugWarn('AVL Vendor Info 삭제 실패: 항목이 존재함', { id });
+ }
+
+ return isDeleted;
+ } catch (err) {
+ debugError('AVL Vendor Info 삭제 실패', { error: err, id });
+ console.error("Error in deleteAvlVendorInfo:", err);
+ return false;
+ }
+}
+
+/**
+ * 프로젝트 AVL 최종 확정
+ * 1. 주어진 프로젝트 정보로 avlList에 레코드를 생성한다.
+ * 2. 현재 avlVendorInfo 레코드들의 avlListId를 새로 생성된 AVL 리스트 ID로 업데이트한다.
+ */
+export async function finalizeProjectAvl(
+ projectCode: string,
+ projectInfo: {
+ projectName: string;
+ constructionSector: string;
+ shipType: string;
+ htDivision: string;
+ },
+ avlVendorInfoIds: number[],
+ currentUser?: string
+): Promise<{ success: boolean; avlListId?: number; message: string }> {
+ try {
+ debugLog('프로젝트 AVL 최종 확정 시작', {
+ projectCode,
+ projectInfo,
+ avlVendorInfoIds: avlVendorInfoIds.length,
+ currentUser
+ });
+
+ // 1. 기존 AVL 리스트의 최고 revision 확인
+ const existingAvlLists = await db
+ .select({ rev: avlList.rev })
+ .from(avlList)
+ .where(and(
+ eq(avlList.projectCode, projectCode),
+ eq(avlList.isTemplate, false)
+ ))
+ .orderBy(desc(avlList.rev))
+ .limit(1);
+
+ const nextRevision = existingAvlLists.length > 0 ? (existingAvlLists[0].rev || 0) + 1 : 1;
+
+ debugLog('AVL 리스트 revision 계산', {
+ projectCode,
+ existingRevision: existingAvlLists.length > 0 ? existingAvlLists[0].rev : 0,
+ nextRevision
+ });
+
+ // 2. AVL 리스트 생성을 위한 데이터 준비
+ const createAvlListData: CreateAvlListInput = {
+ isTemplate: false, // 프로젝트 AVL이므로 false
+ constructionSector: projectInfo.constructionSector,
+ projectCode: projectCode,
+ shipType: projectInfo.shipType,
+ avlKind: "프로젝트 AVL", // 기본값으로 설정
+ htDivision: projectInfo.htDivision,
+ rev: nextRevision, // 계산된 다음 리비전
+ createdBy: currentUser || 'system',
+ updatedBy: currentUser || 'system',
+ };
+
+ debugLog('AVL 리스트 생성 데이터', { createAvlListData });
+
+ // 2. AVL Vendor Info 스냅샷 생성 (AVL 리스트 생성 전에 현재 상태 저장)
+ debugLog('AVL Vendor Info 스냅샷 생성 시작', {
+ vendorInfoIdsCount: avlVendorInfoIds.length,
+ vendorInfoIds: avlVendorInfoIds
+ });
+ const vendorInfoSnapshot = await createVendorInfoSnapshot(avlVendorInfoIds);
+ debugSuccess('AVL Vendor Info 스냅샷 생성 완료', {
+ snapshotCount: vendorInfoSnapshot.length,
+ snapshotSize: JSON.stringify(vendorInfoSnapshot).length,
+ sampleSnapshot: vendorInfoSnapshot.slice(0, 1) // 첫 번째 항목만 로깅
+ });
+
+ // 스냅샷을 AVL 리스트 생성 데이터에 추가
+ createAvlListData.vendorInfoSnapshot = vendorInfoSnapshot;
+ debugLog('스냅샷이 createAvlListData에 추가됨', {
+ hasSnapshot: !!createAvlListData.vendorInfoSnapshot,
+ snapshotLength: createAvlListData.vendorInfoSnapshot?.length
+ });
+
+ // 3. AVL 리스트 생성
+ const newAvlList = await createAvlList(createAvlListData);
+
+ if (!newAvlList) {
+ throw new Error("AVL 리스트 생성에 실패했습니다.");
+ }
+
+ debugSuccess('AVL 리스트 생성 완료', { avlListId: newAvlList.id });
+
+ // 3. avlVendorInfo 레코드들의 avlListId 업데이트
+ if (avlVendorInfoIds.length > 0) {
+ debugLog('AVL Vendor Info 업데이트 시작', {
+ count: avlVendorInfoIds.length,
+ newAvlListId: newAvlList.id
+ });
+
+ const updateResults = await Promise.all(
+ avlVendorInfoIds.map(async (vendorInfoId) => {
+ try {
+ const result = await db
+ .update(avlVendorInfo)
+ .set({
+ avlListId: newAvlList.id,
+ projectCode: projectCode,
+ updatedAt: new Date()
+ })
+ .where(eq(avlVendorInfo.id, vendorInfoId))
+ .returning({ id: avlVendorInfo.id });
+
+ return { id: vendorInfoId, success: true, result };
+ } catch (error) {
+ debugError('AVL Vendor Info 업데이트 실패', { vendorInfoId, error });
+ return { id: vendorInfoId, success: false, error };
+ }
+ })
+ );
+
+ // 업데이트 결과 검증
+ const successCount = updateResults.filter(r => r.success).length;
+ const failCount = updateResults.filter(r => !r.success).length;
+
+ debugLog('AVL Vendor Info 업데이트 결과', {
+ total: avlVendorInfoIds.length,
+ success: successCount,
+ failed: failCount
+ });
+
+ if (failCount > 0) {
+ debugWarn('일부 AVL Vendor Info 업데이트 실패', {
+ failedIds: updateResults.filter(r => !r.success).map(r => r.id)
+ });
+ }
+ }
+
+ // 4. 캐시 무효화
+ revalidateTag('avl-list');
+ revalidateTag('avl-vendor-info');
+
+ debugSuccess('프로젝트 AVL 최종 확정 완료', {
+ avlListId: newAvlList.id,
+ projectCode,
+ vendorInfoCount: avlVendorInfoIds.length
+ });
+
+ return {
+ success: true,
+ avlListId: newAvlList.id,
+ message: `프로젝트 AVL이 성공적으로 확정되었습니다. (AVL ID: ${newAvlList.id}, 벤더 정보: ${avlVendorInfoIds.length}개)`
+ };
+
+ } catch (err) {
+ debugError('프로젝트 AVL 최종 확정 실패', {
+ projectCode,
+ error: err
+ });
+
+ console.error("Error in finalizeProjectAvl:", err);
+
+ return {
+ success: false,
+ message: err instanceof Error ? err.message : "프로젝트 AVL 최종 확정 중 오류가 발생했습니다."
+ };
+ }
+}
+
+/**
+ * 프로젝트 AVL Vendor Info 조회 (프로젝트별, isTemplate=false)
+ * avl_list와 avlVendorInfo를 JOIN하여 프로젝트별 AVL 데이터를 조회합니다.
+ */
+export const getProjectAvlVendorInfo = async (input: GetProjectAvlSchema) => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ debugLog('프로젝트 AVL Vendor Info 조회 시작', { input, offset });
+
+ // 기본 JOIN 쿼리 구성 (프로젝트 AVL이므로 isTemplate=false)
+ // 실제 쿼리는 아래에서 구성됨
+
+ // 검색 조건 구성
+ const whereConditions: any[] = []; // 기본 조건 제거
+
+ // 필수 필터: 프로젝트 코드 (avlVendorInfo에서 직접 필터링)
+ if (input.projectCode) {
+ whereConditions.push(ilike(avlVendorInfo.projectCode, `%${input.projectCode}%`));
+ }
+
+ // 검색어 기반 필터링
+ if (input.search) {
+ const searchTerm = `%${input.search}%`;
+ whereConditions.push(
+ or(
+ ilike(avlVendorInfo.disciplineName, searchTerm),
+ ilike(avlVendorInfo.materialNameCustomerSide, searchTerm),
+ ilike(avlVendorInfo.vendorName, searchTerm),
+ ilike(avlVendorInfo.avlVendorName, searchTerm),
+ ilike(avlVendorInfo.packageName, searchTerm),
+ ilike(avlVendorInfo.materialGroupName, searchTerm)
+ )
+ );
+ }
+
+ // 추가 필터 조건들
+ if (input.equipBulkDivision) {
+ whereConditions.push(eq(avlVendorInfo.equipBulkDivision, input.equipBulkDivision === "EQUIP" ? "E" : "B"));
+ }
+ if (input.disciplineCode) {
+ whereConditions.push(ilike(avlVendorInfo.disciplineCode, `%${input.disciplineCode}%`));
+ }
+ if (input.disciplineName) {
+ whereConditions.push(ilike(avlVendorInfo.disciplineName, `%${input.disciplineName}%`));
+ }
+ if (input.materialNameCustomerSide) {
+ whereConditions.push(ilike(avlVendorInfo.materialNameCustomerSide, `%${input.materialNameCustomerSide}%`));
+ }
+ if (input.packageCode) {
+ whereConditions.push(ilike(avlVendorInfo.packageCode, `%${input.packageCode}%`));
+ }
+ if (input.packageName) {
+ whereConditions.push(ilike(avlVendorInfo.packageName, `%${input.packageName}%`));
+ }
+ if (input.materialGroupCode) {
+ whereConditions.push(ilike(avlVendorInfo.materialGroupCode, `%${input.materialGroupCode}%`));
+ }
+ if (input.materialGroupName) {
+ whereConditions.push(ilike(avlVendorInfo.materialGroupName, `%${input.materialGroupName}%`));
+ }
+ if (input.vendorName) {
+ whereConditions.push(ilike(avlVendorInfo.vendorName, `%${input.vendorName}%`));
+ }
+ if (input.vendorCode) {
+ whereConditions.push(ilike(avlVendorInfo.vendorCode, `%${input.vendorCode}%`));
+ }
+ if (input.avlVendorName) {
+ whereConditions.push(ilike(avlVendorInfo.avlVendorName, `%${input.avlVendorName}%`));
+ }
+ if (input.tier) {
+ whereConditions.push(ilike(avlVendorInfo.tier, `%${input.tier}%`));
+ }
+
+ // 정렬 조건 구성
+ const orderByConditions: any[] = [];
+ input.sort.forEach((sortItem) => {
+ const column = sortItem.id as keyof typeof avlVendorInfo;
+ if (column && avlVendorInfo[column]) {
+ if (sortItem.desc) {
+ orderByConditions.push(sql`${avlVendorInfo[column]} desc`);
+ } else {
+ orderByConditions.push(sql`${avlVendorInfo[column]} asc`);
+ }
+ }
+ });
+
+ // 기본 정렬
+ if (orderByConditions.length === 0) {
+ orderByConditions.push(asc(avlVendorInfo.id));
+ }
+
+ // 총 개수 조회
+ const totalCount = await db
+ .select({ count: count() })
+ .from(avlVendorInfo)
+ .leftJoin(avlList, eq(avlVendorInfo.avlListId, avlList.id))
+ .where(and(...whereConditions));
+
+ // 데이터 조회 - JOIN 결과에서 필요한 필드들을 명시적으로 선택
+ const data = await db
+ .select({
+ // avlVendorInfo의 모든 필드
+ id: avlVendorInfo.id,
+ projectCode: avlVendorInfo.projectCode,
+ avlListId: avlVendorInfo.avlListId,
+ ownerSuggestion: avlVendorInfo.ownerSuggestion,
+ shiSuggestion: avlVendorInfo.shiSuggestion,
+ equipBulkDivision: avlVendorInfo.equipBulkDivision,
+ disciplineCode: avlVendorInfo.disciplineCode,
+ disciplineName: avlVendorInfo.disciplineName,
+ materialNameCustomerSide: avlVendorInfo.materialNameCustomerSide,
+ packageCode: avlVendorInfo.packageCode,
+ packageName: avlVendorInfo.packageName,
+ materialGroupCode: avlVendorInfo.materialGroupCode,
+ materialGroupName: avlVendorInfo.materialGroupName,
+ vendorId: avlVendorInfo.vendorId,
+ vendorName: avlVendorInfo.vendorName,
+ vendorCode: avlVendorInfo.vendorCode,
+ avlVendorName: avlVendorInfo.avlVendorName,
+ tier: avlVendorInfo.tier,
+ faTarget: avlVendorInfo.faTarget,
+ faStatus: avlVendorInfo.faStatus,
+ isAgent: avlVendorInfo.isAgent,
+ contractSignerId: avlVendorInfo.contractSignerId,
+ contractSignerName: avlVendorInfo.contractSignerName,
+ contractSignerCode: avlVendorInfo.contractSignerCode,
+ headquarterLocation: avlVendorInfo.headquarterLocation,
+ manufacturingLocation: avlVendorInfo.manufacturingLocation,
+ hasAvl: avlVendorInfo.hasAvl,
+ isBlacklist: avlVendorInfo.isBlacklist,
+ isBcc: avlVendorInfo.isBcc,
+ techQuoteNumber: avlVendorInfo.techQuoteNumber,
+ quoteCode: avlVendorInfo.quoteCode,
+ quoteVendorId: avlVendorInfo.quoteVendorId,
+ quoteVendorName: avlVendorInfo.quoteVendorName,
+ quoteVendorCode: avlVendorInfo.quoteVendorCode,
+ quoteCountry: avlVendorInfo.quoteCountry,
+ quoteTotalAmount: avlVendorInfo.quoteTotalAmount,
+ quoteReceivedDate: avlVendorInfo.quoteReceivedDate,
+ recentQuoteDate: avlVendorInfo.recentQuoteDate,
+ recentQuoteNumber: avlVendorInfo.recentQuoteNumber,
+ recentOrderDate: avlVendorInfo.recentOrderDate,
+ recentOrderNumber: avlVendorInfo.recentOrderNumber,
+ remark: avlVendorInfo.remark,
+ createdAt: avlVendorInfo.createdAt,
+ updatedAt: avlVendorInfo.updatedAt,
+ })
+ .from(avlVendorInfo)
+ .leftJoin(avlList, eq(avlVendorInfo.avlListId, avlList.id))
+ .where(and(...whereConditions))
+ .orderBy(...orderByConditions)
+ .limit(input.perPage)
+ .offset(offset);
+
+ // 데이터 변환
+ const transformedData: AvlDetailItem[] = data.map((item: any, index) => ({
+ ...item,
+ no: offset + index + 1,
+ selected: false,
+ createdAt: (item.createdAt as Date)?.toISOString().split('T')[0] || '',
+ updatedAt: (item.updatedAt as Date)?.toISOString().split('T')[0] || '',
+ // UI 표시용 필드 변환
+ equipBulkDivision: item.equipBulkDivision === "E" ? "EQUIP" : "BULK",
+ faTarget: item.faTarget ?? false,
+ faStatus: item.faStatus || '',
+ agentStatus: item.isAgent ? "예" : "아니오",
+ shiAvl: item.hasAvl ?? false,
+ shiBlacklist: item.isBlacklist ?? false,
+ shiBcc: item.isBcc ?? false,
+ salesQuoteNumber: item.techQuoteNumber || '',
+ quoteCode: item.quoteCode || '',
+ salesVendorInfo: item.quoteVendorName || '',
+ salesCountry: item.quoteCountry || '',
+ totalAmount: item.quoteTotalAmount ? item.quoteTotalAmount.toString() : '',
+ quoteReceivedDate: item.quoteReceivedDate || '',
+ recentQuoteDate: item.recentQuoteDate || '',
+ recentQuoteNumber: item.recentQuoteNumber || '',
+ recentOrderDate: item.recentOrderDate || '',
+ recentOrderNumber: item.recentOrderNumber || '',
+ remarks: item.remark || '',
+ }));
+
+ const pageCount = Math.ceil(totalCount[0].count / input.perPage);
+
+ debugSuccess('프로젝트 AVL Vendor Info 조회 완료', { recordCount: transformedData.length, pageCount });
+
+ return {
+ data: transformedData,
+ pageCount
+ };
+ } catch (err) {
+ debugError('프로젝트 AVL Vendor Info 조회 실패', { error: err, input });
+ console.error("Error in getProjectAvlVendorInfo:", err);
+ return { data: [], pageCount: 0 };
+ }
+};
+
+
+/**
+ * 표준 AVL Vendor Info 조회 (선종별 표준 AVL, isTemplate=true)
+ * avl_list와 avlVendorInfo를 JOIN하여 표준 AVL 데이터를 조회합니다.
+ */
+export const getStandardAvlVendorInfo = async (input: GetStandardAvlSchema) => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ debugLog('표준 AVL Vendor Info 조회 시작', { input, offset });
+
+ // 기본 JOIN 쿼리 구성 (표준 AVL이므로 isTemplate=true)
+ // 실제 쿼리는 아래에서 구성됨
+
+ // 검색 조건 구성
+ const whereConditions: any[] = [eq(avlVendorInfo.isTemplate, true)]; // 기본 조건: 표준 AVL
+
+ // 필수 필터: 표준 AVL용 (공사부문, 선종, AVL종류, H/T) - avlVendorInfo에서 직접 필터링
+ if (input.constructionSector) {
+ whereConditions.push(ilike(avlVendorInfo.constructionSector, `%${input.constructionSector}%`));
+ }
+ if (input.shipType) {
+ whereConditions.push(ilike(avlVendorInfo.shipType, `%${input.shipType}%`));
+ }
+ if (input.avlKind) {
+ whereConditions.push(ilike(avlVendorInfo.avlKind, `%${input.avlKind}%`));
+ }
+ if (input.htDivision) {
+ whereConditions.push(eq(avlVendorInfo.htDivision, input.htDivision));
+ }
+
+ // 검색어 기반 필터링
+ if (input.search) {
+ const searchTerm = `%${input.search}%`;
+ whereConditions.push(
+ or(
+ ilike(avlVendorInfo.disciplineName, searchTerm),
+ ilike(avlVendorInfo.materialNameCustomerSide, searchTerm),
+ ilike(avlVendorInfo.vendorName, searchTerm),
+ ilike(avlVendorInfo.avlVendorName, searchTerm),
+ ilike(avlVendorInfo.packageName, searchTerm),
+ ilike(avlVendorInfo.materialGroupName, searchTerm)
+ )
+ );
+ }
+
+ // 추가 필터 조건들
+ if (input.equipBulkDivision) {
+ whereConditions.push(eq(avlVendorInfo.equipBulkDivision, input.equipBulkDivision === "EQUIP" ? "E" : "B"));
+ }
+ if (input.disciplineCode) {
+ whereConditions.push(ilike(avlVendorInfo.disciplineCode, `%${input.disciplineCode}%`));
+ }
+ if (input.disciplineName) {
+ whereConditions.push(ilike(avlVendorInfo.disciplineName, `%${input.disciplineName}%`));
+ }
+ if (input.materialNameCustomerSide) {
+ whereConditions.push(ilike(avlVendorInfo.materialNameCustomerSide, `%${input.materialNameCustomerSide}%`));
+ }
+ if (input.packageCode) {
+ whereConditions.push(ilike(avlVendorInfo.packageCode, `%${input.packageCode}%`));
+ }
+ if (input.packageName) {
+ whereConditions.push(ilike(avlVendorInfo.packageName, `%${input.packageName}%`));
+ }
+ if (input.materialGroupCode) {
+ whereConditions.push(ilike(avlVendorInfo.materialGroupCode, `%${input.materialGroupCode}%`));
+ }
+ if (input.materialGroupName) {
+ whereConditions.push(ilike(avlVendorInfo.materialGroupName, `%${input.materialGroupName}%`));
+ }
+ if (input.vendorName) {
+ whereConditions.push(ilike(avlVendorInfo.vendorName, `%${input.vendorName}%`));
+ }
+ if (input.vendorCode) {
+ whereConditions.push(ilike(avlVendorInfo.vendorCode, `%${input.vendorCode}%`));
+ }
+ if (input.avlVendorName) {
+ whereConditions.push(ilike(avlVendorInfo.avlVendorName, `%${input.avlVendorName}%`));
+ }
+ if (input.tier) {
+ whereConditions.push(ilike(avlVendorInfo.tier, `%${input.tier}%`));
+ }
+
+ // 정렬 조건 구성
+ const orderByConditions: any[] = [];
+ input.sort.forEach((sortItem) => {
+ const column = sortItem.id as keyof typeof avlVendorInfo;
+ if (column && avlVendorInfo[column]) {
+ if (sortItem.desc) {
+ orderByConditions.push(sql`${avlVendorInfo[column]} desc`);
+ } else {
+ orderByConditions.push(sql`${avlVendorInfo[column]} asc`);
+ }
+ }
+ });
+
+ // 기본 정렬
+ if (orderByConditions.length === 0) {
+ orderByConditions.push(asc(avlVendorInfo.id));
+ }
+
+ // 총 개수 조회
+ const totalCount = await db
+ .select({ count: count() })
+ .from(avlVendorInfo)
+ .where(and(...whereConditions));
+
+ // 데이터 조회 - avlVendorInfo에서 직접 조회
+ const data = await db
+ .select({
+ // avlVendorInfo의 모든 필드
+ id: avlVendorInfo.id,
+ isTemplate: avlVendorInfo.isTemplate,
+ projectCode: avlVendorInfo.projectCode,
+ avlListId: avlVendorInfo.avlListId,
+ ownerSuggestion: avlVendorInfo.ownerSuggestion,
+ shiSuggestion: avlVendorInfo.shiSuggestion,
+ equipBulkDivision: avlVendorInfo.equipBulkDivision,
+ disciplineCode: avlVendorInfo.disciplineCode,
+ disciplineName: avlVendorInfo.disciplineName,
+ materialNameCustomerSide: avlVendorInfo.materialNameCustomerSide,
+ packageCode: avlVendorInfo.packageCode,
+ packageName: avlVendorInfo.packageName,
+ materialGroupCode: avlVendorInfo.materialGroupCode,
+ materialGroupName: avlVendorInfo.materialGroupName,
+ vendorId: avlVendorInfo.vendorId,
+ vendorName: avlVendorInfo.vendorName,
+ vendorCode: avlVendorInfo.vendorCode,
+ avlVendorName: avlVendorInfo.avlVendorName,
+ tier: avlVendorInfo.tier,
+ faTarget: avlVendorInfo.faTarget,
+ faStatus: avlVendorInfo.faStatus,
+ isAgent: avlVendorInfo.isAgent,
+ contractSignerId: avlVendorInfo.contractSignerId,
+ contractSignerName: avlVendorInfo.contractSignerName,
+ contractSignerCode: avlVendorInfo.contractSignerCode,
+ headquarterLocation: avlVendorInfo.headquarterLocation,
+ manufacturingLocation: avlVendorInfo.manufacturingLocation,
+ hasAvl: avlVendorInfo.hasAvl,
+ isBlacklist: avlVendorInfo.isBlacklist,
+ isBcc: avlVendorInfo.isBcc,
+ techQuoteNumber: avlVendorInfo.techQuoteNumber,
+ quoteCode: avlVendorInfo.quoteCode,
+ quoteVendorId: avlVendorInfo.quoteVendorId,
+ quoteVendorName: avlVendorInfo.quoteVendorName,
+ quoteVendorCode: avlVendorInfo.quoteVendorCode,
+ quoteCountry: avlVendorInfo.quoteCountry,
+ quoteTotalAmount: avlVendorInfo.quoteTotalAmount,
+ quoteReceivedDate: avlVendorInfo.quoteReceivedDate,
+ recentQuoteDate: avlVendorInfo.recentQuoteDate,
+ recentQuoteNumber: avlVendorInfo.recentQuoteNumber,
+ recentOrderDate: avlVendorInfo.recentOrderDate,
+ recentOrderNumber: avlVendorInfo.recentOrderNumber,
+ remark: avlVendorInfo.remark,
+ createdAt: avlVendorInfo.createdAt,
+ updatedAt: avlVendorInfo.updatedAt,
+ })
+ .from(avlVendorInfo)
+ .where(and(...whereConditions))
+ .orderBy(...orderByConditions)
+ .limit(input.perPage)
+ .offset(offset);
+
+ // 데이터 변환
+ const transformedData: AvlDetailItem[] = data.map((item: any, index) => ({
+ ...item,
+ no: offset + index + 1,
+ selected: false,
+ createdAt: (item.createdAt as Date)?.toISOString().split('T')[0] || '',
+ updatedAt: (item.updatedAt as Date)?.toISOString().split('T')[0] || '',
+ // UI 표시용 필드 변환
+ equipBulkDivision: item.equipBulkDivision === "E" ? "EQUIP" : "BULK",
+ faTarget: item.faTarget ?? false,
+ faStatus: item.faStatus || '',
+ agentStatus: item.isAgent ? "예" : "아니오",
+ shiAvl: item.hasAvl ?? false,
+ shiBlacklist: item.isBlacklist ?? false,
+ shiBcc: item.isBcc ?? false,
+ salesQuoteNumber: item.techQuoteNumber || '',
+ quoteCode: item.quoteCode || '',
+ salesVendorInfo: item.quoteVendorName || '',
+ salesCountry: item.quoteCountry || '',
+ totalAmount: item.quoteTotalAmount ? item.quoteTotalAmount.toString() : '',
+ quoteReceivedDate: item.quoteReceivedDate || '',
+ recentQuoteDate: item.recentQuoteDate || '',
+ recentQuoteNumber: item.recentQuoteNumber || '',
+ recentOrderDate: item.recentOrderDate || '',
+ recentOrderNumber: item.recentOrderNumber || '',
+ remarks: item.remark || '',
+ }));
+
+ const pageCount = Math.ceil(totalCount[0].count / input.perPage);
+
+ debugSuccess('표준 AVL Vendor Info 조회 완료', { recordCount: transformedData.length, pageCount });
+
+ return {
+ data: transformedData,
+ pageCount
+ };
+ } catch (err) {
+ debugError('표준 AVL Vendor Info 조회 실패', { error: err, input });
+ console.error("Error in getStandardAvlVendorInfo:", err);
+ return { data: [], pageCount: 0 };
+ }
+};
+
+/**
+ * 선종별표준AVL → 프로젝트AVL로 복사
+ */
+export const copyToProjectAvl = async (
+ selectedIds: number[],
+ targetProjectCode: string,
+ targetAvlListId: number,
+ userName: string
+): Promise<ActionResult> => {
+ try {
+ debugLog('선종별표준AVL → 프로젝트AVL 복사 시작', { selectedIds, targetProjectCode, targetAvlListId });
+
+ if (!selectedIds.length) {
+ return { success: false, message: "복사할 항목을 선택해주세요." };
+ }
+
+ // 선택된 레코드들 조회 (선종별표준AVL에서)
+ const selectedRecords = await db
+ .select()
+ .from(avlVendorInfo)
+ .where(
+ and(
+ eq(avlVendorInfo.isTemplate, true),
+ inArray(avlVendorInfo.id, selectedIds)
+ )
+ );
+
+ if (!selectedRecords.length) {
+ return { success: false, message: "선택된 항목을 찾을 수 없습니다." };
+ }
+
+ // 복사할 데이터 준비
+ const recordsToInsert: NewAvlVendorInfo[] = selectedRecords.map(record => ({
+ ...record,
+ id: undefined, // 새 ID 생성
+ isTemplate: false, // 프로젝트 AVL로 변경
+ projectCode: targetProjectCode, // 대상 프로젝트 코드
+ avlListId: targetAvlListId, // 대상 AVL 리스트 ID
+ // 표준 AVL 필드들은 null로 설정 (프로젝트 AVL에서는 사용하지 않음)
+ constructionSector: null,
+ shipType: null,
+ avlKind: null,
+ htDivision: null,
+ createdAt: undefined,
+ updatedAt: undefined,
+ }));
+
+ // 벌크 인서트
+ await db.insert(avlVendorInfo).values(recordsToInsert);
+
+ debugSuccess('선종별표준AVL → 프로젝트AVL 복사 완료', {
+ copiedCount: recordsToInsert.length,
+ targetProjectCode,
+ userName
+ });
+
+ // 캐시 무효화
+ revalidateTag('avl-lists');
+ revalidateTag('avl-vendor-info');
+
+ return {
+ success: true,
+ message: `${recordsToInsert.length}개의 항목이 프로젝트 AVL로 복사되었습니다.`
+ };
+
+ } catch (error) {
+ debugError('선종별표준AVL → 프로젝트AVL 복사 실패', { error, selectedIds, targetProjectCode });
+ return {
+ success: false,
+ message: "프로젝트 AVL로 복사 중 오류가 발생했습니다."
+ };
+ }
+};
+
+/**
+ * 프로젝트AVL → 선종별표준AVL로 복사
+ */
+export const copyToStandardAvl = async (
+ selectedIds: number[],
+ targetStandardInfo: {
+ constructionSector: string;
+ shipType: string;
+ avlKind: string;
+ htDivision: string;
+ },
+ userName: string
+): Promise<ActionResult> => {
+ try {
+ debugLog('프로젝트AVL → 선종별표준AVL 복사 시작', { selectedIds, targetStandardInfo });
+
+ if (!selectedIds.length) {
+ return { success: false, message: "복사할 항목을 선택해주세요." };
+ }
+
+ // 선종별 표준 AVL 검색 조건 검증
+ if (!targetStandardInfo.constructionSector?.trim() ||
+ !targetStandardInfo.shipType?.trim() ||
+ !targetStandardInfo.avlKind?.trim() ||
+ !targetStandardInfo.htDivision?.trim()) {
+ return { success: false, message: "선종별 표준 AVL 검색 조건을 모두 입력해주세요." };
+ }
+
+ // 선택된 레코드들 조회 (프로젝트AVL에서)
+ const selectedRecords = await db
+ .select()
+ .from(avlVendorInfo)
+ .where(
+ and(
+ eq(avlVendorInfo.isTemplate, false),
+ inArray(avlVendorInfo.id, selectedIds)
+ )
+ );
+
+ if (!selectedRecords.length) {
+ return { success: false, message: "선택된 항목을 찾을 수 없습니다." };
+ }
+
+ // 복사할 데이터 준비
+ const recordsToInsert: NewAvlVendorInfo[] = selectedRecords.map(record => ({
+ ...record,
+ id: undefined, // 새 ID 생성
+ isTemplate: true, // 표준 AVL로 변경
+ // 프로젝트 AVL 필드들은 null로 설정
+ projectCode: null,
+ avlListId: null,
+ // 표준 AVL 필드들 설정
+ constructionSector: targetStandardInfo.constructionSector,
+ shipType: targetStandardInfo.shipType,
+ avlKind: targetStandardInfo.avlKind,
+ htDivision: targetStandardInfo.htDivision,
+ createdAt: undefined,
+ updatedAt: undefined,
+ }));
+
+ // 벌크 인서트
+ await db.insert(avlVendorInfo).values(recordsToInsert);
+
+ debugSuccess('프로젝트AVL → 선종별표준AVL 복사 완료', {
+ copiedCount: recordsToInsert.length,
+ targetStandardInfo,
+ userName
+ });
+
+ // 캐시 무효화
+ revalidateTag('avl-lists');
+ revalidateTag('avl-vendor-info');
+
+ return {
+ success: true,
+ message: `${recordsToInsert.length}개의 항목이 선종별표준AVL로 복사되었습니다.`
+ };
+
+ } catch (error) {
+ debugError('프로젝트AVL → 선종별표준AVL 복사 실패', { error, selectedIds, targetStandardInfo });
+ return {
+ success: false,
+ message: "선종별표준AVL로 복사 중 오류가 발생했습니다."
+ };
+ }
+};
+
+/**
+ * 프로젝트AVL → 벤더풀로 복사
+ */
+export const copyToVendorPool = async (
+ selectedIds: number[],
+ userName: string
+): Promise<ActionResult> => {
+ try {
+ debugLog('프로젝트AVL → 벤더풀 복사 시작', { selectedIds });
+
+ if (!selectedIds.length) {
+ return { success: false, message: "복사할 항목을 선택해주세요." };
+ }
+
+ // 선택된 레코드들 조회 (프로젝트AVL에서)
+ const selectedRecords = await db
+ .select()
+ .from(avlVendorInfo)
+ .where(
+ and(
+ eq(avlVendorInfo.isTemplate, false),
+ inArray(avlVendorInfo.id, selectedIds)
+ )
+ );
+
+ if (!selectedRecords.length) {
+ return { success: false, message: "선택된 항목을 찾을 수 없습니다." };
+ }
+
+ // 벤더풀 테이블로 복사할 데이터 준비 (필드 매핑)
+ const recordsToInsert: NewVendorPool[] = selectedRecords.map(record => ({
+ // 기본 정보 (프로젝트 AVL에서 추출 또는 기본값 설정)
+ constructionSector: record.constructionSector || "조선", // 기본값 설정
+ htDivision: record.htDivision || "H", // 기본값 설정
+
+ // 설계 정보
+ designCategoryCode: "XX", // 기본값 (실제로는 적절한 값으로 매핑 필요)
+ designCategory: record.disciplineName || "기타",
+ equipBulkDivision: record.equipBulkDivision || "E",
+
+ // 패키지 정보
+ packageCode: record.packageCode,
+ packageName: record.packageName,
+
+ // 자재그룹 정보
+ materialGroupCode: record.materialGroupCode,
+ materialGroupName: record.materialGroupName,
+
+ // 자재 관련 정보 (빈 값으로 설정)
+ smCode: null,
+ similarMaterialNamePurchase: null,
+ similarMaterialNameOther: null,
+
+ // 협력업체 정보
+ vendorCode: record.vendorCode,
+ vendorName: record.vendorName,
+
+ // 사업 및 인증 정보
+ taxId: null, // 벤더풀에서 별도 관리
+ faTarget: record.faTarget,
+ faStatus: record.faStatus,
+ faRemark: null,
+ tier: record.tier,
+ isAgent: record.isAgent,
+
+ // 계약 정보
+ contractSignerCode: record.contractSignerCode,
+ contractSignerName: record.contractSignerName,
+
+ // 위치 정보
+ headquarterLocation: null, // 별도 관리 필요
+ manufacturingLocation: null, // 별도 관리 필요
+
+ // AVL 관련 정보
+ avlVendorName: record.avlVendorName,
+ similarVendorName: null,
+ hasAvl: record.hasAvl,
+
+ // 상태 정보
+ isBlacklist: record.isBlacklist,
+ isBcc: record.isBcc,
+ purchaseOpinion: null,
+
+ // AVL 적용 선종 (기본값으로 설정 - 실제로는 로직 필요)
+ shipTypeCommon: true, // 공통으로 설정
+ shipTypeAmax: false,
+ shipTypeSmax: false,
+ shipTypeVlcc: false,
+ shipTypeLngc: false,
+ shipTypeCont: false,
+
+ // AVL 적용 선종(해양) - 기본값
+ offshoreTypeCommon: false,
+ offshoreTypeFpso: false,
+ offshoreTypeFlng: false,
+ offshoreTypeFpu: false,
+ offshoreTypePlatform: false,
+ offshoreTypeWtiv: false,
+ offshoreTypeGom: false,
+
+ // eVCP 미등록 정보 - 빈 값
+ picName: null,
+ picEmail: null,
+ picPhone: null,
+ agentName: null,
+ agentEmail: null,
+ agentPhone: null,
+
+ // 업체 실적 현황
+ recentQuoteDate: record.recentQuoteDate,
+ recentQuoteNumber: record.recentQuoteNumber,
+ recentOrderDate: record.recentOrderDate,
+ recentOrderNumber: record.recentOrderNumber,
+
+ // 업데이트 히스토리
+ registrationDate: undefined, // 현재 시간으로 자동 설정
+ registrant: userName,
+ lastModifiedDate: undefined,
+ lastModifier: userName,
+ }));
+
+ // 입력 데이터에서 중복 제거 (메모리에서 처리)
+ const seen = new Set<string>();
+ const uniqueRecords = recordsToInsert.filter(record => {
+ if (!record.vendorCode || !record.materialGroupCode) return true; // 필수 필드가 없는 경우는 추가
+ const key = `${record.vendorCode}:${record.materialGroupCode}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+
+ // 중복 제거된 레코드 수 계산
+ const duplicateCount = recordsToInsert.length - uniqueRecords.length;
+
+ if (uniqueRecords.length === 0) {
+ return { success: false, message: "복사할 유효한 항목이 없습니다." };
+ }
+
+ // 벌크 인서트
+ await db.insert(vendorPool).values(uniqueRecords);
+
+ debugSuccess('프로젝트AVL → 벤더풀 복사 완료', {
+ copiedCount: uniqueRecords.length,
+ duplicateCount,
+ userName
+ });
+
+ // 캐시 무효화
+ revalidateTag('vendor-pool');
+ revalidateTag('vendor-pool-list');
+ revalidateTag('vendor-pool-stats');
+
+ return {
+ success: true,
+ message: `${uniqueRecords.length}개의 항목이 벤더풀로 복사되었습니다.${duplicateCount > 0 ? ` (${duplicateCount}개 입력 데이터 중복 제외)` : ''}`
+ };
+
+ } catch (error) {
+ debugError('프로젝트AVL → 벤더풀 복사 실패', { error, selectedIds });
+ return {
+ success: false,
+ message: "벤더풀로 복사 중 오류가 발생했습니다."
+ };
+ }
+};
+
+/**
+ * 벤더풀 → 프로젝트AVL로 복사
+ */
+export const copyFromVendorPoolToProjectAvl = async (
+ selectedIds: number[],
+ targetProjectCode: string,
+ targetAvlListId: number,
+ userName: string
+): Promise<ActionResult> => {
+ try {
+ debugLog('벤더풀 → 프로젝트AVL 복사 시작', { selectedIds, targetProjectCode, targetAvlListId });
+
+ if (!selectedIds.length) {
+ return { success: false, message: "복사할 항목을 선택해주세요." };
+ }
+
+ // 선택된 레코드들 조회 (벤더풀에서)
+ const selectedRecords = await db
+ .select()
+ .from(vendorPool)
+ .where(
+ inArray(vendorPool.id, selectedIds)
+ );
+
+ if (!selectedRecords.length) {
+ return { success: false, message: "선택된 항목을 찾을 수 없습니다." };
+ }
+
+ // 벤더풀 데이터를 AVL Vendor Info로 변환하여 복사
+ const recordsToInsert: NewAvlVendorInfo[] = selectedRecords.map(record => ({
+ // 프로젝트 AVL용 필드들
+ projectCode: targetProjectCode,
+ avlListId: targetAvlListId,
+ isTemplate: false,
+
+ // 벤더풀 데이터를 AVL Vendor Info로 매핑
+ vendorId: null, // 벤더풀에서는 vendorId가 없을 수 있음
+ vendorName: record.vendorName,
+ vendorCode: record.vendorCode,
+
+ // 기본 정보 (벤더풀의 데이터 활용)
+ constructionSector: record.constructionSector,
+ htDivision: record.htDivision,
+
+ // 자재그룹 정보
+ materialGroupCode: record.materialGroupCode,
+ materialGroupName: record.materialGroupName,
+
+ // 설계 정보 (벤더풀의 데이터 활용)
+ designCategory: record.designCategory,
+ equipBulkDivision: record.equipBulkDivision,
+
+ // 패키지 정보
+ packageCode: record.packageCode,
+ packageName: record.packageName,
+
+ // 계약 정보
+ contractSignerCode: record.contractSignerCode,
+ contractSignerName: record.contractSignerName,
+
+ // 위치 정보
+ headquarterLocation: record.headquarterLocation,
+ manufacturingLocation: record.manufacturingLocation,
+
+ // AVL 관련 정보
+ avlVendorName: record.avlVendorName,
+ hasAvl: record.hasAvl,
+
+ // 상태 정보
+ isBlacklist: record.isBlacklist,
+ isBcc: record.isBcc,
+
+ // 기본값들
+ ownerSuggestion: false,
+ shiSuggestion: false,
+ faTarget: record.faTarget,
+ faStatus: record.faStatus,
+ isAgent: record.isAgent,
+
+ // 나머지 필드들은 null 또는 기본값
+ disciplineCode: null,
+ disciplineName: null,
+ materialNameCustomerSide: null,
+ tier: record.tier,
+ filters: [],
+ joinOperator: "and",
+ search: "",
+
+ createdAt: undefined,
+ updatedAt: undefined,
+ }));
+
+ // 벌크 인서트
+ await db.insert(avlVendorInfo).values(recordsToInsert);
+
+ debugSuccess('벤더풀 → 프로젝트AVL 복사 완료', {
+ copiedCount: recordsToInsert.length,
+ targetProjectCode,
+ userName
+ });
+
+ // 캐시 무효화
+ revalidateTag('avl-lists');
+ revalidateTag('avl-vendor-info');
+
+ return {
+ success: true,
+ message: `${recordsToInsert.length}개의 항목이 프로젝트 AVL로 복사되었습니다.`
+ };
+
+ } catch (error) {
+ debugError('벤더풀 → 프로젝트AVL 복사 실패', { error, selectedIds, targetProjectCode });
+ return {
+ success: false,
+ message: "프로젝트 AVL로 복사 중 오류가 발생했습니다."
+ };
+ }
+};
+
+/**
+ * 벤더풀 → 선종별표준AVL로 복사
+ */
+export const copyFromVendorPoolToStandardAvl = async (
+ selectedIds: number[],
+ targetStandardInfo: {
+ constructionSector: string;
+ shipType: string;
+ avlKind: string;
+ htDivision: string;
+ },
+ userName: string
+): Promise<ActionResult> => {
+ try {
+ debugLog('벤더풀 → 선종별표준AVL 복사 시작', { selectedIds, targetStandardInfo });
+
+ if (!selectedIds.length) {
+ return { success: false, message: "복사할 항목을 선택해주세요." };
+ }
+
+ // 선택된 레코드들 조회 (벤더풀에서)
+ const selectedRecords = await db
+ .select()
+ .from(vendorPool)
+ .where(
+ inArray(vendorPool.id, selectedIds)
+ );
+
+ if (!selectedRecords.length) {
+ return { success: false, message: "선택된 항목을 찾을 수 없습니다." };
+ }
+
+ // 벤더풀 데이터를 AVL Vendor Info로 변환하여 복사
+ const recordsToInsert: NewAvlVendorInfo[] = selectedRecords.map(record => ({
+ // 선종별 표준 AVL용 필드들
+ isTemplate: true,
+ constructionSector: targetStandardInfo.constructionSector,
+ shipType: targetStandardInfo.shipType,
+ avlKind: targetStandardInfo.avlKind,
+ htDivision: targetStandardInfo.htDivision,
+
+ // 벤더풀 데이터를 AVL Vendor Info로 매핑
+ vendorId: null,
+ vendorName: record.vendorName,
+ vendorCode: record.vendorCode,
+
+ // 자재그룹 정보
+ materialGroupCode: record.materialGroupCode,
+ materialGroupName: record.materialGroupName,
+
+ // 설계 정보
+ disciplineName: record.designCategory,
+ equipBulkDivision: record.equipBulkDivision,
+
+ // 패키지 정보
+ packageCode: record.packageCode,
+ packageName: record.packageName,
+
+ // 계약 정보
+ contractSignerCode: record.contractSignerCode,
+ contractSignerName: record.contractSignerName,
+
+ // 위치 정보
+ headquarterLocation: record.headquarterLocation,
+ manufacturingLocation: record.manufacturingLocation,
+
+ // AVL 관련 정보
+ avlVendorName: record.avlVendorName,
+ hasAvl: record.hasAvl,
+
+ // 상태 정보
+ isBlacklist: record.isBlacklist,
+ isBcc: record.isBcc,
+
+ // 기본값들
+ ownerSuggestion: false,
+ shiSuggestion: false,
+ faTarget: record.faTarget,
+ faStatus: record.faStatus,
+ isAgent: record.isAgent,
+
+ // 선종별 표준 AVL에서는 사용하지 않는 필드들
+ projectCode: null,
+ avlListId: null,
+
+ // 나머지 필드들은 null 또는 기본값
+ disciplineCode: null,
+ materialNameCustomerSide: null,
+ tier: record.tier,
+ filters: [],
+ joinOperator: "and",
+ search: "",
+
+ createdAt: undefined,
+ updatedAt: undefined,
+ }));
+
+ // 벌크 인서트
+ await db.insert(avlVendorInfo).values(recordsToInsert);
+
+ debugSuccess('벤더풀 → 선종별표준AVL 복사 완료', {
+ copiedCount: recordsToInsert.length,
+ targetStandardInfo,
+ userName
+ });
+
+ // 캐시 무효화
+ revalidateTag('avl-lists');
+ revalidateTag('avl-vendor-info');
+
+ return {
+ success: true,
+ message: `${recordsToInsert.length}개의 항목이 선종별표준AVL로 복사되었습니다.`
+ };
+
+ } catch (error) {
+ debugError('벤더풀 → 선종별표준AVL 복사 실패', { error, selectedIds, targetStandardInfo });
+ return {
+ success: false,
+ message: "선종별표준AVL로 복사 중 오류가 발생했습니다."
+ };
+ }
+};
+
+/**
+ * 선종별표준AVL → 벤더풀로 복사
+ */
+export const copyFromStandardAvlToVendorPool = async (
+ selectedIds: number[],
+ userName: string
+): Promise<ActionResult> => {
+ try {
+ debugLog('선종별표준AVL → 벤더풀 복사 시작', { selectedIds });
+
+ if (!selectedIds.length) {
+ return { success: false, message: "복사할 항목을 선택해주세요." };
+ }
+
+ // 선택된 레코드들 조회 (선종별표준AVL에서)
+ const selectedRecords = await db
+ .select()
+ .from(avlVendorInfo)
+ .where(
+ and(
+ eq(avlVendorInfo.isTemplate, true),
+ inArray(avlVendorInfo.id, selectedIds)
+ )
+ );
+
+ if (!selectedRecords.length) {
+ return { success: false, message: "선택된 항목을 찾을 수 없습니다." };
+ }
+
+ // AVL Vendor Info 데이터를 벤더풀로 변환하여 복사
+ const recordsToInsert: NewVendorPool[] = selectedRecords.map(record => ({
+ // 기본 정보
+ constructionSector: record.constructionSector || "조선",
+ htDivision: record.htDivision || "H",
+
+ // 설계 정보
+ designCategoryCode: "XX", // 기본값
+ designCategory: record.disciplineName || "기타",
+ equipBulkDivision: record.equipBulkDivision || "E",
+
+ // 패키지 정보
+ packageCode: record.packageCode,
+ packageName: record.packageName,
+
+ // 자재그룹 정보
+ materialGroupCode: record.materialGroupCode,
+ materialGroupName: record.materialGroupName,
+
+ // 협력업체 정보
+ vendorCode: record.vendorCode,
+ vendorName: record.vendorName,
+
+ // 사업 및 인증 정보
+ taxId: null,
+ faTarget: record.faTarget,
+ faStatus: record.faStatus,
+ faRemark: null,
+ tier: record.tier,
+ isAgent: record.isAgent,
+
+ // 계약 정보
+ contractSignerCode: record.contractSignerCode,
+ contractSignerName: record.contractSignerName,
+
+ // 위치 정보
+ headquarterLocation: record.headquarterLocation,
+ manufacturingLocation: record.manufacturingLocation,
+
+ // AVL 관련 정보
+ avlVendorName: record.avlVendorName,
+ similarVendorName: null,
+ hasAvl: record.hasAvl,
+
+ // 상태 정보
+ isBlacklist: record.isBlacklist,
+ isBcc: record.isBcc,
+ purchaseOpinion: null,
+
+ // AVL 적용 선종 (기본값)
+ shipTypeCommon: true,
+ shipTypeAmax: false,
+ shipTypeSmax: false,
+ shipTypeVlcc: false,
+ shipTypeLngc: false,
+ shipTypeCont: false,
+
+ // AVL 적용 선종(해양) - 기본값
+ offshoreTypeCommon: false,
+ offshoreTypeFpso: false,
+ offshoreTypeFlng: false,
+ offshoreTypeFpu: false,
+ offshoreTypePlatform: false,
+ offshoreTypeWtiv: false,
+ offshoreTypeGom: false,
+
+ // eVCP 미등록 정보
+ picName: null,
+ picEmail: null,
+ picPhone: null,
+ agentName: null,
+ agentEmail: null,
+ agentPhone: null,
+
+ // 업체 실적 현황
+ recentQuoteDate: record.recentQuoteDate,
+ recentQuoteNumber: record.recentQuoteNumber,
+ recentOrderDate: record.recentOrderDate,
+ recentOrderNumber: record.recentOrderNumber,
+
+ // 업데이트 히스토리
+ registrationDate: undefined,
+ registrant: userName,
+ lastModifiedDate: undefined,
+ lastModifier: userName,
+ }));
+
+ // 중복 체크를 위한 고유한 vendorCode + materialGroupCode 조합 생성
+ const uniquePairs = new Set<string>();
+ const validRecords = recordsToInsert.filter(record => {
+ if (!record.vendorCode || !record.materialGroupCode) return false;
+ const key = `${record.vendorCode}:${record.materialGroupCode}`;
+ if (uniquePairs.has(key)) return false;
+ uniquePairs.add(key);
+ return true;
+ });
+
+ if (validRecords.length === 0) {
+ return { success: false, message: "복사할 유효한 항목이 없습니다." };
+ }
+
+ // 벌크 인서트
+ await db.insert(vendorPool).values(validRecords);
+
+ const duplicateCount = recordsToInsert.length - validRecords.length;
+
+ debugSuccess('선종별표준AVL → 벤더풀 복사 완료', {
+ copiedCount: validRecords.length,
+ duplicateCount,
+ userName
+ });
+
+ // 캐시 무효화
+ revalidateTag('vendor-pool');
+
+ return {
+ success: true,
+ message: `${validRecords.length}개의 항목이 벤더풀로 복사되었습니다.${duplicateCount > 0 ? ` (${duplicateCount}개 입력 데이터 중복 제외)` : ''}`
+ };
+
+ } catch (error) {
+ debugError('선종별표준AVL → 벤더풀 복사 실패', { error, selectedIds });
+ return {
+ success: false,
+ message: "벤더풀로 복사 중 오류가 발생했습니다."
+ };
+ }
+};
+
+/**
+ * 표준 AVL 최종 확정
+ * 표준 AVL을 최종 확정하여 AVL 리스트에 등록합니다.
+ */
+export async function finalizeStandardAvl(
+ standardAvlInfo: {
+ constructionSector: string;
+ shipType: string;
+ avlKind: string;
+ htDivision: string;
+ },
+ avlVendorInfoIds: number[],
+ currentUser?: string
+): Promise<{ success: boolean; avlListId?: number; message: string }> {
+ try {
+ debugLog('표준 AVL 최종 확정 시작', {
+ standardAvlInfo,
+ avlVendorInfoIds: avlVendorInfoIds.length,
+ currentUser
+ });
+
+ // 1. 기존 표준 AVL 리스트의 최고 revision 확인
+ const existingAvlLists = await db
+ .select({ rev: avlList.rev })
+ .from(avlList)
+ .where(and(
+ eq(avlList.constructionSector, standardAvlInfo.constructionSector),
+ eq(avlList.shipType, standardAvlInfo.shipType),
+ eq(avlList.avlKind, standardAvlInfo.avlKind),
+ eq(avlList.htDivision, standardAvlInfo.htDivision),
+ eq(avlList.isTemplate, true)
+ ))
+ .orderBy(desc(avlList.rev))
+ .limit(1);
+
+ const nextRevision = existingAvlLists.length > 0 ? (existingAvlLists[0].rev || 0) + 1 : 1;
+
+ debugLog('표준 AVL 리스트 revision 계산', {
+ standardAvlInfo,
+ existingRevision: existingAvlLists.length > 0 ? existingAvlLists[0].rev : 0,
+ nextRevision
+ });
+
+ // 2. AVL 리스트 생성을 위한 데이터 준비
+ const createAvlListData: CreateAvlListInput = {
+ isTemplate: true, // 표준 AVL이므로 true
+ constructionSector: standardAvlInfo.constructionSector,
+ projectCode: null, // 표준 AVL은 프로젝트 코드가 없음
+ shipType: standardAvlInfo.shipType,
+ avlKind: standardAvlInfo.avlKind,
+ htDivision: standardAvlInfo.htDivision,
+ rev: nextRevision, // 계산된 다음 리비전
+ createdBy: currentUser || 'system',
+ updatedBy: currentUser || 'system',
+ };
+
+ debugLog('표준 AVL 리스트 생성 데이터', { createAvlListData });
+
+ // 2-1. AVL Vendor Info 스냅샷 생성 (AVL 리스트 생성 전에 현재 상태 저장)
+ debugLog('표준 AVL Vendor Info 스냅샷 생성 시작', {
+ vendorInfoIdsCount: avlVendorInfoIds.length,
+ vendorInfoIds: avlVendorInfoIds
+ });
+ const vendorInfoSnapshot = await createVendorInfoSnapshot(avlVendorInfoIds);
+ debugSuccess('표준 AVL Vendor Info 스냅샷 생성 완료', {
+ snapshotCount: vendorInfoSnapshot.length,
+ snapshotSize: JSON.stringify(vendorInfoSnapshot).length,
+ sampleSnapshot: vendorInfoSnapshot.slice(0, 1) // 첫 번째 항목만 로깅
+ });
+
+ // 스냅샷을 AVL 리스트 생성 데이터에 추가
+ createAvlListData.vendorInfoSnapshot = vendorInfoSnapshot;
+ debugLog('표준 AVL 스냅샷이 createAvlListData에 추가됨', {
+ hasSnapshot: !!createAvlListData.vendorInfoSnapshot,
+ snapshotLength: createAvlListData.vendorInfoSnapshot?.length
+ });
+
+ // 3. AVL 리스트 생성
+ const newAvlList = await createAvlList(createAvlListData);
+
+ if (!newAvlList) {
+ throw new Error("표준 AVL 리스트 생성에 실패했습니다.");
+ }
+
+ debugSuccess('표준 AVL 리스트 생성 완료', { avlListId: newAvlList.id });
+
+ // 4. avlVendorInfo 레코드들의 avlListId 업데이트
+ if (avlVendorInfoIds.length > 0) {
+ debugLog('표준 AVL Vendor Info 업데이트 시작', {
+ count: avlVendorInfoIds.length,
+ newAvlListId: newAvlList.id
+ });
+
+ const updateResults = await Promise.all(
+ avlVendorInfoIds.map(async (vendorInfoId) => {
+ try {
+ const result = await db
+ .update(avlVendorInfo)
+ .set({
+ avlListId: newAvlList.id,
+ updatedAt: new Date()
+ })
+ .where(eq(avlVendorInfo.id, vendorInfoId))
+ .returning({ id: avlVendorInfo.id });
+
+ return { id: vendorInfoId, success: true, result };
+ } catch (error) {
+ debugError('표준 AVL Vendor Info 업데이트 실패', { vendorInfoId, error });
+ return { id: vendorInfoId, success: false, error };
+ }
+ })
+ );
+
+ // 업데이트 결과 검증
+ const successCount = updateResults.filter(r => r.success).length;
+ const failCount = updateResults.filter(r => !r.success).length;
+
+ debugLog('표준 AVL Vendor Info 업데이트 결과', {
+ total: avlVendorInfoIds.length,
+ success: successCount,
+ failed: failCount
+ });
+
+ if (failCount > 0) {
+ debugWarn('일부 표준 AVL Vendor Info 업데이트 실패', {
+ failedIds: updateResults.filter(r => !r.success).map(r => r.id)
+ });
+ }
+ }
+
+ // 5. 캐시 무효화
+ revalidateTag('avl-list');
+ revalidateTag('avl-vendor-info');
+
+ debugSuccess('표준 AVL 최종 확정 완료', {
+ avlListId: newAvlList.id,
+ standardAvlInfo,
+ vendorInfoCount: avlVendorInfoIds.length
+ });
+
+ return {
+ success: true,
+ avlListId: newAvlList.id,
+ message: `표준 AVL이 성공적으로 확정되었습니다. (AVL ID: ${newAvlList.id}, 벤더 정보: ${avlVendorInfoIds.length}개)`
+ };
+
+ } catch (err) {
+ debugError('표준 AVL 최종 확정 실패', {
+ standardAvlInfo,
+ error: err
+ });
+
+ console.error("Error in finalizeStandardAvl:", err);
+
+ return {
+ success: false,
+ message: err instanceof Error ? err.message : "표준 AVL 최종 확정 중 오류가 발생했습니다."
+ };
+ }
+}
diff --git a/lib/avl/snapshot-utils.ts b/lib/avl/snapshot-utils.ts
new file mode 100644
index 00000000..0f5d9240
--- /dev/null
+++ b/lib/avl/snapshot-utils.ts
@@ -0,0 +1,190 @@
+/**
+ * AVL Vendor Info 스냅샷 관련 유틸리티 함수들
+ */
+
+import db from "@/db/db"
+import { avlVendorInfo } from "@/db/schema/avl/avl"
+import { inArray } from "drizzle-orm"
+
+/**
+ * AVL Vendor Info 스냅샷 데이터 타입
+ */
+export interface VendorInfoSnapshot {
+ id: number
+ // 설계 정보
+ equipBulkDivision: string | null
+ disciplineCode: string | null
+ disciplineName: string | null
+ // 자재 정보
+ materialNameCustomerSide: string | null
+ packageCode: string | null
+ packageName: string | null
+ materialGroupCode: string | null
+ materialGroupName: string | null
+ // 협력업체 정보
+ vendorId: number | null
+ vendorName: string | null
+ vendorCode: string | null
+ avlVendorName: string | null
+ tier: string | null
+ // FA 정보
+ faTarget: boolean
+ faStatus: string | null
+ // Agent 정보
+ isAgent: boolean
+ // 계약 서명주체
+ contractSignerId: number | null
+ contractSignerName: string | null
+ contractSignerCode: string | null
+ // 위치 정보
+ headquarterLocation: string | null
+ manufacturingLocation: string | null
+ // SHI Qualification
+ hasAvl: boolean
+ isBlacklist: boolean
+ isBcc: boolean
+ // 기술영업 견적결과
+ techQuoteNumber: string | null
+ quoteCode: string | null
+ quoteVendorId: number | null
+ quoteVendorName: string | null
+ quoteVendorCode: string | null
+ quoteCountry: string | null
+ quoteTotalAmount: string | null // 숫자를 문자열로 변환
+ quoteReceivedDate: string | null
+ // 업체 실적 현황
+ recentQuoteDate: string | null
+ recentQuoteNumber: string | null
+ recentOrderDate: string | null
+ recentOrderNumber: string | null
+ // 기타
+ remark: string | null
+ // 타임스탬프
+ createdAt: string
+ updatedAt: string
+}
+
+/**
+ * AVL Vendor Info ID 목록으로부터 스냅샷 데이터를 생성합니다.
+ *
+ * @param vendorInfoIds - 스냅샷을 생성할 AVL Vendor Info ID 목록
+ * @returns 스냅샷 데이터 배열
+ */
+export async function createVendorInfoSnapshot(vendorInfoIds: number[]): Promise<VendorInfoSnapshot[]> {
+ if (vendorInfoIds.length === 0) {
+ console.log('[SNAPSHOT] 빈 vendorInfoIds 배열, 빈 스냅샷 반환')
+ return []
+ }
+
+ try {
+ console.log('[SNAPSHOT] 스냅샷 생성 시작', { vendorInfoIds, count: vendorInfoIds.length })
+
+ // AVL Vendor Info 데이터 조회
+ const vendorInfoList = await db
+ .select()
+ .from(avlVendorInfo)
+ .where(inArray(avlVendorInfo.id, vendorInfoIds))
+
+ console.log('[SNAPSHOT] DB 조회 완료', {
+ requestedIds: vendorInfoIds,
+ foundCount: vendorInfoList.length,
+ foundIds: vendorInfoList.map(v => v.id)
+ })
+
+ // 스냅샷 데이터로 변환
+ const snapshot: VendorInfoSnapshot[] = vendorInfoList.map(info => ({
+ id: info.id,
+ // 설계 정보
+ equipBulkDivision: info.equipBulkDivision,
+ disciplineCode: info.disciplineCode,
+ disciplineName: info.disciplineName,
+ // 자재 정보
+ materialNameCustomerSide: info.materialNameCustomerSide,
+ packageCode: info.packageCode,
+ packageName: info.packageName,
+ materialGroupCode: info.materialGroupCode,
+ materialGroupName: info.materialGroupName,
+ // 협력업체 정보
+ vendorId: info.vendorId,
+ vendorName: info.vendorName,
+ vendorCode: info.vendorCode,
+ avlVendorName: info.avlVendorName,
+ tier: info.tier,
+ // FA 정보
+ faTarget: info.faTarget ?? false,
+ faStatus: info.faStatus,
+ // Agent 정보
+ isAgent: info.isAgent ?? false,
+ // 계약 서명주체
+ contractSignerId: info.contractSignerId,
+ contractSignerName: info.contractSignerName,
+ contractSignerCode: info.contractSignerCode,
+ // 위치 정보
+ headquarterLocation: info.headquarterLocation,
+ manufacturingLocation: info.manufacturingLocation,
+ // SHI Qualification
+ hasAvl: info.hasAvl ?? false,
+ isBlacklist: info.isBlacklist ?? false,
+ isBcc: info.isBcc ?? false,
+ // 기술영업 견적결과
+ techQuoteNumber: info.techQuoteNumber,
+ quoteCode: info.quoteCode,
+ quoteVendorId: info.quoteVendorId,
+ quoteVendorName: info.quoteVendorName,
+ quoteVendorCode: info.quoteVendorCode,
+ quoteCountry: info.quoteCountry,
+ quoteTotalAmount: info.quoteTotalAmount?.toString() || null,
+ quoteReceivedDate: info.quoteReceivedDate,
+ // 업체 실적 현황
+ recentQuoteDate: info.recentQuoteDate,
+ recentQuoteNumber: info.recentQuoteNumber,
+ recentOrderDate: info.recentOrderDate,
+ recentOrderNumber: info.recentOrderNumber,
+ // 기타
+ remark: info.remark,
+ // 타임스탬프 (ISO 문자열로 변환)
+ createdAt: info.createdAt?.toISOString() || new Date().toISOString(),
+ updatedAt: info.updatedAt?.toISOString() || new Date().toISOString(),
+ }))
+
+ console.log('[SNAPSHOT] 스냅샷 변환 완료', {
+ snapshotCount: snapshot.length,
+ sampleData: snapshot.slice(0, 2) // 처음 2개 항목만 로깅
+ })
+
+ return snapshot
+
+ } catch (error) {
+ console.error('[SNAPSHOT] Vendor Info 스냅샷 생성 실패:', error)
+ throw new Error('Vendor Info 스냅샷 생성 중 오류가 발생했습니다.')
+ }
+}
+
+/**
+ * 스냅샷 데이터의 통계 정보를 생성합니다.
+ *
+ * @param snapshot - 스냅샷 데이터 배열
+ * @returns 통계 정보 객체
+ */
+export function getSnapshotStatistics(snapshot: VendorInfoSnapshot[]) {
+ return {
+ totalCount: snapshot.length,
+ avlCount: snapshot.filter(item => item.hasAvl).length,
+ blacklistCount: snapshot.filter(item => item.isBlacklist).length,
+ bccCount: snapshot.filter(item => item.isBcc).length,
+ faTargetCount: snapshot.filter(item => item.faTarget).length,
+ agentCount: snapshot.filter(item => item.isAgent).length,
+ tierDistribution: {
+ 'Tier 1': snapshot.filter(item => item.tier === 'Tier 1').length,
+ 'Tier 2': snapshot.filter(item => item.tier === 'Tier 2').length,
+ 'Tier 3': snapshot.filter(item => item.tier === 'Tier 3').length,
+ 'Other': snapshot.filter(item => item.tier && !['Tier 1', 'Tier 2', 'Tier 3'].includes(item.tier)).length,
+ 'Unspecified': snapshot.filter(item => !item.tier).length,
+ },
+ byDiscipline: snapshot.reduce((acc, item) => {
+ const discipline = item.disciplineName || 'Unknown'
+ acc[discipline] = (acc[discipline] || 0) + 1
+ return acc
+ }, {} as Record<string, number>),
+ }
+}
diff --git a/lib/avl/table/avl-detail-table.tsx b/lib/avl/table/avl-detail-table.tsx
new file mode 100644
index 00000000..04384ec8
--- /dev/null
+++ b/lib/avl/table/avl-detail-table.tsx
@@ -0,0 +1,116 @@
+"use client"
+
+import * as React from "react"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { Button } from "@/components/ui/button"
+import { toast } from "sonner"
+
+import { columns, type AvlDetailItem } from "./columns-detail"
+
+interface AvlDetailTableProps {
+ data: AvlDetailItem[]
+ pageCount?: number
+ avlType?: '프로젝트AVL' | '선종별표준AVL' // AVL 타입
+ projectCode?: string // 프로젝트 코드
+ shipOwnerName?: string // 선주명
+ businessType?: string // 사업 유형 (예: 조선/해양)
+}
+
+export function AvlDetailTable({
+ data,
+ pageCount,
+ avlType = '프로젝트AVL',
+ projectCode,
+ shipOwnerName,
+ businessType = '조선'
+}: AvlDetailTableProps) {
+ // 액션 핸들러
+ const handleAction = React.useCallback(async (action: string) => {
+ switch (action) {
+ case 'avl-form':
+ toast.info("AVL 양식을 준비 중입니다.")
+ // TODO: AVL 양식 다운로드 로직 구현
+ break
+
+ case 'quote-request':
+ toast.info("견적 요청을 처리 중입니다.")
+ // TODO: 견적 요청 로직 구현
+ break
+
+ case 'vendor-pool':
+ toast.info("Vendor Pool을 열고 있습니다.")
+ // TODO: Vendor Pool 페이지 이동 또는 모달 열기 로직 구현
+ break
+
+ case 'download':
+ toast.info("데이터를 다운로드 중입니다.")
+ // TODO: 데이터 다운로드 로직 구현
+ break
+
+ default:
+ toast.error(`알 수 없는 액션: ${action}`)
+ }
+ }, [])
+
+
+ // 테이블 메타 설정 (읽기 전용)
+ const tableMeta = React.useMemo(() => ({
+ onAction: handleAction,
+ }), [handleAction])
+
+ // 데이터 테이블 설정
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount: pageCount ?? 1,
+ initialState: {
+ sorting: [{ id: "no", desc: false }],
+ pagination: {
+ pageIndex: 0,
+ pageSize: 10,
+ },
+ },
+ getRowId: (row) => String(row.id),
+ meta: tableMeta,
+ })
+
+
+ return (
+ <div className="space-y-4">
+ {/* 상단 정보 표시 영역 */}
+ <div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
+ <div className="flex items-center gap-4">
+ <h2 className="text-lg font-semibold">AVL 상세내역</h2>
+ <span className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">
+ {avlType}
+ </span>
+ <span className="text-sm text-muted-foreground">
+ [{businessType}] {projectCode || '프로젝트코드'} ({shipOwnerName || '선주명'})
+ </span>
+ </div>
+ </div>
+
+ {/* 상단 버튼 영역 */}
+ <div className="flex items-center gap-2">
+ <Button variant="outline" size="sm" onClick={() => handleAction('avl-form')}>
+ AVL양식
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => handleAction('quote-request')}>
+ 견적요청
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => handleAction('vendor-pool')}>
+ Vendor Pool
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => handleAction('download')}>
+ 다운로드
+ </Button>
+ </div>
+
+ {/* 데이터 테이블 */}
+ <DataTable table={table} />
+
+ </div>
+ )
+}
diff --git a/lib/avl/table/avl-registration-area.tsx b/lib/avl/table/avl-registration-area.tsx
new file mode 100644
index 00000000..52912a2c
--- /dev/null
+++ b/lib/avl/table/avl-registration-area.tsx
@@ -0,0 +1,568 @@
+"use client"
+
+import * as React from "react"
+import { Card } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"
+import { useAtom } from "jotai"
+import { ProjectAvlTable, ProjectAvlTableRef } from "./project-avl-table"
+import { StandardAvlTable, StandardAvlTableRef } from "./standard-avl-table"
+import { VendorPoolTable, VendorPoolTableRef } from "./vendor-pool-table"
+import { selectedAvlRecordAtom } from "../avl-atoms"
+import { copyToProjectAvl, copyToStandardAvl, copyToVendorPool, copyFromVendorPoolToProjectAvl, copyFromVendorPoolToStandardAvl, copyFromStandardAvlToVendorPool } from "../service"
+import { useSession } from "next-auth/react"
+import { toast } from "sonner"
+
+// 선택된 테이블 타입
+type SelectedTable = 'project' | 'standard' | 'vendor' | null
+
+// TODO: 나머지 테이블들도 ref 지원 추가 시 인터페이스 추가 필요
+// interface StandardAvlTableRef {
+// getSelectedIds?: () => number[]
+// }
+//
+// interface VendorPoolTableRef {
+// getSelectedIds?: () => number[]
+// }
+
+
+// 선택 상태 액션 타입
+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
+ }
+}
+
+interface AvlRegistrationAreaProps {
+ disabled?: boolean // 비활성화 상태
+}
+
+export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaProps) {
+
+ // 선택된 AVL 레코드 구독
+ const [selectedAvlRecord] = useAtom(selectedAvlRecordAtom)
+
+ // 세션 정보
+ const { data: session } = useSession()
+
+ // 단일 선택 상태 관리 (useReducer 사용)
+ const [selectionState, dispatch] = React.useReducer(selectionReducer, {
+ selectedTable: null,
+ selectedRowCount: 0,
+ resetCounters: {
+ project: 0,
+ standard: 0,
+ vendor: 0,
+ },
+ })
+
+ // 선택 핸들러들
+ const handleProjectSelection = React.useCallback((count: number) => {
+ console.log('handleProjectSelection called with count:', count)
+ dispatch({ type: 'SELECT_PROJECT', count })
+ }, [])
+
+ const handleStandardSelection = React.useCallback((count: number) => {
+ console.log('handleStandardSelection called with count:', count)
+ dispatch({ type: 'SELECT_STANDARD', count })
+ }, [])
+
+ const handleVendorSelection = React.useCallback((count: number) => {
+ dispatch({ type: 'SELECT_VENDOR', count })
+ }, [])
+
+ const { selectedTable, selectedRowCount, resetCounters } = selectionState
+
+ console.log('selectedTable', selectedTable);
+
+ // 선택된 AVL에 따른 필터 값들
+ const [currentProjectCode, setCurrentProjectCode] = React.useState<string>("")
+ const constructionSector = selectedAvlRecord?.constructionSector || ""
+ const shipType = selectedAvlRecord?.shipType || ""
+ const avlKind = selectedAvlRecord?.avlKind || ""
+ const htDivision = selectedAvlRecord?.htDivision || ""
+ const avlListId = selectedAvlRecord?.id ? String(selectedAvlRecord.id) : ""
+
+ // 선종별 표준 AVL 검색 조건 상태 (복사 버튼 활성화용)
+ const [standardSearchConditions, setStandardSearchConditions] = React.useState({
+ constructionSector: "",
+ shipType: "",
+ avlKind: "",
+ htDivision: ""
+ })
+
+ // 검색 조건이 모두 입력되었는지 확인
+ const isStandardSearchConditionsComplete = React.useMemo(() => {
+ return (
+ standardSearchConditions.constructionSector.trim() !== "" &&
+ standardSearchConditions.shipType.trim() !== "" &&
+ standardSearchConditions.avlKind.trim() !== "" &&
+ standardSearchConditions.htDivision.trim() !== ""
+ )
+ }, [standardSearchConditions])
+
+ // 벤더 풀 리로드 트리거
+ const [vendorPoolReloadTrigger, setVendorPoolReloadTrigger] = React.useState(0)
+
+ // 선종별 표준 AVL 리로드 트리거
+ const [standardAvlReloadTrigger, setStandardAvlReloadTrigger] = React.useState(0)
+
+ // 프로젝트 AVL 리로드 트리거
+ const [projectAvlReloadTrigger, setProjectAvlReloadTrigger] = React.useState(0)
+
+ // 테이블 ref들 (선택된 행 정보 가져오기용)
+ const projectTableRef = React.useRef<ProjectAvlTableRef>(null)
+ const standardTableRef = React.useRef<StandardAvlTableRef>(null)
+ const vendorTableRef = React.useRef<VendorPoolTableRef>(null)
+
+ // 선택된 AVL 레코드가 변경될 때 프로젝트 코드 초기화
+ React.useEffect(() => {
+ setCurrentProjectCode(selectedAvlRecord?.projectCode || "")
+ }, [selectedAvlRecord?.projectCode])
+
+ // 프로젝트 코드 변경 핸들러
+ const handleProjectCodeChange = React.useCallback((projectCode: string) => {
+ setCurrentProjectCode(projectCode)
+ }, [])
+
+ // 선택된 ID들을 가져오는 헬퍼 함수들
+ const getSelectedIds = React.useCallback((tableType: 'project' | 'standard' | 'vendor') => {
+ // 각 테이블 컴포넌트에서 선택된 행들의 ID를 가져오는 로직
+ switch (tableType) {
+ case 'project':
+ return projectTableRef.current?.getSelectedIds?.() || []
+ case 'standard':
+ return standardTableRef.current?.getSelectedIds?.() || []
+ case 'vendor':
+ return vendorTableRef.current?.getSelectedIds?.() || []
+ default:
+ return []
+ }
+ }, [])
+
+ // 복사 버튼 핸들러들
+ const handleCopyToProject = React.useCallback(async () => {
+ if (selectedTable !== 'standard' || selectedRowCount === 0) return
+
+ const selectedIds = getSelectedIds('standard')
+ if (!selectedIds.length) {
+ toast.error("복사할 항목을 선택해주세요.")
+ return
+ }
+
+ if (!currentProjectCode) {
+ toast.error("프로젝트 코드가 설정되지 않았습니다.")
+ return
+ }
+
+ try {
+ const result = await copyToProjectAvl(
+ selectedIds,
+ currentProjectCode,
+ parseInt(avlListId) || 1,
+ session?.user?.name || "unknown"
+ )
+
+ if (result.success) {
+ toast.success(result.message)
+ // 선택 해제
+ dispatch({ type: 'CLEAR_SELECTION' })
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ console.error('프로젝트AVL로 복사 실패:', error)
+ toast.error("복사 중 오류가 발생했습니다.")
+ }
+ }, [selectedTable, selectedRowCount, getSelectedIds, currentProjectCode, avlListId, session])
+
+ const handleCopyToStandard = React.useCallback(async () => {
+ if (selectedTable !== 'project' || selectedRowCount === 0) return
+
+ const selectedIds = getSelectedIds('project')
+ if (!selectedIds.length) {
+ toast.error("복사할 항목을 선택해주세요.")
+ return
+ }
+
+ const targetStandardInfo = {
+ constructionSector: standardSearchConditions.constructionSector || "조선",
+ shipType: standardSearchConditions.shipType || "",
+ avlKind: standardSearchConditions.avlKind || "",
+ htDivision: standardSearchConditions.htDivision || "H"
+ }
+
+ try {
+ const result = await copyToStandardAvl(
+ selectedIds,
+ targetStandardInfo,
+ session?.user?.name || "unknown"
+ )
+
+ if (result.success) {
+ toast.success(result.message)
+ // 선종별 표준 AVL 데이터 리로드
+ setStandardAvlReloadTrigger(prev => prev + 1)
+ // 선택 해제
+ dispatch({ type: 'CLEAR_SELECTION' })
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ console.error('선종별표준AVL로 복사 실패:', error)
+ toast.error("복사 중 오류가 발생했습니다.")
+ }
+ }, [selectedTable, selectedRowCount, getSelectedIds, standardSearchConditions, session])
+
+ const handleCopyToVendorPool = React.useCallback(async () => {
+ if (selectedTable !== 'project' || selectedRowCount === 0) return
+
+ const selectedIds = getSelectedIds('project')
+ if (!selectedIds.length) {
+ toast.error("복사할 항목을 선택해주세요.")
+ return
+ }
+
+ try {
+ const result = await copyToVendorPool(
+ selectedIds,
+ session?.user?.name || "unknown"
+ )
+
+ if (result.success) {
+ toast.success(result.message)
+ // 벤더 풀 데이터 리로드
+ setVendorPoolReloadTrigger(prev => prev + 1)
+ // 선택 해제
+ dispatch({ type: 'CLEAR_SELECTION' })
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ console.error('벤더풀로 복사 실패:', error)
+ toast.error("복사 중 오류가 발생했습니다.")
+ }
+ }, [selectedTable, selectedRowCount, getSelectedIds, session])
+
+ // 추가 복사 버튼 핸들러들
+ const handleCopyFromVendorToProject = React.useCallback(async () => {
+ if (selectedTable !== 'vendor' || selectedRowCount === 0) return
+
+ const selectedIds = getSelectedIds('vendor')
+ if (!selectedIds.length) {
+ toast.error("복사할 항목을 선택해주세요.")
+ return
+ }
+
+ if (!currentProjectCode) {
+ toast.error("프로젝트 코드가 설정되지 않았습니다.")
+ return
+ }
+
+ try {
+ const result = await copyFromVendorPoolToProjectAvl(
+ selectedIds,
+ currentProjectCode,
+ parseInt(avlListId) || 1,
+ session?.user?.name || "unknown"
+ )
+
+ if (result.success) {
+ toast.success(result.message)
+ // 프로젝트 AVL 리로드
+ setProjectAvlReloadTrigger(prev => prev + 1)
+ // 선택 해제
+ dispatch({ type: 'CLEAR_SELECTION' })
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ console.error('벤더풀 → 프로젝트AVL 복사 실패:', error)
+ toast.error("복사 중 오류가 발생했습니다.")
+ }
+ }, [selectedTable, selectedRowCount, getSelectedIds, currentProjectCode, avlListId, session])
+
+ const handleCopyFromVendorToStandard = React.useCallback(async () => {
+ if (selectedTable !== 'vendor' || selectedRowCount === 0) return
+
+ const selectedIds = getSelectedIds('vendor')
+ if (!selectedIds.length) {
+ toast.error("복사할 항목을 선택해주세요.")
+ return
+ }
+
+ const targetStandardInfo = {
+ constructionSector: standardSearchConditions.constructionSector || "조선",
+ shipType: standardSearchConditions.shipType || "",
+ avlKind: standardSearchConditions.avlKind || "",
+ htDivision: standardSearchConditions.htDivision || "H"
+ }
+
+ try {
+ const result = await copyFromVendorPoolToStandardAvl(
+ selectedIds,
+ targetStandardInfo,
+ session?.user?.name || "unknown"
+ )
+
+ if (result.success) {
+ toast.success(result.message)
+ // 선종별 표준 AVL 리로드
+ setStandardAvlReloadTrigger(prev => prev + 1)
+ // 선택 해제
+ dispatch({ type: 'CLEAR_SELECTION' })
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ console.error('벤더풀 → 선종별표준AVL 복사 실패:', error)
+ toast.error("복사 중 오류가 발생했습니다.")
+ }
+ }, [selectedTable, selectedRowCount, getSelectedIds, standardSearchConditions, session])
+
+ const handleCopyFromStandardToVendor = React.useCallback(async () => {
+ if (selectedTable !== 'standard' || selectedRowCount === 0) return
+
+ const selectedIds = getSelectedIds('standard')
+ if (!selectedIds.length) {
+ toast.error("복사할 항목을 선택해주세요.")
+ return
+ }
+
+ try {
+ const result = await copyFromStandardAvlToVendorPool(
+ selectedIds,
+ session?.user?.name || "unknown"
+ )
+
+ if (result.success) {
+ toast.success(result.message)
+ // 벤더 풀 리로드
+ setVendorPoolReloadTrigger(prev => prev + 1)
+ // 선택 해제
+ dispatch({ type: 'CLEAR_SELECTION' })
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ console.error('선종별표준AVL → 벤더풀 복사 실패:', error)
+ toast.error("복사 중 오류가 발생했습니다.")
+ }
+ }, [selectedTable, selectedRowCount, getSelectedIds, session])
+
+ return (
+ <Card className={`h-full min-w-full overflow-visible ${disabled ? 'opacity-50 pointer-events-none' : ''}`}>
+ {/* 고정 헤더 영역 */}
+ <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 등록 {disabled ? "(비활성화)" : ""}</h3>
+ <div className="flex gap-2">
+ {/* <Button variant="outline" size="sm" disabled={disabled}>
+ 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
+ ref={projectTableRef}
+ onSelectionChange={handleProjectSelection}
+ resetCounter={resetCounters.project}
+ projectCode={currentProjectCode}
+ avlListId={parseInt(avlListId) || 1}
+ onProjectCodeChange={handleProjectCodeChange}
+ reloadTrigger={projectAvlReloadTrigger}
+ />
+
+ {/* 이동 버튼들 - 첫 번째 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="프로젝트AVL로 복사"
+ disabled={disabled || selectedTable === 'project' || selectedRowCount === 0}
+ onClick={handleCopyToProject}
+ >
+ <ChevronLeft className="w-4 h-4" />
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="선종별표준AVL로 복사"
+ disabled={disabled || selectedTable !== 'project' || selectedRowCount === 0 || !isStandardSearchConditionsComplete}
+ onClick={handleCopyToStandard}
+ >
+ <ChevronRight className="w-4 h-4" />
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="벤더풀로 복사"
+ disabled={disabled || selectedTable !== 'project' || selectedRowCount === 0}
+ onClick={handleCopyToVendorPool}
+ >
+ <ChevronsRight className="w-4 h-4" />
+ </Button>
+
+ </div>
+ </div>
+ </div>
+
+ {/* 선종별 표준 AVL 테이블 - 8개 컬럼 */}
+ <div className="p-4 border-r relative">
+ <StandardAvlTable
+ ref={standardTableRef}
+ onSelectionChange={handleStandardSelection}
+ resetCounter={resetCounters.standard}
+ constructionSector={constructionSector}
+ shipType={shipType}
+ avlKind={avlKind}
+ htDivision={htDivision}
+ onSearchConditionsChange={setStandardSearchConditions}
+ reloadTrigger={standardAvlReloadTrigger}
+ />
+
+ {/* 이동 버튼들 - 두 번째 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="벤더풀의 항목을 프로젝트AVL로 복사"
+ disabled={disabled || selectedTable !== 'vendor' || selectedRowCount === 0 || !currentProjectCode}
+ onClick={handleCopyFromVendorToProject}
+ >
+ <ChevronsLeft className="w-4 h-4" />
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="벤더풀의 항목을 선종별표준AVL로 복사"
+ disabled={disabled || selectedTable !== 'vendor' || selectedRowCount === 0 || !isStandardSearchConditionsComplete}
+ onClick={handleCopyFromVendorToStandard}
+ >
+ <ChevronLeft className="w-4 h-4" />
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="선종별표준AVL의 항목을 벤더풀로 복사"
+ disabled={disabled || selectedTable !== 'standard' || selectedRowCount === 0}
+ onClick={handleCopyFromStandardToVendor}
+ >
+ <ChevronRight className="w-4 h-4" />
+ </Button>
+
+ </div>
+ </div>
+ </div>
+
+ {/* Vendor Pool 테이블 - 10개 컬럼 */}
+ <div className="p-4 relative">
+ <VendorPoolTable
+ ref={vendorTableRef}
+ onSelectionChange={handleVendorSelection}
+ resetCounter={resetCounters.vendor}
+ reloadTrigger={vendorPoolReloadTrigger}
+ />
+ </div>
+ </div>
+ </div>
+ </Card>
+ )
+}
diff --git a/lib/avl/table/avl-table-columns.tsx b/lib/avl/table/avl-table-columns.tsx
new file mode 100644
index 00000000..8caf012e
--- /dev/null
+++ b/lib/avl/table/avl-table-columns.tsx
@@ -0,0 +1,353 @@
+import { Checkbox } from "@/components/ui/checkbox"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Eye, Edit, Trash2, History } from "lucide-react"
+import { type ColumnDef, TableMeta } from "@tanstack/react-table"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { EditableCell } from "@/components/data-table/editable-cell"
+import { AvlListItem } from "../types"
+
+interface GetColumnsProps {
+ selectedRows?: number[]
+ onRowSelect?: (id: number, selected: boolean) => void
+}
+
+// 수정 여부 확인 헬퍼 함수
+const getIsModified = (table: any, rowId: string, fieldName: string) => {
+ const pendingChanges = table.options.meta?.getPendingChanges?.() || {}
+ return String(rowId) in pendingChanges && fieldName in pendingChanges[String(rowId)]
+}
+
+// 테이블 메타 타입 확장
+declare module "@tanstack/react-table" {
+ interface TableMeta<TData> {
+ onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise<void>
+ onCellCancel?: (id: string, field: keyof TData) => void
+ onAction?: (action: string, data?: any) => void
+ onSaveEmptyRow?: (tempId: string) => Promise<void>
+ onCancelEmptyRow?: (tempId: string) => void
+ isEmptyRow?: (id: string) => boolean
+ }
+}
+
+// 테이블 컬럼 정의 함수
+export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps): ColumnDef<AvlListItem>[] {
+ const columns: ColumnDef<AvlListItem>[] = [
+ // 기본 정보 그룹
+ {
+ header: "기본 정보",
+ columns: [
+ {
+ id: "select",
+ header: () => <div className="text-center">선택</div>,
+ cell: ({ row }) => (
+ <div className="flex justify-center">
+ <Checkbox
+ checked={selectedRows.includes(row.original.id)}
+ onCheckedChange={(checked) => {
+ onRowSelect?.(row.original.id, !!checked)
+ }}
+ aria-label="행 선택"
+ className="translate-y-[2px]"
+ />
+ </div>
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ size: 40,
+ },
+ {
+ accessorKey: "no",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="No" />
+ ),
+ cell: ({ getValue }) => <div className="text-center">{getValue() as number}</div>,
+ size: 60,
+ },
+ {
+ accessorKey: "isTemplate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="AVL 분류" />
+ ),
+ cell: ({ getValue, row, table }) => {
+ const value = getValue() as boolean
+ const isModified = getIsModified(table, row.id, "isTemplate")
+ return (
+ <EditableCell
+ value={value ? "표준 AVL" : "프로젝트 AVL"}
+ isModified={isModified}
+ type="select"
+ options={[
+ { value: false, label: "프로젝트 AVL" },
+ { value: true, label: "표준 AVL" },
+ ]}
+ onUpdate={(newValue) => {
+ table.options.meta?.onCellUpdate?.(row.id, "isTemplate", newValue === "true")
+ }}
+ onCancel={() => {
+ table.options.meta?.onCellCancel?.(row.id, "isTemplate")
+ }}
+ />
+ )
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "constructionSector",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="공사부문" />
+ ),
+ cell: ({ getValue, row, table }) => {
+ const value = getValue() as string
+ const isModified = getIsModified(table, row.id, "constructionSector")
+ return (
+ <EditableCell
+ value={value}
+ isModified={isModified}
+ type="select"
+ options={[
+ { value: "조선", label: "조선" },
+ { value: "해양", label: "해양" },
+ ]}
+ onUpdate={(newValue) => {
+ table.options.meta?.onCellUpdate?.(row.id, "constructionSector", newValue)
+ }}
+ onCancel={() => {
+ table.options.meta?.onCellCancel?.(row.id, "constructionSector")
+ }}
+ />
+ )
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "projectCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트 코드" />
+ ),
+ cell: ({ getValue, row, table }) => {
+ const value = getValue() as string
+ const isModified = getIsModified(table, row.id, "projectCode")
+ return (
+ <EditableCell
+ value={value}
+ isModified={isModified}
+ onUpdate={(newValue) => {
+ table.options.meta?.onCellUpdate?.(row.id, "projectCode", newValue)
+ }}
+ onCancel={() => {
+ table.options.meta?.onCellCancel?.(row.id, "projectCode")
+ }}
+ />
+ )
+ },
+ size: 140,
+ },
+ {
+ accessorKey: "shipType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="선종" />
+ ),
+ cell: ({ getValue, row, table }) => {
+ const value = getValue() as string
+ const isModified = getIsModified(table, row.id, "shipType")
+ return (
+ <EditableCell
+ value={value}
+ isModified={isModified}
+ onUpdate={(newValue) => {
+ table.options.meta?.onCellUpdate?.(row.id, "shipType", newValue)
+ }}
+ onCancel={() => {
+ table.options.meta?.onCellCancel?.(row.id, "shipType")
+ }}
+ />
+ )
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "avlKind",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="AVL 종류" />
+ ),
+ cell: ({ getValue, row, table }) => {
+ const value = getValue() as string
+ const isModified = getIsModified(table, row.id, "avlKind")
+ return (
+ <EditableCell
+ value={value}
+ isModified={isModified}
+ onUpdate={(newValue) => {
+ table.options.meta?.onCellUpdate?.(row.id, "avlKind", newValue)
+ }}
+ onCancel={() => {
+ table.options.meta?.onCellCancel?.(row.id, "avlKind")
+ }}
+ />
+ )
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "htDivision",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="H/T 구분" />
+ ),
+ cell: ({ getValue, row, table }) => {
+ const value = getValue() as string
+ const isModified = getIsModified(table, row.id, "htDivision")
+ return (
+ <EditableCell
+ value={value}
+ isModified={isModified}
+ type="select"
+ options={[
+ { value: "H", label: "H" },
+ { value: "T", label: "T" },
+ ]}
+ onUpdate={(newValue) => {
+ table.options.meta?.onCellUpdate?.(row.id, "htDivision", newValue)
+ }}
+ onCancel={() => {
+ table.options.meta?.onCellCancel?.(row.id, "htDivision")
+ }}
+ />
+ )
+ },
+ size: 80,
+ },
+ {
+ accessorKey: "rev",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Rev" />
+ ),
+ cell: ({ getValue, row, table }) => {
+ const value = getValue() as number
+ return (
+ <div className="flex items-center gap-1">
+ <Badge variant="outline" className="font-mono">
+ {value || 1}
+ </Badge>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0"
+ onClick={() => table.options.meta?.onAction?.('view-history', row.original)}
+ title="리비전 히스토리 보기"
+ >
+ <History className="h-3 w-3" />
+ </Button>
+ </div>
+ )
+ },
+ size: 100,
+ },
+ ],
+ },
+
+ // 등록 정보 그룹
+ {
+ header: "등록 정보",
+ columns: [
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등재일" />
+ ),
+ cell: ({ getValue }) => {
+ const date = getValue() as string
+ return <div className="text-center text-sm">{date}</div>
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ cell: ({ getValue }) => {
+ const date = getValue() as string
+ return <div className="text-center text-sm">{date}</div>
+ },
+ size: 100,
+ },
+ ],
+ },
+
+ // 액션 그룹
+ {
+ id: "actions",
+ header: "액션",
+ columns: [
+ {
+ id: "actions",
+ header: () => <div className="text-center">액션</div>,
+ cell: ({ row, table }) => {
+ const isEmptyRow = table.options.meta?.isEmptyRow?.(row.id) || false
+
+ if (isEmptyRow) {
+ return (
+ <div className="flex items-center justify-center gap-1">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => table.options.meta?.onSaveEmptyRow?.(row.id)}
+ className="h-8 w-8 p-0"
+ >
+ 저장
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => table.options.meta?.onCancelEmptyRow?.(row.id)}
+ className="h-8 w-8 p-0 text-destructive hover:text-destructive"
+ >
+ 취소
+ </Button>
+ </div>
+ )
+ }
+
+ return (
+ <div className="flex items-center justify-center gap-1">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => table.options.meta?.onAction?.("view-detail", { id: row.original.id })}
+ className="h-8 w-8 p-0"
+ title="상세보기"
+ >
+ <Eye className="h-4 w-4" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => table.options.meta?.onAction?.("edit", { id: row.original.id })}
+ className="h-8 w-8 p-0"
+ title="수정"
+ >
+ <Edit className="h-4 w-4" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => table.options.meta?.onAction?.("delete", { id: row.original.id })}
+ className="h-8 w-8 p-0 text-destructive hover:text-destructive"
+ title="삭제"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </div>
+ )
+ },
+ enableSorting: false,
+ enableHiding: false,
+ size: 120,
+ },
+ ],
+ },
+ ]
+
+ return columns
+}
diff --git a/lib/avl/table/avl-table.tsx b/lib/avl/table/avl-table.tsx
new file mode 100644
index 00000000..eb9b2079
--- /dev/null
+++ b/lib/avl/table/avl-table.tsx
@@ -0,0 +1,554 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableFilterField,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { Button } from "@/components/ui/button"
+import { toast } from "sonner"
+
+import { getColumns } from "./avl-table-columns"
+import { createAvlListAction, updateAvlListAction, deleteAvlListAction, handleAvlActionAction } from "../service"
+import type { AvlListItem } from "../types"
+import { AvlHistoryModal, type AvlHistoryRecord } from "@/lib/avl/components/avl-history-modal"
+
+// 테이블 메타 타입 확장
+declare module "@tanstack/react-table" {
+ interface TableMeta<TData> {
+ onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise<void>
+ onCellCancel?: (id: string, field: keyof TData) => void
+ onAction?: (action: string, data?: any) => void
+ onSaveEmptyRow?: (tempId: string) => Promise<void>
+ onCancelEmptyRow?: (tempId: string) => void
+ isEmptyRow?: (id: string) => boolean
+ getPendingChanges?: () => Record<string, Partial<AvlListItem>>
+ }
+}
+
+interface AvlTableProps {
+ data: AvlListItem[]
+ pageCount?: number
+ onRefresh?: () => void // 데이터 새로고침 콜백
+ isLoading?: boolean // 로딩 상태
+ onRegistrationModeChange?: (mode: 'standard' | 'project') => void // 등록 모드 변경 콜백
+ onRowSelect?: (selectedRow: AvlListItem | null) => void // 행 선택 콜백
+}
+
+export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistrationModeChange, onRowSelect }: AvlTableProps) {
+
+ // 단일 선택을 위한 상태 (shi-vendor-po 방식)
+ const [selectedRows, setSelectedRows] = React.useState<number[]>([])
+
+ // 수정사항 추적 (일괄 저장용)
+ const [pendingChanges, setPendingChanges] = React.useState<Record<string, Partial<AvlListItem>>>({})
+ const [isSaving, setIsSaving] = React.useState(false)
+
+ // 빈 행 관리 (신규 등록용)
+ const [emptyRows, setEmptyRows] = React.useState<Record<string, AvlListItem>>({})
+ const [isCreating, setIsCreating] = React.useState(false)
+
+ // 히스토리 모달 관리
+ const [historyModalOpen, setHistoryModalOpen] = React.useState(false)
+ const [selectedAvlItem, setSelectedAvlItem] = React.useState<AvlListItem | null>(null)
+
+ // 히스토리 데이터 로드 함수
+ const loadHistoryData = React.useCallback(async (avlItem: AvlListItem): Promise<AvlHistoryRecord[]> => {
+ try {
+ // 현재 리비전의 스냅샷 데이터 (실제 저장된 데이터 사용)
+ const currentSnapshot = avlItem.vendorInfoSnapshot || []
+
+ const historyData: AvlHistoryRecord[] = [
+ {
+ id: avlItem.id,
+ rev: avlItem.rev || 1,
+ createdAt: avlItem.createdAt || new Date().toISOString(),
+ createdBy: avlItem.createdBy || "system",
+ vendorInfoSnapshot: currentSnapshot,
+ changeDescription: "최신 리비전 (확정완료)"
+ }
+ ]
+
+ // TODO: 실제 구현에서는 DB에서 이전 리비전들의 스냅샷 데이터를 조회해야 함
+ // 현재는 더미 데이터로 이전 리비전들을 시뮬레이션
+ if ((avlItem.rev || 1) > 1) {
+ for (let rev = (avlItem.rev || 1) - 1; rev >= 1; rev--) {
+ historyData.push({
+ id: avlItem.id + rev * 1000, // 임시 ID
+ rev,
+ createdAt: new Date(Date.now() - rev * 24 * 60 * 60 * 1000).toISOString(),
+ createdBy: "system",
+ vendorInfoSnapshot: [], // 이전 리비전의 스냅샷 데이터 (실제로는 DB에서 조회)
+ changeDescription: `리비전 ${rev} 변경사항`
+ })
+ }
+ }
+
+ return historyData
+ } catch (error) {
+ console.error('히스토리 로드 실패:', error)
+ toast.error("히스토리를 불러오는데 실패했습니다.")
+ return []
+ }
+ }, [])
+
+ // 필터 필드 정의
+ const filterFields: DataTableFilterField<AvlListItem>[] = [
+ {
+ id: "isTemplate",
+ label: "AVL 분류",
+ placeholder: "AVL 분류 선택...",
+ options: [
+ { label: "프로젝트 AVL", value: "false" },
+ { label: "표준 AVL", value: "true" },
+ ],
+ },
+ {
+ id: "constructionSector",
+ label: "공사부문",
+ placeholder: "공사부문 선택...",
+ options: [
+ { label: "조선", value: "조선" },
+ { label: "해양", value: "해양" },
+ ],
+ },
+ {
+ id: "htDivision",
+ label: "H/T 구분",
+ placeholder: "H/T 구분 선택...",
+ options: [
+ { label: "H", value: "H" },
+ { label: "T", value: "T" },
+ ],
+ },
+ ]
+
+
+ // 인라인 편집 핸들러 (일괄 저장용)
+ const handleCellUpdate = React.useCallback(async (id: string, field: keyof AvlListItem, newValue: any) => {
+ const isEmptyRow = String(id).startsWith('temp-')
+
+ if (isEmptyRow) {
+ // 빈 행의 경우 emptyRows 상태도 업데이트
+ setEmptyRows(prev => ({
+ ...prev,
+ [id]: {
+ ...prev[id],
+ [field]: newValue
+ }
+ }))
+ }
+
+ // pendingChanges에 변경사항 저장 (실시간 표시용)
+ setPendingChanges(prev => ({
+ ...prev,
+ [id]: {
+ ...prev[id],
+ [field]: newValue
+ }
+ }))
+ }, [])
+
+ // 편집 취소 핸들러
+ const handleCellCancel = React.useCallback((id: string, field: keyof AvlListItem) => {
+ const isEmptyRow = String(id).startsWith('temp-')
+
+ if (isEmptyRow) {
+ // 빈 행의 경우 emptyRows와 pendingChanges 모두 취소
+ setEmptyRows(prev => ({
+ ...prev,
+ [id]: {
+ ...prev[id],
+ [field]: prev[id][field] // 원래 값으로 복원 (pendingChanges의 초기값 사용)
+ }
+ }))
+
+ setPendingChanges(prev => {
+ const itemChanges = { ...prev[id] }
+ delete itemChanges[field]
+
+ if (Object.keys(itemChanges).length === 0) {
+ const newChanges = { ...prev }
+ delete newChanges[id]
+ return newChanges
+ }
+
+ return {
+ ...prev,
+ [id]: itemChanges
+ }
+ })
+ }
+ }, [])
+
+ // 액션 핸들러
+ const handleAction = React.useCallback(async (action: string, data?: any) => {
+ try {
+ switch (action) {
+ case 'new-registration':
+ // 신규 등록 - 빈 행 추가
+ const tempId = `temp-${Date.now()}`
+ const newEmptyRow: AvlListItem = {
+ id: tempId as any,
+ no: 0,
+ selected: false,
+ isTemplate: false,
+ constructionSector: "",
+ projectCode: "",
+ shipType: "",
+ avlKind: "",
+ htDivision: "",
+ rev: 1,
+ vendorInfoSnapshot: null,
+ createdAt: new Date().toISOString().split('T')[0],
+ updatedAt: new Date().toISOString().split('T')[0],
+ createdBy: "system",
+ updatedBy: "system",
+ registrant: "system",
+ lastModifier: "system",
+ }
+
+ setEmptyRows(prev => ({
+ ...prev,
+ [tempId]: newEmptyRow
+ }))
+ toast.success("신규 등록 행이 추가되었습니다.")
+ break
+
+ case 'standard-registration':
+ // 표준 AVL 등록
+ const result = await handleAvlActionAction('standard-registration')
+ if (result.success) {
+ toast.success(result.message)
+ onRegistrationModeChange?.('standard') // 등록 모드 변경 콜백 호출
+ } else {
+ toast.error(result.message)
+ }
+ break
+
+ case 'project-registration':
+ // 프로젝트 AVL 등록
+ const projectResult = await handleAvlActionAction('project-registration')
+ if (projectResult.success) {
+ toast.success(projectResult.message)
+ onRegistrationModeChange?.('project') // 등록 모드 변경 콜백 호출
+ } else {
+ toast.error(projectResult.message)
+ }
+ break
+
+ case 'bulk-import':
+ // 일괄 입력
+ const bulkResult = await handleAvlActionAction('bulk-import')
+ if (bulkResult.success) {
+ toast.success(bulkResult.message)
+ } else {
+ toast.error(bulkResult.message)
+ }
+ break
+
+ case 'save':
+ // 변경사항 저장
+ if (Object.keys(pendingChanges).length === 0) {
+ toast.info("저장할 변경사항이 없습니다.")
+ return
+ }
+
+ setIsSaving(true)
+ try {
+ // 각 변경사항을 순차적으로 저장
+ for (const [id, changes] of Object.entries(pendingChanges)) {
+ if (String(id).startsWith('temp-')) continue // 빈 행은 제외
+
+ const result = await updateAvlListAction(Number(id), changes as any)
+ if (!result) {
+ throw new Error(`항목 ${id} 저장 실패`)
+ }
+ }
+
+ setPendingChanges({})
+ toast.success("변경사항이 저장되었습니다.")
+ onRefresh?.()
+ } catch (error) {
+ console.error('저장 실패:', error)
+ toast.error("저장 중 오류가 발생했습니다.")
+ } finally {
+ setIsSaving(false)
+ }
+ break
+
+ case 'edit':
+ // 수정 모달 열기 (현재는 간단한 토스트로 처리)
+ toast.info(`${data?.id} 항목 수정`)
+ break
+
+ case 'delete':
+ // 삭제 확인 및 실행
+ if (!data?.id || String(data.id).startsWith('temp-')) return
+
+ const confirmed = window.confirm(`항목 ${data.id}을(를) 삭제하시겠습니까?`)
+ if (!confirmed) return
+
+ try {
+ const result = await deleteAvlListAction(Number(data.id))
+ if (result) {
+ toast.success("항목이 삭제되었습니다.")
+ onRefresh?.()
+ } else {
+ toast.error("삭제에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error('삭제 실패:', error)
+ toast.error("삭제 중 오류가 발생했습니다.")
+ }
+ break
+
+ case 'view-detail':
+ // 상세 조회 (페이지 이동)
+ if (data?.id && !String(data.id).startsWith('temp-')) {
+ window.location.href = `/evcp/avl/${data.id}`
+ }
+ break
+
+ case 'view-history':
+ // 리비전 히스토리 조회
+ if (data?.id && !String(data.id).startsWith('temp-')) {
+ setSelectedAvlItem(data as AvlListItem)
+ setHistoryModalOpen(true)
+ }
+ break
+
+ default:
+ toast.error(`알 수 없는 액션: ${action}`)
+ }
+ } catch (error) {
+ console.error('액션 처리 실패:', error)
+ toast.error("액션 처리 중 오류가 발생했습니다.")
+ }
+ }, [pendingChanges, onRefresh, onRegistrationModeChange])
+
+ // 빈 행 저장 핸들러
+ const handleSaveEmptyRow = React.useCallback(async (tempId: string) => {
+ const emptyRow = emptyRows[tempId]
+ if (!emptyRow) return
+
+ try {
+ setIsCreating(true)
+
+ // 필수 필드 검증
+ if (!emptyRow.constructionSector || !emptyRow.avlKind) {
+ toast.error("공사부문과 AVL 종류는 필수 입력 항목입니다.")
+ return
+ }
+
+ // 빈 행 데이터를 생성 데이터로 변환
+ const createData = {
+ isTemplate: emptyRow.isTemplate,
+ constructionSector: emptyRow.constructionSector,
+ projectCode: emptyRow.projectCode || undefined,
+ shipType: emptyRow.shipType || undefined,
+ avlKind: emptyRow.avlKind,
+ htDivision: emptyRow.htDivision || undefined,
+ rev: emptyRow.rev,
+ createdBy: "system",
+ updatedBy: "system",
+ }
+
+ const result = await createAvlListAction(createData as any)
+ if (result) {
+ // 빈 행 제거 및 성공 메시지
+ setEmptyRows(prev => {
+ const newRows = { ...prev }
+ delete newRows[tempId]
+ return newRows
+ })
+
+ // pendingChanges에서도 제거
+ setPendingChanges(prev => {
+ const newChanges = { ...prev }
+ delete newChanges[tempId]
+ return newChanges
+ })
+
+ toast.success("새 항목이 등록되었습니다.")
+ onRefresh?.()
+ } else {
+ toast.error("등록에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error('빈 행 저장 실패:', error)
+ toast.error("등록 중 오류가 발생했습니다.")
+ } finally {
+ setIsCreating(false)
+ }
+ }, [emptyRows, onRefresh])
+
+ // 빈 행 취소 핸들러
+ const handleCancelEmptyRow = React.useCallback((tempId: string) => {
+ setEmptyRows(prev => {
+ const newRows = { ...prev }
+ delete newRows[tempId]
+ return newRows
+ })
+
+ setPendingChanges(prev => {
+ const newChanges = { ...prev }
+ delete newChanges[tempId]
+ return newChanges
+ })
+
+ toast.info("등록이 취소되었습니다.")
+ }, [])
+
+ // 빈 행 포함한 전체 데이터
+ const allData = React.useMemo(() => {
+ // 로딩 중에는 빈 데이터를 표시
+ if (isLoading) {
+ return []
+ }
+ const emptyRowArray = Object.values(emptyRows)
+ return [...data, ...emptyRowArray]
+ }, [data, emptyRows, isLoading])
+
+ // 행 선택 처리 (1개만 선택 가능 - shi-vendor-po 방식)
+ const handleRowSelect = React.useCallback((id: number, selected: boolean) => {
+ if (selected) {
+ setSelectedRows([id]) // 1개만 선택
+ // 선택된 레코드 찾아서 부모 콜백 호출
+ const allData = isLoading ? [] : [...data, ...Object.values(emptyRows)]
+ const selectedRow = allData.find(row => row.id === id)
+ if (selectedRow) {
+ onRowSelect?.(selectedRow)
+ }
+ } else {
+ setSelectedRows([])
+ onRowSelect?.(null)
+ }
+ }, [data, emptyRows, isLoading, onRowSelect])
+
+ // 테이블 메타 설정
+ const tableMeta = React.useMemo(() => ({
+ onCellUpdate: handleCellUpdate,
+ onCellCancel: handleCellCancel,
+ onAction: handleAction,
+ onSaveEmptyRow: handleSaveEmptyRow,
+ onCancelEmptyRow: handleCancelEmptyRow,
+ isEmptyRow: (id: string) => String(id).startsWith('temp-'),
+ getPendingChanges: () => pendingChanges,
+ }), [handleCellUpdate, handleCellCancel, handleAction, handleSaveEmptyRow, handleCancelEmptyRow, pendingChanges])
+
+
+ // 데이터 테이블 설정
+ const { table } = useDataTable({
+ data: allData,
+ columns: getColumns({ selectedRows, onRowSelect: handleRowSelect }),
+ pageCount: pageCount || 1,
+ filterFields,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ pagination: {
+ pageIndex: 0,
+ pageSize: 10,
+ },
+ },
+ getRowId: (row) => String(row.id),
+ meta: tableMeta,
+ })
+
+ // 변경사항이 있는지 확인
+ const hasPendingChanges = Object.keys(pendingChanges).length > 0
+ const hasEmptyRows = Object.keys(emptyRows).length > 0
+
+ return (
+ <div className="space-y-4">
+ {/* 툴바 */}
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={filterFields as any}
+ >
+ <div className="flex items-center gap-2">
+ {/* 액션 버튼들 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleAction('new-registration')}
+ disabled={isCreating}
+ >
+ 신규등록
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleAction('standard-registration')}
+ >
+ 표준AVL등록
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleAction('project-registration')}
+ >
+ 프로젝트AVL등록
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleAction('bulk-import')}
+ >
+ 파일 업로드
+ </Button>
+
+ {/* 저장 버튼 - 변경사항이 있을 때만 활성화 */}
+ {(hasPendingChanges || hasEmptyRows) && (
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => handleAction('save')}
+ disabled={isSaving}
+ >
+ {isSaving ? "저장 중..." : "저장"}
+ </Button>
+ )}
+
+ {/* 새로고침 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onRefresh}
+ >
+ 새로고침
+ </Button>
+ </div>
+ </DataTableAdvancedToolbar>
+
+ {/* 데이터 테이블 */}
+ <DataTable table={table} />
+
+ {/* 히스토리 모달 */}
+ <AvlHistoryModal
+ isOpen={historyModalOpen}
+ onClose={() => {
+ setHistoryModalOpen(false)
+ setSelectedAvlItem(null)
+ }}
+ avlItem={selectedAvlItem}
+ onLoadHistory={loadHistoryData}
+ />
+
+ {/* 디버그 정보 (개발 환경에서만 표시) */}
+ {process.env.NODE_ENV === 'development' && (hasPendingChanges || hasEmptyRows) && (
+ <div className="text-xs text-muted-foreground p-2 bg-muted rounded">
+ <div>Pending Changes: {Object.keys(pendingChanges).length}</div>
+ <div>Empty Rows: {Object.keys(emptyRows).length}</div>
+ </div>
+ )}
+ </div>
+ )
+}
diff --git a/lib/avl/table/avl-vendor-add-and-modify-dialog.tsx b/lib/avl/table/avl-vendor-add-and-modify-dialog.tsx
new file mode 100644
index 00000000..174982e4
--- /dev/null
+++ b/lib/avl/table/avl-vendor-add-and-modify-dialog.tsx
@@ -0,0 +1,945 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { toast } from "sonner"
+import type { AvlVendorInfoInput, AvlDetailItem } from "../types"
+
+interface AvlVendorAddAndModifyDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onAddItem: (item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void>
+ editingItem?: AvlDetailItem // 수정할 항목 (없으면 추가 모드)
+ onUpdateItem?: (id: number, item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void>
+
+ // 모드 설정
+ isTemplate?: boolean // false: 프로젝트 AVL, true: 표준 AVL
+
+ // 표준 AVL용 초기값들 (선택적)
+ initialConstructionSector?: string
+ initialShipType?: string
+ initialAvlKind?: string
+ initialHtDivision?: string
+
+ // 프로젝트 AVL용 초기값들 (선택적)
+ initialProjectCode?: string
+}
+
+export function AvlVendorAddAndModifyDialog({
+ open,
+ onOpenChange,
+ onAddItem,
+ editingItem,
+ onUpdateItem,
+ isTemplate = false, // 기본값: 프로젝트 AVL
+ initialConstructionSector,
+ initialShipType,
+ initialAvlKind,
+ initialHtDivision,
+ initialProjectCode
+}: AvlVendorAddAndModifyDialogProps) {
+ const [formData, setFormData] = React.useState<Omit<AvlVendorInfoInput, 'avlListId'>>({
+ // 공통 기본 설정
+ isTemplate: isTemplate,
+
+ // 프로젝트 AVL용 필드들
+ projectCode: initialProjectCode || "",
+
+ // 표준 AVL용 필드들
+ constructionSector: initialConstructionSector || "",
+ shipType: initialShipType || "",
+ avlKind: initialAvlKind || "",
+ htDivision: initialHtDivision || "",
+
+ // 설계 정보
+ equipBulkDivision: "EQUIP",
+ disciplineCode: "",
+ disciplineName: "",
+
+ // 자재 정보
+ materialNameCustomerSide: "",
+
+ // 패키지 정보
+ packageCode: "",
+ packageName: "",
+
+ // 자재그룹 정보
+ materialGroupCode: "",
+ materialGroupName: "",
+
+ // 협력업체 정보
+ vendorName: "",
+ vendorCode: "",
+
+ // AVL 정보
+ avlVendorName: "",
+ tier: "",
+
+ // 제안방향
+ ownerSuggestion: false,
+ shiSuggestion: false,
+
+ // 위치 정보
+ headquarterLocation: "",
+ manufacturingLocation: "",
+
+ // FA 정보
+ faTarget: false,
+ faStatus: "",
+
+ // Agent 정보
+ isAgent: false,
+
+ // 계약 서명주체
+ contractSignerName: "",
+ contractSignerCode: "",
+
+ // SHI Qualification
+ shiAvl: false,
+ shiBlacklist: false,
+ shiBcc: false,
+
+ // 기술영업 견적결과
+ salesQuoteNumber: "",
+ quoteCode: "",
+ salesVendorInfo: "",
+ salesCountry: "",
+ totalAmount: "",
+ quoteReceivedDate: "",
+
+ // 업체 실적 현황
+ recentQuoteDate: "",
+ recentQuoteNumber: "",
+ recentOrderDate: "",
+ recentOrderNumber: "",
+
+ // 기타
+ remarks: ""
+ })
+
+ // 수정 모드일 때 폼 데이터 초기화
+ React.useEffect(() => {
+ if (editingItem) {
+ setFormData({
+ // 공통 기본 설정
+ isTemplate: editingItem.isTemplate ?? isTemplate,
+
+ // 프로젝트 AVL용 필드들
+ projectCode: editingItem.projectCode || initialProjectCode || "",
+
+ // 표준 AVL용 필드들
+ constructionSector: editingItem.constructionSector || initialConstructionSector || "",
+ shipType: editingItem.shipType || initialShipType || "",
+ avlKind: editingItem.avlKind || initialAvlKind || "",
+ htDivision: editingItem.htDivision || initialHtDivision || "",
+
+ // 설계 정보
+ equipBulkDivision: editingItem.equipBulkDivision === "EQUIP" ? "EQUIP" : "BULK",
+ disciplineCode: editingItem.disciplineCode || "",
+ disciplineName: editingItem.disciplineName || "",
+
+ // 자재 정보
+ materialNameCustomerSide: editingItem.materialNameCustomerSide || "",
+
+ // 패키지 정보
+ packageCode: editingItem.packageCode || "",
+ packageName: editingItem.packageName || "",
+
+ // 자재그룹 정보
+ materialGroupCode: editingItem.materialGroupCode || "",
+ materialGroupName: editingItem.materialGroupName || "",
+
+ // 협력업체 정보
+ vendorName: editingItem.vendorName || "",
+ vendorCode: editingItem.vendorCode || "",
+
+ // AVL 정보
+ avlVendorName: editingItem.avlVendorName || "",
+ tier: editingItem.tier || "",
+
+ // 제안방향
+ ownerSuggestion: editingItem.ownerSuggestion || false,
+ shiSuggestion: editingItem.shiSuggestion || false,
+
+ // 위치 정보
+ headquarterLocation: editingItem.headquarterLocation || "",
+ manufacturingLocation: editingItem.manufacturingLocation || "",
+
+ // FA 정보
+ faTarget: editingItem.faTarget || false,
+ faStatus: editingItem.faStatus || "",
+
+ // Agent 정보
+ isAgent: editingItem.isAgent || false,
+
+ // 계약 서명주체
+ contractSignerName: editingItem.contractSignerName || "",
+ contractSignerCode: editingItem.contractSignerCode || "",
+
+ // SHI Qualification
+ shiAvl: editingItem.shiAvl || false,
+ shiBlacklist: editingItem.shiBlacklist || false,
+ shiBcc: editingItem.shiBcc || false,
+
+ // 기술영업 견적결과
+ salesQuoteNumber: editingItem.salesQuoteNumber || "",
+ quoteCode: editingItem.quoteCode || "",
+ salesVendorInfo: editingItem.salesVendorInfo || "",
+ salesCountry: editingItem.salesCountry || "",
+ totalAmount: editingItem.totalAmount || "",
+ quoteReceivedDate: editingItem.quoteReceivedDate || "",
+
+ // 업체 실적 현황
+ recentQuoteDate: editingItem.recentQuoteDate || "",
+ recentQuoteNumber: editingItem.recentQuoteNumber || "",
+ recentOrderDate: editingItem.recentOrderDate || "",
+ recentOrderNumber: editingItem.recentOrderNumber || "",
+
+ // 기타
+ remarks: editingItem.remarks || ""
+ })
+ }
+ }, [editingItem, isTemplate, initialProjectCode, initialConstructionSector, initialShipType, initialAvlKind, initialHtDivision])
+
+ // 다이얼로그가 열릴 때 초기값 재설정 (수정 모드가 아닐 때만)
+ React.useEffect(() => {
+ if (open && !editingItem) {
+ setFormData(prev => ({
+ ...prev,
+ isTemplate: isTemplate,
+ projectCode: initialProjectCode || "",
+ constructionSector: initialConstructionSector || "",
+ shipType: initialShipType || "",
+ avlKind: initialAvlKind || "",
+ htDivision: initialHtDivision || "",
+ }))
+ }
+ }, [open, editingItem, isTemplate, initialProjectCode, initialConstructionSector, initialShipType, initialAvlKind, initialHtDivision])
+
+ const handleSubmit = async () => {
+ // 공통 필수 필드 검증
+ if (!formData.disciplineName || !formData.materialNameCustomerSide) {
+ toast.error("설계공종과 고객사 AVL 자재명은 필수 입력 항목입니다.")
+ return
+ }
+
+ // 모드별 필수 필드 검증
+ if (isTemplate) {
+ // 표준 AVL 모드
+ if (!formData.constructionSector || !formData.shipType || !formData.avlKind || !formData.htDivision) {
+ toast.error("공사부문, 선종, AVL종류, H/T 구분은 필수 입력 항목입니다.")
+ return
+ }
+ } else {
+ // 프로젝트 AVL 모드
+ if (!formData.projectCode) {
+ toast.error("프로젝트 코드는 필수 입력 항목입니다.")
+ return
+ }
+ }
+
+ try {
+ if (editingItem && onUpdateItem) {
+ // 수정 모드
+ await onUpdateItem(editingItem.id, formData)
+ } else {
+ // 추가 모드
+ await onAddItem(formData)
+ }
+
+ // 폼 초기화
+ setFormData({
+ isTemplate: isTemplate,
+ projectCode: initialProjectCode || "",
+ constructionSector: initialConstructionSector || "",
+ shipType: initialShipType || "",
+ avlKind: initialAvlKind || "",
+ htDivision: initialHtDivision || "",
+ equipBulkDivision: "EQUIP",
+ disciplineCode: "",
+ disciplineName: "",
+ materialNameCustomerSide: "",
+ packageCode: "",
+ packageName: "",
+ materialGroupCode: "",
+ materialGroupName: "",
+ vendorName: "",
+ vendorCode: "",
+ avlVendorName: "",
+ tier: "",
+ ownerSuggestion: false,
+ shiSuggestion: false,
+ headquarterLocation: "",
+ manufacturingLocation: "",
+ faTarget: false,
+ faStatus: "",
+ isAgent: false,
+ contractSignerName: "",
+ contractSignerCode: "",
+ shiAvl: false,
+ shiBlacklist: false,
+ shiBcc: false,
+ salesQuoteNumber: "",
+ quoteCode: "",
+ salesVendorInfo: "",
+ salesCountry: "",
+ totalAmount: "",
+ quoteReceivedDate: "",
+ recentQuoteDate: "",
+ recentQuoteNumber: "",
+ recentOrderDate: "",
+ recentOrderNumber: "",
+ remarks: ""
+ } as Omit<AvlVendorInfoInput, 'avlListId'>)
+
+ onOpenChange(false)
+ } catch (error) {
+ // 에러 처리는 호출하는 쪽에서 담당
+ }
+ }
+
+ const handleCancel = () => {
+ setFormData({
+ isTemplate: isTemplate,
+ projectCode: initialProjectCode || "",
+ constructionSector: initialConstructionSector || "",
+ shipType: initialShipType || "",
+ avlKind: initialAvlKind || "",
+ htDivision: initialHtDivision || "",
+ equipBulkDivision: "EQUIP",
+ disciplineCode: "",
+ disciplineName: "",
+ materialNameCustomerSide: "",
+ packageCode: "",
+ packageName: "",
+ materialGroupCode: "",
+ materialGroupName: "",
+ vendorName: "",
+ vendorCode: "",
+ avlVendorName: "",
+ tier: "",
+ ownerSuggestion: false,
+ shiSuggestion: false,
+ headquarterLocation: "",
+ manufacturingLocation: "",
+ faTarget: false,
+ faStatus: "",
+ isAgent: false,
+ contractSignerName: "",
+ contractSignerCode: "",
+ shiAvl: false,
+ shiBlacklist: false,
+ shiBcc: false,
+ salesQuoteNumber: "",
+ quoteCode: "",
+ salesVendorInfo: "",
+ salesCountry: "",
+ totalAmount: "",
+ quoteReceivedDate: "",
+ recentQuoteDate: "",
+ recentQuoteNumber: "",
+ recentOrderDate: "",
+ recentOrderNumber: "",
+ remarks: ""
+ } as Omit<AvlVendorInfoInput, 'avlListId'>)
+ onOpenChange(false)
+ }
+
+ // 선종 옵션들 (공사부문에 따라 다름)
+ const getShipTypeOptions = (constructionSector: string) => {
+ if (constructionSector === "조선") {
+ return [
+ { value: "A-max", label: "A-max" },
+ { value: "S-max", label: "S-max" },
+ { value: "VLCC", label: "VLCC" },
+ { value: "LNGC", label: "LNGC" },
+ { value: "CONT", label: "CONT" },
+ ]
+ } else if (constructionSector === "해양") {
+ return [
+ { value: "FPSO", label: "FPSO" },
+ { value: "FLNG", label: "FLNG" },
+ { value: "FPU", label: "FPU" },
+ { value: "Platform", label: "Platform" },
+ { value: "WTIV", label: "WTIV" },
+ { value: "GOM", label: "GOM" },
+ ]
+ } else {
+ return []
+ }
+ }
+
+ const shipTypeOptions = getShipTypeOptions(formData.constructionSector)
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>
+ {isTemplate ? "표준 AVL" : "프로젝트 AVL"} {editingItem ? "항목 수정" : "항목 추가"}
+ </DialogTitle>
+ <DialogDescription>
+ {editingItem
+ ? `${isTemplate ? "표준 AVL" : "프로젝트 AVL"} 항목을 수정합니다. 필수 항목을 입력해주세요.`
+ : `새로운 ${isTemplate ? "표준 AVL" : "프로젝트 AVL"} 항목을 추가합니다. 필수 항목을 입력해주세요.`
+ } * 표시된 항목은 필수 입력사항입니다.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="space-y-6 py-4">
+ {/* 모드별 필수 정보 */}
+ {!isTemplate ? (
+ // 프로젝트 AVL 모드
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">프로젝트 정보 *</h4>
+ <div className="grid grid-cols-1 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="projectCode">프로젝트 코드 *</Label>
+ <Input
+ id="projectCode"
+ value={formData.projectCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, projectCode: e.target.value }))}
+ placeholder="프로젝트 코드를 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+ ) : (
+ // 표준 AVL 모드
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">표준 AVL 기본 정보 *</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="constructionSector">공사부문 *</Label>
+ <Select
+ value={formData.constructionSector}
+ onValueChange={(value) => {
+ setFormData(prev => ({
+ ...prev,
+ constructionSector: value,
+ shipType: "" // 공사부문 변경 시 선종 초기화
+ }))
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="공사부문을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="조선">조선</SelectItem>
+ <SelectItem value="해양">해양</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="shipType">선종 *</Label>
+ <Select
+ value={formData.shipType}
+ onValueChange={(value) =>
+ setFormData(prev => ({ ...prev, shipType: value }))
+ }
+ disabled={!formData.constructionSector}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="선종을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {shipTypeOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="avlKind">AVL종류 *</Label>
+ <Select
+ value={formData.avlKind}
+ onValueChange={(value) =>
+ setFormData(prev => ({ ...prev, avlKind: value }))
+ }
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="AVL종류를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="Nearshore">Nearshore</SelectItem>
+ <SelectItem value="Offshore">Offshore</SelectItem>
+ <SelectItem value="IOC">IOC</SelectItem>
+ <SelectItem value="NOC">NOC</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="htDivision">H/T 구분 *</Label>
+ <Select
+ value={formData.htDivision}
+ onValueChange={(value) =>
+ setFormData(prev => ({ ...prev, htDivision: value }))
+ }
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="H/T 구분을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="공통">공통</SelectItem>
+ <SelectItem value="H">Hull (H)</SelectItem>
+ <SelectItem value="T">Topside (T)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* 공통 정보들 (나머지 폼 필드들은 동일하게 유지) */}
+ {/* 기본 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기본 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="equipBulkDivision">EQUIP/BULK 구분</Label>
+ <Select
+ value={formData.equipBulkDivision}
+ onValueChange={(value: "EQUIP" | "BULK") =>
+ setFormData(prev => ({ ...prev, equipBulkDivision: value }))
+ }
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="EQUIP">EQUIP</SelectItem>
+ <SelectItem value="BULK">BULK</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="disciplineCode">설계공종코드</Label>
+ <Input
+ id="disciplineCode"
+ value={formData.disciplineCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, disciplineCode: e.target.value }))}
+ placeholder="설계공종코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2 col-span-2">
+ <Label htmlFor="disciplineName">설계공종명 *</Label>
+ <Input
+ id="disciplineName"
+ value={formData.disciplineName}
+ onChange={(e) => setFormData(prev => ({ ...prev, disciplineName: e.target.value }))}
+ placeholder="설계공종명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2 col-span-2">
+ <Label htmlFor="materialNameCustomerSide">고객사 AVL 자재명 *</Label>
+ <Input
+ id="materialNameCustomerSide"
+ value={formData.materialNameCustomerSide}
+ onChange={(e) => setFormData(prev => ({ ...prev, materialNameCustomerSide: e.target.value }))}
+ placeholder="고객사 AVL 자재명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 패키지 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">패키지 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="packageCode">패키지 코드</Label>
+ <Input
+ id="packageCode"
+ value={formData.packageCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, packageCode: e.target.value }))}
+ placeholder="패키지 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="packageName">패키지 명</Label>
+ <Input
+ id="packageName"
+ value={formData.packageName}
+ onChange={(e) => setFormData(prev => ({ ...prev, packageName: e.target.value }))}
+ placeholder="패키지 명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 자재그룹 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">자재그룹 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="materialGroupCode">자재그룹 코드</Label>
+ <Input
+ id="materialGroupCode"
+ value={formData.materialGroupCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, materialGroupCode: e.target.value }))}
+ placeholder="자재그룹 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="materialGroupName">자재그룹 명</Label>
+ <Input
+ id="materialGroupName"
+ value={formData.materialGroupName}
+ onChange={(e) => setFormData(prev => ({ ...prev, materialGroupName: e.target.value }))}
+ placeholder="자재그룹 명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 협력업체 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">협력업체 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="vendorCode">협력업체 코드</Label>
+ <Input
+ id="vendorCode"
+ value={formData.vendorCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, vendorCode: e.target.value }))}
+ placeholder="협력업체 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="vendorName">협력업체 명</Label>
+ <Input
+ id="vendorName"
+ value={formData.vendorName}
+ onChange={(e) => setFormData(prev => ({ ...prev, vendorName: e.target.value }))}
+ placeholder="협력업체 명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="avlVendorName">AVL 등재업체명</Label>
+ <Input
+ id="avlVendorName"
+ value={formData.avlVendorName}
+ onChange={(e) => setFormData(prev => ({ ...prev, avlVendorName: e.target.value }))}
+ placeholder="AVL 등재업체명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="tier">등급 (Tier)</Label>
+ <Input
+ id="tier"
+ value={formData.tier}
+ onChange={(e) => setFormData(prev => ({ ...prev, tier: e.target.value }))}
+ placeholder="등급을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 제안방향 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">제안방향</h4>
+ <div className="flex gap-6">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="ownerSuggestion"
+ checked={formData.ownerSuggestion}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, ownerSuggestion: !!checked }))
+ }
+ />
+ <Label htmlFor="ownerSuggestion">선주제안</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiSuggestion"
+ checked={formData.shiSuggestion}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiSuggestion: !!checked }))
+ }
+ />
+ <Label htmlFor="shiSuggestion">SHI 제안</Label>
+ </div>
+ </div>
+ </div>
+
+ {/* 위치 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">위치 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="headquarterLocation">본사 위치 (국가)</Label>
+ <Input
+ id="headquarterLocation"
+ value={formData.headquarterLocation}
+ onChange={(e) => setFormData(prev => ({ ...prev, headquarterLocation: e.target.value }))}
+ placeholder="본사 위치를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="manufacturingLocation">제작/선적지 (국가)</Label>
+ <Input
+ id="manufacturingLocation"
+ value={formData.manufacturingLocation}
+ onChange={(e) => setFormData(prev => ({ ...prev, manufacturingLocation: e.target.value }))}
+ placeholder="제작/선적지를 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* FA 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">FA 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="faTarget"
+ checked={formData.faTarget}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, faTarget: !!checked }))
+ }
+ />
+ <Label htmlFor="faTarget">FA 대상</Label>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="faStatus">FA 현황</Label>
+ <Input
+ id="faStatus"
+ value={formData.faStatus}
+ onChange={(e) => setFormData(prev => ({ ...prev, faStatus: e.target.value }))}
+ placeholder="FA 현황을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* Agent 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">Agent 정보</h4>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="isAgent"
+ checked={formData.isAgent}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, isAgent: !!checked }))
+ }
+ />
+ <Label htmlFor="isAgent">Agent 여부</Label>
+ </div>
+ </div>
+
+ {/* 계약 서명주체 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">계약 서명주체</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="contractSignerCode">계약서명주체 코드</Label>
+ <Input
+ id="contractSignerCode"
+ value={formData.contractSignerCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractSignerCode: e.target.value }))}
+ placeholder="계약서명주체 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="contractSignerName">계약서명주체 명</Label>
+ <Input
+ id="contractSignerName"
+ value={formData.contractSignerName}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractSignerName: e.target.value }))}
+ placeholder="계약서명주체 명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* SHI Qualification */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">SHI Qualification</h4>
+ <div className="flex gap-6">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiAvl"
+ checked={formData.shiAvl}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiAvl: !!checked }))
+ }
+ />
+ <Label htmlFor="shiAvl">AVL</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiBlacklist"
+ checked={formData.shiBlacklist}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiBlacklist: !!checked }))
+ }
+ />
+ <Label htmlFor="shiBlacklist">Blacklist</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiBcc"
+ checked={formData.shiBcc}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiBcc: !!checked }))
+ }
+ />
+ <Label htmlFor="shiBcc">BCC</Label>
+ </div>
+ </div>
+ </div>
+
+ {/* 기술영업 견적결과 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기술영업 견적결과</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="salesQuoteNumber">기술영업 견적번호</Label>
+ <Input
+ id="salesQuoteNumber"
+ value={formData.salesQuoteNumber}
+ onChange={(e) => setFormData(prev => ({ ...prev, salesQuoteNumber: e.target.value }))}
+ placeholder="기술영업 견적번호를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="quoteCode">견적서 Code</Label>
+ <Input
+ id="quoteCode"
+ value={formData.quoteCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, quoteCode: e.target.value }))}
+ placeholder="견적서 Code를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="salesVendorInfo">견적 협력업체 명</Label>
+ <Input
+ id="salesVendorInfo"
+ value={formData.salesVendorInfo}
+ onChange={(e) => setFormData(prev => ({ ...prev, salesVendorInfo: e.target.value }))}
+ placeholder="견적 협력업체 명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="salesCountry">국가</Label>
+ <Input
+ id="salesCountry"
+ value={formData.salesCountry}
+ onChange={(e) => setFormData(prev => ({ ...prev, salesCountry: e.target.value }))}
+ placeholder="국가를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="totalAmount">총 금액</Label>
+ <Input
+ id="totalAmount"
+ type="number"
+ value={formData.totalAmount}
+ onChange={(e) => setFormData(prev => ({ ...prev, totalAmount: e.target.value }))}
+ placeholder="총 금액을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="quoteReceivedDate">견적접수일 (YYYY-MM-DD)</Label>
+ <Input
+ id="quoteReceivedDate"
+ value={formData.quoteReceivedDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, quoteReceivedDate: e.target.value }))}
+ placeholder="YYYY-MM-DD"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 업체 실적 현황 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">업체 실적 현황</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="recentQuoteNumber">최근견적번호</Label>
+ <Input
+ id="recentQuoteNumber"
+ value={formData.recentQuoteNumber}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteNumber: e.target.value }))}
+ placeholder="최근견적번호를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="recentQuoteDate">최근견적일 (YYYY-MM-DD)</Label>
+ <Input
+ id="recentQuoteDate"
+ value={formData.recentQuoteDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteDate: e.target.value }))}
+ placeholder="YYYY-MM-DD"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="recentOrderNumber">최근발주번호</Label>
+ <Input
+ id="recentOrderNumber"
+ value={formData.recentOrderNumber}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentOrderNumber: e.target.value }))}
+ placeholder="최근발주번호를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="recentOrderDate">최근발주일 (YYYY-MM-DD)</Label>
+ <Input
+ id="recentOrderDate"
+ value={formData.recentOrderDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentOrderDate: e.target.value }))}
+ placeholder="YYYY-MM-DD"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 기타 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기타</h4>
+ <div className="space-y-2">
+ <Label htmlFor="remarks">비고</Label>
+ <Textarea
+ id="remarks"
+ value={formData.remarks}
+ onChange={(e) => setFormData(prev => ({ ...prev, remarks: e.target.value }))}
+ placeholder="비고를 입력하세요"
+ rows={3}
+ />
+ </div>
+ </div>
+ </div>
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={handleCancel}>
+ 취소
+ </Button>
+ <Button type="button" onClick={handleSubmit}>
+ {editingItem ? "수정" : "추가"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/avl/table/columns-detail.tsx b/lib/avl/table/columns-detail.tsx
new file mode 100644
index 00000000..84ad9d9a
--- /dev/null
+++ b/lib/avl/table/columns-detail.tsx
@@ -0,0 +1,290 @@
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { type ColumnDef } from "@tanstack/react-table"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+
+
+// AVL 상세 아이템 타입
+export type AvlDetailItem = {
+ id: string
+ no: number
+ selected: boolean
+ // AVL 리스트 ID (외래키)
+ avlListId: number
+ // 설계 정보
+ equipBulkDivision: 'EQUIP' | 'BULK'
+ disciplineCode: string
+ disciplineName: string
+ // 자재 정보
+ materialNameCustomerSide: string
+ packageCode: string
+ packageName: string
+ materialGroupCode: string
+ materialGroupName: string
+ // 협력업체 정보
+ vendorId?: number
+ vendorName: string
+ vendorCode: string
+ avlVendorName: string
+ tier: string
+ // FA 정보
+ faTarget: boolean
+ faStatus: string
+ // Agent 정보
+ isAgent: boolean
+ agentStatus: string // UI 표시용
+ // 계약 서명주체
+ contractSignerId?: number
+ contractSignerName: string
+ contractSignerCode: string
+ // 위치 정보
+ headquarterLocation: string
+ manufacturingLocation: string
+ // SHI Qualification
+ shiAvl: boolean
+ shiBlacklist: boolean
+ shiBcc: boolean
+ // 기술영업 견적결과
+ salesQuoteNumber: string
+ quoteCode: string
+ salesVendorInfo: string
+ salesCountry: string
+ totalAmount: string
+ quoteReceivedDate: string
+ // 업체 실적 현황(구매)
+ recentQuoteDate: string
+ recentQuoteNumber: string
+ recentOrderDate: string
+ recentOrderNumber: string
+ // 기타
+ remarks: string
+ // 타임스탬프
+ createdAt: string
+ updatedAt: string
+}
+
+// 테이블 컬럼 정의
+export const columns: ColumnDef<AvlDetailItem>[] = [
+ // 기본 정보 그룹
+ {
+ header: "기본 정보",
+ columns: [
+ {
+ accessorKey: "no",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="No." />
+ ),
+ size: 60,
+ },
+ {
+ accessorKey: "equipBulkDivision",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Equip/Bulk 구분" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("equipBulkDivision") as string
+ return (
+ <Badge variant="outline">
+ {value || "-"}
+ </Badge>
+ )
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "disciplineName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="설계공종" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("disciplineName") as string
+ return <span>{value || "-"}</span>
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "materialNameCustomerSide",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="고객사 AVL 자재명" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("materialNameCustomerSide") as string
+ return <span>{value || "-"}</span>
+ },
+ size: 150,
+ },
+ {
+ accessorKey: "packageName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="패키지 정보" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("packageName") as string
+ return <span>{value || "-"}</span>
+ },
+ size: 130,
+ },
+ {
+ accessorKey: "materialGroupCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재그룹코드" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("materialGroupCode") as string
+ return <span>{value || "-"}</span>
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "materialGroupName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재그룹명" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("materialGroupName") as string
+ return <span>{value || "-"}</span>
+ },
+ size: 130,
+ },
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체코드" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("vendorCode") as string
+ return <span>{value || "-"}</span>
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체명" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("vendorName") as string
+ return <span className="font-medium">{value || "-"}</span>
+ },
+ size: 140,
+ },
+ {
+ accessorKey: "avlVendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="AVL 등재업체명" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("avlVendorName") as string
+ return <span>{value || "-"}</span>
+ },
+ size: 140,
+ },
+ {
+ accessorKey: "tier",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등급 (Tier)" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("tier") as string
+ if (!value) return <span>-</span>
+
+ const tierColor = {
+ "Tier 1": "bg-green-100 text-green-800",
+ "Tier 2": "bg-yellow-100 text-yellow-800",
+ "Tier 3": "bg-red-100 text-red-800"
+ }[value] || "bg-gray-100 text-gray-800"
+
+ return (
+ <Badge className={tierColor}>
+ {value}
+ </Badge>
+ )
+ },
+ size: 100,
+ },
+ ],
+ },
+ // FA 정보 그룹
+ {
+ header: "FA 정보",
+ columns: [
+ {
+ accessorKey: "faTarget",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="FA 대상" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("faTarget") as boolean
+ return (
+ <Badge variant={value ? "default" : "secondary"}>
+ {value ? "대상" : "비대상"}
+ </Badge>
+ )
+ },
+ size: 80,
+ },
+ {
+ accessorKey: "faStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="FA 현황" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("faStatus") as string
+ return <span>{value || "-"}</span>
+ },
+ size: 100,
+ },
+ ],
+ },
+ // SHI Qualification 그룹
+ {
+ header: "SHI Qualification",
+ columns: [
+ {
+ accessorKey: "shiAvl",
+ header: "AVL",
+ cell: ({ row }) => {
+ const value = row.getValue("shiAvl") as boolean
+ return (
+ <Checkbox
+ checked={value}
+ disabled
+ aria-label="SHI AVL 등재 여부"
+ />
+ )
+ },
+ size: 80,
+ },
+ {
+ accessorKey: "shiBlacklist",
+ header: "Blacklist",
+ cell: ({ row }) => {
+ const value = row.getValue("shiBlacklist") as boolean
+ return (
+ <Checkbox
+ checked={value}
+ disabled
+ aria-label="SHI Blacklist 등재 여부"
+ />
+ )
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "shiBcc",
+ header: "BCC",
+ cell: ({ row }) => {
+ const value = row.getValue("shiBcc") as boolean
+ return (
+ <Checkbox
+ checked={value}
+ disabled
+ aria-label="SHI BCC 등재 여부"
+ />
+ )
+ },
+ size: 80,
+ },
+ ],
+ },
+]
diff --git a/lib/avl/table/project-avl-add-dialog.tsx b/lib/avl/table/project-avl-add-dialog.tsx
new file mode 100644
index 00000000..509e4258
--- /dev/null
+++ b/lib/avl/table/project-avl-add-dialog.tsx
@@ -0,0 +1,779 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { toast } from "sonner"
+import type { AvlVendorInfoInput, AvlDetailItem } from "../types"
+
+interface ProjectAvlAddDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onAddItem: (item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void>
+ editingItem?: AvlDetailItem // 수정할 항목 (없으면 추가 모드)
+ onUpdateItem?: (id: number, item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void> // 수정 핸들러
+}
+
+export function ProjectAvlAddDialog({ open, onOpenChange, onAddItem, editingItem, onUpdateItem }: ProjectAvlAddDialogProps) {
+ const [formData, setFormData] = React.useState<Omit<AvlVendorInfoInput, 'avlListId'>>({
+ // 설계 정보
+ equipBulkDivision: "EQUIP",
+ disciplineCode: "",
+ disciplineName: "",
+
+ // 자재 정보
+ materialNameCustomerSide: "",
+
+ // 패키지 정보
+ packageCode: "",
+ packageName: "",
+
+ // 자재그룹 정보
+ materialGroupCode: "",
+ materialGroupName: "",
+
+ // 협력업체 정보
+ vendorName: "",
+ vendorCode: "",
+
+ // AVL 정보
+ avlVendorName: "",
+ tier: "",
+
+ // 제안방향
+ ownerSuggestion: false,
+ shiSuggestion: false,
+
+ // 위치 정보
+ headquarterLocation: "",
+ manufacturingLocation: "",
+
+ // FA 정보
+ faTarget: false,
+ faStatus: "",
+
+ // Agent 정보
+ isAgent: false,
+
+ // 계약 서명주체
+ contractSignerName: "",
+ contractSignerCode: "",
+
+ // SHI Qualification
+ shiAvl: false,
+ shiBlacklist: false,
+ shiBcc: false,
+
+ // 기술영업 견적결과
+ salesQuoteNumber: "",
+ quoteCode: "",
+ salesVendorInfo: "",
+ salesCountry: "",
+ totalAmount: "",
+ quoteReceivedDate: "",
+
+ // 업체 실적 현황
+ recentQuoteDate: "",
+ recentQuoteNumber: "",
+ recentOrderDate: "",
+ recentOrderNumber: "",
+
+ // 기타
+ remarks: ""
+ })
+
+ // 수정 모드일 때 폼 데이터 초기화
+ React.useEffect(() => {
+ if (editingItem) {
+ setFormData({
+ // 설계 정보
+ equipBulkDivision: editingItem.equipBulkDivision === "EQUIP" ? "EQUIP" : "BULK",
+ disciplineCode: editingItem.disciplineCode || "",
+ disciplineName: editingItem.disciplineName || "",
+
+ // 자재 정보
+ materialNameCustomerSide: editingItem.materialNameCustomerSide || "",
+
+ // 패키지 정보
+ packageCode: editingItem.packageCode || "",
+ packageName: editingItem.packageName || "",
+
+ // 자재그룹 정보
+ materialGroupCode: editingItem.materialGroupCode || "",
+ materialGroupName: editingItem.materialGroupName || "",
+
+ // 협력업체 정보
+ vendorName: editingItem.vendorName || "",
+ vendorCode: editingItem.vendorCode || "",
+
+ // AVL 정보
+ avlVendorName: editingItem.avlVendorName || "",
+ tier: editingItem.tier || "",
+
+ // 제안방향
+ ownerSuggestion: editingItem.ownerSuggestion || false,
+ shiSuggestion: editingItem.shiSuggestion || false,
+
+ // 위치 정보
+ headquarterLocation: editingItem.headquarterLocation || "",
+ manufacturingLocation: editingItem.manufacturingLocation || "",
+
+ // FA 정보
+ faTarget: editingItem.faTarget || false,
+ faStatus: editingItem.faStatus || "",
+
+ // Agent 정보
+ isAgent: editingItem.isAgent || false,
+
+ // 계약 서명주체
+ contractSignerName: editingItem.contractSignerName || "",
+ contractSignerCode: editingItem.contractSignerCode || "",
+
+ // SHI Qualification
+ shiAvl: editingItem.shiAvl || false,
+ shiBlacklist: editingItem.shiBlacklist || false,
+ shiBcc: editingItem.shiBcc || false,
+
+ // 기술영업 견적결과
+ salesQuoteNumber: editingItem.salesQuoteNumber || "",
+ quoteCode: editingItem.quoteCode || "",
+ salesVendorInfo: editingItem.salesVendorInfo || "",
+ salesCountry: editingItem.salesCountry || "",
+ totalAmount: editingItem.totalAmount || "",
+ quoteReceivedDate: editingItem.quoteReceivedDate || "",
+
+ // 업체 실적 현황
+ recentQuoteDate: editingItem.recentQuoteDate || "",
+ recentQuoteNumber: editingItem.recentQuoteNumber || "",
+ recentOrderDate: editingItem.recentOrderDate || "",
+ recentOrderNumber: editingItem.recentOrderNumber || "",
+
+ // 기타
+ remarks: editingItem.remarks || ""
+ })
+ }
+ }, [editingItem])
+
+ const handleSubmit = async () => {
+ // 필수 필드 검증
+ if (!formData.disciplineName || !formData.materialNameCustomerSide) {
+ toast.error("설계공종과 고객사 AVL 자재명은 필수 입력 항목입니다.")
+ return
+ }
+
+ try {
+ if (editingItem && onUpdateItem) {
+ // 수정 모드
+ await onUpdateItem(editingItem.id, formData)
+ } else {
+ // 추가 모드
+ await onAddItem(formData)
+ }
+
+ // 폼 초기화 (onAddItem에서 성공적으로 처리된 경우에만)
+ setFormData({
+ // 설계 정보
+ equipBulkDivision: "EQUIP",
+ disciplineCode: "",
+ disciplineName: "",
+
+ // 자재 정보
+ materialNameCustomerSide: "",
+
+ // 패키지 정보
+ packageCode: "",
+ packageName: "",
+
+ // 자재그룹 정보
+ materialGroupCode: "",
+ materialGroupName: "",
+
+ // 협력업체 정보
+ vendorName: "",
+ vendorCode: "",
+
+ // AVL 정보
+ avlVendorName: "",
+ tier: "",
+
+ // 제안방향
+ ownerSuggestion: false,
+ shiSuggestion: false,
+
+ // 위치 정보
+ headquarterLocation: "",
+ manufacturingLocation: "",
+
+ // FA 정보
+ faTarget: false,
+ faStatus: "",
+
+ // Agent 정보
+ isAgent: false,
+
+ // 계약 서명주체
+ contractSignerName: "",
+ contractSignerCode: "",
+
+ // SHI Qualification
+ shiAvl: false,
+ shiBlacklist: false,
+ shiBcc: false,
+
+ // 기술영업 견적결과
+ salesQuoteNumber: "",
+ quoteCode: "",
+ salesVendorInfo: "",
+ salesCountry: "",
+ totalAmount: "",
+ quoteReceivedDate: "",
+
+ // 업체 실적 현황
+ recentQuoteDate: "",
+ recentQuoteNumber: "",
+ recentOrderDate: "",
+ recentOrderNumber: "",
+
+ // 기타
+ remarks: ""
+ } as Omit<AvlVendorInfoInput, 'avlListId'>)
+
+ onOpenChange(false)
+ } catch (error) {
+ // 에러 처리는 onAddItem에서 담당하므로 여기서는 아무것도 하지 않음
+ }
+ }
+
+ const handleCancel = () => {
+ setFormData({
+ // 설계 정보
+ equipBulkDivision: "EQUIP",
+ disciplineCode: "",
+ disciplineName: "",
+
+ // 자재 정보
+ materialNameCustomerSide: "",
+
+ // 패키지 정보
+ packageCode: "",
+ packageName: "",
+
+ // 자재그룹 정보
+ materialGroupCode: "",
+ materialGroupName: "",
+
+ // 협력업체 정보
+ vendorName: "",
+ vendorCode: "",
+
+ // AVL 정보
+ avlVendorName: "",
+ tier: "",
+
+ // 제안방향
+ ownerSuggestion: false,
+ shiSuggestion: false,
+
+ // 위치 정보
+ headquarterLocation: "",
+ manufacturingLocation: "",
+
+ // FA 정보
+ faTarget: false,
+ faStatus: "",
+
+ // Agent 정보
+ isAgent: false,
+
+ // 계약 서명주체
+ contractSignerName: "",
+ contractSignerCode: "",
+
+ // SHI Qualification
+ shiAvl: false,
+ shiBlacklist: false,
+ shiBcc: false,
+
+ // 기술영업 견적결과
+ salesQuoteNumber: "",
+ quoteCode: "",
+ salesVendorInfo: "",
+ salesCountry: "",
+ totalAmount: "",
+ quoteReceivedDate: "",
+
+ // 업체 실적 현황
+ recentQuoteDate: "",
+ recentQuoteNumber: "",
+ recentOrderDate: "",
+ recentOrderNumber: "",
+
+ // 기타
+ remarks: ""
+ } as Omit<AvlVendorInfoInput, 'avlListId'>)
+ onOpenChange(false)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>{editingItem ? "프로젝트 AVL 항목 수정" : "프로젝트 AVL 항목 추가"}</DialogTitle>
+ <DialogDescription>
+ {editingItem
+ ? "AVL 항목을 수정합니다. 필수 항목을 입력해주세요."
+ : "새로운 AVL 항목을 추가합니다. 필수 항목을 입력해주세요."
+ } * 표시된 항목은 필수 입력사항입니다.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="space-y-6 py-4">
+ {/* 기본 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기본 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="equipBulkDivision">EQUIP/BULK 구분</Label>
+ <Select
+ value={formData.equipBulkDivision}
+ onValueChange={(value: "EQUIP" | "BULK") =>
+ setFormData(prev => ({ ...prev, equipBulkDivision: value }))
+ }
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="EQUIP">EQUIP</SelectItem>
+ <SelectItem value="BULK">BULK</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="disciplineCode">설계공종코드</Label>
+ <Input
+ id="disciplineCode"
+ value={formData.disciplineCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, disciplineCode: e.target.value }))}
+ placeholder="설계공종코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2 col-span-2">
+ <Label htmlFor="disciplineName">설계공종명 *</Label>
+ <Input
+ id="disciplineName"
+ value={formData.disciplineName}
+ onChange={(e) => setFormData(prev => ({ ...prev, disciplineName: e.target.value }))}
+ placeholder="설계공종명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2 col-span-2">
+ <Label htmlFor="materialNameCustomerSide">고객사 AVL 자재명 *</Label>
+ <Input
+ id="materialNameCustomerSide"
+ value={formData.materialNameCustomerSide}
+ onChange={(e) => setFormData(prev => ({ ...prev, materialNameCustomerSide: e.target.value }))}
+ placeholder="고객사 AVL 자재명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 패키지 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">패키지 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="packageCode">패키지 코드</Label>
+ <Input
+ id="packageCode"
+ value={formData.packageCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, packageCode: e.target.value }))}
+ placeholder="패키지 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="packageName">패키지 명</Label>
+ <Input
+ id="packageName"
+ value={formData.packageName}
+ onChange={(e) => setFormData(prev => ({ ...prev, packageName: e.target.value }))}
+ placeholder="패키지 명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 자재그룹 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">자재그룹 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="materialGroupCode">자재그룹 코드</Label>
+ <Input
+ id="materialGroupCode"
+ value={formData.materialGroupCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, materialGroupCode: e.target.value }))}
+ placeholder="자재그룹 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="materialGroupName">자재그룹 명</Label>
+ <Input
+ id="materialGroupName"
+ value={formData.materialGroupName}
+ onChange={(e) => setFormData(prev => ({ ...prev, materialGroupName: e.target.value }))}
+ placeholder="자재그룹 명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 협력업체 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">협력업체 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="vendorCode">협력업체 코드</Label>
+ <Input
+ id="vendorCode"
+ value={formData.vendorCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, vendorCode: e.target.value }))}
+ placeholder="협력업체 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="vendorName">협력업체 명</Label>
+ <Input
+ id="vendorName"
+ value={formData.vendorName}
+ onChange={(e) => setFormData(prev => ({ ...prev, vendorName: e.target.value }))}
+ placeholder="협력업체 명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="avlVendorName">AVL 등재업체명</Label>
+ <Input
+ id="avlVendorName"
+ value={formData.avlVendorName}
+ onChange={(e) => setFormData(prev => ({ ...prev, avlVendorName: e.target.value }))}
+ placeholder="AVL 등재업체명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="tier">등급 (Tier)</Label>
+ <Input
+ id="tier"
+ value={formData.tier}
+ onChange={(e) => setFormData(prev => ({ ...prev, tier: e.target.value }))}
+ placeholder="등급을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 제안방향 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">제안방향</h4>
+ <div className="flex gap-6">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="ownerSuggestion"
+ checked={formData.ownerSuggestion}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, ownerSuggestion: !!checked }))
+ }
+ />
+ <Label htmlFor="ownerSuggestion">선주제안</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiSuggestion"
+ checked={formData.shiSuggestion}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiSuggestion: !!checked }))
+ }
+ />
+ <Label htmlFor="shiSuggestion">SHI 제안</Label>
+ </div>
+ </div>
+ </div>
+
+ {/* 위치 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">위치 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="headquarterLocation">본사 위치 (국가)</Label>
+ <Input
+ id="headquarterLocation"
+ value={formData.headquarterLocation}
+ onChange={(e) => setFormData(prev => ({ ...prev, headquarterLocation: e.target.value }))}
+ placeholder="본사 위치를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="manufacturingLocation">제작/선적지 (국가)</Label>
+ <Input
+ id="manufacturingLocation"
+ value={formData.manufacturingLocation}
+ onChange={(e) => setFormData(prev => ({ ...prev, manufacturingLocation: e.target.value }))}
+ placeholder="제작/선적지를 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* FA 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">FA 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="faTarget"
+ checked={formData.faTarget}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, faTarget: !!checked }))
+ }
+ />
+ <Label htmlFor="faTarget">FA 대상</Label>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="faStatus">FA 현황</Label>
+ <Input
+ id="faStatus"
+ value={formData.faStatus}
+ onChange={(e) => setFormData(prev => ({ ...prev, faStatus: e.target.value }))}
+ placeholder="FA 현황을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* Agent 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">Agent 정보</h4>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="isAgent"
+ checked={formData.isAgent}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, isAgent: !!checked }))
+ }
+ />
+ <Label htmlFor="isAgent">Agent 여부</Label>
+ </div>
+ </div>
+
+ {/* 계약 서명주체 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">계약 서명주체</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="contractSignerCode">계약서명주체 코드</Label>
+ <Input
+ id="contractSignerCode"
+ value={formData.contractSignerCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractSignerCode: e.target.value }))}
+ placeholder="계약서명주체 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="contractSignerName">계약서명주체 명</Label>
+ <Input
+ id="contractSignerName"
+ value={formData.contractSignerName}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractSignerName: e.target.value }))}
+ placeholder="계약서명주체 명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* SHI Qualification */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">SHI Qualification</h4>
+ <div className="flex gap-6">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiAvl"
+ checked={formData.shiAvl}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiAvl: !!checked }))
+ }
+ />
+ <Label htmlFor="shiAvl">AVL</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiBlacklist"
+ checked={formData.shiBlacklist}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiBlacklist: !!checked }))
+ }
+ />
+ <Label htmlFor="shiBlacklist">Blacklist</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiBcc"
+ checked={formData.shiBcc}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiBcc: !!checked }))
+ }
+ />
+ <Label htmlFor="shiBcc">BCC</Label>
+ </div>
+ </div>
+ </div>
+
+ {/* 기술영업 견적결과 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기술영업 견적결과</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="salesQuoteNumber">기술영업 견적번호</Label>
+ <Input
+ id="salesQuoteNumber"
+ value={formData.salesQuoteNumber}
+ onChange={(e) => setFormData(prev => ({ ...prev, salesQuoteNumber: e.target.value }))}
+ placeholder="기술영업 견적번호를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="quoteCode">견적서 Code</Label>
+ <Input
+ id="quoteCode"
+ value={formData.quoteCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, quoteCode: e.target.value }))}
+ placeholder="견적서 Code를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="salesVendorInfo">견적 협력업체 명</Label>
+ <Input
+ id="salesVendorInfo"
+ value={formData.salesVendorInfo}
+ onChange={(e) => setFormData(prev => ({ ...prev, salesVendorInfo: e.target.value }))}
+ placeholder="견적 협력업체 명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="salesCountry">국가</Label>
+ <Input
+ id="salesCountry"
+ value={formData.salesCountry}
+ onChange={(e) => setFormData(prev => ({ ...prev, salesCountry: e.target.value }))}
+ placeholder="국가를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="totalAmount">총 금액</Label>
+ <Input
+ id="totalAmount"
+ type="number"
+ value={formData.totalAmount}
+ onChange={(e) => setFormData(prev => ({ ...prev, totalAmount: e.target.value }))}
+ placeholder="총 금액을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="quoteReceivedDate">견적접수일 (YYYY-MM-DD)</Label>
+ <Input
+ id="quoteReceivedDate"
+ value={formData.quoteReceivedDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, quoteReceivedDate: e.target.value }))}
+ placeholder="YYYY-MM-DD"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 업체 실적 현황 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">업체 실적 현황</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="recentQuoteNumber">최근견적번호</Label>
+ <Input
+ id="recentQuoteNumber"
+ value={formData.recentQuoteNumber}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteNumber: e.target.value }))}
+ placeholder="최근견적번호를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="recentQuoteDate">최근견적일 (YYYY-MM-DD)</Label>
+ <Input
+ id="recentQuoteDate"
+ value={formData.recentQuoteDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteDate: e.target.value }))}
+ placeholder="YYYY-MM-DD"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="recentOrderNumber">최근발주번호</Label>
+ <Input
+ id="recentOrderNumber"
+ value={formData.recentOrderNumber}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentOrderNumber: e.target.value }))}
+ placeholder="최근발주번호를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="recentOrderDate">최근발주일 (YYYY-MM-DD)</Label>
+ <Input
+ id="recentOrderDate"
+ value={formData.recentOrderDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentOrderDate: e.target.value }))}
+ placeholder="YYYY-MM-DD"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 기타 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기타</h4>
+ <div className="space-y-2">
+ <Label htmlFor="remarks">비고</Label>
+ <Textarea
+ id="remarks"
+ value={formData.remarks}
+ onChange={(e) => setFormData(prev => ({ ...prev, remarks: e.target.value }))}
+ placeholder="비고를 입력하세요"
+ rows={3}
+ />
+ </div>
+ </div>
+ </div>
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={handleCancel}>
+ 취소
+ </Button>
+ <Button type="button" onClick={handleSubmit}>
+ {editingItem ? "수정" : "추가"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/avl/table/project-avl-table-columns.tsx b/lib/avl/table/project-avl-table-columns.tsx
new file mode 100644
index 00000000..c052e6f7
--- /dev/null
+++ b/lib/avl/table/project-avl-table-columns.tsx
@@ -0,0 +1,167 @@
+import { ColumnDef } from "@tanstack/react-table"
+import { ProjectAvlItem } from "./project-avl-table"
+import { Checkbox } from "@/components/ui/checkbox"
+
+
+// 프로젝트 AVL 테이블 컬럼
+export const getProjectAvlColumns = (): 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,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.no}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "disciplineName",
+ header: "설계공종",
+ size: 120,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.disciplineName}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "materialNameCustomerSide",
+ header: "고객사 AVL 자재명",
+ size: 150,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.materialNameCustomerSide}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "materialGroupCode",
+ header: "자재그룹 코드",
+ size: 120,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.materialGroupCode}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "materialGroupName",
+ header: "자재그룹 명",
+ size: 130,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.materialGroupName}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "avlVendorName",
+ header: "AVL 등재업체명",
+ size: 140,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.avlVendorName}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "vendorCode",
+ header: "협력업체 코드",
+ size: 120,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.vendorCode}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "vendorName",
+ header: "협력업체 명",
+ size: 130,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.vendorName}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "ownerSuggestion",
+ header: "선주제안",
+ size: 100,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.ownerSuggestion ? "예" : "아니오"}
+ </span>
+ )
+ },
+ },
+ {
+ accessorKey: "shiSuggestion",
+ header: "SHI 제안",
+ size: 100,
+ cell: ({ row }) => {
+ return (
+ <span>
+ {row.original.shiSuggestion ? "예" : "아니오"}
+ </span>
+ )
+ },
+ },
+] \ No newline at end of file
diff --git a/lib/avl/table/project-avl-table.tsx b/lib/avl/table/project-avl-table.tsx
new file mode 100644
index 00000000..8664e32b
--- /dev/null
+++ b/lib/avl/table/project-avl-table.tsx
@@ -0,0 +1,650 @@
+"use client"
+
+import * as React from "react"
+import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel } from "@tanstack/react-table"
+import { forwardRef, useImperativeHandle, useLayoutEffect, useMemo } from "react"
+import { DataTable } from "@/components/data-table/data-table"
+import { Button } from "@/components/ui/button"
+import { AvlVendorAddAndModifyDialog } from "./avl-vendor-add-and-modify-dialog"
+import { getProjectAvlVendorInfo, createAvlVendorInfo, updateAvlVendorInfo, deleteAvlVendorInfo, finalizeProjectAvl } from "../service"
+import { getProjectInfoByProjectCode as getProjectInfoFromProjects } from "../../projects/service"
+import { getProjectInfoByProjectCode as getProjectInfoFromBiddingProjects } from "../../bidding-projects/service"
+import { GetProjectAvlSchema } from "../validations"
+import { AvlDetailItem, AvlVendorInfoInput } from "../types"
+import { toast } from "sonner"
+import { getProjectAvlColumns } from "./project-avl-table-columns"
+import {
+ ProjectDisplayField,
+ ProjectFileField
+} from "../components/project-field-components"
+import { ProjectSearchStatus } from "../components/project-field-utils"
+import { useSession } from "next-auth/react"
+
+
+// 프로젝트 AVL 테이블에서는 AvlDetailItem을 사용
+export type ProjectAvlItem = AvlDetailItem
+
+// ref를 통해 외부에서 접근할 수 있는 메소드들
+export interface ProjectAvlTableRef {
+ getSelectedIds: () => number[]
+}
+
+interface ProjectAvlTableProps {
+ onSelectionChange?: (count: number) => void
+ resetCounter?: number
+ projectCode?: string // 프로젝트 코드 필터
+ avlListId?: number // AVL 리스트 ID (관리 영역 표시용)
+ onProjectCodeChange?: (projectCode: string) => void // 프로젝트 코드 변경 콜백
+ reloadTrigger?: number
+}
+
+
+export const ProjectAvlTable = forwardRef<ProjectAvlTableRef, ProjectAvlTableProps>(({
+ onSelectionChange,
+ resetCounter,
+ projectCode,
+ avlListId,
+ onProjectCodeChange,
+ reloadTrigger
+}, ref) => {
+
+ const { data: sessionData } = useSession()
+
+ const [data, setData] = React.useState<ProjectAvlItem[]>([])
+ const [pageCount, setPageCount] = React.useState(0)
+ const [originalFile, setOriginalFile] = React.useState<string>("")
+ const [localProjectCode, setLocalProjectCode] = React.useState<string>(projectCode || "")
+
+ // 행 추가/수정 다이얼로그 상태
+ const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false)
+ const [editingItem, setEditingItem] = React.useState<AvlDetailItem | undefined>(undefined)
+
+ // 프로젝트 정보 상태
+ const [projectInfo, setProjectInfo] = React.useState<{
+ projectName: string
+ constructionSector: string
+ shipType: string
+ htDivision: string
+ } | null>(null)
+
+ // 프로젝트 검색 상태
+ const [projectSearchStatus, setProjectSearchStatus] = React.useState<ProjectSearchStatus>('idle')
+
+ // 검색 버튼 클릭 여부 상태
+ const [isSearchClicked, setIsSearchClicked] = React.useState(false)
+
+ // 페이지네이션 상태
+ const [pagination, setPagination] = React.useState({
+ pageIndex: 0,
+ pageSize: 10,
+ })
+
+
+ // 데이터 로드 함수
+ const loadData = React.useCallback(async (searchParams: Partial<GetProjectAvlSchema> = {}) => {
+ try {
+ const params = {
+ page: searchParams.page ?? 1,
+ perPage: searchParams.perPage ?? 10,
+ sort: searchParams.sort ?? [{ id: "no", desc: false }],
+ flags: searchParams.flags ?? [],
+ projectCode: localProjectCode || "",
+ equipBulkDivision: (searchParams.equipBulkDivision as "EQUIP" | "BULK") ?? "EQUIP",
+ disciplineCode: searchParams.disciplineCode ?? "",
+ disciplineName: searchParams.disciplineName ?? "",
+ materialNameCustomerSide: searchParams.materialNameCustomerSide ?? "",
+ packageCode: searchParams.packageCode ?? "",
+ packageName: searchParams.packageName ?? "",
+ materialGroupCode: searchParams.materialGroupCode ?? "",
+ materialGroupName: searchParams.materialGroupName ?? "",
+ vendorName: searchParams.vendorName ?? "",
+ vendorCode: searchParams.vendorCode ?? "",
+ avlVendorName: searchParams.avlVendorName ?? "",
+ tier: searchParams.tier ?? "",
+ filters: searchParams.filters ?? [],
+ joinOperator: searchParams.joinOperator ?? "and",
+ search: searchParams.search ?? "",
+ }
+ console.log('ProjectAvlTable - API call params:', params)
+ const result = await getProjectAvlVendorInfo(params)
+ console.log('ProjectAvlTable - API result:', {
+ dataCount: result.data.length,
+ pageCount: result.pageCount,
+ requestedPage: params.page
+ })
+ setData(result.data)
+ setPageCount(result.pageCount)
+ } catch (error) {
+ console.error("프로젝트 AVL 데이터 로드 실패:", error)
+ setData([])
+ setPageCount(0)
+ } finally {
+ // 로딩 상태 처리 완료
+ }
+ }, [localProjectCode])
+
+
+
+ // reloadTrigger가 변경될 때마다 데이터 리로드
+ React.useEffect(() => {
+ if (reloadTrigger && reloadTrigger > 0) {
+ console.log('ProjectAvlTable - reloadTrigger changed, reloading data')
+ loadData({})
+ }
+ }, [reloadTrigger, loadData])
+
+ // 초기 데이터 로드 (검색 버튼이 눌렸을 때만)
+ React.useEffect(() => {
+ if (localProjectCode && isSearchClicked) {
+ loadData({})
+ }
+ }, [loadData, localProjectCode, isSearchClicked])
+
+ // 파일 업로드 핸들러
+ const handleFileUpload = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
+ const file = event.target.files?.[0]
+ if (file) {
+ setOriginalFile(file.name)
+ // TODO: 실제 파일 업로드 로직 구현
+ console.log("파일 업로드:", file.name)
+ }
+ }, [])
+
+ // 프로젝트 검색 함수 (공통 로직)
+ const searchProject = React.useCallback(async (projectCode: string) => {
+ if (!projectCode.trim()) {
+ setProjectInfo(null)
+ setProjectSearchStatus('idle')
+ setData([])
+ setPageCount(0)
+ return
+ }
+
+ setProjectSearchStatus('searching') // 검색 시작 상태로 변경
+
+ try {
+ // 1. projects 테이블에서 먼저 검색
+ let projectData: {
+ projectName?: string | null;
+ shipType?: string;
+ projectMsrm?: string | null;
+ projectHtDivision?: string | null;
+ } | null = null
+ let searchSource = 'projects'
+
+ try {
+ projectData = await getProjectInfoFromProjects(projectCode.trim())
+ // projects에서 찾았을 때만 즉시 성공 상태로 변경
+ setProjectSearchStatus('success-projects')
+ } catch {
+ // projects 테이블에 없는 경우 biddingProjects 테이블에서 검색
+ try {
+ projectData = await getProjectInfoFromBiddingProjects(projectCode.trim())
+ if (projectData) {
+ searchSource = 'bidding-projects'
+ setProjectSearchStatus('success-bidding') // bidding에서 찾았을 때 성공 상태로 변경
+ } else {
+ // 둘 다 실패한 경우에만 에러 상태로 변경
+ setProjectInfo(null)
+ setProjectSearchStatus('error')
+ setData([])
+ setPageCount(0)
+ toast.error("입력하신 프로젝트 코드를 찾을 수 없습니다.")
+ return
+ }
+ } catch {
+ // biddingProjects에서도 에러가 발생한 경우
+ setProjectInfo(null)
+ setProjectSearchStatus('error')
+ setData([])
+ setPageCount(0)
+ toast.error("입력하신 프로젝트 코드를 찾을 수 없습니다.")
+ return
+ }
+ }
+
+ if (projectData) {
+ setProjectInfo({
+ projectName: projectData.projectName || "",
+ constructionSector: "조선", // 기본값으로 조선 설정 (필요시 로직 변경)
+ shipType: projectData.shipType || projectData.projectMsrm || "",
+ htDivision: projectData.projectHtDivision || ""
+ })
+
+ const sourceMessage = searchSource === 'projects' ? '프로젝트' : '견적프로젝트'
+ toast.success(`${sourceMessage}에서 프로젝트 정보를 성공적으로 불러왔습니다.`)
+
+ // 검색 성공 시 AVL 데이터 로드 트리거
+ setIsSearchClicked(true)
+ }
+ } catch (error) {
+ console.error("프로젝트 정보 조회 실패:", error)
+ setProjectInfo(null)
+ setProjectSearchStatus('error')
+ setData([])
+ setPageCount(0)
+ toast.error("프로젝트 정보를 불러오는데 실패했습니다.")
+ }
+ }, [setIsSearchClicked])
+
+ // 프로젝트 코드 변경 핸들러 (입력만 처리)
+ const handleProjectCodeChange = React.useCallback((value: string) => {
+ setLocalProjectCode(value)
+ onProjectCodeChange?.(value)
+
+ // 입력이 변경되면 검색 상태를 idle로 초기화하고 검색 클릭 상태를 리셋
+ if (!value.trim()) {
+ setProjectInfo(null)
+ setProjectSearchStatus('idle')
+ setIsSearchClicked(false)
+ setData([])
+ setPageCount(0)
+ } else {
+ // 새로운 프로젝트 코드가 입력되면 검색 클릭 상태를 리셋 (다시 검색 버튼을 눌러야 함)
+ setIsSearchClicked(false)
+ }
+ }, [onProjectCodeChange])
+
+ // 프로젝트 검색 버튼 핸들러
+ const handleProjectSearch = React.useCallback(async () => {
+ // 검색 시 페이지를 1페이지로 리셋
+ setPagination(prev => ({ ...prev, pageIndex: 0 }))
+ // 프로젝트 정보 검색 (성공 시 내부에서 AVL 데이터 로드 트리거)
+ await searchProject(localProjectCode)
+ }, [localProjectCode, searchProject])
+
+ // 행 추가 핸들러
+ const handleAddRow = React.useCallback(() => {
+ if (!localProjectCode.trim()) {
+ toast.error("프로젝트 코드를 먼저 입력해주세요.")
+ return
+ }
+ if (!projectInfo) {
+ toast.error("프로젝트 정보를 불러올 수 없습니다.")
+ return
+ }
+ setIsAddDialogOpen(true)
+ }, [localProjectCode, projectInfo])
+
+
+ // 다이얼로그에서 항목 추가 핸들러
+ const handleAddItem = React.useCallback(async (itemData: Omit<AvlVendorInfoInput, 'avlListId'>) => {
+ try {
+ // DB에 실제 저장할 데이터 준비 (avlListId는 나중에 최종 확정 시 설정)
+ const saveData: AvlVendorInfoInput = {
+ ...itemData,
+ projectCode: localProjectCode, // 현재 프로젝트 코드 저장
+ avlListId: avlListId || undefined // 있으면 설정, 없으면 undefined (null로 저장됨)
+ }
+
+ // DB에 저장
+ const result = await createAvlVendorInfo(saveData)
+
+ if (result) {
+ toast.success("새 항목이 성공적으로 추가되었습니다.")
+
+ // 데이터 새로고침
+ loadData({})
+ } else {
+ toast.error("항목 추가에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("항목 추가 실패:", error)
+ toast.error("항목 추가 중 오류가 발생했습니다.")
+ }
+ }, [avlListId, loadData, localProjectCode])
+
+ // 다이얼로그에서 항목 수정 핸들러
+ const handleUpdateItem = React.useCallback(async (id: number, itemData: Omit<AvlVendorInfoInput, 'avlListId'>) => {
+ try {
+ // DB에 실제 수정
+ const result = await updateAvlVendorInfo(id, itemData)
+
+ if (result) {
+ toast.success("항목이 성공적으로 수정되었습니다.")
+
+ // 데이터 새로고침
+ loadData({})
+
+ // 다이얼로그 닫기 및 수정 모드 해제
+ setIsAddDialogOpen(false)
+ setEditingItem(undefined)
+ } else {
+ toast.error("항목 수정에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("항목 수정 실패:", error)
+ toast.error("항목 수정 중 오류가 발생했습니다.")
+ }
+ }, [loadData])
+
+ // 테이블 메타 설정
+ const tableMeta = React.useMemo(() => ({}), [])
+
+ const table = useReactTable({
+ data,
+ columns: getProjectAvlColumns(),
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ manualPagination: true,
+ pageCount,
+ state: {
+ pagination,
+ },
+ onPaginationChange: (updater) => {
+ // 페이지네이션 상태 업데이트
+ const newPaginationState = typeof updater === 'function' ? updater(pagination) : updater
+
+ console.log('ProjectAvlTable - Pagination changed:', {
+ currentState: pagination,
+ newPaginationState,
+ localProjectCode,
+ isSearchClicked,
+ willLoadData: localProjectCode && isSearchClicked
+ })
+
+ setPagination(newPaginationState)
+
+ if (localProjectCode && isSearchClicked) {
+ const apiParams = {
+ page: newPaginationState.pageIndex + 1,
+ perPage: newPaginationState.pageSize,
+ }
+ console.log('ProjectAvlTable - Loading data with params:', apiParams)
+ loadData(apiParams)
+ }
+ },
+ meta: tableMeta,
+ })
+
+ // 외부에서 선택된 ID들을 가져올 수 있도록 ref에 메소드 노출
+ useImperativeHandle(ref, () => ({
+ getSelectedIds: () => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ return selectedRows.map(row => row.original.id)
+ }
+ }))
+
+ // 항목 수정 핸들러 (버튼 클릭)
+ const handleEditItem = React.useCallback(() => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+
+ if (selectedRows.length !== 1) {
+ toast.error("수정할 항목을 하나만 선택해주세요.")
+ return
+ }
+
+ const selectedItem = selectedRows[0].original
+ setEditingItem(selectedItem)
+ setIsAddDialogOpen(true)
+ }, [table])
+
+ // 항목 삭제 핸들러
+ const handleDeleteItems = React.useCallback(async () => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+
+ if (selectedRows.length === 0) {
+ toast.error("삭제할 항목을 선택해주세요.")
+ return
+ }
+
+ // 사용자 확인
+ const confirmed = window.confirm(`선택한 ${selectedRows.length}개 항목을 정말 삭제하시겠습니까?`)
+ if (!confirmed) return
+
+ try {
+ // 선택된 항목들을 DB에서 삭제
+ const deletePromises = selectedRows.map(async (row) => {
+ await deleteAvlVendorInfo(row.original.id)
+ })
+
+ await Promise.all(deletePromises)
+
+ toast.success(`${selectedRows.length}개 항목이 삭제되었습니다.`)
+
+ // 데이터 새로고침
+ loadData({})
+
+ // 선택 해제
+ table.toggleAllPageRowsSelected(false)
+ } catch (error) {
+ console.error("항목 삭제 실패:", error)
+ toast.error("항목 삭제 중 오류가 발생했습니다.")
+ }
+ }, [table, loadData])
+
+ // 최종 확정 핸들러
+ const handleFinalizeAvl = React.useCallback(async () => {
+ // 1. 필수 조건 검증
+ if (!localProjectCode.trim()) {
+ toast.error("프로젝트 코드를 먼저 입력해주세요.")
+ return
+ }
+
+ if (!projectInfo) {
+ toast.error("프로젝트 정보를 불러올 수 없습니다. 프로젝트 코드를 다시 확인해주세요.")
+ return
+ }
+
+ if (data.length === 0) {
+ toast.error("확정할 AVL 벤더 정보가 없습니다.")
+ return
+ }
+
+ // 2. 사용자 확인
+ const confirmed = window.confirm(
+ `현재 프로젝트(${localProjectCode})의 AVL을 최종 확정하시겠습니까?\n\n` +
+ `- 프로젝트명: ${projectInfo.projectName}\n` +
+ `- 벤더 정보: ${data.length}개\n` +
+ `- 공사부문: ${projectInfo.constructionSector}\n` +
+ `- 선종: ${projectInfo.shipType}\n` +
+ `- H/T 구분: ${projectInfo.htDivision}\n\n` +
+ `확정 후에는 수정이 어려울 수 있습니다.`
+ )
+
+ if (!confirmed) return
+
+ try {
+ // 3. 현재 데이터의 모든 ID 수집
+ const avlVendorInfoIds = data.map(item => item.id)
+
+ // 4. 최종 확정 실행
+ const result = await finalizeProjectAvl(
+ localProjectCode,
+ projectInfo,
+ avlVendorInfoIds,
+ sessionData?.user?.name || ""
+ )
+
+ if (result.success) {
+ toast.success(result.message)
+
+ // 5. 데이터 새로고침
+ loadData({})
+
+ // 6. 선택 해제
+ table.toggleAllPageRowsSelected(false)
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ console.error("AVL 최종 확정 실패:", error)
+ toast.error("AVL 최종 확정 중 오류가 발생했습니다.")
+ }
+ }, [localProjectCode, projectInfo, data, table, loadData, sessionData?.user?.name])
+
+ // 선택된 행 개수 (안정적인 계산을 위해 useMemo 사용)
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedRowCount = useMemo(() => {
+ const count = selectedRows.length
+ console.log('ProjectAvlTable - selectedRowCount calculated:', count)
+ return count
+ }, [selectedRows])
+
+ // 선택 상태 변경 시 콜백 호출
+ useLayoutEffect(() => {
+ console.log('ProjectAvlTable - onSelectionChange called with count:', selectedRowCount)
+ onSelectionChange?.(selectedRowCount)
+ }, [selectedRowCount, onSelectionChange])
+
+ // 선택 해제 요청이 오면 모든 선택 해제
+ React.useEffect(() => {
+ if (resetCounter && resetCounter > 0) {
+ table.toggleAllPageRowsSelected(false)
+ }
+ }, [resetCounter, table])
+
+ return (
+ <div className="h-full 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" onClick={handleAddRow}>
+ 행 추가
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleEditItem}
+ disabled={table.getFilteredSelectedRowModel().rows.length !== 1}
+ >
+ 항목 수정
+ </Button>
+ {/* <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 파일 업로드
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 자동 매핑
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 강제 매핑
+ </Button> */}
+ <Button variant="outline" size="sm" onClick={handleDeleteItems}>
+ 항목 삭제
+ </Button>
+
+ {/* 최종 확정 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleFinalizeAvl}
+ disabled={!localProjectCode.trim() || !projectInfo || data.length === 0}
+ >
+ 최종 확정
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ {/* 조회대상 관리영역 */}
+ <div className="mb-4 p-4 border rounded-lg bg-muted/50">
+ <div className="flex gap-4 overflow-x-auto pb-2">
+ {/* 프로젝트 코드 */}
+ <div className="flex flex-col gap-1 min-w-[200px]">
+ <label className="text-sm font-medium">프로젝트 코드</label>
+ <div className="flex gap-2">
+ <div className="flex-1">
+ <input
+ type="text"
+ value={localProjectCode}
+ onChange={(e) => handleProjectCodeChange(e.target.value)}
+ placeholder="프로젝트 코드를 입력하세요"
+ className={`flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 ${
+ projectSearchStatus === 'error' ? 'border-red-500' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-500' :
+ projectSearchStatus === 'searching' ? 'border-blue-500' : ''
+ }`}
+ disabled={projectSearchStatus === 'searching'}
+ />
+ {projectSearchStatus !== 'idle' && (
+ <div className="text-xs mt-1 text-muted-foreground">
+ {projectSearchStatus === 'success-projects' ? '(프로젝트)' :
+ projectSearchStatus === 'success-bidding' ? '(견적프로젝트)' :
+ projectSearchStatus === 'searching' ? '(검색 중...)' :
+ projectSearchStatus === 'error' ? '(찾을 수 없음)' :
+ undefined}
+ </div>
+ )}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleProjectSearch}
+ disabled={!localProjectCode.trim() || projectSearchStatus === 'searching'}
+ className="px-3 h-9"
+ >
+ {projectSearchStatus === 'searching' ? '검색 중...' : '검색'}
+ </Button>
+ </div>
+ </div>
+
+ {/* 프로젝트명 */}
+ <ProjectDisplayField
+ label="프로젝트명"
+ value={projectInfo?.projectName || ''}
+ status={projectSearchStatus}
+ minWidth="250px"
+ />
+
+ {/* 원본파일 */}
+ <ProjectFileField
+ label="원본파일"
+ originalFile={originalFile}
+ onFileUpload={handleFileUpload}
+ />
+
+ {/* 공사부문 */}
+ <ProjectDisplayField
+ label="공사부문"
+ value={projectInfo?.constructionSector || ''}
+ status={projectSearchStatus}
+ />
+
+ {/* 선종 */}
+ <ProjectDisplayField
+ label="선종"
+ value={projectInfo?.shipType || ''}
+ status={projectSearchStatus}
+ />
+
+ {/* H/T 구분 */}
+ <ProjectDisplayField
+ label="H/T 구분"
+ value={projectInfo?.htDivision || ''}
+ status={projectSearchStatus}
+ minWidth="140px"
+ formatter={(value) =>
+ value === 'H' ? 'Hull (H)' :
+ value === 'T' ? 'Topside (T)' : '-'
+ }
+ />
+ </div>
+ </div>
+
+ <div className="flex-1">
+ <DataTable table={table} />
+ </div>
+
+ {/* 행 추가/수정 다이얼로그 */}
+ <AvlVendorAddAndModifyDialog
+ open={isAddDialogOpen}
+ onOpenChange={(open) => {
+ setIsAddDialogOpen(open)
+ if (!open) {
+ setEditingItem(undefined) // 다이얼로그가 닫힐 때 수정 모드 해제
+ }
+ }}
+ onAddItem={handleAddItem}
+ editingItem={editingItem}
+ onUpdateItem={handleUpdateItem}
+ isTemplate={false} // 프로젝트 AVL 모드
+ initialProjectCode={localProjectCode}
+ />
+ </div>
+ )
+})
+
+ProjectAvlTable.displayName = "ProjectAvlTable"
diff --git a/lib/avl/table/standard-avl-add-dialog.tsx b/lib/avl/table/standard-avl-add-dialog.tsx
new file mode 100644
index 00000000..9e8b016c
--- /dev/null
+++ b/lib/avl/table/standard-avl-add-dialog.tsx
@@ -0,0 +1,960 @@
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { toast } from "sonner"
+import type { AvlVendorInfoInput, AvlDetailItem } from "../types"
+
+interface StandardAvlAddDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onAddItem: (item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void>
+ editingItem?: AvlDetailItem // 수정할 항목 (없으면 추가 모드)
+ onUpdateItem?: (id: number, item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void> // 수정 핸들러
+ // 검색 조건에서 선택한 값들을 초기값으로 사용
+ initialConstructionSector?: string
+ initialShipType?: string
+ initialAvlKind?: string
+ initialHtDivision?: string
+}
+
+export function StandardAvlAddDialog({
+ open,
+ onOpenChange,
+ onAddItem,
+ editingItem,
+ onUpdateItem,
+ initialConstructionSector,
+ initialShipType,
+ initialAvlKind,
+ initialHtDivision
+}: StandardAvlAddDialogProps) {
+ const [formData, setFormData] = React.useState<Omit<AvlVendorInfoInput, 'avlListId'>>({
+ // 표준 AVL용 기본 설정
+ isTemplate: true,
+
+ // 표준 AVL 필수 필드들 (검색 조건에서 선택한 값들로 초기화)
+ constructionSector: initialConstructionSector || "",
+ shipType: initialShipType || "",
+ avlKind: initialAvlKind || "",
+ htDivision: initialHtDivision || "",
+
+ // 설계 정보
+ equipBulkDivision: "EQUIP",
+ disciplineCode: "",
+ disciplineName: "",
+
+ // 자재 정보
+ materialNameCustomerSide: "",
+
+ // 패키지 정보
+ packageCode: "",
+ packageName: "",
+
+ // 자재그룹 정보
+ materialGroupCode: "",
+ materialGroupName: "",
+
+ // 협력업체 정보
+ vendorName: "",
+ vendorCode: "",
+
+ // AVL 정보
+ avlVendorName: "",
+ tier: "",
+
+ // 제안방향
+ ownerSuggestion: false,
+ shiSuggestion: false,
+
+ // 위치 정보
+ headquarterLocation: "",
+ manufacturingLocation: "",
+
+ // FA 정보
+ faTarget: false,
+ faStatus: "",
+
+ // Agent 정보
+ isAgent: false,
+
+ // 계약 서명주체
+ contractSignerName: "",
+ contractSignerCode: "",
+
+ // SHI Qualification
+ shiAvl: false,
+ shiBlacklist: false,
+ shiBcc: false,
+
+ // 기술영업 견적결과
+ salesQuoteNumber: "",
+ quoteCode: "",
+ salesVendorInfo: "",
+ salesCountry: "",
+ totalAmount: "",
+ quoteReceivedDate: "",
+
+ // 업체 실적 현황
+ recentQuoteDate: "",
+ recentQuoteNumber: "",
+ recentOrderDate: "",
+ recentOrderNumber: "",
+
+ // 기타
+ remarks: ""
+ })
+
+ // 수정 모드일 때 폼 데이터 초기화
+ React.useEffect(() => {
+ if (editingItem) {
+ setFormData({
+ // 표준 AVL용 기본 설정
+ isTemplate: true,
+
+ // 표준 AVL 필수 필드들 (기존 값 우선, 없으면 검색 조건 값 사용)
+ constructionSector: editingItem.constructionSector || initialConstructionSector || "",
+ shipType: editingItem.shipType || initialShipType || "",
+ avlKind: editingItem.avlKind || initialAvlKind || "",
+ htDivision: editingItem.htDivision || initialHtDivision || "",
+
+ // 설계 정보
+ equipBulkDivision: editingItem.equipBulkDivision === "EQUIP" ? "EQUIP" : "BULK",
+ disciplineCode: editingItem.disciplineCode || "",
+ disciplineName: editingItem.disciplineName || "",
+
+ // 자재 정보
+ materialNameCustomerSide: editingItem.materialNameCustomerSide || "",
+
+ // 패키지 정보
+ packageCode: editingItem.packageCode || "",
+ packageName: editingItem.packageName || "",
+
+ // 자재그룹 정보
+ materialGroupCode: editingItem.materialGroupCode || "",
+ materialGroupName: editingItem.materialGroupName || "",
+
+ // 협력업체 정보
+ vendorName: editingItem.vendorName || "",
+ vendorCode: editingItem.vendorCode || "",
+
+ // AVL 정보
+ avlVendorName: editingItem.avlVendorName || "",
+ tier: editingItem.tier || "",
+
+ // 제안방향
+ ownerSuggestion: editingItem.ownerSuggestion || false,
+ shiSuggestion: editingItem.shiSuggestion || false,
+
+ // 위치 정보
+ headquarterLocation: editingItem.headquarterLocation || "",
+ manufacturingLocation: editingItem.manufacturingLocation || "",
+
+ // FA 정보
+ faTarget: editingItem.faTarget || false,
+ faStatus: editingItem.faStatus || "",
+
+ // Agent 정보
+ isAgent: editingItem.isAgent || false,
+
+ // 계약 서명주체
+ contractSignerName: editingItem.contractSignerName || "",
+ contractSignerCode: editingItem.contractSignerCode || "",
+
+ // SHI Qualification
+ shiAvl: editingItem.shiAvl || false,
+ shiBlacklist: editingItem.shiBlacklist || false,
+ shiBcc: editingItem.shiBcc || false,
+
+ // 기술영업 견적결과
+ salesQuoteNumber: editingItem.salesQuoteNumber || "",
+ quoteCode: editingItem.quoteCode || "",
+ salesVendorInfo: editingItem.salesVendorInfo || "",
+ salesCountry: editingItem.salesCountry || "",
+ totalAmount: editingItem.totalAmount || "",
+ quoteReceivedDate: editingItem.quoteReceivedDate || "",
+
+ // 업체 실적 현황
+ recentQuoteDate: editingItem.recentQuoteDate || "",
+ recentQuoteNumber: editingItem.recentQuoteNumber || "",
+ recentOrderDate: editingItem.recentOrderDate || "",
+ recentOrderNumber: editingItem.recentOrderNumber || "",
+
+ // 기타
+ remarks: editingItem.remarks || ""
+ })
+ }
+ }, [editingItem, initialConstructionSector, initialShipType, initialAvlKind, initialHtDivision])
+
+ // 다이얼로그가 열릴 때 초기값 재설정 (수정 모드가 아닐 때만)
+ React.useEffect(() => {
+ if (open && !editingItem) {
+ setFormData(prev => ({
+ ...prev,
+ constructionSector: initialConstructionSector || "",
+ shipType: initialShipType || "",
+ avlKind: initialAvlKind || "",
+ htDivision: initialHtDivision || "",
+ }))
+ }
+ }, [open, editingItem, initialConstructionSector, initialShipType, initialAvlKind, initialHtDivision])
+
+ const handleSubmit = async () => {
+ // 필수 필드 검증 (표준 AVL용)
+ if (!formData.constructionSector || !formData.shipType || !formData.avlKind || !formData.htDivision) {
+ toast.error("공사부문, 선종, AVL종류, H/T 구분은 필수 입력 항목입니다.")
+ return
+ }
+
+ if (!formData.disciplineName || !formData.materialNameCustomerSide) {
+ toast.error("설계공종과 고객사 AVL 자재명은 필수 입력 항목입니다.")
+ return
+ }
+
+ try {
+ if (editingItem && onUpdateItem) {
+ // 수정 모드
+ await onUpdateItem(editingItem.id, formData)
+ } else {
+ // 추가 모드
+ await onAddItem(formData)
+ }
+
+ // 폼 초기화 (onAddItem에서 성공적으로 처리된 경우에만)
+ setFormData({
+ // 표준 AVL용 기본 설정
+ isTemplate: true,
+
+ // 표준 AVL 필수 필드들
+ constructionSector: "",
+ shipType: "",
+ avlKind: "",
+ htDivision: "",
+
+ // 설계 정보
+ equipBulkDivision: "EQUIP",
+ disciplineCode: "",
+ disciplineName: "",
+
+ // 자재 정보
+ materialNameCustomerSide: "",
+
+ // 패키지 정보
+ packageCode: "",
+ packageName: "",
+
+ // 자재그룹 정보
+ materialGroupCode: "",
+ materialGroupName: "",
+
+ // 협력업체 정보
+ vendorName: "",
+ vendorCode: "",
+
+ // AVL 정보
+ avlVendorName: "",
+ tier: "",
+
+ // 제안방향
+ ownerSuggestion: false,
+ shiSuggestion: false,
+
+ // 위치 정보
+ headquarterLocation: "",
+ manufacturingLocation: "",
+
+ // FA 정보
+ faTarget: false,
+ faStatus: "",
+
+ // Agent 정보
+ isAgent: false,
+
+ // 계약 서명주체
+ contractSignerName: "",
+ contractSignerCode: "",
+
+ // SHI Qualification
+ shiAvl: false,
+ shiBlacklist: false,
+ shiBcc: false,
+
+ // 기술영업 견적결과
+ salesQuoteNumber: "",
+ quoteCode: "",
+ salesVendorInfo: "",
+ salesCountry: "",
+ totalAmount: "",
+ quoteReceivedDate: "",
+
+ // 업체 실적 현황
+ recentQuoteDate: "",
+ recentQuoteNumber: "",
+ recentOrderDate: "",
+ recentOrderNumber: "",
+
+ // 기타
+ remarks: ""
+ } as Omit<AvlVendorInfoInput, 'avlListId'>)
+
+ onOpenChange(false)
+ } catch (error) {
+ // 에러 처리는 onAddItem에서 담당하므로 여기서는 아무것도 하지 않음
+ }
+ }
+
+ const handleCancel = () => {
+ setFormData({
+ // 표준 AVL용 기본 설정
+ isTemplate: true,
+
+ // 표준 AVL 필수 필드들
+ constructionSector: "",
+ shipType: "",
+ avlKind: "",
+ htDivision: "",
+
+ // 설계 정보
+ equipBulkDivision: "EQUIP",
+ disciplineCode: "",
+ disciplineName: "",
+
+ // 자재 정보
+ materialNameCustomerSide: "",
+
+ // 패키지 정보
+ packageCode: "",
+ packageName: "",
+
+ // 자재그룹 정보
+ materialGroupCode: "",
+ materialGroupName: "",
+
+ // 협력업체 정보
+ vendorName: "",
+ vendorCode: "",
+
+ // AVL 정보
+ avlVendorName: "",
+ tier: "",
+
+ // 제안방향
+ ownerSuggestion: false,
+ shiSuggestion: false,
+
+ // 위치 정보
+ headquarterLocation: "",
+ manufacturingLocation: "",
+
+ // FA 정보
+ faTarget: false,
+ faStatus: "",
+
+ // Agent 정보
+ isAgent: false,
+
+ // 계약 서명주체
+ contractSignerName: "",
+ contractSignerCode: "",
+
+ // SHI Qualification
+ shiAvl: false,
+ shiBlacklist: false,
+ shiBcc: false,
+
+ // 기술영업 견적결과
+ salesQuoteNumber: "",
+ quoteCode: "",
+ salesVendorInfo: "",
+ salesCountry: "",
+ totalAmount: "",
+ quoteReceivedDate: "",
+
+ // 업체 실적 현황
+ recentQuoteDate: "",
+ recentQuoteNumber: "",
+ recentOrderDate: "",
+ recentOrderNumber: "",
+
+ // 기타
+ remarks: ""
+ } as Omit<AvlVendorInfoInput, 'avlListId'>)
+ onOpenChange(false)
+ }
+
+ // 선종 옵션들 (공사부문에 따라 다름)
+ const getShipTypeOptions = (constructionSector: string) => {
+ if (constructionSector === "조선") {
+ return [
+ { value: "A-max", label: "A-max" },
+ { value: "S-max", label: "S-max" },
+ { value: "VLCC", label: "VLCC" },
+ { value: "LNGC", label: "LNGC" },
+ { value: "CONT", label: "CONT" },
+ ]
+ } else if (constructionSector === "해양") {
+ return [
+ { value: "FPSO", label: "FPSO" },
+ { value: "FLNG", label: "FLNG" },
+ { value: "FPU", label: "FPU" },
+ { value: "Platform", label: "Platform" },
+ { value: "WTIV", label: "WTIV" },
+ { value: "GOM", label: "GOM" },
+ ]
+ } else {
+ return []
+ }
+ }
+
+ const shipTypeOptions = getShipTypeOptions(formData.constructionSector)
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>{editingItem ? "표준 AVL 항목 수정" : "표준 AVL 항목 추가"}</DialogTitle>
+ <DialogDescription>
+ {editingItem
+ ? "표준 AVL 항목을 수정합니다. 필수 항목을 입력해주세요."
+ : "새로운 표준 AVL 항목을 추가합니다. 필수 항목을 입력해주세요."
+ } * 표시된 항목은 필수 입력사항입니다.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="space-y-6 py-4">
+ {/* 표준 AVL 기본 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">표준 AVL 기본 정보 *</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="constructionSector">공사부문 *</Label>
+ <Select
+ value={formData.constructionSector}
+ onValueChange={(value) => {
+ setFormData(prev => ({
+ ...prev,
+ constructionSector: value,
+ shipType: "" // 공사부문 변경 시 선종 초기화
+ }))
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="공사부문을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="조선">조선</SelectItem>
+ <SelectItem value="해양">해양</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="shipType">선종 *</Label>
+ <Select
+ value={formData.shipType}
+ onValueChange={(value) =>
+ setFormData(prev => ({ ...prev, shipType: value }))
+ }
+ disabled={!formData.constructionSector}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="선종을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {shipTypeOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="avlKind">AVL종류 *</Label>
+ <Select
+ value={formData.avlKind}
+ onValueChange={(value) =>
+ setFormData(prev => ({ ...prev, avlKind: value }))
+ }
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="AVL종류를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="Nearshore">Nearshore</SelectItem>
+ <SelectItem value="Offshore">Offshore</SelectItem>
+ <SelectItem value="IOC">IOC</SelectItem>
+ <SelectItem value="NOC">NOC</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="htDivision">H/T 구분 *</Label>
+ <Select
+ value={formData.htDivision}
+ onValueChange={(value) =>
+ setFormData(prev => ({ ...prev, htDivision: value }))
+ }
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="H/T 구분을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="공통">공통</SelectItem>
+ <SelectItem value="H">Hull (H)</SelectItem>
+ <SelectItem value="T">Topside (T)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </div>
+
+ {/* 기본 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기본 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="equipBulkDivision">EQUIP/BULK 구분</Label>
+ <Select
+ value={formData.equipBulkDivision}
+ onValueChange={(value: "EQUIP" | "BULK") =>
+ setFormData(prev => ({ ...prev, equipBulkDivision: value }))
+ }
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="EQUIP">EQUIP</SelectItem>
+ <SelectItem value="BULK">BULK</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="disciplineCode">설계공종코드</Label>
+ <Input
+ id="disciplineCode"
+ value={formData.disciplineCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, disciplineCode: e.target.value }))}
+ placeholder="설계공종코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2 col-span-2">
+ <Label htmlFor="disciplineName">설계공종명 *</Label>
+ <Input
+ id="disciplineName"
+ value={formData.disciplineName}
+ onChange={(e) => setFormData(prev => ({ ...prev, disciplineName: e.target.value }))}
+ placeholder="설계공종명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2 col-span-2">
+ <Label htmlFor="materialNameCustomerSide">고객사 AVL 자재명 *</Label>
+ <Input
+ id="materialNameCustomerSide"
+ value={formData.materialNameCustomerSide}
+ onChange={(e) => setFormData(prev => ({ ...prev, materialNameCustomerSide: e.target.value }))}
+ placeholder="고객사 AVL 자재명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 패키지 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">패키지 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="packageCode">패키지 코드</Label>
+ <Input
+ id="packageCode"
+ value={formData.packageCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, packageCode: e.target.value }))}
+ placeholder="패키지 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="packageName">패키지 명</Label>
+ <Input
+ id="packageName"
+ value={formData.packageName}
+ onChange={(e) => setFormData(prev => ({ ...prev, packageName: e.target.value }))}
+ placeholder="패키지 명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 자재그룹 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">자재그룹 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="materialGroupCode">자재그룹 코드</Label>
+ <Input
+ id="materialGroupCode"
+ value={formData.materialGroupCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, materialGroupCode: e.target.value }))}
+ placeholder="자재그룹 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="materialGroupName">자재그룹 명</Label>
+ <Input
+ id="materialGroupName"
+ value={formData.materialGroupName}
+ onChange={(e) => setFormData(prev => ({ ...prev, materialGroupName: e.target.value }))}
+ placeholder="자재그룹 명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 협력업체 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">협력업체 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="vendorCode">협력업체 코드</Label>
+ <Input
+ id="vendorCode"
+ value={formData.vendorCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, vendorCode: e.target.value }))}
+ placeholder="협력업체 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="vendorName">협력업체 명</Label>
+ <Input
+ id="vendorName"
+ value={formData.vendorName}
+ onChange={(e) => setFormData(prev => ({ ...prev, vendorName: e.target.value }))}
+ placeholder="협력업체 명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="avlVendorName">AVL 등재업체명</Label>
+ <Input
+ id="avlVendorName"
+ value={formData.avlVendorName}
+ onChange={(e) => setFormData(prev => ({ ...prev, avlVendorName: e.target.value }))}
+ placeholder="AVL 등재업체명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="tier">등급 (Tier)</Label>
+ <Input
+ id="tier"
+ value={formData.tier}
+ onChange={(e) => setFormData(prev => ({ ...prev, tier: e.target.value }))}
+ placeholder="등급을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 제안방향 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">제안방향</h4>
+ <div className="flex gap-6">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="ownerSuggestion"
+ checked={formData.ownerSuggestion}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, ownerSuggestion: !!checked }))
+ }
+ />
+ <Label htmlFor="ownerSuggestion">선주제안</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiSuggestion"
+ checked={formData.shiSuggestion}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiSuggestion: !!checked }))
+ }
+ />
+ <Label htmlFor="shiSuggestion">SHI 제안</Label>
+ </div>
+ </div>
+ </div>
+
+ {/* 위치 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">위치 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="headquarterLocation">본사 위치 (국가)</Label>
+ <Input
+ id="headquarterLocation"
+ value={formData.headquarterLocation}
+ onChange={(e) => setFormData(prev => ({ ...prev, headquarterLocation: e.target.value }))}
+ placeholder="본사 위치를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="manufacturingLocation">제작/선적지 (국가)</Label>
+ <Input
+ id="manufacturingLocation"
+ value={formData.manufacturingLocation}
+ onChange={(e) => setFormData(prev => ({ ...prev, manufacturingLocation: e.target.value }))}
+ placeholder="제작/선적지를 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* FA 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">FA 정보</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="faTarget"
+ checked={formData.faTarget}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, faTarget: !!checked }))
+ }
+ />
+ <Label htmlFor="faTarget">FA 대상</Label>
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="faStatus">FA 현황</Label>
+ <Input
+ id="faStatus"
+ value={formData.faStatus}
+ onChange={(e) => setFormData(prev => ({ ...prev, faStatus: e.target.value }))}
+ placeholder="FA 현황을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* Agent 정보 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">Agent 정보</h4>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="isAgent"
+ checked={formData.isAgent}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, isAgent: !!checked }))
+ }
+ />
+ <Label htmlFor="isAgent">Agent 여부</Label>
+ </div>
+ </div>
+
+ {/* 계약 서명주체 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">계약 서명주체</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="contractSignerCode">계약서명주체 코드</Label>
+ <Input
+ id="contractSignerCode"
+ value={formData.contractSignerCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractSignerCode: e.target.value }))}
+ placeholder="계약서명주체 코드를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="contractSignerName">계약서명주체 명</Label>
+ <Input
+ id="contractSignerName"
+ value={formData.contractSignerName}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractSignerName: e.target.value }))}
+ placeholder="계약서명주체 명을 입력하세요"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* SHI Qualification */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">SHI Qualification</h4>
+ <div className="flex gap-6">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiAvl"
+ checked={formData.shiAvl}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiAvl: !!checked }))
+ }
+ />
+ <Label htmlFor="shiAvl">AVL</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiBlacklist"
+ checked={formData.shiBlacklist}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiBlacklist: !!checked }))
+ }
+ />
+ <Label htmlFor="shiBlacklist">Blacklist</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="shiBcc"
+ checked={formData.shiBcc}
+ onCheckedChange={(checked) =>
+ setFormData(prev => ({ ...prev, shiBcc: !!checked }))
+ }
+ />
+ <Label htmlFor="shiBcc">BCC</Label>
+ </div>
+ </div>
+ </div>
+
+ {/* 기술영업 견적결과 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기술영업 견적결과</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="salesQuoteNumber">기술영업 견적번호</Label>
+ <Input
+ id="salesQuoteNumber"
+ value={formData.salesQuoteNumber}
+ onChange={(e) => setFormData(prev => ({ ...prev, salesQuoteNumber: e.target.value }))}
+ placeholder="기술영업 견적번호를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="quoteCode">견적서 Code</Label>
+ <Input
+ id="quoteCode"
+ value={formData.quoteCode}
+ onChange={(e) => setFormData(prev => ({ ...prev, quoteCode: e.target.value }))}
+ placeholder="견적서 Code를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="salesVendorInfo">견적 협력업체 명</Label>
+ <Input
+ id="salesVendorInfo"
+ value={formData.salesVendorInfo}
+ onChange={(e) => setFormData(prev => ({ ...prev, salesVendorInfo: e.target.value }))}
+ placeholder="견적 협력업체 명을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="salesCountry">국가</Label>
+ <Input
+ id="salesCountry"
+ value={formData.salesCountry}
+ onChange={(e) => setFormData(prev => ({ ...prev, salesCountry: e.target.value }))}
+ placeholder="국가를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="totalAmount">총 금액</Label>
+ <Input
+ id="totalAmount"
+ type="number"
+ value={formData.totalAmount}
+ onChange={(e) => setFormData(prev => ({ ...prev, totalAmount: e.target.value }))}
+ placeholder="총 금액을 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="quoteReceivedDate">견적접수일 (YYYY-MM-DD)</Label>
+ <Input
+ id="quoteReceivedDate"
+ value={formData.quoteReceivedDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, quoteReceivedDate: e.target.value }))}
+ placeholder="YYYY-MM-DD"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 업체 실적 현황 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">업체 실적 현황</h4>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="recentQuoteNumber">최근견적번호</Label>
+ <Input
+ id="recentQuoteNumber"
+ value={formData.recentQuoteNumber}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteNumber: e.target.value }))}
+ placeholder="최근견적번호를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="recentQuoteDate">최근견적일 (YYYY-MM-DD)</Label>
+ <Input
+ id="recentQuoteDate"
+ value={formData.recentQuoteDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteDate: e.target.value }))}
+ placeholder="YYYY-MM-DD"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="recentOrderNumber">최근발주번호</Label>
+ <Input
+ id="recentOrderNumber"
+ value={formData.recentOrderNumber}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentOrderNumber: e.target.value }))}
+ placeholder="최근발주번호를 입력하세요"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="recentOrderDate">최근발주일 (YYYY-MM-DD)</Label>
+ <Input
+ id="recentOrderDate"
+ value={formData.recentOrderDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, recentOrderDate: e.target.value }))}
+ placeholder="YYYY-MM-DD"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 기타 */}
+ <div className="space-y-4">
+ <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기타</h4>
+ <div className="space-y-2">
+ <Label htmlFor="remarks">비고</Label>
+ <Textarea
+ id="remarks"
+ value={formData.remarks}
+ onChange={(e) => setFormData(prev => ({ ...prev, remarks: e.target.value }))}
+ placeholder="비고를 입력하세요"
+ rows={3}
+ />
+ </div>
+ </div>
+ </div>
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={handleCancel}>
+ 취소
+ </Button>
+ <Button type="button" onClick={handleSubmit}>
+ {editingItem ? "수정" : "추가"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/avl/table/standard-avl-table-columns.tsx b/lib/avl/table/standard-avl-table-columns.tsx
new file mode 100644
index 00000000..903d2590
--- /dev/null
+++ b/lib/avl/table/standard-avl-table-columns.tsx
@@ -0,0 +1,91 @@
+import { Checkbox } from "@/components/ui/checkbox"
+import { ColumnDef } from "@tanstack/react-table"
+import { StandardAvlItem } from "./standard-avl-table"
+
+// 선종별 표준 AVL 테이블 컬럼
+export 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: "disciplineName",
+ header: "설계공종",
+ size: 120,
+ },
+ {
+ accessorKey: "avlVendorName",
+ header: "AVL 등재업체명",
+ size: 140,
+ },
+ {
+ accessorKey: "materialGroupCode",
+ header: "자재그룹 코드",
+ size: 120,
+ },
+ {
+ accessorKey: "materialGroupName",
+ header: "자재그룹 명",
+ size: 130,
+ },
+ {
+ accessorKey: "vendorCode",
+ header: "협력업체 코드",
+ size: 120,
+ },
+ {
+ accessorKey: "vendorName",
+ header: "협력업체 명",
+ size: 130,
+ },
+ {
+ accessorKey: "headquarterLocation",
+ header: "본사 위치 (국가)",
+ size: 140,
+ },
+ {
+ accessorKey: "tier",
+ header: "등급 (Tier)",
+ size: 120,
+ },
+] \ No newline at end of file
diff --git a/lib/avl/table/standard-avl-table.tsx b/lib/avl/table/standard-avl-table.tsx
new file mode 100644
index 00000000..cc39540b
--- /dev/null
+++ b/lib/avl/table/standard-avl-table.tsx
@@ -0,0 +1,651 @@
+"use client"
+
+import * as React from "react"
+import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel } from "@tanstack/react-table"
+import { useLayoutEffect, useMemo, forwardRef, useImperativeHandle } from "react"
+import { DataTable } from "@/components/data-table/data-table"
+import { Button } from "@/components/ui/button"
+import { getStandardAvlVendorInfo } from "../service"
+import { GetStandardAvlSchema } from "../validations"
+import { AvlDetailItem } from "../types"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Search } from "lucide-react"
+import { toast } from "sonner"
+import { standardAvlColumns } from "./standard-avl-table-columns"
+import { AvlVendorAddAndModifyDialog } from "./avl-vendor-add-and-modify-dialog"
+import { createAvlVendorInfo, updateAvlVendorInfo, deleteAvlVendorInfo, finalizeStandardAvl } from "../service"
+import { AvlVendorInfoInput } from "../types"
+import { useSession } from "next-auth/react"
+
+/**
+ * 조선인 경우, 선종:
+ * A-max, S-max, VLCC, LNGC, CONT
+ * 해양인 경우, 선종:
+ * FPSO, FLNG, FPU, Platform, WTIV, GOM
+ *
+ * AVL종류:
+ * Nearshore, Offshore, IOC, NOC
+ */
+
+// 검색 옵션들
+const constructionSectorOptions = [
+ { value: "조선", label: "조선" },
+ { value: "해양", label: "해양" },
+]
+
+// 공사부문에 따른 선종 옵션들
+const getShipTypeOptions = (constructionSector: string) => {
+ if (constructionSector === "조선") {
+ return [
+ { value: "A-max", label: "A-max" },
+ { value: "S-max", label: "S-max" },
+ { value: "VLCC", label: "VLCC" },
+ { value: "LNGC", label: "LNGC" },
+ { value: "CONT", label: "CONT" },
+ ]
+ } else if (constructionSector === "해양") {
+ return [
+ { value: "FPSO", label: "FPSO" },
+ { value: "FLNG", label: "FLNG" },
+ { value: "FPU", label: "FPU" },
+ { value: "Platform", label: "Platform" },
+ { value: "WTIV", label: "WTIV" },
+ { value: "GOM", label: "GOM" },
+ ]
+ } else {
+ // 공사부문이 선택되지 않은 경우 빈 배열
+ return []
+ }
+}
+
+const avlKindOptions = [
+ { value: "Nearshore", label: "Nearshore" },
+ { value: "Offshore", label: "Offshore" },
+ { value: "IOC", label: "IOC" },
+ { value: "NOC", label: "NOC" },
+]
+
+const htDivisionOptions = [
+ { value: "공통", label: "공통" },
+ { value: "H", label: "Hull (H)" },
+ { value: "T", label: "Top (T)" },
+]
+
+// 선종별 표준 AVL 테이블에서는 AvlDetailItem을 사용
+export type StandardAvlItem = AvlDetailItem
+
+// ref를 통해 외부에서 접근할 수 있는 메소드들
+export interface StandardAvlTableRef {
+ getSelectedIds: () => number[]
+}
+
+interface StandardAvlTableProps {
+ onSelectionChange?: (count: number) => void
+ resetCounter?: number
+ constructionSector?: string // 공사부문 필터
+ shipType?: string // 선종 필터
+ avlKind?: string // AVL 종류 필터
+ htDivision?: string // H/T 구분 필터
+ onSearchConditionsChange?: (conditions: {
+ constructionSector: string
+ shipType: string
+ avlKind: string
+ htDivision: string
+ }) => void
+ reloadTrigger?: number
+}
+
+export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTableProps>(({
+ onSelectionChange,
+ resetCounter,
+ constructionSector: initialConstructionSector,
+ shipType: initialShipType,
+ avlKind: initialAvlKind,
+ htDivision: initialHtDivision,
+ onSearchConditionsChange,
+ reloadTrigger
+}, ref) => {
+ const { data: sessionData } = useSession()
+
+ const [data, setData] = React.useState<StandardAvlItem[]>([])
+ const [loading, setLoading] = React.useState(false)
+ const [pageCount, setPageCount] = React.useState(0)
+
+ // 다이얼로그 상태
+ const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false)
+ const [editingItem, setEditingItem] = React.useState<StandardAvlItem | undefined>(undefined)
+
+ // 검색 상태
+ const [searchConstructionSector, setSearchConstructionSector] = React.useState(initialConstructionSector || "")
+ const [searchShipType, setSearchShipType] = React.useState(initialShipType || "")
+ const [searchAvlKind, setSearchAvlKind] = React.useState(initialAvlKind || "")
+ const [searchHtDivision, setSearchHtDivision] = React.useState(initialHtDivision || "")
+
+ // 페이지네이션 상태
+ const [pagination, setPagination] = React.useState({
+ pageIndex: 0,
+ pageSize: 10,
+ })
+
+ const table = useReactTable({
+ data,
+ columns: standardAvlColumns,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ manualPagination: true,
+ pageCount,
+ state: {
+ pagination,
+ },
+ onPaginationChange: (updater) => {
+ // 페이지네이션 상태 업데이트
+ const newPaginationState = typeof updater === 'function' ? updater(pagination) : updater
+
+ console.log('StandardAvlTable - Pagination changed:', {
+ currentState: pagination,
+ newPaginationState,
+ isAllSearchConditionsSelected,
+ willLoadData: isAllSearchConditionsSelected
+ })
+
+ setPagination(newPaginationState)
+
+ if (isAllSearchConditionsSelected) {
+ const apiParams = {
+ page: newPaginationState.pageIndex + 1,
+ perPage: newPaginationState.pageSize,
+ }
+ console.log('StandardAvlTable - Loading data with params:', apiParams)
+ loadData(apiParams)
+ }
+ },
+ })
+
+ // 공사부문 변경 시 선종 초기화
+ const handleConstructionSectorChange = React.useCallback((value: string) => {
+ setSearchConstructionSector(value)
+ // 공사부문이 변경되면 선종을 빈 값으로 초기화
+ setSearchShipType("")
+ }, [])
+
+ // 검색 상태 변경 시 부모 컴포넌트에 전달
+ React.useEffect(() => {
+ onSearchConditionsChange?.({
+ constructionSector: searchConstructionSector,
+ shipType: searchShipType,
+ avlKind: searchAvlKind,
+ htDivision: searchHtDivision
+ })
+ }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision, onSearchConditionsChange])
+
+ // 현재 공사부문에 따른 선종 옵션들
+ const currentShipTypeOptions = React.useMemo(() =>
+ getShipTypeOptions(searchConstructionSector),
+ [searchConstructionSector]
+ )
+
+ // 모든 검색 조건이 선택되었는지 확인
+ const isAllSearchConditionsSelected = React.useMemo(() => {
+ return (
+ searchConstructionSector.trim() !== "" &&
+ searchShipType.trim() !== "" &&
+ searchAvlKind.trim() !== "" &&
+ searchHtDivision.trim() !== ""
+ )
+ }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision])
+
+ // 데이터 로드 함수
+ const loadData = React.useCallback(async (searchParams: Partial<GetStandardAvlSchema> = {}) => {
+ try {
+ setLoading(true)
+
+ const params: GetStandardAvlSchema = {
+ page: searchParams.page ?? 1,
+ perPage: searchParams.perPage ?? 10,
+ sort: searchParams.sort ?? [{ id: "no", desc: false }],
+ flags: searchParams.flags ?? [],
+ constructionSector: searchConstructionSector,
+ shipType: searchShipType,
+ avlKind: searchAvlKind,
+ htDivision: searchHtDivision as "공통" | "H" | "T" | "",
+ equipBulkDivision: searchParams.equipBulkDivision || "",
+ disciplineCode: searchParams.disciplineCode ?? "",
+ disciplineName: searchParams.disciplineName ?? "",
+ materialNameCustomerSide: searchParams.materialNameCustomerSide ?? "",
+ packageCode: searchParams.packageCode ?? "",
+ packageName: searchParams.packageName ?? "",
+ materialGroupCode: searchParams.materialGroupCode ?? "",
+ materialGroupName: searchParams.materialGroupName ?? "",
+ vendorName: searchParams.vendorName ?? "",
+ vendorCode: searchParams.vendorCode ?? "",
+ avlVendorName: searchParams.avlVendorName ?? "",
+ tier: searchParams.tier ?? "",
+ filters: searchParams.filters ?? [],
+ joinOperator: searchParams.joinOperator ?? "and",
+ search: "",
+ ...searchParams,
+ }
+ console.log('StandardAvlTable - API call params:', params)
+ const result = await getStandardAvlVendorInfo(params)
+ console.log('StandardAvlTable - API result:', {
+ dataCount: result.data.length,
+ pageCount: result.pageCount,
+ requestedPage: params.page
+ })
+ setData(result.data)
+ setPageCount(result.pageCount)
+ } catch (error) {
+ console.error("선종별 표준 AVL 데이터 로드 실패:", error)
+ setData([])
+ setPageCount(0)
+ } finally {
+ setLoading(false)
+ }
+ }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision])
+
+ // reloadTrigger가 변경될 때마다 데이터 리로드
+ React.useEffect(() => {
+ if (reloadTrigger && reloadTrigger > 0) {
+ console.log('StandardAvlTable - reloadTrigger changed, reloading data')
+ loadData({})
+ }
+ }, [reloadTrigger, loadData])
+
+ // 검색 초기화 핸들러
+ const handleResetSearch = React.useCallback(() => {
+ setSearchConstructionSector("")
+ setSearchShipType("")
+ setSearchAvlKind("")
+ setSearchHtDivision("")
+ // 초기화 시 빈 데이터로 설정
+ setData([])
+ setPageCount(0)
+ }, [])
+
+ // 검색 핸들러
+ const handleSearch = React.useCallback(() => {
+ if (isAllSearchConditionsSelected) {
+ // 검색 시 페이지를 1페이지로 리셋
+ setPagination(prev => ({ ...prev, pageIndex: 0 }))
+ loadData({ page: 1, perPage: pagination.pageSize })
+ }
+ }, [loadData, isAllSearchConditionsSelected, pagination.pageSize])
+
+ // 항목 추가 핸들러
+ const handleAddItem = React.useCallback(async (itemData: Omit<AvlVendorInfoInput, 'avlListId'>) => {
+ try {
+ const result = await createAvlVendorInfo(itemData)
+
+ if (result) {
+ toast.success("표준 AVL 항목이 성공적으로 추가되었습니다.")
+ // 데이터 새로고침
+ loadData({})
+ } else {
+ toast.error("항목 추가에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("항목 추가 실패:", error)
+ toast.error("항목 추가 중 오류가 발생했습니다.")
+ }
+ }, [loadData])
+
+ // 항목 수정 핸들러
+ const handleUpdateItem = React.useCallback(async (id: number, itemData: Omit<AvlVendorInfoInput, 'avlListId'>) => {
+ try {
+ const result = await updateAvlVendorInfo(id, itemData)
+
+ if (result) {
+ toast.success("표준 AVL 항목이 성공적으로 수정되었습니다.")
+ // 데이터 새로고침
+ loadData({})
+ // 수정 모드 해제
+ setEditingItem(undefined)
+ } else {
+ toast.error("항목 수정에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("항목 수정 실패:", error)
+ toast.error("항목 수정 중 오류가 발생했습니다.")
+ }
+ }, [loadData])
+
+ // 항목 수정 핸들러 (버튼 클릭)
+ const handleEditItem = React.useCallback(() => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+
+ if (selectedRows.length !== 1) {
+ toast.error("수정할 항목을 하나만 선택해주세요.")
+ return
+ }
+
+ const selectedItem = selectedRows[0].original
+ setEditingItem(selectedItem)
+ setIsAddDialogOpen(true)
+ }, [table])
+
+ // 항목 삭제 핸들러
+ const handleDeleteItems = React.useCallback(async () => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+
+ if (selectedRows.length === 0) {
+ toast.error("삭제할 항목을 선택해주세요.")
+ return
+ }
+
+ // 사용자 확인
+ const confirmed = window.confirm(`선택한 ${selectedRows.length}개 항목을 정말 삭제하시겠습니까?`)
+ if (!confirmed) return
+
+ try {
+ // 선택된 항목들을 DB에서 삭제
+ const deletePromises = selectedRows.map(async (row) => {
+ await deleteAvlVendorInfo(row.original.id)
+ })
+
+ await Promise.all(deletePromises)
+
+ toast.success(`${selectedRows.length}개 항목이 삭제되었습니다.`)
+
+ // 데이터 새로고침
+ loadData({})
+
+ // 선택 해제
+ table.toggleAllPageRowsSelected(false)
+ } catch (error) {
+ console.error("항목 삭제 실패:", error)
+ toast.error("항목 삭제 중 오류가 발생했습니다.")
+ }
+ }, [table, loadData])
+
+ // 최종 확정 핸들러 (표준 AVL)
+ const handleFinalizeStandardAvl = React.useCallback(async () => {
+ // 1. 필수 조건 검증
+ if (!isAllSearchConditionsSelected) {
+ toast.error("검색 조건을 모두 선택해주세요.")
+ return
+ }
+
+ if (data.length === 0) {
+ toast.error("확정할 표준 AVL 벤더 정보가 없습니다.")
+ return
+ }
+
+ // 2. 사용자 확인
+ const confirmed = window.confirm(
+ `현재 표준 AVL을 최종 확정하시겠습니까?\n\n` +
+ `- 공사부문: ${searchConstructionSector}\n` +
+ `- 선종: ${searchShipType}\n` +
+ `- AVL종류: ${searchAvlKind}\n` +
+ `- H/T 구분: ${searchHtDivision}\n` +
+ `- 벤더 정보: ${data.length}개\n\n` +
+ `확정 후에는 수정이 어려울 수 있습니다.`
+ )
+
+ if (!confirmed) return
+
+ try {
+ // 3. 현재 데이터의 모든 ID 수집
+ const avlVendorInfoIds = data.map(item => item.id)
+
+ // 4. 최종 확정 실행
+ const standardAvlInfo = {
+ constructionSector: searchConstructionSector,
+ shipType: searchShipType,
+ avlKind: searchAvlKind,
+ htDivision: searchHtDivision
+ }
+
+ const result = await finalizeStandardAvl(
+ standardAvlInfo,
+ avlVendorInfoIds,
+ sessionData?.user?.name || ""
+ )
+
+ if (result.success) {
+ toast.success(result.message)
+
+ // 5. 데이터 새로고침
+ loadData({})
+
+ // 6. 선택 해제
+ table.toggleAllPageRowsSelected(false)
+ } else {
+ toast.error(result.message)
+ }
+ } catch (error) {
+ console.error("표준 AVL 최종 확정 실패:", error)
+ toast.error("표준 AVL 최종 확정 중 오류가 발생했습니다.")
+ }
+ }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision, isAllSearchConditionsSelected, data, table, loadData, sessionData?.user?.name])
+
+ // 초기 데이터 로드 (검색 조건이 모두 입력되었을 때만)
+ React.useEffect(() => {
+ if (isAllSearchConditionsSelected) {
+ // 검색 조건이 모두 입력되면 페이지를 1페이지로 리셋하고 데이터 로드
+ setPagination(prev => ({ ...prev, pageIndex: 0 }))
+ loadData({ page: 1, perPage: pagination.pageSize })
+ } else {
+ // 검색 조건이 모두 입력되지 않은 경우 빈 데이터로 설정
+ setData([])
+ setPageCount(0)
+ }
+ }, [isAllSearchConditionsSelected, pagination.pageSize, searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision])
+
+
+
+ // 외부에서 선택된 ID들을 가져올 수 있도록 ref에 메소드 노출
+ useImperativeHandle(ref, () => ({
+ getSelectedIds: () => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ return selectedRows.map(row => row.original.id)
+ }
+ }))
+
+ // 선택된 행 개수 (안정적인 계산을 위해 useMemo 사용)
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedRowCount = useMemo(() => {
+ const count = selectedRows.length
+ console.log('StandardAvlTable - selectedRowCount calculated:', count)
+ return count
+ }, [selectedRows])
+
+ // 페이지네이션 상태 디버깅
+ React.useEffect(() => {
+ const paginationState = table.getState().pagination
+ console.log('StandardAvlTable - Current pagination state:', {
+ pageIndex: paginationState.pageIndex,
+ pageSize: paginationState.pageSize,
+ canPreviousPage: table.getCanPreviousPage(),
+ canNextPage: table.getCanNextPage(),
+ pageCount: table.getPageCount(),
+ currentDataLength: data.length
+ })
+ }, [table, data])
+
+ // 선택 상태 변경 시 콜백 호출
+ useLayoutEffect(() => {
+ console.log('StandardAvlTable - onSelectionChange called with count:', selectedRowCount)
+ onSelectionChange?.(selectedRowCount)
+ }, [selectedRowCount, onSelectionChange])
+
+ // 선택 해제 요청이 오면 모든 선택 해제
+ React.useEffect(() => {
+ if (resetCounter && resetCounter > 0) {
+ table.toggleAllPageRowsSelected(false)
+ }
+ }, [resetCounter, table])
+
+ return (
+ <div className="h-full 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" onClick={() => setIsAddDialogOpen(true)}>
+ 신규업체 추가
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleEditItem}
+ disabled={table.getFilteredSelectedRowModel().rows.length !== 1}
+ >
+ 항목 수정
+ </Button>
+ {/* <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 파일 업로드
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 일괄입력
+ </Button> */}
+ <Button variant="outline" size="sm" onClick={handleDeleteItems}>
+ 항목 삭제
+ </Button>
+ {/* <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 저장
+ </Button> */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleFinalizeStandardAvl}
+ disabled={!isAllSearchConditionsSelected || data.length === 0}
+ >
+ 최종 확정
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ {/* 검색 UI */}
+ <div className="mb-4 p-4 border rounded-lg bg-muted/50">
+ <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
+ {/* 공사부문 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">공사부문</label>
+ <Select value={searchConstructionSector} onValueChange={handleConstructionSectorChange}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {constructionSectorOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 선종 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">선종</label>
+ <Select value={searchShipType} onValueChange={setSearchShipType}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {currentShipTypeOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* AVL종류 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">AVL종류</label>
+ <Select value={searchAvlKind} onValueChange={setSearchAvlKind}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {avlKindOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* H/T */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">H/T 구분</label>
+ <Select value={searchHtDivision} onValueChange={setSearchHtDivision}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {htDivisionOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 검색 버튼들 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium opacity-0">버튼</label>
+ <div className="flex gap-1">
+ <Button
+ onClick={handleSearch}
+ disabled={loading || !isAllSearchConditionsSelected}
+ size="sm"
+ className="px-3"
+ >
+ <Search className="w-4 h-4 mr-1" />
+ 조회
+ </Button>
+ <Button
+ onClick={handleResetSearch}
+ variant="outline"
+ size="sm"
+ className="px-3"
+ >
+ 초기화
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div className="flex-1">
+ <DataTable table={table} />
+ </div>
+
+ {/* 신규업체 추가 다이얼로그 */}
+ <AvlVendorAddAndModifyDialog
+ open={isAddDialogOpen}
+ onOpenChange={(open) => {
+ setIsAddDialogOpen(open)
+ if (!open) {
+ setEditingItem(undefined) // 다이얼로그가 닫힐 때 수정 모드 해제
+ }
+ }}
+ onAddItem={handleAddItem}
+ editingItem={editingItem}
+ onUpdateItem={handleUpdateItem}
+ isTemplate={true} // 표준 AVL 모드
+ // 검색 조건에서 선택한 값들을 초기값으로 전달
+ initialConstructionSector={searchConstructionSector}
+ initialShipType={searchShipType}
+ initialAvlKind={searchAvlKind}
+ initialHtDivision={searchHtDivision}
+ />
+ </div>
+ )
+})
+
+StandardAvlTable.displayName = "StandardAvlTable"
diff --git a/lib/avl/table/vendor-pool-table-columns.tsx b/lib/avl/table/vendor-pool-table-columns.tsx
new file mode 100644
index 00000000..53db1059
--- /dev/null
+++ b/lib/avl/table/vendor-pool-table-columns.tsx
@@ -0,0 +1,96 @@
+import { Checkbox } from "@/components/ui/checkbox"
+import { ColumnDef } from "@tanstack/react-table"
+import { VendorPoolItem } from "./vendor-pool-table"
+
+// Vendor Pool 테이블 컬럼
+export 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: "materialGroupCode",
+ header: "자재그룹코드",
+ size: 130,
+ },
+ {
+ accessorKey: "materialGroupName",
+ header: "자재그룹명",
+ size: 130,
+ },
+ {
+ accessorKey: "vendorName",
+ header: "협력업체 정보",
+ size: 130,
+ },
+ {
+ accessorKey: "tier",
+ header: "업체분류",
+ size: 100,
+ },
+ {
+ accessorKey: "faStatus",
+ header: "FA현황",
+ size: 100,
+ },
+ {
+ accessorKey: "recentQuoteNumber",
+ header: "최근견적번호",
+ size: 130,
+ },
+ {
+ accessorKey: "recentOrderNumber",
+ header: "최근발주번호",
+ size: 130,
+ },
+] \ No newline at end of file
diff --git a/lib/avl/table/vendor-pool-table.tsx b/lib/avl/table/vendor-pool-table.tsx
new file mode 100644
index 00000000..7ad9eb56
--- /dev/null
+++ b/lib/avl/table/vendor-pool-table.tsx
@@ -0,0 +1,301 @@
+"use client"
+
+import * as React from "react"
+import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel } from "@tanstack/react-table"
+import { forwardRef, useImperativeHandle } from "react"
+import { DataTable } from "@/components/data-table/data-table"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Input } from "@/components/ui/input"
+import { Search } from "lucide-react"
+import { getVendorPools } from "../../vendor-pool/service"
+import { GetVendorPoolSchema } from "../../vendor-pool/validations"
+import { VendorPool } from "../../vendor-pool/types"
+import { vendorPoolColumns } from "./vendor-pool-table-columns"
+
+// Vendor Pool 데이터 타입 (실제 VendorPool 타입 사용)
+export type VendorPoolItem = VendorPool
+
+// ref를 통해 외부에서 접근할 수 있는 메소드들
+export interface VendorPoolTableRef {
+ getSelectedIds: () => number[]
+}
+
+interface VendorPoolTableProps {
+ onSelectionChange?: (count: number) => void
+ resetCounter?: number
+ reloadTrigger?: number
+}
+
+// 실제 데이터는 API에서 가져옴
+
+export const VendorPoolTable = forwardRef<VendorPoolTableRef, VendorPoolTableProps>(({
+ onSelectionChange,
+ resetCounter,
+ reloadTrigger
+}, ref) => {
+ const [data, setData] = React.useState<VendorPoolItem[]>([])
+ const [loading, setLoading] = React.useState(false)
+ const [pageCount, setPageCount] = React.useState(0)
+
+ // 검색 상태
+ const [searchText, setSearchText] = React.useState("")
+ const [showAll, setShowAll] = React.useState(false)
+
+ // 페이지네이션 상태
+ const [pagination, setPagination] = React.useState({
+ pageIndex: 0,
+ pageSize: 10,
+ })
+
+ // 데이터 로드 함수
+ const loadData = React.useCallback(async (searchParams: Partial<GetVendorPoolSchema> = {}) => {
+ try {
+ setLoading(true)
+
+ const params = {
+ page: searchParams.page ?? 1,
+ perPage: searchParams.perPage ?? 10,
+ sort: searchParams.sort ?? [{ id: "registrationDate", desc: true }],
+ flags: [],
+ search: searchText || "",
+ constructionSector: undefined,
+ shipType: undefined,
+ htDivision: undefined,
+ designCategoryCode: undefined,
+ designCategory: undefined,
+ equipBulkDivision: undefined,
+ packageCode: undefined,
+ packageName: undefined,
+ materialGroupCode: undefined,
+ materialGroupName: undefined,
+ vendorCode: undefined,
+ vendorName: undefined,
+ faTarget: undefined,
+ faStatus: undefined,
+ tier: undefined,
+ isAgent: undefined,
+ isBlacklist: undefined,
+ isBcc: undefined,
+ purchaseOpinion: undefined,
+ shipTypeCommon: undefined,
+ shipTypeAmax: undefined,
+ shipTypeSmax: undefined,
+ shipTypeVlcc: undefined,
+ shipTypeLngc: undefined,
+ shipTypeCont: undefined,
+ offshoreTypeCommon: undefined,
+ offshoreTypeFpso: undefined,
+ offshoreTypeFlng: undefined,
+ offshoreTypeFpu: undefined,
+ offshoreTypePlatform: undefined,
+ offshoreTypeWtiv: undefined,
+ offshoreTypeGom: undefined,
+ picName: undefined,
+ picEmail: undefined,
+ picPhone: undefined,
+ agentName: undefined,
+ agentEmail: undefined,
+ agentPhone: undefined,
+ recentQuoteDate: undefined,
+ recentQuoteNumber: undefined,
+ recentOrderDate: undefined,
+ recentOrderNumber: undefined,
+ registrationDate: undefined,
+ registrant: undefined,
+ lastModifiedDate: undefined,
+ lastModifier: undefined,
+ ...searchParams,
+ }
+ console.log('VendorPoolTable - API call params:', params)
+ const result = await getVendorPools(params as GetVendorPoolSchema)
+ console.log('VendorPoolTable - API result:', {
+ dataCount: result.data.length,
+ pageCount: result.pageCount,
+ requestedPage: params.page
+ })
+ setData(result.data)
+ setPageCount(result.pageCount)
+ } catch (error) {
+ console.error("Vendor Pool 데이터 로드 실패:", error)
+ setData([])
+ setPageCount(0)
+ } finally {
+ setLoading(false)
+ }
+ }, [searchText])
+
+ // 검색 핸들러
+ const handleSearch = React.useCallback(() => {
+ if (showAll) {
+ // 전체보기 모드에서는 페이징 없이 전체 데이터 로드
+ loadData({ perPage: 1000 }) // 충분히 큰 숫자로 전체 데이터 가져오기
+ } else {
+ // 검색 시 페이지를 1페이지로 리셋
+ setPagination(prev => ({ ...prev, pageIndex: 0 }))
+ loadData({ page: 1, perPage: pagination.pageSize })
+ }
+ }, [loadData, showAll, pagination.pageSize])
+
+ // 전체보기 토글 핸들러
+ const handleShowAllToggle = React.useCallback((checked: boolean) => {
+ setShowAll(checked)
+ if (checked) {
+ // 전체보기 활성화 시 전체 데이터 로드
+ loadData({ perPage: 1000 })
+ setSearchText("")
+ } else {
+ // 전체보기 비활성화 시 일반 페이징으로 전환
+ loadData({})
+ }
+ }, [loadData])
+
+ // 초기 데이터 로드
+ React.useEffect(() => {
+ // 초기 로드 시 페이지를 1페이지로 설정
+ setPagination(prev => ({ ...prev, pageIndex: 0 }))
+ loadData({ page: 1, perPage: pagination.pageSize })
+ }, [pagination.pageSize])
+
+ // reloadTrigger가 변경될 때마다 데이터 리로드
+ React.useEffect(() => {
+ if (reloadTrigger && reloadTrigger > 0) {
+ console.log('VendorPoolTable - reloadTrigger changed, reloading data')
+ loadData({})
+ }
+ }, [reloadTrigger, loadData])
+
+ const table = useReactTable({
+ data,
+ columns: vendorPoolColumns,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ manualPagination: !showAll, // 전체보기 시에는 수동 페이징 비활성화
+ pageCount: showAll ? 1 : pageCount, // 전체보기 시 1페이지, 일반 모드에서는 API에서 받은 pageCount 사용
+ state: {
+ pagination: showAll ? { pageIndex: 0, pageSize: data.length } : pagination,
+ },
+ onPaginationChange: (updater) => {
+ if (!showAll) {
+ // 전체보기가 아닐 때만 페이징 변경 처리
+ const newPaginationState = typeof updater === 'function' ? updater(pagination) : updater
+
+ console.log('VendorPoolTable - Pagination changed:', {
+ currentState: pagination,
+ newPaginationState,
+ showAll,
+ willLoadData: !showAll
+ })
+
+ setPagination(newPaginationState)
+
+ const apiParams = {
+ page: newPaginationState.pageIndex + 1,
+ perPage: newPaginationState.pageSize,
+ }
+ console.log('VendorPoolTable - Loading data with params:', apiParams)
+ loadData(apiParams)
+ }
+ },
+ })
+
+ // 외부에서 선택된 ID들을 가져올 수 있도록 ref에 메소드 노출
+ useImperativeHandle(ref, () => ({
+ getSelectedIds: () => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ return selectedRows.map(row => row.original.id)
+ }
+ }))
+
+ // 선택된 행 개수
+ const selectedRowCount = table.getFilteredSelectedRowModel().rows.length
+
+ // 선택 상태 변경 시 콜백 호출
+ React.useEffect(() => {
+ onSelectionChange?.(selectedRowCount)
+ }, [selectedRowCount, onSelectionChange])
+
+ // 선택 해제 요청이 오면 모든 선택 해제
+ React.useEffect(() => {
+ if (resetCounter && resetCounter > 0) {
+ table.toggleAllPageRowsSelected(false)
+ }
+ }, [resetCounter, table])
+
+ return (
+ <div className="h-full 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>
+
+ {/* 검색 UI */}
+ <div className="mb-4 p-4 border rounded-lg bg-muted/50">
+ <div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
+ {/* 전체보기 체크박스 */}
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="showAll"
+ checked={showAll}
+ onCheckedChange={handleShowAllToggle}
+ />
+ <label
+ htmlFor="showAll"
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ 전체보기
+ </label>
+ </div>
+
+ {/* 검색어 입력 */}
+ {!showAll && (
+ <div className="flex gap-2 flex-1 max-w-md">
+ <Input
+ placeholder="설계공종, 업체명, 자재그룹 등으로 검색..."
+ value={searchText}
+ onChange={(e) => setSearchText(e.target.value)}
+ className="flex-1"
+ onKeyPress={(e) => {
+ if (e.key === 'Enter') {
+ handleSearch()
+ }
+ }}
+ />
+ <Button
+ onClick={handleSearch}
+ disabled={loading}
+ size="sm"
+ className="px-3"
+ >
+ <Search className="w-4 h-4" />
+ </Button>
+ </div>
+ )}
+
+ {/* 검색 결과 정보 */}
+ <div className="text-sm text-muted-foreground">
+ {showAll ? (
+ `전체 ${data.length}개 항목 표시 중`
+ ) : (
+ `${data.length}개 항목${searchText ? ` (검색어: "${searchText}")` : ""}`
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex-1">
+ <DataTable table={table} />
+ </div>
+ </div>
+ )
+})
+
+VendorPoolTable.displayName = "VendorPoolTable"
diff --git a/lib/avl/types.ts b/lib/avl/types.ts
new file mode 100644
index 00000000..6a7b5143
--- /dev/null
+++ b/lib/avl/types.ts
@@ -0,0 +1,163 @@
+// AVL 관련 타입 정의
+import { AvlList, AvlVendorInfo } from "@/db/schema/avl/avl";
+
+// AVL 리스트 아이템 (UI에서 사용하는 타입)
+export interface AvlListItem extends Omit<AvlList, 'createdAt' | 'updatedAt'> {
+ no: number;
+ selected: boolean;
+ createdAt: string; // UI에서 사용하기 위해 string으로 변환
+ updatedAt: string; // UI에서 사용하기 위해 string으로 변환
+ vendorInfoSnapshot?: any; // JSON 데이터
+
+ // 추가 표시용 필드들 (실제로는 AvlVendorInfo에서 가져와야 함)
+ projectInfo?: string;
+ shipType?: string;
+ avlType?: string;
+ htDivision?: string;
+ rev?: number;
+ pkg?: string;
+ materialGroup?: string;
+ vendor?: string;
+ tier?: string;
+ ownerSuggestion?: string;
+ shiSuggestion?: string;
+ registrant?: string;
+ lastModifier?: string;
+}
+
+// AVL 상세 아이템 (UI에서 사용하는 타입)
+export interface AvlDetailItem extends Omit<AvlVendorInfo, 'createdAt' | 'updatedAt'> {
+ no: number;
+ selected: boolean;
+ createdAt: string;
+ updatedAt: string;
+
+ // UI 표시용 추가 필드들
+ equipBulkDivision: 'EQUIP' | 'BULK'; // UI에서 표시하기 위한 변환
+ faTarget: boolean; // UI에서 표시하기 위한 변환
+ faStatus: string;
+ agentStatus: string; // UI에서 표시하기 위한 변환
+ shiAvl: boolean; // hasAvl로 매핑
+ shiBlacklist: boolean; // isBlacklist로 매핑
+ shiBcc: boolean; // isBcc로 매핑
+ salesQuoteNumber: string; // techQuoteNumber로 매핑
+ quoteCode: string; // quoteCode로 매핑
+ salesVendorInfo: string; // quoteVendorName으로 매핑
+ salesCountry: string; // quoteCountry로 매핑
+ totalAmount: string; // quoteTotalAmount로 매핑 (string으로 변환)
+ quoteReceivedDate: string; // quoteReceivedDate로 매핑
+ recentQuoteDate: string; // recentQuoteDate로 매핑
+ recentQuoteNumber: string; // recentQuoteNumber로 매핑
+ recentOrderDate: string; // recentOrderDate로 매핑
+ recentOrderNumber: string; // recentOrderNumber로 매핑
+ remarks: string; // remark으로 매핑
+}
+
+// AVL 생성을 위한 입력 타입
+export interface CreateAvlListInput extends Omit<AvlList, 'id' | 'createdAt' | 'updatedAt' | 'vendorInfoSnapshot'> {
+ // UI에서 입력받을 추가 필드들
+ projectInfo?: string;
+ shipType?: string;
+ avlType?: string;
+ vendorInfoSnapshot?: any; // JSON 데이터, 선택적 속성
+}
+
+// AVL 업데이트를 위한 입력 타입
+export interface UpdateAvlListInput extends Partial<CreateAvlListInput> {
+ id: number;
+}
+
+
+// AVL Vendor Info UI 입력을 위한 인터페이스
+export interface AvlVendorInfoInput {
+ // AVL 타입 구분
+ isTemplate?: boolean; // false: 프로젝트 AVL, true: 표준 AVL
+
+ // 표준 AVL용 필드들 (isTemplate=true일 경우)
+ constructionSector?: string; // 공사부문
+ shipType?: string; // 선종
+ avlKind?: string; // AVL 종류
+ htDivision?: string; // H/T 구분
+
+ // 프로젝트 코드 (나중에 AVL 리스트와 연결할 때 사용)
+ projectCode?: string;
+
+ // AVL 리스트 ID (생성 시 필수, UI에서는 선택적으로 사용)
+ avlListId?: number;
+
+ // 설계 정보
+ equipBulkDivision: 'EQUIP' | 'BULK';
+ disciplineCode?: string;
+ disciplineName: string;
+
+ // 자재 정보
+ materialNameCustomerSide: string;
+
+ // 패키지 정보
+ packageCode?: string;
+ packageName?: string;
+
+ // 자재그룹 정보
+ materialGroupCode?: string;
+ materialGroupName?: string;
+
+ // 협력업체 정보
+ vendorId?: number;
+ vendorName?: string;
+ vendorCode?: string;
+
+ // AVL 정보
+ avlVendorName?: string;
+ tier?: string;
+
+ // 제안방향
+ ownerSuggestion?: boolean;
+ shiSuggestion?: boolean;
+
+ // 위치 정보
+ headquarterLocation?: string;
+ manufacturingLocation?: string;
+
+ // FA 정보
+ faTarget?: boolean;
+ faStatus?: string;
+
+ // Agent 정보
+ isAgent?: boolean;
+
+ // 계약 서명주체
+ contractSignerId?: number;
+ contractSignerName?: string;
+ contractSignerCode?: string;
+
+ // SHI Qualification
+ shiAvl?: boolean;
+ shiBlacklist?: boolean;
+ shiBcc?: boolean;
+
+ // 기술영업 견적결과
+ salesQuoteNumber?: string;
+ quoteCode?: string;
+ quoteVendorId?: number;
+ salesVendorInfo?: string;
+ quoteVendorCode?: string;
+ salesCountry?: string;
+ totalAmount?: string;
+ quoteReceivedDate?: string;
+
+ // 업체 실적 현황
+ recentQuoteDate?: string;
+ recentQuoteNumber?: string;
+ recentOrderDate?: string;
+ recentOrderNumber?: string;
+
+ // 기타
+ remarks?: string;
+}
+
+// 액션 처리 결과 타입
+export interface ActionResult {
+ success: boolean;
+ message: string;
+ data?: any;
+}
diff --git a/lib/avl/validations.ts b/lib/avl/validations.ts
new file mode 100644
index 00000000..6f09cdfd
--- /dev/null
+++ b/lib/avl/validations.ts
@@ -0,0 +1,170 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { AvlListItem, AvlDetailItem } from "./types"
+
+// AVL 리스트 검색 파라미터 캐시
+export const avlListSearchParamsCache = createSearchParamsCache({
+ // UI 모드나 플래그 관련
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬 (등재일 기준 내림차순)
+ sort: getSortingStateParser<AvlListItem>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // AVL 기본 정보 필드
+ isTemplate: parseAsStringEnum(["true", "false"]).withDefault(""), // 표준 AVL 여부
+ constructionSector: parseAsString.withDefault(""), // 공사부문
+ projectCode: parseAsString.withDefault(""), // 프로젝트코드
+ shipType: parseAsString.withDefault(""), // 선종
+ avlKind: parseAsString.withDefault(""), // AVL 종류
+ htDivision: parseAsStringEnum(["H", "T"]).withDefault(""), // H/T구분
+ rev: parseAsString.withDefault(""), // 리비전
+
+ // 고급 필터(Advanced) & 검색
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+})
+
+// AVL 상세 검색 파라미터 캐시
+export const avlDetailSearchParamsCache = createSearchParamsCache({
+ // UI 모드나 플래그 관련
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬
+ sort: getSortingStateParser<AvlDetailItem>().withDefault([
+ { id: "no", desc: false },
+ ]),
+
+ // AVL Vendor Info 관련 필드들
+ equipBulkDivision: parseAsStringEnum(["EQUIP", "BULK"]).withDefault(""), // Equip/Bulk 구분
+ disciplineCode: parseAsString.withDefault(""), // 설계공종코드
+ disciplineName: parseAsString.withDefault(""), // 설계공종명
+ materialNameCustomerSide: parseAsString.withDefault(""), // 고객사 AVL 자재명
+ packageCode: parseAsString.withDefault(""), // 패키지 코드
+ packageName: parseAsString.withDefault(""), // 패키지 명
+ materialGroupCode: parseAsString.withDefault(""), // 자재그룹 코드
+ materialGroupName: parseAsString.withDefault(""), // 자재그룹 명
+ vendorName: parseAsString.withDefault(""), // 협력업체 명
+ vendorCode: parseAsString.withDefault(""), // 협력업체 코드
+ avlVendorName: parseAsString.withDefault(""), // AVL 등재업체명
+ tier: parseAsString.withDefault(""), // 등급
+ faTarget: parseAsStringEnum(["true", "false"]).withDefault(""), // FA 대상
+ faStatus: parseAsString.withDefault(""), // FA 현황
+ isAgent: parseAsStringEnum(["true", "false"]).withDefault(""), // Agent 여부
+ contractSignerName: parseAsString.withDefault(""), // 계약 서명주체
+ headquarterLocation: parseAsString.withDefault(""), // 본사 위치
+ manufacturingLocation: parseAsString.withDefault(""), // 제작/선적지
+ hasAvl: parseAsStringEnum(["true", "false"]).withDefault(""), // AVL 존재
+ isBlacklist: parseAsStringEnum(["true", "false"]).withDefault(""), // Blacklist
+ isBcc: parseAsStringEnum(["true", "false"]).withDefault(""), // BCC
+ techQuoteNumber: parseAsString.withDefault(""), // 기술영업 견적번호
+ quoteCode: parseAsString.withDefault(""), // 견적서 Code
+ quoteCountry: parseAsString.withDefault(""), // 국가
+ remark: parseAsString.withDefault(""), // 비고
+
+ // 고급 필터(Advanced) & 검색
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+})
+
+// 프로젝트 AVL 검색 파라미터 캐시 (프로젝트별 AVL Vendor Info)
+export const projectAvlSearchParamsCache = createSearchParamsCache({
+ // UI 모드나 플래그 관련
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬
+ sort: getSortingStateParser<AvlDetailItem>().withDefault([
+ { id: "no", desc: false },
+ ]),
+
+ // 필수 필터: 프로젝트 코드
+ projectCode: parseAsString.withDefault(""),
+
+ // 추가 필터들
+ equipBulkDivision: parseAsStringEnum(["EQUIP", "BULK"]).withDefault(""),
+ disciplineCode: parseAsString.withDefault(""),
+ disciplineName: parseAsString.withDefault(""),
+ materialNameCustomerSide: parseAsString.withDefault(""),
+ packageCode: parseAsString.withDefault(""),
+ packageName: parseAsString.withDefault(""),
+ materialGroupCode: parseAsString.withDefault(""),
+ materialGroupName: parseAsString.withDefault(""),
+ vendorName: parseAsString.withDefault(""),
+ vendorCode: parseAsString.withDefault(""),
+ avlVendorName: parseAsString.withDefault(""),
+ tier: parseAsString.withDefault(""),
+
+ // 고급 필터(Advanced) & 검색
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+})
+
+// 표준 AVL 검색 파라미터 캐시 (선종별 표준 AVL Vendor Info)
+export const standardAvlSearchParamsCache = createSearchParamsCache({
+ // UI 모드나 플래그 관련
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬
+ sort: getSortingStateParser<AvlDetailItem>().withDefault([
+ { id: "no", desc: false },
+ ]),
+
+ // 필수 필터: 표준 AVL용 (공사부문, 선종, AVL종류, H/T)
+ constructionSector: parseAsString.withDefault(""),
+ shipType: parseAsString.withDefault(""),
+ avlKind: parseAsString.withDefault(""),
+ htDivision: parseAsStringEnum(["공통", "H", "T", ""]).withDefault(""),
+
+ // 추가 필터들
+ equipBulkDivision: parseAsStringEnum(["EQUIP", "BULK", ""]).withDefault(""),
+ disciplineCode: parseAsString.withDefault(""),
+ disciplineName: parseAsString.withDefault(""),
+ materialNameCustomerSide: parseAsString.withDefault(""),
+ packageCode: parseAsString.withDefault(""),
+ packageName: parseAsString.withDefault(""),
+ materialGroupCode: parseAsString.withDefault(""),
+ materialGroupName: parseAsString.withDefault(""),
+ vendorName: parseAsString.withDefault(""),
+ vendorCode: parseAsString.withDefault(""),
+ avlVendorName: parseAsString.withDefault(""),
+ tier: parseAsString.withDefault(""),
+
+ // 고급 필터(Advanced) & 검색
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+})
+
+// 최종 타입 추론
+export type GetAvlListSchema = Awaited<ReturnType<typeof avlListSearchParamsCache.parse>>
+export type GetAvlDetailSchema = Awaited<ReturnType<typeof avlDetailSearchParamsCache.parse>>
+export type GetProjectAvlSchema = Awaited<ReturnType<typeof projectAvlSearchParamsCache.parse>>
+export type GetStandardAvlSchema = Awaited<ReturnType<typeof standardAvlSearchParamsCache.parse>>
diff --git a/lib/bidding-projects/service.ts b/lib/bidding-projects/service.ts
index 569bd18f..c56b49cf 100644
--- a/lib/bidding-projects/service.ts
+++ b/lib/bidding-projects/service.ts
@@ -115,3 +115,43 @@ export async function getProjectSeriesForProject(pspid: string) {
return []
}
}
+
+
+/**
+ * 프로젝트 코드 기준으로 프로젝트 정보 반환
+ * 사용처:
+ * - 프로젝트 AVL 등록시 프로젝트 정보 가져오기
+ */
+export async function getProjectInfoByProjectCode(projectCode: string) {
+ const projectInfo = await db.select().from(biddingProjects).where(eq(biddingProjects.pspid, projectCode)).limit(1);
+
+ // 프로젝트가 존재하지 않으면 null 반환
+ if (projectInfo.length === 0) {
+ return null;
+ }
+
+ //projectInfo[0].pjtType SHIP/HULL/TOP
+ //TODO evcp는 공통이라는 걸 받지 않는걸로 보임. 여긴 한번 확인..
+
+ let projectHtDivision = null;
+ if (projectInfo[0].pjtType === 'SHIP') {
+ projectHtDivision = 'H';
+ } else if (projectInfo[0].pjtType === 'HULL') {
+ projectHtDivision = 'H';
+ } else if (projectInfo[0].pjtType === 'TOP') {
+ projectHtDivision = 'T';
+ }
+
+ const projectInfoForAvl = {
+ // 프로젝트코드
+ projectCode: projectInfo[0].pspid,
+ // 프로젝트명
+ projectName: projectInfo[0].projNm,
+ // 선종
+ projectMsrm: projectInfo[0].ptypeNm,
+ // H/T 구분
+ projectHtDivision,
+ };
+
+ return projectInfoForAvl;
+} \ No newline at end of file
diff --git a/lib/projects/service.ts b/lib/projects/service.ts
index fe1052f6..acf8b2f5 100644
--- a/lib/projects/service.ts
+++ b/lib/projects/service.ts
@@ -5,7 +5,7 @@ import db from "@/db/db";
import { unstable_cache } from "@/lib/unstable-cache";
import { filterColumns } from "@/lib/filter-columns";
import { tagTypeClassFormMappings } from "@/db/schema/vendorData";
-import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm";
import { countProjectLists, selectProjectLists } from "./repository";
import { projects } from "@/db/schema";
import { GetProjectListsSchema } from "./validation";
@@ -84,4 +84,24 @@ export async function getProjectLists(input: GetProjectListsSchema) {
tags: ["project-lists"],
}
)();
- } \ No newline at end of file
+ }
+
+export async function getProjectInfoByProjectCode(projectCode: string) {
+ const projectInfo = await db.select().from(projects).where(eq(projects.code, projectCode)).limit(1);
+
+ if (!projectInfo || projectInfo.length === 0) {
+ throw new Error(`프로젝트 코드 "${projectCode}"를 찾을 수 없습니다.`);
+ }
+
+ const projectInfoForAvl = {
+ // 프로젝트코드
+ projectCode: projectInfo[0].code,
+ // 프로젝트명
+ projectName: projectInfo[0].name,
+ // 선종
+ shipType: projectInfo[0].SKND || undefined,
+ // H/T 구분
+ projectHtDivision: projectInfo[0].type || undefined,
+ };
+ return projectInfoForAvl;
+} \ No newline at end of file
diff --git a/lib/vendor-pool/table/vendor-pool-table.tsx b/lib/vendor-pool/table/vendor-pool-table.tsx
index caf52865..54c6ea4d 100644
--- a/lib/vendor-pool/table/vendor-pool-table.tsx
+++ b/lib/vendor-pool/table/vendor-pool-table.tsx
@@ -130,14 +130,14 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP
equipBulkDivision = 'B'
} else if (code.startsWith('B')) {
equipBulkDivision = 'E'
- } else {
- equipBulkDivision = null
}
if (equipBulkDivision) {
await handleCellUpdate(id, 'equipBulkDivision', equipBulkDivision)
toast.success(`자재그룹코드에 따라 Equip/Bulk 구분을 '${equipBulkDivision}'으로 자동 설정했습니다.`)
} else {
+ // Equip/Bulk 구분을 빈 값으로 설정하여 pendingChanges에 반영
+ await handleCellUpdate(id, 'equipBulkDivision', '')
toast.info('현 자재그룹코드에 따라 Equip/Bulk 구분을 자동 설정할 수 없습니다.')
}
} catch (error) {