summaryrefslogtreecommitdiff
path: root/lib/avl/table/standard-avl-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/avl/table/standard-avl-table.tsx')
-rw-r--r--lib/avl/table/standard-avl-table.tsx651
1 files changed, 651 insertions, 0 deletions
diff --git a/lib/avl/table/standard-avl-table.tsx b/lib/avl/table/standard-avl-table.tsx
new file mode 100644
index 00000000..cc39540b
--- /dev/null
+++ b/lib/avl/table/standard-avl-table.tsx
@@ -0,0 +1,651 @@
+"use client"
+
+import * as React from "react"
+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 { getStandardAvlVendorInfo } from "../service"
+import { GetStandardAvlSchema } from "../validations"
+import { AvlDetailItem } from "../types"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Search } from "lucide-react"
+import { toast } from "sonner"
+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: "조선", 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: "Nearshore", label: "Nearshore" },
+ { value: "Offshore", label: "Offshore" },
+ { value: "IOC", label: "IOC" },
+ { value: "NOC", label: "NOC" },
+]
+
+const htDivisionOptions = [
+ { value: "공통", label: "공통" },
+ { value: "H", label: "Hull (H)" },
+ { 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
+ constructionSector?: string // 공사부문 필터
+ shipType?: string // 선종 필터
+ avlKind?: string // AVL 종류 필터
+ htDivision?: string // H/T 구분 필터
+ onSearchConditionsChange?: (conditions: {
+ constructionSector: string
+ shipType: string
+ avlKind: string
+ htDivision: string
+ }) => void
+ reloadTrigger?: number
+}
+
+export const StandardAvlTable = forwardRef<StandardAvlTableRef, StandardAvlTableProps>(({
+ onSelectionChange,
+ resetCounter,
+ constructionSector: initialConstructionSector,
+ shipType: initialShipType,
+ avlKind: initialAvlKind,
+ 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 || "")
+ 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> = {}) => {
+ try {
+ setLoading(true)
+
+ const params: GetStandardAvlSchema = {
+ page: searchParams.page ?? 1,
+ perPage: searchParams.perPage ?? 10,
+ sort: searchParams.sort ?? [{ id: "no", desc: false }],
+ 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) {
+ console.error("선종별 표준 AVL 데이터 로드 실패:", error)
+ setData([])
+ setPageCount(0)
+ } finally {
+ setLoading(false)
+ }
+ }, [searchConstructionSector, searchShipType, searchAvlKind, searchHtDivision])
+
+ // reloadTrigger가 변경될 때마다 데이터 리로드
+ React.useEffect(() => {
+ if (reloadTrigger && reloadTrigger > 0) {
+ console.log('StandardAvlTable - reloadTrigger changed, reloading data')
+ loadData({})
+ }
+ }, [reloadTrigger, loadData])
+
+ // 검색 초기화 핸들러
+ const handleResetSearch = React.useCallback(() => {
+ 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])
+
+ // 항목 수정 핸들러
+ 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 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(() => {
+ 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(() => {
+ if (resetCounter && resetCounter > 0) {
+ table.toggleAllPageRowsSelected(false)
+ }
+ }, [resetCounter, table])
+
+ return (
+ <div className="h-full flex flex-col">
+ <div className="mb-2">
+ <div className="flex items-center justify-between mb-2">
+ <h4 className="font-medium">선종별 표준 AVL</h4>
+ <div className="flex gap-1">
+ <Button variant="outline" size="sm" onClick={() => setIsAddDialogOpen(true)}>
+ 신규업체 추가
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleEditItem}
+ disabled={table.getFilteredSelectedRowModel().rows.length !== 1}
+ >
+ 항목 수정
+ </Button>
+ {/* <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 파일 업로드
+ </Button>
+ <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 일괄입력
+ </Button> */}
+ <Button variant="outline" size="sm" onClick={handleDeleteItems}>
+ 항목 삭제
+ </Button>
+ {/* <Button variant="outline" size="sm" onClick={() => toast.info("개발 중입니다.")}>
+ 저장
+ </Button> */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleFinalizeStandardAvl}
+ disabled={!isAllSearchConditionsSelected || data.length === 0}
+ >
+ 최종 확정
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ {/* 검색 UI */}
+ <div className="mb-4 p-4 border rounded-lg bg-muted/50">
+ <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
+ {/* 공사부문 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">공사부문</label>
+ <Select value={searchConstructionSector} onValueChange={handleConstructionSectorChange}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {constructionSectorOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 선종 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">선종</label>
+ <Select value={searchShipType} onValueChange={setSearchShipType}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {currentShipTypeOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* AVL종류 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">AVL종류</label>
+ <Select value={searchAvlKind} onValueChange={setSearchAvlKind}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {avlKindOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* H/T */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">H/T 구분</label>
+ <Select value={searchHtDivision} onValueChange={setSearchHtDivision}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {htDivisionOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 검색 버튼들 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium opacity-0">버튼</label>
+ <div className="flex gap-1">
+ <Button
+ onClick={handleSearch}
+ disabled={loading || !isAllSearchConditionsSelected}
+ size="sm"
+ className="px-3"
+ >
+ <Search className="w-4 h-4 mr-1" />
+ 조회
+ </Button>
+ <Button
+ onClick={handleResetSearch}
+ variant="outline"
+ size="sm"
+ className="px-3"
+ >
+ 초기화
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div className="flex-1">
+ <DataTable table={table} />
+ </div>
+
+ {/* 신규업체 추가 다이얼로그 */}
+ <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"