diff options
Diffstat (limited to 'lib/avl/table')
| -rw-r--r-- | lib/avl/table/avl-detail-table.tsx | 455 | ||||
| -rw-r--r-- | lib/avl/table/avl-registration-area.tsx | 316 | ||||
| -rw-r--r-- | lib/avl/table/avl-table-columns.tsx | 30 | ||||
| -rw-r--r-- | lib/avl/table/avl-table.tsx | 100 | ||||
| -rw-r--r-- | lib/avl/table/avl-vendor-add-and-modify-dialog.tsx | 945 | ||||
| -rw-r--r-- | lib/avl/table/columns-detail.tsx | 516 | ||||
| -rw-r--r-- | lib/avl/table/project-avl-table-columns.tsx | 167 | ||||
| -rw-r--r-- | lib/avl/table/project-avl-table.tsx | 720 | ||||
| -rw-r--r-- | lib/avl/table/standard-avl-add-dialog.tsx | 960 | ||||
| -rw-r--r-- | lib/avl/table/standard-avl-table-columns.tsx | 91 | ||||
| -rw-r--r-- | lib/avl/table/standard-avl-table.tsx | 603 | ||||
| -rw-r--r-- | lib/avl/table/vendor-pool-table-columns.tsx | 96 | ||||
| -rw-r--r-- | lib/avl/table/vendor-pool-table.tsx | 241 |
13 files changed, 3643 insertions, 1597 deletions
diff --git a/lib/avl/table/avl-detail-table.tsx b/lib/avl/table/avl-detail-table.tsx index ba15c6ef..04384ec8 100644 --- a/lib/avl/table/avl-detail-table.tsx +++ b/lib/avl/table/avl-detail-table.tsx @@ -5,31 +5,13 @@ 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 // 선주명 @@ -39,386 +21,61 @@ interface AvlDetailTableProps { 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("액션 처리 중 오류가 발생했습니다.") + const handleAction = React.useCallback(async (action: string) => { + switch (action) { + case 'avl-form': + toast.info("AVL 양식을 준비 중입니다.") + // TODO: AVL 양식 다운로드 로직 구현 + break + + case 'quote-request': + toast.info("견적 요청을 처리 중입니다.") + // TODO: 견적 요청 로직 구현 + break + + case 'vendor-pool': + toast.info("Vendor Pool을 열고 있습니다.") + // TODO: Vendor Pool 페이지 이동 또는 모달 열기 로직 구현 + break + + case 'download': + toast.info("데이터를 다운로드 중입니다.") + // TODO: 데이터 다운로드 로직 구현 + break + + default: + toast.error(`알 수 없는 액션: ${action}`) } - }, [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]) + }), [handleAction]) // 데이터 테이블 설정 const { table } = useDataTable({ - data: allData, + data, columns, - pageCount, + pageCount: pageCount ?? 1, initialState: { sorting: [{ id: "no", desc: false }], - columnPinning: { right: ["actions"] }, + pagination: { + pageIndex: 0, + pageSize: 10, + }, }, getRowId: (row) => String(row.id), meta: tableMeta, }) - // 변경사항이 있는지 확인 - const hasPendingChanges = Object.keys(pendingChanges).length > 0 - const hasEmptyRows = Object.keys(emptyRows).length > 0 return ( <div className="space-y-4"> @@ -435,45 +92,25 @@ export function AvlDetailTable({ </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 className="flex items-center gap-2"> + <Button variant="outline" size="sm" onClick={() => handleAction('avl-form')}> + AVL양식 + </Button> + <Button variant="outline" size="sm" onClick={() => handleAction('quote-request')}> + 견적요청 + </Button> + <Button variant="outline" size="sm" onClick={() => handleAction('vendor-pool')}> + Vendor Pool + </Button> + <Button variant="outline" size="sm" onClick={() => handleAction('download')}> + 다운로드 + </Button> </div> {/* 데이터 테이블 */} <DataTable table={table} /> - {/* 디버그 정보 (개발 환경에서만 표시) */} - {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 index def3d30a..52912a2c 100644 --- a/lib/avl/table/avl-registration-area.tsx +++ b/lib/avl/table/avl-registration-area.tsx @@ -5,15 +5,27 @@ 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 { ProjectAvlTable, ProjectAvlTableRef } from "./project-avl-table" +import { StandardAvlTable, StandardAvlTableRef } from "./standard-avl-table" +import { VendorPoolTable, VendorPoolTableRef } from "./vendor-pool-table" import { selectedAvlRecordAtom } from "../avl-atoms" -import type { AvlListItem } from "../types" +import { copyToProjectAvl, copyToStandardAvl, copyToVendorPool, copyFromVendorPoolToProjectAvl, copyFromVendorPoolToStandardAvl, copyFromStandardAvlToVendorPool } from "../service" +import { useSession } from "next-auth/react" +import { toast } from "sonner" // 선택된 테이블 타입 type SelectedTable = 'project' | 'standard' | 'vendor' | null +// TODO: 나머지 테이블들도 ref 지원 추가 시 인터페이스 추가 필요 +// interface StandardAvlTableRef { +// getSelectedIds?: () => number[] +// } +// +// interface VendorPoolTableRef { +// getSelectedIds?: () => number[] +// } + + // 선택 상태 액션 타입 type SelectionAction = | { type: 'SELECT_PROJECT'; count: number } @@ -105,9 +117,13 @@ interface AvlRegistrationAreaProps { } export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaProps) { + // 선택된 AVL 레코드 구독 const [selectedAvlRecord] = useAtom(selectedAvlRecordAtom) + // 세션 정보 + const { data: session } = useSession() + // 단일 선택 상태 관리 (useReducer 사용) const [selectionState, dispatch] = React.useReducer(selectionReducer, { selectedTable: null, @@ -121,10 +137,12 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro // 선택 핸들러들 const handleProjectSelection = React.useCallback((count: number) => { + console.log('handleProjectSelection called with count:', count) dispatch({ type: 'SELECT_PROJECT', count }) }, []) const handleStandardSelection = React.useCallback((count: number) => { + console.log('handleStandardSelection called with count:', count) dispatch({ type: 'SELECT_STANDARD', count }) }, []) @@ -134,6 +152,8 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro const { selectedTable, selectedRowCount, resetCounters } = selectionState + console.log('selectedTable', selectedTable); + // 선택된 AVL에 따른 필터 값들 const [currentProjectCode, setCurrentProjectCode] = React.useState<string>("") const constructionSector = selectedAvlRecord?.constructionSector || "" @@ -142,6 +162,38 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro const htDivision = selectedAvlRecord?.htDivision || "" const avlListId = selectedAvlRecord?.id ? String(selectedAvlRecord.id) : "" + // 선종별 표준 AVL 검색 조건 상태 (복사 버튼 활성화용) + const [standardSearchConditions, setStandardSearchConditions] = React.useState({ + constructionSector: "", + shipType: "", + avlKind: "", + htDivision: "" + }) + + // 검색 조건이 모두 입력되었는지 확인 + const isStandardSearchConditionsComplete = React.useMemo(() => { + return ( + standardSearchConditions.constructionSector.trim() !== "" && + standardSearchConditions.shipType.trim() !== "" && + standardSearchConditions.avlKind.trim() !== "" && + standardSearchConditions.htDivision.trim() !== "" + ) + }, [standardSearchConditions]) + + // 벤더 풀 리로드 트리거 + const [vendorPoolReloadTrigger, setVendorPoolReloadTrigger] = React.useState(0) + + // 선종별 표준 AVL 리로드 트리거 + const [standardAvlReloadTrigger, setStandardAvlReloadTrigger] = React.useState(0) + + // 프로젝트 AVL 리로드 트리거 + const [projectAvlReloadTrigger, setProjectAvlReloadTrigger] = React.useState(0) + + // 테이블 ref들 (선택된 행 정보 가져오기용) + const projectTableRef = React.useRef<ProjectAvlTableRef>(null) + const standardTableRef = React.useRef<StandardAvlTableRef>(null) + const vendorTableRef = React.useRef<VendorPoolTableRef>(null) + // 선택된 AVL 레코드가 변경될 때 프로젝트 코드 초기화 React.useEffect(() => { setCurrentProjectCode(selectedAvlRecord?.projectCode || "") @@ -152,6 +204,231 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro setCurrentProjectCode(projectCode) }, []) + // 선택된 ID들을 가져오는 헬퍼 함수들 + const getSelectedIds = React.useCallback((tableType: 'project' | 'standard' | 'vendor') => { + // 각 테이블 컴포넌트에서 선택된 행들의 ID를 가져오는 로직 + switch (tableType) { + case 'project': + return projectTableRef.current?.getSelectedIds?.() || [] + case 'standard': + return standardTableRef.current?.getSelectedIds?.() || [] + case 'vendor': + return vendorTableRef.current?.getSelectedIds?.() || [] + default: + return [] + } + }, []) + + // 복사 버튼 핸들러들 + const handleCopyToProject = React.useCallback(async () => { + if (selectedTable !== 'standard' || selectedRowCount === 0) return + + const selectedIds = getSelectedIds('standard') + if (!selectedIds.length) { + toast.error("복사할 항목을 선택해주세요.") + return + } + + if (!currentProjectCode) { + toast.error("프로젝트 코드가 설정되지 않았습니다.") + return + } + + try { + const result = await copyToProjectAvl( + selectedIds, + currentProjectCode, + parseInt(avlListId) || 1, + session?.user?.name || "unknown" + ) + + if (result.success) { + toast.success(result.message) + // 선택 해제 + dispatch({ type: 'CLEAR_SELECTION' }) + } else { + toast.error(result.message) + } + } catch (error) { + console.error('프로젝트AVL로 복사 실패:', error) + toast.error("복사 중 오류가 발생했습니다.") + } + }, [selectedTable, selectedRowCount, getSelectedIds, currentProjectCode, avlListId, session]) + + const handleCopyToStandard = React.useCallback(async () => { + if (selectedTable !== 'project' || selectedRowCount === 0) return + + const selectedIds = getSelectedIds('project') + if (!selectedIds.length) { + toast.error("복사할 항목을 선택해주세요.") + return + } + + const targetStandardInfo = { + constructionSector: standardSearchConditions.constructionSector || "조선", + shipType: standardSearchConditions.shipType || "", + avlKind: standardSearchConditions.avlKind || "", + htDivision: standardSearchConditions.htDivision || "H" + } + + try { + const result = await copyToStandardAvl( + selectedIds, + targetStandardInfo, + session?.user?.name || "unknown" + ) + + if (result.success) { + toast.success(result.message) + // 선종별 표준 AVL 데이터 리로드 + setStandardAvlReloadTrigger(prev => prev + 1) + // 선택 해제 + dispatch({ type: 'CLEAR_SELECTION' }) + } else { + toast.error(result.message) + } + } catch (error) { + console.error('선종별표준AVL로 복사 실패:', error) + toast.error("복사 중 오류가 발생했습니다.") + } + }, [selectedTable, selectedRowCount, getSelectedIds, standardSearchConditions, session]) + + const handleCopyToVendorPool = React.useCallback(async () => { + if (selectedTable !== 'project' || selectedRowCount === 0) return + + const selectedIds = getSelectedIds('project') + if (!selectedIds.length) { + toast.error("복사할 항목을 선택해주세요.") + return + } + + try { + const result = await copyToVendorPool( + selectedIds, + session?.user?.name || "unknown" + ) + + if (result.success) { + toast.success(result.message) + // 벤더 풀 데이터 리로드 + setVendorPoolReloadTrigger(prev => prev + 1) + // 선택 해제 + dispatch({ type: 'CLEAR_SELECTION' }) + } else { + toast.error(result.message) + } + } catch (error) { + console.error('벤더풀로 복사 실패:', error) + toast.error("복사 중 오류가 발생했습니다.") + } + }, [selectedTable, selectedRowCount, getSelectedIds, session]) + + // 추가 복사 버튼 핸들러들 + const handleCopyFromVendorToProject = React.useCallback(async () => { + if (selectedTable !== 'vendor' || selectedRowCount === 0) return + + const selectedIds = getSelectedIds('vendor') + if (!selectedIds.length) { + toast.error("복사할 항목을 선택해주세요.") + return + } + + if (!currentProjectCode) { + toast.error("프로젝트 코드가 설정되지 않았습니다.") + return + } + + try { + const result = await copyFromVendorPoolToProjectAvl( + selectedIds, + currentProjectCode, + parseInt(avlListId) || 1, + session?.user?.name || "unknown" + ) + + if (result.success) { + toast.success(result.message) + // 프로젝트 AVL 리로드 + setProjectAvlReloadTrigger(prev => prev + 1) + // 선택 해제 + dispatch({ type: 'CLEAR_SELECTION' }) + } else { + toast.error(result.message) + } + } catch (error) { + console.error('벤더풀 → 프로젝트AVL 복사 실패:', error) + toast.error("복사 중 오류가 발생했습니다.") + } + }, [selectedTable, selectedRowCount, getSelectedIds, currentProjectCode, avlListId, session]) + + const handleCopyFromVendorToStandard = React.useCallback(async () => { + if (selectedTable !== 'vendor' || selectedRowCount === 0) return + + const selectedIds = getSelectedIds('vendor') + if (!selectedIds.length) { + toast.error("복사할 항목을 선택해주세요.") + return + } + + const targetStandardInfo = { + constructionSector: standardSearchConditions.constructionSector || "조선", + shipType: standardSearchConditions.shipType || "", + avlKind: standardSearchConditions.avlKind || "", + htDivision: standardSearchConditions.htDivision || "H" + } + + try { + const result = await copyFromVendorPoolToStandardAvl( + selectedIds, + targetStandardInfo, + session?.user?.name || "unknown" + ) + + if (result.success) { + toast.success(result.message) + // 선종별 표준 AVL 리로드 + setStandardAvlReloadTrigger(prev => prev + 1) + // 선택 해제 + dispatch({ type: 'CLEAR_SELECTION' }) + } else { + toast.error(result.message) + } + } catch (error) { + console.error('벤더풀 → 선종별표준AVL 복사 실패:', error) + toast.error("복사 중 오류가 발생했습니다.") + } + }, [selectedTable, selectedRowCount, getSelectedIds, standardSearchConditions, session]) + + const handleCopyFromStandardToVendor = React.useCallback(async () => { + if (selectedTable !== 'standard' || selectedRowCount === 0) return + + const selectedIds = getSelectedIds('standard') + if (!selectedIds.length) { + toast.error("복사할 항목을 선택해주세요.") + return + } + + try { + const result = await copyFromStandardAvlToVendorPool( + selectedIds, + session?.user?.name || "unknown" + ) + + if (result.success) { + toast.success(result.message) + // 벤더 풀 리로드 + setVendorPoolReloadTrigger(prev => prev + 1) + // 선택 해제 + dispatch({ type: 'CLEAR_SELECTION' }) + } else { + toast.error(result.message) + } + } catch (error) { + console.error('선종별표준AVL → 벤더풀 복사 실패:', error) + toast.error("복사 중 오류가 발생했습니다.") + } + }, [selectedTable, selectedRowCount, getSelectedIds, session]) + return ( <Card className={`h-full min-w-full overflow-visible ${disabled ? 'opacity-50 pointer-events-none' : ''}`}> {/* 고정 헤더 영역 */} @@ -159,9 +436,9 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro <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}> + {/* <Button variant="outline" size="sm" disabled={disabled}> AVL 불러오기 - </Button> + </Button> */} </div> </div> </div> @@ -172,11 +449,13 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro {/* 프로젝트 AVL 테이블 - 9개 컬럼 */} <div className="p-4 border-r relative"> <ProjectAvlTable + ref={projectTableRef} onSelectionChange={handleProjectSelection} resetCounter={resetCounters.project} projectCode={currentProjectCode} avlListId={parseInt(avlListId) || 1} onProjectCodeChange={handleProjectCodeChange} + reloadTrigger={projectAvlReloadTrigger} /> {/* 이동 버튼들 - 첫 번째 border 위에 오버레이 */} @@ -188,7 +467,8 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro size="sm" className="w-8 h-8 p-0" title="프로젝트AVL로 복사" - disabled={disabled || selectedTable !== 'standard' || selectedRowCount === 0} + disabled={disabled || selectedTable === 'project' || selectedRowCount === 0} + onClick={handleCopyToProject} > <ChevronLeft className="w-4 h-4" /> </Button> @@ -198,7 +478,8 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro size="sm" className="w-8 h-8 p-0" title="선종별표준AVL로 복사" - disabled={disabled || selectedTable !== 'project' || selectedRowCount === 0} + disabled={disabled || selectedTable !== 'project' || selectedRowCount === 0 || !isStandardSearchConditionsComplete} + onClick={handleCopyToStandard} > <ChevronRight className="w-4 h-4" /> </Button> @@ -209,6 +490,7 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro className="w-8 h-8 p-0" title="벤더풀로 복사" disabled={disabled || selectedTable !== 'project' || selectedRowCount === 0} + onClick={handleCopyToVendorPool} > <ChevronsRight className="w-4 h-4" /> </Button> @@ -220,12 +502,15 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro {/* 선종별 표준 AVL 테이블 - 8개 컬럼 */} <div className="p-4 border-r relative"> <StandardAvlTable + ref={standardTableRef} onSelectionChange={handleStandardSelection} resetCounter={resetCounters.standard} constructionSector={constructionSector} shipType={shipType} avlKind={avlKind} htDivision={htDivision} + onSearchConditionsChange={setStandardSearchConditions} + reloadTrigger={standardAvlReloadTrigger} /> {/* 이동 버튼들 - 두 번째 border 위에 오버레이 */} @@ -235,8 +520,9 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro variant="outline" size="sm" className="w-8 h-8 p-0" - title="프로젝트AVL로 복사" - disabled={disabled || selectedTable !== 'vendor' || selectedRowCount === 0} + title="벤더풀의 항목을 프로젝트AVL로 복사" + disabled={disabled || selectedTable !== 'vendor' || selectedRowCount === 0 || !currentProjectCode} + onClick={handleCopyFromVendorToProject} > <ChevronsLeft className="w-4 h-4" /> </Button> @@ -245,8 +531,9 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro variant="outline" size="sm" className="w-8 h-8 p-0" - title="선종별표준AVL로 복사" - disabled={disabled || selectedTable !== 'vendor' || selectedRowCount === 0} + title="벤더풀의 항목을 선종별표준AVL로 복사" + disabled={disabled || selectedTable !== 'vendor' || selectedRowCount === 0 || !isStandardSearchConditionsComplete} + onClick={handleCopyFromVendorToStandard} > <ChevronLeft className="w-4 h-4" /> </Button> @@ -254,8 +541,9 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro variant="outline" size="sm" className="w-8 h-8 p-0" - title="벤더풀로 복사" + title="선종별표준AVL의 항목을 벤더풀로 복사" disabled={disabled || selectedTable !== 'standard' || selectedRowCount === 0} + onClick={handleCopyFromStandardToVendor} > <ChevronRight className="w-4 h-4" /> </Button> @@ -267,8 +555,10 @@ export function AvlRegistrationArea({ disabled = false }: AvlRegistrationAreaPro {/* Vendor Pool 테이블 - 10개 컬럼 */} <div className="p-4 relative"> <VendorPoolTable + ref={vendorTableRef} onSelectionChange={handleVendorSelection} resetCounter={resetCounters.vendor} + reloadTrigger={vendorPoolReloadTrigger} /> </div> </div> diff --git a/lib/avl/table/avl-table-columns.tsx b/lib/avl/table/avl-table-columns.tsx index 77361f36..8caf012e 100644 --- a/lib/avl/table/avl-table-columns.tsx +++ b/lib/avl/table/avl-table-columns.tsx @@ -1,7 +1,7 @@ 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 { Eye, Edit, Trash2, History } from "lucide-react" import { type ColumnDef, TableMeta } from "@tanstack/react-table" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { EditableCell } from "@/components/data-table/editable-cell" @@ -224,22 +224,24 @@ export function getColumns({ selectedRows = [], onRowSelect }: GetColumnsProps): ), 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") - }} - /> + <div className="flex items-center gap-1"> + <Badge variant="outline" className="font-mono"> + {value || 1} + </Badge> + <Button + variant="ghost" + size="sm" + className="h-6 w-6 p-0" + onClick={() => table.options.meta?.onAction?.('view-history', row.original)} + title="리비전 히스토리 보기" + > + <History className="h-3 w-3" /> + </Button> + </div> ) }, - size: 80, + size: 100, }, ], }, diff --git a/lib/avl/table/avl-table.tsx b/lib/avl/table/avl-table.tsx index a6910ef5..eb9b2079 100644 --- a/lib/avl/table/avl-table.tsx +++ b/lib/avl/table/avl-table.tsx @@ -2,7 +2,6 @@ import * as React from "react" import type { - DataTableAdvancedFilterField, DataTableFilterField, } from "@/types/table" @@ -10,12 +9,12 @@ 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" +import { AvlHistoryModal, type AvlHistoryRecord } from "@/lib/avl/components/avl-history-modal" // 테이블 메타 타입 확장 declare module "@tanstack/react-table" { @@ -52,6 +51,50 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration const [emptyRows, setEmptyRows] = React.useState<Record<string, AvlListItem>>({}) const [isCreating, setIsCreating] = React.useState(false) + // 히스토리 모달 관리 + const [historyModalOpen, setHistoryModalOpen] = React.useState(false) + const [selectedAvlItem, setSelectedAvlItem] = React.useState<AvlListItem | null>(null) + + // 히스토리 데이터 로드 함수 + const loadHistoryData = React.useCallback(async (avlItem: AvlListItem): Promise<AvlHistoryRecord[]> => { + try { + // 현재 리비전의 스냅샷 데이터 (실제 저장된 데이터 사용) + const currentSnapshot = avlItem.vendorInfoSnapshot || [] + + const historyData: AvlHistoryRecord[] = [ + { + id: avlItem.id, + rev: avlItem.rev || 1, + createdAt: avlItem.createdAt || new Date().toISOString(), + createdBy: avlItem.createdBy || "system", + vendorInfoSnapshot: currentSnapshot, + changeDescription: "최신 리비전 (확정완료)" + } + ] + + // TODO: 실제 구현에서는 DB에서 이전 리비전들의 스냅샷 데이터를 조회해야 함 + // 현재는 더미 데이터로 이전 리비전들을 시뮬레이션 + if ((avlItem.rev || 1) > 1) { + for (let rev = (avlItem.rev || 1) - 1; rev >= 1; rev--) { + historyData.push({ + id: avlItem.id + rev * 1000, // 임시 ID + rev, + createdAt: new Date(Date.now() - rev * 24 * 60 * 60 * 1000).toISOString(), + createdBy: "system", + vendorInfoSnapshot: [], // 이전 리비전의 스냅샷 데이터 (실제로는 DB에서 조회) + changeDescription: `리비전 ${rev} 변경사항` + }) + } + } + + return historyData + } catch (error) { + console.error('히스토리 로드 실패:', error) + toast.error("히스토리를 불러오는데 실패했습니다.") + return [] + } + }, []) + // 필터 필드 정의 const filterFields: DataTableFilterField<AvlListItem>[] = [ { @@ -83,33 +126,6 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration }, ] - // 고급 필터 필드 정의 - 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) => { @@ -186,6 +202,7 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration avlKind: "", htDivision: "", rev: 1, + vendorInfoSnapshot: null, createdAt: new Date().toISOString().split('T')[0], updatedAt: new Date().toISOString().split('T')[0], createdBy: "system", @@ -296,6 +313,14 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration } break + case 'view-history': + // 리비전 히스토리 조회 + if (data?.id && !String(data.id).startsWith('temp-')) { + setSelectedAvlItem(data as AvlListItem) + setHistoryModalOpen(true) + } + break + default: toast.error(`알 수 없는 액션: ${action}`) } @@ -303,7 +328,7 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration console.error('액션 처리 실패:', error) toast.error("액션 처리 중 오류가 발생했습니다.") } - }, [pendingChanges, onRefresh]) + }, [pendingChanges, onRefresh, onRegistrationModeChange]) // 빈 행 저장 핸들러 const handleSaveEmptyRow = React.useCallback(async (tempId: string) => { @@ -425,6 +450,10 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration initialState: { sorting: [{ id: "createdAt", desc: true }], columnPinning: { right: ["actions"] }, + pagination: { + pageIndex: 0, + pageSize: 10, + }, }, getRowId: (row) => String(row.id), meta: tableMeta, @@ -502,6 +531,17 @@ export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistration {/* 데이터 테이블 */} <DataTable table={table} /> + {/* 히스토리 모달 */} + <AvlHistoryModal + isOpen={historyModalOpen} + onClose={() => { + setHistoryModalOpen(false) + setSelectedAvlItem(null) + }} + avlItem={selectedAvlItem} + onLoadHistory={loadHistoryData} + /> + {/* 디버그 정보 (개발 환경에서만 표시) */} {process.env.NODE_ENV === 'development' && (hasPendingChanges || hasEmptyRows) && ( <div className="text-xs text-muted-foreground p-2 bg-muted rounded"> diff --git a/lib/avl/table/avl-vendor-add-and-modify-dialog.tsx b/lib/avl/table/avl-vendor-add-and-modify-dialog.tsx new file mode 100644 index 00000000..174982e4 --- /dev/null +++ b/lib/avl/table/avl-vendor-add-and-modify-dialog.tsx @@ -0,0 +1,945 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { toast } from "sonner" +import type { AvlVendorInfoInput, AvlDetailItem } from "../types" + +interface AvlVendorAddAndModifyDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onAddItem: (item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void> + editingItem?: AvlDetailItem // 수정할 항목 (없으면 추가 모드) + onUpdateItem?: (id: number, item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void> + + // 모드 설정 + isTemplate?: boolean // false: 프로젝트 AVL, true: 표준 AVL + + // 표준 AVL용 초기값들 (선택적) + initialConstructionSector?: string + initialShipType?: string + initialAvlKind?: string + initialHtDivision?: string + + // 프로젝트 AVL용 초기값들 (선택적) + initialProjectCode?: string +} + +export function AvlVendorAddAndModifyDialog({ + open, + onOpenChange, + onAddItem, + editingItem, + onUpdateItem, + isTemplate = false, // 기본값: 프로젝트 AVL + initialConstructionSector, + initialShipType, + initialAvlKind, + initialHtDivision, + initialProjectCode +}: AvlVendorAddAndModifyDialogProps) { + const [formData, setFormData] = React.useState<Omit<AvlVendorInfoInput, 'avlListId'>>({ + // 공통 기본 설정 + isTemplate: isTemplate, + + // 프로젝트 AVL용 필드들 + projectCode: initialProjectCode || "", + + // 표준 AVL용 필드들 + constructionSector: initialConstructionSector || "", + shipType: initialShipType || "", + avlKind: initialAvlKind || "", + htDivision: initialHtDivision || "", + + // 설계 정보 + equipBulkDivision: "EQUIP", + disciplineCode: "", + disciplineName: "", + + // 자재 정보 + materialNameCustomerSide: "", + + // 패키지 정보 + packageCode: "", + packageName: "", + + // 자재그룹 정보 + materialGroupCode: "", + materialGroupName: "", + + // 협력업체 정보 + vendorName: "", + vendorCode: "", + + // AVL 정보 + avlVendorName: "", + tier: "", + + // 제안방향 + ownerSuggestion: false, + shiSuggestion: false, + + // 위치 정보 + headquarterLocation: "", + manufacturingLocation: "", + + // FA 정보 + faTarget: false, + faStatus: "", + + // Agent 정보 + isAgent: false, + + // 계약 서명주체 + contractSignerName: "", + contractSignerCode: "", + + // SHI Qualification + shiAvl: false, + shiBlacklist: false, + shiBcc: false, + + // 기술영업 견적결과 + salesQuoteNumber: "", + quoteCode: "", + salesVendorInfo: "", + salesCountry: "", + totalAmount: "", + quoteReceivedDate: "", + + // 업체 실적 현황 + recentQuoteDate: "", + recentQuoteNumber: "", + recentOrderDate: "", + recentOrderNumber: "", + + // 기타 + remarks: "" + }) + + // 수정 모드일 때 폼 데이터 초기화 + React.useEffect(() => { + if (editingItem) { + setFormData({ + // 공통 기본 설정 + isTemplate: editingItem.isTemplate ?? isTemplate, + + // 프로젝트 AVL용 필드들 + projectCode: editingItem.projectCode || initialProjectCode || "", + + // 표준 AVL용 필드들 + constructionSector: editingItem.constructionSector || initialConstructionSector || "", + shipType: editingItem.shipType || initialShipType || "", + avlKind: editingItem.avlKind || initialAvlKind || "", + htDivision: editingItem.htDivision || initialHtDivision || "", + + // 설계 정보 + equipBulkDivision: editingItem.equipBulkDivision === "EQUIP" ? "EQUIP" : "BULK", + disciplineCode: editingItem.disciplineCode || "", + disciplineName: editingItem.disciplineName || "", + + // 자재 정보 + materialNameCustomerSide: editingItem.materialNameCustomerSide || "", + + // 패키지 정보 + packageCode: editingItem.packageCode || "", + packageName: editingItem.packageName || "", + + // 자재그룹 정보 + materialGroupCode: editingItem.materialGroupCode || "", + materialGroupName: editingItem.materialGroupName || "", + + // 협력업체 정보 + vendorName: editingItem.vendorName || "", + vendorCode: editingItem.vendorCode || "", + + // AVL 정보 + avlVendorName: editingItem.avlVendorName || "", + tier: editingItem.tier || "", + + // 제안방향 + ownerSuggestion: editingItem.ownerSuggestion || false, + shiSuggestion: editingItem.shiSuggestion || false, + + // 위치 정보 + headquarterLocation: editingItem.headquarterLocation || "", + manufacturingLocation: editingItem.manufacturingLocation || "", + + // FA 정보 + faTarget: editingItem.faTarget || false, + faStatus: editingItem.faStatus || "", + + // Agent 정보 + isAgent: editingItem.isAgent || false, + + // 계약 서명주체 + contractSignerName: editingItem.contractSignerName || "", + contractSignerCode: editingItem.contractSignerCode || "", + + // SHI Qualification + shiAvl: editingItem.shiAvl || false, + shiBlacklist: editingItem.shiBlacklist || false, + shiBcc: editingItem.shiBcc || false, + + // 기술영업 견적결과 + salesQuoteNumber: editingItem.salesQuoteNumber || "", + quoteCode: editingItem.quoteCode || "", + salesVendorInfo: editingItem.salesVendorInfo || "", + salesCountry: editingItem.salesCountry || "", + totalAmount: editingItem.totalAmount || "", + quoteReceivedDate: editingItem.quoteReceivedDate || "", + + // 업체 실적 현황 + recentQuoteDate: editingItem.recentQuoteDate || "", + recentQuoteNumber: editingItem.recentQuoteNumber || "", + recentOrderDate: editingItem.recentOrderDate || "", + recentOrderNumber: editingItem.recentOrderNumber || "", + + // 기타 + remarks: editingItem.remarks || "" + }) + } + }, [editingItem, isTemplate, initialProjectCode, initialConstructionSector, initialShipType, initialAvlKind, initialHtDivision]) + + // 다이얼로그가 열릴 때 초기값 재설정 (수정 모드가 아닐 때만) + React.useEffect(() => { + if (open && !editingItem) { + setFormData(prev => ({ + ...prev, + isTemplate: isTemplate, + projectCode: initialProjectCode || "", + constructionSector: initialConstructionSector || "", + shipType: initialShipType || "", + avlKind: initialAvlKind || "", + htDivision: initialHtDivision || "", + })) + } + }, [open, editingItem, isTemplate, initialProjectCode, initialConstructionSector, initialShipType, initialAvlKind, initialHtDivision]) + + const handleSubmit = async () => { + // 공통 필수 필드 검증 + if (!formData.disciplineName || !formData.materialNameCustomerSide) { + toast.error("설계공종과 고객사 AVL 자재명은 필수 입력 항목입니다.") + return + } + + // 모드별 필수 필드 검증 + if (isTemplate) { + // 표준 AVL 모드 + if (!formData.constructionSector || !formData.shipType || !formData.avlKind || !formData.htDivision) { + toast.error("공사부문, 선종, AVL종류, H/T 구분은 필수 입력 항목입니다.") + return + } + } else { + // 프로젝트 AVL 모드 + if (!formData.projectCode) { + toast.error("프로젝트 코드는 필수 입력 항목입니다.") + return + } + } + + try { + if (editingItem && onUpdateItem) { + // 수정 모드 + await onUpdateItem(editingItem.id, formData) + } else { + // 추가 모드 + await onAddItem(formData) + } + + // 폼 초기화 + setFormData({ + isTemplate: isTemplate, + projectCode: initialProjectCode || "", + constructionSector: initialConstructionSector || "", + shipType: initialShipType || "", + avlKind: initialAvlKind || "", + htDivision: initialHtDivision || "", + equipBulkDivision: "EQUIP", + disciplineCode: "", + disciplineName: "", + materialNameCustomerSide: "", + packageCode: "", + packageName: "", + materialGroupCode: "", + materialGroupName: "", + vendorName: "", + vendorCode: "", + avlVendorName: "", + tier: "", + ownerSuggestion: false, + shiSuggestion: false, + headquarterLocation: "", + manufacturingLocation: "", + faTarget: false, + faStatus: "", + isAgent: false, + contractSignerName: "", + contractSignerCode: "", + shiAvl: false, + shiBlacklist: false, + shiBcc: false, + salesQuoteNumber: "", + quoteCode: "", + salesVendorInfo: "", + salesCountry: "", + totalAmount: "", + quoteReceivedDate: "", + recentQuoteDate: "", + recentQuoteNumber: "", + recentOrderDate: "", + recentOrderNumber: "", + remarks: "" + } as Omit<AvlVendorInfoInput, 'avlListId'>) + + onOpenChange(false) + } catch (error) { + // 에러 처리는 호출하는 쪽에서 담당 + } + } + + const handleCancel = () => { + setFormData({ + isTemplate: isTemplate, + projectCode: initialProjectCode || "", + constructionSector: initialConstructionSector || "", + shipType: initialShipType || "", + avlKind: initialAvlKind || "", + htDivision: initialHtDivision || "", + equipBulkDivision: "EQUIP", + disciplineCode: "", + disciplineName: "", + materialNameCustomerSide: "", + packageCode: "", + packageName: "", + materialGroupCode: "", + materialGroupName: "", + vendorName: "", + vendorCode: "", + avlVendorName: "", + tier: "", + ownerSuggestion: false, + shiSuggestion: false, + headquarterLocation: "", + manufacturingLocation: "", + faTarget: false, + faStatus: "", + isAgent: false, + contractSignerName: "", + contractSignerCode: "", + shiAvl: false, + shiBlacklist: false, + shiBcc: false, + salesQuoteNumber: "", + quoteCode: "", + salesVendorInfo: "", + salesCountry: "", + totalAmount: "", + quoteReceivedDate: "", + recentQuoteDate: "", + recentQuoteNumber: "", + recentOrderDate: "", + recentOrderNumber: "", + remarks: "" + } as Omit<AvlVendorInfoInput, 'avlListId'>) + onOpenChange(false) + } + + // 선종 옵션들 (공사부문에 따라 다름) + const getShipTypeOptions = (constructionSector: string) => { + if (constructionSector === "조선") { + return [ + { value: "A-max", label: "A-max" }, + { value: "S-max", label: "S-max" }, + { value: "VLCC", label: "VLCC" }, + { value: "LNGC", label: "LNGC" }, + { value: "CONT", label: "CONT" }, + ] + } else if (constructionSector === "해양") { + return [ + { value: "FPSO", label: "FPSO" }, + { value: "FLNG", label: "FLNG" }, + { value: "FPU", label: "FPU" }, + { value: "Platform", label: "Platform" }, + { value: "WTIV", label: "WTIV" }, + { value: "GOM", label: "GOM" }, + ] + } else { + return [] + } + } + + const shipTypeOptions = getShipTypeOptions(formData.constructionSector) + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle> + {isTemplate ? "표준 AVL" : "프로젝트 AVL"} {editingItem ? "항목 수정" : "항목 추가"} + </DialogTitle> + <DialogDescription> + {editingItem + ? `${isTemplate ? "표준 AVL" : "프로젝트 AVL"} 항목을 수정합니다. 필수 항목을 입력해주세요.` + : `새로운 ${isTemplate ? "표준 AVL" : "프로젝트 AVL"} 항목을 추가합니다. 필수 항목을 입력해주세요.` + } * 표시된 항목은 필수 입력사항입니다. + </DialogDescription> + </DialogHeader> + <div className="space-y-6 py-4"> + {/* 모드별 필수 정보 */} + {!isTemplate ? ( + // 프로젝트 AVL 모드 + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">프로젝트 정보 *</h4> + <div className="grid grid-cols-1 gap-4"> + <div className="space-y-2"> + <Label htmlFor="projectCode">프로젝트 코드 *</Label> + <Input + id="projectCode" + value={formData.projectCode} + onChange={(e) => setFormData(prev => ({ ...prev, projectCode: e.target.value }))} + placeholder="프로젝트 코드를 입력하세요" + /> + </div> + </div> + </div> + ) : ( + // 표준 AVL 모드 + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">표준 AVL 기본 정보 *</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="constructionSector">공사부문 *</Label> + <Select + value={formData.constructionSector} + onValueChange={(value) => { + setFormData(prev => ({ + ...prev, + constructionSector: value, + shipType: "" // 공사부문 변경 시 선종 초기화 + })) + }} + > + <SelectTrigger> + <SelectValue placeholder="공사부문을 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="조선">조선</SelectItem> + <SelectItem value="해양">해양</SelectItem> + </SelectContent> + </Select> + </div> + <div className="space-y-2"> + <Label htmlFor="shipType">선종 *</Label> + <Select + value={formData.shipType} + onValueChange={(value) => + setFormData(prev => ({ ...prev, shipType: value })) + } + disabled={!formData.constructionSector} + > + <SelectTrigger> + <SelectValue placeholder="선종을 선택하세요" /> + </SelectTrigger> + <SelectContent> + {shipTypeOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <div className="space-y-2"> + <Label htmlFor="avlKind">AVL종류 *</Label> + <Select + value={formData.avlKind} + onValueChange={(value) => + setFormData(prev => ({ ...prev, avlKind: value })) + } + > + <SelectTrigger> + <SelectValue placeholder="AVL종류를 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="Nearshore">Nearshore</SelectItem> + <SelectItem value="Offshore">Offshore</SelectItem> + <SelectItem value="IOC">IOC</SelectItem> + <SelectItem value="NOC">NOC</SelectItem> + </SelectContent> + </Select> + </div> + <div className="space-y-2"> + <Label htmlFor="htDivision">H/T 구분 *</Label> + <Select + value={formData.htDivision} + onValueChange={(value) => + setFormData(prev => ({ ...prev, htDivision: value })) + } + > + <SelectTrigger> + <SelectValue placeholder="H/T 구분을 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="공통">공통</SelectItem> + <SelectItem value="H">Hull (H)</SelectItem> + <SelectItem value="T">Topside (T)</SelectItem> + </SelectContent> + </Select> + </div> + </div> + </div> + )} + + {/* 공통 정보들 (나머지 폼 필드들은 동일하게 유지) */} + {/* 기본 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기본 정보</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="equipBulkDivision">EQUIP/BULK 구분</Label> + <Select + value={formData.equipBulkDivision} + onValueChange={(value: "EQUIP" | "BULK") => + setFormData(prev => ({ ...prev, equipBulkDivision: value })) + } + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="EQUIP">EQUIP</SelectItem> + <SelectItem value="BULK">BULK</SelectItem> + </SelectContent> + </Select> + </div> + <div className="space-y-2"> + <Label htmlFor="disciplineCode">설계공종코드</Label> + <Input + id="disciplineCode" + value={formData.disciplineCode} + onChange={(e) => setFormData(prev => ({ ...prev, disciplineCode: e.target.value }))} + placeholder="설계공종코드를 입력하세요" + /> + </div> + <div className="space-y-2 col-span-2"> + <Label htmlFor="disciplineName">설계공종명 *</Label> + <Input + id="disciplineName" + value={formData.disciplineName} + onChange={(e) => setFormData(prev => ({ ...prev, disciplineName: e.target.value }))} + placeholder="설계공종명을 입력하세요" + /> + </div> + <div className="space-y-2 col-span-2"> + <Label htmlFor="materialNameCustomerSide">고객사 AVL 자재명 *</Label> + <Input + id="materialNameCustomerSide" + value={formData.materialNameCustomerSide} + onChange={(e) => setFormData(prev => ({ ...prev, materialNameCustomerSide: e.target.value }))} + placeholder="고객사 AVL 자재명을 입력하세요" + /> + </div> + </div> + </div> + + {/* 패키지 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">패키지 정보</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="packageCode">패키지 코드</Label> + <Input + id="packageCode" + value={formData.packageCode} + onChange={(e) => setFormData(prev => ({ ...prev, packageCode: e.target.value }))} + placeholder="패키지 코드를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="packageName">패키지 명</Label> + <Input + id="packageName" + value={formData.packageName} + onChange={(e) => setFormData(prev => ({ ...prev, packageName: e.target.value }))} + placeholder="패키지 명을 입력하세요" + /> + </div> + </div> + </div> + + {/* 자재그룹 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">자재그룹 정보</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="materialGroupCode">자재그룹 코드</Label> + <Input + id="materialGroupCode" + value={formData.materialGroupCode} + onChange={(e) => setFormData(prev => ({ ...prev, materialGroupCode: e.target.value }))} + placeholder="자재그룹 코드를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="materialGroupName">자재그룹 명</Label> + <Input + id="materialGroupName" + value={formData.materialGroupName} + onChange={(e) => setFormData(prev => ({ ...prev, materialGroupName: e.target.value }))} + placeholder="자재그룹 명을 입력하세요" + /> + </div> + </div> + </div> + + {/* 협력업체 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">협력업체 정보</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="vendorCode">협력업체 코드</Label> + <Input + id="vendorCode" + value={formData.vendorCode} + onChange={(e) => setFormData(prev => ({ ...prev, vendorCode: e.target.value }))} + placeholder="협력업체 코드를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorName">협력업체 명</Label> + <Input + id="vendorName" + value={formData.vendorName} + onChange={(e) => setFormData(prev => ({ ...prev, vendorName: e.target.value }))} + placeholder="협력업체 명을 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="avlVendorName">AVL 등재업체명</Label> + <Input + id="avlVendorName" + value={formData.avlVendorName} + onChange={(e) => setFormData(prev => ({ ...prev, avlVendorName: e.target.value }))} + placeholder="AVL 등재업체명을 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="tier">등급 (Tier)</Label> + <Input + id="tier" + value={formData.tier} + onChange={(e) => setFormData(prev => ({ ...prev, tier: e.target.value }))} + placeholder="등급을 입력하세요" + /> + </div> + </div> + </div> + + {/* 제안방향 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">제안방향</h4> + <div className="flex gap-6"> + <div className="flex items-center space-x-2"> + <Checkbox + id="ownerSuggestion" + checked={formData.ownerSuggestion} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, ownerSuggestion: !!checked })) + } + /> + <Label htmlFor="ownerSuggestion">선주제안</Label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="shiSuggestion" + checked={formData.shiSuggestion} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, shiSuggestion: !!checked })) + } + /> + <Label htmlFor="shiSuggestion">SHI 제안</Label> + </div> + </div> + </div> + + {/* 위치 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">위치 정보</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="headquarterLocation">본사 위치 (국가)</Label> + <Input + id="headquarterLocation" + value={formData.headquarterLocation} + onChange={(e) => setFormData(prev => ({ ...prev, headquarterLocation: e.target.value }))} + placeholder="본사 위치를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="manufacturingLocation">제작/선적지 (국가)</Label> + <Input + id="manufacturingLocation" + value={formData.manufacturingLocation} + onChange={(e) => setFormData(prev => ({ ...prev, manufacturingLocation: e.target.value }))} + placeholder="제작/선적지를 입력하세요" + /> + </div> + </div> + </div> + + {/* FA 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">FA 정보</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-center space-x-2"> + <Checkbox + id="faTarget" + checked={formData.faTarget} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, faTarget: !!checked })) + } + /> + <Label htmlFor="faTarget">FA 대상</Label> + </div> + <div className="space-y-2"> + <Label htmlFor="faStatus">FA 현황</Label> + <Input + id="faStatus" + value={formData.faStatus} + onChange={(e) => setFormData(prev => ({ ...prev, faStatus: e.target.value }))} + placeholder="FA 현황을 입력하세요" + /> + </div> + </div> + </div> + + {/* Agent 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">Agent 정보</h4> + <div className="flex items-center space-x-2"> + <Checkbox + id="isAgent" + checked={formData.isAgent} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, isAgent: !!checked })) + } + /> + <Label htmlFor="isAgent">Agent 여부</Label> + </div> + </div> + + {/* 계약 서명주체 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">계약 서명주체</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="contractSignerCode">계약서명주체 코드</Label> + <Input + id="contractSignerCode" + value={formData.contractSignerCode} + onChange={(e) => setFormData(prev => ({ ...prev, contractSignerCode: e.target.value }))} + placeholder="계약서명주체 코드를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="contractSignerName">계약서명주체 명</Label> + <Input + id="contractSignerName" + value={formData.contractSignerName} + onChange={(e) => setFormData(prev => ({ ...prev, contractSignerName: e.target.value }))} + placeholder="계약서명주체 명을 입력하세요" + /> + </div> + </div> + </div> + + {/* SHI Qualification */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">SHI Qualification</h4> + <div className="flex gap-6"> + <div className="flex items-center space-x-2"> + <Checkbox + id="shiAvl" + checked={formData.shiAvl} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, shiAvl: !!checked })) + } + /> + <Label htmlFor="shiAvl">AVL</Label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="shiBlacklist" + checked={formData.shiBlacklist} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, shiBlacklist: !!checked })) + } + /> + <Label htmlFor="shiBlacklist">Blacklist</Label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="shiBcc" + checked={formData.shiBcc} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, shiBcc: !!checked })) + } + /> + <Label htmlFor="shiBcc">BCC</Label> + </div> + </div> + </div> + + {/* 기술영업 견적결과 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기술영업 견적결과</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="salesQuoteNumber">기술영업 견적번호</Label> + <Input + id="salesQuoteNumber" + value={formData.salesQuoteNumber} + onChange={(e) => setFormData(prev => ({ ...prev, salesQuoteNumber: e.target.value }))} + placeholder="기술영업 견적번호를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="quoteCode">견적서 Code</Label> + <Input + id="quoteCode" + value={formData.quoteCode} + onChange={(e) => setFormData(prev => ({ ...prev, quoteCode: e.target.value }))} + placeholder="견적서 Code를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="salesVendorInfo">견적 협력업체 명</Label> + <Input + id="salesVendorInfo" + value={formData.salesVendorInfo} + onChange={(e) => setFormData(prev => ({ ...prev, salesVendorInfo: e.target.value }))} + placeholder="견적 협력업체 명을 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="salesCountry">국가</Label> + <Input + id="salesCountry" + value={formData.salesCountry} + onChange={(e) => setFormData(prev => ({ ...prev, salesCountry: e.target.value }))} + placeholder="국가를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="totalAmount">총 금액</Label> + <Input + id="totalAmount" + type="number" + value={formData.totalAmount} + onChange={(e) => setFormData(prev => ({ ...prev, totalAmount: e.target.value }))} + placeholder="총 금액을 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="quoteReceivedDate">견적접수일 (YYYY-MM-DD)</Label> + <Input + id="quoteReceivedDate" + value={formData.quoteReceivedDate} + onChange={(e) => setFormData(prev => ({ ...prev, quoteReceivedDate: e.target.value }))} + placeholder="YYYY-MM-DD" + /> + </div> + </div> + </div> + + {/* 업체 실적 현황 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">업체 실적 현황</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="recentQuoteNumber">최근견적번호</Label> + <Input + id="recentQuoteNumber" + value={formData.recentQuoteNumber} + onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteNumber: e.target.value }))} + placeholder="최근견적번호를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="recentQuoteDate">최근견적일 (YYYY-MM-DD)</Label> + <Input + id="recentQuoteDate" + value={formData.recentQuoteDate} + onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteDate: e.target.value }))} + placeholder="YYYY-MM-DD" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="recentOrderNumber">최근발주번호</Label> + <Input + id="recentOrderNumber" + value={formData.recentOrderNumber} + onChange={(e) => setFormData(prev => ({ ...prev, recentOrderNumber: e.target.value }))} + placeholder="최근발주번호를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="recentOrderDate">최근발주일 (YYYY-MM-DD)</Label> + <Input + id="recentOrderDate" + value={formData.recentOrderDate} + onChange={(e) => setFormData(prev => ({ ...prev, recentOrderDate: e.target.value }))} + placeholder="YYYY-MM-DD" + /> + </div> + </div> + </div> + + {/* 기타 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기타</h4> + <div className="space-y-2"> + <Label htmlFor="remarks">비고</Label> + <Textarea + id="remarks" + value={formData.remarks} + onChange={(e) => setFormData(prev => ({ ...prev, remarks: e.target.value }))} + placeholder="비고를 입력하세요" + rows={3} + /> + </div> + </div> + </div> + <DialogFooter> + <Button type="button" variant="outline" onClick={handleCancel}> + 취소 + </Button> + <Button type="button" onClick={handleSubmit}> + {editingItem ? "수정" : "추가"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/avl/table/columns-detail.tsx b/lib/avl/table/columns-detail.tsx index 204d34f5..84ad9d9a 100644 --- a/lib/avl/table/columns-detail.tsx +++ b/lib/avl/table/columns-detail.tsx @@ -1,28 +1,8 @@ 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 { type ColumnDef } 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 = { @@ -90,29 +70,6 @@ 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." /> @@ -124,33 +81,12 @@ export const columns: ColumnDef<AvlDetailItem>[] = [ header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="Equip/Bulk 구분" /> ), - cell: ({ row, table }) => { + cell: ({ row }) => { 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} - /> + <Badge variant="outline"> + {value || "-"} + </Badge> ) }, size: 120, @@ -160,31 +96,9 @@ export const columns: ColumnDef<AvlDetailItem>[] = [ 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} - /> - ) + cell: ({ row }) => { + const value = row.getValue("disciplineName") as string + return <span>{value || "-"}</span> }, size: 120, }, @@ -193,31 +107,9 @@ export const columns: ColumnDef<AvlDetailItem>[] = [ 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} - /> - ) + cell: ({ row }) => { + const value = row.getValue("materialNameCustomerSide") as string + return <span>{value || "-"}</span> }, size: 150, }, @@ -226,31 +118,9 @@ export const columns: ColumnDef<AvlDetailItem>[] = [ 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} - /> - ) + cell: ({ row }) => { + const value = row.getValue("packageName") as string + return <span>{value || "-"}</span> }, size: 130, }, @@ -259,31 +129,9 @@ export const columns: ColumnDef<AvlDetailItem>[] = [ 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} - /> - ) + cell: ({ row }) => { + const value = row.getValue("materialGroupCode") as string + return <span>{value || "-"}</span> }, size: 120, }, @@ -292,31 +140,9 @@ export const columns: ColumnDef<AvlDetailItem>[] = [ 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} - /> - ) + cell: ({ row }) => { + const value = row.getValue("materialGroupName") as string + return <span>{value || "-"}</span> }, size: 130, }, @@ -325,31 +151,9 @@ export const columns: ColumnDef<AvlDetailItem>[] = [ 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} - /> - ) + cell: ({ row }) => { + const value = row.getValue("vendorCode") as string + return <span>{value || "-"}</span> }, size: 120, }, @@ -358,31 +162,9 @@ export const columns: ColumnDef<AvlDetailItem>[] = [ 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} - /> - ) + cell: ({ row }) => { + const value = row.getValue("vendorName") as string + return <span className="font-medium">{value || "-"}</span> }, size: 140, }, @@ -391,31 +173,9 @@ export const columns: ColumnDef<AvlDetailItem>[] = [ 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} - /> - ) + cell: ({ row }) => { + const value = row.getValue("avlVendorName") as string + return <span>{value || "-"}</span> }, size: 140, }, @@ -424,34 +184,20 @@ export const columns: ColumnDef<AvlDetailItem>[] = [ header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="등급 (Tier)" /> ), - cell: ({ row, table }) => { + cell: ({ row }) => { 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") - + if (!value) return <span>-</span> + + const tierColor = { + "Tier 1": "bg-green-100 text-green-800", + "Tier 2": "bg-yellow-100 text-yellow-800", + "Tier 3": "bg-red-100 text-red-800" + }[value] || "bg-gray-100 text-gray-800" + return ( - <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} - /> + <Badge className={tierColor}> + {value} + </Badge> ) }, size: 100, @@ -467,28 +213,12 @@ export const columns: ColumnDef<AvlDetailItem>[] = [ header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="FA 대상" /> ), - cell: ({ row, table }) => { + cell: ({ row }) => { 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} - /> + <Badge variant={value ? "default" : "secondary"}> + {value ? "대상" : "비대상"} + </Badge> ) }, size: 80, @@ -498,31 +228,9 @@ export const columns: ColumnDef<AvlDetailItem>[] = [ 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} - /> - ) + cell: ({ row }) => { + const value = row.getValue("faStatus") as string + return <span>{value || "-"}</span> }, size: 100, }, @@ -535,27 +243,13 @@ export const columns: ColumnDef<AvlDetailItem>[] = [ { accessorKey: "shiAvl", header: "AVL", - cell: ({ row, table }) => { + cell: ({ row }) => { 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} + <Checkbox + checked={value} + disabled + aria-label="SHI AVL 등재 여부" /> ) }, @@ -564,27 +258,13 @@ export const columns: ColumnDef<AvlDetailItem>[] = [ { accessorKey: "shiBlacklist", header: "Blacklist", - cell: ({ row, table }) => { + cell: ({ row }) => { 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} + <Checkbox + checked={value} + disabled + aria-label="SHI Blacklist 등재 여부" /> ) }, @@ -593,27 +273,13 @@ export const columns: ColumnDef<AvlDetailItem>[] = [ { accessorKey: "shiBcc", header: "BCC", - cell: ({ row, table }) => { + cell: ({ row }) => { 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} + <Checkbox + checked={value} + disabled + aria-label="SHI BCC 등재 여부" /> ) }, @@ -621,60 +287,4 @@ export const columns: ColumnDef<AvlDetailItem>[] = [ }, ], }, - // 액션 컬럼 - { - 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-table-columns.tsx b/lib/avl/table/project-avl-table-columns.tsx new file mode 100644 index 00000000..c052e6f7 --- /dev/null +++ b/lib/avl/table/project-avl-table-columns.tsx @@ -0,0 +1,167 @@ +import { ColumnDef } from "@tanstack/react-table" +import { ProjectAvlItem } from "./project-avl-table" +import { Checkbox } from "@/components/ui/checkbox" + + +// 프로젝트 AVL 테이블 컬럼 +export const getProjectAvlColumns = (): ColumnDef<ProjectAvlItem>[] => [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row, table }) => { + // 프로젝트 AVL 테이블의 단일 선택 핸들러 + const handleRowSelection = (checked: boolean) => { + if (checked) { + // 다른 모든 행의 선택 해제 + table.getRowModel().rows.forEach(r => { + if (r !== row && r.getIsSelected()) { + r.toggleSelected(false) + } + }) + } + // 현재 행 선택/해제 + row.toggleSelected(checked) + } + + return ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={handleRowSelection} + aria-label="Select row" + /> + ) + }, + enableSorting: false, + enableHiding: false, + size: 50, + }, + { + accessorKey: "no", + header: "No.", + size: 60, + cell: ({ row }) => { + return ( + <span> + {row.original.no} + </span> + ) + }, + }, + { + accessorKey: "disciplineName", + header: "설계공종", + size: 120, + cell: ({ row }) => { + return ( + <span> + {row.original.disciplineName} + </span> + ) + }, + }, + { + accessorKey: "materialNameCustomerSide", + header: "고객사 AVL 자재명", + size: 150, + cell: ({ row }) => { + return ( + <span> + {row.original.materialNameCustomerSide} + </span> + ) + }, + }, + { + accessorKey: "materialGroupCode", + header: "자재그룹 코드", + size: 120, + cell: ({ row }) => { + return ( + <span> + {row.original.materialGroupCode} + </span> + ) + }, + }, + { + accessorKey: "materialGroupName", + header: "자재그룹 명", + size: 130, + cell: ({ row }) => { + return ( + <span> + {row.original.materialGroupName} + </span> + ) + }, + }, + { + accessorKey: "avlVendorName", + header: "AVL 등재업체명", + size: 140, + cell: ({ row }) => { + return ( + <span> + {row.original.avlVendorName} + </span> + ) + }, + }, + { + accessorKey: "vendorCode", + header: "협력업체 코드", + size: 120, + cell: ({ row }) => { + return ( + <span> + {row.original.vendorCode} + </span> + ) + }, + }, + { + accessorKey: "vendorName", + header: "협력업체 명", + size: 130, + cell: ({ row }) => { + return ( + <span> + {row.original.vendorName} + </span> + ) + }, + }, + { + accessorKey: "ownerSuggestion", + header: "선주제안", + size: 100, + cell: ({ row }) => { + return ( + <span> + {row.original.ownerSuggestion ? "예" : "아니오"} + </span> + ) + }, + }, + { + accessorKey: "shiSuggestion", + header: "SHI 제안", + size: 100, + cell: ({ row }) => { + return ( + <span> + {row.original.shiSuggestion ? "예" : "아니오"} + </span> + ) + }, + }, +]
\ No newline at end of file diff --git a/lib/avl/table/project-avl-table.tsx b/lib/avl/table/project-avl-table.tsx index c6dd8064..8664e32b 100644 --- a/lib/avl/table/project-avl-table.tsx +++ b/lib/avl/table/project-avl-table.tsx @@ -1,204 +1,57 @@ "use client" import * as React from "react" -import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel, type ColumnDef } from "@tanstack/react-table" +import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel } from "@tanstack/react-table" +import { forwardRef, useImperativeHandle, useLayoutEffect, useMemo } from "react" import { DataTable } from "@/components/data-table/data-table" import { Button } from "@/components/ui/button" -import { 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 { AvlVendorAddAndModifyDialog } from "./avl-vendor-add-and-modify-dialog" +import { getProjectAvlVendorInfo, createAvlVendorInfo, updateAvlVendorInfo, deleteAvlVendorInfo, finalizeProjectAvl } from "../service" import { getProjectInfoByProjectCode as getProjectInfoFromProjects } from "../../projects/service" import { getProjectInfoByProjectCode as getProjectInfoFromBiddingProjects } from "../../bidding-projects/service" import { GetProjectAvlSchema } from "../validations" -import { AvlDetailItem, AvlListItem, AvlVendorInfoInput } from "../types" +import { AvlDetailItem, AvlVendorInfoInput } from "../types" import { toast } from "sonner" +import { getProjectAvlColumns } from "./project-avl-table-columns" +import { + ProjectDisplayField, + ProjectFileField +} from "../components/project-field-components" +import { ProjectSearchStatus } from "../components/project-field-utils" +import { useSession } from "next-auth/react" + // 프로젝트 AVL 테이블에서는 AvlDetailItem을 사용 export type ProjectAvlItem = AvlDetailItem +// ref를 통해 외부에서 접근할 수 있는 메소드들 +export interface ProjectAvlTableRef { + getSelectedIds: () => number[] +} + interface ProjectAvlTableProps { onSelectionChange?: (count: number) => void resetCounter?: number projectCode?: string // 프로젝트 코드 필터 avlListId?: number // AVL 리스트 ID (관리 영역 표시용) onProjectCodeChange?: (projectCode: string) => void // 프로젝트 코드 변경 콜백 + reloadTrigger?: number } -// 프로젝트 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({ +export const ProjectAvlTable = forwardRef<ProjectAvlTableRef, ProjectAvlTableProps>(({ onSelectionChange, resetCounter, projectCode, avlListId, - onProjectCodeChange -}: ProjectAvlTableProps) { + onProjectCodeChange, + reloadTrigger +}, ref) => { + + const { data: sessionData } = useSession() + 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 || "") @@ -215,20 +68,28 @@ export function ProjectAvlTable({ } | null>(null) // 프로젝트 검색 상태 - const [projectSearchStatus, setProjectSearchStatus] = React.useState<'idle' | 'searching' | 'success-projects' | 'success-bidding' | 'error'>('idle') + const [projectSearchStatus, setProjectSearchStatus] = React.useState<ProjectSearchStatus>('idle') + + // 검색 버튼 클릭 여부 상태 + const [isSearchClicked, setIsSearchClicked] = React.useState(false) + + // 페이지네이션 상태 + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }) // 데이터 로드 함수 - const loadData = React.useCallback(async (searchParams: Partial<GetProjectAvlSchema>) => { + const loadData = React.useCallback(async (searchParams: Partial<GetProjectAvlSchema> = {}) => { try { - setLoading(true) - const params: any = { + const params = { page: searchParams.page ?? 1, perPage: searchParams.perPage ?? 10, sort: searchParams.sort ?? [{ id: "no", desc: false }], flags: searchParams.flags ?? [], projectCode: localProjectCode || "", - equipBulkDivision: searchParams.equipBulkDivision ?? "", + equipBulkDivision: (searchParams.equipBulkDivision as "EQUIP" | "BULK") ?? "EQUIP", disciplineCode: searchParams.disciplineCode ?? "", disciplineName: searchParams.disciplineName ?? "", materialNameCustomerSide: searchParams.materialNameCustomerSide ?? "", @@ -244,7 +105,13 @@ export function ProjectAvlTable({ joinOperator: searchParams.joinOperator ?? "and", search: searchParams.search ?? "", } + console.log('ProjectAvlTable - API call params:', params) const result = await getProjectAvlVendorInfo(params) + console.log('ProjectAvlTable - API result:', { + dataCount: result.data.length, + pageCount: result.pageCount, + requestedPage: params.page + }) setData(result.data) setPageCount(result.pageCount) } catch (error) { @@ -252,32 +119,26 @@ export function ProjectAvlTable({ setData([]) setPageCount(0) } finally { - setLoading(false) + // 로딩 상태 처리 완료 } }, [localProjectCode]) - // AVL 리스트 정보 로드 + + + // reloadTrigger가 변경될 때마다 데이터 리로드 React.useEffect(() => { - const loadAvlListInfo = async () => { - if (avlListId) { - try { - const info = await getAvlListById(avlListId) - setAvlListInfo(info) - } catch (error) { - console.error("AVL 리스트 정보 로드 실패:", error) - } - } + if (reloadTrigger && reloadTrigger > 0) { + console.log('ProjectAvlTable - reloadTrigger changed, reloading data') + loadData({}) } + }, [reloadTrigger, loadData]) - loadAvlListInfo() - }, [avlListId]) - - // 초기 데이터 로드 + // 초기 데이터 로드 (검색 버튼이 눌렸을 때만) React.useEffect(() => { - if (localProjectCode) { + if (localProjectCode && isSearchClicked) { loadData({}) } - }, [loadData, localProjectCode]) + }, [loadData, localProjectCode, isSearchClicked]) // 파일 업로드 핸들러 const handleFileUpload = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => { @@ -289,42 +150,41 @@ export function ProjectAvlTable({ } }, []) - // 프로젝트 코드 변경 핸들러 - const handleProjectCodeChange = React.useCallback(async (value: string) => { - setLocalProjectCode(value) - onProjectCodeChange?.(value) + // 프로젝트 검색 함수 (공통 로직) + const searchProject = React.useCallback(async (projectCode: string) => { + if (!projectCode.trim()) { + setProjectInfo(null) + setProjectSearchStatus('idle') + setData([]) + setPageCount(0) + return + } - // 프로젝트 코드가 입력된 경우 프로젝트 정보 조회 - if (value.trim()) { - setProjectSearchStatus('searching') // 검색 시작 상태로 변경 + setProjectSearchStatus('searching') // 검색 시작 상태로 변경 - try { - // 1. projects 테이블에서 먼저 검색 - let projectData = null - let searchSource = 'projects' + try { + // 1. projects 테이블에서 먼저 검색 + let projectData: { + projectName?: string | null; + shipType?: string; + projectMsrm?: string | null; + projectHtDivision?: string | null; + } | null = null + let searchSource = 'projects' + try { + projectData = await getProjectInfoFromProjects(projectCode.trim()) + // projects에서 찾았을 때만 즉시 성공 상태로 변경 + setProjectSearchStatus('success-projects') + } catch { + // projects 테이블에 없는 경우 biddingProjects 테이블에서 검색 try { - projectData = await 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에서도 에러가 발생한 경우 + projectData = await getProjectInfoFromBiddingProjects(projectCode.trim()) + if (projectData) { + searchSource = 'bidding-projects' + setProjectSearchStatus('success-bidding') // bidding에서 찾았을 때 성공 상태로 변경 + } else { + // 둘 다 실패한 경우에만 에러 상태로 변경 setProjectInfo(null) setProjectSearchStatus('error') setData([]) @@ -332,39 +192,67 @@ export function ProjectAvlTable({ toast.error("입력하신 프로젝트 코드를 찾을 수 없습니다.") return } + } catch { + // biddingProjects에서도 에러가 발생한 경우 + setProjectInfo(null) + setProjectSearchStatus('error') + setData([]) + setPageCount(0) + toast.error("입력하신 프로젝트 코드를 찾을 수 없습니다.") + return } + } - if (projectData) { - setProjectInfo({ - projectName: projectData.projectName || "", - constructionSector: "조선", // 기본값으로 조선 설정 (필요시 로직 변경) - shipType: projectData.shipType || projectData.projectMsrm || "", - htDivision: projectData.projectHtDivision || "" - }) + if (projectData) { + setProjectInfo({ + projectName: projectData.projectName || "", + constructionSector: "조선", // 기본값으로 조선 설정 (필요시 로직 변경) + shipType: projectData.shipType || projectData.projectMsrm || "", + htDivision: projectData.projectHtDivision || "" + }) - const sourceMessage = searchSource === 'projects' ? '프로젝트' : '견적프로젝트' - toast.success(`${sourceMessage}에서 프로젝트 정보를 성공적으로 불러왔습니다.`) + const sourceMessage = searchSource === 'projects' ? '프로젝트' : '견적프로젝트' + toast.success(`${sourceMessage}에서 프로젝트 정보를 성공적으로 불러왔습니다.`) - // 프로젝트 검증 성공 시 해당 프로젝트의 AVL 데이터 로드 - loadData({}) - } - } catch (error) { - console.error("프로젝트 정보 조회 실패:", error) - setProjectInfo(null) - setProjectSearchStatus('error') - setData([]) - setPageCount(0) - toast.error("프로젝트 정보를 불러오는데 실패했습니다.") + // 검색 성공 시 AVL 데이터 로드 트리거 + setIsSearchClicked(true) } - } else { - // 프로젝트 코드가 비어있는 경우 프로젝트 정보 초기화 및 데이터 클리어 + } catch (error) { + console.error("프로젝트 정보 조회 실패:", error) + setProjectInfo(null) + setProjectSearchStatus('error') + setData([]) + setPageCount(0) + toast.error("프로젝트 정보를 불러오는데 실패했습니다.") + } + }, [setIsSearchClicked]) + + // 프로젝트 코드 변경 핸들러 (입력만 처리) + const handleProjectCodeChange = React.useCallback((value: string) => { + setLocalProjectCode(value) + onProjectCodeChange?.(value) + + // 입력이 변경되면 검색 상태를 idle로 초기화하고 검색 클릭 상태를 리셋 + if (!value.trim()) { setProjectInfo(null) setProjectSearchStatus('idle') + setIsSearchClicked(false) setData([]) setPageCount(0) + } else { + // 새로운 프로젝트 코드가 입력되면 검색 클릭 상태를 리셋 (다시 검색 버튼을 눌러야 함) + setIsSearchClicked(false) } }, [onProjectCodeChange]) + // 프로젝트 검색 버튼 핸들러 + const handleProjectSearch = React.useCallback(async () => { + // 검색 시 페이지를 1페이지로 리셋 + setPagination(prev => ({ ...prev, pageIndex: 0 })) + // 프로젝트 정보 검색 (성공 시 내부에서 AVL 데이터 로드 트리거) + await searchProject(localProjectCode) + }, [localProjectCode, searchProject]) + // 행 추가 핸들러 const handleAddRow = React.useCallback(() => { if (!localProjectCode.trim()) { @@ -382,16 +270,11 @@ export function ProjectAvlTable({ // 다이얼로그에서 항목 추가 핸들러 const handleAddItem = React.useCallback(async (itemData: Omit<AvlVendorInfoInput, 'avlListId'>) => { try { - // AVL 리스트 ID 확인 - if (!avlListId) { - toast.error("AVL 리스트가 선택되지 않았습니다.") - return - } - - // DB에 실제 저장할 데이터 준비 + // DB에 실제 저장할 데이터 준비 (avlListId는 나중에 최종 확정 시 설정) const saveData: AvlVendorInfoInput = { ...itemData, - avlListId: avlListId // 현재 AVL 리스트 ID 설정 + projectCode: localProjectCode, // 현재 프로젝트 코드 저장 + avlListId: avlListId || undefined // 있으면 설정, 없으면 undefined (null로 저장됨) } // DB에 저장 @@ -409,7 +292,7 @@ export function ProjectAvlTable({ console.error("항목 추가 실패:", error) toast.error("항목 추가 중 오류가 발생했습니다.") } - }, [avlListId, loadData]) + }, [avlListId, loadData, localProjectCode]) // 다이얼로그에서 항목 수정 핸들러 const handleUpdateItem = React.useCallback(async (id: number, itemData: Omit<AvlVendorInfoInput, 'avlListId'>) => { @@ -447,21 +330,43 @@ export function ProjectAvlTable({ getFilteredRowModel: getFilteredRowModel(), manualPagination: true, pageCount, - initialState: { - pagination: { - pageSize: 10, - }, + state: { + pagination, }, onPaginationChange: (updater) => { - const newState = typeof updater === 'function' ? updater(table.getState().pagination) : updater - loadData({ - page: newState.pageIndex + 1, - perPage: newState.pageSize, + // 페이지네이션 상태 업데이트 + const newPaginationState = typeof updater === 'function' ? updater(pagination) : updater + + console.log('ProjectAvlTable - Pagination changed:', { + currentState: pagination, + newPaginationState, + localProjectCode, + isSearchClicked, + willLoadData: localProjectCode && isSearchClicked }) + + setPagination(newPaginationState) + + if (localProjectCode && isSearchClicked) { + const apiParams = { + page: newPaginationState.pageIndex + 1, + perPage: newPaginationState.pageSize, + } + console.log('ProjectAvlTable - Loading data with params:', apiParams) + loadData(apiParams) + } }, meta: tableMeta, }) + // 외부에서 선택된 ID들을 가져올 수 있도록 ref에 메소드 노출 + useImperativeHandle(ref, () => ({ + getSelectedIds: () => { + const selectedRows = table.getFilteredSelectedRowModel().rows + return selectedRows.map(row => row.original.id) + } + })) + // 항목 수정 핸들러 (버튼 클릭) const handleEditItem = React.useCallback(() => { const selectedRows = table.getFilteredSelectedRowModel().rows @@ -510,11 +415,79 @@ export function ProjectAvlTable({ } }, [table, loadData]) + // 최종 확정 핸들러 + const handleFinalizeAvl = React.useCallback(async () => { + // 1. 필수 조건 검증 + if (!localProjectCode.trim()) { + toast.error("프로젝트 코드를 먼저 입력해주세요.") + return + } + + if (!projectInfo) { + toast.error("프로젝트 정보를 불러올 수 없습니다. 프로젝트 코드를 다시 확인해주세요.") + return + } + + if (data.length === 0) { + toast.error("확정할 AVL 벤더 정보가 없습니다.") + return + } + + // 2. 사용자 확인 + const confirmed = window.confirm( + `현재 프로젝트(${localProjectCode})의 AVL을 최종 확정하시겠습니까?\n\n` + + `- 프로젝트명: ${projectInfo.projectName}\n` + + `- 벤더 정보: ${data.length}개\n` + + `- 공사부문: ${projectInfo.constructionSector}\n` + + `- 선종: ${projectInfo.shipType}\n` + + `- H/T 구분: ${projectInfo.htDivision}\n\n` + + `확정 후에는 수정이 어려울 수 있습니다.` + ) + + if (!confirmed) return + + try { + // 3. 현재 데이터의 모든 ID 수집 + const avlVendorInfoIds = data.map(item => item.id) + + // 4. 최종 확정 실행 + const result = await finalizeProjectAvl( + localProjectCode, + projectInfo, + avlVendorInfoIds, + sessionData?.user?.name || "" + ) + + if (result.success) { + toast.success(result.message) + + // 5. 데이터 새로고침 + loadData({}) + + // 6. 선택 해제 + table.toggleAllPageRowsSelected(false) + } else { + toast.error(result.message) + } + } catch (error) { + console.error("AVL 최종 확정 실패:", error) + toast.error("AVL 최종 확정 중 오류가 발생했습니다.") + } + }, [localProjectCode, projectInfo, data, table, loadData, sessionData?.user?.name]) + + // 선택된 행 개수 (안정적인 계산을 위해 useMemo 사용) + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedRowCount = useMemo(() => { + const count = selectedRows.length + console.log('ProjectAvlTable - selectedRowCount calculated:', count) + return count + }, [selectedRows]) + // 선택 상태 변경 시 콜백 호출 - React.useEffect(() => { - const selectedRows = table.getFilteredSelectedRowModel().rows - onSelectionChange?.(selectedRows.length) - }, [table.getFilteredSelectedRowModel().rows.length, onSelectionChange]) + useLayoutEffect(() => { + console.log('ProjectAvlTable - onSelectionChange called with count:', selectedRowCount) + onSelectionChange?.(selectedRowCount) + }, [selectedRowCount, onSelectionChange]) // 선택 해제 요청이 오면 모든 선택 해제 React.useEffect(() => { @@ -540,7 +513,7 @@ export function ProjectAvlTable({ > 항목 수정 </Button> - <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}> + {/* <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}> 파일 업로드 </Button> <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}> @@ -548,13 +521,18 @@ export function ProjectAvlTable({ </Button> <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}> 강제 매핑 - </Button> + </Button> */} <Button variant="outline" size="sm" onClick={handleDeleteItems}> 항목 삭제 </Button> {/* 최종 확정 버튼 */} - <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}> + <Button + variant="outline" + size="sm" + onClick={handleFinalizeAvl} + disabled={!localProjectCode.trim() || !projectInfo || data.length === 0} + > 최종 확정 </Button> </div> @@ -565,140 +543,84 @@ export function ProjectAvlTable({ <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 className="flex flex-col gap-1 min-w-[200px]"> + <label className="text-sm font-medium">프로젝트 코드</label> + <div className="flex gap-2"> + <div className="flex-1"> + <input + type="text" + value={localProjectCode} + onChange={(e) => handleProjectCodeChange(e.target.value)} + placeholder="프로젝트 코드를 입력하세요" + className={`flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 ${ + projectSearchStatus === 'error' ? 'border-red-500' : + projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-500' : + projectSearchStatus === 'searching' ? 'border-blue-500' : '' + }`} + disabled={projectSearchStatus === 'searching'} + /> + {projectSearchStatus !== 'idle' && ( + <div className="text-xs mt-1 text-muted-foreground"> + {projectSearchStatus === 'success-projects' ? '(프로젝트)' : + projectSearchStatus === 'success-bidding' ? '(견적프로젝트)' : + projectSearchStatus === 'searching' ? '(검색 중...)' : + projectSearchStatus === 'error' ? '(찾을 수 없음)' : + undefined} + </div> + )} + </div> + <Button + variant="outline" + size="sm" + onClick={handleProjectSearch} + disabled={!localProjectCode.trim() || projectSearchStatus === 'searching'} + className="px-3 h-9" + > + {projectSearchStatus === 'searching' ? '검색 중...' : '검색'} + </Button> + </div> </div> {/* 프로젝트명 */} - <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> + <ProjectDisplayField + label="프로젝트명" + value={projectInfo?.projectName || ''} + status={projectSearchStatus} + minWidth="250px" + /> {/* 원본파일 */} - <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> + <ProjectFileField + label="원본파일" + originalFile={originalFile} + onFileUpload={handleFileUpload} + /> {/* 공사부문 */} - <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> + <ProjectDisplayField + label="공사부문" + value={projectInfo?.constructionSector || ''} + status={projectSearchStatus} + /> {/* 선종 */} - <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> + <ProjectDisplayField + label="선종" + value={projectInfo?.shipType || ''} + status={projectSearchStatus} + /> {/* 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> + <ProjectDisplayField + label="H/T 구분" + value={projectInfo?.htDivision || ''} + status={projectSearchStatus} + minWidth="140px" + formatter={(value) => + value === 'H' ? 'Hull (H)' : + value === 'T' ? 'Topside (T)' : '-' + } + /> </div> </div> @@ -707,7 +629,7 @@ export function ProjectAvlTable({ </div> {/* 행 추가/수정 다이얼로그 */} - <ProjectAvlAddDialog + <AvlVendorAddAndModifyDialog open={isAddDialogOpen} onOpenChange={(open) => { setIsAddDialogOpen(open) @@ -718,7 +640,11 @@ export function ProjectAvlTable({ onAddItem={handleAddItem} editingItem={editingItem} onUpdateItem={handleUpdateItem} + isTemplate={false} // 프로젝트 AVL 모드 + initialProjectCode={localProjectCode} /> </div> ) -} +}) + +ProjectAvlTable.displayName = "ProjectAvlTable" diff --git a/lib/avl/table/standard-avl-add-dialog.tsx b/lib/avl/table/standard-avl-add-dialog.tsx new file mode 100644 index 00000000..9e8b016c --- /dev/null +++ b/lib/avl/table/standard-avl-add-dialog.tsx @@ -0,0 +1,960 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { toast } from "sonner" +import type { AvlVendorInfoInput, AvlDetailItem } from "../types" + +interface StandardAvlAddDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onAddItem: (item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void> + editingItem?: AvlDetailItem // 수정할 항목 (없으면 추가 모드) + onUpdateItem?: (id: number, item: Omit<AvlVendorInfoInput, 'avlListId'>) => Promise<void> // 수정 핸들러 + // 검색 조건에서 선택한 값들을 초기값으로 사용 + initialConstructionSector?: string + initialShipType?: string + initialAvlKind?: string + initialHtDivision?: string +} + +export function StandardAvlAddDialog({ + open, + onOpenChange, + onAddItem, + editingItem, + onUpdateItem, + initialConstructionSector, + initialShipType, + initialAvlKind, + initialHtDivision +}: StandardAvlAddDialogProps) { + const [formData, setFormData] = React.useState<Omit<AvlVendorInfoInput, 'avlListId'>>({ + // 표준 AVL용 기본 설정 + isTemplate: true, + + // 표준 AVL 필수 필드들 (검색 조건에서 선택한 값들로 초기화) + constructionSector: initialConstructionSector || "", + shipType: initialShipType || "", + avlKind: initialAvlKind || "", + htDivision: initialHtDivision || "", + + // 설계 정보 + equipBulkDivision: "EQUIP", + disciplineCode: "", + disciplineName: "", + + // 자재 정보 + materialNameCustomerSide: "", + + // 패키지 정보 + packageCode: "", + packageName: "", + + // 자재그룹 정보 + materialGroupCode: "", + materialGroupName: "", + + // 협력업체 정보 + vendorName: "", + vendorCode: "", + + // AVL 정보 + avlVendorName: "", + tier: "", + + // 제안방향 + ownerSuggestion: false, + shiSuggestion: false, + + // 위치 정보 + headquarterLocation: "", + manufacturingLocation: "", + + // FA 정보 + faTarget: false, + faStatus: "", + + // Agent 정보 + isAgent: false, + + // 계약 서명주체 + contractSignerName: "", + contractSignerCode: "", + + // SHI Qualification + shiAvl: false, + shiBlacklist: false, + shiBcc: false, + + // 기술영업 견적결과 + salesQuoteNumber: "", + quoteCode: "", + salesVendorInfo: "", + salesCountry: "", + totalAmount: "", + quoteReceivedDate: "", + + // 업체 실적 현황 + recentQuoteDate: "", + recentQuoteNumber: "", + recentOrderDate: "", + recentOrderNumber: "", + + // 기타 + remarks: "" + }) + + // 수정 모드일 때 폼 데이터 초기화 + React.useEffect(() => { + if (editingItem) { + setFormData({ + // 표준 AVL용 기본 설정 + isTemplate: true, + + // 표준 AVL 필수 필드들 (기존 값 우선, 없으면 검색 조건 값 사용) + constructionSector: editingItem.constructionSector || initialConstructionSector || "", + shipType: editingItem.shipType || initialShipType || "", + avlKind: editingItem.avlKind || initialAvlKind || "", + htDivision: editingItem.htDivision || initialHtDivision || "", + + // 설계 정보 + equipBulkDivision: editingItem.equipBulkDivision === "EQUIP" ? "EQUIP" : "BULK", + disciplineCode: editingItem.disciplineCode || "", + disciplineName: editingItem.disciplineName || "", + + // 자재 정보 + materialNameCustomerSide: editingItem.materialNameCustomerSide || "", + + // 패키지 정보 + packageCode: editingItem.packageCode || "", + packageName: editingItem.packageName || "", + + // 자재그룹 정보 + materialGroupCode: editingItem.materialGroupCode || "", + materialGroupName: editingItem.materialGroupName || "", + + // 협력업체 정보 + vendorName: editingItem.vendorName || "", + vendorCode: editingItem.vendorCode || "", + + // AVL 정보 + avlVendorName: editingItem.avlVendorName || "", + tier: editingItem.tier || "", + + // 제안방향 + ownerSuggestion: editingItem.ownerSuggestion || false, + shiSuggestion: editingItem.shiSuggestion || false, + + // 위치 정보 + headquarterLocation: editingItem.headquarterLocation || "", + manufacturingLocation: editingItem.manufacturingLocation || "", + + // FA 정보 + faTarget: editingItem.faTarget || false, + faStatus: editingItem.faStatus || "", + + // Agent 정보 + isAgent: editingItem.isAgent || false, + + // 계약 서명주체 + contractSignerName: editingItem.contractSignerName || "", + contractSignerCode: editingItem.contractSignerCode || "", + + // SHI Qualification + shiAvl: editingItem.shiAvl || false, + shiBlacklist: editingItem.shiBlacklist || false, + shiBcc: editingItem.shiBcc || false, + + // 기술영업 견적결과 + salesQuoteNumber: editingItem.salesQuoteNumber || "", + quoteCode: editingItem.quoteCode || "", + salesVendorInfo: editingItem.salesVendorInfo || "", + salesCountry: editingItem.salesCountry || "", + totalAmount: editingItem.totalAmount || "", + quoteReceivedDate: editingItem.quoteReceivedDate || "", + + // 업체 실적 현황 + recentQuoteDate: editingItem.recentQuoteDate || "", + recentQuoteNumber: editingItem.recentQuoteNumber || "", + recentOrderDate: editingItem.recentOrderDate || "", + recentOrderNumber: editingItem.recentOrderNumber || "", + + // 기타 + remarks: editingItem.remarks || "" + }) + } + }, [editingItem, initialConstructionSector, initialShipType, initialAvlKind, initialHtDivision]) + + // 다이얼로그가 열릴 때 초기값 재설정 (수정 모드가 아닐 때만) + React.useEffect(() => { + if (open && !editingItem) { + setFormData(prev => ({ + ...prev, + constructionSector: initialConstructionSector || "", + shipType: initialShipType || "", + avlKind: initialAvlKind || "", + htDivision: initialHtDivision || "", + })) + } + }, [open, editingItem, initialConstructionSector, initialShipType, initialAvlKind, initialHtDivision]) + + const handleSubmit = async () => { + // 필수 필드 검증 (표준 AVL용) + if (!formData.constructionSector || !formData.shipType || !formData.avlKind || !formData.htDivision) { + toast.error("공사부문, 선종, AVL종류, H/T 구분은 필수 입력 항목입니다.") + return + } + + if (!formData.disciplineName || !formData.materialNameCustomerSide) { + toast.error("설계공종과 고객사 AVL 자재명은 필수 입력 항목입니다.") + return + } + + try { + if (editingItem && onUpdateItem) { + // 수정 모드 + await onUpdateItem(editingItem.id, formData) + } else { + // 추가 모드 + await onAddItem(formData) + } + + // 폼 초기화 (onAddItem에서 성공적으로 처리된 경우에만) + setFormData({ + // 표준 AVL용 기본 설정 + isTemplate: true, + + // 표준 AVL 필수 필드들 + constructionSector: "", + shipType: "", + avlKind: "", + htDivision: "", + + // 설계 정보 + equipBulkDivision: "EQUIP", + disciplineCode: "", + disciplineName: "", + + // 자재 정보 + materialNameCustomerSide: "", + + // 패키지 정보 + packageCode: "", + packageName: "", + + // 자재그룹 정보 + materialGroupCode: "", + materialGroupName: "", + + // 협력업체 정보 + vendorName: "", + vendorCode: "", + + // AVL 정보 + avlVendorName: "", + tier: "", + + // 제안방향 + ownerSuggestion: false, + shiSuggestion: false, + + // 위치 정보 + headquarterLocation: "", + manufacturingLocation: "", + + // FA 정보 + faTarget: false, + faStatus: "", + + // Agent 정보 + isAgent: false, + + // 계약 서명주체 + contractSignerName: "", + contractSignerCode: "", + + // SHI Qualification + shiAvl: false, + shiBlacklist: false, + shiBcc: false, + + // 기술영업 견적결과 + salesQuoteNumber: "", + quoteCode: "", + salesVendorInfo: "", + salesCountry: "", + totalAmount: "", + quoteReceivedDate: "", + + // 업체 실적 현황 + recentQuoteDate: "", + recentQuoteNumber: "", + recentOrderDate: "", + recentOrderNumber: "", + + // 기타 + remarks: "" + } as Omit<AvlVendorInfoInput, 'avlListId'>) + + onOpenChange(false) + } catch (error) { + // 에러 처리는 onAddItem에서 담당하므로 여기서는 아무것도 하지 않음 + } + } + + const handleCancel = () => { + setFormData({ + // 표준 AVL용 기본 설정 + isTemplate: true, + + // 표준 AVL 필수 필드들 + constructionSector: "", + shipType: "", + avlKind: "", + htDivision: "", + + // 설계 정보 + equipBulkDivision: "EQUIP", + disciplineCode: "", + disciplineName: "", + + // 자재 정보 + materialNameCustomerSide: "", + + // 패키지 정보 + packageCode: "", + packageName: "", + + // 자재그룹 정보 + materialGroupCode: "", + materialGroupName: "", + + // 협력업체 정보 + vendorName: "", + vendorCode: "", + + // AVL 정보 + avlVendorName: "", + tier: "", + + // 제안방향 + ownerSuggestion: false, + shiSuggestion: false, + + // 위치 정보 + headquarterLocation: "", + manufacturingLocation: "", + + // FA 정보 + faTarget: false, + faStatus: "", + + // Agent 정보 + isAgent: false, + + // 계약 서명주체 + contractSignerName: "", + contractSignerCode: "", + + // SHI Qualification + shiAvl: false, + shiBlacklist: false, + shiBcc: false, + + // 기술영업 견적결과 + salesQuoteNumber: "", + quoteCode: "", + salesVendorInfo: "", + salesCountry: "", + totalAmount: "", + quoteReceivedDate: "", + + // 업체 실적 현황 + recentQuoteDate: "", + recentQuoteNumber: "", + recentOrderDate: "", + recentOrderNumber: "", + + // 기타 + remarks: "" + } as Omit<AvlVendorInfoInput, 'avlListId'>) + onOpenChange(false) + } + + // 선종 옵션들 (공사부문에 따라 다름) + const getShipTypeOptions = (constructionSector: string) => { + if (constructionSector === "조선") { + return [ + { value: "A-max", label: "A-max" }, + { value: "S-max", label: "S-max" }, + { value: "VLCC", label: "VLCC" }, + { value: "LNGC", label: "LNGC" }, + { value: "CONT", label: "CONT" }, + ] + } else if (constructionSector === "해양") { + return [ + { value: "FPSO", label: "FPSO" }, + { value: "FLNG", label: "FLNG" }, + { value: "FPU", label: "FPU" }, + { value: "Platform", label: "Platform" }, + { value: "WTIV", label: "WTIV" }, + { value: "GOM", label: "GOM" }, + ] + } else { + return [] + } + } + + const shipTypeOptions = getShipTypeOptions(formData.constructionSector) + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>{editingItem ? "표준 AVL 항목 수정" : "표준 AVL 항목 추가"}</DialogTitle> + <DialogDescription> + {editingItem + ? "표준 AVL 항목을 수정합니다. 필수 항목을 입력해주세요." + : "새로운 표준 AVL 항목을 추가합니다. 필수 항목을 입력해주세요." + } * 표시된 항목은 필수 입력사항입니다. + </DialogDescription> + </DialogHeader> + <div className="space-y-6 py-4"> + {/* 표준 AVL 기본 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">표준 AVL 기본 정보 *</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="constructionSector">공사부문 *</Label> + <Select + value={formData.constructionSector} + onValueChange={(value) => { + setFormData(prev => ({ + ...prev, + constructionSector: value, + shipType: "" // 공사부문 변경 시 선종 초기화 + })) + }} + > + <SelectTrigger> + <SelectValue placeholder="공사부문을 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="조선">조선</SelectItem> + <SelectItem value="해양">해양</SelectItem> + </SelectContent> + </Select> + </div> + <div className="space-y-2"> + <Label htmlFor="shipType">선종 *</Label> + <Select + value={formData.shipType} + onValueChange={(value) => + setFormData(prev => ({ ...prev, shipType: value })) + } + disabled={!formData.constructionSector} + > + <SelectTrigger> + <SelectValue placeholder="선종을 선택하세요" /> + </SelectTrigger> + <SelectContent> + {shipTypeOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + <div className="space-y-2"> + <Label htmlFor="avlKind">AVL종류 *</Label> + <Select + value={formData.avlKind} + onValueChange={(value) => + setFormData(prev => ({ ...prev, avlKind: value })) + } + > + <SelectTrigger> + <SelectValue placeholder="AVL종류를 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="Nearshore">Nearshore</SelectItem> + <SelectItem value="Offshore">Offshore</SelectItem> + <SelectItem value="IOC">IOC</SelectItem> + <SelectItem value="NOC">NOC</SelectItem> + </SelectContent> + </Select> + </div> + <div className="space-y-2"> + <Label htmlFor="htDivision">H/T 구분 *</Label> + <Select + value={formData.htDivision} + onValueChange={(value) => + setFormData(prev => ({ ...prev, htDivision: value })) + } + > + <SelectTrigger> + <SelectValue placeholder="H/T 구분을 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="공통">공통</SelectItem> + <SelectItem value="H">Hull (H)</SelectItem> + <SelectItem value="T">Topside (T)</SelectItem> + </SelectContent> + </Select> + </div> + </div> + </div> + + {/* 기본 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기본 정보</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="equipBulkDivision">EQUIP/BULK 구분</Label> + <Select + value={formData.equipBulkDivision} + onValueChange={(value: "EQUIP" | "BULK") => + setFormData(prev => ({ ...prev, equipBulkDivision: value })) + } + > + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="EQUIP">EQUIP</SelectItem> + <SelectItem value="BULK">BULK</SelectItem> + </SelectContent> + </Select> + </div> + <div className="space-y-2"> + <Label htmlFor="disciplineCode">설계공종코드</Label> + <Input + id="disciplineCode" + value={formData.disciplineCode} + onChange={(e) => setFormData(prev => ({ ...prev, disciplineCode: e.target.value }))} + placeholder="설계공종코드를 입력하세요" + /> + </div> + <div className="space-y-2 col-span-2"> + <Label htmlFor="disciplineName">설계공종명 *</Label> + <Input + id="disciplineName" + value={formData.disciplineName} + onChange={(e) => setFormData(prev => ({ ...prev, disciplineName: e.target.value }))} + placeholder="설계공종명을 입력하세요" + /> + </div> + <div className="space-y-2 col-span-2"> + <Label htmlFor="materialNameCustomerSide">고객사 AVL 자재명 *</Label> + <Input + id="materialNameCustomerSide" + value={formData.materialNameCustomerSide} + onChange={(e) => setFormData(prev => ({ ...prev, materialNameCustomerSide: e.target.value }))} + placeholder="고객사 AVL 자재명을 입력하세요" + /> + </div> + </div> + </div> + + {/* 패키지 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">패키지 정보</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="packageCode">패키지 코드</Label> + <Input + id="packageCode" + value={formData.packageCode} + onChange={(e) => setFormData(prev => ({ ...prev, packageCode: e.target.value }))} + placeholder="패키지 코드를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="packageName">패키지 명</Label> + <Input + id="packageName" + value={formData.packageName} + onChange={(e) => setFormData(prev => ({ ...prev, packageName: e.target.value }))} + placeholder="패키지 명을 입력하세요" + /> + </div> + </div> + </div> + + {/* 자재그룹 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">자재그룹 정보</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="materialGroupCode">자재그룹 코드</Label> + <Input + id="materialGroupCode" + value={formData.materialGroupCode} + onChange={(e) => setFormData(prev => ({ ...prev, materialGroupCode: e.target.value }))} + placeholder="자재그룹 코드를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="materialGroupName">자재그룹 명</Label> + <Input + id="materialGroupName" + value={formData.materialGroupName} + onChange={(e) => setFormData(prev => ({ ...prev, materialGroupName: e.target.value }))} + placeholder="자재그룹 명을 입력하세요" + /> + </div> + </div> + </div> + + {/* 협력업체 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">협력업체 정보</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="vendorCode">협력업체 코드</Label> + <Input + id="vendorCode" + value={formData.vendorCode} + onChange={(e) => setFormData(prev => ({ ...prev, vendorCode: e.target.value }))} + placeholder="협력업체 코드를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorName">협력업체 명</Label> + <Input + id="vendorName" + value={formData.vendorName} + onChange={(e) => setFormData(prev => ({ ...prev, vendorName: e.target.value }))} + placeholder="협력업체 명을 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="avlVendorName">AVL 등재업체명</Label> + <Input + id="avlVendorName" + value={formData.avlVendorName} + onChange={(e) => setFormData(prev => ({ ...prev, avlVendorName: e.target.value }))} + placeholder="AVL 등재업체명을 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="tier">등급 (Tier)</Label> + <Input + id="tier" + value={formData.tier} + onChange={(e) => setFormData(prev => ({ ...prev, tier: e.target.value }))} + placeholder="등급을 입력하세요" + /> + </div> + </div> + </div> + + {/* 제안방향 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">제안방향</h4> + <div className="flex gap-6"> + <div className="flex items-center space-x-2"> + <Checkbox + id="ownerSuggestion" + checked={formData.ownerSuggestion} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, ownerSuggestion: !!checked })) + } + /> + <Label htmlFor="ownerSuggestion">선주제안</Label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="shiSuggestion" + checked={formData.shiSuggestion} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, shiSuggestion: !!checked })) + } + /> + <Label htmlFor="shiSuggestion">SHI 제안</Label> + </div> + </div> + </div> + + {/* 위치 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">위치 정보</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="headquarterLocation">본사 위치 (국가)</Label> + <Input + id="headquarterLocation" + value={formData.headquarterLocation} + onChange={(e) => setFormData(prev => ({ ...prev, headquarterLocation: e.target.value }))} + placeholder="본사 위치를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="manufacturingLocation">제작/선적지 (국가)</Label> + <Input + id="manufacturingLocation" + value={formData.manufacturingLocation} + onChange={(e) => setFormData(prev => ({ ...prev, manufacturingLocation: e.target.value }))} + placeholder="제작/선적지를 입력하세요" + /> + </div> + </div> + </div> + + {/* FA 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">FA 정보</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-center space-x-2"> + <Checkbox + id="faTarget" + checked={formData.faTarget} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, faTarget: !!checked })) + } + /> + <Label htmlFor="faTarget">FA 대상</Label> + </div> + <div className="space-y-2"> + <Label htmlFor="faStatus">FA 현황</Label> + <Input + id="faStatus" + value={formData.faStatus} + onChange={(e) => setFormData(prev => ({ ...prev, faStatus: e.target.value }))} + placeholder="FA 현황을 입력하세요" + /> + </div> + </div> + </div> + + {/* Agent 정보 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">Agent 정보</h4> + <div className="flex items-center space-x-2"> + <Checkbox + id="isAgent" + checked={formData.isAgent} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, isAgent: !!checked })) + } + /> + <Label htmlFor="isAgent">Agent 여부</Label> + </div> + </div> + + {/* 계약 서명주체 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">계약 서명주체</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="contractSignerCode">계약서명주체 코드</Label> + <Input + id="contractSignerCode" + value={formData.contractSignerCode} + onChange={(e) => setFormData(prev => ({ ...prev, contractSignerCode: e.target.value }))} + placeholder="계약서명주체 코드를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="contractSignerName">계약서명주체 명</Label> + <Input + id="contractSignerName" + value={formData.contractSignerName} + onChange={(e) => setFormData(prev => ({ ...prev, contractSignerName: e.target.value }))} + placeholder="계약서명주체 명을 입력하세요" + /> + </div> + </div> + </div> + + {/* SHI Qualification */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">SHI Qualification</h4> + <div className="flex gap-6"> + <div className="flex items-center space-x-2"> + <Checkbox + id="shiAvl" + checked={formData.shiAvl} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, shiAvl: !!checked })) + } + /> + <Label htmlFor="shiAvl">AVL</Label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="shiBlacklist" + checked={formData.shiBlacklist} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, shiBlacklist: !!checked })) + } + /> + <Label htmlFor="shiBlacklist">Blacklist</Label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="shiBcc" + checked={formData.shiBcc} + onCheckedChange={(checked) => + setFormData(prev => ({ ...prev, shiBcc: !!checked })) + } + /> + <Label htmlFor="shiBcc">BCC</Label> + </div> + </div> + </div> + + {/* 기술영업 견적결과 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기술영업 견적결과</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="salesQuoteNumber">기술영업 견적번호</Label> + <Input + id="salesQuoteNumber" + value={formData.salesQuoteNumber} + onChange={(e) => setFormData(prev => ({ ...prev, salesQuoteNumber: e.target.value }))} + placeholder="기술영업 견적번호를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="quoteCode">견적서 Code</Label> + <Input + id="quoteCode" + value={formData.quoteCode} + onChange={(e) => setFormData(prev => ({ ...prev, quoteCode: e.target.value }))} + placeholder="견적서 Code를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="salesVendorInfo">견적 협력업체 명</Label> + <Input + id="salesVendorInfo" + value={formData.salesVendorInfo} + onChange={(e) => setFormData(prev => ({ ...prev, salesVendorInfo: e.target.value }))} + placeholder="견적 협력업체 명을 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="salesCountry">국가</Label> + <Input + id="salesCountry" + value={formData.salesCountry} + onChange={(e) => setFormData(prev => ({ ...prev, salesCountry: e.target.value }))} + placeholder="국가를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="totalAmount">총 금액</Label> + <Input + id="totalAmount" + type="number" + value={formData.totalAmount} + onChange={(e) => setFormData(prev => ({ ...prev, totalAmount: e.target.value }))} + placeholder="총 금액을 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="quoteReceivedDate">견적접수일 (YYYY-MM-DD)</Label> + <Input + id="quoteReceivedDate" + value={formData.quoteReceivedDate} + onChange={(e) => setFormData(prev => ({ ...prev, quoteReceivedDate: e.target.value }))} + placeholder="YYYY-MM-DD" + /> + </div> + </div> + </div> + + {/* 업체 실적 현황 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">업체 실적 현황</h4> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="recentQuoteNumber">최근견적번호</Label> + <Input + id="recentQuoteNumber" + value={formData.recentQuoteNumber} + onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteNumber: e.target.value }))} + placeholder="최근견적번호를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="recentQuoteDate">최근견적일 (YYYY-MM-DD)</Label> + <Input + id="recentQuoteDate" + value={formData.recentQuoteDate} + onChange={(e) => setFormData(prev => ({ ...prev, recentQuoteDate: e.target.value }))} + placeholder="YYYY-MM-DD" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="recentOrderNumber">최근발주번호</Label> + <Input + id="recentOrderNumber" + value={formData.recentOrderNumber} + onChange={(e) => setFormData(prev => ({ ...prev, recentOrderNumber: e.target.value }))} + placeholder="최근발주번호를 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="recentOrderDate">최근발주일 (YYYY-MM-DD)</Label> + <Input + id="recentOrderDate" + value={formData.recentOrderDate} + onChange={(e) => setFormData(prev => ({ ...prev, recentOrderDate: e.target.value }))} + placeholder="YYYY-MM-DD" + /> + </div> + </div> + </div> + + {/* 기타 */} + <div className="space-y-4"> + <h4 className="text-sm font-semibold text-muted-foreground border-b pb-2">기타</h4> + <div className="space-y-2"> + <Label htmlFor="remarks">비고</Label> + <Textarea + id="remarks" + value={formData.remarks} + onChange={(e) => setFormData(prev => ({ ...prev, remarks: e.target.value }))} + placeholder="비고를 입력하세요" + rows={3} + /> + </div> + </div> + </div> + <DialogFooter> + <Button type="button" variant="outline" onClick={handleCancel}> + 취소 + </Button> + <Button type="button" onClick={handleSubmit}> + {editingItem ? "수정" : "추가"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/avl/table/standard-avl-table-columns.tsx b/lib/avl/table/standard-avl-table-columns.tsx new file mode 100644 index 00000000..903d2590 --- /dev/null +++ b/lib/avl/table/standard-avl-table-columns.tsx @@ -0,0 +1,91 @@ +import { Checkbox } from "@/components/ui/checkbox" +import { ColumnDef } from "@tanstack/react-table" +import { StandardAvlItem } from "./standard-avl-table" + +// 선종별 표준 AVL 테이블 컬럼 +export const standardAvlColumns: ColumnDef<StandardAvlItem>[] = [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row, table }) => { + // 선종별 표준 AVL 테이블의 단일 선택 핸들러 + const handleRowSelection = (checked: boolean) => { + if (checked) { + // 다른 모든 행의 선택 해제 + table.getRowModel().rows.forEach(r => { + if (r !== row && r.getIsSelected()) { + r.toggleSelected(false) + } + }) + } + // 현재 행 선택/해제 + row.toggleSelected(checked) + } + + return ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={handleRowSelection} + aria-label="Select row" + /> + ) + }, + enableSorting: false, + enableHiding: false, + size: 50, + }, + { + accessorKey: "no", + header: "No.", + size: 60, + }, + { + accessorKey: "disciplineName", + header: "설계공종", + size: 120, + }, + { + accessorKey: "avlVendorName", + header: "AVL 등재업체명", + size: 140, + }, + { + accessorKey: "materialGroupCode", + header: "자재그룹 코드", + size: 120, + }, + { + accessorKey: "materialGroupName", + header: "자재그룹 명", + size: 130, + }, + { + accessorKey: "vendorCode", + header: "협력업체 코드", + size: 120, + }, + { + accessorKey: "vendorName", + header: "협력업체 명", + size: 130, + }, + { + accessorKey: "headquarterLocation", + header: "본사 위치 (국가)", + size: 140, + }, + { + accessorKey: "tier", + header: "등급 (Tier)", + size: 120, + }, +]
\ No newline at end of file diff --git a/lib/avl/table/standard-avl-table.tsx b/lib/avl/table/standard-avl-table.tsx index 924b972a..cc39540b 100644 --- a/lib/avl/table/standard-avl-table.tsx +++ b/lib/avl/table/standard-avl-table.tsx @@ -1,10 +1,10 @@ "use client" import * as React from "react" -import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel, type ColumnDef } from "@tanstack/react-table" +import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel } from "@tanstack/react-table" +import { useLayoutEffect, useMemo, forwardRef, useImperativeHandle } from "react" import { DataTable } from "@/components/data-table/data-table" import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" import { getStandardAvlVendorInfo } from "../service" import { GetStandardAvlSchema } from "../validations" import { AvlDetailItem } from "../types" @@ -17,40 +17,74 @@ import { } from "@/components/ui/select" import { Search } from "lucide-react" import { toast } from "sonner" +import { standardAvlColumns } from "./standard-avl-table-columns" +import { AvlVendorAddAndModifyDialog } from "./avl-vendor-add-and-modify-dialog" +import { createAvlVendorInfo, updateAvlVendorInfo, deleteAvlVendorInfo, finalizeStandardAvl } from "../service" +import { AvlVendorInfoInput } from "../types" +import { useSession } from "next-auth/react" + +/** + * 조선인 경우, 선종: + * A-max, S-max, VLCC, LNGC, CONT + * 해양인 경우, 선종: + * FPSO, FLNG, FPU, Platform, WTIV, GOM + * + * AVL종류: + * Nearshore, Offshore, IOC, NOC + */ // 검색 옵션들 const constructionSectorOptions = [ - { value: "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 getShipTypeOptions = (constructionSector: string) => { + if (constructionSector === "조선") { + return [ + { value: "A-max", label: "A-max" }, + { value: "S-max", label: "S-max" }, + { value: "VLCC", label: "VLCC" }, + { value: "LNGC", label: "LNGC" }, + { value: "CONT", label: "CONT" }, + ] + } else if (constructionSector === "해양") { + return [ + { value: "FPSO", label: "FPSO" }, + { value: "FLNG", label: "FLNG" }, + { value: "FPU", label: "FPU" }, + { value: "Platform", label: "Platform" }, + { value: "WTIV", label: "WTIV" }, + { value: "GOM", label: "GOM" }, + ] + } else { + // 공사부문이 선택되지 않은 경우 빈 배열 + return [] + } +} const avlKindOptions = [ - { value: "all", label: "전체" }, - { value: "표준", label: "표준" }, - { value: "특별", label: "특별" }, - { value: "임시", label: "임시" }, + { value: "Nearshore", label: "Nearshore" }, + { value: "Offshore", label: "Offshore" }, + { value: "IOC", label: "IOC" }, + { value: "NOC", label: "NOC" }, ] const htDivisionOptions = [ - { value: "all", label: "전체" }, + { value: "공통", label: "공통" }, { value: "H", label: "Hull (H)" }, - { value: "T", label: "Topside (T)" }, + { value: "T", label: "Top (T)" }, ] // 선종별 표준 AVL 테이블에서는 AvlDetailItem을 사용 export type StandardAvlItem = AvlDetailItem +// ref를 통해 외부에서 접근할 수 있는 메소드들 +export interface StandardAvlTableRef { + getSelectedIds: () => number[] +} + interface StandardAvlTableProps { onSelectionChange?: (count: number) => void resetCounter?: number @@ -58,130 +92,154 @@ interface StandardAvlTableProps { shipType?: string // 선종 필터 avlKind?: string // AVL 종류 필터 htDivision?: string // H/T 구분 필터 + onSearchConditionsChange?: (conditions: { + constructionSector: string + shipType: string + avlKind: string + htDivision: string + }) => void + reloadTrigger?: number } -// 선종별 표준 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({ +export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTableProps>(({ onSelectionChange, resetCounter, constructionSector: initialConstructionSector, shipType: initialShipType, avlKind: initialAvlKind, - htDivision: initialHtDivision -}: StandardAvlTableProps) { + htDivision: initialHtDivision, + onSearchConditionsChange, + reloadTrigger +}, ref) => { + const { data: sessionData } = useSession() + const [data, setData] = React.useState<StandardAvlItem[]>([]) const [loading, setLoading] = React.useState(false) const [pageCount, setPageCount] = React.useState(0) + // 다이얼로그 상태 + const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false) + const [editingItem, setEditingItem] = React.useState<StandardAvlItem | undefined>(undefined) + // 검색 상태 - const [searchConstructionSector, setSearchConstructionSector] = React.useState(initialConstructionSector || "all") - const [searchShipType, setSearchShipType] = React.useState(initialShipType || "all") - const [searchAvlKind, setSearchAvlKind] = React.useState(initialAvlKind || "all") - const [searchHtDivision, setSearchHtDivision] = React.useState(initialHtDivision || "all") + const [searchConstructionSector, setSearchConstructionSector] = React.useState(initialConstructionSector || "") + const [searchShipType, setSearchShipType] = React.useState(initialShipType || "") + const [searchAvlKind, setSearchAvlKind] = React.useState(initialAvlKind || "") + const [searchHtDivision, setSearchHtDivision] = React.useState(initialHtDivision || "") + + // 페이지네이션 상태 + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }) + + const table = useReactTable({ + data, + columns: standardAvlColumns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + manualPagination: true, + pageCount, + state: { + pagination, + }, + onPaginationChange: (updater) => { + // 페이지네이션 상태 업데이트 + const newPaginationState = typeof updater === 'function' ? updater(pagination) : updater + + console.log('StandardAvlTable - Pagination changed:', { + currentState: pagination, + newPaginationState, + isAllSearchConditionsSelected, + willLoadData: isAllSearchConditionsSelected + }) + + setPagination(newPaginationState) + + if (isAllSearchConditionsSelected) { + const apiParams = { + page: newPaginationState.pageIndex + 1, + perPage: newPaginationState.pageSize, + } + console.log('StandardAvlTable - Loading data with params:', apiParams) + loadData(apiParams) + } + }, + }) + + // 공사부문 변경 시 선종 초기화 + const handleConstructionSectorChange = React.useCallback((value: string) => { + setSearchConstructionSector(value) + // 공사부문이 변경되면 선종을 빈 값으로 초기화 + setSearchShipType("") + }, []) + + // 검색 상태 변경 시 부모 컴포넌트에 전달 + React.useEffect(() => { + onSearchConditionsChange?.({ + constructionSector: searchConstructionSector, + shipType: searchShipType, + avlKind: searchAvlKind, + htDivision: searchHtDivision + }) + }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision, onSearchConditionsChange]) + + // 현재 공사부문에 따른 선종 옵션들 + const currentShipTypeOptions = React.useMemo(() => + getShipTypeOptions(searchConstructionSector), + [searchConstructionSector] + ) + + // 모든 검색 조건이 선택되었는지 확인 + const isAllSearchConditionsSelected = React.useMemo(() => { + return ( + searchConstructionSector.trim() !== "" && + searchShipType.trim() !== "" && + searchAvlKind.trim() !== "" && + searchHtDivision.trim() !== "" + ) + }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision]) // 데이터 로드 함수 - const loadData = React.useCallback(async (searchParams: Partial<GetStandardAvlSchema>) => { + 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 || "", + flags: searchParams.flags ?? [], + constructionSector: searchConstructionSector, + shipType: searchShipType, + avlKind: searchAvlKind, + htDivision: searchHtDivision as "공통" | "H" | "T" | "", + equipBulkDivision: searchParams.equipBulkDivision || "", + disciplineCode: searchParams.disciplineCode ?? "", + disciplineName: searchParams.disciplineName ?? "", + materialNameCustomerSide: searchParams.materialNameCustomerSide ?? "", + packageCode: searchParams.packageCode ?? "", + packageName: searchParams.packageName ?? "", + materialGroupCode: searchParams.materialGroupCode ?? "", + materialGroupName: searchParams.materialGroupName ?? "", + vendorName: searchParams.vendorName ?? "", + vendorCode: searchParams.vendorCode ?? "", + avlVendorName: searchParams.avlVendorName ?? "", + tier: searchParams.tier ?? "", + filters: searchParams.filters ?? [], + joinOperator: searchParams.joinOperator ?? "and", search: "", ...searchParams, } + console.log('StandardAvlTable - API call params:', params) const result = await getStandardAvlVendorInfo(params) + console.log('StandardAvlTable - API result:', { + dataCount: result.data.length, + pageCount: result.pageCount, + requestedPage: params.page + }) setData(result.data) setPageCount(result.pageCount) } catch (error) { @@ -193,52 +251,230 @@ export function StandardAvlTable({ } }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision]) - // 검색 핸들러 - const handleSearch = React.useCallback(() => { - loadData({}) - }, [loadData]) + // reloadTrigger가 변경될 때마다 데이터 리로드 + React.useEffect(() => { + if (reloadTrigger && reloadTrigger > 0) { + console.log('StandardAvlTable - reloadTrigger changed, reloading data') + loadData({}) + } + }, [reloadTrigger, loadData]) // 검색 초기화 핸들러 const handleResetSearch = React.useCallback(() => { - setSearchConstructionSector("all") - setSearchShipType("all") - setSearchAvlKind("all") - setSearchHtDivision("all") - loadData({}) + setSearchConstructionSector("") + setSearchShipType("") + setSearchAvlKind("") + setSearchHtDivision("") + // 초기화 시 빈 데이터로 설정 + setData([]) + setPageCount(0) + }, []) + + // 검색 핸들러 + const handleSearch = React.useCallback(() => { + if (isAllSearchConditionsSelected) { + // 검색 시 페이지를 1페이지로 리셋 + setPagination(prev => ({ ...prev, pageIndex: 0 })) + loadData({ page: 1, perPage: pagination.pageSize }) + } + }, [loadData, isAllSearchConditionsSelected, pagination.pageSize]) + + // 항목 추가 핸들러 + const handleAddItem = React.useCallback(async (itemData: Omit<AvlVendorInfoInput, 'avlListId'>) => { + try { + const result = await createAvlVendorInfo(itemData) + + if (result) { + toast.success("표준 AVL 항목이 성공적으로 추가되었습니다.") + // 데이터 새로고침 + loadData({}) + } else { + toast.error("항목 추가에 실패했습니다.") + } + } catch (error) { + console.error("항목 추가 실패:", error) + toast.error("항목 추가 중 오류가 발생했습니다.") + } }, [loadData]) - // 초기 데이터 로드 - React.useEffect(() => { - loadData({}) + // 항목 수정 핸들러 + const handleUpdateItem = React.useCallback(async (id: number, itemData: Omit<AvlVendorInfoInput, 'avlListId'>) => { + try { + const result = await updateAvlVendorInfo(id, itemData) + + if (result) { + toast.success("표준 AVL 항목이 성공적으로 수정되었습니다.") + // 데이터 새로고침 + loadData({}) + // 수정 모드 해제 + setEditingItem(undefined) + } else { + toast.error("항목 수정에 실패했습니다.") + } + } catch (error) { + console.error("항목 수정 실패:", error) + toast.error("항목 수정 중 오류가 발생했습니다.") + } }, [loadData]) - const 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, + // 항목 수정 핸들러 (버튼 클릭) + const handleEditItem = React.useCallback(() => { + const selectedRows = table.getFilteredSelectedRowModel().rows + + if (selectedRows.length !== 1) { + toast.error("수정할 항목을 하나만 선택해주세요.") + return + } + + const selectedItem = selectedRows[0].original + setEditingItem(selectedItem) + setIsAddDialogOpen(true) + }, [table]) + + // 항목 삭제 핸들러 + const handleDeleteItems = React.useCallback(async () => { + const selectedRows = table.getFilteredSelectedRowModel().rows + + if (selectedRows.length === 0) { + toast.error("삭제할 항목을 선택해주세요.") + return + } + + // 사용자 확인 + const confirmed = window.confirm(`선택한 ${selectedRows.length}개 항목을 정말 삭제하시겠습니까?`) + if (!confirmed) return + + try { + // 선택된 항목들을 DB에서 삭제 + const deletePromises = selectedRows.map(async (row) => { + await deleteAvlVendorInfo(row.original.id) }) - }, - }) - // 선택 상태 변경 시 콜백 호출 + await Promise.all(deletePromises) + + toast.success(`${selectedRows.length}개 항목이 삭제되었습니다.`) + + // 데이터 새로고침 + loadData({}) + + // 선택 해제 + table.toggleAllPageRowsSelected(false) + } catch (error) { + console.error("항목 삭제 실패:", error) + toast.error("항목 삭제 중 오류가 발생했습니다.") + } + }, [table, loadData]) + + // 최종 확정 핸들러 (표준 AVL) + const handleFinalizeStandardAvl = React.useCallback(async () => { + // 1. 필수 조건 검증 + if (!isAllSearchConditionsSelected) { + toast.error("검색 조건을 모두 선택해주세요.") + return + } + + if (data.length === 0) { + toast.error("확정할 표준 AVL 벤더 정보가 없습니다.") + return + } + + // 2. 사용자 확인 + const confirmed = window.confirm( + `현재 표준 AVL을 최종 확정하시겠습니까?\n\n` + + `- 공사부문: ${searchConstructionSector}\n` + + `- 선종: ${searchShipType}\n` + + `- AVL종류: ${searchAvlKind}\n` + + `- H/T 구분: ${searchHtDivision}\n` + + `- 벤더 정보: ${data.length}개\n\n` + + `확정 후에는 수정이 어려울 수 있습니다.` + ) + + if (!confirmed) return + + try { + // 3. 현재 데이터의 모든 ID 수집 + const avlVendorInfoIds = data.map(item => item.id) + + // 4. 최종 확정 실행 + const standardAvlInfo = { + constructionSector: searchConstructionSector, + shipType: searchShipType, + avlKind: searchAvlKind, + htDivision: searchHtDivision + } + + const result = await finalizeStandardAvl( + standardAvlInfo, + avlVendorInfoIds, + sessionData?.user?.name || "" + ) + + if (result.success) { + toast.success(result.message) + + // 5. 데이터 새로고침 + loadData({}) + + // 6. 선택 해제 + table.toggleAllPageRowsSelected(false) + } else { + toast.error(result.message) + } + } catch (error) { + console.error("표준 AVL 최종 확정 실패:", error) + toast.error("표준 AVL 최종 확정 중 오류가 발생했습니다.") + } + }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision, isAllSearchConditionsSelected, data, table, loadData, sessionData?.user?.name]) + + // 초기 데이터 로드 (검색 조건이 모두 입력되었을 때만) React.useEffect(() => { - onSelectionChange?.(table.getFilteredSelectedRowModel().rows.length) - }, [table.getFilteredSelectedRowModel().rows.length, onSelectionChange]) + if (isAllSearchConditionsSelected) { + // 검색 조건이 모두 입력되면 페이지를 1페이지로 리셋하고 데이터 로드 + setPagination(prev => ({ ...prev, pageIndex: 0 })) + loadData({ page: 1, perPage: pagination.pageSize }) + } else { + // 검색 조건이 모두 입력되지 않은 경우 빈 데이터로 설정 + setData([]) + setPageCount(0) + } + }, [isAllSearchConditionsSelected, pagination.pageSize, searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision]) + + + + // 외부에서 선택된 ID들을 가져올 수 있도록 ref에 메소드 노출 + useImperativeHandle(ref, () => ({ + getSelectedIds: () => { + const selectedRows = table.getFilteredSelectedRowModel().rows + return selectedRows.map(row => row.original.id) + } + })) + + // 선택된 행 개수 (안정적인 계산을 위해 useMemo 사용) + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedRowCount = useMemo(() => { + const count = selectedRows.length + console.log('StandardAvlTable - selectedRowCount calculated:', count) + return count + }, [selectedRows]) + + // 페이지네이션 상태 디버깅 + React.useEffect(() => { + const paginationState = table.getState().pagination + console.log('StandardAvlTable - Current pagination state:', { + pageIndex: paginationState.pageIndex, + pageSize: paginationState.pageSize, + canPreviousPage: table.getCanPreviousPage(), + canNextPage: table.getCanNextPage(), + pageCount: table.getPageCount(), + currentDataLength: data.length + }) + }, [table, data]) + + // 선택 상태 변경 시 콜백 호출 + useLayoutEffect(() => { + console.log('StandardAvlTable - onSelectionChange called with count:', selectedRowCount) + onSelectionChange?.(selectedRowCount) + }, [selectedRowCount, onSelectionChange]) // 선택 해제 요청이 오면 모든 선택 해제 React.useEffect(() => { @@ -253,22 +489,35 @@ export function StandardAvlTable({ <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 variant="outline" size="sm" onClick={() => setIsAddDialogOpen(true)}> 신규업체 추가 </Button> - <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}> + <Button + variant="outline" + size="sm" + onClick={handleEditItem} + disabled={table.getFilteredSelectedRowModel().rows.length !== 1} + > + 항목 수정 + </Button> + {/* <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}> 파일 업로드 </Button> <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}> 일괄입력 + </Button> */} + <Button variant="outline" size="sm" onClick={handleDeleteItems}> + 항목 삭제 </Button> - <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}> - 항목삭제 - </Button> - <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}> + {/* <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}> 저장 - </Button> - <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}> + </Button> */} + <Button + variant="outline" + size="sm" + onClick={handleFinalizeStandardAvl} + disabled={!isAllSearchConditionsSelected || data.length === 0} + > 최종 확정 </Button> </div> @@ -281,7 +530,7 @@ export function StandardAvlTable({ {/* 공사부문 */} <div className="space-y-2"> <label className="text-sm font-medium">공사부문</label> - <Select value={searchConstructionSector} onValueChange={setSearchConstructionSector}> + <Select value={searchConstructionSector} onValueChange={handleConstructionSectorChange}> <SelectTrigger> <SelectValue /> </SelectTrigger> @@ -303,7 +552,7 @@ export function StandardAvlTable({ <SelectValue /> </SelectTrigger> <SelectContent> - {shipTypeOptions.map((option) => ( + {currentShipTypeOptions.map((option) => ( <SelectItem key={option.value} value={option.value}> {option.label} </SelectItem> @@ -352,7 +601,7 @@ export function StandardAvlTable({ <div className="flex gap-1"> <Button onClick={handleSearch} - disabled={loading} + disabled={loading || !isAllSearchConditionsSelected} size="sm" className="px-3" > @@ -375,6 +624,28 @@ export function StandardAvlTable({ <div className="flex-1"> <DataTable table={table} /> </div> + + {/* 신규업체 추가 다이얼로그 */} + <AvlVendorAddAndModifyDialog + open={isAddDialogOpen} + onOpenChange={(open) => { + setIsAddDialogOpen(open) + if (!open) { + setEditingItem(undefined) // 다이얼로그가 닫힐 때 수정 모드 해제 + } + }} + onAddItem={handleAddItem} + editingItem={editingItem} + onUpdateItem={handleUpdateItem} + isTemplate={true} // 표준 AVL 모드 + // 검색 조건에서 선택한 값들을 초기값으로 전달 + initialConstructionSector={searchConstructionSector} + initialShipType={searchShipType} + initialAvlKind={searchAvlKind} + initialHtDivision={searchHtDivision} + /> </div> ) -} +}) + +StandardAvlTable.displayName = "StandardAvlTable" diff --git a/lib/avl/table/vendor-pool-table-columns.tsx b/lib/avl/table/vendor-pool-table-columns.tsx new file mode 100644 index 00000000..53db1059 --- /dev/null +++ b/lib/avl/table/vendor-pool-table-columns.tsx @@ -0,0 +1,96 @@ +import { Checkbox } from "@/components/ui/checkbox" +import { ColumnDef } from "@tanstack/react-table" +import { VendorPoolItem } from "./vendor-pool-table" + +// Vendor Pool 테이블 컬럼 +export const vendorPoolColumns: ColumnDef<VendorPoolItem>[] = [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row, table }) => { + // Vendor Pool 테이블의 단일 선택 핸들러 + const handleRowSelection = (checked: boolean) => { + if (checked) { + // 다른 모든 행의 선택 해제 + table.getRowModel().rows.forEach(r => { + if (r !== row && r.getIsSelected()) { + r.toggleSelected(false) + } + }) + } + // 현재 행 선택/해제 + row.toggleSelected(checked) + } + + return ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={handleRowSelection} + aria-label="Select row" + /> + ) + }, + enableSorting: false, + enableHiding: false, + size: 50, + }, + { + accessorKey: "no", + header: "No.", + size: 60, + }, + { + accessorKey: "designCategory", + header: "설계공종", + size: 120, + }, + { + accessorKey: "avlVendorName", + header: "AVL 등재업체명", + size: 140, + }, + { + accessorKey: "materialGroupCode", + header: "자재그룹코드", + size: 130, + }, + { + accessorKey: "materialGroupName", + header: "자재그룹명", + size: 130, + }, + { + accessorKey: "vendorName", + header: "협력업체 정보", + size: 130, + }, + { + accessorKey: "tier", + header: "업체분류", + size: 100, + }, + { + accessorKey: "faStatus", + header: "FA현황", + size: 100, + }, + { + accessorKey: "recentQuoteNumber", + header: "최근견적번호", + size: 130, + }, + { + accessorKey: "recentOrderNumber", + header: "최근발주번호", + size: 130, + }, +]
\ No newline at end of file diff --git a/lib/avl/table/vendor-pool-table.tsx b/lib/avl/table/vendor-pool-table.tsx index 1a0a5fca..7ad9eb56 100644 --- a/lib/avl/table/vendor-pool-table.tsx +++ b/lib/avl/table/vendor-pool-table.tsx @@ -1,7 +1,8 @@ "use client" import * as React from "react" -import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel, type ColumnDef } from "@tanstack/react-table" +import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel } from "@tanstack/react-table" +import { forwardRef, useImperativeHandle } from "react" import { DataTable } from "@/components/data-table/data-table" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" @@ -10,114 +11,29 @@ import { Search } from "lucide-react" import { getVendorPools } from "../../vendor-pool/service" import { GetVendorPoolSchema } from "../../vendor-pool/validations" import { VendorPool } from "../../vendor-pool/types" +import { vendorPoolColumns } from "./vendor-pool-table-columns" // Vendor Pool 데이터 타입 (실제 VendorPool 타입 사용) export type VendorPoolItem = VendorPool +// ref를 통해 외부에서 접근할 수 있는 메소드들 +export interface VendorPoolTableRef { + getSelectedIds: () => number[] +} + interface VendorPoolTableProps { onSelectionChange?: (count: number) => void resetCounter?: number + reloadTrigger?: number } -// 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({ +export const VendorPoolTable = forwardRef<VendorPoolTableRef, VendorPoolTableProps>(({ onSelectionChange, - resetCounter -}: VendorPoolTableProps) { + resetCounter, + reloadTrigger +}, ref) => { const [data, setData] = React.useState<VendorPoolItem[]>([]) const [loading, setLoading] = React.useState(false) const [pageCount, setPageCount] = React.useState(0) @@ -126,18 +42,78 @@ export function VendorPoolTable({ const [searchText, setSearchText] = React.useState("") const [showAll, setShowAll] = React.useState(false) + // 페이지네이션 상태 + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }) + // 데이터 로드 함수 - const loadData = React.useCallback(async (searchParams: Partial<GetVendorPoolSchema>) => { + const loadData = React.useCallback(async (searchParams: Partial<GetVendorPoolSchema> = {}) => { try { setLoading(true) - const params: GetVendorPoolSchema = { + + const params = { page: searchParams.page ?? 1, perPage: searchParams.perPage ?? 10, sort: searchParams.sort ?? [{ id: "registrationDate", desc: true }], + flags: [], search: searchText || "", + constructionSector: undefined, + shipType: undefined, + htDivision: undefined, + designCategoryCode: undefined, + designCategory: undefined, + equipBulkDivision: undefined, + packageCode: undefined, + packageName: undefined, + materialGroupCode: undefined, + materialGroupName: undefined, + vendorCode: undefined, + vendorName: undefined, + faTarget: undefined, + faStatus: undefined, + tier: undefined, + isAgent: undefined, + isBlacklist: undefined, + isBcc: undefined, + purchaseOpinion: undefined, + shipTypeCommon: undefined, + shipTypeAmax: undefined, + shipTypeSmax: undefined, + shipTypeVlcc: undefined, + shipTypeLngc: undefined, + shipTypeCont: undefined, + offshoreTypeCommon: undefined, + offshoreTypeFpso: undefined, + offshoreTypeFlng: undefined, + offshoreTypeFpu: undefined, + offshoreTypePlatform: undefined, + offshoreTypeWtiv: undefined, + offshoreTypeGom: undefined, + picName: undefined, + picEmail: undefined, + picPhone: undefined, + agentName: undefined, + agentEmail: undefined, + agentPhone: undefined, + recentQuoteDate: undefined, + recentQuoteNumber: undefined, + recentOrderDate: undefined, + recentOrderNumber: undefined, + registrationDate: undefined, + registrant: undefined, + lastModifiedDate: undefined, + lastModifier: undefined, ...searchParams, } - const result = await getVendorPools(params) + console.log('VendorPoolTable - API call params:', params) + const result = await getVendorPools(params as GetVendorPoolSchema) + console.log('VendorPoolTable - API result:', { + dataCount: result.data.length, + pageCount: result.pageCount, + requestedPage: params.page + }) setData(result.data) setPageCount(result.pageCount) } catch (error) { @@ -155,9 +131,11 @@ export function VendorPoolTable({ // 전체보기 모드에서는 페이징 없이 전체 데이터 로드 loadData({ perPage: 1000 }) // 충분히 큰 숫자로 전체 데이터 가져오기 } else { - loadData({}) + // 검색 시 페이지를 1페이지로 리셋 + setPagination(prev => ({ ...prev, pageIndex: 0 })) + loadData({ page: 1, perPage: pagination.pageSize }) } - }, [loadData, showAll]) + }, [loadData, showAll, pagination.pageSize]) // 전체보기 토글 핸들러 const handleShowAllToggle = React.useCallback((checked: boolean) => { @@ -174,8 +152,18 @@ export function VendorPoolTable({ // 초기 데이터 로드 React.useEffect(() => { - loadData({}) - }, [loadData]) + // 초기 로드 시 페이지를 1페이지로 설정 + setPagination(prev => ({ ...prev, pageIndex: 0 })) + loadData({ page: 1, perPage: pagination.pageSize }) + }, [pagination.pageSize]) + + // reloadTrigger가 변경될 때마다 데이터 리로드 + React.useEffect(() => { + if (reloadTrigger && reloadTrigger > 0) { + console.log('VendorPoolTable - reloadTrigger changed, reloading data') + loadData({}) + } + }, [reloadTrigger, loadData]) const table = useReactTable({ data, @@ -186,27 +174,48 @@ export function VendorPoolTable({ getFilteredRowModel: getFilteredRowModel(), manualPagination: !showAll, // 전체보기 시에는 수동 페이징 비활성화 pageCount: showAll ? 1 : pageCount, // 전체보기 시 1페이지, 일반 모드에서는 API에서 받은 pageCount 사용 - initialState: { - pagination: { - pageSize: showAll ? data.length : 10, // 전체보기 시 모든 데이터 표시 - }, + state: { + pagination: showAll ? { pageIndex: 0, pageSize: data.length } : pagination, }, onPaginationChange: (updater) => { if (!showAll) { // 전체보기가 아닐 때만 페이징 변경 처리 - const newState = typeof updater === 'function' ? updater(table.getState().pagination) : updater - loadData({ - page: newState.pageIndex + 1, - perPage: newState.pageSize, + const newPaginationState = typeof updater === 'function' ? updater(pagination) : updater + + console.log('VendorPoolTable - Pagination changed:', { + currentState: pagination, + newPaginationState, + showAll, + willLoadData: !showAll }) + + setPagination(newPaginationState) + + const apiParams = { + page: newPaginationState.pageIndex + 1, + perPage: newPaginationState.pageSize, + } + console.log('VendorPoolTable - Loading data with params:', apiParams) + loadData(apiParams) } }, }) + // 외부에서 선택된 ID들을 가져올 수 있도록 ref에 메소드 노출 + useImperativeHandle(ref, () => ({ + getSelectedIds: () => { + const selectedRows = table.getFilteredSelectedRowModel().rows + return selectedRows.map(row => row.original.id) + } + })) + + // 선택된 행 개수 + const selectedRowCount = table.getFilteredSelectedRowModel().rows.length + // 선택 상태 변경 시 콜백 호출 React.useEffect(() => { - onSelectionChange?.(table.getFilteredSelectedRowModel().rows.length) - }, [table.getFilteredSelectedRowModel().rows.length, onSelectionChange]) + onSelectionChange?.(selectedRowCount) + }, [selectedRowCount, onSelectionChange]) // 선택 해제 요청이 오면 모든 선택 해제 React.useEffect(() => { @@ -287,4 +296,6 @@ export function VendorPoolTable({ </div> </div> ) -} +}) + +VendorPoolTable.displayName = "VendorPoolTable" |
