diff options
| author | joonhoekim <26rote@gmail.com> | 2025-09-15 18:59:41 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-09-15 18:59:41 +0900 |
| commit | d5f26d34c4ac6f3eaac16fbc6069de2c2341a6ff (patch) | |
| tree | ad4ecb476a6fd3b754e741e795bd7a3adbbe03ea /lib/avl/table/project-avl-table.tsx | |
| parent | 25b916d040a512cd5248dff319d727ae144d0652 (diff) | |
| parent | 2b490956c9752c1b756780a3461bc1c37b6fe0a7 (diff) | |
[Merge] AVL 및 Vendor-Pool 기능 1차 구현
Diffstat (limited to 'lib/avl/table/project-avl-table.tsx')
| -rw-r--r-- | lib/avl/table/project-avl-table.tsx | 650 |
1 files changed, 650 insertions, 0 deletions
diff --git a/lib/avl/table/project-avl-table.tsx b/lib/avl/table/project-avl-table.tsx new file mode 100644 index 00000000..8664e32b --- /dev/null +++ b/lib/avl/table/project-avl-table.tsx @@ -0,0 +1,650 @@ +"use client" + +import * as React from "react" +import { useReactTable, getCoreRowModel, getPaginationRowModel, getSortedRowModel, getFilteredRowModel } from "@tanstack/react-table" +import { forwardRef, useImperativeHandle, useLayoutEffect, useMemo } from "react" +import { DataTable } from "@/components/data-table/data-table" +import { Button } from "@/components/ui/button" +import { AvlVendorAddAndModifyDialog } from "./avl-vendor-add-and-modify-dialog" +import { getProjectAvlVendorInfo, createAvlVendorInfo, updateAvlVendorInfo, deleteAvlVendorInfo, finalizeProjectAvl } from "../service" +import { getProjectInfoByProjectCode as getProjectInfoFromProjects } from "../../projects/service" +import { getProjectInfoByProjectCode as getProjectInfoFromBiddingProjects } from "../../bidding-projects/service" +import { GetProjectAvlSchema } from "../validations" +import { AvlDetailItem, AvlVendorInfoInput } from "../types" +import { toast } from "sonner" +import { getProjectAvlColumns } from "./project-avl-table-columns" +import { + ProjectDisplayField, + ProjectFileField +} from "../components/project-field-components" +import { ProjectSearchStatus } from "../components/project-field-utils" +import { useSession } from "next-auth/react" + + +// 프로젝트 AVL 테이블에서는 AvlDetailItem을 사용 +export type ProjectAvlItem = AvlDetailItem + +// ref를 통해 외부에서 접근할 수 있는 메소드들 +export interface ProjectAvlTableRef { + getSelectedIds: () => number[] +} + +interface ProjectAvlTableProps { + onSelectionChange?: (count: number) => void + resetCounter?: number + projectCode?: string // 프로젝트 코드 필터 + avlListId?: number // AVL 리스트 ID (관리 영역 표시용) + onProjectCodeChange?: (projectCode: string) => void // 프로젝트 코드 변경 콜백 + reloadTrigger?: number +} + + +export const ProjectAvlTable = forwardRef<ProjectAvlTableRef, ProjectAvlTableProps>(({ + onSelectionChange, + resetCounter, + projectCode, + avlListId, + onProjectCodeChange, + reloadTrigger +}, ref) => { + + const { data: sessionData } = useSession() + + const [data, setData] = React.useState<ProjectAvlItem[]>([]) + const [pageCount, setPageCount] = React.useState(0) + const [originalFile, setOriginalFile] = React.useState<string>("") + const [localProjectCode, setLocalProjectCode] = React.useState<string>(projectCode || "") + + // 행 추가/수정 다이얼로그 상태 + const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false) + const [editingItem, setEditingItem] = React.useState<AvlDetailItem | undefined>(undefined) + + // 프로젝트 정보 상태 + const [projectInfo, setProjectInfo] = React.useState<{ + projectName: string + constructionSector: string + shipType: string + htDivision: string + } | null>(null) + + // 프로젝트 검색 상태 + const [projectSearchStatus, setProjectSearchStatus] = React.useState<ProjectSearchStatus>('idle') + + // 검색 버튼 클릭 여부 상태 + const [isSearchClicked, setIsSearchClicked] = React.useState(false) + + // 페이지네이션 상태 + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }) + + + // 데이터 로드 함수 + const loadData = React.useCallback(async (searchParams: Partial<GetProjectAvlSchema> = {}) => { + try { + const params = { + page: searchParams.page ?? 1, + perPage: searchParams.perPage ?? 10, + sort: searchParams.sort ?? [{ id: "no", desc: false }], + flags: searchParams.flags ?? [], + projectCode: localProjectCode || "", + equipBulkDivision: (searchParams.equipBulkDivision as "EQUIP" | "BULK") ?? "EQUIP", + 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.search ?? "", + } + console.log('ProjectAvlTable - API call params:', params) + const result = await getProjectAvlVendorInfo(params) + console.log('ProjectAvlTable - 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 { + // 로딩 상태 처리 완료 + } + }, [localProjectCode]) + + + + // reloadTrigger가 변경될 때마다 데이터 리로드 + React.useEffect(() => { + if (reloadTrigger && reloadTrigger > 0) { + console.log('ProjectAvlTable - reloadTrigger changed, reloading data') + loadData({}) + } + }, [reloadTrigger, loadData]) + + // 초기 데이터 로드 (검색 버튼이 눌렸을 때만) + React.useEffect(() => { + if (localProjectCode && isSearchClicked) { + loadData({}) + } + }, [loadData, localProjectCode, isSearchClicked]) + + // 파일 업로드 핸들러 + const handleFileUpload = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (file) { + setOriginalFile(file.name) + // TODO: 실제 파일 업로드 로직 구현 + console.log("파일 업로드:", file.name) + } + }, []) + + // 프로젝트 검색 함수 (공통 로직) + const searchProject = React.useCallback(async (projectCode: string) => { + if (!projectCode.trim()) { + setProjectInfo(null) + setProjectSearchStatus('idle') + setData([]) + setPageCount(0) + return + } + + setProjectSearchStatus('searching') // 검색 시작 상태로 변경 + + try { + // 1. projects 테이블에서 먼저 검색 + let projectData: { + projectName?: string | null; + shipType?: string; + projectMsrm?: string | null; + projectHtDivision?: string | null; + } | null = null + let searchSource = 'projects' + + try { + projectData = await getProjectInfoFromProjects(projectCode.trim()) + // projects에서 찾았을 때만 즉시 성공 상태로 변경 + setProjectSearchStatus('success-projects') + } catch { + // projects 테이블에 없는 경우 biddingProjects 테이블에서 검색 + try { + projectData = await getProjectInfoFromBiddingProjects(projectCode.trim()) + if (projectData) { + searchSource = 'bidding-projects' + setProjectSearchStatus('success-bidding') // bidding에서 찾았을 때 성공 상태로 변경 + } else { + // 둘 다 실패한 경우에만 에러 상태로 변경 + setProjectInfo(null) + setProjectSearchStatus('error') + setData([]) + setPageCount(0) + toast.error("입력하신 프로젝트 코드를 찾을 수 없습니다.") + return + } + } catch { + // biddingProjects에서도 에러가 발생한 경우 + setProjectInfo(null) + setProjectSearchStatus('error') + setData([]) + setPageCount(0) + toast.error("입력하신 프로젝트 코드를 찾을 수 없습니다.") + return + } + } + + if (projectData) { + setProjectInfo({ + projectName: projectData.projectName || "", + constructionSector: "조선", // 기본값으로 조선 설정 (필요시 로직 변경) + shipType: projectData.shipType || projectData.projectMsrm || "", + htDivision: projectData.projectHtDivision || "" + }) + + const sourceMessage = searchSource === 'projects' ? '프로젝트' : '견적프로젝트' + toast.success(`${sourceMessage}에서 프로젝트 정보를 성공적으로 불러왔습니다.`) + + // 검색 성공 시 AVL 데이터 로드 트리거 + setIsSearchClicked(true) + } + } catch (error) { + console.error("프로젝트 정보 조회 실패:", error) + setProjectInfo(null) + setProjectSearchStatus('error') + setData([]) + setPageCount(0) + toast.error("프로젝트 정보를 불러오는데 실패했습니다.") + } + }, [setIsSearchClicked]) + + // 프로젝트 코드 변경 핸들러 (입력만 처리) + const handleProjectCodeChange = React.useCallback((value: string) => { + setLocalProjectCode(value) + onProjectCodeChange?.(value) + + // 입력이 변경되면 검색 상태를 idle로 초기화하고 검색 클릭 상태를 리셋 + if (!value.trim()) { + setProjectInfo(null) + setProjectSearchStatus('idle') + setIsSearchClicked(false) + setData([]) + setPageCount(0) + } else { + // 새로운 프로젝트 코드가 입력되면 검색 클릭 상태를 리셋 (다시 검색 버튼을 눌러야 함) + setIsSearchClicked(false) + } + }, [onProjectCodeChange]) + + // 프로젝트 검색 버튼 핸들러 + const handleProjectSearch = React.useCallback(async () => { + // 검색 시 페이지를 1페이지로 리셋 + setPagination(prev => ({ ...prev, pageIndex: 0 })) + // 프로젝트 정보 검색 (성공 시 내부에서 AVL 데이터 로드 트리거) + await searchProject(localProjectCode) + }, [localProjectCode, searchProject]) + + // 행 추가 핸들러 + const handleAddRow = React.useCallback(() => { + if (!localProjectCode.trim()) { + toast.error("프로젝트 코드를 먼저 입력해주세요.") + return + } + if (!projectInfo) { + toast.error("프로젝트 정보를 불러올 수 없습니다.") + return + } + setIsAddDialogOpen(true) + }, [localProjectCode, projectInfo]) + + + // 다이얼로그에서 항목 추가 핸들러 + const handleAddItem = React.useCallback(async (itemData: Omit<AvlVendorInfoInput, 'avlListId'>) => { + try { + // DB에 실제 저장할 데이터 준비 (avlListId는 나중에 최종 확정 시 설정) + const saveData: AvlVendorInfoInput = { + ...itemData, + projectCode: localProjectCode, // 현재 프로젝트 코드 저장 + avlListId: avlListId || undefined // 있으면 설정, 없으면 undefined (null로 저장됨) + } + + // DB에 저장 + const result = await createAvlVendorInfo(saveData) + + if (result) { + toast.success("새 항목이 성공적으로 추가되었습니다.") + + // 데이터 새로고침 + loadData({}) + } else { + toast.error("항목 추가에 실패했습니다.") + } + } catch (error) { + console.error("항목 추가 실패:", error) + toast.error("항목 추가 중 오류가 발생했습니다.") + } + }, [avlListId, loadData, localProjectCode]) + + // 다이얼로그에서 항목 수정 핸들러 + const handleUpdateItem = React.useCallback(async (id: number, itemData: Omit<AvlVendorInfoInput, 'avlListId'>) => { + try { + // DB에 실제 수정 + const result = await updateAvlVendorInfo(id, itemData) + + if (result) { + toast.success("항목이 성공적으로 수정되었습니다.") + + // 데이터 새로고침 + loadData({}) + + // 다이얼로그 닫기 및 수정 모드 해제 + setIsAddDialogOpen(false) + setEditingItem(undefined) + } else { + toast.error("항목 수정에 실패했습니다.") + } + } catch (error) { + console.error("항목 수정 실패:", error) + toast.error("항목 수정 중 오류가 발생했습니다.") + } + }, [loadData]) + + // 테이블 메타 설정 + const tableMeta = React.useMemo(() => ({}), []) + + const table = useReactTable({ + data, + columns: getProjectAvlColumns(), + 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('ProjectAvlTable - Pagination changed:', { + currentState: pagination, + newPaginationState, + localProjectCode, + isSearchClicked, + willLoadData: localProjectCode && isSearchClicked + }) + + setPagination(newPaginationState) + + if (localProjectCode && isSearchClicked) { + const apiParams = { + page: newPaginationState.pageIndex + 1, + perPage: newPaginationState.pageSize, + } + console.log('ProjectAvlTable - Loading data with params:', apiParams) + loadData(apiParams) + } + }, + meta: tableMeta, + }) + + // 외부에서 선택된 ID들을 가져올 수 있도록 ref에 메소드 노출 + useImperativeHandle(ref, () => ({ + getSelectedIds: () => { + const selectedRows = table.getFilteredSelectedRowModel().rows + return selectedRows.map(row => row.original.id) + } + })) + + // 항목 수정 핸들러 (버튼 클릭) + 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]) + + // 최종 확정 핸들러 + const handleFinalizeAvl = React.useCallback(async () => { + // 1. 필수 조건 검증 + if (!localProjectCode.trim()) { + toast.error("프로젝트 코드를 먼저 입력해주세요.") + return + } + + if (!projectInfo) { + toast.error("프로젝트 정보를 불러올 수 없습니다. 프로젝트 코드를 다시 확인해주세요.") + return + } + + if (data.length === 0) { + toast.error("확정할 AVL 벤더 정보가 없습니다.") + return + } + + // 2. 사용자 확인 + const confirmed = window.confirm( + `현재 프로젝트(${localProjectCode})의 AVL을 최종 확정하시겠습니까?\n\n` + + `- 프로젝트명: ${projectInfo.projectName}\n` + + `- 벤더 정보: ${data.length}개\n` + + `- 공사부문: ${projectInfo.constructionSector}\n` + + `- 선종: ${projectInfo.shipType}\n` + + `- H/T 구분: ${projectInfo.htDivision}\n\n` + + `확정 후에는 수정이 어려울 수 있습니다.` + ) + + if (!confirmed) return + + try { + // 3. 현재 데이터의 모든 ID 수집 + const avlVendorInfoIds = data.map(item => item.id) + + // 4. 최종 확정 실행 + const result = await finalizeProjectAvl( + localProjectCode, + projectInfo, + 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 최종 확정 중 오류가 발생했습니다.") + } + }, [localProjectCode, projectInfo, data, table, loadData, sessionData?.user?.name]) + + // 선택된 행 개수 (안정적인 계산을 위해 useMemo 사용) + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedRowCount = useMemo(() => { + const count = selectedRows.length + console.log('ProjectAvlTable - selectedRowCount calculated:', count) + return count + }, [selectedRows]) + + // 선택 상태 변경 시 콜백 호출 + useLayoutEffect(() => { + console.log('ProjectAvlTable - 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={handleAddRow}> + 행 추가 + </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={() => toast.info("개발 중입니다.")}> + 강제 매핑 + </Button> */} + <Button variant="outline" size="sm" onClick={handleDeleteItems}> + 항목 삭제 + </Button> + + {/* 최종 확정 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleFinalizeAvl} + disabled={!localProjectCode.trim() || !projectInfo || data.length === 0} + > + 최종 확정 + </Button> + </div> + </div> + </div> + + {/* 조회대상 관리영역 */} + <div className="mb-4 p-4 border rounded-lg bg-muted/50"> + <div className="flex gap-4 overflow-x-auto pb-2"> + {/* 프로젝트 코드 */} + <div className="flex flex-col gap-1 min-w-[200px]"> + <label className="text-sm font-medium">프로젝트 코드</label> + <div className="flex gap-2"> + <div className="flex-1"> + <input + type="text" + value={localProjectCode} + onChange={(e) => handleProjectCodeChange(e.target.value)} + placeholder="프로젝트 코드를 입력하세요" + className={`flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 ${ + projectSearchStatus === 'error' ? 'border-red-500' : + projectSearchStatus === 'success-projects' || projectSearchStatus === 'success-bidding' ? 'border-green-500' : + projectSearchStatus === 'searching' ? 'border-blue-500' : '' + }`} + disabled={projectSearchStatus === 'searching'} + /> + {projectSearchStatus !== 'idle' && ( + <div className="text-xs mt-1 text-muted-foreground"> + {projectSearchStatus === 'success-projects' ? '(프로젝트)' : + projectSearchStatus === 'success-bidding' ? '(견적프로젝트)' : + projectSearchStatus === 'searching' ? '(검색 중...)' : + projectSearchStatus === 'error' ? '(찾을 수 없음)' : + undefined} + </div> + )} + </div> + <Button + variant="outline" + size="sm" + onClick={handleProjectSearch} + disabled={!localProjectCode.trim() || projectSearchStatus === 'searching'} + className="px-3 h-9" + > + {projectSearchStatus === 'searching' ? '검색 중...' : '검색'} + </Button> + </div> + </div> + + {/* 프로젝트명 */} + <ProjectDisplayField + label="프로젝트명" + value={projectInfo?.projectName || ''} + status={projectSearchStatus} + minWidth="250px" + /> + + {/* 원본파일 */} + <ProjectFileField + label="원본파일" + originalFile={originalFile} + onFileUpload={handleFileUpload} + /> + + {/* 공사부문 */} + <ProjectDisplayField + label="공사부문" + value={projectInfo?.constructionSector || ''} + status={projectSearchStatus} + /> + + {/* 선종 */} + <ProjectDisplayField + label="선종" + value={projectInfo?.shipType || ''} + status={projectSearchStatus} + /> + + {/* H/T 구분 */} + <ProjectDisplayField + label="H/T 구분" + value={projectInfo?.htDivision || ''} + status={projectSearchStatus} + minWidth="140px" + formatter={(value) => + value === 'H' ? 'Hull (H)' : + value === 'T' ? 'Topside (T)' : '-' + } + /> + </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={false} // 프로젝트 AVL 모드 + initialProjectCode={localProjectCode} + /> + </div> + ) +}) + +ProjectAvlTable.displayName = "ProjectAvlTable" |
