Merge pull request '대시보드 수정사항 1차 적용' (#214) from common/feat/dashboard-map into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/214
This commit is contained in:
commit
6c9ce7a4d9
|
|
@ -0,0 +1,209 @@
|
||||||
|
/**
|
||||||
|
* 디지털 트윈 외부 DB (DO_DY) 연결 및 쿼리 테스트 스크립트
|
||||||
|
* READ-ONLY: SELECT 쿼리만 실행
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
import { CredentialEncryption } from "../src/utils/credentialEncryption";
|
||||||
|
|
||||||
|
async function testDigitalTwinDb() {
|
||||||
|
// 내부 DB 연결 (연결 정보 저장용)
|
||||||
|
const internalPool = new Pool({
|
||||||
|
host: process.env.DB_HOST || "localhost",
|
||||||
|
port: parseInt(process.env.DB_PORT || "5432"),
|
||||||
|
database: process.env.DB_NAME || "plm",
|
||||||
|
user: process.env.DB_USER || "postgres",
|
||||||
|
password: process.env.DB_PASSWORD || "ph0909!!",
|
||||||
|
});
|
||||||
|
|
||||||
|
const encryptionKey =
|
||||||
|
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
|
||||||
|
const encryption = new CredentialEncryption(encryptionKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("🚀 디지털 트윈 외부 DB 연결 테스트 시작\n");
|
||||||
|
|
||||||
|
// 디지털 트윈 외부 DB 연결 정보
|
||||||
|
const digitalTwinConnection = {
|
||||||
|
name: "디지털트윈_DO_DY",
|
||||||
|
description: "디지털 트윈 후판(자재) 재고 정보 데이터베이스 (MariaDB)",
|
||||||
|
dbType: "mysql", // MariaDB는 MySQL 프로토콜 사용
|
||||||
|
host: "1.240.13.83",
|
||||||
|
port: 4307,
|
||||||
|
databaseName: "DO_DY",
|
||||||
|
username: "root",
|
||||||
|
password: "pohangms619!#",
|
||||||
|
sslEnabled: false,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("📝 연결 정보:");
|
||||||
|
console.log(` - 이름: ${digitalTwinConnection.name}`);
|
||||||
|
console.log(` - DB 타입: ${digitalTwinConnection.dbType}`);
|
||||||
|
console.log(` - 호스트: ${digitalTwinConnection.host}:${digitalTwinConnection.port}`);
|
||||||
|
console.log(` - 데이터베이스: ${digitalTwinConnection.databaseName}\n`);
|
||||||
|
|
||||||
|
// 1. 외부 DB 직접 연결 테스트
|
||||||
|
console.log("🔍 외부 DB 직접 연결 테스트 중...");
|
||||||
|
|
||||||
|
const externalConnection = await mysql.createConnection({
|
||||||
|
host: digitalTwinConnection.host,
|
||||||
|
port: digitalTwinConnection.port,
|
||||||
|
database: digitalTwinConnection.databaseName,
|
||||||
|
user: digitalTwinConnection.username,
|
||||||
|
password: digitalTwinConnection.password,
|
||||||
|
connectTimeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ 외부 DB 연결 성공!\n");
|
||||||
|
|
||||||
|
// 2. SELECT 쿼리 실행
|
||||||
|
console.log("📊 WSTKKY 테이블 쿼리 실행 중...\n");
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
SKUMKEY -- 제품번호
|
||||||
|
, SKUDESC -- 자재명
|
||||||
|
, SKUTHIC -- 두께
|
||||||
|
, SKUWIDT -- 폭
|
||||||
|
, SKULENG -- 길이
|
||||||
|
, SKUWEIG -- 중량
|
||||||
|
, STOTQTY -- 수량
|
||||||
|
, SUOMKEY -- 단위
|
||||||
|
FROM DO_DY.WSTKKY
|
||||||
|
LIMIT 10
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows] = await externalConnection.execute(query);
|
||||||
|
|
||||||
|
console.log("✅ 쿼리 실행 성공!\n");
|
||||||
|
console.log(`📦 조회된 데이터: ${Array.isArray(rows) ? rows.length : 0}건\n`);
|
||||||
|
|
||||||
|
if (Array.isArray(rows) && rows.length > 0) {
|
||||||
|
console.log("🔍 샘플 데이터 (첫 3건):\n");
|
||||||
|
rows.slice(0, 3).forEach((row: any, index: number) => {
|
||||||
|
console.log(`[${index + 1}]`);
|
||||||
|
console.log(` 제품번호(SKUMKEY): ${row.SKUMKEY}`);
|
||||||
|
console.log(` 자재명(SKUDESC): ${row.SKUDESC}`);
|
||||||
|
console.log(` 두께(SKUTHIC): ${row.SKUTHIC}`);
|
||||||
|
console.log(` 폭(SKUWIDT): ${row.SKUWIDT}`);
|
||||||
|
console.log(` 길이(SKULENG): ${row.SKULENG}`);
|
||||||
|
console.log(` 중량(SKUWEIG): ${row.SKUWEIG}`);
|
||||||
|
console.log(` 수량(STOTQTY): ${row.STOTQTY}`);
|
||||||
|
console.log(` 단위(SUOMKEY): ${row.SUOMKEY}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전체 데이터 JSON 출력
|
||||||
|
console.log("📄 전체 데이터 (JSON):");
|
||||||
|
console.log(JSON.stringify(rows, null, 2));
|
||||||
|
console.log("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
await externalConnection.end();
|
||||||
|
|
||||||
|
// 3. 내부 DB에 연결 정보 저장
|
||||||
|
console.log("💾 내부 DB에 연결 정보 저장 중...");
|
||||||
|
|
||||||
|
const encryptedPassword = encryption.encrypt(digitalTwinConnection.password);
|
||||||
|
|
||||||
|
// 중복 체크
|
||||||
|
const existingResult = await internalPool.query(
|
||||||
|
"SELECT id FROM flow_external_db_connection WHERE name = $1",
|
||||||
|
[digitalTwinConnection.name]
|
||||||
|
);
|
||||||
|
|
||||||
|
let connectionId: number;
|
||||||
|
|
||||||
|
if (existingResult.rows.length > 0) {
|
||||||
|
connectionId = existingResult.rows[0].id;
|
||||||
|
console.log(`⚠️ 이미 존재하는 연결 (ID: ${connectionId})`);
|
||||||
|
|
||||||
|
// 기존 연결 업데이트
|
||||||
|
await internalPool.query(
|
||||||
|
`UPDATE flow_external_db_connection
|
||||||
|
SET description = $1,
|
||||||
|
db_type = $2,
|
||||||
|
host = $3,
|
||||||
|
port = $4,
|
||||||
|
database_name = $5,
|
||||||
|
username = $6,
|
||||||
|
password_encrypted = $7,
|
||||||
|
ssl_enabled = $8,
|
||||||
|
is_active = $9,
|
||||||
|
updated_at = NOW(),
|
||||||
|
updated_by = 'system'
|
||||||
|
WHERE name = $10`,
|
||||||
|
[
|
||||||
|
digitalTwinConnection.description,
|
||||||
|
digitalTwinConnection.dbType,
|
||||||
|
digitalTwinConnection.host,
|
||||||
|
digitalTwinConnection.port,
|
||||||
|
digitalTwinConnection.databaseName,
|
||||||
|
digitalTwinConnection.username,
|
||||||
|
encryptedPassword,
|
||||||
|
digitalTwinConnection.sslEnabled,
|
||||||
|
digitalTwinConnection.isActive,
|
||||||
|
digitalTwinConnection.name,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
console.log(`✅ 연결 정보 업데이트 완료`);
|
||||||
|
} else {
|
||||||
|
// 새 연결 추가
|
||||||
|
const result = await internalPool.query(
|
||||||
|
`INSERT INTO flow_external_db_connection (
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
db_type,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
database_name,
|
||||||
|
username,
|
||||||
|
password_encrypted,
|
||||||
|
ssl_enabled,
|
||||||
|
is_active,
|
||||||
|
created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system')
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
digitalTwinConnection.name,
|
||||||
|
digitalTwinConnection.description,
|
||||||
|
digitalTwinConnection.dbType,
|
||||||
|
digitalTwinConnection.host,
|
||||||
|
digitalTwinConnection.port,
|
||||||
|
digitalTwinConnection.databaseName,
|
||||||
|
digitalTwinConnection.username,
|
||||||
|
encryptedPassword,
|
||||||
|
digitalTwinConnection.sslEnabled,
|
||||||
|
digitalTwinConnection.isActive,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
connectionId = result.rows[0].id;
|
||||||
|
console.log(`✅ 새 연결 추가 완료 (ID: ${connectionId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n✅ 모든 테스트 완료!");
|
||||||
|
console.log(`\n📌 연결 ID: ${connectionId}`);
|
||||||
|
console.log(" 이 ID를 사용하여 플로우 관리나 제어 관리에서 외부 DB를 연동할 수 있습니다.");
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("\n❌ 오류 발생:", error.message);
|
||||||
|
console.error("상세 정보:", error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await internalPool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스크립트 실행
|
||||||
|
testDigitalTwinDb()
|
||||||
|
.then(() => {
|
||||||
|
console.log("\n🎉 스크립트 완료");
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("\n💥 스크립트 실패:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -59,6 +59,7 @@ import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
||||||
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
||||||
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
|
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
|
||||||
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
||||||
|
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
|
||||||
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
|
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
|
||||||
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
|
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
|
||||||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||||
|
|
@ -223,6 +224,7 @@ app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
|
||||||
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
|
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/materials", materialRoutes); // 자재 관리 (임시 주석)
|
||||||
|
app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제)
|
||||||
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
|
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
|
||||||
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
|
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
|
||||||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { pool, queryOne } from "../database/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||||
|
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
||||||
|
|
||||||
|
// 외부 DB 커넥터를 가져오는 헬퍼 함수
|
||||||
|
export async function getExternalDbConnector(connectionId: number) {
|
||||||
|
// 외부 DB 연결 정보 조회
|
||||||
|
const connection = await queryOne<any>(
|
||||||
|
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||||
|
[connectionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
throw new Error(`외부 DB 연결 정보를 찾을 수 없습니다. ID: ${connectionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 패스워드 복호화
|
||||||
|
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||||
|
|
||||||
|
// DB 연결 설정
|
||||||
|
const config = {
|
||||||
|
host: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
user: connection.username,
|
||||||
|
password: decryptedPassword,
|
||||||
|
database: connection.database_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// DB 커넥터 생성
|
||||||
|
return await DatabaseConnectorFactory.createConnector(
|
||||||
|
connection.db_type || "mariadb",
|
||||||
|
config,
|
||||||
|
connectionId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 창고 목록 조회 (사용자 지정 테이블)
|
||||||
|
export const getWarehouses = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const { externalDbConnectionId, tableName } = req.query;
|
||||||
|
|
||||||
|
if (!externalDbConnectionId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "외부 DB 연결 ID가 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "테이블명이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||||
|
|
||||||
|
// 테이블명을 사용하여 모든 컬럼 조회
|
||||||
|
const query = `SELECT * FROM ${tableName} LIMIT 100`;
|
||||||
|
|
||||||
|
const result = await connector.executeQuery(query);
|
||||||
|
|
||||||
|
logger.info("창고 목록 조회", {
|
||||||
|
externalDbConnectionId,
|
||||||
|
tableName,
|
||||||
|
count: result.rows.length,
|
||||||
|
data: result.rows, // 실제 데이터 확인
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("창고 목록 조회 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "창고 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Area 목록 조회 (사용자 지정 테이블)
|
||||||
|
export const getAreas = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const { externalDbConnectionId, tableName, warehouseKey } = req.query;
|
||||||
|
|
||||||
|
if (!externalDbConnectionId || !tableName) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "외부 DB 연결 ID와 테이블명이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||||
|
|
||||||
|
// 테이블명을 사용하여 모든 컬럼 조회
|
||||||
|
let query = `SELECT * FROM ${tableName}`;
|
||||||
|
|
||||||
|
if (warehouseKey) {
|
||||||
|
query += ` WHERE WAREKEY = '${warehouseKey}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` LIMIT 1000`;
|
||||||
|
|
||||||
|
const result = await connector.executeQuery(query);
|
||||||
|
|
||||||
|
logger.info("Area 목록 조회", {
|
||||||
|
externalDbConnectionId,
|
||||||
|
tableName,
|
||||||
|
warehouseKey,
|
||||||
|
count: result.rows.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("Area 목록 조회 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Area 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Location 목록 조회 (사용자 지정 테이블)
|
||||||
|
export const getLocations = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const { externalDbConnectionId, tableName, areaKey } = req.query;
|
||||||
|
|
||||||
|
if (!externalDbConnectionId || !tableName) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "외부 DB 연결 ID와 테이블명이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||||
|
|
||||||
|
// 테이블명을 사용하여 모든 컬럼 조회
|
||||||
|
let query = `SELECT * FROM ${tableName}`;
|
||||||
|
|
||||||
|
if (areaKey) {
|
||||||
|
query += ` WHERE AREAKEY = '${areaKey}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` LIMIT 1000`;
|
||||||
|
|
||||||
|
const result = await connector.executeQuery(query);
|
||||||
|
|
||||||
|
logger.info("Location 목록 조회", {
|
||||||
|
externalDbConnectionId,
|
||||||
|
tableName,
|
||||||
|
areaKey,
|
||||||
|
count: result.rows.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("Location 목록 조회 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "Location 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 자재 목록 조회 (사용자 지정 테이블)
|
||||||
|
export const getMaterials = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const { externalDbConnectionId, tableName, locaKey } = req.query;
|
||||||
|
|
||||||
|
if (!externalDbConnectionId || !tableName) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "외부 DB 연결 ID와 테이블명이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||||
|
|
||||||
|
// 테이블명을 사용하여 모든 컬럼 조회
|
||||||
|
let query = `SELECT * FROM ${tableName}`;
|
||||||
|
|
||||||
|
if (locaKey) {
|
||||||
|
query += ` WHERE LOCAKEY = '${locaKey}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` LIMIT 1000`;
|
||||||
|
|
||||||
|
const result = await connector.executeQuery(query);
|
||||||
|
|
||||||
|
logger.info("자재 목록 조회", {
|
||||||
|
externalDbConnectionId,
|
||||||
|
tableName,
|
||||||
|
locaKey,
|
||||||
|
count: result.rows.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자재 목록 조회 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자재 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Location별 자재 개수 조회 (배치 시 사용 - 사용자 지정 테이블)
|
||||||
|
export const getMaterialCounts = async (req: Request, res: Response): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const { externalDbConnectionId, tableName, locaKeys } = req.query;
|
||||||
|
|
||||||
|
if (!externalDbConnectionId || !tableName || !locaKeys) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "외부 DB 연결 ID, 테이블명, Location 키 목록이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||||
|
|
||||||
|
// locaKeys는 쉼표로 구분된 문자열
|
||||||
|
const locaKeyArray = (locaKeys as string).split(",");
|
||||||
|
const quotedKeys = locaKeyArray.map((key) => `'${key}'`).join(",");
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
LOCAKEY,
|
||||||
|
COUNT(*) as material_count,
|
||||||
|
MAX(LOLAYER) as max_layer
|
||||||
|
FROM ${tableName}
|
||||||
|
WHERE LOCAKEY IN (${quotedKeys})
|
||||||
|
GROUP BY LOCAKEY
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await connector.executeQuery(query);
|
||||||
|
|
||||||
|
logger.info("자재 개수 조회", {
|
||||||
|
externalDbConnectionId,
|
||||||
|
tableName,
|
||||||
|
locaKeyCount: locaKeyArray.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("자재 개수 조회 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "자재 개수 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,386 @@
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { pool } from "../database/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
// 레이아웃 목록 조회
|
||||||
|
export const getLayouts = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const { externalDbConnectionId, warehouseKey } = req.query;
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
l.*,
|
||||||
|
u1.user_name as created_by_name,
|
||||||
|
u2.user_name as updated_by_name,
|
||||||
|
COUNT(o.id) as object_count
|
||||||
|
FROM digital_twin_layout l
|
||||||
|
LEFT JOIN user_info u1 ON l.created_by = u1.user_id
|
||||||
|
LEFT JOIN user_info u2 ON l.updated_by = u2.user_id
|
||||||
|
LEFT JOIN digital_twin_objects o ON l.id = o.layout_id
|
||||||
|
WHERE l.company_code = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [companyCode];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (externalDbConnectionId) {
|
||||||
|
query += ` AND l.external_db_connection_id = $${paramIndex}`;
|
||||||
|
params.push(externalDbConnectionId);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (warehouseKey) {
|
||||||
|
query += ` AND l.warehouse_key = $${paramIndex}`;
|
||||||
|
params.push(warehouseKey);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
GROUP BY l.id, u1.user_name, u2.user_name
|
||||||
|
ORDER BY l.updated_at DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
logger.info("레이아웃 목록 조회", {
|
||||||
|
companyCode,
|
||||||
|
count: result.rowCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("레이아웃 목록 조회 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃 상세 조회 (객체 포함)
|
||||||
|
export const getLayoutById = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// 레이아웃 기본 정보
|
||||||
|
const layoutQuery = `
|
||||||
|
SELECT l.*
|
||||||
|
FROM digital_twin_layout l
|
||||||
|
WHERE l.id = $1 AND l.company_code = $2
|
||||||
|
`;
|
||||||
|
|
||||||
|
const layoutResult = await pool.query(layoutQuery, [id, companyCode]);
|
||||||
|
|
||||||
|
if (layoutResult.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배치된 객체들 조회
|
||||||
|
const objectsQuery = `
|
||||||
|
SELECT *
|
||||||
|
FROM digital_twin_objects
|
||||||
|
WHERE layout_id = $1
|
||||||
|
ORDER BY display_order, created_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const objectsResult = await pool.query(objectsQuery, [id]);
|
||||||
|
|
||||||
|
logger.info("레이아웃 상세 조회", {
|
||||||
|
companyCode,
|
||||||
|
layoutId: id,
|
||||||
|
objectCount: objectsResult.rowCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
layout: layoutResult.rows[0],
|
||||||
|
objects: objectsResult.rows,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("레이아웃 상세 조회 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃 생성
|
||||||
|
export const createLayout = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
const {
|
||||||
|
externalDbConnectionId,
|
||||||
|
warehouseKey,
|
||||||
|
layoutName,
|
||||||
|
description,
|
||||||
|
objects,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 레이아웃 생성
|
||||||
|
const layoutQuery = `
|
||||||
|
INSERT INTO digital_twin_layout (
|
||||||
|
company_code, external_db_connection_id, warehouse_key,
|
||||||
|
layout_name, description, created_by, updated_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $6)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const layoutResult = await client.query(layoutQuery, [
|
||||||
|
companyCode,
|
||||||
|
externalDbConnectionId,
|
||||||
|
warehouseKey,
|
||||||
|
layoutName,
|
||||||
|
description,
|
||||||
|
userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const layoutId = layoutResult.rows[0].id;
|
||||||
|
|
||||||
|
// 객체들 저장
|
||||||
|
if (objects && objects.length > 0) {
|
||||||
|
const objectQuery = `
|
||||||
|
INSERT INTO digital_twin_objects (
|
||||||
|
layout_id, object_type, object_name,
|
||||||
|
position_x, position_y, position_z,
|
||||||
|
size_x, size_y, size_z,
|
||||||
|
rotation, color,
|
||||||
|
area_key, loca_key, loc_type,
|
||||||
|
material_count, material_preview_height,
|
||||||
|
parent_id, display_order, locked
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||||
|
`;
|
||||||
|
|
||||||
|
for (const obj of objects) {
|
||||||
|
await client.query(objectQuery, [
|
||||||
|
layoutId,
|
||||||
|
obj.type,
|
||||||
|
obj.name,
|
||||||
|
obj.position.x,
|
||||||
|
obj.position.y,
|
||||||
|
obj.position.z,
|
||||||
|
obj.size.x,
|
||||||
|
obj.size.y,
|
||||||
|
obj.size.z,
|
||||||
|
obj.rotation || 0,
|
||||||
|
obj.color,
|
||||||
|
obj.areaKey || null,
|
||||||
|
obj.locaKey || null,
|
||||||
|
obj.locType || null,
|
||||||
|
obj.materialCount || 0,
|
||||||
|
obj.materialPreview?.height || null,
|
||||||
|
obj.parentId || null,
|
||||||
|
obj.displayOrder || 0,
|
||||||
|
obj.locked || false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
|
||||||
|
logger.info("레이아웃 생성", {
|
||||||
|
companyCode,
|
||||||
|
layoutId,
|
||||||
|
objectCount: objects?.length || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: layoutResult.rows[0],
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
logger.error("레이아웃 생성 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃 생성 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃 수정
|
||||||
|
export const updateLayout = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
const { id } = req.params;
|
||||||
|
const { layoutName, description, objects } = req.body;
|
||||||
|
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 레이아웃 기본 정보 수정
|
||||||
|
const updateLayoutQuery = `
|
||||||
|
UPDATE digital_twin_layout
|
||||||
|
SET layout_name = $1,
|
||||||
|
description = $2,
|
||||||
|
updated_by = $3,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $4 AND company_code = $5
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const layoutResult = await client.query(updateLayoutQuery, [
|
||||||
|
layoutName,
|
||||||
|
description,
|
||||||
|
userId,
|
||||||
|
id,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (layoutResult.rowCount === 0) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 객체 삭제
|
||||||
|
await client.query(
|
||||||
|
"DELETE FROM digital_twin_objects WHERE layout_id = $1",
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 새 객체 저장
|
||||||
|
if (objects && objects.length > 0) {
|
||||||
|
const objectQuery = `
|
||||||
|
INSERT INTO digital_twin_objects (
|
||||||
|
layout_id, object_type, object_name,
|
||||||
|
position_x, position_y, position_z,
|
||||||
|
size_x, size_y, size_z,
|
||||||
|
rotation, color,
|
||||||
|
area_key, loca_key, loc_type,
|
||||||
|
material_count, material_preview_height,
|
||||||
|
parent_id, display_order, locked
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||||
|
`;
|
||||||
|
|
||||||
|
for (const obj of objects) {
|
||||||
|
await client.query(objectQuery, [
|
||||||
|
id,
|
||||||
|
obj.type,
|
||||||
|
obj.name,
|
||||||
|
obj.position.x,
|
||||||
|
obj.position.y,
|
||||||
|
obj.position.z,
|
||||||
|
obj.size.x,
|
||||||
|
obj.size.y,
|
||||||
|
obj.size.z,
|
||||||
|
obj.rotation || 0,
|
||||||
|
obj.color,
|
||||||
|
obj.areaKey || null,
|
||||||
|
obj.locaKey || null,
|
||||||
|
obj.locType || null,
|
||||||
|
obj.materialCount || 0,
|
||||||
|
obj.materialPreview?.height || null,
|
||||||
|
obj.parentId || null,
|
||||||
|
obj.displayOrder || 0,
|
||||||
|
obj.locked || false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
|
||||||
|
logger.info("레이아웃 수정", {
|
||||||
|
companyCode,
|
||||||
|
layoutId: id,
|
||||||
|
objectCount: objects?.length || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: layoutResult.rows[0],
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
logger.error("레이아웃 수정 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃 수정 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃 삭제
|
||||||
|
export const deleteLayout = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
DELETE FROM digital_twin_layout
|
||||||
|
WHERE id = $1 AND company_code = $2
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [id, companyCode]);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("레이아웃 삭제", {
|
||||||
|
companyCode,
|
||||||
|
layoutId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "레이아웃이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("레이아웃 삭제 실패", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃 삭제 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import express from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
// 레이아웃 관리
|
||||||
|
import {
|
||||||
|
getLayouts,
|
||||||
|
getLayoutById,
|
||||||
|
createLayout,
|
||||||
|
updateLayout,
|
||||||
|
deleteLayout,
|
||||||
|
} from "../controllers/digitalTwinLayoutController";
|
||||||
|
|
||||||
|
// 외부 DB 데이터 조회
|
||||||
|
import {
|
||||||
|
getWarehouses,
|
||||||
|
getAreas,
|
||||||
|
getLocations,
|
||||||
|
getMaterials,
|
||||||
|
getMaterialCounts,
|
||||||
|
} from "../controllers/digitalTwinDataController";
|
||||||
|
|
||||||
|
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); // 레이아웃 삭제
|
||||||
|
|
||||||
|
// ========== 외부 DB 데이터 조회 API ==========
|
||||||
|
router.get("/data/tables/:connectionId", async (req, res) => {
|
||||||
|
// 테이블 목록 조회
|
||||||
|
try {
|
||||||
|
const { ExternalDbConnectionService } = await import("../services/externalDbConnectionService");
|
||||||
|
const result = await ExternalDbConnectionService.getTablesFromConnection(Number(req.params.connectionId));
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
return res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/data/table-preview/:connectionId/:tableName", async (req, res) => {
|
||||||
|
// 테이블 미리보기 (10개 레코드)
|
||||||
|
try {
|
||||||
|
const { connectionId, tableName } = req.params;
|
||||||
|
const { getExternalDbConnector } = await import("../controllers/digitalTwinDataController");
|
||||||
|
const connector = await getExternalDbConnector(Number(connectionId));
|
||||||
|
const result = await connector.executeQuery(`SELECT * FROM ${tableName} LIMIT 10`);
|
||||||
|
return res.json({ success: true, data: result.rows });
|
||||||
|
} catch (error: any) {
|
||||||
|
return res.status(500).json({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/data/warehouses", getWarehouses); // 창고 목록
|
||||||
|
router.get("/data/areas", getAreas); // Area 목록
|
||||||
|
router.get("/data/locations", getLocations); // Location 목록
|
||||||
|
router.get("/data/materials", getMaterials); // 자재 목록 (특정 Location)
|
||||||
|
router.get("/data/material-counts", getMaterialCounts); // 자재 개수 (여러 Location)
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -300,7 +300,14 @@ export default function DashboardListClient({ initialDashboards, initialPaginati
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{dashboards.map((dashboard) => (
|
{dashboards.map((dashboard) => (
|
||||||
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
||||||
<TableCell className="h-16 text-sm font-medium">{dashboard.title}</TableCell>
|
<TableCell className="h-16 text-sm font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
||||||
|
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
||||||
|
>
|
||||||
|
{dashboard.title}
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
||||||
{dashboard.description || "-"}
|
{dashboard.description || "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -355,7 +362,12 @@ export default function DashboardListClient({ initialDashboards, initialPaginati
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="mb-4 flex items-start justify-between">
|
<div className="mb-4 flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-base font-semibold">{dashboard.title}</h3>
|
<button
|
||||||
|
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
||||||
|
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
||||||
|
>
|
||||||
|
<h3 className="text-base font-semibold">{dashboard.title}</h3>
|
||||||
|
</button>
|
||||||
<p className="text-muted-foreground mt-1 text-sm">{dashboard.id}</p>
|
<p className="text-muted-foreground mt-1 text-sm">{dashboard.id}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -812,69 +812,70 @@ export function CanvasElement({
|
||||||
}}
|
}}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{/* 헤더 - showHeader가 false이면 숨김 */}
|
||||||
<div className="flex cursor-move items-center justify-between px-2 py-1">
|
{element.showHeader !== false && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex cursor-move items-center justify-between px-2 py-1">
|
||||||
{/* 차트 타입 전환 드롭다운 (차트일 경우만) */}
|
<div className="flex items-center gap-2">
|
||||||
{element.type === "chart" && (
|
{/* 차트 타입 전환 드롭다운 (차트일 경우만) */}
|
||||||
<Select
|
{element.type === "chart" && (
|
||||||
value={element.subtype}
|
<Select
|
||||||
onValueChange={(newSubtype: string) => {
|
value={element.subtype}
|
||||||
onUpdate(element.id, { subtype: newSubtype as ElementSubtype });
|
onValueChange={(newSubtype: string) => {
|
||||||
}}
|
onUpdate(element.id, { subtype: newSubtype as ElementSubtype });
|
||||||
>
|
}}
|
||||||
<SelectTrigger
|
|
||||||
className="h-6 w-[120px] text-[11px]"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<SelectValue />
|
<SelectTrigger
|
||||||
</SelectTrigger>
|
className="h-6 w-[120px] text-[11px]"
|
||||||
<SelectContent className="z-[99999]" onClick={(e) => e.stopPropagation()}>
|
onClick={(e) => e.stopPropagation()}
|
||||||
{getChartCategory(element.subtype) === "axis-based" ? (
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
<SelectGroup>
|
>
|
||||||
<SelectLabel>축 기반 차트</SelectLabel>
|
<SelectValue />
|
||||||
<SelectItem value="bar">바 차트</SelectItem>
|
</SelectTrigger>
|
||||||
<SelectItem value="horizontal-bar">수평 바 차트</SelectItem>
|
<SelectContent className="z-[99999]" onClick={(e) => e.stopPropagation()}>
|
||||||
<SelectItem value="stacked-bar">누적 바 차트</SelectItem>
|
{getChartCategory(element.subtype) === "axis-based" ? (
|
||||||
<SelectItem value="line">꺾은선 차트</SelectItem>
|
<SelectGroup>
|
||||||
<SelectItem value="area">영역 차트</SelectItem>
|
<SelectLabel>축 기반 차트</SelectLabel>
|
||||||
<SelectItem value="combo">콤보 차트</SelectItem>
|
<SelectItem value="bar">바 차트</SelectItem>
|
||||||
</SelectGroup>
|
<SelectItem value="horizontal-bar">수평 바 차트</SelectItem>
|
||||||
) : (
|
<SelectItem value="stacked-bar">누적 바 차트</SelectItem>
|
||||||
<SelectGroup>
|
<SelectItem value="line">꺾은선 차트</SelectItem>
|
||||||
<SelectLabel>원형 차트</SelectLabel>
|
<SelectItem value="area">영역 차트</SelectItem>
|
||||||
<SelectItem value="pie">원형 차트</SelectItem>
|
<SelectItem value="combo">콤보 차트</SelectItem>
|
||||||
<SelectItem value="donut">도넛 차트</SelectItem>
|
</SelectGroup>
|
||||||
</SelectGroup>
|
) : (
|
||||||
)}
|
<SelectGroup>
|
||||||
</SelectContent>
|
<SelectLabel>원형 차트</SelectLabel>
|
||||||
</Select>
|
<SelectItem value="pie">원형 차트</SelectItem>
|
||||||
)}
|
<SelectItem value="donut">도넛 차트</SelectItem>
|
||||||
{/* 제목 */}
|
</SelectGroup>
|
||||||
{!element.type || element.type !== "chart" ? (
|
)}
|
||||||
element.subtype === "map-summary-v2" && !element.customTitle ? null : (
|
</SelectContent>
|
||||||
<span className="text-foreground text-xs font-bold">{element.customTitle || element.title}</span>
|
</Select>
|
||||||
)
|
)}
|
||||||
) : null}
|
{/* 제목 */}
|
||||||
|
{!element.type || element.type !== "chart" ? (
|
||||||
|
element.subtype === "map-summary-v2" && !element.customTitle ? null : (
|
||||||
|
<span className="text-foreground text-xs font-bold">{element.customTitle || element.title}</span>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
)}
|
||||||
{/* 삭제 버튼 */}
|
|
||||||
<Button
|
{/* 삭제 버튼 - 항상 표시 (우측 상단 절대 위치) */}
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="element-close hover:bg-destructive text-muted-foreground h-5 w-5 hover:text-white"
|
size="icon"
|
||||||
onClick={handleRemove}
|
className="element-close hover:bg-destructive text-muted-foreground absolute top-1 right-1 z-10 h-5 w-5 hover:text-white"
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onClick={handleRemove}
|
||||||
title="삭제"
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
title="삭제"
|
||||||
<X className="h-3 w-3" />
|
>
|
||||||
</Button>
|
<X className="h-3 w-3" />
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 내용 */}
|
{/* 내용 */}
|
||||||
<div className="relative h-[calc(100%-32px)] px-2 pb-2">
|
<div className={`relative px-2 pb-2 ${element.showHeader !== false ? "h-[calc(100%-32px)]" : "h-full"}`}>
|
||||||
{element.type === "chart" ? (
|
{element.type === "chart" ? (
|
||||||
// 차트 렌더링
|
// 차트 렌더링
|
||||||
<div className="bg-background h-full w-full">
|
<div className="bg-background h-full w-full">
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useCallback } from "react";
|
import React, { useState, useCallback } from "react";
|
||||||
import { ChartDataSource, QueryResult, ChartConfig } from "./types";
|
import { ChartDataSource, QueryResult } from "./types";
|
||||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||||
import { dashboardApi } from "@/lib/api/dashboard";
|
import { dashboardApi } from "@/lib/api/dashboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -14,7 +13,6 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { Play, Loader2, Database, Code, ChevronDown, ChevronRight } from "lucide-react";
|
import { Play, Loader2, Database, Code, ChevronDown, ChevronRight } from "lucide-react";
|
||||||
import { applyQueryFilters } from "./utils/queryHelpers";
|
|
||||||
|
|
||||||
interface QueryEditorProps {
|
interface QueryEditorProps {
|
||||||
dataSource?: ChartDataSource;
|
dataSource?: ChartDataSource;
|
||||||
|
|
@ -106,7 +104,6 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
|
||||||
...dataSource,
|
...dataSource,
|
||||||
type: "database",
|
type: "database",
|
||||||
query: query.trim(),
|
query: query.trim(),
|
||||||
refreshInterval: dataSource?.refreshInterval ?? 0,
|
|
||||||
lastExecuted: new Date().toISOString(),
|
lastExecuted: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -168,8 +165,8 @@ ORDER BY 하위부서수 DESC`,
|
||||||
{/* 쿼리 에디터 헤더 */}
|
{/* 쿼리 에디터 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Database className="h-3.5 w-3.5 text-primary" />
|
<Database className="text-primary h-3.5 w-3.5" />
|
||||||
<h4 className="text-xs font-semibold text-foreground">SQL 쿼리 에디터</h4>
|
<h4 className="text-foreground text-xs font-semibold">SQL 쿼리 에디터</h4>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={executeQuery} disabled={isExecuting || !query.trim()} size="sm" className="h-7 text-xs">
|
<Button onClick={executeQuery} disabled={isExecuting || !query.trim()} size="sm" className="h-7 text-xs">
|
||||||
{isExecuting ? (
|
{isExecuting ? (
|
||||||
|
|
@ -188,7 +185,7 @@ ORDER BY 하위부서수 DESC`,
|
||||||
|
|
||||||
{/* 샘플 쿼리 아코디언 */}
|
{/* 샘플 쿼리 아코디언 */}
|
||||||
<Collapsible open={sampleQueryOpen} onOpenChange={setSampleQueryOpen}>
|
<Collapsible open={sampleQueryOpen} onOpenChange={setSampleQueryOpen}>
|
||||||
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded border border-border bg-muted px-2 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted">
|
<CollapsibleTrigger className="border-border bg-muted text-foreground hover:bg-muted flex w-full items-center gap-1.5 rounded border px-2 py-1.5 text-xs font-medium transition-colors">
|
||||||
{sampleQueryOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
{sampleQueryOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||||
샘플 쿼리
|
샘플 쿼리
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|
@ -196,33 +193,33 @@ ORDER BY 하위부서수 DESC`,
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => insertSampleQuery("users")}
|
onClick={() => insertSampleQuery("users")}
|
||||||
className="flex items-center gap-1 rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
className="border-border bg-background hover:bg-muted flex items-center gap-1 rounded border px-2 py-1 text-[11px] transition-colors"
|
||||||
>
|
>
|
||||||
<Code className="h-3 w-3" />
|
<Code className="h-3 w-3" />
|
||||||
부서별 사용자
|
부서별 사용자
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => insertSampleQuery("dept")}
|
onClick={() => insertSampleQuery("dept")}
|
||||||
className="flex items-center gap-1 rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
className="border-border bg-background hover:bg-muted flex items-center gap-1 rounded border px-2 py-1 text-[11px] transition-colors"
|
||||||
>
|
>
|
||||||
<Code className="h-3 w-3" />
|
<Code className="h-3 w-3" />
|
||||||
부서 정보
|
부서 정보
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => insertSampleQuery("usersByDate")}
|
onClick={() => insertSampleQuery("usersByDate")}
|
||||||
className="rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
className="border-border bg-background hover:bg-muted rounded border px-2 py-1 text-[11px] transition-colors"
|
||||||
>
|
>
|
||||||
월별 가입 추이
|
월별 가입 추이
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => insertSampleQuery("usersByPosition")}
|
onClick={() => insertSampleQuery("usersByPosition")}
|
||||||
className="rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
className="border-border bg-background hover:bg-muted rounded border px-2 py-1 text-[11px] transition-colors"
|
||||||
>
|
>
|
||||||
직급별 분포
|
직급별 분포
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => insertSampleQuery("deptHierarchy")}
|
onClick={() => insertSampleQuery("deptHierarchy")}
|
||||||
className="rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
className="border-border bg-background hover:bg-muted rounded border px-2 py-1 text-[11px] transition-colors"
|
||||||
>
|
>
|
||||||
부서 계층
|
부서 계층
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -247,46 +244,6 @@ ORDER BY 하위부서수 DESC`,
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 새로고침 간격 설정 */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Label className="text-xs">자동 새로고침:</Label>
|
|
||||||
<Select
|
|
||||||
value={String(dataSource?.refreshInterval ?? 0)}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
onDataSourceChange({
|
|
||||||
...dataSource,
|
|
||||||
type: "database",
|
|
||||||
query,
|
|
||||||
refreshInterval: parseInt(value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-7 w-24 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="z-[99999]">
|
|
||||||
<SelectItem value="0" className="text-xs">
|
|
||||||
수동
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="10000" className="text-xs">
|
|
||||||
10초
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="30000" className="text-xs">
|
|
||||||
30초
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="60000" className="text-xs">
|
|
||||||
1분
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="300000" className="text-xs">
|
|
||||||
5분
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="600000" className="text-xs">
|
|
||||||
10분
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 오류 메시지 */}
|
{/* 오류 메시지 */}
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive" className="py-2">
|
<Alert variant="destructive" className="py-2">
|
||||||
|
|
@ -300,15 +257,15 @@ ORDER BY 하위부서수 DESC`,
|
||||||
{/* 쿼리 결과 미리보기 */}
|
{/* 쿼리 결과 미리보기 */}
|
||||||
{queryResult && (
|
{queryResult && (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="border-b border-border bg-muted px-2 py-1.5">
|
<div className="border-border bg-muted border-b px-2 py-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-xs font-medium text-foreground">쿼리 결과</span>
|
<span className="text-foreground text-xs font-medium">쿼리 결과</span>
|
||||||
<Badge variant="secondary" className="h-4 text-[10px]">
|
<Badge variant="secondary" className="h-4 text-[10px]">
|
||||||
{queryResult.rows.length}행
|
{queryResult.rows.length}행
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-muted-foreground">실행 시간: {queryResult.executionTime}ms</span>
|
<span className="text-muted-foreground text-[10px]">실행 시간: {queryResult.executionTime}ms</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -339,13 +296,13 @@ ORDER BY 하위부서수 DESC`,
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{queryResult.rows.length > 10 && (
|
{queryResult.rows.length > 10 && (
|
||||||
<div className="mt-2 text-center text-[10px] text-muted-foreground">
|
<div className="text-muted-foreground mt-2 text-center text-[10px]">
|
||||||
... 및 {queryResult.rows.length - 10}개 더 (미리보기는 10행까지만 표시)
|
... 및 {queryResult.rows.length - 10}개 더 (미리보기는 10행까지만 표시)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-6 text-center text-xs text-muted-foreground">결과가 없습니다.</div>
|
<div className="text-muted-foreground py-6 text-center text-xs">결과가 없습니다.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -353,169 +310,3 @@ ORDER BY 하위부서수 DESC`,
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 샘플 쿼리 결과 생성 함수
|
|
||||||
*/
|
|
||||||
function generateSampleQueryResult(query: string): QueryResult {
|
|
||||||
// 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성
|
|
||||||
const queryLower = query.toLowerCase();
|
|
||||||
|
|
||||||
// 디버깅용 로그
|
|
||||||
// console.log('generateSampleQueryResult called with query:', query.substring(0, 100));
|
|
||||||
|
|
||||||
// 가장 구체적인 조건부터 먼저 체크 (순서 중요!)
|
|
||||||
const isComparison =
|
|
||||||
queryLower.includes("galaxy") ||
|
|
||||||
queryLower.includes("갤럭시") ||
|
|
||||||
queryLower.includes("아이폰") ||
|
|
||||||
queryLower.includes("iphone");
|
|
||||||
const isRegional = queryLower.includes("region") || queryLower.includes("지역");
|
|
||||||
const isMonthly = queryLower.includes("month");
|
|
||||||
const isSales = queryLower.includes("sales") || queryLower.includes("매출");
|
|
||||||
const isUsers = queryLower.includes("users") || queryLower.includes("사용자");
|
|
||||||
const isProducts = queryLower.includes("product") || queryLower.includes("상품");
|
|
||||||
const isWeekly = queryLower.includes("week");
|
|
||||||
|
|
||||||
// console.log('Sample data type detection:', {
|
|
||||||
// isComparison,
|
|
||||||
// isRegional,
|
|
||||||
// isWeekly,
|
|
||||||
// isProducts,
|
|
||||||
// isMonthly,
|
|
||||||
// isSales,
|
|
||||||
// isUsers,
|
|
||||||
// querySnippet: query.substring(0, 200)
|
|
||||||
// });
|
|
||||||
|
|
||||||
let columns: string[];
|
|
||||||
let rows: Record<string, any>[];
|
|
||||||
|
|
||||||
// 더 구체적인 조건부터 먼저 체크 (순서 중요!)
|
|
||||||
if (isComparison) {
|
|
||||||
// console.log('✅ Using COMPARISON data');
|
|
||||||
// 제품 비교 데이터 (다중 시리즈)
|
|
||||||
columns = ["month", "galaxy_sales", "iphone_sales", "other_sales"];
|
|
||||||
rows = [
|
|
||||||
{ month: "2024-01", galaxy_sales: 450000, iphone_sales: 620000, other_sales: 130000 },
|
|
||||||
{ month: "2024-02", galaxy_sales: 520000, iphone_sales: 680000, other_sales: 150000 },
|
|
||||||
{ month: "2024-03", galaxy_sales: 480000, iphone_sales: 590000, other_sales: 110000 },
|
|
||||||
{ month: "2024-04", galaxy_sales: 610000, iphone_sales: 650000, other_sales: 160000 },
|
|
||||||
{ month: "2024-05", galaxy_sales: 720000, iphone_sales: 780000, other_sales: 180000 },
|
|
||||||
{ month: "2024-06", galaxy_sales: 680000, iphone_sales: 690000, other_sales: 170000 },
|
|
||||||
{ month: "2024-07", galaxy_sales: 750000, iphone_sales: 800000, other_sales: 170000 },
|
|
||||||
{ month: "2024-08", galaxy_sales: 690000, iphone_sales: 720000, other_sales: 170000 },
|
|
||||||
{ month: "2024-09", galaxy_sales: 730000, iphone_sales: 750000, other_sales: 170000 },
|
|
||||||
{ month: "2024-10", galaxy_sales: 800000, iphone_sales: 810000, other_sales: 170000 },
|
|
||||||
{ month: "2024-11", galaxy_sales: 870000, iphone_sales: 880000, other_sales: 170000 },
|
|
||||||
{ month: "2024-12", galaxy_sales: 950000, iphone_sales: 990000, other_sales: 160000 },
|
|
||||||
];
|
|
||||||
// COMPARISON 데이터를 반환하고 함수 종료
|
|
||||||
// console.log('COMPARISON data generated:', {
|
|
||||||
// columns,
|
|
||||||
// rowCount: rows.length,
|
|
||||||
// sampleRow: rows[0],
|
|
||||||
// allRows: rows,
|
|
||||||
// fieldTypes: {
|
|
||||||
// month: typeof rows[0].month,
|
|
||||||
// galaxy_sales: typeof rows[0].galaxy_sales,
|
|
||||||
// iphone_sales: typeof rows[0].iphone_sales,
|
|
||||||
// other_sales: typeof rows[0].other_sales
|
|
||||||
// },
|
|
||||||
// firstFewRows: rows.slice(0, 3),
|
|
||||||
// lastFewRows: rows.slice(-3)
|
|
||||||
// });
|
|
||||||
return {
|
|
||||||
columns,
|
|
||||||
rows,
|
|
||||||
totalRows: rows.length,
|
|
||||||
executionTime: Math.floor(Math.random() * 200) + 100,
|
|
||||||
};
|
|
||||||
} else if (isRegional) {
|
|
||||||
// console.log('✅ Using REGIONAL data');
|
|
||||||
// 지역별 분기별 매출
|
|
||||||
columns = ["지역", "Q1", "Q2", "Q3", "Q4"];
|
|
||||||
rows = [
|
|
||||||
{ 지역: "서울", Q1: 1200000, Q2: 1350000, Q3: 1420000, Q4: 1580000 },
|
|
||||||
{ 지역: "경기", Q1: 980000, Q2: 1120000, Q3: 1180000, Q4: 1290000 },
|
|
||||||
{ 지역: "부산", Q1: 650000, Q2: 720000, Q3: 780000, Q4: 850000 },
|
|
||||||
{ 지역: "대구", Q1: 450000, Q2: 490000, Q3: 520000, Q4: 580000 },
|
|
||||||
{ 지역: "인천", Q1: 520000, Q2: 580000, Q3: 620000, Q4: 690000 },
|
|
||||||
{ 지역: "광주", Q1: 380000, Q2: 420000, Q3: 450000, Q4: 490000 },
|
|
||||||
{ 지역: "대전", Q1: 410000, Q2: 460000, Q3: 490000, Q4: 530000 },
|
|
||||||
];
|
|
||||||
} else if (isWeekly && isUsers) {
|
|
||||||
// console.log('✅ Using USERS data');
|
|
||||||
// 사용자 가입 추이
|
|
||||||
columns = ["week", "new_users"];
|
|
||||||
rows = [
|
|
||||||
{ week: "2024-W10", new_users: 23 },
|
|
||||||
{ week: "2024-W11", new_users: 31 },
|
|
||||||
{ week: "2024-W12", new_users: 28 },
|
|
||||||
{ week: "2024-W13", new_users: 35 },
|
|
||||||
{ week: "2024-W14", new_users: 42 },
|
|
||||||
{ week: "2024-W15", new_users: 38 },
|
|
||||||
{ week: "2024-W16", new_users: 45 },
|
|
||||||
{ week: "2024-W17", new_users: 52 },
|
|
||||||
{ week: "2024-W18", new_users: 48 },
|
|
||||||
{ week: "2024-W19", new_users: 55 },
|
|
||||||
{ week: "2024-W20", new_users: 61 },
|
|
||||||
{ week: "2024-W21", new_users: 58 },
|
|
||||||
];
|
|
||||||
} else if (isProducts && !isComparison) {
|
|
||||||
// console.log('✅ Using PRODUCTS data');
|
|
||||||
// 상품별 판매량
|
|
||||||
columns = ["product_name", "total_sold", "revenue"];
|
|
||||||
rows = [
|
|
||||||
{ product_name: "스마트폰", total_sold: 156, revenue: 234000000 },
|
|
||||||
{ product_name: "노트북", total_sold: 89, revenue: 178000000 },
|
|
||||||
{ product_name: "태블릿", total_sold: 134, revenue: 67000000 },
|
|
||||||
{ product_name: "이어폰", total_sold: 267, revenue: 26700000 },
|
|
||||||
{ product_name: "스마트워치", total_sold: 98, revenue: 49000000 },
|
|
||||||
{ product_name: "키보드", total_sold: 78, revenue: 15600000 },
|
|
||||||
{ product_name: "마우스", total_sold: 145, revenue: 8700000 },
|
|
||||||
{ product_name: "모니터", total_sold: 67, revenue: 134000000 },
|
|
||||||
{ product_name: "프린터", total_sold: 34, revenue: 17000000 },
|
|
||||||
{ product_name: "웹캠", total_sold: 89, revenue: 8900000 },
|
|
||||||
];
|
|
||||||
} else if (isMonthly && isSales && !isComparison) {
|
|
||||||
// console.log('✅ Using MONTHLY SALES data');
|
|
||||||
// 월별 매출 데이터
|
|
||||||
columns = ["month", "sales", "order_count"];
|
|
||||||
rows = [
|
|
||||||
{ month: "2024-01", sales: 1200000, order_count: 45 },
|
|
||||||
{ month: "2024-02", sales: 1350000, order_count: 52 },
|
|
||||||
{ month: "2024-03", sales: 1180000, order_count: 41 },
|
|
||||||
{ month: "2024-04", sales: 1420000, order_count: 58 },
|
|
||||||
{ month: "2024-05", sales: 1680000, order_count: 67 },
|
|
||||||
{ month: "2024-06", sales: 1540000, order_count: 61 },
|
|
||||||
{ month: "2024-07", sales: 1720000, order_count: 71 },
|
|
||||||
{ month: "2024-08", sales: 1580000, order_count: 63 },
|
|
||||||
{ month: "2024-09", sales: 1650000, order_count: 68 },
|
|
||||||
{ month: "2024-10", sales: 1780000, order_count: 75 },
|
|
||||||
{ month: "2024-11", sales: 1920000, order_count: 82 },
|
|
||||||
{ month: "2024-12", sales: 2100000, order_count: 89 },
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
// console.log('⚠️ Using DEFAULT data');
|
|
||||||
// 기본 샘플 데이터
|
|
||||||
columns = ["category", "value", "count"];
|
|
||||||
rows = [
|
|
||||||
{ category: "A", value: 100, count: 10 },
|
|
||||||
{ category: "B", value: 150, count: 15 },
|
|
||||||
{ category: "C", value: 120, count: 12 },
|
|
||||||
{ category: "D", value: 180, count: 18 },
|
|
||||||
{ category: "E", value: 90, count: 9 },
|
|
||||||
{ category: "F", value: 200, count: 20 },
|
|
||||||
{ category: "G", value: 110, count: 11 },
|
|
||||||
{ category: "H", value: 160, count: 16 },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
columns,
|
|
||||||
rows,
|
|
||||||
totalRows: rows.length,
|
|
||||||
executionTime: Math.floor(Math.random() * 200) + 100, // 100-300ms
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ const needsDataSource = (subtype: ElementSubtype): boolean => {
|
||||||
"chart",
|
"chart",
|
||||||
"map-summary-v2",
|
"map-summary-v2",
|
||||||
"risk-alert-v2",
|
"risk-alert-v2",
|
||||||
"yard-management-3d",
|
// "yard-management-3d", // 데이터 탭 불필요 (레이아웃 선택만 사용)
|
||||||
"todo",
|
"todo",
|
||||||
"document",
|
"document",
|
||||||
"work-history",
|
"work-history",
|
||||||
|
|
@ -449,13 +449,30 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 레이아웃 선택 (야드 관리 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">
|
||||||
|
레이아웃 선택
|
||||||
|
</Label>
|
||||||
|
<p className="text-muted-foreground mb-2 text-xs">표시할 디지털 트윈 레이아웃을 선택하세요</p>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
<p>위젯 내부에서 레이아웃을 선택할 수 있습니다.</p>
|
||||||
|
<p className="mt-1">편집 모드에서 레이아웃 목록을 확인하고 선택하세요.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 자동 새로고침 설정 (지도 위젯 전용) */}
|
{/* 자동 새로고침 설정 (지도 위젯 전용) */}
|
||||||
{element.subtype === "map-summary-v2" && (
|
{element.subtype === "map-summary-v2" && (
|
||||||
<div className="bg-background rounded-lg p-3 shadow-sm">
|
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||||
<Label htmlFor="refresh-interval" className="mb-2 block text-xs font-semibold">
|
<Label htmlFor="refresh-interval" className="mb-2 block text-xs font-semibold">
|
||||||
자동 새로고침 간격
|
자동 새로고침 간격
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={refreshInterval.toString()} onValueChange={(value) => setRefreshInterval(parseInt(value))}>
|
<Select
|
||||||
|
value={refreshInterval.toString()}
|
||||||
|
onValueChange={(value) => setRefreshInterval(parseInt(value))}
|
||||||
|
>
|
||||||
<SelectTrigger id="refresh-interval" className="h-9 text-sm">
|
<SelectTrigger id="refresh-interval" className="h-9 text-sm">
|
||||||
<SelectValue placeholder="간격 선택" />
|
<SelectValue placeholder="간격 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -579,30 +596,16 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
||||||
refreshInterval={element.chartConfig?.refreshInterval || 5}
|
refreshInterval={element.chartConfig?.refreshInterval || 5}
|
||||||
markerType={element.chartConfig?.markerType || "circle"}
|
markerType={element.chartConfig?.markerType || "circle"}
|
||||||
onRefreshIntervalChange={(interval) => {
|
onRefreshIntervalChange={(interval) => {
|
||||||
setElement((prev) =>
|
setChartConfig((prev) => ({
|
||||||
prev
|
...prev,
|
||||||
? {
|
refreshInterval: interval,
|
||||||
...prev,
|
}));
|
||||||
chartConfig: {
|
|
||||||
...prev.chartConfig,
|
|
||||||
refreshInterval: interval,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: prev
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
onMarkerTypeChange={(type) => {
|
onMarkerTypeChange={(type) => {
|
||||||
setElement((prev) =>
|
setChartConfig((prev) => ({
|
||||||
prev
|
...prev,
|
||||||
? {
|
markerType: type,
|
||||||
...prev,
|
}));
|
||||||
chartConfig: {
|
|
||||||
...prev.chartConfig,
|
|
||||||
markerType: type,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: prev
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -626,13 +629,13 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
||||||
// 다중 데이터 소스 위젯: 데이터 소스가 있는데 endpoint가 비어있으면 비활성화
|
// 다중 데이터 소스 위젯: 데이터 소스가 있는데 endpoint가 비어있으면 비활성화
|
||||||
// (데이터 소스가 없는 건 OK - 연결 해제하는 경우)
|
// (데이터 소스가 없는 건 OK - 연결 해제하는 경우)
|
||||||
(element?.subtype === "map-summary-v2" ||
|
(element?.subtype === "map-summary-v2" ||
|
||||||
element?.subtype === "chart" ||
|
element?.subtype === "chart" ||
|
||||||
element?.subtype === "list-v2" ||
|
element?.subtype === "list-v2" ||
|
||||||
element?.subtype === "custom-metric-v2" ||
|
element?.subtype === "custom-metric-v2" ||
|
||||||
element?.subtype === "risk-alert-v2") &&
|
element?.subtype === "risk-alert-v2") &&
|
||||||
dataSources &&
|
dataSources &&
|
||||||
dataSources.length > 0 &&
|
dataSources.length > 0 &&
|
||||||
dataSources.some(ds => ds.type === "api" && !ds.endpoint)
|
dataSources.some((ds) => ds.type === "api" && !ds.endpoint)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
적용
|
적용
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
const [sampleData, setSampleData] = useState<any[]>([]); // 샘플 데이터 (최대 3개)
|
const [sampleData, setSampleData] = useState<any[]>([]); // 샘플 데이터 (최대 3개)
|
||||||
const [columnSearchTerm, setColumnSearchTerm] = useState(""); // 컬럼 검색어
|
const [columnSearchTerm, setColumnSearchTerm] = useState(""); // 컬럼 검색어
|
||||||
|
|
||||||
console.log("🔧 MultiApiConfig - dataSource:", dataSource);
|
|
||||||
|
|
||||||
// 외부 API 커넥션 목록 로드
|
// 외부 API 커넥션 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadApiConnections = async () => {
|
const loadApiConnections = async () => {
|
||||||
|
|
@ -51,14 +49,12 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("불러온 커넥션:", connection);
|
|
||||||
|
|
||||||
// base_url과 endpoint_path를 조합하여 전체 URL 생성
|
// base_url과 endpoint_path를 조합하여 전체 URL 생성
|
||||||
const fullEndpoint = connection.endpoint_path
|
const fullEndpoint = connection.endpoint_path
|
||||||
? `${connection.base_url}${connection.endpoint_path}`
|
? `${connection.base_url}${connection.endpoint_path}`
|
||||||
: connection.base_url;
|
: connection.base_url;
|
||||||
|
|
||||||
console.log("전체 엔드포인트:", fullEndpoint);
|
|
||||||
|
|
||||||
const updates: Partial<ChartDataSource> = {
|
const updates: Partial<ChartDataSource> = {
|
||||||
endpoint: fullEndpoint,
|
endpoint: fullEndpoint,
|
||||||
|
|
@ -76,7 +72,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
value,
|
value,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
console.log("기본 헤더 적용:", headers);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 인증 설정이 있으면 헤더 또는 쿼리 파라미터에 추가
|
// 인증 설정이 있으면 헤더 또는 쿼리 파라미터에 추가
|
||||||
|
|
@ -91,7 +86,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
key: authConfig.keyName,
|
key: authConfig.keyName,
|
||||||
value: authConfig.keyValue,
|
value: authConfig.keyValue,
|
||||||
});
|
});
|
||||||
console.log("API Key 헤더 추가:", authConfig.keyName);
|
|
||||||
} else if (authConfig.keyLocation === "query" && authConfig.keyName && authConfig.keyValue) {
|
} else if (authConfig.keyLocation === "query" && authConfig.keyName && authConfig.keyValue) {
|
||||||
// UTIC API는 'key'를 사용하므로, 'apiKey'를 'key'로 변환
|
// UTIC API는 'key'를 사용하므로, 'apiKey'를 'key'로 변환
|
||||||
const actualKeyName = authConfig.keyName === "apiKey" ? "key" : authConfig.keyName;
|
const actualKeyName = authConfig.keyName === "apiKey" ? "key" : authConfig.keyName;
|
||||||
|
|
@ -100,7 +94,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
key: actualKeyName,
|
key: actualKeyName,
|
||||||
value: authConfig.keyValue,
|
value: authConfig.keyValue,
|
||||||
});
|
});
|
||||||
console.log("API Key 쿼리 파라미터 추가:", actualKeyName, "(원본:", authConfig.keyName, ")");
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -111,7 +104,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
key: "Authorization",
|
key: "Authorization",
|
||||||
value: `Bearer ${authConfig.token}`,
|
value: `Bearer ${authConfig.token}`,
|
||||||
});
|
});
|
||||||
console.log("Bearer Token 헤더 추가");
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -123,7 +115,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
key: "Authorization",
|
key: "Authorization",
|
||||||
value: `Basic ${credentials}`,
|
value: `Basic ${credentials}`,
|
||||||
});
|
});
|
||||||
console.log("Basic Auth 헤더 추가");
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -134,7 +125,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
key: "Authorization",
|
key: "Authorization",
|
||||||
value: `Bearer ${authConfig.accessToken}`,
|
value: `Bearer ${authConfig.accessToken}`,
|
||||||
});
|
});
|
||||||
console.log("OAuth2 Token 헤더 추가");
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +138,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
updates.queryParams = queryParams;
|
updates.queryParams = queryParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("최종 업데이트:", updates);
|
|
||||||
onChange(updates);
|
onChange(updates);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -235,12 +224,12 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
|
console.log("🌐 [API 테스트 결과]", result.data);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// 텍스트 데이터 파싱 함수 (MapTestWidgetV2와 동일)
|
// 텍스트 데이터 파싱 함수 (MapTestWidgetV2와 동일)
|
||||||
const parseTextData = (text: string): any[] => {
|
const parseTextData = (text: string): any[] => {
|
||||||
try {
|
try {
|
||||||
console.log("🔍 텍스트 파싱 시작 (처음 500자):", text.substring(0, 500));
|
|
||||||
|
|
||||||
const lines = text.split('\n').filter(line => {
|
const lines = text.split('\n').filter(line => {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
return trimmed &&
|
return trimmed &&
|
||||||
|
|
@ -249,8 +238,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
!trimmed.startsWith('---');
|
!trimmed.startsWith('---');
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`📝 유효한 라인: ${lines.length}개`);
|
|
||||||
|
|
||||||
if (lines.length === 0) return [];
|
if (lines.length === 0) return [];
|
||||||
|
|
||||||
const result: any[] = [];
|
const result: any[] = [];
|
||||||
|
|
@ -278,7 +265,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📊 파싱 결과:", result.length, "개");
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 텍스트 파싱 오류:", error);
|
console.error("❌ 텍스트 파싱 오류:", error);
|
||||||
|
|
@ -291,10 +277,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
|
|
||||||
// 텍스트 데이터 체크 (기상청 API 등)
|
// 텍스트 데이터 체크 (기상청 API 등)
|
||||||
if (data && typeof data === 'object' && data.text && typeof data.text === 'string') {
|
if (data && typeof data === 'object' && data.text && typeof data.text === 'string') {
|
||||||
console.log("📄 텍스트 형식 데이터 감지, CSV 파싱 시도");
|
|
||||||
const parsedData = parseTextData(data.text);
|
const parsedData = parseTextData(data.text);
|
||||||
if (parsedData.length > 0) {
|
if (parsedData.length > 0) {
|
||||||
console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`);
|
|
||||||
data = parsedData;
|
data = parsedData;
|
||||||
}
|
}
|
||||||
} else if (dataSource.jsonPath) {
|
} else if (dataSource.jsonPath) {
|
||||||
|
|
@ -306,6 +290,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
|
|
||||||
const rows = Array.isArray(data) ? data : [data];
|
const rows = Array.isArray(data) ? data : [data];
|
||||||
|
|
||||||
|
console.log("📊 [최종 파싱된 데이터]", rows);
|
||||||
|
|
||||||
// 컬럼 목록 및 타입 추출
|
// 컬럼 목록 및 타입 추출
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
const columns = Object.keys(rows[0]);
|
const columns = Object.keys(rows[0]);
|
||||||
|
|
@ -336,9 +322,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
|
|
||||||
// 샘플 데이터 저장 (최대 3개)
|
// 샘플 데이터 저장 (최대 3개)
|
||||||
setSampleData(rows.slice(0, 3));
|
setSampleData(rows.slice(0, 3));
|
||||||
|
|
||||||
console.log("📊 발견된 컬럼:", columns);
|
|
||||||
console.log("📊 컬럼 타입:", types);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 위도/경도 또는 coordinates 필드 또는 지역 코드 체크
|
// 위도/경도 또는 coordinates 필드 또는 지역 코드 체크
|
||||||
|
|
@ -422,7 +405,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
id={`endpoint-${dataSource.id}`}
|
id={`endpoint-${dataSource.id}`}
|
||||||
value={dataSource.endpoint || ""}
|
value={dataSource.endpoint || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
console.log("📝 API URL 변경:", e.target.value);
|
|
||||||
onChange({ endpoint: e.target.value });
|
onChange({ endpoint: e.target.value });
|
||||||
}}
|
}}
|
||||||
placeholder="https://api.example.com/data"
|
placeholder="https://api.example.com/data"
|
||||||
|
|
@ -546,6 +528,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="circle" className="text-xs">동그라미</SelectItem>
|
<SelectItem value="circle" className="text-xs">동그라미</SelectItem>
|
||||||
<SelectItem value="arrow" className="text-xs">화살표</SelectItem>
|
<SelectItem value="arrow" className="text-xs">화살표</SelectItem>
|
||||||
|
<SelectItem value="truck" className="text-xs">트럭</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,10 @@ export interface ChartDataSource {
|
||||||
markerColor?: string; // 마커 색상 (예: "#ff0000")
|
markerColor?: string; // 마커 색상 (예: "#ff0000")
|
||||||
polygonColor?: string; // 폴리곤 색상 (예: "#0000ff")
|
polygonColor?: string; // 폴리곤 색상 (예: "#0000ff")
|
||||||
polygonOpacity?: number; // 폴리곤 투명도 (0.0 ~ 1.0, 기본값: 0.5)
|
polygonOpacity?: number; // 폴리곤 투명도 (0.0 ~ 1.0, 기본값: 0.5)
|
||||||
markerType?: string; // 마커 종류 (circle, arrow)
|
markerType?: string; // 마커 종류 (circle, arrow, truck)
|
||||||
|
minZoom?: number; // 최소 줌 레벨 (최대로 멀리 보기, 기본값: 2)
|
||||||
|
maxZoom?: number; // 최대 줌 레벨 (최대로 가까이 보기, 기본값: 18)
|
||||||
|
initialZoom?: number; // 초기 줌 레벨 (기본값: 13)
|
||||||
|
|
||||||
// 컬럼 매핑 (다중 데이터 소스 통합용)
|
// 컬럼 매핑 (다중 데이터 소스 통합용)
|
||||||
columnMapping?: Record<string, string>; // { 원본컬럼: 표시이름 } (예: { "name": "product" })
|
columnMapping?: Record<string, string>; // { 원본컬럼: 표시이름 } (예: { "name": "product" })
|
||||||
|
|
@ -397,6 +400,7 @@ export interface CustomMetricConfig {
|
||||||
unit?: string; // 표시 단위 (원, 건, % 등)
|
unit?: string; // 표시 단위 (원, 건, % 등)
|
||||||
color?: "indigo" | "green" | "blue" | "purple" | "orange" | "gray"; // 카드 색상
|
color?: "indigo" | "green" | "blue" | "purple" | "orange" | "gray"; // 카드 색상
|
||||||
decimals?: number; // 소수점 자릿수 (기본: 0)
|
decimals?: number; // 소수점 자릿수 (기본: 0)
|
||||||
|
refreshInterval?: number; // 자동 새로고침 간격 (초, 0이면 비활성)
|
||||||
|
|
||||||
// 필터 조건
|
// 필터 조건
|
||||||
filters?: Array<{
|
filters?: Array<{
|
||||||
|
|
|
||||||
|
|
@ -89,68 +89,70 @@ export function CustomMetricSection({ queryResult, config, onConfigChange }: Cus
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{config.filters && config.filters.length > 0 ? (
|
{config.filters && config.filters.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
{config.filters.map((filter, index) => (
|
{config.filters.map((filter, index) => (
|
||||||
<div key={index} className="bg-muted/50 flex items-center gap-2 rounded-md border p-2">
|
<div key={index} className="bg-muted/50 space-y-2 rounded-md border p-3">
|
||||||
{/* 컬럼 선택 */}
|
{/* 첫 번째 줄: 컬럼 선택 */}
|
||||||
<Select value={filter.column} onValueChange={(value) => updateFilter(index, "column", value)}>
|
<div className="flex items-center gap-2">
|
||||||
<SelectTrigger className="h-8 flex-1 text-xs">
|
<Select value={filter.column} onValueChange={(value) => updateFilter(index, "column", value)}>
|
||||||
<SelectValue />
|
<SelectTrigger className="h-9 flex-1 text-sm">
|
||||||
</SelectTrigger>
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
{queryResult.columns.map((col) => (
|
<SelectContent>
|
||||||
<SelectItem key={col} value={col} className="text-xs">
|
{queryResult.columns.map((col) => (
|
||||||
{col}
|
<SelectItem key={col} value={col} className="text-sm">
|
||||||
</SelectItem>
|
{col}
|
||||||
))}
|
</SelectItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
{/* 연산자 선택 */}
|
{/* 삭제 버튼 */}
|
||||||
|
<Button onClick={() => removeFilter(index)} variant="ghost" size="icon" className="h-9 w-9 shrink-0">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 두 번째 줄: 연산자 선택 */}
|
||||||
<Select value={filter.operator} onValueChange={(value) => updateFilter(index, "operator", value)}>
|
<Select value={filter.operator} onValueChange={(value) => updateFilter(index, "operator", value)}>
|
||||||
<SelectTrigger className="h-8 w-[100px] text-xs">
|
<SelectTrigger className="h-9 w-full text-sm">
|
||||||
<SelectValue />
|
<SelectValue placeholder="연산자 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="=" className="text-xs">
|
<SelectItem value="=" className="text-sm">
|
||||||
같음 (=)
|
같음 (=)
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="!=" className="text-xs">
|
<SelectItem value="!=" className="text-sm">
|
||||||
다름 (≠)
|
다름 (≠)
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value=">" className="text-xs">
|
<SelectItem value=">" className="text-sm">
|
||||||
큼 (>)
|
큼 (>)
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="<" className="text-xs">
|
<SelectItem value="<" className="text-sm">
|
||||||
작음 (<)
|
작음 (<)
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value=">=" className="text-xs">
|
<SelectItem value=">=" className="text-sm">
|
||||||
크거나 같음 (≥)
|
크거나 같음 (≥)
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="<=" className="text-xs">
|
<SelectItem value="<=" className="text-sm">
|
||||||
작거나 같음 (≤)
|
작거나 같음 (≤)
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="contains" className="text-xs">
|
<SelectItem value="contains" className="text-sm">
|
||||||
포함
|
포함
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="not_contains" className="text-xs">
|
<SelectItem value="not_contains" className="text-sm">
|
||||||
미포함
|
미포함
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* 값 입력 */}
|
{/* 세 번째 줄: 값 입력 */}
|
||||||
<Input
|
<Input
|
||||||
value={filter.value}
|
value={filter.value}
|
||||||
onChange={(e) => updateFilter(index, "value", e.target.value)}
|
onChange={(e) => updateFilter(index, "value", e.target.value)}
|
||||||
placeholder="값"
|
placeholder="값을 입력하세요"
|
||||||
className="h-8 flex-1 text-xs"
|
className="h-9 w-full text-sm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 삭제 버튼 */}
|
|
||||||
<Button onClick={() => removeFilter(index)} variant="ghost" size="icon" className="h-8 w-8">
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -231,6 +233,29 @@ export function CustomMetricSection({ queryResult, config, onConfigChange }: Cus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 6. 자동 새로고침 간격 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">자동 새로고침</Label>
|
||||||
|
<Select
|
||||||
|
value={(config.refreshInterval ?? 30).toString()}
|
||||||
|
onValueChange={(value) => onConfigChange({ refreshInterval: parseInt(value) })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 text-xs">
|
||||||
|
<SelectValue placeholder="간격 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0" className="text-xs">없음</SelectItem>
|
||||||
|
<SelectItem value="10" className="text-xs">10초</SelectItem>
|
||||||
|
<SelectItem value="30" className="text-xs">30초</SelectItem>
|
||||||
|
<SelectItem value="60" className="text-xs">1분</SelectItem>
|
||||||
|
<SelectItem value="300" className="text-xs">5분</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
통계 데이터를 자동으로 갱신하는 주기
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 미리보기 */}
|
{/* 미리보기 */}
|
||||||
{config.valueColumn && config.aggregation && (
|
{config.valueColumn && config.aggregation && (
|
||||||
<div className="bg-muted/50 space-y-1 rounded-md border p-3">
|
<div className="bg-muted/50 space-y-1 rounded-md border p-3">
|
||||||
|
|
@ -241,7 +266,7 @@ export function CustomMetricSection({ queryResult, config, onConfigChange }: Cus
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs font-medium">필터:</p>
|
<p className="text-xs font-medium">필터:</p>
|
||||||
{config.filters.map((filter, idx) => (
|
{config.filters.map((filter, idx) => (
|
||||||
<p key={idx} className="text-muted-foreground text-xs">
|
<p key={idx} className="text-muted-foreground text-xs" dir="ltr">
|
||||||
· {filter.column} {filter.operator} "{filter.value}"
|
· {filter.column} {filter.operator} "{filter.value}"
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
|
||||||
{config.columns.length > 0 && (
|
{config.columns.length > 0 && (
|
||||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||||
<Label className="mb-2 block text-xs font-semibold">테이블 옵션</Label>
|
<Label className="mb-2 block text-xs font-semibold">테이블 옵션</Label>
|
||||||
<ListTableOptions config={config} onChange={onConfigChange} />
|
<ListTableOptions config={config} onConfigChange={onConfigChange} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -25,25 +25,23 @@ export function MapConfigSection({
|
||||||
refreshInterval = 5,
|
refreshInterval = 5,
|
||||||
markerType = "circle",
|
markerType = "circle",
|
||||||
onRefreshIntervalChange,
|
onRefreshIntervalChange,
|
||||||
onMarkerTypeChange
|
onMarkerTypeChange,
|
||||||
}: MapConfigSectionProps) {
|
}: MapConfigSectionProps) {
|
||||||
// 쿼리 결과가 없으면 안내 메시지 표시
|
// 쿼리 결과가 없으면 안내 메시지 표시
|
||||||
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
|
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||||
<Label className="mb-2 block text-xs font-semibold">지도 설정</Label>
|
<Label className="mb-2 block text-xs font-semibold">지도 설정</Label>
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertDescription className="text-xs">
|
<AlertDescription className="text-xs">먼저 데이터 소스를 설정하고 쿼리를 테스트해주세요.</AlertDescription>
|
||||||
먼저 데이터 소스를 설정하고 쿼리를 테스트해주세요.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||||
<Label className="mb-3 block text-xs font-semibold">지도 설정</Label>
|
<Label className="mb-3 block text-xs font-semibold">지도 설정</Label>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -60,16 +58,24 @@ export function MapConfigSection({
|
||||||
<SelectValue placeholder="새로고침 간격 선택" />
|
<SelectValue placeholder="새로고침 간격 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="0" className="text-xs">없음</SelectItem>
|
<SelectItem value="0" className="text-xs">
|
||||||
<SelectItem value="5" className="text-xs">5초</SelectItem>
|
없음
|
||||||
<SelectItem value="10" className="text-xs">10초</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="30" className="text-xs">30초</SelectItem>
|
<SelectItem value="5" className="text-xs">
|
||||||
<SelectItem value="60" className="text-xs">1분</SelectItem>
|
5초
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="10" className="text-xs">
|
||||||
|
10초
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="30" className="text-xs">
|
||||||
|
30초
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="60" className="text-xs">
|
||||||
|
1분
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-muted-foreground text-[10px]">마커 데이터를 자동으로 갱신하는 주기를 설정합니다</p>
|
||||||
마커 데이터를 자동으로 갱신하는 주기를 설정합니다
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 마커 종류 선택 */}
|
{/* 마커 종류 선택 */}
|
||||||
|
|
@ -77,24 +83,25 @@ export function MapConfigSection({
|
||||||
<Label htmlFor="marker-type" className="text-xs">
|
<Label htmlFor="marker-type" className="text-xs">
|
||||||
마커 종류
|
마커 종류
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select value={markerType} onValueChange={(value) => onMarkerTypeChange?.(value)}>
|
||||||
value={markerType}
|
|
||||||
onValueChange={(value) => onMarkerTypeChange?.(value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="marker-type" className="h-9 text-xs">
|
<SelectTrigger id="marker-type" className="h-9 text-xs">
|
||||||
<SelectValue placeholder="마커 종류 선택" />
|
<SelectValue placeholder="마커 종류 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="circle" className="text-xs">동그라미</SelectItem>
|
<SelectItem value="circle" className="text-xs">
|
||||||
<SelectItem value="arrow" className="text-xs">화살표</SelectItem>
|
동그라미
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="arrow" className="text-xs">
|
||||||
|
화살표
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="truck" className="text-xs">
|
||||||
|
트럭
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-muted-foreground text-[10px]">지도에 표시할 마커의 모양을 선택합니다</p>
|
||||||
지도에 표시할 마커의 모양을 선택합니다
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Plus, Check, Trash2 } from "lucide-react";
|
import { Plus, Check, Trash2 } from "lucide-react";
|
||||||
import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal";
|
import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal";
|
||||||
import YardEditor from "./yard-3d/YardEditor";
|
import DigitalTwinEditor from "./yard-3d/DigitalTwinEditor";
|
||||||
import Yard3DViewer from "./yard-3d/Yard3DViewer";
|
import DigitalTwinViewer from "./yard-3d/DigitalTwinViewer";
|
||||||
import { yardLayoutApi } from "@/lib/api/yardLayoutApi";
|
import { getLayouts, createLayout, deleteLayout } from "@/lib/api/digitalTwin";
|
||||||
import type { YardManagementConfig } from "../types";
|
import type { YardManagementConfig } from "../types";
|
||||||
|
|
||||||
interface YardLayout {
|
interface YardLayout {
|
||||||
|
|
@ -40,9 +40,16 @@ export default function YardManagement3DWidget({
|
||||||
const loadLayouts = async () => {
|
const loadLayouts = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await yardLayoutApi.getAllLayouts();
|
const response = await getLayouts();
|
||||||
if (response.success) {
|
if (response.success && response.data) {
|
||||||
setLayouts(response.data as YardLayout[]);
|
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) {
|
} catch (error) {
|
||||||
console.error("야드 레이아웃 목록 조회 실패:", error);
|
console.error("야드 레이아웃 목록 조회 실패:", error);
|
||||||
|
|
@ -81,11 +88,21 @@ export default function YardManagement3DWidget({
|
||||||
// 새 레이아웃 생성
|
// 새 레이아웃 생성
|
||||||
const handleCreateLayout = async (name: string) => {
|
const handleCreateLayout = async (name: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await yardLayoutApi.createLayout({ name });
|
const response = await createLayout({
|
||||||
if (response.success) {
|
layoutName: name,
|
||||||
|
description: "",
|
||||||
|
});
|
||||||
|
if (response.success && response.data) {
|
||||||
await loadLayouts();
|
await loadLayouts();
|
||||||
setIsCreateModalOpen(false);
|
setIsCreateModalOpen(false);
|
||||||
setEditingLayout(response.data as YardLayout);
|
setEditingLayout({
|
||||||
|
id: response.data.id,
|
||||||
|
name: response.data.layout_name,
|
||||||
|
description: response.data.description || "",
|
||||||
|
placement_count: 0,
|
||||||
|
created_at: response.data.created_at,
|
||||||
|
updated_at: response.data.updated_at,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("야드 레이아웃 생성 실패:", error);
|
console.error("야드 레이아웃 생성 실패:", error);
|
||||||
|
|
@ -110,7 +127,7 @@ export default function YardManagement3DWidget({
|
||||||
if (!deleteLayoutId) return;
|
if (!deleteLayoutId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await yardLayoutApi.deleteLayout(deleteLayoutId);
|
const response = await deleteLayout(deleteLayoutId);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
// 삭제된 레이아웃이 현재 선택된 레이아웃이면 설정 초기화
|
// 삭제된 레이아웃이 현재 선택된 레이아웃이면 설정 초기화
|
||||||
if (config?.layoutId === deleteLayoutId && onConfigChange) {
|
if (config?.layoutId === deleteLayoutId && onConfigChange) {
|
||||||
|
|
@ -125,11 +142,15 @@ export default function YardManagement3DWidget({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 편집 모드: 편집 중인 경우 YardEditor 표시
|
// 편집 모드: 편집 중인 경우 DigitalTwinEditor 표시
|
||||||
if (isEditMode && editingLayout) {
|
if (isEditMode && editingLayout) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<YardEditor layout={editingLayout} onBack={handleEditComplete} />
|
<DigitalTwinEditor
|
||||||
|
layoutId={editingLayout.id}
|
||||||
|
layoutName={editingLayout.name}
|
||||||
|
onBack={handleEditComplete}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -269,10 +290,10 @@ export default function YardManagement3DWidget({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 선택된 레이아웃의 3D 뷰어 표시
|
// 선택된 레이아웃의 디지털 트윈 뷰어 표시
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<Yard3DViewer layoutId={config.layoutId} />
|
<DigitalTwinViewer layoutId={config.layoutId} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,501 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { Loader2, Search, Filter, X } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
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";
|
||||||
|
|
||||||
|
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="bg-muted flex h-full items-center justify-center">
|
||||||
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface DigitalTwinViewerProps {
|
||||||
|
layoutId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]);
|
||||||
|
const [selectedObject, setSelectedObject] = useState<PlacedObject | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [materials, setMaterials] = useState<MaterialData[]>([]);
|
||||||
|
const [loadingMaterials, setLoadingMaterials] = useState(false);
|
||||||
|
const [showInfoPanel, setShowInfoPanel] = useState(false);
|
||||||
|
const [externalDbConnectionId, setExternalDbConnectionId] = useState<number | null>(null);
|
||||||
|
const [layoutName, setLayoutName] = useState<string>("");
|
||||||
|
|
||||||
|
// 검색 및 필터
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [filterType, setFilterType] = useState<string>("all");
|
||||||
|
|
||||||
|
// 레이아웃 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLayout = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await getLayoutById(layoutId);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const { layout, objects } = response.data;
|
||||||
|
|
||||||
|
// 레이아웃 정보 저장
|
||||||
|
setLayoutName(layout.layoutName);
|
||||||
|
setExternalDbConnectionId(layout.externalDbConnectionId);
|
||||||
|
|
||||||
|
// 객체 데이터 변환
|
||||||
|
const loadedObjects: PlacedObject[] = objects.map((obj: any) => ({
|
||||||
|
id: obj.id,
|
||||||
|
type: obj.object_type,
|
||||||
|
name: obj.object_name,
|
||||||
|
position: {
|
||||||
|
x: parseFloat(obj.position_x),
|
||||||
|
y: parseFloat(obj.position_y),
|
||||||
|
z: parseFloat(obj.position_z),
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
x: parseFloat(obj.size_x),
|
||||||
|
y: parseFloat(obj.size_y),
|
||||||
|
z: parseFloat(obj.size_z),
|
||||||
|
},
|
||||||
|
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
|
||||||
|
color: obj.color,
|
||||||
|
areaKey: obj.area_key,
|
||||||
|
locaKey: obj.loca_key,
|
||||||
|
locType: obj.loc_type,
|
||||||
|
materialCount: obj.material_count,
|
||||||
|
materialPreview: obj.material_preview_height
|
||||||
|
? { height: parseFloat(obj.material_preview_height) }
|
||||||
|
: undefined,
|
||||||
|
parentId: obj.parent_id,
|
||||||
|
displayOrder: obj.display_order,
|
||||||
|
locked: obj.locked,
|
||||||
|
visible: obj.visible !== false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setPlacedObjects(loadedObjects);
|
||||||
|
} else {
|
||||||
|
throw new Error(response.error || "레이아웃 조회 실패");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 로드 실패:", error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다.";
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "오류",
|
||||||
|
description: errorMessage,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadLayout();
|
||||||
|
}, [layoutId, toast]);
|
||||||
|
|
||||||
|
// Location의 자재 목록 로드
|
||||||
|
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
|
||||||
|
try {
|
||||||
|
setLoadingMaterials(true);
|
||||||
|
setShowInfoPanel(true);
|
||||||
|
const response = await getMaterials(externalDbConnectionId, locaKey);
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const sortedMaterials = response.data.sort((a, b) => a.LOLAYER - b.LOLAYER);
|
||||||
|
setMaterials(sortedMaterials);
|
||||||
|
} else {
|
||||||
|
setMaterials([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("자재 로드 실패:", error);
|
||||||
|
setMaterials([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingMaterials(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 객체 클릭
|
||||||
|
const handleObjectClick = (objectId: number | null) => {
|
||||||
|
if (objectId === null) {
|
||||||
|
setSelectedObject(null);
|
||||||
|
setShowInfoPanel(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = placedObjects.find((o) => o.id === objectId);
|
||||||
|
setSelectedObject(obj || null);
|
||||||
|
|
||||||
|
// Location을 클릭한 경우, 자재 정보 표시
|
||||||
|
if (
|
||||||
|
obj &&
|
||||||
|
(obj.type === "location-bed" ||
|
||||||
|
obj.type === "location-stp" ||
|
||||||
|
obj.type === "location-temp" ||
|
||||||
|
obj.type === "location-dest") &&
|
||||||
|
obj.locaKey &&
|
||||||
|
externalDbConnectionId
|
||||||
|
) {
|
||||||
|
setShowInfoPanel(true);
|
||||||
|
loadMaterialsForLocation(obj.locaKey, externalDbConnectionId);
|
||||||
|
} else {
|
||||||
|
setShowInfoPanel(true);
|
||||||
|
setMaterials([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 타입별 개수 계산 (useMemo로 최적화)
|
||||||
|
const typeCounts = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {
|
||||||
|
all: placedObjects.length,
|
||||||
|
area: 0,
|
||||||
|
"location-bed": 0,
|
||||||
|
"location-stp": 0,
|
||||||
|
"location-temp": 0,
|
||||||
|
"location-dest": 0,
|
||||||
|
"crane-mobile": 0,
|
||||||
|
rack: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
placedObjects.forEach((obj) => {
|
||||||
|
if (counts[obj.type] !== undefined) {
|
||||||
|
counts[obj.type]++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}, [placedObjects]);
|
||||||
|
|
||||||
|
// 필터링된 객체 목록 (useMemo로 최적화)
|
||||||
|
const filteredObjects = useMemo(() => {
|
||||||
|
return placedObjects.filter((obj) => {
|
||||||
|
// 타입 필터
|
||||||
|
if (filterType !== "all" && obj.type !== filterType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색 쿼리
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
obj.name.toLowerCase().includes(query) ||
|
||||||
|
obj.areaKey?.toLowerCase().includes(query) ||
|
||||||
|
obj.locaKey?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [placedObjects, filterType, searchQuery]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex h-full flex-col">
|
||||||
|
{/* 상단 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b p-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">읽기 전용 뷰</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 영역 */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* 좌측: 검색/필터 */}
|
||||||
|
<div className="flex h-full w-[20%] flex-col border-r">
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div>
|
||||||
|
<Label className="mb-2 block text-sm font-semibold">검색</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="이름, Area, Location 검색..."
|
||||||
|
className="h-10 pl-9 text-sm"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 p-0"
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타입 필터 */}
|
||||||
|
<div>
|
||||||
|
<Label className="mb-2 block text-sm font-semibold">타입 필터</Label>
|
||||||
|
<Select value={filterType} onValueChange={setFilterType}>
|
||||||
|
<SelectTrigger className="h-10 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체 ({typeCounts.all})</SelectItem>
|
||||||
|
<SelectItem value="area">Area ({typeCounts.area})</SelectItem>
|
||||||
|
<SelectItem value="location-bed">베드(BED) ({typeCounts["location-bed"]})</SelectItem>
|
||||||
|
<SelectItem value="location-stp">정차포인트(STP) ({typeCounts["location-stp"]})</SelectItem>
|
||||||
|
<SelectItem value="location-temp">임시베드(TMP) ({typeCounts["location-temp"]})</SelectItem>
|
||||||
|
<SelectItem value="location-dest">지정착지(DES) ({typeCounts["location-dest"]})</SelectItem>
|
||||||
|
<SelectItem value="crane-mobile">크레인 ({typeCounts["crane-mobile"]})</SelectItem>
|
||||||
|
<SelectItem value="rack">랙 ({typeCounts.rack})</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 초기화 */}
|
||||||
|
{(searchQuery || filterType !== "all") && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 w-full text-sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
setFilterType("all");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
필터 초기화
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 객체 목록 */}
|
||||||
|
<div className="flex-1 overflow-y-auto border-t p-4">
|
||||||
|
<Label className="mb-2 block text-sm font-semibold">
|
||||||
|
객체 목록 ({filteredObjects.length})
|
||||||
|
</Label>
|
||||||
|
{filteredObjects.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
|
||||||
|
{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 = "랙";
|
||||||
|
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 중앙: 3D 캔버스 */}
|
||||||
|
<div className="relative flex-1">
|
||||||
|
{!isLoading && (
|
||||||
|
<Yard3DCanvas
|
||||||
|
placements={useMemo(
|
||||||
|
() =>
|
||||||
|
placedObjects.map((obj) => ({
|
||||||
|
id: obj.id,
|
||||||
|
name: obj.name,
|
||||||
|
position_x: obj.position.x,
|
||||||
|
position_y: obj.position.y,
|
||||||
|
position_z: obj.position.z,
|
||||||
|
size_x: obj.size.x,
|
||||||
|
size_y: obj.size.y,
|
||||||
|
size_z: obj.size.z,
|
||||||
|
color: obj.color,
|
||||||
|
data_source_type: obj.type,
|
||||||
|
material_count: obj.materialCount,
|
||||||
|
material_preview_height: obj.materialPreview?.height,
|
||||||
|
yard_layout_id: undefined,
|
||||||
|
material_code: null,
|
||||||
|
material_name: null,
|
||||||
|
quantity: null,
|
||||||
|
unit: null,
|
||||||
|
data_source_config: undefined,
|
||||||
|
data_binding: undefined,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})),
|
||||||
|
[placedObjects],
|
||||||
|
)}
|
||||||
|
selectedPlacementId={selectedObject?.id || null}
|
||||||
|
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
|
||||||
|
focusOnPlacementId={null}
|
||||||
|
onCollisionDetected={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 정보 패널 */}
|
||||||
|
{showInfoPanel && selectedObject && (
|
||||||
|
<div className="h-full w-[25%] overflow-y-auto border-l">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">상세 정보</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">{selectedObject.name}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setShowInfoPanel(false)}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<div className="bg-muted space-y-3 rounded-lg p-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground text-xs">타입</Label>
|
||||||
|
<p className="text-sm font-medium">{selectedObject.type}</p>
|
||||||
|
</div>
|
||||||
|
{selectedObject.areaKey && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground text-xs">Area Key</Label>
|
||||||
|
<p className="text-sm font-medium">{selectedObject.areaKey}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedObject.locaKey && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground text-xs">Location Key</Label>
|
||||||
|
<p className="text-sm font-medium">{selectedObject.locaKey}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedObject.materialCount !== undefined && selectedObject.materialCount > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-muted-foreground text-xs">자재 개수</Label>
|
||||||
|
<p className="text-sm font-medium">{selectedObject.materialCount}개</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자재 목록 (Location인 경우) */}
|
||||||
|
{(selectedObject.type === "location-bed" ||
|
||||||
|
selectedObject.type === "location-stp" ||
|
||||||
|
selectedObject.type === "location-temp" ||
|
||||||
|
selectedObject.type === "location-dest") && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Label className="mb-2 block text-sm font-semibold">자재 목록</Label>
|
||||||
|
{loadingMaterials ? (
|
||||||
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : materials.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
|
||||||
|
{externalDbConnectionId
|
||||||
|
? "자재가 없습니다"
|
||||||
|
: "외부 DB 연결이 설정되지 않았습니다"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{materials.map((material, index) => (
|
||||||
|
<div
|
||||||
|
key={`${material.STKKEY}-${index}`}
|
||||||
|
className="bg-muted hover:bg-accent rounded-lg border p-3 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{material.STKKEY}</p>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
층: {material.LOLAYER} | Area: {material.AREAKEY}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground grid grid-cols-2 gap-2 text-xs">
|
||||||
|
{material.STKWIDT && (
|
||||||
|
<div>
|
||||||
|
폭: <span className="font-medium">{material.STKWIDT}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{material.STKLENG && (
|
||||||
|
<div>
|
||||||
|
길이: <span className="font-medium">{material.STKLENG}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{material.STKHEIG && (
|
||||||
|
<div>
|
||||||
|
높이: <span className="font-medium">{material.STKHEIG}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{material.STKWEIG && (
|
||||||
|
<div>
|
||||||
|
무게: <span className="font-medium">{material.STKWEIG}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{material.STKRMKS && (
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs italic">{material.STKRMKS}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,290 @@
|
||||||
|
// (이전 import 및 interface 동일, mobile-crane case만 교체)
|
||||||
|
// 실제로는 기존 파일에서 mobile-crane 케이스만 교체
|
||||||
|
|
||||||
|
// mobile-crane 케이스를 다음으로 교체:
|
||||||
|
|
||||||
|
case "mobile-crane":
|
||||||
|
// 이동식 크레인: 하부(트랙) + 회전대 + 캐빈 + 붐대 + 카운터웨이트 + 후크
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* ========== 하부 크롤러 트랙 시스템 ========== */}
|
||||||
|
{/* 좌측 트랙 메인 */}
|
||||||
|
<Box args={[boxWidth * 0.28, boxHeight * 0.18, boxDepth * 0.98]} position={[-boxWidth * 0.31, -boxHeight * 0.41, 0]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#1a1a1a"
|
||||||
|
roughness={0.9}
|
||||||
|
metalness={0.2}
|
||||||
|
emissive={isSelected ? "#1a1a1a" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.2 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 좌측 트랙 상부 롤러 */}
|
||||||
|
<Box args={[boxWidth * 0.32, boxHeight * 0.08, boxDepth * 0.12]} position={[-boxWidth * 0.31, -boxHeight * 0.28, -boxDepth * 0.42]}>
|
||||||
|
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.5} />
|
||||||
|
</Box>
|
||||||
|
<Box args={[boxWidth * 0.32, boxHeight * 0.08, boxDepth * 0.12]} position={[-boxWidth * 0.31, -boxHeight * 0.28, 0]}>
|
||||||
|
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.5} />
|
||||||
|
</Box>
|
||||||
|
<Box args={[boxWidth * 0.32, boxHeight * 0.08, boxDepth * 0.12]} position={[-boxWidth * 0.31, -boxHeight * 0.28, boxDepth * 0.42]}>
|
||||||
|
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.5} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 우측 트랙 메인 */}
|
||||||
|
<Box args={[boxWidth * 0.28, boxHeight * 0.18, boxDepth * 0.98]} position={[boxWidth * 0.31, -boxHeight * 0.41, 0]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#1a1a1a"
|
||||||
|
roughness={0.9}
|
||||||
|
metalness={0.2}
|
||||||
|
emissive={isSelected ? "#1a1a1a" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.2 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 우측 트랙 상부 롤러 */}
|
||||||
|
<Box args={[boxWidth * 0.32, boxHeight * 0.08, boxDepth * 0.12]} position={[boxWidth * 0.31, -boxHeight * 0.28, -boxDepth * 0.42]}>
|
||||||
|
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.5} />
|
||||||
|
</Box>
|
||||||
|
<Box args={[boxWidth * 0.32, boxHeight * 0.08, boxDepth * 0.12]} position={[boxWidth * 0.31, -boxHeight * 0.28, 0]}>
|
||||||
|
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.5} />
|
||||||
|
</Box>
|
||||||
|
<Box args={[boxWidth * 0.32, boxHeight * 0.08, boxDepth * 0.12]} position={[boxWidth * 0.31, -boxHeight * 0.28, boxDepth * 0.42]}>
|
||||||
|
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.5} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 트랙 연결 프레임 */}
|
||||||
|
<Box args={[boxWidth * 0.12, boxHeight * 0.06, boxDepth * 0.98]} position={[0, -boxHeight * 0.39, 0]}>
|
||||||
|
<meshStandardMaterial color="#374151" roughness={0.5} metalness={0.6} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ========== 회전 상부 구조 ========== */}
|
||||||
|
{/* 메인 회전 플랫폼 */}
|
||||||
|
<Box args={[boxWidth * 0.88, boxHeight * 0.15, boxDepth * 0.88]} position={[0, -boxHeight * 0.28, 0]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={placement.color}
|
||||||
|
roughness={0.25}
|
||||||
|
metalness={0.75}
|
||||||
|
emissive={isSelected ? placement.color : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.9 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 회전 베어링 하우징 */}
|
||||||
|
<Box args={[boxWidth * 0.45, boxHeight * 0.08, boxDepth * 0.45]} position={[0, -boxHeight * 0.35, 0]}>
|
||||||
|
<meshStandardMaterial color="#4b5563" roughness={0.4} metalness={0.8} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ========== 엔진 및 유압 시스템 ========== */}
|
||||||
|
{/* 엔진룸 메인 */}
|
||||||
|
<Box args={[boxWidth * 0.65, boxHeight * 0.32, boxDepth * 0.35]} position={[0, -boxHeight * 0.12, boxDepth * 0.24]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={placement.color}
|
||||||
|
roughness={0.35}
|
||||||
|
metalness={0.65}
|
||||||
|
emissive={isSelected ? placement.color : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 유압 펌프 하우징 */}
|
||||||
|
<Box args={[boxWidth * 0.25, boxHeight * 0.18, boxDepth * 0.25]} position={[-boxWidth * 0.25, -boxHeight * 0.15, boxDepth * 0.18]}>
|
||||||
|
<meshStandardMaterial color="#dc2626" roughness={0.4} metalness={0.7} />
|
||||||
|
</Box>
|
||||||
|
<Box args={[boxWidth * 0.25, boxHeight * 0.18, boxDepth * 0.25]} position={[boxWidth * 0.25, -boxHeight * 0.15, boxDepth * 0.18]}>
|
||||||
|
<meshStandardMaterial color="#dc2626" roughness={0.4} metalness={0.7} />
|
||||||
|
</Box>
|
||||||
|
{/* 배기 파이프 */}
|
||||||
|
<Box args={[boxWidth * 0.08, boxHeight * 0.35, boxDepth * 0.08]} position={[boxWidth * 0.2, -boxHeight * 0.02, boxDepth * 0.35]}>
|
||||||
|
<meshStandardMaterial color="#1f2937" roughness={0.3} metalness={0.8} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ========== 운전실 (캐빈) ========== */}
|
||||||
|
{/* 캐빈 메인 바디 */}
|
||||||
|
<Box args={[boxWidth * 0.38, boxHeight * 0.35, boxDepth * 0.38]} position={[0, -boxHeight * 0.08, -boxDepth * 0.18]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#2d3748"
|
||||||
|
roughness={0.15}
|
||||||
|
metalness={0.85}
|
||||||
|
emissive={isSelected ? "#3b82f6" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.6 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 캐빈 창문 */}
|
||||||
|
<Box args={[boxWidth * 0.35, boxHeight * 0.25, boxDepth * 0.02]} position={[0, -boxHeight * 0.05, -boxDepth * 0.37]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#60a5fa"
|
||||||
|
roughness={0.05}
|
||||||
|
metalness={0.95}
|
||||||
|
transparent
|
||||||
|
opacity={0.6}
|
||||||
|
emissive={isSelected ? "#60a5fa" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 캐빈 지붕 */}
|
||||||
|
<Box args={[boxWidth * 0.4, boxHeight * 0.05, boxDepth * 0.4]} position={[0, boxHeight * 0.07, -boxDepth * 0.18]}>
|
||||||
|
<meshStandardMaterial color="#374151" roughness={0.3} metalness={0.7} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ========== 붐대 시스템 ========== */}
|
||||||
|
{/* 붐대 마운트 베이스 */}
|
||||||
|
<Box args={[boxWidth * 0.25, boxHeight * 0.25, boxDepth * 0.25]} position={[0, -boxHeight * 0.02, 0]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#4b5563"
|
||||||
|
roughness={0.25}
|
||||||
|
metalness={0.85}
|
||||||
|
emissive={isSelected ? "#4b5563" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.5 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 붐대 힌지 실린더 (유압) */}
|
||||||
|
<Box
|
||||||
|
args={[boxWidth * 0.12, boxHeight * 0.28, boxDepth * 0.12]}
|
||||||
|
position={[0, boxHeight * 0.02, boxDepth * 0.08]}
|
||||||
|
rotation={[Math.PI / 3, 0, 0]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial color="#dc2626" roughness={0.3} metalness={0.8} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 메인 붐대 하단 섹션 */}
|
||||||
|
<Box
|
||||||
|
args={[boxWidth * 0.18, boxHeight * 0.55, boxDepth * 0.18]}
|
||||||
|
position={[0, boxHeight * 0.12, -boxDepth * 0.13]}
|
||||||
|
rotation={[Math.PI / 4.2, 0, 0]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#f59e0b"
|
||||||
|
roughness={0.25}
|
||||||
|
metalness={0.75}
|
||||||
|
emissive={isSelected ? "#f59e0b" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.75 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 붐대 상단 섹션 (텔레스코픽) */}
|
||||||
|
<Box
|
||||||
|
args={[boxWidth * 0.14, boxHeight * 0.45, boxDepth * 0.14]}
|
||||||
|
position={[0, boxHeight * 0.32, -boxDepth * 0.32]}
|
||||||
|
rotation={[Math.PI / 4.8, 0, 0]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#fbbf24"
|
||||||
|
roughness={0.25}
|
||||||
|
metalness={0.75}
|
||||||
|
emissive={isSelected ? "#fbbf24" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.75 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 붐대 최상단 섹션 */}
|
||||||
|
<Box
|
||||||
|
args={[boxWidth * 0.1, boxHeight * 0.32, boxDepth * 0.1]}
|
||||||
|
position={[0, boxHeight * 0.45, -boxDepth * 0.44]}
|
||||||
|
rotation={[Math.PI / 5.5, 0, 0]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#fbbf24"
|
||||||
|
roughness={0.25}
|
||||||
|
metalness={0.75}
|
||||||
|
emissive={isSelected ? "#fbbf24" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.75 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 붐대 트러스 구조 (디테일) */}
|
||||||
|
{[-0.15, -0.05, 0.05, 0.15].map((offset, idx) => (
|
||||||
|
<Box
|
||||||
|
key={`truss-${idx}`}
|
||||||
|
args={[boxWidth * 0.02, boxHeight * 0.5, boxDepth * 0.02]}
|
||||||
|
position={[offset, boxHeight * 0.12, -boxDepth * 0.13]}
|
||||||
|
rotation={[Math.PI / 4.2, 0, 0]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial color="#1f2937" roughness={0.4} metalness={0.7} />
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* ========== 카운터웨이트 시스템 ========== */}
|
||||||
|
{/* 카운터웨이트 메인 블록 */}
|
||||||
|
<Box args={[boxWidth * 0.55, boxHeight * 0.25, boxDepth * 0.28]} position={[0, -boxHeight * 0.03, boxDepth * 0.32]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#52525b"
|
||||||
|
roughness={0.7}
|
||||||
|
metalness={0.4}
|
||||||
|
emissive={isSelected ? "#52525b" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 카운터웨이트 추가 블록 (상단) */}
|
||||||
|
<Box args={[boxWidth * 0.48, boxHeight * 0.18, boxDepth * 0.22]} position={[0, boxHeight * 0.08, boxDepth * 0.32]}>
|
||||||
|
<meshStandardMaterial color="#3f3f46" roughness={0.7} metalness={0.4} />
|
||||||
|
</Box>
|
||||||
|
{/* 카운터웨이트 프레임 */}
|
||||||
|
<Box args={[boxWidth * 0.58, boxHeight * 0.08, boxDepth * 0.32]} position={[0, -boxHeight * 0.16, boxDepth * 0.32]}>
|
||||||
|
<meshStandardMaterial color="#1f2937" roughness={0.5} metalness={0.7} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ========== 후크 및 케이블 시스템 ========== */}
|
||||||
|
{/* 붐대 끝단 풀리 */}
|
||||||
|
<Box args={[boxWidth * 0.08, boxHeight * 0.08, boxDepth * 0.08]} position={[0, boxHeight * 0.52, -boxDepth * 0.52]}>
|
||||||
|
<meshStandardMaterial color="#1f2937" roughness={0.3} metalness={0.8} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 메인 호이스트 케이블 */}
|
||||||
|
<Box args={[boxWidth * 0.025, boxHeight * 0.42, boxDepth * 0.025]} position={[0, boxHeight * 0.28, -boxDepth * 0.52]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#0f172a"
|
||||||
|
roughness={0.3}
|
||||||
|
metalness={0.7}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 후크 블록 상단 */}
|
||||||
|
<Box args={[boxWidth * 0.12, boxHeight * 0.08, boxDepth * 0.12]} position={[0, boxHeight * 0.04, -boxDepth * 0.52]}>
|
||||||
|
<meshStandardMaterial color="#1f2937" roughness={0.3} metalness={0.8} />
|
||||||
|
</Box>
|
||||||
|
{/* 후크 메인 (빨간색 안전색) */}
|
||||||
|
<Box args={[boxWidth * 0.1, boxHeight * 0.12, boxDepth * 0.1]} position={[0, -boxHeight * 0.02, -boxDepth * 0.52]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#dc2626"
|
||||||
|
roughness={0.25}
|
||||||
|
metalness={0.85}
|
||||||
|
emissive={isSelected ? "#dc2626" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.9 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 지브 지지 케이블 (좌측) */}
|
||||||
|
<Box
|
||||||
|
args={[boxWidth * 0.018, boxHeight * 0.38, boxDepth * 0.018]}
|
||||||
|
position={[-boxWidth * 0.06, boxHeight * 0.28, -boxDepth * 0.08]}
|
||||||
|
rotation={[Math.PI / 5.5, 0, 0]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial color="#0f172a" roughness={0.2} metalness={0.8} />
|
||||||
|
</Box>
|
||||||
|
{/* 지브 지지 케이블 (우측) */}
|
||||||
|
<Box
|
||||||
|
args={[boxWidth * 0.018, boxHeight * 0.38, boxDepth * 0.018]}
|
||||||
|
position={[boxWidth * 0.06, boxHeight * 0.28, -boxDepth * 0.08]}
|
||||||
|
rotation={[Math.PI / 5.5, 0, 0]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial color="#0f172a" roughness={0.2} metalness={0.8} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ========== 조명 및 안전 장치 ========== */}
|
||||||
|
{/* 작업등 (전방) */}
|
||||||
|
<Box args={[boxWidth * 0.06, boxHeight * 0.04, boxDepth * 0.04]} position={[0, boxHeight * 0.09, -boxDepth * 0.4]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#fef08a"
|
||||||
|
roughness={0.1}
|
||||||
|
metalness={0.9}
|
||||||
|
emissive="#fef08a"
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 1.2 : 0.3}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 경고등 (붐대 상단) */}
|
||||||
|
<Box args={[boxWidth * 0.04, boxHeight * 0.04, boxDepth * 0.04]} position={[0, boxHeight * 0.54, -boxDepth * 0.5]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#ef4444"
|
||||||
|
roughness={0.1}
|
||||||
|
metalness={0.9}
|
||||||
|
emissive="#ef4444"
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 1.5 : 0.4}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
@ -8,7 +8,7 @@ import dynamic from "next/dynamic";
|
||||||
import { YardLayout, YardPlacement } from "./types";
|
import { YardLayout, YardPlacement } from "./types";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { AlertCircle, CheckCircle, XCircle } from "lucide-react";
|
import { AlertCircle, CheckCircle, XCircle } from "lucide-react";
|
||||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, ResizableDialogDescription } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
|
||||||
|
|
@ -70,23 +70,22 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
const [value, setValue] = useState<number>(0);
|
const [value, setValue] = useState<number>(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
|
||||||
|
|
||||||
const config = element?.customMetricConfig;
|
const config = element?.customMetricConfig;
|
||||||
|
|
||||||
console.log("📊 [CustomMetricTestWidget] 렌더링:", {
|
|
||||||
element,
|
|
||||||
config,
|
|
||||||
dataSource: element?.dataSource,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
|
|
||||||
// 자동 새로고침 (30초마다)
|
// 자동 새로고침 (설정된 간격마다, 0이면 비활성)
|
||||||
const interval = setInterval(loadData, 30000);
|
const refreshInterval = config?.refreshInterval ?? 30; // 기본값: 30초
|
||||||
return () => clearInterval(interval);
|
|
||||||
|
if (refreshInterval > 0) {
|
||||||
|
const interval = setInterval(loadData, refreshInterval * 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [element]);
|
}, [element, config?.refreshInterval]);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -132,6 +131,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
if (config?.valueColumn && config?.aggregation) {
|
if (config?.valueColumn && config?.aggregation) {
|
||||||
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
|
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
|
||||||
setValue(calculatedValue);
|
setValue(calculatedValue);
|
||||||
|
setLastUpdateTime(new Date()); // 업데이트 시간 기록
|
||||||
} else {
|
} else {
|
||||||
setValue(0);
|
setValue(0);
|
||||||
}
|
}
|
||||||
|
|
@ -192,6 +192,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
if (config?.valueColumn && config?.aggregation) {
|
if (config?.valueColumn && config?.aggregation) {
|
||||||
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
|
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
|
||||||
setValue(calculatedValue);
|
setValue(calculatedValue);
|
||||||
|
setLastUpdateTime(new Date()); // 업데이트 시간 기록
|
||||||
} else {
|
} else {
|
||||||
setValue(0);
|
setValue(0);
|
||||||
}
|
}
|
||||||
|
|
@ -200,7 +201,6 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("데이터 로드 실패:", err);
|
|
||||||
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -283,6 +283,16 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
||||||
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
|
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
|
||||||
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
|
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 마지막 업데이트 시간 */}
|
||||||
|
{lastUpdateTime && (
|
||||||
|
<div className="text-muted-foreground mt-3 text-[10px]">
|
||||||
|
{lastUpdateTime.toLocaleTimeString("ko-KR")}
|
||||||
|
{config?.refreshInterval && config.refreshInterval > 0 && (
|
||||||
|
<span className="ml-1">• {config.refreshInterval}초마다 갱신</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,17 +70,22 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
const [value, setValue] = useState<number>(0);
|
const [value, setValue] = useState<number>(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
|
||||||
|
|
||||||
const config = element?.customMetricConfig;
|
const config = element?.customMetricConfig;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
|
|
||||||
// 자동 새로고침 (30초마다)
|
// 자동 새로고침 (설정된 간격마다, 0이면 비활성)
|
||||||
const interval = setInterval(loadData, 30000);
|
const refreshInterval = config?.refreshInterval ?? 30; // 기본값: 30초
|
||||||
return () => clearInterval(interval);
|
|
||||||
|
if (refreshInterval > 0) {
|
||||||
|
const interval = setInterval(loadData, refreshInterval * 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [element]);
|
}, [element, config?.refreshInterval]);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -198,15 +203,16 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setLastUpdateTime(new Date());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center bg-background">
|
<div className="bg-background flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
<p className="mt-2 text-sm text-muted-foreground">데이터 로딩 중...</p>
|
<p className="text-muted-foreground mt-2 text-sm">데이터 로딩 중...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -214,12 +220,12 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center bg-background p-4">
|
<div className="bg-background flex h-full items-center justify-center p-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm text-destructive">⚠️ {error}</p>
|
<p className="text-destructive text-sm">⚠️ {error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={loadData}
|
onClick={loadData}
|
||||||
className="mt-2 rounded bg-destructive/10 px-3 py-1 text-xs text-destructive hover:bg-destructive/20"
|
className="bg-destructive/10 text-destructive hover:bg-destructive/20 mt-2 rounded px-3 py-1 text-xs"
|
||||||
>
|
>
|
||||||
다시 시도
|
다시 시도
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -238,10 +244,10 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
// 설정이 없으면 안내 화면
|
// 설정이 없으면 안내 화면
|
||||||
if (!hasDataSource || !hasConfig) {
|
if (!hasDataSource || !hasConfig) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center bg-background p-4">
|
<div className="bg-background flex h-full items-center justify-center p-4">
|
||||||
<div className="max-w-xs space-y-2 text-center">
|
<div className="max-w-xs space-y-2 text-center">
|
||||||
<h3 className="text-sm font-bold text-foreground">통계 카드</h3>
|
<h3 className="text-foreground text-sm font-bold">통계 카드</h3>
|
||||||
<div className="space-y-1.5 text-xs text-foreground">
|
<div className="text-foreground space-y-1.5 text-xs">
|
||||||
<p className="font-medium">📊 단일 통계 위젯</p>
|
<p className="font-medium">📊 단일 통계 위젯</p>
|
||||||
<ul className="space-y-0.5 text-left">
|
<ul className="space-y-0.5 text-left">
|
||||||
<li>• 데이터 소스에서 쿼리를 실행합니다</li>
|
<li>• 데이터 소스에서 쿼리를 실행합니다</li>
|
||||||
|
|
@ -250,7 +256,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
<li>• COUNT, SUM, AVG, MIN, MAX 지원</li>
|
<li>• COUNT, SUM, AVG, MIN, MAX 지원</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 rounded-lg bg-primary/10 p-2 text-[10px] text-primary">
|
<div className="bg-primary/10 text-primary mt-2 rounded-lg p-2 text-[10px]">
|
||||||
<p className="font-medium">⚙️ 설정 방법</p>
|
<p className="font-medium">⚙️ 설정 방법</p>
|
||||||
<p>1. 데이터 탭에서 쿼리 실행</p>
|
<p>1. 데이터 탭에서 쿼리 실행</p>
|
||||||
<p>2. 필터 조건 추가 (선택사항)</p>
|
<p>2. 필터 조건 추가 (선택사항)</p>
|
||||||
|
|
@ -268,7 +274,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
|
|
||||||
// 통계 카드 렌더링
|
// 통계 카드 렌더링
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center bg-card p-6 text-center">
|
<div className="bg-card flex h-full w-full flex-col items-center justify-center p-6 text-center">
|
||||||
{/* 제목 */}
|
{/* 제목 */}
|
||||||
<div className="text-muted-foreground mb-2 text-sm font-medium">{config?.title || "통계"}</div>
|
<div className="text-muted-foreground mb-2 text-sm font-medium">{config?.title || "통계"}</div>
|
||||||
|
|
||||||
|
|
@ -277,6 +283,16 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||||
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
|
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
|
||||||
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
|
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 마지막 업데이트 시간 */}
|
||||||
|
{lastUpdateTime && (
|
||||||
|
<div className="text-muted-foreground mt-3 text-[10px]">
|
||||||
|
{lastUpdateTime.toLocaleTimeString("ko-KR")}
|
||||||
|
{config?.refreshInterval && config.refreshInterval > 0 && (
|
||||||
|
<span className="ml-1">• {config.refreshInterval}초마다 갱신</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState, useCallback, useMemo } from "react";
|
import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -10,6 +10,20 @@ import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
|
// Popup 말풍선 꼬리 제거 스타일
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.textContent = `
|
||||||
|
.leaflet-popup-tip-container {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
import("leaflet").then((L) => {
|
import("leaflet").then((L) => {
|
||||||
|
|
@ -66,7 +80,7 @@ interface PolygonData {
|
||||||
|
|
||||||
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
||||||
const [prevMarkers, setPrevMarkers] = useState<MarkerData[]>([]); // 이전 마커 위치 저장
|
const prevMarkersRef = useRef<MarkerData[]>([]); // 이전 마커 위치 저장 (useRef 사용)
|
||||||
const [polygons, setPolygons] = useState<PolygonData[]>([]);
|
const [polygons, setPolygons] = useState<PolygonData[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -96,11 +110,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
// 다중 데이터 소스 로딩
|
// 다중 데이터 소스 로딩
|
||||||
const loadMultipleDataSources = useCallback(async () => {
|
const loadMultipleDataSources = useCallback(async () => {
|
||||||
if (!dataSources || dataSources.length === 0) {
|
if (!dataSources || dataSources.length === 0) {
|
||||||
// // console.log("⚠️ 데이터 소스가 없습니다.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// // console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
|
@ -109,8 +120,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
dataSources.map(async (source) => {
|
dataSources.map(async (source) => {
|
||||||
try {
|
try {
|
||||||
// // console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`);
|
|
||||||
|
|
||||||
if (source.type === "api") {
|
if (source.type === "api") {
|
||||||
return await loadRestApiData(source);
|
return await loadRestApiData(source);
|
||||||
} else if (source.type === "database") {
|
} else if (source.type === "database") {
|
||||||
|
|
@ -119,7 +128,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
|
|
||||||
return { markers: [], polygons: [] };
|
return { markers: [], polygons: [] };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err);
|
|
||||||
return { markers: [], polygons: [] };
|
return { markers: [], polygons: [] };
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
@ -130,35 +138,24 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
const allPolygons: PolygonData[] = [];
|
const allPolygons: PolygonData[] = [];
|
||||||
|
|
||||||
results.forEach((result, index) => {
|
results.forEach((result, index) => {
|
||||||
// // console.log(`🔍 결과 ${index}:`, result);
|
|
||||||
|
|
||||||
if (result.status === "fulfilled" && result.value) {
|
if (result.status === "fulfilled" && result.value) {
|
||||||
const value = result.value as { markers: MarkerData[]; polygons: PolygonData[] };
|
const value = result.value as { markers: MarkerData[]; polygons: PolygonData[] };
|
||||||
// // console.log(`✅ 데이터 소스 ${index} 성공:`, value);
|
|
||||||
|
|
||||||
// 마커 병합
|
// 마커 병합
|
||||||
if (value.markers && Array.isArray(value.markers)) {
|
if (value.markers && Array.isArray(value.markers)) {
|
||||||
// // console.log(` → 마커 ${value.markers.length}개 추가`);
|
|
||||||
allMarkers.push(...value.markers);
|
allMarkers.push(...value.markers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 폴리곤 병합
|
// 폴리곤 병합
|
||||||
if (value.polygons && Array.isArray(value.polygons)) {
|
if (value.polygons && Array.isArray(value.polygons)) {
|
||||||
// // console.log(` → 폴리곤 ${value.polygons.length}개 추가`);
|
|
||||||
allPolygons.push(...value.polygons);
|
allPolygons.push(...value.polygons);
|
||||||
}
|
}
|
||||||
} else if (result.status === "rejected") {
|
|
||||||
console.error(`❌ 데이터 소스 ${index} 실패:`, result.reason);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// // console.log(`✅ 총 ${allMarkers.length}개의 마커, ${allPolygons.length}개의 폴리곤 로딩 완료`);
|
|
||||||
// // console.log("📍 최종 마커 데이터:", allMarkers);
|
|
||||||
// // console.log("🔷 최종 폴리곤 데이터:", allPolygons);
|
|
||||||
|
|
||||||
// 이전 마커 위치와 비교하여 진행 방향 계산
|
// 이전 마커 위치와 비교하여 진행 방향 계산
|
||||||
const markersWithHeading = allMarkers.map((marker) => {
|
const markersWithHeading = allMarkers.map((marker) => {
|
||||||
const prevMarker = prevMarkers.find((pm) => pm.id === marker.id);
|
const prevMarker = prevMarkersRef.current.find((pm) => pm.id === marker.id);
|
||||||
|
|
||||||
if (prevMarker && (prevMarker.lat !== marker.lat || prevMarker.lng !== marker.lng)) {
|
if (prevMarker && (prevMarker.lat !== marker.lat || prevMarker.lng !== marker.lng)) {
|
||||||
// 이동했으면 방향 계산
|
// 이동했으면 방향 계산
|
||||||
|
|
@ -178,21 +175,19 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
setPrevMarkers(markersWithHeading); // 다음 비교를 위해 현재 위치 저장
|
prevMarkersRef.current = markersWithHeading; // 다음 비교를 위해 현재 위치 저장 (useRef 사용)
|
||||||
setMarkers(markersWithHeading);
|
setMarkers(markersWithHeading);
|
||||||
setPolygons(allPolygons);
|
setPolygons(allPolygons);
|
||||||
setLastRefreshTime(new Date());
|
setLastRefreshTime(new Date());
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("❌ 데이터 로딩 중 오류:", err);
|
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [dataSources, prevMarkers, calculateHeading]);
|
}, [dataSources, calculateHeading]); // prevMarkersRef는 의존성에 포함하지 않음 (useRef이므로)
|
||||||
|
|
||||||
// 수동 새로고침 핸들러
|
// 수동 새로고침 핸들러
|
||||||
const handleManualRefresh = useCallback(() => {
|
const handleManualRefresh = useCallback(() => {
|
||||||
// // console.log("🔄 수동 새로고침 버튼 클릭");
|
|
||||||
loadMultipleDataSources();
|
loadMultipleDataSources();
|
||||||
}, [loadMultipleDataSources]);
|
}, [loadMultipleDataSources]);
|
||||||
|
|
||||||
|
|
@ -200,8 +195,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
const loadRestApiData = async (
|
const loadRestApiData = async (
|
||||||
source: ChartDataSource,
|
source: ChartDataSource,
|
||||||
): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => {
|
): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => {
|
||||||
// // console.log(`🌐 REST API 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType);
|
|
||||||
|
|
||||||
if (!source.endpoint) {
|
if (!source.endpoint) {
|
||||||
throw new Error("API endpoint가 없습니다.");
|
throw new Error("API endpoint가 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
@ -256,13 +249,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
|
|
||||||
// 텍스트 형식 데이터 체크 (기상청 API 등)
|
// 텍스트 형식 데이터 체크 (기상청 API 등)
|
||||||
if (data && typeof data === "object" && data.text && typeof data.text === "string") {
|
if (data && typeof data === "object" && data.text && typeof data.text === "string") {
|
||||||
// // console.log("📄 텍스트 형식 데이터 감지, CSV 파싱 시도");
|
|
||||||
const parsedData = parseTextData(data.text);
|
const parsedData = parseTextData(data.text);
|
||||||
if (parsedData.length > 0) {
|
if (parsedData.length > 0) {
|
||||||
// // console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`);
|
|
||||||
// 컬럼 매핑 적용
|
// 컬럼 매핑 적용
|
||||||
const mappedData = applyColumnMapping(parsedData, source.columnMapping);
|
const mappedData = applyColumnMapping(parsedData, source.columnMapping);
|
||||||
return convertToMapData(mappedData, source.name || source.id || "API", source.mapDisplayType, source);
|
const result = convertToMapData(mappedData, source.name || source.id || "API", source.mapDisplayType, source);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,15 +272,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
const mappedRows = applyColumnMapping(rows, source.columnMapping);
|
const mappedRows = applyColumnMapping(rows, source.columnMapping);
|
||||||
|
|
||||||
// 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달)
|
// 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달)
|
||||||
return convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source);
|
const finalResult = convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source);
|
||||||
|
return finalResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Database 데이터 로딩
|
// Database 데이터 로딩
|
||||||
const loadDatabaseData = async (
|
const loadDatabaseData = async (
|
||||||
source: ChartDataSource,
|
source: ChartDataSource,
|
||||||
): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => {
|
): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => {
|
||||||
// // console.log(`💾 Database 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType);
|
|
||||||
|
|
||||||
if (!source.query) {
|
if (!source.query) {
|
||||||
throw new Error("SQL 쿼리가 없습니다.");
|
throw new Error("SQL 쿼리가 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
@ -330,7 +321,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
// XML 데이터 파싱 (UTIC API 등)
|
// XML 데이터 파싱 (UTIC API 등)
|
||||||
const parseXmlData = (xmlText: string): any[] => {
|
const parseXmlData = (xmlText: string): any[] => {
|
||||||
try {
|
try {
|
||||||
// // console.log(" 📄 XML 파싱 시작");
|
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
|
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
|
||||||
|
|
||||||
|
|
@ -349,10 +339,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
results.push(obj);
|
results.push(obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
// // console.log(` ✅ XML 파싱 완료: ${results.length}개 레코드`);
|
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(" ❌ XML 파싱 실패:", error);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -360,11 +348,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
// 텍스트 데이터 파싱 (CSV, 기상청 형식 등)
|
// 텍스트 데이터 파싱 (CSV, 기상청 형식 등)
|
||||||
const parseTextData = (text: string): any[] => {
|
const parseTextData = (text: string): any[] => {
|
||||||
try {
|
try {
|
||||||
// // console.log(" 🔍 원본 텍스트 (처음 500자):", text.substring(0, 500));
|
|
||||||
|
|
||||||
// XML 형식 감지
|
// XML 형식 감지
|
||||||
if (text.trim().startsWith("<?xml") || text.trim().startsWith("<result>")) {
|
if (text.trim().startsWith("<?xml") || text.trim().startsWith("<result>")) {
|
||||||
// // console.log(" 📄 XML 형식 데이터 감지");
|
|
||||||
return parseXmlData(text);
|
return parseXmlData(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -373,8 +358,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
return trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("=") && !trimmed.startsWith("---");
|
return trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("=") && !trimmed.startsWith("---");
|
||||||
});
|
});
|
||||||
|
|
||||||
// // console.log(` 📝 유효한 라인: ${lines.length}개`);
|
|
||||||
|
|
||||||
if (lines.length === 0) return [];
|
if (lines.length === 0) return [];
|
||||||
|
|
||||||
// CSV 형식으로 파싱
|
// CSV 형식으로 파싱
|
||||||
|
|
@ -384,8 +367,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
const values = line.split(",").map((v) => v.trim().replace(/,=$/g, ""));
|
const values = line.split(",").map((v) => v.trim().replace(/,=$/g, ""));
|
||||||
|
|
||||||
// // console.log(` 라인 ${i}:`, values);
|
|
||||||
|
|
||||||
// 기상특보 형식: 지역코드, 지역명, 하위코드, 하위지역명, 발표시각, 특보종류, 등급, 발표상태, 설명
|
// 기상특보 형식: 지역코드, 지역명, 하위코드, 하위지역명, 발표시각, 특보종류, 등급, 발표상태, 설명
|
||||||
if (values.length >= 4) {
|
if (values.length >= 4) {
|
||||||
const obj: any = {
|
const obj: any = {
|
||||||
|
|
@ -404,14 +385,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
obj.name = obj.subRegion || obj.region || obj.code;
|
obj.name = obj.subRegion || obj.region || obj.code;
|
||||||
|
|
||||||
result.push(obj);
|
result.push(obj);
|
||||||
// console.log(` ✅ 파싱 성공:`, obj);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// // console.log(" 📊 최종 파싱 결과:", result.length, "개");
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(" ❌ 텍스트 파싱 오류:", error);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -423,23 +401,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
mapDisplayType?: "auto" | "marker" | "polygon",
|
mapDisplayType?: "auto" | "marker" | "polygon",
|
||||||
dataSource?: ChartDataSource,
|
dataSource?: ChartDataSource,
|
||||||
): { markers: MarkerData[]; polygons: PolygonData[] } => {
|
): { markers: MarkerData[]; polygons: PolygonData[] } => {
|
||||||
// // console.log(`🔄 ${sourceName} 데이터 변환 시작:`, rows.length, "개 행");
|
|
||||||
// // console.log(` 📌 mapDisplayType:`, mapDisplayType, `(타입: ${typeof mapDisplayType})`);
|
|
||||||
// // console.log(` 🎨 마커 색상:`, dataSource?.markerColor, `폴리곤 색상:`, dataSource?.polygonColor);
|
|
||||||
|
|
||||||
if (rows.length === 0) return { markers: [], polygons: [] };
|
if (rows.length === 0) return { markers: [], polygons: [] };
|
||||||
|
|
||||||
const markers: MarkerData[] = [];
|
const markers: MarkerData[] = [];
|
||||||
const polygons: PolygonData[] = [];
|
const polygons: PolygonData[] = [];
|
||||||
|
|
||||||
rows.forEach((row, index) => {
|
rows.forEach((row, index) => {
|
||||||
// // console.log(` 행 ${index}:`, row);
|
|
||||||
|
|
||||||
// 텍스트 데이터 체크 (기상청 API 등)
|
// 텍스트 데이터 체크 (기상청 API 등)
|
||||||
if (row && typeof row === "object" && row.text && typeof row.text === "string") {
|
if (row && typeof row === "object" && row.text && typeof row.text === "string") {
|
||||||
// // console.log(" 📄 텍스트 형식 데이터 감지, CSV 파싱 시도");
|
|
||||||
const parsedData = parseTextData(row.text);
|
const parsedData = parseTextData(row.text);
|
||||||
// // console.log(` ✅ CSV 파싱 결과: ${parsedData.length}개 행`);
|
|
||||||
|
|
||||||
// 파싱된 데이터를 재귀적으로 변환 (색상 정보 전달)
|
// 파싱된 데이터를 재귀적으로 변환 (색상 정보 전달)
|
||||||
const result = convertToMapData(parsedData, sourceName, mapDisplayType, dataSource);
|
const result = convertToMapData(parsedData, sourceName, mapDisplayType, dataSource);
|
||||||
|
|
@ -450,17 +420,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
|
|
||||||
// 폴리곤 데이터 체크 (coordinates 필드가 배열인 경우 또는 강제 polygon 모드)
|
// 폴리곤 데이터 체크 (coordinates 필드가 배열인 경우 또는 강제 polygon 모드)
|
||||||
if (row.coordinates && Array.isArray(row.coordinates) && row.coordinates.length > 0) {
|
if (row.coordinates && Array.isArray(row.coordinates) && row.coordinates.length > 0) {
|
||||||
// // console.log(` → coordinates 발견:`, row.coordinates.length, "개");
|
|
||||||
// coordinates가 [lat, lng] 배열의 배열인지 확인
|
// coordinates가 [lat, lng] 배열의 배열인지 확인
|
||||||
const firstCoord = row.coordinates[0];
|
const firstCoord = row.coordinates[0];
|
||||||
if (Array.isArray(firstCoord) && firstCoord.length === 2) {
|
if (Array.isArray(firstCoord) && firstCoord.length === 2) {
|
||||||
// console.log(` → 폴리곤으로 처리:`, row.name);
|
|
||||||
polygons.push({
|
polygons.push({
|
||||||
id: row.id || row.code || `polygon-${index}`,
|
id: row.id || row.code || `polygon-${index}`,
|
||||||
name: row.name || row.title || `영역 ${index + 1}`,
|
name: row.name || row.title || `영역 ${index + 1}`,
|
||||||
coordinates: row.coordinates as [number, number][],
|
coordinates: row.coordinates as [number, number][],
|
||||||
status: row.status || row.level,
|
status: row.status || row.level,
|
||||||
description: row.description || JSON.stringify(row, null, 2),
|
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
||||||
source: sourceName,
|
source: sourceName,
|
||||||
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||||||
});
|
});
|
||||||
|
|
@ -471,13 +439,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
// 지역명으로 해상 구역 확인 (auto 또는 polygon 모드일 때만)
|
// 지역명으로 해상 구역 확인 (auto 또는 polygon 모드일 때만)
|
||||||
const regionName = row.name || row.area || row.region || row.location || row.subRegion;
|
const regionName = row.name || row.area || row.region || row.location || row.subRegion;
|
||||||
if (regionName && MARITIME_ZONES[regionName] && mapDisplayType !== "marker") {
|
if (regionName && MARITIME_ZONES[regionName] && mapDisplayType !== "marker") {
|
||||||
// // console.log(` → 해상 구역 발견: ${regionName}, 폴리곤으로 처리`);
|
|
||||||
polygons.push({
|
polygons.push({
|
||||||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
||||||
name: regionName,
|
name: regionName,
|
||||||
coordinates: MARITIME_ZONES[regionName] as [number, number][],
|
coordinates: MARITIME_ZONES[regionName] as [number, number][],
|
||||||
status: row.status || row.level,
|
status: row.status || row.level,
|
||||||
description: row.description || `${row.type || ""} ${row.level || ""}`.trim() || JSON.stringify(row, null, 2),
|
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
||||||
source: sourceName,
|
source: sourceName,
|
||||||
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||||||
});
|
});
|
||||||
|
|
@ -494,24 +461,20 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
(row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId)
|
(row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId)
|
||||||
) {
|
) {
|
||||||
const regionCode = row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId;
|
const regionCode = row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId;
|
||||||
// // console.log(` → 지역 코드 발견: ${regionCode}, 위도/경도 변환 시도`);
|
|
||||||
const coords = getCoordinatesByRegionCode(regionCode);
|
const coords = getCoordinatesByRegionCode(regionCode);
|
||||||
if (coords) {
|
if (coords) {
|
||||||
lat = coords.lat;
|
lat = coords.lat;
|
||||||
lng = coords.lng;
|
lng = coords.lng;
|
||||||
// console.log(` → 변환 성공: (${lat}, ${lng})`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 지역명으로도 시도
|
// 지역명으로도 시도
|
||||||
if ((lat === undefined || lng === undefined) && (row.name || row.area || row.region || row.location)) {
|
if ((lat === undefined || lng === undefined) && (row.name || row.area || row.region || row.location)) {
|
||||||
const regionName = row.name || row.area || row.region || row.location;
|
const regionName = row.name || row.area || row.region || row.location;
|
||||||
// // console.log(` → 지역명 발견: ${regionName}, 위도/경도 변환 시도`);
|
|
||||||
const coords = getCoordinatesByRegionName(regionName);
|
const coords = getCoordinatesByRegionName(regionName);
|
||||||
if (coords) {
|
if (coords) {
|
||||||
lat = coords.lat;
|
lat = coords.lat;
|
||||||
lng = coords.lng;
|
lng = coords.lng;
|
||||||
// console.log(` → 변환 성공: (${lat}, ${lng})`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -519,34 +482,33 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
if (mapDisplayType === "polygon") {
|
if (mapDisplayType === "polygon") {
|
||||||
const regionName = row.name || row.subRegion || row.region || row.area;
|
const regionName = row.name || row.subRegion || row.region || row.area;
|
||||||
if (regionName) {
|
if (regionName) {
|
||||||
// console.log(` 🔷 강제 폴리곤 모드: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`);
|
|
||||||
polygons.push({
|
polygons.push({
|
||||||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`,
|
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
||||||
name: regionName,
|
name: regionName,
|
||||||
coordinates: [], // GeoJSON에서 좌표를 가져올 것
|
coordinates: [], // GeoJSON에서 좌표를 가져올 것
|
||||||
status: row.status || row.level,
|
status: row.status || row.level,
|
||||||
description: row.description || JSON.stringify(row, null, 2),
|
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
||||||
source: sourceName,
|
source: sourceName,
|
||||||
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
// console.log(` ⚠️ 강제 폴리곤 모드지만 지역명 없음 - 스킵`);
|
|
||||||
}
|
}
|
||||||
return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음
|
return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음
|
||||||
}
|
}
|
||||||
|
|
||||||
// 위도/경도가 있고 polygon 모드가 아니면 마커로 처리
|
// 위도/경도가 있고 polygon 모드가 아니면 마커로 처리
|
||||||
if (lat !== undefined && lng !== undefined && (mapDisplayType as string) !== "polygon") {
|
if (lat !== undefined && lng !== undefined && (mapDisplayType as string) !== "polygon") {
|
||||||
// // console.log(` → 마커로 처리: (${lat}, ${lng})`);
|
|
||||||
markers.push({
|
markers.push({
|
||||||
id: `${sourceName}-marker-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
// 진행 방향(heading) 계산을 위해 ID는 새로고침마다 바뀌지 않도록 고정값 사용
|
||||||
|
// - row.id / row.code가 있으면 그 값을 사용
|
||||||
|
// - 없으면 sourceName과 index 조합으로 고정 ID 생성
|
||||||
|
id: row.id || row.code || `${sourceName}-marker-${index}`,
|
||||||
lat: Number(lat),
|
lat: Number(lat),
|
||||||
lng: Number(lng),
|
lng: Number(lng),
|
||||||
latitude: Number(lat),
|
latitude: Number(lat),
|
||||||
longitude: Number(lng),
|
longitude: Number(lng),
|
||||||
name: row.name || row.title || row.area || row.region || `위치 ${index + 1}`,
|
name: row.name || row.title || row.area || row.region || `위치 ${index + 1}`,
|
||||||
status: row.status || row.level,
|
status: row.status || row.level,
|
||||||
description: row.description || JSON.stringify(row, null, 2),
|
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
||||||
source: sourceName,
|
source: sourceName,
|
||||||
color: dataSource?.markerColor || "#3b82f6", // 사용자 지정 색상 또는 기본 파랑
|
color: dataSource?.markerColor || "#3b82f6", // 사용자 지정 색상 또는 기본 파랑
|
||||||
});
|
});
|
||||||
|
|
@ -554,24 +516,19 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
// 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용)
|
// 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용)
|
||||||
const regionName = row.name || row.subRegion || row.region || row.area;
|
const regionName = row.name || row.subRegion || row.region || row.area;
|
||||||
if (regionName) {
|
if (regionName) {
|
||||||
// console.log(` 📍 위도/경도 없지만 지역명 있음: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`);
|
|
||||||
polygons.push({
|
polygons.push({
|
||||||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`,
|
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
||||||
name: regionName,
|
name: regionName,
|
||||||
coordinates: [], // GeoJSON에서 좌표를 가져올 것
|
coordinates: [], // GeoJSON에서 좌표를 가져올 것
|
||||||
status: row.status || row.level,
|
status: row.status || row.level,
|
||||||
description: row.description || JSON.stringify(row, null, 2),
|
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
||||||
source: sourceName,
|
source: sourceName,
|
||||||
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
// console.log(` ⚠️ 위도/경도 없고 지역명도 없음 - 스킵`);
|
|
||||||
// console.log(` 데이터:`, row);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// // console.log(`✅ ${sourceName}: 마커 ${markers.length}개, 폴리곤 ${polygons.length}개 변환 완료`);
|
|
||||||
return { markers, polygons };
|
return { markers, polygons };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -627,6 +584,97 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
|
|
||||||
// 해상 구역 폴리곤 좌표 (기상청 특보 구역 기준)
|
// 해상 구역 폴리곤 좌표 (기상청 특보 구역 기준)
|
||||||
const MARITIME_ZONES: Record<string, Array<[number, number]>> = {
|
const MARITIME_ZONES: Record<string, Array<[number, number]>> = {
|
||||||
|
// 서해 해역
|
||||||
|
"인천·경기북부앞바다": [
|
||||||
|
[37.8, 125.8],
|
||||||
|
[37.8, 126.5],
|
||||||
|
[37.3, 126.5],
|
||||||
|
[37.3, 125.8],
|
||||||
|
],
|
||||||
|
"인천·경기남부앞바다": [
|
||||||
|
[37.3, 125.7],
|
||||||
|
[37.3, 126.4],
|
||||||
|
[36.8, 126.4],
|
||||||
|
[36.8, 125.7],
|
||||||
|
],
|
||||||
|
충남북부앞바다: [
|
||||||
|
[36.8, 125.6],
|
||||||
|
[36.8, 126.3],
|
||||||
|
[36.3, 126.3],
|
||||||
|
[36.3, 125.6],
|
||||||
|
],
|
||||||
|
충남남부앞바다: [
|
||||||
|
[36.3, 125.5],
|
||||||
|
[36.3, 126.2],
|
||||||
|
[35.8, 126.2],
|
||||||
|
[35.8, 125.5],
|
||||||
|
],
|
||||||
|
전북북부앞바다: [
|
||||||
|
[35.8, 125.4],
|
||||||
|
[35.8, 126.1],
|
||||||
|
[35.3, 126.1],
|
||||||
|
[35.3, 125.4],
|
||||||
|
],
|
||||||
|
전북남부앞바다: [
|
||||||
|
[35.3, 125.3],
|
||||||
|
[35.3, 126.0],
|
||||||
|
[34.8, 126.0],
|
||||||
|
[34.8, 125.3],
|
||||||
|
],
|
||||||
|
전남북부서해앞바다: [
|
||||||
|
[35.5, 125.2],
|
||||||
|
[35.5, 125.9],
|
||||||
|
[35.0, 125.9],
|
||||||
|
[35.0, 125.2],
|
||||||
|
],
|
||||||
|
전남중부서해앞바다: [
|
||||||
|
[35.0, 125.1],
|
||||||
|
[35.0, 125.8],
|
||||||
|
[34.5, 125.8],
|
||||||
|
[34.5, 125.1],
|
||||||
|
],
|
||||||
|
전남남부서해앞바다: [
|
||||||
|
[34.5, 125.0],
|
||||||
|
[34.5, 125.7],
|
||||||
|
[34.0, 125.7],
|
||||||
|
[34.0, 125.0],
|
||||||
|
],
|
||||||
|
서해중부안쪽먼바다: [
|
||||||
|
[37.5, 124.5],
|
||||||
|
[37.5, 126.0],
|
||||||
|
[36.0, 126.0],
|
||||||
|
[36.0, 124.5],
|
||||||
|
],
|
||||||
|
서해중부바깥먼바다: [
|
||||||
|
[37.5, 123.5],
|
||||||
|
[37.5, 125.0],
|
||||||
|
[36.0, 125.0],
|
||||||
|
[36.0, 123.5],
|
||||||
|
],
|
||||||
|
서해남부북쪽안쪽먼바다: [
|
||||||
|
[36.0, 124.5],
|
||||||
|
[36.0, 126.0],
|
||||||
|
[35.0, 126.0],
|
||||||
|
[35.0, 124.5],
|
||||||
|
],
|
||||||
|
서해남부북쪽바깥먼바다: [
|
||||||
|
[36.0, 123.5],
|
||||||
|
[36.0, 125.0],
|
||||||
|
[35.0, 125.0],
|
||||||
|
[35.0, 123.5],
|
||||||
|
],
|
||||||
|
서해남부남쪽안쪽먼바다: [
|
||||||
|
[35.0, 124.0],
|
||||||
|
[35.0, 125.5],
|
||||||
|
[34.0, 125.5],
|
||||||
|
[34.0, 124.0],
|
||||||
|
],
|
||||||
|
서해남부남쪽바깥먼바다: [
|
||||||
|
[35.0, 123.0],
|
||||||
|
[35.0, 124.5],
|
||||||
|
[33.5, 124.5],
|
||||||
|
[33.5, 123.0],
|
||||||
|
],
|
||||||
// 제주도 해역
|
// 제주도 해역
|
||||||
제주도남부앞바다: [
|
제주도남부앞바다: [
|
||||||
[33.25, 126.0],
|
[33.25, 126.0],
|
||||||
|
|
@ -862,7 +910,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
const nameColumn = columns.find((col) => /^(name|title|이름|명칭|location)$/i.test(col));
|
const nameColumn = columns.find((col) => /^(name|title|이름|명칭|location)$/i.test(col));
|
||||||
|
|
||||||
if (!latColumn || !lngColumn) {
|
if (!latColumn || !lngColumn) {
|
||||||
console.warn("⚠️ 위도/경도 컬럼을 찾을 수 없습니다.");
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -896,10 +943,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/geojson/korea-municipalities.json");
|
const response = await fetch("/geojson/korea-municipalities.json");
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
// // console.log("🗺️ GeoJSON 로드 완료:", data.features?.length, "개 시/군/구");
|
|
||||||
setGeoJsonData(data);
|
setGeoJsonData(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("❌ GeoJSON 로드 실패:", err);
|
// GeoJSON 로드 실패 처리
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadGeoJsonData();
|
loadGeoJsonData();
|
||||||
|
|
@ -934,9 +980,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [dataSources, element?.chartConfig?.refreshInterval]);
|
}, [dataSources, element?.chartConfig?.refreshInterval]);
|
||||||
|
|
||||||
// 타일맵 URL (chartConfig에서 가져오기)
|
// 타일맵 URL (VWorld 한국 지도)
|
||||||
const tileMapUrl =
|
const tileMapUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`;
|
||||||
element?.chartConfig?.tileMapUrl || `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`;
|
|
||||||
|
|
||||||
// 지도 중심점 계산
|
// 지도 중심점 계산
|
||||||
const center: [number, number] =
|
const center: [number, number] =
|
||||||
|
|
@ -945,7 +990,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
markers.reduce((sum, m) => sum + m.lat, 0) / markers.length,
|
markers.reduce((sum, m) => sum + m.lat, 0) / markers.length,
|
||||||
markers.reduce((sum, m) => sum + m.lng, 0) / markers.length,
|
markers.reduce((sum, m) => sum + m.lng, 0) / markers.length,
|
||||||
]
|
]
|
||||||
: [37.5665, 126.978]; // 기본: 서울
|
: [36.5, 127.5]; // 한국 중심
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex h-full w-full flex-col">
|
<div className="bg-background flex h-full w-full flex-col">
|
||||||
|
|
@ -982,19 +1027,28 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
<p className="text-destructive text-sm">{error}</p>
|
<p className="text-destructive text-sm">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<MapContainer center={center} zoom={13} style={{ width: "100%", height: "100%" }} className="z-0">
|
<MapContainer
|
||||||
<TileLayer url={tileMapUrl} attribution="© VWorld" maxZoom={19} />
|
key={`map-widget-${element.id}`}
|
||||||
|
center={center}
|
||||||
|
zoom={element.chartConfig?.initialZoom ?? 8}
|
||||||
|
minZoom={element.chartConfig?.minZoom ?? 8}
|
||||||
|
maxZoom={element.chartConfig?.maxZoom ?? 18}
|
||||||
|
scrollWheelZoom
|
||||||
|
doubleClickZoom
|
||||||
|
touchZoom
|
||||||
|
zoomControl
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
className="z-0"
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
url={tileMapUrl}
|
||||||
|
attribution="© VWorld"
|
||||||
|
minZoom={element.chartConfig?.minZoom ?? 8}
|
||||||
|
maxZoom={element.chartConfig?.maxZoom ?? 18}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 폴리곤 렌더링 */}
|
{/* 폴리곤 렌더링 */}
|
||||||
{/* GeoJSON 렌더링 (육지 지역 경계선) */}
|
{/* GeoJSON 렌더링 (육지 지역 경계선) */}
|
||||||
{(() => {
|
|
||||||
// console.log(`🗺️ GeoJSON 렌더링 조건 체크:`, {
|
|
||||||
// geoJsonData: !!geoJsonData,
|
|
||||||
// polygonsLength: polygons.length,
|
|
||||||
// polygonNames: polygons.map(p => p.name),
|
|
||||||
// });
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
{geoJsonData && polygons.length > 0 ? (
|
{geoJsonData && polygons.length > 0 ? (
|
||||||
<GeoJSON
|
<GeoJSON
|
||||||
key={JSON.stringify(polygons.map((p) => p.id))} // 폴리곤 변경 시 재렌더링
|
key={JSON.stringify(polygons.map((p) => p.id))} // 폴리곤 변경 시 재렌더링
|
||||||
|
|
@ -1009,31 +1063,25 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
|
|
||||||
// 정확한 매칭
|
// 정확한 매칭
|
||||||
if (p.name === sigName) {
|
if (p.name === sigName) {
|
||||||
// console.log(`✅ 정확 매칭: ${p.name} === ${sigName}`);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (p.name === ctpName) {
|
if (p.name === ctpName) {
|
||||||
// console.log(`✅ 정확 매칭: ${p.name} === ${ctpName}`);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 부분 매칭 (GeoJSON 지역명에 폴리곤 이름이 포함되는지)
|
// 부분 매칭 (GeoJSON 지역명에 폴리곤 이름이 포함되는지)
|
||||||
if (sigName && sigName.includes(p.name)) {
|
if (sigName && sigName.includes(p.name)) {
|
||||||
// console.log(`✅ 부분 매칭: ${sigName} includes ${p.name}`);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (ctpName && ctpName.includes(p.name)) {
|
if (ctpName && ctpName.includes(p.name)) {
|
||||||
// console.log(`✅ 부분 매칭: ${ctpName} includes ${p.name}`);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 역방향 매칭 (폴리곤 이름에 GeoJSON 지역명이 포함되는지)
|
// 역방향 매칭 (폴리곤 이름에 GeoJSON 지역명이 포함되는지)
|
||||||
if (sigName && p.name.includes(sigName)) {
|
if (sigName && p.name.includes(sigName)) {
|
||||||
// console.log(`✅ 역방향 매칭: ${p.name} includes ${sigName}`);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (ctpName && p.name.includes(ctpName)) {
|
if (ctpName && p.name.includes(ctpName)) {
|
||||||
// console.log(`✅ 역방향 매칭: ${p.name} includes ${ctpName}`);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1069,53 +1117,150 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (matchingPolygon) {
|
if (matchingPolygon) {
|
||||||
layer.bindPopup(`
|
// 폴리곤의 데이터 소스 찾기
|
||||||
<div class="min-w-[200px]">
|
const polygonDataSource = dataSources?.find((ds) => ds.name === matchingPolygon.source);
|
||||||
<div class="mb-2 font-semibold">${matchingPolygon.name}</div>
|
const popupFields = polygonDataSource?.popupFields;
|
||||||
${matchingPolygon.source ? `<div class="mb-1 text-xs text-muted-foreground">출처: ${matchingPolygon.source}</div>` : ""}
|
|
||||||
${matchingPolygon.status ? `<div class="mb-1 text-xs">상태: ${matchingPolygon.status}</div>` : ""}
|
let popupContent = "";
|
||||||
${matchingPolygon.description ? `<div class="mt-2 max-h-[200px] overflow-auto text-xs"><pre class="whitespace-pre-wrap">${matchingPolygon.description}</pre></div>` : ""}
|
|
||||||
</div>
|
// popupFields가 설정되어 있으면 설정된 필드만 표시
|
||||||
`);
|
if (popupFields && popupFields.length > 0 && matchingPolygon.description) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(matchingPolygon.description);
|
||||||
|
popupContent = `
|
||||||
|
<div class="min-w-[200px]">
|
||||||
|
${matchingPolygon.source ? `<div class="mb-2 border-b pb-2"><div class="text-gray-500 text-xs">📡 ${matchingPolygon.source}</div></div>` : ""}
|
||||||
|
<div class="bg-gray-100 rounded p-2">
|
||||||
|
<div class="text-gray-900 mb-1 text-xs font-semibold">상세 정보</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
${popupFields
|
||||||
|
.map((field) => {
|
||||||
|
const value = parsed[field.fieldName];
|
||||||
|
if (value === undefined || value === null) return "";
|
||||||
|
return `<div class="text-xs"><span class="text-gray-600 font-medium">${field.label}:</span> <span class="text-gray-900">${value}</span></div>`;
|
||||||
|
})
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (error) {
|
||||||
|
// JSON 파싱 실패 시 기본 표시
|
||||||
|
popupContent = `
|
||||||
|
<div class="min-w-[200px]">
|
||||||
|
<div class="mb-2 font-semibold">${matchingPolygon.name}</div>
|
||||||
|
${matchingPolygon.source ? `<div class="mb-1 text-xs text-muted-foreground">출처: ${matchingPolygon.source}</div>` : ""}
|
||||||
|
${matchingPolygon.status ? `<div class="mb-1 text-xs">상태: ${matchingPolygon.status}</div>` : ""}
|
||||||
|
${matchingPolygon.description ? `<div class="mt-2 max-h-[200px] overflow-auto text-xs"><pre class="whitespace-pre-wrap">${matchingPolygon.description}</pre></div>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// popupFields가 없으면 전체 데이터 표시
|
||||||
|
popupContent = `
|
||||||
|
<div class="min-w-[200px]">
|
||||||
|
<div class="mb-2 font-semibold">${matchingPolygon.name}</div>
|
||||||
|
${matchingPolygon.source ? `<div class="mb-1 text-xs text-muted-foreground">출처: ${matchingPolygon.source}</div>` : ""}
|
||||||
|
${matchingPolygon.status ? `<div class="mb-1 text-xs">상태: ${matchingPolygon.status}</div>` : ""}
|
||||||
|
${matchingPolygon.description ? `<div class="mt-2 max-h-[200px] overflow-auto text-xs"><pre class="whitespace-pre-wrap">${matchingPolygon.description}</pre></div>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
layer.bindPopup(popupContent);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : null}
|
||||||
<>
|
|
||||||
{/* console.log(`⚠️ GeoJSON 렌더링 안 됨: geoJsonData=${!!geoJsonData}, polygons=${polygons.length}`) */}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 폴리곤 렌더링 (해상 구역만) */}
|
{/* 폴리곤 렌더링 (해상 구역만) */}
|
||||||
{polygons
|
{polygons
|
||||||
.filter((p) => MARITIME_ZONES[p.name])
|
.filter((p) => MARITIME_ZONES[p.name])
|
||||||
.map((polygon) => (
|
.map((polygon) => {
|
||||||
<Polygon
|
// 폴리곤의 데이터 소스 찾기
|
||||||
key={polygon.id}
|
const polygonDataSource = dataSources?.find((ds) => ds.name === polygon.source);
|
||||||
positions={polygon.coordinates}
|
const popupFields = polygonDataSource?.popupFields;
|
||||||
pathOptions={{
|
|
||||||
color: polygon.color || "#3b82f6",
|
return (
|
||||||
fillColor: polygon.color || "#3b82f6",
|
<Polygon
|
||||||
fillOpacity: 0.3,
|
key={polygon.id}
|
||||||
weight: 2,
|
positions={polygon.coordinates}
|
||||||
}}
|
pathOptions={{
|
||||||
>
|
color: polygon.color || "#3b82f6",
|
||||||
<Popup>
|
fillColor: polygon.color || "#3b82f6",
|
||||||
<div className="min-w-[200px]">
|
fillOpacity: 0.3,
|
||||||
<div className="mb-2 font-semibold">{polygon.name}</div>
|
weight: 2,
|
||||||
{polygon.source && (
|
}}
|
||||||
<div className="text-muted-foreground mb-1 text-xs">출처: {polygon.source}</div>
|
>
|
||||||
)}
|
<Popup>
|
||||||
{polygon.status && <div className="mb-1 text-xs">상태: {polygon.status}</div>}
|
<div className="min-w-[200px]">
|
||||||
{polygon.description && (
|
{/* popupFields가 설정되어 있으면 설정된 필드만 표시 */}
|
||||||
<div className="mt-2 max-h-[200px] overflow-auto text-xs">
|
{popupFields && popupFields.length > 0 && polygon.description ? (
|
||||||
<pre className="whitespace-pre-wrap">{polygon.description}</pre>
|
(() => {
|
||||||
</div>
|
try {
|
||||||
)}
|
const parsed = JSON.parse(polygon.description);
|
||||||
</div>
|
return (
|
||||||
</Popup>
|
<>
|
||||||
</Polygon>
|
{polygon.source && (
|
||||||
))}
|
<div className="mb-2 border-b pb-2">
|
||||||
|
<div className="text-muted-foreground text-xs">📡 {polygon.source}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="bg-muted rounded p-2">
|
||||||
|
<div className="text-foreground mb-1 text-xs font-semibold">상세 정보</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{popupFields.map((field, idx) => {
|
||||||
|
const value = parsed[field.fieldName];
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
return (
|
||||||
|
<div key={idx} className="text-xs">
|
||||||
|
<span className="text-muted-foreground font-medium">{field.label}:</span>{" "}
|
||||||
|
<span className="text-foreground">{String(value)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// JSON 파싱 실패 시 기본 표시
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-2 font-semibold">{polygon.name}</div>
|
||||||
|
{polygon.source && (
|
||||||
|
<div className="text-muted-foreground mb-1 text-xs">출처: {polygon.source}</div>
|
||||||
|
)}
|
||||||
|
{polygon.status && <div className="mb-1 text-xs">상태: {polygon.status}</div>}
|
||||||
|
{polygon.description && (
|
||||||
|
<div className="mt-2 max-h-[200px] overflow-auto text-xs">
|
||||||
|
<pre className="whitespace-pre-wrap">{polygon.description}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
// popupFields가 없으면 전체 데이터 표시
|
||||||
|
<>
|
||||||
|
<div className="mb-2 font-semibold">{polygon.name}</div>
|
||||||
|
{polygon.source && (
|
||||||
|
<div className="text-muted-foreground mb-1 text-xs">출처: {polygon.source}</div>
|
||||||
|
)}
|
||||||
|
{polygon.status && <div className="mb-1 text-xs">상태: {polygon.status}</div>}
|
||||||
|
{polygon.description && (
|
||||||
|
<div className="mt-2 max-h-[200px] overflow-auto text-xs">
|
||||||
|
<pre className="whitespace-pre-wrap">{polygon.description}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Polygon>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* 마커 렌더링 */}
|
{/* 마커 렌더링 */}
|
||||||
{markers.map((marker) => {
|
{markers.map((marker) => {
|
||||||
|
|
@ -1143,28 +1288,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
||||||
">
|
">
|
||||||
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||||
<!-- 화살표 몸통 -->
|
<!-- 이등변 삼각형 화살표 (뾰족한 방향 표시) -->
|
||||||
<polygon
|
<polygon
|
||||||
points="20,5 25,15 23,15 23,25 17,25 17,15 15,15"
|
points="20,5 28,30 12,30"
|
||||||
fill="${marker.color || "#3b82f6"}"
|
fill="${marker.color || "#3b82f6"}"
|
||||||
stroke="white"
|
stroke="white"
|
||||||
stroke-width="1.5"
|
stroke-width="2"
|
||||||
/>
|
|
||||||
<!-- 화살촉 -->
|
|
||||||
<polygon
|
|
||||||
points="20,2 28,12 12,12"
|
|
||||||
fill="${marker.color || "#3b82f6"}"
|
|
||||||
stroke="white"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
|
||||||
<!-- 중심점 -->
|
|
||||||
<circle
|
|
||||||
cx="20"
|
|
||||||
cy="30"
|
|
||||||
r="3"
|
|
||||||
fill="white"
|
|
||||||
stroke="${marker.color || "#3b82f6"}"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1172,6 +1301,74 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
iconSize: [40, 40],
|
iconSize: [40, 40],
|
||||||
iconAnchor: [20, 20],
|
iconAnchor: [20, 20],
|
||||||
});
|
});
|
||||||
|
} else if (markerType === "truck") {
|
||||||
|
// 트럭 마커
|
||||||
|
markerIcon = L.divIcon({
|
||||||
|
className: "custom-truck-marker",
|
||||||
|
html: `
|
||||||
|
<div style="
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transform: translate(-50%, -50%) rotate(${heading}deg);
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
||||||
|
">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="rotate(-90 20 20)">
|
||||||
|
<!-- 트럭 적재함 -->
|
||||||
|
<rect
|
||||||
|
x="10"
|
||||||
|
y="12"
|
||||||
|
width="12"
|
||||||
|
height="10"
|
||||||
|
fill="${marker.color || "#3b82f6"}"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="1.5"
|
||||||
|
rx="1"
|
||||||
|
/>
|
||||||
|
<!-- 트럭 운전석 -->
|
||||||
|
<path
|
||||||
|
d="M 22 14 L 22 22 L 28 22 L 28 18 L 26 14 Z"
|
||||||
|
fill="${marker.color || "#3b82f6"}"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
<!-- 운전석 창문 -->
|
||||||
|
<rect
|
||||||
|
x="23"
|
||||||
|
y="15"
|
||||||
|
width="3"
|
||||||
|
height="4"
|
||||||
|
fill="white"
|
||||||
|
opacity="0.8"
|
||||||
|
/>
|
||||||
|
<!-- 앞 바퀴 -->
|
||||||
|
<circle
|
||||||
|
cx="25"
|
||||||
|
cy="23"
|
||||||
|
r="2.5"
|
||||||
|
fill="#333"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
<!-- 뒷 바퀴 -->
|
||||||
|
<circle
|
||||||
|
cx="14"
|
||||||
|
cy="23"
|
||||||
|
r="2.5"
|
||||||
|
fill="#333"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
iconSize: [48, 48],
|
||||||
|
iconAnchor: [24, 24],
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// 동그라미 마커 (기본)
|
// 동그라미 마커 (기본)
|
||||||
markerIcon = L.divIcon({
|
markerIcon = L.divIcon({
|
||||||
|
|
@ -1227,8 +1424,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{marker.description &&
|
{marker.description &&
|
||||||
(() => {
|
(() => {
|
||||||
const firstDataSource = dataSources?.[0];
|
// 마커의 소스에 해당하는 데이터 소스 찾기
|
||||||
const popupFields = firstDataSource?.popupFields;
|
const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source);
|
||||||
|
const popupFields = sourceDataSource?.popupFields;
|
||||||
|
|
||||||
// popupFields가 설정되어 있으면 설정된 필드만 표시
|
// popupFields가 설정되어 있으면 설정된 필드만 표시
|
||||||
if (popupFields && popupFields.length > 0) {
|
if (popupFields && popupFields.length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -636,9 +636,10 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
||||||
<div className="mt-1 flex items-center gap-2 text-[9px] text-muted-foreground">
|
<div className="mt-1 flex items-center gap-2 text-[9px] text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{(() => {
|
{(() => {
|
||||||
const ts = String(alert.timestamp);
|
const original = String(alert.timestamp);
|
||||||
|
const ts = original.replace(/\s+/g, ""); // 공백 제거
|
||||||
|
|
||||||
// yyyyMMddHHmm 형식 감지 (예: 20251114 1000)
|
// yyyyMMddHHmm 형식 감지 (12자리 숫자)
|
||||||
if (/^\d{12}$/.test(ts)) {
|
if (/^\d{12}$/.test(ts)) {
|
||||||
const year = ts.substring(0, 4);
|
const year = ts.substring(0, 4);
|
||||||
const month = ts.substring(4, 6);
|
const month = ts.substring(4, 6);
|
||||||
|
|
@ -646,12 +647,20 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
||||||
const hour = ts.substring(8, 10);
|
const hour = ts.substring(8, 10);
|
||||||
const minute = ts.substring(10, 12);
|
const minute = ts.substring(10, 12);
|
||||||
const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:00`);
|
const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:00`);
|
||||||
return isNaN(date.getTime()) ? ts : date.toLocaleString("ko-KR");
|
return isNaN(date.getTime()) ? original : date.toLocaleString("ko-KR");
|
||||||
|
}
|
||||||
|
|
||||||
|
// "2025년 11월 14일 13시 20분" 형식
|
||||||
|
const koreanMatch = original.match(/(\d{4})년\s*(\d{1,2})월\s*(\d{1,2})일\s*(\d{1,2})시\s*(\d{1,2})분/);
|
||||||
|
if (koreanMatch) {
|
||||||
|
const [, year, month, day, hour, minute] = koreanMatch;
|
||||||
|
const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${hour.padStart(2, '0')}:${minute.padStart(2, '0')}:00`);
|
||||||
|
return isNaN(date.getTime()) ? original : date.toLocaleString("ko-KR");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISO 형식 또는 일반 날짜 형식
|
// ISO 형식 또는 일반 날짜 형식
|
||||||
const date = new Date(ts);
|
const date = new Date(original);
|
||||||
return isNaN(date.getTime()) ? ts : date.toLocaleString("ko-KR");
|
return isNaN(date.getTime()) ? original : date.toLocaleString("ko-KR");
|
||||||
})()}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
{alert.source && <span>· {alert.source}</span>}
|
{alert.source && <span>· {alert.source}</span>}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
import type {
|
||||||
|
DigitalTwinLayout,
|
||||||
|
DigitalTwinLayoutDetail,
|
||||||
|
CreateLayoutRequest,
|
||||||
|
UpdateLayoutRequest,
|
||||||
|
Warehouse,
|
||||||
|
Area,
|
||||||
|
Location,
|
||||||
|
MaterialData,
|
||||||
|
MaterialCount,
|
||||||
|
} from "@/types/digitalTwin";
|
||||||
|
|
||||||
|
// API 응답 타입
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 레이아웃 관리 API ==========
|
||||||
|
|
||||||
|
// 레이아웃 목록 조회
|
||||||
|
export const getLayouts = async (params?: {
|
||||||
|
externalDbConnectionId?: number;
|
||||||
|
warehouseKey?: string;
|
||||||
|
}): Promise<ApiResponse<DigitalTwinLayout[]>> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/digital-twin/layouts", { params });
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃 상세 조회
|
||||||
|
export const getLayoutById = async (id: number): Promise<ApiResponse<DigitalTwinLayoutDetail>> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/digital-twin/layouts/${id}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃 생성
|
||||||
|
export const createLayout = async (data: CreateLayoutRequest): Promise<ApiResponse<DigitalTwinLayout>> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post("/digital-twin/layouts", data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃 수정
|
||||||
|
export const updateLayout = async (id: number, data: UpdateLayoutRequest): Promise<ApiResponse<DigitalTwinLayout>> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(`/digital-twin/layouts/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃 삭제
|
||||||
|
export const deleteLayout = async (id: number): Promise<ApiResponse<void>> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.delete(`/digital-twin/layouts/${id}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 외부 DB 테이블 조회 API ==========
|
||||||
|
|
||||||
|
export const getTables = async (
|
||||||
|
connectionId: number
|
||||||
|
): Promise<ApiResponse<Array<{ table_name: string }>>> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/digital-twin/data/tables/${connectionId}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTablePreview = async (
|
||||||
|
connectionId: number,
|
||||||
|
tableName: string
|
||||||
|
): Promise<ApiResponse<any[]>> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/digital-twin/data/table-preview/${connectionId}/${tableName}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 외부 DB 데이터 조회 API ==========
|
||||||
|
|
||||||
|
// 창고 목록 조회
|
||||||
|
export const getWarehouses = async (externalDbConnectionId: number, tableName: string): Promise<ApiResponse<Warehouse[]>> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/digital-twin/data/warehouses", {
|
||||||
|
params: { externalDbConnectionId, tableName },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Area 목록 조회
|
||||||
|
export const getAreas = async (externalDbConnectionId: number, tableName: string, warehouseKey: string): Promise<ApiResponse<Area[]>> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/digital-twin/data/areas", {
|
||||||
|
params: { externalDbConnectionId, tableName, warehouseKey },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Location 목록 조회
|
||||||
|
export const getLocations = async (
|
||||||
|
externalDbConnectionId: number,
|
||||||
|
tableName: string,
|
||||||
|
areaKey: string,
|
||||||
|
): Promise<ApiResponse<Location[]>> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/digital-twin/data/locations", {
|
||||||
|
params: { externalDbConnectionId, tableName, areaKey },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 자재 목록 조회 (특정 Location)
|
||||||
|
export const getMaterials = async (
|
||||||
|
externalDbConnectionId: number,
|
||||||
|
tableName: string,
|
||||||
|
locaKey: string,
|
||||||
|
): Promise<ApiResponse<MaterialData[]>> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/digital-twin/data/materials", {
|
||||||
|
params: { externalDbConnectionId, tableName, locaKey },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 자재 개수 조회 (여러 Location)
|
||||||
|
export const getMaterialCounts = async (
|
||||||
|
externalDbConnectionId: number,
|
||||||
|
tableName: string,
|
||||||
|
locaKeys: string[],
|
||||||
|
): Promise<ApiResponse<MaterialCount[]>> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get("/digital-twin/data/material-counts", {
|
||||||
|
params: {
|
||||||
|
externalDbConnectionId,
|
||||||
|
tableName,
|
||||||
|
locaKeys: locaKeys.join(","),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
// Digital Twin 관련 타입 정의
|
||||||
|
|
||||||
|
// 객체 타입
|
||||||
|
export type ObjectType =
|
||||||
|
| "area" // Area (A동, B동, C동, 겐트리)
|
||||||
|
| "location-bed" // BED (사각형, 파란색, 상자)
|
||||||
|
| "location-stp" // STP (원형, 회색, 주차)
|
||||||
|
| "location-temp" // 임시베드 (BED와 동일)
|
||||||
|
| "location-dest" // 지정착지 (BED와 동일)
|
||||||
|
| "crane-mobile" // 모바일 크레인 (참고용)
|
||||||
|
| "rack"; // 랙 (참고용)
|
||||||
|
|
||||||
|
// 3D 위치
|
||||||
|
export interface Position3D {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3D 크기
|
||||||
|
export interface Size3D {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자재 미리보기 정보
|
||||||
|
export interface MaterialPreview {
|
||||||
|
height: number; // 스택 높이 (LOLAYER 기반)
|
||||||
|
topMaterial?: MaterialData; // 최상단 자재 정보 (선택사항)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자재 데이터 (WSTKKY)
|
||||||
|
export interface MaterialData {
|
||||||
|
STKKEY: string; // 자재 키
|
||||||
|
LOCAKEY: string; // Location 키
|
||||||
|
AREAKEY: string; // Area 키
|
||||||
|
LOLAYER: number; // 층 (스택 순서)
|
||||||
|
STKWIDT?: number; // 폭
|
||||||
|
STKLENG?: number; // 길이
|
||||||
|
STKHEIG?: number; // 높이
|
||||||
|
STKWEIG?: number; // 무게
|
||||||
|
STKQTY?: number; // 수량
|
||||||
|
STKSTAT?: string; // 상태
|
||||||
|
STKREDT?: string; // 등록일
|
||||||
|
STKRMKS?: string; // 비고
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배치된 객체
|
||||||
|
export interface PlacedObject {
|
||||||
|
id: number; // 로컬 ID (음수: 임시, 양수: DB 저장됨)
|
||||||
|
type: ObjectType;
|
||||||
|
name: string;
|
||||||
|
position: Position3D;
|
||||||
|
size: Size3D;
|
||||||
|
rotation?: number; // 회전 각도 (라디안)
|
||||||
|
color: string;
|
||||||
|
|
||||||
|
// 외부 DB 연동
|
||||||
|
areaKey?: string; // MAREMA.AREAKEY
|
||||||
|
locaKey?: string; // MLOCMA.LOCAKEY
|
||||||
|
locType?: string; // MLOCMA.LOCTYPE (BED, STP, TMP, DES)
|
||||||
|
|
||||||
|
// 자재 정보
|
||||||
|
materialCount?: number; // 자재 개수
|
||||||
|
materialPreview?: MaterialPreview; // 자재 미리보기
|
||||||
|
|
||||||
|
// 계층 구조
|
||||||
|
parentId?: number;
|
||||||
|
displayOrder?: number;
|
||||||
|
|
||||||
|
// 편집 제한
|
||||||
|
locked?: boolean; // true면 이동/크기조절 불가
|
||||||
|
visible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이아웃
|
||||||
|
export interface DigitalTwinLayout {
|
||||||
|
id: number;
|
||||||
|
companyCode: string;
|
||||||
|
externalDbConnectionId: number;
|
||||||
|
warehouseKey: string; // WAREKEY (예: DY99)
|
||||||
|
layoutName: string;
|
||||||
|
description?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdBy?: number;
|
||||||
|
updatedBy?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
|
||||||
|
// 통계 (조회 시만)
|
||||||
|
objectCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이아웃 상세 (객체 포함)
|
||||||
|
export interface DigitalTwinLayoutDetail {
|
||||||
|
layout: DigitalTwinLayout;
|
||||||
|
objects: PlacedObject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 창고 (MWAREMA)
|
||||||
|
export interface Warehouse {
|
||||||
|
WAREKEY: string;
|
||||||
|
WARENAME: string;
|
||||||
|
WARETYPE?: string;
|
||||||
|
WARESTAT?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Area (MAREMA)
|
||||||
|
export interface Area {
|
||||||
|
AREAKEY: string;
|
||||||
|
AREANAME: string;
|
||||||
|
AREATYP?: string; // 내부/외부
|
||||||
|
WAREKEY: string;
|
||||||
|
AREASTAT?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location (MLOCMA)
|
||||||
|
export interface Location {
|
||||||
|
LOCAKEY: string;
|
||||||
|
LOCANAME: string;
|
||||||
|
LOCTYPE: string; // BED, STP, TMP, DES
|
||||||
|
AREAKEY: string;
|
||||||
|
LOCWIDT?: number; // 폭 (현재 데이터는 0)
|
||||||
|
LOCLENG?: number; // 길이 (현재 데이터는 0)
|
||||||
|
LOCHEIG?: number; // 높이 (현재 데이터는 0)
|
||||||
|
LOCCUBI?: number; // 용적 (현재 데이터는 0)
|
||||||
|
LOCSTAT?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자재 개수 (배치 시 사용)
|
||||||
|
export interface MaterialCount {
|
||||||
|
LOCAKEY: string;
|
||||||
|
material_count: number;
|
||||||
|
max_layer: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 요청/응답 타입
|
||||||
|
export interface CreateLayoutRequest {
|
||||||
|
externalDbConnectionId: number;
|
||||||
|
warehouseKey: string;
|
||||||
|
layoutName: string;
|
||||||
|
description?: string;
|
||||||
|
objects: PlacedObject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLayoutRequest {
|
||||||
|
layoutName: string;
|
||||||
|
description?: string;
|
||||||
|
objects: PlacedObject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 도구 타입 (UI용)
|
||||||
|
export type ToolType = ObjectType;
|
||||||
|
|
||||||
Loading…
Reference in New Issue