summaryrefslogtreecommitdiff
path: root/lib/avl/table/avl-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/avl/table/avl-table.tsx')
-rw-r--r--lib/avl/table/avl-table.tsx514
1 files changed, 514 insertions, 0 deletions
diff --git a/lib/avl/table/avl-table.tsx b/lib/avl/table/avl-table.tsx
new file mode 100644
index 00000000..a6910ef5
--- /dev/null
+++ b/lib/avl/table/avl-table.tsx
@@ -0,0 +1,514 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { toast } from "sonner"
+
+import { getColumns } from "./avl-table-columns"
+import { createAvlListAction, updateAvlListAction, deleteAvlListAction, handleAvlActionAction } from "../service"
+import type { AvlListItem } from "../types"
+
+// 테이블 메타 타입 확장
+declare module "@tanstack/react-table" {
+ interface TableMeta<TData> {
+ onCellUpdate?: (id: string, field: keyof TData, newValue: any) => Promise<void>
+ onCellCancel?: (id: string, field: keyof TData) => void
+ onAction?: (action: string, data?: any) => void
+ onSaveEmptyRow?: (tempId: string) => Promise<void>
+ onCancelEmptyRow?: (tempId: string) => void
+ isEmptyRow?: (id: string) => boolean
+ getPendingChanges?: () => Record<string, Partial<AvlListItem>>
+ }
+}
+
+interface AvlTableProps {
+ data: AvlListItem[]
+ pageCount?: number
+ onRefresh?: () => void // 데이터 새로고침 콜백
+ isLoading?: boolean // 로딩 상태
+ onRegistrationModeChange?: (mode: 'standard' | 'project') => void // 등록 모드 변경 콜백
+ onRowSelect?: (selectedRow: AvlListItem | null) => void // 행 선택 콜백
+}
+
+export function AvlTable({ data, pageCount, onRefresh, isLoading, onRegistrationModeChange, onRowSelect }: AvlTableProps) {
+
+ // 단일 선택을 위한 상태 (shi-vendor-po 방식)
+ const [selectedRows, setSelectedRows] = React.useState<number[]>([])
+
+ // 수정사항 추적 (일괄 저장용)
+ const [pendingChanges, setPendingChanges] = React.useState<Record<string, Partial<AvlListItem>>>({})
+ const [isSaving, setIsSaving] = React.useState(false)
+
+ // 빈 행 관리 (신규 등록용)
+ const [emptyRows, setEmptyRows] = React.useState<Record<string, AvlListItem>>({})
+ const [isCreating, setIsCreating] = React.useState(false)
+
+ // 필터 필드 정의
+ const filterFields: DataTableFilterField<AvlListItem>[] = [
+ {
+ id: "isTemplate",
+ label: "AVL 분류",
+ placeholder: "AVL 분류 선택...",
+ options: [
+ { label: "프로젝트 AVL", value: "false" },
+ { label: "표준 AVL", value: "true" },
+ ],
+ },
+ {
+ id: "constructionSector",
+ label: "공사부문",
+ placeholder: "공사부문 선택...",
+ options: [
+ { label: "조선", value: "조선" },
+ { label: "해양", value: "해양" },
+ ],
+ },
+ {
+ id: "htDivision",
+ label: "H/T 구분",
+ placeholder: "H/T 구분 선택...",
+ options: [
+ { label: "H", value: "H" },
+ { label: "T", value: "T" },
+ ],
+ },
+ ]
+
+ // 고급 필터 필드 정의
+ const advancedFilterFields: DataTableAdvancedFilterField<AvlListItem>[] = [
+ {
+ id: "projectCode",
+ label: "프로젝트 코드",
+ type: "text",
+ placeholder: "프로젝트 코드 입력...",
+ },
+ {
+ id: "shipType",
+ label: "선종",
+ type: "text",
+ placeholder: "선종 입력...",
+ },
+ {
+ id: "avlKind",
+ label: "AVL 종류",
+ type: "text",
+ placeholder: "AVL 종류 입력...",
+ },
+ {
+ id: "createdBy",
+ label: "등재자",
+ type: "text",
+ placeholder: "등재자 입력...",
+ },
+ ]
+
+ // 인라인 편집 핸들러 (일괄 저장용)
+ const handleCellUpdate = React.useCallback(async (id: string, field: keyof AvlListItem, newValue: any) => {
+ const isEmptyRow = String(id).startsWith('temp-')
+
+ if (isEmptyRow) {
+ // 빈 행의 경우 emptyRows 상태도 업데이트
+ setEmptyRows(prev => ({
+ ...prev,
+ [id]: {
+ ...prev[id],
+ [field]: newValue
+ }
+ }))
+ }
+
+ // pendingChanges에 변경사항 저장 (실시간 표시용)
+ setPendingChanges(prev => ({
+ ...prev,
+ [id]: {
+ ...prev[id],
+ [field]: newValue
+ }
+ }))
+ }, [])
+
+ // 편집 취소 핸들러
+ const handleCellCancel = React.useCallback((id: string, field: keyof AvlListItem) => {
+ const isEmptyRow = String(id).startsWith('temp-')
+
+ if (isEmptyRow) {
+ // 빈 행의 경우 emptyRows와 pendingChanges 모두 취소
+ setEmptyRows(prev => ({
+ ...prev,
+ [id]: {
+ ...prev[id],
+ [field]: prev[id][field] // 원래 값으로 복원 (pendingChanges의 초기값 사용)
+ }
+ }))
+
+ setPendingChanges(prev => {
+ const itemChanges = { ...prev[id] }
+ delete itemChanges[field]
+
+ if (Object.keys(itemChanges).length === 0) {
+ const newChanges = { ...prev }
+ delete newChanges[id]
+ return newChanges
+ }
+
+ return {
+ ...prev,
+ [id]: itemChanges
+ }
+ })
+ }
+ }, [])
+
+ // 액션 핸들러
+ const handleAction = React.useCallback(async (action: string, data?: any) => {
+ try {
+ switch (action) {
+ case 'new-registration':
+ // 신규 등록 - 빈 행 추가
+ const tempId = `temp-${Date.now()}`
+ const newEmptyRow: AvlListItem = {
+ id: tempId as any,
+ no: 0,
+ selected: false,
+ isTemplate: false,
+ constructionSector: "",
+ projectCode: "",
+ shipType: "",
+ avlKind: "",
+ htDivision: "",
+ rev: 1,
+ createdAt: new Date().toISOString().split('T')[0],
+ updatedAt: new Date().toISOString().split('T')[0],
+ createdBy: "system",
+ updatedBy: "system",
+ registrant: "system",
+ lastModifier: "system",
+ }
+
+ setEmptyRows(prev => ({
+ ...prev,
+ [tempId]: newEmptyRow
+ }))
+ toast.success("신규 등록 행이 추가되었습니다.")
+ break
+
+ case 'standard-registration':
+ // 표준 AVL 등록
+ const result = await handleAvlActionAction('standard-registration')
+ if (result.success) {
+ toast.success(result.message)
+ onRegistrationModeChange?.('standard') // 등록 모드 변경 콜백 호출
+ } else {
+ toast.error(result.message)
+ }
+ break
+
+ case 'project-registration':
+ // 프로젝트 AVL 등록
+ const projectResult = await handleAvlActionAction('project-registration')
+ if (projectResult.success) {
+ toast.success(projectResult.message)
+ onRegistrationModeChange?.('project') // 등록 모드 변경 콜백 호출
+ } else {
+ toast.error(projectResult.message)
+ }
+ break
+
+ case 'bulk-import':
+ // 일괄 입력
+ const bulkResult = await handleAvlActionAction('bulk-import')
+ if (bulkResult.success) {
+ toast.success(bulkResult.message)
+ } else {
+ toast.error(bulkResult.message)
+ }
+ break
+
+ case 'save':
+ // 변경사항 저장
+ if (Object.keys(pendingChanges).length === 0) {
+ toast.info("저장할 변경사항이 없습니다.")
+ return
+ }
+
+ setIsSaving(true)
+ try {
+ // 각 변경사항을 순차적으로 저장
+ for (const [id, changes] of Object.entries(pendingChanges)) {
+ if (String(id).startsWith('temp-')) continue // 빈 행은 제외
+
+ const result = await updateAvlListAction(Number(id), changes as any)
+ if (!result) {
+ throw new Error(`항목 ${id} 저장 실패`)
+ }
+ }
+
+ setPendingChanges({})
+ toast.success("변경사항이 저장되었습니다.")
+ onRefresh?.()
+ } catch (error) {
+ console.error('저장 실패:', error)
+ toast.error("저장 중 오류가 발생했습니다.")
+ } finally {
+ setIsSaving(false)
+ }
+ break
+
+ case 'edit':
+ // 수정 모달 열기 (현재는 간단한 토스트로 처리)
+ toast.info(`${data?.id} 항목 수정`)
+ break
+
+ case 'delete':
+ // 삭제 확인 및 실행
+ if (!data?.id || String(data.id).startsWith('temp-')) return
+
+ const confirmed = window.confirm(`항목 ${data.id}을(를) 삭제하시겠습니까?`)
+ if (!confirmed) return
+
+ try {
+ const result = await deleteAvlListAction(Number(data.id))
+ if (result) {
+ toast.success("항목이 삭제되었습니다.")
+ onRefresh?.()
+ } else {
+ toast.error("삭제에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error('삭제 실패:', error)
+ toast.error("삭제 중 오류가 발생했습니다.")
+ }
+ break
+
+ case 'view-detail':
+ // 상세 조회 (페이지 이동)
+ if (data?.id && !String(data.id).startsWith('temp-')) {
+ window.location.href = `/evcp/avl/${data.id}`
+ }
+ break
+
+ default:
+ toast.error(`알 수 없는 액션: ${action}`)
+ }
+ } catch (error) {
+ console.error('액션 처리 실패:', error)
+ toast.error("액션 처리 중 오류가 발생했습니다.")
+ }
+ }, [pendingChanges, onRefresh])
+
+ // 빈 행 저장 핸들러
+ const handleSaveEmptyRow = React.useCallback(async (tempId: string) => {
+ const emptyRow = emptyRows[tempId]
+ if (!emptyRow) return
+
+ try {
+ setIsCreating(true)
+
+ // 필수 필드 검증
+ if (!emptyRow.constructionSector || !emptyRow.avlKind) {
+ toast.error("공사부문과 AVL 종류는 필수 입력 항목입니다.")
+ return
+ }
+
+ // 빈 행 데이터를 생성 데이터로 변환
+ const createData = {
+ isTemplate: emptyRow.isTemplate,
+ constructionSector: emptyRow.constructionSector,
+ projectCode: emptyRow.projectCode || undefined,
+ shipType: emptyRow.shipType || undefined,
+ avlKind: emptyRow.avlKind,
+ htDivision: emptyRow.htDivision || undefined,
+ rev: emptyRow.rev,
+ createdBy: "system",
+ updatedBy: "system",
+ }
+
+ const result = await createAvlListAction(createData as any)
+ if (result) {
+ // 빈 행 제거 및 성공 메시지
+ setEmptyRows(prev => {
+ const newRows = { ...prev }
+ delete newRows[tempId]
+ return newRows
+ })
+
+ // pendingChanges에서도 제거
+ setPendingChanges(prev => {
+ const newChanges = { ...prev }
+ delete newChanges[tempId]
+ return newChanges
+ })
+
+ toast.success("새 항목이 등록되었습니다.")
+ onRefresh?.()
+ } else {
+ toast.error("등록에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error('빈 행 저장 실패:', error)
+ toast.error("등록 중 오류가 발생했습니다.")
+ } finally {
+ setIsCreating(false)
+ }
+ }, [emptyRows, onRefresh])
+
+ // 빈 행 취소 핸들러
+ const handleCancelEmptyRow = React.useCallback((tempId: string) => {
+ setEmptyRows(prev => {
+ const newRows = { ...prev }
+ delete newRows[tempId]
+ return newRows
+ })
+
+ setPendingChanges(prev => {
+ const newChanges = { ...prev }
+ delete newChanges[tempId]
+ return newChanges
+ })
+
+ toast.info("등록이 취소되었습니다.")
+ }, [])
+
+ // 빈 행 포함한 전체 데이터
+ const allData = React.useMemo(() => {
+ // 로딩 중에는 빈 데이터를 표시
+ if (isLoading) {
+ return []
+ }
+ const emptyRowArray = Object.values(emptyRows)
+ return [...data, ...emptyRowArray]
+ }, [data, emptyRows, isLoading])
+
+ // 행 선택 처리 (1개만 선택 가능 - shi-vendor-po 방식)
+ const handleRowSelect = React.useCallback((id: number, selected: boolean) => {
+ if (selected) {
+ setSelectedRows([id]) // 1개만 선택
+ // 선택된 레코드 찾아서 부모 콜백 호출
+ const allData = isLoading ? [] : [...data, ...Object.values(emptyRows)]
+ const selectedRow = allData.find(row => row.id === id)
+ if (selectedRow) {
+ onRowSelect?.(selectedRow)
+ }
+ } else {
+ setSelectedRows([])
+ onRowSelect?.(null)
+ }
+ }, [data, emptyRows, isLoading, onRowSelect])
+
+ // 테이블 메타 설정
+ const tableMeta = React.useMemo(() => ({
+ onCellUpdate: handleCellUpdate,
+ onCellCancel: handleCellCancel,
+ onAction: handleAction,
+ onSaveEmptyRow: handleSaveEmptyRow,
+ onCancelEmptyRow: handleCancelEmptyRow,
+ isEmptyRow: (id: string) => String(id).startsWith('temp-'),
+ getPendingChanges: () => pendingChanges,
+ }), [handleCellUpdate, handleCellCancel, handleAction, handleSaveEmptyRow, handleCancelEmptyRow, pendingChanges])
+
+
+ // 데이터 테이블 설정
+ const { table } = useDataTable({
+ data: allData,
+ columns: getColumns({ selectedRows, onRowSelect: handleRowSelect }),
+ pageCount: pageCount || 1,
+ filterFields,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (row) => String(row.id),
+ meta: tableMeta,
+ })
+
+ // 변경사항이 있는지 확인
+ const hasPendingChanges = Object.keys(pendingChanges).length > 0
+ const hasEmptyRows = Object.keys(emptyRows).length > 0
+
+ return (
+ <div className="space-y-4">
+ {/* 툴바 */}
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={filterFields as any}
+ >
+ <div className="flex items-center gap-2">
+ {/* 액션 버튼들 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleAction('new-registration')}
+ disabled={isCreating}
+ >
+ 신규등록
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleAction('standard-registration')}
+ >
+ 표준AVL등록
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleAction('project-registration')}
+ >
+ 프로젝트AVL등록
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleAction('bulk-import')}
+ >
+ 파일 업로드
+ </Button>
+
+ {/* 저장 버튼 - 변경사항이 있을 때만 활성화 */}
+ {(hasPendingChanges || hasEmptyRows) && (
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => handleAction('save')}
+ disabled={isSaving}
+ >
+ {isSaving ? "저장 중..." : "저장"}
+ </Button>
+ )}
+
+ {/* 새로고침 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onRefresh}
+ >
+ 새로고침
+ </Button>
+ </div>
+ </DataTableAdvancedToolbar>
+
+ {/* 데이터 테이블 */}
+ <DataTable table={table} />
+
+ {/* 디버그 정보 (개발 환경에서만 표시) */}
+ {process.env.NODE_ENV === 'development' && (hasPendingChanges || hasEmptyRows) && (
+ <div className="text-xs text-muted-foreground p-2 bg-muted rounded">
+ <div>Pending Changes: {Object.keys(pendingChanges).length}</div>
+ <div>Empty Rows: {Object.keys(emptyRows).length}</div>
+ </div>
+ )}
+ </div>
+ )
+}