diff options
Diffstat (limited to 'lib/avl/table/avl-detail-table.tsx')
| -rw-r--r-- | lib/avl/table/avl-detail-table.tsx | 479 |
1 files changed, 479 insertions, 0 deletions
diff --git a/lib/avl/table/avl-detail-table.tsx b/lib/avl/table/avl-detail-table.tsx new file mode 100644 index 00000000..ba15c6ef --- /dev/null +++ b/lib/avl/table/avl-detail-table.tsx @@ -0,0 +1,479 @@ +"use client" + +import * as React from "react" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { toast } from "sonner" + +import { columns, type AvlDetailItem } from "./columns-detail" +import { createAvlVendorInfo, updateAvlVendorInfo, deleteAvlVendorInfo, handleAvlAction } from "../service" +import type { AvlDetailItem as AvlDetailType } from "../types" + +// 테이블 메타 타입 확장 +declare module "@tanstack/react-table" { + interface TableMeta<TData> { + onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise<void> + onCellCancel?: (id: string, field: keyof TData) => void + onAction?: (action: string, data?: any) => void + onSaveEmptyRow?: (tempId: string) => Promise<void> + onCancelEmptyRow?: (tempId: string) => void + isEmptyRow?: (id: string) => boolean + getPendingChanges?: () => Record<string, Partial<AvlDetailItem>> + } +} + +interface AvlDetailTableProps { + data: AvlDetailItem[] + pageCount?: number + avlListId: number // 상위 AVL 리스트 ID + onRefresh?: () => void // 데이터 새로고침 콜백 + avlType?: '프로젝트AVL' | '선종별표준AVL' // AVL 타입 + projectCode?: string // 프로젝트 코드 + shipOwnerName?: string // 선주명 + businessType?: string // 사업 유형 (예: 조선/해양) +} + +export function AvlDetailTable({ + data, + pageCount, + avlListId, + onRefresh, + avlType = '프로젝트AVL', + projectCode, + shipOwnerName, + businessType = '조선' +}: AvlDetailTableProps) { + // 수정사항 추적 (일괄 저장용) + const [pendingChanges, setPendingChanges] = React.useState<Record<string, Partial<AvlDetailItem>>>({}) + const [isSaving, setIsSaving] = React.useState(false) + + // 빈 행 관리 (신규 등록용) + const [emptyRows, setEmptyRows] = React.useState<Record<string, AvlDetailItem>>({}) + const [isCreating, setIsCreating] = React.useState(false) + + // 검색 상태 + const [searchValue, setSearchValue] = React.useState("") + + + // 인라인 편집 핸들러 (일괄 저장용) + const handleCellUpdate = React.useCallback(async (id: string, field: keyof AvlDetailItem, newValue: any) => { + const isEmptyRow = String(id).startsWith('temp-') + + if (isEmptyRow) { + // 빈 행의 경우 emptyRows 상태도 업데이트 + setEmptyRows(prev => ({ + ...prev, + [id]: { + ...prev[id], + [field]: newValue + } + })) + } + + // pendingChanges에 변경사항 저장 (실시간 표시용) + setPendingChanges(prev => ({ + ...prev, + [id]: { + ...prev[id], + [field]: newValue + } + })) + }, []) + + // 편집 취소 핸들러 + const handleCellCancel = React.useCallback((id: string, field: keyof AvlDetailItem) => { + const isEmptyRow = String(id).startsWith('temp-') + + if (isEmptyRow) { + // 빈 행의 경우 emptyRows와 pendingChanges 모두 취소 + setEmptyRows(prev => ({ + ...prev, + [id]: { + ...prev[id], + [field]: prev[id][field] // 원래 값으로 복원 (pendingChanges의 초기값 사용) + } + })) + + setPendingChanges(prev => { + const itemChanges = { ...prev[id] } + delete itemChanges[field] + + if (Object.keys(itemChanges).length === 0) { + const newChanges = { ...prev } + delete newChanges[id] + return newChanges + } + + return { + ...prev, + [id]: itemChanges + } + }) + } + }, []) + + // 액션 핸들러 + const handleAction = React.useCallback(async (action: string, data?: any) => { + try { + switch (action) { + case 'new-vendor': + // 신규 협력업체 추가 - 빈 행 추가 + const tempId = `temp-${Date.now()}` + const newEmptyRow: AvlDetailItem = { + id: tempId, + no: 0, + selected: false, + avlListId: avlListId, + equipBulkDivision: "EQUIP", + disciplineCode: "", + disciplineName: "", + materialNameCustomerSide: "", + packageCode: "", + packageName: "", + materialGroupCode: "", + materialGroupName: "", + vendorId: undefined, + vendorName: "", + vendorCode: "", + avlVendorName: "", + tier: "", + faTarget: false, + faStatus: "", + isAgent: false, + agentStatus: "아니오", + contractSignerId: undefined, + contractSignerName: "", + contractSignerCode: "", + headquarterLocation: "", + manufacturingLocation: "", + shiAvl: false, + shiBlacklist: false, + shiBcc: false, + salesQuoteNumber: "", + quoteCode: "", + salesVendorInfo: "", + salesCountry: "", + totalAmount: "", + quoteReceivedDate: "", + recentQuoteDate: "", + recentQuoteNumber: "", + recentOrderDate: "", + recentOrderNumber: "", + remarks: "", + createdAt: new Date().toISOString().split('T')[0], + updatedAt: new Date().toISOString().split('T')[0], + } + + setEmptyRows(prev => ({ + ...prev, + [tempId]: newEmptyRow + })) + toast.success("신규 협력업체 행이 추가되었습니다.") + break + + case 'bulk-import': + // 일괄 입력 + const bulkResult = await handleAvlAction('bulk-import') + if (bulkResult.success) { + toast.success(bulkResult.message) + } else { + toast.error(bulkResult.message) + } + break + + case 'save': + // 변경사항 저장 + if (Object.keys(pendingChanges).length === 0) { + toast.info("저장할 변경사항이 없습니다.") + return + } + + setIsSaving(true) + try { + // 각 변경사항을 순차적으로 저장 + for (const [id, changes] of Object.entries(pendingChanges)) { + if (String(id).startsWith('temp-')) continue // 빈 행은 제외 + + const numericId = Number(id) + if (isNaN(numericId)) { + throw new Error(`유효하지 않은 ID: ${id}`) + } + + const result = await updateAvlVendorInfo(numericId, changes) + if (!result) { + throw new Error(`항목 ${id} 저장 실패`) + } + } + + setPendingChanges({}) + toast.success("변경사항이 저장되었습니다.") + onRefresh?.() + } catch (error) { + console.error('저장 실패:', error) + toast.error("저장 중 오류가 발생했습니다.") + } finally { + setIsSaving(false) + } + break + + case 'edit': + // 수정 모달 열기 (현재는 간단한 토스트로 처리) + toast.info(`${data?.id} 항목 수정`) + break + + case 'delete': + // 삭제 확인 및 실행 + if (!data?.id || String(data.id).startsWith('temp-')) return + + const numericId = Number(data.id) + if (isNaN(numericId)) { + toast.error("유효하지 않은 항목 ID입니다.") + return + } + + const confirmed = window.confirm(`항목 ${data.id}을(를) 삭제하시겠습니까?`) + if (!confirmed) return + + try { + const result = await deleteAvlVendorInfo(numericId) + if (result) { + toast.success("항목이 삭제되었습니다.") + onRefresh?.() + } else { + toast.error("삭제에 실패했습니다.") + } + } catch (error) { + console.error('삭제 실패:', error) + toast.error("삭제 중 오류가 발생했습니다.") + } + break + + case 'avl-form': + // AVL 양식 다운로드/보기 + toast.info("AVL 양식을 준비 중입니다.") + // TODO: AVL 양식 다운로드 로직 구현 + break + + case 'quote-request': + // 견적 요청 + toast.info("견적 요청을 처리 중입니다.") + // TODO: 견적 요청 로직 구현 + break + + case 'vendor-pool': + // Vendor Pool 관리 + toast.info("Vendor Pool을 열고 있습니다.") + // TODO: Vendor Pool 페이지 이동 또는 모달 열기 로직 구현 + break + + case 'download': + // 데이터 다운로드 + toast.info("데이터를 다운로드 중입니다.") + // TODO: 데이터 다운로드 로직 구현 + break + + default: + toast.error(`알 수 없는 액션: ${action}`) + } + } catch (error) { + console.error('액션 처리 실패:', error) + toast.error("액션 처리 중 오류가 발생했습니다.") + } + }, [pendingChanges, onRefresh, avlListId]) + + // 빈 행 저장 핸들러 + const handleSaveEmptyRow = React.useCallback(async (tempId: string) => { + const emptyRow = emptyRows[tempId] + if (!emptyRow) return + + try { + setIsCreating(true) + + // 필수 필드 검증 + if (!emptyRow.disciplineName || !emptyRow.vendorName) { + toast.error("설계공종과 협력업체명은 필수 입력 항목입니다.") + return + } + + // 빈 행 데이터를 생성 데이터로 변환 + const createData = { + avlListId: emptyRow.avlListId, + equipBulkDivision: emptyRow.equipBulkDivision, + disciplineCode: emptyRow.disciplineCode || undefined, + disciplineName: emptyRow.disciplineName, + materialNameCustomerSide: emptyRow.materialNameCustomerSide || undefined, + packageCode: emptyRow.packageCode || undefined, + packageName: emptyRow.packageName || undefined, + materialGroupCode: emptyRow.materialGroupCode || undefined, + materialGroupName: emptyRow.materialGroupName || undefined, + vendorId: emptyRow.vendorId, + vendorName: emptyRow.vendorName, + vendorCode: emptyRow.vendorCode || undefined, + avlVendorName: emptyRow.avlVendorName || undefined, + tier: emptyRow.tier || undefined, + faTarget: emptyRow.faTarget ?? false, + faStatus: emptyRow.faStatus || undefined, + isAgent: emptyRow.isAgent ?? false, + contractSignerId: emptyRow.contractSignerId, + contractSignerName: emptyRow.contractSignerName || undefined, + contractSignerCode: emptyRow.contractSignerCode || undefined, + headquarterLocation: emptyRow.headquarterLocation || undefined, + manufacturingLocation: emptyRow.manufacturingLocation || undefined, + hasAvl: emptyRow.shiAvl ?? false, + isBlacklist: emptyRow.shiBlacklist ?? false, + isBcc: emptyRow.shiBcc ?? false, + techQuoteNumber: emptyRow.salesQuoteNumber || undefined, + quoteCode: emptyRow.quoteCode || undefined, + quoteVendorId: emptyRow.vendorId, + quoteVendorName: emptyRow.salesVendorInfo || undefined, + quoteVendorCode: emptyRow.vendorCode, + quoteCountry: emptyRow.salesCountry || undefined, + quoteTotalAmount: emptyRow.totalAmount ? parseFloat(emptyRow.totalAmount.replace(/,/g, '')) : undefined, + quoteReceivedDate: emptyRow.quoteReceivedDate || undefined, + recentQuoteDate: emptyRow.recentQuoteDate || undefined, + recentQuoteNumber: emptyRow.recentQuoteNumber || undefined, + recentOrderDate: emptyRow.recentOrderDate || undefined, + recentOrderNumber: emptyRow.recentOrderNumber || undefined, + remark: emptyRow.remarks || undefined, + } + + const result = await createAvlVendorInfo(createData) + if (result) { + // 빈 행 제거 및 성공 메시지 + setEmptyRows(prev => { + const newRows = { ...prev } + delete newRows[tempId] + return newRows + }) + + // pendingChanges에서도 제거 + setPendingChanges(prev => { + const newChanges = { ...prev } + delete newChanges[tempId] + return newChanges + }) + + toast.success("새 협력업체가 등록되었습니다.") + onRefresh?.() + } else { + toast.error("등록에 실패했습니다.") + } + } catch (error) { + console.error('빈 행 저장 실패:', error) + toast.error("등록 중 오류가 발생했습니다.") + } finally { + setIsCreating(false) + } + }, [emptyRows, onRefresh]) + + // 빈 행 취소 핸들러 + const handleCancelEmptyRow = React.useCallback((tempId: string) => { + setEmptyRows(prev => { + const newRows = { ...prev } + delete newRows[tempId] + return newRows + }) + + setPendingChanges(prev => { + const newChanges = { ...prev } + delete newChanges[tempId] + return newChanges + }) + + toast.info("등록이 취소되었습니다.") + }, []) + + // 빈 행 포함한 전체 데이터 + const allData = React.useMemo(() => { + const emptyRowArray = Object.values(emptyRows) + return [...data, ...emptyRowArray] + }, [data, emptyRows]) + + // 테이블 메타 설정 + const tableMeta = React.useMemo(() => ({ + onCellUpdate: handleCellUpdate, + onCellCancel: handleCellCancel, + onAction: handleAction, + onSaveEmptyRow: handleSaveEmptyRow, + onCancelEmptyRow: handleCancelEmptyRow, + isEmptyRow: (id: string) => String(id).startsWith('temp-'), + getPendingChanges: () => pendingChanges, + }), [handleCellUpdate, handleCellCancel, handleAction, handleSaveEmptyRow, handleCancelEmptyRow, pendingChanges]) + + // 데이터 테이블 설정 + const { table } = useDataTable({ + data: allData, + columns, + pageCount, + initialState: { + sorting: [{ id: "no", desc: false }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (row) => String(row.id), + meta: tableMeta, + }) + + // 변경사항이 있는지 확인 + const hasPendingChanges = Object.keys(pendingChanges).length > 0 + const hasEmptyRows = Object.keys(emptyRows).length > 0 + + return ( + <div className="space-y-4"> + {/* 상단 정보 표시 영역 */} + <div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg"> + <div className="flex items-center gap-4"> + <h2 className="text-lg font-semibold">AVL 상세내역</h2> + <span className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium"> + {avlType} + </span> + <span className="text-sm text-muted-foreground"> + [{businessType}] {projectCode || '프로젝트코드'} ({shipOwnerName || '선주명'}) + </span> + </div> + </div> + + {/* 상단 버튼 및 검색 영역 */} + <div className="flex items-center justify-between gap-4"> + <div className="flex items-center gap-2"> + <Button variant="outline" size="sm" onClick={() => handleAction('avl-form')}> + AVL양식 + </Button> + <Button variant="outline" size="sm" onClick={() => handleAction('quote-request')}> + 견적요청 + </Button> + <Button variant="outline" size="sm" onClick={() => handleAction('vendor-pool')}> + Vendor Pool + </Button> + <Button variant="outline" size="sm" onClick={() => handleAction('download')}> + 다운로드 + </Button> + </div> + + <div className="flex items-center gap-2"> + <div className="relative"> + <Input + placeholder="검색..." + className="w-64" + value={searchValue} + onChange={(e) => setSearchValue(e.target.value)} + /> + </div> + </div> + </div> + + {/* 데이터 테이블 */} + <DataTable table={table} /> + + {/* 디버그 정보 (개발 환경에서만 표시) */} + {process.env.NODE_ENV === 'development' && (hasPendingChanges || hasEmptyRows) && ( + <div className="text-xs text-muted-foreground p-2 bg-muted rounded"> + <div>Pending Changes: {Object.keys(pendingChanges).length}</div> + <div>Empty Rows: {Object.keys(emptyRows).length}</div> + </div> + )} + </div> + ) +} |
