diff options
Diffstat (limited to 'lib/avl/table')
| -rw-r--r-- | lib/avl/table/avl-detail-table.tsx | 479 | ||||
| -rw-r--r-- | lib/avl/table/avl-registration-area.tsx | 278 | ||||
| -rw-r--r-- | lib/avl/table/avl-table-columns.tsx | 351 | ||||
| -rw-r--r-- | lib/avl/table/avl-table.tsx | 514 | ||||
| -rw-r--r-- | lib/avl/table/columns-detail.tsx | 680 | ||||
| -rw-r--r-- | lib/avl/table/project-avl-add-dialog.tsx | 779 | ||||
| -rw-r--r-- | lib/avl/table/project-avl-table.tsx | 724 | ||||
| -rw-r--r-- | lib/avl/table/standard-avl-table.tsx | 380 | ||||
| -rw-r--r-- | lib/avl/table/vendor-pool-table.tsx | 290 |
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> + ) +} |
