From 5609e6353f6c260d5fc492b998d6577a660d3053 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 8 Dec 2025 17:13:14 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B0=BD=EA=B3=A0=20=EB=A0=89=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=93=B1=EB=A1=9D=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=A4=91=EB=B3=B5=20=EB=B0=A9=EC=A7=80=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tableCategoryValueController.ts | 47 ++ .../src/routes/tableCategoryValueRoutes.ts | 4 + backend-node/src/services/dataService.ts | 44 +- .../src/services/tableCategoryValueService.ts | 64 ++ .../screen/InteractiveScreenViewer.tsx | 33 + frontend/lib/api/tableCategoryValue.ts | 23 + .../rack-structure/RackStructureComponent.tsx | 384 ++++++++++-- .../rack-structure/RackStructureRenderer.tsx | 30 +- .../components/rack-structure/types.ts | 21 +- frontend/lib/utils/buttonActions.ts | 570 +++++++++++++----- 10 files changed, 1027 insertions(+), 193 deletions(-) diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index 248bb867..75e225e6 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -527,6 +527,53 @@ export const deleteColumnMappingsByColumn = async (req: AuthenticatedRequest, re } }; +/** + * 카테고리 코드로 라벨 조회 + * + * POST /api/table-categories/labels-by-codes + * + * Body: + * - valueCodes: 카테고리 코드 배열 (예: ["CATEGORY_767659DCUF", "CATEGORY_8292565608"]) + * + * Response: + * - { [code]: label } 형태의 매핑 객체 + */ +export const getCategoryLabelsByCodes = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const { valueCodes } = req.body; + + if (!valueCodes || !Array.isArray(valueCodes) || valueCodes.length === 0) { + return res.json({ + success: true, + data: {}, + }); + } + + logger.info("카테고리 코드로 라벨 조회", { + valueCodes, + companyCode, + }); + + const labels = await tableCategoryValueService.getCategoryLabelsByCodes( + valueCodes, + companyCode + ); + + return res.json({ + success: true, + data: labels, + }); + } catch (error: any) { + logger.error(`카테고리 라벨 조회 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "카테고리 라벨 조회 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + /** * 2레벨 메뉴 목록 조회 * diff --git a/backend-node/src/routes/tableCategoryValueRoutes.ts b/backend-node/src/routes/tableCategoryValueRoutes.ts index b79aab75..e59d9b9d 100644 --- a/backend-node/src/routes/tableCategoryValueRoutes.ts +++ b/backend-node/src/routes/tableCategoryValueRoutes.ts @@ -13,6 +13,7 @@ import { deleteColumnMapping, deleteColumnMappingsByColumn, getSecondLevelMenus, + getCategoryLabelsByCodes, } from "../controllers/tableCategoryValueController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -42,6 +43,9 @@ router.post("/values/bulk-delete", bulkDeleteCategoryValues); // 카테고리 값 순서 변경 router.post("/values/reorder", reorderCategoryValues); +// 카테고리 코드로 라벨 조회 +router.post("/labels-by-codes", getCategoryLabelsByCodes); + // ================================================ // 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명) // ================================================ diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index fd85248d..a278eb97 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -907,8 +907,27 @@ class DataService { return validation.error!; } - const columns = Object.keys(data); - const values = Object.values(data); + // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외) + const tableColumns = await this.getTableColumnsSimple(tableName); + const validColumnNames = new Set(tableColumns.map((col: any) => col.column_name)); + + const invalidColumns: string[] = []; + const filteredData = Object.fromEntries( + Object.entries(data).filter(([key]) => { + if (validColumnNames.has(key)) { + return true; + } + invalidColumns.push(key); + return false; + }) + ); + + if (invalidColumns.length > 0) { + console.log(`⚠️ [createRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`); + } + + const columns = Object.keys(filteredData); + const values = Object.values(filteredData); const placeholders = values.map((_, index) => `$${index + 1}`).join(", "); const columnNames = columns.map((col) => `"${col}"`).join(", "); @@ -951,9 +970,28 @@ class DataService { // _relationInfo 추출 (조인 관계 업데이트용) const relationInfo = data._relationInfo; - const cleanData = { ...data }; + let cleanData = { ...data }; delete cleanData._relationInfo; + // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼 제외) + const tableColumns = await this.getTableColumnsSimple(tableName); + const validColumnNames = new Set(tableColumns.map((col: any) => col.column_name)); + + const invalidColumns: string[] = []; + cleanData = Object.fromEntries( + Object.entries(cleanData).filter(([key]) => { + if (validColumnNames.has(key)) { + return true; + } + invalidColumns.push(key); + return false; + }) + ); + + if (invalidColumns.length > 0) { + console.log(`⚠️ [updateRecord] 테이블에 없는 컬럼 제외: ${invalidColumns.join(", ")}`); + } + // Primary Key 컬럼 찾기 const pkResult = await query<{ attname: string }>( `SELECT a.attname diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index b68d5f05..cdf1b838 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -1258,6 +1258,70 @@ class TableCategoryValueService { throw error; } } + + /** + * 카테고리 코드로 라벨 조회 + * + * @param valueCodes - 카테고리 코드 배열 + * @param companyCode - 회사 코드 + * @returns { [code]: label } 형태의 매핑 객체 + */ + async getCategoryLabelsByCodes( + valueCodes: string[], + companyCode: string + ): Promise> { + try { + if (!valueCodes || valueCodes.length === 0) { + return {}; + } + + logger.info("카테고리 코드로 라벨 조회", { valueCodes, companyCode }); + + const pool = getPool(); + + // 동적으로 파라미터 플레이스홀더 생성 + const placeholders = valueCodes.map((_, i) => `$${i + 1}`).join(", "); + + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 카테고리 값 조회 + query = ` + SELECT value_code, value_label + FROM table_column_category_values + WHERE value_code IN (${placeholders}) + AND is_active = true + `; + params = valueCodes; + } else { + // 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회 + query = ` + SELECT value_code, value_label + FROM table_column_category_values + WHERE value_code IN (${placeholders}) + AND is_active = true + AND (company_code = $${valueCodes.length + 1} OR company_code = '*') + `; + params = [...valueCodes, companyCode]; + } + + const result = await pool.query(query, params); + + // { [code]: label } 형태로 변환 + const labels: Record = {}; + for (const row of result.rows) { + labels[row.value_code] = row.value_label; + } + + logger.info(`카테고리 라벨 ${Object.keys(labels).length}개 조회 완료`, { companyCode }); + + return labels; + } catch (error: any) { + logger.error(`카테고리 코드로 라벨 조회 실패: ${error.message}`, { error }); + throw error; + } + } } export default new TableCategoryValueService(); diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index d9186999..223490e6 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -441,6 +441,39 @@ export const InteractiveScreenViewer: React.FC = ( ); } + // 🆕 렉 구조 컴포넌트 처리 + if (comp.type === "component" && componentType === "rack-structure") { + const { RackStructureComponent } = require("@/lib/registry/components/rack-structure/RackStructureComponent"); + const componentConfig = (comp as any).componentConfig || {}; + // config가 중첩되어 있을 수 있음: componentConfig.config 또는 componentConfig 직접 + const rackConfig = componentConfig.config || componentConfig; + + console.log("🏗️ 렉 구조 컴포넌트 렌더링:", { + componentType, + componentConfig, + rackConfig, + fieldMapping: rackConfig.fieldMapping, + formData, + }); + + return ( +
+ { + console.log("📦 렉 구조 위치 데이터 변경:", locations.length, "개"); + // 컴포넌트의 columnName을 키로 사용 + const fieldKey = (comp as any).columnName || "_rackStructureLocations"; + updateFormData(fieldKey, locations); + }} + isPreview={false} + /> +
+ ); + } + const { widgetType, label, placeholder, required, readonly, columnName } = comp; const fieldName = columnName || comp.id; const currentValue = formData[fieldName] || ""; diff --git a/frontend/lib/api/tableCategoryValue.ts b/frontend/lib/api/tableCategoryValue.ts index 3c5380d1..253e66d0 100644 --- a/frontend/lib/api/tableCategoryValue.ts +++ b/frontend/lib/api/tableCategoryValue.ts @@ -167,6 +167,29 @@ export async function reorderCategoryValues(orderedValueIds: number[]) { } } +/** + * 카테고리 코드로 라벨 조회 + * + * @param valueCodes - 카테고리 코드 배열 (예: ["CATEGORY_767659DCUF", "CATEGORY_8292565608"]) + * @returns { [code]: label } 형태의 매핑 객체 + */ +export async function getCategoryLabelsByCodes(valueCodes: string[]) { + try { + if (!valueCodes || valueCodes.length === 0) { + return { success: true, data: {} }; + } + + const response = await apiClient.post<{ + success: boolean; + data: Record; + }>("/table-categories/labels-by-codes", { valueCodes }); + return response.data; + } catch (error: any) { + console.error("카테고리 라벨 조회 실패:", error); + return { success: false, error: error.message, data: {} }; + } +} + // ================================================ // 컬럼 매핑 관련 API (논리명 ↔ 물리명) // ================================================ diff --git a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx index f49e4462..7ddd6326 100644 --- a/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx +++ b/frontend/lib/registry/components/rack-structure/RackStructureComponent.tsx @@ -23,6 +23,8 @@ import { import { ScrollArea } from "@/components/ui/scroll-area"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { cn } from "@/lib/utils"; +import { getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue"; +import { DynamicFormApi } from "@/lib/api/dynamicForm"; import { RackStructureComponentProps, RackLineCondition, @@ -31,6 +33,13 @@ import { RackStructureContext, } from "./types"; +// 기존 위치 데이터 타입 +interface ExistingLocation { + row_num: string; + level_num: string; + location_code: string; +} + // 고유 ID 생성 const generateId = () => `cond_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; @@ -185,6 +194,7 @@ export const RackStructureComponent: React.FC = ({ onChange, onConditionsChange, isPreview = false, + tableName, }) => { // 조건 목록 const [conditions, setConditions] = useState( @@ -200,6 +210,11 @@ export const RackStructureComponent: React.FC = ({ // 미리보기 데이터 const [previewData, setPreviewData] = useState([]); const [isPreviewGenerated, setIsPreviewGenerated] = useState(false); + + // 기존 데이터 중복 체크 관련 상태 + const [existingLocations, setExistingLocations] = useState([]); + const [isCheckingDuplicates, setIsCheckingDuplicates] = useState(false); + const [duplicateErrors, setDuplicateErrors] = useState<{ row: number; existingLevels: number[] }[]>([]); // 설정값 const maxConditions = config.maxConditions || 10; @@ -208,6 +223,60 @@ export const RackStructureComponent: React.FC = ({ const readonly = config.readonly || isPreview; const fieldMapping = config.fieldMapping || {}; + // 카테고리 라벨 캐시 상태 + const [categoryLabels, setCategoryLabels] = useState>({}); + + // 카테고리 코드인지 확인 + const isCategoryCode = (value: string | undefined): boolean => { + return typeof value === "string" && value.startsWith("CATEGORY_"); + }; + + // 카테고리 라벨 조회 (비동기) + useEffect(() => { + const loadCategoryLabels = async () => { + if (!formData) return; + + // 카테고리 코드인 값들만 수집 + const valuesToLookup: string[] = []; + const fieldsToCheck = [ + fieldMapping.floorField ? formData[fieldMapping.floorField] : undefined, + fieldMapping.zoneField ? formData[fieldMapping.zoneField] : undefined, + fieldMapping.locationTypeField ? formData[fieldMapping.locationTypeField] : undefined, + fieldMapping.statusField ? formData[fieldMapping.statusField] : undefined, + ]; + + for (const value of fieldsToCheck) { + if (value && isCategoryCode(value) && !categoryLabels[value]) { + valuesToLookup.push(value); + } + } + + if (valuesToLookup.length === 0) return; + + try { + // 카테고리 코드로 라벨 일괄 조회 + const response = await getCategoryLabelsByCodes(valuesToLookup); + if (response.success && response.data) { + console.log("✅ 카테고리 라벨 조회 완료:", response.data); + setCategoryLabels((prev) => ({ ...prev, ...response.data })); + } + } catch (error) { + console.error("카테고리 라벨 조회 실패:", error); + } + }; + + loadCategoryLabels(); + }, [formData, fieldMapping]); + + // 카테고리 코드를 라벨로 변환하는 헬퍼 함수 + const getCategoryLabel = useCallback((value: string | undefined): string | undefined => { + if (!value) return undefined; + if (isCategoryCode(value)) { + return categoryLabels[value] || value; + } + return value; + }, [categoryLabels]); + // 필드 매핑을 통해 formData에서 컨텍스트 추출 const context: RackStructureContext = useMemo(() => { // propContext가 있으면 우선 사용 @@ -216,27 +285,33 @@ export const RackStructureComponent: React.FC = ({ // formData와 fieldMapping을 사용하여 컨텍스트 생성 if (!formData) return {}; - return { + const rawFloor = fieldMapping.floorField ? formData[fieldMapping.floorField] : undefined; + const rawZone = fieldMapping.zoneField ? formData[fieldMapping.zoneField] : undefined; + const rawLocationType = fieldMapping.locationTypeField ? formData[fieldMapping.locationTypeField] : undefined; + const rawStatus = fieldMapping.statusField ? formData[fieldMapping.statusField] : undefined; + + const ctx = { warehouseCode: fieldMapping.warehouseCodeField ? formData[fieldMapping.warehouseCodeField] : undefined, warehouseName: fieldMapping.warehouseNameField ? formData[fieldMapping.warehouseNameField] : undefined, - floor: fieldMapping.floorField - ? formData[fieldMapping.floorField]?.toString() - : undefined, - zone: fieldMapping.zoneField - ? formData[fieldMapping.zoneField] - : undefined, - locationType: fieldMapping.locationTypeField - ? formData[fieldMapping.locationTypeField] - : undefined, - status: fieldMapping.statusField - ? formData[fieldMapping.statusField] - : undefined, + // 카테고리 값은 라벨로 변환 + floor: getCategoryLabel(rawFloor?.toString()), + zone: getCategoryLabel(rawZone), + locationType: getCategoryLabel(rawLocationType), + status: getCategoryLabel(rawStatus), }; - }, [propContext, formData, fieldMapping]); + + console.log("🏗️ [RackStructure] context 생성:", { + fieldMapping, + rawValues: { rawFloor, rawZone, rawLocationType, rawStatus }, + context: ctx, + }); + + return ctx; + }, [propContext, formData, fieldMapping, getCategoryLabel]); // 필수 필드 검증 const missingFields = useMemo(() => { @@ -283,6 +358,154 @@ export const RackStructureComponent: React.FC = ({ setConditions((prev) => prev.filter((cond) => cond.id !== id)); }, []); + // 열 범위 중복 검사 + const rowOverlapErrors = useMemo(() => { + const errors: { conditionIndex: number; overlappingWith: number; overlappingRows: number[] }[] = []; + + for (let i = 0; i < conditions.length; i++) { + const cond1 = conditions[i]; + if (cond1.startRow <= 0 || cond1.endRow < cond1.startRow) continue; + + for (let j = i + 1; j < conditions.length; j++) { + const cond2 = conditions[j]; + if (cond2.startRow <= 0 || cond2.endRow < cond2.startRow) continue; + + // 범위 겹침 확인 + const overlapStart = Math.max(cond1.startRow, cond2.startRow); + const overlapEnd = Math.min(cond1.endRow, cond2.endRow); + + if (overlapStart <= overlapEnd) { + // 겹치는 열 목록 + const overlappingRows: number[] = []; + for (let r = overlapStart; r <= overlapEnd; r++) { + overlappingRows.push(r); + } + + errors.push({ + conditionIndex: i, + overlappingWith: j, + overlappingRows, + }); + } + } + } + + return errors; + }, [conditions]); + + // 중복 열이 있는지 확인 + const hasRowOverlap = rowOverlapErrors.length > 0; + + // 기존 데이터 조회를 위한 값 추출 (useMemo 객체 참조 문제 방지) + const warehouseCodeForQuery = context.warehouseCode; + const floorForQuery = context.floor; + const zoneForQuery = context.zone; + + // 기존 데이터 조회 (창고/층/구역이 변경될 때마다) + useEffect(() => { + const loadExistingLocations = async () => { + console.log("🏗️ [RackStructure] 기존 데이터 조회 체크:", { + warehouseCode: warehouseCodeForQuery, + floor: floorForQuery, + zone: zoneForQuery, + }); + + // 필수 조건이 충족되지 않으면 기존 데이터 초기화 + if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) { + console.log("⚠️ [RackStructure] 필수 조건 미충족 - 조회 스킵"); + setExistingLocations([]); + setDuplicateErrors([]); + return; + } + + setIsCheckingDuplicates(true); + try { + // warehouse_location 테이블에서 해당 창고/층/구역의 기존 데이터 조회 + const filterParams = { + warehouse_id: warehouseCodeForQuery, + floor: floorForQuery, + zone: zoneForQuery, + }; + console.log("🔍 기존 위치 데이터 조회 시작:", filterParams); + + const response = await DynamicFormApi.getTableData("warehouse_location", { + filters: filterParams, + page: 1, + pageSize: 1000, // 충분히 큰 값 + }); + + console.log("🔍 기존 위치 데이터 응답:", response); + + // API 응답 구조: { success: true, data: [...] } 또는 { success: true, data: { data: [...] } } + const dataArray = Array.isArray(response.data) + ? response.data + : (response.data?.data || []); + + if (response.success && dataArray.length > 0) { + const existing = dataArray.map((item: any) => ({ + row_num: item.row_num, + level_num: item.level_num, + location_code: item.location_code, + })); + setExistingLocations(existing); + console.log("✅ 기존 위치 데이터 조회 완료:", existing.length, "개", existing); + } else { + console.log("⚠️ 기존 위치 데이터 없음 또는 조회 실패"); + setExistingLocations([]); + } + } catch (error) { + console.error("기존 위치 데이터 조회 실패:", error); + setExistingLocations([]); + } finally { + setIsCheckingDuplicates(false); + } + }; + + loadExistingLocations(); + }, [warehouseCodeForQuery, floorForQuery, zoneForQuery]); + + // 조건 변경 시 기존 데이터와 중복 체크 + useEffect(() => { + if (existingLocations.length === 0) { + setDuplicateErrors([]); + return; + } + + // 현재 조건에서 생성될 열 목록 + const plannedRows = new Map(); // row -> levels + conditions.forEach((cond) => { + if (cond.startRow > 0 && cond.endRow >= cond.startRow && cond.levels > 0) { + for (let row = cond.startRow; row <= cond.endRow; row++) { + const levels: number[] = []; + for (let level = 1; level <= cond.levels; level++) { + levels.push(level); + } + plannedRows.set(row, levels); + } + } + }); + + // 기존 데이터와 중복 체크 + const errors: { row: number; existingLevels: number[] }[] = []; + plannedRows.forEach((levels, row) => { + const existingForRow = existingLocations.filter( + (loc) => parseInt(loc.row_num) === row + ); + if (existingForRow.length > 0) { + const existingLevels = existingForRow.map((loc) => parseInt(loc.level_num)); + const duplicateLevels = levels.filter((l) => existingLevels.includes(l)); + if (duplicateLevels.length > 0) { + errors.push({ row, existingLevels: duplicateLevels }); + } + } + }); + + setDuplicateErrors(errors); + }, [conditions, existingLocations]); + + // 기존 데이터와 중복이 있는지 확인 + const hasDuplicateWithExisting = duplicateErrors.length > 0; + // 통계 계산 const statistics = useMemo(() => { let totalLocations = 0; @@ -312,11 +535,12 @@ export const RackStructureComponent: React.FC = ({ const floor = context?.floor || "1"; const zone = context?.zone || "A"; - // 코드 생성 (예: WH001-1A-01-1) + // 코드 생성 (예: WH001-1층D구역-01-1) const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`; - // 이름 생성 (예: A구역-01열-1단) - const name = `${zone}구역-${row.toString().padStart(2, "0")}열-${level}단`; + // 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용 + const zoneName = zone.includes("구역") ? zone : `${zone}구역`; + const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`; return { code, name }; }, @@ -325,12 +549,39 @@ export const RackStructureComponent: React.FC = ({ // 미리보기 생성 const generatePreview = useCallback(() => { + console.log("🔍 [generatePreview] 검증 시작:", { + missingFields, + hasRowOverlap, + hasDuplicateWithExisting, + duplicateErrorsCount: duplicateErrors.length, + existingLocationsCount: existingLocations.length, + }); + // 필수 필드 검증 if (missingFields.length > 0) { alert(`다음 필드를 먼저 입력해주세요: ${missingFields.join(", ")}`); return; } + // 열 범위 중복 검증 + if (hasRowOverlap) { + const overlapInfo = rowOverlapErrors.map((err) => { + const rows = err.overlappingRows.join(", "); + return `조건 ${err.conditionIndex + 1}과 조건 ${err.overlappingWith + 1}의 ${rows}열`; + }).join("\n"); + alert(`열 범위가 중복됩니다:\n${overlapInfo}\n\n중복된 열을 수정해주세요.`); + return; + } + + // 기존 데이터와 중복 검증 - duplicateErrors 직접 체크 + if (duplicateErrors.length > 0) { + const duplicateInfo = duplicateErrors.map((err) => { + return `${err.row}열 ${err.existingLevels.join(", ")}단`; + }).join(", "); + alert(`이미 등록된 위치가 있습니다:\n${duplicateInfo}\n\n해당 열/단을 제외하고 등록하거나, 기존 데이터를 삭제해주세요.`); + return; + } + const locations: GeneratedLocation[] = []; conditions.forEach((cond) => { @@ -338,15 +589,17 @@ export const RackStructureComponent: React.FC = ({ for (let row = cond.startRow; row <= cond.endRow; row++) { for (let level = 1; level <= cond.levels; level++) { const { code, name } = generateLocationCode(row, level); + // 테이블 컬럼명과 동일하게 생성 locations.push({ - rowNum: row, - levelNum: level, - locationCode: code, - locationName: name, - locationType: context?.locationType || "선반", + row_num: String(row), + level_num: String(level), + location_code: code, + location_name: name, + location_type: context?.locationType || "선반", status: context?.status || "사용", - // 추가 필드 - warehouseCode: context?.warehouseCode, + // 추가 필드 (테이블 컬럼명과 동일) + warehouse_id: context?.warehouseCode, + warehouse_name: context?.warehouseName, floor: context?.floor, zone: context?.zone, }); @@ -357,14 +610,14 @@ export const RackStructureComponent: React.FC = ({ // 정렬: 열 -> 단 순서 locations.sort((a, b) => { - if (a.rowNum !== b.rowNum) return a.rowNum - b.rowNum; - return a.levelNum - b.levelNum; + if (a.row_num !== b.row_num) return parseInt(a.row_num) - parseInt(b.row_num); + return parseInt(a.level_num) - parseInt(b.level_num); }); setPreviewData(locations); setIsPreviewGenerated(true); onChange?.(locations); - }, [conditions, context, generateLocationCode, onChange, missingFields]); + }, [conditions, context, generateLocationCode, onChange, missingFields, hasRowOverlap, duplicateErrors, existingLocations, rowOverlapErrors]); // 템플릿 저장 const saveTemplate = useCallback(() => { @@ -448,6 +701,66 @@ export const RackStructureComponent: React.FC = ({ )} + {/* 열 범위 중복 경고 */} + {hasRowOverlap && ( + + + + 열 범위가 중복됩니다! +
    + {rowOverlapErrors.map((err, idx) => ( +
  • + 조건 {err.conditionIndex + 1}과 조건 {err.overlappingWith + 1}: {err.overlappingRows.join(", ")}열 중복 +
  • + ))} +
+ + 중복된 열 범위를 수정해주세요. + +
+
+ )} + + {/* 기존 데이터 중복 경고 */} + {hasDuplicateWithExisting && ( + + + + 이미 등록된 위치가 있습니다! +
    + {duplicateErrors.map((err, idx) => ( +
  • + {err.row}열: {err.existingLevels.join(", ")}단 (이미 등록됨) +
  • + ))} +
+ + 해당 열/단을 제외하거나 기존 데이터를 삭제해주세요. + +
+
+ )} + + {/* 기존 데이터 로딩 중 표시 */} + {isCheckingDuplicates && ( + + + + 기존 위치 데이터를 확인하는 중... + + + )} + + {/* 기존 데이터 존재 알림 */} + {!isCheckingDuplicates && existingLocations.length > 0 && !hasDuplicateWithExisting && ( + + + + 해당 창고/층/구역에 {existingLocations.length}개의 위치가 이미 등록되어 있습니다. + + + )} + {/* 현재 매핑된 값 표시 */} {(context.warehouseCode || context.warehouseName || context.floor || context.zone) && (
@@ -548,10 +861,11 @@ export const RackStructureComponent: React.FC = ({ variant="outline" size="sm" onClick={generatePreview} + disabled={hasDuplicateWithExisting || hasRowOverlap || missingFields.length > 0 || isCheckingDuplicates} className="h-8 gap-1" > - 미리보기 생성 + {isCheckingDuplicates ? "확인 중..." : hasDuplicateWithExisting ? "중복 있음" : "미리보기 생성"} @@ -595,15 +909,15 @@ export const RackStructureComponent: React.FC = ({ {previewData.map((loc, idx) => ( {idx + 1} - {loc.locationCode} - {loc.locationName} - {context?.floor || "1"} - {context?.zone || "A"} + {loc.location_code} + {loc.location_name} + {loc.floor || context?.floor || "1"} + {loc.zone || context?.zone || "A"} - {loc.rowNum.toString().padStart(2, "0")} + {loc.row_num.padStart(2, "0")} - {loc.levelNum} - {loc.locationType} + {loc.level_num} + {loc.location_type} - ))} diff --git a/frontend/lib/registry/components/rack-structure/RackStructureRenderer.tsx b/frontend/lib/registry/components/rack-structure/RackStructureRenderer.tsx index ab832f51..e33658b5 100644 --- a/frontend/lib/registry/components/rack-structure/RackStructureRenderer.tsx +++ b/frontend/lib/registry/components/rack-structure/RackStructureRenderer.tsx @@ -14,24 +14,40 @@ export class RackStructureRenderer extends AutoRegisteringComponentRenderer { static componentDefinition = RackStructureDefinition; render(): React.ReactElement { - const { formData, isPreview, config } = this.props as any; + const { formData, isPreview, config, tableName, onFormDataChange } = this.props as Record; return ( } + tableName={tableName as string} + onChange={(locations) => + this.handleLocationsChange( + locations, + onFormDataChange as ((fieldName: string, value: unknown) => void) | undefined, + ) + } + isPreview={isPreview as boolean} /> ); } /** * 생성된 위치 데이터 변경 핸들러 + * formData에 _rackStructureLocations 키로 저장하여 저장 액션에서 감지 */ - protected handleLocationsChange = (locations: GeneratedLocation[]) => { - // 생성된 위치 데이터를 formData에 저장 + protected handleLocationsChange = ( + locations: GeneratedLocation[], + onFormDataChange?: (fieldName: string, value: unknown) => void, + ) => { + // 생성된 위치 데이터를 컴포넌트에 저장 this.updateComponent({ generatedLocations: locations }); + + // formData에도 저장하여 저장 액션에서 감지할 수 있도록 함 + if (onFormDataChange) { + console.log("📦 [RackStructure] 미리보기 데이터를 formData에 저장:", locations.length, "개"); + onFormDataChange("_rackStructureLocations", locations); + } }; } diff --git a/frontend/lib/registry/components/rack-structure/types.ts b/frontend/lib/registry/components/rack-structure/types.ts index 485a2208..5ab7bd7e 100644 --- a/frontend/lib/registry/components/rack-structure/types.ts +++ b/frontend/lib/registry/components/rack-structure/types.ts @@ -18,18 +18,19 @@ export interface RackStructureTemplate { createdAt?: string; } -// 생성될 위치 데이터 +// 생성될 위치 데이터 (테이블 컬럼명과 동일하게 매핑) export interface GeneratedLocation { - rowNum: number; // 열 번호 - levelNum: number; // 단 번호 - locationCode: string; // 위치 코드 (예: WH001-1A-01-1) - locationName: string; // 위치명 (예: A구역-01열-1단) - locationType?: string; // 위치 유형 - status?: string; // 사용 여부 + row_num: string; // 열 번호 (varchar) + level_num: string; // 단 번호 (varchar) + location_code: string; // 위치 코드 (예: WH001-1A-01-1) + location_name: string; // 위치명 (예: A구역-01열-1단) + location_type?: string; // 위치 유형 + status?: string; // 사용 여부 // 추가 필드 (상위 폼에서 매핑된 값) - warehouseCode?: string; - floor?: string; - zone?: string; + warehouse_id?: string; // 창고 ID/코드 + warehouse_name?: string; // 창고명 + floor?: string; // 층 + zone?: string; // 구역 } // 필드 매핑 설정 (상위 폼의 어떤 필드를 사용할지) diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 275efbb5..c5e86849 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -114,7 +114,7 @@ export interface ButtonActionConfig { geolocationExtraValue?: string | number | boolean; // 추가로 변경할 값 (예: "active") geolocationExtraKeyField?: string; // 다른 테이블의 키 필드 (예: "vehicle_id") geolocationExtraKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id") - + // 🆕 두 번째 테이블 설정 (위치정보 + 상태변경을 각각 다른 테이블에) geolocationSecondTableEnabled?: boolean; // 두 번째 테이블 사용 여부 geolocationSecondTableName?: string; // 두 번째 테이블명 (예: "vehicles") @@ -152,7 +152,7 @@ export interface ButtonActionConfig { updateTableName?: string; // 대상 테이블명 (다른 테이블 UPDATE 시) updateKeyField?: string; // 키 필드명 (WHERE 조건에 사용) updateKeySourceField?: string; // 키 값 소스 (폼 필드명 또는 __userId__ 등 특수 키워드) - + // 🆕 필드 값 변경 + 위치정보 수집 (update_field 액션에서 사용) updateWithGeolocation?: boolean; // 위치정보도 함께 수집할지 여부 updateGeolocationLatField?: string; // 위도 저장 필드 @@ -262,7 +262,7 @@ export interface ButtonActionContext { // 🆕 컴포넌트별 설정 (parentDataMapping 등) componentConfigs?: Record; // 컴포넌트 ID → 컴포넌트 설정 - + // 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터) splitPanelParentData?: Record; } @@ -276,10 +276,7 @@ export interface ButtonActionContext { * - __screenId__ : 현재 화면 ID * - __tableName__ : 현재 테이블명 */ -export function resolveSpecialKeyword( - sourceField: string | undefined, - context: ButtonActionContext -): any { +export function resolveSpecialKeyword(sourceField: string | undefined, context: ButtonActionContext): any { if (!sourceField) return undefined; // 특수 키워드 처리 @@ -416,6 +413,81 @@ export class ButtonActionExecutor { console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData:", context.formData); + // 🆕 렉 구조 컴포넌트 일괄 저장 감지 + let rackStructureLocations: any[] | undefined; + let rackStructureFieldKey = "_rackStructureLocations"; + let hasEmptyRackStructureField = false; + + // formData에서 렉 구조 데이터 또는 빈 배열 찾기 + for (const [key, value] of Object.entries(context.formData || {})) { + // 배열인 경우만 체크 + if (Array.isArray(value)) { + if (value.length > 0 && value[0]) { + const firstItem = value[0]; + const isNewFormat = + firstItem.location_code && + firstItem.location_name && + firstItem.row_num !== undefined && + firstItem.level_num !== undefined; + const isOldFormat = + firstItem.locationCode && + firstItem.locationName && + firstItem.rowNum !== undefined && + firstItem.levelNum !== undefined; + + if (isNewFormat || isOldFormat) { + console.log("🏗️ [handleSave] 렉 구조 데이터 감지 - 필드:", key); + rackStructureLocations = value; + rackStructureFieldKey = key; + break; + } + } else if (value.length === 0 && key.startsWith("comp_")) { + // comp_로 시작하는 빈 배열은 렉 구조 컴포넌트일 가능성 있음 + // allComponents에서 확인 + const rackStructureComponentInLayout = context.allComponents?.find( + (comp: any) => + comp.type === "component" && comp.componentId === "rack-structure" && comp.columnName === key, + ); + if (rackStructureComponentInLayout) { + console.log("🏗️ [handleSave] 렉 구조 컴포넌트 감지 (미리보기 없음) - 필드:", key); + hasEmptyRackStructureField = true; + rackStructureFieldKey = key; + } + } + } + } + + // 렉 구조 컴포넌트가 있지만 미리보기 데이터가 없는 경우 + if (hasEmptyRackStructureField && (!rackStructureLocations || rackStructureLocations.length === 0)) { + alert("미리보기를 먼저 생성해주세요.\n\n렉 구조 조건을 설정한 후 '미리보기 생성' 버튼을 클릭하세요."); + return false; + } + + // 🆕 렉 구조 등록 화면 감지 (warehouse_location 테이블 + floor/zone 필드 있음 + 렉 구조 데이터 없음) + // 이 경우 일반 저장을 차단하고 미리보기 생성을 요구 + const isRackStructureScreen = + context.tableName === "warehouse_location" && + context.formData?.floor && + context.formData?.zone && + !rackStructureLocations; + + if (isRackStructureScreen) { + console.log("🏗️ [handleSave] 렉 구조 등록 화면 감지 - 미리보기 데이터 없음"); + alert( + "렉 구조 등록 화면입니다.\n\n" + + "미리보기를 먼저 생성해주세요.\n" + + "- 중복된 위치가 있으면 미리보기가 생성되지 않습니다.\n" + + "- 기존 데이터를 삭제하거나 다른 열/단을 선택해주세요.", + ); + return false; + } + + // 렉 구조 데이터가 있으면 일괄 저장 + if (rackStructureLocations && rackStructureLocations.length > 0) { + console.log("🏗️ [handleSave] 렉 구조 컴포넌트 감지 - 일괄 저장 시작:", rackStructureLocations.length, "개"); + return await this.handleRackStructureBatchSave(config, context, rackStructureLocations, rackStructureFieldKey); + } + // 🆕 SelectedItemsDetailInput 배치 저장 처리 (fieldGroups 구조) console.log("🔍 [handleSave] formData 구조 확인:", { isFormDataArray: Array.isArray(context.formData), @@ -585,12 +657,12 @@ export class ButtonActionExecutor { if (Object.keys(fieldsWithNumbering).length > 0) { console.log("🎯 채번 규칙 할당 시작 (allocateCode 호출)"); const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); - + for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { try { console.log(`🔄 ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`); const allocateResult = await allocateNumberingCode(ruleId); - + if (allocateResult.success && allocateResult.data?.generatedCode) { const newCode = allocateResult.data.generatedCode; console.log(`✅ ${fieldName} 새 코드 할당: ${formData[fieldName]} → ${newCode}`); @@ -691,8 +763,8 @@ export class ButtonActionExecutor { } // 🆕 v3.9: RepeatScreenModal의 외부 테이블 데이터 저장 처리 - const repeatScreenModalKeys = Object.keys(context.formData).filter((key) => - key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations" + const repeatScreenModalKeys = Object.keys(context.formData).filter( + (key) => key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations", ); // RepeatScreenModal 데이터가 있으면 해당 테이블에 대한 메인 저장은 건너뜀 @@ -749,7 +821,7 @@ export class ButtonActionExecutor { console.log(`📝 [handleSave] ${targetTable} INSERT:`, dataWithMeta); const insertResult = await apiClient.post( `/table-management/tables/${targetTable}/add`, - dataWithMeta + dataWithMeta, ); console.log(`✅ [handleSave] ${targetTable} INSERT 완료:`, insertResult.data); } else if (id) { @@ -757,10 +829,10 @@ export class ButtonActionExecutor { const originalData = { id }; const updatedData = { ...dataWithMeta, id }; console.log(`📝 [handleSave] ${targetTable} UPDATE:`, { originalData, updatedData }); - const updateResult = await apiClient.put( - `/table-management/tables/${targetTable}/edit`, - { originalData, updatedData } - ); + const updateResult = await apiClient.put(`/table-management/tables/${targetTable}/edit`, { + originalData, + updatedData, + }); console.log(`✅ [handleSave] ${targetTable} UPDATE 완료:`, updateResult.data); } } catch (error: any) { @@ -794,12 +866,14 @@ export class ButtonActionExecutor { [joinKey.targetField]: sourceValue, }; - console.log(`📊 [handleSave] ${targetTable}.${targetColumn} = ${aggregatedValue} (조인: ${joinKey.sourceField} = ${sourceValue})`); - - const updateResult = await apiClient.put( - `/table-management/tables/${targetTable}/edit`, - { originalData, updatedData } + console.log( + `📊 [handleSave] ${targetTable}.${targetColumn} = ${aggregatedValue} (조인: ${joinKey.sourceField} = ${sourceValue})`, ); + + const updateResult = await apiClient.put(`/table-management/tables/${targetTable}/edit`, { + originalData, + updatedData, + }); console.log(`✅ [handleSave] ${targetTable} 집계 저장 완료:`, updateResult.data); } catch (error: any) { console.error(`❌ [handleSave] ${targetTable} 집계 저장 실패:`, error.response?.data || error.message); @@ -856,7 +930,7 @@ export class ButtonActionExecutor { // 복합키인 경우 로그 출력 if (primaryKeys.length > 1) { - console.log(`🔗 복합 기본키 감지:`, primaryKeys); + console.log("🔗 복합 기본키 감지:", primaryKeys); console.log(`📍 첫 번째 키 (${primaryKeyColumn}) 값을 사용: ${value}`); } @@ -908,6 +982,184 @@ export class ButtonActionExecutor { return await this.handleSave(config, context); } + /** + * 🆕 렉 구조 컴포넌트 일괄 저장 처리 + * 미리보기에서 생성된 위치 데이터를 일괄 INSERT + */ + private static async handleRackStructureBatchSave( + config: ButtonActionConfig, + context: ButtonActionContext, + locations: any[], + rackStructureFieldKey: string = "_rackStructureLocations", + ): Promise { + const { tableName, screenId, userId, companyCode } = context; + + console.log("🏗️ [handleRackStructureBatchSave] 렉 구조 일괄 저장 시작:", { + locationsCount: locations.length, + tableName, + screenId, + rackStructureFieldKey, + }); + + if (!tableName) { + throw new Error("테이블명이 지정되지 않았습니다."); + } + + if (locations.length === 0) { + throw new Error("저장할 위치 데이터가 없습니다. 먼저 미리보기를 생성해주세요."); + } + + console.log("🏗️ [handleRackStructureBatchSave] 렉 구조 데이터 예시:", locations[0]); + + // 저장 전 중복 체크 + const firstLocation = locations[0]; + const warehouseId = firstLocation.warehouse_id || firstLocation.warehouseCode; + const floor = firstLocation.floor; + const zone = firstLocation.zone; + + if (warehouseId && floor && zone) { + console.log("🔍 [handleRackStructureBatchSave] 기존 데이터 중복 체크:", { warehouseId, floor, zone }); + + try { + const existingResponse = await DynamicFormApi.getTableData(tableName, { + filters: { + warehouse_id: warehouseId, + floor: floor, + zone: zone, + }, + page: 1, + pageSize: 1000, + }); + + // API 응답 구조에 따라 데이터 추출 + const responseData = existingResponse.data as any; + const existingData = responseData?.data || responseData || []; + + if (Array.isArray(existingData) && existingData.length > 0) { + // 중복되는 위치 확인 + const existingSet = new Set(existingData.map((loc: any) => `${loc.row_num}-${loc.level_num}`)); + + const duplicates = locations.filter((loc) => { + const key = `${loc.row_num || loc.rowNum}-${loc.level_num || loc.levelNum}`; + return existingSet.has(key); + }); + + if (duplicates.length > 0) { + const duplicateInfo = duplicates + .slice(0, 5) + .map((d) => `${d.row_num || d.rowNum}열 ${d.level_num || d.levelNum}단`) + .join(", "); + + const moreCount = duplicates.length > 5 ? ` 외 ${duplicates.length - 5}개` : ""; + + alert( + `이미 등록된 위치가 있습니다!\n\n중복 위치: ${duplicateInfo}${moreCount}\n\n해당 위치를 제외하거나 기존 데이터를 삭제해주세요.`, + ); + return false; + } + } + } catch (checkError) { + console.warn("⚠️ [handleRackStructureBatchSave] 중복 체크 실패 (저장 계속 진행):", checkError); + } + } + + // 각 위치 데이터를 그대로 저장 (렉 구조 컴포넌트에서 이미 테이블 컬럼명으로 생성됨) + const recordsToInsert = locations.map((loc) => { + // 렉 구조 컴포넌트에서 생성된 데이터를 그대로 사용 + // 새로운 형식(스네이크 케이스)과 기존 형식(카멜 케이스) 모두 지원 + const record: Record = { + // 렉 구조에서 생성된 필드 (이미 테이블 컬럼명과 동일) + location_code: loc.location_code || loc.locationCode, + location_name: loc.location_name || loc.locationName, + row_num: loc.row_num || String(loc.rowNum), + level_num: loc.level_num || String(loc.levelNum), + // 창고 정보 (렉 구조 컴포넌트에서 전달) + warehouse_id: loc.warehouse_id || loc.warehouseCode, + warehouse_name: loc.warehouse_name || loc.warehouseName, + // 위치 정보 (렉 구조 컴포넌트에서 전달) + floor: loc.floor, + zone: loc.zone, + location_type: loc.location_type || loc.locationType, + status: loc.status || "사용", + // 사용자 정보 추가 + writer: userId, + company_code: companyCode, + }; + + return record; + }); + + console.log("🏗️ [handleRackStructureBatchSave] 저장할 레코드 수:", recordsToInsert.length); + console.log("🏗️ [handleRackStructureBatchSave] 첫 번째 레코드 예시:", recordsToInsert[0]); + + // 일괄 INSERT 실행 + try { + let successCount = 0; + let errorCount = 0; + const errors: string[] = []; + + for (let i = 0; i < recordsToInsert.length; i++) { + const record = recordsToInsert[i]; + try { + console.log(`🏗️ [handleRackStructureBatchSave] 저장 중 (${i + 1}/${recordsToInsert.length}):`, record); + + const result = await DynamicFormApi.saveFormData({ + screenId, + tableName, + data: record, + }); + + console.log(`🏗️ [handleRackStructureBatchSave] API 응답 (${i + 1}):`, result); + + if (result.success) { + successCount++; + } else { + errorCount++; + const errorMsg = result.message || result.error || "알 수 없는 오류"; + errors.push(errorMsg); + console.error(`❌ [handleRackStructureBatchSave] 저장 실패 (${i + 1}):`, errorMsg); + } + } catch (error: any) { + errorCount++; + const errorMsg = error.message || "저장 중 오류 발생"; + errors.push(errorMsg); + console.error(`❌ [handleRackStructureBatchSave] 예외 발생 (${i + 1}):`, error); + } + } + + console.log("🏗️ [handleRackStructureBatchSave] 저장 완료:", { + successCount, + errorCount, + errors: errors.slice(0, 5), // 처음 5개 오류만 로그 + }); + + if (errorCount > 0) { + if (successCount > 0) { + alert(`${successCount}개 저장 완료, ${errorCount}개 저장 실패\n\n오류: ${errors.slice(0, 3).join("\n")}`); + } else { + throw new Error(`저장 실패: ${errors[0]}`); + } + } else { + alert(`${successCount}개의 위치가 성공적으로 등록되었습니다.`); + } + + // 성공 후 새로고침 + if (context.onRefresh) { + context.onRefresh(); + } + + // 모달 닫기 + if (context.onClose) { + context.onClose(); + } + + return successCount > 0; + } catch (error: any) { + console.error("🏗️ [handleRackStructureBatchSave] 일괄 저장 오류:", error); + throw error; + } + } + /** * 🆕 배치 저장 액션 처리 (SelectedItemsDetailInput용 - 새로운 데이터 구조) * ItemData[] → 각 품목의 details 배열을 개별 레코드로 저장 @@ -919,7 +1171,7 @@ export class ButtonActionExecutor { ): Promise { const { formData, tableName, screenId, selectedRowsData, originalData } = context; - console.log(`🔍 [handleBatchSave] context 확인:`, { + console.log("🔍 [handleBatchSave] context 확인:", { hasSelectedRowsData: !!selectedRowsData, selectedRowsCount: selectedRowsData?.length || 0, hasOriginalData: !!originalData, @@ -1137,7 +1389,7 @@ export class ButtonActionExecutor { try { // 플로우 선택 데이터 우선 사용 - let dataToDelete = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; + const dataToDelete = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; console.log("🔍 handleDelete - 데이터 소스 확인:", { hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0), @@ -1207,7 +1459,7 @@ export class ButtonActionExecutor { if (idField) deleteId = rowData[idField]; } - console.log(`🔍 폴백 방법으로 ID 추출:`, deleteId); + console.log("🔍 폴백 방법으로 ID 추출:", deleteId); } console.log("선택된 행 데이터:", rowData); @@ -1237,7 +1489,7 @@ export class ButtonActionExecutor { } else { console.log("🔄 테이블 데이터 삭제 완료, 테이블 새로고침 호출"); context.onRefresh?.(); // 테이블 새로고침 - + // 🆕 분할 패널 등 전역 테이블 새로고침 이벤트 발생 window.dispatchEvent(new CustomEvent("refreshTable")); console.log("🔄 refreshTable 전역 이벤트 발생"); @@ -1264,11 +1516,11 @@ export class ButtonActionExecutor { } context.onRefresh?.(); - + // 🆕 분할 패널 등 전역 테이블 새로고침 이벤트 발생 window.dispatchEvent(new CustomEvent("refreshTable")); console.log("🔄 refreshTable 전역 이벤트 발생 (단일 삭제)"); - + toast.success(config.successMessage || "삭제되었습니다."); return true; } catch (error) { @@ -1550,12 +1802,12 @@ export class ButtonActionExecutor { // 🆕 부모 화면의 선택된 데이터 가져오기 (excludeFilter에서 사용) const rawParentData = dataRegistry[dataSourceId]?.[0]?.originalData || dataRegistry[dataSourceId]?.[0] || {}; - + // 🆕 필드 매핑 적용 (소스 컬럼 → 타겟 컬럼) - let parentData = { ...rawParentData }; + const parentData = { ...rawParentData }; if (config.fieldMappings && Array.isArray(config.fieldMappings) && config.fieldMappings.length > 0) { console.log("🔄 [openModalWithData] 필드 매핑 적용:", config.fieldMappings); - + config.fieldMappings.forEach((mapping: { sourceField: string; targetField: string }) => { if (mapping.sourceField && mapping.targetField && rawParentData[mapping.sourceField] !== undefined) { // 타겟 필드에 소스 필드 값 복사 @@ -1564,7 +1816,7 @@ export class ButtonActionExecutor { } }); } - + console.log("📦 [openModalWithData] 부모 데이터 전달:", { dataSourceId, rawParentData, @@ -1688,7 +1940,7 @@ export class ButtonActionExecutor { const { selectedRowsData, flowSelectedData } = context; // 플로우 선택 데이터 우선 사용 - let dataToEdit = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; + const dataToEdit = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; // 선택된 데이터가 없는 경우 if (!dataToEdit || dataToEdit.length === 0) { @@ -1868,7 +2120,7 @@ export class ButtonActionExecutor { const { selectedRowsData, flowSelectedData } = context; // 플로우 선택 데이터 우선 사용 - let dataToCopy = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; + const dataToCopy = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; console.log("📋 handleCopy - 데이터 소스 확인:", { hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0), @@ -1980,7 +2232,7 @@ export class ButtonActionExecutor { }); if (resetFieldName) { - toast.success(`복사본이 생성되었습니다. 품목코드는 저장 시 자동으로 생성됩니다.`); + toast.success("복사본이 생성되었습니다. 품목코드는 저장 시 자동으로 생성됩니다."); } else { console.warn("⚠️ 품목코드 필드를 찾을 수 없습니다. 전체 데이터를 복사합니다."); console.warn("⚠️ 사용 가능한 필드:", Object.keys(copiedData)); @@ -2753,7 +3005,7 @@ export class ButtonActionExecutor { } else { console.warn(`⚠️ 매핑 실패: ${sourceField} → ${targetField} (값을 찾을 수 없음)`); console.warn(` - valueType: ${valueType}, defaultValue: ${defaultValue}`); - console.warn(` - 소스 데이터 키들:`, Object.keys(sourceData)); + console.warn(" - 소스 데이터 키들:", Object.keys(sourceData)); console.warn(` - sourceData[${sourceField}] =`, sourceData[sourceField]); return; // 값이 없으면 해당 필드는 스킵 } @@ -2791,7 +3043,7 @@ export class ButtonActionExecutor { if (result.success) { console.log("✅ 삽입 성공:", result); - toast.success(`데이터가 타겟 테이블에 성공적으로 삽입되었습니다.`); + toast.success("데이터가 타겟 테이블에 성공적으로 삽입되었습니다."); } else { throw new Error(result.message || "삽입 실패"); } @@ -3020,7 +3272,7 @@ export class ButtonActionExecutor { const layoutResponse = await apiClient.get(`/screen-management/screens/${context.screenId}/layout`); if (layoutResponse.data?.success && layoutResponse.data?.data) { - let layoutData = layoutResponse.data.data; + const layoutData = layoutResponse.data.data; // components가 문자열이면 파싱 if (typeof layoutData.components === "string") { @@ -3455,13 +3707,13 @@ export class ButtonActionExecutor { const totalRows = preview.totalAffectedRows; const confirmMerge = confirm( - `⚠️ 코드 병합 확인\n\n` + + "⚠️ 코드 병합 확인\n\n" + `${oldValue} → ${newValue}\n\n` + - `영향받는 데이터:\n` + + "영향받는 데이터:\n" + `- 테이블 수: ${preview.preview.length}개\n` + `- 총 행 수: ${totalRows}개\n\n` + `데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` + - `계속하시겠습니까?`, + "계속하시겠습니까?", ); if (!confirmMerge) { @@ -3486,7 +3738,7 @@ export class ButtonActionExecutor { if (response.data.success) { const data = response.data.data; toast.success( - `코드 병합 완료!\n` + `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`, + "코드 병합 완료!\n" + `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`, ); // 화면 새로고침 @@ -3532,9 +3784,10 @@ export class ButtonActionExecutor { } // Trip ID 생성 - const tripId = config.trackingAutoGenerateTripId !== false - ? `TRIP_${Date.now()}_${context.userId || "unknown"}` - : context.formData?.[config.trackingTripIdField || "trip_id"] || `TRIP_${Date.now()}`; + const tripId = + config.trackingAutoGenerateTripId !== false + ? `TRIP_${Date.now()}_${context.userId || "unknown"}` + : context.formData?.[config.trackingTripIdField || "trip_id"] || `TRIP_${Date.now()}`; this.currentTripId = tripId; this.trackingContext = context; @@ -3565,7 +3818,7 @@ export class ButtonActionExecutor { const keyValue = resolveSpecialKeyword(config.trackingStatusKeySourceField || "__userId__", context); if (keyValue) { - await apiClient.put(`/dynamic-form/update-field`, { + await apiClient.put("/dynamic-form/update-field", { tableName: statusTableName, keyField: keyField, keyValue: keyValue, @@ -3591,9 +3844,11 @@ export class ButtonActionExecutor { toast.success(config.successMessage || `위치 추적이 시작되었습니다. (${interval / 1000}초 간격)`); // 추적 시작 이벤트 발생 (UI 업데이트용) - window.dispatchEvent(new CustomEvent("trackingStarted", { - detail: { tripId, interval } - })); + window.dispatchEvent( + new CustomEvent("trackingStarted", { + detail: { tripId, interval }, + }), + ); return true; } catch (error: any) { @@ -3623,26 +3878,36 @@ export class ButtonActionExecutor { const tripId = this.currentTripId; // 마지막 위치 저장 (trip_status를 completed로) - const departure = this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null; + const departure = + this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null; const arrival = this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null; const departureName = this.trackingContext?.formData?.["departure_name"] || null; const destinationName = this.trackingContext?.formData?.["destination_name"] || null; - const vehicleId = this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null; + const vehicleId = + this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null; - await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId, "completed"); + await this.saveLocationToHistory( + tripId, + departure, + arrival, + departureName, + destinationName, + vehicleId, + "completed", + ); // 🆕 거리/시간 계산 및 저장 if (tripId) { try { const tripStats = await this.calculateTripStats(tripId); console.log("📊 운행 통계:", tripStats); - + // 운행 통계를 두 테이블에 저장 if (tripStats) { const distanceMeters = Math.round(tripStats.totalDistanceKm * 1000); // km → m const timeMinutes = tripStats.totalTimeMinutes; const userId = this.trackingUserId || context.userId; - + console.log("💾 운행 통계 DB 저장 시도:", { tripId, userId, @@ -3651,34 +3916,37 @@ export class ButtonActionExecutor { startTime: tripStats.startTime, endTime: tripStats.endTime, }); - + const { apiClient } = await import("@/lib/api/client"); - + // 1️⃣ vehicle_location_history 마지막 레코드에 통계 저장 (이력용) try { - const lastRecordResponse = await apiClient.post(`/table-management/tables/vehicle_location_history/data`, { - page: 1, - size: 1, - search: { trip_id: tripId }, - sortBy: "recorded_at", - sortOrder: "desc", - autoFilter: true, - }); - + const lastRecordResponse = await apiClient.post( + "/table-management/tables/vehicle_location_history/data", + { + page: 1, + size: 1, + search: { trip_id: tripId }, + sortBy: "recorded_at", + sortOrder: "desc", + autoFilter: true, + }, + ); + const lastRecordData = lastRecordResponse.data?.data?.data || lastRecordResponse.data?.data?.rows || []; if (lastRecordData.length > 0) { const lastRecordId = lastRecordData[0].id; console.log("📍 마지막 레코드 ID:", lastRecordId); - + const historyUpdates = [ { field: "trip_distance", value: distanceMeters }, { field: "trip_time", value: timeMinutes }, { field: "trip_start", value: tripStats.startTime }, { field: "trip_end", value: tripStats.endTime }, ]; - + for (const update of historyUpdates) { - await apiClient.put(`/dynamic-form/update-field`, { + await apiClient.put("/dynamic-form/update-field", { tableName: "vehicle_location_history", keyField: "id", keyValue: lastRecordId, @@ -3693,7 +3961,7 @@ export class ButtonActionExecutor { } catch (historyError) { console.warn("⚠️ vehicle_location_history 저장 실패:", historyError); } - + // 2️⃣ vehicles 테이블에도 마지막 운행 통계 업데이트 (최신 정보용) if (userId) { try { @@ -3703,9 +3971,9 @@ export class ButtonActionExecutor { { field: "last_trip_start", value: tripStats.startTime }, { field: "last_trip_end", value: tripStats.endTime }, ]; - + for (const update of vehicleUpdates) { - await apiClient.put(`/dynamic-form/update-field`, { + await apiClient.put("/dynamic-form/update-field", { tableName: "vehicles", keyField: "user_id", keyValue: userId, @@ -3718,19 +3986,23 @@ export class ButtonActionExecutor { console.warn("⚠️ vehicles 테이블 저장 실패:", vehicleError); } } - + // 이벤트로 통계 전달 (UI에서 표시용) - window.dispatchEvent(new CustomEvent("tripCompleted", { - detail: { - tripId, - totalDistanceKm: tripStats.totalDistanceKm, - totalTimeMinutes: tripStats.totalTimeMinutes, - startTime: tripStats.startTime, - endTime: tripStats.endTime, - } - })); - - toast.success(`운행 종료! 총 ${tripStats.totalDistanceKm.toFixed(1)}km, ${tripStats.totalTimeMinutes}분 소요`); + window.dispatchEvent( + new CustomEvent("tripCompleted", { + detail: { + tripId, + totalDistanceKm: tripStats.totalDistanceKm, + totalTimeMinutes: tripStats.totalTimeMinutes, + startTime: tripStats.startTime, + endTime: tripStats.endTime, + }, + }), + ); + + toast.success( + `운행 종료! 총 ${tripStats.totalDistanceKm.toFixed(1)}km, ${tripStats.totalTimeMinutes}분 소요`, + ); } } catch (statsError) { console.warn("⚠️ 운행 통계 계산 실패:", statsError); @@ -3746,10 +4018,13 @@ export class ButtonActionExecutor { const { apiClient } = await import("@/lib/api/client"); const statusTableName = effectiveConfig.trackingStatusTableName || effectiveContext.tableName; const keyField = effectiveConfig.trackingStatusKeyField || "user_id"; - const keyValue = resolveSpecialKeyword(effectiveConfig.trackingStatusKeySourceField || "__userId__", effectiveContext); + const keyValue = resolveSpecialKeyword( + effectiveConfig.trackingStatusKeySourceField || "__userId__", + effectiveContext, + ); if (keyValue) { - await apiClient.put(`/dynamic-form/update-field`, { + await apiClient.put("/dynamic-form/update-field", { tableName: statusTableName, keyField: keyField, keyValue: keyValue, @@ -3771,9 +4046,11 @@ export class ButtonActionExecutor { toast.success(config.successMessage || "위치 추적이 종료되었습니다."); // 추적 종료 이벤트 발생 (UI 업데이트용) - window.dispatchEvent(new CustomEvent("trackingStopped", { - detail: { tripId } - })); + window.dispatchEvent( + new CustomEvent("trackingStopped", { + detail: { tripId }, + }), + ); // 화면 새로고침 context.onRefresh?.(); @@ -3798,8 +4075,8 @@ export class ButtonActionExecutor { try { // vehicle_location_history에서 해당 trip의 모든 위치 조회 const { apiClient } = await import("@/lib/api/client"); - - const response = await apiClient.post(`/table-management/tables/vehicle_location_history/data`, { + + const response = await apiClient.post("/table-management/tables/vehicle_location_history/data", { page: 1, size: 10000, search: { trip_id: tripId }, @@ -3814,7 +4091,7 @@ export class ButtonActionExecutor { // 응답 형식: data.data.data 또는 data.data.rows const rows = response.data?.data?.data || response.data?.data?.rows || []; - + if (!rows.length) { console.log("📊 통계 계산: 데이터 없음"); return null; @@ -3834,13 +4111,13 @@ export class ButtonActionExecutor { for (let i = 1; i < locations.length; i++) { const prev = locations[i - 1]; const curr = locations[i]; - + if (prev.latitude && prev.longitude && curr.latitude && curr.longitude) { const distance = this.calculateDistance( parseFloat(prev.latitude), parseFloat(prev.longitude), parseFloat(curr.latitude), - parseFloat(curr.longitude) + parseFloat(curr.longitude), ); totalDistanceM += distance; } @@ -3874,12 +4151,11 @@ export class ButtonActionExecutor { */ private static calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { const R = 6371000; // 지구 반경 (미터) - const dLat = (lat2 - lat1) * Math.PI / 180; - const dLon = (lon2 - lon1) * Math.PI / 180; - const a = + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * Math.PI) / 180; + const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * - Math.sin(dLon / 2) * Math.sin(dLon / 2); + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } @@ -3895,7 +4171,7 @@ export class ButtonActionExecutor { departureName: string | null, destinationName: string | null, vehicleId: number | null, - tripStatus: string = "active" + tripStatus: string = "active", ): Promise { return new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition( @@ -3925,7 +4201,7 @@ export class ButtonActionExecutor { console.log("📍 [saveLocationToHistory] 위치 저장:", locationData); // 1. vehicle_location_history에 저장 - const response = await apiClient.post(`/dynamic-form/location-history`, locationData); + const response = await apiClient.post("/dynamic-form/location-history", locationData); if (response.data?.success) { console.log("✅ 위치 이력 저장 성공:", response.data.data); @@ -3943,7 +4219,7 @@ export class ButtonActionExecutor { if (keyValue) { try { // latitude 업데이트 - await apiClient.put(`/dynamic-form/update-field`, { + await apiClient.put("/dynamic-form/update-field", { tableName: vehiclesTableName, keyField, keyValue, @@ -3952,7 +4228,7 @@ export class ButtonActionExecutor { }); // longitude 업데이트 - await apiClient.put(`/dynamic-form/update-field`, { + await apiClient.put("/dynamic-form/update-field", { tableName: vehiclesTableName, keyField, keyValue, @@ -3982,7 +4258,7 @@ export class ButtonActionExecutor { enableHighAccuracy: true, timeout: 10000, maximumAge: 0, - } + }, ); }); } @@ -4172,14 +4448,14 @@ export class ButtonActionExecutor { if (keyValue && targetTableName) { try { const { apiClient } = await import("@/lib/api/client"); - + // 위치 정보 필드들 업데이트 (위도, 경도, 정확도, 타임스탬프) const fieldsToUpdate = { ...updates }; - + // formData에서 departure, arrival만 포함 (테이블에 있을 가능성 높은 필드만) if (context.formData?.departure) fieldsToUpdate.departure = context.formData.departure; if (context.formData?.arrival) fieldsToUpdate.arrival = context.formData.arrival; - + // 추가 필드 변경 (status 등) if (config.geolocationExtraField && config.geolocationExtraValue !== undefined) { fieldsToUpdate[config.geolocationExtraField] = config.geolocationExtraValue; @@ -4191,7 +4467,7 @@ export class ButtonActionExecutor { let successCount = 0; for (const [field, value] of Object.entries(fieldsToUpdate)) { try { - const response = await apiClient.put(`/dynamic-form/update-field`, { + const response = await apiClient.put("/dynamic-form/update-field", { tableName: targetTableName, keyField, keyValue, @@ -4210,10 +4486,15 @@ export class ButtonActionExecutor { // 🆕 연속 위치 추적 시작 (공차 상태에서도 위치 기록) if (config.emptyVehicleTracking !== false) { await this.startEmptyVehicleTracking(config, context, { - latitude, longitude, accuracy, speed, heading, altitude + latitude, + longitude, + accuracy, + speed, + heading, + altitude, }); } - + toast.success(config.successMessage || "공차 등록이 완료되었습니다. 위치 추적을 시작합니다."); } catch (saveError) { console.error("❌ 위치정보 자동 저장 실패:", saveError); @@ -4261,9 +4542,16 @@ export class ButtonActionExecutor { * 공차 상태에서 연속 위치 추적 시작 */ private static async startEmptyVehicleTracking( - config: ButtonActionConfig, + config: ButtonActionConfig, context: ButtonActionContext, - initialPosition: { latitude: number; longitude: number; accuracy: number | null; speed: number | null; heading: number | null; altitude: number | null } + initialPosition: { + latitude: number; + longitude: number; + accuracy: number | null; + speed: number | null; + heading: number | null; + altitude: number | null; + }, ): Promise { try { // 기존 추적이 있으면 중지 @@ -4273,7 +4561,7 @@ export class ButtonActionExecutor { } const { apiClient } = await import("@/lib/api/client"); - + // Trip ID 생성 (공차용) const tripId = `EMPTY-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`; this.emptyVehicleTripId = tripId; @@ -4315,7 +4603,7 @@ export class ButtonActionExecutor { this.emptyVehicleWatchId = navigator.geolocation.watchPosition( async (position) => { const { latitude, longitude, accuracy, speed, heading, altitude } = position.coords; - + try { await apiClient.post("/dynamic-form/location-history", { tripId: this.emptyVehicleTripId, @@ -4345,7 +4633,7 @@ export class ButtonActionExecutor { enableHighAccuracy: true, timeout: trackingInterval, maximumAge: 0, - } + }, ); console.log("🚗 공차 위치 추적 시작:", { tripId, watchId: this.emptyVehicleWatchId }); @@ -4435,26 +4723,30 @@ export class ButtonActionExecutor { * 운행알림 및 종료 액션 처리 * - 위치 수집 + 상태 변경 + 연속 추적 (시작/종료) */ - private static async handleOperationControl(config: ButtonActionConfig, context: ButtonActionContext): Promise { + private static async handleOperationControl( + config: ButtonActionConfig, + context: ButtonActionContext, + ): Promise { try { console.log("🔄 운행알림/종료 액션 실행:", { config, context }); // 🆕 출발지/도착지 필수 체크 (운행 시작 모드일 때만) // updateTrackingMode가 "start"이거나 updateTargetValue가 "active"/"inactive"인 경우 - const isStartMode = config.updateTrackingMode === "start" || - config.updateTargetValue === "active" || - config.updateTargetValue === "inactive"; - + const isStartMode = + config.updateTrackingMode === "start" || + config.updateTargetValue === "active" || + config.updateTargetValue === "inactive"; + if (isStartMode) { // 출발지/도착지 필드명 (기본값: departure, destination) const departureField = config.trackingDepartureField || "departure"; const destinationField = config.trackingArrivalField || "destination"; - + const departure = context.formData?.[departureField]; const destination = context.formData?.[destinationField]; - + console.log("📍 출발지/도착지 체크:", { departureField, destinationField, departure, destination }); - + if (!departure || departure === "" || !destination || destination === "") { toast.error("출발지와 도착지를 먼저 선택해주세요."); return false; @@ -4570,7 +4862,7 @@ export class ButtonActionExecutor { } } catch (geoError: any) { toast.dismiss(loadingToastId); - + // GeolocationPositionError 처리 if (geoError.code === 1) { toast.error("위치 정보 접근이 거부되었습니다."); @@ -4602,11 +4894,11 @@ export class ButtonActionExecutor { const keyField = config.updateKeyField; const keySourceField = config.updateKeySourceField; const targetTableName = config.updateTableName || tableName; - + if (keyField && keySourceField) { // 특수 키워드 변환 (예: __userId__ → 실제 사용자 ID) const keyValue = resolveSpecialKeyword(keySourceField, context); - + console.log("🔄 필드 값 변경 - 키 필드 사용:", { targetTable: targetTableName, keyField, @@ -4614,43 +4906,45 @@ export class ButtonActionExecutor { keyValue, updates, }); - + if (!keyValue) { console.warn("⚠️ 키 값이 없어서 업데이트를 건너뜁니다:", { keySourceField, keyValue }); toast.error("레코드를 식별할 키 값이 없습니다."); return false; } - + try { // 각 필드에 대해 개별 UPDATE 호출 const { apiClient } = await import("@/lib/api/client"); - + for (const [field, value] of Object.entries(updates)) { console.log(`🔄 DB UPDATE: ${targetTableName}.${field} = ${value} WHERE ${keyField} = ${keyValue}`); - - const response = await apiClient.put(`/dynamic-form/update-field`, { + + const response = await apiClient.put("/dynamic-form/update-field", { tableName: targetTableName, keyField: keyField, keyValue: keyValue, updateField: field, updateValue: value, }); - + if (!response.data?.success) { console.error(`❌ ${field} 업데이트 실패:`, response.data); toast.error(`${field} 업데이트에 실패했습니다.`); return false; } } - + console.log("✅ 모든 필드 업데이트 성공"); toast.success(config.successMessage || "상태가 변경되었습니다."); - + // 테이블 새로고침 이벤트 발생 - window.dispatchEvent(new CustomEvent("refreshTableData", { - detail: { tableName: targetTableName } - })); - + window.dispatchEvent( + new CustomEvent("refreshTableData", { + detail: { tableName: targetTableName }, + }), + ); + return true; } catch (apiError) { console.error("❌ 필드 값 변경 API 호출 실패:", apiError); @@ -4658,7 +4952,7 @@ export class ButtonActionExecutor { return false; } } - + // onSave 콜백이 있으면 사용 if (onSave) { console.log("🔄 필드 값 변경 후 자동 저장 (onSave 콜백)");