diff options
Diffstat (limited to 'lib/avl/table/standard-avl-table.tsx')
| -rw-r--r-- | lib/avl/table/standard-avl-table.tsx | 651 |
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" |
