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.tsx479
-rw-r--r--lib/avl/table/avl-registration-area.tsx278
-rw-r--r--lib/avl/table/avl-table-columns.tsx351
-rw-r--r--lib/avl/table/avl-table.tsx514
-rw-r--r--lib/avl/table/columns-detail.tsx680
-rw-r--r--lib/avl/table/project-avl-add-dialog.tsx779
-rw-r--r--lib/avl/table/project-avl-table.tsx724
-rw-r--r--lib/avl/table/standard-avl-table.tsx380
-rw-r--r--lib/avl/table/vendor-pool-table.tsx290
9 files changed, 4475 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..ba15c6ef
--- /dev/null
+++ b/lib/avl/table/avl-detail-table.tsx
@@ -0,0 +1,479 @@
+"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 { Input } from "@/components/ui/input"
+import { toast } from "sonner"
+
+import { columns, type AvlDetailItem } from "./columns-detail"
+import { createAvlVendorInfo, updateAvlVendorInfo, deleteAvlVendorInfo, handleAvlAction } from "../service"
+import type { AvlDetailItem as AvlDetailType } from "../types"
+
+// 테이블 메타 타입 확장
+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<AvlDetailItem>>
+ }
+}
+
+interface AvlDetailTableProps {
+ data: AvlDetailItem[]
+ pageCount?: number
+ avlListId: number // 상위 AVL 리스트 ID
+ onRefresh?: () => void // 데이터 새로고침 콜백
+ avlType?: '프로젝트AVL' | '선종별표준AVL' // AVL 타입
+ projectCode?: string // 프로젝트 코드
+ shipOwnerName?: string // 선주명
+ businessType?: string // 사업 유형 (예: 조선/해양)
+}
+
+export function AvlDetailTable({
+ data,
+ pageCount,
+ avlListId,
+ onRefresh,
+ avlType = '프로젝트AVL',
+ projectCode,
+ shipOwnerName,
+ businessType = '조선'
+}: AvlDetailTableProps) {
+ // 수정사항 추적 (일괄 저장용)
+ const [pendingChanges, setPendingChanges] = React.useState<Record<string, Partial<AvlDetailItem>>>({})
+ const [isSaving, setIsSaving] = React.useState(false)
+
+ // 빈 행 관리 (신규 등록용)
+ const [emptyRows, setEmptyRows] = React.useState<Record<string, AvlDetailItem>>({})
+ const [isCreating, setIsCreating] = React.useState(false)
+
+ // 검색 상태
+ const [searchValue, setSearchValue] = React.useState("")
+
+
+ // 인라인 편집 핸들러 (일괄 저장용)
+ const handleCellUpdate = React.useCallback(async (id: string, field: keyof AvlDetailItem, 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 AvlDetailItem) => {
+ 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-vendor':
+ // 신규 협력업체 추가 - 빈 행 추가
+ const tempId = `temp-${Date.now()}`
+ const newEmptyRow: AvlDetailItem = {
+ id: tempId,
+ no: 0,
+ selected: false,
+ avlListId: avlListId,
+ equipBulkDivision: "EQUIP",
+ disciplineCode: "",
+ disciplineName: "",
+ materialNameCustomerSide: "",
+ packageCode: "",
+ packageName: "",
+ materialGroupCode: "",
+ materialGroupName: "",
+ vendorId: undefined,
+ vendorName: "",
+ vendorCode: "",
+ avlVendorName: "",
+ tier: "",
+ faTarget: false,
+ faStatus: "",
+ isAgent: false,
+ agentStatus: "아니오",
+ contractSignerId: undefined,
+ contractSignerName: "",
+ contractSignerCode: "",
+ headquarterLocation: "",
+ manufacturingLocation: "",
+ shiAvl: false,
+ shiBlacklist: false,
+ shiBcc: false,
+ salesQuoteNumber: "",
+ quoteCode: "",
+ salesVendorInfo: "",
+ salesCountry: "",
+ totalAmount: "",
+ quoteReceivedDate: "",
+ recentQuoteDate: "",
+ recentQuoteNumber: "",
+ recentOrderDate: "",
+ recentOrderNumber: "",
+ remarks: "",
+ createdAt: new Date().toISOString().split('T')[0],
+ updatedAt: new Date().toISOString().split('T')[0],
+ }
+
+ setEmptyRows(prev => ({
+ ...prev,
+ [tempId]: newEmptyRow
+ }))
+ toast.success("신규 협력업체 행이 추가되었습니다.")
+ break
+
+ case 'bulk-import':
+ // 일괄 입력
+ const bulkResult = await handleAvlAction('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 numericId = Number(id)
+ if (isNaN(numericId)) {
+ throw new Error(`유효하지 않은 ID: ${id}`)
+ }
+
+ const result = await updateAvlVendorInfo(numericId, changes)
+ 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 numericId = Number(data.id)
+ if (isNaN(numericId)) {
+ toast.error("유효하지 않은 항목 ID입니다.")
+ return
+ }
+
+ const confirmed = window.confirm(`항목 ${data.id}을(를) 삭제하시겠습니까?`)
+ if (!confirmed) return
+
+ try {
+ const result = await deleteAvlVendorInfo(numericId)
+ if (result) {
+ toast.success("항목이 삭제되었습니다.")
+ onRefresh?.()
+ } else {
+ toast.error("삭제에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error('삭제 실패:', error)
+ toast.error("삭제 중 오류가 발생했습니다.")
+ }
+ break
+
+ case 'avl-form':
+ // AVL 양식 다운로드/보기
+ toast.info("AVL 양식을 준비 중입니다.")
+ // TODO: AVL 양식 다운로드 로직 구현
+ break
+
+ case 'quote-request':
+ // 견적 요청
+ toast.info("견적 요청을 처리 중입니다.")
+ // TODO: 견적 요청 로직 구현
+ break
+
+ case 'vendor-pool':
+ // Vendor Pool 관리
+ toast.info("Vendor Pool을 열고 있습니다.")
+ // TODO: Vendor Pool 페이지 이동 또는 모달 열기 로직 구현
+ break
+
+ case 'download':
+ // 데이터 다운로드
+ toast.info("데이터를 다운로드 중입니다.")
+ // TODO: 데이터 다운로드 로직 구현
+ break
+
+ default:
+ toast.error(`알 수 없는 액션: ${action}`)
+ }
+ } catch (error) {
+ console.error('액션 처리 실패:', error)
+ toast.error("액션 처리 중 오류가 발생했습니다.")
+ }
+ }, [pendingChanges, onRefresh, avlListId])
+
+ // 빈 행 저장 핸들러
+ const handleSaveEmptyRow = React.useCallback(async (tempId: string) => {
+ const emptyRow = emptyRows[tempId]
+ if (!emptyRow) return
+
+ try {
+ setIsCreating(true)
+
+ // 필수 필드 검증
+ if (!emptyRow.disciplineName || !emptyRow.vendorName) {
+ toast.error("설계공종과 협력업체명은 필수 입력 항목입니다.")
+ return
+ }
+
+ // 빈 행 데이터를 생성 데이터로 변환
+ const createData = {
+ avlListId: emptyRow.avlListId,
+ equipBulkDivision: emptyRow.equipBulkDivision,
+ disciplineCode: emptyRow.disciplineCode || undefined,
+ disciplineName: emptyRow.disciplineName,
+ materialNameCustomerSide: emptyRow.materialNameCustomerSide || undefined,
+ packageCode: emptyRow.packageCode || undefined,
+ packageName: emptyRow.packageName || undefined,
+ materialGroupCode: emptyRow.materialGroupCode || undefined,
+ materialGroupName: emptyRow.materialGroupName || undefined,
+ vendorId: emptyRow.vendorId,
+ vendorName: emptyRow.vendorName,
+ vendorCode: emptyRow.vendorCode || undefined,
+ avlVendorName: emptyRow.avlVendorName || undefined,
+ tier: emptyRow.tier || undefined,
+ faTarget: emptyRow.faTarget ?? false,
+ faStatus: emptyRow.faStatus || undefined,
+ isAgent: emptyRow.isAgent ?? false,
+ contractSignerId: emptyRow.contractSignerId,
+ contractSignerName: emptyRow.contractSignerName || undefined,
+ contractSignerCode: emptyRow.contractSignerCode || undefined,
+ headquarterLocation: emptyRow.headquarterLocation || undefined,
+ manufacturingLocation: emptyRow.manufacturingLocation || undefined,
+ hasAvl: emptyRow.shiAvl ?? false,
+ isBlacklist: emptyRow.shiBlacklist ?? false,
+ isBcc: emptyRow.shiBcc ?? false,
+ techQuoteNumber: emptyRow.salesQuoteNumber || undefined,
+ quoteCode: emptyRow.quoteCode || undefined,
+ quoteVendorId: emptyRow.vendorId,
+ quoteVendorName: emptyRow.salesVendorInfo || undefined,
+ quoteVendorCode: emptyRow.vendorCode,
+ quoteCountry: emptyRow.salesCountry || undefined,
+ quoteTotalAmount: emptyRow.totalAmount ? parseFloat(emptyRow.totalAmount.replace(/,/g, '')) : undefined,
+ quoteReceivedDate: emptyRow.quoteReceivedDate || undefined,
+ recentQuoteDate: emptyRow.recentQuoteDate || undefined,
+ recentQuoteNumber: emptyRow.recentQuoteNumber || undefined,
+ recentOrderDate: emptyRow.recentOrderDate || undefined,
+ recentOrderNumber: emptyRow.recentOrderNumber || undefined,
+ remark: emptyRow.remarks || undefined,
+ }
+
+ const result = await createAvlVendorInfo(createData)
+ 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(() => {
+ const emptyRowArray = Object.values(emptyRows)
+ return [...data, ...emptyRowArray]
+ }, [data, emptyRows])
+
+ // 테이블 메타 설정
+ 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,
+ pageCount,
+ initialState: {
+ sorting: [{ id: "no", desc: false }],
+ columnPinning: { right: ["actions"] },
+ },
+ 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">
+ {/* 상단 정보 표시 영역 */}
+ <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 justify-between gap-4">
+ <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>
+
+ <div className="flex items-center gap-2">
+ <div className="relative">
+ <Input
+ placeholder="검색..."
+ className="w-64"
+ value={searchValue}
+ onChange={(e) => setSearchValue(e.target.value)}
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 데이터 테이블 */}
+ <DataTable table={table} />
+
+ {/* 디버그 정보 (개발 환경에서만 표시) */}
+ {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-registration-area.tsx b/lib/avl/table/avl-registration-area.tsx
new file mode 100644
index 00000000..def3d30a
--- /dev/null
+++ b/lib/avl/table/avl-registration-area.tsx
@@ -0,0 +1,278 @@
+"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 } from "./project-avl-table"
+import { StandardAvlTable } from "./standard-avl-table"
+import { VendorPoolTable } from "./vendor-pool-table"
+import { selectedAvlRecordAtom } from "../avl-atoms"
+import type { AvlListItem } from "../types"
+
+// 선택된 테이블 타입
+type SelectedTable = 'project' | 'standard' | 'vendor' | null
+
+// 선택 상태 액션 타입
+type SelectionAction =
+ | { type: 'SELECT_PROJECT'; count: number }
+ | { type: 'SELECT_STANDARD'; count: number }
+ | { type: 'SELECT_VENDOR'; count: number }
+ | { type: 'CLEAR_SELECTION' }
+
+// 선택 상태
+interface SelectionState {
+ selectedTable: SelectedTable
+ selectedRowCount: number
+ resetCounters: {
+ project: number
+ standard: number
+ vendor: number
+ }
+}
+
+// 선택 상태 리듀서
+const selectionReducer = (state: SelectionState, action: SelectionAction): SelectionState => {
+ switch (action.type) {
+ case 'SELECT_PROJECT':
+ if (action.count > 0) {
+ return {
+ selectedTable: 'project',
+ selectedRowCount: action.count,
+ resetCounters: {
+ ...state.resetCounters,
+ standard: state.selectedTable !== 'project' ? state.resetCounters.standard + 1 : state.resetCounters.standard,
+ vendor: state.selectedTable !== 'project' ? state.resetCounters.vendor + 1 : state.resetCounters.vendor,
+ }
+ }
+ } else if (state.selectedTable === 'project') {
+ return {
+ ...state,
+ selectedTable: null,
+ selectedRowCount: 0,
+ }
+ }
+ return state
+
+ case 'SELECT_STANDARD':
+ if (action.count > 0) {
+ return {
+ selectedTable: 'standard',
+ selectedRowCount: action.count,
+ resetCounters: {
+ ...state.resetCounters,
+ project: state.selectedTable !== 'standard' ? state.resetCounters.project + 1 : state.resetCounters.project,
+ vendor: state.selectedTable !== 'standard' ? state.resetCounters.vendor + 1 : state.resetCounters.vendor,
+ }
+ }
+ } else if (state.selectedTable === 'standard') {
+ return {
+ ...state,
+ selectedTable: null,
+ selectedRowCount: 0,
+ }
+ }
+ return state
+
+ case 'SELECT_VENDOR':
+ if (action.count > 0) {
+ return {
+ selectedTable: 'vendor',
+ selectedRowCount: action.count,
+ resetCounters: {
+ ...state.resetCounters,
+ project: state.selectedTable !== 'vendor' ? state.resetCounters.project + 1 : state.resetCounters.project,
+ standard: state.selectedTable !== 'vendor' ? state.resetCounters.standard + 1 : state.resetCounters.standard,
+ }
+ }
+ } else if (state.selectedTable === 'vendor') {
+ return {
+ ...state,
+ selectedTable: null,
+ selectedRowCount: 0,
+ }
+ }
+ return state
+
+ default:
+ return state
+ }
+}
+
+interface AvlRegistrationAreaProps {
+ disabled?: boolean // 비활성화 상태
+}
+
+export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaProps) {
+ // 선택된 AVL 레코드 구독
+ const [selectedAvlRecord] = useAtom(selectedAvlRecordAtom)
+
+ // 단일 선택 상태 관리 (useReducer 사용)
+ const [selectionState, dispatch] = React.useReducer(selectionReducer, {
+ selectedTable: null,
+ selectedRowCount: 0,
+ resetCounters: {
+ project: 0,
+ standard: 0,
+ vendor: 0,
+ },
+ })
+
+ // 선택 핸들러들
+ const handleProjectSelection = React.useCallback((count: number) => {
+ dispatch({ type: 'SELECT_PROJECT', count })
+ }, [])
+
+ const handleStandardSelection = React.useCallback((count: number) => {
+ dispatch({ type: 'SELECT_STANDARD', count })
+ }, [])
+
+ const handleVendorSelection = React.useCallback((count: number) => {
+ dispatch({ type: 'SELECT_VENDOR', count })
+ }, [])
+
+ const { selectedTable, selectedRowCount, resetCounters } = selectionState
+
+ // 선택된 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 레코드가 변경될 때 프로젝트 코드 초기화
+ React.useEffect(() => {
+ setCurrentProjectCode(selectedAvlRecord?.projectCode || "")
+ }, [selectedAvlRecord?.projectCode])
+
+ // 프로젝트 코드 변경 핸들러
+ const handleProjectCodeChange = React.useCallback((projectCode: string) => {
+ setCurrentProjectCode(projectCode)
+ }, [])
+
+ 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
+ onSelectionChange={handleProjectSelection}
+ resetCounter={resetCounters.project}
+ projectCode={currentProjectCode}
+ avlListId={parseInt(avlListId) || 1}
+ onProjectCodeChange={handleProjectCodeChange}
+ />
+
+ {/* 이동 버튼들 - 첫 번째 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 !== 'standard' || selectedRowCount === 0}
+ >
+ <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}
+ >
+ <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}
+ >
+ <ChevronsRight className="w-4 h-4" />
+ </Button>
+
+ </div>
+ </div>
+ </div>
+
+ {/* 선종별 표준 AVL 테이블 - 8개 컬럼 */}
+ <div className="p-4 border-r relative">
+ <StandardAvlTable
+ onSelectionChange={handleStandardSelection}
+ resetCounter={resetCounters.standard}
+ constructionSector={constructionSector}
+ shipType={shipType}
+ avlKind={avlKind}
+ htDivision={htDivision}
+ />
+
+ {/* 이동 버튼들 - 두 번째 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}
+ >
+ <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}
+ >
+ <ChevronLeft className="w-4 h-4" />
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-8 h-8 p-0"
+ title="벤더풀로 복사"
+ disabled={disabled || selectedTable !== 'standard' || selectedRowCount === 0}
+ >
+ <ChevronRight className="w-4 h-4" />
+ </Button>
+
+ </div>
+ </div>
+ </div>
+
+ {/* Vendor Pool 테이블 - 10개 컬럼 */}
+ <div className="p-4 relative">
+ <VendorPoolTable
+ onSelectionChange={handleVendorSelection}
+ resetCounter={resetCounters.vendor}
+ />
+ </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..77361f36
--- /dev/null
+++ b/lib/avl/table/avl-table-columns.tsx
@@ -0,0 +1,351 @@
+import { Checkbox } from "@/components/ui/checkbox"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Eye, Edit, Trash2 } 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
+ const isModified = getIsModified(table, row.id, "rev")
+ return (
+ <EditableCell
+ value={value?.toString() || ""}
+ isModified={isModified}
+ type="number"
+ onUpdate={(newValue) => {
+ table.options.meta?.onCellUpdate?.(row.id, "rev", parseInt(newValue))
+ }}
+ onCancel={() => {
+ table.options.meta?.onCellCancel?.(row.id, "rev")
+ }}
+ />
+ )
+ },
+ size: 80,
+ },
+ ],
+ },
+
+ // 등록 정보 그룹
+ {
+ 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..a6910ef5
--- /dev/null
+++ b/lib/avl/table/avl-table.tsx
@@ -0,0 +1,514 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ 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 { Checkbox } from "@/components/ui/checkbox"
+import { toast } from "sonner"
+
+import { getColumns } from "./avl-table-columns"
+import { createAvlListAction, updateAvlListAction, deleteAvlListAction, handleAvlActionAction } from "../service"
+import type { AvlListItem } from "../types"
+
+// 테이블 메타 타입 확장
+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 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 advancedFilterFields: DataTableAdvancedFilterField<AvlListItem>[] = [
+ {
+ id: "projectCode",
+ label: "프로젝트 코드",
+ type: "text",
+ placeholder: "프로젝트 코드 입력...",
+ },
+ {
+ id: "shipType",
+ label: "선종",
+ type: "text",
+ placeholder: "선종 입력...",
+ },
+ {
+ id: "avlKind",
+ label: "AVL 종류",
+ type: "text",
+ placeholder: "AVL 종류 입력...",
+ },
+ {
+ id: "createdBy",
+ label: "등재자",
+ type: "text",
+ placeholder: "등재자 입력...",
+ },
+ ]
+
+ // 인라인 편집 핸들러 (일괄 저장용)
+ 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,
+ 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
+
+ default:
+ toast.error(`알 수 없는 액션: ${action}`)
+ }
+ } catch (error) {
+ console.error('액션 처리 실패:', error)
+ toast.error("액션 처리 중 오류가 발생했습니다.")
+ }
+ }, [pendingChanges, onRefresh])
+
+ // 빈 행 저장 핸들러
+ 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"] },
+ },
+ 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} />
+
+ {/* 디버그 정보 (개발 환경에서만 표시) */}
+ {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/columns-detail.tsx b/lib/avl/table/columns-detail.tsx
new file mode 100644
index 00000000..204d34f5
--- /dev/null
+++ b/lib/avl/table/columns-detail.tsx
@@ -0,0 +1,680 @@
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Edit, Trash2 } 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"
+
+// 수정 여부 확인 헬퍼 함수
+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
+ }
+}
+
+// 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: [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ size: 50,
+ },
+ {
+ accessorKey: "no",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="No." />
+ ),
+ size: 60,
+ },
+ {
+ accessorKey: "equipBulkDivision",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Equip/Bulk 구분" />
+ ),
+ cell: ({ row, table }) => {
+ const value = row.getValue("equipBulkDivision") as string
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ const onSave = async (newValue: any) => {
+ if (table.options.meta?.onCellUpdate) {
+ await table.options.meta.onCellUpdate(row.original.id, "equipBulkDivision", newValue)
+ }
+ }
+
+ const isModified = getIsModified(table, row.original.id, "equipBulkDivision")
+
+ return (
+ <EditableCell
+ value={value}
+ type="select"
+ onSave={onSave}
+ options={[
+ { label: "EQUIP", value: "EQUIP" },
+ { label: "BULK", value: "BULK" }
+ ]}
+ placeholder="구분 선택"
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "disciplineName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="설계공종" />
+ ),
+ cell: ({ row, table }) => {
+ const value = row.getValue("disciplineName")
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ const onSave = async (newValue: any) => {
+ if (table.options.meta?.onCellUpdate) {
+ await table.options.meta.onCellUpdate(row.original.id, "disciplineName", newValue)
+ }
+ }
+
+ const isModified = getIsModified(table, row.original.id, "disciplineName")
+
+ return (
+ <EditableCell
+ value={value}
+ type="text"
+ onSave={onSave}
+ placeholder="설계공종 입력"
+ maxLength={50}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "materialNameCustomerSide",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="고객사 AVL 자재명" />
+ ),
+ cell: ({ row, table }) => {
+ const value = row.getValue("materialNameCustomerSide")
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ const onSave = async (newValue: any) => {
+ if (table.options.meta?.onCellUpdate) {
+ await table.options.meta.onCellUpdate(row.original.id, "materialNameCustomerSide", newValue)
+ }
+ }
+
+ const isModified = getIsModified(table, row.original.id, "materialNameCustomerSide")
+
+ return (
+ <EditableCell
+ value={value}
+ type="text"
+ onSave={onSave}
+ placeholder="자재명 입력"
+ maxLength={100}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 150,
+ },
+ {
+ accessorKey: "packageName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="패키지 정보" />
+ ),
+ cell: ({ row, table }) => {
+ const value = row.getValue("packageName")
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ const onSave = async (newValue: any) => {
+ if (table.options.meta?.onCellUpdate) {
+ await table.options.meta.onCellUpdate(row.original.id, "packageName", newValue)
+ }
+ }
+
+ const isModified = getIsModified(table, row.original.id, "packageName")
+
+ return (
+ <EditableCell
+ value={value}
+ type="text"
+ onSave={onSave}
+ placeholder="패키지명 입력"
+ maxLength={100}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 130,
+ },
+ {
+ accessorKey: "materialGroupCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재그룹코드" />
+ ),
+ cell: ({ row, table }) => {
+ const value = row.getValue("materialGroupCode")
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ const onSave = async (newValue: any) => {
+ if (table.options.meta?.onCellUpdate) {
+ await table.options.meta.onCellUpdate(row.original.id, "materialGroupCode", newValue)
+ }
+ }
+
+ const isModified = getIsModified(table, row.original.id, "materialGroupCode")
+
+ return (
+ <EditableCell
+ value={value}
+ type="text"
+ onSave={onSave}
+ placeholder="자재그룹코드 입력"
+ maxLength={50}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "materialGroupName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재그룹명" />
+ ),
+ cell: ({ row, table }) => {
+ const value = row.getValue("materialGroupName")
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ const onSave = async (newValue: any) => {
+ if (table.options.meta?.onCellUpdate) {
+ await table.options.meta.onCellUpdate(row.original.id, "materialGroupName", newValue)
+ }
+ }
+
+ const isModified = getIsModified(table, row.original.id, "materialGroupName")
+
+ return (
+ <EditableCell
+ value={value}
+ type="text"
+ onSave={onSave}
+ placeholder="자재그룹명 입력"
+ maxLength={100}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 130,
+ },
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체코드" />
+ ),
+ cell: ({ row, table }) => {
+ const value = row.getValue("vendorCode")
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ const onSave = async (newValue: any) => {
+ if (table.options.meta?.onCellUpdate) {
+ await table.options.meta.onCellUpdate(row.original.id, "vendorCode", newValue)
+ }
+ }
+
+ const isModified = getIsModified(table, row.original.id, "vendorCode")
+
+ return (
+ <EditableCell
+ value={value}
+ type="text"
+ onSave={onSave}
+ placeholder="협력업체코드 입력"
+ maxLength={50}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체명" />
+ ),
+ cell: ({ row, table }) => {
+ const value = row.getValue("vendorName")
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ const onSave = async (newValue: any) => {
+ if (table.options.meta?.onCellUpdate) {
+ await table.options.meta.onCellUpdate(row.original.id, "vendorName", newValue)
+ }
+ }
+
+ const isModified = getIsModified(table, row.original.id, "vendorName")
+
+ return (
+ <EditableCell
+ value={value}
+ type="text"
+ onSave={onSave}
+ placeholder="협력업체명 입력"
+ maxLength={100}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 140,
+ },
+ {
+ accessorKey: "avlVendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="AVL 등재업체명" />
+ ),
+ cell: ({ row, table }) => {
+ const value = row.getValue("avlVendorName")
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ const onSave = async (newValue: any) => {
+ if (table.options.meta?.onCellUpdate) {
+ await table.options.meta.onCellUpdate(row.original.id, "avlVendorName", newValue)
+ }
+ }
+
+ const isModified = getIsModified(table, row.original.id, "avlVendorName")
+
+ return (
+ <EditableCell
+ value={value}
+ type="text"
+ onSave={onSave}
+ placeholder="AVL 등재업체명 입력"
+ maxLength={100}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 140,
+ },
+ {
+ accessorKey: "tier",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등급 (Tier)" />
+ ),
+ cell: ({ row, table }) => {
+ const value = row.getValue("tier") as string
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ const onSave = async (newValue: any) => {
+ if (table.options.meta?.onCellUpdate) {
+ await table.options.meta.onCellUpdate(row.original.id, "tier", newValue)
+ }
+ }
+
+ const isModified = getIsModified(table, row.original.id, "tier")
+
+ return (
+ <EditableCell
+ value={value}
+ type="select"
+ onSave={onSave}
+ options={[
+ { label: "Tier 1", value: "Tier 1" },
+ { label: "Tier 2", value: "Tier 2" },
+ { label: "Tier 3", value: "Tier 3" }
+ ]}
+ placeholder="등급 선택"
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 100,
+ },
+ ],
+ },
+ // FA 정보 그룹
+ {
+ header: "FA 정보",
+ columns: [
+ {
+ accessorKey: "faTarget",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="FA 대상" />
+ ),
+ cell: ({ row, table }) => {
+ const value = row.getValue("faTarget") as boolean
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ const onSave = async (newValue: any) => {
+ if (table.options.meta?.onCellUpdate) {
+ await table.options.meta.onCellUpdate(row.original.id, "faTarget", newValue)
+ }
+ }
+
+ const isModified = getIsModified(table, row.original.id, "faTarget")
+
+ return (
+ <EditableCell
+ value={value}
+ type="checkbox"
+ onSave={onSave}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 80,
+ },
+ {
+ accessorKey: "faStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="FA 현황" />
+ ),
+ cell: ({ row, table }) => {
+ const value = row.getValue("faStatus")
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ const onSave = async (newValue: any) => {
+ if (table.options.meta?.onCellUpdate) {
+ await table.options.meta.onCellUpdate(row.original.id, "faStatus", newValue)
+ }
+ }
+
+ const isModified = getIsModified(table, row.original.id, "faStatus")
+
+ return (
+ <EditableCell
+ value={value}
+ type="text"
+ onSave={onSave}
+ placeholder="FA 현황 입력"
+ maxLength={50}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 100,
+ },
+ ],
+ },
+ // SHI Qualification 그룹
+ {
+ header: "SHI Qualification",
+ columns: [
+ {
+ accessorKey: "shiAvl",
+ header: "AVL",
+ cell: ({ row, table }) => {
+ const value = row.getValue("shiAvl") as boolean
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ const onSave = async (newValue: any) => {
+ if (table.options.meta?.onCellUpdate) {
+ await table.options.meta.onCellUpdate(row.original.id, "shiAvl", newValue)
+ }
+ }
+
+ const isModified = getIsModified(table, row.original.id, "shiAvl")
+
+ return (
+ <EditableCell
+ value={value}
+ type="checkbox"
+ onSave={onSave}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 80,
+ },
+ {
+ accessorKey: "shiBlacklist",
+ header: "Blacklist",
+ cell: ({ row, table }) => {
+ const value = row.getValue("shiBlacklist") as boolean
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ const onSave = async (newValue: any) => {
+ if (table.options.meta?.onCellUpdate) {
+ await table.options.meta.onCellUpdate(row.original.id, "shiBlacklist", newValue)
+ }
+ }
+
+ const isModified = getIsModified(table, row.original.id, "shiBlacklist")
+
+ return (
+ <EditableCell
+ value={value}
+ type="checkbox"
+ onSave={onSave}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "shiBcc",
+ header: "BCC",
+ cell: ({ row, table }) => {
+ const value = row.getValue("shiBcc") as boolean
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ const onSave = async (newValue: any) => {
+ if (table.options.meta?.onCellUpdate) {
+ await table.options.meta.onCellUpdate(row.original.id, "shiBcc", newValue)
+ }
+ }
+
+ const isModified = getIsModified(table, row.original.id, "shiBcc")
+
+ return (
+ <EditableCell
+ value={value}
+ type="checkbox"
+ onSave={onSave}
+ autoSave={true}
+ disabled={false}
+ initialEditMode={isEmptyRow}
+ isModified={isModified}
+ />
+ )
+ },
+ size: 80,
+ },
+ ],
+ },
+ // 액션 컬럼
+ {
+ id: "actions",
+ header: "액션",
+ cell: ({ row, table }) => {
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ return (
+ <div className="flex items-center gap-2">
+ {!isEmptyRow && (
+ <>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => table.options.meta?.onAction?.('edit', row.original)}
+ className="h-8 w-8 p-0"
+ >
+ <Edit className="h-4 w-4" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => table.options.meta?.onAction?.('delete', row.original)}
+ className="h-8 w-8 p-0 text-destructive hover:text-destructive"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </>
+ )}
+ {isEmptyRow && (
+ <>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => table.options.meta?.onSaveEmptyRow?.(row.original.id)}
+ className="h-8 w-8 p-0"
+ >
+ 저장
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => table.options.meta?.onCancelEmptyRow?.(row.original.id)}
+ className="h-8 w-8 p-0"
+ >
+ 취소
+ </Button>
+ </>
+ )}
+ </div>
+ )
+ },
+ size: 100,
+ enableSorting: false,
+ enableHiding: false,
+ },
+]
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.tsx b/lib/avl/table/project-avl-table.tsx
new file mode 100644
index 00000000..c6dd8064
--- /dev/null
+++ b/lib/avl/table/project-avl-table.tsx
@@ -0,0 +1,724 @@
+"use client"
+
+import * as React from "react"
+import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel, type ColumnDef } from "@tanstack/react-table"
+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 { ProjectAvlAddDialog } from "./project-avl-add-dialog"
+import { getProjectAvlVendorInfo, getAvlListById, createAvlVendorInfo, updateAvlVendorInfo, deleteAvlVendorInfo } from "../service"
+import { getProjectInfoByProjectCode as getProjectInfoFromProjects } from "../../projects/service"
+import { getProjectInfoByProjectCode as getProjectInfoFromBiddingProjects } from "../../bidding-projects/service"
+import { GetProjectAvlSchema } from "../validations"
+import { AvlDetailItem, AvlListItem, AvlVendorInfoInput } from "../types"
+import { toast } from "sonner"
+
+// 프로젝트 AVL 테이블에서는 AvlDetailItem을 사용
+export type ProjectAvlItem = AvlDetailItem
+
+interface ProjectAvlTableProps {
+ onSelectionChange?: (count: number) => void
+ resetCounter?: number
+ projectCode?: string // 프로젝트 코드 필터
+ avlListId?: number // AVL 리스트 ID (관리 영역 표시용)
+ onProjectCodeChange?: (projectCode: string) => void // 프로젝트 코드 변경 콜백
+}
+
+// 프로젝트 AVL 테이블 컬럼
+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>
+ )
+ },
+ },
+]
+
+export function ProjectAvlTable({
+ onSelectionChange,
+ resetCounter,
+ projectCode,
+ avlListId,
+ onProjectCodeChange
+}: ProjectAvlTableProps) {
+ const [data, setData] = React.useState<ProjectAvlItem[]>([])
+ const [loading, setLoading] = React.useState(false)
+ const [pageCount, setPageCount] = React.useState(0)
+ const [avlListInfo, setAvlListInfo] = React.useState<AvlListItem | null>(null)
+ 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<'idle' | 'searching' | 'success-projects' | 'success-bidding' | 'error'>('idle')
+
+
+ // 데이터 로드 함수
+ const loadData = React.useCallback(async (searchParams: Partial<GetProjectAvlSchema>) => {
+ try {
+ setLoading(true)
+ const params: any = {
+ page: searchParams.page ?? 1,
+ perPage: searchParams.perPage ?? 10,
+ sort: searchParams.sort ?? [{ id: "no", desc: false }],
+ flags: searchParams.flags ?? [],
+ projectCode: localProjectCode || "",
+ 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.search ?? "",
+ }
+ const result = await getProjectAvlVendorInfo(params)
+ setData(result.data)
+ setPageCount(result.pageCount)
+ } catch (error) {
+ console.error("프로젝트 AVL 데이터 로드 실패:", error)
+ setData([])
+ setPageCount(0)
+ } finally {
+ setLoading(false)
+ }
+ }, [localProjectCode])
+
+ // AVL 리스트 정보 로드
+ React.useEffect(() => {
+ const loadAvlListInfo = async () => {
+ if (avlListId) {
+ try {
+ const info = await getAvlListById(avlListId)
+ setAvlListInfo(info)
+ } catch (error) {
+ console.error("AVL 리스트 정보 로드 실패:", error)
+ }
+ }
+ }
+
+ loadAvlListInfo()
+ }, [avlListId])
+
+ // 초기 데이터 로드
+ React.useEffect(() => {
+ if (localProjectCode) {
+ loadData({})
+ }
+ }, [loadData, localProjectCode])
+
+ // 파일 업로드 핸들러
+ 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 handleProjectCodeChange = React.useCallback(async (value: string) => {
+ setLocalProjectCode(value)
+ onProjectCodeChange?.(value)
+
+ // 프로젝트 코드가 입력된 경우 프로젝트 정보 조회
+ if (value.trim()) {
+ setProjectSearchStatus('searching') // 검색 시작 상태로 변경
+
+ try {
+ // 1. projects 테이블에서 먼저 검색
+ let projectData = null
+ let searchSource = 'projects'
+
+ try {
+ projectData = await getProjectInfoFromProjects(value.trim())
+ // projects에서 찾았을 때만 즉시 성공 상태로 변경
+ setProjectSearchStatus('success-projects')
+ } catch (projectsError) {
+ // projects 테이블에 없는 경우 biddingProjects 테이블에서 검색
+ try {
+ projectData = await getProjectInfoFromBiddingProjects(value.trim())
+ if (projectData) {
+ searchSource = 'bidding-projects'
+ setProjectSearchStatus('success-bidding') // bidding에서 찾았을 때 성공 상태로 변경
+ } else {
+ // 둘 다 실패한 경우에만 에러 상태로 변경
+ setProjectInfo(null)
+ setProjectSearchStatus('error')
+ setData([])
+ setPageCount(0)
+ toast.error("입력하신 프로젝트 코드를 찾을 수 없습니다.")
+ return
+ }
+ } catch (biddingError) {
+ // 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 데이터 로드
+ loadData({})
+ }
+ } catch (error) {
+ console.error("프로젝트 정보 조회 실패:", error)
+ setProjectInfo(null)
+ setProjectSearchStatus('error')
+ setData([])
+ setPageCount(0)
+ toast.error("프로젝트 정보를 불러오는데 실패했습니다.")
+ }
+ } else {
+ // 프로젝트 코드가 비어있는 경우 프로젝트 정보 초기화 및 데이터 클리어
+ setProjectInfo(null)
+ setProjectSearchStatus('idle')
+ setData([])
+ setPageCount(0)
+ }
+ }, [onProjectCodeChange])
+
+ // 행 추가 핸들러
+ 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 {
+ // AVL 리스트 ID 확인
+ if (!avlListId) {
+ toast.error("AVL 리스트가 선택되지 않았습니다.")
+ return
+ }
+
+ // DB에 실제 저장할 데이터 준비
+ const saveData: AvlVendorInfoInput = {
+ ...itemData,
+ avlListId: avlListId // 현재 AVL 리스트 ID 설정
+ }
+
+ // DB에 저장
+ const result = await createAvlVendorInfo(saveData)
+
+ if (result) {
+ toast.success("새 항목이 성공적으로 추가되었습니다.")
+
+ // 데이터 새로고침
+ loadData({})
+ } else {
+ toast.error("항목 추가에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("항목 추가 실패:", error)
+ toast.error("항목 추가 중 오류가 발생했습니다.")
+ }
+ }, [avlListId, loadData])
+
+ // 다이얼로그에서 항목 수정 핸들러
+ 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,
+ initialState: {
+ pagination: {
+ pageSize: 10,
+ },
+ },
+ onPaginationChange: (updater) => {
+ const newState = typeof updater === 'function' ? updater(table.getState().pagination) : updater
+ loadData({
+ page: newState.pageIndex + 1,
+ perPage: newState.pageSize,
+ })
+ },
+ meta: tableMeta,
+ })
+
+ // 항목 수정 핸들러 (버튼 클릭)
+ 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])
+
+ // 선택 상태 변경 시 콜백 호출
+ React.useEffect(() => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ onSelectionChange?.(selectedRows.length)
+ }, [table.getFilteredSelectedRowModel().rows.length, onSelectionChange])
+
+ // 선택 해제 요청이 오면 모든 선택 해제
+ React.useEffect(() => {
+ if (resetCounter && resetCounter > 0) {
+ table.toggleAllPageRowsSelected(false)
+ }
+ }, [resetCounter, table])
+
+ return (
+ <div className="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={() => toast.info("개발 중입니다.")}>
+ 최종 확정
+ </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="space-y-2 min-w-[250px] flex-shrink-0">
+ <label className={`text-sm font-medium ${
+ projectSearchStatus === 'error' ? 'text-red-600' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'text-green-600' :
+ projectSearchStatus === 'searching' ? 'text-blue-600' :
+ 'text-muted-foreground'
+ }`}>
+ 프로젝트 코드
+ {projectSearchStatus === 'success-projects' && <span className="ml-1 text-xs">(프로젝트)</span>}
+ {projectSearchStatus === 'success-bidding' && <span className="ml-1 text-xs">(견적프로젝트)</span>}
+ {projectSearchStatus === 'searching' && <span className="ml-1 text-xs">(검색 중...)</span>}
+ {projectSearchStatus === 'error' && <span className="ml-1 text-xs">(찾을 수 없음)</span>}
+ </label>
+ <Input
+ value={localProjectCode}
+ onChange={(e) => handleProjectCodeChange(e.target.value)}
+ placeholder="프로젝트 코드를 입력하세요"
+ // disabled={projectSearchStatus === 'searching'}
+ className={`h-8 text-sm ${
+ projectSearchStatus === 'error' ? 'border-red-300 focus:border-red-500 focus:ring-red-500/20' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-300 focus:border-green-500 focus:ring-green-500/20' :
+ projectSearchStatus === 'searching' ? 'border-blue-300 focus:border-blue-500 focus:ring-blue-500/20' :
+ ''
+ }`}
+ />
+ </div>
+
+ {/* 프로젝트명 */}
+ <div className="space-y-2 min-w-[250px] flex-shrink-0">
+ <label className={`text-sm font-medium ${
+ projectSearchStatus === 'error' ? 'text-red-600' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'text-green-600' :
+ projectSearchStatus === 'searching' ? 'text-blue-600' :
+ 'text-muted-foreground'
+ }`}>
+ 프로젝트명
+ {projectSearchStatus === 'searching' && <span className="ml-1 text-xs">(조회 중...)</span>}
+ </label>
+ <div className={`text-sm font-medium min-h-[32px] flex items-center border rounded-md px-3 bg-background ${
+ projectSearchStatus === 'error' ? 'border-red-300' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-300' :
+ projectSearchStatus === 'searching' ? 'border-blue-300' :
+ 'border-input'
+ }`}>
+ {projectSearchStatus === 'searching' ? '조회 중...' : (projectInfo?.projectName || '-')}
+ </div>
+ </div>
+
+ {/* 원본파일 */}
+ <div className="space-y-2 min-w-[200px] flex-shrink-0">
+ <label className="text-sm font-medium text-muted-foreground">원본파일</label>
+ <div className="flex items-center gap-2 min-h-[32px]">
+ {originalFile ? (
+ <span className="text-sm text-blue-600">{originalFile}</span>
+ ) : (
+ <div className="relative">
+ <input
+ type="file"
+ onChange={handleFileUpload}
+ className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
+ accept=".xlsx,.xls,.csv"
+ />
+ <Button variant="outline" size="sm" className="text-xs">
+ 파일 선택
+ </Button>
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* 공사부문 */}
+ <div className="space-y-2 min-w-[120px] flex-shrink-0">
+ <label className={`text-sm font-medium ${
+ projectSearchStatus === 'error' ? 'text-red-600' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'text-green-600' :
+ projectSearchStatus === 'searching' ? 'text-blue-600' :
+ 'text-muted-foreground'
+ }`}>
+ 공사부문
+ {projectSearchStatus === 'searching' && <span className="ml-1 text-xs">(조회 중...)</span>}
+ </label>
+ <div className={`text-sm font-medium min-h-[32px] flex items-center border rounded-md px-3 bg-background ${
+ projectSearchStatus === 'error' ? 'border-red-300' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-300' :
+ projectSearchStatus === 'searching' ? 'border-blue-300' :
+ 'border-input'
+ }`}>
+ {projectSearchStatus === 'searching' ? '조회 중...' : (projectInfo?.constructionSector || '-')}
+ </div>
+ </div>
+
+ {/* 선종 */}
+ <div className="space-y-2 min-w-[120px] flex-shrink-0">
+ <label className={`text-sm font-medium ${
+ projectSearchStatus === 'error' ? 'text-red-600' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'text-green-600' :
+ projectSearchStatus === 'searching' ? 'text-blue-600' :
+ 'text-muted-foreground'
+ }`}>
+ 선종
+ {projectSearchStatus === 'searching' && <span className="ml-1 text-xs">(조회 중...)</span>}
+ </label>
+ <div className={`text-sm font-medium min-h-[32px] flex items-center border rounded-md px-3 bg-background ${
+ projectSearchStatus === 'error' ? 'border-red-300' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-300' :
+ projectSearchStatus === 'searching' ? 'border-blue-300' :
+ 'border-input'
+ }`}>
+ {projectSearchStatus === 'searching' ? '조회 중...' : (projectInfo?.shipType || '-')}
+ </div>
+ </div>
+
+ {/* H/T 구분 */}
+ <div className="space-y-2 min-w-[140px] flex-shrink-0">
+ <label className={`text-sm font-medium ${
+ projectSearchStatus === 'error' ? 'text-red-600' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'text-green-600' :
+ projectSearchStatus === 'searching' ? 'text-blue-600' :
+ 'text-muted-foreground'
+ }`}>
+ H/T 구분
+ {projectSearchStatus === 'searching' && <span className="ml-1 text-xs">(조회 중...)</span>}
+ </label>
+ <div className={`text-sm font-medium min-h-[32px] flex items-center border rounded-md px-3 bg-background ${
+ projectSearchStatus === 'error' ? 'border-red-300' :
+ projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-300' :
+ projectSearchStatus === 'searching' ? 'border-blue-300' :
+ 'border-input'
+ }`}>
+ {projectSearchStatus === 'searching' ? '조회 중...' :
+ (projectInfo?.htDivision === 'H' ? 'Hull (H)' :
+ projectInfo?.htDivision === 'T' ? 'Topside (T)' : '-')}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div className="flex-1">
+ <DataTable table={table} />
+ </div>
+
+ {/* 행 추가/수정 다이얼로그 */}
+ <ProjectAvlAddDialog
+ open={isAddDialogOpen}
+ onOpenChange={(open) => {
+ setIsAddDialogOpen(open)
+ if (!open) {
+ setEditingItem(undefined) // 다이얼로그가 닫힐 때 수정 모드 해제
+ }
+ }}
+ onAddItem={handleAddItem}
+ editingItem={editingItem}
+ onUpdateItem={handleUpdateItem}
+ />
+ </div>
+ )
+}
diff --git a/lib/avl/table/standard-avl-table.tsx b/lib/avl/table/standard-avl-table.tsx
new file mode 100644
index 00000000..924b972a
--- /dev/null
+++ b/lib/avl/table/standard-avl-table.tsx
@@ -0,0 +1,380 @@
+"use client"
+
+import * as React from "react"
+import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel, type ColumnDef } from "@tanstack/react-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+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"
+
+// 검색 옵션들
+const constructionSectorOptions = [
+ { value: "all", label: "전체" },
+ { value: "조선", label: "조선" },
+ { value: "해양", label: "해양" },
+]
+
+const shipTypeOptions = [
+ { value: "all", label: "전체" },
+ { value: "컨테이너선", label: "컨테이너선" },
+ { value: "유조선", label: "유조선" },
+ { value: "LNG선", label: "LNG선" },
+ { value: "LPG선", label: "LPG선" },
+ { value: "벌크선", label: "벌크선" },
+ { value: "여객선", label: "여객선" },
+]
+
+const avlKindOptions = [
+ { value: "all", label: "전체" },
+ { value: "표준", label: "표준" },
+ { value: "특별", label: "특별" },
+ { value: "임시", label: "임시" },
+]
+
+const htDivisionOptions = [
+ { value: "all", label: "전체" },
+ { value: "H", label: "Hull (H)" },
+ { value: "T", label: "Topside (T)" },
+]
+
+// 선종별 표준 AVL 테이블에서는 AvlDetailItem을 사용
+export type StandardAvlItem = AvlDetailItem
+
+interface StandardAvlTableProps {
+ onSelectionChange?: (count: number) => void
+ resetCounter?: number
+ constructionSector?: string // 공사부문 필터
+ shipType?: string // 선종 필터
+ avlKind?: string // AVL 종류 필터
+ htDivision?: string // H/T 구분 필터
+}
+
+// 선종별 표준 AVL 테이블 컬럼
+const standardAvlColumns: ColumnDef<StandardAvlItem>[] = [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row, table }) => {
+ // 선종별 표준 AVL 테이블의 단일 선택 핸들러
+ const handleRowSelection = (checked: boolean) => {
+ if (checked) {
+ // 다른 모든 행의 선택 해제
+ table.getRowModel().rows.forEach(r => {
+ if (r !== row && r.getIsSelected()) {
+ r.toggleSelected(false)
+ }
+ })
+ }
+ // 현재 행 선택/해제
+ row.toggleSelected(checked)
+ }
+
+ return (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={handleRowSelection}
+ aria-label="Select row"
+ />
+ )
+ },
+ enableSorting: false,
+ enableHiding: false,
+ size: 50,
+ },
+ {
+ accessorKey: "no",
+ header: "No.",
+ size: 60,
+ },
+ {
+ accessorKey: "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,
+ },
+]
+
+export function StandardAvlTable({
+ onSelectionChange,
+ resetCounter,
+ constructionSector: initialConstructionSector,
+ shipType: initialShipType,
+ avlKind: initialAvlKind,
+ htDivision: initialHtDivision
+}: StandardAvlTableProps) {
+ const [data, setData] = React.useState<StandardAvlItem[]>([])
+ const [loading, setLoading] = React.useState(false)
+ const [pageCount, setPageCount] = React.useState(0)
+
+ // 검색 상태
+ const [searchConstructionSector, setSearchConstructionSector] = React.useState(initialConstructionSector || "all")
+ const [searchShipType, setSearchShipType] = React.useState(initialShipType || "all")
+ const [searchAvlKind, setSearchAvlKind] = React.useState(initialAvlKind || "all")
+ const [searchHtDivision, setSearchHtDivision] = React.useState(initialHtDivision || "all")
+
+ // 데이터 로드 함수
+ 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 }],
+ constructionSector: searchConstructionSector === "all" ? "" : searchConstructionSector || "",
+ shipType: searchShipType === "all" ? "" : searchShipType || "",
+ avlKind: searchAvlKind === "all" ? "" : searchAvlKind || "",
+ htDivision: searchHtDivision === "all" ? "" : searchHtDivision || "",
+ search: "",
+ ...searchParams,
+ }
+ const result = await getStandardAvlVendorInfo(params)
+ setData(result.data)
+ setPageCount(result.pageCount)
+ } catch (error) {
+ console.error("선종별 표준 AVL 데이터 로드 실패:", error)
+ setData([])
+ setPageCount(0)
+ } finally {
+ setLoading(false)
+ }
+ }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision])
+
+ // 검색 핸들러
+ const handleSearch = React.useCallback(() => {
+ loadData({})
+ }, [loadData])
+
+ // 검색 초기화 핸들러
+ const handleResetSearch = React.useCallback(() => {
+ setSearchConstructionSector("all")
+ setSearchShipType("all")
+ setSearchAvlKind("all")
+ setSearchHtDivision("all")
+ loadData({})
+ }, [loadData])
+
+ // 초기 데이터 로드
+ React.useEffect(() => {
+ loadData({})
+ }, [loadData])
+
+ const table = useReactTable({
+ data,
+ columns: standardAvlColumns,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ manualPagination: true,
+ pageCount,
+ initialState: {
+ pagination: {
+ pageSize: 10,
+ },
+ },
+ onPaginationChange: (updater) => {
+ const newState = typeof updater === 'function' ? updater(table.getState().pagination) : updater
+ loadData({
+ page: newState.pageIndex + 1,
+ perPage: newState.pageSize,
+ })
+ },
+ })
+
+ // 선택 상태 변경 시 콜백 호출
+ React.useEffect(() => {
+ onSelectionChange?.(table.getFilteredSelectedRowModel().rows.length)
+ }, [table.getFilteredSelectedRowModel().rows.length, onSelectionChange])
+
+ // 선택 해제 요청이 오면 모든 선택 해제
+ React.useEffect(() => {
+ if (resetCounter && resetCounter > 0) {
+ table.toggleAllPageRowsSelected(false)
+ }
+ }, [resetCounter, table])
+
+ return (
+ <div className="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={() => 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={() => toast.info("개발 중입니다.")}>
+ 항목삭제
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 저장
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 최종 확정
+ </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={setSearchConstructionSector}>
+ <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>
+ {shipTypeOptions.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}
+ 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>
+ </div>
+ )
+}
diff --git a/lib/avl/table/vendor-pool-table.tsx b/lib/avl/table/vendor-pool-table.tsx
new file mode 100644
index 00000000..1a0a5fca
--- /dev/null
+++ b/lib/avl/table/vendor-pool-table.tsx
@@ -0,0 +1,290 @@
+"use client"
+
+import * as React from "react"
+import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel, type ColumnDef } from "@tanstack/react-table"
+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"
+
+// Vendor Pool 데이터 타입 (실제 VendorPool 타입 사용)
+export type VendorPoolItem = VendorPool
+
+interface VendorPoolTableProps {
+ onSelectionChange?: (count: number) => void
+ resetCounter?: number
+}
+
+// Vendor Pool 테이블 컬럼
+const vendorPoolColumns: ColumnDef<VendorPoolItem>[] = [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ />
+ ),
+ cell: ({ row, table }) => {
+ // Vendor Pool 테이블의 단일 선택 핸들러
+ const handleRowSelection = (checked: boolean) => {
+ if (checked) {
+ // 다른 모든 행의 선택 해제
+ table.getRowModel().rows.forEach(r => {
+ if (r !== row && r.getIsSelected()) {
+ r.toggleSelected(false)
+ }
+ })
+ }
+ // 현재 행 선택/해제
+ row.toggleSelected(checked)
+ }
+
+ return (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={handleRowSelection}
+ aria-label="Select row"
+ />
+ )
+ },
+ enableSorting: false,
+ enableHiding: false,
+ size: 50,
+ },
+ {
+ accessorKey: "no",
+ header: "No.",
+ size: 60,
+ },
+ {
+ accessorKey: "designCategory",
+ header: "설계공종",
+ size: 120,
+ },
+ {
+ accessorKey: "avlVendorName",
+ header: "AVL 등재업체명",
+ size: 140,
+ },
+ {
+ accessorKey: "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,
+ },
+]
+
+// 실제 데이터는 API에서 가져옴
+
+export function VendorPoolTable({
+ onSelectionChange,
+ resetCounter
+}: VendorPoolTableProps) {
+ 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 loadData = React.useCallback(async (searchParams: Partial<GetVendorPoolSchema>) => {
+ try {
+ setLoading(true)
+ const params: GetVendorPoolSchema = {
+ page: searchParams.page ?? 1,
+ perPage: searchParams.perPage ?? 10,
+ sort: searchParams.sort ?? [{ id: "registrationDate", desc: true }],
+ search: searchText || "",
+ ...searchParams,
+ }
+ const result = await getVendorPools(params)
+ 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 {
+ loadData({})
+ }
+ }, [loadData, showAll])
+
+ // 전체보기 토글 핸들러
+ const handleShowAllToggle = React.useCallback((checked: boolean) => {
+ setShowAll(checked)
+ if (checked) {
+ // 전체보기 활성화 시 전체 데이터 로드
+ loadData({ perPage: 1000 })
+ setSearchText("")
+ } else {
+ // 전체보기 비활성화 시 일반 페이징으로 전환
+ loadData({})
+ }
+ }, [loadData])
+
+ // 초기 데이터 로드
+ React.useEffect(() => {
+ loadData({})
+ }, [loadData])
+
+ const table = useReactTable({
+ data,
+ columns: vendorPoolColumns,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ manualPagination: !showAll, // 전체보기 시에는 수동 페이징 비활성화
+ pageCount: showAll ? 1 : pageCount, // 전체보기 시 1페이지, 일반 모드에서는 API에서 받은 pageCount 사용
+ initialState: {
+ pagination: {
+ pageSize: showAll ? data.length : 10, // 전체보기 시 모든 데이터 표시
+ },
+ },
+ onPaginationChange: (updater) => {
+ if (!showAll) {
+ // 전체보기가 아닐 때만 페이징 변경 처리
+ const newState = typeof updater === 'function' ? updater(table.getState().pagination) : updater
+ loadData({
+ page: newState.pageIndex + 1,
+ perPage: newState.pageSize,
+ })
+ }
+ },
+ })
+
+ // 선택 상태 변경 시 콜백 호출
+ React.useEffect(() => {
+ onSelectionChange?.(table.getFilteredSelectedRowModel().rows.length)
+ }, [table.getFilteredSelectedRowModel().rows.length, onSelectionChange])
+
+ // 선택 해제 요청이 오면 모든 선택 해제
+ React.useEffect(() => {
+ if (resetCounter && resetCounter > 0) {
+ table.toggleAllPageRowsSelected(false)
+ }
+ }, [resetCounter, table])
+
+ return (
+ <div className="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>
+ )
+}