From 40250c61031263606dd073ce7056a3e8e27f18d0 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Tue, 14 Oct 2025 14:25:28 +0900 Subject: (김준회) AVL 구매요구사항 수정 - AVL 상세 엑셀 익스포트 추가 - 레코드 이동 멀티선택 추가 - 최종확정처리 오류 수정 - 프로젝트 AVL에 H/T 구분 추가 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/avl/service.ts | 167 ++++++++++++++++++++++---- lib/avl/table/avl-detail-table.tsx | 64 +++++++--- lib/avl/table/avl-registration-area.tsx | 25 +++- lib/avl/table/columns-detail.tsx | 54 ++++++++- lib/avl/table/project-avl-table-columns.tsx | 30 ++--- lib/avl/table/project-avl-table.tsx | 173 ++++++++++++++++----------- lib/avl/table/standard-avl-table-columns.tsx | 30 ++--- lib/avl/table/standard-avl-table.tsx | 40 ++++--- lib/avl/table/vendor-pool-table-columns.tsx | 30 ++--- lib/avl/validations.ts | 5 +- 10 files changed, 411 insertions(+), 207 deletions(-) (limited to 'lib') diff --git a/lib/avl/service.ts b/lib/avl/service.ts index 1f781486..5d7c2418 100644 --- a/lib/avl/service.ts +++ b/lib/avl/service.ts @@ -940,10 +940,72 @@ export async function deleteAvlVendorInfo(id: number): Promise { } } +/** + * 프로젝트 AVL 벤더 정보 건수 조회 + */ +export async function getProjectAvlVendorInfoCount( + projectCode: string, + htDivision?: string +): Promise { + try { + const conditions = [ + eq(avlVendorInfo.projectCode, projectCode), + eq(avlVendorInfo.isTemplate, false) + ]; + + // H/T 구분이 있으면 조건에 추가 + if (htDivision) { + conditions.push(eq(avlVendorInfo.htDivision, htDivision)); + } + + const result = await db + .select({ count: count() }) + .from(avlVendorInfo) + .where(and(...conditions)); + + return result[0]?.count || 0; + } catch (error) { + console.error("Error in getProjectAvlVendorInfoCount:", error); + return 0; + } +} + +/** + * 표준 AVL 벤더 정보 건수 조회 + */ +export async function getStandardAvlVendorInfoCount( + standardAvlInfo: { + constructionSector: string; + shipType: string; + avlKind: string; + htDivision: string; + } +): Promise { + try { + const result = await db + .select({ count: count() }) + .from(avlVendorInfo) + .where( + and( + eq(avlVendorInfo.isTemplate, true), + eq(avlVendorInfo.constructionSector, standardAvlInfo.constructionSector), + eq(avlVendorInfo.shipType, standardAvlInfo.shipType), + eq(avlVendorInfo.avlKind, standardAvlInfo.avlKind), + eq(avlVendorInfo.htDivision, standardAvlInfo.htDivision) + ) + ); + + return result[0]?.count || 0; + } catch (error) { + console.error("Error in getStandardAvlVendorInfoCount:", error); + return 0; + } +} + /** * 프로젝트 AVL 최종 확정 * 1. 주어진 프로젝트 정보로 avlList에 레코드를 생성한다. - * 2. 현재 avlVendorInfo 레코드들의 avlListId를 새로 생성된 AVL 리스트 ID로 업데이트한다. + * 2. 해당 프로젝트 코드의 모든 avlVendorInfo 레코드들의 avlListId를 새로 생성된 AVL 리스트 ID로 업데이트한다. */ export async function finalizeProjectAvl( projectCode: string, @@ -953,23 +1015,48 @@ export async function finalizeProjectAvl( shipType: string; htDivision: string; }, - avlVendorInfoIds: number[], currentUser?: string ): Promise<{ success: boolean; avlListId?: number; message: string }> { try { debugLog('프로젝트 AVL 최종 확정 시작', { projectCode, projectInfo, - avlVendorInfoIds: avlVendorInfoIds.length, currentUser }); - // 1. 기존 AVL 리스트의 최고 revision 확인 + // 1. DB에서 해당 프로젝트 코드와 H/T 구분의 모든 avlVendorInfo 레코드 ID 조회 + const allVendorInfoRecords = await db + .select({ id: avlVendorInfo.id }) + .from(avlVendorInfo) + .where( + and( + eq(avlVendorInfo.projectCode, projectCode), + eq(avlVendorInfo.htDivision, projectInfo.htDivision), + eq(avlVendorInfo.isTemplate, false) + ) + ); + + const avlVendorInfoIds = allVendorInfoRecords.map(record => record.id); + + if (avlVendorInfoIds.length === 0) { + return { + success: false, + message: "해당 프로젝트의 AVL 벤더 정보가 없습니다." + }; + } + + debugLog('프로젝트 AVL 벤더 정보 조회 완료', { + projectCode, + totalVendorInfoCount: avlVendorInfoIds.length + }); + + // 2. 기존 AVL 리스트의 최고 revision 확인 (프로젝트 코드 + H/T 구분별로) const existingAvlLists = await db .select({ rev: avlList.rev }) .from(avlList) .where(and( eq(avlList.projectCode, projectCode), + eq(avlList.htDivision, projectInfo.htDivision), eq(avlList.isTemplate, false) )) .orderBy(desc(avlList.rev)) @@ -983,7 +1070,7 @@ export async function finalizeProjectAvl( nextRevision }); - // 2. AVL 리스트 생성을 위한 데이터 준비 + // 3. AVL 리스트 생성을 위한 데이터 준비 const createAvlListData: CreateAvlListInput = { isTemplate: false, // 프로젝트 AVL이므로 false constructionSector: projectInfo.constructionSector, @@ -998,7 +1085,7 @@ export async function finalizeProjectAvl( debugLog('AVL 리스트 생성 데이터', { createAvlListData }); - // 2. AVL Vendor Info 스냅샷 생성 (AVL 리스트 생성 전에 현재 상태 저장) + // 4. AVL Vendor Info 스냅샷 생성 (AVL 리스트 생성 전에 현재 상태 저장) debugLog('AVL Vendor Info 스냅샷 생성 시작', { vendorInfoIdsCount: avlVendorInfoIds.length, vendorInfoIds: avlVendorInfoIds @@ -1017,7 +1104,7 @@ export async function finalizeProjectAvl( snapshotLength: createAvlListData.vendorInfoSnapshot?.length }); - // 3. AVL 리스트 생성 + // 5. AVL 리스트 생성 const newAvlList = await createAvlList(createAvlListData); if (!newAvlList) { @@ -1026,7 +1113,7 @@ export async function finalizeProjectAvl( debugSuccess('AVL 리스트 생성 완료', { avlListId: newAvlList.id }); - // 3. avlVendorInfo 레코드들의 avlListId 업데이트 + // 6. avlVendorInfo 레코드들의 avlListId 업데이트 if (avlVendorInfoIds.length > 0) { debugLog('AVL Vendor Info 업데이트 시작', { count: avlVendorInfoIds.length, @@ -1071,7 +1158,7 @@ export async function finalizeProjectAvl( } } - // 4. 캐시 무효화 + // 7. 캐시 무효화 revalidateTag('avl-list'); revalidateTag('avl-vendor-info'); @@ -1123,6 +1210,11 @@ export const getProjectAvlVendorInfo = async (input: GetProjectAvlSchema) => { whereConditions.push(ilike(avlVendorInfo.projectCode, `%${input.projectCode}%`)); } + // 필수 필터: H/T 구분 (avlVendorInfo에서 직접 필터링) + if (input.htDivision) { + whereConditions.push(eq(avlVendorInfo.htDivision, input.htDivision)); + } + // 검색어 기반 필터링 if (input.search) { const searchTerm = `%${input.search}%`; @@ -1513,11 +1605,12 @@ export const getStandardAvlVendorInfo = async (input: GetStandardAvlSchema) => { export const copyToProjectAvl = async ( selectedIds: number[], targetProjectCode: string, + targetHtDivision: string, targetAvlListId: number, userName: string ): Promise => { try { - debugLog('선종별표준AVL → 프로젝트AVL 복사 시작', { selectedIds, targetProjectCode, targetAvlListId }); + debugLog('선종별표준AVL → 프로젝트AVL 복사 시작', { selectedIds, targetProjectCode, targetHtDivision, targetAvlListId }); if (!selectedIds.length) { return { success: false, message: "복사할 항목을 선택해주세요." }; @@ -1544,12 +1637,12 @@ export const copyToProjectAvl = async ( id: undefined, // 새 ID 생성 isTemplate: false, // 프로젝트 AVL로 변경 projectCode: targetProjectCode, // 대상 프로젝트 코드 + htDivision: targetHtDivision, // 프로젝트 AVL에서 선택한 H/T 구분 적용 avlListId: targetAvlListId, // 대상 AVL 리스트 ID // 표준 AVL 필드들은 null로 설정 (프로젝트 AVL에서는 사용하지 않음) constructionSector: null, shipType: null, avlKind: null, - htDivision: null, createdAt: undefined, updatedAt: undefined, })); @@ -1887,11 +1980,12 @@ export const copyToVendorPool = async ( export const copyFromVendorPoolToProjectAvl = async ( selectedIds: number[], targetProjectCode: string, + targetHtDivision: string, targetAvlListId: number, userName: string ): Promise => { try { - debugLog('벤더풀 → 프로젝트AVL 복사 시작', { selectedIds, targetProjectCode, targetAvlListId }); + debugLog('벤더풀 → 프로젝트AVL 복사 시작', { selectedIds, targetProjectCode, targetHtDivision, targetAvlListId }); if (!selectedIds.length) { return { success: false, message: "복사할 항목을 선택해주세요." }; @@ -1913,6 +2007,7 @@ export const copyFromVendorPoolToProjectAvl = async ( const recordsToInsert: NewAvlVendorInfo[] = selectedRecords.map(record => ({ // 프로젝트 AVL용 필드들 projectCode: targetProjectCode, + htDivision: targetHtDivision, // 프로젝트 AVL에서 선택한 H/T 구분 적용 avlListId: targetAvlListId, isTemplate: false, @@ -1921,9 +2016,8 @@ export const copyFromVendorPoolToProjectAvl = async ( vendorName: record.vendorName, vendorCode: record.vendorCode, - // 기본 정보 (벤더풀의 데이터 활용) - constructionSector: record.constructionSector, - htDivision: record.htDivision, + // 기본 정보 (프로젝트 AVL에서는 constructionSector 사용 안함) + constructionSector: null, // 자재그룹 정보 materialGroupCode: record.materialGroupCode, @@ -2332,6 +2426,7 @@ export const copyFromStandardAvlToVendorPool = async ( /** * 표준 AVL 최종 확정 * 표준 AVL을 최종 확정하여 AVL 리스트에 등록합니다. + * DB에서 해당 조건에 맞는 모든 avlVendorInfo 레코드를 조회하여 확정합니다. */ export async function finalizeStandardAvl( standardAvlInfo: { @@ -2340,17 +2435,43 @@ export async function finalizeStandardAvl( avlKind: string; htDivision: string; }, - avlVendorInfoIds: number[], currentUser?: string ): Promise<{ success: boolean; avlListId?: number; message: string }> { try { debugLog('표준 AVL 최종 확정 시작', { standardAvlInfo, - avlVendorInfoIds: avlVendorInfoIds.length, currentUser }); - // 1. 기존 표준 AVL 리스트의 최고 revision 확인 + // 1. DB에서 해당 조건의 모든 avlVendorInfo 레코드 ID 조회 + const allVendorInfoRecords = await db + .select({ id: avlVendorInfo.id }) + .from(avlVendorInfo) + .where( + and( + eq(avlVendorInfo.isTemplate, true), + eq(avlVendorInfo.constructionSector, standardAvlInfo.constructionSector), + eq(avlVendorInfo.shipType, standardAvlInfo.shipType), + eq(avlVendorInfo.avlKind, standardAvlInfo.avlKind), + eq(avlVendorInfo.htDivision, standardAvlInfo.htDivision) + ) + ); + + const avlVendorInfoIds = allVendorInfoRecords.map(record => record.id); + + if (avlVendorInfoIds.length === 0) { + return { + success: false, + message: "해당 조건의 표준 AVL 벤더 정보가 없습니다." + }; + } + + debugLog('표준 AVL 벤더 정보 조회 완료', { + standardAvlInfo, + totalVendorInfoCount: avlVendorInfoIds.length + }); + + // 2. 기존 표준 AVL 리스트의 최고 revision 확인 const existingAvlLists = await db .select({ rev: avlList.rev }) .from(avlList) @@ -2372,7 +2493,7 @@ export async function finalizeStandardAvl( nextRevision }); - // 2. AVL 리스트 생성을 위한 데이터 준비 + // 3. AVL 리스트 생성을 위한 데이터 준비 const createAvlListData: CreateAvlListInput = { isTemplate: true, // 표준 AVL이므로 true constructionSector: standardAvlInfo.constructionSector, @@ -2387,7 +2508,7 @@ export async function finalizeStandardAvl( debugLog('표준 AVL 리스트 생성 데이터', { createAvlListData }); - // 2-1. AVL Vendor Info 스냅샷 생성 (AVL 리스트 생성 전에 현재 상태 저장) + // 4. AVL Vendor Info 스냅샷 생성 (AVL 리스트 생성 전에 현재 상태 저장) debugLog('표준 AVL Vendor Info 스냅샷 생성 시작', { vendorInfoIdsCount: avlVendorInfoIds.length, vendorInfoIds: avlVendorInfoIds @@ -2406,7 +2527,7 @@ export async function finalizeStandardAvl( snapshotLength: createAvlListData.vendorInfoSnapshot?.length }); - // 3. AVL 리스트 생성 + // 5. AVL 리스트 생성 const newAvlList = await createAvlList(createAvlListData); if (!newAvlList) { @@ -2415,7 +2536,7 @@ export async function finalizeStandardAvl( debugSuccess('표준 AVL 리스트 생성 완료', { avlListId: newAvlList.id }); - // 4. avlVendorInfo 레코드들의 avlListId 업데이트 + // 6. avlVendorInfo 레코드들의 avlListId 업데이트 if (avlVendorInfoIds.length > 0) { debugLog('표준 AVL Vendor Info 업데이트 시작', { count: avlVendorInfoIds.length, @@ -2459,7 +2580,7 @@ export async function finalizeStandardAvl( } } - // 5. 캐시 무효화 + // 7. 캐시 무효화 revalidateTag('avl-list'); revalidateTag('avl-vendor-info'); diff --git a/lib/avl/table/avl-detail-table.tsx b/lib/avl/table/avl-detail-table.tsx index ca3ba7e7..407535db 100644 --- a/lib/avl/table/avl-detail-table.tsx +++ b/lib/avl/table/avl-detail-table.tsx @@ -10,6 +10,7 @@ import { toast } from "sonner" import { columns } from "./columns-detail" import type { AvlDetailItem } from "../types" import { BackButton } from "@/components/ui/back-button" +import { exportTableToExcel } from "@/lib/export_all" interface AvlDetailTableProps { data: AvlDetailItem[] @@ -33,7 +34,42 @@ export function AvlDetailTable({ }: AvlDetailTableProps) { - // 액션 핸들러 + // 데이터 테이블 설정 (초기 meta는 없음) + const { table } = useDataTable({ + data, + columns, + pageCount: pageCount ?? 1, + initialState: { + sorting: [{ id: "no", desc: false }], + pagination: { + pageIndex: 0, + pageSize: 10, + }, + }, + getRowId: (row) => String(row.id), + // meta는 useEffect에서 설정 + // 정적 데이터이므로 무한 스크롤 설정하지 않음 (클라이언트 측 소팅 활성화) + infiniteScrollConfig: undefined, + }) + + // Excel export 핸들러 + const handleExcelExport = React.useCallback(async () => { + try { + toast.info("엑셀 파일을 생성 중입니다...") + await exportTableToExcel(table, { + filename: `AVL_상세내역_${new Date().toISOString().split('T')[0]}`, + allPages: true, + excludeColumns: ["select", "actions"], + useGroupHeader: true, + }) + toast.success("엑셀 파일이 다운로드되었습니다.") + } catch (error) { + console.error("Excel export error:", error) + toast.error("엑셀 파일 생성 중 오류가 발생했습니다.") + } + }, [table]) + + // 액션 핸들러 (table을 직접 사용하지 않는 액션들) const handleAction = React.useCallback(async (action: string) => { switch (action) { case 'vendor-pool': @@ -55,29 +91,15 @@ export function AvlDetailTable({ } }, []) - // 테이블 메타 설정 const tableMeta = React.useMemo(() => ({ onAction: handleAction, }), [handleAction]) - // 데이터 테이블 설정 - const { table } = useDataTable({ - data, - columns, - pageCount: pageCount ?? 1, - initialState: { - sorting: [{ id: "no", desc: false }], - pagination: { - pageIndex: 0, - pageSize: 10, - }, - }, - getRowId: (row) => String(row.id), - meta: tableMeta, - // 정적 데이터이므로 무한 스크롤 설정하지 않음 (클라이언트 측 소팅 활성화) - infiniteScrollConfig: undefined, - }) + // table의 meta 설정 + React.useEffect(() => { + table.options.meta = tableMeta + }, [table, tableMeta]) return (
@@ -101,6 +123,10 @@ export function AvlDetailTable({ {/* 상단 버튼 영역 */}
+ {/* Excel Export 버튼 */} + {/* 단순 이동 버튼 */} @@ -568,11 +588,11 @@ export const ProjectAvlTable = forwardRef {/* 원본파일 */} - + /> */} {/* 공사부문 */} {/* H/T 구분 */} - - value === 'H' ? 'Hull (H)' : - value === 'T' ? 'Topside (T)' : '-' - } - /> +
+ + +
@@ -636,8 +661,10 @@ export const ProjectAvlTable = forwardRef• 프로젝트명: {projectInfo?.projectName || ""}
• 공사부문: {projectInfo?.constructionSector || ""}
• 선종: {projectInfo?.shipType || ""}
-
• H/T 구분: {projectInfo?.htDivision || ""}
-
• 벤더 정보: {data.length}개 (전체 레코드)
+
• H/T 구분: {searchHtDivision === 'H' ? 'Hull (H)' : searchHtDivision === 'T' ? 'Top (T)' : searchHtDivision}
+
+ • 확정될 벤더 정보: {totalVendorInfoCount}개 +
{/*
⚠️ 확정 후 내용 수정을 필요로 하는 경우 동일 건을 다시 최종확정해 revision 처리로 수정해야 합니다.
*/} diff --git a/lib/avl/table/standard-avl-table-columns.tsx b/lib/avl/table/standard-avl-table-columns.tsx index 903d2590..650220f5 100644 --- a/lib/avl/table/standard-avl-table-columns.tsx +++ b/lib/avl/table/standard-avl-table-columns.tsx @@ -16,29 +16,13 @@ export const standardAvlColumns: ColumnDef[] = [ aria-label="Select all" /> ), - cell: ({ row, table }) => { - // 선종별 표준 AVL 테이블의 단일 선택 핸들러 - const handleRowSelection = (checked: boolean) => { - if (checked) { - // 다른 모든 행의 선택 해제 - table.getRowModel().rows.forEach(r => { - if (r !== row && r.getIsSelected()) { - r.toggleSelected(false) - } - }) - } - // 현재 행 선택/해제 - row.toggleSelected(checked) - } - - return ( - - ) - }, + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), enableSorting: false, enableHiding: false, size: 50, diff --git a/lib/avl/table/standard-avl-table.tsx b/lib/avl/table/standard-avl-table.tsx index 06fa6931..c638bd7f 100644 --- a/lib/avl/table/standard-avl-table.tsx +++ b/lib/avl/table/standard-avl-table.tsx @@ -27,7 +27,7 @@ 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 { createAvlVendorInfo, updateAvlVendorInfo, deleteAvlVendorInfo, finalizeStandardAvl, getStandardAvlVendorInfoCount } from "../service" import { AvlVendorInfoInput } from "../types" import { useSession } from "next-auth/react" import { ShipTypeSelector, ShipTypeItem } from "@/components/common/ship-type" @@ -341,6 +341,7 @@ export const StandardAvlTable = forwardRef(0) // 최종 확정 핸들러 (표준 AVL) const handleFinalizeStandardAvl = React.useCallback(async () => { @@ -350,22 +351,31 @@ export const StandardAvlTable = forwardRef { try { - // 3. 현재 데이터의 모든 ID 수집 (전체 레코드 기준) - const avlVendorInfoIds = data.map(item => item.id) - - // 4. 최종 확정 실행 + // 최종 확정 실행 (서버에서 DB의 모든 레코드를 조회하여 확정) const standardAvlInfo = { constructionSector: searchConstructionSector, shipType: selectedShipType?.CD || "", @@ -375,17 +385,16 @@ export const StandardAvlTable = forwardRef { @@ -626,10 +635,9 @@ export const StandardAvlTable = forwardRef• 선종: {selectedShipType?.CD || ""}
• AVL종류: {searchAvlKind}
• H/T 구분: {searchHtDivision}
-
• 벤더 정보: {data.length}개 (전체 레코드)
- {/*
- ⚠️ 확정 후 내용 수정을 필요로 하는 경우 동일 건을 다시 최종확정해 revision 처리로 수정해야 합니다. -
*/} +
+ • 확정될 벤더 정보: {totalVendorInfoCount}개 +