Compare commits
No commits in common. "bb49073bf7be47f665de13cb8b16731c11bf0ebb" and "45ac39741776b151b50cda8d368234f00fddb830" have entirely different histories.
bb49073bf7
...
45ac397417
27
PLAN.MD
27
PLAN.MD
|
|
@ -1,27 +0,0 @@
|
|||
# 프로젝트: Digital Twin 에디터 안정화
|
||||
|
||||
## 개요
|
||||
|
||||
Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러(`TypeError: Cannot read properties of undefined`)를 수정하고, 전반적인 안정성을 확보합니다.
|
||||
|
||||
## 핵심 기능
|
||||
|
||||
1. `DigitalTwinEditor` 버그 수정
|
||||
2. 비동기 함수 입력값 유효성 검증 강화
|
||||
3. 외부 DB 연결 상태에 따른 방어 코드 추가
|
||||
|
||||
## 테스트 계획
|
||||
|
||||
### 1단계: 긴급 버그 수정
|
||||
|
||||
- [x] `loadMaterialCountsForLocations` 함수에서 `locaKeys` undefined 체크 추가 (완료)
|
||||
- [ ] 에디터 로드 및 객체 조작 시 에러 발생 여부 확인
|
||||
|
||||
### 2단계: 잠재적 문제 점검
|
||||
|
||||
- [ ] `loadLayout` 등 주요 로딩 함수의 데이터 유효성 검사
|
||||
- [ ] `handleToolDragStart`, `handleCanvasDrop` 등 인터랙션 함수의 예외 처리
|
||||
|
||||
## 진행 상태
|
||||
|
||||
- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
# 프로젝트 진행 상황 (2025-11-20)
|
||||
|
||||
## 작업 개요: 디지털 트윈 3D 야드 고도화 (동적 계층 구조)
|
||||
|
||||
### 1. 핵심 변경 사항
|
||||
기존의 고정된 `Area` -> `Location` 2단계 구조를 유연한 **N-Level 동적 계층 구조**로 변경하고, 공간적 제약을 강화했습니다.
|
||||
|
||||
### 2. 완료된 작업
|
||||
|
||||
#### 데이터베이스
|
||||
- **마이그레이션 실행**: `db/migrations/042_refactor_digital_twin_hierarchy.sql`
|
||||
- **스키마 변경**:
|
||||
- `digital_twin_layout` 테이블에 `hierarchy_config` (JSONB) 컬럼 추가
|
||||
- `digital_twin_objects` 테이블에 `hierarchy_level`, `parent_key`, `external_key` 컬럼 추가
|
||||
- 기존 하드코딩된 테이블 매핑 컬럼 제거
|
||||
|
||||
#### 백엔드 (Node.js)
|
||||
- **API 추가/수정**:
|
||||
- `POST /api/digital-twin/data/hierarchy`: 계층 설정에 따른 전체 데이터 조회
|
||||
- `POST /api/digital-twin/data/children`: 특정 부모의 하위 데이터 조회
|
||||
- 기존 레거시 API (`getWarehouses` 등) 호환성 유지
|
||||
- **컨트롤러 수정**:
|
||||
- `digitalTwinDataController.ts`: 동적 쿼리 생성 로직 구현
|
||||
- `digitalTwinLayoutController.ts`: 레이아웃 저장/수정 시 `hierarchy_config` 및 객체 계층 정보 처리
|
||||
|
||||
#### 프론트엔드 (React)
|
||||
- **신규 컴포넌트**: `HierarchyConfigPanel.tsx`
|
||||
- 레벨 추가/삭제, 테이블 및 컬럼 매핑 설정 UI
|
||||
- **유틸리티**: `spatialContainment.ts`
|
||||
- `validateSpatialContainment`: 자식 객체가 부모 객체 내부에 있는지 검증 (AABB)
|
||||
- `updateChildrenPositions`: 부모 이동 시 자식 객체 자동 이동 (그룹 이동)
|
||||
- **에디터 통합 (`DigitalTwinEditor.tsx`)**:
|
||||
- `HierarchyConfigPanel` 적용
|
||||
- 동적 데이터 로드 로직 구현
|
||||
- 3D 캔버스 드래그앤드롭 시 공간적 종속성 검증 적용
|
||||
- 객체 이동 시 그룹 이동 적용
|
||||
|
||||
### 3. 현재 상태
|
||||
- **백엔드 서버**: 재시작 완료, 정상 동작 중 (PostgreSQL 연결 이슈 해결됨)
|
||||
- **DB**: 마이그레이션 스크립트 실행 완료
|
||||
|
||||
### 4. 다음 단계 (테스트 필요)
|
||||
새로운 세션에서 다음 시나리오를 테스트해야 합니다:
|
||||
1. **계층 설정**: 에디터에서 창고 -> 구역(Lv1) -> 위치(Lv2) 설정 및 매핑 저장
|
||||
2. **배치 검증**:
|
||||
- 구역 배치 후, 위치를 구역 **내부**에 배치 (성공해야 함)
|
||||
- 위치를 구역 **외부**에 배치 (실패해야 함)
|
||||
3. **이동 검증**: 구역 이동 시 내부의 위치들도 같이 따라오는지 확인
|
||||
|
||||
### 5. 관련 파일
|
||||
- `frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx`
|
||||
- `frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx`
|
||||
- `frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts`
|
||||
- `backend-node/src/controllers/digitalTwinDataController.ts`
|
||||
- `backend-node/src/routes/digitalTwinRoutes.ts`
|
||||
- `db/migrations/042_refactor_digital_twin_hierarchy.sql`
|
||||
|
||||
|
|
@ -36,138 +36,7 @@ export async function getExternalDbConnector(connectionId: number) {
|
|||
);
|
||||
}
|
||||
|
||||
// 동적 계층 구조 데이터 조회 (범용)
|
||||
export const getHierarchyData = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, hierarchyConfig } = req.body;
|
||||
|
||||
if (!externalDbConnectionId || !hierarchyConfig) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결 ID와 계층 구조 설정이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||
const config = JSON.parse(hierarchyConfig);
|
||||
|
||||
const result: any = {
|
||||
warehouse: null,
|
||||
levels: [],
|
||||
materials: [],
|
||||
};
|
||||
|
||||
// 창고 데이터 조회
|
||||
if (config.warehouse) {
|
||||
const warehouseQuery = `SELECT * FROM ${config.warehouse.tableName} LIMIT 100`;
|
||||
const warehouseResult = await connector.executeQuery(warehouseQuery);
|
||||
result.warehouse = warehouseResult.rows;
|
||||
}
|
||||
|
||||
// 각 레벨 데이터 조회
|
||||
if (config.levels && Array.isArray(config.levels)) {
|
||||
for (const level of config.levels) {
|
||||
const levelQuery = `SELECT * FROM ${level.tableName} LIMIT 1000`;
|
||||
const levelResult = await connector.executeQuery(levelQuery);
|
||||
|
||||
result.levels.push({
|
||||
level: level.level,
|
||||
name: level.name,
|
||||
data: levelResult.rows,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 자재 데이터 조회 (개수만)
|
||||
if (config.material) {
|
||||
const materialQuery = `
|
||||
SELECT
|
||||
${config.material.locationKeyColumn} as location_key,
|
||||
COUNT(*) as count
|
||||
FROM ${config.material.tableName}
|
||||
GROUP BY ${config.material.locationKeyColumn}
|
||||
`;
|
||||
const materialResult = await connector.executeQuery(materialQuery);
|
||||
result.materials = materialResult.rows;
|
||||
}
|
||||
|
||||
logger.info("동적 계층 구조 데이터 조회", {
|
||||
externalDbConnectionId,
|
||||
warehouseCount: result.warehouse?.length || 0,
|
||||
levelCounts: result.levels.map((l: any) => ({ level: l.level, count: l.data.length })),
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("동적 계층 구조 데이터 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 레벨의 하위 데이터 조회
|
||||
export const getChildrenData = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = req.body;
|
||||
|
||||
if (!externalDbConnectionId || !hierarchyConfig || !parentLevel || !parentKey) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||
const config = JSON.parse(hierarchyConfig);
|
||||
|
||||
// 다음 레벨 찾기
|
||||
const nextLevel = config.levels?.find((l: any) => l.level === parentLevel + 1);
|
||||
|
||||
if (!nextLevel) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: [],
|
||||
message: "하위 레벨이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 하위 데이터 조회
|
||||
const query = `
|
||||
SELECT * FROM ${nextLevel.tableName}
|
||||
WHERE ${nextLevel.parentKeyColumn} = '${parentKey}'
|
||||
LIMIT 1000
|
||||
`;
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("하위 데이터 조회", {
|
||||
externalDbConnectionId,
|
||||
parentLevel,
|
||||
parentKey,
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||
// 창고 목록 조회 (사용자 지정 테이블)
|
||||
export const getWarehouses = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, tableName } = req.query;
|
||||
|
|
@ -214,29 +83,32 @@ export const getWarehouses = async (req: Request, res: Response): Promise<Respon
|
|||
}
|
||||
};
|
||||
|
||||
// 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||
// Area 목록 조회 (사용자 지정 테이블)
|
||||
export const getAreas = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, warehouseKey, tableName } = req.query;
|
||||
const { externalDbConnectionId, tableName, warehouseKey } = req.query;
|
||||
|
||||
if (!externalDbConnectionId || !warehouseKey || !tableName) {
|
||||
if (!externalDbConnectionId || !tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
message: "외부 DB 연결 ID와 테이블명이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||
|
||||
const query = `
|
||||
SELECT * FROM ${tableName}
|
||||
WHERE WAREKEY = '${warehouseKey}'
|
||||
LIMIT 1000
|
||||
`;
|
||||
// 테이블명을 사용하여 모든 컬럼 조회
|
||||
let query = `SELECT * FROM ${tableName}`;
|
||||
|
||||
if (warehouseKey) {
|
||||
query += ` WHERE WAREKEY = '${warehouseKey}'`;
|
||||
}
|
||||
|
||||
query += ` LIMIT 1000`;
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("구역 목록 조회", {
|
||||
logger.info("Area 목록 조회", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
warehouseKey,
|
||||
|
|
@ -248,38 +120,41 @@ export const getAreas = async (req: Request, res: Response): Promise<Response> =
|
|||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("구역 목록 조회 실패", error);
|
||||
logger.error("Area 목록 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "구역 목록 조회 중 오류가 발생했습니다.",
|
||||
message: "Area 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||
// Location 목록 조회 (사용자 지정 테이블)
|
||||
export const getLocations = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, areaKey, tableName } = req.query;
|
||||
const { externalDbConnectionId, tableName, areaKey } = req.query;
|
||||
|
||||
if (!externalDbConnectionId || !areaKey || !tableName) {
|
||||
if (!externalDbConnectionId || !tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
message: "외부 DB 연결 ID와 테이블명이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||
|
||||
const query = `
|
||||
SELECT * FROM ${tableName}
|
||||
WHERE AREAKEY = '${areaKey}'
|
||||
LIMIT 1000
|
||||
`;
|
||||
// 테이블명을 사용하여 모든 컬럼 조회
|
||||
let query = `SELECT * FROM ${tableName}`;
|
||||
|
||||
if (areaKey) {
|
||||
query += ` WHERE AREAKEY = '${areaKey}'`;
|
||||
}
|
||||
|
||||
query += ` LIMIT 1000`;
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("위치 목록 조회", {
|
||||
logger.info("Location 목록 조회", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
areaKey,
|
||||
|
|
@ -291,46 +166,37 @@ export const getLocations = async (req: Request, res: Response): Promise<Respons
|
|||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("위치 목록 조회 실패", error);
|
||||
logger.error("Location 목록 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "위치 목록 조회 중 오류가 발생했습니다.",
|
||||
message: "Location 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 자재 목록 조회 (동적 컬럼 매핑 지원)
|
||||
// 자재 목록 조회 (사용자 지정 테이블)
|
||||
export const getMaterials = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const {
|
||||
externalDbConnectionId,
|
||||
locaKey,
|
||||
tableName,
|
||||
keyColumn,
|
||||
locationKeyColumn,
|
||||
layerColumn
|
||||
} = req.query;
|
||||
const { externalDbConnectionId, tableName, locaKey } = req.query;
|
||||
|
||||
if (!externalDbConnectionId || !locaKey || !tableName || !locationKeyColumn) {
|
||||
if (!externalDbConnectionId || !tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
message: "외부 DB 연결 ID와 테이블명이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||
|
||||
// 동적 쿼리 생성
|
||||
const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : '';
|
||||
const query = `
|
||||
SELECT * FROM ${tableName}
|
||||
WHERE ${locationKeyColumn} = '${locaKey}'
|
||||
${orderByClause}
|
||||
LIMIT 1000
|
||||
`;
|
||||
|
||||
logger.info(`자재 조회 쿼리: ${query}`);
|
||||
// 테이블명을 사용하여 모든 컬럼 조회
|
||||
let query = `SELECT * FROM ${tableName}`;
|
||||
|
||||
if (locaKey) {
|
||||
query += ` WHERE LOCAKEY = '${locaKey}'`;
|
||||
}
|
||||
|
||||
query += ` LIMIT 1000`;
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
|
|
@ -355,28 +221,31 @@ export const getMaterials = async (req: Request, res: Response): Promise<Respons
|
|||
}
|
||||
};
|
||||
|
||||
// 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지
|
||||
// Location별 자재 개수 조회 (배치 시 사용 - 사용자 지정 테이블)
|
||||
export const getMaterialCounts = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, locationKeys, tableName } = req.body;
|
||||
const { externalDbConnectionId, tableName, locaKeys } = req.query;
|
||||
|
||||
if (!externalDbConnectionId || !locationKeys || !tableName) {
|
||||
if (!externalDbConnectionId || !tableName || !locaKeys) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
message: "외부 DB 연결 ID, 테이블명, Location 키 목록이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||
|
||||
const keysString = locationKeys.map((key: string) => `'${key}'`).join(",");
|
||||
// locaKeys는 쉼표로 구분된 문자열
|
||||
const locaKeyArray = (locaKeys as string).split(",");
|
||||
const quotedKeys = locaKeyArray.map((key) => `'${key}'`).join(",");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
LOCAKEY as location_key,
|
||||
COUNT(*) as count
|
||||
LOCAKEY,
|
||||
COUNT(*) as material_count,
|
||||
MAX(LOLAYER) as max_layer
|
||||
FROM ${tableName}
|
||||
WHERE LOCAKEY IN (${keysString})
|
||||
WHERE LOCAKEY IN (${quotedKeys})
|
||||
GROUP BY LOCAKEY
|
||||
`;
|
||||
|
||||
|
|
@ -385,7 +254,7 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise<Re
|
|||
logger.info("자재 개수 조회", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
locationCount: locationKeys.length,
|
||||
locaKeyCount: locaKeyArray.length,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
|
|
@ -401,3 +270,4 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise<Re
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { Request, Response } from "express";
|
||||
import { pool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
// 레이아웃 목록 조회
|
||||
export const getLayouts = async (
|
||||
req: AuthenticatedRequest,
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
|
|
@ -68,7 +67,7 @@ export const getLayouts = async (
|
|||
|
||||
// 레이아웃 상세 조회 (객체 포함)
|
||||
export const getLayoutById = async (
|
||||
req: AuthenticatedRequest,
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
|
|
@ -126,7 +125,7 @@ export const getLayoutById = async (
|
|||
|
||||
// 레이아웃 생성
|
||||
export const createLayout = async (
|
||||
req: AuthenticatedRequest,
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
const client = await pool.connect();
|
||||
|
|
@ -139,7 +138,6 @@ export const createLayout = async (
|
|||
warehouseKey,
|
||||
layoutName,
|
||||
description,
|
||||
hierarchyConfig,
|
||||
objects,
|
||||
} = req.body;
|
||||
|
||||
|
|
@ -149,9 +147,9 @@ export const createLayout = async (
|
|||
const layoutQuery = `
|
||||
INSERT INTO digital_twin_layout (
|
||||
company_code, external_db_connection_id, warehouse_key,
|
||||
layout_name, description, hierarchy_config, created_by, updated_by
|
||||
layout_name, description, created_by, updated_by
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
|
|
@ -161,7 +159,6 @@ export const createLayout = async (
|
|||
warehouseKey,
|
||||
layoutName,
|
||||
description,
|
||||
hierarchyConfig ? JSON.stringify(hierarchyConfig) : null,
|
||||
userId,
|
||||
]);
|
||||
|
||||
|
|
@ -177,10 +174,9 @@ export const createLayout = async (
|
|||
rotation, color,
|
||||
area_key, loca_key, loc_type,
|
||||
material_count, material_preview_height,
|
||||
parent_id, display_order, locked,
|
||||
hierarchy_level, parent_key, external_key
|
||||
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, $20, $21, $22)
|
||||
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) {
|
||||
|
|
@ -204,9 +200,6 @@ export const createLayout = async (
|
|||
obj.parentId || null,
|
||||
obj.displayOrder || 0,
|
||||
obj.locked || false,
|
||||
obj.hierarchyLevel || 1,
|
||||
obj.parentKey || null,
|
||||
obj.externalKey || null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -238,7 +231,7 @@ export const createLayout = async (
|
|||
|
||||
// 레이아웃 수정
|
||||
export const updateLayout = async (
|
||||
req: AuthenticatedRequest,
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
const client = await pool.connect();
|
||||
|
|
@ -247,14 +240,7 @@ export const updateLayout = async (
|
|||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
const { id } = req.params;
|
||||
const {
|
||||
layoutName,
|
||||
description,
|
||||
hierarchyConfig,
|
||||
externalDbConnectionId,
|
||||
warehouseKey,
|
||||
objects,
|
||||
} = req.body;
|
||||
const { layoutName, description, objects } = req.body;
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
|
|
@ -263,21 +249,15 @@ export const updateLayout = async (
|
|||
UPDATE digital_twin_layout
|
||||
SET layout_name = $1,
|
||||
description = $2,
|
||||
hierarchy_config = $3,
|
||||
external_db_connection_id = $4,
|
||||
warehouse_key = $5,
|
||||
updated_by = $6,
|
||||
updated_by = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $7 AND company_code = $8
|
||||
WHERE id = $4 AND company_code = $5
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const layoutResult = await client.query(updateLayoutQuery, [
|
||||
layoutName,
|
||||
description,
|
||||
hierarchyConfig ? JSON.stringify(hierarchyConfig) : null,
|
||||
externalDbConnectionId || null,
|
||||
warehouseKey || null,
|
||||
userId,
|
||||
id,
|
||||
companyCode,
|
||||
|
|
@ -297,7 +277,7 @@ export const updateLayout = async (
|
|||
[id]
|
||||
);
|
||||
|
||||
// 새 객체 저장 (부모-자식 관계 처리)
|
||||
// 새 객체 저장
|
||||
if (objects && objects.length > 0) {
|
||||
const objectQuery = `
|
||||
INSERT INTO digital_twin_objects (
|
||||
|
|
@ -307,53 +287,12 @@ export const updateLayout = async (
|
|||
rotation, color,
|
||||
area_key, loca_key, loc_type,
|
||||
material_count, material_preview_height,
|
||||
parent_id, display_order, locked,
|
||||
hierarchy_level, parent_key, external_key
|
||||
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, $20, $21, $22)
|
||||
RETURNING id
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||
`;
|
||||
|
||||
// 임시 ID (음수) → 실제 DB ID 매핑
|
||||
const idMapping: { [tempId: number]: number } = {};
|
||||
|
||||
// 1단계: 부모 객체 먼저 저장 (parentId가 없는 것들)
|
||||
for (const obj of objects.filter((o) => !o.parentId)) {
|
||||
const result = 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,
|
||||
null, // parent_id
|
||||
obj.displayOrder || 0,
|
||||
obj.locked || false,
|
||||
obj.hierarchyLevel || 1,
|
||||
obj.parentKey || null,
|
||||
obj.externalKey || null,
|
||||
]);
|
||||
|
||||
// 임시 ID와 실제 DB ID 매핑
|
||||
if (obj.id) {
|
||||
idMapping[obj.id] = result.rows[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
// 2단계: 자식 객체 저장 (parentId가 있는 것들)
|
||||
for (const obj of objects.filter((o) => o.parentId)) {
|
||||
const realParentId = idMapping[obj.parentId!] || null;
|
||||
|
||||
for (const obj of objects) {
|
||||
await client.query(objectQuery, [
|
||||
id,
|
||||
obj.type,
|
||||
|
|
@ -371,12 +310,9 @@ export const updateLayout = async (
|
|||
obj.locType || null,
|
||||
obj.materialCount || 0,
|
||||
obj.materialPreview?.height || null,
|
||||
realParentId, // 실제 DB ID 사용
|
||||
obj.parentId || null,
|
||||
obj.displayOrder || 0,
|
||||
obj.locked || false,
|
||||
obj.hierarchyLevel || 1,
|
||||
obj.parentKey || null,
|
||||
obj.externalKey || null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -408,7 +344,7 @@ export const updateLayout = async (
|
|||
|
||||
// 레이아웃 삭제
|
||||
export const deleteLayout = async (
|
||||
req: AuthenticatedRequest,
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { Request, Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
|
|
@ -7,7 +6,7 @@ import { logger } from "../utils/logger";
|
|||
* 엔티티 검색 API
|
||||
* GET /api/entity-search/:tableName
|
||||
*/
|
||||
export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||
export async function searchEntity(req: Request, res: Response) {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const {
|
||||
|
|
@ -23,8 +22,7 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
|||
logger.warn("엔티티 검색 실패: 테이블명이 없음", { tableName });
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"테이블명이 지정되지 않았습니다. 컴포넌트 설정에서 sourceTable을 확인해주세요.",
|
||||
message: "테이블명이 지정되지 않았습니다. 컴포넌트 설정에서 sourceTable을 확인해주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -113,10 +111,8 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
|||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("엔티티 검색 오류", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
logger.error("엔티티 검색 오류", { error: error.message, stack: error.stack });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { Request, Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
|
|
@ -36,7 +35,7 @@ async function generateOrderNumber(companyCode: string): Promise<string> {
|
|||
* 수주 등록 API
|
||||
* POST /api/orders
|
||||
*/
|
||||
export async function createOrder(req: AuthenticatedRequest, res: Response) {
|
||||
export async function createOrder(req: Request, res: Response) {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
|
|
@ -168,7 +167,7 @@ export async function createOrder(req: AuthenticatedRequest, res: Response) {
|
|||
* 수주 목록 조회 API
|
||||
* GET /api/orders
|
||||
*/
|
||||
export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
||||
export async function getOrders(req: Request, res: Response) {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
|
|
@ -236,3 +235,4 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,17 +14,8 @@ router.get(
|
|||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const {
|
||||
leftTable,
|
||||
rightTable,
|
||||
leftColumn,
|
||||
rightColumn,
|
||||
leftValue,
|
||||
dataFilter,
|
||||
enableEntityJoin,
|
||||
displayColumns,
|
||||
deduplication,
|
||||
} = req.query;
|
||||
const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter, enableEntityJoin, displayColumns, deduplication } =
|
||||
req.query;
|
||||
|
||||
// 입력값 검증
|
||||
if (!leftTable || !rightTable || !leftColumn || !rightColumn) {
|
||||
|
|
@ -47,9 +38,7 @@ router.get(
|
|||
}
|
||||
|
||||
// 🆕 enableEntityJoin 파싱
|
||||
const enableEntityJoinFlag =
|
||||
enableEntityJoin === "true" ||
|
||||
(typeof enableEntityJoin === "boolean" && enableEntityJoin);
|
||||
const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true;
|
||||
|
||||
// SQL 인젝션 방지를 위한 검증
|
||||
const tables = [leftTable as string, rightTable as string];
|
||||
|
|
@ -79,9 +68,7 @@ router.get(
|
|||
const userCompany = req.user?.companyCode;
|
||||
|
||||
// displayColumns 파싱 (item_info.item_name 등)
|
||||
let parsedDisplayColumns:
|
||||
| Array<{ name: string; label?: string }>
|
||||
| undefined;
|
||||
let parsedDisplayColumns: Array<{ name: string; label?: string }> | undefined;
|
||||
if (displayColumns) {
|
||||
try {
|
||||
parsedDisplayColumns = JSON.parse(displayColumns as string);
|
||||
|
|
@ -91,14 +78,12 @@ router.get(
|
|||
}
|
||||
|
||||
// 🆕 deduplication 파싱
|
||||
let parsedDeduplication:
|
||||
| {
|
||||
enabled: boolean;
|
||||
groupByColumn: string;
|
||||
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||
sortColumn?: string;
|
||||
}
|
||||
| undefined;
|
||||
let parsedDeduplication: {
|
||||
enabled: boolean;
|
||||
groupByColumn: string;
|
||||
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||
sortColumn?: string;
|
||||
} | undefined;
|
||||
if (deduplication) {
|
||||
try {
|
||||
parsedDeduplication = JSON.parse(deduplication as string);
|
||||
|
|
@ -355,37 +340,30 @@ router.get(
|
|||
}
|
||||
|
||||
const { enableEntityJoin, groupByColumns } = req.query;
|
||||
const enableEntityJoinFlag =
|
||||
enableEntityJoin === "true" ||
|
||||
(typeof enableEntityJoin === "boolean" && enableEntityJoin);
|
||||
|
||||
const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true;
|
||||
|
||||
// groupByColumns 파싱 (JSON 문자열 또는 쉼표 구분)
|
||||
let groupByColumnsArray: string[] = [];
|
||||
if (groupByColumns) {
|
||||
try {
|
||||
if (typeof groupByColumns === "string") {
|
||||
// JSON 형식이면 파싱, 아니면 쉼표로 분리
|
||||
groupByColumnsArray = groupByColumns.startsWith("[")
|
||||
? JSON.parse(groupByColumns)
|
||||
: groupByColumns.split(",").map((c) => c.trim());
|
||||
groupByColumnsArray = groupByColumns.startsWith("[")
|
||||
? JSON.parse(groupByColumns)
|
||||
: groupByColumns.split(",").map(c => c.trim());
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("groupByColumns 파싱 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, {
|
||||
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, {
|
||||
enableEntityJoin: enableEntityJoinFlag,
|
||||
groupByColumns: groupByColumnsArray,
|
||||
groupByColumns: groupByColumnsArray
|
||||
});
|
||||
|
||||
// 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 포함)
|
||||
const result = await dataService.getRecordDetail(
|
||||
tableName,
|
||||
id,
|
||||
enableEntityJoinFlag,
|
||||
groupByColumnsArray
|
||||
);
|
||||
const result = await dataService.getRecordDetail(tableName, id, enableEntityJoinFlag, groupByColumnsArray);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
|
|
@ -418,7 +396,7 @@ router.get(
|
|||
/**
|
||||
* 그룹화된 데이터 UPSERT API
|
||||
* POST /api/data/upsert-grouped
|
||||
*
|
||||
*
|
||||
* 요청 본문:
|
||||
* {
|
||||
* tableName: string,
|
||||
|
|
@ -437,8 +415,7 @@ router.post(
|
|||
if (!tableName || !parentKeys || !records || !Array.isArray(records)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"필수 파라미터가 누락되었습니다 (tableName, parentKeys, records).",
|
||||
message: "필수 파라미터가 누락되었습니다 (tableName, parentKeys, records).",
|
||||
error: "MISSING_PARAMETERS",
|
||||
});
|
||||
}
|
||||
|
|
@ -473,17 +450,17 @@ router.post(
|
|||
}
|
||||
|
||||
console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, {
|
||||
inserted: result.data?.inserted || 0,
|
||||
updated: result.data?.updated || 0,
|
||||
deleted: result.data?.deleted || 0,
|
||||
inserted: result.inserted,
|
||||
updated: result.updated,
|
||||
deleted: result.deleted,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "데이터가 저장되었습니다.",
|
||||
inserted: result.data?.inserted || 0,
|
||||
updated: result.data?.updated || 0,
|
||||
deleted: result.data?.deleted || 0,
|
||||
inserted: result.inserted,
|
||||
updated: result.updated,
|
||||
deleted: result.deleted,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("그룹화된 데이터 UPSERT 오류:", error);
|
||||
|
|
@ -529,22 +506,16 @@ router.post(
|
|||
|
||||
// company_code와 company_name 자동 추가 (멀티테넌시)
|
||||
const enrichedData = { ...data };
|
||||
|
||||
|
||||
// 테이블에 company_code 컬럼이 있는지 확인하고 자동으로 추가
|
||||
const hasCompanyCode = await dataService.checkColumnExists(
|
||||
tableName,
|
||||
"company_code"
|
||||
);
|
||||
const hasCompanyCode = await dataService.checkColumnExists(tableName, "company_code");
|
||||
if (hasCompanyCode && req.user?.companyCode) {
|
||||
enrichedData.company_code = req.user.companyCode;
|
||||
console.log(`🏢 company_code 자동 추가: ${req.user.companyCode}`);
|
||||
}
|
||||
|
||||
|
||||
// 테이블에 company_name 컬럼이 있는지 확인하고 자동으로 추가
|
||||
const hasCompanyName = await dataService.checkColumnExists(
|
||||
tableName,
|
||||
"company_name"
|
||||
);
|
||||
const hasCompanyName = await dataService.checkColumnExists(tableName, "company_name");
|
||||
if (hasCompanyName && req.user?.companyName) {
|
||||
enrichedData.company_name = req.user.companyName;
|
||||
console.log(`🏢 company_name 자동 추가: ${req.user.companyName}`);
|
||||
|
|
@ -708,10 +679,7 @@ router.post(
|
|||
|
||||
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions });
|
||||
|
||||
const result = await dataService.deleteGroupRecords(
|
||||
tableName,
|
||||
filterConditions
|
||||
);
|
||||
const result = await dataService.deleteGroupRecords(tableName, filterConditions);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ import {
|
|||
|
||||
// 외부 DB 데이터 조회
|
||||
import {
|
||||
getHierarchyData,
|
||||
getChildrenData,
|
||||
getWarehouses,
|
||||
getAreas,
|
||||
getLocations,
|
||||
|
|
@ -34,12 +32,6 @@ router.put("/layouts/:id", updateLayout); // 레이아웃 수정
|
|||
router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제
|
||||
|
||||
// ========== 외부 DB 데이터 조회 API ==========
|
||||
|
||||
// 동적 계층 구조 API
|
||||
router.post("/data/hierarchy", getHierarchyData); // 전체 계층 데이터 조회
|
||||
router.post("/data/children", getChildrenData); // 특정 부모의 하위 데이터 조회
|
||||
|
||||
// 테이블 메타데이터 API
|
||||
router.get("/data/tables/:connectionId", async (req, res) => {
|
||||
// 테이블 목록 조회
|
||||
try {
|
||||
|
|
@ -64,12 +56,11 @@ router.get("/data/table-preview/:connectionId/:tableName", async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// 레거시 API (호환성 유지)
|
||||
router.get("/data/warehouses", getWarehouses); // 창고 목록
|
||||
router.get("/data/areas", getAreas); // Area 목록
|
||||
router.get("/data/locations", getLocations); // Location 목록
|
||||
router.get("/data/materials", getMaterials); // 자재 목록 (특정 Location)
|
||||
router.post("/data/material-counts", getMaterialCounts); // 자재 개수 (여러 Location) - POST로 변경
|
||||
router.get("/data/material-counts", getMaterialCounts); // 자재 개수 (여러 Location)
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
|
|||
|
|
@ -300,9 +300,10 @@ class NumberingRuleService {
|
|||
FROM numbering_rules
|
||||
WHERE
|
||||
scope_type = 'global'
|
||||
OR scope_type = 'table'
|
||||
OR (scope_type = 'menu' AND menu_objid = ANY($1))
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ✅ 메뉴별로 필터링
|
||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ⚠️ 임시: table 스코프도 menu_objid로 필터링
|
||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ⚠️ 임시: 기존 규칙(menu_objid NULL) 포함
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
|
||||
|
|
@ -312,9 +313,9 @@ class NumberingRuleService {
|
|||
created_at DESC
|
||||
`;
|
||||
params = [siblingObjids];
|
||||
logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids });
|
||||
logger.info("최고 관리자: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { siblingObjids });
|
||||
} else {
|
||||
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링)
|
||||
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함)
|
||||
query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
|
|
@ -335,9 +336,10 @@ class NumberingRuleService {
|
|||
WHERE company_code = $1
|
||||
AND (
|
||||
scope_type = 'global'
|
||||
OR scope_type = 'table'
|
||||
OR (scope_type = 'menu' AND menu_objid = ANY($2))
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ✅ 메뉴별로 필터링
|
||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ⚠️ 임시: table 스코프도 menu_objid로 필터링
|
||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ⚠️ 임시: 기존 규칙(menu_objid NULL) 포함
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
|
|
@ -348,7 +350,7 @@ class NumberingRuleService {
|
|||
created_at DESC
|
||||
`;
|
||||
params = [companyCode, siblingObjids];
|
||||
logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids });
|
||||
logger.info("회사별: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { companyCode, siblingObjids });
|
||||
}
|
||||
|
||||
logger.info("🔍 채번 규칙 쿼리 실행", {
|
||||
|
|
|
|||
|
|
@ -18,26 +18,32 @@ import { Pagination, PaginationInfo } from "@/components/common/Pagination";
|
|||
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
|
||||
import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react";
|
||||
|
||||
interface DashboardListClientProps {
|
||||
initialDashboards: Dashboard[];
|
||||
initialPagination: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 목록 클라이언트 컴포넌트
|
||||
* - CSR 방식으로 초기 데이터 로드
|
||||
* - 대시보드 목록 조회
|
||||
* - 대시보드 생성/수정/삭제/복사
|
||||
*/
|
||||
export default function DashboardListClient() {
|
||||
export default function DashboardListClient({ initialDashboards, initialPagination }: DashboardListClientProps) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
// 상태 관리
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>(initialDashboards);
|
||||
const [loading, setLoading] = useState(false); // 초기 로딩은 서버에서 완료
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// 페이지네이션 상태
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(initialPagination.page);
|
||||
const [pageSize, setPageSize] = useState(initialPagination.limit);
|
||||
const [totalCount, setTotalCount] = useState(initialPagination.total);
|
||||
|
||||
// 모달 상태
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
|
@ -67,8 +73,17 @@ export default function DashboardListClient() {
|
|||
}
|
||||
};
|
||||
|
||||
// 검색어/페이지 변경 시 fetch (초기 로딩 포함)
|
||||
// 초기 로드 여부 추적
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// 초기 로드는 건너뛰기 (서버에서 이미 데이터를 가져왔음)
|
||||
if (isInitialLoad) {
|
||||
setIsInitialLoad(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 이후 검색어/페이지 변경 시에만 fetch
|
||||
loadDashboards();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchTerm, currentPage, pageSize]);
|
||||
|
|
@ -76,7 +91,7 @@ export default function DashboardListClient() {
|
|||
// 페이지네이션 정보 계산
|
||||
const paginationInfo: PaginationInfo = {
|
||||
currentPage,
|
||||
totalPages: Math.ceil(totalCount / pageSize) || 1,
|
||||
totalPages: Math.ceil(totalCount / pageSize),
|
||||
totalItems: totalCount,
|
||||
itemsPerPage: pageSize,
|
||||
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
|
||||
|
|
|
|||
|
|
@ -1,22 +1,73 @@
|
|||
import DashboardListClient from "@/app/(main)/admin/dashboard/DashboardListClient";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
/**
|
||||
* 대시보드 관리 페이지
|
||||
* - 클라이언트 컴포넌트를 렌더링하는 래퍼
|
||||
* - 초기 로딩부터 CSR로 처리
|
||||
* 서버에서 초기 대시보드 목록 fetch
|
||||
*/
|
||||
export default function DashboardListPage() {
|
||||
async function getInitialDashboards() {
|
||||
try {
|
||||
// 서버 사이드 전용: 백엔드 API 직접 호출
|
||||
// 도커 네트워크 내부에서는 서비스 이름 사용, 로컬에서는 127.0.0.1
|
||||
const backendUrl = process.env.SERVER_API_URL || "http://backend:8080";
|
||||
|
||||
// 쿠키에서 authToken 추출
|
||||
const cookieStore = await cookies();
|
||||
const authToken = cookieStore.get("authToken")?.value;
|
||||
|
||||
if (!authToken) {
|
||||
// 토큰이 없으면 빈 데이터 반환 (클라이언트에서 로드)
|
||||
return {
|
||||
dashboards: [],
|
||||
pagination: { total: 0, page: 1, limit: 10 },
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(`${backendUrl}/api/dashboards/my?page=1&limit=10`, {
|
||||
cache: "no-store", // 항상 최신 데이터
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${authToken}`, // Authorization 헤더로 전달
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch dashboards: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
dashboards: data.data || [],
|
||||
pagination: data.pagination || { total: 0, page: 1, limit: 10 },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Server-side fetch error:", error);
|
||||
// 에러 발생 시 빈 데이터 반환 (클라이언트에서 재시도 가능)
|
||||
return {
|
||||
dashboards: [],
|
||||
pagination: { total: 0, page: 1, limit: 10 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 대시보드 관리 페이지 (서버 컴포넌트)
|
||||
* - 페이지 헤더 + 초기 데이터를 서버에서 렌더링
|
||||
* - 클라이언트 컴포넌트로 초기 데이터 전달
|
||||
*/
|
||||
export default async function DashboardListPage() {
|
||||
const initialData = await getInitialDashboards();
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen flex-col">
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
{/* 페이지 헤더 (서버에서 렌더링) */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">대시보드 관리</h1>
|
||||
<p className="text-muted-foreground text-sm">대시보드를 생성하고 관리할 수 있습니다</p>
|
||||
</div>
|
||||
|
||||
{/* 클라이언트 컴포넌트 */}
|
||||
<DashboardListClient />
|
||||
{/* 나머지 컨텐츠 (클라이언트 컴포넌트 + 서버 데이터) */}
|
||||
<DashboardListClient initialDashboards={initialData.dashboards} initialPagination={initialData.pagination} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,27 +1,138 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { EntitySearchInputComponent } from "@/lib/registry/components/entity-search-input";
|
||||
import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function TestEntitySearchPage() {
|
||||
const [customerCode, setCustomerCode] = useState<string>("");
|
||||
const [customerData, setCustomerData] = useState<any>(null);
|
||||
|
||||
const [itemCode, setItemCode] = useState<string>("");
|
||||
const [itemData, setItemData] = useState<any>(null);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-6">
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">EntitySearchInput 테스트</h1>
|
||||
<p className="text-muted-foreground mt-2">이 페이지는 빌드 에러로 인해 임시로 비활성화되었습니다.</p>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
엔티티 검색 입력 컴포넌트 동작 테스트
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 거래처 검색 테스트 - 자동완성 방식 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>빌드 에러 수정 중</CardTitle>
|
||||
<CardDescription>순환 참조 문제를 해결한 후 다시 활성화됩니다.</CardDescription>
|
||||
<CardTitle>거래처 검색 (자동완성 드롭다운 방식) ⭐ NEW</CardTitle>
|
||||
<CardDescription>
|
||||
타이핑하면 바로 드롭다운이 나타나는 방식 - 수주 등록에서 사용
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
에러 메시지: ReferenceError: Cannot access 'h' before initialization
|
||||
</p>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>거래처</Label>
|
||||
<AutocompleteSearchInputComponent
|
||||
tableName="customer_mng"
|
||||
displayField="customer_name"
|
||||
valueField="customer_code"
|
||||
searchFields={["customer_name", "customer_code", "business_number"]}
|
||||
placeholder="거래처명 입력하여 검색"
|
||||
showAdditionalInfo
|
||||
additionalFields={["customer_code", "address", "contact_phone"]}
|
||||
value={customerCode}
|
||||
onChange={(code, fullData) => {
|
||||
setCustomerCode(code || "");
|
||||
setCustomerData(fullData);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{customerData && (
|
||||
<div className="mt-4 p-4 bg-muted rounded-md">
|
||||
<h3 className="font-semibold mb-2">선택된 거래처 정보:</h3>
|
||||
<pre className="text-xs">
|
||||
{JSON.stringify(customerData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 거래처 검색 테스트 - 모달 방식 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>거래처 검색 (모달 방식)</CardTitle>
|
||||
<CardDescription>
|
||||
버튼 클릭 → 모달 열기 → 검색 및 선택 방식
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>거래처</Label>
|
||||
<EntitySearchInputComponent
|
||||
tableName="customer_mng"
|
||||
displayField="customer_name"
|
||||
valueField="customer_code"
|
||||
searchFields={["customer_name", "customer_code", "business_number"]}
|
||||
mode="combo"
|
||||
placeholder="거래처를 검색하세요"
|
||||
modalTitle="거래처 검색 및 선택"
|
||||
modalColumns={["customer_code", "customer_name", "address", "contact_phone"]}
|
||||
showAdditionalInfo
|
||||
additionalFields={["address", "contact_phone", "business_number"]}
|
||||
value={customerCode}
|
||||
onChange={(code, fullData) => {
|
||||
setCustomerCode(code || "");
|
||||
setCustomerData(fullData);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 품목 검색 테스트 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>품목 검색 (Modal 모드)</CardTitle>
|
||||
<CardDescription>
|
||||
item_info 테이블에서 품목을 검색합니다
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>품목</Label>
|
||||
<EntitySearchInputComponent
|
||||
tableName="item_info"
|
||||
displayField="item_name"
|
||||
valueField="id"
|
||||
searchFields={["item_name", "id", "item_number"]}
|
||||
mode="modal"
|
||||
placeholder="품목 선택"
|
||||
modalTitle="품목 검색"
|
||||
modalColumns={["id", "item_name", "item_number", "unit", "selling_price"]}
|
||||
showAdditionalInfo
|
||||
additionalFields={["item_number", "unit", "selling_price"]}
|
||||
value={itemCode}
|
||||
onChange={(code, fullData) => {
|
||||
setItemCode(code || "");
|
||||
setItemData(fullData);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{itemData && (
|
||||
<div className="mt-4 p-4 bg-muted rounded-md">
|
||||
<h3 className="font-semibold mb-2">선택된 품목 정보:</h3>
|
||||
<pre className="text-xs">
|
||||
{JSON.stringify(itemData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,27 +1,87 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { OrderRegistrationModal } from "@/components/order/OrderRegistrationModal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function TestOrderRegistrationPage() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const handleSuccess = () => {
|
||||
console.log("수주 등록 성공!");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-6">
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">수주 등록 테스트</h1>
|
||||
<p className="text-muted-foreground mt-2">이 페이지는 빌드 에러로 인해 임시로 비활성화되었습니다.</p>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
EntitySearchInput + ModalRepeaterTable을 활용한 수주 등록 화면
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>빌드 에러 수정 중</CardTitle>
|
||||
<CardDescription>ModalRepeaterTable 순환 참조 문제를 해결한 후 다시 활성화됩니다.</CardDescription>
|
||||
<CardTitle>수주 등록 모달</CardTitle>
|
||||
<CardDescription>
|
||||
모달 버튼을 클릭하여 수주 등록 화면을 테스트하세요
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
에러 메시지: ReferenceError: Cannot access 'h' before initialization
|
||||
</p>
|
||||
<Button onClick={() => setModalOpen(true)}>
|
||||
수주 등록 모달 열기
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>구현된 기능</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-primary">✓</span>
|
||||
<span>EntitySearchInput: 거래처 검색 및 선택 (콤보 모드)</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-primary">✓</span>
|
||||
<span>ModalRepeaterTable: 품목 검색 및 동적 추가</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-primary">✓</span>
|
||||
<span>자동 계산: 수량 × 단가 = 금액</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-primary">✓</span>
|
||||
<span>인라인 편집: 수량, 단가, 납품일, 비고 수정 가능</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-primary">✓</span>
|
||||
<span>중복 방지: 이미 추가된 품목은 선택 불가</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-primary">✓</span>
|
||||
<span>행 삭제: 추가된 품목 개별 삭제 가능</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-primary">✓</span>
|
||||
<span>전체 금액 표시: 모든 품목 금액의 합계</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-primary">✓</span>
|
||||
<span>입력 방식 전환: 거래처 우선 / 견대 방식 / 단가 방식</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 수주 등록 모달 */}
|
||||
<OrderRegistrationModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -199,14 +199,14 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
|||
onValueChange={(value: "current" | "external") => onChange({ connectionType: value })}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="current" id={`current-${dataSource.id}`} />
|
||||
<Label htmlFor={`current-${dataSource.id}`} className="text-xs font-normal">
|
||||
<RadioGroupItem value="current" id={"current-${dataSource.id}"} />
|
||||
<Label htmlFor={"current-${dataSource.id}"} className="text-xs font-normal">
|
||||
현재 데이터베이스
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="external" id={`external-${dataSource.id}`} />
|
||||
<Label htmlFor={`external-${dataSource.id}`} className="text-xs font-normal">
|
||||
<RadioGroupItem value="external" id={"external-${dataSource.id}"} />
|
||||
<Label htmlFor={"external-${dataSource.id}"} className="text-xs font-normal">
|
||||
외부 데이터베이스
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -216,7 +216,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
|||
{/* 외부 DB 선택 */}
|
||||
{dataSource.connectionType === "external" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`external-conn-${dataSource.id}`} className="text-xs">
|
||||
<Label htmlFor={"external-conn-${dataSource.id}"} className="text-xs">
|
||||
외부 데이터베이스 선택 *
|
||||
</Label>
|
||||
{loadingConnections ? (
|
||||
|
|
@ -246,7 +246,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
|
|||
{/* SQL 쿼리 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={`query-${dataSource.id}`} className="text-xs">
|
||||
<Label htmlFor={"query-${dataSource.id}"} className="text-xs">
|
||||
SQL 쿼리 *
|
||||
</Label>
|
||||
<Select
|
||||
|
|
@ -313,7 +313,7 @@ ORDER BY 하위부서수 DESC`,
|
|||
</Select>
|
||||
</div>
|
||||
<Textarea
|
||||
id={`query-${dataSource.id}`}
|
||||
id={"query-${dataSource.id}"}
|
||||
value={dataSource.query || ""}
|
||||
onChange={(e) => onChange({ query: e.target.value })}
|
||||
placeholder="SELECT * FROM table_name WHERE ..."
|
||||
|
|
@ -340,9 +340,6 @@ ORDER BY 하위부서수 DESC`,
|
|||
<SelectItem value="arrow" className="text-xs">
|
||||
화살표
|
||||
</SelectItem>
|
||||
<SelectItem value="truck" className="text-xs">
|
||||
트럭
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-[10px]">지도에 표시할 마커의 모양을 선택합니다</p>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -34,8 +34,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
const [showInfoPanel, setShowInfoPanel] = useState(false);
|
||||
const [externalDbConnectionId, setExternalDbConnectionId] = useState<number | null>(null);
|
||||
const [layoutName, setLayoutName] = useState<string>("");
|
||||
const [hierarchyConfig, setHierarchyConfig] = useState<any>(null);
|
||||
|
||||
|
||||
// 검색 및 필터
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filterType, setFilterType] = useState<string>("all");
|
||||
|
|
@ -50,51 +49,39 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
if (response.success && response.data) {
|
||||
const { layout, objects } = response.data;
|
||||
|
||||
// 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원)
|
||||
setLayoutName(layout.layout_name || layout.layoutName);
|
||||
setExternalDbConnectionId(layout.external_db_connection_id || layout.externalDbConnectionId);
|
||||
|
||||
// hierarchy_config 저장
|
||||
if (layout.hierarchy_config) {
|
||||
const config =
|
||||
typeof layout.hierarchy_config === "string"
|
||||
? JSON.parse(layout.hierarchy_config)
|
||||
: layout.hierarchy_config;
|
||||
setHierarchyConfig(config);
|
||||
}
|
||||
// 레이아웃 정보 저장
|
||||
setLayoutName(layout.layoutName);
|
||||
setExternalDbConnectionId(layout.externalDbConnectionId);
|
||||
|
||||
// 객체 데이터 변환
|
||||
const loadedObjects: PlacedObject[] = objects.map((obj: any) => {
|
||||
const objectType = obj.object_type;
|
||||
return {
|
||||
id: obj.id,
|
||||
type: objectType,
|
||||
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: getObjectColor(objectType), // 타입별 기본 색상 사용
|
||||
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,
|
||||
};
|
||||
});
|
||||
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 {
|
||||
|
|
@ -114,30 +101,16 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
};
|
||||
|
||||
loadLayout();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [layoutId]); // toast 제거 - 무한 루프 방지
|
||||
}, [layoutId, toast]);
|
||||
|
||||
// Location의 자재 목록 로드
|
||||
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
|
||||
if (!hierarchyConfig?.material) {
|
||||
console.warn("hierarchyConfig.material이 없습니다. 자재 로드를 건너뜁니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingMaterials(true);
|
||||
setShowInfoPanel(true);
|
||||
|
||||
const response = await getMaterials(externalDbConnectionId, {
|
||||
tableName: hierarchyConfig.material.tableName,
|
||||
keyColumn: hierarchyConfig.material.keyColumn,
|
||||
locationKeyColumn: hierarchyConfig.material.locationKeyColumn,
|
||||
layerColumn: hierarchyConfig.material.layerColumn,
|
||||
locaKey: locaKey,
|
||||
});
|
||||
const response = await getMaterials(externalDbConnectionId, locaKey);
|
||||
if (response.success && response.data) {
|
||||
const layerColumn = hierarchyConfig.material.layerColumn || "LOLAYER";
|
||||
const sortedMaterials = response.data.sort((a: any, b: any) => (a[layerColumn] || 0) - (b[layerColumn] || 0));
|
||||
const sortedMaterials = response.data.sort((a, b) => a.LOLAYER - b.LOLAYER);
|
||||
setMaterials(sortedMaterials);
|
||||
} else {
|
||||
setMaterials([]);
|
||||
|
|
@ -223,49 +196,6 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
});
|
||||
}, [placedObjects, filterType, searchQuery]);
|
||||
|
||||
// 객체 타입별 기본 색상 (useMemo로 최적화)
|
||||
const getObjectColor = useMemo(() => {
|
||||
return (type: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
area: "#3b82f6", // 파란색
|
||||
"location-bed": "#2563eb", // 진한 파란색
|
||||
"location-stp": "#6b7280", // 회색
|
||||
"location-temp": "#f59e0b", // 주황색
|
||||
"location-dest": "#10b981", // 초록색
|
||||
"crane-mobile": "#8b5cf6", // 보라색
|
||||
rack: "#ef4444", // 빨간색
|
||||
};
|
||||
return colorMap[type] || "#3b82f6";
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 3D 캔버스용 placements 변환 (useMemo로 최적화)
|
||||
const canvasPlacements = useMemo(() => {
|
||||
return 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]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
@ -287,13 +217,13 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
{/* 메인 영역 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 좌측: 검색/필터 */}
|
||||
<div className="flex h-full w-64 flex-shrink-0 flex-col border-r">
|
||||
<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 top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<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)}
|
||||
|
|
@ -304,7 +234,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-1/2 right-1 h-7 w-7 -translate-y-1/2 p-0"
|
||||
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" />
|
||||
|
|
@ -351,7 +281,9 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
|
||||
{/* 객체 목록 */}
|
||||
<div className="flex-1 overflow-y-auto border-t p-4">
|
||||
<Label className="mb-2 block text-sm font-semibold">객체 목록 ({filteredObjects.length})</Label>
|
||||
<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 ? "검색 결과가 없습니다" : "객체가 없습니다"}
|
||||
|
|
@ -374,7 +306,9 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
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"
|
||||
selectedObject?.id === obj.id
|
||||
? "ring-primary bg-primary/5 ring-2"
|
||||
: "hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
|
|
@ -383,13 +317,13 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: getObjectColor(obj.type) }}
|
||||
style={{ backgroundColor: obj.color }}
|
||||
/>
|
||||
<span>{typeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 추가 정보 */}
|
||||
<div className="mt-2 space-y-1">
|
||||
{obj.areaKey && (
|
||||
|
|
@ -420,7 +354,33 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
<div className="relative flex-1">
|
||||
{!isLoading && (
|
||||
<Yard3DCanvas
|
||||
placements={canvasPlacements}
|
||||
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}
|
||||
|
|
@ -430,12 +390,17 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
</div>
|
||||
|
||||
{/* 우측: 정보 패널 */}
|
||||
<div className="h-full w-[480px] flex-shrink-0 overflow-y-auto border-l">
|
||||
{selectedObject ? (
|
||||
{showInfoPanel && selectedObject && (
|
||||
<div className="h-full w-[25%] overflow-y-auto border-l">
|
||||
<div className="p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold">상세 정보</h3>
|
||||
<p className="text-muted-foreground text-xs">{selectedObject.name}</p>
|
||||
<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>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
|
|
@ -464,74 +429,72 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 자재 목록 (Location인 경우) - 아코디언 */}
|
||||
{/* 자재 목록 (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 연결이 설정되지 않았습니다"}
|
||||
{externalDbConnectionId
|
||||
? "자재가 없습니다"
|
||||
: "외부 DB 연결이 설정되지 않았습니다"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label className="mb-2 block text-sm font-semibold">자재 목록 ({materials.length}개)</Label>
|
||||
{materials.map((material, index) => {
|
||||
const displayColumns = hierarchyConfig?.material?.displayColumns || [];
|
||||
return (
|
||||
<details
|
||||
key={`${material.STKKEY}-${index}`}
|
||||
className="bg-muted group hover:bg-accent rounded-lg border transition-colors"
|
||||
>
|
||||
<summary className="flex cursor-pointer items-center justify-between p-3 text-sm font-medium">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">
|
||||
층 {material[hierarchyConfig?.material?.layerColumn || "LOLAYER"]}
|
||||
</span>
|
||||
{displayColumns[0] && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{material[displayColumns[0].column]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="text-muted-foreground h-4 w-4 transition-transform group-open:rotate-180"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div className="space-y-2 border-t p-3 pt-3">
|
||||
{displayColumns.map((colConfig: any) => (
|
||||
<div key={colConfig.column} className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">{colConfig.label}:</span>
|
||||
<span className="font-medium">{material[colConfig.column] || "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
{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>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</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 className="flex h-full items-center justify-center p-4">
|
||||
<p className="text-muted-foreground text-sm">객체를 선택하세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,410 +0,0 @@
|
|||
# 디지털 트윈 동적 계층 구조 마이그레이션 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
**기존 구조**: Area(구역) → Location(위치) 고정 2단계
|
||||
**신규 구조**: 동적 N-Level 계층 (영역 → 하위 영역 → ... → 자재)
|
||||
|
||||
---
|
||||
|
||||
## 1. 데이터베이스 마이그레이션
|
||||
|
||||
### 실행 방법
|
||||
```bash
|
||||
# PostgreSQL 컨테이너 접속
|
||||
docker exec -it pms-db psql -U postgres -d erp
|
||||
|
||||
# 마이그레이션 실행
|
||||
\i db/migrations/042_refactor_digital_twin_hierarchy.sql
|
||||
```
|
||||
|
||||
### 변경 사항
|
||||
- `digital_twin_layout` 테이블에 `hierarchy_config` JSONB 컬럼 추가
|
||||
- 기존 테이블 매핑 컬럼들 제거 (warehouse_table_name, area_table_name 등)
|
||||
- `digital_twin_objects` 테이블에 계층 관련 컬럼 추가:
|
||||
- `hierarchy_level`: 계층 레벨 (1, 2, 3, ...)
|
||||
- `parent_key`: 부모 객체의 외부 DB 키
|
||||
- `external_key`: 자신의 외부 DB 키
|
||||
|
||||
---
|
||||
|
||||
## 2. 백엔드 API 변경 사항
|
||||
|
||||
### 신규 API 엔드포인트
|
||||
|
||||
#### 전체 계층 데이터 조회
|
||||
```
|
||||
POST /api/digital-twin/data/hierarchy
|
||||
Request Body:
|
||||
{
|
||||
"externalDbConnectionId": 15,
|
||||
"hierarchyConfig": "{...}" // JSON 문자열
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"warehouse": [...],
|
||||
"levels": [
|
||||
{ "level": 1, "name": "구역", "data": [...] },
|
||||
{ "level": 2, "name": "위치", "data": [...] }
|
||||
],
|
||||
"materials": [
|
||||
{ "location_key": "LOC001", "count": 150 }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 특정 부모의 하위 데이터 조회
|
||||
```
|
||||
POST /api/digital-twin/data/children
|
||||
Request Body:
|
||||
{
|
||||
"externalDbConnectionId": 15,
|
||||
"hierarchyConfig": "{...}",
|
||||
"parentLevel": 1,
|
||||
"parentKey": "AREA001"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": [...] // 다음 레벨 데이터
|
||||
}
|
||||
```
|
||||
|
||||
### 레거시 API (호환성 유지)
|
||||
- `/api/digital-twin/data/warehouses` (GET)
|
||||
- `/api/digital-twin/data/areas` (GET)
|
||||
- `/api/digital-twin/data/locations` (GET)
|
||||
- `/api/digital-twin/data/materials` (GET)
|
||||
- `/api/digital-twin/data/material-counts` (POST로 변경)
|
||||
|
||||
---
|
||||
|
||||
## 3. 프론트엔드 변경 사항
|
||||
|
||||
### 새로운 컴포넌트
|
||||
|
||||
#### `HierarchyConfigPanel.tsx`
|
||||
동적 계층 구조 설정 UI
|
||||
|
||||
**사용 방법:**
|
||||
```tsx
|
||||
import HierarchyConfigPanel from "./HierarchyConfigPanel";
|
||||
|
||||
<HierarchyConfigPanel
|
||||
externalDbConnectionId={selectedDbConnection}
|
||||
hierarchyConfig={hierarchyConfig}
|
||||
onHierarchyConfigChange={setHierarchyConfig}
|
||||
availableTables={availableTables}
|
||||
onLoadTables={loadTablesFromDb}
|
||||
onLoadColumns={loadColumnsFromTable}
|
||||
/>
|
||||
```
|
||||
|
||||
### 계층 구조 설정 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"warehouse": {
|
||||
"tableName": "MWARMA",
|
||||
"keyColumn": "WAREKEY",
|
||||
"nameColumn": "WARENAME"
|
||||
},
|
||||
"levels": [
|
||||
{
|
||||
"level": 1,
|
||||
"name": "구역",
|
||||
"tableName": "MAREMA",
|
||||
"keyColumn": "AREAKEY",
|
||||
"nameColumn": "AREANAME",
|
||||
"parentKeyColumn": "WAREKEY",
|
||||
"objectTypes": ["area"]
|
||||
},
|
||||
{
|
||||
"level": 2,
|
||||
"name": "위치",
|
||||
"tableName": "MLOCMA",
|
||||
"keyColumn": "LOCAKEY",
|
||||
"nameColumn": "LOCANAME",
|
||||
"parentKeyColumn": "AREAKEY",
|
||||
"typeColumn": "LOCTYPE",
|
||||
"objectTypes": ["location-bed", "location-stp"]
|
||||
}
|
||||
],
|
||||
"material": {
|
||||
"tableName": "WSTKKY",
|
||||
"keyColumn": "STKKEY",
|
||||
"locationKeyColumn": "LOCAKEY",
|
||||
"layerColumn": "LOLAYER",
|
||||
"quantityColumn": "STKQUAN"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 공간적 종속성 (Spatial Containment)
|
||||
|
||||
### 새로운 유틸리티: `spatialContainment.ts`
|
||||
|
||||
#### 주요 함수
|
||||
|
||||
**1. 포함 여부 확인**
|
||||
```typescript
|
||||
import { isContainedIn } from "./spatialContainment";
|
||||
|
||||
const isValid = isContainedIn(childObject, parentObject);
|
||||
// 자식 객체가 부모 객체 내부에 있는지 AABB로 검증
|
||||
```
|
||||
|
||||
**2. 유효한 부모 찾기**
|
||||
```typescript
|
||||
import { findValidParent } from "./spatialContainment";
|
||||
|
||||
const parent = findValidParent(draggedChild, allObjects, hierarchyLevels);
|
||||
// 드래그 중인 자식 객체를 포함하는 부모 객체 자동 감지
|
||||
```
|
||||
|
||||
**3. 검증**
|
||||
```typescript
|
||||
import { validateSpatialContainment } from "./spatialContainment";
|
||||
|
||||
const result = validateSpatialContainment(child, allObjects);
|
||||
if (!result.valid) {
|
||||
alert("하위 영역은 반드시 상위 영역 내부에 배치되어야 합니다!");
|
||||
}
|
||||
```
|
||||
|
||||
**4. 그룹 이동 (부모 이동 시 자식도 함께)**
|
||||
```typescript
|
||||
import { updateChildrenPositions, getAllDescendants } from "./spatialContainment";
|
||||
|
||||
// 부모 객체 이동 시
|
||||
const updatedChildren = updateChildrenPositions(
|
||||
parentObject,
|
||||
oldPosition,
|
||||
newPosition,
|
||||
allObjects
|
||||
);
|
||||
|
||||
// 모든 하위 자손(재귀) 가져오기
|
||||
const descendants = getAllDescendants(parentId, allObjects);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. DigitalTwinEditor 통합 방법
|
||||
|
||||
### Step 1: HierarchyConfigPanel 추가
|
||||
```tsx
|
||||
// DigitalTwinEditor.tsx
|
||||
|
||||
import HierarchyConfigPanel, { HierarchyConfig } from "./HierarchyConfigPanel";
|
||||
|
||||
const [hierarchyConfig, setHierarchyConfig] = useState<HierarchyConfig | null>(null);
|
||||
|
||||
// 좌측 사이드바에 추가
|
||||
<HierarchyConfigPanel
|
||||
externalDbConnectionId={selectedDbConnection}
|
||||
hierarchyConfig={hierarchyConfig}
|
||||
onHierarchyConfigChange={setHierarchyConfig}
|
||||
availableTables={availableTables}
|
||||
onLoadTables={loadTables}
|
||||
onLoadColumns={loadColumns}
|
||||
/>
|
||||
```
|
||||
|
||||
### Step 2: 계층 데이터 로드
|
||||
```tsx
|
||||
import { getHierarchyData, getChildrenData } from "@/lib/api/digitalTwin";
|
||||
|
||||
const loadHierarchyData = async () => {
|
||||
if (!selectedDbConnection || !hierarchyConfig) return;
|
||||
|
||||
const response = await getHierarchyData(selectedDbConnection, hierarchyConfig);
|
||||
if (response.success && response.data) {
|
||||
// 창고 데이터
|
||||
setWarehouses(response.data.warehouse);
|
||||
|
||||
// 각 레벨 데이터
|
||||
response.data.levels.forEach((level) => {
|
||||
if (level.level === 1) {
|
||||
setAvailableAreas(level.data);
|
||||
} else if (level.level === 2) {
|
||||
setAvailableLocations(level.data);
|
||||
}
|
||||
// ... 추가 레벨
|
||||
});
|
||||
|
||||
// 자재 개수
|
||||
setMaterialCounts(response.data.materials);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Step 3: Yard3DCanvas에서 검증
|
||||
```tsx
|
||||
// Yard3DCanvas.tsx 또는 DigitalTwinEditor.tsx
|
||||
|
||||
import { validateSpatialContainment } from "./spatialContainment";
|
||||
|
||||
const handleObjectDrop = (droppedObject: PlacedObject) => {
|
||||
const result = validateSpatialContainment(
|
||||
{
|
||||
id: droppedObject.id,
|
||||
position: droppedObject.position,
|
||||
size: droppedObject.size,
|
||||
hierarchyLevel: droppedObject.hierarchyLevel || 1,
|
||||
parentId: droppedObject.parentId,
|
||||
},
|
||||
placedObjects.map((obj) => ({
|
||||
id: obj.id,
|
||||
position: obj.position,
|
||||
size: obj.size,
|
||||
hierarchyLevel: obj.hierarchyLevel || 1,
|
||||
parentId: obj.parentId,
|
||||
}))
|
||||
);
|
||||
|
||||
if (!result.valid) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "배치 오류",
|
||||
description: "하위 영역은 반드시 상위 영역 내부에 배치되어야 합니다.",
|
||||
});
|
||||
return; // 배치 취소
|
||||
}
|
||||
|
||||
// 유효하면 부모 ID 업데이트
|
||||
droppedObject.parentId = result.parent?.id;
|
||||
|
||||
// 상태 업데이트
|
||||
setPlacedObjects([...placedObjects, droppedObject]);
|
||||
};
|
||||
```
|
||||
|
||||
### Step 4: 그룹 이동 구현
|
||||
```tsx
|
||||
import { updateChildrenPositions, getAllDescendants } from "./spatialContainment";
|
||||
|
||||
const handleObjectMove = (
|
||||
movedObject: PlacedObject,
|
||||
oldPosition: { x: number; y: number; z: number },
|
||||
newPosition: { x: number; y: number; z: number }
|
||||
) => {
|
||||
// 이동한 객체 업데이트
|
||||
const updatedObjects = placedObjects.map((obj) =>
|
||||
obj.id === movedObject.id
|
||||
? { ...obj, position: newPosition }
|
||||
: obj
|
||||
);
|
||||
|
||||
// 모든 하위 자손 가져오기
|
||||
const descendants = getAllDescendants(
|
||||
movedObject.id,
|
||||
placedObjects.map((obj) => ({
|
||||
id: obj.id,
|
||||
position: obj.position,
|
||||
size: obj.size,
|
||||
hierarchyLevel: obj.hierarchyLevel || 1,
|
||||
parentId: obj.parentId,
|
||||
}))
|
||||
);
|
||||
|
||||
// 하위 자손들도 같이 이동
|
||||
const delta = {
|
||||
x: newPosition.x - oldPosition.x,
|
||||
y: newPosition.y - oldPosition.y,
|
||||
z: newPosition.z - oldPosition.z,
|
||||
};
|
||||
|
||||
descendants.forEach((descendant) => {
|
||||
const index = updatedObjects.findIndex((obj) => obj.id === descendant.id);
|
||||
if (index !== -1) {
|
||||
updatedObjects[index].position = {
|
||||
x: updatedObjects[index].position.x + delta.x,
|
||||
y: updatedObjects[index].position.y + delta.y,
|
||||
z: updatedObjects[index].position.z + delta.z,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
setPlacedObjects(updatedObjects);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 테스트 시나리오
|
||||
|
||||
### 테스트 1: 계층 구조 설정
|
||||
1. 외부 DB 선택
|
||||
2. 창고 테이블 선택 및 컬럼 매핑
|
||||
3. 레벨 추가 (레벨 1: 구역, 레벨 2: 위치)
|
||||
4. 각 레벨의 테이블 및 컬럼 매핑
|
||||
5. 자재 테이블 설정
|
||||
6. "저장" 클릭하여 `hierarchy_config` 저장
|
||||
|
||||
### 테스트 2: 데이터 로드
|
||||
1. 계층 구조 설정 완료 후
|
||||
2. 창고 선택
|
||||
3. 각 레벨 데이터가 좌측 패널에 표시되는지 확인
|
||||
4. 자재 개수가 올바르게 표시되는지 확인
|
||||
|
||||
### 테스트 3: 3D 배치 및 공간적 종속성
|
||||
1. 레벨 1 (구역) 객체를 3D 캔버스에 드래그앤드롭
|
||||
2. 레벨 2 (위치) 객체를 레벨 1 객체 **내부**에 드래그앤드롭 → 성공
|
||||
3. 레벨 2 객체를 레벨 1 객체 **외부**에 드롭 → 오류 메시지 표시
|
||||
|
||||
### 테스트 4: 그룹 이동
|
||||
1. 레벨 1 객체를 이동
|
||||
2. 해당 레벨 1 객체의 모든 하위 객체(레벨 2, 3, ...)도 같이 이동하는지 확인
|
||||
3. 부모-자식 관계가 유지되는지 확인
|
||||
|
||||
### 테스트 5: 레이아웃 저장/로드
|
||||
1. 위 단계를 완료한 후 "저장" 클릭
|
||||
2. 페이지 새로고침
|
||||
3. 레이아웃을 다시 로드하여 계층 구조 및 객체 위치가 복원되는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 7. 마이그레이션 체크리스트
|
||||
|
||||
- [ ] DB 마이그레이션 실행 (042_refactor_digital_twin_hierarchy.sql)
|
||||
- [ ] 백엔드 API 테스트 (Postman/cURL)
|
||||
- [ ] `HierarchyConfigPanel` 컴포넌트 통합
|
||||
- [ ] `spatialContainment.ts` 유틸리티 통합
|
||||
- [ ] `DigitalTwinEditor`에서 계층 데이터 로드 구현
|
||||
- [ ] `Yard3DCanvas`에서 공간적 종속성 검증 구현
|
||||
- [ ] 그룹 이동 기능 구현
|
||||
- [ ] 모든 테스트 시나리오 통과
|
||||
- [ ] 레거시 API와의 호환성 확인
|
||||
|
||||
---
|
||||
|
||||
## 8. 주의사항
|
||||
|
||||
1. **기존 레이아웃 데이터**: 마이그레이션 전 기존 레이아웃이 있다면 백업 필요
|
||||
2. **컬럼 매핑 검증**: 외부 DB 테이블의 컬럼명이 변경될 수 있으므로 auto-mapping 로직 필수
|
||||
3. **성능**: N-Level이 3단계 이상 깊어지면 재귀 쿼리 성능 모니터링 필요
|
||||
4. **권한**: 외부 DB에 대한 읽기 권한 확인
|
||||
|
||||
---
|
||||
|
||||
## 9. 향후 개선 사항
|
||||
|
||||
1. **드래그 중 실시간 검증**: 드래그하는 동안 부모 영역 하이라이트
|
||||
2. **시각적 피드백**: 유효한 배치 위치를 그리드에 색상으로 표시
|
||||
3. **계층 구조 시각화**: 좌측 패널에 트리 구조로 표시
|
||||
4. **Undo/Redo**: 객체 배치 실행 취소 기능
|
||||
5. **스냅 가이드**: 부모 영역 테두리에 스냅 가이드라인 표시
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-11-20
|
||||
**작성자**: AI Assistant
|
||||
|
||||
|
|
@ -1,559 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Loader2, Plus, Trash2, GripVertical } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
// 계층 레벨 설정 인터페이스
|
||||
export interface HierarchyLevel {
|
||||
level: number;
|
||||
name: string;
|
||||
tableName: string;
|
||||
keyColumn: string;
|
||||
nameColumn: string;
|
||||
parentKeyColumn: string;
|
||||
typeColumn?: string;
|
||||
objectTypes: string[];
|
||||
}
|
||||
|
||||
// 전체 계층 구조 설정
|
||||
export interface HierarchyConfig {
|
||||
warehouseKey: string; // 이 레이아웃이 속한 창고 키 (예: "DY99")
|
||||
warehouse?: {
|
||||
tableName: string; // 창고 테이블명 (예: "MWARMA")
|
||||
keyColumn: string;
|
||||
nameColumn: string;
|
||||
};
|
||||
levels: HierarchyLevel[];
|
||||
material?: {
|
||||
tableName: string;
|
||||
keyColumn: string;
|
||||
locationKeyColumn: string;
|
||||
layerColumn?: string;
|
||||
quantityColumn?: string;
|
||||
displayColumns?: Array<{ column: string; label: string }>; // 우측 패널에 표시할 컬럼들 (컬럼명 + 표시명)
|
||||
};
|
||||
}
|
||||
|
||||
interface HierarchyConfigPanelProps {
|
||||
externalDbConnectionId: number | null;
|
||||
hierarchyConfig: HierarchyConfig | null;
|
||||
onHierarchyConfigChange: (config: HierarchyConfig) => void;
|
||||
availableTables: string[];
|
||||
onLoadTables: () => Promise<void>;
|
||||
onLoadColumns: (tableName: string) => Promise<string[]>;
|
||||
}
|
||||
|
||||
export default function HierarchyConfigPanel({
|
||||
externalDbConnectionId,
|
||||
hierarchyConfig,
|
||||
onHierarchyConfigChange,
|
||||
availableTables,
|
||||
onLoadTables,
|
||||
onLoadColumns,
|
||||
}: HierarchyConfigPanelProps) {
|
||||
const [localConfig, setLocalConfig] = useState<HierarchyConfig>(
|
||||
hierarchyConfig || {
|
||||
warehouseKey: "",
|
||||
levels: [],
|
||||
},
|
||||
);
|
||||
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: string[] }>({});
|
||||
|
||||
// 외부에서 변경된 경우 동기화
|
||||
useEffect(() => {
|
||||
if (hierarchyConfig) {
|
||||
setLocalConfig(hierarchyConfig);
|
||||
}
|
||||
}, [hierarchyConfig]);
|
||||
|
||||
// 테이블 선택 시 컬럼 로드
|
||||
const handleTableChange = async (tableName: string, type: "warehouse" | "material" | number) => {
|
||||
if (columnsCache[tableName]) return; // 이미 로드된 경우 스킵
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const columns = await onLoadColumns(tableName);
|
||||
setColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
|
||||
} catch (error) {
|
||||
console.error("컬럼 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 창고 키 변경 (제거됨 - 상위 컴포넌트에서 처리)
|
||||
|
||||
// 레벨 추가
|
||||
const handleAddLevel = () => {
|
||||
const maxLevel = localConfig.levels.length > 0 ? Math.max(...localConfig.levels.map((l) => l.level)) : 0;
|
||||
const newLevel: HierarchyLevel = {
|
||||
level: maxLevel + 1,
|
||||
name: `레벨 ${maxLevel + 1}`,
|
||||
tableName: "",
|
||||
keyColumn: "",
|
||||
nameColumn: "",
|
||||
parentKeyColumn: "",
|
||||
objectTypes: [],
|
||||
};
|
||||
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
levels: [...localConfig.levels, newLevel],
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
// onHierarchyConfigChange(newConfig); // 즉시 전달하지 않음
|
||||
};
|
||||
|
||||
// 레벨 삭제
|
||||
const handleRemoveLevel = (level: number) => {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
levels: localConfig.levels.filter((l) => l.level !== level),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
// onHierarchyConfigChange(newConfig); // 즉시 전달하지 않음
|
||||
};
|
||||
|
||||
// 레벨 설정 변경
|
||||
const handleLevelChange = (level: number, field: keyof HierarchyLevel, value: any) => {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
levels: localConfig.levels.map((l) => (l.level === level ? { ...l, [field]: value } : l)),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
// onHierarchyConfigChange(newConfig); // 즉시 전달하지 않음
|
||||
};
|
||||
|
||||
// 자재 설정 변경
|
||||
const handleMaterialChange = (field: keyof NonNullable<HierarchyConfig["material"]>, value: string) => {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
material: {
|
||||
...localConfig.material,
|
||||
[field]: value,
|
||||
} as NonNullable<HierarchyConfig["material"]>,
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
// onHierarchyConfigChange(newConfig); // 즉시 전달하지 않음
|
||||
};
|
||||
|
||||
// 창고 설정 변경
|
||||
const handleWarehouseChange = (field: keyof NonNullable<HierarchyConfig["warehouse"]>, value: string) => {
|
||||
const newWarehouse = {
|
||||
...localConfig.warehouse,
|
||||
[field]: value,
|
||||
} as NonNullable<HierarchyConfig["warehouse"]>;
|
||||
setLocalConfig({ ...localConfig, warehouse: newWarehouse });
|
||||
};
|
||||
|
||||
// 설정 적용
|
||||
const handleApplyConfig = () => {
|
||||
onHierarchyConfigChange(localConfig);
|
||||
};
|
||||
|
||||
if (!externalDbConnectionId) {
|
||||
return <div className="text-muted-foreground p-4 text-center text-sm">외부 DB를 먼저 선택하세요</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 창고 설정 */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-3">
|
||||
<CardTitle className="text-sm">창고 설정</CardTitle>
|
||||
<CardDescription className="text-[10px]">창고 테이블 및 컬럼 매핑</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 p-4 pt-0">
|
||||
{/* 창고 테이블 선택 */}
|
||||
<div>
|
||||
<Label className="text-[10px]">테이블</Label>
|
||||
<Select
|
||||
value={localConfig.warehouse?.tableName || ""}
|
||||
onValueChange={async (value) => {
|
||||
handleWarehouseChange("tableName", value);
|
||||
await handleTableChange(value, "warehouse");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="테이블 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table} value={table} className="text-[10px]">
|
||||
{table}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 창고 컬럼 매핑 */}
|
||||
{localConfig.warehouse?.tableName && columnsCache[localConfig.warehouse.tableName] && (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-[10px]">ID 컬럼</Label>
|
||||
<Select
|
||||
value={localConfig.warehouse.keyColumn || ""}
|
||||
onValueChange={(value) => handleWarehouseChange("keyColumn", value)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[localConfig.warehouse.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-[10px]">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">이름 컬럼</Label>
|
||||
<Select
|
||||
value={localConfig.warehouse.nameColumn || ""}
|
||||
onValueChange={(value) => handleWarehouseChange("nameColumn", value)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[localConfig.warehouse.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-[10px]">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 계층 레벨 목록 */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-3">
|
||||
<CardTitle className="text-sm">계층 레벨</CardTitle>
|
||||
<CardDescription className="text-[10px]">영역, 하위 영역 등</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
{localConfig.levels.length === 0 && (
|
||||
<div className="text-muted-foreground py-6 text-center text-xs">레벨을 추가하세요</div>
|
||||
)}
|
||||
|
||||
{localConfig.levels.map((level) => (
|
||||
<Card key={level.level} className="border-muted">
|
||||
<CardHeader className="flex flex-row items-center justify-between p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
value={level.name}
|
||||
onChange={(e) => handleLevelChange(level.level, "name", e.target.value)}
|
||||
className="h-7 w-32 text-xs"
|
||||
placeholder="레벨명"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveLevel(level.level)}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 p-3 pt-0">
|
||||
<div>
|
||||
<Label className="text-[10px]">테이블</Label>
|
||||
<Select
|
||||
value={level.tableName}
|
||||
onValueChange={(val) => {
|
||||
handleLevelChange(level.level, "tableName", val);
|
||||
handleTableChange(val, level.level);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table} value={table} className="text-xs">
|
||||
{table}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{level.tableName && columnsCache[level.tableName] && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-[10px]">ID 컬럼</Label>
|
||||
<Select
|
||||
value={level.keyColumn}
|
||||
onValueChange={(val) => handleLevelChange(level.level, "keyColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[level.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">이름 컬럼</Label>
|
||||
<Select
|
||||
value={level.nameColumn}
|
||||
onValueChange={(val) => handleLevelChange(level.level, "nameColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[level.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">부모 키 컬럼</Label>
|
||||
<Select
|
||||
value={level.parentKeyColumn}
|
||||
onValueChange={(val) => handleLevelChange(level.level, "parentKeyColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="부모 키 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[level.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">타입 컬럼 (선택)</Label>
|
||||
<Select
|
||||
value={level.typeColumn || "__none__"}
|
||||
onValueChange={(val) =>
|
||||
handleLevelChange(level.level, "typeColumn", val === "__none__" ? undefined : val)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="타입 컬럼 (선택)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{columnsCache[level.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleAddLevel} className="h-8 w-full text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
레벨 추가
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 자재 설정 */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-3">
|
||||
<CardTitle className="text-sm">자재 설정</CardTitle>
|
||||
<CardDescription className="text-[10px]">최하위 레벨의 데이터</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-4 pt-0">
|
||||
<div>
|
||||
<Label className="text-[10px]">테이블</Label>
|
||||
<Select
|
||||
value={localConfig.material?.tableName || ""}
|
||||
onValueChange={(val) => {
|
||||
handleMaterialChange("tableName", val);
|
||||
handleTableChange(val, "material");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table} value={table} className="text-xs">
|
||||
{table}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{localConfig.material?.tableName && columnsCache[localConfig.material.tableName] && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-[10px]">ID 컬럼</Label>
|
||||
<Select
|
||||
value={localConfig.material.keyColumn}
|
||||
onValueChange={(val) => handleMaterialChange("keyColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">위치 키 컬럼</Label>
|
||||
<Select
|
||||
value={localConfig.material.locationKeyColumn}
|
||||
onValueChange={(val) => handleMaterialChange("locationKeyColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">레이어 컬럼 (선택)</Label>
|
||||
<Select
|
||||
value={localConfig.material.layerColumn || "__none__"}
|
||||
onValueChange={(val) => handleMaterialChange("layerColumn", val === "__none__" ? undefined : val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="레이어 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">수량 컬럼 (선택)</Label>
|
||||
<Select
|
||||
value={localConfig.material.quantityColumn || "__none__"}
|
||||
onValueChange={(val) => handleMaterialChange("quantityColumn", val === "__none__" ? undefined : val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="수량 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
{/* 표시 컬럼 선택 */}
|
||||
<div>
|
||||
<Label className="text-[10px]">우측 패널 표시 컬럼</Label>
|
||||
<p className="text-muted-foreground mb-2 text-[9px]">
|
||||
자재 클릭 시 표시할 정보를 선택하고 라벨을 입력하세요
|
||||
</p>
|
||||
<div className="max-h-60 space-y-2 overflow-y-auto rounded border p-2">
|
||||
{columnsCache[localConfig.material.tableName].map((col) => {
|
||||
const displayItem = localConfig.material?.displayColumns?.find((d) => d.column === col);
|
||||
const isSelected = !!displayItem;
|
||||
return (
|
||||
<div key={col} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
const currentDisplay = localConfig.material?.displayColumns || [];
|
||||
const newDisplay = e.target.checked
|
||||
? [...currentDisplay, { column: col, label: col }]
|
||||
: currentDisplay.filter((d) => d.column !== col);
|
||||
handleMaterialChange("displayColumns", newDisplay);
|
||||
}}
|
||||
className="h-3 w-3 shrink-0"
|
||||
/>
|
||||
<span className="w-20 shrink-0 text-[10px]">{col}</span>
|
||||
{isSelected && (
|
||||
<Input
|
||||
value={displayItem?.label || col}
|
||||
onChange={(e) => {
|
||||
const currentDisplay = localConfig.material?.displayColumns || [];
|
||||
const newDisplay = currentDisplay.map((d) =>
|
||||
d.column === col ? { ...d, label: e.target.value } : d,
|
||||
);
|
||||
handleMaterialChange("displayColumns", newDisplay);
|
||||
}}
|
||||
placeholder="표시명 입력..."
|
||||
className="h-6 flex-1 text-[10px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 적용 버튼 */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleApplyConfig} className="h-10 gap-2 text-sm font-medium">
|
||||
설정 적용
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
/**
|
||||
* 공간적 종속성 검증 유틸리티
|
||||
*
|
||||
* 하위 영역이 상위 영역 내부에 배치되는지 검증
|
||||
*/
|
||||
|
||||
export interface SpatialObject {
|
||||
id: number;
|
||||
position: { x: number; y: number; z: number };
|
||||
size: { x: number; y: number; z: number };
|
||||
hierarchyLevel: number;
|
||||
parentId?: number;
|
||||
parentKey?: string; // 외부 DB 키 (데이터 바인딩용)
|
||||
}
|
||||
|
||||
/**
|
||||
* 객체 A가 객체 B 안에 포함되는지 확인 (AABB)
|
||||
*/
|
||||
export function isContainedIn(child: SpatialObject, parent: SpatialObject): boolean {
|
||||
// AABB (Axis-Aligned Bounding Box) 계산
|
||||
const childMin = {
|
||||
x: child.position.x - child.size.x / 2,
|
||||
z: child.position.z - child.size.z / 2,
|
||||
};
|
||||
const childMax = {
|
||||
x: child.position.x + child.size.x / 2,
|
||||
z: child.position.z + child.size.z / 2,
|
||||
};
|
||||
|
||||
const parentMin = {
|
||||
x: parent.position.x - parent.size.x / 2,
|
||||
z: parent.position.z - parent.size.z / 2,
|
||||
};
|
||||
const parentMax = {
|
||||
x: parent.position.x + parent.size.x / 2,
|
||||
z: parent.position.z + parent.size.z / 2,
|
||||
};
|
||||
|
||||
// 자식 객체의 모든 모서리가 부모 객체 내부에 있어야 함 (XZ 평면에서)
|
||||
return (
|
||||
childMin.x >= parentMin.x &&
|
||||
childMax.x <= parentMax.x &&
|
||||
childMin.z >= parentMin.z &&
|
||||
childMax.z <= parentMax.z
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 드래그 시 부모 영역을 찾아서 검증
|
||||
* @param child 드래그 중인 자식 객체
|
||||
* @param allObjects 모든 배치된 객체들
|
||||
* @param hierarchyLevels 계층 레벨 설정 (1, 2, 3, ...)
|
||||
* @returns 유효한 부모 객체 또는 null
|
||||
*/
|
||||
export function findValidParent(
|
||||
child: SpatialObject,
|
||||
allObjects: SpatialObject[],
|
||||
hierarchyLevels: number
|
||||
): SpatialObject | null {
|
||||
// 최상위 레벨(레벨 1)은 부모가 없음
|
||||
if (child.hierarchyLevel === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 부모 레벨 (자신보다 1단계 위)
|
||||
const parentLevel = child.hierarchyLevel - 1;
|
||||
|
||||
// 부모 레벨의 모든 객체 중에서 포함하는 객체 찾기
|
||||
const possibleParents = allObjects.filter(
|
||||
(obj) => obj.hierarchyLevel === parentLevel
|
||||
);
|
||||
|
||||
for (const parent of possibleParents) {
|
||||
if (isContainedIn(child, parent)) {
|
||||
return parent;
|
||||
}
|
||||
}
|
||||
|
||||
// 포함하는 부모가 없으면 null
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 드래그 종료 시 공간적 종속성 검증
|
||||
* @param child 드래그 종료된 자식 객체
|
||||
* @param allObjects 모든 배치된 객체들
|
||||
* @returns { valid: boolean, parent: SpatialObject | null }
|
||||
*/
|
||||
export function validateSpatialContainment(
|
||||
child: SpatialObject,
|
||||
allObjects: SpatialObject[]
|
||||
): { valid: boolean; parent: SpatialObject | null } {
|
||||
// 최상위 레벨은 항상 유효
|
||||
if (child.hierarchyLevel === 1) {
|
||||
return { valid: true, parent: null };
|
||||
}
|
||||
|
||||
const parent = findValidParent(child, allObjects, child.hierarchyLevel);
|
||||
|
||||
return {
|
||||
valid: parent !== null,
|
||||
parent: parent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 부모 객체 이동 시 모든 자식 객체의 위치 재계산
|
||||
* @param parent 이동한 부모 객체
|
||||
* @param oldPosition 이전 위치
|
||||
* @param newPosition 새 위치
|
||||
* @param allObjects 모든 배치된 객체들
|
||||
* @returns 업데이트된 자식 객체 배열
|
||||
*/
|
||||
export function updateChildrenPositions(
|
||||
parent: SpatialObject,
|
||||
oldPosition: { x: number; y: number; z: number },
|
||||
newPosition: { x: number; y: number; z: number },
|
||||
allObjects: SpatialObject[]
|
||||
): SpatialObject[] {
|
||||
const delta = {
|
||||
x: newPosition.x - oldPosition.x,
|
||||
y: newPosition.y - oldPosition.y,
|
||||
z: newPosition.z - oldPosition.z,
|
||||
};
|
||||
|
||||
// 직계 자식 (부모 ID가 일치하는 객체)
|
||||
const directChildren = allObjects.filter(
|
||||
(obj) => obj.parentId === parent.id
|
||||
);
|
||||
|
||||
// 자식들의 위치 업데이트
|
||||
return directChildren.map((child) => ({
|
||||
...child,
|
||||
position: {
|
||||
x: child.position.x + delta.x,
|
||||
y: child.position.y + delta.y,
|
||||
z: child.position.z + delta.z,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 객체의 모든 하위 자손 찾기 (재귀)
|
||||
* @param parentId 부모 객체 ID
|
||||
* @param allObjects 모든 배치된 객체들
|
||||
* @returns 모든 하위 자손 객체 배열
|
||||
*/
|
||||
export function getAllDescendants(
|
||||
parentId: number,
|
||||
allObjects: SpatialObject[]
|
||||
): SpatialObject[] {
|
||||
const directChildren = allObjects.filter((obj) => obj.parentId === parentId);
|
||||
|
||||
let descendants = [...directChildren];
|
||||
|
||||
// 재귀적으로 손자, 증손자... 찾기
|
||||
for (const child of directChildren) {
|
||||
const grandChildren = getAllDescendants(child.id, allObjects);
|
||||
descendants = [...descendants, ...grandChildren];
|
||||
}
|
||||
|
||||
return descendants;
|
||||
}
|
||||
|
||||
|
|
@ -424,7 +424,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const firstCoord = row.coordinates[0];
|
||||
if (Array.isArray(firstCoord) && firstCoord.length === 2) {
|
||||
polygons.push({
|
||||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
||||
id: row.id || row.code || `polygon-${index}`,
|
||||
name: row.name || row.title || `영역 ${index + 1}`,
|
||||
coordinates: row.coordinates as [number, number][],
|
||||
status: row.status || row.level,
|
||||
|
|
@ -499,8 +499,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
if (lat !== undefined && lng !== undefined && (mapDisplayType as string) !== "polygon") {
|
||||
markers.push({
|
||||
// 진행 방향(heading) 계산을 위해 ID는 새로고침마다 바뀌지 않도록 고정값 사용
|
||||
// 중복 방지를 위해 sourceName과 index를 조합하여 고유 ID 생성
|
||||
id: `${sourceName}-${row.id || row.code || "marker"}-${index}`,
|
||||
// - row.id / row.code가 있으면 그 값을 사용
|
||||
// - 없으면 sourceName과 index 조합으로 고정 ID 생성
|
||||
id: row.id || row.code || `${sourceName}-marker-${index}`,
|
||||
lat: Number(lat),
|
||||
lng: Number(lng),
|
||||
latitude: Number(lat),
|
||||
|
|
@ -1263,15 +1264,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
|
||||
{/* 마커 렌더링 */}
|
||||
{markers.map((marker) => {
|
||||
// 마커의 소스에 해당하는 데이터 소스 찾기
|
||||
const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source) || dataSources?.[0];
|
||||
const markerType = sourceDataSource?.markerType || "circle";
|
||||
// 첫 번째 데이터 소스의 마커 종류 가져오기
|
||||
const firstDataSource = dataSources?.[0];
|
||||
const markerType = firstDataSource?.markerType || "circle";
|
||||
|
||||
let markerIcon: any;
|
||||
if (typeof window !== "undefined") {
|
||||
const L = require("leaflet");
|
||||
// heading이 없거나 0일 때 기본값 90(동쪽/오른쪽)으로 설정하여 처음에 오른쪽을 보게 함
|
||||
const heading = marker.heading || 90;
|
||||
const heading = marker.heading || 0;
|
||||
|
||||
if (markerType === "arrow") {
|
||||
// 화살표 마커
|
||||
|
|
@ -1303,9 +1303,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
});
|
||||
} else if (markerType === "truck") {
|
||||
// 트럭 마커
|
||||
// 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요
|
||||
const rotation = heading - 90;
|
||||
|
||||
markerIcon = L.divIcon({
|
||||
className: "custom-truck-marker",
|
||||
html: `
|
||||
|
|
@ -1315,11 +1312,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translate(-50%, -50%) rotate(${rotation}deg);
|
||||
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>
|
||||
<g transform="rotate(-90 20 20)">
|
||||
<!-- 트럭 적재함 -->
|
||||
<rect
|
||||
x="10"
|
||||
|
|
|
|||
|
|
@ -377,8 +377,8 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
new Date().toISOString();
|
||||
|
||||
const alert: Alert = {
|
||||
// 중복 방지를 위해 소스명과 인덱스를 포함하여 고유 ID 생성
|
||||
id: `${sourceName}-${index}-${row.id || row.alert_id || row.incidentId || row.eventId || row.code || row.subCode || Date.now()}`,
|
||||
id: row.id || row.alert_id || row.incidentId || row.eventId ||
|
||||
row.code || row.subCode || `${sourceName}-${index}-${Date.now()}`,
|
||||
type,
|
||||
severity,
|
||||
title,
|
||||
|
|
@ -614,9 +614,8 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
<p className="text-sm">알림이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredAlerts.map((alert, idx) => (
|
||||
// key 중복 방지를 위해 인덱스 추가
|
||||
<Card key={`${alert.id}-${idx}`} className="p-2">
|
||||
filteredAlerts.map((alert) => (
|
||||
<Card key={alert.id} className="p-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className={`mt-0.5 rounded-full p-1 ${alert.severity === "high" ? "bg-destructive/10 text-destructive" : alert.severity === "medium" ? "bg-warning/10 text-warning" : "bg-primary/10 text-primary"}`}>
|
||||
{getTypeIcon(alert.type)}
|
||||
|
|
|
|||
|
|
@ -119,25 +119,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
const [localHeight, setLocalHeight] = useState<string>("");
|
||||
const [localWidth, setLocalWidth] = useState<string>("");
|
||||
|
||||
// 🆕 전체 테이블 목록 (selected-items-detail-input 등에서 사용)
|
||||
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName?: string }>>([]);
|
||||
|
||||
// 🆕 전체 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadAllTables = async () => {
|
||||
try {
|
||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setAllTables(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("전체 테이블 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadAllTables();
|
||||
}, []);
|
||||
|
||||
// 새로운 컴포넌트 시스템의 webType 동기화
|
||||
useEffect(() => {
|
||||
if (selectedComponent?.type === "component") {
|
||||
|
|
@ -298,18 +279,14 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
};
|
||||
|
||||
const handleConfigChange = (newConfig: any) => {
|
||||
// 기존 config와 병합하여 다른 속성 유지
|
||||
const currentConfig = selectedComponent.componentConfig?.config || {};
|
||||
const mergedConfig = { ...currentConfig, ...newConfig };
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig.config", mergedConfig);
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
|
||||
};
|
||||
|
||||
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도
|
||||
const componentId =
|
||||
selectedComponent.componentType || // ⭐ section-card 등
|
||||
selectedComponent.componentConfig?.type ||
|
||||
selectedComponent.componentConfig?.id ||
|
||||
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
|
||||
selectedComponent.componentConfig?.id;
|
||||
|
||||
if (componentId) {
|
||||
const definition = ComponentRegistry.getComponent(componentId);
|
||||
|
|
@ -341,14 +318,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<Settings className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||
</div>
|
||||
<ConfigPanelComponent
|
||||
config={config}
|
||||
onChange={handleConfigChange}
|
||||
tables={tables} // 테이블 정보 전달
|
||||
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
||||
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
|
||||
/>
|
||||
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1024,16 +994,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// 🆕 ComponentRegistry에서 전용 ConfigPanel이 있는지 먼저 확인
|
||||
const definition = ComponentRegistry.getComponent(componentId);
|
||||
if (definition?.configPanel) {
|
||||
// 전용 ConfigPanel이 있으면 renderComponentConfigPanel 호출
|
||||
const configPanelContent = renderComponentConfigPanel();
|
||||
if (configPanelContent) {
|
||||
return configPanelContent;
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 웹타입의 기본 입력 타입 추출
|
||||
const currentBaseInputType = webType ? getBaseInputType(webType as any) : null;
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ interface Props {
|
|||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onFiltersApplied?: (filters: TableFilter[]) => void; // 필터 적용 시 콜백
|
||||
screenId?: number; // 화면 ID 추가
|
||||
}
|
||||
|
||||
// 필터 타입별 연산자
|
||||
|
|
@ -70,7 +69,7 @@ interface ColumnFilterConfig {
|
|||
selectOptions?: Array<{ label: string; value: string }>;
|
||||
}
|
||||
|
||||
export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied, screenId }) => {
|
||||
export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied }) => {
|
||||
const { getTable, selectedTableId } = useTableOptions();
|
||||
const table = selectedTableId ? getTable(selectedTableId) : undefined;
|
||||
|
||||
|
|
@ -80,10 +79,7 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
|||
// localStorage에서 저장된 필터 설정 불러오기
|
||||
useEffect(() => {
|
||||
if (table?.columns && table?.tableName) {
|
||||
// 화면별로 독립적인 필터 설정 저장
|
||||
const storageKey = screenId
|
||||
? `table_filters_${table.tableName}_screen_${screenId}`
|
||||
: `table_filters_${table.tableName}`;
|
||||
const storageKey = `table_filters_${table.tableName}`;
|
||||
const savedFilters = localStorage.getItem(storageKey);
|
||||
|
||||
let filters: ColumnFilterConfig[];
|
||||
|
|
@ -196,11 +192,9 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
|||
width: cf.width || 200, // 너비 포함 (기본 200px)
|
||||
}));
|
||||
|
||||
// localStorage에 저장 (화면별로 독립적)
|
||||
// localStorage에 저장
|
||||
if (table?.tableName) {
|
||||
const storageKey = screenId
|
||||
? `table_filters_${table.tableName}_screen_${screenId}`
|
||||
: `table_filters_${table.tableName}`;
|
||||
const storageKey = `table_filters_${table.tableName}`;
|
||||
localStorage.setItem(storageKey, JSON.stringify(columnFilters));
|
||||
}
|
||||
|
||||
|
|
@ -222,11 +216,9 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
|||
setColumnFilters(clearedFilters);
|
||||
setSelectAll(false);
|
||||
|
||||
// localStorage에서 제거 (화면별로 독립적)
|
||||
// localStorage에서 제거
|
||||
if (table?.tableName) {
|
||||
const storageKey = screenId
|
||||
? `table_filters_${table.tableName}_screen_${screenId}`
|
||||
: `table_filters_${table.tableName}`;
|
||||
const storageKey = `table_filters_${table.tableName}`;
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,9 @@ export const deleteLayout = async (id: number): Promise<ApiResponse<void>> => {
|
|||
|
||||
// ========== 외부 DB 테이블 조회 API ==========
|
||||
|
||||
export const getTables = async (connectionId: number): Promise<ApiResponse<Array<{ table_name: string }>>> => {
|
||||
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;
|
||||
|
|
@ -103,7 +105,10 @@ export const getTables = async (connectionId: number): Promise<ApiResponse<Array
|
|||
}
|
||||
};
|
||||
|
||||
export const getTablePreview = async (connectionId: number, tableName: string): Promise<ApiResponse<any[]>> => {
|
||||
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;
|
||||
|
|
@ -118,10 +123,7 @@ export const getTablePreview = async (connectionId: number, tableName: string):
|
|||
// ========== 외부 DB 데이터 조회 API ==========
|
||||
|
||||
// 창고 목록 조회
|
||||
export const getWarehouses = async (
|
||||
externalDbConnectionId: number,
|
||||
tableName: string,
|
||||
): Promise<ApiResponse<Warehouse[]>> => {
|
||||
export const getWarehouses = async (externalDbConnectionId: number, tableName: string): Promise<ApiResponse<Warehouse[]>> => {
|
||||
try {
|
||||
const response = await apiClient.get("/digital-twin/data/warehouses", {
|
||||
params: { externalDbConnectionId, tableName },
|
||||
|
|
@ -136,11 +138,7 @@ export const getWarehouses = async (
|
|||
};
|
||||
|
||||
// Area 목록 조회
|
||||
export const getAreas = async (
|
||||
externalDbConnectionId: number,
|
||||
tableName: string,
|
||||
warehouseKey: string,
|
||||
): Promise<ApiResponse<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 },
|
||||
|
|
@ -176,24 +174,12 @@ export const getLocations = async (
|
|||
// 자재 목록 조회 (특정 Location)
|
||||
export const getMaterials = async (
|
||||
externalDbConnectionId: number,
|
||||
materialConfig: {
|
||||
tableName: string;
|
||||
keyColumn: string;
|
||||
locationKeyColumn: string;
|
||||
layerColumn?: string;
|
||||
locaKey: string;
|
||||
},
|
||||
tableName: string,
|
||||
locaKey: string,
|
||||
): Promise<ApiResponse<MaterialData[]>> => {
|
||||
try {
|
||||
const response = await apiClient.get("/digital-twin/data/materials", {
|
||||
params: {
|
||||
externalDbConnectionId,
|
||||
tableName: materialConfig.tableName,
|
||||
keyColumn: materialConfig.keyColumn,
|
||||
locationKeyColumn: materialConfig.locationKeyColumn,
|
||||
layerColumn: materialConfig.layerColumn,
|
||||
locaKey: materialConfig.locaKey,
|
||||
},
|
||||
params: { externalDbConnectionId, tableName, locaKey },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
|
|
@ -211,10 +197,12 @@ export const getMaterialCounts = async (
|
|||
locaKeys: string[],
|
||||
): Promise<ApiResponse<MaterialCount[]>> => {
|
||||
try {
|
||||
const response = await apiClient.post("/digital-twin/data/material-counts", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
locationKeys: locaKeys,
|
||||
const response = await apiClient.get("/digital-twin/data/material-counts", {
|
||||
params: {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
locaKeys: locaKeys.join(","),
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
|
|
@ -225,59 +213,3 @@ export const getMaterialCounts = async (
|
|||
}
|
||||
};
|
||||
|
||||
// ========== 동적 계층 구조 API ==========
|
||||
|
||||
export interface HierarchyData {
|
||||
warehouse: any[];
|
||||
levels: Array<{
|
||||
level: number;
|
||||
name: string;
|
||||
data: any[];
|
||||
}>;
|
||||
materials: Array<{
|
||||
location_key: string;
|
||||
count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// 전체 계층 데이터 조회
|
||||
export const getHierarchyData = async (
|
||||
externalDbConnectionId: number,
|
||||
hierarchyConfig: any,
|
||||
): Promise<ApiResponse<HierarchyData>> => {
|
||||
try {
|
||||
const response = await apiClient.post("/digital-twin/data/hierarchy", {
|
||||
externalDbConnectionId,
|
||||
hierarchyConfig: JSON.stringify(hierarchyConfig),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 부모의 하위 데이터 조회
|
||||
export const getChildrenData = async (
|
||||
externalDbConnectionId: number,
|
||||
hierarchyConfig: any,
|
||||
parentLevel: number,
|
||||
parentKey: string,
|
||||
): Promise<ApiResponse<any[]>> => {
|
||||
try {
|
||||
const response = await apiClient.post("/digital-twin/data/children", {
|
||||
externalDbConnectionId,
|
||||
hierarchyConfig: JSON.stringify(hierarchyConfig),
|
||||
parentLevel,
|
||||
parentKey,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -150,10 +150,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
const columnName = (component as any).columnName;
|
||||
|
||||
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
|
||||
// ⚠️ 단, componentType이 "select-basic"인 경우는 ComponentRegistry로 처리 (다중선택 등 고급 기능 지원)
|
||||
if ((inputType === "category" || webType === "category") && tableName && columnName && componentType === "select-basic") {
|
||||
// select-basic은 ComponentRegistry에서 처리하도록 아래로 통과
|
||||
} else if ((inputType === "category" || webType === "category") && tableName && columnName) {
|
||||
if ((inputType === "category" || webType === "category") && tableName && columnName) {
|
||||
try {
|
||||
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
|
||||
const fieldName = columnName || component.id;
|
||||
|
|
@ -216,16 +213,6 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// 1. 새 컴포넌트 시스템에서 먼저 조회
|
||||
const newComponent = ComponentRegistry.getComponent(componentType);
|
||||
|
||||
// 🔍 디버깅: select-basic 조회 결과 확인
|
||||
if (componentType === "select-basic") {
|
||||
console.log("🔍 [DynamicComponentRenderer] select-basic 조회:", {
|
||||
componentType,
|
||||
found: !!newComponent,
|
||||
componentId: component.id,
|
||||
componentConfig: component.componentConfig,
|
||||
});
|
||||
}
|
||||
|
||||
if (newComponent) {
|
||||
// 새 컴포넌트 시스템으로 렌더링
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -50,47 +50,11 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
menuObjid, // 🆕 메뉴 OBJID
|
||||
...props
|
||||
}) => {
|
||||
// 🚨 최초 렌더링 확인용 (테스트 후 제거)
|
||||
console.log("🚨🚨🚨 [SelectBasicComponent] 렌더링됨!!!!", {
|
||||
componentId: component.id,
|
||||
componentType: (component as any).componentType,
|
||||
columnName: (component as any).columnName,
|
||||
"props.multiple": (props as any).multiple,
|
||||
"componentConfig.multiple": componentConfig?.multiple,
|
||||
});
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
|
||||
const config = (props as any).webTypeConfig || componentConfig || {};
|
||||
|
||||
// 🆕 multiple 값: props.multiple (spread된 값) > config.multiple 순서로 우선순위
|
||||
const isMultiple = (props as any).multiple ?? config?.multiple ?? false;
|
||||
|
||||
// 🔍 디버깅: config 및 multiple 확인
|
||||
useEffect(() => {
|
||||
console.log("🔍 [SelectBasicComponent] ========== 다중선택 디버깅 ==========");
|
||||
console.log(" 컴포넌트 ID:", component.id);
|
||||
console.log(" 최종 isMultiple 값:", isMultiple);
|
||||
console.log(" ----------------------------------------");
|
||||
console.log(" props.multiple:", (props as any).multiple);
|
||||
console.log(" config.multiple:", config?.multiple);
|
||||
console.log(" componentConfig.multiple:", componentConfig?.multiple);
|
||||
console.log(" component.componentConfig.multiple:", component.componentConfig?.multiple);
|
||||
console.log(" ----------------------------------------");
|
||||
console.log(" config 전체:", config);
|
||||
console.log(" componentConfig 전체:", componentConfig);
|
||||
console.log(" component.componentConfig 전체:", component.componentConfig);
|
||||
console.log(" =======================================");
|
||||
|
||||
// 다중선택이 활성화되었는지 알림
|
||||
if (isMultiple) {
|
||||
console.log("✅ 다중선택 모드 활성화됨!");
|
||||
} else {
|
||||
console.log("❌ 단일선택 모드 (다중선택 비활성화)");
|
||||
}
|
||||
}, [(props as any).multiple, config?.multiple, componentConfig?.multiple, component.componentConfig?.multiple]);
|
||||
|
||||
// webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식)
|
||||
const webType = component.componentConfig?.webType || "select";
|
||||
|
||||
|
|
@ -98,14 +62,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || "");
|
||||
const [selectedLabel, setSelectedLabel] = useState("");
|
||||
|
||||
// multiselect의 경우 배열로 관리 (콤마 구분자로 파싱)
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>(() => {
|
||||
const initialValue = externalValue || config?.value || "";
|
||||
if (isMultiple && typeof initialValue === "string" && initialValue) {
|
||||
return initialValue.split(",").map(v => v.trim()).filter(v => v);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
// multiselect의 경우 배열로 관리
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
|
||||
// autocomplete의 경우 검색어 관리
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
|
@ -138,58 +96,6 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
isFetching,
|
||||
} = useCodeOptions(codeCategory, isCodeCategoryValid, menuObjid);
|
||||
|
||||
// 🆕 카테고리 타입 (category webType)을 위한 옵션 로딩
|
||||
const [categoryOptions, setCategoryOptions] = useState<Option[]>([]);
|
||||
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (webType === "category" && component.tableName && component.columnName) {
|
||||
console.log("🔍 [SelectBasic] 카테고리 값 로딩 시작:", {
|
||||
tableName: component.tableName,
|
||||
columnName: component.columnName,
|
||||
webType,
|
||||
});
|
||||
|
||||
setIsLoadingCategories(true);
|
||||
|
||||
import("@/lib/api/tableCategoryValue").then(({ getCategoryValues }) => {
|
||||
getCategoryValues(component.tableName!, component.columnName!)
|
||||
.then((response) => {
|
||||
console.log("🔍 [SelectBasic] 카테고리 API 응답:", response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
console.log("🔍 [SelectBasic] 원본 데이터 샘플:", {
|
||||
firstItem: response.data[0],
|
||||
keys: response.data[0] ? Object.keys(response.data[0]) : [],
|
||||
});
|
||||
|
||||
const activeValues = response.data.filter((v) => v.isActive !== false);
|
||||
const options = activeValues.map((v) => ({
|
||||
value: v.valueCode,
|
||||
label: v.valueLabel || v.valueCode,
|
||||
}));
|
||||
|
||||
console.log("✅ [SelectBasic] 카테고리 옵션 설정:", {
|
||||
activeValuesCount: activeValues.length,
|
||||
options,
|
||||
sampleOption: options[0],
|
||||
});
|
||||
|
||||
setCategoryOptions(options);
|
||||
} else {
|
||||
console.error("❌ [SelectBasic] 카테고리 응답 실패:", response);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("❌ [SelectBasic] 카테고리 값 조회 실패:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingCategories(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [webType, component.tableName, component.columnName]);
|
||||
|
||||
// 디버깅: menuObjid가 제대로 전달되는지 확인
|
||||
useEffect(() => {
|
||||
if (codeCategory && codeCategory !== "none") {
|
||||
|
|
@ -207,42 +113,11 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
// 외부 value prop 변경 시 selectedValue 업데이트
|
||||
useEffect(() => {
|
||||
const newValue = externalValue || config?.value || "";
|
||||
|
||||
console.log("🔍 [SelectBasic] 외부 값 변경 감지:", {
|
||||
componentId: component.id,
|
||||
columnName: (component as any).columnName,
|
||||
isMultiple,
|
||||
newValue,
|
||||
selectedValue,
|
||||
selectedValues,
|
||||
externalValue,
|
||||
"config.value": config?.value,
|
||||
});
|
||||
|
||||
// 다중선택 모드인 경우
|
||||
if (isMultiple) {
|
||||
if (typeof newValue === "string" && newValue) {
|
||||
const values = newValue.split(",").map(v => v.trim()).filter(v => v);
|
||||
const currentValuesStr = selectedValues.join(",");
|
||||
|
||||
if (newValue !== currentValuesStr) {
|
||||
console.log("✅ [SelectBasic] 다중선택 값 업데이트:", {
|
||||
from: selectedValues,
|
||||
to: values,
|
||||
});
|
||||
setSelectedValues(values);
|
||||
}
|
||||
} else if (!newValue && selectedValues.length > 0) {
|
||||
console.log("✅ [SelectBasic] 다중선택 값 초기화");
|
||||
setSelectedValues([]);
|
||||
}
|
||||
} else {
|
||||
// 단일선택 모드인 경우
|
||||
if (newValue !== selectedValue) {
|
||||
setSelectedValue(newValue);
|
||||
}
|
||||
// 값이 실제로 다른 경우에만 업데이트 (빈 문자열도 유효한 값으로 처리)
|
||||
if (newValue !== selectedValue) {
|
||||
setSelectedValue(newValue);
|
||||
}
|
||||
}, [externalValue, config?.value, isMultiple]);
|
||||
}, [externalValue, config?.value]);
|
||||
|
||||
// ✅ React Query가 자동으로 처리하므로 복잡한 전역 상태 관리 제거
|
||||
// - 캐싱: React Query가 자동 관리 (10분 staleTime, 30분 gcTime)
|
||||
|
|
@ -253,7 +128,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
useEffect(() => {
|
||||
const getAllOptions = () => {
|
||||
const configOptions = config.options || [];
|
||||
return [...codeOptions, ...categoryOptions, ...configOptions];
|
||||
return [...codeOptions, ...configOptions];
|
||||
};
|
||||
|
||||
const options = getAllOptions();
|
||||
|
|
@ -329,24 +204,12 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
// 모든 옵션 가져오기
|
||||
const getAllOptions = () => {
|
||||
const configOptions = config.options || [];
|
||||
return [...codeOptions, ...categoryOptions, ...configOptions];
|
||||
return [...codeOptions, ...configOptions];
|
||||
};
|
||||
|
||||
const allOptions = getAllOptions();
|
||||
const placeholder = componentConfig.placeholder || "선택하세요";
|
||||
|
||||
// 🔍 디버깅: 최종 옵션 확인
|
||||
useEffect(() => {
|
||||
if (webType === "category" && allOptions.length > 0) {
|
||||
console.log("🔍 [SelectBasic] 최종 allOptions:", {
|
||||
count: allOptions.length,
|
||||
categoryOptionsCount: categoryOptions.length,
|
||||
codeOptionsCount: codeOptions.length,
|
||||
sampleOptions: allOptions.slice(0, 3),
|
||||
});
|
||||
}
|
||||
}, [webType, allOptions.length, categoryOptions.length, codeOptions.length]);
|
||||
|
||||
// DOM props에서 React 전용 props 필터링
|
||||
const {
|
||||
component: _component,
|
||||
|
|
@ -637,93 +500,6 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
}
|
||||
|
||||
// select (기본 선택박스)
|
||||
// 다중선택 모드인 경우
|
||||
if (isMultiple) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"box-border flex h-full min-h-[40px] w-full flex-wrap gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
|
||||
!isDesignMode && "hover:border-orange-400",
|
||||
isSelected && "ring-2 ring-orange-500",
|
||||
)}
|
||||
onClick={() => !isDesignMode && setIsOpen(true)}
|
||||
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
|
||||
>
|
||||
{selectedValues.map((val, idx) => {
|
||||
const opt = allOptions.find((o) => o.value === val);
|
||||
return (
|
||||
<span key={idx} className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800">
|
||||
{opt?.label || val}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const newVals = selectedValues.filter((v) => v !== val);
|
||||
setSelectedValues(newVals);
|
||||
const newValue = newVals.join(",");
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
}
|
||||
}}
|
||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{selectedValues.length === 0 && (
|
||||
<span className="text-gray-500">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
{isOpen && !isDesignMode && (
|
||||
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
{(isLoadingCodes || isLoadingCategories) ? (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||
) : allOptions.length > 0 ? (
|
||||
allOptions.map((option, index) => {
|
||||
const isSelected = selectedValues.includes(option.value);
|
||||
return (
|
||||
<div
|
||||
key={`${option.value}-${index}`}
|
||||
className={cn(
|
||||
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
|
||||
isSelected && "bg-blue-50 font-medium"
|
||||
)}
|
||||
onClick={() => {
|
||||
const newVals = isSelected
|
||||
? selectedValues.filter((v) => v !== option.value)
|
||||
: [...selectedValues, option.value];
|
||||
setSelectedValues(newVals);
|
||||
const newValue = newVals.join(",");
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span>{option.label || option.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="bg-white px-3 py-2 text-gray-900">옵션이 없습니다</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 단일선택 모드
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -21,9 +21,7 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
|||
onChange,
|
||||
}) => {
|
||||
const handleChange = (key: keyof SelectBasicConfig, value: any) => {
|
||||
// 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호)
|
||||
const newConfig = { ...config, [key]: value };
|
||||
onChange(newConfig);
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -69,15 +67,6 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
|||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="multiple">다중 선택</Label>
|
||||
<Checkbox
|
||||
id="multiple"
|
||||
checked={config.multiple || false}
|
||||
onCheckedChange={(checked) => handleChange("multiple", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -73,117 +73,6 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
// 🆕 부모 데이터 매핑: 각 매핑별 소스 테이블 컬럼 상태
|
||||
const [mappingSourceColumns, setMappingSourceColumns] = useState<Record<number, Array<{ columnName: string; columnLabel?: string; dataType?: string }>>>({});
|
||||
|
||||
// 🆕 추가 입력 필드별 자동 채우기 테이블 컬럼 상태
|
||||
const [autoFillTableColumns, setAutoFillTableColumns] = useState<Record<number, Array<{ columnName: string; columnLabel?: string; dataType?: string }>>>({});
|
||||
|
||||
// 🆕 원본/대상 테이블 컬럼 상태 (내부에서 로드)
|
||||
const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string }>>([]);
|
||||
const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string }>>([]);
|
||||
|
||||
// 🆕 원본 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (!config.sourceTable) {
|
||||
setLoadedSourceTableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadColumns = async () => {
|
||||
try {
|
||||
console.log("🔍 원본 테이블 컬럼 로드:", config.sourceTable);
|
||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const response = await tableManagementApi.getColumnList(config.sourceTable);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const columns = response.data.columns || [];
|
||||
setLoadedSourceTableColumns(columns.map((col: any) => ({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnLabel || col.columnName,
|
||||
dataType: col.dataType,
|
||||
})));
|
||||
console.log("✅ 원본 테이블 컬럼 로드 성공:", columns.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 원본 테이블 컬럼 로드 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadColumns();
|
||||
}, [config.sourceTable]);
|
||||
|
||||
// 🆕 대상 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (!config.targetTable) {
|
||||
setLoadedTargetTableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadColumns = async () => {
|
||||
try {
|
||||
console.log("🔍 대상 테이블 컬럼 로드:", config.targetTable);
|
||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const response = await tableManagementApi.getColumnList(config.targetTable);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const columns = response.data.columns || [];
|
||||
setLoadedTargetTableColumns(columns.map((col: any) => ({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnLabel || col.columnName,
|
||||
dataType: col.dataType,
|
||||
})));
|
||||
console.log("✅ 대상 테이블 컬럼 로드 성공:", columns.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 대상 테이블 컬럼 로드 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadColumns();
|
||||
}, [config.targetTable]);
|
||||
|
||||
// 🆕 초기 렌더링 시 기존 필드들의 autoFillFromTable 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (!localFields || localFields.length === 0) return;
|
||||
|
||||
localFields.forEach((field, index) => {
|
||||
if (field.autoFillFromTable && !autoFillTableColumns[index]) {
|
||||
console.log(`🔍 [초기화] 필드 ${index}의 기존 테이블 컬럼 로드:`, field.autoFillFromTable);
|
||||
loadAutoFillTableColumns(field.autoFillFromTable, index);
|
||||
}
|
||||
});
|
||||
}, []); // 초기 한 번만 실행
|
||||
|
||||
// 🆕 자동 채우기 테이블 선택 시 컬럼 로드
|
||||
const loadAutoFillTableColumns = async (tableName: string, fieldIndex: number) => {
|
||||
if (!tableName) {
|
||||
setAutoFillTableColumns(prev => ({ ...prev, [fieldIndex]: [] }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🔍 [필드 ${fieldIndex}] 자동 채우기 테이블 컬럼 로드:`, tableName);
|
||||
|
||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const columns = response.data.columns || [];
|
||||
setAutoFillTableColumns(prev => ({
|
||||
...prev,
|
||||
[fieldIndex]: columns.map((col: any) => ({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnLabel || col.columnName,
|
||||
dataType: col.dataType,
|
||||
}))
|
||||
}));
|
||||
console.log(`✅ [필드 ${fieldIndex}] 컬럼 로드 성공:`, columns.length);
|
||||
} else {
|
||||
console.error(`❌ [필드 ${fieldIndex}] 컬럼 로드 실패:`, response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ [필드 ${fieldIndex}] 컬럼 로드 오류:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
// 🆕 소스 테이블 선택 시 컬럼 로드
|
||||
const loadMappingSourceColumns = async (tableName: string, mappingIndex: number) => {
|
||||
try {
|
||||
|
|
@ -291,8 +180,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
}, [screenTableName]); // config.targetTable은 의존성에서 제외 (한 번만 실행)
|
||||
|
||||
const handleChange = (key: keyof SelectedItemsDetailInputConfig, value: any) => {
|
||||
// 🔧 기존 config와 병합하여 다른 속성 유지
|
||||
onChange({ ...config, [key]: value });
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
||||
const handleFieldsChange = (fields: AdditionalFieldDefinition[]) => {
|
||||
|
|
@ -373,19 +261,15 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
|
||||
// 🆕 표시 컬럼용: 원본 테이블에서 사용되지 않은 컬럼 목록
|
||||
const availableColumns = useMemo(() => {
|
||||
// 🔧 로드된 컬럼 우선 사용, props로 받은 컬럼은 백업
|
||||
const columns = loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns;
|
||||
const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]);
|
||||
return columns.filter((col) => !usedColumns.has(col.columnName));
|
||||
}, [loadedSourceTableColumns, sourceTableColumns, displayColumns, localFields]);
|
||||
return sourceTableColumns.filter((col) => !usedColumns.has(col.columnName));
|
||||
}, [sourceTableColumns, displayColumns, localFields]);
|
||||
|
||||
// 🆕 추가 입력 필드용: 대상 테이블에서 사용되지 않은 컬럼 목록
|
||||
const availableTargetColumns = useMemo(() => {
|
||||
// 🔧 로드된 컬럼 우선 사용, props로 받은 컬럼은 백업
|
||||
const columns = loadedTargetTableColumns.length > 0 ? loadedTargetTableColumns : targetTableColumns;
|
||||
const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]);
|
||||
return columns.filter((col) => !usedColumns.has(col.columnName));
|
||||
}, [loadedTargetTableColumns, targetTableColumns, displayColumns, localFields]);
|
||||
return targetTableColumns.filter((col) => !usedColumns.has(col.columnName));
|
||||
}, [targetTableColumns, displayColumns, localFields]);
|
||||
|
||||
// 🆕 원본 테이블 필터링
|
||||
const filteredSourceTables = useMemo(() => {
|
||||
|
|
@ -519,6 +403,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
role="combobox"
|
||||
aria-expanded={sourceTableSelectOpen}
|
||||
className="h-8 w-full justify-between text-xs sm:text-sm"
|
||||
disabled={allTables.length === 0}
|
||||
>
|
||||
{selectedSourceTableLabel}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50 sm:h-4 sm:w-4" />
|
||||
|
|
@ -792,66 +677,15 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
<div className="space-y-2">
|
||||
<Label className="text-[10px] sm:text-xs">자동 채우기 (선택)</Label>
|
||||
|
||||
{/* 테이블 선택 드롭다운 */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-6 w-full justify-between text-[10px] sm:h-7 sm:text-xs"
|
||||
>
|
||||
{field.autoFillFromTable
|
||||
? allTables.find(t => t.tableName === field.autoFillFromTable)?.displayName || field.autoFillFromTable
|
||||
: "원본 테이블 (기본)"}
|
||||
<ChevronsUpDown className="ml-1 h-2 w-2 shrink-0 opacity-50 sm:h-3 sm:w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[250px] p-0 sm:w-[300px]">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-6 text-[10px] sm:h-7 sm:text-xs" />
|
||||
<CommandEmpty className="text-[10px] sm:text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
<CommandItem
|
||||
value=""
|
||||
onSelect={() => {
|
||||
updateField(index, { autoFillFromTable: undefined, autoFillFrom: undefined });
|
||||
setAutoFillTableColumns(prev => ({ ...prev, [index]: [] }));
|
||||
}}
|
||||
className="text-[10px] sm:text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
|
||||
!field.autoFillFromTable ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
원본 테이블 ({config.sourceTable || "미설정"})
|
||||
</CommandItem>
|
||||
{allTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={(value) => {
|
||||
updateField(index, { autoFillFromTable: value, autoFillFrom: undefined });
|
||||
loadAutoFillTableColumns(value, index);
|
||||
}}
|
||||
className="text-[10px] sm:text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
|
||||
field.autoFillFromTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{table.displayName || table.tableName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{/* 테이블명 입력 */}
|
||||
<Input
|
||||
value={field.autoFillFromTable || ""}
|
||||
onChange={(e) => updateField(index, { autoFillFromTable: e.target.value })}
|
||||
placeholder="비워두면 주 데이터 (예: item_price)"
|
||||
className="h-6 w-full text-[10px] sm:h-7 sm:text-xs"
|
||||
/>
|
||||
<p className="text-[9px] text-gray-500 sm:text-[10px]">
|
||||
다른 테이블에서 가져올 경우 테이블 선택
|
||||
다른 테이블에서 가져올 경우 테이블명 입력
|
||||
</p>
|
||||
|
||||
{/* 필드 선택 */}
|
||||
|
|
@ -862,26 +696,16 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
role="combobox"
|
||||
className="h-6 w-full justify-between text-[10px] sm:h-7 sm:text-xs"
|
||||
>
|
||||
{(() => {
|
||||
if (!field.autoFillFrom) return "필드 선택 안 함";
|
||||
|
||||
// 선택된 테이블의 컬럼에서 찾기
|
||||
const columns = field.autoFillFromTable
|
||||
? (autoFillTableColumns[index] || [])
|
||||
: (loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns);
|
||||
|
||||
const found = columns.find(c => c.columnName === field.autoFillFrom);
|
||||
return found?.columnLabel || field.autoFillFrom;
|
||||
})()}
|
||||
{field.autoFillFrom
|
||||
? sourceTableColumns.find(c => c.columnName === field.autoFillFrom)?.columnLabel || field.autoFillFrom
|
||||
: "필드 선택 안 함"}
|
||||
<ChevronsUpDown className="ml-1 h-2 w-2 shrink-0 opacity-50 sm:h-3 sm:w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[180px] p-0 sm:w-[200px]">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-6 text-[10px] sm:h-7 sm:text-xs" />
|
||||
<CommandEmpty className="text-[10px] sm:text-xs">
|
||||
{field.autoFillFromTable ? "컬럼을 찾을 수 없습니다" : "원본 테이블을 먼저 선택하세요"}
|
||||
</CommandEmpty>
|
||||
<CommandEmpty className="text-[10px] sm:text-xs">원본 테이블을 먼저 선택하세요.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[150px] overflow-auto sm:max-h-[200px]">
|
||||
<CommandItem
|
||||
value=""
|
||||
|
|
@ -896,32 +720,25 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
/>
|
||||
선택 안 함
|
||||
</CommandItem>
|
||||
{(() => {
|
||||
// 선택된 테이블의 컬럼 또는 기본 원본 테이블 컬럼
|
||||
const columns = field.autoFillFromTable
|
||||
? (autoFillTableColumns[index] || [])
|
||||
: (loadedSourceTableColumns.length > 0 ? loadedSourceTableColumns : sourceTableColumns);
|
||||
|
||||
return columns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={(value) => updateField(index, { autoFillFrom: value })}
|
||||
className="text-[10px] sm:text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
|
||||
field.autoFillFrom === column.columnName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{column.columnLabel || column.columnName}</div>
|
||||
{column.dataType && <div className="text-[8px] text-gray-500">{column.dataType}</div>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
));
|
||||
})()}
|
||||
{sourceTableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={() => updateField(index, { autoFillFrom: column.columnName })}
|
||||
className="text-[10px] sm:text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
|
||||
field.autoFillFrom === column.columnName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{column.columnLabel}</div>
|
||||
<div className="text-[9px] text-gray-500">{column.columnName}</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
|
|
|||
|
|
@ -1447,7 +1447,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
</div>
|
||||
|
||||
{/* 요약 표시 설정 (LIST 모드에서만) */}
|
||||
{(config.rightPanel?.displayMode || "list") === "list" && (
|
||||
{config.rightPanel?.displayMode === "list" && (
|
||||
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<Label className="text-sm font-semibold">요약 표시 설정</Label>
|
||||
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ export interface TableListComponentProps {
|
|||
tableName?: string;
|
||||
onRefresh?: () => void;
|
||||
onClose?: () => void;
|
||||
screenId?: number | string; // 화면 ID (필터 설정 저장용)
|
||||
screenId?: string;
|
||||
userId?: string; // 사용자 ID (컬럼 순서 저장용)
|
||||
onSelectedRowsChange?: (
|
||||
selectedRows: any[],
|
||||
|
|
@ -183,7 +183,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
refreshKey,
|
||||
tableName,
|
||||
userId,
|
||||
screenId, // 화면 ID 추출
|
||||
}) => {
|
||||
// ========================================
|
||||
// 설정 및 스타일
|
||||
|
|
@ -1228,9 +1227,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
|
||||
// 체크박스 컬럼 (나중에 위치 결정)
|
||||
// 기본값: enabled가 undefined면 true로 처리
|
||||
let checkboxCol: ColumnConfig | null = null;
|
||||
if (tableConfig.checkbox?.enabled ?? true) {
|
||||
if (tableConfig.checkbox?.enabled) {
|
||||
checkboxCol = {
|
||||
columnName: "__checkbox__",
|
||||
displayName: "",
|
||||
|
|
@ -1259,7 +1257,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
// 체크박스를 맨 앞 또는 맨 뒤에 추가
|
||||
if (checkboxCol) {
|
||||
if (tableConfig.checkbox?.position === "right") {
|
||||
if (tableConfig.checkbox.position === "right") {
|
||||
cols = [...cols, checkboxCol];
|
||||
} else {
|
||||
cols = [checkboxCol, ...cols];
|
||||
|
|
@ -1425,73 +1423,33 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원)
|
||||
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원)
|
||||
if (inputType === "category") {
|
||||
if (!value) return "";
|
||||
|
||||
const mapping = categoryMappings[column.columnName];
|
||||
const { Badge } = require("@/components/ui/badge");
|
||||
const categoryData = mapping?.[String(value)];
|
||||
|
||||
// 다중 값 처리: 콤마로 구분된 값들을 분리
|
||||
const valueStr = String(value);
|
||||
const values = valueStr.includes(",")
|
||||
? valueStr.split(",").map(v => v.trim()).filter(v => v)
|
||||
: [valueStr];
|
||||
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상
|
||||
const displayLabel = categoryData?.label || String(value);
|
||||
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
|
||||
|
||||
// 단일 값인 경우 (기존 로직)
|
||||
if (values.length === 1) {
|
||||
const categoryData = mapping?.[values[0]];
|
||||
const displayLabel = categoryData?.label || values[0];
|
||||
const displayColor = categoryData?.color || "#64748b";
|
||||
|
||||
if (displayColor === "none") {
|
||||
return <span className="text-sm">{displayLabel}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: displayColor,
|
||||
borderColor: displayColor,
|
||||
}}
|
||||
className="text-white"
|
||||
>
|
||||
{displayLabel}
|
||||
</Badge>
|
||||
);
|
||||
// 배지 없음 옵션: color가 "none"이면 텍스트만 표시
|
||||
if (displayColor === "none") {
|
||||
return <span className="text-sm">{displayLabel}</span>;
|
||||
}
|
||||
|
||||
// 다중 값인 경우: 여러 배지 렌더링
|
||||
const { Badge } = require("@/components/ui/badge");
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{values.map((val, idx) => {
|
||||
const categoryData = mapping?.[val];
|
||||
const displayLabel = categoryData?.label || val;
|
||||
const displayColor = categoryData?.color || "#64748b";
|
||||
|
||||
if (displayColor === "none") {
|
||||
return (
|
||||
<span key={idx} className="text-sm">
|
||||
{displayLabel}
|
||||
{idx < values.length - 1 && ", "}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={idx}
|
||||
style={{
|
||||
backgroundColor: displayColor,
|
||||
borderColor: displayColor,
|
||||
}}
|
||||
className="text-white"
|
||||
>
|
||||
{displayLabel}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: displayColor,
|
||||
borderColor: displayColor,
|
||||
}}
|
||||
className="text-white"
|
||||
>
|
||||
{displayLabel}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1577,21 +1535,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// useEffect 훅
|
||||
// ========================================
|
||||
|
||||
// 필터 설정 localStorage 키 생성 (화면별로 독립적)
|
||||
// 필터 설정 localStorage 키 생성
|
||||
const filterSettingKey = useMemo(() => {
|
||||
if (!tableConfig.selectedTable) return null;
|
||||
return screenId
|
||||
? `tableList_filterSettings_${tableConfig.selectedTable}_screen_${screenId}`
|
||||
: `tableList_filterSettings_${tableConfig.selectedTable}`;
|
||||
}, [tableConfig.selectedTable, screenId]);
|
||||
return `tableList_filterSettings_${tableConfig.selectedTable}`;
|
||||
}, [tableConfig.selectedTable]);
|
||||
|
||||
// 그룹 설정 localStorage 키 생성 (화면별로 독립적)
|
||||
// 그룹 설정 localStorage 키 생성
|
||||
const groupSettingKey = useMemo(() => {
|
||||
if (!tableConfig.selectedTable) return null;
|
||||
return screenId
|
||||
? `tableList_groupSettings_${tableConfig.selectedTable}_screen_${screenId}`
|
||||
: `tableList_groupSettings_${tableConfig.selectedTable}`;
|
||||
}, [tableConfig.selectedTable, screenId]);
|
||||
return `tableList_groupSettings_${tableConfig.selectedTable}`;
|
||||
}, [tableConfig.selectedTable]);
|
||||
|
||||
// 저장된 필터 설정 불러오기
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -269,9 +269,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
// });
|
||||
|
||||
const parentValue = config[parentKey] as any;
|
||||
// 전체 config와 병합하여 다른 속성 유지
|
||||
const newConfig = {
|
||||
...config,
|
||||
[parentKey]: {
|
||||
...parentValue,
|
||||
[childKey]: value,
|
||||
|
|
@ -756,52 +754,6 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 체크박스 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">체크박스 설정</h3>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="checkboxEnabled"
|
||||
checked={config.checkbox?.enabled ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("checkbox", "enabled", checked)}
|
||||
/>
|
||||
<Label htmlFor="checkboxEnabled">체크박스 표시</Label>
|
||||
</div>
|
||||
|
||||
{config.checkbox?.enabled && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="checkboxSelectAll"
|
||||
checked={config.checkbox?.selectAll ?? true}
|
||||
onCheckedChange={(checked) => handleNestedChange("checkbox", "selectAll", checked)}
|
||||
/>
|
||||
<Label htmlFor="checkboxSelectAll">전체 선택 체크박스 표시</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="checkboxPosition" className="text-xs">
|
||||
체크박스 위치
|
||||
</Label>
|
||||
<select
|
||||
id="checkboxPosition"
|
||||
value={config.checkbox?.position || "left"}
|
||||
onChange={(e) => handleNestedChange("checkbox", "position", e.target.value)}
|
||||
className="w-full h-8 text-xs border rounded-md px-2"
|
||||
>
|
||||
<option value="left">왼쪽</option>
|
||||
<option value="right">오른쪽</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 가로 스크롤 및 컬럼 고정 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -12,14 +12,6 @@ import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
|
|||
import { TableFilter } from "@/types/table-options";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface PresetFilter {
|
||||
id: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
filterType: "text" | "number" | "date" | "select";
|
||||
width?: number;
|
||||
}
|
||||
|
||||
interface TableSearchWidgetProps {
|
||||
component: {
|
||||
id: string;
|
||||
|
|
@ -33,8 +25,6 @@ interface TableSearchWidgetProps {
|
|||
componentConfig?: {
|
||||
autoSelectFirstTable?: boolean; // 첫 번째 테이블 자동 선택 여부
|
||||
showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부
|
||||
filterMode?: "dynamic" | "preset"; // 필터 모드
|
||||
presetFilters?: PresetFilter[]; // 고정 필터 목록
|
||||
};
|
||||
};
|
||||
screenId?: number; // 화면 ID
|
||||
|
|
@ -73,8 +63,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
|
||||
const autoSelectFirstTable = component.componentConfig?.autoSelectFirstTable ?? true;
|
||||
const showTableSelector = component.componentConfig?.showTableSelector ?? true;
|
||||
const filterMode = component.componentConfig?.filterMode ?? "dynamic";
|
||||
const presetFilters = component.componentConfig?.presetFilters ?? [];
|
||||
|
||||
// Map을 배열로 변환
|
||||
const tableList = Array.from(registeredTables.values());
|
||||
|
|
@ -89,58 +77,41 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
}
|
||||
}, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]);
|
||||
|
||||
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
|
||||
// 현재 테이블의 저장된 필터 불러오기
|
||||
useEffect(() => {
|
||||
if (!currentTable?.tableName) return;
|
||||
if (currentTable?.tableName) {
|
||||
const storageKey = `table_filters_${currentTable.tableName}`;
|
||||
const savedFilters = localStorage.getItem(storageKey);
|
||||
|
||||
// 고정 모드: presetFilters를 activeFilters로 설정
|
||||
if (filterMode === "preset") {
|
||||
const activeFiltersList: TableFilter[] = presetFilters.map((f) => ({
|
||||
columnName: f.columnName,
|
||||
operator: "contains",
|
||||
value: "",
|
||||
filterType: f.filterType,
|
||||
width: f.width || 200,
|
||||
}));
|
||||
setActiveFilters(activeFiltersList);
|
||||
return;
|
||||
}
|
||||
if (savedFilters) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedFilters) as Array<{
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
inputType: string;
|
||||
enabled: boolean;
|
||||
filterType: "text" | "number" | "date" | "select";
|
||||
width?: number;
|
||||
}>;
|
||||
|
||||
// 동적 모드: 화면별로 독립적인 필터 설정 불러오기
|
||||
const storageKey = screenId
|
||||
? `table_filters_${currentTable.tableName}_screen_${screenId}`
|
||||
: `table_filters_${currentTable.tableName}`;
|
||||
const savedFilters = localStorage.getItem(storageKey);
|
||||
// enabled된 필터들만 activeFilters로 설정
|
||||
const activeFiltersList: TableFilter[] = parsed
|
||||
.filter((f) => f.enabled)
|
||||
.map((f) => ({
|
||||
columnName: f.columnName,
|
||||
operator: "contains",
|
||||
value: "",
|
||||
filterType: f.filterType,
|
||||
width: f.width || 200, // 저장된 너비 포함
|
||||
}));
|
||||
|
||||
if (savedFilters) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedFilters) as Array<{
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
inputType: string;
|
||||
enabled: boolean;
|
||||
filterType: "text" | "number" | "date" | "select";
|
||||
width?: number;
|
||||
}>;
|
||||
|
||||
// enabled된 필터들만 activeFilters로 설정
|
||||
const activeFiltersList: TableFilter[] = parsed
|
||||
.filter((f) => f.enabled)
|
||||
.map((f) => ({
|
||||
columnName: f.columnName,
|
||||
operator: "contains",
|
||||
value: "",
|
||||
filterType: f.filterType,
|
||||
width: f.width || 200, // 저장된 너비 포함
|
||||
}));
|
||||
|
||||
setActiveFilters(activeFiltersList);
|
||||
} catch (error) {
|
||||
console.error("저장된 필터 불러오기 실패:", error);
|
||||
setActiveFilters(activeFiltersList);
|
||||
} catch (error) {
|
||||
console.error("저장된 필터 불러오기 실패:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentTable?.tableName, filterMode, screenId, JSON.stringify(presetFilters)]);
|
||||
}, [currentTable?.tableName]);
|
||||
|
||||
// select 옵션 초기 로드 (한 번만 실행, 이후 유지)
|
||||
useEffect(() => {
|
||||
|
|
@ -391,7 +362,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
{/* 필터가 없을 때는 빈 공간 */}
|
||||
{activeFilters.length === 0 && <div className="flex-1" />}
|
||||
|
||||
{/* 오른쪽: 데이터 건수 + 설정 버튼들 (고정 모드에서는 숨김) */}
|
||||
{/* 오른쪽: 데이터 건수 + 설정 버튼들 */}
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{/* 데이터 건수 표시 */}
|
||||
{currentTable?.dataCount !== undefined && (
|
||||
|
|
@ -400,43 +371,38 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 동적 모드일 때만 설정 버튼들 표시 */}
|
||||
{filterMode === "dynamic" && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setColumnVisibilityOpen(true)}
|
||||
disabled={!selectedTableId}
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
>
|
||||
<Settings className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||
테이블 옵션
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setColumnVisibilityOpen(true)}
|
||||
disabled={!selectedTableId}
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
>
|
||||
<Settings className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||
테이블 옵션
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setFilterOpen(true)}
|
||||
disabled={!selectedTableId}
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
>
|
||||
<Filter className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||
필터 설정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setFilterOpen(true)}
|
||||
disabled={!selectedTableId}
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
>
|
||||
<Filter className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||
필터 설정
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setGroupingOpen(true)}
|
||||
disabled={!selectedTableId}
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
>
|
||||
<Layers className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||
그룹 설정
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setGroupingOpen(true)}
|
||||
disabled={!selectedTableId}
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
>
|
||||
<Layers className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||
그룹 설정
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 패널들 */}
|
||||
|
|
@ -445,7 +411,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
isOpen={filterOpen}
|
||||
onClose={() => setFilterOpen(false)}
|
||||
onFiltersApplied={(filters) => setActiveFilters(filters)}
|
||||
screenId={screenId}
|
||||
/>
|
||||
<GroupingPanel isOpen={groupingOpen} onClose={() => setGroupingOpen(false)} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,126 +3,27 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface TableSearchWidgetConfigPanelProps {
|
||||
component?: any; // 레거시 지원
|
||||
config?: any; // 새 인터페이스
|
||||
onUpdateProperty?: (property: string, value: any) => void; // 레거시 지원
|
||||
onChange?: (newConfig: any) => void; // 새 인터페이스
|
||||
tables?: any[]; // 화면의 테이블 정보
|
||||
}
|
||||
|
||||
interface PresetFilter {
|
||||
id: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
filterType: "text" | "number" | "date" | "select";
|
||||
width?: number;
|
||||
component: any;
|
||||
onUpdateProperty: (property: string, value: any) => void;
|
||||
}
|
||||
|
||||
export function TableSearchWidgetConfigPanel({
|
||||
component,
|
||||
config,
|
||||
onUpdateProperty,
|
||||
onChange,
|
||||
tables = [],
|
||||
}: TableSearchWidgetConfigPanelProps) {
|
||||
// 레거시와 새 인터페이스 모두 지원
|
||||
const currentConfig = config || component?.componentConfig || {};
|
||||
const updateConfig = onChange || ((key: string, value: any) => {
|
||||
if (onUpdateProperty) {
|
||||
onUpdateProperty(`componentConfig.${key}`, value);
|
||||
}
|
||||
});
|
||||
|
||||
// 첫 번째 테이블의 컬럼 목록 가져오기
|
||||
const availableColumns = tables.length > 0 && tables[0].columns ? tables[0].columns : [];
|
||||
|
||||
// inputType에서 filterType 추출 헬퍼 함수
|
||||
const getFilterTypeFromInputType = (inputType: string): "text" | "number" | "date" | "select" => {
|
||||
if (inputType.includes("number") || inputType.includes("decimal") || inputType.includes("integer")) {
|
||||
return "number";
|
||||
}
|
||||
if (inputType.includes("date") || inputType.includes("time")) {
|
||||
return "date";
|
||||
}
|
||||
if (inputType.includes("select") || inputType.includes("dropdown") || inputType.includes("code") || inputType.includes("category")) {
|
||||
return "select";
|
||||
}
|
||||
return "text";
|
||||
};
|
||||
|
||||
const [localAutoSelect, setLocalAutoSelect] = useState(
|
||||
currentConfig.autoSelectFirstTable ?? true
|
||||
component.componentConfig?.autoSelectFirstTable ?? true
|
||||
);
|
||||
const [localShowSelector, setLocalShowSelector] = useState(
|
||||
currentConfig.showTableSelector ?? true
|
||||
);
|
||||
const [localFilterMode, setLocalFilterMode] = useState<"dynamic" | "preset">(
|
||||
currentConfig.filterMode ?? "dynamic"
|
||||
);
|
||||
const [localPresetFilters, setLocalPresetFilters] = useState<PresetFilter[]>(
|
||||
currentConfig.presetFilters ?? []
|
||||
component.componentConfig?.showTableSelector ?? true
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalAutoSelect(currentConfig.autoSelectFirstTable ?? true);
|
||||
setLocalShowSelector(currentConfig.showTableSelector ?? true);
|
||||
setLocalFilterMode(currentConfig.filterMode ?? "dynamic");
|
||||
setLocalPresetFilters(currentConfig.presetFilters ?? []);
|
||||
}, [currentConfig]);
|
||||
|
||||
// 설정 업데이트 헬퍼
|
||||
const handleUpdate = (key: string, value: any) => {
|
||||
if (onChange) {
|
||||
// 새 인터페이스: 전체 config 업데이트
|
||||
onChange({ ...currentConfig, [key]: value });
|
||||
} else if (onUpdateProperty) {
|
||||
// 레거시: 개별 속성 업데이트
|
||||
onUpdateProperty(`componentConfig.${key}`, value);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터 추가
|
||||
const addFilter = () => {
|
||||
const newFilter: PresetFilter = {
|
||||
id: `filter_${Date.now()}`,
|
||||
columnName: "",
|
||||
columnLabel: "",
|
||||
filterType: "text",
|
||||
width: 200,
|
||||
};
|
||||
const updatedFilters = [...localPresetFilters, newFilter];
|
||||
setLocalPresetFilters(updatedFilters);
|
||||
handleUpdate("presetFilters", updatedFilters);
|
||||
};
|
||||
|
||||
// 필터 삭제
|
||||
const removeFilter = (id: string) => {
|
||||
const updatedFilters = localPresetFilters.filter((f) => f.id !== id);
|
||||
setLocalPresetFilters(updatedFilters);
|
||||
handleUpdate("presetFilters", updatedFilters);
|
||||
};
|
||||
|
||||
// 필터 업데이트
|
||||
const updateFilter = (id: string, field: keyof PresetFilter, value: any) => {
|
||||
const updatedFilters = localPresetFilters.map((f) =>
|
||||
f.id === id ? { ...f, [field]: value } : f
|
||||
);
|
||||
setLocalPresetFilters(updatedFilters);
|
||||
handleUpdate("presetFilters", updatedFilters);
|
||||
};
|
||||
setLocalAutoSelect(component.componentConfig?.autoSelectFirstTable ?? true);
|
||||
setLocalShowSelector(component.componentConfig?.showTableSelector ?? true);
|
||||
}, [component.componentConfig]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -140,7 +41,7 @@ export function TableSearchWidgetConfigPanel({
|
|||
checked={localAutoSelect}
|
||||
onCheckedChange={(checked) => {
|
||||
setLocalAutoSelect(checked as boolean);
|
||||
handleUpdate("autoSelectFirstTable", checked);
|
||||
onUpdateProperty("componentConfig.autoSelectFirstTable", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="autoSelectFirstTable" className="text-xs sm:text-sm cursor-pointer">
|
||||
|
|
@ -155,7 +56,7 @@ export function TableSearchWidgetConfigPanel({
|
|||
checked={localShowSelector}
|
||||
onCheckedChange={(checked) => {
|
||||
setLocalShowSelector(checked as boolean);
|
||||
handleUpdate("showTableSelector", checked);
|
||||
onUpdateProperty("componentConfig.showTableSelector", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="showTableSelector" className="text-xs sm:text-sm cursor-pointer">
|
||||
|
|
@ -163,178 +64,12 @@ export function TableSearchWidgetConfigPanel({
|
|||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 필터 모드 선택 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Label className="text-xs sm:text-sm font-medium">필터 모드</Label>
|
||||
<RadioGroup
|
||||
value={localFilterMode}
|
||||
onValueChange={(value: "dynamic" | "preset") => {
|
||||
setLocalFilterMode(value);
|
||||
handleUpdate("filterMode", value);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="dynamic" id="mode-dynamic" />
|
||||
<Label htmlFor="mode-dynamic" className="text-xs sm:text-sm cursor-pointer font-normal">
|
||||
동적 모드 (사용자가 필터 설정 버튼으로 선택)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="preset" id="mode-preset" />
|
||||
<Label htmlFor="mode-preset" className="text-xs sm:text-sm cursor-pointer font-normal">
|
||||
고정 모드 (디자이너가 미리 필터 지정)
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 고정 모드일 때만 필터 설정 UI 표시 */}
|
||||
{localFilterMode === "preset" && (
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm font-medium">고정 필터 목록</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addFilter}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
필터 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{localPresetFilters.length === 0 ? (
|
||||
<div className="rounded-md bg-muted p-3 text-center text-xs text-muted-foreground">
|
||||
필터가 없습니다. 필터 추가 버튼을 클릭하세요.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{localPresetFilters.map((filter) => (
|
||||
<div
|
||||
key={filter.id}
|
||||
className="rounded-md border bg-card p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">필터 설정</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeFilter(filter.id)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 선택 */}
|
||||
<div>
|
||||
<Label className="text-[10px] sm:text-xs mb-1">컬럼 선택</Label>
|
||||
{availableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={filter.columnName}
|
||||
onValueChange={(value) => {
|
||||
// 선택된 컬럼 정보 가져오기
|
||||
const selectedColumn = availableColumns.find(
|
||||
(col: any) => col.columnName === value
|
||||
);
|
||||
// 컬럼명과 라벨 동시 업데이트
|
||||
const updatedFilters = localPresetFilters.map((f) =>
|
||||
f.id === filter.id
|
||||
? {
|
||||
...f,
|
||||
columnName: value,
|
||||
columnLabel: selectedColumn?.columnLabel || value,
|
||||
filterType: getFilterTypeFromInputType(selectedColumn?.inputType || "text"),
|
||||
}
|
||||
: f
|
||||
);
|
||||
setLocalPresetFilters(updatedFilters);
|
||||
handleUpdate("presetFilters", updatedFilters);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableColumns.map((col: any) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{col.columnLabel}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
({col.columnName})
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={filter.columnName}
|
||||
onChange={(e) => updateFilter(filter.id, "columnName", e.target.value)}
|
||||
placeholder="예: customer_name"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
)}
|
||||
{filter.columnLabel && (
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
표시명: {filter.columnLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필터 타입 */}
|
||||
<div>
|
||||
<Label className="text-[10px] sm:text-xs mb-1">필터 타입</Label>
|
||||
<Select
|
||||
value={filter.filterType}
|
||||
onValueChange={(value: "text" | "number" | "date" | "select") =>
|
||||
updateFilter(filter.id, "filterType", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">텍스트</SelectItem>
|
||||
<SelectItem value="number">숫자</SelectItem>
|
||||
<SelectItem value="date">날짜</SelectItem>
|
||||
<SelectItem value="select">선택</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 너비 */}
|
||||
<div>
|
||||
<Label className="text-[10px] sm:text-xs mb-1">너비 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={filter.width || 200}
|
||||
onChange={(e) => updateFilter(filter.id, "width", parseInt(e.target.value))}
|
||||
placeholder="200"
|
||||
className="h-7 text-xs"
|
||||
min={100}
|
||||
max={500}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md bg-muted p-3 text-xs">
|
||||
<p className="font-medium mb-1">참고사항:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>테이블 리스트, 분할 패널, 플로우 위젯이 자동 감지됩니다</li>
|
||||
<li>여러 테이블이 있으면 드롭다운에서 선택할 수 있습니다</li>
|
||||
{localFilterMode === "dynamic" ? (
|
||||
<li>사용자가 필터 설정 버튼을 클릭하여 원하는 필터를 선택합니다</li>
|
||||
) : (
|
||||
<li>고정 모드에서는 설정 버튼이 숨겨지고 지정된 필터만 표시됩니다</li>
|
||||
)}
|
||||
<li>선택한 테이블의 컬럼 정보가 자동으로 로드됩니다</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import React from "react";
|
|||
import { TableSearchWidget } from "./TableSearchWidget";
|
||||
|
||||
export class TableSearchWidgetRenderer {
|
||||
static render(component: any, props?: any) {
|
||||
return <TableSearchWidget component={component} screenId={props?.screenId} />;
|
||||
static render(component: any) {
|
||||
return <TableSearchWidget component={component} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,11 +72,6 @@ export interface PlacedObject {
|
|||
// 편집 제한
|
||||
locked?: boolean; // true면 이동/크기조절 불가
|
||||
visible?: boolean;
|
||||
|
||||
// 동적 계층 구조
|
||||
hierarchyLevel?: number; // 1, 2, 3...
|
||||
parentKey?: string; // 부모 객체의 외부 DB 키
|
||||
externalKey?: string; // 자신의 외부 DB 키
|
||||
}
|
||||
|
||||
// 레이아웃
|
||||
|
|
@ -87,7 +82,6 @@ export interface DigitalTwinLayout {
|
|||
warehouseKey: string; // WAREKEY (예: DY99)
|
||||
layoutName: string;
|
||||
description?: string;
|
||||
hierarchyConfig?: any; // JSON 설정
|
||||
isActive: boolean;
|
||||
createdBy?: number;
|
||||
updatedBy?: number;
|
||||
|
|
|
|||
Loading…
Reference in New Issue