summaryrefslogtreecommitdiff
path: root/lib/avl/table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/avl/table')
-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
14 files changed, 6521 insertions, 0 deletions
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"