Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
ef0af26147
1
PLAN.MD
1
PLAN.MD
|
|
@ -25,3 +25,4 @@ Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러
|
|||
## 진행 상태
|
||||
|
||||
- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중
|
||||
|
||||
|
|
|
|||
|
|
@ -55,3 +55,4 @@
|
|||
- `backend-node/src/routes/digitalTwinRoutes.ts`
|
||||
- `db/migrations/042_refactor_digital_twin_hierarchy.sql`
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관
|
|||
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
|
||||
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
||||
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
||||
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
|
||||
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
|
||||
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
||||
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
|
||||
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
|
||||
|
|
@ -222,7 +222,7 @@ app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
|
|||
app.use("/api/todos", todoRoutes); // To-Do 관리
|
||||
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
|
||||
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
|
||||
app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
|
||||
app.use("/api/yard-layouts", yardLayoutRoutes); // 3D 필드
|
||||
// app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석)
|
||||
app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제)
|
||||
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import {
|
||||
DigitalTwinTemplateService,
|
||||
DigitalTwinLayoutTemplate,
|
||||
} from "../services/DigitalTwinTemplateService";
|
||||
|
||||
export const listMappingTemplates = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const externalDbConnectionId = req.query.externalDbConnectionId
|
||||
? Number(req.query.externalDbConnectionId)
|
||||
: undefined;
|
||||
const layoutType =
|
||||
typeof req.query.layoutType === "string"
|
||||
? req.query.layoutType
|
||||
: undefined;
|
||||
|
||||
const result = await DigitalTwinTemplateService.listTemplates(
|
||||
companyCode,
|
||||
{
|
||||
externalDbConnectionId,
|
||||
layoutType,
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: result.message,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data as DigitalTwinLayoutTemplate[],
|
||||
});
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getMappingTemplateById = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await DigitalTwinTemplateService.getTemplateById(
|
||||
companyCode,
|
||||
id,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: result.message || "매핑 템플릿을 찾을 수 없습니다.",
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const createMappingTemplate = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!companyCode || !userId) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
externalDbConnectionId,
|
||||
layoutType,
|
||||
config,
|
||||
} = req.body;
|
||||
|
||||
if (!name || !externalDbConnectionId || !config) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await DigitalTwinTemplateService.createTemplate(
|
||||
companyCode,
|
||||
userId,
|
||||
{
|
||||
name,
|
||||
description,
|
||||
externalDbConnectionId,
|
||||
layoutType,
|
||||
config,
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: result.message || "매핑 템플릿 생성 중 오류가 발생했습니다.",
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 생성 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
||||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
||||
import {
|
||||
DatabaseConnector,
|
||||
ConnectionConfig,
|
||||
QueryResult,
|
||||
} from "../interfaces/DatabaseConnector";
|
||||
import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
|
||||
// @ts-ignore
|
||||
import * as mysql from 'mysql2/promise';
|
||||
import * as mysql from "mysql2/promise";
|
||||
|
||||
export class MariaDBConnector implements DatabaseConnector {
|
||||
private connection: mysql.Connection | null = null;
|
||||
|
|
@ -20,7 +24,7 @@ export class MariaDBConnector implements DatabaseConnector {
|
|||
password: this.config.password,
|
||||
database: this.config.database,
|
||||
connectTimeout: this.config.connectionTimeoutMillis,
|
||||
ssl: typeof this.config.ssl === 'boolean' ? undefined : this.config.ssl,
|
||||
ssl: typeof this.config.ssl === "boolean" ? undefined : this.config.ssl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -36,7 +40,9 @@ export class MariaDBConnector implements DatabaseConnector {
|
|||
const startTime = Date.now();
|
||||
try {
|
||||
await this.connect();
|
||||
const [rows] = await this.connection!.query("SELECT VERSION() as version");
|
||||
const [rows] = await this.connection!.query(
|
||||
"SELECT VERSION() as version"
|
||||
);
|
||||
const version = (rows as any[])[0]?.version || "Unknown";
|
||||
const responseTime = Date.now() - startTime;
|
||||
await this.disconnect();
|
||||
|
|
@ -89,15 +95,13 @@ export class MariaDBConnector implements DatabaseConnector {
|
|||
ORDER BY TABLE_NAME;
|
||||
`);
|
||||
|
||||
const tables: TableInfo[] = [];
|
||||
for (const row of rows as any[]) {
|
||||
const columns = await this.getColumns(row.table_name);
|
||||
tables.push({
|
||||
table_name: row.table_name,
|
||||
description: row.description || null,
|
||||
columns: columns,
|
||||
});
|
||||
}
|
||||
// 테이블 목록만 반환 (컬럼 정보는 getColumns에서 개별 조회)
|
||||
const tables: TableInfo[] = (rows as any[]).map((row) => ({
|
||||
table_name: row.table_name,
|
||||
description: row.description || null,
|
||||
columns: [],
|
||||
}));
|
||||
|
||||
await this.disconnect();
|
||||
return tables;
|
||||
} catch (error: any) {
|
||||
|
|
@ -111,21 +115,43 @@ export class MariaDBConnector implements DatabaseConnector {
|
|||
console.log(`[MariaDBConnector] getColumns 호출: tableName=${tableName}`);
|
||||
await this.connect();
|
||||
console.log(`[MariaDBConnector] 연결 완료, 쿼리 실행 시작`);
|
||||
|
||||
const [rows] = await this.connection!.query(`
|
||||
|
||||
const [rows] = await this.connection!.query(
|
||||
`
|
||||
SELECT
|
||||
COLUMN_NAME as column_name,
|
||||
DATA_TYPE as data_type,
|
||||
IS_NULLABLE as is_nullable,
|
||||
COLUMN_DEFAULT as column_default
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
`, [tableName]);
|
||||
|
||||
c.COLUMN_NAME AS column_name,
|
||||
c.DATA_TYPE AS data_type,
|
||||
c.IS_NULLABLE AS is_nullable,
|
||||
c.COLUMN_DEFAULT AS column_default,
|
||||
c.COLUMN_COMMENT AS description,
|
||||
CASE
|
||||
WHEN tc.CONSTRAINT_TYPE = 'PRIMARY KEY' THEN 'YES'
|
||||
ELSE 'NO'
|
||||
END AS is_primary_key
|
||||
FROM information_schema.COLUMNS c
|
||||
LEFT JOIN information_schema.KEY_COLUMN_USAGE k
|
||||
ON c.TABLE_SCHEMA = k.TABLE_SCHEMA
|
||||
AND c.TABLE_NAME = k.TABLE_NAME
|
||||
AND c.COLUMN_NAME = k.COLUMN_NAME
|
||||
LEFT JOIN information_schema.TABLE_CONSTRAINTS tc
|
||||
ON k.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA
|
||||
AND k.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
|
||||
AND k.TABLE_SCHEMA = tc.TABLE_SCHEMA
|
||||
AND k.TABLE_NAME = tc.TABLE_NAME
|
||||
AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY'
|
||||
WHERE c.TABLE_SCHEMA = DATABASE()
|
||||
AND c.TABLE_NAME = ?
|
||||
ORDER BY c.ORDINAL_POSITION;
|
||||
`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
console.log(`[MariaDBConnector] 쿼리 결과:`, rows);
|
||||
console.log(`[MariaDBConnector] 결과 개수:`, Array.isArray(rows) ? rows.length : 'not array');
|
||||
|
||||
console.log(
|
||||
`[MariaDBConnector] 결과 개수:`,
|
||||
Array.isArray(rows) ? rows.length : "not array"
|
||||
);
|
||||
|
||||
await this.disconnect();
|
||||
return rows as any[];
|
||||
} catch (error: any) {
|
||||
|
|
|
|||
|
|
@ -210,15 +210,33 @@ export class PostgreSQLConnector implements DatabaseConnector {
|
|||
const result = await tempClient.query(
|
||||
`
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default,
|
||||
col_description(c.oid, a.attnum) as column_comment
|
||||
isc.column_name,
|
||||
isc.data_type,
|
||||
isc.is_nullable,
|
||||
isc.column_default,
|
||||
col_description(c.oid, a.attnum) as column_comment,
|
||||
CASE
|
||||
WHEN tc.constraint_type = 'PRIMARY KEY' THEN 'YES'
|
||||
ELSE 'NO'
|
||||
END AS is_primary_key
|
||||
FROM information_schema.columns isc
|
||||
LEFT JOIN pg_class c ON c.relname = isc.table_name
|
||||
LEFT JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = isc.column_name
|
||||
WHERE isc.table_schema = 'public' AND isc.table_name = $1
|
||||
LEFT JOIN pg_class c
|
||||
ON c.relname = isc.table_name
|
||||
AND c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = isc.table_schema)
|
||||
LEFT JOIN pg_attribute a
|
||||
ON a.attrelid = c.oid
|
||||
AND a.attname = isc.column_name
|
||||
LEFT JOIN information_schema.key_column_usage k
|
||||
ON k.table_name = isc.table_name
|
||||
AND k.table_schema = isc.table_schema
|
||||
AND k.column_name = isc.column_name
|
||||
LEFT JOIN information_schema.table_constraints tc
|
||||
ON tc.constraint_name = k.constraint_name
|
||||
AND tc.table_schema = k.table_schema
|
||||
AND tc.table_name = k.table_name
|
||||
AND tc.constraint_type = 'PRIMARY KEY'
|
||||
WHERE isc.table_schema = 'public'
|
||||
AND isc.table_name = $1
|
||||
ORDER BY isc.ordinal_position;
|
||||
`,
|
||||
[tableName]
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ import {
|
|||
updateLayout,
|
||||
deleteLayout,
|
||||
} from "../controllers/digitalTwinLayoutController";
|
||||
import {
|
||||
listMappingTemplates,
|
||||
getMappingTemplateById,
|
||||
createMappingTemplate,
|
||||
} from "../controllers/digitalTwinTemplateController";
|
||||
|
||||
// 외부 DB 데이터 조회
|
||||
import {
|
||||
|
|
@ -27,11 +32,16 @@ const router = express.Router();
|
|||
router.use(authenticateToken);
|
||||
|
||||
// ========== 레이아웃 관리 API ==========
|
||||
router.get("/layouts", getLayouts); // 레이아웃 목록
|
||||
router.get("/layouts/:id", getLayoutById); // 레이아웃 상세
|
||||
router.post("/layouts", createLayout); // 레이아웃 생성
|
||||
router.put("/layouts/:id", updateLayout); // 레이아웃 수정
|
||||
router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제
|
||||
router.get("/layouts", getLayouts); // 레이아웃 목록
|
||||
router.get("/layouts/:id", getLayoutById); // 레이아웃 상세
|
||||
router.post("/layouts", createLayout); // 레이아웃 생성
|
||||
router.put("/layouts/:id", updateLayout); // 레이아웃 수정
|
||||
router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제
|
||||
|
||||
// ========== 매핑 템플릿 API ==========
|
||||
router.get("/mapping-templates", listMappingTemplates);
|
||||
router.get("/mapping-templates/:id", getMappingTemplateById);
|
||||
router.post("/mapping-templates", createMappingTemplate);
|
||||
|
||||
// ========== 외부 DB 데이터 조회 API ==========
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
import { pool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
export interface DigitalTwinLayoutTemplate {
|
||||
id: string;
|
||||
company_code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
external_db_connection_id: number;
|
||||
layout_type: string;
|
||||
config: any;
|
||||
created_by: string;
|
||||
created_at: Date;
|
||||
updated_by: string;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
interface ServiceResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class DigitalTwinTemplateService {
|
||||
static async listTemplates(
|
||||
companyCode: string,
|
||||
options: { externalDbConnectionId?: number; layoutType?: string } = {},
|
||||
): Promise<ServiceResponse<DigitalTwinLayoutTemplate[]>> {
|
||||
try {
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
let query = `
|
||||
SELECT *
|
||||
FROM digital_twin_layout_template
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
|
||||
if (options.layoutType) {
|
||||
query += ` AND layout_type = $${paramIndex++}`;
|
||||
params.push(options.layoutType);
|
||||
}
|
||||
|
||||
if (options.externalDbConnectionId) {
|
||||
query += ` AND external_db_connection_id = $${paramIndex++}`;
|
||||
params.push(options.externalDbConnectionId);
|
||||
}
|
||||
|
||||
query += `
|
||||
ORDER BY updated_at DESC, name ASC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("디지털 트윈 매핑 템플릿 목록 조회", {
|
||||
companyCode,
|
||||
count: result.rowCount,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.rows as DigitalTwinLayoutTemplate[],
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error("디지털 트윈 매핑 템플릿 목록 조회 실패", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static async getTemplateById(
|
||||
companyCode: string,
|
||||
id: string,
|
||||
): Promise<ServiceResponse<DigitalTwinLayoutTemplate>> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM digital_twin_layout_template
|
||||
WHERE id = $1 AND company_code = $2
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [id, companyCode]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "매핑 템플릿을 찾을 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.rows[0] as DigitalTwinLayoutTemplate,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error("디지털 트윈 매핑 템플릿 조회 실패", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
message: "매핑 템플릿 조회 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static async createTemplate(
|
||||
companyCode: string,
|
||||
userId: string,
|
||||
payload: {
|
||||
name: string;
|
||||
description?: string;
|
||||
externalDbConnectionId: number;
|
||||
layoutType?: string;
|
||||
config: any;
|
||||
},
|
||||
): Promise<ServiceResponse<DigitalTwinLayoutTemplate>> {
|
||||
try {
|
||||
const query = `
|
||||
INSERT INTO digital_twin_layout_template (
|
||||
company_code,
|
||||
name,
|
||||
description,
|
||||
external_db_connection_id,
|
||||
layout_type,
|
||||
config,
|
||||
created_by,
|
||||
created_at,
|
||||
updated_by,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $7, NOW())
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const values = [
|
||||
companyCode,
|
||||
payload.name,
|
||||
payload.description || null,
|
||||
payload.externalDbConnectionId,
|
||||
payload.layoutType || "yard-3d",
|
||||
JSON.stringify(payload.config),
|
||||
userId,
|
||||
];
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
|
||||
logger.info("디지털 트윈 매핑 템플릿 생성", {
|
||||
companyCode,
|
||||
templateId: result.rows[0].id,
|
||||
externalDbConnectionId: payload.externalDbConnectionId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.rows[0] as DigitalTwinLayoutTemplate,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error("디지털 트윈 매핑 템플릿 생성 실패", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
message: "매핑 템플릿 생성 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -811,9 +811,39 @@ export class DynamicFormService {
|
|||
const primaryKeyColumn = primaryKeys[0];
|
||||
console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`);
|
||||
|
||||
// 동적 UPDATE SQL 생성 (변경된 필드만)
|
||||
// 🆕 컬럼 타입 조회 (타입 캐스팅용)
|
||||
const columnTypesQuery = `
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = $1 AND table_schema = 'public'
|
||||
`;
|
||||
const columnTypesResult = await query<{ column_name: string; data_type: string }>(
|
||||
columnTypesQuery,
|
||||
[tableName]
|
||||
);
|
||||
const columnTypes: Record<string, string> = {};
|
||||
columnTypesResult.forEach((row) => {
|
||||
columnTypes[row.column_name] = row.data_type;
|
||||
});
|
||||
|
||||
console.log("📊 컬럼 타입 정보:", columnTypes);
|
||||
|
||||
// 🆕 동적 UPDATE SQL 생성 (타입 캐스팅 포함)
|
||||
const setClause = Object.keys(changedFields)
|
||||
.map((key, index) => `${key} = $${index + 1}`)
|
||||
.map((key, index) => {
|
||||
const dataType = columnTypes[key];
|
||||
// 숫자 타입인 경우 명시적 캐스팅
|
||||
if (dataType === 'integer' || dataType === 'bigint' || dataType === 'smallint') {
|
||||
return `${key} = $${index + 1}::integer`;
|
||||
} else if (dataType === 'numeric' || dataType === 'decimal' || dataType === 'real' || dataType === 'double precision') {
|
||||
return `${key} = $${index + 1}::numeric`;
|
||||
} else if (dataType === 'boolean') {
|
||||
return `${key} = $${index + 1}::boolean`;
|
||||
} else {
|
||||
// 문자열 타입은 캐스팅 불필요
|
||||
return `${key} = $${index + 1}`;
|
||||
}
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
const values: any[] = Object.values(changedFields);
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ import { ListWidget } from "./widgets/ListWidget";
|
|||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// 야드 관리 3D 위젯
|
||||
// 3D 필드 위젯
|
||||
const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
|
|
@ -312,6 +312,24 @@ export function CanvasElement({
|
|||
return;
|
||||
}
|
||||
|
||||
// 위젯 테두리(바깥쪽 영역)를 클릭한 경우에만 선택/드래그 허용
|
||||
// - 내용 영역을 클릭해도 대시보드 설정 사이드바가 튀어나오지 않도록 하기 위함
|
||||
const container = elementRef.current;
|
||||
if (container) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const BORDER_HIT_WIDTH = 8; // px, 테두리로 인식할 범위
|
||||
const isOnBorder =
|
||||
e.clientX <= rect.left + BORDER_HIT_WIDTH ||
|
||||
e.clientX >= rect.right - BORDER_HIT_WIDTH ||
|
||||
e.clientY <= rect.top + BORDER_HIT_WIDTH ||
|
||||
e.clientY >= rect.bottom - BORDER_HIT_WIDTH;
|
||||
|
||||
if (!isOnBorder) {
|
||||
// 테두리가 아닌 내부 클릭은 선택/드래그 처리하지 않음
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 선택되지 않은 경우에만 선택 처리
|
||||
if (!isSelected) {
|
||||
onSelect(element.id);
|
||||
|
|
@ -1067,7 +1085,7 @@ export function CanvasElement({
|
|||
<ListWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "yard-management-3d" ? (
|
||||
// 야드 관리 3D 위젯 렌더링
|
||||
// 3D 필드 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<YardManagement3DWidget
|
||||
isEditMode={true}
|
||||
|
|
|
|||
|
|
@ -749,7 +749,7 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
|
|||
case "document":
|
||||
return "문서 위젯";
|
||||
case "yard-management-3d":
|
||||
return "야드 관리 3D";
|
||||
return "3D 필드";
|
||||
case "work-history":
|
||||
return "작업 이력";
|
||||
case "transport-stats":
|
||||
|
|
|
|||
|
|
@ -362,7 +362,7 @@ export function DashboardTopMenu({
|
|||
<SelectItem value="list-v2">리스트</SelectItem>
|
||||
<SelectItem value="custom-metric-v2">통계 카드</SelectItem>
|
||||
<SelectItem value="risk-alert-v2">리스크/알림</SelectItem>
|
||||
<SelectItem value="yard-management-3d">야드 관리 3D</SelectItem>
|
||||
<SelectItem value="yard-management-3d">3D 필드</SelectItem>
|
||||
{/* <SelectItem value="transport-stats">커스텀 통계 카드</SelectItem> */}
|
||||
{/* <SelectItem value="status-summary">커스텀 상태 카드</SelectItem> */}
|
||||
</SelectGroup>
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ const getWidgetTitle = (subtype: ElementSubtype): string => {
|
|||
chart: "차트",
|
||||
"map-summary-v2": "지도",
|
||||
"risk-alert-v2": "리스크 알림",
|
||||
"yard-management-3d": "야드 관리 3D",
|
||||
"yard-management-3d": "3D 필드",
|
||||
weather: "날씨 위젯",
|
||||
exchange: "환율 위젯",
|
||||
calculator: "계산기",
|
||||
|
|
@ -449,7 +449,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 레이아웃 선택 (야드 관리 3D 위젯 전용) */}
|
||||
{/* 레이아웃 선택 (3D 필드 위젯 전용) */}
|
||||
{element.subtype === "yard-management-3d" && (
|
||||
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||
<Label htmlFor="layout-id" className="mb-2 block text-xs font-semibold">
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export type ElementSubtype =
|
|||
| "maintenance"
|
||||
| "document"
|
||||
// | "list" // (구버전 - 주석 처리: 2025-10-28, list-v2로 대체)
|
||||
| "yard-management-3d" // 야드 관리 3D 위젯
|
||||
| "yard-management-3d" // 3D 필드 위젯
|
||||
| "work-history" // 작업 이력 위젯
|
||||
| "transport-stats"; // 커스텀 통계 카드 위젯
|
||||
// | "custom-metric"; // (구버전 - 주석 처리: 2025-10-28, custom-metric-v2로 대체)
|
||||
|
|
@ -116,7 +116,7 @@ export interface DashboardElement {
|
|||
calendarConfig?: CalendarConfig; // 달력 설정
|
||||
driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정
|
||||
listConfig?: ListWidgetConfig; // 리스트 위젯 설정
|
||||
yardConfig?: YardManagementConfig; // 야드 관리 3D 설정
|
||||
yardConfig?: YardManagementConfig; // 3D 필드 설정
|
||||
customMetricConfig?: CustomMetricConfig; // 사용자 커스텀 카드 설정
|
||||
}
|
||||
|
||||
|
|
@ -385,7 +385,7 @@ export interface ListColumn {
|
|||
visible?: boolean; // 표시 여부 (기본: true)
|
||||
}
|
||||
|
||||
// 야드 관리 3D 설정
|
||||
// 3D 필드 설정
|
||||
export interface YardManagementConfig {
|
||||
layoutId: number; // 선택된 야드 레이아웃 ID
|
||||
layoutName?: string; // 레이아웃 이름 (표시용)
|
||||
|
|
|
|||
|
|
@ -42,14 +42,16 @@ export default function YardManagement3DWidget({
|
|||
setIsLoading(true);
|
||||
const response = await getLayouts();
|
||||
if (response.success && response.data) {
|
||||
setLayouts(response.data.map((layout: any) => ({
|
||||
id: layout.id,
|
||||
name: layout.layout_name,
|
||||
description: layout.description || "",
|
||||
placement_count: layout.object_count || 0,
|
||||
created_at: layout.created_at,
|
||||
updated_at: layout.updated_at,
|
||||
})));
|
||||
setLayouts(
|
||||
response.data.map((layout: any) => ({
|
||||
id: layout.id,
|
||||
name: layout.layout_name,
|
||||
description: layout.description || "",
|
||||
placement_count: layout.object_count || 0,
|
||||
created_at: layout.created_at,
|
||||
updated_at: layout.updated_at,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("야드 레이아웃 목록 조회 실패:", error);
|
||||
|
|
@ -145,12 +147,14 @@ export default function YardManagement3DWidget({
|
|||
// 편집 모드: 편집 중인 경우 DigitalTwinEditor 표시
|
||||
if (isEditMode && editingLayout) {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<DigitalTwinEditor
|
||||
layoutId={editingLayout.id}
|
||||
layoutName={editingLayout.name}
|
||||
onBack={handleEditComplete}
|
||||
/>
|
||||
// 대시보드 위젯 선택/사이드바 오픈과 독립적으로 동작해야 하므로
|
||||
// widget-interactive-area 클래스를 부여하고, 마우스 이벤트 전파를 막아준다.
|
||||
<div
|
||||
className="widget-interactive-area h-full w-full"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DigitalTwinEditor layoutId={editingLayout.id} layoutName={editingLayout.name} onBack={handleEditComplete} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -158,30 +162,31 @@ export default function YardManagement3DWidget({
|
|||
// 편집 모드: 레이아웃 선택 UI
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<div className="widget-interactive-area flex h-full w-full flex-col bg-background">
|
||||
<div className="widget-interactive-area bg-background flex h-full w-full flex-col">
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">야드 레이아웃 선택</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 야드 레이아웃을 선택하세요"}
|
||||
<h3 className="text-foreground text-sm font-semibold">3D 필드 선택</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 3D필드를 선택하세요"}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setIsCreateModalOpen(true)} size="sm">
|
||||
<Plus className="mr-1 h-4 w-4" />새 야드 생성
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
새로운 3D필드 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
||||
<div className="text-muted-foreground text-sm">로딩 중...</div>
|
||||
</div>
|
||||
) : layouts.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">🏗️</div>
|
||||
<div className="text-sm text-foreground">생성된 야드 레이아웃이 없습니다</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">먼저 야드 레이아웃을 생성하세요</div>
|
||||
<div className="text-foreground text-sm">생성된 3D필드가 없습니다</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">먼저 3D필드가 생성하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -196,11 +201,11 @@ export default function YardManagement3DWidget({
|
|||
<div className="flex items-start justify-between gap-3">
|
||||
<button onClick={() => handleSelectLayout(layout)} className="flex-1 text-left hover:opacity-80">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">{layout.name}</span>
|
||||
{config?.layoutId === layout.id && <Check className="h-4 w-4 text-primary" />}
|
||||
<span className="text-foreground font-medium">{layout.name}</span>
|
||||
{config?.layoutId === layout.id && <Check className="text-primary h-4 w-4" />}
|
||||
</div>
|
||||
{layout.description && <p className="mt-1 text-xs text-muted-foreground">{layout.description}</p>}
|
||||
<div className="mt-2 text-xs text-muted-foreground">배치된 자재: {layout.placement_count}개</div>
|
||||
{layout.description && <p className="text-muted-foreground mt-1 text-xs">{layout.description}</p>}
|
||||
<div className="text-muted-foreground mt-2 text-xs">배치된 자재: {layout.placement_count}개</div>
|
||||
</button>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
|
|
@ -251,12 +256,12 @@ export default function YardManagement3DWidget({
|
|||
<DialogTitle>야드 레이아웃 삭제</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-foreground">
|
||||
<p className="text-foreground text-sm">
|
||||
이 야드 레이아웃을 삭제하시겠습니까?
|
||||
<br />
|
||||
레이아웃 내의 모든 배치 정보도 함께 삭제됩니다.
|
||||
<br />
|
||||
<span className="font-semibold text-destructive">이 작업은 되돌릴 수 없습니다.</span>
|
||||
<span className="text-destructive font-semibold">이 작업은 되돌릴 수 없습니다.</span>
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setDeleteLayoutId(null)}>
|
||||
|
|
@ -277,14 +282,12 @@ export default function YardManagement3DWidget({
|
|||
if (!config?.layoutId) {
|
||||
console.warn("⚠️ 야드관리 위젯: layoutId가 설정되지 않음", { config, isEditMode });
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||||
<div className="bg-muted flex h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">🏗️</div>
|
||||
<div className="text-sm font-medium text-foreground">야드 레이아웃이 설정되지 않았습니다</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">대시보드 편집에서 레이아웃을 선택하세요</div>
|
||||
<div className="mt-2 text-xs text-destructive">
|
||||
디버그: config={JSON.stringify(config)}
|
||||
</div>
|
||||
<div className="text-foreground text-sm font-medium">3D필드가 설정되지 않았습니다</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">대시보드 편집에서 3D필드를 선택하세요</div>
|
||||
<div className="text-destructive mt-2 text-xs">디버그: config={JSON.stringify(config)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,12 +20,24 @@ import {
|
|||
getMaterials,
|
||||
getHierarchyData,
|
||||
getChildrenData,
|
||||
getMappingTemplates,
|
||||
createMappingTemplate,
|
||||
type HierarchyData,
|
||||
type DigitalTwinMappingTemplate,
|
||||
} from "@/lib/api/digitalTwin";
|
||||
import type { MaterialData } from "@/types/digitalTwin";
|
||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
import HierarchyConfigPanel, { HierarchyConfig } from "./HierarchyConfigPanel";
|
||||
import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants";
|
||||
import { validateSpatialContainment, updateChildrenPositions, getAllDescendants } from "./spatialContainment";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
// 백엔드 DB 객체 타입 (snake_case)
|
||||
interface DbObject {
|
||||
|
|
@ -73,6 +85,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
const [draggedTool, setDraggedTool] = useState<ToolType | null>(null);
|
||||
const [draggedAreaData, setDraggedAreaData] = useState<Area | null>(null); // 드래그 중인 Area 정보
|
||||
const [draggedLocationData, setDraggedLocationData] = useState<Location | null>(null); // 드래그 중인 Location 정보
|
||||
const [previewPosition, setPreviewPosition] = useState<{ x: number; z: number } | null>(null); // 드래그 프리뷰 위치
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
|
@ -92,9 +105,17 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
const [loadingMaterials, setLoadingMaterials] = useState(false);
|
||||
const [showMaterialPanel, setShowMaterialPanel] = useState(false);
|
||||
|
||||
// 매핑 템플릿
|
||||
const [mappingTemplates, setMappingTemplates] = useState<DigitalTwinMappingTemplate[]>([]);
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
||||
const [loadingTemplates, setLoadingTemplates] = useState(false);
|
||||
const [isSaveTemplateDialogOpen, setIsSaveTemplateDialogOpen] = useState(false);
|
||||
const [newTemplateName, setNewTemplateName] = useState("");
|
||||
const [newTemplateDescription, setNewTemplateDescription] = useState("");
|
||||
|
||||
// 동적 계층 구조 설정
|
||||
const [hierarchyConfig, setHierarchyConfig] = useState<HierarchyConfig | null>(null);
|
||||
const [availableTables, setAvailableTables] = useState<string[]>([]);
|
||||
const [availableTables, setAvailableTables] = useState<Array<{ table_name: string; description?: string }>>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
|
||||
// 레거시: 테이블 매핑 (구 Area/Location 방식 호환용)
|
||||
|
|
@ -164,6 +185,36 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
}));
|
||||
}, [placedObjects, layoutId]);
|
||||
|
||||
// 외부 DB 또는 레이아웃 타입이 변경될 때 템플릿 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTemplates = async () => {
|
||||
if (!selectedDbConnection) {
|
||||
setMappingTemplates([]);
|
||||
setSelectedTemplateId("");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingTemplates(true);
|
||||
const response = await getMappingTemplates({
|
||||
externalDbConnectionId: selectedDbConnection,
|
||||
layoutType: "yard-3d",
|
||||
});
|
||||
if (response.success && response.data) {
|
||||
setMappingTemplates(response.data);
|
||||
} else {
|
||||
setMappingTemplates([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("매핑 템플릿 목록 조회 실패:", error);
|
||||
} finally {
|
||||
setLoadingTemplates(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTemplates();
|
||||
}, [selectedDbConnection]);
|
||||
|
||||
// 외부 DB 연결 목록 로드
|
||||
useEffect(() => {
|
||||
const loadExternalDbConnections = async () => {
|
||||
|
|
@ -206,12 +257,23 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
const loadTables = async () => {
|
||||
try {
|
||||
setLoadingTables(true);
|
||||
const { getTables } = await import("@/lib/api/digitalTwin");
|
||||
const response = await getTables(selectedDbConnection);
|
||||
// 외부 DB 메타데이터 API 사용 (테이블 + 설명)
|
||||
const response = await ExternalDbConnectionAPI.getTables(selectedDbConnection);
|
||||
if (response.success && response.data) {
|
||||
const tableNames = response.data.map((t) => t.table_name);
|
||||
setAvailableTables(tableNames);
|
||||
console.log("📋 테이블 목록:", tableNames);
|
||||
const rawTables = response.data as any[];
|
||||
const normalized = rawTables.map((t: any) =>
|
||||
typeof t === "string"
|
||||
? { table_name: t }
|
||||
: {
|
||||
table_name: t.table_name || t.TABLE_NAME || String(t),
|
||||
description: t.description || t.table_description || undefined,
|
||||
},
|
||||
);
|
||||
setAvailableTables(normalized);
|
||||
console.log("📋 테이블 목록:", normalized);
|
||||
} else {
|
||||
setAvailableTables([]);
|
||||
console.warn("테이블 목록 조회 실패:", response.message || response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 실패:", error);
|
||||
|
|
@ -656,13 +718,13 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
};
|
||||
|
||||
// 캔버스에 드롭
|
||||
const handleCanvasDrop = (x: number, z: number) => {
|
||||
const handleCanvasDrop = async (x: number, z: number) => {
|
||||
if (!draggedTool) return;
|
||||
|
||||
const defaults = getToolDefaults(draggedTool);
|
||||
|
||||
// Area는 바닥(y=0.05)에, 다른 객체는 중앙 정렬
|
||||
const yPosition = draggedTool === "area" ? 0.05 : (defaults.size?.y || 1) / 2;
|
||||
let yPosition = draggedTool === "area" ? 0.05 : (defaults.size?.y || 1) / 2;
|
||||
|
||||
// 외부 DB 데이터에서 드래그한 경우 해당 정보 사용
|
||||
let objectName = defaults.name || "새 객체";
|
||||
|
|
@ -696,13 +758,52 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
externalKey = draggedLocationData.LOCAKEY;
|
||||
}
|
||||
|
||||
// 기본 크기 설정
|
||||
let objectSize = defaults.size || { x: 5, y: 5, z: 5 };
|
||||
|
||||
// Location 배치 시 자재 개수에 따라 높이 자동 설정
|
||||
if (
|
||||
(draggedTool === "location-bed" ||
|
||||
draggedTool === "location-stp" ||
|
||||
draggedTool === "location-temp" ||
|
||||
draggedTool === "location-dest") &&
|
||||
locaKey &&
|
||||
selectedDbConnection &&
|
||||
hierarchyConfig?.material
|
||||
) {
|
||||
try {
|
||||
// 해당 Location의 자재 개수 조회
|
||||
const countsResponse = await getMaterialCounts(selectedDbConnection, hierarchyConfig.material.tableName, [
|
||||
locaKey,
|
||||
]);
|
||||
|
||||
if (countsResponse.success && countsResponse.data && countsResponse.data.length > 0) {
|
||||
const materialCount = countsResponse.data[0].count;
|
||||
|
||||
// 자재 개수에 비례해서 높이(Y축) 설정 (최소 5, 최대 30)
|
||||
// 자재 1개 = 높이 5, 자재 10개 = 높이 15, 자재 50개 = 높이 30
|
||||
const calculatedHeight = Math.min(30, Math.max(5, 5 + materialCount * 0.5));
|
||||
|
||||
objectSize = {
|
||||
...objectSize,
|
||||
y: calculatedHeight, // Y축이 높이!
|
||||
};
|
||||
|
||||
// 높이가 높아진 만큼 Y 위치도 올려서 바닥을 뚫지 않게 조정
|
||||
yPosition = calculatedHeight / 2;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("자재 개수 조회 실패, 기본 높이 사용:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const newObject: PlacedObject = {
|
||||
id: nextObjectId,
|
||||
type: draggedTool,
|
||||
name: objectName,
|
||||
position: { x, y: yPosition, z },
|
||||
size: defaults.size || { x: 5, y: 5, z: 5 },
|
||||
color: defaults.color || "#9ca3af",
|
||||
size: objectSize,
|
||||
color: OBJECT_COLORS[draggedTool] || DEFAULT_COLOR, // 타입별 기본 색상
|
||||
areaKey,
|
||||
locaKey,
|
||||
locType,
|
||||
|
|
@ -739,9 +840,32 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
return;
|
||||
}
|
||||
|
||||
// 부모 ID 설정
|
||||
// 부모 ID 설정 및 논리적 유효성 검사
|
||||
if (validation.parent) {
|
||||
// 1. 부모 객체 찾기
|
||||
const parentObj = placedObjects.find((obj) => obj.id === validation.parent!.id);
|
||||
|
||||
// 2. 논리적 키 검사 (DB에서 가져온 데이터인 경우)
|
||||
if (parentObj && parentObj.externalKey && newObject.parentKey) {
|
||||
if (parentObj.externalKey !== newObject.parentKey) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "배치 오류",
|
||||
description: `이 Location은 '${newObject.parentKey}' Area에만 배치할 수 있습니다. (현재 선택된 Area: ${parentObj.externalKey})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
newObject.parentId = validation.parent.id;
|
||||
} else if (newObject.parentKey) {
|
||||
// DB 데이터인데 부모 영역 위에 놓이지 않은 경우
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "배치 오류",
|
||||
description: `이 Location은 '${newObject.parentKey}' Area 내부에 배치해야 합니다.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -770,7 +894,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
|
||||
// Location의 자재 목록 로드
|
||||
const loadMaterialsForLocation = async (locaKey: string) => {
|
||||
console.log("🔍 자재 조회 시작:", { locaKey, selectedDbConnection, material: hierarchyConfig?.material });
|
||||
|
||||
if (!selectedDbConnection || !hierarchyConfig?.material) {
|
||||
console.error("❌ 설정 누락:", { selectedDbConnection, material: hierarchyConfig?.material });
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "자재 조회 실패",
|
||||
|
|
@ -782,10 +909,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
try {
|
||||
setLoadingMaterials(true);
|
||||
setShowMaterialPanel(true);
|
||||
const response = await getMaterials(selectedDbConnection, {
|
||||
|
||||
const materialConfig = {
|
||||
...hierarchyConfig.material,
|
||||
locaKey: locaKey,
|
||||
});
|
||||
};
|
||||
console.log("📡 API 호출:", { externalDbConnectionId: selectedDbConnection, materialConfig });
|
||||
|
||||
const response = await getMaterials(selectedDbConnection, materialConfig);
|
||||
console.log("📦 API 응답:", response);
|
||||
if (response.success && response.data) {
|
||||
// layerColumn이 있으면 정렬
|
||||
const sortedMaterials = hierarchyConfig.material.layerColumn
|
||||
|
|
@ -904,6 +1036,110 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
}
|
||||
};
|
||||
|
||||
// 매핑 템플릿 적용
|
||||
const handleApplyTemplate = (templateId: string) => {
|
||||
if (!templateId) return;
|
||||
const template = mappingTemplates.find((t) => t.id === templateId);
|
||||
if (!template) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "템플릿 적용 실패",
|
||||
description: "선택한 템플릿을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const config = template.config as HierarchyConfig;
|
||||
setHierarchyConfig(config);
|
||||
|
||||
// 선택된 테이블 정보 동기화
|
||||
const newSelectedTables: any = {
|
||||
warehouse: config.warehouse?.tableName || "",
|
||||
area: "",
|
||||
location: "",
|
||||
material: "",
|
||||
};
|
||||
|
||||
if (config.levels && config.levels.length > 0) {
|
||||
// 레벨 1 = Area
|
||||
if (config.levels[0]?.tableName) {
|
||||
newSelectedTables.area = config.levels[0].tableName;
|
||||
}
|
||||
// 레벨 2 = Location
|
||||
if (config.levels[1]?.tableName) {
|
||||
newSelectedTables.location = config.levels[1].tableName;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.material?.tableName) {
|
||||
newSelectedTables.material = config.material.tableName;
|
||||
}
|
||||
|
||||
setSelectedTables(newSelectedTables);
|
||||
setSelectedWarehouse(config.warehouseKey || null);
|
||||
setHasUnsavedChanges(true);
|
||||
|
||||
toast({
|
||||
title: "템플릿 적용 완료",
|
||||
description: `"${template.name}" 템플릿이 적용되었습니다.`,
|
||||
});
|
||||
};
|
||||
|
||||
// 매핑 템플릿 저장
|
||||
const handleSaveTemplate = async () => {
|
||||
if (!selectedDbConnection || !hierarchyConfig) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "템플릿 저장 불가",
|
||||
description: "외부 DB와 계층 설정을 먼저 완료해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newTemplateName.trim()) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "템플릿 이름 필요",
|
||||
description: "템플릿 이름을 입력해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await createMappingTemplate({
|
||||
name: newTemplateName.trim(),
|
||||
description: newTemplateDescription.trim() || undefined,
|
||||
externalDbConnectionId: selectedDbConnection,
|
||||
layoutType: "yard-3d",
|
||||
config: hierarchyConfig,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
setMappingTemplates((prev) => [response.data!, ...prev]);
|
||||
setIsSaveTemplateDialogOpen(false);
|
||||
setNewTemplateName("");
|
||||
setNewTemplateDescription("");
|
||||
toast({
|
||||
title: "템플릿 저장 완료",
|
||||
description: `"${response.data.name}" 템플릿이 저장되었습니다.`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "템플릿 저장 실패",
|
||||
description: response.error || "템플릿을 저장하지 못했습니다.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("매핑 템플릿 저장 실패:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "템플릿 저장 실패",
|
||||
description: "템플릿을 저장하는 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 객체 이동
|
||||
const handleObjectMove = (objectId: number, newX: number, newZ: number, newY?: number) => {
|
||||
setPlacedObjects((prev) => {
|
||||
|
|
@ -925,7 +1161,59 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
return obj;
|
||||
});
|
||||
|
||||
// 2. 그룹 이동: 자식 객체들도 함께 이동
|
||||
// 2. 하위 계층 객체 이동 시 논리적 키 검증
|
||||
if (hierarchyConfig && targetObj.hierarchyLevel && targetObj.hierarchyLevel > 1) {
|
||||
const spatialObjects = updatedObjects.map((obj) => ({
|
||||
id: obj.id,
|
||||
position: obj.position,
|
||||
size: obj.size,
|
||||
hierarchyLevel: obj.hierarchyLevel || 1,
|
||||
parentId: obj.parentId,
|
||||
}));
|
||||
|
||||
const targetSpatialObj = spatialObjects.find((obj) => obj.id === objectId);
|
||||
if (targetSpatialObj) {
|
||||
const validation = validateSpatialContainment(
|
||||
targetSpatialObj,
|
||||
spatialObjects.filter((obj) => obj.id !== objectId),
|
||||
);
|
||||
|
||||
// 새로운 부모 영역 찾기
|
||||
if (validation.parent) {
|
||||
const newParentObj = prev.find((obj) => obj.id === validation.parent!.id);
|
||||
|
||||
// DB에서 가져온 데이터인 경우 논리적 키 검증
|
||||
if (newParentObj && newParentObj.externalKey && targetObj.parentKey) {
|
||||
if (newParentObj.externalKey !== targetObj.parentKey) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "이동 불가",
|
||||
description: `이 Location은 '${targetObj.parentKey}' Area에만 배치할 수 있습니다. (현재 선택된 Area: ${newParentObj.externalKey})`,
|
||||
});
|
||||
return prev; // 이동 취소
|
||||
}
|
||||
}
|
||||
|
||||
// 부모 ID 업데이트
|
||||
updatedObjects = updatedObjects.map((obj) => {
|
||||
if (obj.id === objectId) {
|
||||
return { ...obj, parentId: validation.parent!.id };
|
||||
}
|
||||
return obj;
|
||||
});
|
||||
} else if (targetObj.parentKey) {
|
||||
// DB 데이터인데 부모 영역 밖으로 이동하려는 경우
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "이동 불가",
|
||||
description: `이 Location은 '${targetObj.parentKey}' Area 내부에 있어야 합니다.`,
|
||||
});
|
||||
return prev; // 이동 취소
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 그룹 이동: 자식 객체들도 함께 이동
|
||||
const spatialObjects = updatedObjects.map((obj) => ({
|
||||
id: obj.id,
|
||||
position: obj.position,
|
||||
|
|
@ -1164,13 +1452,14 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 도구 팔레트 */}
|
||||
{/* 도구 팔레트 (현재 숨김 처리 - 나중에 재사용 가능) */}
|
||||
{/*
|
||||
<div className="bg-muted flex items-center justify-center gap-2 border-b p-4">
|
||||
<span className="text-muted-foreground text-sm font-medium">도구:</span>
|
||||
{[
|
||||
{ type: "area" as ToolType, label: "영역", icon: Grid3x3, color: "text-blue-500" },
|
||||
{ type: "location-bed" as ToolType, label: "베드", icon: Package, color: "text-emerald-500" },
|
||||
{ type: "location-stp" as ToolType, label: "정차", icon: Move, color: "text-orange-500" },
|
||||
{ type: "location-bed" as ToolType, label: "베드", icon: Package, color: "text-blue-600" },
|
||||
{ type: "location-stp" as ToolType, label: "정차", icon: Move, color: "text-gray-500" },
|
||||
// { type: "crane-gantry" as ToolType, label: "겐트리", icon: Combine, color: "text-green-500" },
|
||||
{ type: "crane-mobile" as ToolType, label: "크레인", icon: Truck, color: "text-yellow-500" },
|
||||
{ type: "rack" as ToolType, label: "랙", icon: Box, color: "text-purple-500" },
|
||||
|
|
@ -1190,6 +1479,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
*/}
|
||||
|
||||
{/* 메인 영역 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
|
|
@ -1205,6 +1495,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
onValueChange={(value) => {
|
||||
setSelectedDbConnection(parseInt(value));
|
||||
setSelectedWarehouse(null);
|
||||
setSelectedTemplateId("");
|
||||
setHasUnsavedChanges(true);
|
||||
}}
|
||||
>
|
||||
|
|
@ -1219,55 +1510,66 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 창고 테이블 및 컬럼 매핑 */}
|
||||
{selectedDbConnection && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">창고 선택</Label>
|
||||
|
||||
{/* 이 레이아웃의 창고 선택 */}
|
||||
{hierarchyConfig?.warehouse?.tableName && hierarchyConfig?.warehouse?.keyColumn && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground mb-1 block text-xs">이 레이아웃의 창고</Label>
|
||||
{loadingWarehouses ? (
|
||||
<div className="flex h-9 items-center justify-center rounded-md border">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedWarehouse || ""}
|
||||
onValueChange={(value) => {
|
||||
setSelectedWarehouse(value);
|
||||
// hierarchyConfig 업데이트 (없으면 새로 생성)
|
||||
setHierarchyConfig((prev) => ({
|
||||
warehouseKey: value,
|
||||
levels: prev?.levels || [],
|
||||
material: prev?.material,
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue placeholder="창고 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((wh: any) => {
|
||||
const keyCol = hierarchyConfig.warehouse!.keyColumn;
|
||||
const nameCol = hierarchyConfig.warehouse!.nameColumn;
|
||||
return (
|
||||
<SelectItem key={wh[keyCol]} value={wh[keyCol]} className="text-xs">
|
||||
{wh[nameCol] || wh[keyCol]}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* 매핑 템플릿 선택/저장 */}
|
||||
{selectedDbConnection && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-semibold">매핑 템플릿</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setNewTemplateName(layoutName || "");
|
||||
setIsSaveTemplateDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
템플릿 저장
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={selectedTemplateId}
|
||||
onValueChange={(val) => setSelectedTemplateId(val)}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-xs">
|
||||
<SelectValue placeholder={loadingTemplates ? "로딩 중..." : "템플릿 선택..."} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mappingTemplates.length === 0 ? (
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs">
|
||||
사용 가능한 템플릿이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
mappingTemplates.map((tpl) => (
|
||||
<SelectItem key={tpl.id} value={tpl.id} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{tpl.name}</span>
|
||||
{tpl.description && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{tpl.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
disabled={!selectedTemplateId}
|
||||
onClick={() => handleApplyTemplate(selectedTemplateId)}
|
||||
>
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 계층 설정 패널 (신규) */}
|
||||
{selectedDbConnection && (
|
||||
|
|
@ -1311,12 +1613,21 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
}}
|
||||
onLoadColumns={async (tableName: string) => {
|
||||
try {
|
||||
const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName);
|
||||
const response = await ExternalDbConnectionAPI.getTableColumns(
|
||||
selectedDbConnection,
|
||||
tableName,
|
||||
);
|
||||
if (response.success && response.data) {
|
||||
// 객체 배열을 문자열 배열로 변환
|
||||
return response.data.map((col: any) =>
|
||||
typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col),
|
||||
);
|
||||
// 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그)
|
||||
return response.data.map((col: any) => ({
|
||||
column_name:
|
||||
typeof col === "string"
|
||||
? col
|
||||
: col.column_name || col.COLUMN_NAME || String(col),
|
||||
data_type: col.data_type || col.DATA_TYPE,
|
||||
description: col.description || col.COLUMN_COMMENT || undefined,
|
||||
is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
|
|
@ -1327,6 +1638,53 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* 창고 선택 (HierarchyConfigPanel 아래로 이동) */}
|
||||
{selectedDbConnection && hierarchyConfig?.warehouse?.tableName && hierarchyConfig?.warehouse?.keyColumn && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">창고 선택</Label>
|
||||
|
||||
<div>
|
||||
<Label className="text-muted-foreground mb-1 block text-xs">배치할 창고를 선택하세요</Label>
|
||||
{loadingWarehouses ? (
|
||||
<div className="flex h-9 items-center justify-center rounded-md border">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedWarehouse || ""}
|
||||
onValueChange={(value) => {
|
||||
setSelectedWarehouse(value);
|
||||
// hierarchyConfig 업데이트
|
||||
setHierarchyConfig((prev) => ({
|
||||
...prev,
|
||||
warehouseKey: value,
|
||||
levels: prev?.levels || [],
|
||||
material: prev?.material,
|
||||
warehouse: prev?.warehouse,
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue placeholder="창고 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((wh: any) => {
|
||||
const keyCol = hierarchyConfig.warehouse!.keyColumn;
|
||||
const nameCol = hierarchyConfig.warehouse!.nameColumn;
|
||||
return (
|
||||
<SelectItem key={wh[keyCol]} value={wh[keyCol]} className="text-xs">
|
||||
{wh[nameCol] || wh[keyCol]}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Area 목록 */}
|
||||
{selectedDbConnection && selectedWarehouse && (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -1452,77 +1810,185 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 배치된 객체 목록 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{/* 배치된 객체 목록 (계층 구조) */}
|
||||
<div className="flex-1 overflow-y-auto border-t p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">배치된 객체 ({placedObjects.length})</h3>
|
||||
|
||||
{placedObjects.length === 0 ? (
|
||||
<div className="text-muted-foreground text-center text-sm">상단 도구를 드래그하여 배치하세요</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{placedObjects.map((obj) => (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
selectedObject?.id === obj.id ? "border-primary bg-primary/10" : "hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{obj.name}</span>
|
||||
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: obj.color }} />
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
위치: ({obj.position.x.toFixed(1)}, {obj.position.z.toFixed(1)})
|
||||
</p>
|
||||
{obj.areaKey && <p className="text-muted-foreground mt-1 text-xs">Area: {obj.areaKey}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{/* Area별로 그룹핑 */}
|
||||
{(() => {
|
||||
// Area 객체들
|
||||
const areaObjects = placedObjects.filter((obj) => obj.type === "area");
|
||||
|
||||
// Area가 없으면 기존 방식으로 표시
|
||||
if (areaObjects.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{placedObjects.map((obj) => (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
selectedObject?.id === obj.id ? "border-primary bg-primary/10" : "hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{obj.name}</span>
|
||||
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: obj.color }} />
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
위치: ({obj.position.x.toFixed(1)}, {obj.position.z.toFixed(1)})
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Area별로 Location들을 그룹핑
|
||||
return areaObjects.map((areaObj) => {
|
||||
// 이 Area의 자식 Location들 찾기
|
||||
const childLocations = placedObjects.filter(
|
||||
(obj) =>
|
||||
obj.type !== "area" &&
|
||||
obj.areaKey === areaObj.areaKey &&
|
||||
(obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey),
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionItem key={areaObj.id} value={`area-${areaObj.id}`} className="border-b">
|
||||
<AccordionTrigger className="px-2 py-3 hover:no-underline">
|
||||
<div
|
||||
className={`flex w-full items-center justify-between pr-2 ${
|
||||
selectedObject?.id === areaObj.id ? "text-primary font-semibold" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleObjectClick(areaObj.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Grid3x3 className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{areaObj.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">({childLocations.length})</span>
|
||||
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: areaObj.color }} />
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-2 pb-3">
|
||||
{childLocations.length === 0 ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">Location이 없습니다</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{childLocations.map((locationObj) => (
|
||||
<div
|
||||
key={locationObj.id}
|
||||
onClick={() => handleObjectClick(locationObj.id)}
|
||||
className={`cursor-pointer rounded-lg border p-2 transition-all ${
|
||||
selectedObject?.id === locationObj.id
|
||||
? "border-primary bg-primary/10"
|
||||
: "hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-3 w-3" />
|
||||
<span className="text-xs font-medium">{locationObj.name}</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: locationObj.color }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)})
|
||||
</p>
|
||||
{locationObj.locaKey && (
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
Key: {locationObj.locaKey}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 3D 캔버스 */}
|
||||
<div
|
||||
className="h-full flex-1 bg-gray-100"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const rawX = ((e.clientX - rect.left) / rect.width - 0.5) * 100;
|
||||
const rawZ = ((e.clientY - rect.top) / rect.height - 0.5) * 100;
|
||||
|
||||
// 그리드 크기 (5 단위)
|
||||
const gridSize = 5;
|
||||
|
||||
// 그리드에 스냅
|
||||
// Area(20x20)는 그리드 교차점에, 다른 객체(5x5)는 타일 중앙에
|
||||
let snappedX = Math.round(rawX / gridSize) * gridSize;
|
||||
let snappedZ = Math.round(rawZ / gridSize) * gridSize;
|
||||
|
||||
// 5x5 객체는 타일 중앙으로 오프셋 (Area는 제외)
|
||||
if (draggedTool !== "area") {
|
||||
snappedX += gridSize / 2;
|
||||
snappedZ += gridSize / 2;
|
||||
}
|
||||
|
||||
handleCanvasDrop(snappedX, snappedZ);
|
||||
}}
|
||||
>
|
||||
<div className="relative h-full flex-1 bg-gray-100">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Yard3DCanvas
|
||||
placements={placements}
|
||||
selectedPlacementId={selectedObject?.id || null}
|
||||
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
|
||||
onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)}
|
||||
focusOnPlacementId={null}
|
||||
onCollisionDetected={() => {}}
|
||||
/>
|
||||
<>
|
||||
<Yard3DCanvas
|
||||
placements={placements}
|
||||
selectedPlacementId={selectedObject?.id || null}
|
||||
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
|
||||
onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)}
|
||||
focusOnPlacementId={null}
|
||||
onCollisionDetected={() => {}}
|
||||
previewTool={draggedTool}
|
||||
previewPosition={previewPosition}
|
||||
onPreviewPositionUpdate={setPreviewPosition}
|
||||
/>
|
||||
{/* 드래그 중일 때 Canvas 위에 투명한 오버레이 (프리뷰 및 드롭 이벤트 캐치용) */}
|
||||
{draggedTool && (
|
||||
<div
|
||||
className="pointer-events-auto absolute inset-0"
|
||||
style={{ zIndex: 10 }}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const rawX = ((e.clientX - rect.left) / rect.width - 0.5) * 100;
|
||||
const rawZ = ((e.clientY - rect.top) / rect.height - 0.5) * 100;
|
||||
|
||||
// 그리드 크기 (5 단위)
|
||||
const gridSize = 5;
|
||||
|
||||
// 그리드에 스냅
|
||||
let snappedX = Math.round(rawX / gridSize) * gridSize;
|
||||
let snappedZ = Math.round(rawZ / gridSize) * gridSize;
|
||||
|
||||
// 5x5 객체는 타일 중앙으로 오프셋 (Area는 제외)
|
||||
if (draggedTool !== "area") {
|
||||
snappedX += gridSize / 2;
|
||||
snappedZ += gridSize / 2;
|
||||
}
|
||||
|
||||
setPreviewPosition({ x: snappedX, z: snappedZ });
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
setPreviewPosition(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (previewPosition) {
|
||||
handleCanvasDrop(previewPosition.x, previewPosition.z);
|
||||
setPreviewPosition(null);
|
||||
}
|
||||
setDraggedTool(null);
|
||||
setDraggedAreaData(null);
|
||||
setDraggedLocationData(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -1605,7 +2071,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
</Label>
|
||||
<Input
|
||||
id="object-name"
|
||||
value={selectedObject.name}
|
||||
value={selectedObject.name || ""}
|
||||
onChange={(e) => handleObjectUpdate({ name: e.target.value })}
|
||||
className="mt-1.5 h-9 text-sm"
|
||||
/>
|
||||
|
|
@ -1622,7 +2088,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Input
|
||||
id="pos-x"
|
||||
type="number"
|
||||
value={selectedObject.position.x.toFixed(1)}
|
||||
value={(selectedObject.position?.x || 0).toFixed(1)}
|
||||
onChange={(e) =>
|
||||
handleObjectUpdate({
|
||||
position: {
|
||||
|
|
@ -1641,7 +2107,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Input
|
||||
id="pos-z"
|
||||
type="number"
|
||||
value={selectedObject.position.z.toFixed(1)}
|
||||
value={(selectedObject.position?.z || 0).toFixed(1)}
|
||||
onChange={(e) =>
|
||||
handleObjectUpdate({
|
||||
position: {
|
||||
|
|
@ -1669,7 +2135,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
type="number"
|
||||
step="5"
|
||||
min="5"
|
||||
value={selectedObject.size.x}
|
||||
value={selectedObject.size?.x || 5}
|
||||
onChange={(e) =>
|
||||
handleObjectUpdate({
|
||||
size: {
|
||||
|
|
@ -1688,7 +2154,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Input
|
||||
id="size-y"
|
||||
type="number"
|
||||
value={selectedObject.size.y}
|
||||
value={selectedObject.size?.y || 5}
|
||||
onChange={(e) =>
|
||||
handleObjectUpdate({
|
||||
size: {
|
||||
|
|
@ -1709,7 +2175,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
type="number"
|
||||
step="5"
|
||||
min="5"
|
||||
value={selectedObject.size.z}
|
||||
value={selectedObject.size?.z || 5}
|
||||
onChange={(e) =>
|
||||
handleObjectUpdate({
|
||||
size: {
|
||||
|
|
@ -1732,7 +2198,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Input
|
||||
id="object-color"
|
||||
type="color"
|
||||
value={selectedObject.color}
|
||||
value={selectedObject.color || "#3b82f6"}
|
||||
onChange={(e) => handleObjectUpdate({ color: e.target.value })}
|
||||
className="mt-1.5 h-9"
|
||||
/>
|
||||
|
|
@ -1751,6 +2217,58 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 매핑 템플릿 저장 다이얼로그 */}
|
||||
<Dialog open={isSaveTemplateDialogOpen} onOpenChange={setIsSaveTemplateDialogOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">매핑 템플릿 저장</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
현재 창고/계층/자재 설정을 템플릿으로 저장하여 다른 레이아웃에서 재사용할 수 있습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="template-name" className="text-xs sm:text-sm">
|
||||
템플릿 이름 *
|
||||
</Label>
|
||||
<Input
|
||||
id="template-name"
|
||||
value={newTemplateName}
|
||||
onChange={(e) => setNewTemplateName(e.target.value)}
|
||||
placeholder="예: 동연 야드 표준 매핑"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="template-desc" className="text-xs sm:text-sm">
|
||||
설명 (선택)
|
||||
</Label>
|
||||
<Input
|
||||
id="template-desc"
|
||||
value={newTemplateDescription}
|
||||
onChange={(e) => setNewTemplateDescription(e.target.value)}
|
||||
placeholder="이 템플릿에 대한 설명을 입력하세요"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsSaveTemplateDialogOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveTemplate}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Loader2, Search, Filter, X } from "lucide-react";
|
||||
import { Loader2, Search, X, Grid3x3, Package } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -10,6 +10,8 @@ import dynamic from "next/dynamic";
|
|||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { PlacedObject, MaterialData } from "@/types/digitalTwin";
|
||||
import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin";
|
||||
import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
|
||||
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
||||
ssr: false,
|
||||
|
|
@ -81,7 +83,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
z: parseFloat(obj.size_z),
|
||||
},
|
||||
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
|
||||
color: getObjectColor(objectType), // 타입별 기본 색상 사용
|
||||
color: getObjectColor(objectType, obj.color), // 저장된 색상 우선, 없으면 타입별 기본 색상
|
||||
areaKey: obj.area_key,
|
||||
locaKey: obj.loca_key,
|
||||
locType: obj.loc_type,
|
||||
|
|
@ -93,6 +95,9 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
displayOrder: obj.display_order,
|
||||
locked: obj.locked,
|
||||
visible: obj.visible !== false,
|
||||
hierarchyLevel: obj.hierarchy_level,
|
||||
parentKey: obj.parent_key,
|
||||
externalKey: obj.external_key,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -225,17 +230,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
|
||||
// 객체 타입별 기본 색상 (useMemo로 최적화)
|
||||
const getObjectColor = useMemo(() => {
|
||||
return (type: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
area: "#3b82f6", // 파란색
|
||||
"location-bed": "#2563eb", // 진한 파란색
|
||||
"location-stp": "#6b7280", // 회색
|
||||
"location-temp": "#f59e0b", // 주황색
|
||||
"location-dest": "#10b981", // 초록색
|
||||
"crane-mobile": "#8b5cf6", // 보라색
|
||||
rack: "#ef4444", // 빨간색
|
||||
};
|
||||
return colorMap[type] || "#3b82f6";
|
||||
return (type: string, savedColor?: string): string => {
|
||||
// 저장된 색상이 있으면 우선 사용
|
||||
if (savedColor) return savedColor;
|
||||
// 없으면 타입별 기본 색상 사용
|
||||
return OBJECT_COLORS[type] || DEFAULT_COLOR;
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -357,61 +356,154 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
{searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredObjects.map((obj) => {
|
||||
// 타입별 레이블
|
||||
let typeLabel = obj.type;
|
||||
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||
else if (obj.type === "area") typeLabel = "Area";
|
||||
else if (obj.type === "rack") typeLabel = "랙";
|
||||
(() => {
|
||||
// Area 객체가 있는 경우 계층 트리 아코디언 적용
|
||||
const areaObjects = filteredObjects.filter((obj) => obj.type === "area");
|
||||
|
||||
// Area가 없으면 기존 평면 리스트 유지
|
||||
if (areaObjects.length === 0) {
|
||||
return (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{obj.name}</p>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: getObjectColor(obj.type) }}
|
||||
/>
|
||||
<span>{typeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{filteredObjects.map((obj) => {
|
||||
let typeLabel = obj.type;
|
||||
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||
else if (obj.type === "area") typeLabel = "Area";
|
||||
else if (obj.type === "rack") typeLabel = "랙";
|
||||
|
||||
{/* 추가 정보 */}
|
||||
<div className="mt-2 space-y-1">
|
||||
{obj.areaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Area: <span className="font-medium">{obj.areaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.locaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Location: <span className="font-medium">{obj.locaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.materialCount !== undefined && obj.materialCount > 0 && (
|
||||
<p className="text-xs text-yellow-600">
|
||||
자재: <span className="font-semibold">{obj.materialCount}개</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{obj.name}</p>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: obj.color }}
|
||||
/>
|
||||
<span>{typeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{obj.areaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Area: <span className="font-medium">{obj.areaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.locaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Location: <span className="font-medium">{obj.locaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.materialCount !== undefined && obj.materialCount > 0 && (
|
||||
<p className="text-xs text-yellow-600">
|
||||
자재: <span className="font-semibold">{obj.materialCount}개</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
|
||||
// Area가 있는 경우: Area → Location 계층 아코디언
|
||||
return (
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{areaObjects.map((areaObj) => {
|
||||
const childLocations = filteredObjects.filter(
|
||||
(obj) =>
|
||||
obj.type !== "area" &&
|
||||
obj.areaKey === areaObj.areaKey &&
|
||||
(obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey),
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionItem key={areaObj.id} value={`area-${areaObj.id}`} className="border-b">
|
||||
<AccordionTrigger className="px-2 py-3 hover:no-underline">
|
||||
<div
|
||||
className={`flex w-full items-center justify-between pr-2 ${
|
||||
selectedObject?.id === areaObj.id ? "text-primary font-semibold" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleObjectClick(areaObj.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Grid3x3 className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{areaObj.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">({childLocations.length})</span>
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: areaObj.color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-2 pb-3">
|
||||
{childLocations.length === 0 ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">Location이 없습니다</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{childLocations.map((locationObj) => (
|
||||
<div
|
||||
key={locationObj.id}
|
||||
onClick={() => handleObjectClick(locationObj.id)}
|
||||
className={`cursor-pointer rounded-lg border p-2 transition-all ${
|
||||
selectedObject?.id === locationObj.id
|
||||
? "border-primary bg-primary/10"
|
||||
: "hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-3 w-3" />
|
||||
<span className="text-xs font-medium">{locationObj.name}</span>
|
||||
</div>
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: locationObj.color }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
위치: ({locationObj.position.x.toFixed(1)},{" "}
|
||||
{locationObj.position.z.toFixed(1)})
|
||||
</p>
|
||||
{locationObj.locaKey && (
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
Location: <span className="font-medium">{locationObj.locaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{locationObj.materialCount !== undefined && locationObj.materialCount > 0 && (
|
||||
<p className="mt-0.5 text-[10px] text-yellow-600">
|
||||
자재: <span className="font-semibold">{locationObj.materialCount}개</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -408,3 +408,4 @@ const handleObjectMove = (
|
|||
**작성일**: 2025-11-20
|
||||
**작성자**: AI Assistant
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -40,13 +40,26 @@ export interface HierarchyConfig {
|
|||
};
|
||||
}
|
||||
|
||||
interface TableInfo {
|
||||
table_name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
column_name: string;
|
||||
data_type?: string;
|
||||
description?: string;
|
||||
// 백엔드에서 내려주는 Primary Key 플래그 ("YES"/"NO" 또는 boolean)
|
||||
is_primary_key?: string | boolean;
|
||||
}
|
||||
|
||||
interface HierarchyConfigPanelProps {
|
||||
externalDbConnectionId: number | null;
|
||||
hierarchyConfig: HierarchyConfig | null;
|
||||
onHierarchyConfigChange: (config: HierarchyConfig) => void;
|
||||
availableTables: string[];
|
||||
availableTables: TableInfo[];
|
||||
onLoadTables: () => Promise<void>;
|
||||
onLoadColumns: (tableName: string) => Promise<string[]>;
|
||||
onLoadColumns: (tableName: string) => Promise<ColumnInfo[]>;
|
||||
}
|
||||
|
||||
export default function HierarchyConfigPanel({
|
||||
|
|
@ -65,28 +78,156 @@ export default function HierarchyConfigPanel({
|
|||
);
|
||||
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: string[] }>({});
|
||||
const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: ColumnInfo[] }>({});
|
||||
|
||||
// 외부에서 변경된 경우 동기화
|
||||
// 동일한 column_name 이 여러 번 내려오는 경우(조인 중복 등) 제거
|
||||
const normalizeColumns = (columns: ColumnInfo[]): ColumnInfo[] => {
|
||||
const map = new Map<string, ColumnInfo>();
|
||||
for (const col of columns) {
|
||||
const key = col.column_name;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, col);
|
||||
}
|
||||
}
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
// 외부에서 변경된 경우 동기화 및 컬럼 자동 로드
|
||||
// 외부에서 변경된 경우 동기화 및 컬럼 자동 로드
|
||||
useEffect(() => {
|
||||
if (hierarchyConfig) {
|
||||
setLocalConfig(hierarchyConfig);
|
||||
}
|
||||
}, [hierarchyConfig]);
|
||||
|
||||
// 테이블 선택 시 컬럼 로드
|
||||
// 저장된 설정의 테이블들에 대한 컬럼 자동 로드
|
||||
const loadSavedColumns = async () => {
|
||||
const tablesToLoad: string[] = [];
|
||||
|
||||
// 창고 테이블
|
||||
if (hierarchyConfig.warehouse?.tableName) {
|
||||
tablesToLoad.push(hierarchyConfig.warehouse.tableName);
|
||||
}
|
||||
|
||||
// 계층 레벨 테이블들
|
||||
hierarchyConfig.levels?.forEach((level) => {
|
||||
if (level.tableName) {
|
||||
tablesToLoad.push(level.tableName);
|
||||
}
|
||||
});
|
||||
|
||||
// 자재 테이블
|
||||
if (hierarchyConfig.material?.tableName) {
|
||||
tablesToLoad.push(hierarchyConfig.material.tableName);
|
||||
}
|
||||
|
||||
// 중복 제거 후, 아직 캐시에 없는 테이블만 병렬로 로드
|
||||
const uniqueTables = [...new Set(tablesToLoad)];
|
||||
const tablesToFetch = uniqueTables.filter((tableName) => !columnsCache[tableName]);
|
||||
|
||||
if (tablesToFetch.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
await Promise.all(
|
||||
tablesToFetch.map(async (tableName) => {
|
||||
try {
|
||||
const columns = await onLoadColumns(tableName);
|
||||
const normalized = normalizeColumns(columns);
|
||||
setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
|
||||
} catch (error) {
|
||||
console.error(`컬럼 로드 실패 (${tableName}):`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (externalDbConnectionId) {
|
||||
loadSavedColumns();
|
||||
}
|
||||
}
|
||||
}, [hierarchyConfig, externalDbConnectionId]);
|
||||
|
||||
// 지정된 컬럼이 Primary Key 인지 여부
|
||||
const isPrimaryKey = (col: ColumnInfo): boolean => {
|
||||
if (col.is_primary_key === true) return true;
|
||||
if (typeof col.is_primary_key === "string") {
|
||||
const v = col.is_primary_key.toUpperCase();
|
||||
return v === "YES" || v === "Y" || v === "TRUE" || v === "PK";
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 테이블 선택 시 컬럼 로드 + PK 기반 기본값 설정
|
||||
const handleTableChange = async (tableName: string, type: "warehouse" | "material" | number) => {
|
||||
if (columnsCache[tableName]) return; // 이미 로드된 경우 스킵
|
||||
let loadedColumns = columnsCache[tableName];
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const columns = await onLoadColumns(tableName);
|
||||
setColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
|
||||
} catch (error) {
|
||||
console.error("컬럼 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
// 아직 캐시에 없으면 먼저 컬럼 조회
|
||||
if (!loadedColumns) {
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const fetched = await onLoadColumns(tableName);
|
||||
loadedColumns = normalizeColumns(fetched);
|
||||
setColumnsCache((prev) => ({ ...prev, [tableName]: loadedColumns! }));
|
||||
} catch (error) {
|
||||
console.error("컬럼 로드 실패:", error);
|
||||
loadedColumns = [];
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = loadedColumns || [];
|
||||
|
||||
// PK 기반으로 keyColumn 기본값 자동 설정 (이미 값이 있으면 건드리지 않음)
|
||||
// PK 정보가 없으면 첫 번째 컬럼을 기본값으로 사용
|
||||
setLocalConfig((prev) => {
|
||||
const next = { ...prev };
|
||||
const primaryColumns = columns.filter((col) => isPrimaryKey(col));
|
||||
const pkName = (primaryColumns[0] || columns[0])?.column_name;
|
||||
|
||||
if (!pkName) {
|
||||
return next;
|
||||
}
|
||||
|
||||
if (type === "warehouse") {
|
||||
const wh = {
|
||||
...(next.warehouse || { tableName }),
|
||||
tableName: next.warehouse?.tableName || tableName,
|
||||
};
|
||||
if (!wh.keyColumn) {
|
||||
wh.keyColumn = pkName;
|
||||
}
|
||||
next.warehouse = wh;
|
||||
} else if (type === "material") {
|
||||
const material = {
|
||||
...(next.material || { tableName }),
|
||||
tableName: next.material?.tableName || tableName,
|
||||
};
|
||||
if (!material.keyColumn) {
|
||||
material.keyColumn = pkName;
|
||||
}
|
||||
next.material = material as NonNullable<HierarchyConfig["material"]>;
|
||||
} else if (typeof type === "number") {
|
||||
// 계층 레벨
|
||||
next.levels = next.levels.map((lvl) => {
|
||||
if (lvl.level !== type) return lvl;
|
||||
const updated: HierarchyLevel = {
|
||||
...lvl,
|
||||
tableName: lvl.tableName || tableName,
|
||||
};
|
||||
if (!updated.keyColumn) {
|
||||
updated.keyColumn = pkName;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 창고 키 변경 (제거됨 - 상위 컴포넌트에서 처리)
|
||||
|
|
@ -187,12 +328,22 @@ export default function HierarchyConfigPanel({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table} value={table} className="text-[10px]">
|
||||
{table}
|
||||
<SelectItem key={table.table_name} value={table.table_name} className="text-[10px]">
|
||||
<div className="flex flex-col">
|
||||
<span>{table.table_name}</span>
|
||||
{table.description && (
|
||||
<span className="text-muted-foreground text-[9px]">{table.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!localConfig.warehouse?.tableName && (
|
||||
<p className="text-muted-foreground mt-1 text-[9px]">
|
||||
ℹ️ 창고 테이블을 선택하고 "설정 적용"을 눌러주세요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 창고 컬럼 매핑 */}
|
||||
|
|
@ -208,11 +359,22 @@ export default function HierarchyConfigPanel({
|
|||
<SelectValue placeholder="선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[localConfig.warehouse.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-[10px]">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
{columnsCache[localConfig.warehouse.tableName].map((col) => {
|
||||
const pk = isPrimaryKey(col);
|
||||
return (
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-[10px]">
|
||||
<div className="flex flex-col">
|
||||
<span>
|
||||
{col.column_name}
|
||||
{pk && <span className="text-amber-500 ml-1 text-[8px]">PK</span>}
|
||||
</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[8px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -228,8 +390,13 @@ export default function HierarchyConfigPanel({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[localConfig.warehouse.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-[10px]">
|
||||
{col}
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-[10px]">
|
||||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[8px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -237,6 +404,15 @@ export default function HierarchyConfigPanel({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localConfig.warehouse?.tableName &&
|
||||
!columnsCache[localConfig.warehouse.tableName] &&
|
||||
loadingColumns && (
|
||||
<div className="flex items-center gap-2 pt-2 text-[10px] text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>컬럼 정보를 불러오는 중입니다...</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
@ -257,7 +433,7 @@ export default function HierarchyConfigPanel({
|
|||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
value={level.name}
|
||||
value={level.name || ""}
|
||||
onChange={(e) => handleLevelChange(level.level, "name", e.target.value)}
|
||||
className="h-7 w-32 text-xs"
|
||||
placeholder="레벨명"
|
||||
|
|
@ -276,7 +452,7 @@ export default function HierarchyConfigPanel({
|
|||
<div>
|
||||
<Label className="text-[10px]">테이블</Label>
|
||||
<Select
|
||||
value={level.tableName}
|
||||
value={level.tableName || ""}
|
||||
onValueChange={(val) => {
|
||||
handleLevelChange(level.level, "tableName", val);
|
||||
handleTableChange(val, level.level);
|
||||
|
|
@ -287,8 +463,13 @@ export default function HierarchyConfigPanel({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table} value={table} className="text-xs">
|
||||
{table}
|
||||
<SelectItem key={table.table_name} value={table.table_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{table.table_name}</span>
|
||||
{table.description && (
|
||||
<span className="text-muted-foreground text-[10px]">{table.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -297,48 +478,64 @@ export default function HierarchyConfigPanel({
|
|||
|
||||
{level.tableName && columnsCache[level.tableName] && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-[10px]">ID 컬럼</Label>
|
||||
<Select
|
||||
value={level.keyColumn}
|
||||
onValueChange={(val) => handleLevelChange(level.level, "keyColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[level.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
<div>
|
||||
<Label className="text-[10px]">ID 컬럼</Label>
|
||||
<Select
|
||||
value={level.keyColumn || ""}
|
||||
onValueChange={(val) => handleLevelChange(level.level, "keyColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[level.tableName].map((col) => {
|
||||
const pk = isPrimaryKey(col);
|
||||
return (
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>
|
||||
{col.column_name}
|
||||
{pk && <span className="text-amber-500 ml-1 text-[9px]">PK</span>}
|
||||
</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">이름 컬럼</Label>
|
||||
<Select
|
||||
value={level.nameColumn}
|
||||
onValueChange={(val) => handleLevelChange(level.level, "nameColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[level.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div>
|
||||
<Label className="text-[10px]">이름 컬럼</Label>
|
||||
<Select
|
||||
value={level.nameColumn || ""}
|
||||
onValueChange={(val) => handleLevelChange(level.level, "nameColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[level.tableName].map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">부모 키 컬럼</Label>
|
||||
<Select
|
||||
value={level.parentKeyColumn}
|
||||
value={level.parentKeyColumn || ""}
|
||||
onValueChange={(val) => handleLevelChange(level.level, "parentKeyColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
|
|
@ -346,8 +543,13 @@ export default function HierarchyConfigPanel({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[level.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -368,8 +570,13 @@ export default function HierarchyConfigPanel({
|
|||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{columnsCache[level.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -377,6 +584,13 @@ export default function HierarchyConfigPanel({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{level.tableName && !columnsCache[level.tableName] && loadingColumns && (
|
||||
<div className="flex items-center gap-2 pt-2 text-[10px] text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>컬럼 정보를 불러오는 중입니다...</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
|
@ -409,8 +623,13 @@ export default function HierarchyConfigPanel({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table} value={table} className="text-xs">
|
||||
{table}
|
||||
<SelectItem key={table.table_name} value={table.table_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{table.table_name}</span>
|
||||
{table.description && (
|
||||
<span className="text-muted-foreground text-[10px]">{table.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -419,82 +638,108 @@ export default function HierarchyConfigPanel({
|
|||
|
||||
{localConfig.material?.tableName && columnsCache[localConfig.material.tableName] && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-[10px]">ID 컬럼</Label>
|
||||
<Select
|
||||
value={localConfig.material.keyColumn}
|
||||
onValueChange={(val) => handleMaterialChange("keyColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">위치 키 컬럼</Label>
|
||||
<Select
|
||||
value={localConfig.material.locationKeyColumn}
|
||||
onValueChange={(val) => handleMaterialChange("locationKeyColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div>
|
||||
<Label className="text-[10px]">ID 컬럼</Label>
|
||||
<Select
|
||||
value={localConfig.material.keyColumn || ""}
|
||||
onValueChange={(val) => handleMaterialChange("keyColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => {
|
||||
const pk = isPrimaryKey(col);
|
||||
return (
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>
|
||||
{col.column_name}
|
||||
{pk && <span className="text-amber-500 ml-1 text-[9px]">PK</span>}
|
||||
</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">레이어 컬럼 (선택)</Label>
|
||||
<Select
|
||||
<div>
|
||||
<Label className="text-[10px]">위치 키 컬럼</Label>
|
||||
<Select
|
||||
value={localConfig.material.locationKeyColumn || ""}
|
||||
onValueChange={(val) => handleMaterialChange("locationKeyColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">레이어 컬럼 (선택)</Label>
|
||||
<Select
|
||||
value={localConfig.material.layerColumn || "__none__"}
|
||||
onValueChange={(val) => handleMaterialChange("layerColumn", val === "__none__" ? undefined : val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="레이어 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="레이어 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">수량 컬럼 (선택)</Label>
|
||||
<Select
|
||||
<div>
|
||||
<Label className="text-[10px]">수량 컬럼 (선택)</Label>
|
||||
<Select
|
||||
value={localConfig.material.quantityColumn || "__none__"}
|
||||
onValueChange={(val) => handleMaterialChange("quantityColumn", val === "__none__" ? undefined : val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="수량 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="수량 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
|
@ -507,30 +752,35 @@ export default function HierarchyConfigPanel({
|
|||
</p>
|
||||
<div className="max-h-60 space-y-2 overflow-y-auto rounded border p-2">
|
||||
{columnsCache[localConfig.material.tableName].map((col) => {
|
||||
const displayItem = localConfig.material?.displayColumns?.find((d) => d.column === col);
|
||||
const displayItem = localConfig.material?.displayColumns?.find((d) => d.column === col.column_name);
|
||||
const isSelected = !!displayItem;
|
||||
return (
|
||||
<div key={col} className="flex items-center gap-2">
|
||||
<div key={col.column_name} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
const currentDisplay = localConfig.material?.displayColumns || [];
|
||||
const newDisplay = e.target.checked
|
||||
? [...currentDisplay, { column: col, label: col }]
|
||||
: currentDisplay.filter((d) => d.column !== col);
|
||||
? [...currentDisplay, { column: col.column_name, label: col.column_name }]
|
||||
: currentDisplay.filter((d) => d.column !== col.column_name);
|
||||
handleMaterialChange("displayColumns", newDisplay);
|
||||
}}
|
||||
className="h-3 w-3 shrink-0"
|
||||
/>
|
||||
<span className="w-20 shrink-0 text-[10px]">{col}</span>
|
||||
<div className="flex w-24 shrink-0 flex-col">
|
||||
<span className="text-[10px]">{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[8px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Input
|
||||
value={displayItem?.label || col}
|
||||
value={displayItem?.label ?? ""}
|
||||
onChange={(e) => {
|
||||
const currentDisplay = localConfig.material?.displayColumns || [];
|
||||
const newDisplay = currentDisplay.map((d) =>
|
||||
d.column === col ? { ...d, label: e.target.value } : d,
|
||||
d.column === col.column_name ? { ...d, label: e.target.value } : d,
|
||||
);
|
||||
handleMaterialChange("displayColumns", newDisplay);
|
||||
}}
|
||||
|
|
@ -545,6 +795,15 @@ export default function HierarchyConfigPanel({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{localConfig.material?.tableName &&
|
||||
!columnsCache[localConfig.material.tableName] &&
|
||||
loadingColumns && (
|
||||
<div className="flex items-center gap-2 pt-2 text-[10px] text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>컬럼 정보를 불러오는 중입니다...</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ interface Yard3DCanvasProps {
|
|||
gridSize?: number; // 그리드 크기 (기본값: 5)
|
||||
onCollisionDetected?: () => void; // 충돌 감지 시 콜백
|
||||
focusOnPlacementId?: number | null; // 카메라가 포커스할 요소 ID
|
||||
previewTool?: string | null; // 드래그 중인 도구 타입
|
||||
previewPosition?: { x: number; z: number } | null; // 프리뷰 위치
|
||||
onPreviewPositionUpdate?: (position: { x: number; z: number } | null) => void;
|
||||
}
|
||||
|
||||
// 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일)
|
||||
|
|
@ -442,20 +445,79 @@ function MaterialBox({
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Area 이름 텍스트 */}
|
||||
{/* Area 이름 텍스트 - 위쪽 (바닥) */}
|
||||
{placement.name && (
|
||||
<Text
|
||||
position={[0, 0.15, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.2}
|
||||
color={placement.color}
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.05}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{placement.name}
|
||||
</Text>
|
||||
<>
|
||||
<Text
|
||||
position={[0, 0.15, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.2}
|
||||
color={placement.color}
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.05}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{placement.name}
|
||||
</Text>
|
||||
|
||||
{/* 4면에 텍스트 표시 */}
|
||||
{/* 앞면 (+Z) */}
|
||||
<Text
|
||||
position={[0, boxHeight / 2, boxDepth / 2 + 0.01]}
|
||||
rotation={[0, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxHeight) * 0.3}
|
||||
color="#ffffff"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.08}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{placement.name}
|
||||
</Text>
|
||||
|
||||
{/* 뒷면 (-Z) */}
|
||||
<Text
|
||||
position={[0, boxHeight / 2, -boxDepth / 2 - 0.01]}
|
||||
rotation={[0, Math.PI, 0]}
|
||||
fontSize={Math.min(boxWidth, boxHeight) * 0.3}
|
||||
color="#ffffff"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.08}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{placement.name}
|
||||
</Text>
|
||||
|
||||
{/* 왼쪽면 (-X) */}
|
||||
<Text
|
||||
position={[-boxWidth / 2 - 0.01, boxHeight / 2, 0]}
|
||||
rotation={[0, -Math.PI / 2, 0]}
|
||||
fontSize={Math.min(boxDepth, boxHeight) * 0.3}
|
||||
color="#ffffff"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.08}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{placement.name}
|
||||
</Text>
|
||||
|
||||
{/* 오른쪽면 (+X) */}
|
||||
<Text
|
||||
position={[boxWidth / 2 + 0.01, boxHeight / 2, 0]}
|
||||
rotation={[0, Math.PI / 2, 0]}
|
||||
fontSize={Math.min(boxDepth, boxHeight) * 0.3}
|
||||
color="#ffffff"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.08}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{placement.name}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
@ -1007,10 +1069,26 @@ function Scene({
|
|||
gridSize = 5,
|
||||
onCollisionDetected,
|
||||
focusOnPlacementId,
|
||||
previewTool,
|
||||
previewPosition,
|
||||
}: Yard3DCanvasProps) {
|
||||
const [isDraggingAny, setIsDraggingAny] = useState(false);
|
||||
const orbitControlsRef = useRef<any>(null);
|
||||
|
||||
// 프리뷰 박스 크기 계산
|
||||
const getPreviewSize = (tool: string) => {
|
||||
if (tool === "area") return { x: 20, y: 0.1, z: 20 };
|
||||
return { x: 5, y: 5, z: 5 };
|
||||
};
|
||||
|
||||
// 프리뷰 박스 색상
|
||||
const getPreviewColor = (tool: string) => {
|
||||
if (tool === "area") return "#3b82f6";
|
||||
if (tool === "location-bed") return "#10b981";
|
||||
if (tool === "location-stp") return "#f59e0b";
|
||||
return "#9ca3af";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 카메라 포커스 컨트롤러 */}
|
||||
|
|
@ -1069,6 +1147,30 @@ function Scene({
|
|||
/>
|
||||
))}
|
||||
|
||||
{/* 드래그 프리뷰 박스 */}
|
||||
{previewTool && previewPosition && (
|
||||
<Box
|
||||
args={[
|
||||
getPreviewSize(previewTool).x,
|
||||
getPreviewSize(previewTool).y,
|
||||
getPreviewSize(previewTool).z,
|
||||
]}
|
||||
position={[
|
||||
previewPosition.x,
|
||||
previewTool === "area" ? 0.05 : getPreviewSize(previewTool).y / 2,
|
||||
previewPosition.z,
|
||||
]}
|
||||
>
|
||||
<meshStandardMaterial
|
||||
color={getPreviewColor(previewTool)}
|
||||
transparent
|
||||
opacity={0.4}
|
||||
roughness={0.5}
|
||||
metalness={0.1}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 카메라 컨트롤 */}
|
||||
<OrbitControls
|
||||
ref={orbitControlsRef}
|
||||
|
|
@ -1095,6 +1197,9 @@ export default function Yard3DCanvas({
|
|||
gridSize = 5,
|
||||
onCollisionDetected,
|
||||
focusOnPlacementId,
|
||||
previewTool,
|
||||
previewPosition,
|
||||
onPreviewPositionUpdate,
|
||||
}: Yard3DCanvasProps) {
|
||||
const handleCanvasClick = (e: any) => {
|
||||
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
|
||||
|
|
@ -1123,6 +1228,8 @@ export default function Yard3DCanvas({
|
|||
gridSize={gridSize}
|
||||
onCollisionDetected={onCollisionDetected}
|
||||
focusOnPlacementId={focusOnPlacementId}
|
||||
previewTool={previewTool}
|
||||
previewPosition={previewPosition}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
|
|
|
|||
|
|
@ -68,15 +68,15 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
|
|||
<ResizableDialogContent className="sm:max-w-[500px]" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<ResizableDialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<ResizableDialogTitle>새 야드 생성</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>야드 이름을 입력하세요</ResizableDialogDescription>
|
||||
<ResizableDialogTitle>새로운 3D필드 생성</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>필드 이름을 입력하세요</ResizableDialogDescription>
|
||||
</div>
|
||||
</ResizableDialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="yard-name">
|
||||
야드 이름 <span className="text-destructive">*</span>
|
||||
필드 이름 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="yard-name"
|
||||
|
|
@ -86,7 +86,7 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
|
|||
setError("");
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="예: A구역, 1번 야드"
|
||||
placeholder="예: A 공장"
|
||||
disabled={isCreating}
|
||||
autoFocus
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* 디지털 트윈 3D 야드 - 공통 상수
|
||||
*/
|
||||
|
||||
// 객체 타입별 색상 매핑 (HEX 코드)
|
||||
export const OBJECT_COLORS: Record<string, string> = {
|
||||
area: "#3b82f6", // 파란색
|
||||
"location-bed": "#2563eb", // 진한 파란색
|
||||
"location-stp": "#6b7280", // 회색
|
||||
"location-temp": "#f59e0b", // 주황색
|
||||
"location-dest": "#10b981", // 초록색
|
||||
"crane-mobile": "#8b5cf6", // 보라색
|
||||
rack: "#ef4444", // 빨간색
|
||||
};
|
||||
|
||||
// Tailwind 색상 클래스 매핑 (아이콘용)
|
||||
export const OBJECT_COLOR_CLASSES: Record<string, string> = {
|
||||
area: "text-blue-500",
|
||||
"location-bed": "text-blue-600",
|
||||
"location-stp": "text-gray-500",
|
||||
"location-temp": "text-orange-500",
|
||||
"location-dest": "text-emerald-500",
|
||||
"crane-mobile": "text-purple-500",
|
||||
rack: "text-red-500",
|
||||
};
|
||||
|
||||
// 기본 색상
|
||||
export const DEFAULT_COLOR = "#3b82f6";
|
||||
export const DEFAULT_COLOR_CLASS = "text-blue-500";
|
||||
|
||||
|
|
@ -163,3 +163,4 @@ export function getAllDescendants(
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// 야드 관리 3D - 타입 정의
|
||||
// 3D 필드 - 타입 정의
|
||||
|
||||
import { ChartDataSource } from "../../types";
|
||||
|
||||
|
|
|
|||
|
|
@ -305,84 +305,194 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
|
||||
try {
|
||||
// 🆕 그룹 데이터가 있는 경우: 모든 품목 일괄 수정
|
||||
if (groupData.length > 0) {
|
||||
console.log("🔄 그룹 데이터 일괄 수정 시작:", {
|
||||
// 🆕 그룹 데이터가 있는 경우: 모든 품목 일괄 처리 (추가/수정/삭제)
|
||||
if (groupData.length > 0 || originalGroupData.length > 0) {
|
||||
console.log("🔄 그룹 데이터 일괄 처리 시작:", {
|
||||
groupDataLength: groupData.length,
|
||||
originalGroupDataLength: originalGroupData.length,
|
||||
groupData,
|
||||
originalGroupData,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
screenId: modalState.screenId,
|
||||
});
|
||||
|
||||
let insertedCount = 0;
|
||||
let updatedCount = 0;
|
||||
let deletedCount = 0;
|
||||
|
||||
for (let i = 0; i < groupData.length; i++) {
|
||||
const currentData = groupData[i];
|
||||
const originalItemData = originalGroupData[i];
|
||||
// 1️⃣ 신규 품목 추가 (id가 없는 항목)
|
||||
for (const currentData of groupData) {
|
||||
if (!currentData.id) {
|
||||
console.log("➕ 신규 품목 추가:", currentData);
|
||||
console.log("📋 [신규 품목] currentData 키 목록:", Object.keys(currentData));
|
||||
|
||||
if (!originalItemData) {
|
||||
console.warn(`원본 데이터가 없습니다 (index: ${i})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 변경된 필드만 추출
|
||||
const changedData: Record<string, any> = {};
|
||||
|
||||
// 🆕 sales_order_mng 테이블의 실제 컬럼만 포함 (조인된 컬럼 제외)
|
||||
const salesOrderColumns = [
|
||||
"id",
|
||||
"order_no",
|
||||
"customer_code",
|
||||
"customer_name",
|
||||
"order_date",
|
||||
"delivery_date",
|
||||
"item_code",
|
||||
"quantity",
|
||||
"unit_price",
|
||||
"amount",
|
||||
"status",
|
||||
"notes",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"company_code",
|
||||
];
|
||||
|
||||
Object.keys(currentData).forEach((key) => {
|
||||
// sales_order_mng 테이블의 컬럼만 처리 (조인 컬럼 제외)
|
||||
if (!salesOrderColumns.includes(key)) {
|
||||
return;
|
||||
}
|
||||
// 🆕 모든 데이터를 포함 (id 제외)
|
||||
const insertData: Record<string, any> = { ...currentData };
|
||||
console.log("📦 [신규 품목] 복사 직후 insertData:", insertData);
|
||||
console.log("📋 [신규 품목] insertData 키 목록:", Object.keys(insertData));
|
||||
|
||||
if (currentData[key] !== originalItemData[key]) {
|
||||
changedData[key] = currentData[key];
|
||||
delete insertData.id; // id는 자동 생성되므로 제거
|
||||
|
||||
// 🆕 groupByColumns의 값을 강제로 포함 (order_no 등)
|
||||
if (modalState.groupByColumns && modalState.groupByColumns.length > 0) {
|
||||
modalState.groupByColumns.forEach((colName) => {
|
||||
// 기존 품목(originalGroupData[0])에서 groupByColumns 값 가져오기
|
||||
const referenceData = originalGroupData[0] || groupData.find((item) => item.id);
|
||||
if (referenceData && referenceData[colName]) {
|
||||
insertData[colName] = referenceData[colName];
|
||||
console.log(`🔑 [신규 품목] ${colName} 값 추가:`, referenceData[colName]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 변경사항이 없으면 스킵
|
||||
if (Object.keys(changedData).length === 0) {
|
||||
console.log(`변경사항 없음 (index: ${i})`);
|
||||
continue;
|
||||
}
|
||||
// 🆕 공통 필드 추가 (거래처, 담당자, 납품처, 메모 등)
|
||||
// formData에서 품목별 필드가 아닌 공통 필드를 복사
|
||||
const commonFields = [
|
||||
'partner_id', // 거래처
|
||||
'manager_id', // 담당자
|
||||
'delivery_partner_id', // 납품처
|
||||
'delivery_address', // 납품장소
|
||||
'memo', // 메모
|
||||
'order_date', // 주문일
|
||||
'due_date', // 납기일
|
||||
'shipping_method', // 배송방법
|
||||
'status', // 상태
|
||||
'sales_type', // 영업유형
|
||||
];
|
||||
|
||||
// 기본키 확인
|
||||
const recordId = originalItemData.id || Object.values(originalItemData)[0];
|
||||
commonFields.forEach((fieldName) => {
|
||||
// formData에 값이 있으면 추가
|
||||
if (formData[fieldName] !== undefined && formData[fieldName] !== null) {
|
||||
insertData[fieldName] = formData[fieldName];
|
||||
console.log(`🔗 [공통 필드] ${fieldName} 값 추가:`, formData[fieldName]);
|
||||
}
|
||||
});
|
||||
|
||||
// UPDATE 실행
|
||||
const response = await dynamicFormApi.updateFormDataPartial(
|
||||
recordId,
|
||||
originalItemData,
|
||||
changedData,
|
||||
screenData.screenInfo.tableName,
|
||||
);
|
||||
console.log("📦 [신규 품목] 최종 insertData:", insertData);
|
||||
console.log("📋 [신규 품목] 최종 insertData 키 목록:", Object.keys(insertData));
|
||||
|
||||
if (response.success) {
|
||||
updatedCount++;
|
||||
console.log(`✅ 품목 ${i + 1} 수정 성공 (id: ${recordId})`);
|
||||
} else {
|
||||
console.error(`❌ 품목 ${i + 1} 수정 실패 (id: ${recordId}):`, response.message);
|
||||
try {
|
||||
const response = await dynamicFormApi.saveFormData({
|
||||
screenId: modalState.screenId || 0,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
data: insertData,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
insertedCount++;
|
||||
console.log("✅ 신규 품목 추가 성공:", response.data);
|
||||
} else {
|
||||
console.error("❌ 신규 품목 추가 실패:", response.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("❌ 신규 품목 추가 오류:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedCount > 0) {
|
||||
toast.success(`${updatedCount}개의 품목이 수정되었습니다.`);
|
||||
// 2️⃣ 기존 품목 수정 (id가 있는 항목)
|
||||
for (const currentData of groupData) {
|
||||
if (currentData.id) {
|
||||
// id 기반 매칭 (인덱스 기반 X)
|
||||
const originalItemData = originalGroupData.find(
|
||||
(orig) => orig.id === currentData.id
|
||||
);
|
||||
|
||||
if (!originalItemData) {
|
||||
console.warn(`원본 데이터를 찾을 수 없습니다 (id: ${currentData.id})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 🆕 값 정규화 함수 (타입 통일)
|
||||
const normalizeValue = (val: any): any => {
|
||||
if (val === null || val === undefined || val === "") return null;
|
||||
if (typeof val === "string" && !isNaN(Number(val))) {
|
||||
// 숫자로 변환 가능한 문자열은 숫자로
|
||||
return Number(val);
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
// 변경된 필드만 추출 (id 제외)
|
||||
const changedData: Record<string, any> = {};
|
||||
Object.keys(currentData).forEach((key) => {
|
||||
// id는 변경 불가
|
||||
if (key === "id") {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 타입 정규화 후 비교
|
||||
const currentValue = normalizeValue(currentData[key]);
|
||||
const originalValue = normalizeValue(originalItemData[key]);
|
||||
|
||||
// 값이 변경된 경우만 포함
|
||||
if (currentValue !== originalValue) {
|
||||
console.log(`🔍 [품목 수정 감지] ${key}: ${originalValue} → ${currentValue}`);
|
||||
changedData[key] = currentData[key]; // 원본 값 사용 (문자열 그대로)
|
||||
}
|
||||
});
|
||||
|
||||
// 변경사항이 없으면 스킵
|
||||
if (Object.keys(changedData).length === 0) {
|
||||
console.log(`변경사항 없음 (id: ${currentData.id})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// UPDATE 실행
|
||||
try {
|
||||
const response = await dynamicFormApi.updateFormDataPartial(
|
||||
currentData.id,
|
||||
originalItemData,
|
||||
changedData,
|
||||
screenData.screenInfo.tableName,
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
updatedCount++;
|
||||
console.log(`✅ 품목 수정 성공 (id: ${currentData.id})`);
|
||||
} else {
|
||||
console.error(`❌ 품목 수정 실패 (id: ${currentData.id}):`, response.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`❌ 품목 수정 오류 (id: ${currentData.id}):`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3️⃣ 삭제된 품목 제거 (원본에는 있지만 현재 데이터에는 없는 항목)
|
||||
const currentIds = new Set(groupData.map((item) => item.id).filter(Boolean));
|
||||
const deletedItems = originalGroupData.filter(
|
||||
(orig) => orig.id && !currentIds.has(orig.id)
|
||||
);
|
||||
|
||||
for (const deletedItem of deletedItems) {
|
||||
console.log("🗑️ 품목 삭제:", deletedItem);
|
||||
|
||||
try {
|
||||
const response = await dynamicFormApi.deleteFormDataFromTable(
|
||||
deletedItem.id,
|
||||
screenData.screenInfo.tableName
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
deletedCount++;
|
||||
console.log(`✅ 품목 삭제 성공 (id: ${deletedItem.id})`);
|
||||
} else {
|
||||
console.error(`❌ 품목 삭제 실패 (id: ${deletedItem.id}):`, response.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`❌ 품목 삭제 오류 (id: ${deletedItem.id}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 결과 메시지
|
||||
const messages: string[] = [];
|
||||
if (insertedCount > 0) messages.push(`${insertedCount}개 추가`);
|
||||
if (updatedCount > 0) messages.push(`${updatedCount}개 수정`);
|
||||
if (deletedCount > 0) messages.push(`${deletedCount}개 삭제`);
|
||||
|
||||
if (messages.length > 0) {
|
||||
toast.success(`품목이 저장되었습니다 (${messages.join(", ")})`);
|
||||
|
||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||
if (modalState.onSave) {
|
||||
|
|
@ -585,8 +695,11 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
onSave={handleSave}
|
||||
isInModal={true}
|
||||
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
|
||||
groupedData={groupData.length > 0 ? groupData : undefined}
|
||||
// 🆕 수정 모달에서 읽기 전용 필드 지정 (수주번호, 거래처)
|
||||
disabledFields={["order_no", "partner_id"]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ interface InteractiveScreenViewerProps {
|
|||
companyCode?: string;
|
||||
// 🆕 그룹 데이터 (EditModal에서 전달)
|
||||
groupedData?: Record<string, any>[];
|
||||
// 🆕 비활성화할 필드 목록 (EditModal에서 전달)
|
||||
disabledFields?: string[];
|
||||
// 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
|
||||
isInModal?: boolean;
|
||||
}
|
||||
|
||||
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
|
||||
|
|
@ -66,6 +70,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
userName: externalUserName,
|
||||
companyCode: externalCompanyCode,
|
||||
groupedData,
|
||||
disabledFields = [],
|
||||
isInModal = false,
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const { userName: authUserName, user: authUser } = useAuth();
|
||||
|
|
@ -332,6 +338,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
userId={user?.userId} // ✅ 사용자 ID 전달
|
||||
userName={user?.userName} // ✅ 사용자 이름 전달
|
||||
companyCode={user?.companyCode} // ✅ 회사 코드 전달
|
||||
onSave={onSave} // 🆕 EditModal의 handleSave 콜백 전달
|
||||
allComponents={allComponents} // 🆕 같은 화면의 모든 컴포넌트 전달 (TableList 자동 감지용)
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(selectedRows, selectedData) => {
|
||||
|
|
@ -340,6 +347,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
}}
|
||||
// 🆕 그룹 데이터 전달 (EditModal → ModalRepeaterTable)
|
||||
groupedData={groupedData}
|
||||
// 🆕 비활성화 필드 전달 (EditModal → 각 컴포넌트)
|
||||
disabledFields={disabledFields}
|
||||
flowSelectedData={flowSelectedData}
|
||||
flowSelectedStepId={flowSelectedStepId}
|
||||
onFlowSelectedDataChange={(selectedData, stepId) => {
|
||||
|
|
@ -404,6 +413,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
required: required,
|
||||
placeholder: placeholder,
|
||||
className: "w-full h-full",
|
||||
isInModal: isInModal, // 🆕 EditModal 내부 여부 전달
|
||||
onSave: onSave, // 🆕 EditModal의 handleSave 콜백 전달
|
||||
}}
|
||||
config={widget.webTypeConfig}
|
||||
onEvent={(event: string, data: any) => {
|
||||
|
|
|
|||
|
|
@ -401,22 +401,10 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
|
||||
// 컴포넌트 스타일 계산
|
||||
const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
|
||||
const isSectionPaper = type === "component" && (component as any).componentConfig?.type === "section-paper";
|
||||
|
||||
// 높이 결정 로직
|
||||
let finalHeight = size?.height || 10;
|
||||
if (isFlowWidget && actualHeight) {
|
||||
finalHeight = actualHeight;
|
||||
}
|
||||
|
||||
// 🔍 디버깅: position.x 값 확인
|
||||
const positionX = position?.x || 0;
|
||||
console.log("🔍 RealtimePreview componentStyle 설정:", {
|
||||
componentId: id,
|
||||
positionX,
|
||||
sizeWidth: size?.width,
|
||||
styleWidth: style?.width,
|
||||
willUse100Percent: positionX === 0,
|
||||
});
|
||||
const positionY = position?.y || 0;
|
||||
|
||||
// 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀)
|
||||
const getWidth = () => {
|
||||
|
|
@ -432,20 +420,35 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
return size?.width || 200;
|
||||
};
|
||||
|
||||
// 높이 결정 로직: style.height > actualHeight (Flow Widget) > size.height
|
||||
const getHeight = () => {
|
||||
// 1순위: style.height가 있으면 우선 사용 (픽셀/퍼센트 값)
|
||||
if (style?.height) {
|
||||
return style.height;
|
||||
}
|
||||
// 2순위: Flow Widget의 실제 측정 높이
|
||||
if (isFlowWidget && actualHeight) {
|
||||
return actualHeight;
|
||||
}
|
||||
// 3순위: size.height 픽셀 값
|
||||
return size?.height || 10;
|
||||
};
|
||||
|
||||
const componentStyle = {
|
||||
position: "absolute" as const,
|
||||
...style, // 먼저 적용하고
|
||||
left: positionX,
|
||||
top: position?.y || 0,
|
||||
top: positionY,
|
||||
width: getWidth(), // 우선순위에 따른 너비
|
||||
height: finalHeight,
|
||||
height: getHeight(), // 우선순위에 따른 높이
|
||||
zIndex: position?.z || 1,
|
||||
// right 속성 강제 제거
|
||||
right: undefined,
|
||||
};
|
||||
|
||||
// 선택된 컴포넌트 스타일
|
||||
const selectionStyle = isSelected
|
||||
// Section Paper는 자체적으로 선택 상태 테두리를 처리하므로 outline 제거
|
||||
const selectionStyle = isSelected && !isSectionPaper
|
||||
? {
|
||||
outline: "2px solid rgb(59, 130, 246)",
|
||||
outlineOffset: "2px",
|
||||
|
|
@ -628,6 +631,24 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */}
|
||||
{type === "component" && (() => {
|
||||
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
|
||||
return (
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isSelected={isSelected}
|
||||
isDesignMode={isDesignMode}
|
||||
onClick={onClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...restProps}
|
||||
>
|
||||
{children}
|
||||
</DynamicComponentRenderer>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
||||
{type === "widget" && !isFileComponent(component) && (
|
||||
<div className="h-full w-full">
|
||||
|
|
|
|||
|
|
@ -4603,10 +4603,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
});
|
||||
}}
|
||||
>
|
||||
{/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
|
||||
{/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
|
||||
{(component.type === "group" ||
|
||||
component.type === "container" ||
|
||||
component.type === "area") &&
|
||||
component.type === "area" ||
|
||||
component.type === "component") &&
|
||||
layout.components
|
||||
.filter((child) => child.parentId === component.id)
|
||||
.map((child) => {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,21 @@ interface ApiResponse<T> {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
// 매핑 템플릿 타입
|
||||
export interface DigitalTwinMappingTemplate {
|
||||
id: string;
|
||||
company_code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
external_db_connection_id: number;
|
||||
layout_type: string;
|
||||
config: any;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_by: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ========== 레이아웃 관리 API ==========
|
||||
|
||||
// 레이아웃 목록 조회
|
||||
|
|
@ -281,3 +296,60 @@ export const getChildrenData = async (
|
|||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 매핑 템플릿 API ==========
|
||||
|
||||
// 템플릿 목록 조회 (회사 단위, 현재 사용자 기준)
|
||||
export const getMappingTemplates = async (params?: {
|
||||
externalDbConnectionId?: number;
|
||||
layoutType?: string;
|
||||
}): Promise<ApiResponse<DigitalTwinMappingTemplate[]>> => {
|
||||
try {
|
||||
const response = await apiClient.get("/digital-twin/mapping-templates", {
|
||||
params: {
|
||||
externalDbConnectionId: params?.externalDbConnectionId,
|
||||
layoutType: params?.layoutType,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 템플릿 생성
|
||||
export const createMappingTemplate = async (data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
externalDbConnectionId: number;
|
||||
layoutType?: string;
|
||||
config: any;
|
||||
}): Promise<ApiResponse<DigitalTwinMappingTemplate>> => {
|
||||
try {
|
||||
const response = await apiClient.post("/digital-twin/mapping-templates", data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 템플릿 단건 조회
|
||||
export const getMappingTemplateById = async (
|
||||
id: string,
|
||||
): Promise<ApiResponse<DigitalTwinMappingTemplate>> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/digital-twin/mapping-templates/${id}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -290,8 +290,13 @@ export class ExternalDbConnectionAPI {
|
|||
static async getTableColumns(connectionId: number, tableName: string): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
console.log("컬럼 정보 API 요청:", `${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`);
|
||||
// 컬럼 메타데이터 조회는 외부 DB 성능/네트워크 영향으로 오래 걸릴 수 있으므로
|
||||
// 기본 30초보다 넉넉한 타임아웃을 사용
|
||||
const response = await apiClient.get<ApiResponse<any[]>>(
|
||||
`${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`,
|
||||
{
|
||||
timeout: 120000, // 120초
|
||||
},
|
||||
);
|
||||
console.log("컬럼 정보 API 응답:", response.data);
|
||||
return response.data;
|
||||
|
|
|
|||
|
|
@ -105,10 +105,13 @@ export interface DynamicComponentRendererProps {
|
|||
companyCode?: string; // 🆕 현재 사용자의 회사 코드
|
||||
onRefresh?: () => void;
|
||||
onClose?: () => void;
|
||||
onSave?: () => Promise<void>; // 🆕 EditModal의 handleSave 콜백
|
||||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||
selectedRows?: any[];
|
||||
// 🆕 그룹 데이터 (EditModal → ModalRepeaterTable)
|
||||
groupedData?: Record<string, any>[];
|
||||
// 🆕 비활성화할 필드 목록 (EditModal → 각 컴포넌트)
|
||||
disabledFields?: string[];
|
||||
selectedRowsData?: any[];
|
||||
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void;
|
||||
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||
|
|
@ -167,6 +170,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
}
|
||||
};
|
||||
|
||||
// 🆕 disabledFields 체크
|
||||
const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).readonly;
|
||||
|
||||
return (
|
||||
<CategorySelectComponent
|
||||
tableName={tableName}
|
||||
|
|
@ -175,7 +181,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
onChange={handleChange}
|
||||
placeholder={component.componentConfig?.placeholder || "선택하세요"}
|
||||
required={(component as any).required}
|
||||
disabled={(component as any).readonly}
|
||||
disabled={isFieldDisabled}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
|
|
@ -244,6 +250,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
selectedScreen, // 🆕 화면 정보
|
||||
onRefresh,
|
||||
onClose,
|
||||
onSave, // 🆕 EditModal의 handleSave 콜백
|
||||
screenId,
|
||||
userId, // 🆕 사용자 ID
|
||||
userName, // 🆕 사용자 이름
|
||||
|
|
@ -269,6 +276,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
onConfigChange,
|
||||
isPreview,
|
||||
autoGeneration,
|
||||
disabledFields, // 🆕 비활성화 필드 목록
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
|
|
@ -358,6 +366,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
selectedScreen, // 🆕 화면 정보
|
||||
onRefresh,
|
||||
onClose,
|
||||
onSave, // 🆕 EditModal의 handleSave 콜백
|
||||
screenId,
|
||||
userId, // 🆕 사용자 ID
|
||||
userName, // 🆕 사용자 이름
|
||||
|
|
@ -365,7 +374,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
mode,
|
||||
isInModal,
|
||||
readonly: component.readonly,
|
||||
disabled: component.readonly,
|
||||
// 🆕 disabledFields 체크 또는 기존 readonly
|
||||
disabled: disabledFields?.includes(fieldName) || component.readonly,
|
||||
originalData,
|
||||
allComponents,
|
||||
onUpdateLayout,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
|||
onRefresh?: () => void;
|
||||
onClose?: () => void;
|
||||
onFlowRefresh?: () => void;
|
||||
onSave?: () => Promise<void>; // 🆕 EditModal의 handleSave 콜백
|
||||
|
||||
// 폼 데이터 관련
|
||||
originalData?: Record<string, any>; // 부분 업데이트용 원본 데이터
|
||||
|
|
@ -83,6 +84,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
onRefresh,
|
||||
onClose,
|
||||
onFlowRefresh,
|
||||
onSave, // 🆕 EditModal의 handleSave 콜백
|
||||
sortBy, // 🆕 정렬 컬럼
|
||||
sortOrder, // 🆕 정렬 방향
|
||||
columnOrder, // 🆕 컬럼 순서
|
||||
|
|
@ -95,6 +97,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
...props
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
|
||||
// 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출)
|
||||
const propsOnSave = (props as any).onSave as (() => Promise<void>) | undefined;
|
||||
const finalOnSave = onSave || propsOnSave;
|
||||
|
||||
// 🆕 플로우 단계별 표시 제어
|
||||
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig;
|
||||
|
|
@ -415,6 +421,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
onRefresh,
|
||||
onClose,
|
||||
onFlowRefresh, // 플로우 새로고침 콜백 추가
|
||||
onSave: finalOnSave, // 🆕 EditModal의 handleSave 콜백 (props에서도 추출)
|
||||
// 테이블 선택된 행 정보 추가
|
||||
selectedRows,
|
||||
selectedRowsData,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export function ConditionalContainerComponent({
|
|||
style,
|
||||
className,
|
||||
groupedData, // 🆕 그룹 데이터
|
||||
onSave, // 🆕 EditModal의 handleSave 콜백
|
||||
}: ConditionalContainerProps) {
|
||||
console.log("🎯 ConditionalContainerComponent 렌더링!", {
|
||||
isDesignMode,
|
||||
|
|
@ -179,6 +180,7 @@ export function ConditionalContainerComponent({
|
|||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
groupedData={groupedData}
|
||||
onSave={onSave}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -199,6 +201,7 @@ export function ConditionalContainerComponent({
|
|||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
groupedData={groupedData}
|
||||
onSave={onSave}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export function ConditionalSectionViewer({
|
|||
formData,
|
||||
onFormDataChange,
|
||||
groupedData, // 🆕 그룹 데이터
|
||||
onSave, // 🆕 EditModal의 handleSave 콜백
|
||||
}: ConditionalSectionViewerProps) {
|
||||
const { userId, userName, user } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -163,6 +164,7 @@ export function ConditionalSectionViewer({
|
|||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
groupedData={groupedData}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export interface ConditionalContainerProps {
|
|||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
groupedData?: Record<string, any>[]; // 🆕 그룹 데이터 (EditModal → ModalRepeaterTable)
|
||||
onSave?: () => Promise<void>; // 🆕 EditModal의 handleSave 콜백
|
||||
|
||||
// 화면 편집기 관련
|
||||
isDesignMode?: boolean; // 디자인 모드 여부
|
||||
|
|
@ -77,5 +78,6 @@ export interface ConditionalSectionViewerProps {
|
|||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
groupedData?: Record<string, any>[]; // 🆕 그룹 데이터
|
||||
onSave?: () => Promise<void>; // 🆕 EditModal의 handleSave 콜백
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -195,13 +195,18 @@ export function ModalRepeaterTableComponent({
|
|||
const columnName = component?.columnName;
|
||||
const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
|
||||
|
||||
// ✅ onChange 래퍼 (기존 onChange 콜백만 호출, formData는 beforeFormSave에서 처리)
|
||||
// ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출)
|
||||
const handleChange = (newData: any[]) => {
|
||||
// 기존 onChange 콜백 호출 (호환성)
|
||||
const externalOnChange = componentConfig?.onChange || propOnChange;
|
||||
if (externalOnChange) {
|
||||
externalOnChange(newData);
|
||||
}
|
||||
|
||||
// 🆕 onFormDataChange 호출하여 EditModal의 groupData 업데이트
|
||||
if (onFormDataChange && columnName) {
|
||||
onFormDataChange(columnName, newData);
|
||||
}
|
||||
};
|
||||
|
||||
// uniqueField 자동 보정: order_no는 item_info 테이블에 없으므로 item_number로 변경
|
||||
|
|
|
|||
|
|
@ -83,11 +83,22 @@ export function SectionPaperComponent({
|
|||
? { backgroundColor: config.customColor }
|
||||
: {};
|
||||
|
||||
// 선택 상태 테두리 처리 (outline 사용하여 크기 영향 없음)
|
||||
const selectionStyle = isDesignMode && isSelected
|
||||
? {
|
||||
outline: "2px solid #3b82f6",
|
||||
outlineOffset: "0px", // 크기에 영향 없이 딱 맞게 표시
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// 기본 스타일
|
||||
"relative transition-colors overflow-visible",
|
||||
"relative transition-colors",
|
||||
|
||||
// 높이 고정을 위한 overflow 처리
|
||||
"overflow-auto",
|
||||
|
||||
// 배경색
|
||||
backgroundColor !== "custom" && backgroundColorMap[backgroundColor],
|
||||
|
|
@ -101,37 +112,36 @@ export function SectionPaperComponent({
|
|||
// 그림자
|
||||
shadowMap[shadow],
|
||||
|
||||
// 테두리 (선택)
|
||||
showBorder &&
|
||||
// 테두리 (선택 상태가 아닐 때만)
|
||||
!isSelected && showBorder &&
|
||||
borderStyle === "subtle" &&
|
||||
"border border-border/30",
|
||||
|
||||
// 디자인 모드에서 선택된 상태
|
||||
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2",
|
||||
|
||||
// 디자인 모드에서 빈 상태 표시
|
||||
isDesignMode && !children && "min-h-[100px] border-2 border-dashed border-muted-foreground/30",
|
||||
// 디자인 모드에서 빈 상태 표시 (테두리만, 최소 높이 제거)
|
||||
isDesignMode && !children && "border-2 border-dashed border-muted-foreground/30",
|
||||
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
// 크기를 100%로 설정하여 부모 크기에 맞춤
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
boxSizing: "border-box", // padding과 border를 크기에 포함
|
||||
...customBgStyle,
|
||||
...component?.style,
|
||||
...selectionStyle,
|
||||
...component?.style, // 사용자 설정이 최종 우선순위
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* 디자인 모드에서 빈 상태 안내 */}
|
||||
{isDesignMode && !children && (
|
||||
{/* 자식 컴포넌트들 */}
|
||||
{children || (isDesignMode && (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
<div className="text-center">
|
||||
<div className="mb-1">📄 Section Paper</div>
|
||||
<div className="text-xs">컴포넌트를 이곳에 배치하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 자식 컴포넌트들 */}
|
||||
{children}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -322,6 +322,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
const hasInitializedSort = useRef(false);
|
||||
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
|
||||
const [tableLabel, setTableLabel] = useState<string>("");
|
||||
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
|
||||
|
|
@ -508,6 +509,28 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
unregisterTable,
|
||||
]);
|
||||
|
||||
// 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기
|
||||
useEffect(() => {
|
||||
if (!tableConfig.selectedTable || !userId || hasInitializedSort.current) return;
|
||||
|
||||
const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`;
|
||||
const savedSort = localStorage.getItem(storageKey);
|
||||
|
||||
if (savedSort) {
|
||||
try {
|
||||
const { column, direction } = JSON.parse(savedSort);
|
||||
if (column && direction) {
|
||||
setSortColumn(column);
|
||||
setSortDirection(direction);
|
||||
hasInitializedSort.current = true;
|
||||
console.log("📂 localStorage에서 정렬 상태 복원:", { column, direction });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 정렬 상태 복원 실패:", error);
|
||||
}
|
||||
}
|
||||
}, [tableConfig.selectedTable, userId]);
|
||||
|
||||
// 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기
|
||||
useEffect(() => {
|
||||
if (!tableConfig.selectedTable || !userId) return;
|
||||
|
|
@ -955,6 +978,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
newSortDirection = "asc";
|
||||
}
|
||||
|
||||
// 🎯 정렬 상태를 localStorage에 저장 (사용자별)
|
||||
if (tableConfig.selectedTable && userId) {
|
||||
const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`;
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({
|
||||
column: newSortColumn,
|
||||
direction: newSortDirection
|
||||
}));
|
||||
console.log("💾 정렬 상태 저장:", { column: newSortColumn, direction: newSortDirection });
|
||||
} catch (error) {
|
||||
console.error("❌ 정렬 상태 저장 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("📊 새로운 정렬 정보:", { newSortColumn, newSortDirection });
|
||||
console.log("🔍 onSelectedRowsChange 존재 여부:", !!onSelectedRowsChange);
|
||||
|
||||
|
|
@ -1876,11 +1913,59 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
};
|
||||
}, [tableConfig.selectedTable, isDesignMode]);
|
||||
|
||||
// 초기 컬럼 너비 측정 (한 번만)
|
||||
// 🎯 컬럼 너비 자동 계산 (내용 기반)
|
||||
const calculateOptimalColumnWidth = useCallback((columnName: string, displayName: string): number => {
|
||||
// 기본 너비 설정
|
||||
const MIN_WIDTH = 100;
|
||||
const MAX_WIDTH = 400;
|
||||
const PADDING = 48; // 좌우 패딩 + 여유 공간
|
||||
const HEADER_PADDING = 60; // 헤더 추가 여유 (정렬 아이콘 등)
|
||||
|
||||
// 헤더 텍스트 너비 계산 (대략 8px per character)
|
||||
const headerWidth = (displayName?.length || columnName.length) * 10 + HEADER_PADDING;
|
||||
|
||||
// 데이터 셀 너비 계산 (상위 50개 샘플링)
|
||||
const sampleSize = Math.min(50, data.length);
|
||||
let maxDataWidth = headerWidth;
|
||||
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const cellValue = data[i]?.[columnName];
|
||||
if (cellValue !== null && cellValue !== undefined) {
|
||||
const cellText = String(cellValue);
|
||||
// 숫자는 좁게, 텍스트는 넓게 계산
|
||||
const isNumber = !isNaN(Number(cellValue)) && cellValue !== "";
|
||||
const charWidth = isNumber ? 8 : 9;
|
||||
const cellWidth = cellText.length * charWidth + PADDING;
|
||||
maxDataWidth = Math.max(maxDataWidth, cellWidth);
|
||||
}
|
||||
}
|
||||
|
||||
// 최소/최대 범위 내로 제한
|
||||
return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.ceil(maxDataWidth)));
|
||||
}, [data]);
|
||||
|
||||
// 🎯 localStorage에서 컬럼 너비 불러오기 및 초기 계산
|
||||
useEffect(() => {
|
||||
if (!hasInitializedWidths.current && visibleColumns.length > 0) {
|
||||
// 약간의 지연을 두고 DOM이 완전히 렌더링된 후 측정
|
||||
if (!hasInitializedWidths.current && visibleColumns.length > 0 && data.length > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
const storageKey = tableConfig.selectedTable && userId
|
||||
? `table_column_widths_${tableConfig.selectedTable}_${userId}`
|
||||
: null;
|
||||
|
||||
// 1. localStorage에서 저장된 너비 불러오기
|
||||
let savedWidths: Record<string, number> = {};
|
||||
if (storageKey) {
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
savedWidths = JSON.parse(saved);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 너비 불러오기 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 자동 계산 또는 저장된 너비 적용
|
||||
const newWidths: Record<string, number> = {};
|
||||
let hasAnyWidth = false;
|
||||
|
||||
|
|
@ -1888,13 +1973,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// 체크박스 컬럼은 제외 (고정 48px)
|
||||
if (column.columnName === "__checkbox__") return;
|
||||
|
||||
const thElement = columnRefs.current[column.columnName];
|
||||
if (thElement) {
|
||||
const measuredWidth = thElement.offsetWidth;
|
||||
if (measuredWidth > 0) {
|
||||
newWidths[column.columnName] = measuredWidth;
|
||||
hasAnyWidth = true;
|
||||
}
|
||||
// 저장된 너비가 있으면 우선 사용
|
||||
if (savedWidths[column.columnName]) {
|
||||
newWidths[column.columnName] = savedWidths[column.columnName];
|
||||
hasAnyWidth = true;
|
||||
} else {
|
||||
// 저장된 너비가 없으면 자동 계산
|
||||
const optimalWidth = calculateOptimalColumnWidth(
|
||||
column.columnName,
|
||||
columnLabels[column.columnName] || column.displayName
|
||||
);
|
||||
newWidths[column.columnName] = optimalWidth;
|
||||
hasAnyWidth = true;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1902,11 +1992,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
setColumnWidths(newWidths);
|
||||
hasInitializedWidths.current = true;
|
||||
}
|
||||
}, 100);
|
||||
}, 150); // DOM 렌더링 대기
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [visibleColumns]);
|
||||
}, [visibleColumns, data, tableConfig.selectedTable, userId, calculateOptimalColumnWidth, columnLabels]);
|
||||
|
||||
// ========================================
|
||||
// 페이지네이션 JSX
|
||||
|
|
@ -2241,7 +2331,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// 최종 너비를 state에 저장
|
||||
if (thElement) {
|
||||
const finalWidth = Math.max(80, thElement.offsetWidth);
|
||||
setColumnWidths((prev) => ({ ...prev, [column.columnName]: finalWidth }));
|
||||
setColumnWidths((prev) => {
|
||||
const newWidths = { ...prev, [column.columnName]: finalWidth };
|
||||
|
||||
// 🎯 localStorage에 컬럼 너비 저장 (사용자별)
|
||||
if (tableConfig.selectedTable && userId) {
|
||||
const storageKey = `table_column_widths_${tableConfig.selectedTable}_${userId}`;
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(newWidths));
|
||||
} catch (error) {
|
||||
console.error("컬럼 너비 저장 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return newWidths;
|
||||
});
|
||||
}
|
||||
|
||||
// 텍스트 선택 복원
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ export interface ButtonActionContext {
|
|||
onClose?: () => void;
|
||||
onRefresh?: () => void;
|
||||
onFlowRefresh?: () => void; // 플로우 새로고침 콜백
|
||||
onSave?: () => Promise<void>; // 🆕 EditModal의 handleSave 콜백
|
||||
|
||||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||
selectedRows?: any[];
|
||||
|
|
@ -213,9 +214,23 @@ export class ButtonActionExecutor {
|
|||
* 저장 액션 처리 (INSERT/UPDATE 자동 판단 - DB 기반)
|
||||
*/
|
||||
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||
const { formData, originalData, tableName, screenId } = context;
|
||||
const { formData, originalData, tableName, screenId, onSave } = context;
|
||||
|
||||
console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId });
|
||||
console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId, hasOnSave: !!onSave });
|
||||
|
||||
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
||||
if (onSave) {
|
||||
console.log("✅ [handleSave] onSave 콜백 발견 - 콜백 실행");
|
||||
try {
|
||||
await onSave();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ [handleSave] onSave 콜백 실행 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행");
|
||||
|
||||
// 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집)
|
||||
// context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함
|
||||
|
|
|
|||
Loading…
Reference in New Issue