Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream
This commit is contained in:
commit
6cbe200f00
|
|
@ -0,0 +1,27 @@
|
|||
# 프로젝트: 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단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# 프로젝트 진행 상황 (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`
|
||||
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* 디지털 트윈 외부 DB (DO_DY) 연결 및 쿼리 테스트 스크립트
|
||||
* READ-ONLY: SELECT 쿼리만 실행
|
||||
*/
|
||||
|
||||
import { Pool } from "pg";
|
||||
import mysql from "mysql2/promise";
|
||||
import { CredentialEncryption } from "../src/utils/credentialEncryption";
|
||||
|
||||
async function testDigitalTwinDb() {
|
||||
// 내부 DB 연결 (연결 정보 저장용)
|
||||
const internalPool = new Pool({
|
||||
host: process.env.DB_HOST || "localhost",
|
||||
port: parseInt(process.env.DB_PORT || "5432"),
|
||||
database: process.env.DB_NAME || "plm",
|
||||
user: process.env.DB_USER || "postgres",
|
||||
password: process.env.DB_PASSWORD || "ph0909!!",
|
||||
});
|
||||
|
||||
const encryptionKey =
|
||||
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
|
||||
const encryption = new CredentialEncryption(encryptionKey);
|
||||
|
||||
try {
|
||||
console.log("🚀 디지털 트윈 외부 DB 연결 테스트 시작\n");
|
||||
|
||||
// 디지털 트윈 외부 DB 연결 정보
|
||||
const digitalTwinConnection = {
|
||||
name: "디지털트윈_DO_DY",
|
||||
description: "디지털 트윈 후판(자재) 재고 정보 데이터베이스 (MariaDB)",
|
||||
dbType: "mysql", // MariaDB는 MySQL 프로토콜 사용
|
||||
host: "1.240.13.83",
|
||||
port: 4307,
|
||||
databaseName: "DO_DY",
|
||||
username: "root",
|
||||
password: "pohangms619!#",
|
||||
sslEnabled: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
console.log("📝 연결 정보:");
|
||||
console.log(` - 이름: ${digitalTwinConnection.name}`);
|
||||
console.log(` - DB 타입: ${digitalTwinConnection.dbType}`);
|
||||
console.log(` - 호스트: ${digitalTwinConnection.host}:${digitalTwinConnection.port}`);
|
||||
console.log(` - 데이터베이스: ${digitalTwinConnection.databaseName}\n`);
|
||||
|
||||
// 1. 외부 DB 직접 연결 테스트
|
||||
console.log("🔍 외부 DB 직접 연결 테스트 중...");
|
||||
|
||||
const externalConnection = await mysql.createConnection({
|
||||
host: digitalTwinConnection.host,
|
||||
port: digitalTwinConnection.port,
|
||||
database: digitalTwinConnection.databaseName,
|
||||
user: digitalTwinConnection.username,
|
||||
password: digitalTwinConnection.password,
|
||||
connectTimeout: 10000,
|
||||
});
|
||||
|
||||
console.log("✅ 외부 DB 연결 성공!\n");
|
||||
|
||||
// 2. SELECT 쿼리 실행
|
||||
console.log("📊 WSTKKY 테이블 쿼리 실행 중...\n");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
SKUMKEY -- 제품번호
|
||||
, SKUDESC -- 자재명
|
||||
, SKUTHIC -- 두께
|
||||
, SKUWIDT -- 폭
|
||||
, SKULENG -- 길이
|
||||
, SKUWEIG -- 중량
|
||||
, STOTQTY -- 수량
|
||||
, SUOMKEY -- 단위
|
||||
FROM DO_DY.WSTKKY
|
||||
LIMIT 10
|
||||
`;
|
||||
|
||||
const [rows] = await externalConnection.execute(query);
|
||||
|
||||
console.log("✅ 쿼리 실행 성공!\n");
|
||||
console.log(`📦 조회된 데이터: ${Array.isArray(rows) ? rows.length : 0}건\n`);
|
||||
|
||||
if (Array.isArray(rows) && rows.length > 0) {
|
||||
console.log("🔍 샘플 데이터 (첫 3건):\n");
|
||||
rows.slice(0, 3).forEach((row: any, index: number) => {
|
||||
console.log(`[${index + 1}]`);
|
||||
console.log(` 제품번호(SKUMKEY): ${row.SKUMKEY}`);
|
||||
console.log(` 자재명(SKUDESC): ${row.SKUDESC}`);
|
||||
console.log(` 두께(SKUTHIC): ${row.SKUTHIC}`);
|
||||
console.log(` 폭(SKUWIDT): ${row.SKUWIDT}`);
|
||||
console.log(` 길이(SKULENG): ${row.SKULENG}`);
|
||||
console.log(` 중량(SKUWEIG): ${row.SKUWEIG}`);
|
||||
console.log(` 수량(STOTQTY): ${row.STOTQTY}`);
|
||||
console.log(` 단위(SUOMKEY): ${row.SUOMKEY}\n`);
|
||||
});
|
||||
|
||||
// 전체 데이터 JSON 출력
|
||||
console.log("📄 전체 데이터 (JSON):");
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
console.log("\n");
|
||||
}
|
||||
|
||||
await externalConnection.end();
|
||||
|
||||
// 3. 내부 DB에 연결 정보 저장
|
||||
console.log("💾 내부 DB에 연결 정보 저장 중...");
|
||||
|
||||
const encryptedPassword = encryption.encrypt(digitalTwinConnection.password);
|
||||
|
||||
// 중복 체크
|
||||
const existingResult = await internalPool.query(
|
||||
"SELECT id FROM flow_external_db_connection WHERE name = $1",
|
||||
[digitalTwinConnection.name]
|
||||
);
|
||||
|
||||
let connectionId: number;
|
||||
|
||||
if (existingResult.rows.length > 0) {
|
||||
connectionId = existingResult.rows[0].id;
|
||||
console.log(`⚠️ 이미 존재하는 연결 (ID: ${connectionId})`);
|
||||
|
||||
// 기존 연결 업데이트
|
||||
await internalPool.query(
|
||||
`UPDATE flow_external_db_connection
|
||||
SET description = $1,
|
||||
db_type = $2,
|
||||
host = $3,
|
||||
port = $4,
|
||||
database_name = $5,
|
||||
username = $6,
|
||||
password_encrypted = $7,
|
||||
ssl_enabled = $8,
|
||||
is_active = $9,
|
||||
updated_at = NOW(),
|
||||
updated_by = 'system'
|
||||
WHERE name = $10`,
|
||||
[
|
||||
digitalTwinConnection.description,
|
||||
digitalTwinConnection.dbType,
|
||||
digitalTwinConnection.host,
|
||||
digitalTwinConnection.port,
|
||||
digitalTwinConnection.databaseName,
|
||||
digitalTwinConnection.username,
|
||||
encryptedPassword,
|
||||
digitalTwinConnection.sslEnabled,
|
||||
digitalTwinConnection.isActive,
|
||||
digitalTwinConnection.name,
|
||||
]
|
||||
);
|
||||
console.log(`✅ 연결 정보 업데이트 완료`);
|
||||
} else {
|
||||
// 새 연결 추가
|
||||
const result = await internalPool.query(
|
||||
`INSERT INTO flow_external_db_connection (
|
||||
name,
|
||||
description,
|
||||
db_type,
|
||||
host,
|
||||
port,
|
||||
database_name,
|
||||
username,
|
||||
password_encrypted,
|
||||
ssl_enabled,
|
||||
is_active,
|
||||
created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system')
|
||||
RETURNING id`,
|
||||
[
|
||||
digitalTwinConnection.name,
|
||||
digitalTwinConnection.description,
|
||||
digitalTwinConnection.dbType,
|
||||
digitalTwinConnection.host,
|
||||
digitalTwinConnection.port,
|
||||
digitalTwinConnection.databaseName,
|
||||
digitalTwinConnection.username,
|
||||
encryptedPassword,
|
||||
digitalTwinConnection.sslEnabled,
|
||||
digitalTwinConnection.isActive,
|
||||
]
|
||||
);
|
||||
connectionId = result.rows[0].id;
|
||||
console.log(`✅ 새 연결 추가 완료 (ID: ${connectionId})`);
|
||||
}
|
||||
|
||||
console.log("\n✅ 모든 테스트 완료!");
|
||||
console.log(`\n📌 연결 ID: ${connectionId}`);
|
||||
console.log(" 이 ID를 사용하여 플로우 관리나 제어 관리에서 외부 DB를 연동할 수 있습니다.");
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("\n❌ 오류 발생:", error.message);
|
||||
console.error("상세 정보:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
await internalPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 실행
|
||||
testDigitalTwinDb()
|
||||
.then(() => {
|
||||
console.log("\n🎉 스크립트 완료");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("\n💥 스크립트 실패:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
|
|
@ -59,6 +59,7 @@ import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
|||
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
||||
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
|
||||
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
||||
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
|
||||
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
|
||||
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
|
||||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||
|
|
@ -68,6 +69,8 @@ import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
|||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||
import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
||||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
||||
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -221,6 +224,7 @@ app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
|
|||
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
|
||||
app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
|
||||
// app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석)
|
||||
app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제)
|
||||
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
|
||||
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
|
||||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||
|
|
@ -230,6 +234,8 @@ app.use("/api/departments", departmentRoutes); // 부서 관리
|
|||
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
|
||||
app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
||||
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
||||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||
app.use("/api/orders", orderRoutes); // 수주 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,403 @@
|
|||
import { Request, Response } from "express";
|
||||
import { pool, queryOne } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
||||
|
||||
// 외부 DB 커넥터를 가져오는 헬퍼 함수
|
||||
export async function getExternalDbConnector(connectionId: number) {
|
||||
// 외부 DB 연결 정보 조회
|
||||
const connection = await queryOne<any>(
|
||||
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||
[connectionId]
|
||||
);
|
||||
|
||||
if (!connection) {
|
||||
throw new Error(`외부 DB 연결 정보를 찾을 수 없습니다. ID: ${connectionId}`);
|
||||
}
|
||||
|
||||
// 패스워드 복호화
|
||||
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
|
||||
|
||||
// DB 연결 설정
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
user: connection.username,
|
||||
password: decryptedPassword,
|
||||
database: connection.database_name,
|
||||
};
|
||||
|
||||
// DB 커넥터 생성
|
||||
return await DatabaseConnectorFactory.createConnector(
|
||||
connection.db_type || "mariadb",
|
||||
config,
|
||||
connectionId
|
||||
);
|
||||
}
|
||||
|
||||
// 동적 계층 구조 데이터 조회 (범용)
|
||||
export const 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;
|
||||
|
||||
if (!externalDbConnectionId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "외부 DB 연결 ID가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||
|
||||
// 테이블명을 사용하여 모든 컬럼 조회
|
||||
const query = `SELECT * FROM ${tableName} LIMIT 100`;
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("창고 목록 조회", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
count: result.rows.length,
|
||||
data: result.rows, // 실제 데이터 확인
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("창고 목록 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "창고 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
|
||||
export const getAreas = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, warehouseKey, tableName } = req.query;
|
||||
|
||||
if (!externalDbConnectionId || !warehouseKey || !tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||
|
||||
const query = `
|
||||
SELECT * FROM ${tableName}
|
||||
WHERE WAREKEY = '${warehouseKey}'
|
||||
LIMIT 1000
|
||||
`;
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("구역 목록 조회", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
warehouseKey,
|
||||
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 getLocations = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, areaKey, tableName } = req.query;
|
||||
|
||||
if (!externalDbConnectionId || !areaKey || !tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||
|
||||
const query = `
|
||||
SELECT * FROM ${tableName}
|
||||
WHERE AREAKEY = '${areaKey}'
|
||||
LIMIT 1000
|
||||
`;
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("위치 목록 조회", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
areaKey,
|
||||
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 getMaterials = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const {
|
||||
externalDbConnectionId,
|
||||
locaKey,
|
||||
tableName,
|
||||
keyColumn,
|
||||
locationKeyColumn,
|
||||
layerColumn
|
||||
} = req.query;
|
||||
|
||||
if (!externalDbConnectionId || !locaKey || !tableName || !locationKeyColumn) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("자재 목록 조회", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
locaKey,
|
||||
count: result.rows.length,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자재 목록 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자재 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지
|
||||
export const getMaterialCounts = async (req: Request, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { externalDbConnectionId, locationKeys, tableName } = req.body;
|
||||
|
||||
if (!externalDbConnectionId || !locationKeys || !tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
|
||||
|
||||
const keysString = locationKeys.map((key: string) => `'${key}'`).join(",");
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
LOCAKEY as location_key,
|
||||
COUNT(*) as count
|
||||
FROM ${tableName}
|
||||
WHERE LOCAKEY IN (${keysString})
|
||||
GROUP BY LOCAKEY
|
||||
`;
|
||||
|
||||
const result = await connector.executeQuery(query);
|
||||
|
||||
logger.info("자재 개수 조회", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
locationCount: locationKeys.length,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("자재 개수 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "자재 개수 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,450 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { pool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
// 레이아웃 목록 조회
|
||||
export const getLayouts = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const { externalDbConnectionId, warehouseKey } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
l.*,
|
||||
u1.user_name as created_by_name,
|
||||
u2.user_name as updated_by_name,
|
||||
COUNT(o.id) as object_count
|
||||
FROM digital_twin_layout l
|
||||
LEFT JOIN user_info u1 ON l.created_by = u1.user_id
|
||||
LEFT JOIN user_info u2 ON l.updated_by = u2.user_id
|
||||
LEFT JOIN digital_twin_objects o ON l.id = o.layout_id
|
||||
WHERE l.company_code = $1
|
||||
`;
|
||||
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (externalDbConnectionId) {
|
||||
query += ` AND l.external_db_connection_id = $${paramIndex}`;
|
||||
params.push(externalDbConnectionId);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (warehouseKey) {
|
||||
query += ` AND l.warehouse_key = $${paramIndex}`;
|
||||
params.push(warehouseKey);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
query += `
|
||||
GROUP BY l.id, u1.user_name, u2.user_name
|
||||
ORDER BY l.updated_at DESC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("레이아웃 목록 조회", {
|
||||
companyCode,
|
||||
count: result.rowCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("레이아웃 목록 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레이아웃 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 레이아웃 상세 조회 (객체 포함)
|
||||
export const getLayoutById = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
// 레이아웃 기본 정보
|
||||
const layoutQuery = `
|
||||
SELECT l.*
|
||||
FROM digital_twin_layout l
|
||||
WHERE l.id = $1 AND l.company_code = $2
|
||||
`;
|
||||
|
||||
const layoutResult = await pool.query(layoutQuery, [id, companyCode]);
|
||||
|
||||
if (layoutResult.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 배치된 객체들 조회
|
||||
const objectsQuery = `
|
||||
SELECT *
|
||||
FROM digital_twin_objects
|
||||
WHERE layout_id = $1
|
||||
ORDER BY display_order, created_at
|
||||
`;
|
||||
|
||||
const objectsResult = await pool.query(objectsQuery, [id]);
|
||||
|
||||
logger.info("레이아웃 상세 조회", {
|
||||
companyCode,
|
||||
layoutId: id,
|
||||
objectCount: objectsResult.rowCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
layout: layoutResult.rows[0],
|
||||
objects: objectsResult.rows,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("레이아웃 상세 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레이아웃 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 레이아웃 생성
|
||||
export const createLayout = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
const {
|
||||
externalDbConnectionId,
|
||||
warehouseKey,
|
||||
layoutName,
|
||||
description,
|
||||
hierarchyConfig,
|
||||
objects,
|
||||
} = req.body;
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 레이아웃 생성
|
||||
const layoutQuery = `
|
||||
INSERT INTO digital_twin_layout (
|
||||
company_code, external_db_connection_id, warehouse_key,
|
||||
layout_name, description, hierarchy_config, created_by, updated_by
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const layoutResult = await client.query(layoutQuery, [
|
||||
companyCode,
|
||||
externalDbConnectionId,
|
||||
warehouseKey,
|
||||
layoutName,
|
||||
description,
|
||||
hierarchyConfig ? JSON.stringify(hierarchyConfig) : null,
|
||||
userId,
|
||||
]);
|
||||
|
||||
const layoutId = layoutResult.rows[0].id;
|
||||
|
||||
// 객체들 저장
|
||||
if (objects && objects.length > 0) {
|
||||
const objectQuery = `
|
||||
INSERT INTO digital_twin_objects (
|
||||
layout_id, object_type, object_name,
|
||||
position_x, position_y, position_z,
|
||||
size_x, size_y, size_z,
|
||||
rotation, color,
|
||||
area_key, loca_key, loc_type,
|
||||
material_count, material_preview_height,
|
||||
parent_id, display_order, locked,
|
||||
hierarchy_level, parent_key, external_key
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
|
||||
`;
|
||||
|
||||
for (const obj of objects) {
|
||||
await client.query(objectQuery, [
|
||||
layoutId,
|
||||
obj.type,
|
||||
obj.name,
|
||||
obj.position.x,
|
||||
obj.position.y,
|
||||
obj.position.z,
|
||||
obj.size.x,
|
||||
obj.size.y,
|
||||
obj.size.z,
|
||||
obj.rotation || 0,
|
||||
obj.color,
|
||||
obj.areaKey || null,
|
||||
obj.locaKey || null,
|
||||
obj.locType || null,
|
||||
obj.materialCount || 0,
|
||||
obj.materialPreview?.height || null,
|
||||
obj.parentId || null,
|
||||
obj.displayOrder || 0,
|
||||
obj.locked || false,
|
||||
obj.hierarchyLevel || 1,
|
||||
obj.parentKey || null,
|
||||
obj.externalKey || null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("레이아웃 생성", {
|
||||
companyCode,
|
||||
layoutId,
|
||||
objectCount: objects?.length || 0,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: layoutResult.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("레이아웃 생성 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레이아웃 생성 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
// 레이아웃 수정
|
||||
export const updateLayout = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
const { id } = req.params;
|
||||
const {
|
||||
layoutName,
|
||||
description,
|
||||
hierarchyConfig,
|
||||
externalDbConnectionId,
|
||||
warehouseKey,
|
||||
objects,
|
||||
} = req.body;
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 레이아웃 기본 정보 수정
|
||||
const updateLayoutQuery = `
|
||||
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_at = NOW()
|
||||
WHERE id = $7 AND company_code = $8
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const layoutResult = await client.query(updateLayoutQuery, [
|
||||
layoutName,
|
||||
description,
|
||||
hierarchyConfig ? JSON.stringify(hierarchyConfig) : null,
|
||||
externalDbConnectionId || null,
|
||||
warehouseKey || null,
|
||||
userId,
|
||||
id,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
if (layoutResult.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 기존 객체 삭제
|
||||
await client.query(
|
||||
"DELETE FROM digital_twin_objects WHERE layout_id = $1",
|
||||
[id]
|
||||
);
|
||||
|
||||
// 새 객체 저장 (부모-자식 관계 처리)
|
||||
if (objects && objects.length > 0) {
|
||||
const objectQuery = `
|
||||
INSERT INTO digital_twin_objects (
|
||||
layout_id, object_type, object_name,
|
||||
position_x, position_y, position_z,
|
||||
size_x, size_y, size_z,
|
||||
rotation, color,
|
||||
area_key, loca_key, loc_type,
|
||||
material_count, material_preview_height,
|
||||
parent_id, display_order, locked,
|
||||
hierarchy_level, parent_key, external_key
|
||||
)
|
||||
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
|
||||
`;
|
||||
|
||||
// 임시 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;
|
||||
|
||||
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,
|
||||
realParentId, // 실제 DB ID 사용
|
||||
obj.displayOrder || 0,
|
||||
obj.locked || false,
|
||||
obj.hierarchyLevel || 1,
|
||||
obj.parentKey || null,
|
||||
obj.externalKey || null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("레이아웃 수정", {
|
||||
companyCode,
|
||||
layoutId: id,
|
||||
objectCount: objects?.length || 0,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: layoutResult.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("레이아웃 수정 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레이아웃 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
// 레이아웃 삭제
|
||||
export const deleteLayout = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
const query = `
|
||||
DELETE FROM digital_twin_layout
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [id, companyCode]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("레이아웃 삭제", {
|
||||
companyCode,
|
||||
layoutId: id,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "레이아웃이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("레이아웃 삭제 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레이아웃 삭제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 엔티티 검색 API
|
||||
* GET /api/entity-search/:tableName
|
||||
*/
|
||||
export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const {
|
||||
searchText = "",
|
||||
searchFields = "",
|
||||
filterCondition = "{}",
|
||||
page = "1",
|
||||
limit = "20",
|
||||
} = req.query;
|
||||
|
||||
// tableName 유효성 검증
|
||||
if (!tableName || tableName === "undefined" || tableName === "null") {
|
||||
logger.warn("엔티티 검색 실패: 테이블명이 없음", { tableName });
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"테이블명이 지정되지 않았습니다. 컴포넌트 설정에서 sourceTable을 확인해주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
// 멀티테넌시
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
// 검색 필드 파싱
|
||||
const fields = searchFields
|
||||
? (searchFields as string).split(",").map((f) => f.trim())
|
||||
: [];
|
||||
|
||||
// WHERE 조건 생성
|
||||
const whereConditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 멀티테넌시 필터링
|
||||
if (companyCode !== "*") {
|
||||
whereConditions.push(`company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 검색 조건
|
||||
if (searchText && fields.length > 0) {
|
||||
const searchConditions = fields.map((field) => {
|
||||
const condition = `${field}::text ILIKE $${paramIndex}`;
|
||||
paramIndex++;
|
||||
return condition;
|
||||
});
|
||||
whereConditions.push(`(${searchConditions.join(" OR ")})`);
|
||||
|
||||
// 검색어 파라미터 추가
|
||||
fields.forEach(() => {
|
||||
params.push(`%${searchText}%`);
|
||||
});
|
||||
}
|
||||
|
||||
// 추가 필터 조건
|
||||
const additionalFilter = JSON.parse(filterCondition as string);
|
||||
for (const [key, value] of Object.entries(additionalFilter)) {
|
||||
whereConditions.push(`${key} = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 페이징
|
||||
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
|
||||
const whereClause =
|
||||
whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 쿼리 실행
|
||||
const pool = getPool();
|
||||
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
|
||||
const dataQuery = `
|
||||
SELECT * FROM ${tableName} ${whereClause}
|
||||
ORDER BY id DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
params.push(parseInt(limit as string));
|
||||
params.push(offset);
|
||||
|
||||
const countResult = await pool.query(
|
||||
countQuery,
|
||||
params.slice(0, params.length - 2)
|
||||
);
|
||||
const dataResult = await pool.query(dataQuery, params);
|
||||
|
||||
logger.info("엔티티 검색 성공", {
|
||||
tableName,
|
||||
searchText,
|
||||
companyCode,
|
||||
rowCount: dataResult.rowCount,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: dataResult.rows,
|
||||
pagination: {
|
||||
total: parseInt(countResult.rows[0].count),
|
||||
page: parseInt(page as string),
|
||||
limit: parseInt(limit as string),
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("엔티티 검색 오류", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 수주 번호 생성 함수
|
||||
* 형식: ORD + YYMMDD + 4자리 시퀀스
|
||||
* 예: ORD250114001
|
||||
*/
|
||||
async function generateOrderNumber(companyCode: string): Promise<string> {
|
||||
const pool = getPool();
|
||||
const today = new Date();
|
||||
const year = today.getFullYear().toString().slice(2); // 25
|
||||
const month = String(today.getMonth() + 1).padStart(2, "0"); // 01
|
||||
const day = String(today.getDate()).padStart(2, "0"); // 14
|
||||
const dateStr = `${year}${month}${day}`; // 250114
|
||||
|
||||
// 당일 수주 카운트 조회
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM order_mng_master
|
||||
WHERE objid LIKE $1
|
||||
AND writer LIKE $2
|
||||
`;
|
||||
|
||||
const pattern = `ORD${dateStr}%`;
|
||||
const result = await pool.query(countQuery, [pattern, `%${companyCode}%`]);
|
||||
const count = parseInt(result.rows[0]?.count || "0");
|
||||
const seq = count + 1;
|
||||
|
||||
return `ORD${dateStr}${String(seq).padStart(4, "0")}`; // ORD250114001
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 등록 API
|
||||
* POST /api/orders
|
||||
*/
|
||||
export async function createOrder(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const {
|
||||
inputMode, // 입력 방식
|
||||
customerCode, // 거래처 코드
|
||||
deliveryDate, // 납품일
|
||||
items, // 품목 목록
|
||||
memo, // 메모
|
||||
} = req.body;
|
||||
|
||||
// 멀티테넌시
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
// 유효성 검사
|
||||
if (!customerCode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "거래처 코드는 필수입니다",
|
||||
});
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "품목은 최소 1개 이상 필요합니다",
|
||||
});
|
||||
}
|
||||
|
||||
// 수주 번호 생성
|
||||
const orderNo = await generateOrderNumber(companyCode);
|
||||
|
||||
// 전체 금액 계산
|
||||
const totalAmount = items.reduce(
|
||||
(sum: number, item: any) => sum + (item.amount || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// 수주 마스터 생성
|
||||
const masterQuery = `
|
||||
INSERT INTO order_mng_master (
|
||||
objid,
|
||||
partner_objid,
|
||||
final_delivery_date,
|
||||
reason,
|
||||
status,
|
||||
reg_date,
|
||||
writer
|
||||
) VALUES ($1, $2, $3, $4, $5, NOW(), $6)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const masterResult = await pool.query(masterQuery, [
|
||||
orderNo,
|
||||
customerCode,
|
||||
deliveryDate || null,
|
||||
memo || null,
|
||||
"진행중",
|
||||
`${userId}|${companyCode}`,
|
||||
]);
|
||||
|
||||
const masterObjid = masterResult.rows[0].objid;
|
||||
|
||||
// 수주 상세 (품목) 생성
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const subObjid = `${orderNo}_${i + 1}`;
|
||||
|
||||
const subQuery = `
|
||||
INSERT INTO order_mng_sub (
|
||||
objid,
|
||||
order_mng_master_objid,
|
||||
part_objid,
|
||||
partner_objid,
|
||||
partner_price,
|
||||
partner_qty,
|
||||
delivery_date,
|
||||
status,
|
||||
regdate,
|
||||
writer
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9)
|
||||
`;
|
||||
|
||||
await pool.query(subQuery, [
|
||||
subObjid,
|
||||
masterObjid,
|
||||
item.item_code || item.id, // 품목 코드
|
||||
customerCode,
|
||||
item.unit_price || 0,
|
||||
item.quantity || 0,
|
||||
item.delivery_date || deliveryDate || null,
|
||||
"진행중",
|
||||
`${userId}|${companyCode}`,
|
||||
]);
|
||||
}
|
||||
|
||||
logger.info("수주 등록 성공", {
|
||||
companyCode,
|
||||
orderNo,
|
||||
masterObjid,
|
||||
itemCount: items.length,
|
||||
totalAmount,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
orderNo,
|
||||
masterObjid,
|
||||
itemCount: items.length,
|
||||
totalAmount,
|
||||
},
|
||||
message: "수주가 등록되었습니다",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("수주 등록 오류", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "수주 등록 중 오류가 발생했습니다",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 목록 조회 API
|
||||
* GET /api/orders
|
||||
*/
|
||||
export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const { page = "1", limit = "20", searchText = "" } = req.query;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
|
||||
|
||||
// WHERE 조건
|
||||
const whereConditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 멀티테넌시 (writer 필드에 company_code 포함)
|
||||
if (companyCode !== "*") {
|
||||
whereConditions.push(`writer LIKE $${paramIndex}`);
|
||||
params.push(`%${companyCode}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 검색
|
||||
if (searchText) {
|
||||
whereConditions.push(`objid LIKE $${paramIndex}`);
|
||||
params.push(`%${searchText}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 카운트 쿼리
|
||||
const countQuery = `SELECT COUNT(*) as count FROM order_mng_master ${whereClause}`;
|
||||
const countResult = await pool.query(countQuery, params);
|
||||
const total = parseInt(countResult.rows[0]?.count || "0");
|
||||
|
||||
// 데이터 쿼리
|
||||
const dataQuery = `
|
||||
SELECT * FROM order_mng_master
|
||||
${whereClause}
|
||||
ORDER BY reg_date DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
params.push(parseInt(limit as string));
|
||||
params.push(offset);
|
||||
|
||||
const dataResult = await pool.query(dataQuery, params);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: dataResult.rows,
|
||||
pagination: {
|
||||
total,
|
||||
page: parseInt(page as string),
|
||||
limit: parseInt(limit as string),
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("수주 목록 조회 오류", { error: error.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,8 @@ export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
|
|||
const result = await screenManagementService.getScreensByCompany(
|
||||
targetCompanyCode,
|
||||
parseInt(page as string),
|
||||
parseInt(size as string)
|
||||
parseInt(size as string),
|
||||
searchTerm as string // 검색어 전달
|
||||
);
|
||||
|
||||
res.json({
|
||||
|
|
|
|||
|
|
@ -187,6 +187,16 @@ export const deleteCategoryValue = async (req: AuthenticatedRequest, res: Respon
|
|||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 값 삭제 실패: ${error.message}`);
|
||||
|
||||
// 사용 중인 경우 상세 에러 메시지 반환 (400)
|
||||
if (error.message.includes("삭제할 수 없습니다")) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
// 기타 에러 (500)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "카테고리 값 삭제 중 오류가 발생했습니다",
|
||||
|
|
|
|||
|
|
@ -1604,10 +1604,14 @@ export async function toggleLogTable(
|
|||
}
|
||||
|
||||
/**
|
||||
* 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회
|
||||
* 메뉴의 상위 메뉴들이 설정한 모든 카테고리 타입 컬럼 조회 (계층 구조 상속)
|
||||
*
|
||||
* @route GET /api/table-management/menu/:menuObjid/category-columns
|
||||
* @description 형제 메뉴들의 화면에서 사용하는 테이블의 input_type='category' 컬럼 조회
|
||||
* @description 현재 메뉴와 상위 메뉴들에서 설정한 category_column_mapping의 모든 카테고리 컬럼 조회
|
||||
*
|
||||
* 예시:
|
||||
* - 2레벨 메뉴 "고객사관리"에서 discount_type, rounding_type 설정
|
||||
* - 3레벨 메뉴 "고객등록", "고객조회" 등에서도 동일하게 보임 (상속)
|
||||
*/
|
||||
export async function getCategoryColumnsByMenu(
|
||||
req: AuthenticatedRequest,
|
||||
|
|
@ -1627,40 +1631,10 @@ export async function getCategoryColumnsByMenu(
|
|||
return;
|
||||
}
|
||||
|
||||
// 1. 형제 메뉴 조회
|
||||
const { getSiblingMenuObjids } = await import("../services/menuService");
|
||||
const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid));
|
||||
|
||||
logger.info("✅ 형제 메뉴 조회 완료", { siblingObjids });
|
||||
|
||||
// 2. 형제 메뉴들이 사용하는 테이블 조회
|
||||
const { getPool } = await import("../database/db");
|
||||
const pool = getPool();
|
||||
|
||||
const tablesQuery = `
|
||||
SELECT DISTINCT sd.table_name
|
||||
FROM screen_menu_assignments sma
|
||||
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
|
||||
WHERE sma.menu_objid = ANY($1)
|
||||
AND sma.company_code = $2
|
||||
AND sd.table_name IS NOT NULL
|
||||
`;
|
||||
|
||||
const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]);
|
||||
const tableNames = tablesResult.rows.map((row: any) => row.table_name);
|
||||
|
||||
logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length });
|
||||
|
||||
if (tableNames.length === 0) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: [],
|
||||
message: "형제 메뉴에 연결된 테이블이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. category_column_mapping 테이블 존재 여부 확인
|
||||
// 1. category_column_mapping 테이블 존재 여부 확인
|
||||
const tableExistsResult = await pool.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
|
|
@ -1672,33 +1646,42 @@ export async function getCategoryColumnsByMenu(
|
|||
let columnsResult;
|
||||
|
||||
if (mappingTableExists) {
|
||||
// 🆕 category_column_mapping을 사용한 필터링
|
||||
logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
|
||||
// 🆕 category_column_mapping을 사용한 계층 구조 기반 조회
|
||||
logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회 (계층 구조 상속)", { menuObjid, companyCode });
|
||||
|
||||
// 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀)
|
||||
const ancestorMenuQuery = `
|
||||
WITH RECURSIVE menu_hierarchy AS (
|
||||
-- 현재 메뉴
|
||||
SELECT objid, parent_obj_id, menu_type
|
||||
SELECT objid, parent_obj_id, menu_type, menu_name_kor
|
||||
FROM menu_info
|
||||
WHERE objid = $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 부모 메뉴 재귀 조회
|
||||
SELECT m.objid, m.parent_obj_id, m.menu_type
|
||||
SELECT m.objid, m.parent_obj_id, m.menu_type, m.menu_name_kor
|
||||
FROM menu_info m
|
||||
INNER JOIN menu_hierarchy mh ON m.objid = mh.parent_obj_id
|
||||
WHERE m.parent_obj_id != 0 -- 최상위 메뉴(parent_obj_id=0) 제외
|
||||
)
|
||||
SELECT ARRAY_AGG(objid) as menu_objids
|
||||
SELECT
|
||||
ARRAY_AGG(objid) as menu_objids,
|
||||
ARRAY_AGG(menu_name_kor) as menu_names
|
||||
FROM menu_hierarchy
|
||||
`;
|
||||
|
||||
const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]);
|
||||
const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)];
|
||||
const ancestorMenuNames = ancestorMenuResult.rows[0]?.menu_names || [];
|
||||
|
||||
logger.info("✅ 상위 메뉴 계층 조회 완료", {
|
||||
ancestorMenuObjids,
|
||||
ancestorMenuNames,
|
||||
hierarchyDepth: ancestorMenuObjids.length
|
||||
});
|
||||
|
||||
// 상위 메뉴들에 설정된 모든 카테고리 컬럼 조회 (테이블 필터링 제거)
|
||||
const columnsQuery = `
|
||||
SELECT DISTINCT
|
||||
ttc.table_name AS "tableName",
|
||||
|
|
@ -1711,7 +1694,8 @@ export async function getCategoryColumnsByMenu(
|
|||
cl.column_label,
|
||||
initcap(replace(ccm.logical_column_name, '_', ' '))
|
||||
) AS "columnLabel",
|
||||
ttc.input_type AS "inputType"
|
||||
ttc.input_type AS "inputType",
|
||||
ccm.menu_objid AS "definedAtMenuObjid"
|
||||
FROM category_column_mapping ccm
|
||||
INNER JOIN table_type_columns ttc
|
||||
ON ccm.table_name = ttc.table_name
|
||||
|
|
@ -1721,18 +1705,48 @@ export async function getCategoryColumnsByMenu(
|
|||
AND ttc.column_name = cl.column_name
|
||||
LEFT JOIN table_labels tl
|
||||
ON ttc.table_name = tl.table_name
|
||||
WHERE ccm.table_name = ANY($1)
|
||||
AND ccm.company_code = $2
|
||||
AND ccm.menu_objid = ANY($3)
|
||||
WHERE ccm.company_code = $1
|
||||
AND ccm.menu_objid = ANY($2)
|
||||
AND ttc.input_type = 'category'
|
||||
ORDER BY ttc.table_name, ccm.logical_column_name
|
||||
`;
|
||||
|
||||
columnsResult = await pool.query(columnsQuery, [tableNames, companyCode, ancestorMenuObjids]);
|
||||
logger.info("✅ category_column_mapping 기반 조회 완료", { rowCount: columnsResult.rows.length });
|
||||
columnsResult = await pool.query(columnsQuery, [companyCode, ancestorMenuObjids]);
|
||||
logger.info("✅ category_column_mapping 기반 조회 완료 (계층 구조 상속)", {
|
||||
rowCount: columnsResult.rows.length,
|
||||
columns: columnsResult.rows.map((r: any) => `${r.tableName}.${r.columnName}`)
|
||||
});
|
||||
} else {
|
||||
// 🔄 기존 방식: table_type_columns에서 모든 카테고리 컬럼 조회
|
||||
logger.info("🔍 레거시 방식: table_type_columns 기반 카테고리 컬럼 조회", { tableNames, companyCode });
|
||||
// 🔄 레거시 방식: 형제 메뉴들의 테이블에서 모든 카테고리 컬럼 조회
|
||||
logger.info("🔍 레거시 방식: 형제 메뉴 테이블 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
|
||||
|
||||
// 형제 메뉴 조회
|
||||
const { getSiblingMenuObjids } = await import("../services/menuService");
|
||||
const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid));
|
||||
|
||||
// 형제 메뉴들이 사용하는 테이블 조회
|
||||
const tablesQuery = `
|
||||
SELECT DISTINCT sd.table_name
|
||||
FROM screen_menu_assignments sma
|
||||
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
|
||||
WHERE sma.menu_objid = ANY($1)
|
||||
AND sma.company_code = $2
|
||||
AND sd.table_name IS NOT NULL
|
||||
`;
|
||||
|
||||
const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]);
|
||||
const tableNames = tablesResult.rows.map((row: any) => row.table_name);
|
||||
|
||||
logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length });
|
||||
|
||||
if (tableNames.length === 0) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: [],
|
||||
message: "형제 메뉴에 연결된 테이블이 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const columnsQuery = `
|
||||
SELECT
|
||||
|
|
|
|||
|
|
@ -14,8 +14,17 @@ router.get(
|
|||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter } =
|
||||
req.query;
|
||||
const {
|
||||
leftTable,
|
||||
rightTable,
|
||||
leftColumn,
|
||||
rightColumn,
|
||||
leftValue,
|
||||
dataFilter,
|
||||
enableEntityJoin,
|
||||
displayColumns,
|
||||
deduplication,
|
||||
} = req.query;
|
||||
|
||||
// 입력값 검증
|
||||
if (!leftTable || !rightTable || !leftColumn || !rightColumn) {
|
||||
|
|
@ -37,6 +46,11 @@ router.get(
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 enableEntityJoin 파싱
|
||||
const enableEntityJoinFlag =
|
||||
enableEntityJoin === "true" ||
|
||||
(typeof enableEntityJoin === "boolean" && enableEntityJoin);
|
||||
|
||||
// SQL 인젝션 방지를 위한 검증
|
||||
const tables = [leftTable as string, rightTable as string];
|
||||
const columns = [leftColumn as string, rightColumn as string];
|
||||
|
|
@ -64,6 +78,35 @@ router.get(
|
|||
// 회사 코드 추출 (멀티테넌시 필터링)
|
||||
const userCompany = req.user?.companyCode;
|
||||
|
||||
// displayColumns 파싱 (item_info.item_name 등)
|
||||
let parsedDisplayColumns:
|
||||
| Array<{ name: string; label?: string }>
|
||||
| undefined;
|
||||
if (displayColumns) {
|
||||
try {
|
||||
parsedDisplayColumns = JSON.parse(displayColumns as string);
|
||||
} catch (e) {
|
||||
console.error("displayColumns 파싱 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 deduplication 파싱
|
||||
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);
|
||||
} catch (e) {
|
||||
console.error("deduplication 파싱 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🔗 조인 데이터 조회:`, {
|
||||
leftTable,
|
||||
rightTable,
|
||||
|
|
@ -71,10 +114,13 @@ router.get(
|
|||
rightColumn,
|
||||
leftValue,
|
||||
userCompany,
|
||||
dataFilter: parsedDataFilter, // 🆕 데이터 필터 로그
|
||||
dataFilter: parsedDataFilter,
|
||||
enableEntityJoin: enableEntityJoinFlag,
|
||||
displayColumns: parsedDisplayColumns, // 🆕 표시 컬럼 로그
|
||||
deduplication: parsedDeduplication, // 🆕 중복 제거 로그
|
||||
});
|
||||
|
||||
// 조인 데이터 조회 (회사 코드 + 데이터 필터 전달)
|
||||
// 조인 데이터 조회 (회사 코드 + 데이터 필터 + Entity 조인 + 표시 컬럼 + 중복 제거 전달)
|
||||
const result = await dataService.getJoinedData(
|
||||
leftTable as string,
|
||||
rightTable as string,
|
||||
|
|
@ -82,7 +128,10 @@ router.get(
|
|||
rightColumn as string,
|
||||
leftValue as string,
|
||||
userCompany,
|
||||
parsedDataFilter // 🆕 데이터 필터 전달
|
||||
parsedDataFilter,
|
||||
enableEntityJoinFlag,
|
||||
parsedDisplayColumns, // 🆕 표시 컬럼 전달
|
||||
parsedDeduplication // 🆕 중복 제거 설정 전달
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -305,10 +354,38 @@ router.get(
|
|||
});
|
||||
}
|
||||
|
||||
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`);
|
||||
const { enableEntityJoin, groupByColumns } = req.query;
|
||||
const enableEntityJoinFlag =
|
||||
enableEntityJoin === "true" ||
|
||||
(typeof enableEntityJoin === "boolean" && enableEntityJoin);
|
||||
|
||||
// 레코드 상세 조회
|
||||
const result = await dataService.getRecordDetail(tableName, id);
|
||||
// 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());
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("groupByColumns 파싱 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, {
|
||||
enableEntityJoin: enableEntityJoinFlag,
|
||||
groupByColumns: groupByColumnsArray,
|
||||
});
|
||||
|
||||
// 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 포함)
|
||||
const result = await dataService.getRecordDetail(
|
||||
tableName,
|
||||
id,
|
||||
enableEntityJoinFlag,
|
||||
groupByColumnsArray
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
|
|
@ -338,6 +415,87 @@ router.get(
|
|||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 그룹화된 데이터 UPSERT API
|
||||
* POST /api/data/upsert-grouped
|
||||
*
|
||||
* 요청 본문:
|
||||
* {
|
||||
* tableName: string,
|
||||
* parentKeys: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" },
|
||||
* records: [ { customer_item_code: "84-44", start_date: "2025-11-18", ... }, ... ]
|
||||
* }
|
||||
*/
|
||||
router.post(
|
||||
"/upsert-grouped",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { tableName, parentKeys, records } = req.body;
|
||||
|
||||
// 입력값 검증
|
||||
if (!tableName || !parentKeys || !records || !Array.isArray(records)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"필수 파라미터가 누락되었습니다 (tableName, parentKeys, records).",
|
||||
error: "MISSING_PARAMETERS",
|
||||
});
|
||||
}
|
||||
|
||||
// 테이블명 검증
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명입니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`🔄 그룹화된 데이터 UPSERT: ${tableName}`, {
|
||||
parentKeys,
|
||||
recordCount: records.length,
|
||||
userCompany: req.user?.companyCode,
|
||||
userId: req.user?.userId,
|
||||
});
|
||||
|
||||
// UPSERT 수행
|
||||
const result = await dataService.upsertGroupedRecords(
|
||||
tableName,
|
||||
parentKeys,
|
||||
records,
|
||||
req.user?.companyCode,
|
||||
req.user?.userId
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
|
||||
console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, {
|
||||
inserted: result.data?.inserted || 0,
|
||||
updated: result.data?.updated || 0,
|
||||
deleted: result.data?.deleted || 0,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "데이터가 저장되었습니다.",
|
||||
inserted: result.data?.inserted || 0,
|
||||
updated: result.data?.updated || 0,
|
||||
deleted: result.data?.deleted || 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("그룹화된 데이터 UPSERT 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "데이터 저장 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 레코드 생성 API
|
||||
* POST /api/data/{tableName}
|
||||
|
|
@ -371,16 +529,22 @@ 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}`);
|
||||
|
|
@ -523,6 +687,49 @@ router.post(
|
|||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 그룹 삭제 API
|
||||
* POST /api/data/:tableName/delete-group
|
||||
*/
|
||||
router.post(
|
||||
"/:tableName/delete-group",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const filterConditions = req.body;
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions });
|
||||
|
||||
const result = await dataService.deleteGroupRecords(
|
||||
tableName,
|
||||
filterConditions
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
|
||||
console.log(`✅ 그룹 삭제: ${result.data?.deleted}개`);
|
||||
return res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error("그룹 삭제 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "그룹 삭제 실패",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:tableName/:id",
|
||||
authenticateToken,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import express from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
// 레이아웃 관리
|
||||
import {
|
||||
getLayouts,
|
||||
getLayoutById,
|
||||
createLayout,
|
||||
updateLayout,
|
||||
deleteLayout,
|
||||
} from "../controllers/digitalTwinLayoutController";
|
||||
|
||||
// 외부 DB 데이터 조회
|
||||
import {
|
||||
getHierarchyData,
|
||||
getChildrenData,
|
||||
getWarehouses,
|
||||
getAreas,
|
||||
getLocations,
|
||||
getMaterials,
|
||||
getMaterialCounts,
|
||||
} from "../controllers/digitalTwinDataController";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// ========== 레이아웃 관리 API ==========
|
||||
router.get("/layouts", getLayouts); // 레이아웃 목록
|
||||
router.get("/layouts/:id", getLayoutById); // 레이아웃 상세
|
||||
router.post("/layouts", createLayout); // 레이아웃 생성
|
||||
router.put("/layouts/:id", updateLayout); // 레이아웃 수정
|
||||
router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제
|
||||
|
||||
// ========== 외부 DB 데이터 조회 API ==========
|
||||
|
||||
// 동적 계층 구조 API
|
||||
router.post("/data/hierarchy", getHierarchyData); // 전체 계층 데이터 조회
|
||||
router.post("/data/children", getChildrenData); // 특정 부모의 하위 데이터 조회
|
||||
|
||||
// 테이블 메타데이터 API
|
||||
router.get("/data/tables/:connectionId", async (req, res) => {
|
||||
// 테이블 목록 조회
|
||||
try {
|
||||
const { ExternalDbConnectionService } = await import("../services/externalDbConnectionService");
|
||||
const result = await ExternalDbConnectionService.getTablesFromConnection(Number(req.params.connectionId));
|
||||
return res.json(result);
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/data/table-preview/:connectionId/:tableName", async (req, res) => {
|
||||
// 테이블 미리보기 (10개 레코드)
|
||||
try {
|
||||
const { connectionId, tableName } = req.params;
|
||||
const { getExternalDbConnector } = await import("../controllers/digitalTwinDataController");
|
||||
const connector = await getExternalDbConnector(Number(connectionId));
|
||||
const result = await connector.executeQuery(`SELECT * FROM ${tableName} LIMIT 10`);
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 레거시 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로 변경
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { searchEntity } from "../controllers/entitySearchController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* 엔티티 검색 API
|
||||
* GET /api/entity-search/:tableName
|
||||
*/
|
||||
router.get("/:tableName", authenticateToken, searchEntity);
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { createOrder, getOrders } from "../controllers/orderController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* 수주 등록
|
||||
* POST /api/orders
|
||||
*/
|
||||
router.post("/", authenticateToken, createOrder);
|
||||
|
||||
/**
|
||||
* 수주 목록 조회
|
||||
* GET /api/orders
|
||||
*/
|
||||
router.get("/", authenticateToken, getOrders);
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -14,7 +14,9 @@
|
|||
* - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능
|
||||
*/
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool import
|
||||
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
|
||||
import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성
|
||||
|
||||
interface GetTableDataParams {
|
||||
tableName: string;
|
||||
|
|
@ -53,6 +55,103 @@ const BLOCKED_TABLES = [
|
|||
const TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
class DataService {
|
||||
/**
|
||||
* 중복 데이터 제거 (메모리 내 처리)
|
||||
*/
|
||||
private deduplicateData(
|
||||
data: any[],
|
||||
config: {
|
||||
groupByColumn: string;
|
||||
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||
sortColumn?: string;
|
||||
}
|
||||
): any[] {
|
||||
if (!data || data.length === 0) return data;
|
||||
|
||||
// 그룹별로 데이터 분류
|
||||
const groups: Record<string, any[]> = {};
|
||||
|
||||
for (const row of data) {
|
||||
const groupKey = row[config.groupByColumn];
|
||||
if (groupKey === undefined || groupKey === null) continue;
|
||||
|
||||
if (!groups[groupKey]) {
|
||||
groups[groupKey] = [];
|
||||
}
|
||||
groups[groupKey].push(row);
|
||||
}
|
||||
|
||||
// 각 그룹에서 하나의 행만 선택
|
||||
const result: any[] = [];
|
||||
|
||||
for (const [groupKey, rows] of Object.entries(groups)) {
|
||||
if (rows.length === 0) continue;
|
||||
|
||||
let selectedRow: any;
|
||||
|
||||
switch (config.keepStrategy) {
|
||||
case "latest":
|
||||
// 정렬 컬럼 기준 최신 (가장 큰 값)
|
||||
if (config.sortColumn) {
|
||||
rows.sort((a, b) => {
|
||||
const aVal = a[config.sortColumn!];
|
||||
const bVal = b[config.sortColumn!];
|
||||
if (aVal === bVal) return 0;
|
||||
if (aVal > bVal) return -1;
|
||||
return 1;
|
||||
});
|
||||
}
|
||||
selectedRow = rows[0];
|
||||
break;
|
||||
|
||||
case "earliest":
|
||||
// 정렬 컬럼 기준 최초 (가장 작은 값)
|
||||
if (config.sortColumn) {
|
||||
rows.sort((a, b) => {
|
||||
const aVal = a[config.sortColumn!];
|
||||
const bVal = b[config.sortColumn!];
|
||||
if (aVal === bVal) return 0;
|
||||
if (aVal < bVal) return -1;
|
||||
return 1;
|
||||
});
|
||||
}
|
||||
selectedRow = rows[0];
|
||||
break;
|
||||
|
||||
case "base_price":
|
||||
// base_price = true인 행 찾기
|
||||
selectedRow = rows.find(row => row.base_price === true) || rows[0];
|
||||
break;
|
||||
|
||||
case "current_date":
|
||||
// start_date <= CURRENT_DATE <= end_date 조건에 맞는 행
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0); // 시간 제거
|
||||
|
||||
selectedRow = rows.find(row => {
|
||||
const startDate = row.start_date ? new Date(row.start_date) : null;
|
||||
const endDate = row.end_date ? new Date(row.end_date) : null;
|
||||
|
||||
if (startDate) startDate.setHours(0, 0, 0, 0);
|
||||
if (endDate) endDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const afterStart = !startDate || today >= startDate;
|
||||
const beforeEnd = !endDate || today <= endDate;
|
||||
|
||||
return afterStart && beforeEnd;
|
||||
}) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행
|
||||
break;
|
||||
|
||||
default:
|
||||
selectedRow = rows[0];
|
||||
}
|
||||
|
||||
result.push(selectedRow);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 접근 검증 (공통 메서드)
|
||||
*/
|
||||
|
|
@ -374,11 +473,13 @@ class DataService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 레코드 상세 조회
|
||||
* 레코드 상세 조회 (Entity Join 지원 + 그룹핑 기반 다중 레코드 조회)
|
||||
*/
|
||||
async getRecordDetail(
|
||||
tableName: string,
|
||||
id: string | number
|
||||
id: string | number,
|
||||
enableEntityJoin: boolean = false,
|
||||
groupByColumns: string[] = []
|
||||
): Promise<ServiceResponse<any>> {
|
||||
try {
|
||||
// 테이블 접근 검증
|
||||
|
|
@ -401,6 +502,108 @@ class DataService {
|
|||
pkColumn = pkResult[0].attname;
|
||||
}
|
||||
|
||||
// 🆕 Entity Join이 활성화된 경우
|
||||
if (enableEntityJoin) {
|
||||
const { EntityJoinService } = await import("./entityJoinService");
|
||||
const entityJoinService = new EntityJoinService();
|
||||
|
||||
// Entity Join 구성 감지
|
||||
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
||||
|
||||
if (joinConfigs.length > 0) {
|
||||
console.log(`✅ Entity Join 감지: ${joinConfigs.length}개`);
|
||||
|
||||
// Entity Join 쿼리 생성 (개별 파라미터로 전달)
|
||||
const { query: joinQuery } = entityJoinService.buildJoinQuery(
|
||||
tableName,
|
||||
joinConfigs,
|
||||
["*"],
|
||||
`main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결
|
||||
);
|
||||
|
||||
const result = await pool.query(joinQuery, [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "레코드를 찾을 수 없습니다.",
|
||||
error: "RECORD_NOT_FOUND",
|
||||
};
|
||||
}
|
||||
|
||||
// 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환
|
||||
const normalizeDates = (rows: any[]) => {
|
||||
return rows.map(row => {
|
||||
const normalized: any = {};
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
if (value instanceof Date) {
|
||||
// Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시)
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(value.getDate()).padStart(2, '0');
|
||||
normalized[key] = `${year}-${month}-${day}`;
|
||||
} else {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
};
|
||||
|
||||
const normalizedRows = normalizeDates(result.rows);
|
||||
console.log(`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`, normalizedRows[0]);
|
||||
|
||||
// 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회
|
||||
if (groupByColumns.length > 0) {
|
||||
const baseRecord = result.rows[0];
|
||||
|
||||
// 그룹핑 컬럼들의 값 추출
|
||||
const groupConditions: string[] = [];
|
||||
const groupValues: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
for (const col of groupByColumns) {
|
||||
const value = normalizedRows[0][col];
|
||||
if (value !== undefined && value !== null) {
|
||||
groupConditions.push(`main."${col}" = $${paramIndex}`);
|
||||
groupValues.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (groupConditions.length > 0) {
|
||||
const groupWhereClause = groupConditions.join(" AND ");
|
||||
|
||||
console.log(`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`, groupValues);
|
||||
|
||||
// 그룹핑 기준으로 모든 레코드 조회
|
||||
const { query: groupQuery } = entityJoinService.buildJoinQuery(
|
||||
tableName,
|
||||
joinConfigs,
|
||||
["*"],
|
||||
groupWhereClause
|
||||
);
|
||||
|
||||
const groupResult = await pool.query(groupQuery, groupValues);
|
||||
|
||||
const normalizedGroupRows = normalizeDates(groupResult.rows);
|
||||
console.log(`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: normalizedGroupRows, // 🔧 배열로 반환!
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: normalizedRows[0], // 그룹핑 없으면 단일 레코드
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 쿼리 (Entity Join 없음)
|
||||
const queryText = `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
||||
const result = await query<any>(queryText, [id]);
|
||||
|
||||
|
|
@ -427,7 +630,7 @@ class DataService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 조인된 데이터 조회
|
||||
* 조인된 데이터 조회 (🆕 Entity 조인 지원)
|
||||
*/
|
||||
async getJoinedData(
|
||||
leftTable: string,
|
||||
|
|
@ -436,7 +639,15 @@ class DataService {
|
|||
rightColumn: string,
|
||||
leftValue?: string | number,
|
||||
userCompany?: string,
|
||||
dataFilter?: any // 🆕 데이터 필터
|
||||
dataFilter?: any, // 🆕 데이터 필터
|
||||
enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화
|
||||
displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등)
|
||||
deduplication?: { // 🆕 중복 제거 설정
|
||||
enabled: boolean;
|
||||
groupByColumn: string;
|
||||
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||
sortColumn?: string;
|
||||
}
|
||||
): Promise<ServiceResponse<any[]>> {
|
||||
try {
|
||||
// 왼쪽 테이블 접근 검증
|
||||
|
|
@ -451,6 +662,162 @@ class DataService {
|
|||
return rightValidation.error!;
|
||||
}
|
||||
|
||||
// 🆕 Entity 조인이 활성화된 경우 entityJoinService 사용
|
||||
if (enableEntityJoin) {
|
||||
try {
|
||||
const { entityJoinService } = await import("./entityJoinService");
|
||||
const joinConfigs = await entityJoinService.detectEntityJoins(rightTable);
|
||||
|
||||
// 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등)
|
||||
if (displayColumns && Array.isArray(displayColumns)) {
|
||||
// 테이블별로 요청된 컬럼들을 그룹핑
|
||||
const tableColumns: Record<string, Set<string>> = {};
|
||||
|
||||
for (const col of displayColumns) {
|
||||
if (col.name && col.name.includes('.')) {
|
||||
const [refTable, refColumn] = col.name.split('.');
|
||||
if (!tableColumns[refTable]) {
|
||||
tableColumns[refTable] = new Set();
|
||||
}
|
||||
tableColumns[refTable].add(refColumn);
|
||||
}
|
||||
}
|
||||
|
||||
// 각 테이블별로 처리
|
||||
for (const [refTable, refColumns] of Object.entries(tableColumns)) {
|
||||
// 이미 조인 설정에 있는지 확인
|
||||
const existingJoins = joinConfigs.filter(jc => jc.referenceTable === refTable);
|
||||
|
||||
if (existingJoins.length > 0) {
|
||||
// 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리
|
||||
for (const refColumn of refColumns) {
|
||||
// 이미 해당 컬럼을 표시하는 조인이 있는지 확인
|
||||
const existingJoin = existingJoins.find(
|
||||
jc => jc.displayColumns.length === 1 && jc.displayColumns[0] === refColumn
|
||||
);
|
||||
|
||||
if (!existingJoin) {
|
||||
// 없으면 새 조인 설정 복제하여 추가
|
||||
const baseJoin = existingJoins[0];
|
||||
const newJoin = {
|
||||
...baseJoin,
|
||||
displayColumns: [refColumn],
|
||||
aliasColumn: `${baseJoin.sourceColumn}_${refColumn}`, // 고유한 별칭 생성 (예: item_id_size)
|
||||
// ⚠️ 중요: referenceTable과 referenceColumn을 명시하여 JOIN된 테이블에서 가져옴
|
||||
referenceTable: refTable,
|
||||
referenceColumn: baseJoin.referenceColumn, // item_number 등
|
||||
};
|
||||
joinConfigs.push(newJoin);
|
||||
console.log(`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`⚠️ 조인 설정 없음: ${refTable}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (joinConfigs.length > 0) {
|
||||
console.log(`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`);
|
||||
|
||||
// WHERE 조건 생성
|
||||
const whereConditions: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 좌측 테이블 조인 조건 (leftValue로 필터링)
|
||||
// rightColumn을 직접 사용 (customer_item_mapping.customer_id = 'CUST-0002')
|
||||
if (leftValue !== undefined && leftValue !== null) {
|
||||
whereConditions.push(`main."${rightColumn}" = $${paramIndex}`);
|
||||
values.push(leftValue);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 회사별 필터링
|
||||
if (userCompany && userCompany !== "*") {
|
||||
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
|
||||
if (hasCompanyCode) {
|
||||
whereConditions.push(`main.company_code = $${paramIndex}`);
|
||||
values.push(userCompany);
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터 필터 적용 (buildDataFilterWhereClause 사용)
|
||||
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
|
||||
const { buildDataFilterWhereClause } = await import("../utils/dataFilterUtil");
|
||||
const filterResult = buildDataFilterWhereClause(dataFilter, "main", paramIndex);
|
||||
if (filterResult.whereClause) {
|
||||
whereConditions.push(filterResult.whereClause);
|
||||
values.push(...filterResult.params);
|
||||
paramIndex += filterResult.params.length;
|
||||
console.log(`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
|
||||
console.log(`📊 필터 파라미터:`, filterResult.params);
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
|
||||
|
||||
// Entity 조인 쿼리 빌드
|
||||
// buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달
|
||||
const selectColumns = ["*"];
|
||||
|
||||
const { query: finalQuery, aliasMap } = entityJoinService.buildJoinQuery(
|
||||
rightTable,
|
||||
joinConfigs,
|
||||
selectColumns,
|
||||
whereClause,
|
||||
"",
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
console.log(`🔍 Entity 조인 쿼리 실행 (전체):`, finalQuery);
|
||||
console.log(`🔍 파라미터:`, values);
|
||||
|
||||
const result = await pool.query(finalQuery, values);
|
||||
|
||||
// 🔧 날짜 타입 타임존 문제 해결
|
||||
const normalizeDates = (rows: any[]) => {
|
||||
return rows.map(row => {
|
||||
const normalized: any = {};
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
if (value instanceof Date) {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(value.getDate()).padStart(2, '0');
|
||||
normalized[key] = `${year}-${month}-${day}`;
|
||||
} else {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
};
|
||||
|
||||
const normalizedRows = normalizeDates(result.rows);
|
||||
console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`);
|
||||
|
||||
// 🆕 중복 제거 처리
|
||||
let finalData = normalizedRows;
|
||||
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
|
||||
finalData = this.deduplicateData(normalizedRows, deduplication);
|
||||
console.log(`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: finalData,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Entity 조인 처리 실패, 기본 조인으로 폴백:", error);
|
||||
// Entity 조인 실패 시 기본 조인으로 폴백
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 조인 쿼리 (Entity 조인 미사용 또는 실패 시)
|
||||
let queryText = `
|
||||
SELECT DISTINCT r.*
|
||||
FROM "${rightTable}" r
|
||||
|
|
@ -501,9 +868,17 @@ class DataService {
|
|||
|
||||
const result = await query<any>(queryText, values);
|
||||
|
||||
// 🆕 중복 제거 처리
|
||||
let finalData = result;
|
||||
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
|
||||
finalData = this.deduplicateData(result, deduplication);
|
||||
console.log(`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}개`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
data: finalData,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
|
@ -728,6 +1103,290 @@ class DataService {
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건에 맞는 모든 레코드 삭제 (그룹 삭제)
|
||||
*/
|
||||
async deleteGroupRecords(
|
||||
tableName: string,
|
||||
filterConditions: Record<string, any>
|
||||
): Promise<ServiceResponse<{ deleted: number }>> {
|
||||
try {
|
||||
const validation = await this.validateTableAccess(tableName);
|
||||
if (!validation.valid) {
|
||||
return validation.error!;
|
||||
}
|
||||
|
||||
const whereConditions: string[] = [];
|
||||
const whereValues: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
for (const [key, value] of Object.entries(filterConditions)) {
|
||||
whereConditions.push(`"${key}" = $${paramIndex}`);
|
||||
whereValues.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (whereConditions.length === 0) {
|
||||
return { success: false, message: "삭제 조건이 없습니다.", error: "NO_CONDITIONS" };
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(" AND ");
|
||||
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`;
|
||||
|
||||
console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions });
|
||||
|
||||
const result = await pool.query(deleteQuery, whereValues);
|
||||
|
||||
console.log(`✅ 그룹 삭제 성공: ${result.rowCount}개`);
|
||||
|
||||
return { success: true, data: { deleted: result.rowCount || 0 } };
|
||||
} catch (error) {
|
||||
console.error("그룹 삭제 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "그룹 삭제 실패",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹화된 데이터 UPSERT
|
||||
* - 부모 키(예: customer_id, item_id)와 레코드 배열을 받아
|
||||
* - 기존 DB의 레코드들과 비교하여 INSERT/UPDATE/DELETE 수행
|
||||
* - 각 레코드의 모든 필드 조합을 고유 키로 사용
|
||||
*/
|
||||
async upsertGroupedRecords(
|
||||
tableName: string,
|
||||
parentKeys: Record<string, any>,
|
||||
records: Array<Record<string, any>>,
|
||||
userCompany?: string,
|
||||
userId?: string
|
||||
): Promise<ServiceResponse<{ inserted: number; updated: number; deleted: number }>> {
|
||||
try {
|
||||
// 테이블 접근 권한 검증
|
||||
const validation = await this.validateTableAccess(tableName);
|
||||
if (!validation.valid) {
|
||||
return validation.error!;
|
||||
}
|
||||
|
||||
// Primary Key 감지
|
||||
const pkColumns = await this.getPrimaryKeyColumns(tableName);
|
||||
if (!pkColumns || pkColumns.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: `테이블 '${tableName}'의 Primary Key를 찾을 수 없습니다.`,
|
||||
error: "PRIMARY_KEY_NOT_FOUND",
|
||||
};
|
||||
}
|
||||
const pkColumn = pkColumns[0]; // 첫 번째 PK 사용
|
||||
|
||||
console.log(`🔍 UPSERT 시작: ${tableName}`, {
|
||||
parentKeys,
|
||||
newRecordsCount: records.length,
|
||||
primaryKey: pkColumn,
|
||||
});
|
||||
|
||||
// 1. 기존 DB 레코드 조회 (parentKeys 기준)
|
||||
const whereConditions: string[] = [];
|
||||
const whereValues: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
for (const [key, value] of Object.entries(parentKeys)) {
|
||||
whereConditions.push(`"${key}" = $${paramIndex}`);
|
||||
whereValues.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(" AND ");
|
||||
const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`;
|
||||
|
||||
console.log(`📋 기존 레코드 조회:`, { query: selectQuery, values: whereValues });
|
||||
|
||||
const existingRecords = await pool.query(selectQuery, whereValues);
|
||||
|
||||
console.log(`✅ 기존 레코드: ${existingRecords.rows.length}개`);
|
||||
|
||||
// 2. 새 레코드와 기존 레코드 비교
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let deleted = 0;
|
||||
|
||||
// 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수
|
||||
const normalizeDateValue = (value: any): any => {
|
||||
if (value == null) return value;
|
||||
|
||||
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
|
||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
return value.split('T')[0]; // YYYY-MM-DD 만 추출
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
// 새 레코드 처리 (INSERT or UPDATE)
|
||||
for (const newRecord of records) {
|
||||
console.log(`🔍 처리할 새 레코드:`, newRecord);
|
||||
|
||||
// 날짜 필드 정규화
|
||||
const normalizedRecord: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(newRecord)) {
|
||||
normalizedRecord[key] = normalizeDateValue(value);
|
||||
}
|
||||
|
||||
console.log(`🔄 정규화된 레코드:`, normalizedRecord);
|
||||
|
||||
// 전체 레코드 데이터 (parentKeys + normalizedRecord)
|
||||
const fullRecord = { ...parentKeys, ...normalizedRecord };
|
||||
|
||||
// 고유 키: parentKeys 제외한 나머지 필드들
|
||||
const uniqueFields = Object.keys(normalizedRecord);
|
||||
|
||||
console.log(`🔑 고유 필드들:`, uniqueFields);
|
||||
|
||||
// 기존 레코드에서 일치하는 것 찾기
|
||||
const existingRecord = existingRecords.rows.find((existing) => {
|
||||
return uniqueFields.every((field) => {
|
||||
const existingValue = existing[field];
|
||||
const newValue = normalizedRecord[field];
|
||||
|
||||
// null/undefined 처리
|
||||
if (existingValue == null && newValue == null) return true;
|
||||
if (existingValue == null || newValue == null) return false;
|
||||
|
||||
// Date 타입 처리
|
||||
if (existingValue instanceof Date && typeof newValue === 'string') {
|
||||
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
|
||||
}
|
||||
|
||||
// 문자열 비교
|
||||
return String(existingValue) === String(newValue);
|
||||
});
|
||||
});
|
||||
|
||||
if (existingRecord) {
|
||||
// UPDATE: 기존 레코드가 있으면 업데이트
|
||||
const updateFields: string[] = [];
|
||||
const updateValues: any[] = [];
|
||||
let updateParamIndex = 1;
|
||||
|
||||
for (const [key, value] of Object.entries(fullRecord)) {
|
||||
if (key !== pkColumn) { // Primary Key는 업데이트하지 않음
|
||||
updateFields.push(`"${key}" = $${updateParamIndex}`);
|
||||
updateValues.push(value);
|
||||
updateParamIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
updateValues.push(existingRecord[pkColumn]); // WHERE 조건용
|
||||
const updateQuery = `
|
||||
UPDATE "${tableName}"
|
||||
SET ${updateFields.join(", ")}, updated_date = NOW()
|
||||
WHERE "${pkColumn}" = $${updateParamIndex}
|
||||
`;
|
||||
|
||||
await pool.query(updateQuery, updateValues);
|
||||
updated++;
|
||||
|
||||
console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`);
|
||||
} else {
|
||||
// INSERT: 기존 레코드가 없으면 삽입
|
||||
|
||||
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
|
||||
const recordWithMeta: Record<string, any> = {
|
||||
...fullRecord,
|
||||
id: uuidv4(), // 새 ID 생성
|
||||
created_date: "NOW()",
|
||||
updated_date: "NOW()",
|
||||
};
|
||||
|
||||
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
|
||||
if (!recordWithMeta.company_code && userCompany && userCompany !== "*") {
|
||||
recordWithMeta.company_code = userCompany;
|
||||
}
|
||||
|
||||
// writer가 없으면 userId 사용
|
||||
if (!recordWithMeta.writer && userId) {
|
||||
recordWithMeta.writer = userId;
|
||||
}
|
||||
|
||||
const insertFields = Object.keys(recordWithMeta).filter(key =>
|
||||
recordWithMeta[key] !== "NOW()"
|
||||
);
|
||||
const insertPlaceholders: string[] = [];
|
||||
const insertValues: any[] = [];
|
||||
let insertParamIndex = 1;
|
||||
|
||||
for (const field of Object.keys(recordWithMeta)) {
|
||||
if (recordWithMeta[field] === "NOW()") {
|
||||
insertPlaceholders.push("NOW()");
|
||||
} else {
|
||||
insertPlaceholders.push(`$${insertParamIndex}`);
|
||||
insertValues.push(recordWithMeta[field]);
|
||||
insertParamIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
const insertQuery = `
|
||||
INSERT INTO "${tableName}" (${Object.keys(recordWithMeta).map(f => `"${f}"`).join(", ")})
|
||||
VALUES (${insertPlaceholders.join(", ")})
|
||||
`;
|
||||
|
||||
console.log(`➕ INSERT 쿼리:`, { query: insertQuery, values: insertValues });
|
||||
|
||||
await pool.query(insertQuery, insertValues);
|
||||
inserted++;
|
||||
|
||||
console.log(`➕ INSERT: 새 레코드`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것)
|
||||
for (const existingRecord of existingRecords.rows) {
|
||||
const uniqueFields = Object.keys(records[0] || {});
|
||||
|
||||
const stillExists = records.some((newRecord) => {
|
||||
return uniqueFields.every((field) => {
|
||||
const existingValue = existingRecord[field];
|
||||
const newValue = newRecord[field];
|
||||
|
||||
if (existingValue == null && newValue == null) return true;
|
||||
if (existingValue == null || newValue == null) return false;
|
||||
|
||||
if (existingValue instanceof Date && typeof newValue === 'string') {
|
||||
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
|
||||
}
|
||||
|
||||
return String(existingValue) === String(newValue);
|
||||
});
|
||||
});
|
||||
|
||||
if (!stillExists) {
|
||||
// DELETE: 새 레코드에 없으면 삭제
|
||||
const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
||||
await pool.query(deleteQuery, [existingRecord[pkColumn]]);
|
||||
deleted++;
|
||||
|
||||
console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { inserted, updated, deleted },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`UPSERT 오류 (${tableName}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "데이터 저장 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const dataService = new DataService();
|
||||
|
|
|
|||
|
|
@ -81,18 +81,18 @@ export class EntityJoinService {
|
|||
let referenceColumn = column.reference_column;
|
||||
let displayColumn = column.display_column;
|
||||
|
||||
if (column.input_type === 'category') {
|
||||
// 카테고리 타입: reference 정보가 비어있어도 자동 설정
|
||||
referenceTable = referenceTable || 'table_column_category_values';
|
||||
referenceColumn = referenceColumn || 'value_code';
|
||||
displayColumn = displayColumn || 'value_label';
|
||||
|
||||
logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, {
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn,
|
||||
});
|
||||
}
|
||||
if (column.input_type === "category") {
|
||||
// 카테고리 타입: reference 정보가 비어있어도 자동 설정
|
||||
referenceTable = referenceTable || "table_column_category_values";
|
||||
referenceColumn = referenceColumn || "value_code";
|
||||
displayColumn = displayColumn || "value_label";
|
||||
|
||||
logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, {
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
|
||||
column_name: column.column_name,
|
||||
|
|
@ -134,23 +134,32 @@ export class EntityJoinService {
|
|||
`🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}`
|
||||
);
|
||||
} else {
|
||||
// display_column이 "none"이거나 없는 경우 기본 표시 컬럼 설정
|
||||
let defaultDisplayColumn = referenceColumn;
|
||||
if (referenceTable === "dept_info") {
|
||||
defaultDisplayColumn = "dept_name";
|
||||
} else if (referenceTable === "company_info") {
|
||||
defaultDisplayColumn = "company_name";
|
||||
} else if (referenceTable === "user_info") {
|
||||
defaultDisplayColumn = "user_name";
|
||||
} else if (referenceTable === "category_values") {
|
||||
defaultDisplayColumn = "category_name";
|
||||
}
|
||||
// display_column이 "none"이거나 없는 경우 참조 테이블의 모든 컬럼 가져오기
|
||||
logger.info(`🔍 ${referenceTable}의 모든 컬럼 조회 중...`);
|
||||
|
||||
displayColumns = [defaultDisplayColumn];
|
||||
logger.info(
|
||||
`🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name} → ${defaultDisplayColumn} (${referenceTable})`
|
||||
// 참조 테이블의 모든 컬럼 이름 가져오기
|
||||
const tableColumnsResult = await query<{ column_name: string }>(
|
||||
`SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = $1
|
||||
AND table_schema = 'public'
|
||||
ORDER BY ordinal_position`,
|
||||
[referenceTable]
|
||||
);
|
||||
logger.info(`🔍 생성된 displayColumns 배열:`, displayColumns);
|
||||
|
||||
if (tableColumnsResult.length > 0) {
|
||||
displayColumns = tableColumnsResult.map((col) => col.column_name);
|
||||
logger.info(
|
||||
`✅ ${referenceTable}의 모든 컬럼 자동 포함 (${displayColumns.length}개):`,
|
||||
displayColumns.join(", ")
|
||||
);
|
||||
} else {
|
||||
// 테이블 컬럼을 못 찾으면 기본값 사용
|
||||
displayColumns = [referenceColumn];
|
||||
logger.warn(
|
||||
`⚠️ ${referenceTable}의 컬럼 조회 실패, 기본값 사용: ${referenceColumn}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 별칭 컬럼명 생성 (writer -> writer_name)
|
||||
|
|
@ -200,6 +209,25 @@ export class EntityJoinService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 컬럼을 YYYY-MM-DD 형식으로 변환하는 SQL 표현식
|
||||
*/
|
||||
private formatDateColumn(
|
||||
tableAlias: string,
|
||||
columnName: string,
|
||||
dataType?: string
|
||||
): string {
|
||||
// date, timestamp 타입이면 TO_CHAR로 변환
|
||||
if (
|
||||
dataType &&
|
||||
(dataType.includes("date") || dataType.includes("timestamp"))
|
||||
) {
|
||||
return `TO_CHAR(${tableAlias}.${columnName}, 'YYYY-MM-DD')`;
|
||||
}
|
||||
// 기본은 TEXT 캐스팅
|
||||
return `${tableAlias}.${columnName}::TEXT`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity 조인이 포함된 SQL 쿼리 생성
|
||||
*/
|
||||
|
|
@ -210,13 +238,30 @@ export class EntityJoinService {
|
|||
whereClause: string = "",
|
||||
orderBy: string = "",
|
||||
limit?: number,
|
||||
offset?: number
|
||||
offset?: number,
|
||||
columnTypes?: Map<string, string> // 컬럼명 → 데이터 타입 매핑
|
||||
): { query: string; aliasMap: Map<string, string> } {
|
||||
try {
|
||||
// 기본 SELECT 컬럼들 (TEXT로 캐스팅하여 record 타입 오류 방지)
|
||||
const baseColumns = selectColumns
|
||||
.map((col) => `main.${col}::TEXT AS ${col}`)
|
||||
.join(", ");
|
||||
// 기본 SELECT 컬럼들 (날짜는 YYYY-MM-DD 형식, 나머지는 TEXT 캐스팅)
|
||||
// 🔧 "*"는 전체 조회하되, 날짜 타입 타임존 문제를 피하기 위해
|
||||
// jsonb_build_object를 사용하여 명시적으로 변환
|
||||
let baseColumns: string;
|
||||
if (selectColumns.length === 1 && selectColumns[0] === "*") {
|
||||
// main.* 사용 시 날짜 타입 필드만 TO_CHAR로 변환
|
||||
// PostgreSQL의 날짜 → 타임스탬프 자동 변환으로 인한 타임존 문제 방지
|
||||
baseColumns = `main.*`;
|
||||
logger.info(
|
||||
`⚠️ [buildJoinQuery] main.* 사용 - 날짜 타임존 변환 주의 필요`
|
||||
);
|
||||
} else {
|
||||
baseColumns = selectColumns
|
||||
.map((col) => {
|
||||
const dataType = columnTypes?.get(col);
|
||||
const formattedCol = this.formatDateColumn("main", col, dataType);
|
||||
return `${formattedCol} AS ${col}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
// Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리)
|
||||
// 별칭 매핑 생성 (JOIN 절과 동일한 로직)
|
||||
|
|
@ -255,7 +300,9 @@ export class EntityJoinService {
|
|||
// 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응)
|
||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||
aliasMap.set(aliasKey, alias);
|
||||
logger.info(`🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn} → ${alias}`);
|
||||
logger.info(
|
||||
`🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn} → ${alias}`
|
||||
);
|
||||
});
|
||||
|
||||
const joinColumns = joinConfigs
|
||||
|
|
@ -266,78 +313,82 @@ export class EntityJoinService {
|
|||
config.displayColumn,
|
||||
];
|
||||
const separator = config.separator || " - ";
|
||||
|
||||
|
||||
// 결과 컬럼 배열 (aliasColumn + _label 필드)
|
||||
const resultColumns: string[] = [];
|
||||
|
||||
if (displayColumns.length === 0 || !displayColumns[0]) {
|
||||
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
|
||||
// 조인 테이블의 referenceColumn을 기본값으로 사용
|
||||
resultColumns.push(`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`);
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`
|
||||
);
|
||||
} else if (displayColumns.length === 1) {
|
||||
// 단일 컬럼인 경우
|
||||
const col = displayColumns[0];
|
||||
const isJoinTableColumn = [
|
||||
"dept_name",
|
||||
"dept_code",
|
||||
"master_user_id",
|
||||
"location_name",
|
||||
"parent_dept_code",
|
||||
"master_sabun",
|
||||
"location",
|
||||
"data_type",
|
||||
"company_name",
|
||||
"sales_yn",
|
||||
"status",
|
||||
"value_label", // table_column_category_values
|
||||
"user_name", // user_info
|
||||
].includes(col);
|
||||
|
||||
// ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴
|
||||
// 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원
|
||||
const isJoinTableColumn =
|
||||
config.referenceTable && config.referenceTable !== tableName;
|
||||
|
||||
if (isJoinTableColumn) {
|
||||
resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`);
|
||||
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||
);
|
||||
|
||||
// _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
|
||||
// sourceColumn_label 형식으로 추가
|
||||
resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`);
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`
|
||||
);
|
||||
|
||||
// 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용)
|
||||
// 예: customer_code, item_number 등
|
||||
// col과 동일해도 별도의 alias로 추가 (customer_code as customer_code)
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
|
||||
);
|
||||
} else {
|
||||
resultColumns.push(`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`);
|
||||
resultColumns.push(
|
||||
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 여러 컬럼인 경우 CONCAT으로 연결
|
||||
// 기본 테이블과 조인 테이블의 컬럼을 구분해서 처리
|
||||
const concatParts = displayColumns
|
||||
.map((col) => {
|
||||
// 조인 테이블의 컬럼인지 확인 (조인 테이블에 존재하는 컬럼만 조인 별칭 사용)
|
||||
// 현재는 dept_info 테이블의 컬럼들을 확인
|
||||
const isJoinTableColumn = [
|
||||
"dept_name",
|
||||
"dept_code",
|
||||
"master_user_id",
|
||||
"location_name",
|
||||
"parent_dept_code",
|
||||
"master_sabun",
|
||||
"location",
|
||||
"data_type",
|
||||
"company_name",
|
||||
"sales_yn",
|
||||
"status",
|
||||
"value_label", // table_column_category_values
|
||||
"user_name", // user_info
|
||||
].includes(col);
|
||||
// 🆕 여러 컬럼인 경우 각 컬럼을 개별 alias로 반환 (합치지 않음)
|
||||
// 예: item_info.standard_price → sourceColumn_standard_price (item_id_standard_price)
|
||||
displayColumns.forEach((col) => {
|
||||
const isJoinTableColumn =
|
||||
config.referenceTable && config.referenceTable !== tableName;
|
||||
|
||||
if (isJoinTableColumn) {
|
||||
// 조인 테이블 컬럼은 조인 별칭 사용
|
||||
return `COALESCE(${alias}.${col}::TEXT, '')`;
|
||||
} else {
|
||||
// 기본 테이블 컬럼은 main 별칭 사용
|
||||
return `COALESCE(main.${col}::TEXT, '')`;
|
||||
}
|
||||
})
|
||||
.join(` || '${separator}' || `);
|
||||
const individualAlias = `${config.sourceColumn}_${col}`;
|
||||
|
||||
resultColumns.push(`(${concatParts}) AS ${config.aliasColumn}`);
|
||||
if (isJoinTableColumn) {
|
||||
// 조인 테이블 컬럼은 조인 별칭 사용
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}`
|
||||
);
|
||||
} else {
|
||||
// 기본 테이블 컬럼은 main 별칭 사용
|
||||
resultColumns.push(
|
||||
`COALESCE(main.${col}::TEXT, '') AS ${individualAlias}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용)
|
||||
const isJoinTableColumn =
|
||||
config.referenceTable && config.referenceTable !== tableName;
|
||||
if (
|
||||
isJoinTableColumn &&
|
||||
!displayColumns.includes(config.referenceColumn)
|
||||
) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 모든 resultColumns를 반환
|
||||
return resultColumns.join(", ");
|
||||
})
|
||||
|
|
@ -356,13 +407,13 @@ export class EntityJoinService {
|
|||
.map((config) => {
|
||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||
const alias = aliasMap.get(aliasKey);
|
||||
|
||||
|
||||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
|
||||
if (config.referenceTable === 'table_column_category_values') {
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
}
|
||||
|
||||
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
|
@ -424,7 +475,7 @@ export class EntityJoinService {
|
|||
}
|
||||
|
||||
// table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가
|
||||
if (config.referenceTable === 'table_column_category_values') {
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
logger.info(
|
||||
`🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}`
|
||||
);
|
||||
|
|
@ -578,13 +629,13 @@ export class EntityJoinService {
|
|||
.map((config) => {
|
||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||
const alias = aliasMap.get(aliasKey);
|
||||
|
||||
|
||||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
|
||||
if (config.referenceTable === 'table_column_category_values') {
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
}
|
||||
|
||||
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
|
|
|||
|
|
@ -98,7 +98,8 @@ export class ScreenManagementService {
|
|||
async getScreensByCompany(
|
||||
companyCode: string,
|
||||
page: number = 1,
|
||||
size: number = 20
|
||||
size: number = 20,
|
||||
searchTerm?: string // 검색어 추가
|
||||
): Promise<PaginatedResponse<ScreenDefinition>> {
|
||||
const offset = (page - 1) * size;
|
||||
|
||||
|
|
@ -111,6 +112,16 @@ export class ScreenManagementService {
|
|||
params.push(companyCode);
|
||||
}
|
||||
|
||||
// 검색어 필터링 추가 (화면명, 화면 코드, 테이블명 검색)
|
||||
if (searchTerm && searchTerm.trim() !== "") {
|
||||
whereConditions.push(`(
|
||||
screen_name ILIKE $${params.length + 1} OR
|
||||
screen_code ILIKE $${params.length + 1} OR
|
||||
table_name ILIKE $${params.length + 1}
|
||||
)`);
|
||||
params.push(`%${searchTerm.trim()}%`);
|
||||
}
|
||||
|
||||
const whereSQL = whereConditions.join(" AND ");
|
||||
|
||||
// 페이징 쿼리 (Raw Query)
|
||||
|
|
@ -1068,43 +1079,131 @@ export class ScreenManagementService {
|
|||
[tableName]
|
||||
);
|
||||
|
||||
// column_labels 테이블에서 입력타입 정보 조회 (있는 경우)
|
||||
const webTypeInfo = await query<{
|
||||
// 🆕 table_type_columns에서 입력타입 정보 조회 (회사별만, fallback 없음)
|
||||
// 멀티테넌시: 각 회사는 자신의 설정만 사용, 최고관리자 설정은 별도 관리
|
||||
console.log(`🔍 [getTableColumns] 시작: table=${tableName}, company=${companyCode}`);
|
||||
|
||||
const typeInfo = await query<{
|
||||
column_name: string;
|
||||
input_type: string | null;
|
||||
column_label: string | null;
|
||||
detail_settings: any;
|
||||
}>(
|
||||
`SELECT column_name, input_type, column_label, detail_settings
|
||||
`SELECT column_name, input_type, detail_settings
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
AND company_code = $2
|
||||
ORDER BY id DESC`, // 최신 레코드 우선 (중복 방지)
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
console.log(`📊 [getTableColumns] typeInfo 조회 완료: ${typeInfo.length}개`);
|
||||
const currencyCodeType = typeInfo.find(t => t.column_name === 'currency_code');
|
||||
if (currencyCodeType) {
|
||||
console.log(`💰 [getTableColumns] currency_code 발견:`, currencyCodeType);
|
||||
} else {
|
||||
console.log(`⚠️ [getTableColumns] currency_code 없음`);
|
||||
}
|
||||
|
||||
// column_labels 테이블에서 라벨 정보 조회 (우선순위 2)
|
||||
const labelInfo = await query<{
|
||||
column_name: string;
|
||||
column_label: string | null;
|
||||
}>(
|
||||
`SELECT column_name, column_label
|
||||
FROM column_labels
|
||||
WHERE table_name = $1`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
// 컬럼 정보 매핑
|
||||
return columns.map((column: any) => {
|
||||
const webTypeData = webTypeInfo.find(
|
||||
(wt) => wt.column_name === column.column_name
|
||||
);
|
||||
// 🆕 category_column_mapping에서 코드 카테고리 정보 조회
|
||||
const categoryInfo = await query<{
|
||||
physical_column_name: string;
|
||||
logical_column_name: string;
|
||||
}>(
|
||||
`SELECT physical_column_name, logical_column_name
|
||||
FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND company_code = $2`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
return {
|
||||
// 컬럼 정보 매핑
|
||||
const columnMap = new Map<string, any>();
|
||||
|
||||
// 먼저 information_schema에서 가져온 컬럼들로 기본 맵 생성
|
||||
columns.forEach((column: any) => {
|
||||
columnMap.set(column.column_name, {
|
||||
tableName: tableName,
|
||||
columnName: column.column_name,
|
||||
columnLabel:
|
||||
webTypeData?.column_label ||
|
||||
this.getColumnLabel(column.column_name),
|
||||
dataType: column.data_type,
|
||||
webType:
|
||||
(webTypeData?.input_type as WebType) ||
|
||||
this.inferWebType(column.data_type),
|
||||
isNullable: column.is_nullable,
|
||||
columnDefault: column.column_default || undefined,
|
||||
characterMaximumLength: column.character_maximum_length || undefined,
|
||||
numericPrecision: column.numeric_precision || undefined,
|
||||
numericScale: column.numeric_scale || undefined,
|
||||
detailSettings: webTypeData?.detail_settings || undefined,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`🗺️ [getTableColumns] 기본 columnMap 생성: ${columnMap.size}개`);
|
||||
|
||||
// table_type_columns에서 input_type 추가 (중복 시 최신 것만)
|
||||
const addedTypes = new Set<string>();
|
||||
typeInfo.forEach((type) => {
|
||||
const colName = type.column_name;
|
||||
if (!addedTypes.has(colName) && columnMap.has(colName)) {
|
||||
const col = columnMap.get(colName);
|
||||
col.inputType = type.input_type;
|
||||
col.webType = type.input_type; // webType도 동일하게 설정
|
||||
col.detailSettings = type.detail_settings;
|
||||
addedTypes.add(colName);
|
||||
|
||||
if (colName === 'currency_code') {
|
||||
console.log(`✅ [getTableColumns] currency_code inputType 설정됨: ${type.input_type}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`🏷️ [getTableColumns] inputType 추가 완료: ${addedTypes.size}개`);
|
||||
|
||||
// column_labels에서 라벨 추가
|
||||
labelInfo.forEach((label) => {
|
||||
const col = columnMap.get(label.column_name);
|
||||
if (col) {
|
||||
col.columnLabel = label.column_label || this.getColumnLabel(label.column_name);
|
||||
}
|
||||
});
|
||||
|
||||
// category_column_mapping에서 코드 카테고리 추가
|
||||
categoryInfo.forEach((cat) => {
|
||||
const col = columnMap.get(cat.physical_column_name);
|
||||
if (col) {
|
||||
col.codeCategory = cat.logical_column_name;
|
||||
}
|
||||
});
|
||||
|
||||
// 최종 결과 생성
|
||||
const result = Array.from(columnMap.values()).map((col) => ({
|
||||
...col,
|
||||
// 기본값 설정
|
||||
columnLabel: col.columnLabel || this.getColumnLabel(col.columnName),
|
||||
inputType: col.inputType || this.inferWebType(col.dataType),
|
||||
webType: col.webType || this.inferWebType(col.dataType),
|
||||
detailSettings: col.detailSettings || undefined,
|
||||
codeCategory: col.codeCategory || undefined,
|
||||
}));
|
||||
|
||||
// 디버깅: currency_code의 최종 inputType 확인
|
||||
const currencyCodeResult = result.find(r => r.columnName === 'currency_code');
|
||||
if (currencyCodeResult) {
|
||||
console.log(`🎯 [getTableColumns] 최종 currency_code:`, {
|
||||
inputType: currencyCodeResult.inputType,
|
||||
webType: currencyCodeResult.webType,
|
||||
dataType: currencyCodeResult.dataType
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✅ [getTableColumns] 반환: ${result.length}개 컬럼`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("테이블 컬럼 조회 실패:", error);
|
||||
throw new Error("테이블 컬럼 정보를 조회할 수 없습니다.");
|
||||
|
|
@ -2013,55 +2112,109 @@ export class ScreenManagementService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 화면에 연결된 모달 화면들을 자동 감지
|
||||
* 버튼 컴포넌트의 popup 액션에서 targetScreenId를 추출
|
||||
* 화면에 연결된 모달/화면들을 재귀적으로 자동 감지
|
||||
* - 버튼 컴포넌트: popup/modal/edit/openModalWithData 액션의 targetScreenId
|
||||
* - 조건부 컨테이너: sections[].screenId (조건별 화면 할당)
|
||||
* - 중첩된 화면들도 모두 감지 (재귀)
|
||||
*/
|
||||
async detectLinkedModalScreens(
|
||||
screenId: number
|
||||
): Promise<{ screenId: number; screenName: string; screenCode: string }[]> {
|
||||
// 화면의 모든 레이아웃 조회
|
||||
const layouts = await query<any>(
|
||||
`SELECT layout_id, properties
|
||||
FROM screen_layouts
|
||||
WHERE screen_id = $1
|
||||
AND component_type = 'component'
|
||||
AND properties IS NOT NULL`,
|
||||
[screenId]
|
||||
);
|
||||
console.log(`\n🔍 [재귀 감지 시작] 화면 ID: ${screenId}`);
|
||||
|
||||
const allLinkedScreenIds = new Set<number>();
|
||||
const visited = new Set<number>(); // 무한 루프 방지
|
||||
const queue: number[] = [screenId]; // BFS 큐
|
||||
|
||||
const linkedScreenIds = new Set<number>();
|
||||
// BFS로 연결된 모든 화면 탐색
|
||||
while (queue.length > 0) {
|
||||
const currentScreenId = queue.shift()!;
|
||||
|
||||
// 이미 방문한 화면은 스킵 (순환 참조 방지)
|
||||
if (visited.has(currentScreenId)) {
|
||||
console.log(`⏭️ 이미 방문한 화면 스킵: ${currentScreenId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.add(currentScreenId);
|
||||
console.log(`\n📋 현재 탐색 중인 화면: ${currentScreenId} (깊이: ${visited.size})`);
|
||||
|
||||
// 각 레이아웃에서 버튼의 popup/modal/edit 액션 확인
|
||||
for (const layout of layouts) {
|
||||
try {
|
||||
const properties = layout.properties;
|
||||
|
||||
// 버튼 컴포넌트인지 확인
|
||||
if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) {
|
||||
const action = properties?.componentConfig?.action;
|
||||
// 현재 화면의 모든 레이아웃 조회
|
||||
const layouts = await query<any>(
|
||||
`SELECT layout_id, properties
|
||||
FROM screen_layouts
|
||||
WHERE screen_id = $1
|
||||
AND component_type = 'component'
|
||||
AND properties IS NOT NULL`,
|
||||
[currentScreenId]
|
||||
);
|
||||
|
||||
console.log(` 📦 레이아웃 개수: ${layouts.length}`);
|
||||
|
||||
// 각 레이아웃에서 연결된 화면 ID 확인
|
||||
for (const layout of layouts) {
|
||||
try {
|
||||
const properties = layout.properties;
|
||||
|
||||
// popup, modal, edit 액션이고 targetScreenId가 있는 경우
|
||||
// edit 액션도 수정 폼 모달을 열기 때문에 포함
|
||||
if ((action?.type === "popup" || action?.type === "modal" || action?.type === "edit") && action?.targetScreenId) {
|
||||
const targetScreenId = parseInt(action.targetScreenId);
|
||||
if (!isNaN(targetScreenId)) {
|
||||
linkedScreenIds.add(targetScreenId);
|
||||
console.log(`🔗 연결된 모달 화면 발견: screenId=${targetScreenId}, actionType=${action.type} (레이아웃 ${layout.layout_id})`);
|
||||
// 1. 버튼 컴포넌트의 액션 확인
|
||||
if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) {
|
||||
const action = properties?.componentConfig?.action;
|
||||
|
||||
const modalActionTypes = ["popup", "modal", "edit", "openModalWithData"];
|
||||
if (modalActionTypes.includes(action?.type) && action?.targetScreenId) {
|
||||
const targetScreenId = parseInt(action.targetScreenId);
|
||||
if (!isNaN(targetScreenId) && targetScreenId !== currentScreenId) {
|
||||
// 메인 화면이 아닌 경우에만 추가
|
||||
if (targetScreenId !== screenId) {
|
||||
allLinkedScreenIds.add(targetScreenId);
|
||||
}
|
||||
// 아직 방문하지 않은 화면이면 큐에 추가
|
||||
if (!visited.has(targetScreenId)) {
|
||||
queue.push(targetScreenId);
|
||||
console.log(` 🔗 [버튼] 연결된 화면 발견: ${targetScreenId} (action: ${action.type}) → 큐에 추가`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. conditional-container 컴포넌트의 sections 확인
|
||||
if (properties?.componentType === "conditional-container") {
|
||||
const sections = properties?.componentConfig?.sections || [];
|
||||
|
||||
for (const section of sections) {
|
||||
if (section?.screenId) {
|
||||
const sectionScreenId = parseInt(section.screenId);
|
||||
if (!isNaN(sectionScreenId) && sectionScreenId !== currentScreenId) {
|
||||
// 메인 화면이 아닌 경우에만 추가
|
||||
if (sectionScreenId !== screenId) {
|
||||
allLinkedScreenIds.add(sectionScreenId);
|
||||
}
|
||||
// 아직 방문하지 않은 화면이면 큐에 추가
|
||||
if (!visited.has(sectionScreenId)) {
|
||||
queue.push(sectionScreenId);
|
||||
console.log(` 🔗 [조건부컨테이너] 연결된 화면 발견: ${sectionScreenId} (condition: ${section.condition}) → 큐에 추가`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(` ⚠️ 레이아웃 ${layout.layout_id} 파싱 오류:`, error);
|
||||
}
|
||||
} catch (error) {
|
||||
// JSON 파싱 오류 등은 무시하고 계속 진행
|
||||
console.warn(`레이아웃 ${layout.layout_id} 파싱 오류:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ [재귀 감지 완료] 총 방문한 화면: ${visited.size}개, 연결된 화면: ${allLinkedScreenIds.size}개`);
|
||||
console.log(` 방문한 화면 ID: [${Array.from(visited).join(", ")}]`);
|
||||
console.log(` 연결된 화면 ID: [${Array.from(allLinkedScreenIds).join(", ")}]`);
|
||||
|
||||
// 감지된 화면 ID들의 정보 조회
|
||||
if (linkedScreenIds.size === 0) {
|
||||
if (allLinkedScreenIds.size === 0) {
|
||||
console.log(`ℹ️ 연결된 화면이 없습니다.`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const screenIds = Array.from(linkedScreenIds);
|
||||
const screenIds = Array.from(allLinkedScreenIds);
|
||||
const placeholders = screenIds.map((_, i) => `$${i + 1}`).join(", ");
|
||||
|
||||
const linkedScreens = await query<any>(
|
||||
|
|
@ -2073,6 +2226,11 @@ export class ScreenManagementService {
|
|||
screenIds
|
||||
);
|
||||
|
||||
console.log(`\n📋 최종 감지된 화면 목록:`);
|
||||
linkedScreens.forEach((s: any) => {
|
||||
console.log(` - ${s.screen_name} (ID: ${s.screen_id}, 코드: ${s.screen_code})`);
|
||||
});
|
||||
|
||||
return linkedScreens.map((s) => ({
|
||||
screenId: s.screen_id,
|
||||
screenName: s.screen_name,
|
||||
|
|
@ -2342,23 +2500,23 @@ export class ScreenManagementService {
|
|||
for (const layout of layouts) {
|
||||
try {
|
||||
const properties = layout.properties;
|
||||
let needsUpdate = false;
|
||||
|
||||
// 버튼 컴포넌트인지 확인
|
||||
// 1. 버튼 컴포넌트의 targetScreenId 업데이트
|
||||
if (
|
||||
properties?.componentType === "button" ||
|
||||
properties?.componentType?.startsWith("button-")
|
||||
) {
|
||||
const action = properties?.componentConfig?.action;
|
||||
|
||||
// targetScreenId가 있는 액션 (popup, modal, edit)
|
||||
// targetScreenId가 있는 액션 (popup, modal, edit, openModalWithData)
|
||||
const modalActionTypes = ["popup", "modal", "edit", "openModalWithData"];
|
||||
if (
|
||||
(action?.type === "popup" ||
|
||||
action?.type === "modal" ||
|
||||
action?.type === "edit") &&
|
||||
modalActionTypes.includes(action?.type) &&
|
||||
action?.targetScreenId
|
||||
) {
|
||||
const oldScreenId = parseInt(action.targetScreenId);
|
||||
console.log(`🔍 버튼 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`);
|
||||
console.log(`🔍 [버튼] 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`);
|
||||
|
||||
// 매핑에 있으면 업데이트
|
||||
if (screenIdMapping.has(oldScreenId)) {
|
||||
|
|
@ -2368,31 +2526,63 @@ export class ScreenManagementService {
|
|||
// properties 업데이트
|
||||
properties.componentConfig.action.targetScreenId =
|
||||
newScreenId.toString();
|
||||
needsUpdate = true;
|
||||
|
||||
// 데이터베이스 업데이트
|
||||
await query(
|
||||
`UPDATE screen_layouts
|
||||
SET properties = $1
|
||||
WHERE layout_id = $2`,
|
||||
[JSON.stringify(properties), layout.layout_id]
|
||||
);
|
||||
|
||||
updateCount++;
|
||||
console.log(
|
||||
`🔗 버튼 targetScreenId 업데이트: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id})`
|
||||
`🔗 [버튼] targetScreenId 업데이트 준비: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id})`
|
||||
);
|
||||
} else {
|
||||
console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. conditional-container 컴포넌트의 sections[].screenId 업데이트
|
||||
if (properties?.componentType === "conditional-container") {
|
||||
const sections = properties?.componentConfig?.sections || [];
|
||||
|
||||
for (const section of sections) {
|
||||
if (section?.screenId) {
|
||||
const oldScreenId = parseInt(section.screenId);
|
||||
console.log(`🔍 [조건부컨테이너] section 발견: layout ${layout.layout_id}, condition=${section.condition}, screenId=${oldScreenId}`);
|
||||
|
||||
// 매핑에 있으면 업데이트
|
||||
if (screenIdMapping.has(oldScreenId)) {
|
||||
const newScreenId = screenIdMapping.get(oldScreenId)!;
|
||||
console.log(`✅ 매핑 발견: ${oldScreenId} → ${newScreenId}`);
|
||||
|
||||
// section.screenId 업데이트
|
||||
section.screenId = newScreenId;
|
||||
needsUpdate = true;
|
||||
|
||||
console.log(
|
||||
`🔗 [조건부컨테이너] screenId 업데이트 준비: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id}, condition=${section.condition})`
|
||||
);
|
||||
} else {
|
||||
console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 업데이트가 필요한 경우 DB 저장
|
||||
if (needsUpdate) {
|
||||
await query(
|
||||
`UPDATE screen_layouts
|
||||
SET properties = $1
|
||||
WHERE layout_id = $2`,
|
||||
[JSON.stringify(properties), layout.layout_id]
|
||||
);
|
||||
updateCount++;
|
||||
console.log(`💾 레이아웃 ${layout.layout_id} 업데이트 완료`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`❌ 레이아웃 ${layout.layout_id} 업데이트 오류:`, error);
|
||||
// 개별 레이아웃 오류는 무시하고 계속 진행
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ 총 ${updateCount}개 버튼의 targetScreenId 업데이트 완료`);
|
||||
console.log(`✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`);
|
||||
return updateCount;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -445,7 +445,129 @@ class TableCategoryValueService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제 (비활성화)
|
||||
* 카테고리 값 사용 여부 확인
|
||||
* 실제 데이터 테이블에서 해당 카테고리 값이 사용되고 있는지 확인
|
||||
*/
|
||||
async checkCategoryValueUsage(
|
||||
valueId: number,
|
||||
companyCode: string
|
||||
): Promise<{ isUsed: boolean; usedInTables: any[]; totalCount: number }> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
logger.info("카테고리 값 사용 여부 확인", { valueId, companyCode });
|
||||
|
||||
// 1. 카테고리 값 정보 조회
|
||||
let valueQuery: string;
|
||||
let valueParams: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
valueQuery = `
|
||||
SELECT table_name, column_name, value_code
|
||||
FROM table_column_category_values
|
||||
WHERE value_id = $1
|
||||
`;
|
||||
valueParams = [valueId];
|
||||
} else {
|
||||
valueQuery = `
|
||||
SELECT table_name, column_name, value_code
|
||||
FROM table_column_category_values
|
||||
WHERE value_id = $1
|
||||
AND company_code = $2
|
||||
`;
|
||||
valueParams = [valueId, companyCode];
|
||||
}
|
||||
|
||||
const valueResult = await pool.query(valueQuery, valueParams);
|
||||
|
||||
if (valueResult.rowCount === 0) {
|
||||
throw new Error("카테고리 값을 찾을 수 없습니다");
|
||||
}
|
||||
|
||||
const { table_name, column_name, value_code } = valueResult.rows[0];
|
||||
|
||||
// 2. 실제 데이터 테이블에서 사용 여부 확인
|
||||
// 테이블이 존재하는지 먼저 확인
|
||||
const tableExistsQuery = `
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
) as exists
|
||||
`;
|
||||
|
||||
const tableExistsResult = await pool.query(tableExistsQuery, [table_name]);
|
||||
|
||||
if (!tableExistsResult.rows[0].exists) {
|
||||
logger.info("테이블이 존재하지 않음", { table_name });
|
||||
return { isUsed: false, usedInTables: [], totalCount: 0 };
|
||||
}
|
||||
|
||||
// 3. 해당 테이블에서 value_code를 사용하는 데이터 개수 확인
|
||||
let dataCountQuery: string;
|
||||
let dataCountParams: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
dataCountQuery = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM ${table_name}
|
||||
WHERE ${column_name} = $1
|
||||
`;
|
||||
dataCountParams = [value_code];
|
||||
} else {
|
||||
dataCountQuery = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM ${table_name}
|
||||
WHERE ${column_name} = $1
|
||||
AND company_code = $2
|
||||
`;
|
||||
dataCountParams = [value_code, companyCode];
|
||||
}
|
||||
|
||||
const dataCountResult = await pool.query(dataCountQuery, dataCountParams);
|
||||
const totalCount = parseInt(dataCountResult.rows[0].count);
|
||||
const isUsed = totalCount > 0;
|
||||
|
||||
// 4. 사용 중인 메뉴 목록 조회 (해당 테이블을 사용하는 화면/메뉴)
|
||||
const menuQuery = `
|
||||
SELECT DISTINCT
|
||||
mi.objid as menu_objid,
|
||||
mi.menu_name_kor as menu_name,
|
||||
mi.menu_url
|
||||
FROM menu_info mi
|
||||
INNER JOIN screen_menu_assignments sma ON sma.menu_objid = mi.objid
|
||||
INNER JOIN screen_definitions sd ON sd.screen_id = sma.screen_id
|
||||
WHERE sd.table_name = $1
|
||||
AND mi.company_code = $2
|
||||
ORDER BY mi.menu_name_kor
|
||||
`;
|
||||
|
||||
const menuResult = await pool.query(menuQuery, [table_name, companyCode]);
|
||||
|
||||
const usedInTables = menuResult.rows.map((row) => ({
|
||||
menuObjid: row.menu_objid,
|
||||
menuName: row.menu_name,
|
||||
menuUrl: row.menu_url,
|
||||
tableName: table_name,
|
||||
columnName: column_name,
|
||||
}));
|
||||
|
||||
logger.info("카테고리 값 사용 여부 확인 완료", {
|
||||
valueId,
|
||||
isUsed,
|
||||
totalCount,
|
||||
usedInMenusCount: usedInTables.length,
|
||||
});
|
||||
|
||||
return { isUsed, usedInTables, totalCount };
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 값 사용 여부 확인 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제 (물리적 삭제)
|
||||
*/
|
||||
async deleteCategoryValue(
|
||||
valueId: number,
|
||||
|
|
@ -455,7 +577,24 @@ class TableCategoryValueService {
|
|||
const pool = getPool();
|
||||
|
||||
try {
|
||||
// 하위 값 체크 (멀티테넌시 적용)
|
||||
// 1. 사용 여부 확인
|
||||
const usage = await this.checkCategoryValueUsage(valueId, companyCode);
|
||||
|
||||
if (usage.isUsed) {
|
||||
let errorMessage = "이 카테고리 값을 삭제할 수 없습니다.\n";
|
||||
errorMessage += `\n현재 ${usage.totalCount}개의 데이터에서 사용 중입니다.`;
|
||||
|
||||
if (usage.usedInTables.length > 0) {
|
||||
const menuNames = usage.usedInTables.map((t) => t.menuName).join(", ");
|
||||
errorMessage += `\n\n다음 메뉴에서 사용 중입니다:\n${menuNames}`;
|
||||
}
|
||||
|
||||
errorMessage += "\n\n메뉴에서 사용하는 카테고리 항목을 수정한 후 다시 삭제해주세요.";
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// 2. 하위 값 체크 (멀티테넌시 적용)
|
||||
let checkQuery: string;
|
||||
let checkParams: any[];
|
||||
|
||||
|
|
@ -465,7 +604,6 @@ class TableCategoryValueService {
|
|||
SELECT COUNT(*) as count
|
||||
FROM table_column_category_values
|
||||
WHERE parent_value_id = $1
|
||||
AND is_active = true
|
||||
`;
|
||||
checkParams = [valueId];
|
||||
} else {
|
||||
|
|
@ -475,7 +613,6 @@ class TableCategoryValueService {
|
|||
FROM table_column_category_values
|
||||
WHERE parent_value_id = $1
|
||||
AND company_code = $2
|
||||
AND is_active = true
|
||||
`;
|
||||
checkParams = [valueId, companyCode];
|
||||
}
|
||||
|
|
@ -486,27 +623,25 @@ class TableCategoryValueService {
|
|||
throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다");
|
||||
}
|
||||
|
||||
// 비활성화 (멀티테넌시 적용)
|
||||
// 3. 물리적 삭제 (멀티테넌시 적용)
|
||||
let deleteQuery: string;
|
||||
let deleteParams: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 카테고리 값 삭제 가능
|
||||
deleteQuery = `
|
||||
UPDATE table_column_category_values
|
||||
SET is_active = false, updated_at = NOW(), updated_by = $2
|
||||
DELETE FROM table_column_category_values
|
||||
WHERE value_id = $1
|
||||
`;
|
||||
deleteParams = [valueId, userId];
|
||||
deleteParams = [valueId];
|
||||
} else {
|
||||
// 일반 회사: 자신의 카테고리 값만 삭제 가능
|
||||
deleteQuery = `
|
||||
UPDATE table_column_category_values
|
||||
SET is_active = false, updated_at = NOW(), updated_by = $3
|
||||
DELETE FROM table_column_category_values
|
||||
WHERE value_id = $1
|
||||
AND company_code = $2
|
||||
`;
|
||||
deleteParams = [valueId, companyCode, userId];
|
||||
deleteParams = [valueId, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(deleteQuery, deleteParams);
|
||||
|
|
@ -515,7 +650,7 @@ class TableCategoryValueService {
|
|||
throw new Error("카테고리 값을 찾을 수 없거나 권한이 없습니다");
|
||||
}
|
||||
|
||||
logger.info("카테고리 값 삭제(비활성화) 완료", {
|
||||
logger.info("카테고리 값 삭제 완료", {
|
||||
valueId,
|
||||
companyCode,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -144,6 +144,19 @@ export class TableManagementService {
|
|||
logger.info(
|
||||
`컬럼 정보 캐시에서 조회: ${tableName}, ${cachedResult.columns.length}/${cachedResult.total}개`
|
||||
);
|
||||
|
||||
// 디버깅: 캐시된 currency_code 확인
|
||||
const cachedCurrency = cachedResult.columns.find(
|
||||
(col: any) => col.columnName === "currency_code"
|
||||
);
|
||||
if (cachedCurrency) {
|
||||
console.log(`💾 [캐시] currency_code:`, {
|
||||
columnName: cachedCurrency.columnName,
|
||||
inputType: cachedCurrency.inputType,
|
||||
webType: cachedCurrency.webType,
|
||||
});
|
||||
}
|
||||
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
|
|
@ -174,6 +187,8 @@ export class TableManagementService {
|
|||
c.data_type as "dbType",
|
||||
COALESCE(cl.input_type, 'text') as "webType",
|
||||
COALESCE(ttc.input_type, cl.input_type, 'direct') as "inputType",
|
||||
ttc.input_type as "ttc_input_type",
|
||||
cl.input_type as "cl_input_type",
|
||||
COALESCE(ttc.detail_settings::text, cl.detail_settings, '') as "detailSettings",
|
||||
COALESCE(cl.description, '') as "description",
|
||||
c.is_nullable as "isNullable",
|
||||
|
|
@ -782,8 +797,13 @@ export class TableManagementService {
|
|||
]
|
||||
);
|
||||
|
||||
// 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제
|
||||
const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`;
|
||||
cache.delete(cacheKeyPattern);
|
||||
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
|
||||
|
||||
logger.info(
|
||||
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}, company: ${companyCode}`
|
||||
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}, company: ${companyCode} (캐시 무효화 완료)`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
|
|
|
|||
|
|
@ -6,9 +6,28 @@
|
|||
export interface ColumnFilter {
|
||||
id: string;
|
||||
columnName: string;
|
||||
operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" | "is_null" | "is_not_null";
|
||||
operator:
|
||||
| "equals"
|
||||
| "not_equals"
|
||||
| "in"
|
||||
| "not_in"
|
||||
| "contains"
|
||||
| "starts_with"
|
||||
| "ends_with"
|
||||
| "is_null"
|
||||
| "is_not_null"
|
||||
| "greater_than"
|
||||
| "less_than"
|
||||
| "greater_than_or_equal"
|
||||
| "less_than_or_equal"
|
||||
| "between"
|
||||
| "date_range_contains";
|
||||
value: string | string[];
|
||||
valueType: "static" | "category" | "code";
|
||||
valueType: "static" | "category" | "code" | "dynamic";
|
||||
rangeConfig?: {
|
||||
startColumn: string;
|
||||
endColumn: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DataFilterConfig {
|
||||
|
|
@ -123,6 +142,71 @@ export function buildDataFilterWhereClause(
|
|||
conditions.push(`${columnRef} IS NOT NULL`);
|
||||
break;
|
||||
|
||||
case "greater_than":
|
||||
conditions.push(`${columnRef} > $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
|
||||
case "less_than":
|
||||
conditions.push(`${columnRef} < $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
|
||||
case "greater_than_or_equal":
|
||||
conditions.push(`${columnRef} >= $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
|
||||
case "less_than_or_equal":
|
||||
conditions.push(`${columnRef} <= $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
|
||||
case "between":
|
||||
if (Array.isArray(value) && value.length === 2) {
|
||||
conditions.push(`${columnRef} BETWEEN $${paramIndex} AND $${paramIndex + 1}`);
|
||||
params.push(value[0], value[1]);
|
||||
paramIndex += 2;
|
||||
}
|
||||
break;
|
||||
|
||||
case "date_range_contains":
|
||||
// 날짜 범위 포함: start_date <= value <= end_date
|
||||
// filter.rangeConfig = { startColumn: "start_date", endColumn: "end_date" }
|
||||
// NULL 처리:
|
||||
// - start_date만 있고 end_date가 NULL이면: start_date <= value (이후 계속)
|
||||
// - end_date만 있고 start_date가 NULL이면: value <= end_date (이전 계속)
|
||||
// - 둘 다 있으면: start_date <= value <= end_date
|
||||
if (filter.rangeConfig && filter.rangeConfig.startColumn && filter.rangeConfig.endColumn) {
|
||||
const startCol = getColumnRef(filter.rangeConfig.startColumn);
|
||||
const endCol = getColumnRef(filter.rangeConfig.endColumn);
|
||||
|
||||
// value가 "TODAY"면 현재 날짜로 변환
|
||||
const actualValue = filter.valueType === "dynamic" && value === "TODAY"
|
||||
? "CURRENT_DATE"
|
||||
: `$${paramIndex}`;
|
||||
|
||||
if (actualValue === "CURRENT_DATE") {
|
||||
// CURRENT_DATE는 파라미터가 아니므로 직접 SQL에 포함
|
||||
// NULL 처리: (start_date IS NULL OR start_date <= CURRENT_DATE) AND (end_date IS NULL OR end_date >= CURRENT_DATE)
|
||||
conditions.push(
|
||||
`((${startCol} IS NULL OR ${startCol} <= CURRENT_DATE) AND (${endCol} IS NULL OR ${endCol} >= CURRENT_DATE))`
|
||||
);
|
||||
} else {
|
||||
// NULL 처리: (start_date IS NULL OR start_date <= $param) AND (end_date IS NULL OR end_date >= $param)
|
||||
conditions.push(
|
||||
`((${startCol} IS NULL OR ${startCol} <= $${paramIndex}) AND (${endCol} IS NULL OR ${endCol} >= $${paramIndex}))`
|
||||
);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// 알 수 없는 연산자는 무시
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,382 @@
|
|||
# 기간별 단가 설정 시스템 구현 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
**선택항목 상세입력(selected-items-detail-input)** 컴포넌트를 활용하여 기간별 단가를 설정하는 범용 시스템입니다.
|
||||
|
||||
## 데이터베이스 설계
|
||||
|
||||
### 1. 마이그레이션 실행
|
||||
|
||||
```bash
|
||||
# 마이그레이션 파일 위치
|
||||
db/migrations/999_add_period_price_columns_to_customer_item_mapping.sql
|
||||
|
||||
# 실행 (로컬)
|
||||
npm run migrate:local
|
||||
|
||||
# 또는 수동 실행
|
||||
psql -U your_user -d erp_db -f db/migrations/999_add_period_price_columns_to_customer_item_mapping.sql
|
||||
```
|
||||
|
||||
### 2. 추가된 컬럼들
|
||||
|
||||
| 컬럼명 | 타입 | 설명 | 사진 항목 |
|
||||
|--------|------|------|-----------|
|
||||
| `start_date` | DATE | 기간 시작일 | ✅ 시작일 DatePicker |
|
||||
| `end_date` | DATE | 기간 종료일 | ✅ 종료일 DatePicker |
|
||||
| `discount_type` | VARCHAR(50) | 할인 방식 | ✅ 할인율/할인금액 Select |
|
||||
| `discount_value` | NUMERIC(15,2) | 할인율 또는 할인금액 | ✅ 숫자 입력 |
|
||||
| `rounding_type` | VARCHAR(50) | 반올림 방식 | ✅ 반올림/절삭/올림 Select |
|
||||
| `rounding_unit_value` | VARCHAR(50) | 반올림 단위 | ✅ 1원/10원/100원/1,000원 Select |
|
||||
| `calculated_price` | NUMERIC(15,2) | 계산된 최종 단가 | ✅ 계산 결과 표시 |
|
||||
| `is_base_price` | BOOLEAN | 기준단가 여부 | ✅ 기준단가 Checkbox |
|
||||
|
||||
## 화면 편집기 설정 방법
|
||||
|
||||
### Step 1: 선택항목 상세입력 컴포넌트 추가
|
||||
|
||||
1. 화면 편집기에서 "선택항목 상세입력" 컴포넌트를 캔버스에 드래그앤드롭
|
||||
2. 컴포넌트 ID: `customer-item-price-periods`
|
||||
|
||||
### Step 2: 데이터 소스 설정
|
||||
|
||||
- **원본 데이터 테이블**: `item_info` (품목 정보)
|
||||
- **저장 대상 테이블**: `customer_item_mapping`
|
||||
- **데이터 소스 ID**: URL 파라미터에서 자동 설정 (Button 컴포넌트가 전달)
|
||||
|
||||
### Step 3: 표시할 원본 데이터 컬럼 설정
|
||||
|
||||
이전 화면(품목 선택 모달)에서 전달받은 품목 정보를 표시:
|
||||
|
||||
```
|
||||
컬럼1: item_code (품목코드)
|
||||
컬럼2: item_name (품목명)
|
||||
컬럼3: spec (규격)
|
||||
```
|
||||
|
||||
### Step 4: 필드 그룹 2개 생성
|
||||
|
||||
#### 그룹 1: 거래처 품목/품명 관리 (group_customer)
|
||||
|
||||
| 필드명 | 라벨 | 타입 | 설명 |
|
||||
|--------|------|------|------|
|
||||
| `customer_item_code` | 거래처 품번 | text | 거래처에서 사용하는 품번 |
|
||||
| `customer_item_name` | 거래처 품명 | text | 거래처에서 사용하는 품명 |
|
||||
|
||||
#### 그룹 2: 기간별 단가 설정 (group_period_price)
|
||||
|
||||
| 필드명 | 라벨 | 타입 | 자동 채우기 | 설명 |
|
||||
|--------|------|------|-------------|------|
|
||||
| `start_date` | 시작일 | date | - | 단가 적용 시작일 |
|
||||
| `end_date` | 종료일 | date | - | 단가 적용 종료일 (NULL이면 무기한) |
|
||||
| `current_unit_price` | 단가 | number | `item_info.standard_price` | 기본 단가 (품목에서 자동 채우기) |
|
||||
| `currency_code` | 통화 | code/category | - | 통화 코드 (KRW, USD 등) |
|
||||
| `discount_type` | 할인 방식 | code/category | - | 할인율없음/할인율(%)/할인금액 |
|
||||
| `discount_value` | 할인값 | number | - | 할인율(5) 또는 할인금액 |
|
||||
| `rounding_type` | 반올림 방식 | code/category | - | 반올림없음/반올림/절삭/올림 |
|
||||
| `rounding_unit_value` | 반올림 단위 | code/category | - | 1원/10원/100원/1,000원 |
|
||||
| `calculated_price` | 최종 단가 | number | - | 계산된 최종 단가 (읽기 전용) |
|
||||
| `is_base_price` | 기준단가 | checkbox | - | 기준단가 여부 |
|
||||
|
||||
### Step 5: 그룹별 표시 항목 설정 (DisplayItems)
|
||||
|
||||
**그룹 2 (기간별 단가 설정)의 표시 설정:**
|
||||
|
||||
```
|
||||
1. [필드] start_date | 라벨: "" | 형식: date | 빈 값: 기본값 (미설정)
|
||||
2. [텍스트] " ~ "
|
||||
3. [필드] end_date | 라벨: "" | 형식: date | 빈 값: 기본값 (무기한)
|
||||
4. [텍스트] " | "
|
||||
5. [필드] calculated_price | 라벨: "" | 형식: currency | 빈 값: 기본값 (계산 중)
|
||||
6. [텍스트] " "
|
||||
7. [필드] currency_code | 라벨: "" | 형식: text | 빈 값: 기본값 (KRW)
|
||||
8. [조건] is_base_price가 true이면 → [배지] "기준단가" (variant: default)
|
||||
```
|
||||
|
||||
**렌더링 예시:**
|
||||
```
|
||||
2024-01-01 ~ 2024-06-30 | 50,000 KRW [기준단가]
|
||||
2024-07-01 ~ 무기한 | 55,000 KRW
|
||||
```
|
||||
|
||||
## 데이터 흐름
|
||||
|
||||
### 1. 품목 선택 모달 (이전 화면)
|
||||
|
||||
```tsx
|
||||
// TableList 컴포넌트에서 품목 선택
|
||||
<Button
|
||||
onClick={() => {
|
||||
const selectedItems = tableData.filter(item => selectedRowIds.includes(item.id));
|
||||
|
||||
// modalDataStore에 데이터 저장
|
||||
useModalDataStore.getState().setData("item_info", selectedItems);
|
||||
|
||||
// 다음 화면으로 이동 (dataSourceId 전달)
|
||||
router.push("/screen/period-price-settings?dataSourceId=item_info");
|
||||
}}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
```
|
||||
|
||||
### 2. 기간별 단가 설정 화면
|
||||
|
||||
```tsx
|
||||
// 선택항목 상세입력 컴포넌트가 자동으로 처리
|
||||
// 1. URL 파라미터에서 dataSourceId 읽기
|
||||
// 2. modalDataStore에서 item_info 데이터 가져오기
|
||||
// 3. 사용자가 그룹별로 여러 개의 기간별 단가 입력
|
||||
// 4. 저장 버튼 클릭 시 customer_item_mapping 테이블에 저장
|
||||
```
|
||||
|
||||
### 3. 저장 데이터 구조
|
||||
|
||||
**하나의 품목(item_id = "ITEM001")에 대해 3개의 기간별 단가를 입력한 경우:**
|
||||
|
||||
```sql
|
||||
-- customer_item_mapping 테이블에 3개의 행으로 저장
|
||||
INSERT INTO customer_item_mapping (
|
||||
customer_id, item_id,
|
||||
customer_item_code, customer_item_name,
|
||||
start_date, end_date,
|
||||
current_unit_price, currency_code,
|
||||
discount_type, discount_value,
|
||||
rounding_type, rounding_unit_value,
|
||||
calculated_price, is_base_price
|
||||
) VALUES
|
||||
-- 첫 번째 기간 (기준단가)
|
||||
('CUST001', 'ITEM001',
|
||||
'CUST-A-001', '실리콘 고무 시트',
|
||||
'2024-01-01', '2024-06-30',
|
||||
50000, 'KRW',
|
||||
'할인율없음', 0,
|
||||
'반올림', '100원',
|
||||
50000, true),
|
||||
|
||||
-- 두 번째 기간
|
||||
('CUST001', 'ITEM001',
|
||||
'CUST-A-001', '실리콘 고무 시트',
|
||||
'2024-07-01', '2024-12-31',
|
||||
50000, 'KRW',
|
||||
'할인율(%)', 5,
|
||||
'절삭', '1원',
|
||||
47500, false),
|
||||
|
||||
-- 세 번째 기간 (무기한)
|
||||
('CUST001', 'ITEM001',
|
||||
'CUST-A-001', '실리콘 고무 시트',
|
||||
'2025-01-01', NULL,
|
||||
50000, 'KRW',
|
||||
'할인금액', 3000,
|
||||
'올림', '1000원',
|
||||
47000, false);
|
||||
```
|
||||
|
||||
## 계산 로직 (선택사항)
|
||||
|
||||
단가 계산을 자동화하려면 프론트엔드에서 `calculated_price`를 자동 계산:
|
||||
|
||||
```typescript
|
||||
const calculatePrice = (
|
||||
basePrice: number,
|
||||
discountType: string,
|
||||
discountValue: number,
|
||||
roundingType: string,
|
||||
roundingUnit: string
|
||||
): number => {
|
||||
let price = basePrice;
|
||||
|
||||
// 1단계: 할인 적용
|
||||
if (discountType === "할인율(%)") {
|
||||
price = price * (1 - discountValue / 100);
|
||||
} else if (discountType === "할인금액") {
|
||||
price = price - discountValue;
|
||||
}
|
||||
|
||||
// 2단계: 반올림 적용
|
||||
const unitMap: Record<string, number> = {
|
||||
"1원": 1,
|
||||
"10원": 10,
|
||||
"100원": 100,
|
||||
"1,000원": 1000,
|
||||
};
|
||||
|
||||
const unit = unitMap[roundingUnit] || 1;
|
||||
|
||||
if (roundingType === "반올림") {
|
||||
price = Math.round(price / unit) * unit;
|
||||
} else if (roundingType === "절삭") {
|
||||
price = Math.floor(price / unit) * unit;
|
||||
} else if (roundingType === "올림") {
|
||||
price = Math.ceil(price / unit) * unit;
|
||||
}
|
||||
|
||||
return price;
|
||||
};
|
||||
|
||||
// 필드 변경 시 자동 계산
|
||||
useEffect(() => {
|
||||
const calculatedPrice = calculatePrice(
|
||||
basePrice,
|
||||
discountType,
|
||||
discountValue,
|
||||
roundingType,
|
||||
roundingUnit
|
||||
);
|
||||
|
||||
// calculated_price 필드 업데이트
|
||||
handleFieldChange(itemId, groupId, entryId, "calculated_price", calculatedPrice);
|
||||
}, [basePrice, discountType, discountValue, roundingType, roundingUnit]);
|
||||
```
|
||||
|
||||
## 백엔드 API 구현 (필요시)
|
||||
|
||||
### 기간별 단가 조회
|
||||
|
||||
```typescript
|
||||
// GET /api/customer-item/price-periods?customer_id=CUST001&item_id=ITEM001
|
||||
router.get("/price-periods", async (req, res) => {
|
||||
const { customer_id, item_id } = req.query;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
const query = `
|
||||
SELECT * FROM customer_item_mapping
|
||||
WHERE customer_id = $1
|
||||
AND item_id = $2
|
||||
AND company_code = $3
|
||||
ORDER BY start_date ASC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [customer_id, item_id, companyCode]);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
});
|
||||
```
|
||||
|
||||
### 기간별 단가 저장
|
||||
|
||||
```typescript
|
||||
// POST /api/customer-item/price-periods
|
||||
router.post("/price-periods", async (req, res) => {
|
||||
const { items } = req.body; // 선택항목 상세입력 컴포넌트에서 전달
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
for (const item of items) {
|
||||
// item.fieldGroups.group_period_price 배열의 각 항목을 INSERT
|
||||
const periodPrices = item.fieldGroups.group_period_price || [];
|
||||
|
||||
for (const periodPrice of periodPrices) {
|
||||
const query = `
|
||||
INSERT INTO customer_item_mapping (
|
||||
company_code, customer_id, item_id,
|
||||
customer_item_code, customer_item_name,
|
||||
start_date, end_date,
|
||||
current_unit_price, currency_code,
|
||||
discount_type, discount_value,
|
||||
rounding_type, rounding_unit_value,
|
||||
calculated_price, is_base_price
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
`;
|
||||
|
||||
await client.query(query, [
|
||||
companyCode,
|
||||
item.originalData.customer_id,
|
||||
item.originalData.item_id,
|
||||
periodPrice.customer_item_code,
|
||||
periodPrice.customer_item_name,
|
||||
periodPrice.start_date,
|
||||
periodPrice.end_date || null,
|
||||
periodPrice.current_unit_price,
|
||||
periodPrice.currency_code,
|
||||
periodPrice.discount_type,
|
||||
periodPrice.discount_value,
|
||||
periodPrice.rounding_type,
|
||||
periodPrice.rounding_unit_value,
|
||||
periodPrice.calculated_price,
|
||||
periodPrice.is_base_price
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
return res.json({ success: true, message: "기간별 단가가 저장되었습니다." });
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
console.error("기간별 단가 저장 실패:", error);
|
||||
return res.status(500).json({ success: false, error: "저장 실패" });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 사용 시나리오 예시
|
||||
|
||||
### 시나리오 1: 거래처별 단가 관리
|
||||
|
||||
1. 거래처 선택 모달 → 거래처 선택 → 다음
|
||||
2. 품목 선택 모달 → 품목 여러 개 선택 → 다음
|
||||
3. **기간별 단가 설정 화면**
|
||||
- 품목1 (실리콘 고무 시트)
|
||||
- **그룹1 추가**: 거래처 품번: CUST-A-001, 품명: 실리콘 시트
|
||||
- **그룹2 추가**: 2024-01-01 ~ 2024-06-30, 50,000원 (기준단가)
|
||||
- **그룹2 추가**: 2024-07-01 ~ 무기한, 할인율 5% → 47,500원
|
||||
- 품목2 (스테인리스 판)
|
||||
- **그룹1 추가**: 거래처 품번: CUST-A-002, 품명: SUS304 판
|
||||
- **그룹2 추가**: 2024-01-01 ~ 무기한, 150,000원 (기준단가)
|
||||
4. 저장 버튼 클릭 → customer_item_mapping 테이블에 4개 행 저장
|
||||
|
||||
### 시나리오 2: 단순 단가 입력
|
||||
|
||||
필드 그룹을 사용하지 않고 단일 입력도 가능:
|
||||
|
||||
```
|
||||
그룹 없이 필드 정의:
|
||||
- customer_item_code
|
||||
- customer_item_name
|
||||
- current_unit_price
|
||||
- currency_code
|
||||
|
||||
→ 각 품목당 1개의 행만 저장
|
||||
```
|
||||
|
||||
## 장점
|
||||
|
||||
### 1. 범용성
|
||||
- 기간별 단가뿐만 아니라 **모든 숫자 계산 시나리오**에 적용 가능
|
||||
- 견적서, 발주서, 판매 단가, 구매 단가 등
|
||||
|
||||
### 2. 유연성
|
||||
- 필드 그룹으로 자유롭게 섹션 구성
|
||||
- 표시 항목 설정으로 UI 커스터마이징
|
||||
|
||||
### 3. 데이터 무결성
|
||||
- 1:N 관계로 여러 기간별 데이터 관리
|
||||
- 기간 중복 체크는 백엔드에서 처리
|
||||
|
||||
### 4. 사용자 경험
|
||||
- 품목별로 여러 개의 기간별 단가를 손쉽게 입력
|
||||
- 입력 완료 후 작은 카드로 요약 표시
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. **마이그레이션 실행** (999_add_period_price_columns_to_customer_item_mapping.sql)
|
||||
2. **화면 편집기에서 설정** (위 Step 1~5 참고)
|
||||
3. **백엔드 API 구현** (저장/조회 엔드포인트)
|
||||
4. **계산 로직 추가** (선택사항: 자동 계산)
|
||||
5. **테스트** (품목 선택 → 기간별 단가 입력 → 저장 → 조회)
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- 선택항목 상세입력 컴포넌트: `frontend/lib/registry/components/selected-items-detail-input/`
|
||||
- 타입 정의: `frontend/lib/registry/components/selected-items-detail-input/types.ts`
|
||||
- 설정 패널: `SelectedItemsDetailInputConfigPanel.tsx`
|
||||
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
# Modal Repeater Table 디버깅 가이드
|
||||
|
||||
## 📊 콘솔 로그 확인 순서
|
||||
|
||||
새로고침 후 수주 등록 모달을 열고, 아래 순서대로 콘솔 로그를 확인하세요:
|
||||
|
||||
### 1️⃣ 컴포넌트 마운트 (초기 로드)
|
||||
|
||||
```
|
||||
🎬 ModalRepeaterTableComponent 마운트: {
|
||||
config: {...},
|
||||
propColumns: [...],
|
||||
columns: [...],
|
||||
columnsLength: N, // ⚠️ 0이면 문제!
|
||||
value: [],
|
||||
valueLength: 0,
|
||||
sourceTable: "item_info",
|
||||
sourceColumns: [...],
|
||||
uniqueField: "item_number"
|
||||
}
|
||||
```
|
||||
|
||||
**✅ 정상:**
|
||||
- `columnsLength: 8` (품번, 품명, 규격, 재질, 수량, 단가, 금액, 납기일)
|
||||
- `columns` 배열에 각 컬럼의 `field`, `label`, `type` 정보가 있어야 함
|
||||
|
||||
**❌ 문제:**
|
||||
- `columnsLength: 0` → **이것이 문제의 원인!**
|
||||
- 빈 배열이면 테이블에 컬럼이 표시되지 않음
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ 항목 검색 모달 열림
|
||||
|
||||
```
|
||||
🚪 모달 열림 - uniqueField: "item_number", multiSelect: true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ 품목 체크 (선택)
|
||||
|
||||
```
|
||||
🖱️ 행 클릭: {
|
||||
item: { item_number: "SLI-2025-0003", item_name: "실리콘 고무 시트", ... },
|
||||
uniqueField: "item_number",
|
||||
itemValue: "SLI-2025-0003",
|
||||
currentSelected: 0,
|
||||
selectedValues: []
|
||||
}
|
||||
|
||||
✅ 매칭 발견: { selectedValue: "SLI-2025-0003", itemValue: "SLI-2025-0003", uniqueField: "item_number" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ 추가 버튼 클릭
|
||||
|
||||
```
|
||||
✅ ItemSelectionModal 추가 버튼 클릭: {
|
||||
selectedCount: 1,
|
||||
selectedItems: [{ item_number: "SLI-2025-0003", item_name: "실리콘 고무 시트", ... }],
|
||||
uniqueField: "item_number"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5️⃣ 데이터 추가 처리
|
||||
|
||||
```
|
||||
➕ handleAddItems 호출: {
|
||||
selectedItems: [{ item_number: "SLI-2025-0003", ... }],
|
||||
currentValue: [],
|
||||
columns: [...], // ⚠️ 여기도 확인!
|
||||
calculationRules: [...]
|
||||
}
|
||||
|
||||
📝 기본값 적용 후: [{ item_number: "SLI-2025-0003", quantity: 1, ... }]
|
||||
|
||||
🔢 계산 필드 적용 후: [{ item_number: "SLI-2025-0003", quantity: 1, selling_price: 1000, amount: 1000, ... }]
|
||||
|
||||
✅ 최종 데이터 (onChange 호출): [{ item_number: "SLI-2025-0003", quantity: 1, selling_price: 1000, amount: 1000, ... }]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6️⃣ Renderer 업데이트
|
||||
|
||||
```
|
||||
🔄 ModalRepeaterTableRenderer onChange 호출: {
|
||||
previousValue: [],
|
||||
newValue: [{ item_number: "SLI-2025-0003", ... }]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7️⃣ value 변경 감지
|
||||
|
||||
```
|
||||
📦 ModalRepeaterTableComponent value 변경: {
|
||||
valueLength: 1,
|
||||
value: [{ item_number: "SLI-2025-0003", ... }],
|
||||
columns: [...] // ⚠️ 여기도 확인!
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8️⃣ 테이블 리렌더링
|
||||
|
||||
```
|
||||
📊 RepeaterTable 데이터 업데이트: {
|
||||
rowCount: 1,
|
||||
data: [{ item_number: "SLI-2025-0003", ... }],
|
||||
columns: ["item_number", "item_name", "specification", "material", "quantity", "selling_price", "amount", "delivery_date"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 문제 진단
|
||||
|
||||
### Case 1: columns가 비어있음 (columnsLength: 0)
|
||||
|
||||
**원인:**
|
||||
- 화면관리 시스템에서 modal-repeater-table 컴포넌트의 `columns` 설정을 하지 않음
|
||||
- DB에 컬럼 설정이 저장되지 않음
|
||||
|
||||
**해결:**
|
||||
1. 화면 관리 페이지로 이동
|
||||
2. 해당 화면 편집
|
||||
3. modal-repeater-table 컴포넌트 선택
|
||||
4. 우측 설정 패널에서 "컬럼 설정" 탭 열기
|
||||
5. 다음 컬럼들을 추가:
|
||||
- 품번 (item_number, text, 편집불가)
|
||||
- 품명 (item_name, text, 편집불가)
|
||||
- 규격 (specification, text, 편집불가)
|
||||
- 재질 (material, text, 편집불가)
|
||||
- 수량 (quantity, number, 편집가능, 기본값: 1)
|
||||
- 단가 (selling_price, number, 편집가능)
|
||||
- 금액 (amount, number, 편집불가, 계산필드)
|
||||
- 납기일 (delivery_date, date, 편집가능)
|
||||
6. 저장
|
||||
|
||||
---
|
||||
|
||||
### Case 2: 로그가 8번까지 나오는데 화면에 안 보임
|
||||
|
||||
**원인:**
|
||||
- React 리렌더링 문제
|
||||
- 화면관리 시스템의 상태 동기화 문제
|
||||
|
||||
**해결:**
|
||||
1. 브라우저 개발자 도구 → Elements 탭
|
||||
2. `#component-comp_5jdmuzai .border.rounded-md table tbody` 찾기
|
||||
3. 실제 DOM에 `<tr>` 요소가 추가되었는지 확인
|
||||
4. 추가되었다면 CSS 문제 (display: none 등)
|
||||
5. 추가 안 되었다면 컴포넌트 렌더링 문제
|
||||
|
||||
---
|
||||
|
||||
### Case 3: 로그가 5번까지만 나오고 멈춤
|
||||
|
||||
**원인:**
|
||||
- `onChange` 콜백이 제대로 전달되지 않음
|
||||
- Renderer의 `updateComponent`가 작동하지 않음
|
||||
|
||||
**해결:**
|
||||
- 이미 수정한 `ModalRepeaterTableRenderer.tsx` 코드 확인
|
||||
- `handleChange` 함수가 호출되는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 📝 다음 단계
|
||||
|
||||
위 로그를 **모두** 복사해서 공유해주세요. 특히:
|
||||
|
||||
1. **🎬 마운트 로그의 `columnsLength` 값**
|
||||
2. **로그가 어디까지 출력되는지**
|
||||
3. **Elements 탭에서 `tbody` 내부 HTML 구조**
|
||||
|
||||
이 정보로 정확한 문제를 진단할 수 있습니다!
|
||||
|
||||
|
|
@ -18,32 +18,26 @@ 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({ initialDashboards, initialPagination }: DashboardListClientProps) {
|
||||
export default function DashboardListClient() {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>(initialDashboards);
|
||||
const [loading, setLoading] = useState(false); // 초기 로딩은 서버에서 완료
|
||||
|
||||
// 상태 관리
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// 페이지네이션 상태
|
||||
const [currentPage, setCurrentPage] = useState(initialPagination.page);
|
||||
const [pageSize, setPageSize] = useState(initialPagination.limit);
|
||||
const [totalCount, setTotalCount] = useState(initialPagination.total);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 모달 상태
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
|
@ -73,17 +67,8 @@ export default function DashboardListClient({ initialDashboards, initialPaginati
|
|||
}
|
||||
};
|
||||
|
||||
// 초기 로드 여부 추적
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||
|
||||
// 검색어/페이지 변경 시 fetch (초기 로딩 포함)
|
||||
useEffect(() => {
|
||||
// 초기 로드는 건너뛰기 (서버에서 이미 데이터를 가져왔음)
|
||||
if (isInitialLoad) {
|
||||
setIsInitialLoad(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 이후 검색어/페이지 변경 시에만 fetch
|
||||
loadDashboards();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchTerm, currentPage, pageSize]);
|
||||
|
|
@ -91,7 +76,7 @@ export default function DashboardListClient({ initialDashboards, initialPaginati
|
|||
// 페이지네이션 정보 계산
|
||||
const paginationInfo: PaginationInfo = {
|
||||
currentPage,
|
||||
totalPages: Math.ceil(totalCount / pageSize),
|
||||
totalPages: Math.ceil(totalCount / pageSize) || 1,
|
||||
totalItems: totalCount,
|
||||
itemsPerPage: pageSize,
|
||||
startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1,
|
||||
|
|
@ -300,7 +285,14 @@ export default function DashboardListClient({ initialDashboards, initialPaginati
|
|||
<TableBody>
|
||||
{dashboards.map((dashboard) => (
|
||||
<TableRow key={dashboard.id} className="hover:bg-muted/50 border-b transition-colors">
|
||||
<TableCell className="h-16 text-sm font-medium">{dashboard.title}</TableCell>
|
||||
<TableCell className="h-16 text-sm font-medium">
|
||||
<button
|
||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
||||
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
||||
>
|
||||
{dashboard.title}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
|
||||
{dashboard.description || "-"}
|
||||
</TableCell>
|
||||
|
|
@ -355,7 +347,12 @@ export default function DashboardListClient({ initialDashboards, initialPaginati
|
|||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-semibold">{dashboard.title}</h3>
|
||||
<button
|
||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
||||
className="hover:text-primary cursor-pointer text-left transition-colors hover:underline"
|
||||
>
|
||||
<h3 className="text-base font-semibold">{dashboard.title}</h3>
|
||||
</button>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{dashboard.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,73 +1,22 @@
|
|||
import DashboardListClient from "@/app/(main)/admin/dashboard/DashboardListClient";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
/**
|
||||
* 서버에서 초기 대시보드 목록 fetch
|
||||
* 대시보드 관리 페이지
|
||||
* - 클라이언트 컴포넌트를 렌더링하는 래퍼
|
||||
* - 초기 로딩부터 CSR로 처리
|
||||
*/
|
||||
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();
|
||||
|
||||
export default function DashboardListPage() {
|
||||
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 initialDashboards={initialData.dashboards} initialPagination={initialData.pagination} />
|
||||
{/* 클라이언트 컴포넌트 */}
|
||||
<DashboardListClient />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ function ScreenViewPage() {
|
|||
// 플로우 새로고침을 위한 키 (값이 변경되면 플로우 데이터가 리렌더링됨)
|
||||
const [flowRefreshKey, setFlowRefreshKey] = useState(0);
|
||||
|
||||
// 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이)
|
||||
const [conditionalContainerHeights, setConditionalContainerHeights] = useState<Record<string, number>>({});
|
||||
|
||||
// 편집 모달 상태
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editModalConfig, setEditModalConfig] = useState<{
|
||||
|
|
@ -402,19 +405,39 @@ function ScreenViewPage() {
|
|||
(c) => (c as any).componentId === "table-search-widget"
|
||||
);
|
||||
|
||||
// TableSearchWidget 높이 차이를 계산하여 Y 위치 조정
|
||||
// 디버그: 모든 컴포넌트 타입 확인
|
||||
console.log("🔍 전체 컴포넌트 타입:", regularComponents.map(c => ({
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
componentType: (c as any).componentType,
|
||||
componentId: (c as any).componentId,
|
||||
})));
|
||||
|
||||
// 🆕 조건부 컨테이너들을 찾기
|
||||
const conditionalContainers = regularComponents.filter(
|
||||
(c) => (c as any).componentId === "conditional-container" || (c as any).componentType === "conditional-container"
|
||||
);
|
||||
|
||||
console.log("🔍 조건부 컨테이너 발견:", conditionalContainers.map(c => ({
|
||||
id: c.id,
|
||||
y: c.position.y,
|
||||
size: c.size,
|
||||
})));
|
||||
|
||||
// TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정
|
||||
const adjustedComponents = regularComponents.map((component) => {
|
||||
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
||||
const isConditionalContainer = (component as any).componentId === "conditional-container";
|
||||
|
||||
if (isTableSearchWidget) {
|
||||
// TableSearchWidget 자체는 조정하지 않음
|
||||
if (isTableSearchWidget || isConditionalContainer) {
|
||||
// 자기 자신은 조정하지 않음
|
||||
return component;
|
||||
}
|
||||
|
||||
let totalHeightAdjustment = 0;
|
||||
|
||||
// TableSearchWidget 높이 조정
|
||||
for (const widget of tableSearchWidgets) {
|
||||
// 현재 컴포넌트가 이 위젯 아래에 있는지 확인
|
||||
const isBelow = component.position.y > widget.position.y;
|
||||
const heightDiff = getHeightDiff(screenId, widget.id);
|
||||
|
||||
|
|
@ -423,6 +446,31 @@ function ScreenViewPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 조건부 컨테이너 높이 조정
|
||||
for (const container of conditionalContainers) {
|
||||
const isBelow = component.position.y > container.position.y;
|
||||
const actualHeight = conditionalContainerHeights[container.id];
|
||||
const originalHeight = container.size?.height || 200;
|
||||
const heightDiff = actualHeight ? (actualHeight - originalHeight) : 0;
|
||||
|
||||
console.log(`🔍 높이 조정 체크:`, {
|
||||
componentId: component.id,
|
||||
componentY: component.position.y,
|
||||
containerY: container.position.y,
|
||||
isBelow,
|
||||
actualHeight,
|
||||
originalHeight,
|
||||
heightDiff,
|
||||
containerId: container.id,
|
||||
containerSize: container.size,
|
||||
});
|
||||
|
||||
if (isBelow && heightDiff > 0) {
|
||||
totalHeightAdjustment += heightDiff;
|
||||
console.log(`📐 컴포넌트 ${component.id} 위치 조정: ${heightDiff}px (조건부 컨테이너 ${container.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (totalHeightAdjustment > 0) {
|
||||
return {
|
||||
...component,
|
||||
|
|
@ -491,6 +539,12 @@ function ScreenViewPage() {
|
|||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
onHeightChange={(componentId, newHeight) => {
|
||||
setConditionalContainerHeights((prev) => ({
|
||||
...prev,
|
||||
[componentId]: newHeight,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{/* 자식 컴포넌트들 */}
|
||||
{(component.type === "group" || component.type === "container" || component.type === "area") &&
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export default function TestAutocompleteMapping() {
|
||||
const [selectedValue, setSelectedValue] = useState("");
|
||||
const [customerName, setCustomerName] = useState("");
|
||||
const [address, setAddress] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AutocompleteSearchInput 필드 자동 매핑 테스트</CardTitle>
|
||||
<CardDescription>
|
||||
거래처를 선택하면 아래 입력 필드들이 자동으로 채워집니다
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 검색 컴포넌트 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래처 검색</Label>
|
||||
<AutocompleteSearchInputComponent
|
||||
config={{
|
||||
tableName: "customer_mng",
|
||||
displayField: "customer_name",
|
||||
valueField: "customer_code",
|
||||
searchFields: ["customer_name", "customer_code"],
|
||||
placeholder: "거래처명 또는 코드로 검색",
|
||||
enableFieldMapping: true,
|
||||
fieldMappings: [
|
||||
{
|
||||
sourceField: "customer_name",
|
||||
targetField: "customer_name_input",
|
||||
label: "거래처명",
|
||||
},
|
||||
{
|
||||
sourceField: "address",
|
||||
targetField: "address_input",
|
||||
label: "주소",
|
||||
},
|
||||
{
|
||||
sourceField: "phone",
|
||||
targetField: "phone_input",
|
||||
label: "전화번호",
|
||||
},
|
||||
],
|
||||
}}
|
||||
value={selectedValue}
|
||||
onChange={(value, fullData) => {
|
||||
setSelectedValue(value);
|
||||
console.log("선택된 항목:", fullData);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-sm font-semibold mb-4">
|
||||
자동으로 채워지는 필드들
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* 거래처명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="customer_name_input">거래처명</Label>
|
||||
<Input
|
||||
id="customer_name_input"
|
||||
value={customerName}
|
||||
onChange={(e) => setCustomerName(e.target.value)}
|
||||
placeholder="자동으로 채워집니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 주소 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_input">주소</Label>
|
||||
<Input
|
||||
id="address_input"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="자동으로 채워집니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 전화번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone_input">전화번호</Label>
|
||||
<Input
|
||||
id="phone_input"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="자동으로 채워집니다"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 표시 */}
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-sm font-semibold mb-2">현재 상태</h3>
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<pre className="text-xs">
|
||||
{JSON.stringify(
|
||||
{
|
||||
selectedValue,
|
||||
customerName,
|
||||
address,
|
||||
phone,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 사용 안내 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">사용 방법</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<ol className="list-decimal list-inside space-y-2">
|
||||
<li>위의 검색 필드에 거래처명이나 코드를 입력하세요</li>
|
||||
<li>드롭다운에서 원하는 거래처를 선택하세요</li>
|
||||
<li>아래 입력 필드들이 자동으로 채워지는 것을 확인하세요</li>
|
||||
<li>필요한 경우 자동으로 채워진 값을 수정할 수 있습니다</li>
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function TestEntitySearchPage() {
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">EntitySearchInput 테스트</h1>
|
||||
<p className="text-muted-foreground mt-2">이 페이지는 빌드 에러로 인해 임시로 비활성화되었습니다.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>빌드 에러 수정 중</CardTitle>
|
||||
<CardDescription>순환 참조 문제를 해결한 후 다시 활성화됩니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
에러 메시지: ReferenceError: Cannot access 'h' before initialization
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export default function TestOrderRegistrationPage() {
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">수주 등록 테스트</h1>
|
||||
<p className="text-muted-foreground mt-2">이 페이지는 빌드 에러로 인해 임시로 비활성화되었습니다.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>빌드 에러 수정 중</CardTitle>
|
||||
<CardDescription>ModalRepeaterTable 순환 참조 문제를 해결한 후 다시 활성화됩니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
에러 메시지: ReferenceError: Cannot access 'h' before initialization
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -812,69 +812,70 @@ export function CanvasElement({
|
|||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex cursor-move items-center justify-between px-2 py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 차트 타입 전환 드롭다운 (차트일 경우만) */}
|
||||
{element.type === "chart" && (
|
||||
<Select
|
||||
value={element.subtype}
|
||||
onValueChange={(newSubtype: string) => {
|
||||
onUpdate(element.id, { subtype: newSubtype as ElementSubtype });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-6 w-[120px] text-[11px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
{/* 헤더 - showHeader가 false이면 숨김 */}
|
||||
{element.showHeader !== false && (
|
||||
<div className="flex cursor-move items-center justify-between px-2 py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 차트 타입 전환 드롭다운 (차트일 경우만) */}
|
||||
{element.type === "chart" && (
|
||||
<Select
|
||||
value={element.subtype}
|
||||
onValueChange={(newSubtype: string) => {
|
||||
onUpdate(element.id, { subtype: newSubtype as ElementSubtype });
|
||||
}}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[99999]" onClick={(e) => e.stopPropagation()}>
|
||||
{getChartCategory(element.subtype) === "axis-based" ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>축 기반 차트</SelectLabel>
|
||||
<SelectItem value="bar">바 차트</SelectItem>
|
||||
<SelectItem value="horizontal-bar">수평 바 차트</SelectItem>
|
||||
<SelectItem value="stacked-bar">누적 바 차트</SelectItem>
|
||||
<SelectItem value="line">꺾은선 차트</SelectItem>
|
||||
<SelectItem value="area">영역 차트</SelectItem>
|
||||
<SelectItem value="combo">콤보 차트</SelectItem>
|
||||
</SelectGroup>
|
||||
) : (
|
||||
<SelectGroup>
|
||||
<SelectLabel>원형 차트</SelectLabel>
|
||||
<SelectItem value="pie">원형 차트</SelectItem>
|
||||
<SelectItem value="donut">도넛 차트</SelectItem>
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* 제목 */}
|
||||
{!element.type || element.type !== "chart" ? (
|
||||
element.subtype === "map-summary-v2" && !element.customTitle ? null : (
|
||||
<span className="text-foreground text-xs font-bold">{element.customTitle || element.title}</span>
|
||||
)
|
||||
) : null}
|
||||
<SelectTrigger
|
||||
className="h-6 w-[120px] text-[11px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[99999]" onClick={(e) => e.stopPropagation()}>
|
||||
{getChartCategory(element.subtype) === "axis-based" ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>축 기반 차트</SelectLabel>
|
||||
<SelectItem value="bar">바 차트</SelectItem>
|
||||
<SelectItem value="horizontal-bar">수평 바 차트</SelectItem>
|
||||
<SelectItem value="stacked-bar">누적 바 차트</SelectItem>
|
||||
<SelectItem value="line">꺾은선 차트</SelectItem>
|
||||
<SelectItem value="area">영역 차트</SelectItem>
|
||||
<SelectItem value="combo">콤보 차트</SelectItem>
|
||||
</SelectGroup>
|
||||
) : (
|
||||
<SelectGroup>
|
||||
<SelectLabel>원형 차트</SelectLabel>
|
||||
<SelectItem value="pie">원형 차트</SelectItem>
|
||||
<SelectItem value="donut">도넛 차트</SelectItem>
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* 제목 */}
|
||||
{!element.type || element.type !== "chart" ? (
|
||||
element.subtype === "map-summary-v2" && !element.customTitle ? null : (
|
||||
<span className="text-foreground text-xs font-bold">{element.customTitle || element.title}</span>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="element-close hover:bg-destructive text-muted-foreground h-5 w-5 hover:text-white"
|
||||
onClick={handleRemove}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
title="삭제"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 삭제 버튼 - 항상 표시 (우측 상단 절대 위치) */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="element-close hover:bg-destructive text-muted-foreground absolute top-1 right-1 z-10 h-5 w-5 hover:text-white"
|
||||
onClick={handleRemove}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
title="삭제"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="relative h-[calc(100%-32px)] px-2 pb-2">
|
||||
<div className={`relative px-2 pb-2 ${element.showHeader !== false ? "h-[calc(100%-32px)]" : "h-full"}`}>
|
||||
{element.type === "chart" ? (
|
||||
// 차트 렌더링
|
||||
<div className="bg-background h-full w-full">
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { ChartDataSource, QueryResult, ChartConfig } from "./types";
|
||||
import { ChartDataSource, QueryResult } from "./types";
|
||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -14,7 +13,6 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
|
|||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Play, Loader2, Database, Code, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { applyQueryFilters } from "./utils/queryHelpers";
|
||||
|
||||
interface QueryEditorProps {
|
||||
dataSource?: ChartDataSource;
|
||||
|
|
@ -106,7 +104,6 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
|
|||
...dataSource,
|
||||
type: "database",
|
||||
query: query.trim(),
|
||||
refreshInterval: dataSource?.refreshInterval ?? 0,
|
||||
lastExecuted: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
@ -168,8 +165,8 @@ ORDER BY 하위부서수 DESC`,
|
|||
{/* 쿼리 에디터 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Database className="h-3.5 w-3.5 text-primary" />
|
||||
<h4 className="text-xs font-semibold text-foreground">SQL 쿼리 에디터</h4>
|
||||
<Database className="text-primary h-3.5 w-3.5" />
|
||||
<h4 className="text-foreground text-xs font-semibold">SQL 쿼리 에디터</h4>
|
||||
</div>
|
||||
<Button onClick={executeQuery} disabled={isExecuting || !query.trim()} size="sm" className="h-7 text-xs">
|
||||
{isExecuting ? (
|
||||
|
|
@ -188,7 +185,7 @@ ORDER BY 하위부서수 DESC`,
|
|||
|
||||
{/* 샘플 쿼리 아코디언 */}
|
||||
<Collapsible open={sampleQueryOpen} onOpenChange={setSampleQueryOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded border border-border bg-muted px-2 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted">
|
||||
<CollapsibleTrigger className="border-border bg-muted text-foreground hover:bg-muted flex w-full items-center gap-1.5 rounded border px-2 py-1.5 text-xs font-medium transition-colors">
|
||||
{sampleQueryOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
샘플 쿼리
|
||||
</CollapsibleTrigger>
|
||||
|
|
@ -196,33 +193,33 @@ ORDER BY 하위부서수 DESC`,
|
|||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => insertSampleQuery("users")}
|
||||
className="flex items-center gap-1 rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
||||
className="border-border bg-background hover:bg-muted flex items-center gap-1 rounded border px-2 py-1 text-[11px] transition-colors"
|
||||
>
|
||||
<Code className="h-3 w-3" />
|
||||
부서별 사용자
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery("dept")}
|
||||
className="flex items-center gap-1 rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
||||
className="border-border bg-background hover:bg-muted flex items-center gap-1 rounded border px-2 py-1 text-[11px] transition-colors"
|
||||
>
|
||||
<Code className="h-3 w-3" />
|
||||
부서 정보
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery("usersByDate")}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
||||
className="border-border bg-background hover:bg-muted rounded border px-2 py-1 text-[11px] transition-colors"
|
||||
>
|
||||
월별 가입 추이
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery("usersByPosition")}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
||||
className="border-border bg-background hover:bg-muted rounded border px-2 py-1 text-[11px] transition-colors"
|
||||
>
|
||||
직급별 분포
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery("deptHierarchy")}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
||||
className="border-border bg-background hover:bg-muted rounded border px-2 py-1 text-[11px] transition-colors"
|
||||
>
|
||||
부서 계층
|
||||
</button>
|
||||
|
|
@ -247,46 +244,6 @@ ORDER BY 하위부서수 DESC`,
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 새로고침 간격 설정 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs">자동 새로고침:</Label>
|
||||
<Select
|
||||
value={String(dataSource?.refreshInterval ?? 0)}
|
||||
onValueChange={(value) =>
|
||||
onDataSourceChange({
|
||||
...dataSource,
|
||||
type: "database",
|
||||
query,
|
||||
refreshInterval: parseInt(value),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[99999]">
|
||||
<SelectItem value="0" className="text-xs">
|
||||
수동
|
||||
</SelectItem>
|
||||
<SelectItem value="10000" className="text-xs">
|
||||
10초
|
||||
</SelectItem>
|
||||
<SelectItem value="30000" className="text-xs">
|
||||
30초
|
||||
</SelectItem>
|
||||
<SelectItem value="60000" className="text-xs">
|
||||
1분
|
||||
</SelectItem>
|
||||
<SelectItem value="300000" className="text-xs">
|
||||
5분
|
||||
</SelectItem>
|
||||
<SelectItem value="600000" className="text-xs">
|
||||
10분
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 오류 메시지 */}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="py-2">
|
||||
|
|
@ -300,15 +257,15 @@ ORDER BY 하위부서수 DESC`,
|
|||
{/* 쿼리 결과 미리보기 */}
|
||||
{queryResult && (
|
||||
<Card>
|
||||
<div className="border-b border-border bg-muted px-2 py-1.5">
|
||||
<div className="border-border bg-muted border-b px-2 py-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-foreground">쿼리 결과</span>
|
||||
<span className="text-foreground text-xs font-medium">쿼리 결과</span>
|
||||
<Badge variant="secondary" className="h-4 text-[10px]">
|
||||
{queryResult.rows.length}행
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">실행 시간: {queryResult.executionTime}ms</span>
|
||||
<span className="text-muted-foreground text-[10px]">실행 시간: {queryResult.executionTime}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -339,13 +296,13 @@ ORDER BY 하위부서수 DESC`,
|
|||
</Table>
|
||||
|
||||
{queryResult.rows.length > 10 && (
|
||||
<div className="mt-2 text-center text-[10px] text-muted-foreground">
|
||||
<div className="text-muted-foreground mt-2 text-center text-[10px]">
|
||||
... 및 {queryResult.rows.length - 10}개 더 (미리보기는 10행까지만 표시)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6 text-center text-xs text-muted-foreground">결과가 없습니다.</div>
|
||||
<div className="text-muted-foreground py-6 text-center text-xs">결과가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
|
@ -353,169 +310,3 @@ ORDER BY 하위부서수 DESC`,
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 샘플 쿼리 결과 생성 함수
|
||||
*/
|
||||
function generateSampleQueryResult(query: string): QueryResult {
|
||||
// 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
// 디버깅용 로그
|
||||
// console.log('generateSampleQueryResult called with query:', query.substring(0, 100));
|
||||
|
||||
// 가장 구체적인 조건부터 먼저 체크 (순서 중요!)
|
||||
const isComparison =
|
||||
queryLower.includes("galaxy") ||
|
||||
queryLower.includes("갤럭시") ||
|
||||
queryLower.includes("아이폰") ||
|
||||
queryLower.includes("iphone");
|
||||
const isRegional = queryLower.includes("region") || queryLower.includes("지역");
|
||||
const isMonthly = queryLower.includes("month");
|
||||
const isSales = queryLower.includes("sales") || queryLower.includes("매출");
|
||||
const isUsers = queryLower.includes("users") || queryLower.includes("사용자");
|
||||
const isProducts = queryLower.includes("product") || queryLower.includes("상품");
|
||||
const isWeekly = queryLower.includes("week");
|
||||
|
||||
// console.log('Sample data type detection:', {
|
||||
// isComparison,
|
||||
// isRegional,
|
||||
// isWeekly,
|
||||
// isProducts,
|
||||
// isMonthly,
|
||||
// isSales,
|
||||
// isUsers,
|
||||
// querySnippet: query.substring(0, 200)
|
||||
// });
|
||||
|
||||
let columns: string[];
|
||||
let rows: Record<string, any>[];
|
||||
|
||||
// 더 구체적인 조건부터 먼저 체크 (순서 중요!)
|
||||
if (isComparison) {
|
||||
// console.log('✅ Using COMPARISON data');
|
||||
// 제품 비교 데이터 (다중 시리즈)
|
||||
columns = ["month", "galaxy_sales", "iphone_sales", "other_sales"];
|
||||
rows = [
|
||||
{ month: "2024-01", galaxy_sales: 450000, iphone_sales: 620000, other_sales: 130000 },
|
||||
{ month: "2024-02", galaxy_sales: 520000, iphone_sales: 680000, other_sales: 150000 },
|
||||
{ month: "2024-03", galaxy_sales: 480000, iphone_sales: 590000, other_sales: 110000 },
|
||||
{ month: "2024-04", galaxy_sales: 610000, iphone_sales: 650000, other_sales: 160000 },
|
||||
{ month: "2024-05", galaxy_sales: 720000, iphone_sales: 780000, other_sales: 180000 },
|
||||
{ month: "2024-06", galaxy_sales: 680000, iphone_sales: 690000, other_sales: 170000 },
|
||||
{ month: "2024-07", galaxy_sales: 750000, iphone_sales: 800000, other_sales: 170000 },
|
||||
{ month: "2024-08", galaxy_sales: 690000, iphone_sales: 720000, other_sales: 170000 },
|
||||
{ month: "2024-09", galaxy_sales: 730000, iphone_sales: 750000, other_sales: 170000 },
|
||||
{ month: "2024-10", galaxy_sales: 800000, iphone_sales: 810000, other_sales: 170000 },
|
||||
{ month: "2024-11", galaxy_sales: 870000, iphone_sales: 880000, other_sales: 170000 },
|
||||
{ month: "2024-12", galaxy_sales: 950000, iphone_sales: 990000, other_sales: 160000 },
|
||||
];
|
||||
// COMPARISON 데이터를 반환하고 함수 종료
|
||||
// console.log('COMPARISON data generated:', {
|
||||
// columns,
|
||||
// rowCount: rows.length,
|
||||
// sampleRow: rows[0],
|
||||
// allRows: rows,
|
||||
// fieldTypes: {
|
||||
// month: typeof rows[0].month,
|
||||
// galaxy_sales: typeof rows[0].galaxy_sales,
|
||||
// iphone_sales: typeof rows[0].iphone_sales,
|
||||
// other_sales: typeof rows[0].other_sales
|
||||
// },
|
||||
// firstFewRows: rows.slice(0, 3),
|
||||
// lastFewRows: rows.slice(-3)
|
||||
// });
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
totalRows: rows.length,
|
||||
executionTime: Math.floor(Math.random() * 200) + 100,
|
||||
};
|
||||
} else if (isRegional) {
|
||||
// console.log('✅ Using REGIONAL data');
|
||||
// 지역별 분기별 매출
|
||||
columns = ["지역", "Q1", "Q2", "Q3", "Q4"];
|
||||
rows = [
|
||||
{ 지역: "서울", Q1: 1200000, Q2: 1350000, Q3: 1420000, Q4: 1580000 },
|
||||
{ 지역: "경기", Q1: 980000, Q2: 1120000, Q3: 1180000, Q4: 1290000 },
|
||||
{ 지역: "부산", Q1: 650000, Q2: 720000, Q3: 780000, Q4: 850000 },
|
||||
{ 지역: "대구", Q1: 450000, Q2: 490000, Q3: 520000, Q4: 580000 },
|
||||
{ 지역: "인천", Q1: 520000, Q2: 580000, Q3: 620000, Q4: 690000 },
|
||||
{ 지역: "광주", Q1: 380000, Q2: 420000, Q3: 450000, Q4: 490000 },
|
||||
{ 지역: "대전", Q1: 410000, Q2: 460000, Q3: 490000, Q4: 530000 },
|
||||
];
|
||||
} else if (isWeekly && isUsers) {
|
||||
// console.log('✅ Using USERS data');
|
||||
// 사용자 가입 추이
|
||||
columns = ["week", "new_users"];
|
||||
rows = [
|
||||
{ week: "2024-W10", new_users: 23 },
|
||||
{ week: "2024-W11", new_users: 31 },
|
||||
{ week: "2024-W12", new_users: 28 },
|
||||
{ week: "2024-W13", new_users: 35 },
|
||||
{ week: "2024-W14", new_users: 42 },
|
||||
{ week: "2024-W15", new_users: 38 },
|
||||
{ week: "2024-W16", new_users: 45 },
|
||||
{ week: "2024-W17", new_users: 52 },
|
||||
{ week: "2024-W18", new_users: 48 },
|
||||
{ week: "2024-W19", new_users: 55 },
|
||||
{ week: "2024-W20", new_users: 61 },
|
||||
{ week: "2024-W21", new_users: 58 },
|
||||
];
|
||||
} else if (isProducts && !isComparison) {
|
||||
// console.log('✅ Using PRODUCTS data');
|
||||
// 상품별 판매량
|
||||
columns = ["product_name", "total_sold", "revenue"];
|
||||
rows = [
|
||||
{ product_name: "스마트폰", total_sold: 156, revenue: 234000000 },
|
||||
{ product_name: "노트북", total_sold: 89, revenue: 178000000 },
|
||||
{ product_name: "태블릿", total_sold: 134, revenue: 67000000 },
|
||||
{ product_name: "이어폰", total_sold: 267, revenue: 26700000 },
|
||||
{ product_name: "스마트워치", total_sold: 98, revenue: 49000000 },
|
||||
{ product_name: "키보드", total_sold: 78, revenue: 15600000 },
|
||||
{ product_name: "마우스", total_sold: 145, revenue: 8700000 },
|
||||
{ product_name: "모니터", total_sold: 67, revenue: 134000000 },
|
||||
{ product_name: "프린터", total_sold: 34, revenue: 17000000 },
|
||||
{ product_name: "웹캠", total_sold: 89, revenue: 8900000 },
|
||||
];
|
||||
} else if (isMonthly && isSales && !isComparison) {
|
||||
// console.log('✅ Using MONTHLY SALES data');
|
||||
// 월별 매출 데이터
|
||||
columns = ["month", "sales", "order_count"];
|
||||
rows = [
|
||||
{ month: "2024-01", sales: 1200000, order_count: 45 },
|
||||
{ month: "2024-02", sales: 1350000, order_count: 52 },
|
||||
{ month: "2024-03", sales: 1180000, order_count: 41 },
|
||||
{ month: "2024-04", sales: 1420000, order_count: 58 },
|
||||
{ month: "2024-05", sales: 1680000, order_count: 67 },
|
||||
{ month: "2024-06", sales: 1540000, order_count: 61 },
|
||||
{ month: "2024-07", sales: 1720000, order_count: 71 },
|
||||
{ month: "2024-08", sales: 1580000, order_count: 63 },
|
||||
{ month: "2024-09", sales: 1650000, order_count: 68 },
|
||||
{ month: "2024-10", sales: 1780000, order_count: 75 },
|
||||
{ month: "2024-11", sales: 1920000, order_count: 82 },
|
||||
{ month: "2024-12", sales: 2100000, order_count: 89 },
|
||||
];
|
||||
} else {
|
||||
// console.log('⚠️ Using DEFAULT data');
|
||||
// 기본 샘플 데이터
|
||||
columns = ["category", "value", "count"];
|
||||
rows = [
|
||||
{ category: "A", value: 100, count: 10 },
|
||||
{ category: "B", value: 150, count: 15 },
|
||||
{ category: "C", value: 120, count: 12 },
|
||||
{ category: "D", value: 180, count: 18 },
|
||||
{ category: "E", value: 90, count: 9 },
|
||||
{ category: "F", value: 200, count: 20 },
|
||||
{ category: "G", value: 110, count: 11 },
|
||||
{ category: "H", value: 160, count: 16 },
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
totalRows: rows.length,
|
||||
executionTime: Math.floor(Math.random() * 200) + 100, // 100-300ms
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ const needsDataSource = (subtype: ElementSubtype): boolean => {
|
|||
"chart",
|
||||
"map-summary-v2",
|
||||
"risk-alert-v2",
|
||||
"yard-management-3d",
|
||||
// "yard-management-3d", // 데이터 탭 불필요 (레이아웃 선택만 사용)
|
||||
"todo",
|
||||
"document",
|
||||
"work-history",
|
||||
|
|
@ -449,13 +449,30 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 레이아웃 선택 (야드 관리 3D 위젯 전용) */}
|
||||
{element.subtype === "yard-management-3d" && (
|
||||
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||
<Label htmlFor="layout-id" className="mb-2 block text-xs font-semibold">
|
||||
레이아웃 선택
|
||||
</Label>
|
||||
<p className="text-muted-foreground mb-2 text-xs">표시할 디지털 트윈 레이아웃을 선택하세요</p>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
<p>위젯 내부에서 레이아웃을 선택할 수 있습니다.</p>
|
||||
<p className="mt-1">편집 모드에서 레이아웃 목록을 확인하고 선택하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 자동 새로고침 설정 (지도 위젯 전용) */}
|
||||
{element.subtype === "map-summary-v2" && (
|
||||
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||
<Label htmlFor="refresh-interval" className="mb-2 block text-xs font-semibold">
|
||||
자동 새로고침 간격
|
||||
</Label>
|
||||
<Select value={refreshInterval.toString()} onValueChange={(value) => setRefreshInterval(parseInt(value))}>
|
||||
<Select
|
||||
value={refreshInterval.toString()}
|
||||
onValueChange={(value) => setRefreshInterval(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger id="refresh-interval" className="h-9 text-sm">
|
||||
<SelectValue placeholder="간격 선택" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -579,30 +596,16 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
refreshInterval={element.chartConfig?.refreshInterval || 5}
|
||||
markerType={element.chartConfig?.markerType || "circle"}
|
||||
onRefreshIntervalChange={(interval) => {
|
||||
setElement((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
chartConfig: {
|
||||
...prev.chartConfig,
|
||||
refreshInterval: interval,
|
||||
},
|
||||
}
|
||||
: prev
|
||||
);
|
||||
setChartConfig((prev) => ({
|
||||
...prev,
|
||||
refreshInterval: interval,
|
||||
}));
|
||||
}}
|
||||
onMarkerTypeChange={(type) => {
|
||||
setElement((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
chartConfig: {
|
||||
...prev.chartConfig,
|
||||
markerType: type,
|
||||
},
|
||||
}
|
||||
: prev
|
||||
);
|
||||
setChartConfig((prev) => ({
|
||||
...prev,
|
||||
markerType: type,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -619,20 +622,20 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
<Button variant="outline" onClick={onClose} className="h-9 flex-1 text-sm">
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApply}
|
||||
<Button
|
||||
onClick={handleApply}
|
||||
className="h-9 flex-1 text-sm"
|
||||
disabled={
|
||||
// 다중 데이터 소스 위젯: 데이터 소스가 있는데 endpoint가 비어있으면 비활성화
|
||||
// (데이터 소스가 없는 건 OK - 연결 해제하는 경우)
|
||||
(element?.subtype === "map-summary-v2" ||
|
||||
element?.subtype === "chart" ||
|
||||
element?.subtype === "list-v2" ||
|
||||
element?.subtype === "custom-metric-v2" ||
|
||||
element?.subtype === "risk-alert-v2") &&
|
||||
dataSources &&
|
||||
dataSources.length > 0 &&
|
||||
dataSources.some(ds => ds.type === "api" && !ds.endpoint)
|
||||
(element?.subtype === "map-summary-v2" ||
|
||||
element?.subtype === "chart" ||
|
||||
element?.subtype === "list-v2" ||
|
||||
element?.subtype === "custom-metric-v2" ||
|
||||
element?.subtype === "risk-alert-v2") &&
|
||||
dataSources &&
|
||||
dataSources.length > 0 &&
|
||||
dataSources.some((ds) => ds.type === "api" && !ds.endpoint)
|
||||
}
|
||||
>
|
||||
적용
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
const [sampleData, setSampleData] = useState<any[]>([]); // 샘플 데이터 (최대 3개)
|
||||
const [columnSearchTerm, setColumnSearchTerm] = useState(""); // 컬럼 검색어
|
||||
|
||||
console.log("🔧 MultiApiConfig - dataSource:", dataSource);
|
||||
|
||||
// 외부 API 커넥션 목록 로드
|
||||
useEffect(() => {
|
||||
const loadApiConnections = async () => {
|
||||
|
|
@ -51,14 +49,12 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
return;
|
||||
}
|
||||
|
||||
console.log("불러온 커넥션:", connection);
|
||||
|
||||
// base_url과 endpoint_path를 조합하여 전체 URL 생성
|
||||
const fullEndpoint = connection.endpoint_path
|
||||
? `${connection.base_url}${connection.endpoint_path}`
|
||||
: connection.base_url;
|
||||
|
||||
console.log("전체 엔드포인트:", fullEndpoint);
|
||||
|
||||
const updates: Partial<ChartDataSource> = {
|
||||
endpoint: fullEndpoint,
|
||||
|
|
@ -76,7 +72,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
value,
|
||||
});
|
||||
});
|
||||
console.log("기본 헤더 적용:", headers);
|
||||
}
|
||||
|
||||
// 인증 설정이 있으면 헤더 또는 쿼리 파라미터에 추가
|
||||
|
|
@ -91,7 +86,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
key: authConfig.keyName,
|
||||
value: authConfig.keyValue,
|
||||
});
|
||||
console.log("API Key 헤더 추가:", authConfig.keyName);
|
||||
} else if (authConfig.keyLocation === "query" && authConfig.keyName && authConfig.keyValue) {
|
||||
// UTIC API는 'key'를 사용하므로, 'apiKey'를 'key'로 변환
|
||||
const actualKeyName = authConfig.keyName === "apiKey" ? "key" : authConfig.keyName;
|
||||
|
|
@ -100,7 +94,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
key: actualKeyName,
|
||||
value: authConfig.keyValue,
|
||||
});
|
||||
console.log("API Key 쿼리 파라미터 추가:", actualKeyName, "(원본:", authConfig.keyName, ")");
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -111,7 +104,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
key: "Authorization",
|
||||
value: `Bearer ${authConfig.token}`,
|
||||
});
|
||||
console.log("Bearer Token 헤더 추가");
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -123,7 +115,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
key: "Authorization",
|
||||
value: `Basic ${credentials}`,
|
||||
});
|
||||
console.log("Basic Auth 헤더 추가");
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -134,7 +125,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
key: "Authorization",
|
||||
value: `Bearer ${authConfig.accessToken}`,
|
||||
});
|
||||
console.log("OAuth2 Token 헤더 추가");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -148,7 +138,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
updates.queryParams = queryParams;
|
||||
}
|
||||
|
||||
console.log("최종 업데이트:", updates);
|
||||
onChange(updates);
|
||||
};
|
||||
|
||||
|
|
@ -235,12 +224,12 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
|
||||
const result = await response.json();
|
||||
|
||||
console.log("🌐 [API 테스트 결과]", result.data);
|
||||
|
||||
if (result.success) {
|
||||
// 텍스트 데이터 파싱 함수 (MapTestWidgetV2와 동일)
|
||||
const parseTextData = (text: string): any[] => {
|
||||
try {
|
||||
console.log("🔍 텍스트 파싱 시작 (처음 500자):", text.substring(0, 500));
|
||||
|
||||
const lines = text.split('\n').filter(line => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed &&
|
||||
|
|
@ -249,8 +238,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
!trimmed.startsWith('---');
|
||||
});
|
||||
|
||||
console.log(`📝 유효한 라인: ${lines.length}개`);
|
||||
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
const result: any[] = [];
|
||||
|
|
@ -278,7 +265,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
}
|
||||
}
|
||||
|
||||
console.log("📊 파싱 결과:", result.length, "개");
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("❌ 텍스트 파싱 오류:", error);
|
||||
|
|
@ -291,10 +277,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
|
||||
// 텍스트 데이터 체크 (기상청 API 등)
|
||||
if (data && typeof data === 'object' && data.text && typeof data.text === 'string') {
|
||||
console.log("📄 텍스트 형식 데이터 감지, CSV 파싱 시도");
|
||||
const parsedData = parseTextData(data.text);
|
||||
if (parsedData.length > 0) {
|
||||
console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`);
|
||||
data = parsedData;
|
||||
}
|
||||
} else if (dataSource.jsonPath) {
|
||||
|
|
@ -306,6 +290,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
|
||||
const rows = Array.isArray(data) ? data : [data];
|
||||
|
||||
console.log("📊 [최종 파싱된 데이터]", rows);
|
||||
|
||||
// 컬럼 목록 및 타입 추출
|
||||
if (rows.length > 0) {
|
||||
const columns = Object.keys(rows[0]);
|
||||
|
|
@ -336,9 +322,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
|
||||
// 샘플 데이터 저장 (최대 3개)
|
||||
setSampleData(rows.slice(0, 3));
|
||||
|
||||
console.log("📊 발견된 컬럼:", columns);
|
||||
console.log("📊 컬럼 타입:", types);
|
||||
}
|
||||
|
||||
// 위도/경도 또는 coordinates 필드 또는 지역 코드 체크
|
||||
|
|
@ -422,7 +405,6 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
id={`endpoint-${dataSource.id}`}
|
||||
value={dataSource.endpoint || ""}
|
||||
onChange={(e) => {
|
||||
console.log("📝 API URL 변경:", e.target.value);
|
||||
onChange({ endpoint: e.target.value });
|
||||
}}
|
||||
placeholder="https://api.example.com/data"
|
||||
|
|
@ -546,6 +528,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
|
|||
<SelectContent>
|
||||
<SelectItem value="circle" className="text-xs">동그라미</SelectItem>
|
||||
<SelectItem value="arrow" className="text-xs">화살표</SelectItem>
|
||||
<SelectItem value="truck" className="text-xs">트럭</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -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,6 +340,9 @@ 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>
|
||||
|
|
|
|||
|
|
@ -163,7 +163,10 @@ export interface ChartDataSource {
|
|||
markerColor?: string; // 마커 색상 (예: "#ff0000")
|
||||
polygonColor?: string; // 폴리곤 색상 (예: "#0000ff")
|
||||
polygonOpacity?: number; // 폴리곤 투명도 (0.0 ~ 1.0, 기본값: 0.5)
|
||||
markerType?: string; // 마커 종류 (circle, arrow)
|
||||
markerType?: string; // 마커 종류 (circle, arrow, truck)
|
||||
minZoom?: number; // 최소 줌 레벨 (최대로 멀리 보기, 기본값: 2)
|
||||
maxZoom?: number; // 최대 줌 레벨 (최대로 가까이 보기, 기본값: 18)
|
||||
initialZoom?: number; // 초기 줌 레벨 (기본값: 13)
|
||||
|
||||
// 컬럼 매핑 (다중 데이터 소스 통합용)
|
||||
columnMapping?: Record<string, string>; // { 원본컬럼: 표시이름 } (예: { "name": "product" })
|
||||
|
|
@ -397,6 +400,7 @@ export interface CustomMetricConfig {
|
|||
unit?: string; // 표시 단위 (원, 건, % 등)
|
||||
color?: "indigo" | "green" | "blue" | "purple" | "orange" | "gray"; // 카드 색상
|
||||
decimals?: number; // 소수점 자릿수 (기본: 0)
|
||||
refreshInterval?: number; // 자동 새로고침 간격 (초, 0이면 비활성)
|
||||
|
||||
// 필터 조건
|
||||
filters?: Array<{
|
||||
|
|
|
|||
|
|
@ -89,68 +89,70 @@ export function CustomMetricSection({ queryResult, config, onConfigChange }: Cus
|
|||
</div>
|
||||
|
||||
{config.filters && config.filters.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{config.filters.map((filter, index) => (
|
||||
<div key={index} className="bg-muted/50 flex items-center gap-2 rounded-md border p-2">
|
||||
{/* 컬럼 선택 */}
|
||||
<Select value={filter.column} onValueChange={(value) => updateFilter(index, "column", value)}>
|
||||
<SelectTrigger className="h-8 flex-1 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{queryResult.columns.map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div key={index} className="bg-muted/50 space-y-2 rounded-md border p-3">
|
||||
{/* 첫 번째 줄: 컬럼 선택 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={filter.column} onValueChange={(value) => updateFilter(index, "column", value)}>
|
||||
<SelectTrigger className="h-9 flex-1 text-sm">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{queryResult.columns.map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-sm">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button onClick={() => removeFilter(index)} variant="ghost" size="icon" className="h-9 w-9 shrink-0">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
{/* 두 번째 줄: 연산자 선택 */}
|
||||
<Select value={filter.operator} onValueChange={(value) => updateFilter(index, "operator", value)}>
|
||||
<SelectTrigger className="h-8 w-[100px] text-xs">
|
||||
<SelectValue />
|
||||
<SelectTrigger className="h-9 w-full text-sm">
|
||||
<SelectValue placeholder="연산자 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="=" className="text-xs">
|
||||
<SelectItem value="=" className="text-sm">
|
||||
같음 (=)
|
||||
</SelectItem>
|
||||
<SelectItem value="!=" className="text-xs">
|
||||
<SelectItem value="!=" className="text-sm">
|
||||
다름 (≠)
|
||||
</SelectItem>
|
||||
<SelectItem value=">" className="text-xs">
|
||||
<SelectItem value=">" className="text-sm">
|
||||
큼 (>)
|
||||
</SelectItem>
|
||||
<SelectItem value="<" className="text-xs">
|
||||
<SelectItem value="<" className="text-sm">
|
||||
작음 (<)
|
||||
</SelectItem>
|
||||
<SelectItem value=">=" className="text-xs">
|
||||
<SelectItem value=">=" className="text-sm">
|
||||
크거나 같음 (≥)
|
||||
</SelectItem>
|
||||
<SelectItem value="<=" className="text-xs">
|
||||
<SelectItem value="<=" className="text-sm">
|
||||
작거나 같음 (≤)
|
||||
</SelectItem>
|
||||
<SelectItem value="contains" className="text-xs">
|
||||
<SelectItem value="contains" className="text-sm">
|
||||
포함
|
||||
</SelectItem>
|
||||
<SelectItem value="not_contains" className="text-xs">
|
||||
<SelectItem value="not_contains" className="text-sm">
|
||||
미포함
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 값 입력 */}
|
||||
{/* 세 번째 줄: 값 입력 */}
|
||||
<Input
|
||||
value={filter.value}
|
||||
onChange={(e) => updateFilter(index, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="h-8 flex-1 text-xs"
|
||||
placeholder="값을 입력하세요"
|
||||
className="h-9 w-full text-sm"
|
||||
/>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button onClick={() => removeFilter(index)} variant="ghost" size="icon" className="h-8 w-8">
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -231,6 +233,29 @@ export function CustomMetricSection({ queryResult, config, onConfigChange }: Cus
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 6. 자동 새로고침 간격 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">자동 새로고침</Label>
|
||||
<Select
|
||||
value={(config.refreshInterval ?? 30).toString()}
|
||||
onValueChange={(value) => onConfigChange({ refreshInterval: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue placeholder="간격 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0" className="text-xs">없음</SelectItem>
|
||||
<SelectItem value="10" className="text-xs">10초</SelectItem>
|
||||
<SelectItem value="30" className="text-xs">30초</SelectItem>
|
||||
<SelectItem value="60" className="text-xs">1분</SelectItem>
|
||||
<SelectItem value="300" className="text-xs">5분</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
통계 데이터를 자동으로 갱신하는 주기
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
{config.valueColumn && config.aggregation && (
|
||||
<div className="bg-muted/50 space-y-1 rounded-md border p-3">
|
||||
|
|
@ -241,7 +266,7 @@ export function CustomMetricSection({ queryResult, config, onConfigChange }: Cus
|
|||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">필터:</p>
|
||||
{config.filters.map((filter, idx) => (
|
||||
<p key={idx} className="text-muted-foreground text-xs">
|
||||
<p key={idx} className="text-muted-foreground text-xs" dir="ltr">
|
||||
· {filter.column} {filter.operator} "{filter.value}"
|
||||
</p>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
|
|||
{config.columns.length > 0 && (
|
||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||
<Label className="mb-2 block text-xs font-semibold">테이블 옵션</Label>
|
||||
<ListTableOptions config={config} onChange={onConfigChange} />
|
||||
<ListTableOptions config={config} onConfigChange={onConfigChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -20,32 +20,30 @@ interface MapConfigSectionProps {
|
|||
* - 자동 새로고침 간격 설정
|
||||
* - 마커 종류 선택
|
||||
*/
|
||||
export function MapConfigSection({
|
||||
queryResult,
|
||||
export function MapConfigSection({
|
||||
queryResult,
|
||||
refreshInterval = 5,
|
||||
markerType = "circle",
|
||||
onRefreshIntervalChange,
|
||||
onMarkerTypeChange
|
||||
onMarkerTypeChange,
|
||||
}: MapConfigSectionProps) {
|
||||
// 쿼리 결과가 없으면 안내 메시지 표시
|
||||
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||
<Label className="mb-2 block text-xs font-semibold">지도 설정</Label>
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
먼저 데이터 소스를 설정하고 쿼리를 테스트해주세요.
|
||||
</AlertDescription>
|
||||
<AlertDescription className="text-xs">먼저 데이터 소스를 설정하고 쿼리를 테스트해주세요.</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-background p-3 shadow-sm">
|
||||
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||
<Label className="mb-3 block text-xs font-semibold">지도 설정</Label>
|
||||
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 자동 새로고침 간격 */}
|
||||
<div className="space-y-1.5">
|
||||
|
|
@ -60,16 +58,24 @@ export function MapConfigSection({
|
|||
<SelectValue placeholder="새로고침 간격 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0" className="text-xs">없음</SelectItem>
|
||||
<SelectItem value="5" className="text-xs">5초</SelectItem>
|
||||
<SelectItem value="10" className="text-xs">10초</SelectItem>
|
||||
<SelectItem value="30" className="text-xs">30초</SelectItem>
|
||||
<SelectItem value="60" className="text-xs">1분</SelectItem>
|
||||
<SelectItem value="0" className="text-xs">
|
||||
없음
|
||||
</SelectItem>
|
||||
<SelectItem value="5" className="text-xs">
|
||||
5초
|
||||
</SelectItem>
|
||||
<SelectItem value="10" className="text-xs">
|
||||
10초
|
||||
</SelectItem>
|
||||
<SelectItem value="30" className="text-xs">
|
||||
30초
|
||||
</SelectItem>
|
||||
<SelectItem value="60" className="text-xs">
|
||||
1분
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
마커 데이터를 자동으로 갱신하는 주기를 설정합니다
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[10px]">마커 데이터를 자동으로 갱신하는 주기를 설정합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 마커 종류 선택 */}
|
||||
|
|
@ -77,24 +83,25 @@ export function MapConfigSection({
|
|||
<Label htmlFor="marker-type" className="text-xs">
|
||||
마커 종류
|
||||
</Label>
|
||||
<Select
|
||||
value={markerType}
|
||||
onValueChange={(value) => onMarkerTypeChange?.(value)}
|
||||
>
|
||||
<Select value={markerType} onValueChange={(value) => onMarkerTypeChange?.(value)}>
|
||||
<SelectTrigger id="marker-type" className="h-9 text-xs">
|
||||
<SelectValue placeholder="마커 종류 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="circle" className="text-xs">동그라미</SelectItem>
|
||||
<SelectItem value="arrow" className="text-xs">화살표</SelectItem>
|
||||
<SelectItem value="circle" className="text-xs">
|
||||
동그라미
|
||||
</SelectItem>
|
||||
<SelectItem value="arrow" className="text-xs">
|
||||
화살표
|
||||
</SelectItem>
|
||||
<SelectItem value="truck" className="text-xs">
|
||||
트럭
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
지도에 표시할 마커의 모양을 선택합니다
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[10px]">지도에 표시할 마커의 모양을 선택합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Plus, Check, Trash2 } from "lucide-react";
|
||||
import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal";
|
||||
import YardEditor from "./yard-3d/YardEditor";
|
||||
import Yard3DViewer from "./yard-3d/Yard3DViewer";
|
||||
import { yardLayoutApi } from "@/lib/api/yardLayoutApi";
|
||||
import DigitalTwinEditor from "./yard-3d/DigitalTwinEditor";
|
||||
import DigitalTwinViewer from "./yard-3d/DigitalTwinViewer";
|
||||
import { getLayouts, createLayout, deleteLayout } from "@/lib/api/digitalTwin";
|
||||
import type { YardManagementConfig } from "../types";
|
||||
|
||||
interface YardLayout {
|
||||
|
|
@ -40,9 +40,16 @@ export default function YardManagement3DWidget({
|
|||
const loadLayouts = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await yardLayoutApi.getAllLayouts();
|
||||
if (response.success) {
|
||||
setLayouts(response.data as YardLayout[]);
|
||||
const response = await getLayouts();
|
||||
if (response.success && response.data) {
|
||||
setLayouts(response.data.map((layout: any) => ({
|
||||
id: layout.id,
|
||||
name: layout.layout_name,
|
||||
description: layout.description || "",
|
||||
placement_count: layout.object_count || 0,
|
||||
created_at: layout.created_at,
|
||||
updated_at: layout.updated_at,
|
||||
})));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("야드 레이아웃 목록 조회 실패:", error);
|
||||
|
|
@ -81,11 +88,21 @@ export default function YardManagement3DWidget({
|
|||
// 새 레이아웃 생성
|
||||
const handleCreateLayout = async (name: string) => {
|
||||
try {
|
||||
const response = await yardLayoutApi.createLayout({ name });
|
||||
if (response.success) {
|
||||
const response = await createLayout({
|
||||
layoutName: name,
|
||||
description: "",
|
||||
});
|
||||
if (response.success && response.data) {
|
||||
await loadLayouts();
|
||||
setIsCreateModalOpen(false);
|
||||
setEditingLayout(response.data as YardLayout);
|
||||
setEditingLayout({
|
||||
id: response.data.id,
|
||||
name: response.data.layout_name,
|
||||
description: response.data.description || "",
|
||||
placement_count: 0,
|
||||
created_at: response.data.created_at,
|
||||
updated_at: response.data.updated_at,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("야드 레이아웃 생성 실패:", error);
|
||||
|
|
@ -110,7 +127,7 @@ export default function YardManagement3DWidget({
|
|||
if (!deleteLayoutId) return;
|
||||
|
||||
try {
|
||||
const response = await yardLayoutApi.deleteLayout(deleteLayoutId);
|
||||
const response = await deleteLayout(deleteLayoutId);
|
||||
if (response.success) {
|
||||
// 삭제된 레이아웃이 현재 선택된 레이아웃이면 설정 초기화
|
||||
if (config?.layoutId === deleteLayoutId && onConfigChange) {
|
||||
|
|
@ -125,11 +142,15 @@ export default function YardManagement3DWidget({
|
|||
}
|
||||
};
|
||||
|
||||
// 편집 모드: 편집 중인 경우 YardEditor 표시
|
||||
// 편집 모드: 편집 중인 경우 DigitalTwinEditor 표시
|
||||
if (isEditMode && editingLayout) {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<YardEditor layout={editingLayout} onBack={handleEditComplete} />
|
||||
<DigitalTwinEditor
|
||||
layoutId={editingLayout.id}
|
||||
layoutName={editingLayout.name}
|
||||
onBack={handleEditComplete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -269,10 +290,10 @@ export default function YardManagement3DWidget({
|
|||
);
|
||||
}
|
||||
|
||||
// 선택된 레이아웃의 3D 뷰어 표시
|
||||
// 선택된 레이아웃의 디지털 트윈 뷰어 표시
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<Yard3DViewer layoutId={config.layoutId} />
|
||||
<DigitalTwinViewer layoutId={config.layoutId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,538 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Loader2, Search, Filter, X } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { PlacedObject, MaterialData } from "@/types/digitalTwin";
|
||||
import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin";
|
||||
|
||||
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="bg-muted flex h-full items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
interface DigitalTwinViewerProps {
|
||||
layoutId: number;
|
||||
}
|
||||
|
||||
export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {
|
||||
const { toast } = useToast();
|
||||
const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]);
|
||||
const [selectedObject, setSelectedObject] = useState<PlacedObject | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [materials, setMaterials] = useState<MaterialData[]>([]);
|
||||
const [loadingMaterials, setLoadingMaterials] = useState(false);
|
||||
const [showInfoPanel, setShowInfoPanel] = useState(false);
|
||||
const [externalDbConnectionId, setExternalDbConnectionId] = useState<number | null>(null);
|
||||
const [layoutName, setLayoutName] = useState<string>("");
|
||||
const [hierarchyConfig, setHierarchyConfig] = useState<any>(null);
|
||||
|
||||
// 검색 및 필터
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filterType, setFilterType] = useState<string>("all");
|
||||
|
||||
// 레이아웃 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadLayout = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await getLayoutById(layoutId);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const { layout, objects } = response.data;
|
||||
|
||||
// 레이아웃 정보 저장 (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);
|
||||
}
|
||||
|
||||
// 객체 데이터 변환
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
setPlacedObjects(loadedObjects);
|
||||
} else {
|
||||
throw new Error(response.error || "레이아웃 조회 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("레이아웃 로드 실패:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다.";
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "오류",
|
||||
description: errorMessage,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadLayout();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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,
|
||||
});
|
||||
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));
|
||||
setMaterials(sortedMaterials);
|
||||
} else {
|
||||
setMaterials([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("자재 로드 실패:", error);
|
||||
setMaterials([]);
|
||||
} finally {
|
||||
setLoadingMaterials(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 객체 클릭
|
||||
const handleObjectClick = (objectId: number | null) => {
|
||||
if (objectId === null) {
|
||||
setSelectedObject(null);
|
||||
setShowInfoPanel(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const obj = placedObjects.find((o) => o.id === objectId);
|
||||
setSelectedObject(obj || null);
|
||||
|
||||
// Location을 클릭한 경우, 자재 정보 표시
|
||||
if (
|
||||
obj &&
|
||||
(obj.type === "location-bed" ||
|
||||
obj.type === "location-stp" ||
|
||||
obj.type === "location-temp" ||
|
||||
obj.type === "location-dest") &&
|
||||
obj.locaKey &&
|
||||
externalDbConnectionId
|
||||
) {
|
||||
setShowInfoPanel(true);
|
||||
loadMaterialsForLocation(obj.locaKey, externalDbConnectionId);
|
||||
} else {
|
||||
setShowInfoPanel(true);
|
||||
setMaterials([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 타입별 개수 계산 (useMemo로 최적화)
|
||||
const typeCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {
|
||||
all: placedObjects.length,
|
||||
area: 0,
|
||||
"location-bed": 0,
|
||||
"location-stp": 0,
|
||||
"location-temp": 0,
|
||||
"location-dest": 0,
|
||||
"crane-mobile": 0,
|
||||
rack: 0,
|
||||
};
|
||||
|
||||
placedObjects.forEach((obj) => {
|
||||
if (counts[obj.type] !== undefined) {
|
||||
counts[obj.type]++;
|
||||
}
|
||||
});
|
||||
|
||||
return counts;
|
||||
}, [placedObjects]);
|
||||
|
||||
// 필터링된 객체 목록 (useMemo로 최적화)
|
||||
const filteredObjects = useMemo(() => {
|
||||
return placedObjects.filter((obj) => {
|
||||
// 타입 필터
|
||||
if (filterType !== "all" && obj.type !== filterType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 검색 쿼리
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
obj.name.toLowerCase().includes(query) ||
|
||||
obj.areaKey?.toLowerCase().includes(query) ||
|
||||
obj.locaKey?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [placedObjects, filterType, searchQuery]);
|
||||
|
||||
// 객체 타입별 기본 색상 (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">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-full flex-col">
|
||||
{/* 상단 헤더 */}
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2>
|
||||
<p className="text-muted-foreground text-sm">읽기 전용 뷰</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 영역 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 좌측: 검색/필터 */}
|
||||
<div className="flex h-full w-64 flex-shrink-0 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" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="이름, Area, Location 검색..."
|
||||
className="h-10 pl-9 text-sm"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-1/2 right-1 h-7 w-7 -translate-y-1/2 p-0"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타입 필터 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-semibold">타입 필터</Label>
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger className="h-10 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 ({typeCounts.all})</SelectItem>
|
||||
<SelectItem value="area">Area ({typeCounts.area})</SelectItem>
|
||||
<SelectItem value="location-bed">베드(BED) ({typeCounts["location-bed"]})</SelectItem>
|
||||
<SelectItem value="location-stp">정차포인트(STP) ({typeCounts["location-stp"]})</SelectItem>
|
||||
<SelectItem value="location-temp">임시베드(TMP) ({typeCounts["location-temp"]})</SelectItem>
|
||||
<SelectItem value="location-dest">지정착지(DES) ({typeCounts["location-dest"]})</SelectItem>
|
||||
<SelectItem value="crane-mobile">크레인 ({typeCounts["crane-mobile"]})</SelectItem>
|
||||
<SelectItem value="rack">랙 ({typeCounts.rack})</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 필터 초기화 */}
|
||||
{(searchQuery || filterType !== "all") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 w-full text-sm"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setFilterType("all");
|
||||
}}
|
||||
>
|
||||
필터 초기화
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 객체 목록 */}
|
||||
<div className="flex-1 overflow-y-auto border-t p-4">
|
||||
<Label className="mb-2 block text-sm font-semibold">객체 목록 ({filteredObjects.length})</Label>
|
||||
{filteredObjects.length === 0 ? (
|
||||
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
|
||||
{searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredObjects.map((obj) => {
|
||||
// 타입별 레이블
|
||||
let typeLabel = obj.type;
|
||||
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||
else if (obj.type === "area") typeLabel = "Area";
|
||||
else if (obj.type === "rack") typeLabel = "랙";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{obj.name}</p>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: getObjectColor(obj.type) }}
|
||||
/>
|
||||
<span>{typeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 추가 정보 */}
|
||||
<div className="mt-2 space-y-1">
|
||||
{obj.areaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Area: <span className="font-medium">{obj.areaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.locaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Location: <span className="font-medium">{obj.locaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.materialCount !== undefined && obj.materialCount > 0 && (
|
||||
<p className="text-xs text-yellow-600">
|
||||
자재: <span className="font-semibold">{obj.materialCount}개</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 3D 캔버스 */}
|
||||
<div className="relative flex-1">
|
||||
{!isLoading && (
|
||||
<Yard3DCanvas
|
||||
placements={canvasPlacements}
|
||||
selectedPlacementId={selectedObject?.id || null}
|
||||
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
|
||||
focusOnPlacementId={null}
|
||||
onCollisionDetected={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 정보 패널 */}
|
||||
<div className="h-full w-[480px] flex-shrink-0 overflow-y-auto border-l">
|
||||
{selectedObject ? (
|
||||
<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>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div className="bg-muted space-y-3 rounded-lg p-3">
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">타입</Label>
|
||||
<p className="text-sm font-medium">{selectedObject.type}</p>
|
||||
</div>
|
||||
{selectedObject.areaKey && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">Area Key</Label>
|
||||
<p className="text-sm font-medium">{selectedObject.areaKey}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedObject.locaKey && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">Location Key</Label>
|
||||
<p className="text-sm font-medium">{selectedObject.locaKey}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedObject.materialCount !== undefined && selectedObject.materialCount > 0 && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">자재 개수</Label>
|
||||
<p className="text-sm font-medium">{selectedObject.materialCount}개</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 자재 목록 (Location인 경우) - 아코디언 */}
|
||||
{(selectedObject.type === "location-bed" ||
|
||||
selectedObject.type === "location-stp" ||
|
||||
selectedObject.type === "location-temp" ||
|
||||
selectedObject.type === "location-dest") && (
|
||||
<div className="mt-4">
|
||||
{loadingMaterials ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : materials.length === 0 ? (
|
||||
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-sm">
|
||||
{externalDbConnectionId ? "자재가 없습니다" : "외부 DB 연결이 설정되지 않았습니다"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,410 @@
|
|||
# 디지털 트윈 동적 계층 구조 마이그레이션 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
**기존 구조**: 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
|
||||
|
||||
|
|
@ -0,0 +1,559 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,290 @@
|
|||
// (이전 import 및 interface 동일, mobile-crane case만 교체)
|
||||
// 실제로는 기존 파일에서 mobile-crane 케이스만 교체
|
||||
|
||||
// mobile-crane 케이스를 다음으로 교체:
|
||||
|
||||
case "mobile-crane":
|
||||
// 이동식 크레인: 하부(트랙) + 회전대 + 캐빈 + 붐대 + 카운터웨이트 + 후크
|
||||
return (
|
||||
<group>
|
||||
{/* ========== 하부 크롤러 트랙 시스템 ========== */}
|
||||
{/* 좌측 트랙 메인 */}
|
||||
<Box args={[boxWidth * 0.28, boxHeight * 0.18, boxDepth * 0.98]} position={[-boxWidth * 0.31, -boxHeight * 0.41, 0]}>
|
||||
<meshStandardMaterial
|
||||
color="#1a1a1a"
|
||||
roughness={0.9}
|
||||
metalness={0.2}
|
||||
emissive={isSelected ? "#1a1a1a" : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.2 : 0}
|
||||
/>
|
||||
</Box>
|
||||
{/* 좌측 트랙 상부 롤러 */}
|
||||
<Box args={[boxWidth * 0.32, boxHeight * 0.08, boxDepth * 0.12]} position={[-boxWidth * 0.31, -boxHeight * 0.28, -boxDepth * 0.42]}>
|
||||
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.5} />
|
||||
</Box>
|
||||
<Box args={[boxWidth * 0.32, boxHeight * 0.08, boxDepth * 0.12]} position={[-boxWidth * 0.31, -boxHeight * 0.28, 0]}>
|
||||
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.5} />
|
||||
</Box>
|
||||
<Box args={[boxWidth * 0.32, boxHeight * 0.08, boxDepth * 0.12]} position={[-boxWidth * 0.31, -boxHeight * 0.28, boxDepth * 0.42]}>
|
||||
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.5} />
|
||||
</Box>
|
||||
|
||||
{/* 우측 트랙 메인 */}
|
||||
<Box args={[boxWidth * 0.28, boxHeight * 0.18, boxDepth * 0.98]} position={[boxWidth * 0.31, -boxHeight * 0.41, 0]}>
|
||||
<meshStandardMaterial
|
||||
color="#1a1a1a"
|
||||
roughness={0.9}
|
||||
metalness={0.2}
|
||||
emissive={isSelected ? "#1a1a1a" : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.2 : 0}
|
||||
/>
|
||||
</Box>
|
||||
{/* 우측 트랙 상부 롤러 */}
|
||||
<Box args={[boxWidth * 0.32, boxHeight * 0.08, boxDepth * 0.12]} position={[boxWidth * 0.31, -boxHeight * 0.28, -boxDepth * 0.42]}>
|
||||
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.5} />
|
||||
</Box>
|
||||
<Box args={[boxWidth * 0.32, boxHeight * 0.08, boxDepth * 0.12]} position={[boxWidth * 0.31, -boxHeight * 0.28, 0]}>
|
||||
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.5} />
|
||||
</Box>
|
||||
<Box args={[boxWidth * 0.32, boxHeight * 0.08, boxDepth * 0.12]} position={[boxWidth * 0.31, -boxHeight * 0.28, boxDepth * 0.42]}>
|
||||
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.5} />
|
||||
</Box>
|
||||
|
||||
{/* 트랙 연결 프레임 */}
|
||||
<Box args={[boxWidth * 0.12, boxHeight * 0.06, boxDepth * 0.98]} position={[0, -boxHeight * 0.39, 0]}>
|
||||
<meshStandardMaterial color="#374151" roughness={0.5} metalness={0.6} />
|
||||
</Box>
|
||||
|
||||
{/* ========== 회전 상부 구조 ========== */}
|
||||
{/* 메인 회전 플랫폼 */}
|
||||
<Box args={[boxWidth * 0.88, boxHeight * 0.15, boxDepth * 0.88]} position={[0, -boxHeight * 0.28, 0]}>
|
||||
<meshStandardMaterial
|
||||
color={placement.color}
|
||||
roughness={0.25}
|
||||
metalness={0.75}
|
||||
emissive={isSelected ? placement.color : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.9 : 0}
|
||||
/>
|
||||
</Box>
|
||||
{/* 회전 베어링 하우징 */}
|
||||
<Box args={[boxWidth * 0.45, boxHeight * 0.08, boxDepth * 0.45]} position={[0, -boxHeight * 0.35, 0]}>
|
||||
<meshStandardMaterial color="#4b5563" roughness={0.4} metalness={0.8} />
|
||||
</Box>
|
||||
|
||||
{/* ========== 엔진 및 유압 시스템 ========== */}
|
||||
{/* 엔진룸 메인 */}
|
||||
<Box args={[boxWidth * 0.65, boxHeight * 0.32, boxDepth * 0.35]} position={[0, -boxHeight * 0.12, boxDepth * 0.24]}>
|
||||
<meshStandardMaterial
|
||||
color={placement.color}
|
||||
roughness={0.35}
|
||||
metalness={0.65}
|
||||
emissive={isSelected ? placement.color : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
||||
/>
|
||||
</Box>
|
||||
{/* 유압 펌프 하우징 */}
|
||||
<Box args={[boxWidth * 0.25, boxHeight * 0.18, boxDepth * 0.25]} position={[-boxWidth * 0.25, -boxHeight * 0.15, boxDepth * 0.18]}>
|
||||
<meshStandardMaterial color="#dc2626" roughness={0.4} metalness={0.7} />
|
||||
</Box>
|
||||
<Box args={[boxWidth * 0.25, boxHeight * 0.18, boxDepth * 0.25]} position={[boxWidth * 0.25, -boxHeight * 0.15, boxDepth * 0.18]}>
|
||||
<meshStandardMaterial color="#dc2626" roughness={0.4} metalness={0.7} />
|
||||
</Box>
|
||||
{/* 배기 파이프 */}
|
||||
<Box args={[boxWidth * 0.08, boxHeight * 0.35, boxDepth * 0.08]} position={[boxWidth * 0.2, -boxHeight * 0.02, boxDepth * 0.35]}>
|
||||
<meshStandardMaterial color="#1f2937" roughness={0.3} metalness={0.8} />
|
||||
</Box>
|
||||
|
||||
{/* ========== 운전실 (캐빈) ========== */}
|
||||
{/* 캐빈 메인 바디 */}
|
||||
<Box args={[boxWidth * 0.38, boxHeight * 0.35, boxDepth * 0.38]} position={[0, -boxHeight * 0.08, -boxDepth * 0.18]}>
|
||||
<meshStandardMaterial
|
||||
color="#2d3748"
|
||||
roughness={0.15}
|
||||
metalness={0.85}
|
||||
emissive={isSelected ? "#3b82f6" : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.6 : 0}
|
||||
/>
|
||||
</Box>
|
||||
{/* 캐빈 창문 */}
|
||||
<Box args={[boxWidth * 0.35, boxHeight * 0.25, boxDepth * 0.02]} position={[0, -boxHeight * 0.05, -boxDepth * 0.37]}>
|
||||
<meshStandardMaterial
|
||||
color="#60a5fa"
|
||||
roughness={0.05}
|
||||
metalness={0.95}
|
||||
transparent
|
||||
opacity={0.6}
|
||||
emissive={isSelected ? "#60a5fa" : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
||||
/>
|
||||
</Box>
|
||||
{/* 캐빈 지붕 */}
|
||||
<Box args={[boxWidth * 0.4, boxHeight * 0.05, boxDepth * 0.4]} position={[0, boxHeight * 0.07, -boxDepth * 0.18]}>
|
||||
<meshStandardMaterial color="#374151" roughness={0.3} metalness={0.7} />
|
||||
</Box>
|
||||
|
||||
{/* ========== 붐대 시스템 ========== */}
|
||||
{/* 붐대 마운트 베이스 */}
|
||||
<Box args={[boxWidth * 0.25, boxHeight * 0.25, boxDepth * 0.25]} position={[0, -boxHeight * 0.02, 0]}>
|
||||
<meshStandardMaterial
|
||||
color="#4b5563"
|
||||
roughness={0.25}
|
||||
metalness={0.85}
|
||||
emissive={isSelected ? "#4b5563" : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.5 : 0}
|
||||
/>
|
||||
</Box>
|
||||
{/* 붐대 힌지 실린더 (유압) */}
|
||||
<Box
|
||||
args={[boxWidth * 0.12, boxHeight * 0.28, boxDepth * 0.12]}
|
||||
position={[0, boxHeight * 0.02, boxDepth * 0.08]}
|
||||
rotation={[Math.PI / 3, 0, 0]}
|
||||
>
|
||||
<meshStandardMaterial color="#dc2626" roughness={0.3} metalness={0.8} />
|
||||
</Box>
|
||||
|
||||
{/* 메인 붐대 하단 섹션 */}
|
||||
<Box
|
||||
args={[boxWidth * 0.18, boxHeight * 0.55, boxDepth * 0.18]}
|
||||
position={[0, boxHeight * 0.12, -boxDepth * 0.13]}
|
||||
rotation={[Math.PI / 4.2, 0, 0]}
|
||||
>
|
||||
<meshStandardMaterial
|
||||
color="#f59e0b"
|
||||
roughness={0.25}
|
||||
metalness={0.75}
|
||||
emissive={isSelected ? "#f59e0b" : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.75 : 0}
|
||||
/>
|
||||
</Box>
|
||||
{/* 붐대 상단 섹션 (텔레스코픽) */}
|
||||
<Box
|
||||
args={[boxWidth * 0.14, boxHeight * 0.45, boxDepth * 0.14]}
|
||||
position={[0, boxHeight * 0.32, -boxDepth * 0.32]}
|
||||
rotation={[Math.PI / 4.8, 0, 0]}
|
||||
>
|
||||
<meshStandardMaterial
|
||||
color="#fbbf24"
|
||||
roughness={0.25}
|
||||
metalness={0.75}
|
||||
emissive={isSelected ? "#fbbf24" : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.75 : 0}
|
||||
/>
|
||||
</Box>
|
||||
{/* 붐대 최상단 섹션 */}
|
||||
<Box
|
||||
args={[boxWidth * 0.1, boxHeight * 0.32, boxDepth * 0.1]}
|
||||
position={[0, boxHeight * 0.45, -boxDepth * 0.44]}
|
||||
rotation={[Math.PI / 5.5, 0, 0]}
|
||||
>
|
||||
<meshStandardMaterial
|
||||
color="#fbbf24"
|
||||
roughness={0.25}
|
||||
metalness={0.75}
|
||||
emissive={isSelected ? "#fbbf24" : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.75 : 0}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 붐대 트러스 구조 (디테일) */}
|
||||
{[-0.15, -0.05, 0.05, 0.15].map((offset, idx) => (
|
||||
<Box
|
||||
key={`truss-${idx}`}
|
||||
args={[boxWidth * 0.02, boxHeight * 0.5, boxDepth * 0.02]}
|
||||
position={[offset, boxHeight * 0.12, -boxDepth * 0.13]}
|
||||
rotation={[Math.PI / 4.2, 0, 0]}
|
||||
>
|
||||
<meshStandardMaterial color="#1f2937" roughness={0.4} metalness={0.7} />
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* ========== 카운터웨이트 시스템 ========== */}
|
||||
{/* 카운터웨이트 메인 블록 */}
|
||||
<Box args={[boxWidth * 0.55, boxHeight * 0.25, boxDepth * 0.28]} position={[0, -boxHeight * 0.03, boxDepth * 0.32]}>
|
||||
<meshStandardMaterial
|
||||
color="#52525b"
|
||||
roughness={0.7}
|
||||
metalness={0.4}
|
||||
emissive={isSelected ? "#52525b" : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
|
||||
/>
|
||||
</Box>
|
||||
{/* 카운터웨이트 추가 블록 (상단) */}
|
||||
<Box args={[boxWidth * 0.48, boxHeight * 0.18, boxDepth * 0.22]} position={[0, boxHeight * 0.08, boxDepth * 0.32]}>
|
||||
<meshStandardMaterial color="#3f3f46" roughness={0.7} metalness={0.4} />
|
||||
</Box>
|
||||
{/* 카운터웨이트 프레임 */}
|
||||
<Box args={[boxWidth * 0.58, boxHeight * 0.08, boxDepth * 0.32]} position={[0, -boxHeight * 0.16, boxDepth * 0.32]}>
|
||||
<meshStandardMaterial color="#1f2937" roughness={0.5} metalness={0.7} />
|
||||
</Box>
|
||||
|
||||
{/* ========== 후크 및 케이블 시스템 ========== */}
|
||||
{/* 붐대 끝단 풀리 */}
|
||||
<Box args={[boxWidth * 0.08, boxHeight * 0.08, boxDepth * 0.08]} position={[0, boxHeight * 0.52, -boxDepth * 0.52]}>
|
||||
<meshStandardMaterial color="#1f2937" roughness={0.3} metalness={0.8} />
|
||||
</Box>
|
||||
|
||||
{/* 메인 호이스트 케이블 */}
|
||||
<Box args={[boxWidth * 0.025, boxHeight * 0.42, boxDepth * 0.025]} position={[0, boxHeight * 0.28, -boxDepth * 0.52]}>
|
||||
<meshStandardMaterial
|
||||
color="#0f172a"
|
||||
roughness={0.3}
|
||||
metalness={0.7}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 후크 블록 상단 */}
|
||||
<Box args={[boxWidth * 0.12, boxHeight * 0.08, boxDepth * 0.12]} position={[0, boxHeight * 0.04, -boxDepth * 0.52]}>
|
||||
<meshStandardMaterial color="#1f2937" roughness={0.3} metalness={0.8} />
|
||||
</Box>
|
||||
{/* 후크 메인 (빨간색 안전색) */}
|
||||
<Box args={[boxWidth * 0.1, boxHeight * 0.12, boxDepth * 0.1]} position={[0, -boxHeight * 0.02, -boxDepth * 0.52]}>
|
||||
<meshStandardMaterial
|
||||
color="#dc2626"
|
||||
roughness={0.25}
|
||||
metalness={0.85}
|
||||
emissive={isSelected ? "#dc2626" : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.9 : 0}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 지브 지지 케이블 (좌측) */}
|
||||
<Box
|
||||
args={[boxWidth * 0.018, boxHeight * 0.38, boxDepth * 0.018]}
|
||||
position={[-boxWidth * 0.06, boxHeight * 0.28, -boxDepth * 0.08]}
|
||||
rotation={[Math.PI / 5.5, 0, 0]}
|
||||
>
|
||||
<meshStandardMaterial color="#0f172a" roughness={0.2} metalness={0.8} />
|
||||
</Box>
|
||||
{/* 지브 지지 케이블 (우측) */}
|
||||
<Box
|
||||
args={[boxWidth * 0.018, boxHeight * 0.38, boxDepth * 0.018]}
|
||||
position={[boxWidth * 0.06, boxHeight * 0.28, -boxDepth * 0.08]}
|
||||
rotation={[Math.PI / 5.5, 0, 0]}
|
||||
>
|
||||
<meshStandardMaterial color="#0f172a" roughness={0.2} metalness={0.8} />
|
||||
</Box>
|
||||
|
||||
{/* ========== 조명 및 안전 장치 ========== */}
|
||||
{/* 작업등 (전방) */}
|
||||
<Box args={[boxWidth * 0.06, boxHeight * 0.04, boxDepth * 0.04]} position={[0, boxHeight * 0.09, -boxDepth * 0.4]}>
|
||||
<meshStandardMaterial
|
||||
color="#fef08a"
|
||||
roughness={0.1}
|
||||
metalness={0.9}
|
||||
emissive="#fef08a"
|
||||
emissiveIntensity={isSelected ? glowIntensity * 1.2 : 0.3}
|
||||
/>
|
||||
</Box>
|
||||
{/* 경고등 (붐대 상단) */}
|
||||
<Box args={[boxWidth * 0.04, boxHeight * 0.04, boxDepth * 0.04]} position={[0, boxHeight * 0.54, -boxDepth * 0.5]}>
|
||||
<meshStandardMaterial
|
||||
color="#ef4444"
|
||||
roughness={0.1}
|
||||
metalness={0.9}
|
||||
emissive="#ef4444"
|
||||
emissiveIntensity={isSelected ? glowIntensity * 1.5 : 0.4}
|
||||
/>
|
||||
</Box>
|
||||
</group>
|
||||
);
|
||||
|
||||
|
|
@ -8,7 +8,7 @@ import dynamic from "next/dynamic";
|
|||
import { YardLayout, YardPlacement } from "./types";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { AlertCircle, CheckCircle, XCircle } from "lucide-react";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, ResizableDialogDescription } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* 공간적 종속성 검증 유틸리티
|
||||
*
|
||||
* 하위 영역이 상위 영역 내부에 배치되는지 검증
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -15,6 +15,8 @@ import { screenApi } from "@/lib/api/screen";
|
|||
import { ComponentData } from "@/types/screen";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
|
||||
|
||||
interface ScreenModalState {
|
||||
isOpen: boolean;
|
||||
|
|
@ -29,7 +31,7 @@ interface ScreenModalProps {
|
|||
}
|
||||
|
||||
export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||
const { userId } = useAuth();
|
||||
const { userId, userName, user } = useAuth();
|
||||
|
||||
const [modalState, setModalState] = useState<ScreenModalState>({
|
||||
isOpen: false,
|
||||
|
|
@ -54,11 +56,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
// 폼 데이터 상태 추가
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
|
||||
|
||||
// 연속 등록 모드 상태 (localStorage에 저장하여 리렌더링에 영향받지 않도록)
|
||||
const continuousModeRef = useRef(false);
|
||||
const [, setForceUpdate] = useState(0); // 강제 리렌더링용 (값은 사용하지 않음)
|
||||
|
||||
|
||||
// localStorage에서 연속 모드 상태 복원
|
||||
useEffect(() => {
|
||||
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
||||
|
|
@ -119,7 +121,19 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 전역 모달 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleOpenModal = (event: CustomEvent) => {
|
||||
const { screenId, title, description, size } = event.detail;
|
||||
const { screenId, title, description, size, urlParams } = event.detail;
|
||||
|
||||
// 🆕 URL 파라미터가 있으면 현재 URL에 추가
|
||||
if (urlParams && typeof window !== "undefined") {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
Object.entries(urlParams).forEach(([key, value]) => {
|
||||
currentUrl.searchParams.set(key, String(value));
|
||||
});
|
||||
// pushState로 URL 변경 (페이지 새로고침 없이)
|
||||
window.history.pushState({}, "", currentUrl.toString());
|
||||
console.log("✅ URL 파라미터 추가:", urlParams);
|
||||
}
|
||||
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
screenId,
|
||||
|
|
@ -130,6 +144,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
// 🆕 URL 파라미터 제거
|
||||
if (typeof window !== "undefined") {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
// dataSourceId 파라미터 제거
|
||||
currentUrl.searchParams.delete("dataSourceId");
|
||||
window.history.pushState({}, "", currentUrl.toString());
|
||||
console.log("🧹 URL 파라미터 제거");
|
||||
}
|
||||
|
||||
setModalState({
|
||||
isOpen: false,
|
||||
screenId: null,
|
||||
|
|
@ -150,14 +173,14 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// console.log("💾 저장 성공 이벤트 수신");
|
||||
// console.log("📌 현재 연속 모드 상태 (ref):", isContinuousMode);
|
||||
// console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode"));
|
||||
|
||||
|
||||
if (isContinuousMode) {
|
||||
// 연속 모드: 폼만 초기화하고 모달은 유지
|
||||
// console.log("✅ 연속 모드 활성화 - 폼만 초기화");
|
||||
|
||||
|
||||
// 폼만 초기화 (연속 모드 상태는 localStorage에 저장되어 있으므로 유지됨)
|
||||
setFormData({});
|
||||
|
||||
|
||||
toast.success("저장되었습니다. 계속 입력하세요.");
|
||||
} else {
|
||||
// 일반 모드: 모달 닫기
|
||||
|
|
@ -198,13 +221,139 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
console.log("API 응답:", { screenInfo, layoutData });
|
||||
|
||||
// 🆕 URL 파라미터 확인 (수정 모드)
|
||||
if (typeof window !== "undefined") {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const mode = urlParams.get("mode");
|
||||
const editId = urlParams.get("editId");
|
||||
const tableName = urlParams.get("tableName") || screenInfo.tableName;
|
||||
const groupByColumnsParam = urlParams.get("groupByColumns");
|
||||
|
||||
console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam });
|
||||
|
||||
// 수정 모드이고 editId가 있으면 해당 레코드 조회
|
||||
if (mode === "edit" && editId && tableName) {
|
||||
try {
|
||||
console.log("🔍 수정 데이터 조회 시작:", { tableName, editId, groupByColumnsParam });
|
||||
|
||||
const { dataApi } = await import("@/lib/api/data");
|
||||
|
||||
// groupByColumns 파싱
|
||||
let groupByColumns: string[] = [];
|
||||
if (groupByColumnsParam) {
|
||||
try {
|
||||
groupByColumns = JSON.parse(groupByColumnsParam);
|
||||
console.log("✅ [ScreenModal] groupByColumns 파싱 성공:", groupByColumns);
|
||||
} catch (e) {
|
||||
console.warn("groupByColumns 파싱 실패:", e);
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ [ScreenModal] groupByColumnsParam이 없습니다!");
|
||||
}
|
||||
|
||||
console.log("🚀 [ScreenModal] API 호출 직전:", {
|
||||
tableName,
|
||||
editId,
|
||||
enableEntityJoin: true,
|
||||
groupByColumns,
|
||||
groupByColumnsLength: groupByColumns.length,
|
||||
});
|
||||
|
||||
// 🆕 apiClient를 named import로 가져오기
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const params: any = {
|
||||
enableEntityJoin: true, // 엔티티 조인 활성화 (모든 엔티티 컬럼 자동 포함)
|
||||
};
|
||||
if (groupByColumns.length > 0) {
|
||||
params.groupByColumns = JSON.stringify(groupByColumns);
|
||||
console.log("✅ [ScreenModal] groupByColumns를 params에 추가:", params.groupByColumns);
|
||||
}
|
||||
|
||||
console.log("📡 [ScreenModal] 실제 API 요청:", {
|
||||
url: `/data/${tableName}/${editId}`,
|
||||
params,
|
||||
});
|
||||
|
||||
const apiResponse = await apiClient.get(`/data/${tableName}/${editId}`, { params });
|
||||
const response = apiResponse.data;
|
||||
|
||||
console.log("📩 [ScreenModal] API 응답 받음:", {
|
||||
success: response.success,
|
||||
hasData: !!response.data,
|
||||
dataType: response.data ? (Array.isArray(response.data) ? "배열" : "객체") : "없음",
|
||||
dataLength: Array.isArray(response.data) ? response.data.length : 1,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 배열인 경우 (그룹핑) vs 단일 객체
|
||||
const isArray = Array.isArray(response.data);
|
||||
|
||||
if (isArray) {
|
||||
console.log(`✅ 수정 데이터 로드 완료 (그룹 레코드: ${response.data.length}개)`);
|
||||
console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2));
|
||||
} else {
|
||||
console.log("✅ 수정 데이터 로드 완료 (필드 수:", Object.keys(response.data).length, ")");
|
||||
console.log("📊 모든 필드 키:", Object.keys(response.data));
|
||||
console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2));
|
||||
}
|
||||
|
||||
// 🔧 날짜 필드 정규화 (타임존 제거)
|
||||
const normalizeDates = (data: any): any => {
|
||||
if (Array.isArray(data)) {
|
||||
return data.map(normalizeDates);
|
||||
}
|
||||
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const normalized: any = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
// ISO 날짜 형식 감지: YYYY-MM-DD만 추출
|
||||
const before = value;
|
||||
const after = value.split('T')[0];
|
||||
console.log(`🔧 [날짜 정규화] ${key}: ${before} → ${after}`);
|
||||
normalized[key] = after;
|
||||
} else {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
console.log("📥 [ScreenModal] API 응답 원본:", JSON.stringify(response.data, null, 2));
|
||||
const normalizedData = normalizeDates(response.data);
|
||||
console.log("📥 [ScreenModal] 정규화 후:", JSON.stringify(normalizedData, null, 2));
|
||||
|
||||
// 🔧 배열 데이터는 formData로 설정하지 않음 (SelectedItemsDetailInput만 사용)
|
||||
if (Array.isArray(normalizedData)) {
|
||||
console.log("⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.");
|
||||
setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용
|
||||
} else {
|
||||
setFormData(normalizedData);
|
||||
}
|
||||
|
||||
// setFormData 직후 확인
|
||||
console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)");
|
||||
} else {
|
||||
console.error("❌ 수정 데이터 로드 실패:", response.error);
|
||||
toast.error("데이터를 불러올 수 없습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 수정 데이터 조회 오류:", error);
|
||||
toast.error("데이터를 불러오는 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// screenApi는 직접 데이터를 반환하므로 .success 체크 불필요
|
||||
if (screenInfo && layoutData) {
|
||||
const components = layoutData.components || [];
|
||||
|
||||
// 화면 관리에서 설정한 해상도 사용 (우선순위)
|
||||
const screenResolution = (layoutData as any).screenResolution || (screenInfo as any).screenResolution;
|
||||
|
||||
|
||||
let dimensions;
|
||||
if (screenResolution && screenResolution.width && screenResolution.height) {
|
||||
// 화면 관리에서 설정한 해상도 사용
|
||||
|
|
@ -220,7 +369,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
dimensions = calculateScreenDimensions(components);
|
||||
console.log("⚠️ 자동 계산된 크기 사용:", dimensions);
|
||||
}
|
||||
|
||||
|
||||
setScreenDimensions(dimensions);
|
||||
|
||||
setScreenData({
|
||||
|
|
@ -245,6 +394,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// 🔧 URL 파라미터 제거 (mode, editId, tableName 등)
|
||||
if (typeof window !== "undefined") {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.delete("mode");
|
||||
currentUrl.searchParams.delete("editId");
|
||||
currentUrl.searchParams.delete("tableName");
|
||||
currentUrl.searchParams.delete("groupByColumns");
|
||||
window.history.pushState({}, "", currentUrl.toString());
|
||||
console.log("🧹 [ScreenModal] URL 파라미터 제거 (모달 닫힘)");
|
||||
}
|
||||
|
||||
setModalState({
|
||||
isOpen: false,
|
||||
screenId: null,
|
||||
|
|
@ -280,17 +440,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
};
|
||||
|
||||
const modalStyle = getModalStyle();
|
||||
|
||||
|
||||
// 안정적인 modalId를 상태로 저장 (모달이 닫혀도 유지)
|
||||
const [persistedModalId, setPersistedModalId] = useState<string | undefined>(undefined);
|
||||
|
||||
|
||||
// modalId 생성 및 업데이트
|
||||
useEffect(() => {
|
||||
// 모달이 열려있고 screenId가 있을 때만 업데이트
|
||||
if (!modalState.isOpen) return;
|
||||
|
||||
|
||||
let newModalId: string | undefined;
|
||||
|
||||
|
||||
// 1순위: screenId (가장 안정적)
|
||||
if (modalState.screenId) {
|
||||
newModalId = `screen-modal-${modalState.screenId}`;
|
||||
|
|
@ -328,11 +488,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// result: newModalId,
|
||||
// });
|
||||
}
|
||||
|
||||
|
||||
if (newModalId) {
|
||||
setPersistedModalId(newModalId);
|
||||
}
|
||||
}, [modalState.isOpen, modalState.screenId, modalState.title, screenData?.screenInfo?.tableName, screenData?.screenInfo?.screenName]);
|
||||
}, [
|
||||
modalState.isOpen,
|
||||
modalState.screenId,
|
||||
modalState.title,
|
||||
screenData?.screenInfo?.tableName,
|
||||
screenData?.screenInfo?.screenName,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
|
|
@ -373,55 +539,62 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</div>
|
||||
) : screenData ? (
|
||||
<div
|
||||
className="relative bg-white mx-auto"
|
||||
style={{
|
||||
width: `${screenDimensions?.width || 800}px`,
|
||||
height: `${screenDimensions?.height || 600}px`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
>
|
||||
{screenData.components.map((component) => {
|
||||
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
<TableOptionsProvider>
|
||||
<div
|
||||
className="relative mx-auto bg-white"
|
||||
style={{
|
||||
width: `${screenDimensions?.width || 800}px`,
|
||||
height: `${screenDimensions?.height || 600}px`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
>
|
||||
{screenData.components.map((component) => {
|
||||
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
|
||||
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
|
||||
const adjustedComponent = (offsetX === 0 && offsetY === 0) ? component : {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
|
||||
},
|
||||
};
|
||||
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
|
||||
const adjustedComponent =
|
||||
offsetX === 0 && offsetY === 0
|
||||
? component
|
||||
: {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
component={adjustedComponent}
|
||||
allComponents={screenData.components}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
// console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`);
|
||||
// console.log("📋 현재 formData:", formData);
|
||||
setFormData((prev) => {
|
||||
const newFormData = {
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
component={adjustedComponent}
|
||||
allComponents={screenData.components}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
// console.log("📝 ScreenModal 업데이트된 formData:", newFormData);
|
||||
return newFormData;
|
||||
});
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}));
|
||||
}}
|
||||
onRefresh={() => {
|
||||
// 부모 화면의 테이블 새로고침 이벤트 발송
|
||||
console.log("🔄 모달에서 부모 화면 테이블 새로고침 이벤트 발송");
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TableOptionsProvider>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">화면 데이터가 없습니다.</p>
|
||||
|
|
@ -443,10 +616,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// console.log("🔄 연속 모드 변경:", isChecked);
|
||||
}}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="continuous-mode"
|
||||
className="text-sm font-normal cursor-pointer select-none"
|
||||
>
|
||||
<Label htmlFor="continuous-mode" className="cursor-pointer text-sm font-normal select-none">
|
||||
저장 후 계속 입력 (연속 등록 모드)
|
||||
</Label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -70,23 +70,22 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
const [value, setValue] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
|
||||
|
||||
const config = element?.customMetricConfig;
|
||||
|
||||
console.log("📊 [CustomMetricTestWidget] 렌더링:", {
|
||||
element,
|
||||
config,
|
||||
dataSource: element?.dataSource,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
// 자동 새로고침 (설정된 간격마다, 0이면 비활성)
|
||||
const refreshInterval = config?.refreshInterval ?? 30; // 기본값: 30초
|
||||
|
||||
if (refreshInterval > 0) {
|
||||
const interval = setInterval(loadData, refreshInterval * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [element]);
|
||||
}, [element, config?.refreshInterval]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
|
|
@ -132,6 +131,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
if (config?.valueColumn && config?.aggregation) {
|
||||
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
|
||||
setValue(calculatedValue);
|
||||
setLastUpdateTime(new Date()); // 업데이트 시간 기록
|
||||
} else {
|
||||
setValue(0);
|
||||
}
|
||||
|
|
@ -192,6 +192,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
if (config?.valueColumn && config?.aggregation) {
|
||||
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
|
||||
setValue(calculatedValue);
|
||||
setLastUpdateTime(new Date()); // 업데이트 시간 기록
|
||||
} else {
|
||||
setValue(0);
|
||||
}
|
||||
|
|
@ -200,7 +201,6 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("데이터 로드 실패:", err);
|
||||
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -283,6 +283,16 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
|
||||
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
|
||||
</div>
|
||||
|
||||
{/* 마지막 업데이트 시간 */}
|
||||
{lastUpdateTime && (
|
||||
<div className="text-muted-foreground mt-3 text-[10px]">
|
||||
{lastUpdateTime.toLocaleTimeString("ko-KR")}
|
||||
{config?.refreshInterval && config.refreshInterval > 0 && (
|
||||
<span className="ml-1">• {config.refreshInterval}초마다 갱신</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,17 +70,22 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
const [value, setValue] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
|
||||
|
||||
const config = element?.customMetricConfig;
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
// 자동 새로고침 (설정된 간격마다, 0이면 비활성)
|
||||
const refreshInterval = config?.refreshInterval ?? 30; // 기본값: 30초
|
||||
|
||||
if (refreshInterval > 0) {
|
||||
const interval = setInterval(loadData, refreshInterval * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [element]);
|
||||
}, [element, config?.refreshInterval]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
|
|
@ -198,15 +203,16 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLastUpdateTime(new Date());
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-background">
|
||||
<div className="bg-background flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">데이터 로딩 중...</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -214,12 +220,12 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-background p-4">
|
||||
<div className="bg-background flex h-full items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-destructive">⚠️ {error}</p>
|
||||
<p className="text-destructive text-sm">⚠️ {error}</p>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-2 rounded bg-destructive/10 px-3 py-1 text-xs text-destructive hover:bg-destructive/20"
|
||||
className="bg-destructive/10 text-destructive hover:bg-destructive/20 mt-2 rounded px-3 py-1 text-xs"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
|
|
@ -238,10 +244,10 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
// 설정이 없으면 안내 화면
|
||||
if (!hasDataSource || !hasConfig) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-background p-4">
|
||||
<div className="bg-background flex h-full items-center justify-center p-4">
|
||||
<div className="max-w-xs space-y-2 text-center">
|
||||
<h3 className="text-sm font-bold text-foreground">통계 카드</h3>
|
||||
<div className="space-y-1.5 text-xs text-foreground">
|
||||
<h3 className="text-foreground text-sm font-bold">통계 카드</h3>
|
||||
<div className="text-foreground space-y-1.5 text-xs">
|
||||
<p className="font-medium">📊 단일 통계 위젯</p>
|
||||
<ul className="space-y-0.5 text-left">
|
||||
<li>• 데이터 소스에서 쿼리를 실행합니다</li>
|
||||
|
|
@ -250,7 +256,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
<li>• COUNT, SUM, AVG, MIN, MAX 지원</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg bg-primary/10 p-2 text-[10px] text-primary">
|
||||
<div className="bg-primary/10 text-primary mt-2 rounded-lg p-2 text-[10px]">
|
||||
<p className="font-medium">⚙️ 설정 방법</p>
|
||||
<p>1. 데이터 탭에서 쿼리 실행</p>
|
||||
<p>2. 필터 조건 추가 (선택사항)</p>
|
||||
|
|
@ -268,7 +274,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
|
||||
// 통계 카드 렌더링
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center bg-card p-6 text-center">
|
||||
<div className="bg-card flex h-full w-full flex-col items-center justify-center p-6 text-center">
|
||||
{/* 제목 */}
|
||||
<div className="text-muted-foreground mb-2 text-sm font-medium">{config?.title || "통계"}</div>
|
||||
|
||||
|
|
@ -277,6 +283,16 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
|
||||
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
|
||||
</div>
|
||||
|
||||
{/* 마지막 업데이트 시간 */}
|
||||
{lastUpdateTime && (
|
||||
<div className="text-muted-foreground mt-3 text-[10px]">
|
||||
{lastUpdateTime.toLocaleTimeString("ko-KR")}
|
||||
{config?.refreshInterval && config.refreshInterval > 0 && (
|
||||
<span className="ml-1">• {config.refreshInterval}초마다 갱신</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -10,6 +10,20 @@ import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
|||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Popup 말풍선 꼬리 제거 스타일
|
||||
if (typeof document !== "undefined") {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
.leaflet-popup-tip-container {
|
||||
display: none !important;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
||||
if (typeof window !== "undefined") {
|
||||
import("leaflet").then((L) => {
|
||||
|
|
@ -66,7 +80,7 @@ interface PolygonData {
|
|||
|
||||
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
||||
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
||||
const [prevMarkers, setPrevMarkers] = useState<MarkerData[]>([]); // 이전 마커 위치 저장
|
||||
const prevMarkersRef = useRef<MarkerData[]>([]); // 이전 마커 위치 저장 (useRef 사용)
|
||||
const [polygons, setPolygons] = useState<PolygonData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -96,11 +110,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
// 다중 데이터 소스 로딩
|
||||
const loadMultipleDataSources = useCallback(async () => {
|
||||
if (!dataSources || dataSources.length === 0) {
|
||||
// // console.log("⚠️ 데이터 소스가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// // console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
|
@ -109,8 +120,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const results = await Promise.allSettled(
|
||||
dataSources.map(async (source) => {
|
||||
try {
|
||||
// // console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`);
|
||||
|
||||
if (source.type === "api") {
|
||||
return await loadRestApiData(source);
|
||||
} else if (source.type === "database") {
|
||||
|
|
@ -119,7 +128,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
|
||||
return { markers: [], polygons: [] };
|
||||
} catch (err: any) {
|
||||
console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err);
|
||||
return { markers: [], polygons: [] };
|
||||
}
|
||||
}),
|
||||
|
|
@ -130,35 +138,24 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const allPolygons: PolygonData[] = [];
|
||||
|
||||
results.forEach((result, index) => {
|
||||
// // console.log(`🔍 결과 ${index}:`, result);
|
||||
|
||||
if (result.status === "fulfilled" && result.value) {
|
||||
const value = result.value as { markers: MarkerData[]; polygons: PolygonData[] };
|
||||
// // console.log(`✅ 데이터 소스 ${index} 성공:`, value);
|
||||
|
||||
// 마커 병합
|
||||
if (value.markers && Array.isArray(value.markers)) {
|
||||
// // console.log(` → 마커 ${value.markers.length}개 추가`);
|
||||
allMarkers.push(...value.markers);
|
||||
}
|
||||
|
||||
// 폴리곤 병합
|
||||
if (value.polygons && Array.isArray(value.polygons)) {
|
||||
// // console.log(` → 폴리곤 ${value.polygons.length}개 추가`);
|
||||
allPolygons.push(...value.polygons);
|
||||
}
|
||||
} else if (result.status === "rejected") {
|
||||
console.error(`❌ 데이터 소스 ${index} 실패:`, result.reason);
|
||||
}
|
||||
});
|
||||
|
||||
// // console.log(`✅ 총 ${allMarkers.length}개의 마커, ${allPolygons.length}개의 폴리곤 로딩 완료`);
|
||||
// // console.log("📍 최종 마커 데이터:", allMarkers);
|
||||
// // console.log("🔷 최종 폴리곤 데이터:", allPolygons);
|
||||
|
||||
// 이전 마커 위치와 비교하여 진행 방향 계산
|
||||
const markersWithHeading = allMarkers.map((marker) => {
|
||||
const prevMarker = prevMarkers.find((pm) => pm.id === marker.id);
|
||||
const prevMarker = prevMarkersRef.current.find((pm) => pm.id === marker.id);
|
||||
|
||||
if (prevMarker && (prevMarker.lat !== marker.lat || prevMarker.lng !== marker.lng)) {
|
||||
// 이동했으면 방향 계산
|
||||
|
|
@ -178,21 +175,19 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
};
|
||||
});
|
||||
|
||||
setPrevMarkers(markersWithHeading); // 다음 비교를 위해 현재 위치 저장
|
||||
prevMarkersRef.current = markersWithHeading; // 다음 비교를 위해 현재 위치 저장 (useRef 사용)
|
||||
setMarkers(markersWithHeading);
|
||||
setPolygons(allPolygons);
|
||||
setLastRefreshTime(new Date());
|
||||
} catch (err: any) {
|
||||
console.error("❌ 데이터 로딩 중 오류:", err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dataSources, prevMarkers, calculateHeading]);
|
||||
}, [dataSources, calculateHeading]); // prevMarkersRef는 의존성에 포함하지 않음 (useRef이므로)
|
||||
|
||||
// 수동 새로고침 핸들러
|
||||
const handleManualRefresh = useCallback(() => {
|
||||
// // console.log("🔄 수동 새로고침 버튼 클릭");
|
||||
loadMultipleDataSources();
|
||||
}, [loadMultipleDataSources]);
|
||||
|
||||
|
|
@ -200,8 +195,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const loadRestApiData = async (
|
||||
source: ChartDataSource,
|
||||
): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => {
|
||||
// // console.log(`🌐 REST API 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType);
|
||||
|
||||
if (!source.endpoint) {
|
||||
throw new Error("API endpoint가 없습니다.");
|
||||
}
|
||||
|
|
@ -256,13 +249,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
|
||||
// 텍스트 형식 데이터 체크 (기상청 API 등)
|
||||
if (data && typeof data === "object" && data.text && typeof data.text === "string") {
|
||||
// // console.log("📄 텍스트 형식 데이터 감지, CSV 파싱 시도");
|
||||
const parsedData = parseTextData(data.text);
|
||||
if (parsedData.length > 0) {
|
||||
// // console.log(`✅ CSV 파싱 성공: ${parsedData.length}개 행`);
|
||||
// 컬럼 매핑 적용
|
||||
const mappedData = applyColumnMapping(parsedData, source.columnMapping);
|
||||
return convertToMapData(mappedData, source.name || source.id || "API", source.mapDisplayType, source);
|
||||
const result = convertToMapData(mappedData, source.name || source.id || "API", source.mapDisplayType, source);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -280,15 +272,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const mappedRows = applyColumnMapping(rows, source.columnMapping);
|
||||
|
||||
// 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달)
|
||||
return convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source);
|
||||
const finalResult = convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source);
|
||||
return finalResult;
|
||||
};
|
||||
|
||||
// Database 데이터 로딩
|
||||
const loadDatabaseData = async (
|
||||
source: ChartDataSource,
|
||||
): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => {
|
||||
// // console.log(`💾 Database 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType);
|
||||
|
||||
if (!source.query) {
|
||||
throw new Error("SQL 쿼리가 없습니다.");
|
||||
}
|
||||
|
|
@ -330,7 +321,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
// XML 데이터 파싱 (UTIC API 등)
|
||||
const parseXmlData = (xmlText: string): any[] => {
|
||||
try {
|
||||
// // console.log(" 📄 XML 파싱 시작");
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
|
||||
|
||||
|
|
@ -349,10 +339,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
results.push(obj);
|
||||
}
|
||||
|
||||
// // console.log(` ✅ XML 파싱 완료: ${results.length}개 레코드`);
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(" ❌ XML 파싱 실패:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
|
@ -360,11 +348,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
// 텍스트 데이터 파싱 (CSV, 기상청 형식 등)
|
||||
const parseTextData = (text: string): any[] => {
|
||||
try {
|
||||
// // console.log(" 🔍 원본 텍스트 (처음 500자):", text.substring(0, 500));
|
||||
|
||||
// XML 형식 감지
|
||||
if (text.trim().startsWith("<?xml") || text.trim().startsWith("<result>")) {
|
||||
// // console.log(" 📄 XML 형식 데이터 감지");
|
||||
return parseXmlData(text);
|
||||
}
|
||||
|
||||
|
|
@ -373,8 +358,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
return trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("=") && !trimmed.startsWith("---");
|
||||
});
|
||||
|
||||
// // console.log(` 📝 유효한 라인: ${lines.length}개`);
|
||||
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
// CSV 형식으로 파싱
|
||||
|
|
@ -384,8 +367,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const line = lines[i];
|
||||
const values = line.split(",").map((v) => v.trim().replace(/,=$/g, ""));
|
||||
|
||||
// // console.log(` 라인 ${i}:`, values);
|
||||
|
||||
// 기상특보 형식: 지역코드, 지역명, 하위코드, 하위지역명, 발표시각, 특보종류, 등급, 발표상태, 설명
|
||||
if (values.length >= 4) {
|
||||
const obj: any = {
|
||||
|
|
@ -404,14 +385,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
obj.name = obj.subRegion || obj.region || obj.code;
|
||||
|
||||
result.push(obj);
|
||||
// console.log(` ✅ 파싱 성공:`, obj);
|
||||
}
|
||||
}
|
||||
|
||||
// // console.log(" 📊 최종 파싱 결과:", result.length, "개");
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(" ❌ 텍스트 파싱 오류:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
|
@ -423,23 +401,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
mapDisplayType?: "auto" | "marker" | "polygon",
|
||||
dataSource?: ChartDataSource,
|
||||
): { markers: MarkerData[]; polygons: PolygonData[] } => {
|
||||
// // console.log(`🔄 ${sourceName} 데이터 변환 시작:`, rows.length, "개 행");
|
||||
// // console.log(` 📌 mapDisplayType:`, mapDisplayType, `(타입: ${typeof mapDisplayType})`);
|
||||
// // console.log(` 🎨 마커 색상:`, dataSource?.markerColor, `폴리곤 색상:`, dataSource?.polygonColor);
|
||||
|
||||
if (rows.length === 0) return { markers: [], polygons: [] };
|
||||
|
||||
const markers: MarkerData[] = [];
|
||||
const polygons: PolygonData[] = [];
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
// // console.log(` 행 ${index}:`, row);
|
||||
|
||||
// 텍스트 데이터 체크 (기상청 API 등)
|
||||
if (row && typeof row === "object" && row.text && typeof row.text === "string") {
|
||||
// // console.log(" 📄 텍스트 형식 데이터 감지, CSV 파싱 시도");
|
||||
const parsedData = parseTextData(row.text);
|
||||
// // console.log(` ✅ CSV 파싱 결과: ${parsedData.length}개 행`);
|
||||
|
||||
// 파싱된 데이터를 재귀적으로 변환 (색상 정보 전달)
|
||||
const result = convertToMapData(parsedData, sourceName, mapDisplayType, dataSource);
|
||||
|
|
@ -450,17 +420,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
|
||||
// 폴리곤 데이터 체크 (coordinates 필드가 배열인 경우 또는 강제 polygon 모드)
|
||||
if (row.coordinates && Array.isArray(row.coordinates) && row.coordinates.length > 0) {
|
||||
// // console.log(` → coordinates 발견:`, row.coordinates.length, "개");
|
||||
// coordinates가 [lat, lng] 배열의 배열인지 확인
|
||||
const firstCoord = row.coordinates[0];
|
||||
if (Array.isArray(firstCoord) && firstCoord.length === 2) {
|
||||
// console.log(` → 폴리곤으로 처리:`, row.name);
|
||||
polygons.push({
|
||||
id: row.id || row.code || `polygon-${index}`,
|
||||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
||||
name: row.name || row.title || `영역 ${index + 1}`,
|
||||
coordinates: row.coordinates as [number, number][],
|
||||
status: row.status || row.level,
|
||||
description: row.description || JSON.stringify(row, null, 2),
|
||||
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
||||
source: sourceName,
|
||||
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||||
});
|
||||
|
|
@ -471,13 +439,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
// 지역명으로 해상 구역 확인 (auto 또는 polygon 모드일 때만)
|
||||
const regionName = row.name || row.area || row.region || row.location || row.subRegion;
|
||||
if (regionName && MARITIME_ZONES[regionName] && mapDisplayType !== "marker") {
|
||||
// // console.log(` → 해상 구역 발견: ${regionName}, 폴리곤으로 처리`);
|
||||
polygons.push({
|
||||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
||||
name: regionName,
|
||||
coordinates: MARITIME_ZONES[regionName] as [number, number][],
|
||||
status: row.status || row.level,
|
||||
description: row.description || `${row.type || ""} ${row.level || ""}`.trim() || JSON.stringify(row, null, 2),
|
||||
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
||||
source: sourceName,
|
||||
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||||
});
|
||||
|
|
@ -494,24 +461,20 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
(row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId)
|
||||
) {
|
||||
const regionCode = row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId;
|
||||
// // console.log(` → 지역 코드 발견: ${regionCode}, 위도/경도 변환 시도`);
|
||||
const coords = getCoordinatesByRegionCode(regionCode);
|
||||
if (coords) {
|
||||
lat = coords.lat;
|
||||
lng = coords.lng;
|
||||
// console.log(` → 변환 성공: (${lat}, ${lng})`);
|
||||
}
|
||||
}
|
||||
|
||||
// 지역명으로도 시도
|
||||
if ((lat === undefined || lng === undefined) && (row.name || row.area || row.region || row.location)) {
|
||||
const regionName = row.name || row.area || row.region || row.location;
|
||||
// // console.log(` → 지역명 발견: ${regionName}, 위도/경도 변환 시도`);
|
||||
const coords = getCoordinatesByRegionName(regionName);
|
||||
if (coords) {
|
||||
lat = coords.lat;
|
||||
lng = coords.lng;
|
||||
// console.log(` → 변환 성공: (${lat}, ${lng})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -519,34 +482,32 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
if (mapDisplayType === "polygon") {
|
||||
const regionName = row.name || row.subRegion || row.region || row.area;
|
||||
if (regionName) {
|
||||
// console.log(` 🔷 강제 폴리곤 모드: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`);
|
||||
polygons.push({
|
||||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`,
|
||||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
||||
name: regionName,
|
||||
coordinates: [], // GeoJSON에서 좌표를 가져올 것
|
||||
status: row.status || row.level,
|
||||
description: row.description || JSON.stringify(row, null, 2),
|
||||
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
||||
source: sourceName,
|
||||
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||||
});
|
||||
} else {
|
||||
// console.log(` ⚠️ 강제 폴리곤 모드지만 지역명 없음 - 스킵`);
|
||||
}
|
||||
return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음
|
||||
}
|
||||
|
||||
// 위도/경도가 있고 polygon 모드가 아니면 마커로 처리
|
||||
if (lat !== undefined && lng !== undefined && (mapDisplayType as string) !== "polygon") {
|
||||
// // console.log(` → 마커로 처리: (${lat}, ${lng})`);
|
||||
markers.push({
|
||||
id: `${sourceName}-marker-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
||||
// 진행 방향(heading) 계산을 위해 ID는 새로고침마다 바뀌지 않도록 고정값 사용
|
||||
// 중복 방지를 위해 sourceName과 index를 조합하여 고유 ID 생성
|
||||
id: `${sourceName}-${row.id || row.code || "marker"}-${index}`,
|
||||
lat: Number(lat),
|
||||
lng: Number(lng),
|
||||
latitude: Number(lat),
|
||||
longitude: Number(lng),
|
||||
name: row.name || row.title || row.area || row.region || `위치 ${index + 1}`,
|
||||
status: row.status || row.level,
|
||||
description: row.description || JSON.stringify(row, null, 2),
|
||||
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
||||
source: sourceName,
|
||||
color: dataSource?.markerColor || "#3b82f6", // 사용자 지정 색상 또는 기본 파랑
|
||||
});
|
||||
|
|
@ -554,24 +515,19 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
// 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용)
|
||||
const regionName = row.name || row.subRegion || row.region || row.area;
|
||||
if (regionName) {
|
||||
// console.log(` 📍 위도/경도 없지만 지역명 있음: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`);
|
||||
polygons.push({
|
||||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`,
|
||||
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
|
||||
name: regionName,
|
||||
coordinates: [], // GeoJSON에서 좌표를 가져올 것
|
||||
status: row.status || row.level,
|
||||
description: row.description || JSON.stringify(row, null, 2),
|
||||
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
|
||||
source: sourceName,
|
||||
color: dataSource?.polygonColor || getColorByStatus(row.status || row.level),
|
||||
});
|
||||
} else {
|
||||
// console.log(` ⚠️ 위도/경도 없고 지역명도 없음 - 스킵`);
|
||||
// console.log(` 데이터:`, row);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// // console.log(`✅ ${sourceName}: 마커 ${markers.length}개, 폴리곤 ${polygons.length}개 변환 완료`);
|
||||
return { markers, polygons };
|
||||
};
|
||||
|
||||
|
|
@ -627,6 +583,97 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
|
||||
// 해상 구역 폴리곤 좌표 (기상청 특보 구역 기준)
|
||||
const MARITIME_ZONES: Record<string, Array<[number, number]>> = {
|
||||
// 서해 해역
|
||||
"인천·경기북부앞바다": [
|
||||
[37.8, 125.8],
|
||||
[37.8, 126.5],
|
||||
[37.3, 126.5],
|
||||
[37.3, 125.8],
|
||||
],
|
||||
"인천·경기남부앞바다": [
|
||||
[37.3, 125.7],
|
||||
[37.3, 126.4],
|
||||
[36.8, 126.4],
|
||||
[36.8, 125.7],
|
||||
],
|
||||
충남북부앞바다: [
|
||||
[36.8, 125.6],
|
||||
[36.8, 126.3],
|
||||
[36.3, 126.3],
|
||||
[36.3, 125.6],
|
||||
],
|
||||
충남남부앞바다: [
|
||||
[36.3, 125.5],
|
||||
[36.3, 126.2],
|
||||
[35.8, 126.2],
|
||||
[35.8, 125.5],
|
||||
],
|
||||
전북북부앞바다: [
|
||||
[35.8, 125.4],
|
||||
[35.8, 126.1],
|
||||
[35.3, 126.1],
|
||||
[35.3, 125.4],
|
||||
],
|
||||
전북남부앞바다: [
|
||||
[35.3, 125.3],
|
||||
[35.3, 126.0],
|
||||
[34.8, 126.0],
|
||||
[34.8, 125.3],
|
||||
],
|
||||
전남북부서해앞바다: [
|
||||
[35.5, 125.2],
|
||||
[35.5, 125.9],
|
||||
[35.0, 125.9],
|
||||
[35.0, 125.2],
|
||||
],
|
||||
전남중부서해앞바다: [
|
||||
[35.0, 125.1],
|
||||
[35.0, 125.8],
|
||||
[34.5, 125.8],
|
||||
[34.5, 125.1],
|
||||
],
|
||||
전남남부서해앞바다: [
|
||||
[34.5, 125.0],
|
||||
[34.5, 125.7],
|
||||
[34.0, 125.7],
|
||||
[34.0, 125.0],
|
||||
],
|
||||
서해중부안쪽먼바다: [
|
||||
[37.5, 124.5],
|
||||
[37.5, 126.0],
|
||||
[36.0, 126.0],
|
||||
[36.0, 124.5],
|
||||
],
|
||||
서해중부바깥먼바다: [
|
||||
[37.5, 123.5],
|
||||
[37.5, 125.0],
|
||||
[36.0, 125.0],
|
||||
[36.0, 123.5],
|
||||
],
|
||||
서해남부북쪽안쪽먼바다: [
|
||||
[36.0, 124.5],
|
||||
[36.0, 126.0],
|
||||
[35.0, 126.0],
|
||||
[35.0, 124.5],
|
||||
],
|
||||
서해남부북쪽바깥먼바다: [
|
||||
[36.0, 123.5],
|
||||
[36.0, 125.0],
|
||||
[35.0, 125.0],
|
||||
[35.0, 123.5],
|
||||
],
|
||||
서해남부남쪽안쪽먼바다: [
|
||||
[35.0, 124.0],
|
||||
[35.0, 125.5],
|
||||
[34.0, 125.5],
|
||||
[34.0, 124.0],
|
||||
],
|
||||
서해남부남쪽바깥먼바다: [
|
||||
[35.0, 123.0],
|
||||
[35.0, 124.5],
|
||||
[33.5, 124.5],
|
||||
[33.5, 123.0],
|
||||
],
|
||||
// 제주도 해역
|
||||
제주도남부앞바다: [
|
||||
[33.25, 126.0],
|
||||
|
|
@ -862,7 +909,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
const nameColumn = columns.find((col) => /^(name|title|이름|명칭|location)$/i.test(col));
|
||||
|
||||
if (!latColumn || !lngColumn) {
|
||||
console.warn("⚠️ 위도/경도 컬럼을 찾을 수 없습니다.");
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -896,10 +942,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
try {
|
||||
const response = await fetch("/geojson/korea-municipalities.json");
|
||||
const data = await response.json();
|
||||
// // console.log("🗺️ GeoJSON 로드 완료:", data.features?.length, "개 시/군/구");
|
||||
setGeoJsonData(data);
|
||||
} catch (err) {
|
||||
console.error("❌ GeoJSON 로드 실패:", err);
|
||||
// GeoJSON 로드 실패 처리
|
||||
}
|
||||
};
|
||||
loadGeoJsonData();
|
||||
|
|
@ -934,9 +979,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataSources, element?.chartConfig?.refreshInterval]);
|
||||
|
||||
// 타일맵 URL (chartConfig에서 가져오기)
|
||||
const tileMapUrl =
|
||||
element?.chartConfig?.tileMapUrl || `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`;
|
||||
// 타일맵 URL (VWorld 한국 지도)
|
||||
const tileMapUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`;
|
||||
|
||||
// 지도 중심점 계산
|
||||
const center: [number, number] =
|
||||
|
|
@ -945,7 +989,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
markers.reduce((sum, m) => sum + m.lat, 0) / markers.length,
|
||||
markers.reduce((sum, m) => sum + m.lng, 0) / markers.length,
|
||||
]
|
||||
: [37.5665, 126.978]; // 기본: 서울
|
||||
: [36.5, 127.5]; // 한국 중심
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-full w-full flex-col">
|
||||
|
|
@ -982,19 +1026,28 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
<p className="text-destructive text-sm">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<MapContainer center={center} zoom={13} style={{ width: "100%", height: "100%" }} className="z-0">
|
||||
<TileLayer url={tileMapUrl} attribution="© VWorld" maxZoom={19} />
|
||||
<MapContainer
|
||||
key={`map-widget-${element.id}`}
|
||||
center={center}
|
||||
zoom={element.chartConfig?.initialZoom ?? 8}
|
||||
minZoom={element.chartConfig?.minZoom ?? 8}
|
||||
maxZoom={element.chartConfig?.maxZoom ?? 18}
|
||||
scrollWheelZoom
|
||||
doubleClickZoom
|
||||
touchZoom
|
||||
zoomControl
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
className="z-0"
|
||||
>
|
||||
<TileLayer
|
||||
url={tileMapUrl}
|
||||
attribution="© VWorld"
|
||||
minZoom={element.chartConfig?.minZoom ?? 8}
|
||||
maxZoom={element.chartConfig?.maxZoom ?? 18}
|
||||
/>
|
||||
|
||||
{/* 폴리곤 렌더링 */}
|
||||
{/* GeoJSON 렌더링 (육지 지역 경계선) */}
|
||||
{(() => {
|
||||
// console.log(`🗺️ GeoJSON 렌더링 조건 체크:`, {
|
||||
// geoJsonData: !!geoJsonData,
|
||||
// polygonsLength: polygons.length,
|
||||
// polygonNames: polygons.map(p => p.name),
|
||||
// });
|
||||
return null;
|
||||
})()}
|
||||
{geoJsonData && polygons.length > 0 ? (
|
||||
<GeoJSON
|
||||
key={JSON.stringify(polygons.map((p) => p.id))} // 폴리곤 변경 시 재렌더링
|
||||
|
|
@ -1009,31 +1062,25 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
|
||||
// 정확한 매칭
|
||||
if (p.name === sigName) {
|
||||
// console.log(`✅ 정확 매칭: ${p.name} === ${sigName}`);
|
||||
return true;
|
||||
}
|
||||
if (p.name === ctpName) {
|
||||
// console.log(`✅ 정확 매칭: ${p.name} === ${ctpName}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 부분 매칭 (GeoJSON 지역명에 폴리곤 이름이 포함되는지)
|
||||
if (sigName && sigName.includes(p.name)) {
|
||||
// console.log(`✅ 부분 매칭: ${sigName} includes ${p.name}`);
|
||||
return true;
|
||||
}
|
||||
if (ctpName && ctpName.includes(p.name)) {
|
||||
// console.log(`✅ 부분 매칭: ${ctpName} includes ${p.name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 역방향 매칭 (폴리곤 이름에 GeoJSON 지역명이 포함되는지)
|
||||
if (sigName && p.name.includes(sigName)) {
|
||||
// console.log(`✅ 역방향 매칭: ${p.name} includes ${sigName}`);
|
||||
return true;
|
||||
}
|
||||
if (ctpName && p.name.includes(ctpName)) {
|
||||
// console.log(`✅ 역방향 매칭: ${p.name} includes ${ctpName}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -1069,64 +1116,162 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
});
|
||||
|
||||
if (matchingPolygon) {
|
||||
layer.bindPopup(`
|
||||
<div class="min-w-[200px]">
|
||||
<div class="mb-2 font-semibold">${matchingPolygon.name}</div>
|
||||
${matchingPolygon.source ? `<div class="mb-1 text-xs text-muted-foreground">출처: ${matchingPolygon.source}</div>` : ""}
|
||||
${matchingPolygon.status ? `<div class="mb-1 text-xs">상태: ${matchingPolygon.status}</div>` : ""}
|
||||
${matchingPolygon.description ? `<div class="mt-2 max-h-[200px] overflow-auto text-xs"><pre class="whitespace-pre-wrap">${matchingPolygon.description}</pre></div>` : ""}
|
||||
</div>
|
||||
`);
|
||||
// 폴리곤의 데이터 소스 찾기
|
||||
const polygonDataSource = dataSources?.find((ds) => ds.name === matchingPolygon.source);
|
||||
const popupFields = polygonDataSource?.popupFields;
|
||||
|
||||
let popupContent = "";
|
||||
|
||||
// popupFields가 설정되어 있으면 설정된 필드만 표시
|
||||
if (popupFields && popupFields.length > 0 && matchingPolygon.description) {
|
||||
try {
|
||||
const parsed = JSON.parse(matchingPolygon.description);
|
||||
popupContent = `
|
||||
<div class="min-w-[200px]">
|
||||
${matchingPolygon.source ? `<div class="mb-2 border-b pb-2"><div class="text-gray-500 text-xs">📡 ${matchingPolygon.source}</div></div>` : ""}
|
||||
<div class="bg-gray-100 rounded p-2">
|
||||
<div class="text-gray-900 mb-1 text-xs font-semibold">상세 정보</div>
|
||||
<div class="space-y-2">
|
||||
${popupFields
|
||||
.map((field) => {
|
||||
const value = parsed[field.fieldName];
|
||||
if (value === undefined || value === null) return "";
|
||||
return `<div class="text-xs"><span class="text-gray-600 font-medium">${field.label}:</span> <span class="text-gray-900">${value}</span></div>`;
|
||||
})
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
// JSON 파싱 실패 시 기본 표시
|
||||
popupContent = `
|
||||
<div class="min-w-[200px]">
|
||||
<div class="mb-2 font-semibold">${matchingPolygon.name}</div>
|
||||
${matchingPolygon.source ? `<div class="mb-1 text-xs text-muted-foreground">출처: ${matchingPolygon.source}</div>` : ""}
|
||||
${matchingPolygon.status ? `<div class="mb-1 text-xs">상태: ${matchingPolygon.status}</div>` : ""}
|
||||
${matchingPolygon.description ? `<div class="mt-2 max-h-[200px] overflow-auto text-xs"><pre class="whitespace-pre-wrap">${matchingPolygon.description}</pre></div>` : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
// popupFields가 없으면 전체 데이터 표시
|
||||
popupContent = `
|
||||
<div class="min-w-[200px]">
|
||||
<div class="mb-2 font-semibold">${matchingPolygon.name}</div>
|
||||
${matchingPolygon.source ? `<div class="mb-1 text-xs text-muted-foreground">출처: ${matchingPolygon.source}</div>` : ""}
|
||||
${matchingPolygon.status ? `<div class="mb-1 text-xs">상태: ${matchingPolygon.status}</div>` : ""}
|
||||
${matchingPolygon.description ? `<div class="mt-2 max-h-[200px] overflow-auto text-xs"><pre class="whitespace-pre-wrap">${matchingPolygon.description}</pre></div>` : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
layer.bindPopup(popupContent);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* console.log(`⚠️ GeoJSON 렌더링 안 됨: geoJsonData=${!!geoJsonData}, polygons=${polygons.length}`) */}
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* 폴리곤 렌더링 (해상 구역만) */}
|
||||
{polygons
|
||||
.filter((p) => MARITIME_ZONES[p.name])
|
||||
.map((polygon) => (
|
||||
<Polygon
|
||||
key={polygon.id}
|
||||
positions={polygon.coordinates}
|
||||
pathOptions={{
|
||||
color: polygon.color || "#3b82f6",
|
||||
fillColor: polygon.color || "#3b82f6",
|
||||
fillOpacity: 0.3,
|
||||
weight: 2,
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="min-w-[200px]">
|
||||
<div className="mb-2 font-semibold">{polygon.name}</div>
|
||||
{polygon.source && (
|
||||
<div className="text-muted-foreground mb-1 text-xs">출처: {polygon.source}</div>
|
||||
)}
|
||||
{polygon.status && <div className="mb-1 text-xs">상태: {polygon.status}</div>}
|
||||
{polygon.description && (
|
||||
<div className="mt-2 max-h-[200px] overflow-auto text-xs">
|
||||
<pre className="whitespace-pre-wrap">{polygon.description}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Polygon>
|
||||
))}
|
||||
.map((polygon) => {
|
||||
// 폴리곤의 데이터 소스 찾기
|
||||
const polygonDataSource = dataSources?.find((ds) => ds.name === polygon.source);
|
||||
const popupFields = polygonDataSource?.popupFields;
|
||||
|
||||
return (
|
||||
<Polygon
|
||||
key={polygon.id}
|
||||
positions={polygon.coordinates}
|
||||
pathOptions={{
|
||||
color: polygon.color || "#3b82f6",
|
||||
fillColor: polygon.color || "#3b82f6",
|
||||
fillOpacity: 0.3,
|
||||
weight: 2,
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="min-w-[200px]">
|
||||
{/* popupFields가 설정되어 있으면 설정된 필드만 표시 */}
|
||||
{popupFields && popupFields.length > 0 && polygon.description ? (
|
||||
(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(polygon.description);
|
||||
return (
|
||||
<>
|
||||
{polygon.source && (
|
||||
<div className="mb-2 border-b pb-2">
|
||||
<div className="text-muted-foreground text-xs">📡 {polygon.source}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-muted rounded p-2">
|
||||
<div className="text-foreground mb-1 text-xs font-semibold">상세 정보</div>
|
||||
<div className="space-y-2">
|
||||
{popupFields.map((field, idx) => {
|
||||
const value = parsed[field.fieldName];
|
||||
if (value === undefined || value === null) return null;
|
||||
return (
|
||||
<div key={idx} className="text-xs">
|
||||
<span className="text-muted-foreground font-medium">{field.label}:</span>{" "}
|
||||
<span className="text-foreground">{String(value)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} catch (error) {
|
||||
// JSON 파싱 실패 시 기본 표시
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 font-semibold">{polygon.name}</div>
|
||||
{polygon.source && (
|
||||
<div className="text-muted-foreground mb-1 text-xs">출처: {polygon.source}</div>
|
||||
)}
|
||||
{polygon.status && <div className="mb-1 text-xs">상태: {polygon.status}</div>}
|
||||
{polygon.description && (
|
||||
<div className="mt-2 max-h-[200px] overflow-auto text-xs">
|
||||
<pre className="whitespace-pre-wrap">{polygon.description}</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
})()
|
||||
) : (
|
||||
// popupFields가 없으면 전체 데이터 표시
|
||||
<>
|
||||
<div className="mb-2 font-semibold">{polygon.name}</div>
|
||||
{polygon.source && (
|
||||
<div className="text-muted-foreground mb-1 text-xs">출처: {polygon.source}</div>
|
||||
)}
|
||||
{polygon.status && <div className="mb-1 text-xs">상태: {polygon.status}</div>}
|
||||
{polygon.description && (
|
||||
<div className="mt-2 max-h-[200px] overflow-auto text-xs">
|
||||
<pre className="whitespace-pre-wrap">{polygon.description}</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Polygon>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 마커 렌더링 */}
|
||||
{markers.map((marker) => {
|
||||
// 첫 번째 데이터 소스의 마커 종류 가져오기
|
||||
const firstDataSource = dataSources?.[0];
|
||||
const markerType = firstDataSource?.markerType || "circle";
|
||||
// 마커의 소스에 해당하는 데이터 소스 찾기
|
||||
const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source) || dataSources?.[0];
|
||||
const markerType = sourceDataSource?.markerType || "circle";
|
||||
|
||||
let markerIcon: any;
|
||||
if (typeof window !== "undefined") {
|
||||
const L = require("leaflet");
|
||||
const heading = marker.heading || 0;
|
||||
// heading이 없거나 0일 때 기본값 90(동쪽/오른쪽)으로 설정하여 처음에 오른쪽을 보게 함
|
||||
const heading = marker.heading || 90;
|
||||
|
||||
if (markerType === "arrow") {
|
||||
// 화살표 마커
|
||||
|
|
@ -1143,28 +1288,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
|
||||
">
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- 화살표 몸통 -->
|
||||
<!-- 이등변 삼각형 화살표 (뾰족한 방향 표시) -->
|
||||
<polygon
|
||||
points="20,5 25,15 23,15 23,25 17,25 17,15 15,15"
|
||||
points="20,5 28,30 12,30"
|
||||
fill="${marker.color || "#3b82f6"}"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<!-- 화살촉 -->
|
||||
<polygon
|
||||
points="20,2 28,12 12,12"
|
||||
fill="${marker.color || "#3b82f6"}"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<!-- 중심점 -->
|
||||
<circle
|
||||
cx="20"
|
||||
cy="30"
|
||||
r="3"
|
||||
fill="white"
|
||||
stroke="${marker.color || "#3b82f6"}"
|
||||
stroke-width="1.5"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
|
@ -1172,6 +1301,77 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 20],
|
||||
});
|
||||
} else if (markerType === "truck") {
|
||||
// 트럭 마커
|
||||
// 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요
|
||||
const rotation = heading - 90;
|
||||
|
||||
markerIcon = L.divIcon({
|
||||
className: "custom-truck-marker",
|
||||
html: `
|
||||
<div style="
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translate(-50%, -50%) rotate(${rotation}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>
|
||||
<!-- 트럭 적재함 -->
|
||||
<rect
|
||||
x="10"
|
||||
y="12"
|
||||
width="12"
|
||||
height="10"
|
||||
fill="${marker.color || "#3b82f6"}"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
rx="1"
|
||||
/>
|
||||
<!-- 트럭 운전석 -->
|
||||
<path
|
||||
d="M 22 14 L 22 22 L 28 22 L 28 18 L 26 14 Z"
|
||||
fill="${marker.color || "#3b82f6"}"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<!-- 운전석 창문 -->
|
||||
<rect
|
||||
x="23"
|
||||
y="15"
|
||||
width="3"
|
||||
height="4"
|
||||
fill="white"
|
||||
opacity="0.8"
|
||||
/>
|
||||
<!-- 앞 바퀴 -->
|
||||
<circle
|
||||
cx="25"
|
||||
cy="23"
|
||||
r="2.5"
|
||||
fill="#333"
|
||||
stroke="white"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<!-- 뒷 바퀴 -->
|
||||
<circle
|
||||
cx="14"
|
||||
cy="23"
|
||||
r="2.5"
|
||||
fill="#333"
|
||||
stroke="white"
|
||||
stroke-width="1"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [48, 48],
|
||||
iconAnchor: [24, 24],
|
||||
});
|
||||
} else {
|
||||
// 동그라미 마커 (기본)
|
||||
markerIcon = L.divIcon({
|
||||
|
|
@ -1227,8 +1427,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
|
|||
<div className="space-y-2">
|
||||
{marker.description &&
|
||||
(() => {
|
||||
const firstDataSource = dataSources?.[0];
|
||||
const popupFields = firstDataSource?.popupFields;
|
||||
// 마커의 소스에 해당하는 데이터 소스 찾기
|
||||
const sourceDataSource = dataSources?.find((ds) => ds.name === marker.source);
|
||||
const popupFields = sourceDataSource?.popupFields;
|
||||
|
||||
// popupFields가 설정되어 있으면 설정된 필드만 표시
|
||||
if (popupFields && popupFields.length > 0) {
|
||||
|
|
|
|||
|
|
@ -377,8 +377,8 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
new Date().toISOString();
|
||||
|
||||
const alert: Alert = {
|
||||
id: row.id || row.alert_id || row.incidentId || row.eventId ||
|
||||
row.code || row.subCode || `${sourceName}-${index}-${Date.now()}`,
|
||||
// 중복 방지를 위해 소스명과 인덱스를 포함하여 고유 ID 생성
|
||||
id: `${sourceName}-${index}-${row.id || row.alert_id || row.incidentId || row.eventId || row.code || row.subCode || Date.now()}`,
|
||||
type,
|
||||
severity,
|
||||
title,
|
||||
|
|
@ -614,8 +614,9 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
<p className="text-sm">알림이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredAlerts.map((alert) => (
|
||||
<Card key={alert.id} className="p-2">
|
||||
filteredAlerts.map((alert, idx) => (
|
||||
// key 중복 방지를 위해 인덱스 추가
|
||||
<Card key={`${alert.id}-${idx}`} 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)}
|
||||
|
|
@ -636,9 +637,10 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
<div className="mt-1 flex items-center gap-2 text-[9px] text-muted-foreground">
|
||||
<span>
|
||||
{(() => {
|
||||
const ts = String(alert.timestamp);
|
||||
const original = String(alert.timestamp);
|
||||
const ts = original.replace(/\s+/g, ""); // 공백 제거
|
||||
|
||||
// yyyyMMddHHmm 형식 감지 (예: 20251114 1000)
|
||||
// yyyyMMddHHmm 형식 감지 (12자리 숫자)
|
||||
if (/^\d{12}$/.test(ts)) {
|
||||
const year = ts.substring(0, 4);
|
||||
const month = ts.substring(4, 6);
|
||||
|
|
@ -646,12 +648,20 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
|
|||
const hour = ts.substring(8, 10);
|
||||
const minute = ts.substring(10, 12);
|
||||
const date = new Date(`${year}-${month}-${day}T${hour}:${minute}:00`);
|
||||
return isNaN(date.getTime()) ? ts : date.toLocaleString("ko-KR");
|
||||
return isNaN(date.getTime()) ? original : date.toLocaleString("ko-KR");
|
||||
}
|
||||
|
||||
// "2025년 11월 14일 13시 20분" 형식
|
||||
const koreanMatch = original.match(/(\d{4})년\s*(\d{1,2})월\s*(\d{1,2})일\s*(\d{1,2})시\s*(\d{1,2})분/);
|
||||
if (koreanMatch) {
|
||||
const [, year, month, day, hour, minute] = koreanMatch;
|
||||
const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}T${hour.padStart(2, '0')}:${minute.padStart(2, '0')}:00`);
|
||||
return isNaN(date.getTime()) ? original : date.toLocaleString("ko-KR");
|
||||
}
|
||||
|
||||
// ISO 형식 또는 일반 날짜 형식
|
||||
const date = new Date(ts);
|
||||
return isNaN(date.getTime()) ? ts : date.toLocaleString("ko-KR");
|
||||
const date = new Date(original);
|
||||
return isNaN(date.getTime()) ? original : date.toLocaleString("ko-KR");
|
||||
})()}
|
||||
</span>
|
||||
{alert.source && <span>· {alert.source}</span>}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input";
|
||||
|
||||
/**
|
||||
* 수주 등록 전용 거래처 검색 컴포넌트
|
||||
*
|
||||
* 이 컴포넌트는 수주 등록 화면 전용이며, 설정이 고정되어 있습니다.
|
||||
* 범용 AutocompleteSearchInput과 달리 customer_mng 테이블만 조회합니다.
|
||||
*/
|
||||
|
||||
interface OrderCustomerSearchProps {
|
||||
/** 현재 선택된 거래처 코드 */
|
||||
value: string;
|
||||
/** 거래처 선택 시 콜백 (거래처 코드, 전체 데이터) */
|
||||
onChange: (customerCode: string | null, fullData?: any) => void;
|
||||
/** 비활성화 여부 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function OrderCustomerSearch({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: OrderCustomerSearchProps) {
|
||||
return (
|
||||
<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"]}
|
||||
|
||||
// 외부에서 제어 가능한 prop
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ModalRepeaterTableComponent } from "@/lib/registry/components/modal-repeater-table";
|
||||
import type {
|
||||
RepeaterColumnConfig,
|
||||
CalculationRule,
|
||||
} from "@/lib/registry/components/modal-repeater-table";
|
||||
|
||||
/**
|
||||
* 수주 등록 전용 품목 반복 테이블 컴포넌트
|
||||
*
|
||||
* 이 컴포넌트는 수주 등록 화면 전용이며, 설정이 고정되어 있습니다.
|
||||
* 범용 ModalRepeaterTable과 달리 item_info 테이블만 조회하며,
|
||||
* 수주 등록에 필요한 컬럼과 계산 공식이 미리 설정되어 있습니다.
|
||||
*/
|
||||
|
||||
interface OrderItemRepeaterTableProps {
|
||||
/** 현재 선택된 품목 목록 */
|
||||
value: any[];
|
||||
/** 품목 목록 변경 시 콜백 */
|
||||
onChange: (items: any[]) => void;
|
||||
/** 비활성화 여부 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// 수주 등록 전용 컬럼 설정 (고정)
|
||||
const ORDER_COLUMNS: RepeaterColumnConfig[] = [
|
||||
{
|
||||
field: "item_number",
|
||||
label: "품번",
|
||||
editable: false,
|
||||
width: "120px",
|
||||
},
|
||||
{
|
||||
field: "item_name",
|
||||
label: "품명",
|
||||
editable: false,
|
||||
width: "180px",
|
||||
},
|
||||
{
|
||||
field: "specification",
|
||||
label: "규격",
|
||||
editable: false,
|
||||
width: "150px",
|
||||
},
|
||||
{
|
||||
field: "material",
|
||||
label: "재질",
|
||||
editable: false,
|
||||
width: "120px",
|
||||
},
|
||||
{
|
||||
field: "quantity",
|
||||
label: "수량",
|
||||
type: "number",
|
||||
editable: true,
|
||||
required: true,
|
||||
defaultValue: 1,
|
||||
width: "100px",
|
||||
},
|
||||
{
|
||||
field: "selling_price",
|
||||
label: "단가",
|
||||
type: "number",
|
||||
editable: true,
|
||||
required: true,
|
||||
width: "120px",
|
||||
},
|
||||
{
|
||||
field: "amount",
|
||||
label: "금액",
|
||||
type: "number",
|
||||
editable: false,
|
||||
calculated: true,
|
||||
width: "120px",
|
||||
},
|
||||
{
|
||||
field: "delivery_date",
|
||||
label: "납기일",
|
||||
type: "date",
|
||||
editable: true,
|
||||
width: "130px",
|
||||
},
|
||||
];
|
||||
|
||||
// 수주 등록 전용 계산 공식 (고정)
|
||||
const ORDER_CALCULATION_RULES: CalculationRule[] = [
|
||||
{
|
||||
result: "amount",
|
||||
formula: "quantity * selling_price",
|
||||
dependencies: ["quantity", "selling_price"],
|
||||
},
|
||||
];
|
||||
|
||||
export function OrderItemRepeaterTable({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: OrderItemRepeaterTableProps) {
|
||||
return (
|
||||
<ModalRepeaterTableComponent
|
||||
// 고정 설정 (수주 등록 전용)
|
||||
sourceTable="item_info"
|
||||
sourceColumns={[
|
||||
"item_number",
|
||||
"item_name",
|
||||
"specification",
|
||||
"material",
|
||||
"unit",
|
||||
"selling_price",
|
||||
]}
|
||||
sourceSearchFields={["item_name", "item_number", "specification"]}
|
||||
modalTitle="품목 검색 및 선택"
|
||||
modalButtonText="품목 검색"
|
||||
multiSelect={true}
|
||||
columns={ORDER_COLUMNS}
|
||||
calculationRules={ORDER_CALCULATION_RULES}
|
||||
uniqueField="item_number"
|
||||
|
||||
// 외부에서 제어 가능한 prop
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,530 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { OrderCustomerSearch } from "./OrderCustomerSearch";
|
||||
import { OrderItemRepeaterTable } from "./OrderItemRepeaterTable";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface OrderRegistrationModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function OrderRegistrationModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: OrderRegistrationModalProps) {
|
||||
// 입력 방식
|
||||
const [inputMode, setInputMode] = useState<string>("customer_first");
|
||||
|
||||
// 판매 유형 (국내/해외)
|
||||
const [salesType, setSalesType] = useState<string>("domestic");
|
||||
|
||||
// 단가 기준 (기준단가/거래처별단가)
|
||||
const [priceType, setPriceType] = useState<string>("standard");
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<any>({
|
||||
customerCode: "",
|
||||
customerName: "",
|
||||
contactPerson: "",
|
||||
deliveryDestination: "",
|
||||
deliveryAddress: "",
|
||||
deliveryDate: "",
|
||||
memo: "",
|
||||
// 무역 정보 (해외 판매 시)
|
||||
incoterms: "",
|
||||
paymentTerms: "",
|
||||
currency: "KRW",
|
||||
portOfLoading: "",
|
||||
portOfDischarge: "",
|
||||
hsCode: "",
|
||||
});
|
||||
|
||||
// 선택된 품목 목록
|
||||
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
||||
|
||||
// 저장 중
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 저장 처리
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 유효성 검사
|
||||
if (!formData.customerCode) {
|
||||
toast.error("거래처를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedItems.length === 0) {
|
||||
toast.error("품목을 추가해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
// 수주 등록 API 호출
|
||||
const orderData: any = {
|
||||
inputMode,
|
||||
salesType,
|
||||
priceType,
|
||||
customerCode: formData.customerCode,
|
||||
contactPerson: formData.contactPerson,
|
||||
deliveryDestination: formData.deliveryDestination,
|
||||
deliveryAddress: formData.deliveryAddress,
|
||||
deliveryDate: formData.deliveryDate,
|
||||
items: selectedItems,
|
||||
memo: formData.memo,
|
||||
};
|
||||
|
||||
// 해외 판매 시 무역 정보 추가
|
||||
if (salesType === "export") {
|
||||
orderData.tradeInfo = {
|
||||
incoterms: formData.incoterms,
|
||||
paymentTerms: formData.paymentTerms,
|
||||
currency: formData.currency,
|
||||
portOfLoading: formData.portOfLoading,
|
||||
portOfDischarge: formData.portOfDischarge,
|
||||
hsCode: formData.hsCode,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await apiClient.post("/orders", orderData);
|
||||
|
||||
if (response.data.success) {
|
||||
toast.success("수주가 등록되었습니다");
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
|
||||
// 폼 초기화
|
||||
resetForm();
|
||||
} else {
|
||||
toast.error(response.data.message || "수주 등록에 실패했습니다");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("수주 등록 오류:", error);
|
||||
toast.error(
|
||||
error.response?.data?.message || "수주 등록 중 오류가 발생했습니다"
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 취소 처리
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false);
|
||||
resetForm();
|
||||
};
|
||||
|
||||
// 폼 초기화
|
||||
const resetForm = () => {
|
||||
setInputMode("customer_first");
|
||||
setSalesType("domestic");
|
||||
setPriceType("standard");
|
||||
setFormData({
|
||||
customerCode: "",
|
||||
customerName: "",
|
||||
contactPerson: "",
|
||||
deliveryDestination: "",
|
||||
deliveryAddress: "",
|
||||
deliveryDate: "",
|
||||
memo: "",
|
||||
incoterms: "",
|
||||
paymentTerms: "",
|
||||
currency: "KRW",
|
||||
portOfLoading: "",
|
||||
portOfDischarge: "",
|
||||
hsCode: "",
|
||||
});
|
||||
setSelectedItems([]);
|
||||
};
|
||||
|
||||
// 전체 금액 계산
|
||||
const totalAmount = selectedItems.reduce(
|
||||
(sum, item) => sum + (item.amount || 0),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">수주 등록</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
새로운 수주를 등록합니다
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 상단 셀렉트 박스 3개 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{/* 입력 방식 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inputMode" className="text-xs sm:text-sm flex items-center gap-1">
|
||||
<span className="text-amber-500">📝</span> 입력 방식
|
||||
</Label>
|
||||
<Select value={inputMode} onValueChange={setInputMode}>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="입력 방식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="customer_first">거래처 우선</SelectItem>
|
||||
<SelectItem value="quotation">견대 방식</SelectItem>
|
||||
<SelectItem value="unit_price">단가 방식</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 판매 유형 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salesType" className="text-xs sm:text-sm flex items-center gap-1">
|
||||
<span className="text-blue-500">🌏</span> 판매 유형
|
||||
</Label>
|
||||
<Select value={salesType} onValueChange={setSalesType}>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="판매 유형 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="domestic">국내 판매</SelectItem>
|
||||
<SelectItem value="export">해외 판매</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 단가 기준 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="priceType" className="text-xs sm:text-sm flex items-center gap-1">
|
||||
<span className="text-green-500">💰</span> 단가 방식
|
||||
</Label>
|
||||
<Select value={priceType} onValueChange={setPriceType}>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="단가 방식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">기준 단가</SelectItem>
|
||||
<SelectItem value="customer">거래처별 단가</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 거래처 정보 (항상 표시) */}
|
||||
{inputMode === "customer_first" && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50/50 p-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-gray-700">
|
||||
<span>🏢</span>
|
||||
<span>거래처 정보</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 거래처 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">거래처 *</Label>
|
||||
<OrderCustomerSearch
|
||||
value={formData.customerCode}
|
||||
onChange={(code, fullData) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
customerCode: code || "",
|
||||
customerName: fullData?.customer_name || "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 담당자 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contactPerson" className="text-xs sm:text-sm">
|
||||
담당자
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
id="contactPerson"
|
||||
placeholder="담당자"
|
||||
value={formData.contactPerson}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, contactPerson: e.target.value })
|
||||
}
|
||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 납품처 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="deliveryDestination" className="text-xs sm:text-sm">
|
||||
납품처
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
id="deliveryDestination"
|
||||
placeholder="납품처"
|
||||
value={formData.deliveryDestination}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, deliveryDestination: e.target.value })
|
||||
}
|
||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 납품장소 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="deliveryAddress" className="text-xs sm:text-sm">
|
||||
납품장소
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
id="deliveryAddress"
|
||||
placeholder="납품장소"
|
||||
value={formData.deliveryAddress}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, deliveryAddress: e.target.value })
|
||||
}
|
||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inputMode === "quotation" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">견대 번호 *</Label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="견대 번호를 입력하세요"
|
||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inputMode === "unit_price" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">단가 방식 설정</Label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="단가 정보 입력"
|
||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가된 품목 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">추가된 품목</Label>
|
||||
<OrderItemRepeaterTable
|
||||
value={selectedItems}
|
||||
onChange={setSelectedItems}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 전체 금액 표시 */}
|
||||
{selectedItems.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<div className="text-sm sm:text-base font-semibold">
|
||||
전체 금액: {totalAmount.toLocaleString()}원
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 무역 정보 (해외 판매 시에만 표시) */}
|
||||
{salesType === "export" && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50/50 p-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-blue-700">
|
||||
<span>🌏</span>
|
||||
<span>무역 정보</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{/* 인코텀즈 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="incoterms" className="text-xs sm:text-sm">
|
||||
인코텀즈
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.incoterms}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, incoterms: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EXW">EXW</SelectItem>
|
||||
<SelectItem value="FOB">FOB</SelectItem>
|
||||
<SelectItem value="CIF">CIF</SelectItem>
|
||||
<SelectItem value="DDP">DDP</SelectItem>
|
||||
<SelectItem value="DAP">DAP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 결제 조건 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="paymentTerms" className="text-xs sm:text-sm">
|
||||
결제 조건
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.paymentTerms}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, paymentTerms: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="advance">선결제</SelectItem>
|
||||
<SelectItem value="cod">착불</SelectItem>
|
||||
<SelectItem value="lc">신용장(L/C)</SelectItem>
|
||||
<SelectItem value="net30">NET 30</SelectItem>
|
||||
<SelectItem value="net60">NET 60</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 통화 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currency" className="text-xs sm:text-sm">
|
||||
통화
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.currency}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, currency: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="통화 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="KRW">KRW (원)</SelectItem>
|
||||
<SelectItem value="USD">USD (달러)</SelectItem>
|
||||
<SelectItem value="EUR">EUR (유로)</SelectItem>
|
||||
<SelectItem value="JPY">JPY (엔)</SelectItem>
|
||||
<SelectItem value="CNY">CNY (위안)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{/* 선적항 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="portOfLoading" className="text-xs sm:text-sm">
|
||||
선적항
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
id="portOfLoading"
|
||||
placeholder="선적항"
|
||||
value={formData.portOfLoading}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, portOfLoading: e.target.value })
|
||||
}
|
||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 도착항 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="portOfDischarge" className="text-xs sm:text-sm">
|
||||
도착항
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
id="portOfDischarge"
|
||||
placeholder="도착항"
|
||||
value={formData.portOfDischarge}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, portOfDischarge: e.target.value })
|
||||
}
|
||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* HS Code */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hsCode" className="text-xs sm:text-sm">
|
||||
HS Code
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
id="hsCode"
|
||||
placeholder="HS Code"
|
||||
value={formData.hsCode}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, hsCode: e.target.value })
|
||||
}
|
||||
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 메모 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="memo" className="text-xs sm:text-sm">
|
||||
메모
|
||||
</Label>
|
||||
<textarea
|
||||
id="memo"
|
||||
placeholder="메모를 입력하세요"
|
||||
value={formData.memo}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, memo: e.target.value })
|
||||
}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isSaving ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
# 수주 등록 컴포넌트
|
||||
|
||||
## 개요
|
||||
|
||||
수주 등록 기능을 위한 전용 컴포넌트들입니다. 이 컴포넌트들은 범용 컴포넌트를 래핑하여 수주 등록에 최적화된 고정 설정을 제공합니다.
|
||||
|
||||
## 컴포넌트 구조
|
||||
|
||||
```
|
||||
frontend/components/order/
|
||||
├── OrderRegistrationModal.tsx # 수주 등록 메인 모달
|
||||
├── OrderCustomerSearch.tsx # 거래처 검색 (전용)
|
||||
├── OrderItemRepeaterTable.tsx # 품목 반복 테이블 (전용)
|
||||
└── README.md # 문서 (현재 파일)
|
||||
```
|
||||
|
||||
## 1. OrderRegistrationModal
|
||||
|
||||
수주 등록 메인 모달 컴포넌트입니다.
|
||||
|
||||
### Props
|
||||
|
||||
```typescript
|
||||
interface OrderRegistrationModalProps {
|
||||
/** 모달 열림/닫힘 상태 */
|
||||
open: boolean;
|
||||
/** 모달 상태 변경 핸들러 */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** 수주 등록 성공 시 콜백 */
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```tsx
|
||||
import { OrderRegistrationModal } from "@/components/order/OrderRegistrationModal";
|
||||
|
||||
function MyComponent() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsOpen(true)}>수주 등록</Button>
|
||||
|
||||
<OrderRegistrationModal
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
onSuccess={() => {
|
||||
console.log("수주 등록 완료!");
|
||||
// 목록 새로고침 등
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 기능
|
||||
|
||||
- **입력 방식 선택**: 거래처 우선, 견적 방식, 단가 방식
|
||||
- **거래처 검색**: 자동완성 드롭다운으로 거래처 검색 및 선택
|
||||
- **품목 관리**: 모달에서 품목 검색 및 추가, 수량/단가 입력, 금액 자동 계산
|
||||
- **전체 금액 표시**: 추가된 품목들의 총 금액 계산
|
||||
- **유효성 검사**: 거래처 및 품목 필수 입력 체크
|
||||
|
||||
---
|
||||
|
||||
## 2. OrderCustomerSearch
|
||||
|
||||
수주 등록 전용 거래처 검색 컴포넌트입니다.
|
||||
|
||||
### 특징
|
||||
|
||||
- `customer_mng` 테이블만 조회 (고정)
|
||||
- 거래처명, 거래처코드, 사업자번호로 검색 (고정)
|
||||
- 추가 정보 표시 (주소, 연락처)
|
||||
|
||||
### Props
|
||||
|
||||
```typescript
|
||||
interface OrderCustomerSearchProps {
|
||||
/** 현재 선택된 거래처 코드 */
|
||||
value: string;
|
||||
/** 거래처 선택 시 콜백 (거래처 코드, 전체 데이터) */
|
||||
onChange: (customerCode: string | null, fullData?: any) => void;
|
||||
/** 비활성화 여부 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```tsx
|
||||
import { OrderCustomerSearch } from "@/components/order/OrderCustomerSearch";
|
||||
|
||||
function MyForm() {
|
||||
const [customerCode, setCustomerCode] = useState("");
|
||||
const [customerName, setCustomerName] = useState("");
|
||||
|
||||
return (
|
||||
<OrderCustomerSearch
|
||||
value={customerCode}
|
||||
onChange={(code, fullData) => {
|
||||
setCustomerCode(code || "");
|
||||
setCustomerName(fullData?.customer_name || "");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 고정 설정
|
||||
|
||||
| 설정 | 값 | 설명 |
|
||||
|------|-----|------|
|
||||
| `tableName` | `customer_mng` | 거래처 테이블 |
|
||||
| `displayField` | `customer_name` | 표시 필드 |
|
||||
| `valueField` | `customer_code` | 값 필드 |
|
||||
| `searchFields` | `["customer_name", "customer_code", "business_number"]` | 검색 대상 필드 |
|
||||
| `additionalFields` | `["customer_code", "address", "contact_phone"]` | 추가 표시 필드 |
|
||||
|
||||
---
|
||||
|
||||
## 3. OrderItemRepeaterTable
|
||||
|
||||
수주 등록 전용 품목 반복 테이블 컴포넌트입니다.
|
||||
|
||||
### 특징
|
||||
|
||||
- `item_info` 테이블만 조회 (고정)
|
||||
- 수주에 필요한 컬럼만 표시 (품번, 품명, 수량, 단가, 금액 등)
|
||||
- 금액 자동 계산 (`수량 * 단가`)
|
||||
|
||||
### Props
|
||||
|
||||
```typescript
|
||||
interface OrderItemRepeaterTableProps {
|
||||
/** 현재 선택된 품목 목록 */
|
||||
value: any[];
|
||||
/** 품목 목록 변경 시 콜백 */
|
||||
onChange: (items: any[]) => void;
|
||||
/** 비활성화 여부 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 사용 예시
|
||||
|
||||
```tsx
|
||||
import { OrderItemRepeaterTable } from "@/components/order/OrderItemRepeaterTable";
|
||||
|
||||
function MyForm() {
|
||||
const [items, setItems] = useState([]);
|
||||
|
||||
return (
|
||||
<OrderItemRepeaterTable
|
||||
value={items}
|
||||
onChange={setItems}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 고정 컬럼 설정
|
||||
|
||||
| 필드 | 라벨 | 타입 | 편집 | 필수 | 계산 | 설명 |
|
||||
|------|------|------|------|------|------|------|
|
||||
| `id` | 품번 | text | ❌ | - | - | 품목 ID |
|
||||
| `item_name` | 품명 | text | ❌ | - | - | 품목명 |
|
||||
| `item_number` | 품목번호 | text | ❌ | - | - | 품목 번호 |
|
||||
| `quantity` | 수량 | number | ✅ | ✅ | - | 주문 수량 (기본값: 1) |
|
||||
| `selling_price` | 단가 | number | ✅ | ✅ | - | 판매 단가 |
|
||||
| `amount` | 금액 | number | ❌ | - | ✅ | 자동 계산 (수량 * 단가) |
|
||||
| `delivery_date` | 납품일 | date | ✅ | - | - | 납품 예정일 |
|
||||
| `note` | 비고 | text | ✅ | - | - | 추가 메모 |
|
||||
|
||||
### 계산 규칙
|
||||
|
||||
```javascript
|
||||
amount = quantity * selling_price
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 범용 컴포넌트 vs 전용 컴포넌트
|
||||
|
||||
### 왜 전용 컴포넌트를 만들었나?
|
||||
|
||||
| 항목 | 범용 컴포넌트 | 전용 컴포넌트 |
|
||||
|------|--------------|--------------|
|
||||
| **목적** | 화면 편집기에서 다양한 용도로 사용 | 수주 등록 전용 |
|
||||
| **설정** | ConfigPanel에서 자유롭게 변경 가능 | 하드코딩으로 고정 |
|
||||
| **유연성** | 높음 (모든 테이블/필드 지원) | 낮음 (수주에 최적화) |
|
||||
| **안정성** | 사용자 실수 가능 | 설정 변경 불가로 안전 |
|
||||
| **위치** | `lib/registry/components/` | `components/order/` |
|
||||
|
||||
### 범용 컴포넌트 (화면 편집기용)
|
||||
|
||||
```tsx
|
||||
// ❌ 수주 등록에서 사용 금지
|
||||
<AutocompleteSearchInputComponent
|
||||
tableName="???" // ConfigPanel에서 변경 가능
|
||||
displayField="???" // 다른 테이블로 바꿀 수 있음
|
||||
valueField="???" // 필드가 맞지 않으면 에러
|
||||
/>
|
||||
```
|
||||
|
||||
**문제점:**
|
||||
- 사용자가 `tableName`을 `item_info`로 변경하면 거래처가 아닌 품목이 조회됨
|
||||
- `valueField`를 변경하면 `formData.customerCode`에 잘못된 값 저장
|
||||
- 수주 로직이 깨짐
|
||||
|
||||
### 전용 컴포넌트 (수주 등록용)
|
||||
|
||||
```tsx
|
||||
// ✅ 수주 등록에서 사용
|
||||
<OrderCustomerSearch
|
||||
value={customerCode} // 외부에서 제어 가능
|
||||
onChange={handleChange} // 값 변경만 처리
|
||||
// 나머지 설정은 내부에서 고정
|
||||
/>
|
||||
```
|
||||
|
||||
**장점:**
|
||||
- 설정이 하드코딩되어 있어 변경 불가
|
||||
- 수주 등록 로직에 최적화
|
||||
- 안전하고 예측 가능
|
||||
|
||||
---
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
### 거래처 검색
|
||||
|
||||
```
|
||||
GET /api/entity-search/customer_mng
|
||||
Query Parameters:
|
||||
- searchText: 검색어
|
||||
- searchFields: customer_name,customer_code,business_number
|
||||
- page: 페이지 번호
|
||||
- limit: 페이지 크기
|
||||
```
|
||||
|
||||
### 품목 검색
|
||||
|
||||
```
|
||||
GET /api/entity-search/item_info
|
||||
Query Parameters:
|
||||
- searchText: 검색어
|
||||
- searchFields: item_name,id,item_number
|
||||
- page: 페이지 번호
|
||||
- limit: 페이지 크기
|
||||
```
|
||||
|
||||
### 수주 등록
|
||||
|
||||
```
|
||||
POST /api/orders
|
||||
Body:
|
||||
{
|
||||
inputMode: "customer_first" | "quotation" | "unit_price",
|
||||
customerCode: string,
|
||||
deliveryDate?: string,
|
||||
items: Array<{
|
||||
id: string,
|
||||
item_name: string,
|
||||
quantity: number,
|
||||
selling_price: number,
|
||||
amount: number,
|
||||
delivery_date?: string,
|
||||
note?: string
|
||||
}>,
|
||||
memo?: string
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
success: boolean,
|
||||
data?: {
|
||||
orderNumber: string,
|
||||
orderId: number
|
||||
},
|
||||
message?: string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 멀티테넌시 (Multi-Tenancy)
|
||||
|
||||
모든 API 호출은 자동으로 `company_code` 필터링이 적용됩니다.
|
||||
|
||||
- 거래처 검색: 현재 로그인한 사용자의 회사에 속한 거래처만 조회
|
||||
- 품목 검색: 현재 로그인한 사용자의 회사에 속한 품목만 조회
|
||||
- 수주 등록: 자동으로 현재 사용자의 `company_code` 추가
|
||||
|
||||
---
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
### 1. 거래처가 검색되지 않음
|
||||
|
||||
**원인**: `customer_mng` 테이블에 데이터가 없거나 `company_code`가 다름
|
||||
|
||||
**해결**:
|
||||
```sql
|
||||
-- 거래처 데이터 확인
|
||||
SELECT * FROM customer_mng WHERE company_code = 'YOUR_COMPANY_CODE';
|
||||
```
|
||||
|
||||
### 2. 품목이 검색되지 않음
|
||||
|
||||
**원인**: `item_info` 테이블에 데이터가 없거나 `company_code`가 다름
|
||||
|
||||
**해결**:
|
||||
```sql
|
||||
-- 품목 데이터 확인
|
||||
SELECT * FROM item_info WHERE company_code = 'YOUR_COMPANY_CODE';
|
||||
```
|
||||
|
||||
### 3. 수주 등록 실패
|
||||
|
||||
**원인**: 필수 필드 누락 또는 백엔드 API 오류
|
||||
|
||||
**해결**:
|
||||
1. 브라우저 개발자 도구 콘솔 확인
|
||||
2. 네트워크 탭에서 API 응답 확인
|
||||
3. 백엔드 로그 확인
|
||||
|
||||
---
|
||||
|
||||
## 개발 참고 사항
|
||||
|
||||
### 새로운 전용 컴포넌트 추가 시
|
||||
|
||||
1. **범용 컴포넌트 활용**: 기존 범용 컴포넌트를 래핑
|
||||
2. **설정 고정**: 비즈니스 로직에 필요한 설정을 하드코딩
|
||||
3. **Props 최소화**: 외부에서 제어 가능한 최소한의 prop만 노출
|
||||
4. **문서 작성**: README에 사용법 및 고정 설정 명시
|
||||
|
||||
### 예시: 견적 등록 전용 컴포넌트
|
||||
|
||||
```tsx
|
||||
// QuotationCustomerSearch.tsx
|
||||
export function QuotationCustomerSearch({ value, onChange }: Props) {
|
||||
return (
|
||||
<AutocompleteSearchInputComponent
|
||||
tableName="customer_mng" // 고정
|
||||
displayField="customer_name" // 고정
|
||||
valueField="customer_code" // 고정
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일
|
||||
|
||||
- 범용 컴포넌트:
|
||||
- `lib/registry/components/autocomplete-search-input/`
|
||||
- `lib/registry/components/entity-search-input/`
|
||||
- `lib/registry/components/modal-repeater-table/`
|
||||
|
||||
- 백엔드 API:
|
||||
- `backend-node/src/controllers/entitySearchController.ts`
|
||||
- `backend-node/src/controllers/orderController.ts`
|
||||
|
||||
- 계획서:
|
||||
- `수주등록_화면_개발_계획서.md`
|
||||
|
||||
|
|
@ -8,7 +8,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, ResizableDialog, ResizableDialogContent, ResizableDialogHeader } from "@/components/ui/dialog";
|
||||
import { CalendarIcon, File, Upload, X } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
|
|
|
|||
|
|
@ -40,6 +40,12 @@ interface InteractiveScreenViewerProps {
|
|||
tableName?: string;
|
||||
};
|
||||
onSave?: () => Promise<void>;
|
||||
onRefresh?: () => void;
|
||||
onFlowRefresh?: () => void;
|
||||
// 🆕 외부에서 전달받는 사용자 정보 (ScreenModal 등에서 사용)
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
companyCode?: string;
|
||||
}
|
||||
|
||||
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
|
||||
|
|
@ -50,9 +56,26 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
hideLabel = false,
|
||||
screenInfo,
|
||||
onSave,
|
||||
onRefresh,
|
||||
onFlowRefresh,
|
||||
userId: externalUserId,
|
||||
userName: externalUserName,
|
||||
companyCode: externalCompanyCode,
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const { userName, user } = useAuth();
|
||||
const { userName: authUserName, user: authUser } = useAuth();
|
||||
|
||||
// 외부에서 전달받은 사용자 정보가 있으면 우선 사용 (ScreenModal 등에서)
|
||||
const userName = externalUserName || authUserName;
|
||||
const user =
|
||||
externalUserId && externalUserId !== authUser?.userId
|
||||
? {
|
||||
userId: externalUserId,
|
||||
userName: externalUserName || authUserName || "",
|
||||
companyCode: externalCompanyCode || authUser?.companyCode || "",
|
||||
isAdmin: authUser?.isAdmin || false,
|
||||
}
|
||||
: authUser;
|
||||
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
||||
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
|
||||
|
||||
|
|
@ -126,59 +149,55 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
const handleEnterKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
|
||||
// 한글 조합 중이면 무시 (한글 입력 문제 방지)
|
||||
if ((e as any).isComposing || e.keyCode === 229) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// textarea는 제외 (여러 줄 입력)
|
||||
if (target.tagName === "TEXTAREA") {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// input, select 등의 폼 요소에서만 작동
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "SELECT" ||
|
||||
target.getAttribute("role") === "combobox"
|
||||
) {
|
||||
if (target.tagName === "INPUT" || target.tagName === "SELECT" || target.getAttribute("role") === "combobox") {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
// 모든 포커스 가능한 요소 찾기
|
||||
const focusableElements = document.querySelectorAll<HTMLElement>(
|
||||
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [role="combobox"]:not([disabled])'
|
||||
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [role="combobox"]:not([disabled])',
|
||||
);
|
||||
|
||||
|
||||
// 화면에 보이는 순서(Y 좌표 → X 좌표)대로 정렬
|
||||
const focusableArray = Array.from(focusableElements).sort((a, b) => {
|
||||
const rectA = a.getBoundingClientRect();
|
||||
const rectB = b.getBoundingClientRect();
|
||||
|
||||
|
||||
// Y 좌표 차이가 10px 이상이면 Y 좌표로 정렬 (위에서 아래로)
|
||||
if (Math.abs(rectA.top - rectB.top) > 10) {
|
||||
return rectA.top - rectB.top;
|
||||
}
|
||||
|
||||
|
||||
// 같은 줄이면 X 좌표로 정렬 (왼쪽에서 오른쪽으로)
|
||||
return rectA.left - rectB.left;
|
||||
});
|
||||
|
||||
|
||||
const currentIndex = focusableArray.indexOf(target);
|
||||
|
||||
|
||||
if (currentIndex !== -1 && currentIndex < focusableArray.length - 1) {
|
||||
// 다음 요소로 포커스 이동
|
||||
const nextElement = focusableArray[currentIndex + 1];
|
||||
nextElement.focus();
|
||||
|
||||
|
||||
// select() 제거: 한글 입력 시 이전 필드의 마지막 글자가 복사되는 버그 방지
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
document.addEventListener("keydown", handleEnterKey);
|
||||
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEnterKey);
|
||||
};
|
||||
|
|
@ -189,31 +208,26 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
const initAutoInputFields = async () => {
|
||||
for (const comp of allComponents) {
|
||||
// type: "component" 또는 type: "widget" 모두 처리
|
||||
if (comp.type === 'widget' || comp.type === 'component') {
|
||||
if (comp.type === "widget" || comp.type === "component") {
|
||||
const widget = comp as any;
|
||||
const fieldName = widget.columnName || widget.id;
|
||||
|
||||
|
||||
// autoFill 처리 (테이블 조회 기반 자동 입력)
|
||||
if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) {
|
||||
const autoFillConfig = widget.autoFill || (comp as any).autoFill;
|
||||
const currentValue = formData[fieldName];
|
||||
|
||||
if (currentValue === undefined || currentValue === '') {
|
||||
|
||||
if (currentValue === undefined || currentValue === "") {
|
||||
const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig;
|
||||
|
||||
|
||||
// 사용자 정보에서 필터 값 가져오기
|
||||
const userValue = user?.[userField];
|
||||
|
||||
|
||||
if (userValue && sourceTable && filterColumn && displayColumn) {
|
||||
try {
|
||||
const { tableTypeApi } = await import("@/lib/api/screen");
|
||||
const result = await tableTypeApi.getTableRecord(
|
||||
sourceTable,
|
||||
filterColumn,
|
||||
userValue,
|
||||
displayColumn
|
||||
);
|
||||
|
||||
const result = await tableTypeApi.getTableRecord(sourceTable, filterColumn, userValue, displayColumn);
|
||||
|
||||
updateFormData(fieldName, result.value);
|
||||
} catch (error) {
|
||||
console.error(`autoFill 조회 실패: ${fieldName}`, error);
|
||||
|
|
@ -312,6 +326,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
userId={user?.userId} // ✅ 사용자 ID 전달
|
||||
userName={user?.userName} // ✅ 사용자 이름 전달
|
||||
companyCode={user?.companyCode} // ✅ 회사 코드 전달
|
||||
allComponents={allComponents} // 🆕 같은 화면의 모든 컴포넌트 전달 (TableList 자동 감지용)
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(selectedRows, selectedData) => {
|
||||
console.log("🔍 테이블에서 선택된 행 데이터:", selectedData);
|
||||
|
|
@ -324,9 +339,14 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
setFlowSelectedData(selectedData);
|
||||
setFlowSelectedStepId(stepId);
|
||||
}}
|
||||
onRefresh={() => {
|
||||
// 테이블 컴포넌트는 자체적으로 loadData 호출
|
||||
}}
|
||||
onRefresh={
|
||||
onRefresh ||
|
||||
(() => {
|
||||
// 부모로부터 전달받은 onRefresh 또는 기본 동작
|
||||
console.log("🔄 InteractiveScreenViewerDynamic onRefresh 호출");
|
||||
})
|
||||
}
|
||||
onFlowRefresh={onFlowRefresh}
|
||||
onClose={() => {
|
||||
// buttonActions.ts가 이미 처리함
|
||||
}}
|
||||
|
|
@ -350,7 +370,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
return React.cloneElement(element, {
|
||||
style: {
|
||||
...element.props.style,
|
||||
...styleWithoutSize, // width/height 제외한 스타일만 적용
|
||||
...styleWithoutSize, // width/height 제외한 스타일만 적용
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: "100%",
|
||||
|
|
@ -556,8 +576,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
backgroundColor: config?.backgroundColor || comp.style?.backgroundColor,
|
||||
color: config?.textColor || comp.style?.color,
|
||||
// 부모 컨테이너 크기에 맞춤
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{label || "버튼"}
|
||||
|
|
@ -682,18 +702,18 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
|
||||
// ✅ 격자 시스템 잔재 제거: style.width, style.height 무시
|
||||
const { width: styleWidth, height: styleHeight, ...styleWithoutSize } = style;
|
||||
|
||||
|
||||
// TableSearchWidget의 경우 높이를 자동으로 설정
|
||||
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
||||
|
||||
|
||||
const componentStyle = {
|
||||
position: "absolute" as const,
|
||||
left: position?.x || 0,
|
||||
top: position?.y || 0,
|
||||
zIndex: position?.z || 1,
|
||||
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
|
||||
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
|
||||
height: isTableSearchWidget ? "auto" : (size?.height || 10),
|
||||
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
|
||||
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
|
||||
height: isTableSearchWidget ? "auto" : size?.height || 10,
|
||||
minHeight: isTableSearchWidget ? "48px" : undefined,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ interface RealtimePreviewProps {
|
|||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
columnOrder?: string[];
|
||||
|
||||
// 🆕 조건부 컨테이너 높이 변화 콜백
|
||||
onHeightChange?: (componentId: string, newHeight: number) => void;
|
||||
}
|
||||
|
||||
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
|
||||
|
|
@ -123,6 +126,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
onFlowRefresh,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
|
||||
}) => {
|
||||
const [actualHeight, setActualHeight] = React.useState<number | null>(null);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
|
@ -212,19 +216,18 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
// 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래
|
||||
// 🔥 모든 컴포넌트를 픽셀 기준으로 통일 (스케일로만 조정)
|
||||
const getWidth = () => {
|
||||
// table-list는 화면 너비 전체 사용
|
||||
if (component.componentConfig?.type === "table-list") {
|
||||
// 디자인 해상도 기준으로 픽셀 반환
|
||||
const screenWidth = 1920; // 기본 디자인 해상도
|
||||
return `${screenWidth}px`;
|
||||
}
|
||||
|
||||
// 모든 컴포넌트는 size.width 픽셀 사용
|
||||
// 모든 컴포넌트는 size.width 픽셀 사용 (table-list 포함)
|
||||
const width = `${size?.width || 100}px`;
|
||||
return width;
|
||||
};
|
||||
|
||||
const getHeight = () => {
|
||||
// 🆕 조건부 컨테이너는 높이를 자동으로 설정 (내용물에 따라 자동 조정)
|
||||
const isConditionalContainer = (component as any).componentType === "conditional-container";
|
||||
if (isConditionalContainer && !isDesignMode) {
|
||||
return "auto"; // 런타임에서는 내용물 높이에 맞춤
|
||||
}
|
||||
|
||||
// 플로우 위젯의 경우 측정된 높이 사용
|
||||
const isFlowWidget = component.type === "component" && (component as any).componentType === "flow-widget";
|
||||
if (isFlowWidget && actualHeight) {
|
||||
|
|
@ -259,19 +262,26 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
}
|
||||
: component;
|
||||
|
||||
// componentStyle에서 width, height 제거 (size.width, size.height만 사용)
|
||||
const { width: _styleWidth, height: _styleHeight, ...restComponentStyle } = componentStyle || {};
|
||||
|
||||
const baseStyle = {
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
...restComponentStyle, // width/height 제외한 스타일 먼저 적용
|
||||
width: getWidth(), // size.width로 덮어쓰기
|
||||
height: getHeight(), // size.height로 덮어쓰기
|
||||
...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨)
|
||||
width: getWidth(), // getWidth() 우선 (table-list 등 특수 케이스)
|
||||
height: getHeight(), // getHeight() 우선 (flow-widget 등 특수 케이스)
|
||||
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||
right: undefined,
|
||||
};
|
||||
|
||||
// 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제)
|
||||
// if (component.id && isSelected) {
|
||||
// console.log("📐 RealtimePreview baseStyle:", {
|
||||
// componentId: component.id,
|
||||
// componentType: (component as any).componentType || component.type,
|
||||
// sizeWidth: size?.width,
|
||||
// sizeHeight: size?.height,
|
||||
// });
|
||||
// }
|
||||
|
||||
// 🔍 DOM 렌더링 후 실제 크기 측정
|
||||
const innerDivRef = React.useRef<HTMLDivElement>(null);
|
||||
const outerDivRef = React.useRef<HTMLDivElement>(null);
|
||||
|
|
@ -325,7 +335,12 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
(contentRef as any).current = node;
|
||||
}
|
||||
}}
|
||||
className={`${component.type === "component" && (component as any).componentType === "flow-widget" ? "h-auto" : "h-full"} overflow-visible`}
|
||||
className={`${
|
||||
(component.type === "component" && (component as any).componentType === "flow-widget") ||
|
||||
((component as any).componentType === "conditional-container" && !isDesignMode)
|
||||
? "h-auto"
|
||||
: "h-full"
|
||||
} overflow-visible`}
|
||||
style={{ width: "100%", maxWidth: "100%" }}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
|
|
@ -361,6 +376,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
columnOrder={columnOrder}
|
||||
onHeightChange={onHeightChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -525,6 +525,34 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const finalKey = pathParts[pathParts.length - 1];
|
||||
current[finalKey] = value;
|
||||
|
||||
// 🆕 size 변경 시 style도 함께 업데이트 (파란 테두리와 실제 크기 동기화)
|
||||
if (path === "size.width" || path === "size.height" || path === "size") {
|
||||
if (!newComp.style) {
|
||||
newComp.style = {};
|
||||
}
|
||||
|
||||
if (path === "size.width") {
|
||||
newComp.style.width = `${value}px`;
|
||||
} else if (path === "size.height") {
|
||||
newComp.style.height = `${value}px`;
|
||||
} else if (path === "size") {
|
||||
// size 객체 전체가 변경된 경우
|
||||
if (value.width !== undefined) {
|
||||
newComp.style.width = `${value.width}px`;
|
||||
}
|
||||
if (value.height !== undefined) {
|
||||
newComp.style.height = `${value.height}px`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🔄 size 변경 → style 동기화:", {
|
||||
componentId: newComp.id,
|
||||
path,
|
||||
value,
|
||||
updatedStyle: newComp.style,
|
||||
});
|
||||
}
|
||||
|
||||
// gridColumns 변경 시 크기 자동 업데이트 제거 (격자 시스템 제거됨)
|
||||
// if (path === "gridColumns" && prevLayout.gridSettings) {
|
||||
// const updatedSize = updateSizeFromGridColumns(newComp, prevLayout.gridSettings as GridUtilSettings);
|
||||
|
|
@ -2217,7 +2245,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
labelColor: "#212121",
|
||||
labelFontWeight: "500",
|
||||
labelMarginBottom: "4px",
|
||||
width: `${widthPercent}%`, // gridColumns에 맞춘 퍼센트 너비
|
||||
width: `${componentSize.width}px`, // size와 동기화 (픽셀 단위)
|
||||
height: `${componentSize.height}px`, // size와 동기화 (픽셀 단위)
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -66,17 +67,31 @@ type DeletedScreenDefinition = ScreenDefinition & {
|
|||
};
|
||||
|
||||
export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: ScreenListProps) {
|
||||
const { user } = useAuth();
|
||||
const isSuperAdmin = user?.userType === "SUPER_ADMIN" || user?.companyCode === "*";
|
||||
|
||||
const [activeTab, setActiveTab] = useState("active");
|
||||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||
const [deletedScreens, setDeletedScreens] = useState<DeletedScreenDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(true); // 초기 로딩
|
||||
const [isSearching, setIsSearching] = useState(false); // 검색 중 로딩 (포커스 유지)
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||
const [selectedCompanyCode, setSelectedCompanyCode] = useState<string>("all");
|
||||
const [companies, setCompanies] = useState<any[]>([]);
|
||||
const [loadingCompanies, setLoadingCompanies] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isCopyOpen, setIsCopyOpen] = useState(false);
|
||||
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
|
||||
|
||||
// 검색어 디바운스를 위한 타이머 ref
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 첫 로딩 여부를 추적 (한 번만 true)
|
||||
const isFirstLoad = useRef(true);
|
||||
|
||||
// 삭제 관련 상태
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [screenToDelete, setScreenToDelete] = useState<ScreenDefinition | null>(null);
|
||||
|
|
@ -110,7 +125,10 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
screenName: "",
|
||||
description: "",
|
||||
isActive: "Y",
|
||||
tableName: "",
|
||||
});
|
||||
const [tables, setTables] = useState<string[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
|
||||
// 미리보기 관련 상태
|
||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
|
||||
|
|
@ -119,14 +137,75 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
|
||||
const [previewFormData, setPreviewFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 화면 목록 로드 (실제 API)
|
||||
// 최고 관리자인 경우 회사 목록 로드
|
||||
useEffect(() => {
|
||||
if (isSuperAdmin) {
|
||||
loadCompanies();
|
||||
}
|
||||
}, [isSuperAdmin]);
|
||||
|
||||
const loadCompanies = async () => {
|
||||
try {
|
||||
setLoadingCompanies(true);
|
||||
const { apiClient } = await import("@/lib/api/client"); // named export
|
||||
const response = await apiClient.get("/admin/companies");
|
||||
const data = response.data.data || response.data || [];
|
||||
setCompanies(data.map((c: any) => ({
|
||||
companyCode: c.company_code || c.companyCode,
|
||||
companyName: c.company_name || c.companyName,
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error("회사 목록 조회 실패:", error);
|
||||
} finally {
|
||||
setLoadingCompanies(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 검색어 디바운스 처리 (150ms 지연 - 빠른 응답)
|
||||
useEffect(() => {
|
||||
// 이전 타이머 취소
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
// 새 타이머 설정
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
setDebouncedSearchTerm(searchTerm);
|
||||
}, 150);
|
||||
|
||||
// 클린업
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, [searchTerm]);
|
||||
|
||||
// 화면 목록 로드 (실제 API) - debouncedSearchTerm 사용
|
||||
useEffect(() => {
|
||||
let abort = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// 첫 로딩인 경우에만 loading=true, 그 외에는 isSearching=true
|
||||
if (isFirstLoad.current) {
|
||||
setLoading(true);
|
||||
isFirstLoad.current = false; // 첫 로딩 완료 표시
|
||||
} else {
|
||||
setIsSearching(true);
|
||||
}
|
||||
|
||||
if (activeTab === "active") {
|
||||
const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm });
|
||||
const params: any = { page: currentPage, size: 20, searchTerm: debouncedSearchTerm };
|
||||
|
||||
// 최고 관리자이고 특정 회사를 선택한 경우
|
||||
if (isSuperAdmin && selectedCompanyCode !== "all") {
|
||||
params.companyCode = selectedCompanyCode;
|
||||
}
|
||||
|
||||
console.log("🔍 화면 목록 API 호출:", params); // 디버깅용
|
||||
const resp = await screenApi.getScreens(params);
|
||||
console.log("✅ 화면 목록 응답:", resp); // 디버깅용
|
||||
|
||||
if (abort) return;
|
||||
setScreens(resp.data || []);
|
||||
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
||||
|
|
@ -137,7 +216,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
||||
}
|
||||
} catch (e) {
|
||||
// console.error("화면 목록 조회 실패", e);
|
||||
console.error("화면 목록 조회 실패", e);
|
||||
if (activeTab === "active") {
|
||||
setScreens([]);
|
||||
} else {
|
||||
|
|
@ -145,28 +224,38 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
}
|
||||
setTotalPages(1);
|
||||
} finally {
|
||||
if (!abort) setLoading(false);
|
||||
if (!abort) {
|
||||
setLoading(false);
|
||||
setIsSearching(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
abort = true;
|
||||
};
|
||||
}, [currentPage, searchTerm, activeTab]);
|
||||
}, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, isSuperAdmin]);
|
||||
|
||||
const filteredScreens = screens; // 서버 필터 기준 사용
|
||||
|
||||
// 화면 목록 다시 로드
|
||||
const reloadScreens = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm });
|
||||
setIsSearching(true);
|
||||
const params: any = { page: currentPage, size: 20, searchTerm: debouncedSearchTerm };
|
||||
|
||||
// 최고 관리자이고 특정 회사를 선택한 경우
|
||||
if (isSuperAdmin && selectedCompanyCode !== "all") {
|
||||
params.companyCode = selectedCompanyCode;
|
||||
}
|
||||
|
||||
const resp = await screenApi.getScreens(params);
|
||||
setScreens(resp.data || []);
|
||||
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
||||
} catch (e) {
|
||||
// console.error("화면 목록 조회 실패", e);
|
||||
console.error("화면 목록 조회 실패", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -174,14 +263,31 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
onScreenSelect(screen);
|
||||
};
|
||||
|
||||
const handleEdit = (screen: ScreenDefinition) => {
|
||||
const handleEdit = async (screen: ScreenDefinition) => {
|
||||
setScreenToEdit(screen);
|
||||
setEditFormData({
|
||||
screenName: screen.screenName,
|
||||
description: screen.description || "",
|
||||
isActive: screen.isActive,
|
||||
tableName: screen.tableName || "",
|
||||
});
|
||||
setEditDialogOpen(true);
|
||||
|
||||
// 테이블 목록 로드
|
||||
try {
|
||||
setLoadingTables(true);
|
||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
// tableName만 추출 (camelCase)
|
||||
const tableNames = response.data.map((table: any) => table.tableName);
|
||||
setTables(tableNames);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditSave = async () => {
|
||||
|
|
@ -405,18 +511,48 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
<div className="space-y-4">
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="w-full sm:w-[400px]">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="화면명, 코드, 테이블명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
disabled={activeTab === "trash"}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
{/* 최고 관리자 전용: 회사 필터 */}
|
||||
{isSuperAdmin && (
|
||||
<div className="w-full sm:w-[200px]">
|
||||
<Select value={selectedCompanyCode} onValueChange={setSelectedCompanyCode} disabled={activeTab === "trash"}>
|
||||
<SelectTrigger className="h-10 text-sm">
|
||||
<SelectValue placeholder="전체 회사" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 회사</SelectItem>
|
||||
{companies.map((company) => (
|
||||
<SelectItem key={company.companyCode} value={company.companyCode}>
|
||||
{company.companyName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="w-full sm:w-[400px]">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
key="screen-search-input" // 리렌더링 시에도 동일한 Input 유지
|
||||
placeholder="화면명, 코드, 테이블명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
disabled={activeTab === "trash"}
|
||||
/>
|
||||
{/* 검색 중 인디케이터 */}
|
||||
{isSearching && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
disabled={activeTab === "trash"}
|
||||
|
|
@ -1064,6 +1200,25 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
placeholder="화면명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-tableName">테이블 *</Label>
|
||||
<Select
|
||||
value={editFormData.tableName}
|
||||
onValueChange={(value) => setEditFormData({ ...editFormData, tableName: value })}
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<SelectTrigger id="edit-tableName">
|
||||
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블을 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((tableName) => (
|
||||
<SelectItem key={tableName} value={tableName}>
|
||||
{tableName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-description">설명</Label>
|
||||
<Textarea
|
||||
|
|
@ -1094,7 +1249,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleEditSave} disabled={!editFormData.screenName.trim()}>
|
||||
<Button onClick={handleEditSave} disabled={!editFormData.screenName.trim() || !editFormData.tableName.trim()}>
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import { Switch } from "@/components/ui/switch";
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, ChevronsUpDown, Search } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Check, ChevronsUpDown, Search, Plus, X, ChevronUp, ChevronDown, Type, Database } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
|
@ -16,6 +17,15 @@ import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
|
|||
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
|
||||
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
|
||||
|
||||
// 🆕 제목 블록 타입
|
||||
interface TitleBlock {
|
||||
id: string;
|
||||
type: "text" | "field";
|
||||
value: string; // text: 텍스트 내용, field: 컬럼명
|
||||
tableName?: string; // field일 때 테이블명
|
||||
label?: string; // field일 때 표시용 라벨
|
||||
}
|
||||
|
||||
interface ButtonConfigPanelProps {
|
||||
component: ComponentData;
|
||||
onUpdateProperty: (path: string, value: any) => void;
|
||||
|
|
@ -64,6 +74,15 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
const [displayColumnOpen, setDisplayColumnOpen] = useState(false);
|
||||
const [displayColumnSearch, setDisplayColumnSearch] = useState("");
|
||||
|
||||
// 🆕 제목 블록 빌더 상태
|
||||
const [titleBlocks, setTitleBlocks] = useState<TitleBlock[]>([]);
|
||||
const [availableTables, setAvailableTables] = useState<Array<{ name: string; label: string }>>([]); // 시스템의 모든 테이블 목록
|
||||
const [tableColumnsMap, setTableColumnsMap] = useState<Record<string, Array<{ name: string; label: string }>>>({});
|
||||
const [blockTableSearches, setBlockTableSearches] = useState<Record<string, string>>({}); // 블록별 테이블 검색어
|
||||
const [blockColumnSearches, setBlockColumnSearches] = useState<Record<string, string>>({}); // 블록별 컬럼 검색어
|
||||
const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태
|
||||
const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태
|
||||
|
||||
// 🎯 플로우 위젯이 화면에 있는지 확인
|
||||
const hasFlowWidget = useMemo(() => {
|
||||
const found = allComponents.some((comp: any) => {
|
||||
|
|
@ -95,9 +114,150 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
editModalDescription: String(latestAction.editModalDescription || ""),
|
||||
targetUrl: String(latestAction.targetUrl || ""),
|
||||
});
|
||||
|
||||
// 🆕 제목 블록 초기화
|
||||
if (latestAction.modalTitleBlocks && latestAction.modalTitleBlocks.length > 0) {
|
||||
setTitleBlocks(latestAction.modalTitleBlocks);
|
||||
} else {
|
||||
// 기본값: 빈 배열
|
||||
setTitleBlocks([]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [component.id]);
|
||||
|
||||
// 🆕 제목 블록 핸들러
|
||||
const addTextBlock = () => {
|
||||
const newBlock: TitleBlock = {
|
||||
id: `text-${Date.now()}`,
|
||||
type: "text",
|
||||
value: "",
|
||||
};
|
||||
const updatedBlocks = [...titleBlocks, newBlock];
|
||||
setTitleBlocks(updatedBlocks);
|
||||
onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks);
|
||||
};
|
||||
|
||||
const addFieldBlock = () => {
|
||||
const newBlock: TitleBlock = {
|
||||
id: `field-${Date.now()}`,
|
||||
type: "field",
|
||||
value: "",
|
||||
tableName: "",
|
||||
label: "",
|
||||
};
|
||||
const updatedBlocks = [...titleBlocks, newBlock];
|
||||
setTitleBlocks(updatedBlocks);
|
||||
onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks);
|
||||
};
|
||||
|
||||
const updateBlock = (id: string, updates: Partial<TitleBlock>) => {
|
||||
const updatedBlocks = titleBlocks.map((block) =>
|
||||
block.id === id ? { ...block, ...updates } : block
|
||||
);
|
||||
setTitleBlocks(updatedBlocks);
|
||||
onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks);
|
||||
};
|
||||
|
||||
const removeBlock = (id: string) => {
|
||||
const updatedBlocks = titleBlocks.filter((block) => block.id !== id);
|
||||
setTitleBlocks(updatedBlocks);
|
||||
onUpdateProperty("componentConfig.action.modalTitleBlocks", updatedBlocks);
|
||||
};
|
||||
|
||||
const moveBlockUp = (id: string) => {
|
||||
const index = titleBlocks.findIndex((b) => b.id === id);
|
||||
if (index <= 0) return;
|
||||
const newBlocks = [...titleBlocks];
|
||||
[newBlocks[index - 1], newBlocks[index]] = [newBlocks[index], newBlocks[index - 1]];
|
||||
setTitleBlocks(newBlocks);
|
||||
onUpdateProperty("componentConfig.action.modalTitleBlocks", newBlocks);
|
||||
};
|
||||
|
||||
const moveBlockDown = (id: string) => {
|
||||
const index = titleBlocks.findIndex((b) => b.id === id);
|
||||
if (index < 0 || index >= titleBlocks.length - 1) return;
|
||||
const newBlocks = [...titleBlocks];
|
||||
[newBlocks[index], newBlocks[index + 1]] = [newBlocks[index + 1], newBlocks[index]];
|
||||
setTitleBlocks(newBlocks);
|
||||
onUpdateProperty("componentConfig.action.modalTitleBlocks", newBlocks);
|
||||
};
|
||||
|
||||
// 🆕 제목 미리보기 생성
|
||||
const generateTitlePreview = (): string => {
|
||||
if (titleBlocks.length === 0) return "(제목 없음)";
|
||||
return titleBlocks
|
||||
.map((block) => {
|
||||
if (block.type === "text") {
|
||||
return block.value || "(텍스트)";
|
||||
} else {
|
||||
return block.label || block.value || "(필드)";
|
||||
}
|
||||
})
|
||||
.join("");
|
||||
};
|
||||
|
||||
// 🆕 시스템의 모든 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const fetchAllTables = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/table-management/tables");
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const tables = response.data.data.map((table: any) => ({
|
||||
name: table.tableName,
|
||||
label: table.displayName || table.tableName,
|
||||
}));
|
||||
setAvailableTables(tables);
|
||||
console.log(`✅ 전체 테이블 목록 로드 성공:`, tables.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAllTables();
|
||||
}, []);
|
||||
|
||||
// 🆕 특정 테이블의 컬럼 로드
|
||||
const loadTableColumns = async (tableName: string) => {
|
||||
if (!tableName || tableColumnsMap[tableName]) return;
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
console.log(`📥 테이블 ${tableName} 컬럼 응답:`, response.data);
|
||||
|
||||
if (response.data.success) {
|
||||
// data가 배열인지 확인
|
||||
let columnData = response.data.data;
|
||||
|
||||
// data.columns 형태일 수도 있음
|
||||
if (!Array.isArray(columnData) && columnData?.columns) {
|
||||
columnData = columnData.columns;
|
||||
}
|
||||
|
||||
// data.data 형태일 수도 있음
|
||||
if (!Array.isArray(columnData) && columnData?.data) {
|
||||
columnData = columnData.data;
|
||||
}
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => {
|
||||
const name = col.name || col.columnName;
|
||||
const label = col.displayName || col.label || col.columnLabel || name;
|
||||
console.log(` - 컬럼: ${name} → "${label}"`);
|
||||
return { name, label };
|
||||
});
|
||||
setTableColumnsMap((prev) => ({ ...prev, [tableName]: columns }));
|
||||
console.log(`✅ 테이블 ${tableName} 컬럼 로드 성공:`, columns.length, "개");
|
||||
} else {
|
||||
console.error("❌ 컬럼 데이터가 배열이 아닙니다:", columnData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
|
||||
useEffect(() => {
|
||||
const fetchScreens = async () => {
|
||||
|
|
@ -274,6 +434,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<SelectItem value="edit">편집</SelectItem>
|
||||
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
||||
<SelectItem value="navigate">페이지 이동</SelectItem>
|
||||
<SelectItem value="openModalWithData">데이터 전달 + 모달 열기 🆕</SelectItem>
|
||||
<SelectItem value="modal">모달 열기</SelectItem>
|
||||
<SelectItem value="control">제어 흐름</SelectItem>
|
||||
<SelectItem value="view_table_history">테이블 이력 보기</SelectItem>
|
||||
|
|
@ -409,6 +570,400 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 🆕 데이터 전달 + 모달 열기 액션 설정 */}
|
||||
{component.componentConfig?.action?.type === "openModalWithData" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-blue-50 p-4 dark:bg-blue-950/20">
|
||||
<h4 className="text-sm font-medium text-foreground">데이터 전달 + 모달 설정</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
TableList에서 선택된 데이터를 다음 모달로 전달합니다
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="data-source-id">
|
||||
데이터 소스 ID <span className="text-primary">(선택사항)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="data-source-id"
|
||||
placeholder="비워두면 자동으로 감지됩니다"
|
||||
value={component.componentConfig?.action?.dataSourceId || ""}
|
||||
onChange={(e) => {
|
||||
onUpdateProperty("componentConfig.action.dataSourceId", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-primary font-medium">
|
||||
✨ 비워두면 현재 화면의 TableList를 자동으로 감지합니다
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
• 자동 감지: 현재 화면의 TableList 선택 데이터<br/>
|
||||
• 누적 전달: 이전 모달의 모든 데이터도 자동으로 함께 전달<br/>
|
||||
• 다음 화면에서 tableName으로 바로 사용 가능<br/>
|
||||
• 수동 설정: 필요시 직접 테이블명 입력 (예: item_info)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 🆕 블록 기반 제목 빌더 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>모달 제목 구성</Label>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addTextBlock}
|
||||
className="h-6 text-xs"
|
||||
>
|
||||
<Type className="mr-1 h-3 w-3" />
|
||||
텍스트 추가
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addFieldBlock}
|
||||
className="h-6 text-xs"
|
||||
>
|
||||
<Database className="mr-1 h-3 w-3" />
|
||||
필드 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 블록 목록 */}
|
||||
<div className="space-y-2">
|
||||
{titleBlocks.length === 0 ? (
|
||||
<div className="text-center py-4 text-xs text-muted-foreground border-2 border-dashed rounded">
|
||||
텍스트나 필드를 추가하여 제목을 구성하세요
|
||||
</div>
|
||||
) : (
|
||||
titleBlocks.map((block, index) => (
|
||||
<Card key={block.id} className="p-2">
|
||||
<div className="flex items-start gap-2">
|
||||
{/* 순서 변경 버튼 */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => moveBlockUp(block.id)}
|
||||
disabled={index === 0}
|
||||
className="h-5 w-5 p-0"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => moveBlockDown(block.id)}
|
||||
disabled={index === titleBlocks.length - 1}
|
||||
className="h-5 w-5 p-0"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 블록 타입 표시 */}
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{block.type === "text" ? (
|
||||
<Type className="h-4 w-4 text-blue-500" />
|
||||
) : (
|
||||
<Database className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 블록 설정 */}
|
||||
<div className="flex-1 space-y-2">
|
||||
{block.type === "text" ? (
|
||||
// 텍스트 블록
|
||||
<Input
|
||||
placeholder="텍스트 입력 (예: 품목 상세정보 - )"
|
||||
value={block.value}
|
||||
onChange={(e) => updateBlock(block.id, { value: e.target.value })}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
) : (
|
||||
// 필드 블록
|
||||
<>
|
||||
{/* 테이블 선택 - Combobox */}
|
||||
<Popover
|
||||
open={blockTablePopoverOpen[block.id] || false}
|
||||
onOpenChange={(open) => {
|
||||
setBlockTablePopoverOpen((prev) => ({ ...prev, [block.id]: open }));
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
>
|
||||
{block.tableName
|
||||
? (availableTables.find((t) => t.name === block.tableName)?.label || block.tableName)
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="테이블 검색 (라벨 또는 이름)..."
|
||||
className="h-7 text-xs"
|
||||
value={blockTableSearches[block.id] || ""}
|
||||
onValueChange={(value) => {
|
||||
setBlockTableSearches((prev) => ({ ...prev, [block.id]: value }));
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables
|
||||
.filter((table) => {
|
||||
const search = (blockTableSearches[block.id] || "").toLowerCase();
|
||||
if (!search) return true;
|
||||
return (
|
||||
table.label.toLowerCase().includes(search) ||
|
||||
table.name.toLowerCase().includes(search)
|
||||
);
|
||||
})
|
||||
.map((table) => (
|
||||
<CommandItem
|
||||
key={table.name}
|
||||
value={table.name}
|
||||
onSelect={() => {
|
||||
updateBlock(block.id, { tableName: table.name, value: "" });
|
||||
loadTableColumns(table.name);
|
||||
setBlockTableSearches((prev) => ({ ...prev, [block.id]: "" }));
|
||||
setBlockTablePopoverOpen((prev) => ({ ...prev, [block.id]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
block.tableName === table.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{table.label}</span>
|
||||
<span className="ml-2 text-[10px] text-muted-foreground">({table.name})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{block.tableName && (
|
||||
<>
|
||||
{/* 컬럼 선택 - Combobox (라벨명 표시) */}
|
||||
<Popover
|
||||
open={blockColumnPopoverOpen[block.id] || false}
|
||||
onOpenChange={(open) => {
|
||||
setBlockColumnPopoverOpen((prev) => ({ ...prev, [block.id]: open }));
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
>
|
||||
{block.value
|
||||
? (tableColumnsMap[block.tableName]?.find((c) => c.name === block.value)?.label || block.value)
|
||||
: "컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색 (라벨 또는 이름)..."
|
||||
className="h-7 text-xs"
|
||||
value={blockColumnSearches[block.id] || ""}
|
||||
onValueChange={(value) => {
|
||||
setBlockColumnSearches((prev) => ({ ...prev, [block.id]: value }));
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{(tableColumnsMap[block.tableName] || [])
|
||||
.filter((col) => {
|
||||
const search = (blockColumnSearches[block.id] || "").toLowerCase();
|
||||
if (!search) return true;
|
||||
return (
|
||||
col.label.toLowerCase().includes(search) ||
|
||||
col.name.toLowerCase().includes(search)
|
||||
);
|
||||
})
|
||||
.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={col.name}
|
||||
onSelect={() => {
|
||||
updateBlock(block.id, {
|
||||
value: col.name,
|
||||
label: col.label,
|
||||
});
|
||||
setBlockColumnSearches((prev) => ({ ...prev, [block.id]: "" }));
|
||||
setBlockColumnPopoverOpen((prev) => ({ ...prev, [block.id]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
block.value === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{col.label}</span>
|
||||
<span className="ml-2 text-[10px] text-muted-foreground">({col.name})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Input
|
||||
placeholder="표시 라벨 (예: 품목명)"
|
||||
value={block.label || ""}
|
||||
onChange={(e) => updateBlock(block.id, { label: e.target.value })}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeBlock(block.id)}
|
||||
className="h-7 w-7 p-0 text-red-500"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
{titleBlocks.length > 0 && (
|
||||
<div className="mt-2 p-2 bg-muted rounded text-xs">
|
||||
<span className="text-muted-foreground">미리보기: </span>
|
||||
<span className="font-medium">{generateTitlePreview()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
• 텍스트: 고정 텍스트 입력 (예: "품목 상세정보 - ")<br/>
|
||||
• 필드: 이전 화면 데이터로 자동 채워짐 (예: 품목명, 규격)<br/>
|
||||
• 순서 변경: ↑↓ 버튼으로 자유롭게 배치<br/>
|
||||
• 데이터가 없으면 "표시 라벨"이 대신 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="modal-size-with-data">모달 크기</Label>
|
||||
<Select
|
||||
value={component.componentConfig?.action?.modalSize || "lg"}
|
||||
onValueChange={(value) => {
|
||||
onUpdateProperty("componentConfig.action.modalSize", value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="모달 크기 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sm">작음 (Small)</SelectItem>
|
||||
<SelectItem value="md">보통 (Medium)</SelectItem>
|
||||
<SelectItem value="lg">큼 (Large) - 권장</SelectItem>
|
||||
<SelectItem value="xl">매우 큼 (Extra Large)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="target-screen-with-data">대상 화면 선택</Label>
|
||||
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={modalScreenOpen}
|
||||
className="h-6 w-full justify-between px-2 py-0"
|
||||
style={{ fontSize: "12px" }}
|
||||
disabled={screensLoading}
|
||||
>
|
||||
{config.action?.targetScreenId
|
||||
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
|
||||
"화면을 선택하세요..."
|
||||
: "화면을 선택하세요..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center border-b px-3 py-2">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<Input
|
||||
placeholder="화면 검색..."
|
||||
value={modalSearchTerm}
|
||||
onChange={(e) => setModalSearchTerm(e.target.value)}
|
||||
className="border-0 p-0 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[200px] overflow-auto">
|
||||
{(() => {
|
||||
const filteredScreens = filterScreens(modalSearchTerm);
|
||||
if (screensLoading) {
|
||||
return <div className="p-3 text-sm text-muted-foreground">화면 목록을 불러오는 중...</div>;
|
||||
}
|
||||
if (filteredScreens.length === 0) {
|
||||
return <div className="p-3 text-sm text-muted-foreground">검색 결과가 없습니다.</div>;
|
||||
}
|
||||
return filteredScreens.map((screen, index) => (
|
||||
<div
|
||||
key={`modal-data-screen-${screen.id}-${index}`}
|
||||
className="flex cursor-pointer items-center px-3 py-2 hover:bg-muted"
|
||||
onClick={() => {
|
||||
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
|
||||
setModalScreenOpen(false);
|
||||
setModalSearchTerm("");
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{screen.name}</span>
|
||||
{screen.description && <span className="text-xs text-muted-foreground">{screen.description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
SelectedItemsDetailInput 컴포넌트가 있는 화면을 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 수정 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "edit" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-success/10 p-4">
|
||||
|
|
|
|||
|
|
@ -186,75 +186,93 @@ export function DataFilterConfigPanel({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">컬럼</Label>
|
||||
<Select
|
||||
value={filter.columnName}
|
||||
onValueChange={(value) => {
|
||||
const column = columns.find((col) => col.columnName === value);
|
||||
|
||||
console.log("🔍 컬럼 선택:", {
|
||||
columnName: value,
|
||||
input_type: column?.input_type,
|
||||
column,
|
||||
});
|
||||
|
||||
// 컬럼 타입에 따라 valueType 자동 설정
|
||||
let valueType: "static" | "category" | "code" = "static";
|
||||
if (column?.input_type === "category") {
|
||||
valueType = "category";
|
||||
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
|
||||
loadCategoryValues(value); // 카테고리 값 로드
|
||||
} else if (column?.input_type === "code") {
|
||||
valueType = "code";
|
||||
}
|
||||
|
||||
// 한 번에 모든 변경사항 적용
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id
|
||||
? { ...f, columnName: value, valueType, value: "" }
|
||||
: f
|
||||
),
|
||||
};
|
||||
|
||||
console.log("✅ 필터 설정 업데이트:", {
|
||||
filterId: filter.id,
|
||||
columnName: value,
|
||||
valueType,
|
||||
newConfig,
|
||||
});
|
||||
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
{(col.input_type === "category" || col.input_type === "code") && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({col.input_type})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */}
|
||||
{filter.operator !== "date_range_contains" && (
|
||||
<div>
|
||||
<Label className="text-xs">컬럼</Label>
|
||||
<Select
|
||||
value={filter.columnName}
|
||||
onValueChange={(value) => {
|
||||
const column = columns.find((col) => col.columnName === value);
|
||||
|
||||
console.log("🔍 컬럼 선택:", {
|
||||
columnName: value,
|
||||
input_type: column?.input_type,
|
||||
column,
|
||||
});
|
||||
|
||||
// 컬럼 타입에 따라 valueType 자동 설정
|
||||
let valueType: "static" | "category" | "code" = "static";
|
||||
if (column?.input_type === "category") {
|
||||
valueType = "category";
|
||||
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
|
||||
loadCategoryValues(value); // 카테고리 값 로드
|
||||
} else if (column?.input_type === "code") {
|
||||
valueType = "code";
|
||||
}
|
||||
|
||||
// 한 번에 모든 변경사항 적용
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id
|
||||
? { ...f, columnName: value, valueType, value: "" }
|
||||
: f
|
||||
),
|
||||
};
|
||||
|
||||
console.log("✅ 필터 설정 업데이트:", {
|
||||
filterId: filter.id,
|
||||
columnName: value,
|
||||
valueType,
|
||||
newConfig,
|
||||
});
|
||||
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
{(col.input_type === "category" || col.input_type === "code") && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({col.input_type})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">연산자</Label>
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(value: any) => handleFilterChange(filter.id, "operator", value)}
|
||||
onValueChange={(value: any) => {
|
||||
// date_range_contains 선택 시 한 번에 모든 변경사항 적용
|
||||
if (value === "date_range_contains") {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id
|
||||
? { ...f, operator: value, valueType: "dynamic", value: "TODAY" }
|
||||
: f
|
||||
),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
} else {
|
||||
handleFilterChange(filter.id, "operator", value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
|
|
@ -262,6 +280,11 @@ export function DataFilterConfigPanel({
|
|||
<SelectContent>
|
||||
<SelectItem value="equals">같음 (=)</SelectItem>
|
||||
<SelectItem value="not_equals">같지 않음 (≠)</SelectItem>
|
||||
<SelectItem value="greater_than">크다 (>)</SelectItem>
|
||||
<SelectItem value="less_than">작다 (<)</SelectItem>
|
||||
<SelectItem value="greater_than_or_equal">크거나 같다 (≥)</SelectItem>
|
||||
<SelectItem value="less_than_or_equal">작거나 같다 (≤)</SelectItem>
|
||||
<SelectItem value="between">사이 (BETWEEN)</SelectItem>
|
||||
<SelectItem value="in">포함됨 (IN)</SelectItem>
|
||||
<SelectItem value="not_in">포함되지 않음 (NOT IN)</SelectItem>
|
||||
<SelectItem value="contains">포함 (LIKE %value%)</SelectItem>
|
||||
|
|
@ -269,34 +292,138 @@ export function DataFilterConfigPanel({
|
|||
<SelectItem value="ends_with">끝 (LIKE %value)</SelectItem>
|
||||
<SelectItem value="is_null">NULL</SelectItem>
|
||||
<SelectItem value="is_not_null">NOT NULL</SelectItem>
|
||||
<SelectItem value="date_range_contains">날짜 범위 포함 (기간 내)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 값 타입 선택 (카테고리/코드 컬럼만) */}
|
||||
{isCategoryOrCodeColumn(filter.columnName) && (
|
||||
{/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */}
|
||||
{filter.operator === "date_range_contains" && (
|
||||
<>
|
||||
<div className="col-span-2">
|
||||
<p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
|
||||
💡 날짜 범위 필터링 규칙:
|
||||
<br />• 시작일만 있고 종료일이 NULL → 시작일 이후 모든 데이터
|
||||
<br />• 종료일만 있고 시작일이 NULL → 종료일 이전 모든 데이터
|
||||
<br />• 둘 다 있으면 → 기간 내 데이터만
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">시작일 컬럼</Label>
|
||||
<Select
|
||||
value={filter.rangeConfig?.startColumn || ""}
|
||||
onValueChange={(value) => {
|
||||
const newRangeConfig = {
|
||||
...filter.rangeConfig,
|
||||
startColumn: value,
|
||||
endColumn: filter.rangeConfig?.endColumn || "",
|
||||
};
|
||||
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="시작일 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.filter(col =>
|
||||
col.dataType?.toLowerCase().includes('date') ||
|
||||
col.dataType?.toLowerCase().includes('time')
|
||||
).map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">종료일 컬럼</Label>
|
||||
<Select
|
||||
value={filter.rangeConfig?.endColumn || ""}
|
||||
onValueChange={(value) => {
|
||||
const newRangeConfig = {
|
||||
...filter.rangeConfig,
|
||||
startColumn: filter.rangeConfig?.startColumn || "",
|
||||
endColumn: value,
|
||||
};
|
||||
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="종료일 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.filter(col =>
|
||||
col.dataType?.toLowerCase().includes('date') ||
|
||||
col.dataType?.toLowerCase().includes('time')
|
||||
).map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */}
|
||||
{(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && (
|
||||
<div>
|
||||
<Label className="text-xs">값 타입</Label>
|
||||
<Select
|
||||
value={filter.valueType}
|
||||
onValueChange={(value: any) =>
|
||||
handleFilterChange(filter.id, "valueType", value)
|
||||
}
|
||||
onValueChange={(value: any) => {
|
||||
// dynamic 선택 시 한 번에 valueType과 value를 설정
|
||||
if (value === "dynamic" && filter.operator === "date_range_contains") {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id
|
||||
? { ...f, valueType: value, value: "TODAY" }
|
||||
: f
|
||||
),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
} else {
|
||||
// static이나 다른 타입은 value를 빈 문자열로 초기화
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id
|
||||
? { ...f, valueType: value, value: "" }
|
||||
: f
|
||||
),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">직접 입력</SelectItem>
|
||||
<SelectItem value="category">카테고리 선택</SelectItem>
|
||||
<SelectItem value="code">코드 선택</SelectItem>
|
||||
{filter.operator === "date_range_contains" && (
|
||||
<SelectItem value="dynamic">동적 값 (오늘 날짜)</SelectItem>
|
||||
)}
|
||||
{isCategoryOrCodeColumn(filter.columnName) && (
|
||||
<>
|
||||
<SelectItem value="category">카테고리 선택</SelectItem>
|
||||
<SelectItem value="code">코드 선택</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 값 입력 (NULL 체크 제외) */}
|
||||
{filter.operator !== "is_null" && filter.operator !== "is_not_null" && (
|
||||
{/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */}
|
||||
{filter.operator !== "is_null" &&
|
||||
filter.operator !== "is_not_null" &&
|
||||
!(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && (
|
||||
<div>
|
||||
<Label className="text-xs">값</Label>
|
||||
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
|
||||
|
|
@ -328,11 +455,22 @@ export function DataFilterConfigPanel({
|
|||
placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
) : filter.operator === "between" ? (
|
||||
<Input
|
||||
value={Array.isArray(filter.value) ? filter.value.join(" ~ ") : filter.value}
|
||||
onChange={(e) => {
|
||||
const values = e.target.value.split("~").map((v) => v.trim());
|
||||
handleFilterChange(filter.id, "value", values.length === 2 ? values : [values[0] || "", ""]);
|
||||
}}
|
||||
placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={filter.operator === "date_range_contains" ? "date" : "text"}
|
||||
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
|
||||
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
|
||||
placeholder="필터 값 입력"
|
||||
placeholder={filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -341,10 +479,23 @@ export function DataFilterConfigPanel({
|
|||
? "카테고리 값을 선택하세요"
|
||||
: filter.operator === "in" || filter.operator === "not_in"
|
||||
? "여러 값은 쉼표(,)로 구분하세요"
|
||||
: filter.operator === "between"
|
||||
? "시작과 종료 값을 ~로 구분하세요"
|
||||
: filter.operator === "date_range_contains"
|
||||
? "기간 내에 포함되는지 확인할 날짜를 선택하세요"
|
||||
: "필터링할 값을 입력하세요"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* date_range_contains의 dynamic 타입 안내 */}
|
||||
{filter.operator === "date_range_contains" && filter.valueType === "dynamic" && (
|
||||
<div className="rounded-md bg-blue-50 p-2">
|
||||
<p className="text-[10px] text-blue-700">
|
||||
ℹ️ 오늘 날짜를 기준으로 기간 내 데이터를 필터링합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -63,8 +63,9 @@ export function ComponentsPanel({
|
|||
),
|
||||
action: allComponents.filter((c) => c.category === ComponentCategory.ACTION),
|
||||
display: allComponents.filter((c) => c.category === ComponentCategory.DISPLAY),
|
||||
data: allComponents.filter((c) => c.category === ComponentCategory.DATA), // 🆕 데이터 카테고리 추가
|
||||
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT),
|
||||
utility: allComponents.filter((c) => c.category === ComponentCategory.UTILITY), // 🆕 유틸리티 카테고리 추가
|
||||
utility: allComponents.filter((c) => c.category === ComponentCategory.UTILITY),
|
||||
};
|
||||
}, [allComponents]);
|
||||
|
||||
|
|
@ -92,6 +93,8 @@ export function ComponentsPanel({
|
|||
return <Palette className="h-6 w-6" />;
|
||||
case "action":
|
||||
return <Zap className="h-6 w-6" />;
|
||||
case "data":
|
||||
return <Database className="h-6 w-6" />;
|
||||
case "layout":
|
||||
return <Layers className="h-6 w-6" />;
|
||||
case "utility":
|
||||
|
|
@ -185,7 +188,7 @@ export function ComponentsPanel({
|
|||
|
||||
{/* 카테고리 탭 */}
|
||||
<Tabs defaultValue="input" className="flex min-h-0 flex-1 flex-col">
|
||||
<TabsList className="mb-3 grid h-8 w-full flex-shrink-0 grid-cols-6 gap-1 p-1">
|
||||
<TabsList className="mb-3 grid h-8 w-full flex-shrink-0 grid-cols-7 gap-1 p-1">
|
||||
<TabsTrigger
|
||||
value="tables"
|
||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||
|
|
@ -198,6 +201,14 @@ export function ComponentsPanel({
|
|||
<Edit3 className="h-3 w-3" />
|
||||
<span className="hidden">입력</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="data"
|
||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||
title="데이터"
|
||||
>
|
||||
<Grid className="h-3 w-3" />
|
||||
<span className="hidden">데이터</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="action"
|
||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||
|
|
@ -260,6 +271,13 @@ export function ComponentsPanel({
|
|||
: renderEmptyState()}
|
||||
</TabsContent>
|
||||
|
||||
{/* 데이터 컴포넌트 */}
|
||||
<TabsContent value="data" className="mt-0 flex-1 space-y-2 overflow-y-auto">
|
||||
{getFilteredComponents("data").length > 0
|
||||
? getFilteredComponents("data").map(renderComponentCard)
|
||||
: renderEmptyState()}
|
||||
</TabsContent>
|
||||
|
||||
{/* 액션 컴포넌트 */}
|
||||
<TabsContent value="action" className="mt-0 flex-1 space-y-2 overflow-y-auto">
|
||||
{getFilteredComponents("action").length > 0
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ import { BadgeConfigPanel } from "../config-panels/BadgeConfigPanel";
|
|||
// 동적 컴포넌트 설정 패널
|
||||
import { DynamicComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
|
||||
|
||||
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
|
||||
interface DetailSettingsPanelProps {
|
||||
selectedComponent?: ComponentData;
|
||||
onUpdateProperty: (componentId: string, path: string, value: any) => void;
|
||||
|
|
@ -859,6 +862,57 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onUpdateProperty(selectedComponent.id, path, value);
|
||||
};
|
||||
|
||||
const handleConfigChange = (newConfig: any) => {
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
|
||||
};
|
||||
|
||||
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기
|
||||
const componentId = selectedComponent.componentConfig?.type || selectedComponent.componentConfig?.id;
|
||||
if (componentId) {
|
||||
const definition = ComponentRegistry.getComponent(componentId);
|
||||
if (definition?.configPanel) {
|
||||
const ConfigPanelComponent = definition.configPanel;
|
||||
const currentConfig = selectedComponent.componentConfig || {};
|
||||
|
||||
console.log("✅ ConfigPanel 표시:", {
|
||||
componentId,
|
||||
definitionName: definition.name,
|
||||
hasConfigPanel: !!definition.configPanel,
|
||||
currentConfig,
|
||||
});
|
||||
|
||||
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
|
||||
const ConfigPanelWrapper = () => {
|
||||
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
|
||||
const config = currentConfig || definition.defaultProps?.componentConfig || {};
|
||||
|
||||
const handleConfigChange = (newConfig: any) => {
|
||||
// componentConfig 전체를 업데이트
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Settings className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||
</div>
|
||||
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return <ConfigPanelWrapper key={selectedComponent.id} />;
|
||||
} else {
|
||||
console.warn("⚠️ ConfigPanel 없음:", {
|
||||
componentId,
|
||||
definitionName: definition?.name,
|
||||
hasDefinition: !!definition,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 하드코딩된 설정 패널들 (레거시)
|
||||
switch (componentType) {
|
||||
case "button":
|
||||
case "button-primary":
|
||||
|
|
@ -904,8 +958,10 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||
<Settings className="mb-4 h-12 w-12 text-gray-400" />
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">설정 패널 준비 중</h3>
|
||||
<p className="text-sm text-gray-500">컴포넌트 타입 "{componentType}"의 설정 패널이 준비 중입니다.</p>
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">⚠️ 설정 패널 없음</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
컴포넌트 "{componentId || componentType}"에 대한 설정 패널이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Separator } from "@/components/ui/separator";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ChevronDown, Settings, Info, Database, Trash2, Copy, Palette, Monitor } from "lucide-react";
|
||||
import {
|
||||
|
|
@ -48,6 +49,9 @@ import { ButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
|
|||
import { CardConfigPanel } from "../config-panels/CardConfigPanel";
|
||||
import { DashboardConfigPanel } from "../config-panels/DashboardConfigPanel";
|
||||
import { StatsCardConfigPanel } from "../config-panels/StatsCardConfigPanel";
|
||||
|
||||
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import { ProgressBarConfigPanel } from "../config-panels/ProgressBarConfigPanel";
|
||||
import { ChartConfigPanel } from "../config-panels/ChartConfigPanel";
|
||||
import { AlertConfigPanel } from "../config-panels/AlertConfigPanel";
|
||||
|
|
@ -263,12 +267,74 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
const renderComponentConfigPanel = () => {
|
||||
if (!selectedComponent) return null;
|
||||
|
||||
const componentType = selectedComponent.componentConfig?.type || selectedComponent.type;
|
||||
// 🎯 Section Card, Section Paper 등 신규 컴포넌트는 componentType에서 감지
|
||||
const componentType =
|
||||
selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등)
|
||||
selectedComponent.componentConfig?.type ||
|
||||
selectedComponent.componentConfig?.id ||
|
||||
selectedComponent.type;
|
||||
|
||||
const handleUpdateProperty = (path: string, value: any) => {
|
||||
onUpdateProperty(selectedComponent.id, path, value);
|
||||
};
|
||||
|
||||
const handleConfigChange = (newConfig: any) => {
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
|
||||
};
|
||||
|
||||
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도
|
||||
const componentId =
|
||||
selectedComponent.componentType || // ⭐ section-card 등
|
||||
selectedComponent.componentConfig?.type ||
|
||||
selectedComponent.componentConfig?.id;
|
||||
|
||||
if (componentId) {
|
||||
const definition = ComponentRegistry.getComponent(componentId);
|
||||
|
||||
if (definition?.configPanel) {
|
||||
const ConfigPanelComponent = definition.configPanel;
|
||||
const currentConfig = selectedComponent.componentConfig || {};
|
||||
|
||||
console.log("✅ ConfigPanel 표시:", {
|
||||
componentId,
|
||||
definitionName: definition.name,
|
||||
hasConfigPanel: !!definition.configPanel,
|
||||
currentConfig,
|
||||
});
|
||||
|
||||
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
|
||||
const ConfigPanelWrapper = () => {
|
||||
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
|
||||
const config = currentConfig || definition.defaultProps?.componentConfig || {};
|
||||
|
||||
const handleConfigChange = (newConfig: any) => {
|
||||
// componentConfig 전체를 업데이트
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Settings className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||
</div>
|
||||
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return <ConfigPanelWrapper key={selectedComponent.id} />;
|
||||
} else {
|
||||
console.warn("⚠️ ComponentRegistry에서 ConfigPanel을 찾을 수 없음 - switch case로 이동:", {
|
||||
componentId,
|
||||
definitionName: definition?.name,
|
||||
hasDefinition: !!definition,
|
||||
});
|
||||
// ConfigPanel이 없으면 아래 switch case로 넘어감
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 하드코딩된 설정 패널들 (레거시)
|
||||
switch (componentType) {
|
||||
case "button":
|
||||
case "button-primary":
|
||||
|
|
@ -311,8 +377,291 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
case "badge-status":
|
||||
return <BadgeConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||
|
||||
case "section-card":
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Section Card 설정</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
제목과 테두리가 있는 명확한 그룹화 컨테이너
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 헤더 표시 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showHeader"
|
||||
checked={selectedComponent.componentConfig?.showHeader !== false}
|
||||
onCheckedChange={(checked) => {
|
||||
handleUpdateProperty(selectedComponent.id, "componentConfig.showHeader", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="showHeader" className="text-xs cursor-pointer">
|
||||
헤더 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
{selectedComponent.componentConfig?.showHeader !== false && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">제목</Label>
|
||||
<Input
|
||||
value={selectedComponent.componentConfig?.title || ""}
|
||||
onChange={(e) => {
|
||||
handleUpdateProperty(selectedComponent.id, "componentConfig.title", e.target.value);
|
||||
}}
|
||||
placeholder="섹션 제목 입력"
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 설명 */}
|
||||
{selectedComponent.componentConfig?.showHeader !== false && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">설명 (선택)</Label>
|
||||
<Textarea
|
||||
value={selectedComponent.componentConfig?.description || ""}
|
||||
onChange={(e) => {
|
||||
handleUpdateProperty(selectedComponent.id, "componentConfig.description", e.target.value);
|
||||
}}
|
||||
placeholder="섹션 설명 입력"
|
||||
className="text-xs resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 패딩 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">내부 여백</Label>
|
||||
<Select
|
||||
value={selectedComponent.componentConfig?.padding || "md"}
|
||||
onValueChange={(value) => {
|
||||
handleUpdateProperty(selectedComponent.id, "componentConfig.padding", value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
<SelectItem value="sm">작게 (12px)</SelectItem>
|
||||
<SelectItem value="md">중간 (24px)</SelectItem>
|
||||
<SelectItem value="lg">크게 (32px)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 배경색 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">배경색</Label>
|
||||
<Select
|
||||
value={selectedComponent.componentConfig?.backgroundColor || "default"}
|
||||
onValueChange={(value) => {
|
||||
handleUpdateProperty(selectedComponent.id, "componentConfig.backgroundColor", value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">기본 (카드)</SelectItem>
|
||||
<SelectItem value="muted">회색</SelectItem>
|
||||
<SelectItem value="transparent">투명</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테두리 스타일 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">테두리 스타일</Label>
|
||||
<Select
|
||||
value={selectedComponent.componentConfig?.borderStyle || "solid"}
|
||||
onValueChange={(value) => {
|
||||
handleUpdateProperty(selectedComponent.id, "componentConfig.borderStyle", value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">실선</SelectItem>
|
||||
<SelectItem value="dashed">점선</SelectItem>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 접기/펼치기 기능 */}
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="collapsible"
|
||||
checked={selectedComponent.componentConfig?.collapsible || false}
|
||||
onCheckedChange={(checked) => {
|
||||
handleUpdateProperty(selectedComponent.id, "componentConfig.collapsible", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="collapsible" className="text-xs cursor-pointer">
|
||||
접기/펼치기 가능
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{selectedComponent.componentConfig?.collapsible && (
|
||||
<div className="flex items-center space-x-2 ml-6">
|
||||
<Checkbox
|
||||
id="defaultOpen"
|
||||
checked={selectedComponent.componentConfig?.defaultOpen !== false}
|
||||
onCheckedChange={(checked) => {
|
||||
handleUpdateProperty(selectedComponent.id, "componentConfig.defaultOpen", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="defaultOpen" className="text-xs cursor-pointer">
|
||||
기본으로 펼치기
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "section-paper":
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Section Paper 설정</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
배경색 기반의 미니멀한 그룹화 컨테이너
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 배경색 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">배경색</Label>
|
||||
<Select
|
||||
value={selectedComponent.componentConfig?.backgroundColor || "default"}
|
||||
onValueChange={(value) => {
|
||||
handleUpdateProperty(selectedComponent.id, "componentConfig.backgroundColor", value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">기본 (연한 회색)</SelectItem>
|
||||
<SelectItem value="muted">회색</SelectItem>
|
||||
<SelectItem value="accent">강조 (연한 파랑)</SelectItem>
|
||||
<SelectItem value="primary">브랜드 컬러</SelectItem>
|
||||
<SelectItem value="custom">커스텀</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 커스텀 색상 */}
|
||||
{selectedComponent.componentConfig?.backgroundColor === "custom" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">커스텀 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selectedComponent.componentConfig?.customColor || "#f0f0f0"}
|
||||
onChange={(e) => {
|
||||
handleUpdateProperty(selectedComponent.id, "componentConfig.customColor", e.target.value);
|
||||
}}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 패딩 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">내부 여백</Label>
|
||||
<Select
|
||||
value={selectedComponent.componentConfig?.padding || "md"}
|
||||
onValueChange={(value) => {
|
||||
handleUpdateProperty(selectedComponent.id, "componentConfig.padding", value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
<SelectItem value="sm">작게 (12px)</SelectItem>
|
||||
<SelectItem value="md">중간 (16px)</SelectItem>
|
||||
<SelectItem value="lg">크게 (24px)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 둥근 모서리 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">둥근 모서리</Label>
|
||||
<Select
|
||||
value={selectedComponent.componentConfig?.roundedCorners || "md"}
|
||||
onValueChange={(value) => {
|
||||
handleUpdateProperty(selectedComponent.id, "componentConfig.roundedCorners", value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
<SelectItem value="sm">작게 (2px)</SelectItem>
|
||||
<SelectItem value="md">중간 (6px)</SelectItem>
|
||||
<SelectItem value="lg">크게 (8px)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 그림자 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">그림자</Label>
|
||||
<Select
|
||||
value={selectedComponent.componentConfig?.shadow || "none"}
|
||||
onValueChange={(value) => {
|
||||
handleUpdateProperty(selectedComponent.id, "componentConfig.shadow", value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
<SelectItem value="sm">작게</SelectItem>
|
||||
<SelectItem value="md">중간</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테두리 표시 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showBorder"
|
||||
checked={selectedComponent.componentConfig?.showBorder || false}
|
||||
onCheckedChange={(checked) => {
|
||||
handleUpdateProperty(selectedComponent.id, "componentConfig.showBorder", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
|
||||
미묘한 테두리 표시
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
// ConfigPanel이 없는 경우 경고 표시
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||
<Settings className="mb-4 h-12 w-12 text-muted-foreground" />
|
||||
<h3 className="mb-2 text-base font-medium">⚠️ 설정 패널 없음</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
컴포넌트 "{componentId || componentType}"에 대한 설정 패널이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -573,11 +922,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
|
||||
// 상세 설정 탭 (DetailSettingsPanel의 전체 로직 통합)
|
||||
const renderDetailTab = () => {
|
||||
console.log("🔍 [renderDetailTab] selectedComponent.type:", selectedComponent.type);
|
||||
|
||||
// 1. DataTable 컴포넌트
|
||||
if (selectedComponent.type === "datatable") {
|
||||
console.log("✅ [renderDetailTab] DataTable 컴포넌트");
|
||||
return (
|
||||
<DataTableConfigPanel
|
||||
component={selectedComponent as DataTableComponent}
|
||||
|
|
@ -634,7 +980,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
|
||||
// 5. 새로운 컴포넌트 시스템 (type: "component")
|
||||
if (selectedComponent.type === "component") {
|
||||
console.log("✅ [renderDetailTab] Component 타입");
|
||||
const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type;
|
||||
const webType = selectedComponent.componentConfig?.webType;
|
||||
|
||||
|
|
@ -694,7 +1039,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
tables={tables}
|
||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||
onChange={(newConfig) => {
|
||||
console.log("🔄 DynamicComponentConfigPanel onChange:", newConfig);
|
||||
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
handleUpdate(`componentConfig.${key}`, value);
|
||||
|
|
|
|||
|
|
@ -77,3 +77,4 @@ export const numberingRuleTemplate = {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -79,14 +79,14 @@ export const CategoryValueAddDialog: React.FC<
|
|||
const valueCode = generateCode(valueLabel);
|
||||
|
||||
onAdd({
|
||||
tableName: "",
|
||||
columnName: "",
|
||||
tableName: "", // CategoryValueManager에서 오버라이드됨
|
||||
columnName: "", // CategoryValueManager에서 오버라이드됨
|
||||
valueCode,
|
||||
valueLabel: valueLabel.trim(),
|
||||
description: description.trim(),
|
||||
color: color,
|
||||
description: description.trim() || undefined, // 빈 문자열 대신 undefined
|
||||
color: color === "none" ? undefined : color, // "none"은 undefined로
|
||||
isDefault: false,
|
||||
});
|
||||
} as TableCategoryValue);
|
||||
|
||||
// 초기화
|
||||
setValueLabel("");
|
||||
|
|
|
|||
|
|
@ -184,11 +184,18 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
|||
title: "성공",
|
||||
description: "카테고리 값이 삭제되었습니다",
|
||||
});
|
||||
} else {
|
||||
// 백엔드에서 반환한 상세 에러 메시지 표시
|
||||
toast({
|
||||
title: "삭제 불가",
|
||||
description: response.error || response.message || "카테고리 값 삭제에 실패했습니다",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "카테고리 값 삭제에 실패했습니다",
|
||||
description: "카테고리 값 삭제 중 오류가 발생했습니다",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -377,8 +377,8 @@ const ResizableDialogContent = React.forwardRef<
|
|||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="h-full w-full"
|
||||
style={{ display: 'block', overflow: 'hidden' }}
|
||||
className="h-full w-full relative"
|
||||
style={{ display: 'block', overflow: 'hidden', pointerEvents: 'auto', zIndex: 1 }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
|
@ -387,45 +387,56 @@ const ResizableDialogContent = React.forwardRef<
|
|||
{/* 오른쪽 */}
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full w-2 cursor-ew-resize hover:bg-primary/20 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("e")}
|
||||
/>
|
||||
{/* 아래 */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-full h-2 cursor-ns-resize hover:bg-primary/20 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("s")}
|
||||
/>
|
||||
{/* 오른쪽 아래 */}
|
||||
<div
|
||||
className="absolute right-0 bottom-0 w-4 h-4 cursor-nwse-resize hover:bg-primary/30 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("se")}
|
||||
/>
|
||||
{/* 왼쪽 */}
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full w-2 cursor-ew-resize hover:bg-primary/20 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("w")}
|
||||
/>
|
||||
{/* 위 */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-full h-2 cursor-ns-resize hover:bg-primary/20 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("n")}
|
||||
/>
|
||||
{/* 왼쪽 아래 */}
|
||||
<div
|
||||
className="absolute left-0 bottom-0 w-4 h-4 cursor-nesw-resize hover:bg-primary/30 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("sw")}
|
||||
/>
|
||||
{/* 오른쪽 위 */}
|
||||
<div
|
||||
className="absolute right-0 top-0 w-4 h-4 cursor-nesw-resize hover:bg-primary/30 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("ne")}
|
||||
/>
|
||||
{/* 왼쪽 위 */}
|
||||
<div
|
||||
className="absolute left-0 top-0 w-4 h-4 cursor-nwse-resize hover:bg-primary/30 transition-colors"
|
||||
style={{ pointerEvents: 'auto', zIndex: 10 }}
|
||||
onMouseDown={startResize("nw")}
|
||||
/>
|
||||
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<DialogPrimitive.Close
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
||||
style={{ zIndex: 20 }}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition } from "@/types/repeater";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
|
|||
|
|
@ -42,10 +42,49 @@ export const dataApi = {
|
|||
* 특정 레코드 상세 조회
|
||||
* @param tableName 테이블명
|
||||
* @param id 레코드 ID
|
||||
* @param enableEntityJoin Entity 조인 활성화 여부 (기본값: false)
|
||||
* @param groupByColumns 그룹핑 기준 컬럼들 (배열)
|
||||
*/
|
||||
getRecordDetail: async (tableName: string, id: string | number): Promise<any> => {
|
||||
const response = await apiClient.get(`/data/${tableName}/${id}`);
|
||||
return response.data?.data || response.data;
|
||||
getRecordDetail: async (
|
||||
tableName: string,
|
||||
id: string | number,
|
||||
enableEntityJoin: boolean = false,
|
||||
groupByColumns: string[] = []
|
||||
): Promise<{ success: boolean; data?: any; error?: string }> => {
|
||||
try {
|
||||
const params: any = {};
|
||||
if (enableEntityJoin) {
|
||||
params.enableEntityJoin = true;
|
||||
}
|
||||
if (groupByColumns.length > 0) {
|
||||
params.groupByColumns = JSON.stringify(groupByColumns);
|
||||
}
|
||||
|
||||
console.log("🌐 [dataApi.getRecordDetail] API 호출:", {
|
||||
tableName,
|
||||
id,
|
||||
enableEntityJoin,
|
||||
groupByColumns,
|
||||
params,
|
||||
url: `/data/${tableName}/${id}`,
|
||||
});
|
||||
|
||||
const response = await apiClient.get(`/data/${tableName}/${id}`, { params });
|
||||
|
||||
console.log("📥 [dataApi.getRecordDetail] API 응답:", {
|
||||
success: response.data?.success,
|
||||
dataType: Array.isArray(response.data?.data) ? "배열" : "객체",
|
||||
dataCount: Array.isArray(response.data?.data) ? response.data.data.length : 1,
|
||||
});
|
||||
|
||||
return response.data; // { success: true, data: ... } 형식 그대로 반환
|
||||
} catch (error: any) {
|
||||
console.error("❌ [dataApi.getRecordDetail] API 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message || "레코드 조회 실패",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -55,6 +94,9 @@ export const dataApi = {
|
|||
* @param leftColumn 좌측 컬럼명
|
||||
* @param rightColumn 우측 컬럼명 (외래키)
|
||||
* @param leftValue 좌측 값 (필터링)
|
||||
* @param dataFilter 데이터 필터
|
||||
* @param enableEntityJoin Entity 조인 활성화
|
||||
* @param displayColumns 표시할 컬럼 목록 (tableName.columnName 형식 포함)
|
||||
*/
|
||||
getJoinedData: async (
|
||||
leftTable: string,
|
||||
|
|
@ -62,7 +104,15 @@ export const dataApi = {
|
|||
leftColumn: string,
|
||||
rightColumn: string,
|
||||
leftValue?: any,
|
||||
dataFilter?: any, // 🆕 데이터 필터
|
||||
dataFilter?: any,
|
||||
enableEntityJoin?: boolean,
|
||||
displayColumns?: Array<{ name: string; label?: string }>,
|
||||
deduplication?: { // 🆕 중복 제거 설정
|
||||
enabled: boolean;
|
||||
groupByColumn: string;
|
||||
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||
sortColumn?: string;
|
||||
},
|
||||
): Promise<any[]> => {
|
||||
const response = await apiClient.get(`/data/join`, {
|
||||
params: {
|
||||
|
|
@ -71,7 +121,10 @@ export const dataApi = {
|
|||
leftColumn,
|
||||
rightColumn,
|
||||
leftValue,
|
||||
dataFilter: dataFilter ? JSON.stringify(dataFilter) : undefined, // 🆕 데이터 필터 전달
|
||||
dataFilter: dataFilter ? JSON.stringify(dataFilter) : undefined,
|
||||
enableEntityJoin: enableEntityJoin ?? true,
|
||||
displayColumns: displayColumns ? JSON.stringify(displayColumns) : undefined, // 🆕 표시 컬럼 전달
|
||||
deduplication: deduplication ? JSON.stringify(deduplication) : undefined, // 🆕 중복 제거 설정 전달
|
||||
},
|
||||
});
|
||||
const raw = response.data || {};
|
||||
|
|
@ -115,4 +168,98 @@ export const dataApi = {
|
|||
const response = await apiClient.delete(`/data/${tableName}/${id}`);
|
||||
return response.data; // success, message 포함된 전체 응답 반환
|
||||
},
|
||||
|
||||
/**
|
||||
* 조건에 맞는 모든 레코드 삭제 (그룹 삭제)
|
||||
* @param tableName 테이블명
|
||||
* @param filterConditions 삭제 조건 (예: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" })
|
||||
*/
|
||||
deleteGroupRecords: async (
|
||||
tableName: string,
|
||||
filterConditions: Record<string, any>
|
||||
): Promise<{ success: boolean; deleted?: number; message?: string; error?: string }> => {
|
||||
try {
|
||||
console.log(`🗑️ [dataApi] 그룹 삭제 요청:`, { tableName, filterConditions });
|
||||
|
||||
const response = await apiClient.post(`/data/${tableName}/delete-group`, filterConditions);
|
||||
|
||||
console.log(`✅ [dataApi] 그룹 삭제 성공:`, response.data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [dataApi] 그룹 삭제 실패:`, error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message || "그룹 삭제 실패",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 특정 레코드 상세 조회
|
||||
* @param tableName 테이블명
|
||||
* @param id 레코드 ID
|
||||
* @param enableEntityJoin Entity 조인 활성화 여부 (기본값: false)
|
||||
*/
|
||||
getRecordDetail: async (
|
||||
tableName: string,
|
||||
id: string | number,
|
||||
enableEntityJoin: boolean = false
|
||||
): Promise<{ success: boolean; data?: any; error?: string }> => {
|
||||
try {
|
||||
const params: any = {};
|
||||
if (enableEntityJoin) {
|
||||
params.enableEntityJoin = "true";
|
||||
}
|
||||
const response = await apiClient.get(`/data/${tableName}/${id}`, { params });
|
||||
return response.data; // { success: true, data: ... } 형식 그대로 반환
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message || "레코드 조회 실패",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 그룹화된 데이터 UPSERT
|
||||
* @param tableName 테이블명
|
||||
* @param parentKeys 부모 키 (예: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" })
|
||||
* @param records 레코드 배열
|
||||
*/
|
||||
upsertGroupedRecords: async (
|
||||
tableName: string,
|
||||
parentKeys: Record<string, any>,
|
||||
records: Array<Record<string, any>>
|
||||
): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; message?: string; error?: string }> => {
|
||||
try {
|
||||
console.log("📡 [dataApi.upsertGroupedRecords] 요청 데이터:", {
|
||||
tableName,
|
||||
tableNameType: typeof tableName,
|
||||
tableNameValue: JSON.stringify(tableName),
|
||||
parentKeys,
|
||||
recordsCount: records.length,
|
||||
});
|
||||
|
||||
const requestBody = {
|
||||
tableName,
|
||||
parentKeys,
|
||||
records,
|
||||
};
|
||||
console.log("📦 [dataApi.upsertGroupedRecords] 요청 본문 (JSON):", JSON.stringify(requestBody, null, 2));
|
||||
|
||||
const response = await apiClient.post('/data/upsert-grouped', requestBody);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("❌ [dataApi.upsertGroupedRecords] 에러:", {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
message: error.message,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message || "데이터 저장 실패",
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,283 @@
|
|||
import { apiClient } from "./client";
|
||||
import type {
|
||||
DigitalTwinLayout,
|
||||
DigitalTwinLayoutDetail,
|
||||
CreateLayoutRequest,
|
||||
UpdateLayoutRequest,
|
||||
Warehouse,
|
||||
Area,
|
||||
Location,
|
||||
MaterialData,
|
||||
MaterialCount,
|
||||
} from "@/types/digitalTwin";
|
||||
|
||||
// API 응답 타입
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ========== 레이아웃 관리 API ==========
|
||||
|
||||
// 레이아웃 목록 조회
|
||||
export const getLayouts = async (params?: {
|
||||
externalDbConnectionId?: number;
|
||||
warehouseKey?: string;
|
||||
}): Promise<ApiResponse<DigitalTwinLayout[]>> => {
|
||||
try {
|
||||
const response = await apiClient.get("/digital-twin/layouts", { params });
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 레이아웃 상세 조회
|
||||
export const getLayoutById = async (id: number): Promise<ApiResponse<DigitalTwinLayoutDetail>> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/digital-twin/layouts/${id}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 레이아웃 생성
|
||||
export const createLayout = async (data: CreateLayoutRequest): Promise<ApiResponse<DigitalTwinLayout>> => {
|
||||
try {
|
||||
const response = await apiClient.post("/digital-twin/layouts", data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 레이아웃 수정
|
||||
export const updateLayout = async (id: number, data: UpdateLayoutRequest): Promise<ApiResponse<DigitalTwinLayout>> => {
|
||||
try {
|
||||
const response = await apiClient.put(`/digital-twin/layouts/${id}`, data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 레이아웃 삭제
|
||||
export const deleteLayout = async (id: number): Promise<ApiResponse<void>> => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/digital-twin/layouts/${id}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 외부 DB 테이블 조회 API ==========
|
||||
|
||||
export const getTables = async (connectionId: number): Promise<ApiResponse<Array<{ table_name: string }>>> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/digital-twin/data/tables/${connectionId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getTablePreview = async (connectionId: number, tableName: string): Promise<ApiResponse<any[]>> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/digital-twin/data/table-preview/${connectionId}/${tableName}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 외부 DB 데이터 조회 API ==========
|
||||
|
||||
// 창고 목록 조회
|
||||
export const getWarehouses = async (
|
||||
externalDbConnectionId: number,
|
||||
tableName: string,
|
||||
): Promise<ApiResponse<Warehouse[]>> => {
|
||||
try {
|
||||
const response = await apiClient.get("/digital-twin/data/warehouses", {
|
||||
params: { externalDbConnectionId, tableName },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Area 목록 조회
|
||||
export const getAreas = async (
|
||||
externalDbConnectionId: number,
|
||||
tableName: string,
|
||||
warehouseKey: string,
|
||||
): Promise<ApiResponse<Area[]>> => {
|
||||
try {
|
||||
const response = await apiClient.get("/digital-twin/data/areas", {
|
||||
params: { externalDbConnectionId, tableName, warehouseKey },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Location 목록 조회
|
||||
export const getLocations = async (
|
||||
externalDbConnectionId: number,
|
||||
tableName: string,
|
||||
areaKey: string,
|
||||
): Promise<ApiResponse<Location[]>> => {
|
||||
try {
|
||||
const response = await apiClient.get("/digital-twin/data/locations", {
|
||||
params: { externalDbConnectionId, tableName, areaKey },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 자재 목록 조회 (특정 Location)
|
||||
export const getMaterials = async (
|
||||
externalDbConnectionId: number,
|
||||
materialConfig: {
|
||||
tableName: string;
|
||||
keyColumn: string;
|
||||
locationKeyColumn: string;
|
||||
layerColumn?: 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,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 자재 개수 조회 (여러 Location)
|
||||
export const getMaterialCounts = async (
|
||||
externalDbConnectionId: number,
|
||||
tableName: string,
|
||||
locaKeys: string[],
|
||||
): Promise<ApiResponse<MaterialCount[]>> => {
|
||||
try {
|
||||
const response = await apiClient.post("/digital-twin/data/material-counts", {
|
||||
externalDbConnectionId,
|
||||
tableName,
|
||||
locationKeys: locaKeys,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 동적 계층 구조 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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -71,25 +71,6 @@ export const entityJoinApi = {
|
|||
dataFilter?: any; // 🆕 데이터 필터
|
||||
} = {},
|
||||
): Promise<EntityJoinResponse> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params.page) searchParams.append("page", params.page.toString());
|
||||
if (params.size) searchParams.append("size", params.size.toString());
|
||||
if (params.sortBy) searchParams.append("sortBy", params.sortBy);
|
||||
if (params.sortOrder) searchParams.append("sortOrder", params.sortOrder);
|
||||
if (params.enableEntityJoin !== undefined) {
|
||||
searchParams.append("enableEntityJoin", params.enableEntityJoin.toString());
|
||||
}
|
||||
|
||||
// 검색 조건 추가
|
||||
if (params.search) {
|
||||
Object.entries(params.search).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
|
||||
const autoFilter = {
|
||||
enabled: true,
|
||||
|
|
@ -99,7 +80,11 @@ export const entityJoinApi = {
|
|||
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, {
|
||||
params: {
|
||||
...params,
|
||||
page: params.page,
|
||||
size: params.size,
|
||||
sortBy: params.sortBy,
|
||||
sortOrder: params.sortOrder,
|
||||
enableEntityJoin: params.enableEntityJoin,
|
||||
search: params.search ? JSON.stringify(params.search) : undefined,
|
||||
additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined,
|
||||
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
|
||||
|
|
|
|||
|
|
@ -109,7 +109,10 @@ export async function deleteCategoryValue(valueId: number) {
|
|||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("카테고리 값 삭제 실패:", error);
|
||||
return { success: false, error: error.message };
|
||||
|
||||
// 백엔드에서 반환한 에러 메시지 전달
|
||||
const errorMessage = error.response?.data?.message || error.message;
|
||||
return { success: false, error: errorMessage, message: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -262,7 +262,14 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
|
||||
// 컴포넌트의 columnName에 해당하는 formData 값 추출
|
||||
const fieldName = (component as any).columnName || component.id;
|
||||
const currentValue = formData?.[fieldName] || "";
|
||||
|
||||
// modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화
|
||||
let currentValue;
|
||||
if (componentType === "modal-repeater-table") {
|
||||
currentValue = formData?.[fieldName] || [];
|
||||
} else {
|
||||
currentValue = formData?.[fieldName] || "";
|
||||
}
|
||||
|
||||
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
|
||||
const handleChange = (value: any) => {
|
||||
|
|
@ -274,31 +281,30 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
}
|
||||
|
||||
if (onFormDataChange) {
|
||||
// modal-repeater-table은 배열 데이터를 다룸
|
||||
if (componentType === "modal-repeater-table") {
|
||||
onFormDataChange(fieldName, actualValue);
|
||||
}
|
||||
// RepeaterInput 같은 복합 컴포넌트는 전체 데이터를 전달
|
||||
// 단순 input 컴포넌트는 (fieldName, value) 형태로 전달받음
|
||||
if (componentType === "repeater-field-group" || componentType === "repeater") {
|
||||
// fieldName과 함께 전달
|
||||
else if (componentType === "repeater-field-group" || componentType === "repeater") {
|
||||
onFormDataChange(fieldName, actualValue);
|
||||
} else {
|
||||
// 이미 fieldName이 포함된 경우는 그대로 전달
|
||||
onFormDataChange(fieldName, actualValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 렌더러 props 구성
|
||||
// component.style에서 height 제거 (RealtimePreviewDynamic에서 size.height로 처리)
|
||||
// 단, layout 타입 컴포넌트(split-panel-layout 등)는 height 유지
|
||||
const isLayoutComponent =
|
||||
component.type === "layout" ||
|
||||
componentType === "split-panel-layout" ||
|
||||
componentType?.includes("layout");
|
||||
|
||||
const { height: _height, ...styleWithoutHeight } = component.style || {};
|
||||
|
||||
// 숨김 값 추출
|
||||
const hiddenValue = component.hidden || component.componentConfig?.hidden;
|
||||
|
||||
// size.width와 size.height를 style.width와 style.height로 변환
|
||||
const finalStyle: React.CSSProperties = {
|
||||
...component.style,
|
||||
width: component.size?.width ? `${component.size.width}px` : component.style?.width,
|
||||
height: component.size?.height ? `${component.size.height}px` : component.style?.height,
|
||||
};
|
||||
|
||||
const rendererProps = {
|
||||
component,
|
||||
isSelected,
|
||||
|
|
@ -307,9 +313,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
onDragEnd,
|
||||
size: component.size || newComponent.defaultSize,
|
||||
position: component.position,
|
||||
style: isLayoutComponent ? component.style : styleWithoutHeight, // 레이아웃은 height 유지
|
||||
style: finalStyle, // size를 포함한 최종 style
|
||||
config: component.componentConfig,
|
||||
componentConfig: component.componentConfig,
|
||||
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
|
||||
...(component.componentConfig || {}),
|
||||
value: currentValue, // formData에서 추출한 현재 값 전달
|
||||
// 새로운 기능들 전달
|
||||
autoGeneration: component.autoGeneration || component.componentConfig?.autoGeneration,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,225 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Loader2, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
|
||||
import { EntitySearchResult } from "../entity-search-input/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AutocompleteSearchInputConfig, FieldMapping } from "./types";
|
||||
|
||||
interface AutocompleteSearchInputProps extends Partial<AutocompleteSearchInputConfig> {
|
||||
config?: AutocompleteSearchInputConfig;
|
||||
filterCondition?: Record<string, any>;
|
||||
disabled?: boolean;
|
||||
value?: any;
|
||||
onChange?: (value: any, fullData?: any) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AutocompleteSearchInputComponent({
|
||||
config,
|
||||
tableName: propTableName,
|
||||
displayField: propDisplayField,
|
||||
valueField: propValueField,
|
||||
searchFields: propSearchFields,
|
||||
filterCondition = {},
|
||||
placeholder: propPlaceholder,
|
||||
disabled = false,
|
||||
value,
|
||||
onChange,
|
||||
showAdditionalInfo: propShowAdditionalInfo,
|
||||
additionalFields: propAdditionalFields,
|
||||
className,
|
||||
}: AutocompleteSearchInputProps) {
|
||||
// config prop 우선, 없으면 개별 prop 사용
|
||||
const tableName = config?.tableName || propTableName || "";
|
||||
const displayField = config?.displayField || propDisplayField || "";
|
||||
const valueField = config?.valueField || propValueField || "";
|
||||
const searchFields = config?.searchFields || propSearchFields || [displayField];
|
||||
const placeholder = config?.placeholder || propPlaceholder || "검색...";
|
||||
const showAdditionalInfo = config?.showAdditionalInfo ?? propShowAdditionalInfo ?? false;
|
||||
const additionalFields = config?.additionalFields || propAdditionalFields || [];
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { searchText, setSearchText, results, loading, clearSearch } = useEntitySearch({
|
||||
tableName,
|
||||
searchFields,
|
||||
filterCondition,
|
||||
});
|
||||
|
||||
// value가 변경되면 표시값 업데이트
|
||||
useEffect(() => {
|
||||
if (value && selectedData) {
|
||||
setInputValue(selectedData[displayField] || "");
|
||||
} else if (!value) {
|
||||
setInputValue("");
|
||||
setSelectedData(null);
|
||||
}
|
||||
}, [value, displayField]);
|
||||
|
||||
// 외부 클릭 감지
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setInputValue(newValue);
|
||||
setSearchText(newValue);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
// 필드 자동 매핑 처리
|
||||
const applyFieldMappings = (item: EntitySearchResult) => {
|
||||
if (!config?.enableFieldMapping || !config?.fieldMappings) {
|
||||
return;
|
||||
}
|
||||
|
||||
config.fieldMappings.forEach((mapping: FieldMapping) => {
|
||||
if (!mapping.sourceField || !mapping.targetField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = item[mapping.sourceField];
|
||||
|
||||
// DOM에서 타겟 필드 찾기 (id로 검색)
|
||||
const targetElement = document.getElementById(mapping.targetField);
|
||||
|
||||
if (targetElement) {
|
||||
// input, textarea 등의 값 설정
|
||||
if (
|
||||
targetElement instanceof HTMLInputElement ||
|
||||
targetElement instanceof HTMLTextAreaElement
|
||||
) {
|
||||
targetElement.value = value?.toString() || "";
|
||||
|
||||
// React의 change 이벤트 트리거
|
||||
const event = new Event("input", { bubbles: true });
|
||||
targetElement.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelect = (item: EntitySearchResult) => {
|
||||
setSelectedData(item);
|
||||
setInputValue(item[displayField] || "");
|
||||
onChange?.(item[valueField], item);
|
||||
|
||||
// 필드 자동 매핑 실행
|
||||
applyFieldMappings(item);
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setInputValue("");
|
||||
setSelectedData(null);
|
||||
onChange?.(null, null);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleInputFocus = () => {
|
||||
// 포커스 시 항상 검색 실행 (빈 값이면 전체 목록)
|
||||
if (!selectedData) {
|
||||
setSearchText(inputValue || "");
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)} ref={containerRef}>
|
||||
{/* 입력 필드 */}
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm pr-16"
|
||||
/>
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
{loading && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{inputValue && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 드롭다운 결과 */}
|
||||
{isOpen && (results.length > 0 || loading) && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-background border rounded-md shadow-lg max-h-[300px] overflow-y-auto">
|
||||
{loading && results.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin mx-auto mb-2" />
|
||||
검색 중...
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
검색 결과가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{results.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => handleSelect(item)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-accent text-xs sm:text-sm transition-colors"
|
||||
>
|
||||
<div className="font-medium">{item[displayField]}</div>
|
||||
{additionalFields.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground mt-1 space-y-0.5">
|
||||
{additionalFields.map((field) => (
|
||||
<div key={field}>
|
||||
{field}: {item[field] || "-"}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가 정보 표시 */}
|
||||
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
|
||||
<div className="mt-2 text-xs text-muted-foreground space-y-1 px-2">
|
||||
{additionalFields.map((field) => (
|
||||
<div key={field} className="flex gap-2">
|
||||
<span className="font-medium">{field}:</span>
|
||||
<span>{selectedData[field] || "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,801 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { AutocompleteSearchInputConfig, FieldMapping, ValueFieldStorage } from "./types";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AutocompleteSearchInputConfigPanelProps {
|
||||
config: AutocompleteSearchInputConfig;
|
||||
onConfigChange: (config: AutocompleteSearchInputConfig) => void;
|
||||
}
|
||||
|
||||
export function AutocompleteSearchInputConfigPanel({
|
||||
config,
|
||||
onConfigChange,
|
||||
}: AutocompleteSearchInputConfigPanelProps) {
|
||||
const [localConfig, setLocalConfig] = useState(config);
|
||||
const [allTables, setAllTables] = useState<any[]>([]);
|
||||
const [tableColumns, setTableColumns] = useState<any[]>([]);
|
||||
const [isLoadingTables, setIsLoadingTables] = useState(false);
|
||||
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
|
||||
const [openTableCombo, setOpenTableCombo] = useState(false);
|
||||
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
|
||||
const [openValueFieldCombo, setOpenValueFieldCombo] = useState(false);
|
||||
const [openStorageTableCombo, setOpenStorageTableCombo] = useState(false);
|
||||
const [openStorageColumnCombo, setOpenStorageColumnCombo] = useState(false);
|
||||
const [storageTableColumns, setStorageTableColumns] = useState<any[]>([]);
|
||||
const [isLoadingStorageColumns, setIsLoadingStorageColumns] = useState(false);
|
||||
|
||||
// 전체 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
setIsLoadingTables(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setAllTables(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!localConfig.tableName) {
|
||||
setTableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingColumns(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(localConfig.tableName);
|
||||
if (response.success && response.data) {
|
||||
setTableColumns(response.data.columns);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
setTableColumns([]);
|
||||
} finally {
|
||||
setIsLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [localConfig.tableName]);
|
||||
|
||||
// 저장 대상 테이블의 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
const loadStorageColumns = async () => {
|
||||
const storageTable = localConfig.valueFieldStorage?.targetTable;
|
||||
if (!storageTable) {
|
||||
setStorageTableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingStorageColumns(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(storageTable);
|
||||
if (response.success && response.data) {
|
||||
setStorageTableColumns(response.data.columns);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("저장 테이블 컬럼 로드 실패:", error);
|
||||
setStorageTableColumns([]);
|
||||
} finally {
|
||||
setIsLoadingStorageColumns(false);
|
||||
}
|
||||
};
|
||||
loadStorageColumns();
|
||||
}, [localConfig.valueFieldStorage?.targetTable]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalConfig(config);
|
||||
}, [config]);
|
||||
|
||||
const updateConfig = (updates: Partial<AutocompleteSearchInputConfig>) => {
|
||||
const newConfig = { ...localConfig, ...updates };
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
};
|
||||
|
||||
const addSearchField = () => {
|
||||
const fields = localConfig.searchFields || [];
|
||||
updateConfig({ searchFields: [...fields, ""] });
|
||||
};
|
||||
|
||||
const updateSearchField = (index: number, value: string) => {
|
||||
const fields = [...(localConfig.searchFields || [])];
|
||||
fields[index] = value;
|
||||
updateConfig({ searchFields: fields });
|
||||
};
|
||||
|
||||
const removeSearchField = (index: number) => {
|
||||
const fields = [...(localConfig.searchFields || [])];
|
||||
fields.splice(index, 1);
|
||||
updateConfig({ searchFields: fields });
|
||||
};
|
||||
|
||||
const addAdditionalField = () => {
|
||||
const fields = localConfig.additionalFields || [];
|
||||
updateConfig({ additionalFields: [...fields, ""] });
|
||||
};
|
||||
|
||||
const updateAdditionalField = (index: number, value: string) => {
|
||||
const fields = [...(localConfig.additionalFields || [])];
|
||||
fields[index] = value;
|
||||
updateConfig({ additionalFields: fields });
|
||||
};
|
||||
|
||||
const removeAdditionalField = (index: number) => {
|
||||
const fields = [...(localConfig.additionalFields || [])];
|
||||
fields.splice(index, 1);
|
||||
updateConfig({ additionalFields: fields });
|
||||
};
|
||||
|
||||
// 필드 매핑 관리 함수
|
||||
const addFieldMapping = () => {
|
||||
const mappings = localConfig.fieldMappings || [];
|
||||
updateConfig({
|
||||
fieldMappings: [
|
||||
...mappings,
|
||||
{ sourceField: "", targetField: "", label: "" },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const updateFieldMapping = (index: number, updates: Partial<FieldMapping>) => {
|
||||
const mappings = [...(localConfig.fieldMappings || [])];
|
||||
mappings[index] = { ...mappings[index], ...updates };
|
||||
updateConfig({ fieldMappings: mappings });
|
||||
};
|
||||
|
||||
const removeFieldMapping = (index: number) => {
|
||||
const mappings = [...(localConfig.fieldMappings || [])];
|
||||
mappings.splice(index, 1);
|
||||
updateConfig({ fieldMappings: mappings });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">테이블명 *</Label>
|
||||
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openTableCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={isLoadingTables}
|
||||
>
|
||||
{localConfig.tableName
|
||||
? allTables.find((t) => t.tableName === localConfig.tableName)?.displayName || localConfig.tableName
|
||||
: isLoadingTables ? "로딩 중..." : "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={() => {
|
||||
updateConfig({ tableName: table.tableName });
|
||||
setOpenTableCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", localConfig.tableName === table.tableName ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||
{table.displayName && <span className="text-[10px] text-gray-500">{table.tableName}</span>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
검색할 데이터가 저장된 테이블을 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">표시 필드 *</Label>
|
||||
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openDisplayFieldCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
{localConfig.displayField
|
||||
? tableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
|
||||
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={() => {
|
||||
updateConfig({ displayField: column.columnName });
|
||||
setOpenDisplayFieldCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", localConfig.displayField === column.columnName ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{column.displayName || column.columnName}</span>
|
||||
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
사용자에게 보여줄 필드 (예: 거래처명)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">값 필드 *</Label>
|
||||
<Popover open={openValueFieldCombo} onOpenChange={setOpenValueFieldCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openValueFieldCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
{localConfig.valueField
|
||||
? tableColumns.find((c) => c.columnName === localConfig.valueField)?.displayName || localConfig.valueField
|
||||
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={() => {
|
||||
updateConfig({ valueField: column.columnName });
|
||||
setOpenValueFieldCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", localConfig.valueField === column.columnName ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{column.displayName || column.columnName}</span>
|
||||
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
검색 테이블에서 가져올 값의 컬럼 (예: customer_code)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">플레이스홀더</Label>
|
||||
<Input
|
||||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig({ placeholder: e.target.value })}
|
||||
placeholder="검색..."
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 값 필드 저장 위치 설정 */}
|
||||
<div className="space-y-4 border rounded-lg p-4 bg-card">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-1">값 필드 저장 위치 (고급)</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
위에서 선택한 "값 필드"의 데이터를 어느 테이블/컬럼에 저장할지 지정합니다.
|
||||
<br />
|
||||
미설정 시 화면의 연결 테이블에 컴포넌트의 바인딩 필드로 자동 저장됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 저장 테이블 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">저장 테이블</Label>
|
||||
<Popover open={openStorageTableCombo} onOpenChange={setOpenStorageTableCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openStorageTableCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={isLoadingTables}
|
||||
>
|
||||
{localConfig.valueFieldStorage?.targetTable
|
||||
? allTables.find((t) => t.tableName === localConfig.valueFieldStorage?.targetTable)?.displayName ||
|
||||
localConfig.valueFieldStorage.targetTable
|
||||
: "기본값 (화면 연결 테이블)"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{/* 기본값 옵션 */}
|
||||
<CommandItem
|
||||
value=""
|
||||
onSelect={() => {
|
||||
updateConfig({
|
||||
valueFieldStorage: {
|
||||
...localConfig.valueFieldStorage,
|
||||
targetTable: undefined,
|
||||
targetColumn: undefined,
|
||||
},
|
||||
});
|
||||
setOpenStorageTableCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", !localConfig.valueFieldStorage?.targetTable ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">기본값</span>
|
||||
<span className="text-[10px] text-gray-500">화면의 연결 테이블 사용</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
{allTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={() => {
|
||||
updateConfig({
|
||||
valueFieldStorage: {
|
||||
...localConfig.valueFieldStorage,
|
||||
targetTable: table.tableName,
|
||||
targetColumn: undefined, // 테이블 변경 시 컬럼 초기화
|
||||
},
|
||||
});
|
||||
setOpenStorageTableCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
localConfig.valueFieldStorage?.targetTable === table.tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||
{table.displayName && <span className="text-[10px] text-gray-500">{table.tableName}</span>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
값을 저장할 테이블 (기본값: 화면 연결 테이블)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 저장 컬럼 선택 */}
|
||||
{localConfig.valueFieldStorage?.targetTable && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">저장 컬럼</Label>
|
||||
<Popover open={openStorageColumnCombo} onOpenChange={setOpenStorageColumnCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openStorageColumnCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={isLoadingStorageColumns}
|
||||
>
|
||||
{localConfig.valueFieldStorage?.targetColumn
|
||||
? storageTableColumns.find((c) => c.columnName === localConfig.valueFieldStorage?.targetColumn)
|
||||
?.displayName || localConfig.valueFieldStorage.targetColumn
|
||||
: isLoadingStorageColumns
|
||||
? "로딩 중..."
|
||||
: "컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{storageTableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={() => {
|
||||
updateConfig({
|
||||
valueFieldStorage: {
|
||||
...localConfig.valueFieldStorage,
|
||||
targetColumn: column.columnName,
|
||||
},
|
||||
});
|
||||
setOpenStorageColumnCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
localConfig.valueFieldStorage?.targetColumn === column.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{column.displayName || column.columnName}</span>
|
||||
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
값을 저장할 컬럼명
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 설명 박스 */}
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-950 rounded border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-xs font-medium mb-2 text-blue-800 dark:text-blue-200">
|
||||
저장 위치 동작
|
||||
</p>
|
||||
<div className="text-[10px] text-blue-700 dark:text-blue-300 space-y-1">
|
||||
{localConfig.valueFieldStorage?.targetTable ? (
|
||||
<>
|
||||
<p>
|
||||
선택한 값(<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">{localConfig.valueField}</code>)을
|
||||
</p>
|
||||
<p>
|
||||
<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">
|
||||
{localConfig.valueFieldStorage.targetTable}
|
||||
</code>{" "}
|
||||
테이블의{" "}
|
||||
<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">
|
||||
{localConfig.valueFieldStorage.targetColumn || "(컬럼 미지정)"}
|
||||
</code>{" "}
|
||||
컬럼에 저장합니다.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>기본값: 화면의 연결 테이블에 컴포넌트의 바인딩 필드로 저장됩니다.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">검색 필드</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={addSearchField}
|
||||
className="h-7 text-xs"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(localConfig.searchFields || []).map((field, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={field}
|
||||
onValueChange={(value) => updateSearchField(index, value)}
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeSearchField(index)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">추가 정보 표시</Label>
|
||||
<Switch
|
||||
checked={localConfig.showAdditionalInfo || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ showAdditionalInfo: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localConfig.showAdditionalInfo && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">추가 필드</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={addAdditionalField}
|
||||
className="h-7 text-xs"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(localConfig.additionalFields || []).map((field, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={field}
|
||||
onValueChange={(value) => updateAdditionalField(index, value)}
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeAdditionalField(index)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 자동 매핑 설정 */}
|
||||
<div className="space-y-4 border rounded-lg p-4 bg-card">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-1">필드 자동 매핑</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
선택한 항목의 필드를 화면의 다른 입력 필드에 자동으로 채워넣습니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">필드 매핑 활성화</Label>
|
||||
<Switch
|
||||
checked={localConfig.enableFieldMapping || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ enableFieldMapping: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
활성화하면 항목 선택 시 설정된 필드들이 자동으로 채워집니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{localConfig.enableFieldMapping && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">매핑 필드 목록</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={addFieldMapping}
|
||||
className="h-7 text-xs"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{(localConfig.fieldMappings || []).map((mapping, index) => (
|
||||
<div key={index} className="border rounded-lg p-3 space-y-3 bg-background">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
매핑 #{index + 1}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeFieldMapping(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 표시명 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">표시명</Label>
|
||||
<Input
|
||||
value={mapping.label || ""}
|
||||
onChange={(e) =>
|
||||
updateFieldMapping(index, { label: e.target.value })
|
||||
}
|
||||
placeholder="예: 거래처명"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
이 매핑의 설명 (선택사항)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 소스 필드 (테이블의 컬럼) */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">
|
||||
소스 필드 (테이블 컬럼) *
|
||||
</Label>
|
||||
<Select
|
||||
value={mapping.sourceField}
|
||||
onValueChange={(value) =>
|
||||
updateFieldMapping(index, { sourceField: value })
|
||||
}
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{col.displayName || col.columnName}
|
||||
</span>
|
||||
{col.displayName && (
|
||||
<span className="text-[10px] text-gray-500">
|
||||
{col.columnName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
가져올 데이터의 컬럼명
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 타겟 필드 (화면의 input ID) */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">
|
||||
타겟 필드 (화면 컴포넌트 ID) *
|
||||
</Label>
|
||||
<Input
|
||||
value={mapping.targetField}
|
||||
onChange={(e) =>
|
||||
updateFieldMapping(index, { targetField: e.target.value })
|
||||
}
|
||||
placeholder="예: customer_name_input"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
값을 채울 화면 컴포넌트의 ID (예: input의 id 속성)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 예시 설명 */}
|
||||
<div className="p-2 bg-blue-50 dark:bg-blue-950 rounded border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-[10px] text-blue-700 dark:text-blue-300">
|
||||
{mapping.sourceField && mapping.targetField ? (
|
||||
<>
|
||||
<span className="font-semibold">{mapping.label || "이 필드"}</span>: 테이블의{" "}
|
||||
<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">
|
||||
{mapping.sourceField}
|
||||
</code>{" "}
|
||||
값을 화면의{" "}
|
||||
<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">
|
||||
{mapping.targetField}
|
||||
</code>{" "}
|
||||
컴포넌트에 자동으로 채웁니다
|
||||
</>
|
||||
) : (
|
||||
"소스 필드와 타겟 필드를 모두 선택하세요"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 사용 안내 */}
|
||||
{localConfig.fieldMappings && localConfig.fieldMappings.length > 0 && (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-950 rounded border border-amber-200 dark:border-amber-800">
|
||||
<p className="text-xs font-medium mb-2 text-amber-800 dark:text-amber-200">
|
||||
사용 방법
|
||||
</p>
|
||||
<ul className="text-[10px] text-amber-700 dark:text-amber-300 space-y-1 list-disc list-inside">
|
||||
<li>화면에서 이 검색 컴포넌트로 항목을 선택하면</li>
|
||||
<li>설정된 매핑에 따라 다른 입력 필드들이 자동으로 채워집니다</li>
|
||||
<li>타겟 필드 ID는 화면 디자이너에서 설정한 컴포넌트 ID와 일치해야 합니다</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { AutocompleteSearchInputDefinition } from "./index";
|
||||
import { AutocompleteSearchInputComponent } from "./AutocompleteSearchInputComponent";
|
||||
|
||||
/**
|
||||
* AutocompleteSearchInput 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class AutocompleteSearchInputRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = AutocompleteSearchInputDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <AutocompleteSearchInputComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 값 변경 처리
|
||||
*/
|
||||
protected handleValueChange = (value: any) => {
|
||||
this.updateComponent({ value });
|
||||
};
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
AutocompleteSearchInputRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
AutocompleteSearchInputRenderer.enableHotReload();
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
# AutocompleteSearchInput 컴포넌트
|
||||
|
||||
자동완성 드롭다운 방식의 엔티티 검색 입력 컴포넌트입니다.
|
||||
|
||||
## 특징
|
||||
|
||||
- 타이핑하면 즉시 드롭다운 표시
|
||||
- 빈 값일 때 전체 목록 조회
|
||||
- 추가 정보 표시 가능
|
||||
- X 버튼으로 선택 초기화
|
||||
- 외부 클릭 시 자동 닫힘
|
||||
- **필드 자동 매핑**: 선택한 항목의 값을 화면의 다른 입력 필드에 자동으로 채움
|
||||
|
||||
## 사용 예시
|
||||
|
||||
### 기본 사용
|
||||
|
||||
```tsx
|
||||
<AutocompleteSearchInputComponent
|
||||
tableName="customer_mng"
|
||||
displayField="customer_name"
|
||||
valueField="customer_code"
|
||||
searchFields={["customer_name", "customer_code"]}
|
||||
placeholder="거래처명 입력"
|
||||
showAdditionalInfo
|
||||
additionalFields={["customer_code", "address"]}
|
||||
value={selectedCode}
|
||||
onChange={(code, fullData) => {
|
||||
console.log("선택됨:", code, fullData);
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### 필드 자동 매핑 사용
|
||||
|
||||
```tsx
|
||||
<AutocompleteSearchInputComponent
|
||||
config={{
|
||||
tableName: "customer_mng",
|
||||
displayField: "customer_name",
|
||||
valueField: "customer_code",
|
||||
searchFields: ["customer_name", "customer_code"],
|
||||
placeholder: "거래처 검색",
|
||||
enableFieldMapping: true,
|
||||
fieldMappings: [
|
||||
{
|
||||
sourceField: "customer_name", // 테이블의 컬럼명
|
||||
targetField: "customer_name_input", // 화면 input의 id
|
||||
label: "거래처명"
|
||||
},
|
||||
{
|
||||
sourceField: "address",
|
||||
targetField: "address_input",
|
||||
label: "주소"
|
||||
},
|
||||
{
|
||||
sourceField: "phone",
|
||||
targetField: "phone_input",
|
||||
label: "전화번호"
|
||||
}
|
||||
]
|
||||
}}
|
||||
onChange={(code, fullData) => {
|
||||
console.log("선택됨:", code, fullData);
|
||||
// 필드 매핑은 자동으로 실행됩니다
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- 화면의 다른 곳에 있는 입력 필드들 -->
|
||||
<input id="customer_name_input" placeholder="거래처명" />
|
||||
<input id="address_input" placeholder="주소" />
|
||||
<input id="phone_input" placeholder="전화번호" />
|
||||
```
|
||||
|
||||
## 설정 옵션
|
||||
|
||||
### 기본 설정
|
||||
|
||||
- `tableName`: 검색할 테이블명
|
||||
- `displayField`: 표시할 필드
|
||||
- `valueField`: 값으로 사용할 필드
|
||||
- `searchFields`: 검색 대상 필드들
|
||||
- `placeholder`: 플레이스홀더
|
||||
- `showAdditionalInfo`: 추가 정보 표시 여부
|
||||
- `additionalFields`: 추가로 표시할 필드들
|
||||
|
||||
### 값 필드 저장 위치 설정 (고급)
|
||||
|
||||
- `valueFieldStorage`: 값 필드 저장 위치 지정
|
||||
- `targetTable`: 저장할 테이블명 (미설정 시 화면 연결 테이블)
|
||||
- `targetColumn`: 저장할 컬럼명 (미설정 시 바인딩 필드)
|
||||
|
||||
### 필드 자동 매핑 설정
|
||||
|
||||
- `enableFieldMapping`: 필드 자동 매핑 활성화 여부
|
||||
- `fieldMappings`: 매핑할 필드 목록
|
||||
- `sourceField`: 소스 테이블의 컬럼명 (예: customer_name)
|
||||
- `targetField`: 타겟 필드 ID (예: 화면의 input id 속성)
|
||||
- `label`: 표시명 (선택사항)
|
||||
|
||||
## 필드 자동 매핑 동작 방식
|
||||
|
||||
1. 사용자가 검색 컴포넌트에서 항목을 선택합니다
|
||||
2. 선택된 항목의 데이터에서 `sourceField`에 해당하는 값을 가져옵니다
|
||||
3. 화면에서 `targetField` ID를 가진 컴포넌트를 찾습니다
|
||||
4. 해당 컴포넌트에 값을 자동으로 채워넣습니다
|
||||
5. React의 change 이벤트를 트리거하여 상태 업데이트를 유발합니다
|
||||
|
||||
## 주의사항
|
||||
|
||||
- 타겟 필드 ID는 화면 디자이너에서 설정한 컴포넌트 ID와 정확히 일치해야 합니다
|
||||
- 필드 매핑은 input, textarea 타입의 요소에만 동작합니다
|
||||
- 여러 필드를 한 번에 매핑할 수 있습니다
|
||||
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { AutocompleteSearchInputComponent } from "./AutocompleteSearchInputComponent";
|
||||
import { AutocompleteSearchInputConfigPanel } from "./AutocompleteSearchInputConfigPanel";
|
||||
|
||||
/**
|
||||
* AutocompleteSearchInput 컴포넌트 정의
|
||||
* 자동완성 드롭다운 방식의 엔티티 검색 입력
|
||||
*/
|
||||
export const AutocompleteSearchInputDefinition = createComponentDefinition({
|
||||
id: "autocomplete-search-input",
|
||||
name: "자동완성 검색 입력",
|
||||
nameEng: "Autocomplete Search Input",
|
||||
description: "타이핑하면 드롭다운이 나타나는 엔티티 검색 입력 (거래처, 사용자 등)",
|
||||
category: ComponentCategory.INPUT,
|
||||
webType: "entity",
|
||||
component: AutocompleteSearchInputComponent,
|
||||
defaultConfig: {
|
||||
tableName: "customer_mng",
|
||||
displayField: "customer_name",
|
||||
valueField: "customer_code",
|
||||
searchFields: ["customer_name", "customer_code"],
|
||||
placeholder: "검색...",
|
||||
showAdditionalInfo: false,
|
||||
additionalFields: [],
|
||||
},
|
||||
defaultSize: { width: 300, height: 40 },
|
||||
configPanel: AutocompleteSearchInputConfigPanel,
|
||||
icon: "Search",
|
||||
tags: ["검색", "자동완성", "엔티티", "드롭다운", "거래처"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { AutocompleteSearchInputConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { AutocompleteSearchInputComponent } from "./AutocompleteSearchInputComponent";
|
||||
export { AutocompleteSearchInputRenderer } from "./AutocompleteSearchInputRenderer";
|
||||
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
// 값 필드 저장 설정
|
||||
export interface ValueFieldStorage {
|
||||
targetTable?: string; // 저장할 테이블명 (기본값: 화면의 연결 테이블)
|
||||
targetColumn?: string; // 저장할 컬럼명 (기본값: 바인딩 필드)
|
||||
}
|
||||
|
||||
// 필드 매핑 설정
|
||||
export interface FieldMapping {
|
||||
sourceField: string; // 소스 테이블의 컬럼명 (예: customer_name)
|
||||
targetField: string; // 매핑될 타겟 필드 ID (예: 화면의 input ID)
|
||||
label?: string; // 표시명
|
||||
targetTable?: string; // 저장할 테이블 (선택사항, 기본값은 화면 연결 테이블)
|
||||
targetColumn?: string; // 저장할 컬럼명 (선택사항, targetField가 화면 ID가 아닌 경우)
|
||||
}
|
||||
|
||||
export interface AutocompleteSearchInputConfig {
|
||||
tableName: string;
|
||||
displayField: string;
|
||||
valueField: string;
|
||||
searchFields?: string[];
|
||||
filterCondition?: Record<string, any>;
|
||||
placeholder?: string;
|
||||
showAdditionalInfo?: boolean;
|
||||
additionalFields?: string[];
|
||||
// 값 필드 저장 위치 설정
|
||||
valueFieldStorage?: ValueFieldStorage;
|
||||
// 필드 자동 매핑 설정
|
||||
enableFieldMapping?: boolean; // 필드 자동 매핑 활성화 여부
|
||||
fieldMappings?: FieldMapping[]; // 매핑할 필드 목록
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,300 @@
|
|||
# AutocompleteSearchInput 컴포넌트 사용 가이드
|
||||
|
||||
## 📌 이 컴포넌트는 무엇인가요?
|
||||
|
||||
검색 가능한 드롭다운 선택 박스입니다.
|
||||
거래처, 품목, 직원 등을 검색해서 선택할 때 사용합니다.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 패널 설정 방법
|
||||
|
||||
### 1. 기본 검색 설정 (필수)
|
||||
|
||||
#### 테이블명
|
||||
- **의미**: 어디서 검색할 것인가?
|
||||
- **예시**: `customer_mng` (거래처 테이블)
|
||||
|
||||
#### 표시 필드
|
||||
- **의미**: 사용자에게 무엇을 보여줄 것인가?
|
||||
- **예시**: `customer_name` → 화면에 "삼성전자" 표시
|
||||
|
||||
#### 값 필드
|
||||
- **의미**: 실제로 어떤 값을 가져올 것인가?
|
||||
- **예시**: `customer_code` → "CUST-0001" 가져오기
|
||||
|
||||
#### 검색 필드 (선택)
|
||||
- **의미**: 어떤 컬럼으로 검색할 것인가?
|
||||
- **예시**: `customer_name`, `customer_code` 추가
|
||||
- **동작**: 이름으로도 검색, 코드로도 검색 가능
|
||||
|
||||
---
|
||||
|
||||
### 2. 값 필드 저장 위치 (고급, 선택)
|
||||
|
||||
#### 저장 테이블
|
||||
- **기본값**: 화면의 연결 테이블에 자동 저장
|
||||
- **변경 시**: 다른 테이블에 저장 가능
|
||||
|
||||
#### 저장 컬럼
|
||||
- **기본값**: 컴포넌트의 바인딩 필드
|
||||
- **변경 시**: 다른 컬럼에 저장 가능
|
||||
|
||||
> 💡 **대부분은 기본값을 사용하면 됩니다!**
|
||||
|
||||
---
|
||||
|
||||
## 📖 사용 예제
|
||||
|
||||
### 예제 1: 거래처 선택 (가장 일반적)
|
||||
|
||||
#### 패널 설정
|
||||
```
|
||||
테이블명: customer_mng
|
||||
표시 필드: customer_name
|
||||
값 필드: customer_code
|
||||
검색 필드: customer_name, customer_code
|
||||
플레이스홀더: 거래처명 또는 코드 입력
|
||||
```
|
||||
|
||||
#### 동작
|
||||
```
|
||||
사용자 입력: "삼성"
|
||||
드롭다운 표시: "삼성전자", "삼성물산", ...
|
||||
선택: "삼성전자"
|
||||
저장 값: "CUST-0001" (customer_code)
|
||||
```
|
||||
|
||||
#### 결과
|
||||
```
|
||||
order_mng 테이블
|
||||
┌───────────┬───────────────┐
|
||||
│ order_id │ customer_code │
|
||||
├───────────┼───────────────┤
|
||||
│ ORD-0001 │ CUST-0001 │ ✅
|
||||
└───────────┴───────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 예제 2: 거래처명을 직접 저장
|
||||
|
||||
#### 패널 설정
|
||||
```
|
||||
테이블명: customer_mng
|
||||
표시 필드: customer_name
|
||||
값 필드: customer_name ← 이름을 가져옴
|
||||
플레이스홀더: 거래처명 입력
|
||||
```
|
||||
|
||||
#### 동작
|
||||
```
|
||||
사용자 선택: "삼성전자"
|
||||
저장 값: "삼성전자" (customer_name)
|
||||
```
|
||||
|
||||
#### 결과
|
||||
```
|
||||
order_mng 테이블
|
||||
┌───────────┬───────────────┐
|
||||
│ order_id │ customer_name │
|
||||
├───────────┼───────────────┤
|
||||
│ ORD-0001 │ 삼성전자 │ ✅
|
||||
└───────────┴───────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 예제 3: 품목 선택 (추가 정보 표시)
|
||||
|
||||
#### 패널 설정
|
||||
```
|
||||
테이블명: item_mng
|
||||
표시 필드: item_name
|
||||
값 필드: item_code
|
||||
검색 필드: item_name, item_code, category
|
||||
플레이스홀더: 품목명, 코드, 카테고리로 검색
|
||||
|
||||
추가 정보 표시: ON
|
||||
추가 필드: item_code, unit_price
|
||||
```
|
||||
|
||||
#### 동작
|
||||
```
|
||||
드롭다운:
|
||||
┌────────────────────────────┐
|
||||
│ 삼성 노트북 │
|
||||
│ item_code: ITEM-0123 │
|
||||
│ unit_price: 1,500,000 │
|
||||
├────────────────────────────┤
|
||||
│ LG 그램 노트북 │
|
||||
│ item_code: ITEM-0124 │
|
||||
│ unit_price: 1,800,000 │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 필드 선택 가이드
|
||||
|
||||
### 언제 표시 필드 ≠ 값 필드 인가?
|
||||
|
||||
**대부분의 경우 (권장)**
|
||||
```
|
||||
표시 필드: customer_name (이름 - 사람이 읽기 쉬움)
|
||||
값 필드: customer_code (코드 - 데이터베이스에 저장)
|
||||
|
||||
이유:
|
||||
✅ 외래키 관계 유지
|
||||
✅ 데이터 무결성
|
||||
✅ 이름이 바뀌어도 코드는 그대로
|
||||
```
|
||||
|
||||
### 언제 표시 필드 = 값 필드 인가?
|
||||
|
||||
**특수한 경우**
|
||||
```
|
||||
표시 필드: customer_name
|
||||
값 필드: customer_name
|
||||
|
||||
사용 케이스:
|
||||
- 이름 자체를 저장해야 할 때
|
||||
- 외래키가 필요 없을 때
|
||||
- 간단한 참조용 데이터
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 자주 묻는 질문
|
||||
|
||||
### Q1. 저장 위치를 설정하지 않으면?
|
||||
|
||||
**A**: 자동으로 화면의 연결 테이블에 바인딩 필드로 저장됩니다.
|
||||
|
||||
```
|
||||
화면: 수주 등록 (연결 테이블: order_mng)
|
||||
컴포넌트 바인딩 필드: customer_code
|
||||
|
||||
→ order_mng.customer_code에 자동 저장 ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Q2. 값 필드와 저장 위치의 차이는?
|
||||
|
||||
**A**:
|
||||
- **값 필드**: 검색 테이블에서 무엇을 가져올지
|
||||
- **저장 위치**: 가져온 값을 어디에 저장할지
|
||||
|
||||
```
|
||||
값 필드: customer_mng.customer_code (어떤 값?)
|
||||
저장 위치: order_mng.customer_code (어디에?)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Q3. 검색 필드는 왜 여러 개 추가하나요?
|
||||
|
||||
**A**: 여러 방법으로 검색할 수 있게 하기 위해서입니다.
|
||||
|
||||
```
|
||||
검색 필드: [customer_name, customer_code]
|
||||
|
||||
사용자가 "삼성" 입력 → customer_name에서 검색
|
||||
사용자가 "CUST" 입력 → customer_code에서 검색
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Q4. 추가 정보 표시는 언제 사용하나요?
|
||||
|
||||
**A**: 선택할 때 참고할 정보를 함께 보여주고 싶을 때 사용합니다.
|
||||
|
||||
```
|
||||
추가 정보 표시: ON
|
||||
추가 필드: [address, phone]
|
||||
|
||||
드롭다운:
|
||||
┌────────────────────────────┐
|
||||
│ 삼성전자 │
|
||||
│ address: 서울시 서초구 │
|
||||
│ phone: 02-1234-5678 │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 빠른 시작
|
||||
|
||||
### 1단계: 기본 설정만 입력
|
||||
|
||||
```
|
||||
테이블명: [검색할 테이블]
|
||||
표시 필드: [사용자에게 보여줄 컬럼]
|
||||
값 필드: [저장할 컬럼]
|
||||
```
|
||||
|
||||
### 2단계: 화면 디자이너에서 바인딩 필드 설정
|
||||
|
||||
```
|
||||
컴포넌트 ID: customer_search
|
||||
바인딩 필드: customer_code
|
||||
```
|
||||
|
||||
### 3단계: 완료!
|
||||
|
||||
이제 사용자가 선택하면 자동으로 저장됩니다.
|
||||
|
||||
---
|
||||
|
||||
## ✅ 체크리스트
|
||||
|
||||
설정 전:
|
||||
- [ ] 어느 테이블에서 검색할지 알고 있나요?
|
||||
- [ ] 사용자에게 무엇을 보여줄지 정했나요?
|
||||
- [ ] 어떤 값을 저장할지 정했나요?
|
||||
|
||||
설정 후:
|
||||
- [ ] 검색이 정상적으로 되나요?
|
||||
- [ ] 드롭다운에 원하는 항목이 보이나요?
|
||||
- [ ] 선택 후 값이 저장되나요?
|
||||
|
||||
---
|
||||
|
||||
## 📊 설정 패턴 비교
|
||||
|
||||
| 패턴 | 표시 필드 | 값 필드 | 사용 케이스 |
|
||||
|------|----------|---------|------------|
|
||||
| 1 | customer_name | customer_code | 이름 표시, 코드 저장 (일반적) |
|
||||
| 2 | customer_name | customer_name | 이름 표시, 이름 저장 (특수) |
|
||||
| 3 | item_name | item_code | 품목명 표시, 품목코드 저장 |
|
||||
| 4 | employee_name | employee_id | 직원명 표시, ID 저장 |
|
||||
|
||||
---
|
||||
|
||||
## 🎨 실전 팁
|
||||
|
||||
### 1. 검색 필드는 2-3개가 적당
|
||||
```
|
||||
✅ 좋음: [name, code]
|
||||
✅ 좋음: [name, code, category]
|
||||
❌ 과함: [name, code, address, phone, email, ...]
|
||||
```
|
||||
|
||||
### 2. 플레이스홀더는 구체적으로
|
||||
```
|
||||
❌ "검색..."
|
||||
✅ "거래처명 또는 코드 입력"
|
||||
✅ "품목명, 코드, 카테고리로 검색"
|
||||
```
|
||||
|
||||
### 3. 추가 정보는 선택에 도움되는 것만
|
||||
```
|
||||
✅ 도움됨: 가격, 주소, 전화번호
|
||||
❌ 불필요: 등록일, 수정일, ID
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
이 가이드로 autocomplete-search-input 컴포넌트를 쉽게 사용할 수 있습니다! 🎉
|
||||
|
||||
|
|
@ -52,6 +52,9 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
|||
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
||||
flowSelectedData?: any[];
|
||||
flowSelectedStepId?: number | null;
|
||||
|
||||
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
|
||||
allComponents?: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -88,6 +91,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
selectedRowsData,
|
||||
flowSelectedData,
|
||||
flowSelectedStepId,
|
||||
allComponents, // 🆕 같은 화면의 모든 컴포넌트
|
||||
...props
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
|
|
@ -389,6 +393,16 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// 🆕 모든 컴포넌트의 설정 수집 (parentDataMapping 등)
|
||||
const componentConfigs: Record<string, any> = {};
|
||||
if (allComponents && Array.isArray(allComponents)) {
|
||||
for (const comp of allComponents) {
|
||||
if (comp.id && comp.componentConfig) {
|
||||
componentConfigs[comp.id] = comp.componentConfig;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const context: ButtonActionContext = {
|
||||
formData: formData || {},
|
||||
originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가
|
||||
|
|
@ -409,10 +423,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
sortOrder, // 🆕 정렬 방향
|
||||
columnOrder, // 🆕 컬럼 순서
|
||||
tableDisplayData, // 🆕 화면에 표시된 데이터
|
||||
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
|
||||
allComponents,
|
||||
// 플로우 선택된 데이터 정보 추가
|
||||
flowSelectedData,
|
||||
flowSelectedStepId,
|
||||
};
|
||||
// 🆕 컴포넌트별 설정 (parentDataMapping 등)
|
||||
componentConfigs,
|
||||
} as ButtonActionContext;
|
||||
|
||||
// 확인이 필요한 액션인지 확인
|
||||
if (confirmationRequiredActions.includes(processedConfig.action.type)) {
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ButtonPrimaryConfig } from "./types";
|
||||
|
||||
export interface ButtonPrimaryConfigPanelProps {
|
||||
config: ButtonPrimaryConfig;
|
||||
onChange: (config: Partial<ButtonPrimaryConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ButtonPrimary 설정 패널
|
||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||
*/
|
||||
export const ButtonPrimaryConfigPanel: React.FC<ButtonPrimaryConfigPanelProps> = ({ config, onChange }) => {
|
||||
const handleChange = (key: keyof ButtonPrimaryConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">button-primary 설정</div>
|
||||
|
||||
{/* 버튼 관련 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="text">버튼 텍스트</Label>
|
||||
<Input id="text" value={config.text || ""} onChange={(e) => handleChange("text", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="actionType">액션 타입</Label>
|
||||
<Select value={config.actionType || "button"} onValueChange={(value) => handleChange("actionType", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="button">Button</SelectItem>
|
||||
<SelectItem value="submit">Submit</SelectItem>
|
||||
<SelectItem value="reset">Reset</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disabled">비활성화</Label>
|
||||
<Checkbox
|
||||
id="disabled"
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => handleChange("disabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="required">필수 입력</Label>
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={config.required || false}
|
||||
onCheckedChange={(checked) => handleChange("required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="readonly">읽기 전용</Label>
|
||||
<Checkbox
|
||||
id="readonly"
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -5,7 +5,6 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
|
|||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { ButtonPrimaryWrapper } from "./ButtonPrimaryComponent";
|
||||
import { ButtonPrimaryConfigPanel } from "./ButtonPrimaryConfigPanel";
|
||||
import { ButtonPrimaryConfig } from "./types";
|
||||
|
||||
/**
|
||||
|
|
@ -31,7 +30,7 @@ export const ButtonPrimaryDefinition = createComponentDefinition({
|
|||
},
|
||||
},
|
||||
defaultSize: { width: 120, height: 40 },
|
||||
configPanel: ButtonPrimaryConfigPanel,
|
||||
configPanel: undefined, // 상세 설정 패널(ButtonConfigPanel)이 대신 사용됨
|
||||
icon: "MousePointer",
|
||||
tags: ["버튼", "액션", "클릭"],
|
||||
version: "1.0.0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ConditionalContainerProps, ConditionalSection } from "./types";
|
||||
import { ConditionalSectionViewer } from "./ConditionalSectionViewer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
console.log("🚀 ConditionalContainerComponent 모듈 로드됨!");
|
||||
|
||||
/**
|
||||
* 조건부 컨테이너 컴포넌트
|
||||
* 상단 셀렉트박스 값에 따라 하단에 다른 UI를 표시
|
||||
*/
|
||||
export function ConditionalContainerComponent({
|
||||
config,
|
||||
controlField: propControlField,
|
||||
controlLabel: propControlLabel,
|
||||
sections: propSections,
|
||||
defaultValue: propDefaultValue,
|
||||
showBorder: propShowBorder,
|
||||
spacing: propSpacing,
|
||||
value,
|
||||
onChange,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
isDesignMode = false,
|
||||
onUpdateComponent,
|
||||
onDeleteComponent,
|
||||
onSelectComponent,
|
||||
selectedComponentId,
|
||||
onHeightChange,
|
||||
componentId,
|
||||
style,
|
||||
className,
|
||||
}: ConditionalContainerProps) {
|
||||
console.log("🎯 ConditionalContainerComponent 렌더링!", {
|
||||
isDesignMode,
|
||||
hasOnHeightChange: !!onHeightChange,
|
||||
componentId,
|
||||
});
|
||||
|
||||
// config prop 우선, 없으면 개별 prop 사용
|
||||
const controlField = config?.controlField || propControlField || "condition";
|
||||
const controlLabel = config?.controlLabel || propControlLabel || "조건 선택";
|
||||
const sections = config?.sections || propSections || [];
|
||||
const defaultValue = config?.defaultValue || propDefaultValue || sections[0]?.condition;
|
||||
const showBorder = config?.showBorder ?? propShowBorder ?? true;
|
||||
const spacing = config?.spacing || propSpacing || "normal";
|
||||
|
||||
// 현재 선택된 값
|
||||
const [selectedValue, setSelectedValue] = useState<string>(
|
||||
value || formData?.[controlField] || defaultValue || ""
|
||||
);
|
||||
|
||||
// formData 변경 시 동기화
|
||||
useEffect(() => {
|
||||
if (formData?.[controlField]) {
|
||||
setSelectedValue(formData[controlField]);
|
||||
}
|
||||
}, [formData, controlField]);
|
||||
|
||||
// 값 변경 핸들러
|
||||
const handleValueChange = (newValue: string) => {
|
||||
setSelectedValue(newValue);
|
||||
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
}
|
||||
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(controlField, newValue);
|
||||
}
|
||||
};
|
||||
|
||||
// 컨테이너 높이 측정용 ref
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const previousHeightRef = useRef<number>(0);
|
||||
|
||||
// 🔍 디버그: props 확인
|
||||
useEffect(() => {
|
||||
console.log("🔍 ConditionalContainer props:", {
|
||||
isDesignMode,
|
||||
hasOnHeightChange: !!onHeightChange,
|
||||
componentId,
|
||||
selectedValue,
|
||||
});
|
||||
}, [isDesignMode, onHeightChange, componentId, selectedValue]);
|
||||
|
||||
// 높이 변화 감지 및 콜백 호출
|
||||
useEffect(() => {
|
||||
console.log("🔍 ResizeObserver 등록 조건:", {
|
||||
hasContainer: !!containerRef.current,
|
||||
isDesignMode,
|
||||
hasOnHeightChange: !!onHeightChange,
|
||||
});
|
||||
|
||||
if (!containerRef.current || isDesignMode || !onHeightChange) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const newHeight = entry.contentRect.height;
|
||||
|
||||
// 높이가 실제로 변경되었을 때만 콜백 호출
|
||||
if (Math.abs(newHeight - previousHeightRef.current) > 5) {
|
||||
console.log(`📏 조건부 컨테이너 높이 변화: ${previousHeightRef.current}px → ${newHeight}px`);
|
||||
previousHeightRef.current = newHeight;
|
||||
onHeightChange(newHeight);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [isDesignMode, onHeightChange, selectedValue]); // selectedValue 변경 시에도 감지
|
||||
|
||||
// 간격 스타일
|
||||
const spacingClass = {
|
||||
tight: "space-y-2",
|
||||
normal: "space-y-4",
|
||||
loose: "space-y-8",
|
||||
}[spacing];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("w-full flex flex-col", spacingClass, className)}
|
||||
style={style}
|
||||
>
|
||||
{/* 제어 셀렉트박스 */}
|
||||
<div className="space-y-2 flex-shrink-0">
|
||||
<Label htmlFor={controlField} className="text-xs sm:text-sm">
|
||||
{controlLabel}
|
||||
</Label>
|
||||
<Select value={selectedValue} onValueChange={handleValueChange}>
|
||||
<SelectTrigger
|
||||
id={controlField}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sections.map((section) => (
|
||||
<SelectItem key={section.id} value={section.condition}>
|
||||
{section.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 조건별 섹션들 */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{isDesignMode ? (
|
||||
// 디자인 모드: 모든 섹션 표시
|
||||
<div className={spacingClass}>
|
||||
{sections.map((section) => (
|
||||
<ConditionalSectionViewer
|
||||
key={section.id}
|
||||
sectionId={section.id}
|
||||
condition={section.condition}
|
||||
label={section.label}
|
||||
screenId={section.screenId}
|
||||
screenName={section.screenName}
|
||||
isActive={selectedValue === section.condition}
|
||||
isDesignMode={isDesignMode}
|
||||
showBorder={showBorder}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// 실행 모드: 활성 섹션만 표시
|
||||
sections.map((section) =>
|
||||
selectedValue === section.condition ? (
|
||||
<ConditionalSectionViewer
|
||||
key={section.id}
|
||||
sectionId={section.id}
|
||||
condition={section.condition}
|
||||
label={section.label}
|
||||
screenId={section.screenId}
|
||||
screenName={section.screenName}
|
||||
isActive={true}
|
||||
isDesignMode={false}
|
||||
showBorder={showBorder}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 섹션이 없는 경우 안내 */}
|
||||
{sections.length === 0 && isDesignMode && (
|
||||
<div className="flex items-center justify-center min-h-[200px] border-2 border-dashed border-muted-foreground/30 rounded-lg bg-muted/20">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
설정 패널에서 조건을 추가하세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus, Trash2, GripVertical, Loader2 } from "lucide-react";
|
||||
import { ConditionalContainerConfig, ConditionalSection } from "./types";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
|
||||
interface ConditionalContainerConfigPanelProps {
|
||||
config: ConditionalContainerConfig;
|
||||
onConfigChange: (config: ConditionalContainerConfig) => void;
|
||||
}
|
||||
|
||||
export function ConditionalContainerConfigPanel({
|
||||
config,
|
||||
onConfigChange,
|
||||
}: ConditionalContainerConfigPanelProps) {
|
||||
const [localConfig, setLocalConfig] = useState<ConditionalContainerConfig>({
|
||||
controlField: config.controlField || "condition",
|
||||
controlLabel: config.controlLabel || "조건 선택",
|
||||
sections: config.sections || [],
|
||||
defaultValue: config.defaultValue || "",
|
||||
showBorder: config.showBorder ?? true,
|
||||
spacing: config.spacing || "normal",
|
||||
});
|
||||
|
||||
// 화면 목록 상태
|
||||
const [screens, setScreens] = useState<any[]>([]);
|
||||
const [screensLoading, setScreensLoading] = useState(false);
|
||||
|
||||
// 화면 목록 로드
|
||||
useEffect(() => {
|
||||
const loadScreens = async () => {
|
||||
setScreensLoading(true);
|
||||
try {
|
||||
const response = await screenApi.getScreens({ page: 1, size: 1000 });
|
||||
if (response.data) {
|
||||
setScreens(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("화면 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setScreensLoading(false);
|
||||
}
|
||||
};
|
||||
loadScreens();
|
||||
}, []);
|
||||
|
||||
// 설정 업데이트 헬퍼
|
||||
const updateConfig = (updates: Partial<ConditionalContainerConfig>) => {
|
||||
const newConfig = { ...localConfig, ...updates };
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
};
|
||||
|
||||
// 새 섹션 추가
|
||||
const addSection = () => {
|
||||
const newSection: ConditionalSection = {
|
||||
id: `section_${Date.now()}`,
|
||||
condition: `condition_${localConfig.sections.length + 1}`,
|
||||
label: `조건 ${localConfig.sections.length + 1}`,
|
||||
screenId: null,
|
||||
screenName: undefined,
|
||||
};
|
||||
|
||||
updateConfig({
|
||||
sections: [...localConfig.sections, newSection],
|
||||
});
|
||||
};
|
||||
|
||||
// 섹션 삭제
|
||||
const removeSection = (sectionId: string) => {
|
||||
updateConfig({
|
||||
sections: localConfig.sections.filter((s) => s.id !== sectionId),
|
||||
});
|
||||
};
|
||||
|
||||
// 섹션 업데이트
|
||||
const updateSection = (
|
||||
sectionId: string,
|
||||
updates: Partial<ConditionalSection>
|
||||
) => {
|
||||
updateConfig({
|
||||
sections: localConfig.sections.map((s) =>
|
||||
s.id === sectionId ? { ...s, ...updates } : s
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-4">조건부 컨테이너 설정</h3>
|
||||
|
||||
{/* 제어 필드 설정 */}
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="controlField" className="text-xs">
|
||||
제어 필드명
|
||||
</Label>
|
||||
<Input
|
||||
id="controlField"
|
||||
value={localConfig.controlField}
|
||||
onChange={(e) => updateConfig({ controlField: e.target.value })}
|
||||
placeholder="예: inputMode"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
formData에 저장될 필드명
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="controlLabel" className="text-xs">
|
||||
셀렉트박스 라벨
|
||||
</Label>
|
||||
<Input
|
||||
id="controlLabel"
|
||||
value={localConfig.controlLabel}
|
||||
onChange={(e) => updateConfig({ controlLabel: e.target.value })}
|
||||
placeholder="예: 입력 방식"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조건별 섹션 설정 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold">조건별 섹션</Label>
|
||||
<Button
|
||||
onClick={addSection}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
섹션 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{localConfig.sections.length === 0 ? (
|
||||
<div className="text-center py-8 border-2 border-dashed rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
조건별 섹션을 추가하세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{localConfig.sections.map((section, index) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="p-3 border rounded-lg space-y-3 bg-muted/20"
|
||||
>
|
||||
{/* 섹션 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">
|
||||
섹션 {index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => removeSection(section.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 조건 값 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
조건 값 (고유값)
|
||||
</Label>
|
||||
<Input
|
||||
value={section.condition}
|
||||
onChange={(e) =>
|
||||
updateSection(section.id, { condition: e.target.value })
|
||||
}
|
||||
placeholder="예: customer_first"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 조건 라벨 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
표시 라벨
|
||||
</Label>
|
||||
<Input
|
||||
value={section.label}
|
||||
onChange={(e) =>
|
||||
updateSection(section.id, { label: e.target.value })
|
||||
}
|
||||
placeholder="예: 거래처 우선"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 화면 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
표시할 화면
|
||||
</Label>
|
||||
{screensLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground h-7 px-3 border rounded">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
로딩 중...
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={section.screenId?.toString() || "none"}
|
||||
onValueChange={(value) => {
|
||||
if (value === "none") {
|
||||
updateSection(section.id, {
|
||||
screenId: null,
|
||||
screenName: undefined,
|
||||
});
|
||||
} else {
|
||||
const screenId = parseInt(value);
|
||||
const selectedScreen = screens.find(
|
||||
(s) => s.screenId === screenId
|
||||
);
|
||||
updateSection(section.id, {
|
||||
screenId,
|
||||
screenName: selectedScreen?.screenName,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="화면 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안 함</SelectItem>
|
||||
{screens.map((screen) => (
|
||||
<SelectItem
|
||||
key={screen.screenId}
|
||||
value={screen.screenId.toString()}
|
||||
>
|
||||
{screen.screenName}
|
||||
{screen.description && (
|
||||
<span className="text-[10px] text-muted-foreground ml-1">
|
||||
({screen.description})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{section.screenId && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
화면 ID: {section.screenId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 기본값 설정 */}
|
||||
{localConfig.sections.length > 0 && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<Label htmlFor="defaultValue" className="text-xs">
|
||||
기본 선택 값
|
||||
</Label>
|
||||
<Select
|
||||
value={localConfig.defaultValue || ""}
|
||||
onValueChange={(value) => updateConfig({ defaultValue: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="기본값 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{localConfig.sections.map((section) => (
|
||||
<SelectItem key={section.id} value={section.condition}>
|
||||
{section.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 스타일 설정 */}
|
||||
<div className="space-y-4 mt-6 pt-6 border-t">
|
||||
<Label className="text-xs font-semibold">스타일 설정</Label>
|
||||
|
||||
{/* 테두리 표시 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showBorder" className="text-xs">
|
||||
섹션 테두리 표시
|
||||
</Label>
|
||||
<Switch
|
||||
id="showBorder"
|
||||
checked={localConfig.showBorder}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ showBorder: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 간격 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="spacing" className="text-xs">
|
||||
섹션 간격
|
||||
</Label>
|
||||
<Select
|
||||
value={localConfig.spacing || "normal"}
|
||||
onValueChange={(value: any) => updateConfig({ spacing: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tight">좁게</SelectItem>
|
||||
<SelectItem value="normal">보통</SelectItem>
|
||||
<SelectItem value="loose">넓게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import ConditionalContainerDefinition from "./index";
|
||||
import { ConditionalContainerComponent } from "./ConditionalContainerComponent";
|
||||
import { ConditionalContainerConfigPanel } from "./ConditionalContainerConfigPanel";
|
||||
|
||||
// 컴포넌트 자동 등록
|
||||
if (typeof window !== "undefined") {
|
||||
ComponentRegistry.registerComponent({
|
||||
...ConditionalContainerDefinition,
|
||||
component: ConditionalContainerComponent,
|
||||
renderer: ConditionalContainerComponent,
|
||||
configPanel: ConditionalContainerConfigPanel,
|
||||
} as any);
|
||||
}
|
||||
|
||||
export { ConditionalContainerComponent };
|
||||
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ConditionalSectionViewerProps } from "./types";
|
||||
import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
/**
|
||||
* 조건부 섹션 뷰어 컴포넌트
|
||||
* 각 조건에 해당하는 화면을 표시
|
||||
*/
|
||||
export function ConditionalSectionViewer({
|
||||
sectionId,
|
||||
condition,
|
||||
label,
|
||||
screenId,
|
||||
screenName,
|
||||
isActive,
|
||||
isDesignMode,
|
||||
showBorder = true,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
}: ConditionalSectionViewerProps) {
|
||||
const { userId, userName, user } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [components, setComponents] = useState<ComponentData[]>([]);
|
||||
const [screenInfo, setScreenInfo] = useState<{ id: number; tableName?: string } | null>(null);
|
||||
const [screenResolution, setScreenResolution] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
// 화면 로드
|
||||
useEffect(() => {
|
||||
if (!screenId) {
|
||||
setComponents([]);
|
||||
setScreenInfo(null);
|
||||
setScreenResolution(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadScreen = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [layout, screen] = await Promise.all([screenApi.getLayout(screenId), screenApi.getScreen(screenId)]);
|
||||
|
||||
setComponents(layout.components || []);
|
||||
setScreenInfo({
|
||||
id: screenId,
|
||||
tableName: screen.tableName,
|
||||
});
|
||||
setScreenResolution(layout.screenResolution || null);
|
||||
} catch (error) {
|
||||
console.error("화면 로드 실패:", error);
|
||||
setComponents([]);
|
||||
setScreenInfo(null);
|
||||
setScreenResolution(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadScreen();
|
||||
}, [screenId]);
|
||||
|
||||
// 디자인 모드가 아니고 비활성 섹션이면 렌더링하지 않음
|
||||
if (!isDesignMode && !isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full transition-all",
|
||||
isDesignMode && showBorder && "border-muted-foreground/30 bg-muted/20 rounded-lg border-2 border-dashed",
|
||||
!isDesignMode && !isActive && "hidden",
|
||||
)}
|
||||
style={{
|
||||
minHeight: isDesignMode ? "200px" : undefined,
|
||||
}}
|
||||
data-section-id={sectionId}
|
||||
>
|
||||
{/* 섹션 라벨 (디자인 모드에서만 표시) */}
|
||||
{isDesignMode && (
|
||||
<div className="bg-background text-muted-foreground absolute -top-3 left-4 z-10 px-2 text-xs font-medium">
|
||||
{label} {isActive && "(활성)"}
|
||||
{screenId && ` - 화면 ID: ${screenId}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 화면 미선택 안내 (디자인 모드 + 화면 없을 때) */}
|
||||
{isDesignMode && !screenId && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-muted-foreground text-center">
|
||||
<p className="text-sm">설정 패널에서 화면을 선택하세요</p>
|
||||
<p className="mt-1 text-xs">조건: {condition}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 중 */}
|
||||
{isLoading && (
|
||||
<div className="bg-background/50 absolute inset-0 z-20 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
||||
<p className="text-muted-foreground text-xs">화면 로드 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 화면 렌더링 */}
|
||||
{screenId && components.length > 0 && (
|
||||
<>
|
||||
{isDesignMode ? (
|
||||
/* 디자인 모드: 화면 정보만 표시 */
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-foreground mb-2 text-sm font-medium">{screenName || `화면 ID: ${screenId}`}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{screenResolution?.width} x {screenResolution?.height}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">컴포넌트 {components.length}개</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* 실행 모드: 실제 화면 렌더링 */
|
||||
<div className="w-full">
|
||||
{/* 화면 크기만큼의 절대 위치 캔버스 */}
|
||||
<div
|
||||
className="relative mx-auto"
|
||||
style={{
|
||||
width: screenResolution?.width ? `${screenResolution.width}px` : "100%",
|
||||
height: screenResolution?.height ? `${screenResolution.height}px` : "auto",
|
||||
minHeight: "200px",
|
||||
}}
|
||||
>
|
||||
{components.map((component) => (
|
||||
<RealtimePreview
|
||||
key={component.id}
|
||||
component={component}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
screenId={screenInfo?.id}
|
||||
tableName={screenInfo?.tableName}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
# 조건부 컨테이너 (ConditionalContainer) - 화면 선택 방식
|
||||
|
||||
제어 셀렉트박스 값에 따라 다른 **화면**을 표시하는 조건부 컨테이너 컴포넌트입니다.
|
||||
|
||||
## 📋 개요
|
||||
|
||||
화면 편집기에서 조건별로 표시할 화면을 선택하여 조건부 UI를 구성할 수 있는 컨테이너입니다. 상단의 셀렉트박스 값에 따라 하단에 미리 만들어진 화면을 표시합니다.
|
||||
|
||||
## ✨ 주요 기능
|
||||
|
||||
- ✅ **조건별 화면 전환**: 셀렉트박스 값에 따라 다른 화면 표시
|
||||
- ✅ **화면 재사용**: 기존에 만든 화면을 조건별로 할당
|
||||
- ✅ **간편한 구성**: 복잡한 입력 폼도 화면 선택으로 간단히 구성
|
||||
- ✅ **자동 동기화**: 화면 수정 시 자동 반영
|
||||
- ✅ **폼 데이터 연동**: formData와 자동 동기화
|
||||
- ✅ **커스터마이징**: 테두리, 간격, 기본값 등 설정 가능
|
||||
|
||||
## 🎯 사용 사례
|
||||
|
||||
### 1. 입력 방식 선택
|
||||
```
|
||||
[셀렉트: 입력 방식]
|
||||
├─ 거래처 우선: "거래처_우선_입력_화면" (화면 ID: 101)
|
||||
├─ 견적서 기반: "견적서_업로드_화면" (화면 ID: 102)
|
||||
└─ 단가 직접입력: "단가_직접입력_화면" (화면 ID: 103)
|
||||
```
|
||||
|
||||
### 2. 판매 유형 선택
|
||||
```
|
||||
[셀렉트: 판매 유형]
|
||||
├─ 국내 판매: "국내판매_기본폼" (화면 ID: 201)
|
||||
└─ 해외 판매: "해외판매_무역정보폼" (화면 ID: 202)
|
||||
```
|
||||
|
||||
### 3. 문서 유형 선택
|
||||
```
|
||||
[셀렉트: 문서 유형]
|
||||
├─ 신규 작성: "신규문서_입력폼" (화면 ID: 301)
|
||||
├─ 복사 생성: "문서복사_화면" (화면 ID: 302)
|
||||
└─ 불러오기: "파일업로드_화면" (화면 ID: 303)
|
||||
```
|
||||
|
||||
## 📐 구조
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ ConditionalContainer │
|
||||
├─────────────────────────────────┤
|
||||
│ [제어 셀렉트박스] │ ← controlField, controlLabel
|
||||
├─────────────────────────────────┤
|
||||
│ 📄 조건 1: "옵션 A" 선택 시 │ ← sections[0]
|
||||
│ ┌─────────────────────────────┐│
|
||||
│ │ [선택된 화면이 표시됨] ││ ← screenId로 지정된 화면
|
||||
│ │ (화면 ID: 101) ││
|
||||
│ │ ││
|
||||
│ └─────────────────────────────┘│
|
||||
├─────────────────────────────────┤
|
||||
│ 📄 조건 2: "옵션 B" 선택 시 │ ← sections[1]
|
||||
│ ┌─────────────────────────────┐│
|
||||
│ │ [다른 화면이 표시됨] ││ ← screenId로 지정된 다른 화면
|
||||
│ │ (화면 ID: 102) ││
|
||||
│ └─────────────────────────────┘│
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🔧 설정 방법
|
||||
|
||||
### 1. 컴포넌트 추가
|
||||
화면 편집기의 컴포넌트 패널에서 **"조건부 컨테이너"**를 드래그하여 캔버스에 배치합니다.
|
||||
|
||||
### 2. 설정 패널에서 구성
|
||||
|
||||
#### 제어 필드 설정
|
||||
- **제어 필드명**: formData에 저장될 필드명 (예: `inputMode`)
|
||||
- **셀렉트박스 라벨**: 화면에 표시될 라벨 (예: "입력 방식")
|
||||
|
||||
#### 조건별 섹션 추가
|
||||
1. **"섹션 추가"** 버튼 클릭
|
||||
2. 각 섹션 설정:
|
||||
- **조건 값**: 고유한 값 (예: `customer_first`)
|
||||
- **표시 라벨**: 사용자에게 보이는 텍스트 (예: "거래처 우선")
|
||||
|
||||
#### 기본값 설정
|
||||
- 처음 화면 로드 시 선택될 기본 조건 선택
|
||||
|
||||
#### 스타일 설정
|
||||
- **섹션 테두리 표시**: ON/OFF
|
||||
- **섹션 간격**: 좁게 / 보통 / 넓게
|
||||
|
||||
### 3. 조건별 화면 선택
|
||||
|
||||
1. **디자인 모드**에서 모든 조건 섹션이 표시됩니다
|
||||
2. 각 섹션의 **"표시할 화면"** 드롭다운에서 화면을 선택합니다
|
||||
3. 선택된 화면 ID와 이름이 자동으로 저장됩니다
|
||||
|
||||
**장점:**
|
||||
- ✅ 이미 만든 화면을 재사용
|
||||
- ✅ 복잡한 입력 폼도 간단히 구성
|
||||
- ✅ 화면 수정 시 자동 반영
|
||||
|
||||
### 4. 실행 모드 동작
|
||||
|
||||
- 셀렉트박스에서 조건 선택
|
||||
- 선택된 조건의 **화면**이 표시됨
|
||||
- 다른 조건의 화면은 자동으로 숨김
|
||||
|
||||
## 💻 기술 사양
|
||||
|
||||
### Props
|
||||
|
||||
```typescript
|
||||
interface ConditionalContainerProps {
|
||||
// 제어 필드
|
||||
controlField: string; // 예: "inputMode"
|
||||
controlLabel: string; // 예: "입력 방식"
|
||||
|
||||
// 조건별 섹션
|
||||
sections: ConditionalSection[];
|
||||
|
||||
// 기본값
|
||||
defaultValue?: string;
|
||||
|
||||
// 스타일
|
||||
showBorder?: boolean; // 기본: true
|
||||
spacing?: "tight" | "normal" | "loose"; // 기본: "normal"
|
||||
|
||||
// 폼 연동
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
}
|
||||
|
||||
interface ConditionalSection {
|
||||
id: string; // 고유 ID
|
||||
condition: string; // 조건 값
|
||||
label: string; // 표시 라벨
|
||||
screenId: number | null; // 표시할 화면 ID
|
||||
screenName?: string; // 화면 이름 (표시용)
|
||||
}
|
||||
```
|
||||
|
||||
### 기본 설정
|
||||
|
||||
```typescript
|
||||
defaultSize: {
|
||||
width: 800,
|
||||
height: 600,
|
||||
}
|
||||
|
||||
defaultConfig: {
|
||||
controlField: "condition",
|
||||
controlLabel: "조건 선택",
|
||||
sections: [
|
||||
{
|
||||
id: "section_1",
|
||||
condition: "option1",
|
||||
label: "옵션 1",
|
||||
screenId: null, // 화면 미선택 상태
|
||||
},
|
||||
{
|
||||
id: "section_2",
|
||||
condition: "option2",
|
||||
label: "옵션 2",
|
||||
screenId: null, // 화면 미선택 상태
|
||||
},
|
||||
],
|
||||
defaultValue: "option1",
|
||||
showBorder: true,
|
||||
spacing: "normal",
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 디자인 모드 vs 실행 모드
|
||||
|
||||
### 디자인 모드 (편집기)
|
||||
- ✅ 모든 조건 섹션 표시
|
||||
- ✅ 각 섹션에 "조건: XXX" 라벨 표시
|
||||
- ✅ 화면 선택 안내 메시지 (미선택 시)
|
||||
- ✅ 선택된 화면 ID 표시
|
||||
- ✅ 활성 조건 "(활성)" 표시
|
||||
|
||||
### 실행 모드 (할당된 화면)
|
||||
- ✅ 선택된 조건의 화면만 표시
|
||||
- ✅ 다른 조건의 화면 자동 숨김
|
||||
- ✅ 깔끔한 UI (라벨, 점선 테두리 제거)
|
||||
- ✅ 선택된 화면이 완전히 통합되어 표시
|
||||
|
||||
## 📊 폼 데이터 연동
|
||||
|
||||
### 자동 동기화
|
||||
```typescript
|
||||
// formData 읽기
|
||||
formData[controlField] // 현재 선택된 값
|
||||
|
||||
// formData 쓰기
|
||||
onFormDataChange(controlField, newValue)
|
||||
```
|
||||
|
||||
### 예시
|
||||
```typescript
|
||||
// controlField = "salesType"
|
||||
formData = {
|
||||
salesType: "export", // ← 자동으로 여기에 저장됨
|
||||
// ... 다른 필드들
|
||||
}
|
||||
|
||||
// 셀렉트박스 값 변경 시 자동으로 formData 업데이트
|
||||
```
|
||||
|
||||
## 🔍 주의사항
|
||||
|
||||
1. **조건 값은 고유해야 함**: 각 섹션의 `condition` 값은 중복되면 안 됩니다
|
||||
2. **최소 1개 섹션 필요**: 섹션이 없으면 안내 메시지 표시
|
||||
3. **컴포넌트 ID 충돌 방지**: 각 섹션의 컴포넌트 ID는 전역적으로 고유해야 함
|
||||
|
||||
## 📝 예시: 수주 입력 방식 선택
|
||||
|
||||
```typescript
|
||||
{
|
||||
controlField: "inputMode",
|
||||
controlLabel: "입력 방식",
|
||||
sections: [
|
||||
{
|
||||
id: "customer_first",
|
||||
condition: "customer_first",
|
||||
label: "거래처 우선",
|
||||
components: [
|
||||
// 거래처 검색 컴포넌트
|
||||
// 품목 선택 테이블
|
||||
// 저장 버튼
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "quotation",
|
||||
condition: "quotation",
|
||||
label: "견적서 기반",
|
||||
components: [
|
||||
// 견적서 검색 컴포넌트
|
||||
// 견적서 내용 표시
|
||||
// 수주 전환 버튼
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "unit_price",
|
||||
condition: "unit_price",
|
||||
label: "단가 직접입력",
|
||||
components: [
|
||||
// 품목 입력 테이블
|
||||
// 단가 입력 필드들
|
||||
// 계산 위젯
|
||||
]
|
||||
}
|
||||
],
|
||||
defaultValue: "customer_first",
|
||||
showBorder: true,
|
||||
spacing: "normal"
|
||||
}
|
||||
```
|
||||
|
||||
## 🚀 로드맵
|
||||
|
||||
- [ ] 다중 제어 필드 지원 (AND/OR 조건)
|
||||
- [ ] 섹션 전환 애니메이션
|
||||
- [ ] 조건별 검증 규칙
|
||||
- [ ] 템플릿 저장/불러오기
|
||||
|
||||
## 🐛 트러블슈팅
|
||||
|
||||
### Q: 섹션이 전환되지 않아요
|
||||
A: `controlField` 값이 formData에 제대로 저장되고 있는지 확인하세요.
|
||||
|
||||
### Q: 컴포넌트가 드롭되지 않아요
|
||||
A: 디자인 모드인지 확인하고, 드롭존 영역에 정확히 드롭하세요.
|
||||
|
||||
### Q: 다른 조건의 UI가 계속 보여요
|
||||
A: 실행 모드로 전환했는지 확인하세요. 디자인 모드에서는 모든 조건이 표시됩니다.
|
||||
|
||||
## 📦 파일 구조
|
||||
|
||||
```
|
||||
conditional-container/
|
||||
├── types.ts # 타입 정의
|
||||
├── ConditionalContainerComponent.tsx # 메인 컴포넌트
|
||||
├── ConditionalSectionDropZone.tsx # 드롭존 컴포넌트
|
||||
├── ConditionalContainerConfigPanel.tsx # 설정 패널
|
||||
├── ConditionalContainerRenderer.tsx # 렌더러 및 등록
|
||||
├── index.ts # 컴포넌트 정의
|
||||
└── README.md # 이 파일
|
||||
```
|
||||
|
||||
## 🎉 완료!
|
||||
|
||||
이제 화면 편집기에서 **조건부 컨테이너**를 사용하여 동적인 UI를 만들 수 있습니다! 🚀
|
||||
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* 조건부 컨테이너 컴포넌트
|
||||
* 제어 셀렉트박스 값에 따라 다른 UI를 표시하는 컨테이너
|
||||
*/
|
||||
|
||||
import { ComponentDefinition, ComponentCategory } from "@/types/component";
|
||||
|
||||
export const ConditionalContainerDefinition: Omit<
|
||||
ComponentDefinition,
|
||||
"renderer" | "configPanel" | "component"
|
||||
> = {
|
||||
id: "conditional-container",
|
||||
name: "조건부 컨테이너",
|
||||
category: ComponentCategory.LAYOUT,
|
||||
webType: "container" as const,
|
||||
description: "셀렉트박스 값에 따라 다른 UI를 표시하는 조건부 컨테이너",
|
||||
icon: "GitBranch",
|
||||
version: "1.0.0",
|
||||
author: "WACE",
|
||||
tags: ["조건부", "분기", "동적", "레이아웃"],
|
||||
|
||||
defaultSize: {
|
||||
width: 1400,
|
||||
height: 800,
|
||||
},
|
||||
|
||||
defaultConfig: {
|
||||
controlField: "condition",
|
||||
controlLabel: "조건 선택",
|
||||
sections: [
|
||||
{
|
||||
id: "section_1",
|
||||
condition: "option1",
|
||||
label: "옵션 1",
|
||||
screenId: null,
|
||||
},
|
||||
{
|
||||
id: "section_2",
|
||||
condition: "option2",
|
||||
label: "옵션 2",
|
||||
screenId: null,
|
||||
},
|
||||
],
|
||||
defaultValue: "option1",
|
||||
showBorder: true,
|
||||
spacing: "normal",
|
||||
},
|
||||
|
||||
defaultProps: {
|
||||
style: {
|
||||
width: "1400px",
|
||||
height: "800px",
|
||||
},
|
||||
},
|
||||
|
||||
configSchema: {
|
||||
controlField: {
|
||||
type: "string",
|
||||
label: "제어 필드명",
|
||||
defaultValue: "condition",
|
||||
},
|
||||
controlLabel: {
|
||||
type: "string",
|
||||
label: "셀렉트박스 라벨",
|
||||
defaultValue: "조건 선택",
|
||||
},
|
||||
sections: {
|
||||
type: "array",
|
||||
label: "조건별 섹션",
|
||||
defaultValue: [],
|
||||
},
|
||||
defaultValue: {
|
||||
type: "string",
|
||||
label: "기본 선택 값",
|
||||
defaultValue: "",
|
||||
},
|
||||
showBorder: {
|
||||
type: "boolean",
|
||||
label: "섹션 테두리 표시",
|
||||
defaultValue: true,
|
||||
},
|
||||
spacing: {
|
||||
type: "select",
|
||||
label: "섹션 간격",
|
||||
options: [
|
||||
{ label: "좁게", value: "tight" },
|
||||
{ label: "보통", value: "normal" },
|
||||
{ label: "넓게", value: "loose" },
|
||||
],
|
||||
defaultValue: "normal",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default ConditionalContainerDefinition;
|
||||
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* ConditionalContainer 컴포넌트 타입 정의
|
||||
* 제어 셀렉트박스 값에 따라 다른 UI를 표시하는 조건부 컨테이너
|
||||
*/
|
||||
|
||||
import { ComponentData } from "@/types/screen";
|
||||
|
||||
export interface ConditionalSection {
|
||||
id: string; // 고유 ID
|
||||
condition: string; // 조건 값 (예: "customer_first", "quotation")
|
||||
label: string; // 조건 라벨 (예: "거래처 우선", "견적서 기반")
|
||||
screenId: number | null; // 이 조건일 때 표시할 화면 ID
|
||||
screenName?: string; // 화면 이름 (표시용)
|
||||
}
|
||||
|
||||
export interface ConditionalContainerConfig {
|
||||
// 제어 셀렉트박스 설정
|
||||
controlField: string; // 제어할 필드명 (예: "inputMode")
|
||||
controlLabel: string; // 셀렉트박스 라벨 (예: "입력 방식")
|
||||
|
||||
// 조건별 섹션
|
||||
sections: ConditionalSection[];
|
||||
|
||||
// 기본 선택 값
|
||||
defaultValue?: string;
|
||||
|
||||
// 스타일
|
||||
showBorder?: boolean; // 섹션별 테두리 표시
|
||||
spacing?: "tight" | "normal" | "loose"; // 섹션 간격
|
||||
}
|
||||
|
||||
export interface ConditionalContainerProps {
|
||||
config?: ConditionalContainerConfig;
|
||||
|
||||
// 개별 props (config 우선)
|
||||
controlField?: string;
|
||||
controlLabel?: string;
|
||||
sections?: ConditionalSection[];
|
||||
defaultValue?: string;
|
||||
showBorder?: boolean;
|
||||
spacing?: "tight" | "normal" | "loose";
|
||||
|
||||
// 폼 데이터 연동
|
||||
value?: any; // 현재 선택된 값
|
||||
onChange?: (value: string) => void;
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
|
||||
// 화면 편집기 관련
|
||||
isDesignMode?: boolean; // 디자인 모드 여부
|
||||
onUpdateComponent?: (componentId: string, updates: Partial<ComponentData>) => void;
|
||||
onDeleteComponent?: (componentId: string) => void;
|
||||
onSelectComponent?: (componentId: string) => void;
|
||||
selectedComponentId?: string;
|
||||
|
||||
// 높이 변화 알림 (아래 컴포넌트 재배치용)
|
||||
onHeightChange?: (newHeight: number) => void;
|
||||
componentId?: string; // 자신의 컴포넌트 ID
|
||||
|
||||
// 스타일
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 조건부 섹션 뷰어 Props
|
||||
export interface ConditionalSectionViewerProps {
|
||||
sectionId: string;
|
||||
condition: string;
|
||||
label: string;
|
||||
screenId: number | null; // 표시할 화면 ID
|
||||
screenName?: string; // 화면 이름
|
||||
isActive: boolean; // 현재 조건이 활성화되어 있는지
|
||||
isDesignMode: boolean;
|
||||
showBorder?: boolean;
|
||||
// 폼 데이터 전달
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { CustomerItemMappingConfig } from "./types";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface CustomerItemMappingComponentProps {
|
||||
component: any;
|
||||
isDesignMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
isInteractive?: boolean;
|
||||
config?: CustomerItemMappingConfig;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
onClick?: (e?: React.MouseEvent) => void;
|
||||
onDragStart?: (e: React.DragEvent) => void;
|
||||
onDragEnd?: (e: React.DragEvent) => void;
|
||||
}
|
||||
|
||||
export const CustomerItemMappingComponent: React.FC<CustomerItemMappingComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
}) => {
|
||||
const finalConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
} as CustomerItemMappingConfig;
|
||||
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||
const [isAllSelected, setIsAllSelected] = useState(false);
|
||||
|
||||
// 데이터 로드 (실제 구현 시 API 호출)
|
||||
useEffect(() => {
|
||||
if (!isDesignMode && finalConfig.selectedTable) {
|
||||
// TODO: API 호출로 데이터 로드
|
||||
setData([]);
|
||||
}
|
||||
}, [finalConfig.selectedTable, isDesignMode]);
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const allIds = data.map((_, index) => `row-${index}`);
|
||||
setSelectedRows(new Set(allIds));
|
||||
setIsAllSelected(true);
|
||||
} else {
|
||||
setSelectedRows(new Set());
|
||||
setIsAllSelected(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowSelection = (rowId: string, checked: boolean) => {
|
||||
const newSelected = new Set(selectedRows);
|
||||
if (checked) {
|
||||
newSelected.add(rowId);
|
||||
} else {
|
||||
newSelected.delete(rowId);
|
||||
}
|
||||
setSelectedRows(newSelected);
|
||||
setIsAllSelected(newSelected.size === data.length && data.length > 0);
|
||||
};
|
||||
|
||||
const columns = finalConfig.columns || [];
|
||||
const showCheckbox = finalConfig.checkbox?.enabled !== false;
|
||||
|
||||
// 스타일 계산
|
||||
const componentStyle: React.CSSProperties = {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
overflow: "hidden",
|
||||
boxSizing: "border-box",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: isDesignMode ? "300px" : "100%",
|
||||
...style, // style prop이 위의 기본값들을 덮어씀
|
||||
};
|
||||
|
||||
// 이벤트 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-full h-full", className)}
|
||||
style={componentStyle}
|
||||
onClick={handleClick}
|
||||
onDragStart={isDesignMode ? onDragStart : undefined}
|
||||
onDragEnd={isDesignMode ? onDragEnd : undefined}
|
||||
draggable={isDesignMode}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="w-full border-border bg-muted flex h-12 flex-shrink-0 items-center justify-between border-b px-4 sm:h-14 sm:px-6">
|
||||
<h3 className="text-sm font-semibold sm:text-base">
|
||||
품목 추가 - {finalConfig.selectedTable || "[테이블 선택]"}
|
||||
{finalConfig.showCompanyName && finalConfig.companyNameColumn && (
|
||||
<span className="text-muted-foreground ml-2 text-xs font-normal sm:text-sm">
|
||||
| {finalConfig.companyNameColumn}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<button className="hover:bg-muted-foreground/10 rounded p-1">
|
||||
<X className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색/카테고리 영역 */}
|
||||
{finalConfig.showSearchArea && (
|
||||
<div className="w-full border-border bg-background flex-shrink-0 border-b p-3 sm:p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
||||
{/* 검색 입력 */}
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={finalConfig.searchPlaceholder || "품목코드, 품목명, 규격 검색"}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring h-9 w-full rounded-md border px-3 py-1 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:text-sm"
|
||||
disabled={isDesignMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
{finalConfig.enableCategoryFilter && (
|
||||
<div className="w-full sm:w-auto sm:min-w-[160px]">
|
||||
<select
|
||||
className="border-input bg-background ring-offset-background focus-visible:ring-ring h-9 w-full rounded-md border px-3 py-1 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:text-sm"
|
||||
disabled={isDesignMode}
|
||||
>
|
||||
{(finalConfig.categories || ["전체"]).map((category, idx) => (
|
||||
<option key={idx} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 목록 헤더 */}
|
||||
<div className="w-full border-border bg-muted/50 flex h-10 flex-shrink-0 items-center justify-between border-b px-4 sm:h-12 sm:px-6">
|
||||
<span className="text-xs font-semibold sm:text-sm">판매품목 목록</span>
|
||||
<div className="flex items-center gap-3 sm:gap-6">
|
||||
{showCheckbox && finalConfig.checkbox?.selectAll && (
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} />
|
||||
<span className="text-xs sm:text-sm">전체 선택</span>
|
||||
</label>
|
||||
)}
|
||||
<span className="text-muted-foreground text-xs font-medium sm:text-sm">
|
||||
선택: {selectedRows.size}개
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 컨테이너 */}
|
||||
<div className="flex w-full flex-1 flex-col overflow-hidden">
|
||||
{/* 테이블 헤더 */}
|
||||
{columns.length > 0 && (
|
||||
<div className="border-border flex-shrink-0 border-b">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" style={{ minWidth: "100%" }}>
|
||||
<thead>
|
||||
<tr className="bg-muted/30 h-10 sm:h-12">
|
||||
{showCheckbox && (
|
||||
<th className="border-border w-12 border-r px-2 text-center sm:w-16 sm:px-3"></th>
|
||||
)}
|
||||
{columns.map((col, index) => (
|
||||
<th
|
||||
key={col}
|
||||
className={cn(
|
||||
"border-border text-foreground px-3 text-left text-xs font-semibold sm:px-6 sm:text-sm",
|
||||
index < columns.length - 1 && "border-r"
|
||||
)}
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 영역 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{data.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center sm:gap-4 sm:p-12">
|
||||
<div className="bg-muted/50 flex h-16 w-16 items-center justify-center rounded-full sm:h-20 sm:w-20">
|
||||
<svg
|
||||
className="text-muted-foreground h-8 w-8 sm:h-10 sm:w-10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<p className="text-foreground text-base font-semibold sm:text-lg">
|
||||
{finalConfig.emptyMessage || "데이터가 없습니다"}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{finalConfig.emptyDescription || "품목 데이터가 추가되면 여기에 표시됩니다"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" style={{ minWidth: "100%" }}>
|
||||
<tbody>
|
||||
{data.map((row, index) => (
|
||||
<tr key={index} className="hover:bg-muted/50 border-b transition-colors">
|
||||
{showCheckbox && (
|
||||
<td className="border-border w-12 border-r px-2 text-center sm:w-16 sm:px-3">
|
||||
<Checkbox
|
||||
checked={selectedRows.has(`row-${index}`)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRowSelection(`row-${index}`, checked as boolean)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{columns.map((col, colIndex) => (
|
||||
<td
|
||||
key={col}
|
||||
className={cn(
|
||||
"border-border px-3 py-2 text-xs sm:px-6 sm:py-3 sm:text-sm",
|
||||
colIndex < columns.length - 1 && "border-r"
|
||||
)}
|
||||
>
|
||||
{row[col] || "-"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,397 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { CustomerItemMappingConfig } from "./types";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus, X } from "lucide-react";
|
||||
|
||||
export interface CustomerItemMappingConfigPanelProps {
|
||||
config: CustomerItemMappingConfig;
|
||||
onChange: (config: CustomerItemMappingConfig) => void;
|
||||
onConfigChange?: (config: CustomerItemMappingConfig) => void;
|
||||
screenTableName?: string;
|
||||
tableColumns?: any[];
|
||||
tables?: any[];
|
||||
allTables?: any[];
|
||||
onTableChange?: (tableName: string) => void;
|
||||
menuObjid?: number;
|
||||
}
|
||||
|
||||
export const CustomerItemMappingConfigPanel: React.FC<
|
||||
CustomerItemMappingConfigPanelProps
|
||||
> = ({
|
||||
config,
|
||||
onChange,
|
||||
onConfigChange,
|
||||
screenTableName,
|
||||
tableColumns: propTableColumns,
|
||||
tables: propTables,
|
||||
allTables,
|
||||
onTableChange: propOnTableChange,
|
||||
menuObjid,
|
||||
}) => {
|
||||
// onChange와 onConfigChange를 통합
|
||||
const handleChange = (newConfig: CustomerItemMappingConfig) => {
|
||||
onChange?.(newConfig);
|
||||
onConfigChange?.(newConfig);
|
||||
};
|
||||
const [tables, setTables] = useState<any[]>([]);
|
||||
const [availableColumns, setAvailableColumns] = useState<any[]>([]);
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
const tableList = await tableTypeApi.getTables();
|
||||
setTables(tableList);
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
if (config.selectedTable) {
|
||||
const loadColumns = async () => {
|
||||
try {
|
||||
const columns = await tableTypeApi.getColumns(config.selectedTable!);
|
||||
setAvailableColumns(columns);
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}
|
||||
}, [config.selectedTable]);
|
||||
|
||||
const handleTableChange = (tableName: string) => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
selectedTable: tableName,
|
||||
columns: [], // 테이블 변경 시 컬럼 초기화
|
||||
};
|
||||
handleChange(newConfig);
|
||||
propOnTableChange?.(tableName);
|
||||
};
|
||||
|
||||
const handleAddColumn = (columnName: string) => {
|
||||
if (!config.columns.includes(columnName)) {
|
||||
handleChange({
|
||||
...config,
|
||||
columns: [...config.columns, columnName],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveColumn = (columnName: string) => {
|
||||
handleChange({
|
||||
...config,
|
||||
columns: config.columns.filter((col) => col !== columnName),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
{/* 테이블 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 선택</Label>
|
||||
<Select value={config.selectedTable} onValueChange={handleTableChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{table.displayName || table.tableName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label>표시할 컬럼</Label>
|
||||
<div className="space-y-2">
|
||||
{/* 선택된 컬럼 목록 */}
|
||||
{config.columns.length > 0 && (
|
||||
<div className="border-border space-y-1 rounded border p-2">
|
||||
{config.columns.map((col, index) => (
|
||||
<div
|
||||
key={col}
|
||||
className="bg-muted flex items-center justify-between rounded px-2 py-1"
|
||||
>
|
||||
<span className="text-sm">{col}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveColumn(col)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 추가 */}
|
||||
{availableColumns.length > 0 && (
|
||||
<Select onValueChange={handleAddColumn}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 추가" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableColumns
|
||||
.filter((col) => !config.columns.includes(col.columnName))
|
||||
.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 체크박스 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label>체크박스 옵션</Label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={config.checkbox?.enabled !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
...config,
|
||||
checkbox: {
|
||||
...config.checkbox,
|
||||
enabled: checked as boolean,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">체크박스 표시</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={config.checkbox?.selectAll !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
...config,
|
||||
checkbox: {
|
||||
...config.checkbox,
|
||||
selectAll: checked as boolean,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">전체 선택 표시</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={config.checkbox?.multiple !== false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
...config,
|
||||
checkbox: {
|
||||
...config.checkbox,
|
||||
multiple: checked as boolean,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">다중 선택 허용</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 헤더 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label>헤더 설정</Label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={config.showCompanyName === true}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
...config,
|
||||
showCompanyName: checked as boolean,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-sm font-medium">회사명 표시</span>
|
||||
</label>
|
||||
|
||||
{config.showCompanyName && availableColumns.length > 0 && (
|
||||
<div className="space-y-2 pl-6">
|
||||
<Label className="text-xs">회사명 컬럼</Label>
|
||||
<Select
|
||||
value={config.companyNameColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
handleChange({
|
||||
...config,
|
||||
companyNameColumn: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
헤더에 표시할 회사명 데이터가 있는 컬럼을 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 검색 영역 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label>검색/필터 영역</Label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={config.showSearchArea === true}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
...config,
|
||||
showSearchArea: checked as boolean,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
검색/필터 영역 표시
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{config.showSearchArea && (
|
||||
<div className="space-y-3 pl-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">검색 플레이스홀더</Label>
|
||||
<Input
|
||||
value={config.searchPlaceholder || ""}
|
||||
onChange={(e) =>
|
||||
handleChange({
|
||||
...config,
|
||||
searchPlaceholder: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="품목코드, 품목명, 규격 검색"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 설정 */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={config.enableCategoryFilter === true}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({
|
||||
...config,
|
||||
enableCategoryFilter: checked as boolean,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-xs font-medium">카테고리 필터 표시</span>
|
||||
</label>
|
||||
|
||||
{config.enableCategoryFilter && (
|
||||
<div className="space-y-2 pl-6">
|
||||
<Label className="text-xs">카테고리 목록 (쉼표로 구분)</Label>
|
||||
<Input
|
||||
value={(config.categories || []).join(", ")}
|
||||
onChange={(e) =>
|
||||
handleChange({
|
||||
...config,
|
||||
categories: e.target.value.split(",").map((c) => c.trim()).filter(Boolean),
|
||||
})
|
||||
}
|
||||
placeholder="전체, 원자재, 반도체, 완제품"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
예: 전체, 원자재, 반도체, 완제품
|
||||
</p>
|
||||
|
||||
{availableColumns.length > 0 && (
|
||||
<>
|
||||
<Label className="text-xs">카테고리 데이터 컬럼</Label>
|
||||
<Select
|
||||
value={config.categoryColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
handleChange({
|
||||
...config,
|
||||
categoryColumn: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 빈 데이터 메시지 */}
|
||||
<div className="space-y-2">
|
||||
<Label>빈 데이터 메시지</Label>
|
||||
<Input
|
||||
value={config.emptyMessage || ""}
|
||||
onChange={(e) =>
|
||||
handleChange({ ...config, emptyMessage: e.target.value })
|
||||
}
|
||||
placeholder="데이터가 없습니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>빈 데이터 설명</Label>
|
||||
<Input
|
||||
value={config.emptyDescription || ""}
|
||||
onChange={(e) =>
|
||||
handleChange({ ...config, emptyDescription: e.target.value })
|
||||
}
|
||||
placeholder="품목 데이터가 추가되면 여기에 표시됩니다"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||
import { CustomerItemMappingDefinition } from "./index";
|
||||
|
||||
// 컴포넌트 자동 등록
|
||||
ComponentRegistry.registerComponent(CustomerItemMappingDefinition);
|
||||
|
||||
console.log("✅ CustomerItemMapping 컴포넌트 등록 완료");
|
||||
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { CustomerItemMappingComponent } from "./CustomerItemMappingComponent";
|
||||
import { CustomerItemMappingConfigPanel } from "./CustomerItemMappingConfigPanel";
|
||||
import { CustomerItemMappingConfig } from "./types";
|
||||
|
||||
export const CustomerItemMappingDefinition = createComponentDefinition({
|
||||
id: "customer-item-mapping",
|
||||
name: "거래처별 품목정보",
|
||||
nameEng: "Customer Item Mapping",
|
||||
description: "거래처별 품목 정보를 표시하고 선택하는 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "text",
|
||||
component: CustomerItemMappingComponent,
|
||||
defaultConfig: {
|
||||
selectedTable: undefined,
|
||||
columns: [],
|
||||
checkbox: {
|
||||
enabled: true,
|
||||
multiple: true,
|
||||
selectAll: true,
|
||||
},
|
||||
showSearchArea: true, // 기본적으로 검색 영역 표시
|
||||
searchAreaHeight: 80,
|
||||
searchPlaceholder: "품목코드, 품목명, 규격 검색",
|
||||
enableCategoryFilter: true, // 기본적으로 카테고리 필터 표시
|
||||
categoryColumn: undefined,
|
||||
categories: ["전체", "원자재", "반도체", "완제품"],
|
||||
showCompanyName: false,
|
||||
companyNameColumn: undefined,
|
||||
emptyMessage: "데이터가 없습니다",
|
||||
emptyDescription: "품목 데이터가 추가되면 여기에 표시됩니다",
|
||||
} as CustomerItemMappingConfig,
|
||||
defaultSize: { width: 800, height: 600 },
|
||||
configPanel: CustomerItemMappingConfigPanel,
|
||||
icon: "Package",
|
||||
tags: ["거래처", "품목", "매핑", "목록"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
export type { CustomerItemMappingConfig } from "./types";
|
||||
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
export interface CustomerItemMappingConfig {
|
||||
// 테이블 설정
|
||||
selectedTable?: string;
|
||||
|
||||
// 컬럼 설정
|
||||
columns: string[]; // 표시할 컬럼 목록
|
||||
|
||||
// 체크박스 설정
|
||||
checkbox: {
|
||||
enabled: boolean;
|
||||
multiple: boolean;
|
||||
selectAll: boolean;
|
||||
};
|
||||
|
||||
// 검색/필터 영역
|
||||
showSearchArea?: boolean;
|
||||
searchAreaHeight?: number;
|
||||
searchPlaceholder?: string; // 검색 플레이스홀더
|
||||
|
||||
// 카테고리 필터
|
||||
enableCategoryFilter?: boolean; // 카테고리 필터 활성화
|
||||
categoryColumn?: string; // 카테고리 데이터 컬럼명
|
||||
categories?: string[]; // 카테고리 목록 (예: ["전체", "원자재", "반도체", "완제품"])
|
||||
|
||||
// 헤더 설정
|
||||
showCompanyName?: boolean; // 회사명 표시 여부
|
||||
companyNameColumn?: string; // 회사명을 가져올 컬럼명
|
||||
|
||||
// 빈 데이터 메시지
|
||||
emptyMessage?: string;
|
||||
emptyDescription?: string;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { EntitySearchModal } from "./EntitySearchModal";
|
||||
import { EntitySearchInputProps, EntitySearchResult } from "./types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function EntitySearchInputComponent({
|
||||
tableName,
|
||||
displayField,
|
||||
valueField,
|
||||
searchFields = [displayField],
|
||||
mode = "combo",
|
||||
placeholder = "검색...",
|
||||
disabled = false,
|
||||
filterCondition = {},
|
||||
value,
|
||||
onChange,
|
||||
modalTitle = "검색",
|
||||
modalColumns = [],
|
||||
showAdditionalInfo = false,
|
||||
additionalFields = [],
|
||||
className,
|
||||
}: EntitySearchInputProps) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [displayValue, setDisplayValue] = useState("");
|
||||
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
|
||||
|
||||
// value가 변경되면 표시값 업데이트
|
||||
useEffect(() => {
|
||||
if (value && selectedData) {
|
||||
setDisplayValue(selectedData[displayField] || "");
|
||||
} else {
|
||||
setDisplayValue("");
|
||||
setSelectedData(null);
|
||||
}
|
||||
}, [value, displayField]);
|
||||
|
||||
const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
|
||||
setSelectedData(fullData);
|
||||
setDisplayValue(fullData[displayField] || "");
|
||||
onChange?.(newValue, fullData);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setDisplayValue("");
|
||||
setSelectedData(null);
|
||||
onChange?.(null, null);
|
||||
};
|
||||
|
||||
const handleOpenModal = () => {
|
||||
if (!disabled) {
|
||||
setModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{/* 입력 필드 */}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
value={displayValue}
|
||||
onChange={(e) => setDisplayValue(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
readOnly={mode === "modal" || mode === "combo"}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm pr-8"
|
||||
/>
|
||||
{displayValue && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(mode === "modal" || mode === "combo") && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleOpenModal}
|
||||
disabled={disabled}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 추가 정보 표시 */}
|
||||
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground space-y-1 px-2">
|
||||
{additionalFields.map((field) => (
|
||||
<div key={field} className="flex gap-2">
|
||||
<span className="font-medium">{field}:</span>
|
||||
<span>{selectedData[field] || "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 모달 */}
|
||||
<EntitySearchModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
tableName={tableName}
|
||||
displayField={displayField}
|
||||
valueField={valueField}
|
||||
searchFields={searchFields}
|
||||
filterCondition={filterCondition}
|
||||
modalTitle={modalTitle}
|
||||
modalColumns={modalColumns}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,498 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { EntitySearchInputConfig } from "./config";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface EntitySearchInputConfigPanelProps {
|
||||
config: EntitySearchInputConfig;
|
||||
onConfigChange: (config: EntitySearchInputConfig) => void;
|
||||
}
|
||||
|
||||
export function EntitySearchInputConfigPanel({
|
||||
config,
|
||||
onConfigChange,
|
||||
}: EntitySearchInputConfigPanelProps) {
|
||||
const [localConfig, setLocalConfig] = useState(config);
|
||||
const [allTables, setAllTables] = useState<any[]>([]);
|
||||
const [tableColumns, setTableColumns] = useState<any[]>([]);
|
||||
const [isLoadingTables, setIsLoadingTables] = useState(false);
|
||||
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
|
||||
const [openTableCombo, setOpenTableCombo] = useState(false);
|
||||
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
|
||||
const [openValueFieldCombo, setOpenValueFieldCombo] = useState(false);
|
||||
|
||||
// 전체 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
setIsLoadingTables(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setAllTables(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!localConfig.tableName) {
|
||||
setTableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingColumns(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(localConfig.tableName);
|
||||
if (response.success && response.data) {
|
||||
setTableColumns(response.data.columns);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
setTableColumns([]);
|
||||
} finally {
|
||||
setIsLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [localConfig.tableName]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalConfig(config);
|
||||
}, [config]);
|
||||
|
||||
const updateConfig = (updates: Partial<EntitySearchInputConfig>) => {
|
||||
const newConfig = { ...localConfig, ...updates };
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
};
|
||||
|
||||
const addSearchField = () => {
|
||||
const fields = localConfig.searchFields || [];
|
||||
updateConfig({ searchFields: [...fields, ""] });
|
||||
};
|
||||
|
||||
const updateSearchField = (index: number, value: string) => {
|
||||
const fields = [...(localConfig.searchFields || [])];
|
||||
fields[index] = value;
|
||||
updateConfig({ searchFields: fields });
|
||||
};
|
||||
|
||||
const removeSearchField = (index: number) => {
|
||||
const fields = [...(localConfig.searchFields || [])];
|
||||
fields.splice(index, 1);
|
||||
updateConfig({ searchFields: fields });
|
||||
};
|
||||
|
||||
const addModalColumn = () => {
|
||||
const columns = localConfig.modalColumns || [];
|
||||
updateConfig({ modalColumns: [...columns, ""] });
|
||||
};
|
||||
|
||||
const updateModalColumn = (index: number, value: string) => {
|
||||
const columns = [...(localConfig.modalColumns || [])];
|
||||
columns[index] = value;
|
||||
updateConfig({ modalColumns: columns });
|
||||
};
|
||||
|
||||
const removeModalColumn = (index: number) => {
|
||||
const columns = [...(localConfig.modalColumns || [])];
|
||||
columns.splice(index, 1);
|
||||
updateConfig({ modalColumns: columns });
|
||||
};
|
||||
|
||||
const addAdditionalField = () => {
|
||||
const fields = localConfig.additionalFields || [];
|
||||
updateConfig({ additionalFields: [...fields, ""] });
|
||||
};
|
||||
|
||||
const updateAdditionalField = (index: number, value: string) => {
|
||||
const fields = [...(localConfig.additionalFields || [])];
|
||||
fields[index] = value;
|
||||
updateConfig({ additionalFields: fields });
|
||||
};
|
||||
|
||||
const removeAdditionalField = (index: number) => {
|
||||
const fields = [...(localConfig.additionalFields || [])];
|
||||
fields.splice(index, 1);
|
||||
updateConfig({ additionalFields: fields });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">테이블명 *</Label>
|
||||
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openTableCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={isLoadingTables}
|
||||
>
|
||||
{localConfig.tableName
|
||||
? allTables.find((t) => t.tableName === localConfig.tableName)?.displayName || localConfig.tableName
|
||||
: isLoadingTables ? "로딩 중..." : "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.displayName || table.tableName}-${table.tableName}`}
|
||||
onSelect={() => {
|
||||
updateConfig({ tableName: table.tableName });
|
||||
setOpenTableCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
localConfig.tableName === table.tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||
{table.displayName && table.displayName !== table.tableName && (
|
||||
<span className="text-[10px] text-gray-500">{table.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">표시 필드 *</Label>
|
||||
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openDisplayFieldCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
{localConfig.displayField
|
||||
? tableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
|
||||
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={`${column.displayName || column.columnName}-${column.columnName}`}
|
||||
onSelect={() => {
|
||||
updateConfig({ displayField: column.columnName });
|
||||
setOpenDisplayFieldCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
localConfig.displayField === column.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{column.displayName || column.columnName}</span>
|
||||
{column.displayName && column.displayName !== column.columnName && (
|
||||
<span className="text-[10px] text-gray-500">{column.columnName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">값 필드 *</Label>
|
||||
<Popover open={openValueFieldCombo} onOpenChange={setOpenValueFieldCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openValueFieldCombo}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
{localConfig.valueField
|
||||
? tableColumns.find((c) => c.columnName === localConfig.valueField)?.displayName || localConfig.valueField
|
||||
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={`${column.displayName || column.columnName}-${column.columnName}`}
|
||||
onSelect={() => {
|
||||
updateConfig({ valueField: column.columnName });
|
||||
setOpenValueFieldCombo(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
localConfig.valueField === column.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{column.displayName || column.columnName}</span>
|
||||
{column.displayName && column.displayName !== column.columnName && (
|
||||
<span className="text-[10px] text-gray-500">{column.columnName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">UI 모드</Label>
|
||||
<Select
|
||||
value={localConfig.mode || "combo"}
|
||||
onValueChange={(value: "autocomplete" | "modal" | "combo") =>
|
||||
updateConfig({ mode: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="combo">콤보 (입력 + 모달)</SelectItem>
|
||||
<SelectItem value="modal">모달만</SelectItem>
|
||||
<SelectItem value="autocomplete">자동완성만</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">플레이스홀더</Label>
|
||||
<Input
|
||||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig({ placeholder: e.target.value })}
|
||||
placeholder="검색..."
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(localConfig.mode === "modal" || localConfig.mode === "combo") && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">모달 제목</Label>
|
||||
<Input
|
||||
value={localConfig.modalTitle || ""}
|
||||
onChange={(e) => updateConfig({ modalTitle: e.target.value })}
|
||||
placeholder="검색 및 선택"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">모달 컬럼</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={addModalColumn}
|
||||
className="h-7 text-xs"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(localConfig.modalColumns || []).map((column, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={column}
|
||||
onValueChange={(value) => updateModalColumn(index, value)}
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeModalColumn(index)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">검색 필드</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={addSearchField}
|
||||
className="h-7 text-xs"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(localConfig.searchFields || []).map((field, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={field}
|
||||
onValueChange={(value) => updateSearchField(index, value)}
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeSearchField(index)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">추가 정보 표시</Label>
|
||||
<Switch
|
||||
checked={localConfig.showAdditionalInfo || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ showAdditionalInfo: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localConfig.showAdditionalInfo && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">추가 필드</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={addAdditionalField}
|
||||
className="h-7 text-xs"
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(localConfig.additionalFields || []).map((field, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={field}
|
||||
onValueChange={(value) => updateAdditionalField(index, value)}
|
||||
disabled={!localConfig.tableName || isLoadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeAdditionalField(index)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue