summaryrefslogtreecommitdiff
path: root/lib/avl/table/avl-detail-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/avl/table/avl-detail-table.tsx')
-rw-r--r--lib/avl/table/avl-detail-table.tsx479
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>
+ )
+}