창고 렉 구조 등록 컴포넌트 중복 방지기능 추가
This commit is contained in:
parent
76bad47bc7
commit
ae7c47ee5f
|
|
@ -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레벨 메뉴 목록 조회
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
// ================================================
|
||||
// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명)
|
||||
// ================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1258,6 +1258,70 @@ class TableCategoryValueService {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 코드로 라벨 조회
|
||||
*
|
||||
* @param valueCodes - 카테고리 코드 배열
|
||||
* @param companyCode - 회사 코드
|
||||
* @returns { [code]: label } 형태의 매핑 객체
|
||||
*/
|
||||
async getCategoryLabelsByCodes(
|
||||
valueCodes: string[],
|
||||
companyCode: string
|
||||
): Promise<Record<string, string>> {
|
||||
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<string, string> = {};
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -441,6 +441,39 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
);
|
||||
}
|
||||
|
||||
// 🆕 렉 구조 컴포넌트 처리
|
||||
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 (
|
||||
<div className="h-full w-full overflow-auto">
|
||||
<RackStructureComponent
|
||||
config={rackConfig}
|
||||
formData={formData}
|
||||
tableName={tableName}
|
||||
onChange={(locations: any[]) => {
|
||||
console.log("📦 렉 구조 위치 데이터 변경:", locations.length, "개");
|
||||
// 컴포넌트의 columnName을 키로 사용
|
||||
const fieldKey = (comp as any).columnName || "_rackStructureLocations";
|
||||
updateFormData(fieldKey, locations);
|
||||
}}
|
||||
isPreview={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { widgetType, label, placeholder, required, readonly, columnName } = comp;
|
||||
const fieldName = columnName || comp.id;
|
||||
const currentValue = formData[fieldName] || "";
|
||||
|
|
|
|||
|
|
@ -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<string, string>;
|
||||
}>("/table-categories/labels-by-codes", { valueCodes });
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("카테고리 라벨 조회 실패:", error);
|
||||
return { success: false, error: error.message, data: {} };
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// 컬럼 매핑 관련 API (논리명 ↔ 물리명)
|
||||
// ================================================
|
||||
|
|
|
|||
|
|
@ -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<RackStructureComponentProps> = ({
|
|||
onChange,
|
||||
onConditionsChange,
|
||||
isPreview = false,
|
||||
tableName,
|
||||
}) => {
|
||||
// 조건 목록
|
||||
const [conditions, setConditions] = useState<RackLineCondition[]>(
|
||||
|
|
@ -201,6 +211,11 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
const [previewData, setPreviewData] = useState<GeneratedLocation[]>([]);
|
||||
const [isPreviewGenerated, setIsPreviewGenerated] = useState(false);
|
||||
|
||||
// 기존 데이터 중복 체크 관련 상태
|
||||
const [existingLocations, setExistingLocations] = useState<ExistingLocation[]>([]);
|
||||
const [isCheckingDuplicates, setIsCheckingDuplicates] = useState(false);
|
||||
const [duplicateErrors, setDuplicateErrors] = useState<{ row: number; existingLevels: number[] }[]>([]);
|
||||
|
||||
// 설정값
|
||||
const maxConditions = config.maxConditions || 10;
|
||||
const maxRows = config.maxRows || 99;
|
||||
|
|
@ -208,6 +223,60 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
const readonly = config.readonly || isPreview;
|
||||
const fieldMapping = config.fieldMapping || {};
|
||||
|
||||
// 카테고리 라벨 캐시 상태
|
||||
const [categoryLabels, setCategoryLabels] = useState<Record<string, string>>({});
|
||||
|
||||
// 카테고리 코드인지 확인
|
||||
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<RackStructureComponentProps> = ({
|
|||
// 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<RackStructureComponentProps> = ({
|
|||
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<number, number[]>(); // 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<RackStructureComponentProps> = ({
|
|||
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<RackStructureComponentProps> = ({
|
|||
|
||||
// 미리보기 생성
|
||||
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<RackStructureComponentProps> = ({
|
|||
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<RackStructureComponentProps> = ({
|
|||
|
||||
// 정렬: 열 -> 단 순서
|
||||
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<RackStructureComponentProps> = ({
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 열 범위 중복 경고 */}
|
||||
{hasRowOverlap && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>열 범위가 중복됩니다!</strong>
|
||||
<ul className="mt-1 list-inside list-disc text-xs">
|
||||
{rowOverlapErrors.map((err, idx) => (
|
||||
<li key={idx}>
|
||||
조건 {err.conditionIndex + 1}과 조건 {err.overlappingWith + 1}: {err.overlappingRows.join(", ")}열 중복
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<span className="mt-1 block text-xs">
|
||||
중복된 열 범위를 수정해주세요.
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 기존 데이터 중복 경고 */}
|
||||
{hasDuplicateWithExisting && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>이미 등록된 위치가 있습니다!</strong>
|
||||
<ul className="mt-1 list-inside list-disc text-xs">
|
||||
{duplicateErrors.map((err, idx) => (
|
||||
<li key={idx}>
|
||||
{err.row}열: {err.existingLevels.join(", ")}단 (이미 등록됨)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<span className="mt-1 block text-xs">
|
||||
해당 열/단을 제외하거나 기존 데이터를 삭제해주세요.
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 기존 데이터 로딩 중 표시 */}
|
||||
{isCheckingDuplicates && (
|
||||
<Alert className="mb-4">
|
||||
<AlertCircle className="h-4 w-4 animate-spin" />
|
||||
<AlertDescription>
|
||||
기존 위치 데이터를 확인하는 중...
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 기존 데이터 존재 알림 */}
|
||||
{!isCheckingDuplicates && existingLocations.length > 0 && !hasDuplicateWithExisting && (
|
||||
<Alert className="mb-4 border-blue-200 bg-blue-50">
|
||||
<AlertCircle className="h-4 w-4 text-blue-600" />
|
||||
<AlertDescription className="text-blue-800">
|
||||
해당 창고/층/구역에 <strong>{existingLocations.length}개</strong>의 위치가 이미 등록되어 있습니다.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 현재 매핑된 값 표시 */}
|
||||
{(context.warehouseCode || context.warehouseName || context.floor || context.zone) && (
|
||||
<div className="mb-4 flex flex-wrap gap-2 rounded-lg bg-gray-50 p-3">
|
||||
|
|
@ -548,10 +861,11 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={generatePreview}
|
||||
disabled={hasDuplicateWithExisting || hasRowOverlap || missingFields.length > 0 || isCheckingDuplicates}
|
||||
className="h-8 gap-1"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
미리보기 생성
|
||||
{isCheckingDuplicates ? "확인 중..." : hasDuplicateWithExisting ? "중복 있음" : "미리보기 생성"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -595,15 +909,15 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
{previewData.map((loc, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="font-mono">{loc.locationCode}</TableCell>
|
||||
<TableCell>{loc.locationName}</TableCell>
|
||||
<TableCell className="text-center">{context?.floor || "1"}</TableCell>
|
||||
<TableCell className="text-center">{context?.zone || "A"}</TableCell>
|
||||
<TableCell className="font-mono">{loc.location_code}</TableCell>
|
||||
<TableCell>{loc.location_name}</TableCell>
|
||||
<TableCell className="text-center">{loc.floor || context?.floor || "1"}</TableCell>
|
||||
<TableCell className="text-center">{loc.zone || context?.zone || "A"}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{loc.rowNum.toString().padStart(2, "0")}
|
||||
{loc.row_num.padStart(2, "0")}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{loc.levelNum}</TableCell>
|
||||
<TableCell className="text-center">{loc.locationType}</TableCell>
|
||||
<TableCell className="text-center">{loc.level_num}</TableCell>
|
||||
<TableCell className="text-center">{loc.location_type}</TableCell>
|
||||
<TableCell className="text-center">-</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
|
||||
return (
|
||||
<RackStructureComponent
|
||||
config={config || {}}
|
||||
formData={formData} // formData 전달 (필드 매핑에서 사용)
|
||||
onChange={this.handleLocationsChange}
|
||||
isPreview={isPreview}
|
||||
config={(config as object) || {}}
|
||||
formData={formData as Record<string, unknown>}
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; // 위치 유형
|
||||
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; // 구역
|
||||
}
|
||||
|
||||
// 필드 매핑 설정 (상위 폼의 어떤 필드를 사용할지)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
@ -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<boolean> {
|
||||
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<string, any> = {
|
||||
// 렉 구조에서 생성된 필드 (이미 테이블 컬럼명과 동일)
|
||||
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<boolean> {
|
||||
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);
|
||||
|
|
@ -1552,7 +1804,7 @@ export class ButtonActionExecutor {
|
|||
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);
|
||||
|
||||
|
|
@ -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,7 +3784,8 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
|
||||
// Trip ID 생성
|
||||
const tripId = config.trackingAutoGenerateTripId !== false
|
||||
const tripId =
|
||||
config.trackingAutoGenerateTripId !== false
|
||||
? `TRIP_${Date.now()}_${context.userId || "unknown"}`
|
||||
: context.formData?.[config.trackingTripIdField || "trip_id"] || `TRIP_${Date.now()}`;
|
||||
|
||||
|
|
@ -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,13 +3878,23 @@ 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) {
|
||||
|
|
@ -3656,14 +3921,17 @@ export class ButtonActionExecutor {
|
|||
|
||||
// 1️⃣ vehicle_location_history 마지막 레코드에 통계 저장 (이력용)
|
||||
try {
|
||||
const lastRecordResponse = await apiClient.post(`/table-management/tables/vehicle_location_history/data`, {
|
||||
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) {
|
||||
|
|
@ -3678,7 +3946,7 @@ export class ButtonActionExecutor {
|
|||
];
|
||||
|
||||
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,
|
||||
|
|
@ -3705,7 +3973,7 @@ export class ButtonActionExecutor {
|
|||
];
|
||||
|
||||
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,
|
||||
|
|
@ -3720,17 +3988,21 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
|
||||
// 이벤트로 통계 전달 (UI에서 표시용)
|
||||
window.dispatchEvent(new CustomEvent("tripCompleted", {
|
||||
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}분 소요`);
|
||||
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?.();
|
||||
|
|
@ -3799,7 +4076,7 @@ export class ButtonActionExecutor {
|
|||
// 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 },
|
||||
|
|
@ -3840,7 +4117,7 @@ export class ButtonActionExecutor {
|
|||
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 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<void> {
|
||||
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,
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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,7 +4486,12 @@ export class ButtonActionExecutor {
|
|||
// 🆕 연속 위치 추적 시작 (공차 상태에서도 위치 기록)
|
||||
if (config.emptyVehicleTracking !== false) {
|
||||
await this.startEmptyVehicleTracking(config, context, {
|
||||
latitude, longitude, accuracy, speed, heading, altitude
|
||||
latitude,
|
||||
longitude,
|
||||
accuracy,
|
||||
speed,
|
||||
heading,
|
||||
altitude,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -4263,7 +4544,14 @@ export class ButtonActionExecutor {
|
|||
private static async startEmptyVehicleTracking(
|
||||
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<void> {
|
||||
try {
|
||||
// 기존 추적이 있으면 중지
|
||||
|
|
@ -4345,7 +4633,7 @@ export class ButtonActionExecutor {
|
|||
enableHighAccuracy: true,
|
||||
timeout: trackingInterval,
|
||||
maximumAge: 0,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
console.log("🚗 공차 위치 추적 시작:", { tripId, watchId: this.emptyVehicleWatchId });
|
||||
|
|
@ -4435,13 +4723,17 @@ export class ButtonActionExecutor {
|
|||
* 운행알림 및 종료 액션 처리
|
||||
* - 위치 수집 + 상태 변경 + 연속 추적 (시작/종료)
|
||||
*/
|
||||
private static async handleOperationControl(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
private static async handleOperationControl(
|
||||
config: ButtonActionConfig,
|
||||
context: ButtonActionContext,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
console.log("🔄 운행알림/종료 액션 실행:", { config, context });
|
||||
|
||||
// 🆕 출발지/도착지 필수 체크 (운행 시작 모드일 때만)
|
||||
// updateTrackingMode가 "start"이거나 updateTargetValue가 "active"/"inactive"인 경우
|
||||
const isStartMode = config.updateTrackingMode === "start" ||
|
||||
const isStartMode =
|
||||
config.updateTrackingMode === "start" ||
|
||||
config.updateTargetValue === "active" ||
|
||||
config.updateTargetValue === "inactive";
|
||||
|
||||
|
|
@ -4628,7 +4920,7 @@ export class ButtonActionExecutor {
|
|||
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,
|
||||
|
|
@ -4647,9 +4939,11 @@ export class ButtonActionExecutor {
|
|||
toast.success(config.successMessage || "상태가 변경되었습니다.");
|
||||
|
||||
// 테이블 새로고침 이벤트 발생
|
||||
window.dispatchEvent(new CustomEvent("refreshTableData", {
|
||||
detail: { tableName: targetTableName }
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("refreshTableData", {
|
||||
detail: { tableName: targetTableName },
|
||||
}),
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (apiError) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue