Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
1510ad1f3f
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* dashboards 테이블 구조 확인 스크립트
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function checkDashboardStructure() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔍 dashboards 테이블 구조 확인 중...\n');
|
||||
|
||||
// 컬럼 정보 조회
|
||||
const columns = await client.query(`
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'dashboards'
|
||||
ORDER BY ordinal_position
|
||||
`);
|
||||
|
||||
console.log('📋 dashboards 테이블 컬럼:\n');
|
||||
columns.rows.forEach((col, index) => {
|
||||
console.log(`${index + 1}. ${col.column_name} (${col.data_type}) - Nullable: ${col.is_nullable}`);
|
||||
});
|
||||
|
||||
// 샘플 데이터 조회
|
||||
console.log('\n📊 샘플 데이터 (첫 1개):');
|
||||
const sample = await client.query(`
|
||||
SELECT * FROM dashboards LIMIT 1
|
||||
`);
|
||||
|
||||
if (sample.rows.length > 0) {
|
||||
console.log(JSON.stringify(sample.rows[0], null, 2));
|
||||
} else {
|
||||
console.log('❌ 데이터가 없습니다.');
|
||||
}
|
||||
|
||||
// dashboard_elements 테이블도 확인
|
||||
console.log('\n🔍 dashboard_elements 테이블 구조 확인 중...\n');
|
||||
|
||||
const elemColumns = await client.query(`
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'dashboard_elements'
|
||||
ORDER BY ordinal_position
|
||||
`);
|
||||
|
||||
console.log('📋 dashboard_elements 테이블 컬럼:\n');
|
||||
elemColumns.rows.forEach((col, index) => {
|
||||
console.log(`${index + 1}. ${col.column_name} (${col.data_type}) - Nullable: ${col.is_nullable}`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 오류 발생:', error.message);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
checkDashboardStructure();
|
||||
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* 데이터베이스 테이블 확인 스크립트
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function checkTables() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔍 데이터베이스 테이블 확인 중...\n');
|
||||
|
||||
// 테이블 목록 조회
|
||||
const result = await client.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name
|
||||
`);
|
||||
|
||||
console.log(`📊 총 ${result.rows.length}개의 테이블 발견:\n`);
|
||||
result.rows.forEach((row, index) => {
|
||||
console.log(`${index + 1}. ${row.table_name}`);
|
||||
});
|
||||
|
||||
// dashboard 관련 테이블 검색
|
||||
console.log('\n🔎 dashboard 관련 테이블:');
|
||||
const dashboardTables = result.rows.filter(row =>
|
||||
row.table_name.toLowerCase().includes('dashboard')
|
||||
);
|
||||
|
||||
if (dashboardTables.length === 0) {
|
||||
console.log('❌ dashboard 관련 테이블을 찾을 수 없습니다.');
|
||||
} else {
|
||||
dashboardTables.forEach(row => {
|
||||
console.log(`✅ ${row.table_name}`);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 오류 발생:', error.message);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
checkTables();
|
||||
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* SQL 마이그레이션 실행 스크립트
|
||||
* 사용법: node scripts/run-migration.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
// DATABASE_URL에서 연결 정보 파싱
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
// 데이터베이스 연결 설정
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function runMigration() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔄 마이그레이션 시작...\n');
|
||||
|
||||
// SQL 파일 읽기 (Docker 컨테이너 내부 경로)
|
||||
const sqlPath = '/tmp/migration.sql';
|
||||
const sql = fs.readFileSync(sqlPath, 'utf8');
|
||||
|
||||
console.log('📄 SQL 파일 로드 완료');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
|
||||
// SQL 실행
|
||||
await client.query(sql);
|
||||
|
||||
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('✅ 마이그레이션 성공적으로 완료되었습니다!');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.error('❌ 마이그레이션 실패:');
|
||||
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.error(error);
|
||||
console.error('\n💡 롤백이 필요한 경우 롤백 스크립트를 실행하세요.');
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
// 실행
|
||||
runMigration();
|
||||
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* 마이그레이션 검증 스크립트
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
async function verifyMigration() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log('🔍 마이그레이션 결과 검증 중...\n');
|
||||
|
||||
// 전체 요소 수
|
||||
const total = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements
|
||||
`);
|
||||
|
||||
// 새로운 subtype별 개수
|
||||
const mapV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'map-summary-v2'
|
||||
`);
|
||||
|
||||
const chart = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'chart'
|
||||
`);
|
||||
|
||||
const listV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'list-v2'
|
||||
`);
|
||||
|
||||
const metricV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'custom-metric-v2'
|
||||
`);
|
||||
|
||||
const alertV2 = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'risk-alert-v2'
|
||||
`);
|
||||
|
||||
// 테스트 subtype 남아있는지 확인
|
||||
const remaining = await client.query(`
|
||||
SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype LIKE '%-test%'
|
||||
`);
|
||||
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('📊 마이그레이션 결과 요약');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log(`전체 요소 수: ${total.rows[0].count}`);
|
||||
console.log(`map-summary-v2: ${mapV2.rows[0].count}`);
|
||||
console.log(`chart: ${chart.rows[0].count}`);
|
||||
console.log(`list-v2: ${listV2.rows[0].count}`);
|
||||
console.log(`custom-metric-v2: ${metricV2.rows[0].count}`);
|
||||
console.log(`risk-alert-v2: ${alertV2.rows[0].count}`);
|
||||
console.log('');
|
||||
|
||||
if (parseInt(remaining.rows[0].count) > 0) {
|
||||
console.log(`⚠️ 테스트 subtype이 ${remaining.rows[0].count}개 남아있습니다!`);
|
||||
} else {
|
||||
console.log('✅ 모든 테스트 subtype이 정상적으로 변경되었습니다!');
|
||||
}
|
||||
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('');
|
||||
console.log('🎉 마이그레이션이 성공적으로 완료되었습니다!');
|
||||
console.log('');
|
||||
console.log('다음 단계:');
|
||||
console.log('1. 프론트엔드 애플리케이션을 새로고침하세요');
|
||||
console.log('2. 대시보드를 열어 위젯이 정상적으로 작동하는지 확인하세요');
|
||||
console.log('3. 문제가 발생하면 백업에서 복원하세요');
|
||||
console.log('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 오류 발생:', error.message);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
verifyMigration();
|
||||
|
||||
|
|
@ -606,16 +606,32 @@ export class DashboardController {
|
|||
}
|
||||
});
|
||||
|
||||
// 외부 API 호출
|
||||
// 외부 API 호출 (타임아웃 30초)
|
||||
// @ts-ignore - node-fetch dynamic import
|
||||
const fetch = (await import("node-fetch")).default;
|
||||
const response = await fetch(urlObj.toString(), {
|
||||
method: method.toUpperCase(),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
|
||||
// 타임아웃 설정 (Node.js 글로벌 AbortController 사용)
|
||||
const controller = new (global as any).AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60초 (기상청 API는 느림)
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(urlObj.toString(), {
|
||||
method: method.toUpperCase(),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
} catch (err: any) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error('외부 API 요청 타임아웃 (30초 초과)');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
|
|
@ -623,7 +639,40 @@ export class DashboardController {
|
|||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// Content-Type에 따라 응답 파싱
|
||||
const contentType = response.headers.get("content-type");
|
||||
let data: any;
|
||||
|
||||
// 한글 인코딩 처리 (EUC-KR → UTF-8)
|
||||
const isKoreanApi = urlObj.hostname.includes('kma.go.kr') ||
|
||||
urlObj.hostname.includes('data.go.kr');
|
||||
|
||||
if (isKoreanApi) {
|
||||
// 한국 정부 API는 EUC-KR 인코딩 사용
|
||||
const buffer = await response.arrayBuffer();
|
||||
const decoder = new TextDecoder('euc-kr');
|
||||
const text = decoder.decode(buffer);
|
||||
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
data = { text, contentType };
|
||||
}
|
||||
} else if (contentType && contentType.includes("application/json")) {
|
||||
data = await response.json();
|
||||
} else if (contentType && contentType.includes("text/")) {
|
||||
// 텍스트 응답 (CSV, 일반 텍스트 등)
|
||||
const text = await response.text();
|
||||
data = { text, contentType };
|
||||
} else {
|
||||
// 기타 응답 (JSON으로 시도)
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
const text = await response.text();
|
||||
data = { text, contentType };
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -59,12 +59,56 @@ export class AuthController {
|
|||
logger.info(`- userName: ${userInfo.userName}`);
|
||||
logger.info(`- companyCode: ${userInfo.companyCode}`);
|
||||
|
||||
// 사용자의 첫 번째 접근 가능한 메뉴 조회
|
||||
let firstMenuPath: string | null = null;
|
||||
try {
|
||||
const { AdminService } = await import("../services/adminService");
|
||||
const paramMap = {
|
||||
userId: loginResult.userInfo.userId,
|
||||
userCompanyCode: loginResult.userInfo.companyCode || "ILSHIN",
|
||||
userType: loginResult.userInfo.userType,
|
||||
userLang: "ko",
|
||||
};
|
||||
|
||||
const menuList = await AdminService.getUserMenuList(paramMap);
|
||||
logger.info(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
|
||||
|
||||
// 접근 가능한 첫 번째 메뉴 찾기
|
||||
// 조건:
|
||||
// 1. LEV (레벨)이 2 이상 (최상위 폴더 제외)
|
||||
// 2. MENU_URL이 있고 비어있지 않음
|
||||
// 3. 이미 PATH, SEQ로 정렬되어 있으므로 첫 번째로 찾은 것이 첫 번째 메뉴
|
||||
const firstMenu = menuList.find((menu: any) => {
|
||||
const level = menu.lev || menu.level;
|
||||
const url = menu.menu_url || menu.url;
|
||||
|
||||
return level >= 2 && url && url.trim() !== "" && url !== "#";
|
||||
});
|
||||
|
||||
if (firstMenu) {
|
||||
firstMenuPath = firstMenu.menu_url || firstMenu.url;
|
||||
logger.info(`✅ 첫 번째 접근 가능한 메뉴 발견:`, {
|
||||
name: firstMenu.menu_name_kor || firstMenu.translated_name,
|
||||
url: firstMenuPath,
|
||||
level: firstMenu.lev || firstMenu.level,
|
||||
seq: firstMenu.seq,
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
"⚠️ 접근 가능한 메뉴가 없습니다. 메인 페이지로 이동합니다."
|
||||
);
|
||||
}
|
||||
} catch (menuError) {
|
||||
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "로그인 성공",
|
||||
data: {
|
||||
userInfo,
|
||||
token: loginResult.token,
|
||||
firstMenuPath, // 첫 번째 접근 가능한 메뉴 경로 추가
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ export const deleteFormData = async (
|
|||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { tableName } = req.body;
|
||||
|
||||
if (!tableName) {
|
||||
|
|
@ -226,7 +226,7 @@ export const deleteFormData = async (
|
|||
});
|
||||
}
|
||||
|
||||
await dynamicFormService.deleteFormData(id, tableName); // parseInt 제거 - 문자열 ID 지원
|
||||
await dynamicFormService.deleteFormData(id, tableName, companyCode, userId); // userId 추가
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -64,7 +64,8 @@ export class DataflowControlService {
|
|||
relationshipId: string,
|
||||
triggerType: "insert" | "update" | "delete",
|
||||
sourceData: Record<string, any>,
|
||||
tableName: string
|
||||
tableName: string,
|
||||
userId: string = "system"
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
|
|
@ -78,6 +79,7 @@ export class DataflowControlService {
|
|||
triggerType,
|
||||
sourceData,
|
||||
tableName,
|
||||
userId,
|
||||
});
|
||||
|
||||
// 관계도 정보 조회
|
||||
|
|
@ -238,7 +240,8 @@ export class DataflowControlService {
|
|||
const actionResult = await this.executeMultiConnectionAction(
|
||||
action,
|
||||
sourceData,
|
||||
targetPlan.sourceTable
|
||||
targetPlan.sourceTable,
|
||||
userId
|
||||
);
|
||||
|
||||
executedActions.push({
|
||||
|
|
@ -288,7 +291,8 @@ export class DataflowControlService {
|
|||
private async executeMultiConnectionAction(
|
||||
action: ControlAction,
|
||||
sourceData: Record<string, any>,
|
||||
sourceTable: string
|
||||
sourceTable: string,
|
||||
userId: string = "system"
|
||||
): Promise<any> {
|
||||
try {
|
||||
const extendedAction = action as any; // redesigned UI 구조 접근
|
||||
|
|
@ -321,7 +325,8 @@ export class DataflowControlService {
|
|||
targetTable,
|
||||
fromConnection.id,
|
||||
toConnection.id,
|
||||
multiConnService
|
||||
multiConnService,
|
||||
userId
|
||||
);
|
||||
|
||||
case "update":
|
||||
|
|
@ -332,7 +337,8 @@ export class DataflowControlService {
|
|||
targetTable,
|
||||
fromConnection.id,
|
||||
toConnection.id,
|
||||
multiConnService
|
||||
multiConnService,
|
||||
userId
|
||||
);
|
||||
|
||||
case "delete":
|
||||
|
|
@ -343,7 +349,8 @@ export class DataflowControlService {
|
|||
targetTable,
|
||||
fromConnection.id,
|
||||
toConnection.id,
|
||||
multiConnService
|
||||
multiConnService,
|
||||
userId
|
||||
);
|
||||
|
||||
default:
|
||||
|
|
@ -368,7 +375,8 @@ export class DataflowControlService {
|
|||
targetTable: string,
|
||||
fromConnectionId: number,
|
||||
toConnectionId: number,
|
||||
multiConnService: any
|
||||
multiConnService: any,
|
||||
userId: string = "system"
|
||||
): Promise<any> {
|
||||
try {
|
||||
// 필드 매핑 적용
|
||||
|
|
@ -387,6 +395,14 @@ export class DataflowControlService {
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 변경자 정보 추가
|
||||
if (!mappedData.created_by) {
|
||||
mappedData.created_by = userId;
|
||||
}
|
||||
if (!mappedData.updated_by) {
|
||||
mappedData.updated_by = userId;
|
||||
}
|
||||
|
||||
console.log(`📋 매핑된 데이터:`, mappedData);
|
||||
|
||||
// 대상 연결에 데이터 삽입
|
||||
|
|
@ -421,11 +437,32 @@ export class DataflowControlService {
|
|||
targetTable: string,
|
||||
fromConnectionId: number,
|
||||
toConnectionId: number,
|
||||
multiConnService: any
|
||||
multiConnService: any,
|
||||
userId: string = "system"
|
||||
): Promise<any> {
|
||||
try {
|
||||
// UPDATE 로직 구현 (향후 확장)
|
||||
// 필드 매핑 적용
|
||||
const mappedData: Record<string, any> = {};
|
||||
|
||||
for (const mapping of action.fieldMappings) {
|
||||
const sourceField = mapping.sourceField;
|
||||
const targetField = mapping.targetField;
|
||||
|
||||
if (mapping.defaultValue !== undefined) {
|
||||
mappedData[targetField] = mapping.defaultValue;
|
||||
} else if (sourceField && sourceData[sourceField] !== undefined) {
|
||||
mappedData[targetField] = sourceData[sourceField];
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 변경자 정보 추가
|
||||
if (!mappedData.updated_by) {
|
||||
mappedData.updated_by = userId;
|
||||
}
|
||||
|
||||
console.log(`📋 UPDATE 매핑된 데이터:`, mappedData);
|
||||
console.log(`⚠️ UPDATE 액션은 향후 구현 예정`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "UPDATE 액션 실행됨 (향후 구현)",
|
||||
|
|
@ -449,11 +486,11 @@ export class DataflowControlService {
|
|||
targetTable: string,
|
||||
fromConnectionId: number,
|
||||
toConnectionId: number,
|
||||
multiConnService: any
|
||||
multiConnService: any,
|
||||
userId: string = "system"
|
||||
): Promise<any> {
|
||||
try {
|
||||
// DELETE 로직 구현 (향후 확장)
|
||||
console.log(`⚠️ DELETE 액션은 향후 구현 예정`);
|
||||
console.log(`⚠️ DELETE 액션은 향후 구현 예정 (변경자: ${userId})`);
|
||||
return {
|
||||
success: true,
|
||||
message: "DELETE 액션 실행됨 (향후 구현)",
|
||||
|
|
@ -941,7 +978,9 @@ export class DataflowControlService {
|
|||
sourceData: Record<string, any>
|
||||
): Promise<any> {
|
||||
// 보안상 외부 DB에 대한 DELETE 작업은 비활성화
|
||||
throw new Error("보안상 외부 데이터베이스에 대한 DELETE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요.");
|
||||
throw new Error(
|
||||
"보안상 외부 데이터베이스에 대한 DELETE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요."
|
||||
);
|
||||
|
||||
const results = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -220,8 +220,14 @@ export class DynamicFormService {
|
|||
console.log(`🔑 테이블 ${tableName}의 Primary Key:`, primaryKeys);
|
||||
|
||||
// 메타데이터 제거 (실제 테이블 컬럼이 아님)
|
||||
const { created_by, updated_by, company_code, screen_id, ...actualData } =
|
||||
data;
|
||||
const {
|
||||
created_by,
|
||||
updated_by,
|
||||
writer,
|
||||
company_code,
|
||||
screen_id,
|
||||
...actualData
|
||||
} = data;
|
||||
|
||||
// 기본 데이터 준비
|
||||
const dataToInsert: any = { ...actualData };
|
||||
|
|
@ -236,8 +242,17 @@ export class DynamicFormService {
|
|||
if (tableColumns.includes("regdate") && !dataToInsert.regdate) {
|
||||
dataToInsert.regdate = new Date();
|
||||
}
|
||||
if (tableColumns.includes("created_date") && !dataToInsert.created_date) {
|
||||
dataToInsert.created_date = new Date();
|
||||
}
|
||||
if (tableColumns.includes("updated_date") && !dataToInsert.updated_date) {
|
||||
dataToInsert.updated_date = new Date();
|
||||
}
|
||||
|
||||
// 생성자/수정자 정보가 있고 해당 컬럼이 존재한다면 추가
|
||||
// 작성자 정보 추가 (writer 컬럼 우선, 없으면 created_by/updated_by)
|
||||
if (writer && tableColumns.includes("writer")) {
|
||||
dataToInsert.writer = writer;
|
||||
}
|
||||
if (created_by && tableColumns.includes("created_by")) {
|
||||
dataToInsert.created_by = created_by;
|
||||
}
|
||||
|
|
@ -579,7 +594,8 @@ export class DynamicFormService {
|
|||
screenId,
|
||||
tableName,
|
||||
insertedRecord as Record<string, any>,
|
||||
"insert"
|
||||
"insert",
|
||||
created_by || "system"
|
||||
);
|
||||
} catch (controlError) {
|
||||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||||
|
|
@ -876,7 +892,8 @@ export class DynamicFormService {
|
|||
0, // UPDATE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
||||
tableName,
|
||||
updatedRecord as Record<string, any>,
|
||||
"update"
|
||||
"update",
|
||||
updated_by || "system"
|
||||
);
|
||||
} catch (controlError) {
|
||||
console.error("⚠️ 제어관리 실행 오류:", controlError);
|
||||
|
|
@ -905,7 +922,8 @@ export class DynamicFormService {
|
|||
async deleteFormData(
|
||||
id: string | number,
|
||||
tableName: string,
|
||||
companyCode?: string
|
||||
companyCode?: string,
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
||||
|
|
@ -1010,7 +1028,8 @@ export class DynamicFormService {
|
|||
0, // DELETE는 screenId를 알 수 없으므로 0으로 설정 (추후 개선 필요)
|
||||
tableName,
|
||||
deletedRecord,
|
||||
"delete"
|
||||
"delete",
|
||||
userId || "system"
|
||||
);
|
||||
}
|
||||
} catch (controlError) {
|
||||
|
|
@ -1315,7 +1334,8 @@ export class DynamicFormService {
|
|||
screenId: number,
|
||||
tableName: string,
|
||||
savedData: Record<string, any>,
|
||||
triggerType: "insert" | "update" | "delete"
|
||||
triggerType: "insert" | "update" | "delete",
|
||||
userId: string = "system"
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`);
|
||||
|
|
@ -1364,7 +1384,8 @@ export class DynamicFormService {
|
|||
relationshipId,
|
||||
triggerType,
|
||||
savedData,
|
||||
tableName
|
||||
tableName,
|
||||
userId
|
||||
);
|
||||
|
||||
console.log(`🎯 제어관리 실행 결과:`, controlResult);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export class ExternalRestApiConnectionService {
|
|||
try {
|
||||
let query = `
|
||||
SELECT
|
||||
id, connection_name, description, base_url, default_headers,
|
||||
id, connection_name, description, base_url, endpoint_path, default_headers,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
company_code, is_active, created_date, created_by,
|
||||
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
||||
|
|
@ -128,7 +128,7 @@ export class ExternalRestApiConnectionService {
|
|||
try {
|
||||
let query = `
|
||||
SELECT
|
||||
id, connection_name, description, base_url, default_headers,
|
||||
id, connection_name, description, base_url, endpoint_path, default_headers,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
company_code, is_active, created_date, created_by,
|
||||
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
||||
|
|
@ -193,10 +193,10 @@ export class ExternalRestApiConnectionService {
|
|||
|
||||
const query = `
|
||||
INSERT INTO external_rest_api_connections (
|
||||
connection_name, description, base_url, default_headers,
|
||||
connection_name, description, base_url, endpoint_path, default_headers,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
company_code, is_active, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
|
|
@ -204,6 +204,7 @@ export class ExternalRestApiConnectionService {
|
|||
data.connection_name,
|
||||
data.description || null,
|
||||
data.base_url,
|
||||
data.endpoint_path || null,
|
||||
JSON.stringify(data.default_headers || {}),
|
||||
data.auth_type,
|
||||
encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null,
|
||||
|
|
@ -288,6 +289,12 @@ export class ExternalRestApiConnectionService {
|
|||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.endpoint_path !== undefined) {
|
||||
updateFields.push(`endpoint_path = $${paramIndex}`);
|
||||
params.push(data.endpoint_path);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.default_headers !== undefined) {
|
||||
updateFields.push(`default_headers = $${paramIndex}`);
|
||||
params.push(JSON.stringify(data.default_headers));
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export class RiskAlertService {
|
|||
disp: 0,
|
||||
authKey: apiKey,
|
||||
},
|
||||
timeout: 10000,
|
||||
timeout: 30000, // 30초로 증가
|
||||
responseType: 'arraybuffer', // 인코딩 문제 해결
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface ExternalRestApiConnection {
|
|||
connection_name: string;
|
||||
description?: string;
|
||||
base_url: string;
|
||||
endpoint_path?: string;
|
||||
default_headers: Record<string, string>;
|
||||
auth_type: AuthType;
|
||||
auth_config?: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,406 @@
|
|||
# 위젯 승격 완료 보고서
|
||||
|
||||
**작성일**: 2025-10-28
|
||||
**작성자**: AI Assistant
|
||||
**상태**: ✅ 완료
|
||||
|
||||
---
|
||||
|
||||
## 📋 개요
|
||||
|
||||
테스트 위젯들이 안정성과 기능성을 검증받아 정식 위젯으로 승격되었습니다.
|
||||
|
||||
### 🎯 승격 목적
|
||||
|
||||
1. **기능 통합**: 다중 데이터 소스 지원 기능을 정식 위젯으로 제공
|
||||
2. **사용자 경험 개선**: 테스트 버전의 혼란 제거
|
||||
3. **유지보수성 향상**: 단일 버전 관리로 코드베이스 간소화
|
||||
|
||||
---
|
||||
|
||||
## ✅ 승격된 위젯 목록
|
||||
|
||||
| # | 테스트 버전 | 파일명 | 정식 subtype | 상태 |
|
||||
|---|------------|--------|-------------|------|
|
||||
| 1 | MapTestWidgetV2 | `MapTestWidgetV2.tsx` | `map-summary-v2` | ✅ 완료 |
|
||||
| 2 | ChartTestWidget | `ChartTestWidget.tsx` | `chart` | ✅ 완료 |
|
||||
| 3 | ListTestWidget | `ListTestWidget.tsx` | `list-v2` | ✅ 완료 |
|
||||
| 4 | CustomMetricTestWidget | `CustomMetricTestWidget.tsx` | `custom-metric-v2` | ✅ 완료 |
|
||||
| 5 | RiskAlertTestWidget | `RiskAlertTestWidget.tsx` | `risk-alert-v2` | ✅ 완료 |
|
||||
|
||||
**참고**: 파일명은 변경하지 않고, subtype만 변경하여 기존 import 경로 유지
|
||||
|
||||
---
|
||||
|
||||
## 📝 변경 사항 상세
|
||||
|
||||
### 1. 타입 정의 (`types.ts`)
|
||||
|
||||
#### 변경 전
|
||||
```typescript
|
||||
| "map-test-v2" // 테스트
|
||||
| "chart-test" // 테스트
|
||||
| "list-test" // 테스트
|
||||
| "custom-metric-test" // 테스트
|
||||
| "risk-alert-test" // 테스트
|
||||
```
|
||||
|
||||
#### 변경 후
|
||||
```typescript
|
||||
| "map-summary-v2" // 정식 (승격)
|
||||
| "chart" // 정식 (승격)
|
||||
| "list-v2" // 정식 (승격)
|
||||
| "custom-metric-v2" // 정식 (승격)
|
||||
| "risk-alert-v2" // 정식 (승격)
|
||||
```
|
||||
|
||||
#### 주석 처리된 타입
|
||||
```typescript
|
||||
// | "map-summary" // (구버전 - 주석 처리: 2025-10-28)
|
||||
// | "map-test-v2" // (테스트 버전 - 주석 처리: 2025-10-28)
|
||||
// | "chart-test" // (테스트 버전 - 주석 처리: 2025-10-28)
|
||||
// | "list" // (구버전 - 주석 처리: 2025-10-28)
|
||||
// | "list-test" // (테스트 버전 - 주석 처리: 2025-10-28)
|
||||
// | "custom-metric" // (구버전 - 주석 처리: 2025-10-28)
|
||||
// | "custom-metric-test"// (테스트 버전 - 주석 처리: 2025-10-28)
|
||||
// | "risk-alert" // (구버전 - 주석 처리: 2025-10-28)
|
||||
// | "risk-alert-test" // (테스트 버전 - 주석 처리: 2025-10-28)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 기존 원본 위젯 처리
|
||||
|
||||
다음 파일들이 주석 처리되었습니다 (삭제 X, 백업 보관):
|
||||
|
||||
| 파일 | 경로 | 대체 버전 |
|
||||
|------|------|----------|
|
||||
| `MapSummaryWidget.tsx` | `frontend/components/dashboard/widgets/` | MapTestWidgetV2.tsx |
|
||||
| `CustomMetricWidget.tsx` | `frontend/components/dashboard/widgets/` | CustomMetricTestWidget.tsx |
|
||||
| `RiskAlertWidget.tsx` | `frontend/components/dashboard/widgets/` | RiskAlertTestWidget.tsx |
|
||||
| `ListWidget.tsx` | `frontend/components/admin/dashboard/widgets/` | ListTestWidget.tsx |
|
||||
|
||||
**주석 처리 형식**:
|
||||
```typescript
|
||||
/*
|
||||
* ⚠️ DEPRECATED - 이 위젯은 더 이상 사용되지 않습니다.
|
||||
*
|
||||
* 이 파일은 2025-10-28에 주석 처리되었습니다.
|
||||
* 새로운 버전: [새 파일명] (subtype: [새 subtype])
|
||||
*
|
||||
* 변경 이유:
|
||||
* - 다중 데이터 소스 지원
|
||||
* - 컬럼 매핑 기능 추가
|
||||
* - 자동 새로고침 간격 설정 가능
|
||||
*
|
||||
* 롤백 방법:
|
||||
* 1. 이 파일의 주석 제거
|
||||
* 2. types.ts에서 기존 subtype 활성화
|
||||
* 3. 새 subtype 주석 처리
|
||||
*/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 컴포넌트 렌더링 로직 변경
|
||||
|
||||
#### A. `CanvasElement.tsx` (편집 모드)
|
||||
|
||||
**변경 전**:
|
||||
```typescript
|
||||
element.subtype === "map-test-v2"
|
||||
element.subtype === "chart-test"
|
||||
element.subtype === "list-test"
|
||||
element.subtype === "custom-metric-test"
|
||||
element.subtype === "risk-alert-test"
|
||||
```
|
||||
|
||||
**변경 후**:
|
||||
```typescript
|
||||
element.subtype === "map-summary-v2"
|
||||
element.subtype === "chart"
|
||||
element.subtype === "list-v2"
|
||||
element.subtype === "custom-metric-v2"
|
||||
element.subtype === "risk-alert-v2"
|
||||
```
|
||||
|
||||
#### B. `DashboardViewer.tsx` (뷰어 모드)
|
||||
|
||||
동일한 subtype 변경 적용
|
||||
|
||||
#### C. `ElementConfigSidebar.tsx` (설정 패널)
|
||||
|
||||
**다중 데이터 소스 위젯 체크 로직 변경**:
|
||||
```typescript
|
||||
// 변경 전
|
||||
const isMultiDS =
|
||||
element.subtype === "map-test-v2" ||
|
||||
element.subtype === "chart-test" ||
|
||||
element.subtype === "list-test" ||
|
||||
element.subtype === "custom-metric-test" ||
|
||||
element.subtype === "risk-alert-test";
|
||||
|
||||
// 변경 후
|
||||
const isMultiDS =
|
||||
element.subtype === "map-summary-v2" ||
|
||||
element.subtype === "chart" ||
|
||||
element.subtype === "list-v2" ||
|
||||
element.subtype === "custom-metric-v2" ||
|
||||
element.subtype === "risk-alert-v2";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 메뉴 재구성 (`DashboardTopMenu.tsx`)
|
||||
|
||||
#### 변경 전
|
||||
```tsx
|
||||
<SelectGroup>
|
||||
<SelectLabel>🧪 테스트 위젯 (다중 데이터 소스)</SelectLabel>
|
||||
<SelectItem value="map-test-v2">🧪 지도 테스트 V2</SelectItem>
|
||||
<SelectItem value="chart-test">🧪 차트 테스트</SelectItem>
|
||||
<SelectItem value="list-test">🧪 리스트 테스트</SelectItem>
|
||||
<SelectItem value="custom-metric-test">통계 카드</SelectItem>
|
||||
<SelectItem value="risk-alert-test">🧪 리스크/알림 테스트</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>데이터 위젯</SelectLabel>
|
||||
<SelectItem value="list">리스트 위젯</SelectItem>
|
||||
<SelectItem value="custom-metric">사용자 커스텀 카드</SelectItem>
|
||||
<SelectItem value="map-summary">커스텀 지도 카드</SelectItem>
|
||||
</SelectGroup>
|
||||
```
|
||||
|
||||
#### 변경 후
|
||||
```tsx
|
||||
<SelectGroup>
|
||||
<SelectLabel>데이터 위젯</SelectLabel>
|
||||
<SelectItem value="map-summary-v2">지도</SelectItem>
|
||||
<SelectItem value="chart">차트</SelectItem>
|
||||
<SelectItem value="list-v2">리스트</SelectItem>
|
||||
<SelectItem value="custom-metric-v2">통계 카드</SelectItem>
|
||||
<SelectItem value="risk-alert-v2">리스크/알림</SelectItem>
|
||||
<SelectItem value="yard-management-3d">야드 관리 3D</SelectItem>
|
||||
</SelectGroup>
|
||||
```
|
||||
|
||||
**변경 사항**:
|
||||
- 🧪 테스트 위젯 섹션 제거
|
||||
- 이모지 및 "테스트" 문구 제거
|
||||
- 간결한 이름으로 변경
|
||||
|
||||
---
|
||||
|
||||
### 5. 데이터베이스 마이그레이션
|
||||
|
||||
#### 스크립트 파일
|
||||
- **경로**: `db/migrations/999_upgrade_test_widgets_to_production.sql`
|
||||
- **실행 방법**: 사용자가 직접 실행 (자동 실행 X)
|
||||
|
||||
#### 마이그레이션 내용
|
||||
|
||||
```sql
|
||||
-- 1. MapTestWidgetV2 → MapSummaryWidget (v2)
|
||||
UPDATE dashboard_layouts
|
||||
SET layout_data = jsonb_set(...)
|
||||
WHERE layout_data::text LIKE '%"subtype":"map-test-v2"%';
|
||||
|
||||
-- 2. ChartTestWidget → ChartWidget
|
||||
-- 3. ListTestWidget → ListWidget (v2)
|
||||
-- 4. CustomMetricTestWidget → CustomMetricWidget (v2)
|
||||
-- 5. RiskAlertTestWidget → RiskAlertWidget (v2)
|
||||
```
|
||||
|
||||
#### 검증 쿼리
|
||||
|
||||
스크립트 실행 후 자동으로 다음을 확인:
|
||||
- 각 위젯별 레이아웃 개수
|
||||
- 남아있는 테스트 위젯 개수 (0이어야 정상)
|
||||
|
||||
#### 롤백 스크립트
|
||||
|
||||
문제 발생 시 사용할 수 있는 롤백 스크립트도 포함되어 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 🎉 승격의 이점
|
||||
|
||||
### 1. 사용자 경험 개선
|
||||
|
||||
**변경 전**:
|
||||
- 🧪 테스트 위젯 섹션과 정식 위젯 섹션이 분리
|
||||
- "테스트" 문구로 인한 혼란
|
||||
- 어떤 위젯을 사용해야 할지 불명확
|
||||
|
||||
**변경 후**:
|
||||
- 단일 "데이터 위젯" 섹션으로 통합
|
||||
- 간결하고 명확한 위젯 이름
|
||||
- 모든 위젯이 정식 버전으로 제공
|
||||
|
||||
### 2. 기능 강화
|
||||
|
||||
모든 승격된 위젯은 다음 기능을 제공합니다:
|
||||
|
||||
- ✅ **다중 데이터 소스 지원**
|
||||
- REST API 다중 연결
|
||||
- Database 다중 연결
|
||||
- REST API + Database 혼합
|
||||
- ✅ **컬럼 매핑**: 서로 다른 데이터 소스의 컬럼명 통일
|
||||
- ✅ **자동 새로고침**: 데이터 소스별 간격 설정
|
||||
- ✅ **수동 새로고침**: 즉시 데이터 갱신
|
||||
- ✅ **마지막 새로고침 시간 표시**
|
||||
- ✅ **XML/CSV 파싱** (Map, RiskAlert)
|
||||
|
||||
### 3. 유지보수성 향상
|
||||
|
||||
- 코드베이스 간소화 (테스트/정식 버전 통합)
|
||||
- 단일 버전 관리로 버그 수정 용이
|
||||
- 문서화 간소화
|
||||
|
||||
---
|
||||
|
||||
## 📊 영향 범위
|
||||
|
||||
### 영향받는 파일
|
||||
|
||||
| 카테고리 | 파일 수 | 파일 목록 |
|
||||
|---------|--------|----------|
|
||||
| 타입 정의 | 1 | `types.ts` |
|
||||
| 위젯 파일 (주석 처리) | 4 | `MapSummaryWidget.tsx`, `CustomMetricWidget.tsx`, `RiskAlertWidget.tsx`, `ListWidget.tsx` |
|
||||
| 렌더링 로직 | 3 | `CanvasElement.tsx`, `DashboardViewer.tsx`, `ElementConfigSidebar.tsx` |
|
||||
| 메뉴 | 1 | `DashboardTopMenu.tsx` |
|
||||
| 데이터베이스 | 1 | `999_upgrade_test_widgets_to_production.sql` |
|
||||
| 문서 | 3 | `테스트_위젯_누락_기능_분석_보고서.md`, `컬럼_매핑_사용_가이드.md`, `위젯_승격_완료_보고서.md` |
|
||||
| **총계** | **13** | |
|
||||
|
||||
### 영향받는 사용자
|
||||
|
||||
- **기존 테스트 위젯 사용자**: SQL 마이그레이션 실행 필요
|
||||
- **새 사용자**: 자동으로 정식 위젯 사용
|
||||
- **개발자**: 새로운 subtype 참조 필요
|
||||
|
||||
---
|
||||
|
||||
## 🔧 롤백 방법
|
||||
|
||||
문제 발생 시 다음 순서로 롤백할 수 있습니다:
|
||||
|
||||
### 1. 코드 롤백
|
||||
|
||||
```bash
|
||||
# Git으로 이전 커밋으로 되돌리기
|
||||
git revert <commit-hash>
|
||||
|
||||
# 또는 주석 처리된 원본 파일 복구
|
||||
# 1. 주석 제거
|
||||
# 2. types.ts에서 기존 subtype 활성화
|
||||
# 3. 새 subtype 주석 처리
|
||||
```
|
||||
|
||||
### 2. 데이터베이스 롤백
|
||||
|
||||
```sql
|
||||
-- 롤백 스크립트 실행
|
||||
-- 파일: db/migrations/999_rollback_widget_upgrade.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE dashboard_layouts
|
||||
SET layout_data = jsonb_set(
|
||||
layout_data,
|
||||
'{elements}',
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
CASE
|
||||
WHEN elem->>'subtype' = 'map-summary-v2' THEN jsonb_set(elem, '{subtype}', '"map-test-v2"'::jsonb)
|
||||
WHEN elem->>'subtype' = 'chart' THEN jsonb_set(elem, '{subtype}', '"chart-test"'::jsonb)
|
||||
WHEN elem->>'subtype' = 'list-v2' THEN jsonb_set(elem, '{subtype}', '"list-test"'::jsonb)
|
||||
WHEN elem->>'subtype' = 'custom-metric-v2' THEN jsonb_set(elem, '{subtype}', '"custom-metric-test"'::jsonb)
|
||||
WHEN elem->>'subtype' = 'risk-alert-v2' THEN jsonb_set(elem, '{subtype}', '"risk-alert-test"'::jsonb)
|
||||
ELSE elem
|
||||
END
|
||||
)
|
||||
FROM jsonb_array_elements(layout_data->'elements') elem
|
||||
)
|
||||
)
|
||||
WHERE layout_data::text LIKE '%"-v2"%' OR layout_data::text LIKE '%"chart"%';
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 테스트 체크리스트
|
||||
|
||||
승격 후 다음 사항을 확인하세요:
|
||||
|
||||
### 코드 레벨
|
||||
- [x] TypeScript 컴파일 에러 없음
|
||||
- [x] 모든 import 경로 정상 작동
|
||||
- [x] Prettier 포맷팅 적용
|
||||
|
||||
### 기능 테스트
|
||||
- [ ] 대시보드 편집 모드에서 위젯 추가 가능
|
||||
- [ ] 데이터 소스 연결 정상 작동
|
||||
- [ ] 자동 새로고침 정상 작동
|
||||
- [ ] 뷰어 모드에서 정상 표시
|
||||
- [ ] 저장/불러오기 정상 작동
|
||||
- [ ] 기존 대시보드 레이아웃 정상 로드 (마이그레이션 후)
|
||||
|
||||
### 데이터베이스
|
||||
- [ ] SQL 마이그레이션 스크립트 문법 검증
|
||||
- [ ] 백업 수행
|
||||
- [ ] 마이그레이션 실행
|
||||
- [ ] 검증 쿼리 확인
|
||||
|
||||
---
|
||||
|
||||
## 📚 관련 문서
|
||||
|
||||
1. [테스트 위젯 누락 기능 분석 보고서](./테스트_위젯_누락_기능_분석_보고서.md)
|
||||
- 원본 vs 테스트 위젯 비교 분석
|
||||
- 승격 결정 근거
|
||||
|
||||
2. [컬럼 매핑 사용 가이드](./컬럼_매핑_사용_가이드.md)
|
||||
- 다중 데이터 소스 활용법
|
||||
- 컬럼 매핑 기능 설명
|
||||
|
||||
3. [SQL 마이그레이션 스크립트](../db/migrations/999_upgrade_test_widgets_to_production.sql)
|
||||
- 데이터베이스 마이그레이션 가이드
|
||||
- 롤백 방법 포함
|
||||
|
||||
---
|
||||
|
||||
## 🎯 다음 단계
|
||||
|
||||
### 즉시 수행
|
||||
1. [ ] 프론트엔드 빌드 및 배포
|
||||
2. [ ] SQL 마이그레이션 스크립트 실행 (사용자)
|
||||
3. [ ] 기능 테스트 수행
|
||||
|
||||
### 향후 계획
|
||||
1. [ ] 사용자 피드백 수집
|
||||
2. [ ] 성능 모니터링
|
||||
3. [ ] 추가 기능 개발 (필요 시)
|
||||
|
||||
---
|
||||
|
||||
**승격 완료일**: 2025-10-28
|
||||
**작성자**: AI Assistant
|
||||
**상태**: ✅ 완료
|
||||
|
||||
---
|
||||
|
||||
## 📞 문의
|
||||
|
||||
문제 발생 시 다음 정보를 포함하여 문의하세요:
|
||||
|
||||
1. 발생한 오류 메시지
|
||||
2. 브라우저 콘솔 로그
|
||||
3. 사용 중인 위젯 및 데이터 소스
|
||||
4. 마이그레이션 실행 여부
|
||||
|
||||
---
|
||||
|
||||
**이 보고서는 위젯 승격 작업의 완전한 기록입니다.**
|
||||
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
# 컬럼 매핑 기능 사용 가이드
|
||||
|
||||
## 📋 개요
|
||||
|
||||
**컬럼 매핑**은 여러 데이터 소스의 서로 다른 컬럼명을 통일된 이름으로 변환하여 데이터를 통합할 수 있게 해주는 기능입니다.
|
||||
|
||||
## 🎯 사용 시나리오
|
||||
|
||||
### 시나리오 1: 여러 데이터베이스 통합
|
||||
|
||||
```
|
||||
데이터 소스 1 (PostgreSQL):
|
||||
SELECT name, amount, created_at FROM orders
|
||||
|
||||
데이터 소스 2 (MySQL):
|
||||
SELECT product_name, total, order_date FROM sales
|
||||
|
||||
데이터 소스 3 (Oracle):
|
||||
SELECT item, price, timestamp FROM transactions
|
||||
```
|
||||
|
||||
**문제**: 각 데이터베이스의 컬럼명이 달라서 통합이 어렵습니다.
|
||||
|
||||
**해결**: 컬럼 매핑으로 통일!
|
||||
|
||||
```
|
||||
데이터 소스 1 매핑:
|
||||
name → product
|
||||
amount → value
|
||||
created_at → date
|
||||
|
||||
데이터 소스 2 매핑:
|
||||
product_name → product
|
||||
total → value
|
||||
order_date → date
|
||||
|
||||
데이터 소스 3 매핑:
|
||||
item → product
|
||||
price → value
|
||||
timestamp → date
|
||||
```
|
||||
|
||||
**결과**: 모든 데이터가 `product`, `value`, `date` 컬럼으로 통합됩니다!
|
||||
|
||||
---
|
||||
|
||||
## 🔧 사용 방법
|
||||
|
||||
### 1️⃣ 데이터 소스 추가
|
||||
|
||||
대시보드 편집 모드에서 위젯의 "데이터 소스 관리" 섹션으로 이동합니다.
|
||||
|
||||
### 2️⃣ 쿼리/API 테스트
|
||||
|
||||
- **Database**: SQL 쿼리 입력 후 "쿼리 테스트" 클릭
|
||||
- **REST API**: API 설정 후 "API 테스트" 클릭
|
||||
|
||||
### 3️⃣ 컬럼 매핑 설정
|
||||
|
||||
테스트 성공 후 **"🔄 컬럼 매핑 (선택사항)"** 섹션이 나타납니다.
|
||||
|
||||
#### 매핑 추가:
|
||||
1. 드롭다운에서 원본 컬럼 선택
|
||||
2. 표시 이름 입력 (예: `name` → `product`)
|
||||
3. 자동으로 매핑 추가됨
|
||||
|
||||
#### 매핑 수정:
|
||||
- 오른쪽 입력 필드에서 표시 이름 변경
|
||||
|
||||
#### 매핑 삭제:
|
||||
- 각 매핑 행의 ❌ 버튼 클릭
|
||||
- 또는 "초기화" 버튼으로 전체 삭제
|
||||
|
||||
### 4️⃣ 적용 및 저장
|
||||
|
||||
1. "적용" 버튼 클릭
|
||||
2. 대시보드 저장
|
||||
|
||||
---
|
||||
|
||||
## 📊 지원 위젯
|
||||
|
||||
컬럼 매핑은 다음 **모든 다중 데이터 소스 위젯**에서 사용 가능합니다:
|
||||
|
||||
- ✅ **지도 위젯** (`map-summary-v2`)
|
||||
- ✅ **통계 카드** (`custom-metric-v2`)
|
||||
- ✅ **리스트 위젯** (`list-v2`)
|
||||
- ✅ **리스크/알림 위젯** (`risk-alert-v2`)
|
||||
- ✅ **차트 위젯** (`chart`)
|
||||
|
||||
---
|
||||
|
||||
## 💡 실전 예시
|
||||
|
||||
### 예시 1: 주문 데이터 통합
|
||||
|
||||
**데이터 소스 1 (내부 DB)**
|
||||
```sql
|
||||
SELECT
|
||||
customer_name,
|
||||
order_amount,
|
||||
order_date
|
||||
FROM orders
|
||||
```
|
||||
|
||||
**컬럼 매핑:**
|
||||
- `customer_name` → `name`
|
||||
- `order_amount` → `amount`
|
||||
- `order_date` → `date`
|
||||
|
||||
---
|
||||
|
||||
**데이터 소스 2 (외부 API)**
|
||||
|
||||
API 응답:
|
||||
```json
|
||||
[
|
||||
{ "clientName": "홍길동", "totalPrice": 50000, "timestamp": "2025-01-01" }
|
||||
]
|
||||
```
|
||||
|
||||
**컬럼 매핑:**
|
||||
- `clientName` → `name`
|
||||
- `totalPrice` → `amount`
|
||||
- `timestamp` → `date`
|
||||
|
||||
---
|
||||
|
||||
**결과 (통합된 데이터):**
|
||||
```json
|
||||
[
|
||||
{ "name": "홍길동", "amount": 50000, "date": "2025-01-01", "_source": "내부 DB" },
|
||||
{ "name": "홍길동", "amount": 50000, "date": "2025-01-01", "_source": "외부 API" }
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 예시 2: 지도 위젯 - 위치 데이터 통합
|
||||
|
||||
**데이터 소스 1 (기상청 API)**
|
||||
```json
|
||||
[
|
||||
{ "location": "서울", "lat": 37.5665, "lon": 126.9780, "temp": 15 }
|
||||
]
|
||||
```
|
||||
|
||||
**컬럼 매핑:**
|
||||
- `lat` → `latitude`
|
||||
- `lon` → `longitude`
|
||||
- `location` → `name`
|
||||
|
||||
---
|
||||
|
||||
**데이터 소스 2 (교통정보 DB)**
|
||||
```sql
|
||||
SELECT
|
||||
address,
|
||||
y_coord AS latitude,
|
||||
x_coord AS longitude,
|
||||
status
|
||||
FROM traffic_info
|
||||
```
|
||||
|
||||
**컬럼 매핑:**
|
||||
- `address` → `name`
|
||||
- (latitude, longitude는 이미 올바른 이름)
|
||||
|
||||
---
|
||||
|
||||
**결과**: 모든 데이터가 `name`, `latitude`, `longitude`로 통일되어 지도에 표시됩니다!
|
||||
|
||||
---
|
||||
|
||||
## 🔍 SQL Alias vs 컬럼 매핑
|
||||
|
||||
### SQL Alias (방법 1)
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
name AS product,
|
||||
amount AS value,
|
||||
created_at AS date
|
||||
FROM orders
|
||||
```
|
||||
|
||||
**장점:**
|
||||
- SQL 쿼리에서 직접 처리
|
||||
- 백엔드에서 이미 변환됨
|
||||
|
||||
**단점:**
|
||||
- SQL 지식 필요
|
||||
- REST API에는 사용 불가
|
||||
|
||||
---
|
||||
|
||||
### 컬럼 매핑 (방법 2)
|
||||
|
||||
UI에서 클릭만으로 설정:
|
||||
- `name` → `product`
|
||||
- `amount` → `value`
|
||||
- `created_at` → `date`
|
||||
|
||||
**장점:**
|
||||
- SQL 지식 불필요
|
||||
- REST API에도 사용 가능
|
||||
- 언제든지 수정 가능
|
||||
- 실시간 미리보기
|
||||
|
||||
**단점:**
|
||||
- 프론트엔드에서 처리 (약간의 오버헤드)
|
||||
|
||||
---
|
||||
|
||||
## ✨ 권장 사항
|
||||
|
||||
### 언제 SQL Alias를 사용할까?
|
||||
- SQL에 익숙한 경우
|
||||
- 백엔드에서 처리하고 싶은 경우
|
||||
- 복잡한 변환 로직이 필요한 경우
|
||||
|
||||
### 언제 컬럼 매핑을 사용할까?
|
||||
- SQL을 모르는 경우
|
||||
- REST API 데이터를 다룰 때
|
||||
- 빠르게 테스트하고 싶을 때
|
||||
- 여러 데이터 소스를 통합할 때
|
||||
|
||||
### 두 가지 모두 사용 가능!
|
||||
- SQL Alias로 일차 변환
|
||||
- 컬럼 매핑으로 추가 변환
|
||||
- 예: `SELECT name AS product_name` → 컬럼 매핑: `product_name` → `product`
|
||||
|
||||
---
|
||||
|
||||
## 🚨 주의사항
|
||||
|
||||
### 1. 매핑하지 않은 컬럼은 원본 이름 유지
|
||||
```
|
||||
원본: { name: "A", amount: 100, status: "active" }
|
||||
매핑: { name: "product" }
|
||||
결과: { product: "A", amount: 100, status: "active" }
|
||||
```
|
||||
|
||||
### 2. 중복 컬럼명 주의
|
||||
```
|
||||
원본: { name: "A", product: "B" }
|
||||
매핑: { name: "product" }
|
||||
결과: { product: "A" } // 기존 product 컬럼이 덮어씌워짐!
|
||||
```
|
||||
|
||||
### 3. 대소문자 구분
|
||||
- PostgreSQL: 소문자 권장 (`user_name`)
|
||||
- JavaScript: 카멜케이스 권장 (`userName`)
|
||||
- 매핑으로 통일 가능!
|
||||
|
||||
---
|
||||
|
||||
## 🔄 데이터 흐름
|
||||
|
||||
```
|
||||
1. 데이터 소스에서 원본 데이터 로드
|
||||
↓
|
||||
2. 컬럼 매핑 적용 (applyColumnMapping)
|
||||
↓
|
||||
3. 통일된 컬럼명으로 변환된 데이터
|
||||
↓
|
||||
4. 위젯에서 표시/처리
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 기술 세부사항
|
||||
|
||||
### 유틸리티 함수
|
||||
|
||||
**파일**: `frontend/lib/utils/columnMapping.ts`
|
||||
|
||||
#### `applyColumnMapping(data, columnMapping)`
|
||||
- 데이터 배열에 컬럼 매핑 적용
|
||||
- 매핑이 없으면 원본 그대로 반환
|
||||
|
||||
#### `mergeDataSources(dataSets)`
|
||||
- 여러 데이터 소스를 병합
|
||||
- 각 데이터 소스의 매핑을 자동 적용
|
||||
- `_source` 필드로 출처 표시
|
||||
|
||||
---
|
||||
|
||||
## 🎓 학습 자료
|
||||
|
||||
### 관련 파일
|
||||
- 타입 정의: `frontend/components/admin/dashboard/types.ts`
|
||||
- UI 컴포넌트: `frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx`
|
||||
- UI 컴포넌트: `frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx`
|
||||
- 유틸리티: `frontend/lib/utils/columnMapping.ts`
|
||||
|
||||
### 위젯 구현 예시
|
||||
- 지도: `frontend/components/dashboard/widgets/MapTestWidgetV2.tsx` (subtype: `map-summary-v2`)
|
||||
- 통계 카드: `frontend/components/dashboard/widgets/CustomMetricTestWidget.tsx` (subtype: `custom-metric-v2`)
|
||||
- 리스트: `frontend/components/dashboard/widgets/ListTestWidget.tsx` (subtype: `list-v2`)
|
||||
- 알림: `frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx` (subtype: `risk-alert-v2`)
|
||||
- 차트: `frontend/components/dashboard/widgets/ChartTestWidget.tsx` (subtype: `chart`)
|
||||
|
||||
---
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
### Q1: 컬럼 매핑이 저장되나요?
|
||||
**A**: 네! 대시보드 저장 시 함께 저장됩니다.
|
||||
|
||||
### Q2: 매핑 후 원본 컬럼명으로 되돌릴 수 있나요?
|
||||
**A**: 네! 해당 매핑을 삭제하면 원본 이름으로 돌아갑니다.
|
||||
|
||||
### Q3: REST API와 Database를 동시에 매핑할 수 있나요?
|
||||
**A**: 네! 각 데이터 소스마다 독립적으로 매핑할 수 있습니다.
|
||||
|
||||
### Q4: 성능에 영향이 있나요?
|
||||
**A**: 매우 적습니다. 단순 객체 키 변환이므로 빠릅니다.
|
||||
|
||||
### Q5: 컬럼 타입이 변경되나요?
|
||||
**A**: 아니요! 컬럼 이름만 변경되고, 값과 타입은 그대로 유지됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 🎉 마무리
|
||||
|
||||
컬럼 매핑 기능을 사용하면:
|
||||
- ✅ 여러 데이터 소스를 쉽게 통합
|
||||
- ✅ SQL 지식 없이도 데이터 변환
|
||||
- ✅ REST API와 Database 모두 지원
|
||||
- ✅ 실시간으로 결과 확인
|
||||
- ✅ 언제든지 수정 가능
|
||||
|
||||
**지금 바로 사용해보세요!** 🚀
|
||||
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
# 테스트 위젯 누락 기능 분석 보고서
|
||||
|
||||
**작성일**: 2025-10-28
|
||||
**목적**: 원본 위젯과 테스트 위젯 간의 기능 차이를 분석하여 누락된 기능을 파악
|
||||
|
||||
---
|
||||
|
||||
## 📊 위젯 비교 매트릭스
|
||||
|
||||
| 원본 위젯 | 테스트 위젯 | 상태 | 누락된 기능 |
|
||||
|-----------|-------------|------|-------------|
|
||||
| CustomMetricWidget | 통계 카드 (CustomMetricTestWidget) | ✅ **완료** | ~~Group By Mode~~ (추가 완료) |
|
||||
| RiskAlertWidget | RiskAlertTestWidget | ⚠️ **검토 필요** | 새 알림 애니메이션 (불필요) |
|
||||
| ChartWidget | ChartTestWidget | 🔍 **분석 중** | TBD |
|
||||
| ListWidget | ListTestWidget | 🔍 **분석 중** | TBD |
|
||||
| MapSummaryWidget | MapTestWidgetV2 | 🔍 **분석 중** | TBD |
|
||||
| MapTestWidget | (주석 처리됨) | ⏸️ **비활성** | N/A |
|
||||
| StatusSummaryWidget | (주석 처리됨) | ⏸️ **비활성** | N/A |
|
||||
|
||||
---
|
||||
|
||||
## 1️⃣ CustomMetricWidget vs 통계 카드 (CustomMetricTestWidget)
|
||||
|
||||
### ✅ 상태: **완료**
|
||||
|
||||
### 원본 기능
|
||||
- 단일 데이터 소스 (Database 또는 REST API)
|
||||
- 그룹별 카드 모드 (`groupByMode`)
|
||||
- 일반 메트릭 카드
|
||||
- 자동 새로고침 (30초)
|
||||
|
||||
### 테스트 버전 기능
|
||||
- ✅ **다중 데이터 소스** (REST API + Database 혼합)
|
||||
- ✅ **그룹별 카드 모드** (원본에서 복사 완료)
|
||||
- ✅ **일반 메트릭 카드**
|
||||
- ✅ **자동 새로고침** (설정 가능)
|
||||
- ✅ **수동 새로고침 버튼**
|
||||
- ✅ **마지막 새로고침 시간 표시**
|
||||
- ✅ **상세 정보 모달** (클릭 시 원본 데이터 표시)
|
||||
- ✅ **컬럼 매핑 지원**
|
||||
|
||||
### 🎯 결론
|
||||
**테스트 버전이 원본보다 기능이 많습니다.** 누락된 기능 없음.
|
||||
|
||||
---
|
||||
|
||||
## 2️⃣ RiskAlertWidget vs RiskAlertTestWidget
|
||||
|
||||
### ⚠️ 상태: **검토 필요**
|
||||
|
||||
### 원본 기능
|
||||
- 백엔드 캐시 API 호출 (`/risk-alerts`)
|
||||
- 강제 새로고침 API (`/risk-alerts/refresh`)
|
||||
- **새 알림 애니메이션** (`newAlertIds` 상태)
|
||||
- 새로운 알림 감지
|
||||
- 3초간 애니메이션 표시
|
||||
- 자동으로 애니메이션 제거
|
||||
- 자동 새로고침 (1분)
|
||||
- 알림 타입별 필터링
|
||||
|
||||
### 테스트 버전 기능
|
||||
- ✅ **다중 데이터 소스** (REST API + Database 혼합)
|
||||
- ✅ **알림 타입별 필터링**
|
||||
- ✅ **자동 새로고침** (설정 가능)
|
||||
- ✅ **수동 새로고침 버튼**
|
||||
- ✅ **마지막 새로고침 시간 표시**
|
||||
- ✅ **XML/CSV 데이터 파싱**
|
||||
- ✅ **컬럼 매핑 지원**
|
||||
- ❌ **새 알림 애니메이션** (사용자 요청으로 제외)
|
||||
|
||||
### 🎯 결론
|
||||
**새 알림 애니메이션은 사용자 요청으로 불필요하다고 판단됨.** 다른 누락 기능 없음.
|
||||
|
||||
---
|
||||
|
||||
## 3️⃣ ChartWidget vs ChartTestWidget
|
||||
|
||||
### ✅ 상태: **완료**
|
||||
|
||||
### 원본 기능
|
||||
**❌ 원본 ChartWidget 파일이 존재하지 않습니다!**
|
||||
|
||||
ChartTestWidget은 처음부터 **신규 개발**된 위젯입니다.
|
||||
|
||||
### 테스트 버전 기능
|
||||
- ✅ **다중 데이터 소스** (REST API + Database 혼합)
|
||||
- ✅ **차트 타입**: 라인, 바, 파이, 도넛, 영역
|
||||
- ✅ **혼합 차트** (ComposedChart)
|
||||
- 각 데이터 소스별로 다른 차트 타입 지정 가능
|
||||
- 바 + 라인 + 영역 동시 표시
|
||||
- ✅ **데이터 병합 모드** (`mergeMode`)
|
||||
- 여러 데이터 소스를 하나의 라인/바로 병합
|
||||
- ✅ **자동 새로고침** (설정 가능)
|
||||
- ✅ **수동 새로고침 버튼**
|
||||
- ✅ **마지막 새로고침 시간 표시**
|
||||
- ✅ **컬럼 매핑 지원**
|
||||
|
||||
### 🎯 결론
|
||||
**원본이 없으므로 비교 불필요.** ChartTestWidget은 완전히 새로운 위젯입니다.
|
||||
|
||||
---
|
||||
|
||||
## 4️⃣ ListWidget vs ListTestWidget
|
||||
|
||||
### ✅ 상태: **완료**
|
||||
|
||||
### 원본 기능
|
||||
**❌ 원본 ListWidget 파일이 존재하지 않습니다!**
|
||||
|
||||
ListTestWidget은 처음부터 **신규 개발**된 위젯입니다.
|
||||
|
||||
**참고**: `ListSummaryWidget`이라는 유사한 위젯이 있으나, 현재 **주석 처리**되어 있습니다.
|
||||
|
||||
### 테스트 버전 기능
|
||||
- ✅ **다중 데이터 소스** (REST API + Database 혼합)
|
||||
- ✅ **테이블/카드 뷰 전환**
|
||||
- ✅ **페이지네이션**
|
||||
- ✅ **컬럼 설정** (자동/수동)
|
||||
- ✅ **자동 새로고침** (설정 가능)
|
||||
- ✅ **수동 새로고침 버튼**
|
||||
- ✅ **마지막 새로고침 시간 표시**
|
||||
- ✅ **컬럼 매핑 지원**
|
||||
|
||||
### 🎯 결론
|
||||
**원본이 없으므로 비교 불필요.** ListTestWidget은 완전히 새로운 위젯입니다.
|
||||
|
||||
---
|
||||
|
||||
## 5️⃣ MapSummaryWidget vs MapTestWidgetV2
|
||||
|
||||
### ✅ 상태: **완료**
|
||||
|
||||
### 원본 기능 (MapSummaryWidget)
|
||||
- 단일 데이터 소스 (Database 쿼리)
|
||||
- 마커 표시
|
||||
- VWorld 타일맵 (고정)
|
||||
- **날씨 정보 통합**
|
||||
- 주요 도시 날씨 API 연동
|
||||
- 마커별 날씨 캐싱
|
||||
- **기상특보 표시** (`showWeatherAlerts`)
|
||||
- 육지 기상특보 (GeoJSON 레이어)
|
||||
- 해상 기상특보 (폴리곤)
|
||||
- 하드코딩된 해상 구역 좌표
|
||||
- 자동 새로고침 (30초)
|
||||
- 테이블명 한글 번역
|
||||
|
||||
### 테스트 버전 기능 (MapTestWidgetV2)
|
||||
- ✅ **다중 데이터 소스** (REST API + Database 혼합)
|
||||
- ✅ **마커 표시**
|
||||
- ✅ **폴리곤 표시** (GeoJSON)
|
||||
- ✅ **VWorld 타일맵** (설정 가능)
|
||||
- ✅ **데이터 소스별 색상 설정**
|
||||
- ✅ **자동 새로고침** (설정 가능)
|
||||
- ✅ **수동 새로고침 버튼**
|
||||
- ✅ **마지막 새로고침 시간 표시**
|
||||
- ✅ **컬럼 매핑 지원**
|
||||
- ✅ **XML/CSV 데이터 파싱**
|
||||
- ✅ **지역 코드/이름 → 좌표 변환**
|
||||
- ❌ **날씨 정보 통합** (누락)
|
||||
- ❌ **기상특보 표시** (누락)
|
||||
|
||||
### 🎯 결론
|
||||
**MapTestWidgetV2에 누락된 기능**:
|
||||
1. 날씨 API 통합 (주요 도시 날씨)
|
||||
2. 기상특보 표시 (육지/해상)
|
||||
|
||||
**단, 기상특보는 REST API 데이터 소스로 대체 가능하므로 중요도가 낮습니다.**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 주요 발견 사항
|
||||
|
||||
### 1. 테스트 위젯의 공통 강화 기능
|
||||
|
||||
모든 테스트 위젯은 원본 대비 다음 기능이 **추가**되었습니다:
|
||||
|
||||
- ✅ **다중 데이터 소스 지원**
|
||||
- REST API 다중 연결
|
||||
- Database 다중 연결
|
||||
- REST API + Database 혼합
|
||||
- ✅ **컬럼 매핑**
|
||||
- 서로 다른 데이터 소스의 컬럼명 통일
|
||||
- ✅ **자동 새로고침 간격 설정**
|
||||
- 데이터 소스별 개별 설정
|
||||
- ✅ **수동 새로고침 버튼**
|
||||
- ✅ **마지막 새로고침 시간 표시**
|
||||
- ✅ **XML/CSV 파싱** (Map, RiskAlert)
|
||||
|
||||
### 2. 원본에만 있는 기능 (누락 가능성)
|
||||
|
||||
현재까지 확인된 원본 전용 기능:
|
||||
|
||||
1. **통계 카드 (CustomMetricWidget)**
|
||||
- ~~Group By Mode~~ → **테스트 버전에 추가 완료** ✅
|
||||
|
||||
2. **RiskAlertWidget**
|
||||
- 새 알림 애니메이션 → **사용자 요청으로 제외** ⚠️
|
||||
|
||||
3. **기타 위젯**
|
||||
- 추가 분석 필요 🔍
|
||||
|
||||
### 3. 테스트 위젯 전용 기능
|
||||
|
||||
테스트 버전에만 있는 고급 기능:
|
||||
|
||||
- **ChartTestWidget**: 혼합 차트 (ComposedChart), 데이터 병합 모드
|
||||
- **MapTestWidgetV2**: 폴리곤 표시, 데이터 소스별 색상
|
||||
- **통계 카드 (CustomMetricTestWidget)**: 상세 정보 모달 (원본 데이터 표시)
|
||||
|
||||
---
|
||||
|
||||
## 📋 다음 단계
|
||||
|
||||
### 즉시 수행
|
||||
- [ ] ChartWidget 원본 파일 확인
|
||||
- [ ] ListWidget 원본 파일 확인 (존재 여부)
|
||||
- [ ] MapSummaryWidget 원본 파일 확인
|
||||
|
||||
### 검토 필요
|
||||
- [ ] 사용자에게 새 알림 애니메이션 필요 여부 재확인
|
||||
- [ ] 원본 위젯의 숨겨진 기능 파악
|
||||
|
||||
### 장기 계획
|
||||
- [ ] 테스트 위젯을 원본으로 승격 고려
|
||||
- [ ] 원본 위젯 deprecated 처리 고려
|
||||
|
||||
---
|
||||
|
||||
## 📊 통계
|
||||
|
||||
- **분석 완료**: 5/5 (100%) ✅
|
||||
- **누락 기능 발견**: 3개
|
||||
1. ~~Group By Mode~~ → **해결 완료** ✅
|
||||
2. 날씨 API 통합 (MapTestWidgetV2) → **낮은 우선순위** ⚠️
|
||||
3. 기상특보 표시 (MapTestWidgetV2) → **REST API로 대체 가능** ⚠️
|
||||
- **원본이 없는 위젯**: 2개 (ChartTestWidget, ListTestWidget)
|
||||
- **테스트 버전 추가 기능**: 10개 이상
|
||||
- **전체 평가**: **테스트 버전이 원본보다 기능적으로 우수함** 🏆
|
||||
|
||||
---
|
||||
|
||||
## 🎉 최종 결론
|
||||
|
||||
### ✅ 분석 완료
|
||||
|
||||
모든 테스트 위젯과 원본 위젯의 비교 분석이 완료되었습니다.
|
||||
|
||||
### 🔍 주요 발견
|
||||
|
||||
1. **통계 카드 (CustomMetricTestWidget)**: 원본의 모든 기능 포함 + 다중 데이터 소스 + 상세 모달
|
||||
2. **RiskAlertTestWidget**: 원본의 핵심 기능 포함 + 다중 데이터 소스 (새 알림 애니메이션은 불필요)
|
||||
3. **ChartTestWidget**: 원본 없음 (신규 개발)
|
||||
4. **ListTestWidget**: 원본 없음 (신규 개발)
|
||||
5. **MapTestWidgetV2**: 원본 대비 날씨 API 누락 (REST API로 대체 가능)
|
||||
|
||||
### 📈 테스트 위젯의 우수성
|
||||
|
||||
테스트 위젯은 다음과 같은 **공통 강화 기능**을 제공합니다:
|
||||
|
||||
- ✅ 다중 데이터 소스 (REST API + Database 혼합)
|
||||
- ✅ 컬럼 매핑 (데이터 통합)
|
||||
- ✅ 자동 새로고침 간격 설정
|
||||
- ✅ 수동 새로고침 버튼
|
||||
- ✅ 마지막 새로고침 시간 표시
|
||||
- ✅ XML/CSV 파싱 (Map, RiskAlert)
|
||||
|
||||
### 🎯 권장 사항
|
||||
|
||||
1. **통계 카드 (CustomMetricTestWidget)**: 원본 대체 가능 ✅
|
||||
2. **RiskAlertTestWidget**: 원본 대체 가능 ✅
|
||||
3. **ChartTestWidget**: 이미 프로덕션 준비 완료 ✅
|
||||
4. **ListTestWidget**: 이미 프로덕션 준비 완료 ✅
|
||||
5. **MapTestWidgetV2**: 날씨 기능이 필요하지 않다면 원본 대체 가능 ⚠️
|
||||
|
||||
### 🚀 다음 단계
|
||||
|
||||
- [x] 테스트 위젯을 원본으로 승격 고려 → **✅ 완료 (2025-10-28)**
|
||||
- [x] 원본 위젯 deprecated 처리 고려 → **✅ 완료 (주석 처리)**
|
||||
- [ ] MapTestWidgetV2에 날씨 API 추가 여부 결정 (선택사항) → **보류 (사용자 요청으로 그냥 승격)**
|
||||
|
||||
---
|
||||
|
||||
## 🎉 승격 완료 (2025-10-28)
|
||||
|
||||
### ✅ 승격된 위젯
|
||||
|
||||
| 테스트 버전 | 정식 버전 | 새 subtype |
|
||||
|------------|----------|-----------|
|
||||
| MapTestWidgetV2 | MapSummaryWidget | `map-summary-v2` |
|
||||
| ChartTestWidget | ChartWidget | `chart` |
|
||||
| ListTestWidget | ListWidget | `list-v2` |
|
||||
| CustomMetricTestWidget | CustomMetricWidget | `custom-metric-v2` |
|
||||
| RiskAlertTestWidget | RiskAlertWidget | `risk-alert-v2` |
|
||||
|
||||
### 📝 변경 사항
|
||||
|
||||
1. **types.ts**: 테스트 subtype 주석 처리, 정식 subtype 추가
|
||||
2. **기존 원본 위젯**: 주석 처리 (백업 보관)
|
||||
3. **CanvasElement.tsx**: subtype 조건문 변경
|
||||
4. **DashboardViewer.tsx**: subtype 조건문 변경
|
||||
5. **ElementConfigSidebar.tsx**: subtype 조건문 변경
|
||||
6. **DashboardTopMenu.tsx**: 메뉴 재구성 (테스트 섹션 제거)
|
||||
7. **SQL 마이그레이션**: 스크립트 생성 완료
|
||||
|
||||
### 🔗 관련 문서
|
||||
|
||||
- [위젯 승격 완료 보고서](./위젯_승격_완료_보고서.md)
|
||||
|
||||
---
|
||||
|
||||
**보고서 작성 완료일**: 2025-10-28
|
||||
**작성자**: AI Assistant
|
||||
**상태**: ✅ 완료 → ✅ 승격 완료
|
||||
|
||||
|
|
@ -15,12 +15,17 @@ import { FlowButtonGroup } from "@/components/screen/widgets/FlowButtonGroup";
|
|||
import { FlowVisibilityConfig } from "@/types/control-management";
|
||||
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
||||
import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
|
||||
|
||||
export default function ScreenViewPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const screenId = parseInt(params.screenId as string);
|
||||
|
||||
// 🆕 현재 로그인한 사용자 정보
|
||||
const { user, userName, companyCode } = useAuth();
|
||||
|
||||
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
|
||||
const [layout, setLayout] = useState<LayoutData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -211,302 +216,314 @@ export default function ScreenViewPage() {
|
|||
const screenHeight = layout?.screenResolution?.height || 800;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="bg-background flex h-full w-full items-start justify-start overflow-hidden">
|
||||
{/* 절대 위치 기반 렌더링 */}
|
||||
{layout && layout.components.length > 0 ? (
|
||||
<div
|
||||
className="bg-background relative origin-top-left"
|
||||
style={{
|
||||
width: layout?.screenResolution?.width || 1200,
|
||||
height: layout?.screenResolution?.height || 800,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* 최상위 컴포넌트들 렌더링 */}
|
||||
{(() => {
|
||||
// 🆕 플로우 버튼 그룹 감지 및 처리
|
||||
const topLevelComponents = layout.components.filter((component) => !component.parentId);
|
||||
<ScreenPreviewProvider isPreviewMode={false}>
|
||||
<div ref={containerRef} className="bg-background flex h-full w-full items-start justify-start overflow-hidden">
|
||||
{/* 절대 위치 기반 렌더링 */}
|
||||
{layout && layout.components.length > 0 ? (
|
||||
<div
|
||||
className="bg-background relative origin-top-left"
|
||||
style={{
|
||||
width: layout?.screenResolution?.width || 1200,
|
||||
height: layout?.screenResolution?.height || 800,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* 최상위 컴포넌트들 렌더링 */}
|
||||
{(() => {
|
||||
// 🆕 플로우 버튼 그룹 감지 및 처리
|
||||
const topLevelComponents = layout.components.filter((component) => !component.parentId);
|
||||
|
||||
const buttonGroups: Record<string, any[]> = {};
|
||||
const processedButtonIds = new Set<string>();
|
||||
const buttonGroups: Record<string, any[]> = {};
|
||||
const processedButtonIds = new Set<string>();
|
||||
|
||||
topLevelComponents.forEach((component) => {
|
||||
const isButton =
|
||||
component.type === "button" ||
|
||||
(component.type === "component" &&
|
||||
["button-primary", "button-secondary"].includes((component as any).componentType));
|
||||
topLevelComponents.forEach((component) => {
|
||||
const isButton =
|
||||
component.type === "button" ||
|
||||
(component.type === "component" &&
|
||||
["button-primary", "button-secondary"].includes((component as any).componentType));
|
||||
|
||||
if (isButton) {
|
||||
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as
|
||||
| FlowVisibilityConfig
|
||||
| undefined;
|
||||
if (isButton) {
|
||||
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as
|
||||
| FlowVisibilityConfig
|
||||
| undefined;
|
||||
|
||||
if (flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId) {
|
||||
if (!buttonGroups[flowConfig.groupId]) {
|
||||
buttonGroups[flowConfig.groupId] = [];
|
||||
if (flowConfig?.enabled && flowConfig.layoutBehavior === "auto-compact" && flowConfig.groupId) {
|
||||
if (!buttonGroups[flowConfig.groupId]) {
|
||||
buttonGroups[flowConfig.groupId] = [];
|
||||
}
|
||||
buttonGroups[flowConfig.groupId].push(component);
|
||||
processedButtonIds.add(component.id);
|
||||
}
|
||||
buttonGroups[flowConfig.groupId].push(component);
|
||||
processedButtonIds.add(component.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
|
||||
const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 일반 컴포넌트들 */}
|
||||
{regularComponents.map((component) => (
|
||||
<RealtimePreview
|
||||
key={component.id}
|
||||
component={component}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(_, selectedData) => {
|
||||
console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
|
||||
setSelectedRowsData(selectedData);
|
||||
}}
|
||||
flowSelectedData={flowSelectedData}
|
||||
flowSelectedStepId={flowSelectedStepId}
|
||||
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
|
||||
console.log("🔍 [page.tsx] 플로우 선택된 데이터 받음:", {
|
||||
dataCount: selectedData.length,
|
||||
selectedData,
|
||||
stepId,
|
||||
});
|
||||
setFlowSelectedData(selectedData);
|
||||
setFlowSelectedStepId(stepId);
|
||||
console.log("🔍 [page.tsx] 상태 업데이트 완료");
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
console.log("🔄 테이블 새로고침 요청됨");
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]); // 선택 해제
|
||||
}}
|
||||
flowRefreshKey={flowRefreshKey}
|
||||
onFlowRefresh={() => {
|
||||
console.log("🔄 플로우 새로고침 요청됨");
|
||||
setFlowRefreshKey((prev) => prev + 1);
|
||||
setFlowSelectedData([]); // 선택 해제
|
||||
setFlowSelectedStepId(null);
|
||||
}}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log("📝 폼 데이터 변경:", fieldName, "=", value);
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
>
|
||||
{/* 자식 컴포넌트들 */}
|
||||
{(component.type === "group" || component.type === "container" || component.type === "area") &&
|
||||
layout.components
|
||||
.filter((child) => child.parentId === component.id)
|
||||
.map((child) => {
|
||||
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
|
||||
const relativeChildComponent = {
|
||||
...child,
|
||||
position: {
|
||||
x: child.position.x - component.position.x,
|
||||
y: child.position.y - component.position.y,
|
||||
z: child.position.z || 1,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<RealtimePreview
|
||||
key={child.id}
|
||||
component={relativeChildComponent}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(_, selectedData) => {
|
||||
console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData);
|
||||
setSelectedRowsData(selectedData);
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
console.log("🔄 테이블 새로고침 요청됨 (자식)");
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]); // 선택 해제
|
||||
}}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log("📝 폼 데이터 변경 (자식):", fieldName, "=", value);
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RealtimePreview>
|
||||
))}
|
||||
|
||||
{/* 🆕 플로우 버튼 그룹들 */}
|
||||
{Object.entries(buttonGroups).map(([groupId, buttons]) => {
|
||||
if (buttons.length === 0) return null;
|
||||
|
||||
const firstButton = buttons[0];
|
||||
const groupConfig = (firstButton as any).webTypeConfig?.flowVisibilityConfig as FlowVisibilityConfig;
|
||||
|
||||
// 그룹의 위치는 모든 버튼 중 가장 왼쪽/위쪽 버튼의 위치 사용
|
||||
const groupPosition = buttons.reduce(
|
||||
(min, button) => ({
|
||||
x: Math.min(min.x, button.position.x),
|
||||
y: Math.min(min.y, button.position.y),
|
||||
z: min.z,
|
||||
}),
|
||||
{ x: buttons[0].position.x, y: buttons[0].position.y, z: buttons[0].position.z || 2 },
|
||||
);
|
||||
|
||||
// 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산
|
||||
const direction = groupConfig.groupDirection || "horizontal";
|
||||
const gap = groupConfig.groupGap ?? 8;
|
||||
|
||||
let groupWidth = 0;
|
||||
let groupHeight = 0;
|
||||
|
||||
if (direction === "horizontal") {
|
||||
groupWidth = buttons.reduce((total, button, index) => {
|
||||
const buttonWidth = button.size?.width || 100;
|
||||
const gapWidth = index < buttons.length - 1 ? gap : 0;
|
||||
return total + buttonWidth + gapWidth;
|
||||
}, 0);
|
||||
groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40));
|
||||
} else {
|
||||
groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100));
|
||||
groupHeight = buttons.reduce((total, button, index) => {
|
||||
const buttonHeight = button.size?.height || 40;
|
||||
const gapHeight = index < buttons.length - 1 ? gap : 0;
|
||||
return total + buttonHeight + gapHeight;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`flow-button-group-${groupId}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${groupPosition.x}px`,
|
||||
top: `${groupPosition.y}px`,
|
||||
zIndex: groupPosition.z,
|
||||
width: `${groupWidth}px`,
|
||||
height: `${groupHeight}px`,
|
||||
return (
|
||||
<>
|
||||
{/* 일반 컴포넌트들 */}
|
||||
{regularComponents.map((component) => (
|
||||
<RealtimePreview
|
||||
key={component.id}
|
||||
component={component}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
userId={user?.userId}
|
||||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(_, selectedData) => {
|
||||
console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
|
||||
setSelectedRowsData(selectedData);
|
||||
}}
|
||||
flowSelectedData={flowSelectedData}
|
||||
flowSelectedStepId={flowSelectedStepId}
|
||||
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
|
||||
console.log("🔍 [page.tsx] 플로우 선택된 데이터 받음:", {
|
||||
dataCount: selectedData.length,
|
||||
selectedData,
|
||||
stepId,
|
||||
});
|
||||
setFlowSelectedData(selectedData);
|
||||
setFlowSelectedStepId(stepId);
|
||||
console.log("🔍 [page.tsx] 상태 업데이트 완료");
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
console.log("🔄 테이블 새로고침 요청됨");
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]); // 선택 해제
|
||||
}}
|
||||
flowRefreshKey={flowRefreshKey}
|
||||
onFlowRefresh={() => {
|
||||
console.log("🔄 플로우 새로고침 요청됨");
|
||||
setFlowRefreshKey((prev) => prev + 1);
|
||||
setFlowSelectedData([]); // 선택 해제
|
||||
setFlowSelectedStepId(null);
|
||||
}}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log("📝 폼 데이터 변경:", fieldName, "=", value);
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
>
|
||||
<FlowButtonGroup
|
||||
buttons={buttons}
|
||||
groupConfig={groupConfig}
|
||||
isDesignMode={false}
|
||||
renderButton={(button) => {
|
||||
const relativeButton = {
|
||||
...button,
|
||||
position: { x: 0, y: 0, z: button.position.z || 1 },
|
||||
};
|
||||
{/* 자식 컴포넌트들 */}
|
||||
{(component.type === "group" || component.type === "container" || component.type === "area") &&
|
||||
layout.components
|
||||
.filter((child) => child.parentId === component.id)
|
||||
.map((child) => {
|
||||
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
|
||||
const relativeChildComponent = {
|
||||
...child,
|
||||
position: {
|
||||
x: child.position.x - component.position.x,
|
||||
y: child.position.y - component.position.y,
|
||||
z: child.position.z || 1,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={button.id}
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "inline-block",
|
||||
width: button.size?.width || 100,
|
||||
height: button.size?.height || 40,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
<DynamicComponentRenderer
|
||||
component={relativeButton}
|
||||
isDesignMode={false}
|
||||
isInteractive={true}
|
||||
formData={formData}
|
||||
onDataflowComplete={() => {}}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(_, selectedData) => {
|
||||
setSelectedRowsData(selectedData);
|
||||
}}
|
||||
flowSelectedData={flowSelectedData}
|
||||
flowSelectedStepId={flowSelectedStepId}
|
||||
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
|
||||
setFlowSelectedData(selectedData);
|
||||
setFlowSelectedStepId(stepId);
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]);
|
||||
}}
|
||||
flowRefreshKey={flowRefreshKey}
|
||||
onFlowRefresh={() => {
|
||||
setFlowRefreshKey((prev) => prev + 1);
|
||||
setFlowSelectedData([]);
|
||||
setFlowSelectedStepId(null);
|
||||
}}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<RealtimePreview
|
||||
key={child.id}
|
||||
component={relativeChildComponent}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
userId={user?.userId}
|
||||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(_, selectedData) => {
|
||||
console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData);
|
||||
setSelectedRowsData(selectedData);
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
console.log("🔄 테이블 새로고침 요청됨 (자식)");
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]); // 선택 해제
|
||||
}}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log("📝 폼 데이터 변경 (자식):", fieldName, "=", value);
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RealtimePreview>
|
||||
))}
|
||||
|
||||
{/* 🆕 플로우 버튼 그룹들 */}
|
||||
{Object.entries(buttonGroups).map(([groupId, buttons]) => {
|
||||
if (buttons.length === 0) return null;
|
||||
|
||||
const firstButton = buttons[0];
|
||||
const groupConfig = (firstButton as any).webTypeConfig
|
||||
?.flowVisibilityConfig as FlowVisibilityConfig;
|
||||
|
||||
// 그룹의 위치는 모든 버튼 중 가장 왼쪽/위쪽 버튼의 위치 사용
|
||||
const groupPosition = buttons.reduce(
|
||||
(min, button) => ({
|
||||
x: Math.min(min.x, button.position.x),
|
||||
y: Math.min(min.y, button.position.y),
|
||||
z: min.z,
|
||||
}),
|
||||
{ x: buttons[0].position.x, y: buttons[0].position.y, z: buttons[0].position.z || 2 },
|
||||
);
|
||||
|
||||
// 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산
|
||||
const direction = groupConfig.groupDirection || "horizontal";
|
||||
const gap = groupConfig.groupGap ?? 8;
|
||||
|
||||
let groupWidth = 0;
|
||||
let groupHeight = 0;
|
||||
|
||||
if (direction === "horizontal") {
|
||||
groupWidth = buttons.reduce((total, button, index) => {
|
||||
const buttonWidth = button.size?.width || 100;
|
||||
const gapWidth = index < buttons.length - 1 ? gap : 0;
|
||||
return total + buttonWidth + gapWidth;
|
||||
}, 0);
|
||||
groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40));
|
||||
} else {
|
||||
groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100));
|
||||
groupHeight = buttons.reduce((total, button, index) => {
|
||||
const buttonHeight = button.size?.height || 40;
|
||||
const gapHeight = index < buttons.length - 1 ? gap : 0;
|
||||
return total + buttonHeight + gapHeight;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`flow-button-group-${groupId}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${groupPosition.x}px`,
|
||||
top: `${groupPosition.y}px`,
|
||||
zIndex: groupPosition.z,
|
||||
width: `${groupWidth}px`,
|
||||
height: `${groupHeight}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
// 빈 화면일 때
|
||||
<div className="bg-background flex items-center justify-center" style={{ minHeight: screenHeight }}>
|
||||
<div className="text-center">
|
||||
<div className="bg-muted mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full shadow-sm">
|
||||
<span className="text-2xl">📄</span>
|
||||
</div>
|
||||
<h2 className="text-foreground mb-2 text-xl font-semibold">화면이 비어있습니다</h2>
|
||||
<p className="text-muted-foreground">이 화면에는 아직 설계된 컴포넌트가 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<FlowButtonGroup
|
||||
buttons={buttons}
|
||||
groupConfig={groupConfig}
|
||||
isDesignMode={false}
|
||||
renderButton={(button) => {
|
||||
const relativeButton = {
|
||||
...button,
|
||||
position: { x: 0, y: 0, z: button.position.z || 1 },
|
||||
};
|
||||
|
||||
{/* 편집 모달 */}
|
||||
<EditModal
|
||||
isOpen={editModalOpen}
|
||||
onClose={() => {
|
||||
setEditModalOpen(false);
|
||||
setEditModalConfig({});
|
||||
}}
|
||||
screenId={editModalConfig.screenId}
|
||||
modalSize={editModalConfig.modalSize}
|
||||
editData={editModalConfig.editData}
|
||||
onSave={editModalConfig.onSave}
|
||||
modalTitle={editModalConfig.modalTitle}
|
||||
modalDescription={editModalConfig.modalDescription}
|
||||
onDataChange={(changedFormData) => {
|
||||
console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData);
|
||||
// 변경된 데이터를 메인 폼에 반영
|
||||
setFormData((prev) => {
|
||||
const updatedFormData = {
|
||||
...prev,
|
||||
...changedFormData, // 변경된 필드들만 업데이트
|
||||
};
|
||||
console.log("📊 메인 폼 데이터 업데이트:", updatedFormData);
|
||||
return updatedFormData;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={button.id}
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "inline-block",
|
||||
width: button.size?.width || 100,
|
||||
height: button.size?.height || 40,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
<DynamicComponentRenderer
|
||||
component={relativeButton}
|
||||
isDesignMode={false}
|
||||
isInteractive={true}
|
||||
formData={formData}
|
||||
onDataflowComplete={() => {}}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
userId={user?.userId}
|
||||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(_, selectedData) => {
|
||||
setSelectedRowsData(selectedData);
|
||||
}}
|
||||
flowSelectedData={flowSelectedData}
|
||||
flowSelectedStepId={flowSelectedStepId}
|
||||
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
|
||||
setFlowSelectedData(selectedData);
|
||||
setFlowSelectedStepId(stepId);
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]);
|
||||
}}
|
||||
flowRefreshKey={flowRefreshKey}
|
||||
onFlowRefresh={() => {
|
||||
setFlowRefreshKey((prev) => prev + 1);
|
||||
setFlowSelectedData([]);
|
||||
setFlowSelectedStepId(null);
|
||||
}}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
// 빈 화면일 때
|
||||
<div className="bg-background flex items-center justify-center" style={{ minHeight: screenHeight }}>
|
||||
<div className="text-center">
|
||||
<div className="bg-muted mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full shadow-sm">
|
||||
<span className="text-2xl">📄</span>
|
||||
</div>
|
||||
<h2 className="text-foreground mb-2 text-xl font-semibold">화면이 비어있습니다</h2>
|
||||
<p className="text-muted-foreground">이 화면에는 아직 설계된 컴포넌트가 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 편집 모달 */}
|
||||
<EditModal
|
||||
isOpen={editModalOpen}
|
||||
onClose={() => {
|
||||
setEditModalOpen(false);
|
||||
setEditModalConfig({});
|
||||
}}
|
||||
screenId={editModalConfig.screenId}
|
||||
modalSize={editModalConfig.modalSize}
|
||||
editData={editModalConfig.editData}
|
||||
onSave={editModalConfig.onSave}
|
||||
modalTitle={editModalConfig.modalTitle}
|
||||
modalDescription={editModalConfig.modalDescription}
|
||||
onDataChange={(changedFormData) => {
|
||||
console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData);
|
||||
// 변경된 데이터를 메인 폼에 반영
|
||||
setFormData((prev) => {
|
||||
const updatedFormData = {
|
||||
...prev,
|
||||
...changedFormData, // 변경된 필드들만 업데이트
|
||||
};
|
||||
console.log("📊 메인 폼 데이터 업데이트:", updatedFormData);
|
||||
return updatedFormData;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ScreenPreviewProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
const [connectionName, setConnectionName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [endpointPath, setEndpointPath] = useState("");
|
||||
const [defaultHeaders, setDefaultHeaders] = useState<Record<string, string>>({});
|
||||
const [authType, setAuthType] = useState<AuthType>("none");
|
||||
const [authConfig, setAuthConfig] = useState<any>({});
|
||||
|
|
@ -55,6 +56,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
setConnectionName(connection.connection_name);
|
||||
setDescription(connection.description || "");
|
||||
setBaseUrl(connection.base_url);
|
||||
setEndpointPath(connection.endpoint_path || "");
|
||||
setDefaultHeaders(connection.default_headers || {});
|
||||
setAuthType(connection.auth_type);
|
||||
setAuthConfig(connection.auth_config || {});
|
||||
|
|
@ -67,6 +69,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
setConnectionName("");
|
||||
setDescription("");
|
||||
setBaseUrl("");
|
||||
setEndpointPath("");
|
||||
setDefaultHeaders({ "Content-Type": "application/json" });
|
||||
setAuthType("none");
|
||||
setAuthConfig({});
|
||||
|
|
@ -175,6 +178,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
connection_name: connectionName,
|
||||
description: description || undefined,
|
||||
base_url: baseUrl,
|
||||
endpoint_path: endpointPath || undefined,
|
||||
default_headers: defaultHeaders,
|
||||
auth_type: authType,
|
||||
auth_config: authType === "none" ? undefined : authConfig,
|
||||
|
|
@ -257,6 +261,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
도메인 부분만 입력하세요 (예: https://apihub.kma.go.kr)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint-path">엔드포인트 경로</Label>
|
||||
<Input
|
||||
id="endpoint-path"
|
||||
value={endpointPath}
|
||||
onChange={(e) => setEndpointPath(e.target.value)}
|
||||
placeholder="/api/typ01/url/wrn_now_data.php"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
API 엔드포인트 경로를 입력하세요 (선택사항)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
|
|
|
|||
|
|
@ -60,6 +60,42 @@ const MapSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/Ma
|
|||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 🧪 테스트용 지도 위젯 (REST API 지원)
|
||||
const MapTestWidget = dynamic(() => import("@/components/dashboard/widgets/MapTestWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 🧪 테스트용 지도 위젯 V2 (다중 데이터 소스)
|
||||
const MapTestWidgetV2 = dynamic(() => import("@/components/dashboard/widgets/MapTestWidgetV2"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 🧪 테스트용 차트 위젯 (다중 데이터 소스)
|
||||
const ChartTestWidget = dynamic(() => import("@/components/dashboard/widgets/ChartTestWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const ListTestWidget = dynamic(
|
||||
() => import("@/components/dashboard/widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
},
|
||||
);
|
||||
|
||||
const CustomMetricTestWidget = dynamic(() => import("@/components/dashboard/widgets/CustomMetricTestWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const RiskAlertTestWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertTestWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 범용 상태 요약 위젯 (차량, 배송 등 모든 상태 위젯 통합)
|
||||
const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/StatusSummaryWidget"), {
|
||||
ssr: false,
|
||||
|
|
@ -116,6 +152,7 @@ import { ClockWidget } from "./widgets/ClockWidget";
|
|||
import { CalendarWidget } from "./widgets/CalendarWidget";
|
||||
// 기사 관리 위젯 임포트
|
||||
import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
|
||||
// 리스트 위젯 임포트 (구버전)
|
||||
import { ListWidget } from "./widgets/ListWidget";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -851,6 +888,36 @@ export function CanvasElement({
|
|||
<div className="widget-interactive-area h-full w-full">
|
||||
<MapSummaryWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "map-test" ? (
|
||||
// 🧪 테스트용 지도 위젯 (REST API 지원)
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<MapTestWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "map-summary-v2" ? (
|
||||
// 지도 위젯 (다중 데이터 소스) - 승격 완료
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<MapTestWidgetV2 element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "chart" ? (
|
||||
// 차트 위젯 (다중 데이터 소스) - 승격 완료
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<ChartTestWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "list-v2" ? (
|
||||
// 리스트 위젯 (다중 데이터 소스) - 승격 완료
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<ListTestWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "custom-metric-v2" ? (
|
||||
// 통계 카드 위젯 (다중 데이터 소스) - 승격 완료
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<CustomMetricTestWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "risk-alert-v2" ? (
|
||||
// 리스크/알림 위젯 (다중 데이터 소스) - 승격 완료
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<RiskAlertTestWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "vehicle-map" ? (
|
||||
// 차량 위치 지도 위젯 렌더링 (구버전 - 호환용)
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
|
|
@ -925,7 +992,7 @@ export function CanvasElement({
|
|||
) : element.type === "widget" && element.subtype === "risk-alert" ? (
|
||||
// 리스크/알림 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<RiskAlertWidget />
|
||||
<RiskAlertWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "calendar" ? (
|
||||
// 달력 위젯 렌더링
|
||||
|
|
@ -948,7 +1015,7 @@ export function CanvasElement({
|
|||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "list" ? (
|
||||
// 리스트 위젯 렌더링
|
||||
// 리스트 위젯 렌더링 (구버전)
|
||||
<div className="h-full w-full">
|
||||
<ListWidget element={element} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -194,7 +194,13 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
|
||||
// 요소들 설정
|
||||
if (dashboard.elements && dashboard.elements.length > 0) {
|
||||
setElements(dashboard.elements);
|
||||
// chartConfig.dataSources를 element.dataSources로 복사 (프론트엔드 호환성)
|
||||
const elementsWithDataSources = dashboard.elements.map((el) => ({
|
||||
...el,
|
||||
dataSources: el.chartConfig?.dataSources || el.dataSources,
|
||||
}));
|
||||
|
||||
setElements(elementsWithDataSources);
|
||||
|
||||
// elementCounter를 가장 큰 ID 번호로 설정
|
||||
const maxId = dashboard.elements.reduce((max, el) => {
|
||||
|
|
@ -463,7 +469,11 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
showHeader: el.showHeader,
|
||||
content: el.content,
|
||||
dataSource: el.dataSource,
|
||||
chartConfig: el.chartConfig,
|
||||
// dataSources는 chartConfig에 포함시켜서 저장 (백엔드 스키마 수정 불필요)
|
||||
chartConfig:
|
||||
el.dataSources && el.dataSources.length > 0
|
||||
? { ...el.chartConfig, dataSources: el.dataSources }
|
||||
: el.chartConfig,
|
||||
listConfig: el.listConfig,
|
||||
yardConfig: el.yardConfig,
|
||||
customMetricConfig: el.customMetricConfig,
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ export function DashboardTopMenu({
|
|||
)}
|
||||
|
||||
<div className="h-6 w-px bg-gray-300" />
|
||||
|
||||
{/* 차트 선택 */}
|
||||
<Select value={chartValue} onValueChange={handleChartSelect}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
|
|
@ -183,11 +184,13 @@ export function DashboardTopMenu({
|
|||
<SelectContent className="z-[99999]">
|
||||
<SelectGroup>
|
||||
<SelectLabel>데이터 위젯</SelectLabel>
|
||||
<SelectItem value="list">리스트 위젯</SelectItem>
|
||||
<SelectItem value="custom-metric">사용자 커스텀 카드</SelectItem>
|
||||
<SelectItem value="map-summary-v2">지도</SelectItem>
|
||||
{/* <SelectItem value="chart">차트</SelectItem> */} {/* 주석 처리: 2025-10-29, 시기상조 */}
|
||||
<SelectItem value="list-v2">리스트</SelectItem>
|
||||
<SelectItem value="custom-metric-v2">통계 카드</SelectItem>
|
||||
<SelectItem value="risk-alert-v2">리스크/알림</SelectItem>
|
||||
<SelectItem value="yard-management-3d">야드 관리 3D</SelectItem>
|
||||
{/* <SelectItem value="transport-stats">커스텀 통계 카드</SelectItem> */}
|
||||
<SelectItem value="map-summary">커스텀 지도 카드</SelectItem>
|
||||
{/* <SelectItem value="status-summary">커스텀 상태 카드</SelectItem> */}
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
|
|
@ -201,7 +204,7 @@ export function DashboardTopMenu({
|
|||
<SelectItem value="todo">일정관리 위젯</SelectItem>
|
||||
{/* <SelectItem value="booking-alert">예약 알림</SelectItem> */}
|
||||
<SelectItem value="document">문서</SelectItem>
|
||||
<SelectItem value="risk-alert">리스크 알림</SelectItem>
|
||||
{/* <SelectItem value="risk-alert">리스크 알림</SelectItem> */}
|
||||
</SelectGroup>
|
||||
{/* 범용 위젯으로 대체 가능하여 주석처리 */}
|
||||
{/* <SelectGroup>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./t
|
|||
import { QueryEditor } from "./QueryEditor";
|
||||
import { ChartConfigPanel } from "./ChartConfigPanel";
|
||||
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
|
||||
import { MapTestConfigPanel } from "./MapTestConfigPanel";
|
||||
import { DataSourceSelector } from "./data-sources/DataSourceSelector";
|
||||
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
||||
import { ApiConfig } from "./data-sources/ApiConfig";
|
||||
|
|
@ -17,6 +18,7 @@ interface ElementConfigModalProps {
|
|||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (element: DashboardElement) => void;
|
||||
onPreview?: (element: DashboardElement) => void; // 실시간 미리보기용 (저장 전)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -24,7 +26,7 @@ interface ElementConfigModalProps {
|
|||
* - 2단계 플로우: 데이터 소스 선택 → 데이터 설정 및 차트 설정
|
||||
* - 새로운 데이터 소스 컴포넌트 통합
|
||||
*/
|
||||
export function ElementConfigModal({ element, isOpen, onClose, onSave }: ElementConfigModalProps) {
|
||||
export function ElementConfigModal({ element, isOpen, onClose, onSave, onPreview }: ElementConfigModalProps) {
|
||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
|
||||
);
|
||||
|
|
@ -61,7 +63,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
element.subtype === "calculator"; // 계산기 위젯 (자체 기능)
|
||||
|
||||
// 지도 위젯 (위도/경도 매핑 필요)
|
||||
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary";
|
||||
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary" || element.subtype === "map-test";
|
||||
|
||||
// 주석
|
||||
// 모달이 열릴 때 초기화
|
||||
|
|
@ -132,7 +134,18 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
// 차트 설정 변경 처리
|
||||
const handleChartConfigChange = useCallback((newConfig: ChartConfig) => {
|
||||
setChartConfig(newConfig);
|
||||
}, []);
|
||||
|
||||
// 🎯 실시간 미리보기: chartConfig 변경 시 즉시 부모에게 전달
|
||||
if (onPreview) {
|
||||
onPreview({
|
||||
...element,
|
||||
chartConfig: newConfig,
|
||||
dataSource: dataSource,
|
||||
customTitle: customTitle,
|
||||
showHeader: showHeader,
|
||||
});
|
||||
}
|
||||
}, [element, dataSource, customTitle, showHeader, onPreview]);
|
||||
|
||||
// 쿼리 테스트 결과 처리
|
||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
||||
|
|
@ -208,12 +221,16 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 (차트 설정 불필요)
|
||||
currentStep === 2 && queryResult && queryResult.rows.length > 0
|
||||
: isMapWidget
|
||||
? // 지도 위젯: 위도/경도 매핑 필요
|
||||
currentStep === 2 &&
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.latitudeColumn &&
|
||||
chartConfig.longitudeColumn
|
||||
? // 지도 위젯: 타일맵 URL 또는 위도/경도 매핑 필요
|
||||
element.subtype === "map-test"
|
||||
? // 🧪 지도 테스트 위젯: 타일맵 URL만 있으면 저장 가능
|
||||
currentStep === 2 && chartConfig.tileMapUrl
|
||||
: // 기존 지도 위젯: 쿼리 결과 + 위도/경도 필수
|
||||
currentStep === 2 &&
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.latitudeColumn &&
|
||||
chartConfig.longitudeColumn
|
||||
: // 차트: 기존 로직 (2단계에서 차트 설정 필요)
|
||||
currentStep === 2 &&
|
||||
queryResult &&
|
||||
|
|
@ -324,7 +341,15 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
<div>
|
||||
{isMapWidget ? (
|
||||
// 지도 위젯: 위도/경도 매핑 패널
|
||||
queryResult && queryResult.rows.length > 0 ? (
|
||||
element.subtype === "map-test" ? (
|
||||
// 🧪 지도 테스트 위젯: 타일맵 URL 필수, 마커 데이터 선택사항
|
||||
<MapTestConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult || undefined}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
) : queryResult && queryResult.rows.length > 0 ? (
|
||||
// 기존 지도 위젯: 쿼리 결과 필수
|
||||
<VehicleMapConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./t
|
|||
import { QueryEditor } from "./QueryEditor";
|
||||
import { ChartConfigPanel } from "./ChartConfigPanel";
|
||||
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
|
||||
import { MapTestConfigPanel } from "./MapTestConfigPanel";
|
||||
import { MultiChartConfigPanel } from "./MultiChartConfigPanel";
|
||||
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
||||
import { ApiConfig } from "./data-sources/ApiConfig";
|
||||
import MultiDataSourceConfig from "./data-sources/MultiDataSourceConfig";
|
||||
import { ListWidgetConfigSidebar } from "./widgets/ListWidgetConfigSidebar";
|
||||
import { YardWidgetConfigSidebar } from "./widgets/YardWidgetConfigSidebar";
|
||||
import { X } from "lucide-react";
|
||||
|
|
@ -33,19 +36,47 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
connectionType: "current",
|
||||
refreshInterval: 0,
|
||||
});
|
||||
const [dataSources, setDataSources] = useState<ChartDataSource[]>([]);
|
||||
const [chartConfig, setChartConfig] = useState<ChartConfig>({});
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [customTitle, setCustomTitle] = useState<string>("");
|
||||
const [showHeader, setShowHeader] = useState<boolean>(true);
|
||||
|
||||
// 멀티 데이터 소스의 테스트 결과 저장 (ChartTestWidget용)
|
||||
const [testResults, setTestResults] = useState<Map<string, { columns: string[]; rows: Record<string, unknown>[] }>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
// 사이드바가 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen && element) {
|
||||
console.log("🔄 ElementConfigSidebar 초기화 - element.id:", element.id);
|
||||
console.log("🔄 element.dataSources:", element.dataSources);
|
||||
console.log("🔄 element.chartConfig?.dataSources:", element.chartConfig?.dataSources);
|
||||
|
||||
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
|
||||
|
||||
// dataSources는 element.dataSources 또는 chartConfig.dataSources에서 로드
|
||||
// ⚠️ 중요: 없으면 반드시 빈 배열로 초기화
|
||||
const initialDataSources = element.dataSources || element.chartConfig?.dataSources || [];
|
||||
console.log("🔄 초기화된 dataSources:", initialDataSources);
|
||||
setDataSources(initialDataSources);
|
||||
|
||||
setChartConfig(element.chartConfig || {});
|
||||
setQueryResult(null);
|
||||
setTestResults(new Map()); // 테스트 결과도 초기화
|
||||
setCustomTitle(element.customTitle || "");
|
||||
setShowHeader(element.showHeader !== false);
|
||||
} else if (!isOpen) {
|
||||
// 사이드바가 닫힐 때 모든 상태 초기화
|
||||
console.log("🧹 ElementConfigSidebar 닫힘 - 상태 초기화");
|
||||
setDataSource({ type: "database", connectionType: "current", refreshInterval: 0 });
|
||||
setDataSources([]);
|
||||
setChartConfig({});
|
||||
setQueryResult(null);
|
||||
setTestResults(new Map());
|
||||
setCustomTitle("");
|
||||
setShowHeader(true);
|
||||
}
|
||||
}, [isOpen, element]);
|
||||
|
||||
|
|
@ -89,9 +120,23 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
}, []);
|
||||
|
||||
// 차트 설정 변경 처리
|
||||
const handleChartConfigChange = useCallback((newConfig: ChartConfig) => {
|
||||
setChartConfig(newConfig);
|
||||
}, []);
|
||||
const handleChartConfigChange = useCallback(
|
||||
(newConfig: ChartConfig) => {
|
||||
setChartConfig(newConfig);
|
||||
|
||||
// 🎯 실시간 미리보기: 즉시 부모에게 전달 (map-test 위젯용)
|
||||
if (element && element.subtype === "map-test" && newConfig.tileMapUrl) {
|
||||
onApply({
|
||||
...element,
|
||||
chartConfig: newConfig,
|
||||
dataSource: dataSource,
|
||||
customTitle: customTitle,
|
||||
showHeader: showHeader,
|
||||
});
|
||||
}
|
||||
},
|
||||
[element, dataSource, customTitle, showHeader, onApply],
|
||||
);
|
||||
|
||||
// 쿼리 테스트 결과 처리
|
||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
||||
|
|
@ -103,17 +148,32 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
const handleApply = useCallback(() => {
|
||||
if (!element) return;
|
||||
|
||||
console.log("🔧 적용 버튼 클릭 - dataSource:", dataSource);
|
||||
console.log("🔧 적용 버튼 클릭 - dataSources:", dataSources);
|
||||
console.log("🔧 적용 버튼 클릭 - chartConfig:", chartConfig);
|
||||
|
||||
// 다중 데이터 소스 위젯 체크
|
||||
const isMultiDS =
|
||||
element.subtype === "map-summary-v2" ||
|
||||
element.subtype === "chart" ||
|
||||
element.subtype === "list-v2" ||
|
||||
element.subtype === "custom-metric-v2" ||
|
||||
element.subtype === "risk-alert-v2";
|
||||
|
||||
const updatedElement: DashboardElement = {
|
||||
...element,
|
||||
dataSource,
|
||||
chartConfig,
|
||||
// 다중 데이터 소스 위젯은 dataSources를 chartConfig에 저장
|
||||
chartConfig: isMultiDS ? { ...chartConfig, dataSources } : chartConfig,
|
||||
dataSources: isMultiDS ? dataSources : undefined, // 프론트엔드 호환성
|
||||
dataSource: isMultiDS ? undefined : dataSource,
|
||||
customTitle: customTitle.trim() || undefined,
|
||||
showHeader,
|
||||
};
|
||||
|
||||
console.log("🔧 적용할 요소:", updatedElement);
|
||||
onApply(updatedElement);
|
||||
// 사이드바는 열린 채로 유지 (연속 수정 가능)
|
||||
}, [element, dataSource, chartConfig, customTitle, showHeader, onApply]);
|
||||
}, [element, dataSource, dataSources, chartConfig, customTitle, showHeader, onApply]);
|
||||
|
||||
// 요소가 없으면 렌더링하지 않음
|
||||
if (!element) return null;
|
||||
|
|
@ -184,13 +244,23 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
element.subtype === "weather" || element.subtype === "exchange" || element.subtype === "calculator";
|
||||
|
||||
// 지도 위젯 (위도/경도 매핑 필요)
|
||||
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary";
|
||||
const isMapWidget =
|
||||
element.subtype === "vehicle-map" || element.subtype === "map-summary" || element.subtype === "map-test";
|
||||
|
||||
// 헤더 전용 위젯
|
||||
const isHeaderOnlyWidget =
|
||||
element.type === "widget" &&
|
||||
(element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget);
|
||||
|
||||
// 다중 데이터 소스 위젯
|
||||
const isMultiDataSourceWidget =
|
||||
element.subtype === "map-summary-v2" ||
|
||||
element.subtype === "chart" ||
|
||||
element.subtype === "list-v2" ||
|
||||
element.subtype === "custom-metric-v2" ||
|
||||
element.subtype === "status-summary-test" ||
|
||||
element.subtype === "risk-alert-v2";
|
||||
|
||||
// 저장 가능 여부 확인
|
||||
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
||||
const isApiSource = dataSource.type === "api";
|
||||
|
|
@ -205,19 +275,23 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
const canApply =
|
||||
isTitleChanged ||
|
||||
isHeaderChanged ||
|
||||
(isSimpleWidget
|
||||
? queryResult && queryResult.rows.length > 0
|
||||
: isMapWidget
|
||||
? queryResult && queryResult.rows.length > 0 && chartConfig.latitudeColumn && chartConfig.longitudeColumn
|
||||
: queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.xAxis &&
|
||||
(isPieChart || isApiSource ? (chartConfig.aggregation === "count" ? true : hasYAxis) : hasYAxis));
|
||||
(isMultiDataSourceWidget
|
||||
? true // 다중 데이터 소스 위젯은 항상 적용 가능
|
||||
: isSimpleWidget
|
||||
? queryResult && queryResult.rows.length > 0
|
||||
: isMapWidget
|
||||
? element.subtype === "map-test"
|
||||
? chartConfig.tileMapUrl || (queryResult && queryResult.rows.length > 0) // 🧪 지도 테스트 위젯: 타일맵 URL 또는 API 데이터
|
||||
: queryResult && queryResult.rows.length > 0 && chartConfig.latitudeColumn && chartConfig.longitudeColumn
|
||||
: queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.xAxis &&
|
||||
(isPieChart || isApiSource ? (chartConfig.aggregation === "count" ? true : hasYAxis) : hasYAxis));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
|
||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-72 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
|
||||
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
||||
)}
|
||||
>
|
||||
|
|
@ -269,8 +343,102 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 다중 데이터 소스 위젯 */}
|
||||
{isMultiDataSourceWidget && (
|
||||
<>
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<MultiDataSourceConfig
|
||||
dataSources={dataSources}
|
||||
onChange={setDataSources}
|
||||
onTestResult={(result, dataSourceId) => {
|
||||
// API 테스트 결과를 queryResult로 설정 (차트 설정용)
|
||||
setQueryResult({
|
||||
...result,
|
||||
totalRows: result.rows.length,
|
||||
executionTime: 0,
|
||||
});
|
||||
console.log("📊 API 테스트 결과 수신:", result, "데이터 소스 ID:", dataSourceId);
|
||||
|
||||
// ChartTestWidget용: 각 데이터 소스의 테스트 결과 저장
|
||||
setTestResults((prev) => {
|
||||
const updated = new Map(prev);
|
||||
updated.set(dataSourceId, result);
|
||||
console.log("📊 테스트 결과 저장:", dataSourceId, result);
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 지도 위젯: 타일맵 URL 설정 */}
|
||||
{element.subtype === "map-summary-v2" && (
|
||||
<div className="rounded-lg bg-white shadow-sm">
|
||||
<details className="group">
|
||||
<summary className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-50">
|
||||
<div>
|
||||
<div className="text-xs font-semibold tracking-wide text-gray-500 uppercase">
|
||||
타일맵 설정 (선택사항)
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-[10px]">기본 VWorld 타일맵 사용 중</div>
|
||||
</div>
|
||||
<svg
|
||||
className="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="border-t p-3">
|
||||
<MapTestConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={undefined}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 차트 위젯: 차트 설정 */}
|
||||
{element.subtype === "chart" && (
|
||||
<div className="rounded-lg bg-white shadow-sm">
|
||||
<details className="group" open>
|
||||
<summary className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-50">
|
||||
<div>
|
||||
<div className="text-xs font-semibold tracking-wide text-gray-500 uppercase">차트 설정</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
{testResults.size > 0
|
||||
? `${testResults.size}개 데이터 소스 • X축, Y축, 차트 타입 설정`
|
||||
: "먼저 데이터 소스를 추가하고 API 테스트를 실행하세요"}
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="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="border-t p-3">
|
||||
<MultiChartConfigPanel
|
||||
config={chartConfig}
|
||||
dataSources={dataSources}
|
||||
testResults={testResults}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 헤더 전용 위젯이 아닐 때만 데이터 소스 표시 */}
|
||||
{!isHeaderOnlyWidget && (
|
||||
{!isHeaderOnlyWidget && !isMultiDataSourceWidget && (
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">데이터 소스</div>
|
||||
|
||||
|
|
@ -303,52 +471,82 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
/>
|
||||
|
||||
{/* 차트/지도 설정 */}
|
||||
{!isSimpleWidget && queryResult && queryResult.rows.length > 0 && (
|
||||
<div className="mt-2">
|
||||
{isMapWidget ? (
|
||||
<VehicleMapConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
) : (
|
||||
<ChartConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
chartType={element.subtype}
|
||||
dataSourceType={dataSource.type}
|
||||
query={dataSource.query}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isSimpleWidget &&
|
||||
(element.subtype === "map-test" || (queryResult && queryResult.rows.length > 0)) && (
|
||||
<div className="mt-2">
|
||||
{isMapWidget ? (
|
||||
element.subtype === "map-test" ? (
|
||||
<MapTestConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult || undefined}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
) : (
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 && (
|
||||
<VehicleMapConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 && (
|
||||
<ChartConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
chartType={element.subtype}
|
||||
dataSourceType={dataSource.type}
|
||||
query={dataSource.query}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="api" className="mt-2 space-y-2">
|
||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
||||
|
||||
{/* 차트/지도 설정 */}
|
||||
{!isSimpleWidget && queryResult && queryResult.rows.length > 0 && (
|
||||
<div className="mt-2">
|
||||
{isMapWidget ? (
|
||||
<VehicleMapConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
) : (
|
||||
<ChartConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
chartType={element.subtype}
|
||||
dataSourceType={dataSource.type}
|
||||
query={dataSource.query}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isSimpleWidget &&
|
||||
(element.subtype === "map-test" || (queryResult && queryResult.rows.length > 0)) && (
|
||||
<div className="mt-2">
|
||||
{isMapWidget ? (
|
||||
element.subtype === "map-test" ? (
|
||||
<MapTestConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult || undefined}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
) : (
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 && (
|
||||
<VehicleMapConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 && (
|
||||
<ChartConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
chartType={element.subtype}
|
||||
dataSourceType={dataSource.type}
|
||||
query={dataSource.query}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,415 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { ChartConfig, QueryResult, ChartDataSource } from './types';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { ExternalDbConnectionAPI, ExternalApiConnection } from '@/lib/api/externalDbConnection';
|
||||
|
||||
interface MapTestConfigPanelProps {
|
||||
config?: ChartConfig;
|
||||
queryResult?: QueryResult;
|
||||
onConfigChange: (config: ChartConfig) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지도 테스트 위젯 설정 패널
|
||||
* - 타일맵 URL 설정 (VWorld, OpenStreetMap 등)
|
||||
* - 위도/경도 컬럼 매핑
|
||||
* - 라벨/상태 컬럼 설정
|
||||
*/
|
||||
export function MapTestConfigPanel({ config, queryResult, onConfigChange }: MapTestConfigPanelProps) {
|
||||
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
|
||||
const [connections, setConnections] = useState<ExternalApiConnection[]>([]);
|
||||
const [tileMapSources, setTileMapSources] = useState<Array<{ id: string; url: string }>>([
|
||||
{ id: `tilemap_${Date.now()}`, url: '' }
|
||||
]);
|
||||
|
||||
// config prop 변경 시 currentConfig 동기화
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setCurrentConfig(config);
|
||||
console.log('🔄 config 업데이트:', config);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// 외부 API 커넥션 목록 불러오기 (REST API만)
|
||||
useEffect(() => {
|
||||
const loadApiConnections = async () => {
|
||||
try {
|
||||
const apiConnections = await ExternalDbConnectionAPI.getApiConnections({ is_active: 'Y' });
|
||||
setConnections(apiConnections);
|
||||
console.log('✅ REST API 커넥션 로드 완료:', apiConnections);
|
||||
console.log(`📊 총 ${apiConnections.length}개의 REST API 커넥션`);
|
||||
} catch (error) {
|
||||
console.error('❌ REST API 커넥션 로드 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadApiConnections();
|
||||
}, []);
|
||||
|
||||
// 타일맵 URL을 템플릿 형식으로 변환 (10/856/375.png → {z}/{y}/{x}.png)
|
||||
const convertToTileTemplate = (url: string): string => {
|
||||
// 이미 템플릿 형식이면 그대로 반환
|
||||
if (url.includes('{z}') && url.includes('{y}') && url.includes('{x}')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// 특정 타일 URL 패턴 감지: /숫자/숫자/숫자.png
|
||||
const tilePattern = /\/(\d+)\/(\d+)\/(\d+)\.(png|jpg|jpeg)$/i;
|
||||
const match = url.match(tilePattern);
|
||||
|
||||
if (match) {
|
||||
// /10/856/375.png → /{z}/{y}/{x}.png
|
||||
const convertedUrl = url.replace(tilePattern, '/{z}/{y}/{x}.$4');
|
||||
console.log('🔄 타일 URL 자동 변환:', url, '→', convertedUrl);
|
||||
return convertedUrl;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
// 설정 업데이트
|
||||
const updateConfig = useCallback((updates: Partial<ChartConfig>) => {
|
||||
// tileMapUrl이 업데이트되면 자동으로 템플릿 형식으로 변환
|
||||
if (updates.tileMapUrl) {
|
||||
updates.tileMapUrl = convertToTileTemplate(updates.tileMapUrl);
|
||||
}
|
||||
|
||||
const newConfig = { ...currentConfig, ...updates };
|
||||
setCurrentConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}, [currentConfig, onConfigChange]);
|
||||
|
||||
// 타일맵 소스 추가
|
||||
const addTileMapSource = () => {
|
||||
setTileMapSources([...tileMapSources, { id: `tilemap_${Date.now()}`, url: '' }]);
|
||||
};
|
||||
|
||||
// 타일맵 소스 제거
|
||||
const removeTileMapSource = (id: string) => {
|
||||
if (tileMapSources.length === 1) return; // 최소 1개는 유지
|
||||
setTileMapSources(tileMapSources.filter(s => s.id !== id));
|
||||
};
|
||||
|
||||
// 타일맵 소스 업데이트
|
||||
const updateTileMapSource = (id: string, url: string) => {
|
||||
setTileMapSources(tileMapSources.map(s => s.id === id ? { ...s, url } : s));
|
||||
// 첫 번째 타일맵 URL을 config에 저장
|
||||
const firstUrl = id === tileMapSources[0].id ? url : tileMapSources[0].url;
|
||||
updateConfig({ tileMapUrl: firstUrl });
|
||||
};
|
||||
|
||||
// 외부 커넥션에서 URL 가져오기
|
||||
const loadFromConnection = (sourceId: string, connectionId: string) => {
|
||||
const connection = connections.find(c => c.id?.toString() === connectionId);
|
||||
if (connection) {
|
||||
console.log('🔗 선택된 커넥션:', connection.connection_name, '→', connection.base_url);
|
||||
updateTileMapSource(sourceId, connection.base_url);
|
||||
}
|
||||
};
|
||||
|
||||
// 사용 가능한 컬럼 목록
|
||||
const availableColumns = queryResult?.columns || [];
|
||||
const sampleData = queryResult?.rows?.[0] || {};
|
||||
|
||||
// 기상특보 데이터인지 감지 (reg_ko, wrn 컬럼이 있으면 기상특보)
|
||||
const isWeatherAlertData = availableColumns.includes('reg_ko') && availableColumns.includes('wrn');
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 타일맵 URL 설정 (외부 커넥션 또는 직접 입력) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-gray-700">
|
||||
타일맵 소스 (지도 배경)
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</Label>
|
||||
|
||||
{/* 외부 커넥션 선택 */}
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const connectionId = e.target.value;
|
||||
if (connectionId) {
|
||||
const connection = connections.find(c => c.id?.toString() === connectionId);
|
||||
if (connection) {
|
||||
console.log('🗺️ 타일맵 커넥션 선택:', connection.connection_name, '→', connection.base_url);
|
||||
updateConfig({ tileMapUrl: connection.base_url });
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-md text-xs h-8 bg-white"
|
||||
>
|
||||
<option value="">저장된 커넥션 선택</option>
|
||||
{connections.map((conn) => (
|
||||
<option key={conn.id} value={conn.id?.toString()}>
|
||||
{conn.connection_name}
|
||||
{conn.description && ` (${conn.description})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* 타일맵 URL 직접 입력 */}
|
||||
<Input
|
||||
type="text"
|
||||
value={currentConfig.tileMapUrl || ''}
|
||||
onChange={(e) => updateConfig({ tileMapUrl: e.target.value })}
|
||||
placeholder="https://api.vworld.kr/req/wmts/1.0.0/{API_KEY}/Base/{z}/{y}/{x}.png"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
💡 {'{z}/{y}/{x}'}는 그대로 입력하세요 (지도 라이브러리가 자동 치환)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 타일맵 소스 목록 */}
|
||||
{/* <div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
타일맵 소스 (REST API)
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addTileMapSource}
|
||||
className="h-7 gap-1 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tileMapSources.map((source, index) => (
|
||||
<div key={source.id} className="space-y-2 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-medium text-gray-600">
|
||||
외부 커넥션 선택 (선택사항)
|
||||
</label>
|
||||
<select
|
||||
onChange={(e) => loadFromConnection(source.id, e.target.value)}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-md text-xs h-8 bg-white"
|
||||
>
|
||||
<option value="">직접 입력 또는 커넥션 선택</option>
|
||||
{connections.map((conn) => (
|
||||
<option key={conn.id} value={conn.id?.toString()}>
|
||||
{conn.connection_name}
|
||||
{conn.description && ` (${conn.description})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={source.url}
|
||||
onChange={(e) => updateTileMapSource(source.id, e.target.value)}
|
||||
placeholder="https://api.vworld.kr/req/wmts/1.0.0/{API_KEY}/Base/{z}/{y}/{x}.png"
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
{tileMapSources.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeTileMapSource(source.id)}
|
||||
className="h-8 w-8 text-gray-500 hover:text-red-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
💡 {'{z}/{y}/{x}'}는 그대로 입력하세요 (지도 라이브러리가 자동 치환)
|
||||
</p>
|
||||
</div> */}
|
||||
|
||||
{/* 지도 제목 */}
|
||||
{/* <div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">지도 제목</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={currentConfig.title || ''}
|
||||
onChange={(e) => updateConfig({ title: e.target.value })}
|
||||
placeholder="위치 지도"
|
||||
className="h-10 text-xs"
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
{/* 구분선 */}
|
||||
{/* <div className="border-t pt-3">
|
||||
<h5 className="text-xs font-semibold text-gray-700 mb-2">📍 마커 데이터 설정 (선택사항)</h5>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
데이터 소스 탭에서 API 또는 데이터베이스를 연결하면 마커를 표시할 수 있습니다.
|
||||
</p>
|
||||
</div> */}
|
||||
|
||||
{/* 쿼리 결과가 없을 때 */}
|
||||
{/* {!queryResult && (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="text-yellow-800 text-xs">
|
||||
💡 데이터 소스를 연결하고 쿼리를 실행하면 마커 설정이 가능합니다.
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
{/* 데이터 필드 매핑 */}
|
||||
{queryResult && !isWeatherAlertData && (
|
||||
<>
|
||||
{/* 위도 컬럼 설정 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
위도 컬럼 (Latitude)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.latitudeColumn || ''}
|
||||
onChange={(e) => updateConfig({ latitudeColumn: e.target.value })}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col} {sampleData[col] && `(예: ${sampleData[col]})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 경도 컬럼 설정 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
경도 컬럼 (Longitude)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.longitudeColumn || ''}
|
||||
onChange={(e) => updateConfig({ longitudeColumn: e.target.value })}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col} {sampleData[col] && `(예: ${sampleData[col]})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 라벨 컬럼 (선택사항) */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
라벨 컬럼 (마커 표시명)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.labelColumn || ''}
|
||||
onChange={(e) => updateConfig({ labelColumn: e.target.value })}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
>
|
||||
<option value="">선택하세요 (선택사항)</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 상태 컬럼 (선택사항) */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
상태 컬럼 (마커 색상)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.statusColumn || ''}
|
||||
onChange={(e) => updateConfig({ statusColumn: e.target.value })}
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
>
|
||||
<option value="">선택하세요 (선택사항)</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 기상특보 데이터 안내 */}
|
||||
{queryResult && isWeatherAlertData && (
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="text-blue-800 text-xs">
|
||||
🚨 기상특보 데이터가 감지되었습니다. 지역명(reg_ko)을 기준으로 자동으로 영역이 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queryResult && (
|
||||
<>
|
||||
|
||||
{/* 날씨 정보 표시 옵션 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentConfig.showWeather || false}
|
||||
onChange={(e) => updateConfig({ showWeather: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<span>날씨 정보 표시</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 ml-6">
|
||||
마커 팝업에 해당 위치의 날씨 정보를 함께 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentConfig.showWeatherAlerts || false}
|
||||
onChange={(e) => updateConfig({ showWeatherAlerts: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<span>기상특보 영역 표시</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 ml-6">
|
||||
현재 발효 중인 기상특보(주의보/경보)를 지도에 색상 영역으로 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 설정 미리보기 */}
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-xs font-medium text-gray-700 mb-2">📋 설정 미리보기</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div><strong>타일맵:</strong> {currentConfig.tileMapUrl ? '✅ 설정됨' : '❌ 미설정'}</div>
|
||||
<div><strong>위도:</strong> {currentConfig.latitudeColumn || '미설정'}</div>
|
||||
<div><strong>경도:</strong> {currentConfig.longitudeColumn || '미설정'}</div>
|
||||
<div><strong>라벨:</strong> {currentConfig.labelColumn || '없음'}</div>
|
||||
<div><strong>상태:</strong> {currentConfig.statusColumn || '없음'}</div>
|
||||
<div><strong>날씨 표시:</strong> {currentConfig.showWeather ? '활성화' : '비활성화'}</div>
|
||||
<div><strong>기상특보 표시:</strong> {currentConfig.showWeatherAlerts ? '활성화' : '비활성화'}</div>
|
||||
<div><strong>데이터 개수:</strong> {queryResult.rows.length}개</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 필수 필드 확인 */}
|
||||
{/* {!currentConfig.tileMapUrl && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="text-red-800 text-xs">
|
||||
⚠️ 타일맵 URL을 입력해야 지도가 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,327 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ChartConfig, ChartDataSource } from "./types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
interface MultiChartConfigPanelProps {
|
||||
config: ChartConfig;
|
||||
dataSources: ChartDataSource[];
|
||||
testResults: Map<string, { columns: string[]; rows: Record<string, unknown>[] }>; // 각 데이터 소스의 테스트 결과
|
||||
onConfigChange: (config: ChartConfig) => void;
|
||||
}
|
||||
|
||||
export function MultiChartConfigPanel({
|
||||
config,
|
||||
dataSources,
|
||||
testResults,
|
||||
onConfigChange,
|
||||
}: MultiChartConfigPanelProps) {
|
||||
const [chartType, setChartType] = useState<string>(config.chartType || "line");
|
||||
const [mergeMode, setMergeMode] = useState<boolean>(config.mergeMode || false);
|
||||
const [dataSourceConfigs, setDataSourceConfigs] = useState<
|
||||
Array<{
|
||||
dataSourceId: string;
|
||||
xAxis: string;
|
||||
yAxis: string[];
|
||||
label?: string;
|
||||
}>
|
||||
>(config.dataSourceConfigs || []);
|
||||
|
||||
// 데이터 소스별 사용 가능한 컬럼
|
||||
const getColumnsForDataSource = (dataSourceId: string): string[] => {
|
||||
const result = testResults.get(dataSourceId);
|
||||
return result?.columns || [];
|
||||
};
|
||||
|
||||
// 데이터 소스별 숫자 컬럼
|
||||
const getNumericColumnsForDataSource = (dataSourceId: string): string[] => {
|
||||
const result = testResults.get(dataSourceId);
|
||||
if (!result || !result.rows || result.rows.length === 0) return [];
|
||||
|
||||
const firstRow = result.rows[0];
|
||||
return Object.keys(firstRow).filter((key) => {
|
||||
const value = firstRow[key];
|
||||
return typeof value === "number" || !isNaN(Number(value));
|
||||
});
|
||||
};
|
||||
|
||||
// 차트 타입 변경
|
||||
const handleChartTypeChange = (type: string) => {
|
||||
setChartType(type);
|
||||
onConfigChange({
|
||||
...config,
|
||||
chartType: type,
|
||||
mergeMode,
|
||||
dataSourceConfigs,
|
||||
});
|
||||
};
|
||||
|
||||
// 병합 모드 변경
|
||||
const handleMergeModeChange = (checked: boolean) => {
|
||||
setMergeMode(checked);
|
||||
onConfigChange({
|
||||
...config,
|
||||
chartType,
|
||||
mergeMode: checked,
|
||||
dataSourceConfigs,
|
||||
});
|
||||
};
|
||||
|
||||
// 데이터 소스 설정 추가
|
||||
const handleAddDataSourceConfig = (dataSourceId: string) => {
|
||||
const columns = getColumnsForDataSource(dataSourceId);
|
||||
const numericColumns = getNumericColumnsForDataSource(dataSourceId);
|
||||
|
||||
const newConfig = {
|
||||
dataSourceId,
|
||||
xAxis: columns[0] || "",
|
||||
yAxis: numericColumns.length > 0 ? [numericColumns[0]] : [],
|
||||
label: dataSources.find((ds) => ds.id === dataSourceId)?.name || "",
|
||||
};
|
||||
|
||||
const updated = [...dataSourceConfigs, newConfig];
|
||||
setDataSourceConfigs(updated);
|
||||
onConfigChange({
|
||||
...config,
|
||||
chartType,
|
||||
mergeMode,
|
||||
dataSourceConfigs: updated,
|
||||
});
|
||||
};
|
||||
|
||||
// 데이터 소스 설정 삭제
|
||||
const handleRemoveDataSourceConfig = (dataSourceId: string) => {
|
||||
const updated = dataSourceConfigs.filter((c) => c.dataSourceId !== dataSourceId);
|
||||
setDataSourceConfigs(updated);
|
||||
onConfigChange({
|
||||
...config,
|
||||
chartType,
|
||||
mergeMode,
|
||||
dataSourceConfigs: updated,
|
||||
});
|
||||
};
|
||||
|
||||
// X축 변경
|
||||
const handleXAxisChange = (dataSourceId: string, xAxis: string) => {
|
||||
const updated = dataSourceConfigs.map((c) => (c.dataSourceId === dataSourceId ? { ...c, xAxis } : c));
|
||||
setDataSourceConfigs(updated);
|
||||
onConfigChange({
|
||||
...config,
|
||||
chartType,
|
||||
mergeMode,
|
||||
dataSourceConfigs: updated,
|
||||
});
|
||||
};
|
||||
|
||||
// Y축 변경
|
||||
const handleYAxisChange = (dataSourceId: string, yAxis: string) => {
|
||||
const updated = dataSourceConfigs.map((c) => (c.dataSourceId === dataSourceId ? { ...c, yAxis: [yAxis] } : c));
|
||||
setDataSourceConfigs(updated);
|
||||
onConfigChange({
|
||||
...config,
|
||||
chartType,
|
||||
mergeMode,
|
||||
dataSourceConfigs: updated,
|
||||
});
|
||||
};
|
||||
|
||||
// 🆕 개별 차트 타입 변경
|
||||
const handleIndividualChartTypeChange = (dataSourceId: string, chartType: "bar" | "line" | "area") => {
|
||||
const updated = dataSourceConfigs.map((c) => (c.dataSourceId === dataSourceId ? { ...c, chartType } : c));
|
||||
setDataSourceConfigs(updated);
|
||||
onConfigChange({
|
||||
...config,
|
||||
chartType: "mixed", // 혼합 모드로 설정
|
||||
mergeMode,
|
||||
dataSourceConfigs: updated,
|
||||
});
|
||||
};
|
||||
|
||||
// 설정되지 않은 데이터 소스 (테스트 완료된 것만)
|
||||
const availableDataSources = dataSources.filter(
|
||||
(ds) => testResults.has(ds.id!) && !dataSourceConfigs.some((c) => c.dataSourceId === ds.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 차트 타입 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">차트 타입</Label>
|
||||
<Select value={chartType} onValueChange={handleChartTypeChange}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="차트 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="line">라인 차트</SelectItem>
|
||||
<SelectItem value="bar">바 차트</SelectItem>
|
||||
<SelectItem value="area">영역 차트</SelectItem>
|
||||
<SelectItem value="pie">파이 차트</SelectItem>
|
||||
<SelectItem value="donut">도넛 차트</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 데이터 병합 모드 */}
|
||||
{dataSourceConfigs.length > 1 && (
|
||||
<div className="bg-muted/50 flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-xs font-medium">데이터 병합 모드</Label>
|
||||
<p className="text-muted-foreground text-[10px]">여러 데이터 소스를 하나의 라인/바로 합쳐서 표시</p>
|
||||
</div>
|
||||
<Switch checked={mergeMode} onCheckedChange={handleMergeModeChange} aria-label="데이터 병합 모드" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 소스별 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">데이터 소스별 축 설정</Label>
|
||||
{availableDataSources.length > 0 && (
|
||||
<Select onValueChange={handleAddDataSourceConfig}>
|
||||
<SelectTrigger className="h-7 w-32 text-xs">
|
||||
<SelectValue placeholder="추가" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableDataSources.map((ds) => (
|
||||
<SelectItem key={ds.id} value={ds.id!} className="text-xs">
|
||||
{ds.name || ds.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dataSourceConfigs.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-4 text-center">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
데이터 소스를 추가하고 API 테스트를 실행한 후<br />위 드롭다운에서 차트에 표시할 데이터를 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
dataSourceConfigs.map((dsConfig) => {
|
||||
const dataSource = dataSources.find((ds) => ds.id === dsConfig.dataSourceId);
|
||||
const columns = getColumnsForDataSource(dsConfig.dataSourceId);
|
||||
const numericColumns = getNumericColumnsForDataSource(dsConfig.dataSourceId);
|
||||
|
||||
return (
|
||||
<div key={dsConfig.dataSourceId} className="bg-muted/50 space-y-3 rounded-lg border p-3">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-xs font-semibold">{dataSource?.name || dsConfig.dataSourceId}</h5>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveDataSourceConfig(dsConfig.dataSourceId)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* X축 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">X축 (카테고리/시간)</Label>
|
||||
<Select
|
||||
value={dsConfig.xAxis}
|
||||
onValueChange={(value) => handleXAxisChange(dsConfig.dataSourceId, value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="X축 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Y축 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Y축 (값)</Label>
|
||||
<Select
|
||||
value={dsConfig.yAxis[0] || ""}
|
||||
onValueChange={(value) => handleYAxisChange(dsConfig.dataSourceId, value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Y축 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{numericColumns.map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 🆕 개별 차트 타입 (병합 모드가 아닐 때만) */}
|
||||
{!mergeMode && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">차트 타입</Label>
|
||||
<Select
|
||||
value={dsConfig.chartType || "line"}
|
||||
onValueChange={(value) =>
|
||||
handleIndividualChartTypeChange(dsConfig.dataSourceId, value as "bar" | "line" | "area")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="차트 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bar" className="text-xs">
|
||||
📊 바 차트
|
||||
</SelectItem>
|
||||
<SelectItem value="line" className="text-xs">
|
||||
📈 라인 차트
|
||||
</SelectItem>
|
||||
<SelectItem value="area" className="text-xs">
|
||||
📉 영역 차트
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
{dataSourceConfigs.length > 0 && (
|
||||
<div className="rounded-lg bg-blue-50 p-3">
|
||||
<p className="text-xs text-blue-900">
|
||||
{mergeMode ? (
|
||||
<>
|
||||
🔗 {dataSourceConfigs.length}개의 데이터 소스가 하나의 라인/바로 병합되어 표시됩니다.
|
||||
<br />
|
||||
<span className="text-[10px]">
|
||||
⚠️ 중요: 첫 번째 데이터 소스의 X축/Y축 컬럼명이 기준이 됩니다.
|
||||
<br />
|
||||
다른 데이터 소스에 동일한 컬럼명이 없으면 해당 데이터는 표시되지 않습니다.
|
||||
<br />
|
||||
💡 컬럼명이 다르면 "컬럼 매핑" 기능을 사용하여 통일하세요.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
💡 {dataSourceConfigs.length}개의 데이터 소스가 하나의 차트에 표시됩니다.
|
||||
<br />각 데이터 소스마다 다른 차트 타입(바/라인/영역)을 선택할 수 있습니다.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,15 @@ import { Plus, X, Play, AlertCircle } from "lucide-react";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
|
||||
|
||||
// 개별 API 소스 인터페이스
|
||||
interface ApiSource {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
headers: KeyValuePair[];
|
||||
queryParams: KeyValuePair[];
|
||||
jsonPath?: string;
|
||||
}
|
||||
|
||||
interface ApiConfigProps {
|
||||
dataSource: ChartDataSource;
|
||||
onChange: (updates: Partial<ChartDataSource>) => void;
|
||||
|
|
@ -52,8 +61,15 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
console.log("불러온 커넥션:", connection);
|
||||
|
||||
// 커넥션 설정을 API 설정에 자동 적용
|
||||
// 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: connection.base_url,
|
||||
endpoint: fullEndpoint,
|
||||
};
|
||||
|
||||
const headers: KeyValuePair[] = [];
|
||||
|
|
@ -119,6 +135,8 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
}
|
||||
}
|
||||
|
||||
updates.type = "api"; // ⭐ 중요: type을 api로 명시
|
||||
updates.method = "GET"; // 기본 메서드
|
||||
updates.headers = headers;
|
||||
updates.queryParams = queryParams;
|
||||
console.log("최종 업데이트:", updates);
|
||||
|
|
@ -201,6 +219,17 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
return;
|
||||
}
|
||||
|
||||
// 타일맵 URL 감지 (이미지 파일이므로 테스트 불가)
|
||||
const isTilemapUrl =
|
||||
dataSource.endpoint.includes('{z}') &&
|
||||
dataSource.endpoint.includes('{y}') &&
|
||||
dataSource.endpoint.includes('{x}');
|
||||
|
||||
if (isTilemapUrl) {
|
||||
setTestError("타일맵 URL은 테스트할 수 없습니다. 지도 위젯에서 직접 확인하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
setTestError(null);
|
||||
setTestResult(null);
|
||||
|
|
@ -248,7 +277,36 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
throw new Error(apiResponse.message || "외부 API 호출 실패");
|
||||
}
|
||||
|
||||
const apiData = apiResponse.data;
|
||||
let apiData = apiResponse.data;
|
||||
|
||||
// 텍스트 응답인 경우 파싱
|
||||
if (apiData && typeof apiData === "object" && "text" in apiData && typeof apiData.text === "string") {
|
||||
const textData = apiData.text;
|
||||
|
||||
// CSV 형식 파싱 (기상청 API)
|
||||
if (textData.includes("#START7777") || textData.includes(",")) {
|
||||
const lines = textData.split("\n").filter((line) => line.trim() && !line.startsWith("#"));
|
||||
const parsedRows = lines.map((line) => {
|
||||
const values = line.split(",").map((v) => v.trim());
|
||||
return {
|
||||
reg_up: values[0] || "",
|
||||
reg_up_ko: values[1] || "",
|
||||
reg_id: values[2] || "",
|
||||
reg_ko: values[3] || "",
|
||||
tm_fc: values[4] || "",
|
||||
tm_ef: values[5] || "",
|
||||
wrn: values[6] || "",
|
||||
lvl: values[7] || "",
|
||||
cmd: values[8] || "",
|
||||
ed_tm: values[9] || "",
|
||||
};
|
||||
});
|
||||
apiData = parsedRows;
|
||||
} else {
|
||||
// 일반 텍스트는 그대로 반환
|
||||
apiData = [{ text: textData }];
|
||||
}
|
||||
}
|
||||
|
||||
// JSON Path 처리
|
||||
let data = apiData;
|
||||
|
|
@ -313,41 +371,47 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 외부 커넥션 선택 */}
|
||||
{apiConnections.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-gray-700">외부 커넥션 (선택)</Label>
|
||||
<Select value={selectedConnectionId} onValueChange={handleConnectionSelect}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="저장된 커넥션 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
<SelectItem value="manual" className="text-xs">
|
||||
직접 입력
|
||||
</SelectItem>
|
||||
{apiConnections.map((conn) => (
|
||||
{/* 외부 커넥션 선택 - 항상 표시 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-gray-700">외부 커넥션 (선택)</Label>
|
||||
<Select value={selectedConnectionId} onValueChange={handleConnectionSelect}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="저장된 커넥션 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]" position="popper" sideOffset={4}>
|
||||
<SelectItem value="manual" className="text-xs">
|
||||
직접 입력
|
||||
</SelectItem>
|
||||
{apiConnections.length > 0 ? (
|
||||
apiConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={String(conn.id)} className="text-xs">
|
||||
{conn.connection_name}
|
||||
{conn.description && <span className="ml-1.5 text-[10px] text-gray-500">({conn.description})</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-gray-500">저장한 REST API 설정을 불러올 수 있습니다</p>
|
||||
</div>
|
||||
)}
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="no-connections" disabled className="text-xs text-gray-500">
|
||||
등록된 커넥션이 없습니다
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-gray-500">저장한 REST API 설정을 불러올 수 있습니다</p>
|
||||
</div>
|
||||
|
||||
{/* API URL */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-gray-700">API URL *</Label>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://api.example.com/data"
|
||||
placeholder="https://api.example.com/data 또는 /api/typ01/url/wrn_now_data.php"
|
||||
value={dataSource.endpoint || ""}
|
||||
onChange={(e) => onChange({ endpoint: e.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500">GET 요청을 보낼 API 엔드포인트</p>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
전체 URL 또는 base_url 이후 경로를 입력하세요 (외부 커넥션 선택 시 base_url 자동 입력)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 쿼리 파라미터 */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,896 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ChartDataSource, KeyValuePair } from "@/components/admin/dashboard/types";
|
||||
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 { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
|
||||
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
|
||||
|
||||
interface MultiApiConfigProps {
|
||||
dataSource: ChartDataSource;
|
||||
onChange: (updates: Partial<ChartDataSource>) => void;
|
||||
onTestResult?: (data: any) => void; // 테스트 결과 데이터 전달
|
||||
}
|
||||
|
||||
export default function MultiApiConfig({ dataSource, onChange, onTestResult }: MultiApiConfigProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [apiConnections, setApiConnections] = useState<ExternalApiConnection[]>([]);
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string>("");
|
||||
const [availableColumns, setAvailableColumns] = useState<string[]>([]); // API 테스트 후 발견된 컬럼 목록
|
||||
const [columnTypes, setColumnTypes] = useState<Record<string, string>>({}); // 컬럼 타입 정보
|
||||
const [sampleData, setSampleData] = useState<any[]>([]); // 샘플 데이터 (최대 3개)
|
||||
const [columnSearchTerm, setColumnSearchTerm] = useState(""); // 컬럼 검색어
|
||||
|
||||
console.log("🔧 MultiApiConfig - dataSource:", dataSource);
|
||||
|
||||
// 외부 API 커넥션 목록 로드
|
||||
useEffect(() => {
|
||||
const loadApiConnections = async () => {
|
||||
const connections = await ExternalDbConnectionAPI.getApiConnections({ is_active: "Y" });
|
||||
setApiConnections(connections);
|
||||
};
|
||||
loadApiConnections();
|
||||
}, []);
|
||||
|
||||
// 외부 커넥션 선택 핸들러
|
||||
const handleConnectionSelect = async (connectionId: string) => {
|
||||
setSelectedConnectionId(connectionId);
|
||||
|
||||
if (!connectionId || connectionId === "manual") {
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = await ExternalDbConnectionAPI.getApiConnectionById(Number(connectionId));
|
||||
if (!connection) {
|
||||
console.error("커넥션을 찾을 수 없습니다:", connectionId);
|
||||
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,
|
||||
};
|
||||
|
||||
const headers: KeyValuePair[] = [];
|
||||
const queryParams: KeyValuePair[] = [];
|
||||
|
||||
// 기본 헤더가 있으면 적용
|
||||
if (connection.default_headers && Object.keys(connection.default_headers).length > 0) {
|
||||
Object.entries(connection.default_headers).forEach(([key, value]) => {
|
||||
headers.push({
|
||||
id: `header_${Date.now()}_${Math.random()}`,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
});
|
||||
console.log("기본 헤더 적용:", headers);
|
||||
}
|
||||
|
||||
// 인증 설정이 있으면 헤더 또는 쿼리 파라미터에 추가
|
||||
if (connection.auth_type && connection.auth_type !== "none" && connection.auth_config) {
|
||||
const authConfig = connection.auth_config;
|
||||
|
||||
switch (connection.auth_type) {
|
||||
case "api-key":
|
||||
if (authConfig.keyLocation === "header" && authConfig.keyName && authConfig.keyValue) {
|
||||
headers.push({
|
||||
id: `auth_header_${Date.now()}`,
|
||||
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;
|
||||
queryParams.push({
|
||||
id: `auth_query_${Date.now()}`,
|
||||
key: actualKeyName,
|
||||
value: authConfig.keyValue,
|
||||
});
|
||||
console.log("API Key 쿼리 파라미터 추가:", actualKeyName, "(원본:", authConfig.keyName, ")");
|
||||
}
|
||||
break;
|
||||
|
||||
case "bearer":
|
||||
if (authConfig.token) {
|
||||
headers.push({
|
||||
id: `auth_bearer_${Date.now()}`,
|
||||
key: "Authorization",
|
||||
value: `Bearer ${authConfig.token}`,
|
||||
});
|
||||
console.log("Bearer Token 헤더 추가");
|
||||
}
|
||||
break;
|
||||
|
||||
case "basic":
|
||||
if (authConfig.username && authConfig.password) {
|
||||
const credentials = btoa(`${authConfig.username}:${authConfig.password}`);
|
||||
headers.push({
|
||||
id: `auth_basic_${Date.now()}`,
|
||||
key: "Authorization",
|
||||
value: `Basic ${credentials}`,
|
||||
});
|
||||
console.log("Basic Auth 헤더 추가");
|
||||
}
|
||||
break;
|
||||
|
||||
case "oauth2":
|
||||
if (authConfig.accessToken) {
|
||||
headers.push({
|
||||
id: `auth_oauth_${Date.now()}`,
|
||||
key: "Authorization",
|
||||
value: `Bearer ${authConfig.accessToken}`,
|
||||
});
|
||||
console.log("OAuth2 Token 헤더 추가");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 헤더와 쿼리 파라미터 적용
|
||||
if (headers.length > 0) {
|
||||
updates.headers = headers;
|
||||
}
|
||||
if (queryParams.length > 0) {
|
||||
updates.queryParams = queryParams;
|
||||
}
|
||||
|
||||
console.log("최종 업데이트:", updates);
|
||||
onChange(updates);
|
||||
};
|
||||
|
||||
// 헤더 추가
|
||||
const handleAddHeader = () => {
|
||||
const headers = dataSource.headers || [];
|
||||
onChange({
|
||||
headers: [...headers, { id: Date.now().toString(), key: "", value: "" }],
|
||||
});
|
||||
};
|
||||
|
||||
// 헤더 삭제
|
||||
const handleDeleteHeader = (id: string) => {
|
||||
const headers = (dataSource.headers || []).filter((h) => h.id !== id);
|
||||
onChange({ headers });
|
||||
};
|
||||
|
||||
// 헤더 업데이트
|
||||
const handleUpdateHeader = (id: string, field: "key" | "value", value: string) => {
|
||||
const headers = (dataSource.headers || []).map((h) =>
|
||||
h.id === id ? { ...h, [field]: value } : h
|
||||
);
|
||||
onChange({ headers });
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 추가
|
||||
const handleAddQueryParam = () => {
|
||||
const queryParams = dataSource.queryParams || [];
|
||||
onChange({
|
||||
queryParams: [...queryParams, { id: Date.now().toString(), key: "", value: "" }],
|
||||
});
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 삭제
|
||||
const handleDeleteQueryParam = (id: string) => {
|
||||
const queryParams = (dataSource.queryParams || []).filter((q) => q.id !== id);
|
||||
onChange({ queryParams });
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 업데이트
|
||||
const handleUpdateQueryParam = (id: string, field: "key" | "value", value: string) => {
|
||||
const queryParams = (dataSource.queryParams || []).map((q) =>
|
||||
q.id === id ? { ...q, [field]: value } : q
|
||||
);
|
||||
onChange({ queryParams });
|
||||
};
|
||||
|
||||
// API 테스트
|
||||
const handleTestApi = async () => {
|
||||
if (!dataSource.endpoint) {
|
||||
setTestResult({ success: false, message: "API URL을 입력해주세요" });
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const queryParams: Record<string, string> = {};
|
||||
(dataSource.queryParams || []).forEach((param) => {
|
||||
if (param.key && param.value) {
|
||||
queryParams[param.key] = param.value;
|
||||
}
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
(dataSource.headers || []).forEach((header) => {
|
||||
if (header.key && header.value) {
|
||||
headers[header.key] = header.value;
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch("/api/dashboards/fetch-external-api", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
url: dataSource.endpoint,
|
||||
method: dataSource.method || "GET",
|
||||
headers,
|
||||
queryParams,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
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 &&
|
||||
!trimmed.startsWith('#') &&
|
||||
!trimmed.startsWith('=') &&
|
||||
!trimmed.startsWith('---');
|
||||
});
|
||||
|
||||
console.log(`📝 유효한 라인: ${lines.length}개`);
|
||||
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
const result: any[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const values = line.split(',').map(v => v.trim().replace(/,=$/g, ''));
|
||||
|
||||
// 기상특보 형식: 지역코드, 지역명, 하위코드, 하위지역명, 발표시각, 특보종류, 등급, 발표상태, 설명
|
||||
if (values.length >= 4) {
|
||||
const obj: any = {
|
||||
code: values[0] || '', // 지역 코드 (예: L1070000)
|
||||
region: values[1] || '', // 지역명 (예: 경상북도)
|
||||
subCode: values[2] || '', // 하위 코드 (예: L1071600)
|
||||
subRegion: values[3] || '', // 하위 지역명 (예: 영주시)
|
||||
tmFc: values[4] || '', // 발표시각
|
||||
type: values[5] || '', // 특보종류 (강풍, 호우 등)
|
||||
level: values[6] || '', // 등급 (주의, 경보)
|
||||
status: values[7] || '', // 발표상태
|
||||
description: values.slice(8).join(', ').trim() || '',
|
||||
name: values[3] || values[1] || values[0], // 하위 지역명 우선
|
||||
};
|
||||
|
||||
result.push(obj);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("📊 파싱 결과:", result.length, "개");
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("❌ 텍스트 파싱 오류:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// JSON Path로 데이터 추출
|
||||
let data = result.data;
|
||||
|
||||
// 텍스트 데이터 체크 (기상청 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) {
|
||||
const pathParts = dataSource.jsonPath.split(".");
|
||||
for (const part of pathParts) {
|
||||
data = data?.[part];
|
||||
}
|
||||
}
|
||||
|
||||
const rows = Array.isArray(data) ? data : [data];
|
||||
|
||||
// 컬럼 목록 및 타입 추출
|
||||
if (rows.length > 0) {
|
||||
const columns = Object.keys(rows[0]);
|
||||
setAvailableColumns(columns);
|
||||
|
||||
// 컬럼 타입 분석 (첫 번째 행 기준)
|
||||
const types: Record<string, string> = {};
|
||||
columns.forEach(col => {
|
||||
const value = rows[0][col];
|
||||
if (value === null || value === undefined) {
|
||||
types[col] = "unknown";
|
||||
} else if (typeof value === "number") {
|
||||
types[col] = "number";
|
||||
} else if (typeof value === "boolean") {
|
||||
types[col] = "boolean";
|
||||
} else if (typeof value === "string") {
|
||||
// 날짜 형식 체크
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
|
||||
types[col] = "date";
|
||||
} else {
|
||||
types[col] = "string";
|
||||
}
|
||||
} else {
|
||||
types[col] = "object";
|
||||
}
|
||||
});
|
||||
setColumnTypes(types);
|
||||
|
||||
// 샘플 데이터 저장 (최대 3개)
|
||||
setSampleData(rows.slice(0, 3));
|
||||
|
||||
console.log("📊 발견된 컬럼:", columns);
|
||||
console.log("📊 컬럼 타입:", types);
|
||||
}
|
||||
|
||||
// 위도/경도 또는 coordinates 필드 또는 지역 코드 체크
|
||||
const hasLocationData = rows.some((row) => {
|
||||
const hasLatLng = (row.lat || row.latitude) && (row.lng || row.longitude);
|
||||
const hasCoordinates = row.coordinates && Array.isArray(row.coordinates);
|
||||
const hasRegionCode = row.code || row.areaCode || row.regionCode;
|
||||
return hasLatLng || hasCoordinates || hasRegionCode;
|
||||
});
|
||||
|
||||
if (hasLocationData) {
|
||||
const markerCount = rows.filter(r =>
|
||||
((r.lat || r.latitude) && (r.lng || r.longitude)) ||
|
||||
r.code || r.areaCode || r.regionCode
|
||||
).length;
|
||||
const polygonCount = rows.filter(r => r.coordinates && Array.isArray(r.coordinates)).length;
|
||||
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `API 연결 성공 - 마커 ${markerCount}개, 영역 ${polygonCount}개 발견`
|
||||
});
|
||||
|
||||
// 부모에게 테스트 결과 전달 (지도 미리보기용)
|
||||
if (onTestResult) {
|
||||
onTestResult(rows);
|
||||
}
|
||||
} else {
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `API 연결 성공 - ${rows.length}개 데이터 (위치 정보 없음)`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setTestResult({ success: false, message: result.message || "API 호출 실패" });
|
||||
}
|
||||
} catch (error: any) {
|
||||
setTestResult({ success: false, message: error.message || "네트워크 오류" });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<h5 className="text-sm font-semibold">REST API 설정</h5>
|
||||
|
||||
{/* 외부 연결 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`connection-${dataSource.id}`} className="text-xs">
|
||||
외부 연결 선택
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedConnectionId}
|
||||
onValueChange={handleConnectionSelect}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="외부 연결 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="manual" className="text-xs">
|
||||
직접 입력
|
||||
</SelectItem>
|
||||
{apiConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id?.toString() || ""} className="text-xs">
|
||||
{conn.connection_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
외부 연결을 선택하면 API URL이 자동으로 입력됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API URL (직접 입력 또는 수정) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`endpoint-${dataSource.id}`} className="text-xs">
|
||||
API URL *
|
||||
</Label>
|
||||
<Input
|
||||
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"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
외부 연결을 선택하거나 직접 입력할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* JSON Path */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`jsonPath-\${dataSource.id}`} className="text-xs">
|
||||
JSON Path (선택)
|
||||
</Label>
|
||||
<Input
|
||||
id={`jsonPath-\${dataSource.id}`}
|
||||
value={dataSource.jsonPath || ""}
|
||||
onChange={(e) => onChange({ jsonPath: e.target.value })}
|
||||
placeholder="예: data.results"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
응답 JSON에서 데이터를 추출할 경로
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 쿼리 파라미터 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">쿼리 파라미터</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAddQueryParam}
|
||||
className="h-6 gap-1 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
{(dataSource.queryParams || []).map((param) => (
|
||||
<div key={param.id} className="flex gap-2">
|
||||
<Input
|
||||
value={param.key}
|
||||
onChange={(e) => handleUpdateQueryParam(param.id, "key", e.target.value)}
|
||||
placeholder="키"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={param.value}
|
||||
onChange={(e) => handleUpdateQueryParam(param.id, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteQueryParam(param.id)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">헤더</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAddHeader}
|
||||
className="h-6 gap-1 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
{(dataSource.headers || []).map((header) => (
|
||||
<div key={header.id} className="flex gap-2">
|
||||
<Input
|
||||
value={header.key}
|
||||
onChange={(e) => handleUpdateHeader(header.id, "key", e.target.value)}
|
||||
placeholder="키"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={header.value}
|
||||
onChange={(e) => handleUpdateHeader(header.id, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteHeader(header.id)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 자동 새로고침 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`refresh-${dataSource.id}`} className="text-xs">
|
||||
자동 새로고침 간격
|
||||
</Label>
|
||||
<Select
|
||||
value={String(dataSource.refreshInterval || 0)}
|
||||
onValueChange={(value) => onChange({ refreshInterval: Number(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="새로고침 안 함" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">새로고침 안 함</SelectItem>
|
||||
<SelectItem value="10">10초마다</SelectItem>
|
||||
<SelectItem value="30">30초마다</SelectItem>
|
||||
<SelectItem value="60">1분마다</SelectItem>
|
||||
<SelectItem value="300">5분마다</SelectItem>
|
||||
<SelectItem value="600">10분마다</SelectItem>
|
||||
<SelectItem value="1800">30분마다</SelectItem>
|
||||
<SelectItem value="3600">1시간마다</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
설정한 간격마다 자동으로 데이터를 다시 불러옵니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 지도 색상 설정 (MapTestWidgetV2 전용) */}
|
||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
||||
<h5 className="text-xs font-semibold">🎨 지도 색상 선택</h5>
|
||||
|
||||
{/* 색상 팔레트 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">색상</Label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[
|
||||
{ name: "파랑", marker: "#3b82f6", polygon: "#3b82f6" },
|
||||
{ name: "빨강", marker: "#ef4444", polygon: "#ef4444" },
|
||||
{ name: "초록", marker: "#10b981", polygon: "#10b981" },
|
||||
{ name: "노랑", marker: "#f59e0b", polygon: "#f59e0b" },
|
||||
{ name: "보라", marker: "#8b5cf6", polygon: "#8b5cf6" },
|
||||
{ name: "주황", marker: "#f97316", polygon: "#f97316" },
|
||||
{ name: "청록", marker: "#06b6d4", polygon: "#06b6d4" },
|
||||
{ name: "분홍", marker: "#ec4899", polygon: "#ec4899" },
|
||||
].map((color) => {
|
||||
const isSelected = dataSource.markerColor === color.marker;
|
||||
return (
|
||||
<button
|
||||
key={color.name}
|
||||
type="button"
|
||||
onClick={() => onChange({
|
||||
markerColor: color.marker,
|
||||
polygonColor: color.polygon,
|
||||
polygonOpacity: 0.5,
|
||||
})}
|
||||
className={`flex h-16 flex-col items-center justify-center gap-1 rounded-md border-2 transition-all hover:scale-105 ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: "border-border bg-background hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="h-6 w-6 rounded-full border-2 border-white shadow-sm"
|
||||
style={{ backgroundColor: color.marker }}
|
||||
/>
|
||||
<span className="text-[10px] font-medium">{color.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
선택한 색상이 마커와 폴리곤에 모두 적용됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테스트 버튼 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTestApi}
|
||||
disabled={testing || !dataSource.endpoint}
|
||||
className="h-8 w-full gap-2 text-xs"
|
||||
>
|
||||
{testing ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
테스트 중...
|
||||
</>
|
||||
) : (
|
||||
"API 테스트"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{testResult && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-md p-2 text-xs ${
|
||||
testResult.success
|
||||
? "bg-green-50 text-green-700"
|
||||
: "bg-red-50 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{testResult.success ? (
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<XCircle className="h-3 w-3" />
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 컬럼 선택 (메트릭 위젯용) - 개선된 UI */}
|
||||
{availableColumns.length > 0 && (
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm font-semibold">메트릭 컬럼 선택</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{dataSource.selectedColumns && dataSource.selectedColumns.length > 0
|
||||
? `${dataSource.selectedColumns.length}개 컬럼 선택됨`
|
||||
: "모든 컬럼 표시"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange({ selectedColumns: availableColumns })}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
전체
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange({ selectedColumns: [] })}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
해제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
{availableColumns.length > 5 && (
|
||||
<Input
|
||||
placeholder="컬럼 검색..."
|
||||
value={columnSearchTerm}
|
||||
onChange={(e) => setColumnSearchTerm(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 컬럼 카드 그리드 */}
|
||||
<div className="grid grid-cols-1 gap-2 max-h-80 overflow-y-auto">
|
||||
{availableColumns
|
||||
.filter(col =>
|
||||
!columnSearchTerm ||
|
||||
col.toLowerCase().includes(columnSearchTerm.toLowerCase())
|
||||
)
|
||||
.map((col) => {
|
||||
const isSelected =
|
||||
!dataSource.selectedColumns ||
|
||||
dataSource.selectedColumns.length === 0 ||
|
||||
dataSource.selectedColumns.includes(col);
|
||||
|
||||
const type = columnTypes[col] || "unknown";
|
||||
const typeIcon = {
|
||||
number: "🔢",
|
||||
string: "📝",
|
||||
date: "📅",
|
||||
boolean: "✓",
|
||||
object: "📦",
|
||||
unknown: "❓"
|
||||
}[type];
|
||||
|
||||
const typeColor = {
|
||||
number: "text-blue-600 bg-blue-50",
|
||||
string: "text-gray-600 bg-gray-50",
|
||||
date: "text-purple-600 bg-purple-50",
|
||||
boolean: "text-green-600 bg-green-50",
|
||||
object: "text-orange-600 bg-orange-50",
|
||||
unknown: "text-gray-400 bg-gray-50"
|
||||
}[type];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col}
|
||||
onClick={() => {
|
||||
const currentSelected = dataSource.selectedColumns && dataSource.selectedColumns.length > 0
|
||||
? dataSource.selectedColumns
|
||||
: availableColumns;
|
||||
|
||||
const newSelected = isSelected
|
||||
? currentSelected.filter(c => c !== col)
|
||||
: [...currentSelected, col];
|
||||
|
||||
onChange({ selectedColumns: newSelected });
|
||||
}}
|
||||
className={`
|
||||
relative flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-all
|
||||
${isSelected
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-border bg-card hover:border-primary/50 hover:bg-muted/50"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* 체크박스 */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<div className={`
|
||||
h-4 w-4 rounded border-2 flex items-center justify-center transition-colors
|
||||
${isSelected
|
||||
? "border-primary bg-primary"
|
||||
: "border-gray-300 bg-background"
|
||||
}
|
||||
`}>
|
||||
{isSelected && (
|
||||
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 정보 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{col}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${typeColor}`}>
|
||||
{typeIcon} {type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 샘플 데이터 */}
|
||||
{sampleData.length > 0 && (
|
||||
<div className="mt-1.5 text-xs text-muted-foreground">
|
||||
<span className="font-medium">예시:</span>{" "}
|
||||
{sampleData.slice(0, 2).map((row, i) => (
|
||||
<span key={i}>
|
||||
{String(row[col]).substring(0, 20)}
|
||||
{String(row[col]).length > 20 && "..."}
|
||||
{i < Math.min(sampleData.length - 1, 1) && ", "}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 검색 결과 없음 */}
|
||||
{columnSearchTerm && availableColumns.filter(col =>
|
||||
col.toLowerCase().includes(columnSearchTerm.toLowerCase())
|
||||
).length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
"{columnSearchTerm}"에 대한 컬럼을 찾을 수 없습니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 매핑 (API 테스트 성공 후에만 표시) */}
|
||||
{testResult?.success && availableColumns.length > 0 && (
|
||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 className="text-xs font-semibold">🔄 컬럼 매핑 (선택사항)</h5>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
다른 데이터 소스와 통합할 때 컬럼명을 통일할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onChange({ columnMapping: {} })}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 매핑 목록 */}
|
||||
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(dataSource.columnMapping).map(([original, mapped]) => (
|
||||
<div key={original} className="flex items-center gap-2">
|
||||
{/* 원본 컬럼 (읽기 전용) */}
|
||||
<Input
|
||||
value={original}
|
||||
disabled
|
||||
className="h-8 flex-1 text-xs bg-muted"
|
||||
/>
|
||||
|
||||
{/* 화살표 */}
|
||||
<span className="text-muted-foreground text-xs">→</span>
|
||||
|
||||
{/* 표시 이름 (편집 가능) */}
|
||||
<Input
|
||||
value={mapped}
|
||||
onChange={(e) => {
|
||||
const newMapping = { ...dataSource.columnMapping };
|
||||
newMapping[original] = e.target.value;
|
||||
onChange({ columnMapping: newMapping });
|
||||
}}
|
||||
placeholder="표시 이름"
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newMapping = { ...dataSource.columnMapping };
|
||||
delete newMapping[original];
|
||||
onChange({ columnMapping: newMapping });
|
||||
}}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<XCircle className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 매핑 추가 */}
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(col) => {
|
||||
const newMapping = { ...dataSource.columnMapping } || {};
|
||||
newMapping[col] = col; // 기본값은 원본과 동일
|
||||
onChange({ columnMapping: newMapping });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택하여 매핑 추가" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableColumns
|
||||
.filter(col => !dataSource.columnMapping || !dataSource.columnMapping[col])
|
||||
.map(col => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
💡 매핑하지 않은 컬럼은 원본 이름 그대로 사용됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ChartDataSource } from "@/components/admin/dashboard/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Plus, Trash2, Database, Globe } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import MultiApiConfig from "./MultiApiConfig";
|
||||
import MultiDatabaseConfig from "./MultiDatabaseConfig";
|
||||
|
||||
interface MultiDataSourceConfigProps {
|
||||
dataSources: ChartDataSource[];
|
||||
onChange: (dataSources: ChartDataSource[]) => void;
|
||||
onTestResult?: (result: { columns: string[]; rows: any[] }, dataSourceId: string) => void;
|
||||
}
|
||||
|
||||
export default function MultiDataSourceConfig({
|
||||
dataSources = [],
|
||||
onChange,
|
||||
onTestResult,
|
||||
}: MultiDataSourceConfigProps) {
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
dataSources.length > 0 ? dataSources[0].id || "0" : "new"
|
||||
);
|
||||
const [previewData, setPreviewData] = useState<any[]>([]);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [showAddMenu, setShowAddMenu] = useState(false);
|
||||
|
||||
// 새 데이터 소스 추가 (타입 지정)
|
||||
const handleAddDataSource = (type: "api" | "database") => {
|
||||
const newId = Date.now().toString();
|
||||
const newSource: ChartDataSource = {
|
||||
id: newId,
|
||||
name: `${type === "api" ? "REST API" : "Database"} ${dataSources.length + 1}`,
|
||||
type,
|
||||
};
|
||||
|
||||
onChange([...dataSources, newSource]);
|
||||
setActiveTab(newId);
|
||||
setShowAddMenu(false);
|
||||
};
|
||||
|
||||
// 데이터 소스 삭제
|
||||
const handleDeleteDataSource = (id: string) => {
|
||||
const filtered = dataSources.filter((ds) => ds.id !== id);
|
||||
onChange(filtered);
|
||||
|
||||
// 삭제 후 첫 번째 탭으로 이동
|
||||
if (filtered.length > 0) {
|
||||
setActiveTab(filtered[0].id || "0");
|
||||
} else {
|
||||
setActiveTab("new");
|
||||
}
|
||||
};
|
||||
|
||||
// 데이터 소스 업데이트
|
||||
const handleUpdateDataSource = (id: string, updates: Partial<ChartDataSource>) => {
|
||||
const updated = dataSources.map((ds) =>
|
||||
ds.id === id ? { ...ds, ...updates } : ds
|
||||
);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">데이터 소스 관리</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
여러 데이터 소스를 연결하여 데이터를 통합할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenu open={showAddMenu} onOpenChange={setShowAddMenu}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-2 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleAddDataSource("api")}>
|
||||
<Globe className="mr-2 h-4 w-4" />
|
||||
REST API 추가
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleAddDataSource("database")}>
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
Database 추가
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* 데이터 소스가 없는 경우 */}
|
||||
{dataSources.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8">
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
연결된 데이터 소스가 없습니다
|
||||
</p>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-8 gap-2 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
첫 번째 데이터 소스 추가
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center">
|
||||
<DropdownMenuItem onClick={() => handleAddDataSource("api")}>
|
||||
<Globe className="mr-2 h-4 w-4" />
|
||||
REST API 추가
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleAddDataSource("database")}>
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
Database 추가
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : (
|
||||
/* 탭 UI */
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="w-full justify-start overflow-x-auto">
|
||||
{dataSources.map((ds, index) => (
|
||||
<TabsTrigger
|
||||
key={ds.id}
|
||||
value={ds.id || index.toString()}
|
||||
className="text-xs"
|
||||
>
|
||||
{ds.name || `소스 ${index + 1}`}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{dataSources.map((ds, index) => (
|
||||
<TabsContent
|
||||
key={ds.id}
|
||||
value={ds.id || index.toString()}
|
||||
className="space-y-4"
|
||||
>
|
||||
{/* 데이터 소스 기본 정보 */}
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
{/* 이름 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`name-${ds.id}`} className="text-xs">
|
||||
데이터 소스 이름
|
||||
</Label>
|
||||
<Input
|
||||
id={`name-${ds.id}`}
|
||||
value={ds.name || ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateDataSource(ds.id!, { name: e.target.value })
|
||||
}
|
||||
placeholder="예: 기상특보, 교통정보"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 타입 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">데이터 소스 타입</Label>
|
||||
<RadioGroup
|
||||
value={ds.type}
|
||||
onValueChange={(value: "database" | "api") =>
|
||||
handleUpdateDataSource(ds.id!, { type: value })
|
||||
}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="api" id={`api-${ds.id}`} />
|
||||
<Label
|
||||
htmlFor={`api-${ds.id}`}
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
REST API
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="database" id={`db-${ds.id}`} />
|
||||
<Label
|
||||
htmlFor={`db-${ds.id}`}
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
Database
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<div className="border-t pt-3">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteDataSource(ds.id!)}
|
||||
className="h-8 gap-2 text-xs"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
이 데이터 소스 삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 지도 표시 방식 선택 (지도 위젯만) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">지도 표시 방식</Label>
|
||||
<RadioGroup
|
||||
value={ds.mapDisplayType || "auto"}
|
||||
onValueChange={(value) =>
|
||||
handleUpdateDataSource(ds.id!, { mapDisplayType: value as "auto" | "marker" | "polygon" })
|
||||
}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="auto" id={`auto-${ds.id}`} />
|
||||
<Label htmlFor={`auto-${ds.id}`} className="text-xs font-normal cursor-pointer">
|
||||
자동 (데이터 기반)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="marker" id={`marker-${ds.id}`} />
|
||||
<Label htmlFor={`marker-${ds.id}`} className="text-xs font-normal cursor-pointer">
|
||||
📍 마커
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="polygon" id={`polygon-${ds.id}`} />
|
||||
<Label htmlFor={`polygon-${ds.id}`} className="text-xs font-normal cursor-pointer">
|
||||
🔷 영역
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ds.mapDisplayType === "marker" && "모든 데이터를 마커로 표시합니다"}
|
||||
{ds.mapDisplayType === "polygon" && "모든 데이터를 영역(폴리곤)으로 표시합니다"}
|
||||
{(!ds.mapDisplayType || ds.mapDisplayType === "auto") && "데이터에 coordinates가 있으면 영역, 없으면 마커로 자동 표시"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 타입별 설정 */}
|
||||
{ds.type === "api" ? (
|
||||
<MultiApiConfig
|
||||
dataSource={ds}
|
||||
onChange={(updates) => handleUpdateDataSource(ds.id!, updates)}
|
||||
onTestResult={(data) => {
|
||||
setPreviewData(data);
|
||||
setShowPreview(true);
|
||||
// 부모로 테스트 결과 전달 (차트 설정용)
|
||||
if (onTestResult && data.length > 0 && ds.id) {
|
||||
const columns = Object.keys(data[0]);
|
||||
onTestResult({ columns, rows: data }, ds.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MultiDatabaseConfig
|
||||
dataSource={ds}
|
||||
onChange={(updates) => handleUpdateDataSource(ds.id!, updates)}
|
||||
onTestResult={(data) => {
|
||||
// 부모로 테스트 결과 전달 (차트 설정용)
|
||||
if (onTestResult && data.length > 0 && ds.id) {
|
||||
const columns = Object.keys(data[0]);
|
||||
onTestResult({ columns, rows: data }, ds.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{/* 지도 미리보기 */}
|
||||
{showPreview && previewData.length > 0 && (
|
||||
<div className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h5 className="text-sm font-semibold">
|
||||
데이터 미리보기 ({previewData.length}건)
|
||||
</h5>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
"적용" 버튼을 눌러 지도에 표시하세요
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(false)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[400px] space-y-2 overflow-y-auto">
|
||||
{previewData.map((item, index) => {
|
||||
const hasLatLng = (item.lat || item.latitude) && (item.lng || item.longitude);
|
||||
const hasCoordinates = item.coordinates && Array.isArray(item.coordinates);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded border bg-background p-3 text-xs"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="font-medium">
|
||||
{item.name || item.title || item.area || item.region || `항목 ${index + 1}`}
|
||||
</div>
|
||||
{(item.status || item.level) && (
|
||||
<div className={`rounded px-2 py-0.5 text-[10px] font-medium ${
|
||||
(item.status || item.level)?.includes('경보') || (item.status || item.level)?.includes('위험')
|
||||
? 'bg-red-100 text-red-700'
|
||||
: (item.status || item.level)?.includes('주의')
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{item.status || item.level}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasLatLng && (
|
||||
<div className="text-muted-foreground">
|
||||
📍 마커: ({item.lat || item.latitude}, {item.lng || item.longitude})
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCoordinates && (
|
||||
<div className="text-muted-foreground">
|
||||
🔷 영역: {item.coordinates.length}개 좌표
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(item.type || item.description) && (
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
{item.type && `${item.type} `}
|
||||
{item.description && item.description !== item.type && `- ${item.description}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,678 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ChartDataSource } from "@/components/admin/dashboard/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Loader2, CheckCircle, XCircle } from "lucide-react";
|
||||
|
||||
interface MultiDatabaseConfigProps {
|
||||
dataSource: ChartDataSource;
|
||||
onChange: (updates: Partial<ChartDataSource>) => void;
|
||||
onTestResult?: (data: any[]) => void;
|
||||
}
|
||||
|
||||
interface ExternalConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult }: MultiDatabaseConfigProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string; rowCount?: number } | null>(null);
|
||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||
const [loadingConnections, setLoadingConnections] = useState(false);
|
||||
const [availableColumns, setAvailableColumns] = useState<string[]>([]); // 쿼리 테스트 후 발견된 컬럼 목록
|
||||
const [columnTypes, setColumnTypes] = useState<Record<string, string>>({}); // 컬럼 타입 정보
|
||||
const [sampleData, setSampleData] = useState<any[]>([]); // 샘플 데이터 (최대 3개)
|
||||
const [columnSearchTerm, setColumnSearchTerm] = useState(""); // 컬럼 검색어
|
||||
|
||||
// 외부 DB 커넥션 목록 로드
|
||||
useEffect(() => {
|
||||
if (dataSource.connectionType === "external") {
|
||||
loadExternalConnections();
|
||||
}
|
||||
}, [dataSource.connectionType]);
|
||||
|
||||
const loadExternalConnections = async () => {
|
||||
setLoadingConnections(true);
|
||||
try {
|
||||
// ExternalDbConnectionAPI 사용 (인증 토큰 자동 포함)
|
||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||
const connections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
|
||||
|
||||
console.log("✅ 외부 DB 커넥션 로드 성공:", connections.length, "개");
|
||||
setExternalConnections(connections.map((conn: any) => ({
|
||||
id: String(conn.id),
|
||||
name: conn.connection_name,
|
||||
type: conn.db_type,
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error("❌ 외부 DB 커넥션 로드 실패:", error);
|
||||
setExternalConnections([]);
|
||||
} finally {
|
||||
setLoadingConnections(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 쿼리 테스트
|
||||
const handleTestQuery = async () => {
|
||||
if (!dataSource.query) {
|
||||
setTestResult({ success: false, message: "SQL 쿼리를 입력해주세요" });
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
// dashboardApi 사용 (인증 토큰 자동 포함)
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
|
||||
if (dataSource.connectionType === "external" && dataSource.externalConnectionId) {
|
||||
// 외부 DB
|
||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||
const result = await ExternalDbConnectionAPI.executeQuery(
|
||||
parseInt(dataSource.externalConnectionId),
|
||||
dataSource.query
|
||||
);
|
||||
|
||||
if (result.success && result.data) {
|
||||
const rows = Array.isArray(result.data.rows) ? result.data.rows : [];
|
||||
const rowCount = rows.length;
|
||||
|
||||
// 컬럼 목록 및 타입 추출
|
||||
if (rows.length > 0) {
|
||||
const columns = Object.keys(rows[0]);
|
||||
setAvailableColumns(columns);
|
||||
|
||||
// 컬럼 타입 분석
|
||||
const types: Record<string, string> = {};
|
||||
columns.forEach(col => {
|
||||
const value = rows[0][col];
|
||||
if (value === null || value === undefined) {
|
||||
types[col] = "unknown";
|
||||
} else if (typeof value === "number") {
|
||||
types[col] = "number";
|
||||
} else if (typeof value === "boolean") {
|
||||
types[col] = "boolean";
|
||||
} else if (typeof value === "string") {
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
|
||||
types[col] = "date";
|
||||
} else {
|
||||
types[col] = "string";
|
||||
}
|
||||
} else {
|
||||
types[col] = "object";
|
||||
}
|
||||
});
|
||||
setColumnTypes(types);
|
||||
setSampleData(rows.slice(0, 3));
|
||||
|
||||
console.log("📊 발견된 컬럼:", columns);
|
||||
console.log("📊 컬럼 타입:", types);
|
||||
}
|
||||
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: "쿼리 실행 성공",
|
||||
rowCount,
|
||||
});
|
||||
|
||||
// 부모로 테스트 결과 전달 (차트 설정용)
|
||||
if (onTestResult && rows && rows.length > 0) {
|
||||
onTestResult(rows);
|
||||
}
|
||||
} else {
|
||||
setTestResult({ success: false, message: result.message || "쿼리 실행 실패" });
|
||||
}
|
||||
} else {
|
||||
// 현재 DB
|
||||
const result = await dashboardApi.executeQuery(dataSource.query);
|
||||
|
||||
// 컬럼 목록 및 타입 추출
|
||||
if (result.rows && result.rows.length > 0) {
|
||||
const columns = Object.keys(result.rows[0]);
|
||||
setAvailableColumns(columns);
|
||||
|
||||
// 컬럼 타입 분석
|
||||
const types: Record<string, string> = {};
|
||||
columns.forEach(col => {
|
||||
const value = result.rows[0][col];
|
||||
if (value === null || value === undefined) {
|
||||
types[col] = "unknown";
|
||||
} else if (typeof value === "number") {
|
||||
types[col] = "number";
|
||||
} else if (typeof value === "boolean") {
|
||||
types[col] = "boolean";
|
||||
} else if (typeof value === "string") {
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
|
||||
types[col] = "date";
|
||||
} else {
|
||||
types[col] = "string";
|
||||
}
|
||||
} else {
|
||||
types[col] = "object";
|
||||
}
|
||||
});
|
||||
setColumnTypes(types);
|
||||
setSampleData(result.rows.slice(0, 3));
|
||||
|
||||
console.log("📊 발견된 컬럼:", columns);
|
||||
console.log("📊 컬럼 타입:", types);
|
||||
}
|
||||
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: "쿼리 실행 성공",
|
||||
rowCount: result.rowCount || 0,
|
||||
});
|
||||
|
||||
// 부모로 테스트 결과 전달 (차트 설정용)
|
||||
if (onTestResult && result.rows && result.rows.length > 0) {
|
||||
onTestResult(result.rows);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
setTestResult({ success: false, message: error.message || "네트워크 오류" });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border p-3">
|
||||
<h5 className="text-xs font-semibold">Database 설정</h5>
|
||||
|
||||
{/* 커넥션 타입 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">데이터베이스 연결</Label>
|
||||
<RadioGroup
|
||||
value={dataSource.connectionType || "current"}
|
||||
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"
|
||||
>
|
||||
현재 데이터베이스
|
||||
</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"
|
||||
>
|
||||
외부 데이터베이스
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 외부 DB 선택 */}
|
||||
{dataSource.connectionType === "external" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`external-conn-\${dataSource.id}`} className="text-xs">
|
||||
외부 데이터베이스 선택 *
|
||||
</Label>
|
||||
{loadingConnections ? (
|
||||
<div className="flex h-10 items-center justify-center rounded-md border">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={dataSource.externalConnectionId || ""}
|
||||
onValueChange={(value) => onChange({ externalConnectionId: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="외부 DB 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{externalConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id} className="text-xs">
|
||||
{conn.name} ({conn.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SQL 쿼리 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={`query-\${dataSource.id}`} className="text-xs">
|
||||
SQL 쿼리 *
|
||||
</Label>
|
||||
<Select onValueChange={(value) => {
|
||||
const samples = {
|
||||
users: `SELECT
|
||||
dept_name as 부서명,
|
||||
COUNT(*) as 회원수
|
||||
FROM user_info
|
||||
WHERE dept_name IS NOT NULL
|
||||
GROUP BY dept_name
|
||||
ORDER BY 회원수 DESC`,
|
||||
dept: `SELECT
|
||||
dept_code as 부서코드,
|
||||
dept_name as 부서명,
|
||||
location_name as 위치,
|
||||
TO_CHAR(regdate, 'YYYY-MM-DD') as 등록일
|
||||
FROM dept_info
|
||||
ORDER BY dept_code`,
|
||||
usersByDate: `SELECT
|
||||
DATE_TRUNC('month', regdate)::date as 월,
|
||||
COUNT(*) as 신규사용자수
|
||||
FROM user_info
|
||||
WHERE regdate >= CURRENT_DATE - INTERVAL '12 months'
|
||||
GROUP BY DATE_TRUNC('month', regdate)
|
||||
ORDER BY 월`,
|
||||
usersByPosition: `SELECT
|
||||
position_name as 직급,
|
||||
COUNT(*) as 인원수
|
||||
FROM user_info
|
||||
WHERE position_name IS NOT NULL
|
||||
GROUP BY position_name
|
||||
ORDER BY 인원수 DESC`,
|
||||
deptHierarchy: `SELECT
|
||||
COALESCE(parent_dept_code, '최상위') as 상위부서코드,
|
||||
COUNT(*) as 하위부서수
|
||||
FROM dept_info
|
||||
GROUP BY parent_dept_code
|
||||
ORDER BY 하위부서수 DESC`,
|
||||
};
|
||||
onChange({ query: samples[value as keyof typeof samples] || "" });
|
||||
}}>
|
||||
<SelectTrigger className="h-7 w-32 text-xs">
|
||||
<SelectValue placeholder="샘플 쿼리" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="users" className="text-xs">부서별 회원수</SelectItem>
|
||||
<SelectItem value="dept" className="text-xs">부서 목록</SelectItem>
|
||||
<SelectItem value="usersByDate" className="text-xs">월별 신규사용자</SelectItem>
|
||||
<SelectItem value="usersByPosition" className="text-xs">직급별 인원수</SelectItem>
|
||||
<SelectItem value="deptHierarchy" className="text-xs">부서 계층구조</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Textarea
|
||||
id={`query-\${dataSource.id}`}
|
||||
value={dataSource.query || ""}
|
||||
onChange={(e) => onChange({ query: e.target.value })}
|
||||
placeholder="SELECT * FROM table_name WHERE ..."
|
||||
className="min-h-[80px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 자동 새로고침 설정 */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`refresh-${dataSource.id}`} className="text-xs">
|
||||
자동 새로고침 간격
|
||||
</Label>
|
||||
<Select
|
||||
value={String(dataSource.refreshInterval || 0)}
|
||||
onValueChange={(value) => onChange({ refreshInterval: Number(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="새로고침 안 함" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">새로고침 안 함</SelectItem>
|
||||
<SelectItem value="10">10초마다</SelectItem>
|
||||
<SelectItem value="30">30초마다</SelectItem>
|
||||
<SelectItem value="60">1분마다</SelectItem>
|
||||
<SelectItem value="300">5분마다</SelectItem>
|
||||
<SelectItem value="600">10분마다</SelectItem>
|
||||
<SelectItem value="1800">30분마다</SelectItem>
|
||||
<SelectItem value="3600">1시간마다</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 지도 색상 설정 (MapTestWidgetV2 전용) */}
|
||||
<div className="space-y-2 rounded-lg border bg-muted/30 p-2">
|
||||
<h5 className="text-xs font-semibold">🎨 지도 색상</h5>
|
||||
|
||||
{/* 색상 팔레트 */}
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{[
|
||||
{ name: "파랑", marker: "#3b82f6", polygon: "#3b82f6" },
|
||||
{ name: "빨강", marker: "#ef4444", polygon: "#ef4444" },
|
||||
{ name: "초록", marker: "#10b981", polygon: "#10b981" },
|
||||
{ name: "노랑", marker: "#f59e0b", polygon: "#f59e0b" },
|
||||
{ name: "보라", marker: "#8b5cf6", polygon: "#8b5cf6" },
|
||||
{ name: "주황", marker: "#f97316", polygon: "#f97316" },
|
||||
{ name: "청록", marker: "#06b6d4", polygon: "#06b6d4" },
|
||||
{ name: "분홍", marker: "#ec4899", polygon: "#ec4899" },
|
||||
].map((color) => {
|
||||
const isSelected = dataSource.markerColor === color.marker;
|
||||
return (
|
||||
<button
|
||||
key={color.name}
|
||||
type="button"
|
||||
onClick={() => onChange({
|
||||
markerColor: color.marker,
|
||||
polygonColor: color.polygon,
|
||||
polygonOpacity: 0.5,
|
||||
})}
|
||||
className={`flex h-12 flex-col items-center justify-center gap-0.5 rounded-md border-2 transition-all hover:scale-105 ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: "border-border bg-background hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="h-5 w-5 rounded-full border-2 border-white shadow-sm"
|
||||
style={{ backgroundColor: color.marker }}
|
||||
/>
|
||||
<span className="text-[9px] font-medium">{color.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테스트 버튼 */}
|
||||
<div className="space-y-2 border-t pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTestQuery}
|
||||
disabled={testing || !dataSource.query}
|
||||
className="h-8 w-full gap-2 text-xs"
|
||||
>
|
||||
{testing ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
테스트 중...
|
||||
</>
|
||||
) : (
|
||||
"쿼리 테스트"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{testResult && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-md p-2 text-xs ${
|
||||
testResult.success
|
||||
? "bg-green-50 text-green-700"
|
||||
: "bg-red-50 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{testResult.success ? (
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<XCircle className="h-3 w-3" />
|
||||
)}
|
||||
<div>
|
||||
{testResult.message}
|
||||
{testResult.rowCount !== undefined && (
|
||||
<span className="ml-1">({testResult.rowCount}행)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 컬럼 선택 (메트릭 위젯용) - 개선된 UI */}
|
||||
{availableColumns.length > 0 && (
|
||||
<div className="space-y-2 border-t pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs font-semibold">메트릭 컬럼 선택</Label>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{dataSource.selectedColumns && dataSource.selectedColumns.length > 0
|
||||
? `${dataSource.selectedColumns.length}개 선택됨`
|
||||
: "모든 컬럼 표시"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange({ selectedColumns: availableColumns })}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
전체
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange({ selectedColumns: [] })}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
해제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
{availableColumns.length > 5 && (
|
||||
<Input
|
||||
placeholder="컬럼 검색..."
|
||||
value={columnSearchTerm}
|
||||
onChange={(e) => setColumnSearchTerm(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 컬럼 카드 그리드 */}
|
||||
<div className="grid grid-cols-1 gap-1.5 max-h-60 overflow-y-auto">
|
||||
{availableColumns
|
||||
.filter(col =>
|
||||
!columnSearchTerm ||
|
||||
col.toLowerCase().includes(columnSearchTerm.toLowerCase())
|
||||
)
|
||||
.map((col) => {
|
||||
const isSelected =
|
||||
!dataSource.selectedColumns ||
|
||||
dataSource.selectedColumns.length === 0 ||
|
||||
dataSource.selectedColumns.includes(col);
|
||||
|
||||
const type = columnTypes[col] || "unknown";
|
||||
const typeIcon = {
|
||||
number: "🔢",
|
||||
string: "📝",
|
||||
date: "📅",
|
||||
boolean: "✓",
|
||||
object: "📦",
|
||||
unknown: "❓"
|
||||
}[type];
|
||||
|
||||
const typeColor = {
|
||||
number: "text-blue-600 bg-blue-50",
|
||||
string: "text-gray-600 bg-gray-50",
|
||||
date: "text-purple-600 bg-purple-50",
|
||||
boolean: "text-green-600 bg-green-50",
|
||||
object: "text-orange-600 bg-orange-50",
|
||||
unknown: "text-gray-400 bg-gray-50"
|
||||
}[type];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col}
|
||||
onClick={() => {
|
||||
const currentSelected = dataSource.selectedColumns && dataSource.selectedColumns.length > 0
|
||||
? dataSource.selectedColumns
|
||||
: availableColumns;
|
||||
|
||||
const newSelected = isSelected
|
||||
? currentSelected.filter(c => c !== col)
|
||||
: [...currentSelected, col];
|
||||
|
||||
onChange({ selectedColumns: newSelected });
|
||||
}}
|
||||
className={`
|
||||
relative flex items-start gap-2 rounded-lg border p-2 cursor-pointer transition-all
|
||||
${isSelected
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-border bg-card hover:border-primary/50 hover:bg-muted/50"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* 체크박스 */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<div className={`
|
||||
h-4 w-4 rounded border-2 flex items-center justify-center transition-colors
|
||||
${isSelected
|
||||
? "border-primary bg-primary"
|
||||
: "border-gray-300 bg-background"
|
||||
}
|
||||
`}>
|
||||
{isSelected && (
|
||||
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 정보 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{col}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${typeColor}`}>
|
||||
{typeIcon} {type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 샘플 데이터 */}
|
||||
{sampleData.length > 0 && (
|
||||
<div className="mt-1.5 text-xs text-muted-foreground">
|
||||
<span className="font-medium">예시:</span>{" "}
|
||||
{sampleData.slice(0, 2).map((row, i) => (
|
||||
<span key={i}>
|
||||
{String(row[col]).substring(0, 20)}
|
||||
{String(row[col]).length > 20 && "..."}
|
||||
{i < Math.min(sampleData.length - 1, 1) && ", "}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 검색 결과 없음 */}
|
||||
{columnSearchTerm && availableColumns.filter(col =>
|
||||
col.toLowerCase().includes(columnSearchTerm.toLowerCase())
|
||||
).length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
"{columnSearchTerm}"에 대한 컬럼을 찾을 수 없습니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 매핑 (쿼리 테스트 성공 후에만 표시) */}
|
||||
{testResult?.success && availableColumns.length > 0 && (
|
||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 className="text-xs font-semibold">🔄 컬럼 매핑 (선택사항)</h5>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
다른 데이터 소스와 통합할 때 컬럼명을 통일할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onChange({ columnMapping: {} })}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 매핑 목록 */}
|
||||
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(dataSource.columnMapping).map(([original, mapped]) => (
|
||||
<div key={original} className="flex items-center gap-2">
|
||||
{/* 원본 컬럼 (읽기 전용) */}
|
||||
<Input
|
||||
value={original}
|
||||
disabled
|
||||
className="h-8 flex-1 text-xs bg-muted"
|
||||
/>
|
||||
|
||||
{/* 화살표 */}
|
||||
<span className="text-muted-foreground text-xs">→</span>
|
||||
|
||||
{/* 표시 이름 (편집 가능) */}
|
||||
<Input
|
||||
value={mapped}
|
||||
onChange={(e) => {
|
||||
const newMapping = { ...dataSource.columnMapping };
|
||||
newMapping[original] = e.target.value;
|
||||
onChange({ columnMapping: newMapping });
|
||||
}}
|
||||
placeholder="표시 이름"
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newMapping = { ...dataSource.columnMapping };
|
||||
delete newMapping[original];
|
||||
onChange({ columnMapping: newMapping });
|
||||
}}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<XCircle className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 매핑 추가 */}
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(col) => {
|
||||
const newMapping = { ...dataSource.columnMapping } || {};
|
||||
newMapping[col] = col; // 기본값은 원본과 동일
|
||||
onChange({ columnMapping: newMapping });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택하여 매핑 추가" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableColumns
|
||||
.filter(col => !dataSource.columnMapping || !dataSource.columnMapping[col])
|
||||
.map(col => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
💡 매핑하지 않은 컬럼은 원본 이름 그대로 사용됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,7 +22,19 @@ export type ElementSubtype =
|
|||
| "vehicle-status"
|
||||
| "vehicle-list" // (구버전 - 호환용)
|
||||
| "vehicle-map" // (구버전 - 호환용)
|
||||
| "map-summary" // 범용 지도 카드 (통합)
|
||||
// | "map-summary" // (구버전 - 주석 처리: 2025-10-28, map-summary-v2로 대체)
|
||||
// | "map-test" // 🧪 지도 테스트 위젯 (REST API 지원) - V2로 대체
|
||||
| "map-summary-v2" // 지도 위젯 (다중 데이터 소스) - 승격 완료
|
||||
// | "map-test-v2" // (테스트 버전 - 주석 처리: 2025-10-28, map-summary-v2로 승격)
|
||||
| "chart" // 차트 위젯 (다중 데이터 소스) - 승격 완료
|
||||
// | "chart-test" // (테스트 버전 - 주석 처리: 2025-10-28, chart로 승격)
|
||||
| "list-v2" // 리스트 위젯 (다중 데이터 소스) - 승격 완료
|
||||
// | "list-test" // (테스트 버전 - 주석 처리: 2025-10-28, list-v2로 승격)
|
||||
| "custom-metric-v2" // 통계 카드 위젯 (다중 데이터 소스) - 승격 완료
|
||||
// | "custom-metric-test" // (테스트 버전 - 주석 처리: 2025-10-28, custom-metric-v2로 승격)
|
||||
// | "status-summary-test" // 🧪 상태 요약 테스트 (CustomMetricTest로 대체 가능)
|
||||
| "risk-alert-v2" // 리스크/알림 위젯 (다중 데이터 소스) - 승격 완료
|
||||
// | "risk-alert-test" // (테스트 버전 - 주석 처리: 2025-10-28, risk-alert-v2로 승격)
|
||||
| "delivery-status"
|
||||
| "status-summary" // 범용 상태 카드 (통합)
|
||||
// | "list-summary" // 범용 목록 카드 (다른 분 작업 중 - 임시 주석)
|
||||
|
|
@ -30,17 +42,17 @@ export type ElementSubtype =
|
|||
| "delivery-today-stats" // (구버전 - 호환용)
|
||||
| "cargo-list" // (구버전 - 호환용)
|
||||
| "customer-issues" // (구버전 - 호환용)
|
||||
| "risk-alert"
|
||||
// | "risk-alert" // (구버전 - 주석 처리: 2025-10-28, risk-alert-v2로 대체)
|
||||
| "driver-management" // (구버전 - 호환용)
|
||||
| "todo"
|
||||
| "booking-alert"
|
||||
| "maintenance"
|
||||
| "document"
|
||||
| "list"
|
||||
// | "list" // (구버전 - 주석 처리: 2025-10-28, list-v2로 대체)
|
||||
| "yard-management-3d" // 야드 관리 3D 위젯
|
||||
| "work-history" // 작업 이력 위젯
|
||||
| "transport-stats" // 커스텀 통계 카드 위젯
|
||||
| "custom-metric"; // 사용자 커스텀 카드 위젯
|
||||
| "transport-stats"; // 커스텀 통계 카드 위젯
|
||||
// | "custom-metric"; // (구버전 - 주석 처리: 2025-10-28, custom-metric-v2로 대체)
|
||||
|
||||
// 차트 분류
|
||||
export type ChartCategory = "axis-based" | "circular";
|
||||
|
|
@ -97,7 +109,8 @@ export interface DashboardElement {
|
|||
customTitle?: string; // 사용자 정의 제목 (옵션)
|
||||
showHeader?: boolean; // 헤더 표시 여부 (기본값: true)
|
||||
content: string;
|
||||
dataSource?: ChartDataSource; // 데이터 소스 설정
|
||||
dataSource?: ChartDataSource; // 데이터 소스 설정 (단일, 하위 호환용)
|
||||
dataSources?: ChartDataSource[]; // 다중 데이터 소스 설정 (테스트 위젯용)
|
||||
chartConfig?: ChartConfig; // 차트 설정
|
||||
clockConfig?: ClockConfig; // 시계 설정
|
||||
calendarConfig?: CalendarConfig; // 달력 설정
|
||||
|
|
@ -125,6 +138,8 @@ export interface KeyValuePair {
|
|||
}
|
||||
|
||||
export interface ChartDataSource {
|
||||
id?: string; // 고유 ID (다중 데이터 소스용)
|
||||
name?: string; // 사용자 지정 이름 (예: "기상특보", "교통정보")
|
||||
type: "database" | "api"; // 데이터 소스 타입
|
||||
|
||||
// DB 커넥션 관련
|
||||
|
|
@ -143,10 +158,36 @@ export interface ChartDataSource {
|
|||
refreshInterval?: number; // 자동 새로고침 (초, 0이면 수동)
|
||||
lastExecuted?: string; // 마지막 실행 시간
|
||||
lastError?: string; // 마지막 오류 메시지
|
||||
mapDisplayType?: "auto" | "marker" | "polygon"; // 지도 표시 방식 (auto: 자동, marker: 마커, polygon: 영역)
|
||||
|
||||
// 지도 색상 설정 (MapTestWidgetV2용)
|
||||
markerColor?: string; // 마커 색상 (예: "#ff0000")
|
||||
polygonColor?: string; // 폴리곤 색상 (예: "#0000ff")
|
||||
polygonOpacity?: number; // 폴리곤 투명도 (0.0 ~ 1.0, 기본값: 0.5)
|
||||
|
||||
// 컬럼 매핑 (다중 데이터 소스 통합용)
|
||||
columnMapping?: Record<string, string>; // { 원본컬럼: 표시이름 } (예: { "name": "product" })
|
||||
|
||||
// 메트릭 설정 (CustomMetricTestWidget용)
|
||||
selectedColumns?: string[]; // 표시할 컬럼 선택 (빈 배열이면 전체 표시)
|
||||
}
|
||||
|
||||
export interface ChartConfig {
|
||||
// 축 매핑
|
||||
// 다중 데이터 소스 (테스트 위젯용)
|
||||
dataSources?: ChartDataSource[]; // 여러 데이터 소스 (REST API + Database 혼합 가능)
|
||||
|
||||
// 멀티 차트 설정 (ChartTestWidget용)
|
||||
chartType?: string; // 차트 타입 (line, bar, pie, etc.)
|
||||
mergeMode?: boolean; // 데이터 병합 모드 (여러 데이터 소스를 하나의 라인/바로 합침)
|
||||
dataSourceConfigs?: Array<{
|
||||
dataSourceId: string; // 데이터 소스 ID
|
||||
xAxis: string; // X축 필드명
|
||||
yAxis: string[]; // Y축 필드명 배열
|
||||
label?: string; // 데이터 소스 라벨
|
||||
chartType?: "bar" | "line" | "area"; // 🆕 각 데이터 소스별 차트 타입 (바/라인/영역 혼합 가능)
|
||||
}>;
|
||||
|
||||
// 축 매핑 (단일 데이터 소스용)
|
||||
xAxis?: string; // X축 필드명
|
||||
yAxis?: string | string[]; // Y축 필드명 (다중 가능)
|
||||
|
||||
|
|
@ -199,6 +240,7 @@ export interface ChartConfig {
|
|||
stackMode?: "normal" | "percent"; // 누적 모드
|
||||
|
||||
// 지도 관련 설정
|
||||
tileMapUrl?: string; // 타일맵 URL (예: VWorld, OpenStreetMap)
|
||||
latitudeColumn?: string; // 위도 컬럼
|
||||
longitudeColumn?: string; // 경도 컬럼
|
||||
labelColumn?: string; // 라벨 컬럼
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DashboardElement, QueryResult, ListColumn } from "../types";
|
||||
import { DashboardElement, QueryResult, ListWidgetConfig } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface ListWidgetProps {
|
||||
element: DashboardElement;
|
||||
onConfigUpdate?: (config: Partial<DashboardElement>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -16,7 +17,7 @@ interface ListWidgetProps {
|
|||
* - 테이블 형태로 데이터 표시
|
||||
* - 페이지네이션, 정렬, 검색 기능
|
||||
*/
|
||||
export function ListWidget({ element }: ListWidgetProps) {
|
||||
export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||
const [data, setData] = useState<QueryResult | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -52,7 +53,7 @@ export function ListWidget({ element }: ListWidgetProps) {
|
|||
if (element.dataSource.queryParams) {
|
||||
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
params.append(key, String(value));
|
||||
params.append(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -113,19 +114,13 @@ export function ListWidget({ element }: ListWidgetProps) {
|
|||
parseInt(element.dataSource.externalConnectionId),
|
||||
element.dataSource.query,
|
||||
);
|
||||
if (!externalResult.success || !externalResult.data) {
|
||||
if (!externalResult.success) {
|
||||
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
||||
}
|
||||
|
||||
const resultData = externalResult.data as unknown as {
|
||||
columns: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
rowCount: number;
|
||||
};
|
||||
queryResult = {
|
||||
columns: resultData.columns,
|
||||
rows: resultData.rows,
|
||||
totalRows: resultData.rowCount,
|
||||
columns: externalResult.data.columns,
|
||||
rows: externalResult.data.rows,
|
||||
totalRows: externalResult.data.rowCount,
|
||||
executionTime: 0,
|
||||
};
|
||||
} else {
|
||||
|
|
@ -159,7 +154,13 @@ export function ListWidget({ element }: ListWidgetProps) {
|
|||
const interval = setInterval(loadData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [element.dataSource]);
|
||||
}, [
|
||||
element.dataSource?.query,
|
||||
element.dataSource?.connectionType,
|
||||
element.dataSource?.externalConnectionId,
|
||||
element.dataSource?.endpoint,
|
||||
element.dataSource?.refreshInterval,
|
||||
]);
|
||||
|
||||
// 로딩 중
|
||||
if (isLoading) {
|
||||
|
|
@ -191,22 +192,23 @@ export function ListWidget({ element }: ListWidgetProps) {
|
|||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4">
|
||||
<div className="text-center">
|
||||
<div className="mt-1 text-xs text-gray-500">데이터와 컬럼을 설정해주세요</div>
|
||||
<div className="mb-2 text-4xl">📋</div>
|
||||
<div className="text-sm font-medium text-gray-700">리스트를 설정하세요</div>
|
||||
<div className="mt-1 text-xs text-gray-500">⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 컬럼 설정이 없으면 자동으로 모든 컬럼 표시
|
||||
const displayColumns: ListColumn[] =
|
||||
const displayColumns =
|
||||
config.columns.length > 0
|
||||
? config.columns
|
||||
: data.columns.map((col) => ({
|
||||
id: col,
|
||||
label: col,
|
||||
field: col,
|
||||
name: col,
|
||||
dataKey: col,
|
||||
visible: true,
|
||||
align: "left" as const,
|
||||
}));
|
||||
|
||||
// 페이지네이션
|
||||
|
|
@ -216,7 +218,12 @@ export function ListWidget({ element }: ListWidgetProps) {
|
|||
const paginatedRows = config.enablePagination ? data.rows.slice(startIdx, endIdx) : data.rows;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col gap-3 p-4">
|
||||
<div className="flex h-full w-full flex-col p-4">
|
||||
{/* 제목 - 항상 표시 */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">{element.customTitle || element.title}</h3>
|
||||
</div>
|
||||
|
||||
{/* 테이블 뷰 */}
|
||||
{config.viewMode === "table" && (
|
||||
<div className={`flex-1 overflow-auto rounded-lg border ${config.compactMode ? "text-xs" : "text-sm"}`}>
|
||||
|
|
@ -232,7 +239,7 @@ export function ListWidget({ element }: ListWidgetProps) {
|
|||
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
|
||||
style={{ width: col.width ? `${col.width}px` : undefined }}
|
||||
>
|
||||
{col.label}
|
||||
{col.label || col.name}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
|
|
@ -250,7 +257,7 @@ export function ListWidget({ element }: ListWidgetProps) {
|
|||
</TableRow>
|
||||
) : (
|
||||
paginatedRows.map((row, idx) => (
|
||||
<TableRow key={idx} className={config.stripedRows && idx % 2 === 1 ? "bg-muted/50" : ""}>
|
||||
<TableRow key={idx} className={config.stripedRows ? undefined : ""}>
|
||||
{displayColumns
|
||||
.filter((col) => col.visible)
|
||||
.map((col) => (
|
||||
|
|
@ -258,7 +265,7 @@ export function ListWidget({ element }: ListWidgetProps) {
|
|||
key={col.id}
|
||||
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
|
||||
>
|
||||
{String(row[col.field] ?? "")}
|
||||
{String(row[col.dataKey || col.field] ?? "")}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
|
|
@ -288,11 +295,11 @@ export function ListWidget({ element }: ListWidgetProps) {
|
|||
.filter((col) => col.visible)
|
||||
.map((col) => (
|
||||
<div key={col.id}>
|
||||
<div className="text-xs font-medium text-gray-500">{col.label}</div>
|
||||
<div className="text-xs font-medium text-gray-500">{col.label || col.name}</div>
|
||||
<div
|
||||
className={`font-medium text-gray-900 ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
|
||||
>
|
||||
{String(row[col.field] ?? "")}
|
||||
{String(row[col.dataKey || col.field] ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -306,7 +313,7 @@ export function ListWidget({ element }: ListWidgetProps) {
|
|||
|
||||
{/* 페이지네이션 */}
|
||||
{config.enablePagination && totalPages > 1 && (
|
||||
<div className="flex shrink-0 items-center justify-between border-t pt-3 text-sm">
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<div className="text-gray-600">
|
||||
{startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
const [screenDimensions, setScreenDimensions] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
} | null>(null);
|
||||
|
||||
// 폼 데이터 상태 추가
|
||||
|
|
@ -42,11 +44,20 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
// 화면의 실제 크기 계산 함수
|
||||
const calculateScreenDimensions = (components: ComponentData[]) => {
|
||||
if (components.length === 0) {
|
||||
return {
|
||||
width: 400,
|
||||
height: 300,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 모든 컴포넌트의 경계 찾기
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
components.forEach((component) => {
|
||||
const x = parseFloat(component.position?.x?.toString() || "0");
|
||||
|
|
@ -60,17 +71,22 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
maxY = Math.max(maxY, y + height);
|
||||
});
|
||||
|
||||
// 컨텐츠 실제 크기 + 넉넉한 여백 (양쪽 각 64px)
|
||||
// 실제 컨텐츠 크기 계산
|
||||
const contentWidth = maxX - minX;
|
||||
const contentHeight = maxY - minY;
|
||||
const padding = 128; // 좌우 또는 상하 합계 여백
|
||||
|
||||
const finalWidth = Math.max(contentWidth + padding, 400); // 최소 400px
|
||||
const finalHeight = Math.max(contentHeight + padding, 300); // 최소 300px
|
||||
// 적절한 여백 추가
|
||||
const paddingX = 40;
|
||||
const paddingY = 40;
|
||||
|
||||
const finalWidth = Math.max(contentWidth + paddingX, 400);
|
||||
const finalHeight = Math.max(contentHeight + paddingY, 300);
|
||||
|
||||
return {
|
||||
width: Math.min(finalWidth, window.innerWidth * 0.98),
|
||||
height: Math.min(finalHeight, window.innerHeight * 0.95),
|
||||
width: Math.min(finalWidth, window.innerWidth * 0.95),
|
||||
height: Math.min(finalHeight, window.innerHeight * 0.9),
|
||||
offsetX: Math.max(0, minX - paddingX / 2), // 좌측 여백 고려
|
||||
offsetY: Math.max(0, minY - paddingY / 2), // 상단 여백 고려
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -172,20 +188,20 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
const getModalStyle = () => {
|
||||
if (!screenDimensions) {
|
||||
return {
|
||||
className: "w-fit min-w-[400px] max-w-4xl max-h-[80vh] overflow-hidden",
|
||||
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
|
||||
style: {},
|
||||
};
|
||||
}
|
||||
|
||||
// 헤더 높이만 고려 (패딩 제거)
|
||||
const headerHeight = 73; // DialogHeader 실제 높이 (border-b px-6 py-4 포함)
|
||||
// 헤더 높이를 최소화 (제목 영역만)
|
||||
const headerHeight = 60; // DialogHeader 최소 높이 (타이틀 + 최소 패딩)
|
||||
const totalHeight = screenDimensions.height + headerHeight;
|
||||
|
||||
return {
|
||||
className: "overflow-hidden p-0",
|
||||
style: {
|
||||
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`, // 화면 크기 그대로
|
||||
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`, // 헤더 + 화면 높이
|
||||
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`,
|
||||
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
|
||||
maxWidth: "98vw",
|
||||
maxHeight: "95vh",
|
||||
},
|
||||
|
|
@ -197,12 +213,14 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
return (
|
||||
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className={`${modalStyle.className} ${className || ""}`} style={modalStyle.style}>
|
||||
<DialogHeader className="border-b px-6 py-4">
|
||||
<DialogTitle>{modalState.title}</DialogTitle>
|
||||
<DialogDescription>{loading ? "화면을 불러오는 중입니다..." : "화면 내용을 표시합니다."}</DialogDescription>
|
||||
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
||||
<DialogTitle className="text-base">{modalState.title}</DialogTitle>
|
||||
{loading && (
|
||||
<DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center overflow-hidden">
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -216,35 +234,50 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
style={{
|
||||
width: screenDimensions?.width || 800,
|
||||
height: screenDimensions?.height || 600,
|
||||
transformOrigin: 'center center',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
transformOrigin: "center center",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
}}
|
||||
>
|
||||
{screenData.components.map((component) => (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
component={component}
|
||||
allComponents={screenData.components}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`);
|
||||
console.log("📋 현재 formData:", formData);
|
||||
setFormData((prev) => {
|
||||
const newFormData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
console.log("📝 ScreenModal 업데이트된 formData:", newFormData);
|
||||
return newFormData;
|
||||
});
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{screenData.components.map((component) => {
|
||||
// 컴포넌트 위치를 offset만큼 조정 (왼쪽 상단으로 정렬)
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
|
||||
const adjustedComponent = {
|
||||
...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 = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
console.log("📝 ScreenModal 업데이트된 formData:", newFormData);
|
||||
return newFormData;
|
||||
});
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
|
|||
|
|
@ -9,6 +9,15 @@ import dynamic from "next/dynamic";
|
|||
|
||||
// 위젯 동적 import - 모든 위젯
|
||||
const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { ssr: false });
|
||||
const MapTestWidget = dynamic(() => import("./widgets/MapTestWidget"), { ssr: false });
|
||||
const MapTestWidgetV2 = dynamic(() => import("./widgets/MapTestWidgetV2"), { ssr: false });
|
||||
const ChartTestWidget = dynamic(() => import("./widgets/ChartTestWidget"), { ssr: false });
|
||||
const ListTestWidget = dynamic(
|
||||
() => import("./widgets/ListTestWidget").then((mod) => ({ default: mod.ListTestWidget })),
|
||||
{ ssr: false },
|
||||
);
|
||||
const CustomMetricTestWidget = dynamic(() => import("./widgets/CustomMetricTestWidget"), { ssr: false });
|
||||
const RiskAlertTestWidget = dynamic(() => import("./widgets/RiskAlertTestWidget"), { ssr: false });
|
||||
const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { ssr: false });
|
||||
const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false });
|
||||
const WeatherWidget = dynamic(() => import("./widgets/WeatherWidget"), { ssr: false });
|
||||
|
|
@ -76,6 +85,18 @@ function renderWidget(element: DashboardElement) {
|
|||
return <ClockWidget element={element} />;
|
||||
case "map-summary":
|
||||
return <MapSummaryWidget element={element} />;
|
||||
case "map-test":
|
||||
return <MapTestWidget element={element} />;
|
||||
case "map-summary-v2":
|
||||
return <MapTestWidgetV2 element={element} />;
|
||||
case "chart":
|
||||
return <ChartTestWidget element={element} />;
|
||||
case "list-v2":
|
||||
return <ListTestWidget element={element} />;
|
||||
case "custom-metric-v2":
|
||||
return <CustomMetricTestWidget element={element} />;
|
||||
case "risk-alert-v2":
|
||||
return <RiskAlertTestWidget element={element} />;
|
||||
case "risk-alert":
|
||||
return <RiskAlertWidget element={element} />;
|
||||
case "calendar":
|
||||
|
|
|
|||
|
|
@ -0,0 +1,361 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { DashboardElement, ChartDataSource, ChartData } from "@/components/admin/dashboard/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||
import { Chart } from "@/components/admin/dashboard/charts/Chart";
|
||||
|
||||
interface ChartTestWidgetProps {
|
||||
element: DashboardElement;
|
||||
}
|
||||
|
||||
const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899"];
|
||||
|
||||
export default function ChartTestWidget({ element }: ChartTestWidgetProps) {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerSize, setContainerSize] = useState({ width: 600, height: 400 });
|
||||
|
||||
console.log("🧪 ChartTestWidget 렌더링 (D3 기반)!", element);
|
||||
|
||||
const dataSources = useMemo(() => {
|
||||
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
||||
|
||||
// 컨테이너 크기 측정
|
||||
useEffect(() => {
|
||||
const updateSize = () => {
|
||||
if (containerRef.current) {
|
||||
const width = containerRef.current.offsetWidth || 600;
|
||||
const height = containerRef.current.offsetHeight || 400;
|
||||
setContainerSize({ width, height });
|
||||
}
|
||||
};
|
||||
|
||||
updateSize();
|
||||
window.addEventListener("resize", updateSize);
|
||||
return () => window.removeEventListener("resize", updateSize);
|
||||
}, []);
|
||||
|
||||
// 다중 데이터 소스 로딩
|
||||
const loadMultipleDataSources = useCallback(async () => {
|
||||
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
|
||||
|
||||
if (!dataSources || dataSources.length === 0) {
|
||||
console.log("⚠️ 데이터 소스가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
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") {
|
||||
return await loadDatabaseData(source);
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (err: any) {
|
||||
console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const allData: any[] = [];
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === "fulfilled" && Array.isArray(result.value)) {
|
||||
const sourceData = result.value.map((item: any) => ({
|
||||
...item,
|
||||
_source: dataSources[index].name || dataSources[index].id || `소스 ${index + 1}`,
|
||||
}));
|
||||
allData.push(...sourceData);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`✅ 총 ${allData.length}개의 데이터 로딩 완료`);
|
||||
setData(allData);
|
||||
setLastRefreshTime(new Date());
|
||||
} catch (err: any) {
|
||||
console.error("❌ 데이터 로딩 중 오류:", err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
||||
|
||||
// 수동 새로고침
|
||||
const handleManualRefresh = useCallback(() => {
|
||||
console.log("🔄 수동 새로고침 버튼 클릭");
|
||||
loadMultipleDataSources();
|
||||
}, [loadMultipleDataSources]);
|
||||
|
||||
// REST API 데이터 로딩
|
||||
const loadRestApiData = async (source: ChartDataSource): Promise<any[]> => {
|
||||
if (!source.endpoint) {
|
||||
throw new Error("API endpoint가 없습니다.");
|
||||
}
|
||||
|
||||
const queryParams: Record<string, string> = {};
|
||||
if (source.queryParams) {
|
||||
source.queryParams.forEach((param) => {
|
||||
if (param.key && param.value) {
|
||||
queryParams[param.key] = param.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (source.headers) {
|
||||
source.headers.forEach((header) => {
|
||||
if (header.key && header.value) {
|
||||
headers[header.key] = header.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch("/api/dashboards/fetch-external-api", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
url: source.endpoint,
|
||||
method: source.method || "GET",
|
||||
headers,
|
||||
queryParams,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API 호출 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "API 호출 실패");
|
||||
}
|
||||
|
||||
let apiData = result.data;
|
||||
if (source.jsonPath) {
|
||||
const pathParts = source.jsonPath.split(".");
|
||||
for (const part of pathParts) {
|
||||
apiData = apiData?.[part];
|
||||
}
|
||||
}
|
||||
|
||||
const rows = Array.isArray(apiData) ? apiData : [apiData];
|
||||
return applyColumnMapping(rows, source.columnMapping);
|
||||
};
|
||||
|
||||
// Database 데이터 로딩
|
||||
const loadDatabaseData = async (source: ChartDataSource): Promise<any[]> => {
|
||||
if (!source.query) {
|
||||
throw new Error("SQL 쿼리가 없습니다.");
|
||||
}
|
||||
|
||||
let result;
|
||||
if (source.connectionType === "external" && source.externalConnectionId) {
|
||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||
result = await ExternalDbConnectionAPI.executeQuery(parseInt(source.externalConnectionId), source.query);
|
||||
} else {
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
|
||||
try {
|
||||
const queryResult = await dashboardApi.executeQuery(source.query);
|
||||
result = {
|
||||
success: true,
|
||||
rows: queryResult.rows || [],
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.error("❌ 내부 DB 쿼리 실패:", err);
|
||||
throw new Error(err.message || "쿼리 실패");
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "쿼리 실패");
|
||||
}
|
||||
|
||||
const rows = result.rows || result.data || [];
|
||||
return applyColumnMapping(rows, source.columnMapping);
|
||||
};
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
if (dataSources && dataSources.length > 0) {
|
||||
loadMultipleDataSources();
|
||||
}
|
||||
}, [dataSources, loadMultipleDataSources]);
|
||||
|
||||
// 자동 새로고침
|
||||
useEffect(() => {
|
||||
if (!dataSources || dataSources.length === 0) return;
|
||||
|
||||
const intervals = dataSources
|
||||
.map((ds) => ds.refreshInterval)
|
||||
.filter((interval): interval is number => typeof interval === "number" && interval > 0);
|
||||
|
||||
if (intervals.length === 0) return;
|
||||
|
||||
const minInterval = Math.min(...intervals);
|
||||
console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
console.log("🔄 자동 새로고침 실행");
|
||||
loadMultipleDataSources();
|
||||
}, minInterval * 1000);
|
||||
|
||||
return () => {
|
||||
console.log("⏹️ 자동 새로고침 정리");
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [dataSources, loadMultipleDataSources]);
|
||||
|
||||
const chartConfig = element?.chartConfig || {};
|
||||
const chartType = chartConfig.chartType || "line";
|
||||
const mergeMode = chartConfig.mergeMode || false;
|
||||
const dataSourceConfigs = chartConfig.dataSourceConfigs || [];
|
||||
|
||||
// 데이터를 D3 Chart 컴포넌트 형식으로 변환
|
||||
const chartData = useMemo((): ChartData | null => {
|
||||
if (data.length === 0 || dataSourceConfigs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labels = new Set<string>();
|
||||
const datasets: any[] = [];
|
||||
|
||||
// 병합 모드: 여러 데이터 소스를 하나로 합침
|
||||
if (mergeMode && dataSourceConfigs.length > 1) {
|
||||
const baseConfig = dataSourceConfigs[0];
|
||||
const xAxisField = baseConfig.xAxis;
|
||||
const yAxisField = baseConfig.yAxis[0];
|
||||
|
||||
// X축 값 수집
|
||||
dataSourceConfigs.forEach((dsConfig) => {
|
||||
const sourceName = dataSources?.find((ds) => ds.id === dsConfig.dataSourceId)?.name;
|
||||
const sourceData = data.filter((item) => item._source === sourceName);
|
||||
sourceData.forEach((item) => {
|
||||
if (item[xAxisField] !== undefined) {
|
||||
labels.add(String(item[xAxisField]));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 데이터 병합
|
||||
const mergedData: number[] = [];
|
||||
labels.forEach((label) => {
|
||||
let totalValue = 0;
|
||||
dataSourceConfigs.forEach((dsConfig) => {
|
||||
const sourceName = dataSources?.find((ds) => ds.id === dsConfig.dataSourceId)?.name;
|
||||
const sourceData = data.filter((item) => item._source === sourceName);
|
||||
const matchingItem = sourceData.find((item) => String(item[xAxisField]) === label);
|
||||
if (matchingItem && yAxisField) {
|
||||
totalValue += parseFloat(matchingItem[yAxisField]) || 0;
|
||||
}
|
||||
});
|
||||
mergedData.push(totalValue);
|
||||
});
|
||||
|
||||
datasets.push({
|
||||
label: yAxisField,
|
||||
data: mergedData,
|
||||
color: COLORS[0],
|
||||
});
|
||||
} else {
|
||||
// 일반 모드: 각 데이터 소스를 별도로 표시
|
||||
dataSourceConfigs.forEach((dsConfig, index) => {
|
||||
const sourceName = dataSources?.find((ds) => ds.id === dsConfig.dataSourceId)?.name || `소스 ${index + 1}`;
|
||||
const sourceData = data.filter((item) => item._source === sourceName);
|
||||
|
||||
// X축 값 수집
|
||||
sourceData.forEach((item) => {
|
||||
const xValue = item[dsConfig.xAxis];
|
||||
if (xValue !== undefined) {
|
||||
labels.add(String(xValue));
|
||||
}
|
||||
});
|
||||
|
||||
// Y축 데이터 수집
|
||||
const yField = dsConfig.yAxis[0];
|
||||
const dataValues: number[] = [];
|
||||
|
||||
labels.forEach((label) => {
|
||||
const matchingItem = sourceData.find((item) => String(item[dsConfig.xAxis]) === label);
|
||||
dataValues.push(matchingItem && yField ? parseFloat(matchingItem[yField]) || 0 : 0);
|
||||
});
|
||||
|
||||
datasets.push({
|
||||
label: dsConfig.label || sourceName,
|
||||
data: dataValues,
|
||||
color: COLORS[index % COLORS.length],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
labels: Array.from(labels),
|
||||
datasets,
|
||||
};
|
||||
}, [data, dataSourceConfigs, mergeMode, dataSources]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-white">
|
||||
{/* 차트 영역 - 전체 공간 사용 */}
|
||||
<div ref={containerRef} className="flex-1 overflow-hidden p-2">
|
||||
{error ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-destructive text-sm">{error}</p>
|
||||
</div>
|
||||
) : !dataSources || dataSources.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">데이터 소스를 연결해주세요</p>
|
||||
</div>
|
||||
) : loading && data.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground mt-2 text-xs">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : !chartData ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-center text-sm">
|
||||
차트 설정에서 데이터 소스를 추가하고
|
||||
<br />
|
||||
X축, Y축을 설정해주세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Chart
|
||||
chartType={chartType as any}
|
||||
data={chartData}
|
||||
config={chartConfig}
|
||||
width={containerSize.width - 16}
|
||||
height={containerSize.height - 16}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터 - 주석 처리 (공간 확보) */}
|
||||
{/* {data.length > 0 && (
|
||||
<div className="text-muted-foreground border-t px-3 py-1.5 text-[10px]">
|
||||
총 {data.length.toLocaleString()}개 데이터 표시 중
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,889 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
interface CustomMetricTestWidgetProps {
|
||||
element: DashboardElement;
|
||||
}
|
||||
|
||||
// 집계 함수 실행
|
||||
const calculateMetric = (rows: any[], field: string, aggregation: string): number => {
|
||||
if (rows.length === 0) return 0;
|
||||
|
||||
switch (aggregation) {
|
||||
case "count":
|
||||
return rows.length;
|
||||
case "sum": {
|
||||
return rows.reduce((sum, row) => sum + (parseFloat(row[field]) || 0), 0);
|
||||
}
|
||||
case "avg": {
|
||||
const sum = rows.reduce((s, row) => s + (parseFloat(row[field]) || 0), 0);
|
||||
return rows.length > 0 ? sum / rows.length : 0;
|
||||
}
|
||||
case "min": {
|
||||
return Math.min(...rows.map((row) => parseFloat(row[field]) || 0));
|
||||
}
|
||||
case "max": {
|
||||
return Math.max(...rows.map((row) => parseFloat(row[field]) || 0));
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// 색상 스타일 매핑
|
||||
const colorMap = {
|
||||
indigo: { bg: "bg-indigo-50", text: "text-indigo-600", border: "border-indigo-200" },
|
||||
green: { bg: "bg-green-50", text: "text-green-600", border: "border-green-200" },
|
||||
blue: { bg: "bg-blue-50", text: "text-blue-600", border: "border-blue-200" },
|
||||
purple: { bg: "bg-purple-50", text: "text-purple-600", border: "border-purple-200" },
|
||||
orange: { bg: "bg-orange-50", text: "text-orange-600", border: "border-orange-200" },
|
||||
gray: { bg: "bg-gray-50", text: "text-gray-600", border: "border-gray-200" },
|
||||
};
|
||||
|
||||
/**
|
||||
* 통계 카드 위젯 (다중 데이터 소스 지원)
|
||||
* - 여러 REST API 연결 가능
|
||||
* - 여러 Database 연결 가능
|
||||
* - REST API + Database 혼합 가능
|
||||
* - 데이터 자동 병합 후 집계
|
||||
*/
|
||||
export default function CustomMetricTestWidget({ element }: CustomMetricTestWidgetProps) {
|
||||
const [metrics, setMetrics] = useState<any[]>([]);
|
||||
const [groupedCards, setGroupedCards] = useState<Array<{ label: string; value: number }>>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||
const [selectedMetric, setSelectedMetric] = useState<any | null>(null);
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||
|
||||
console.log("🧪 CustomMetricTestWidget 렌더링!", element);
|
||||
|
||||
const dataSources = useMemo(() => {
|
||||
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
||||
|
||||
// 🆕 그룹별 카드 모드 체크
|
||||
const isGroupByMode = element?.customMetricConfig?.groupByMode || false;
|
||||
|
||||
// 메트릭 설정 (없으면 기본값 사용) - useMemo로 메모이제이션
|
||||
const metricConfig = useMemo(() => {
|
||||
return (
|
||||
element?.customMetricConfig?.metrics || [
|
||||
{
|
||||
label: "총 개수",
|
||||
field: "id",
|
||||
aggregation: "count",
|
||||
color: "indigo",
|
||||
},
|
||||
]
|
||||
);
|
||||
}, [element?.customMetricConfig?.metrics]);
|
||||
|
||||
// 🆕 그룹별 카드 데이터 로드 (원본에서 복사)
|
||||
const loadGroupByData = useCallback(async () => {
|
||||
const groupByDS = element?.customMetricConfig?.groupByDataSource;
|
||||
if (!groupByDS) return;
|
||||
|
||||
const dataSourceType = groupByDS.type;
|
||||
|
||||
// Database 타입
|
||||
if (dataSourceType === "database") {
|
||||
if (!groupByDS.query) return;
|
||||
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(groupByDS.query);
|
||||
|
||||
if (result.success && result.data?.rows) {
|
||||
const rows = result.data.rows;
|
||||
if (rows.length > 0) {
|
||||
const columns = result.data.columns || Object.keys(rows[0]);
|
||||
const labelColumn = columns[0];
|
||||
const valueColumn = columns[1];
|
||||
|
||||
const cards = rows.map((row: any) => ({
|
||||
label: String(row[labelColumn] || ""),
|
||||
value: parseFloat(row[valueColumn]) || 0,
|
||||
}));
|
||||
|
||||
setGroupedCards(cards);
|
||||
}
|
||||
}
|
||||
}
|
||||
// API 타입
|
||||
else if (dataSourceType === "api") {
|
||||
if (!groupByDS.endpoint) return;
|
||||
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.fetchExternalApi({
|
||||
method: "GET",
|
||||
url: groupByDS.endpoint,
|
||||
headers: (groupByDS as any).headers || {},
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
let rows: any[] = [];
|
||||
if (Array.isArray(result.data)) {
|
||||
rows = result.data;
|
||||
} else if (result.data.results && Array.isArray(result.data.results)) {
|
||||
rows = result.data.results;
|
||||
} else if (result.data.items && Array.isArray(result.data.items)) {
|
||||
rows = result.data.items;
|
||||
} else if (result.data.data && Array.isArray(result.data.data)) {
|
||||
rows = result.data.data;
|
||||
} else {
|
||||
rows = [result.data];
|
||||
}
|
||||
|
||||
if (rows.length > 0) {
|
||||
const columns = Object.keys(rows[0]);
|
||||
const labelColumn = columns[0];
|
||||
const valueColumn = columns[1];
|
||||
|
||||
const cards = rows.map((row: any) => ({
|
||||
label: String(row[labelColumn] || ""),
|
||||
value: parseFloat(row[valueColumn]) || 0,
|
||||
}));
|
||||
|
||||
setGroupedCards(cards);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [element?.customMetricConfig?.groupByDataSource]);
|
||||
|
||||
// 다중 데이터 소스 로딩
|
||||
const loadMultipleDataSources = useCallback(async () => {
|
||||
const dataSources = element?.dataSources || element?.chartConfig?.dataSources;
|
||||
|
||||
if (!dataSources || dataSources.length === 0) {
|
||||
console.log("⚠️ 데이터 소스가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 모든 데이터 소스를 병렬로 로딩 (각각 별도로 처리)
|
||||
const results = await Promise.allSettled(
|
||||
dataSources.map(async (source, sourceIndex) => {
|
||||
try {
|
||||
console.log(`📡 데이터 소스 ${sourceIndex + 1} "${source.name || source.id}" 로딩 중...`);
|
||||
|
||||
let rows: any[] = [];
|
||||
if (source.type === "api") {
|
||||
rows = await loadRestApiData(source);
|
||||
} else if (source.type === "database") {
|
||||
rows = await loadDatabaseData(source);
|
||||
}
|
||||
|
||||
console.log(`✅ 데이터 소스 ${sourceIndex + 1}: ${rows.length}개 행`);
|
||||
|
||||
return {
|
||||
sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`,
|
||||
sourceIndex: sourceIndex,
|
||||
rows: rows,
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err);
|
||||
return {
|
||||
sourceName: source.name || `데이터 소스 ${sourceIndex + 1}`,
|
||||
sourceIndex: sourceIndex,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(`✅ 총 ${results.length}개의 데이터 소스 로딩 완료`);
|
||||
|
||||
// 각 데이터 소스별로 메트릭 생성
|
||||
const allMetrics: any[] = [];
|
||||
const colors = ["indigo", "green", "blue", "purple", "orange", "gray"];
|
||||
|
||||
results.forEach((result) => {
|
||||
if (result.status !== "fulfilled" || !result.value.rows || result.value.rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { sourceName, rows } = result.value;
|
||||
|
||||
// 집계된 데이터인지 확인 (행이 적고 숫자 컬럼이 있으면)
|
||||
const hasAggregatedData = rows.length > 0 && rows.length <= 100;
|
||||
|
||||
if (hasAggregatedData && rows.length > 0) {
|
||||
const firstRow = rows[0];
|
||||
const columns = Object.keys(firstRow);
|
||||
|
||||
// 숫자 컬럼 찾기
|
||||
const numericColumns = columns.filter((col) => {
|
||||
const value = firstRow[col];
|
||||
return typeof value === "number" || !isNaN(Number(value));
|
||||
});
|
||||
|
||||
// 문자열 컬럼 찾기
|
||||
const stringColumns = columns.filter((col) => {
|
||||
const value = firstRow[col];
|
||||
return typeof value === "string" || !numericColumns.includes(col);
|
||||
});
|
||||
|
||||
console.log(`📊 [${sourceName}] 컬럼 분석:`, {
|
||||
전체: columns,
|
||||
숫자: numericColumns,
|
||||
문자열: stringColumns,
|
||||
});
|
||||
|
||||
// 🆕 자동 집계 로직: 집계 컬럼 이름으로 판단 (count, 개수, sum, avg 등)
|
||||
const isAggregated = numericColumns.some((col) =>
|
||||
/count|개수|sum|합계|avg|평균|min|최소|max|최대|total|전체/i.test(col)
|
||||
);
|
||||
|
||||
if (isAggregated && numericColumns.length > 0) {
|
||||
// 집계 컬럼이 있으면 이미 집계된 데이터로 판단 (GROUP BY 결과)
|
||||
console.log(`✅ [${sourceName}] 집계된 데이터로 판단 (집계 컬럼 발견: ${numericColumns.join(", ")})`);
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
// 라벨: 첫 번째 문자열 컬럼
|
||||
const labelField = stringColumns[0] || columns[0];
|
||||
const label = String(row[labelField] || `항목 ${index + 1}`);
|
||||
|
||||
// 값: 첫 번째 숫자 컬럼
|
||||
const valueField = numericColumns[0];
|
||||
const value = Number(row[valueField]) || 0;
|
||||
|
||||
console.log(` [${sourceName}] 메트릭: ${label} = ${value}`);
|
||||
|
||||
allMetrics.push({
|
||||
label: label,
|
||||
value: value,
|
||||
field: valueField,
|
||||
aggregation: "custom",
|
||||
color: colors[allMetrics.length % colors.length],
|
||||
sourceName: sourceName,
|
||||
rawData: [row],
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 숫자 컬럼이 없으면 자동 집계 (마지막 컬럼 기준)
|
||||
console.log(`✅ [${sourceName}] 자동 집계 모드 (숫자 컬럼 없음)`);
|
||||
|
||||
// 마지막 컬럼을 집계 기준으로 사용
|
||||
const aggregateField = columns[columns.length - 1];
|
||||
console.log(` [${sourceName}] 집계 기준 컬럼: ${aggregateField}`);
|
||||
|
||||
// 해당 컬럼의 값별로 카운트
|
||||
const countMap = new Map<string, number>();
|
||||
rows.forEach((row) => {
|
||||
const value = String(row[aggregateField] || "기타");
|
||||
countMap.set(value, (countMap.get(value) || 0) + 1);
|
||||
});
|
||||
|
||||
// 카운트 결과를 메트릭으로 변환
|
||||
countMap.forEach((count, label) => {
|
||||
console.log(` [${sourceName}] 자동 집계: ${label} = ${count}개`);
|
||||
|
||||
allMetrics.push({
|
||||
label: label,
|
||||
value: count,
|
||||
field: aggregateField,
|
||||
aggregation: "count",
|
||||
color: colors[allMetrics.length % colors.length],
|
||||
sourceName: sourceName,
|
||||
rawData: rows.filter((row) => String(row[aggregateField]) === label),
|
||||
});
|
||||
});
|
||||
|
||||
// 전체 개수도 추가
|
||||
allMetrics.push({
|
||||
label: "전체",
|
||||
value: rows.length,
|
||||
field: "count",
|
||||
aggregation: "count",
|
||||
color: colors[allMetrics.length % colors.length],
|
||||
sourceName: sourceName,
|
||||
rawData: rows,
|
||||
});
|
||||
}
|
||||
|
||||
// 🆕 숫자 컬럼이 없을 때의 기존 로직은 주석 처리
|
||||
if (false) {
|
||||
// 숫자 컬럼이 없으면 각 컬럼별 고유값 개수 표시
|
||||
console.log(`📊 [${sourceName}] 문자열 데이터, 각 컬럼별 고유값 개수 표시`);
|
||||
|
||||
// 데이터 소스에서 선택된 컬럼 가져오기
|
||||
const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
|
||||
(ds) => ds.name === sourceName || ds.id === result.value.sourceIndex.toString(),
|
||||
);
|
||||
const selectedColumns = dataSourceConfig?.selectedColumns || [];
|
||||
|
||||
// 선택된 컬럼이 있으면 해당 컬럼만, 없으면 전체 컬럼 표시
|
||||
const columnsToShow = selectedColumns.length > 0 ? selectedColumns : columns;
|
||||
|
||||
console.log(` [${sourceName}] 표시할 컬럼:`, columnsToShow);
|
||||
|
||||
columnsToShow.forEach((col) => {
|
||||
// 해당 컬럼이 실제로 존재하는지 확인
|
||||
if (!columns.includes(col)) {
|
||||
console.warn(` [${sourceName}] 컬럼 "${col}"이 데이터에 없습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 해당 컬럼의 고유값 개수 계산
|
||||
const uniqueValues = new Set(rows.map((row) => row[col]));
|
||||
const uniqueCount = uniqueValues.size;
|
||||
|
||||
console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
|
||||
|
||||
allMetrics.push({
|
||||
label: `${sourceName} - ${col} (고유값)`,
|
||||
value: uniqueCount,
|
||||
field: col,
|
||||
aggregation: "distinct",
|
||||
color: colors[allMetrics.length % colors.length],
|
||||
sourceName: sourceName,
|
||||
rawData: rows, // 원본 데이터 저장
|
||||
});
|
||||
});
|
||||
|
||||
// 총 행 개수도 추가
|
||||
allMetrics.push({
|
||||
label: `${sourceName} - 총 개수`,
|
||||
value: rows.length,
|
||||
field: "count",
|
||||
aggregation: "count",
|
||||
color: colors[allMetrics.length % colors.length],
|
||||
sourceName: sourceName,
|
||||
rawData: rows, // 원본 데이터 저장
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 행이 많으면 각 컬럼별 고유값 개수 + 총 개수 표시
|
||||
console.log(`📊 [${sourceName}] 일반 데이터 (행 많음), 컬럼별 통계 표시`);
|
||||
|
||||
const firstRow = rows[0];
|
||||
const columns = Object.keys(firstRow);
|
||||
|
||||
// 데이터 소스에서 선택된 컬럼 가져오기
|
||||
const dataSourceConfig = (element?.dataSources || element?.chartConfig?.dataSources)?.find(
|
||||
(ds) => ds.name === sourceName || ds.id === result.value.sourceIndex.toString(),
|
||||
);
|
||||
const selectedColumns = dataSourceConfig?.selectedColumns || [];
|
||||
|
||||
// 선택된 컬럼이 있으면 해당 컬럼만, 없으면 전체 컬럼 표시
|
||||
const columnsToShow = selectedColumns.length > 0 ? selectedColumns : columns;
|
||||
|
||||
console.log(` [${sourceName}] 표시할 컬럼:`, columnsToShow);
|
||||
|
||||
// 각 컬럼별 고유값 개수
|
||||
columnsToShow.forEach((col) => {
|
||||
// 해당 컬럼이 실제로 존재하는지 확인
|
||||
if (!columns.includes(col)) {
|
||||
console.warn(` [${sourceName}] 컬럼 "${col}"이 데이터에 없습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueValues = new Set(rows.map((row) => row[col]));
|
||||
const uniqueCount = uniqueValues.size;
|
||||
|
||||
console.log(` [${sourceName}] ${col}: ${uniqueCount}개 고유값`);
|
||||
|
||||
allMetrics.push({
|
||||
label: `${sourceName} - ${col} (고유값)`,
|
||||
value: uniqueCount,
|
||||
field: col,
|
||||
aggregation: "distinct",
|
||||
color: colors[allMetrics.length % colors.length],
|
||||
sourceName: sourceName,
|
||||
rawData: rows, // 원본 데이터 저장
|
||||
});
|
||||
});
|
||||
|
||||
// 총 행 개수
|
||||
allMetrics.push({
|
||||
label: `${sourceName} - 총 개수`,
|
||||
value: rows.length,
|
||||
field: "count",
|
||||
aggregation: "count",
|
||||
color: colors[allMetrics.length % colors.length],
|
||||
sourceName: sourceName,
|
||||
rawData: rows, // 원본 데이터 저장
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`✅ 총 ${allMetrics.length}개의 메트릭 생성 완료`);
|
||||
setMetrics(allMetrics);
|
||||
setLastRefreshTime(new Date());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [element?.dataSources, element?.chartConfig?.dataSources, metricConfig]);
|
||||
|
||||
// 🆕 통합 데이터 로딩 (그룹별 카드 + 일반 메트릭)
|
||||
const loadAllData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 그룹별 카드 데이터 로드
|
||||
if (isGroupByMode && element?.customMetricConfig?.groupByDataSource) {
|
||||
await loadGroupByData();
|
||||
}
|
||||
|
||||
// 일반 메트릭 데이터 로드
|
||||
if (dataSources && dataSources.length > 0) {
|
||||
await loadMultipleDataSources();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("데이터 로드 실패:", err);
|
||||
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [
|
||||
isGroupByMode,
|
||||
element?.customMetricConfig?.groupByDataSource,
|
||||
dataSources,
|
||||
loadGroupByData,
|
||||
loadMultipleDataSources,
|
||||
]);
|
||||
|
||||
// 수동 새로고침 핸들러
|
||||
const handleManualRefresh = useCallback(() => {
|
||||
console.log("🔄 수동 새로고침 버튼 클릭");
|
||||
loadAllData();
|
||||
}, [loadAllData]);
|
||||
|
||||
// XML 데이터 파싱
|
||||
const parseXmlData = (xmlText: string): any[] => {
|
||||
console.log("🔍 XML 파싱 시작");
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
|
||||
|
||||
const records = xmlDoc.getElementsByTagName("record");
|
||||
const result: any[] = [];
|
||||
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const record = records[i];
|
||||
const obj: any = {};
|
||||
|
||||
for (let j = 0; j < record.children.length; j++) {
|
||||
const child = record.children[j];
|
||||
obj[child.tagName] = child.textContent || "";
|
||||
}
|
||||
|
||||
result.push(obj);
|
||||
}
|
||||
|
||||
console.log(`✅ XML 파싱 완료: ${result.length}개 레코드`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("❌ XML 파싱 실패:", error);
|
||||
throw new Error("XML 파싱 실패");
|
||||
}
|
||||
};
|
||||
|
||||
// 텍스트/CSV 데이터 파싱
|
||||
const parseTextData = (text: string): any[] => {
|
||||
console.log("🔍 텍스트 파싱 시작 (처음 500자):", text.substring(0, 500));
|
||||
|
||||
// XML 감지
|
||||
if (text.trim().startsWith("<?xml") || text.trim().startsWith("<result>")) {
|
||||
console.log("📄 XML 형식 감지");
|
||||
return parseXmlData(text);
|
||||
}
|
||||
|
||||
// CSV 파싱
|
||||
console.log("📄 CSV 형식으로 파싱 시도");
|
||||
const lines = text.trim().split("\n");
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
const headers = lines[0].split(",").map((h) => h.trim());
|
||||
const result: any[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values = lines[i].split(",");
|
||||
const obj: any = {};
|
||||
|
||||
headers.forEach((header, index) => {
|
||||
obj[header] = values[index]?.trim() || "";
|
||||
});
|
||||
|
||||
result.push(obj);
|
||||
}
|
||||
|
||||
console.log(`✅ CSV 파싱 완료: ${result.length}개 행`);
|
||||
return result;
|
||||
};
|
||||
|
||||
// REST API 데이터 로딩
|
||||
const loadRestApiData = async (source: ChartDataSource): Promise<any[]> => {
|
||||
if (!source.endpoint) {
|
||||
throw new Error("API endpoint가 없습니다.");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// queryParams 배열 또는 객체 처리
|
||||
if (source.queryParams) {
|
||||
if (Array.isArray(source.queryParams)) {
|
||||
source.queryParams.forEach((param: any) => {
|
||||
if (param.key && param.value) {
|
||||
params.append(param.key, String(param.value));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
Object.entries(source.queryParams).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🌐 API 호출:", source.endpoint, "파라미터:", Object.fromEntries(params));
|
||||
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: source.endpoint,
|
||||
method: "GET",
|
||||
headers: source.headers || {},
|
||||
queryParams: Object.fromEntries(params),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("❌ API 호출 실패:", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body: errorText.substring(0, 500),
|
||||
});
|
||||
throw new Error(`HTTP ${response.status}: ${errorText.substring(0, 100)}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log("✅ API 응답:", result);
|
||||
|
||||
if (!result.success) {
|
||||
console.error("❌ API 실패:", result);
|
||||
throw new Error(result.message || result.error || "외부 API 호출 실패");
|
||||
}
|
||||
|
||||
let processedData = result.data;
|
||||
|
||||
// 텍스트/XML 데이터 처리
|
||||
if (typeof processedData === "string") {
|
||||
console.log("📄 텍스트 형식 데이터 감지");
|
||||
processedData = parseTextData(processedData);
|
||||
} else if (processedData && typeof processedData === "object" && processedData.text) {
|
||||
console.log("📄 래핑된 텍스트 데이터 감지");
|
||||
processedData = parseTextData(processedData.text);
|
||||
}
|
||||
|
||||
// JSON Path 처리
|
||||
if (source.jsonPath) {
|
||||
const paths = source.jsonPath.split(".");
|
||||
for (const path of paths) {
|
||||
if (processedData && typeof processedData === "object" && path in processedData) {
|
||||
processedData = processedData[path];
|
||||
} else {
|
||||
throw new Error(`JSON Path "${source.jsonPath}"에서 데이터를 찾을 수 없습니다`);
|
||||
}
|
||||
}
|
||||
} else if (!Array.isArray(processedData) && typeof processedData === "object") {
|
||||
// JSON Path 없으면 자동으로 배열 찾기
|
||||
console.log("🔍 JSON Path 없음, 자동으로 배열 찾기 시도");
|
||||
const arrayKeys = ["data", "items", "result", "records", "rows", "list"];
|
||||
|
||||
for (const key of arrayKeys) {
|
||||
if (Array.isArray(processedData[key])) {
|
||||
console.log(`✅ 배열 발견: ${key}`);
|
||||
processedData = processedData[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows = Array.isArray(processedData) ? processedData : [processedData];
|
||||
|
||||
// 컬럼 매핑 적용
|
||||
return applyColumnMapping(rows, source.columnMapping);
|
||||
};
|
||||
|
||||
// Database 데이터 로딩
|
||||
const loadDatabaseData = async (source: ChartDataSource): Promise<any[]> => {
|
||||
if (!source.query) {
|
||||
throw new Error("SQL 쿼리가 없습니다.");
|
||||
}
|
||||
|
||||
let rows: any[] = [];
|
||||
|
||||
if (source.connectionType === "external" && source.externalConnectionId) {
|
||||
// 외부 DB
|
||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||
const externalResult = await ExternalDbConnectionAPI.executeQuery(
|
||||
parseInt(source.externalConnectionId),
|
||||
source.query,
|
||||
);
|
||||
|
||||
if (!externalResult.success || !externalResult.data) {
|
||||
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
||||
}
|
||||
|
||||
const resultData = externalResult.data as unknown as {
|
||||
rows: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
rows = resultData.rows;
|
||||
} else {
|
||||
// 현재 DB
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(source.query);
|
||||
|
||||
rows = result.rows;
|
||||
}
|
||||
|
||||
// 컬럼 매핑 적용
|
||||
return applyColumnMapping(rows, source.columnMapping);
|
||||
};
|
||||
|
||||
// 초기 로드 (🆕 loadAllData 사용)
|
||||
useEffect(() => {
|
||||
if ((dataSources && dataSources.length > 0) || (isGroupByMode && element?.customMetricConfig?.groupByDataSource)) {
|
||||
loadAllData();
|
||||
}
|
||||
}, [dataSources, isGroupByMode, element?.customMetricConfig?.groupByDataSource, loadAllData]);
|
||||
|
||||
// 자동 새로고침 (🆕 loadAllData 사용)
|
||||
useEffect(() => {
|
||||
if (!dataSources || dataSources.length === 0) return;
|
||||
|
||||
const intervals = dataSources
|
||||
.map((ds) => ds.refreshInterval)
|
||||
.filter((interval): interval is number => typeof interval === "number" && interval > 0);
|
||||
|
||||
if (intervals.length === 0) return;
|
||||
|
||||
const minInterval = Math.min(...intervals);
|
||||
console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
console.log("🔄 자동 새로고침 실행");
|
||||
loadAllData();
|
||||
}, minInterval * 1000);
|
||||
|
||||
return () => {
|
||||
console.log("⏹️ 자동 새로고침 정리");
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [dataSources, loadAllData]);
|
||||
|
||||
// renderMetricCard 함수 제거 - 인라인으로 렌더링
|
||||
|
||||
// 로딩 상태 (원본 스타일)
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||
<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-gray-500">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태 (원본 스타일)
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-red-600">⚠️ {error}</p>
|
||||
<button
|
||||
onClick={handleManualRefresh}
|
||||
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 소스 없음 (원본 스타일)
|
||||
if (!(element?.dataSources || element?.chartConfig?.dataSources) && !isGroupByMode) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||
<p className="text-sm text-gray-500">데이터 소스를 연결해주세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 메트릭 설정 없음 (원본 스타일)
|
||||
if (metricConfig.length === 0 && !isGroupByMode) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||
<p className="text-sm text-gray-500">메트릭을 설정해주세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 메인 렌더링 (원본 스타일 - 심플하게)
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-white p-2">
|
||||
{/* 콘텐츠 영역 - 스크롤 가능하도록 개선 */}
|
||||
<div className="grid w-full gap-2 overflow-y-auto" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))" }}>
|
||||
{/* 그룹별 카드 (활성화 시) */}
|
||||
{isGroupByMode &&
|
||||
groupedCards.map((card, index) => {
|
||||
// 색상 순환 (6가지 색상)
|
||||
const colorKeys = Object.keys(colorMap) as Array<keyof typeof colorMap>;
|
||||
const colorKey = colorKeys[index % colorKeys.length];
|
||||
const colors = colorMap[colorKey];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`group-${index}`}
|
||||
className={`flex flex-col items-center justify-center rounded-lg border ${colors.bg} ${colors.border} p-2`}
|
||||
>
|
||||
<div className="text-[10px] text-gray-600">{card.label}</div>
|
||||
<div className={`mt-0.5 text-xl font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 일반 지표 카드 (항상 표시) */}
|
||||
{metrics.map((metric, index) => {
|
||||
const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
|
||||
const formattedValue = metric.value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`metric-${index}`}
|
||||
onClick={() => {
|
||||
setSelectedMetric(metric);
|
||||
setIsDetailOpen(true);
|
||||
}}
|
||||
className={`flex cursor-pointer flex-col items-center justify-center rounded-lg border ${colors.bg} ${colors.border} p-2 transition-all hover:shadow-md`}
|
||||
>
|
||||
<div className="text-[10px] text-gray-600">{metric.label}</div>
|
||||
<div className={`mt-0.5 text-xl font-bold ${colors.text}`}>
|
||||
{formattedValue}
|
||||
{metric.unit && <span className="ml-0.5 text-sm">{metric.unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 모달 */}
|
||||
<Dialog open={isDetailOpen} onOpenChange={setIsDetailOpen}>
|
||||
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-y-auto sm:max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">{selectedMetric?.label || "메트릭 상세"}</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
데이터 소스: {selectedMetric?.sourceName} • 총 {selectedMetric?.rawData?.length || 0}개 항목
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 메트릭 요약 */}
|
||||
<div className="bg-muted/50 rounded-lg border p-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">계산 방법</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{selectedMetric?.aggregation === "count" && "전체 데이터 개수"}
|
||||
{selectedMetric?.aggregation === "distinct" && `"${selectedMetric?.field}" 컬럼의 고유값 개수`}
|
||||
{selectedMetric?.aggregation === "custom" && `"${selectedMetric?.field}" 컬럼의 값`}
|
||||
{selectedMetric?.aggregation === "sum" && `"${selectedMetric?.field}" 컬럼의 합계`}
|
||||
{selectedMetric?.aggregation === "avg" && `"${selectedMetric?.field}" 컬럼의 평균`}
|
||||
{selectedMetric?.aggregation === "min" && `"${selectedMetric?.field}" 컬럼의 최소값`}
|
||||
{selectedMetric?.aggregation === "max" && `"${selectedMetric?.field}" 컬럼의 최대값`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">계산 결과</p>
|
||||
<p className="text-primary text-lg font-bold">
|
||||
{selectedMetric?.value?.toLocaleString()}
|
||||
{selectedMetric?.unit && ` ${selectedMetric.unit}`}
|
||||
{selectedMetric?.aggregation === "distinct" && "개"}
|
||||
{selectedMetric?.aggregation === "count" && "개"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">전체 데이터 개수</p>
|
||||
<p className="text-lg font-bold">{selectedMetric?.rawData?.length || 0}개</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 원본 데이터 테이블 */}
|
||||
{selectedMetric?.rawData && selectedMetric.rawData.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold">원본 데이터 (최대 100개)</h4>
|
||||
<div className="rounded-lg border">
|
||||
<div className="max-h-96 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
{Object.keys(selectedMetric.rawData[0]).map((col) => (
|
||||
<TableHead key={col} className="text-xs font-semibold">
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedMetric.rawData.slice(0, 100).map((row: any, idx: number) => (
|
||||
<TableRow key={idx}>
|
||||
{Object.keys(selectedMetric.rawData[0]).map((col) => (
|
||||
<TableCell key={col} className="text-xs">
|
||||
{String(row[col])}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
{selectedMetric.rawData.length > 100 && (
|
||||
<p className="text-muted-foreground mt-2 text-center text-xs">
|
||||
총 {selectedMetric.rawData.length}개 중 100개만 표시됩니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 없음 */}
|
||||
{(!selectedMetric?.rawData || selectedMetric.rawData.length === 0) && (
|
||||
<div className="bg-muted/30 flex h-32 items-center justify-center rounded-lg border">
|
||||
<p className="text-muted-foreground text-sm">표시할 데이터가 없습니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,445 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { applyColumnMapping } from "@/lib/utils/columnMapping";
|
||||
|
||||
interface ListTestWidgetProps {
|
||||
element: DashboardElement;
|
||||
}
|
||||
|
||||
interface QueryResult {
|
||||
columns: string[];
|
||||
rows: Record<string, any>[];
|
||||
totalRows: number;
|
||||
executionTime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스트 테스트 위젯 (다중 데이터 소스 지원)
|
||||
* - 여러 REST API 연결 가능
|
||||
* - 여러 Database 연결 가능
|
||||
* - REST API + Database 혼합 가능
|
||||
* - 데이터 자동 병합
|
||||
*/
|
||||
export function ListTestWidget({ element }: ListTestWidgetProps) {
|
||||
const [data, setData] = useState<QueryResult | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||
|
||||
// console.log("🧪 ListTestWidget 렌더링!", element);
|
||||
|
||||
const dataSources = useMemo(() => {
|
||||
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
||||
|
||||
// console.log("📊 dataSources 확인:", {
|
||||
// hasDataSources: !!dataSources,
|
||||
// dataSourcesLength: dataSources?.length || 0,
|
||||
// dataSources: dataSources,
|
||||
// });
|
||||
|
||||
const config = element.listConfig || {
|
||||
columnMode: "auto",
|
||||
viewMode: "table",
|
||||
columns: [],
|
||||
pageSize: 10,
|
||||
enablePagination: true,
|
||||
showHeader: true,
|
||||
stripedRows: true,
|
||||
compactMode: false,
|
||||
cardColumns: 3,
|
||||
};
|
||||
|
||||
// 다중 데이터 소스 로딩
|
||||
const loadMultipleDataSources = useCallback(async () => {
|
||||
if (!dataSources || dataSources.length === 0) {
|
||||
console.log("⚠️ 데이터 소스가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔄 ${dataSources.length}개의 데이터 소스 로딩 시작...`);
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 모든 데이터 소스를 병렬로 로딩
|
||||
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") {
|
||||
return await loadDatabaseData(source);
|
||||
}
|
||||
|
||||
return { columns: [], rows: [] };
|
||||
} catch (err: any) {
|
||||
console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err);
|
||||
return { columns: [], rows: [] };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 성공한 데이터만 병합
|
||||
const allColumns = new Set<string>();
|
||||
const allRows: Record<string, any>[] = [];
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === "fulfilled") {
|
||||
const { columns, rows } = result.value;
|
||||
|
||||
// 컬럼 수집
|
||||
columns.forEach((col: string) => allColumns.add(col));
|
||||
|
||||
// 행 병합 (소스 정보 추가)
|
||||
const sourceName = dataSources[index].name || dataSources[index].id || `소스 ${index + 1}`;
|
||||
rows.forEach((row: any) => {
|
||||
allRows.push({
|
||||
...row,
|
||||
_source: sourceName,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const finalColumns = Array.from(allColumns);
|
||||
|
||||
// _source 컬럼을 맨 앞으로
|
||||
const sortedColumns = finalColumns.includes("_source")
|
||||
? ["_source", ...finalColumns.filter((c) => c !== "_source")]
|
||||
: finalColumns;
|
||||
|
||||
setData({
|
||||
columns: sortedColumns,
|
||||
rows: allRows,
|
||||
totalRows: allRows.length,
|
||||
executionTime: 0,
|
||||
});
|
||||
setLastRefreshTime(new Date());
|
||||
|
||||
console.log(`✅ 총 ${allRows.length}개의 행 로딩 완료`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [dataSources]);
|
||||
|
||||
// 수동 새로고침 핸들러
|
||||
const handleManualRefresh = useCallback(() => {
|
||||
console.log("🔄 수동 새로고침 버튼 클릭");
|
||||
loadMultipleDataSources();
|
||||
}, [loadMultipleDataSources]);
|
||||
|
||||
// REST API 데이터 로딩
|
||||
const loadRestApiData = async (source: ChartDataSource): Promise<{ columns: string[]; rows: any[] }> => {
|
||||
if (!source.endpoint) {
|
||||
throw new Error("API endpoint가 없습니다.");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (source.queryParams) {
|
||||
Object.entries(source.queryParams).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: source.endpoint,
|
||||
method: "GET",
|
||||
headers: source.headers || {},
|
||||
queryParams: Object.fromEntries(params),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("❌ API 호출 실패:", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body: errorText.substring(0, 500),
|
||||
});
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log("✅ API 응답:", result);
|
||||
|
||||
if (!result.success) {
|
||||
console.error("❌ API 실패:", result);
|
||||
throw new Error(result.message || result.error || "외부 API 호출 실패");
|
||||
}
|
||||
|
||||
let processedData = result.data;
|
||||
|
||||
// JSON Path 처리
|
||||
if (source.jsonPath) {
|
||||
const paths = source.jsonPath.split(".");
|
||||
for (const path of paths) {
|
||||
if (processedData && typeof processedData === "object" && path in processedData) {
|
||||
processedData = processedData[path];
|
||||
} else {
|
||||
throw new Error(`JSON Path "${source.jsonPath}"에서 데이터를 찾을 수 없습니다`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let rows = Array.isArray(processedData) ? processedData : [processedData];
|
||||
|
||||
// 컬럼 매핑 적용
|
||||
rows = applyColumnMapping(rows, source.columnMapping);
|
||||
|
||||
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
|
||||
|
||||
return { columns, rows };
|
||||
};
|
||||
|
||||
// Database 데이터 로딩
|
||||
const loadDatabaseData = async (source: ChartDataSource): Promise<{ columns: string[]; rows: any[] }> => {
|
||||
if (!source.query) {
|
||||
throw new Error("SQL 쿼리가 없습니다.");
|
||||
}
|
||||
|
||||
if (source.connectionType === "external" && source.externalConnectionId) {
|
||||
// 외부 DB
|
||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||
const externalResult = await ExternalDbConnectionAPI.executeQuery(
|
||||
parseInt(source.externalConnectionId),
|
||||
source.query,
|
||||
);
|
||||
|
||||
if (!externalResult.success || !externalResult.data) {
|
||||
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
||||
}
|
||||
|
||||
const resultData = externalResult.data as unknown as {
|
||||
columns: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
// 컬럼 매핑 적용
|
||||
const mappedRows = applyColumnMapping(resultData.rows, source.columnMapping);
|
||||
const columns = mappedRows.length > 0 ? Object.keys(mappedRows[0]) : resultData.columns;
|
||||
|
||||
return {
|
||||
columns,
|
||||
rows: mappedRows,
|
||||
};
|
||||
} else {
|
||||
// 현재 DB
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(source.query);
|
||||
|
||||
// console.log("💾 내부 DB 쿼리 결과:", {
|
||||
// hasRows: !!result.rows,
|
||||
// rowCount: result.rows?.length || 0,
|
||||
// hasColumns: !!result.columns,
|
||||
// columnCount: result.columns?.length || 0,
|
||||
// firstRow: result.rows?.[0],
|
||||
// resultKeys: Object.keys(result),
|
||||
// });
|
||||
|
||||
// 컬럼 매핑 적용
|
||||
const mappedRows = applyColumnMapping(result.rows, source.columnMapping);
|
||||
const columns = mappedRows.length > 0 ? Object.keys(mappedRows[0]) : result.columns;
|
||||
|
||||
// console.log("✅ 매핑 후:", {
|
||||
// columns,
|
||||
// rowCount: mappedRows.length,
|
||||
// firstMappedRow: mappedRows[0],
|
||||
// });
|
||||
|
||||
return {
|
||||
columns,
|
||||
rows: mappedRows,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
if (dataSources && dataSources.length > 0) {
|
||||
loadMultipleDataSources();
|
||||
}
|
||||
}, [dataSources, loadMultipleDataSources]);
|
||||
|
||||
// 자동 새로고침
|
||||
useEffect(() => {
|
||||
if (!dataSources || dataSources.length === 0) return;
|
||||
|
||||
const intervals = dataSources
|
||||
.map((ds) => ds.refreshInterval)
|
||||
.filter((interval): interval is number => typeof interval === "number" && interval > 0);
|
||||
|
||||
if (intervals.length === 0) return;
|
||||
|
||||
const minInterval = Math.min(...intervals);
|
||||
console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
console.log("🔄 자동 새로고침 실행");
|
||||
loadMultipleDataSources();
|
||||
}, minInterval * 1000);
|
||||
|
||||
return () => {
|
||||
console.log("⏹️ 자동 새로고침 정리");
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [dataSources, loadMultipleDataSources]);
|
||||
|
||||
// 페이지네이션
|
||||
const pageSize = config.pageSize || 10;
|
||||
const totalPages = data ? Math.ceil(data.totalRows / pageSize) : 0;
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedRows = data?.rows.slice(startIndex, endIndex) || [];
|
||||
|
||||
// 테이블 뷰
|
||||
const renderTable = () => (
|
||||
<div className="overflow-auto">
|
||||
<Table>
|
||||
{config.showHeader && (
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{data?.columns.map((col) => (
|
||||
<TableHead key={col} className="whitespace-nowrap">
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
)}
|
||||
<TableBody>
|
||||
{paginatedRows.map((row, idx) => (
|
||||
<TableRow key={idx} className={config.stripedRows && idx % 2 === 0 ? "bg-muted/50" : ""}>
|
||||
{data?.columns.map((col) => (
|
||||
<TableCell key={col} className="whitespace-nowrap">
|
||||
{String(row[col] ?? "")}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 카드 뷰
|
||||
const renderCards = () => (
|
||||
<div className={`grid gap-4 grid-cols-1 md:grid-cols-${config.cardColumns || 3}`}>
|
||||
{paginatedRows.map((row, idx) => (
|
||||
<Card key={idx} className="p-4">
|
||||
{data?.columns.map((col) => (
|
||||
<div key={col} className="mb-2">
|
||||
<span className="font-semibold">{col}: </span>
|
||||
<span>{String(row[col] ?? "")}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col rounded-lg border bg-card shadow-sm">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{element?.customTitle || "리스트"}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dataSources?.length || 0}개 데이터 소스 • {data?.totalRows || 0}개 행
|
||||
{lastRefreshTime && (
|
||||
<span className="ml-2">
|
||||
• {lastRefreshTime.toLocaleTimeString("ko-KR")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleManualRefresh}
|
||||
disabled={isLoading}
|
||||
className="h-8 gap-2 text-xs"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{error ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
) : !(element?.dataSources || element?.chartConfig?.dataSources) || (element?.dataSources || element?.chartConfig?.dataSources)?.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
데이터 소스를 연결해주세요
|
||||
</p>
|
||||
</div>
|
||||
) : !data || data.rows.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
데이터가 없습니다
|
||||
</p>
|
||||
</div>
|
||||
) : config.viewMode === "card" ? (
|
||||
renderCards()
|
||||
) : (
|
||||
renderTable()
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{config.enablePagination && data && data.rows.length > 0 && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t p-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
총 {data.totalRows}개 항목 (페이지 {currentPage}/{totalPages})
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -3,11 +3,6 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
import { getWeather, WeatherData, getWeatherAlerts, WeatherAlert } from "@/lib/api/openApi";
|
||||
import { Cloud, CloudRain, CloudSnow, Sun, Wind, AlertTriangle } from "lucide-react";
|
||||
import turfUnion from "@turf/union";
|
||||
import { polygon } from "@turf/helpers";
|
||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
||||
|
|
@ -26,8 +21,6 @@ const MapContainer = dynamic(() => import("react-leaflet").then((mod) => mod.Map
|
|||
const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLayer), { ssr: false });
|
||||
const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false });
|
||||
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
|
||||
const GeoJSON = dynamic(() => import("react-leaflet").then((mod) => mod.GeoJSON), { ssr: false });
|
||||
const Polygon = dynamic(() => import("react-leaflet").then((mod) => mod.Polygon), { ssr: false });
|
||||
|
||||
// 브이월드 API 키
|
||||
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
|
||||
|
|
@ -41,239 +34,28 @@ interface MarkerData {
|
|||
lng: number;
|
||||
name: string;
|
||||
info: any;
|
||||
weather?: WeatherData | null;
|
||||
markerColor?: string; // 마커 색상
|
||||
}
|
||||
|
||||
// 테이블명 한글 번역
|
||||
const translateTableName = (name: string): string => {
|
||||
const tableTranslations: { [key: string]: string } = {
|
||||
vehicle_locations: "차량",
|
||||
vehicles: "차량",
|
||||
warehouses: "창고",
|
||||
warehouse: "창고",
|
||||
customers: "고객",
|
||||
customer: "고객",
|
||||
deliveries: "배송",
|
||||
delivery: "배송",
|
||||
drivers: "기사",
|
||||
driver: "기사",
|
||||
stores: "매장",
|
||||
store: "매장",
|
||||
"vehicle_locations": "차량",
|
||||
"vehicles": "차량",
|
||||
"warehouses": "창고",
|
||||
"warehouse": "창고",
|
||||
"customers": "고객",
|
||||
"customer": "고객",
|
||||
"deliveries": "배송",
|
||||
"delivery": "배송",
|
||||
"drivers": "기사",
|
||||
"driver": "기사",
|
||||
"stores": "매장",
|
||||
"store": "매장",
|
||||
};
|
||||
|
||||
return tableTranslations[name.toLowerCase()] || tableTranslations[name.replace(/_/g, "").toLowerCase()] || name;
|
||||
};
|
||||
|
||||
// 주요 도시 좌표 (날씨 API 지원 도시)
|
||||
const CITY_COORDINATES = [
|
||||
{ name: "서울", lat: 37.5665, lng: 126.978 },
|
||||
{ name: "부산", lat: 35.1796, lng: 129.0756 },
|
||||
{ name: "인천", lat: 37.4563, lng: 126.7052 },
|
||||
{ name: "대구", lat: 35.8714, lng: 128.6014 },
|
||||
{ name: "광주", lat: 35.1595, lng: 126.8526 },
|
||||
{ name: "대전", lat: 36.3504, lng: 127.3845 },
|
||||
{ name: "울산", lat: 35.5384, lng: 129.3114 },
|
||||
{ name: "세종", lat: 36.48, lng: 127.289 },
|
||||
{ name: "제주", lat: 33.4996, lng: 126.5312 },
|
||||
];
|
||||
|
||||
// 해상 구역 폴리곤 좌표 (기상청 특보 구역 기준 - 깔끔한 사각형)
|
||||
const MARITIME_ZONES: Record<string, Array<[number, number]>> = {
|
||||
// 제주도 해역
|
||||
제주도남부앞바다: [
|
||||
[33.25, 126.0],
|
||||
[33.25, 126.85],
|
||||
[33.0, 126.85],
|
||||
[33.0, 126.0],
|
||||
],
|
||||
제주도남쪽바깥먼바다: [
|
||||
[33.15, 125.7],
|
||||
[33.15, 127.3],
|
||||
[32.5, 127.3],
|
||||
[32.5, 125.7],
|
||||
],
|
||||
제주도동부앞바다: [
|
||||
[33.4, 126.7],
|
||||
[33.4, 127.25],
|
||||
[33.05, 127.25],
|
||||
[33.05, 126.7],
|
||||
],
|
||||
제주도남동쪽안쪽먼바다: [
|
||||
[33.3, 126.85],
|
||||
[33.3, 127.95],
|
||||
[32.65, 127.95],
|
||||
[32.65, 126.85],
|
||||
],
|
||||
제주도남서쪽안쪽먼바다: [
|
||||
[33.3, 125.35],
|
||||
[33.3, 126.45],
|
||||
[32.7, 126.45],
|
||||
[32.7, 125.35],
|
||||
],
|
||||
|
||||
// 남해 해역
|
||||
남해동부앞바다: [
|
||||
[34.65, 128.3],
|
||||
[34.65, 129.65],
|
||||
[33.95, 129.65],
|
||||
[33.95, 128.3],
|
||||
],
|
||||
남해동부안쪽먼바다: [
|
||||
[34.25, 127.95],
|
||||
[34.25, 129.75],
|
||||
[33.45, 129.75],
|
||||
[33.45, 127.95],
|
||||
],
|
||||
남해동부바깥먼바다: [
|
||||
[33.65, 127.95],
|
||||
[33.65, 130.35],
|
||||
[32.45, 130.35],
|
||||
[32.45, 127.95],
|
||||
],
|
||||
|
||||
// 동해 해역
|
||||
경북북부앞바다: [
|
||||
[36.65, 129.2],
|
||||
[36.65, 130.1],
|
||||
[35.95, 130.1],
|
||||
[35.95, 129.2],
|
||||
],
|
||||
경북남부앞바다: [
|
||||
[36.15, 129.1],
|
||||
[36.15, 129.95],
|
||||
[35.45, 129.95],
|
||||
[35.45, 129.1],
|
||||
],
|
||||
동해남부남쪽안쪽먼바다: [
|
||||
[35.65, 129.35],
|
||||
[35.65, 130.65],
|
||||
[34.95, 130.65],
|
||||
[34.95, 129.35],
|
||||
],
|
||||
동해남부남쪽바깥먼바다: [
|
||||
[35.25, 129.45],
|
||||
[35.25, 131.15],
|
||||
[34.15, 131.15],
|
||||
[34.15, 129.45],
|
||||
],
|
||||
동해남부북쪽안쪽먼바다: [
|
||||
[36.6, 129.65],
|
||||
[36.6, 130.95],
|
||||
[35.85, 130.95],
|
||||
[35.85, 129.65],
|
||||
],
|
||||
동해남부북쪽바깥먼바다: [
|
||||
[36.65, 130.35],
|
||||
[36.65, 132.15],
|
||||
[35.85, 132.15],
|
||||
[35.85, 130.35],
|
||||
],
|
||||
|
||||
// 강원 해역
|
||||
강원북부앞바다: [
|
||||
[38.15, 128.4],
|
||||
[38.15, 129.55],
|
||||
[37.45, 129.55],
|
||||
[37.45, 128.4],
|
||||
],
|
||||
강원중부앞바다: [
|
||||
[37.65, 128.7],
|
||||
[37.65, 129.6],
|
||||
[36.95, 129.6],
|
||||
[36.95, 128.7],
|
||||
],
|
||||
강원남부앞바다: [
|
||||
[37.15, 128.9],
|
||||
[37.15, 129.85],
|
||||
[36.45, 129.85],
|
||||
[36.45, 128.9],
|
||||
],
|
||||
동해중부안쪽먼바다: [
|
||||
[38.55, 129.35],
|
||||
[38.55, 131.15],
|
||||
[37.25, 131.15],
|
||||
[37.25, 129.35],
|
||||
],
|
||||
동해중부바깥먼바다: [
|
||||
[38.6, 130.35],
|
||||
[38.6, 132.55],
|
||||
[37.65, 132.55],
|
||||
[37.65, 130.35],
|
||||
],
|
||||
|
||||
// 울릉도·독도
|
||||
"울릉도.독도": [
|
||||
[37.7, 130.7],
|
||||
[37.7, 132.0],
|
||||
[37.4, 132.0],
|
||||
[37.4, 130.7],
|
||||
],
|
||||
};
|
||||
|
||||
// 두 좌표 간 거리 계산 (Haversine formula)
|
||||
const getDistance = (lat1: number, lng1: number, lat2: number, lng2: number): number => {
|
||||
const R = 6371; // 지구 반경 (km)
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLng = ((lng2 - lng1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
};
|
||||
|
||||
// 가장 가까운 도시 찾기
|
||||
const findNearestCity = (lat: number, lng: number): string => {
|
||||
let nearestCity = "서울";
|
||||
let minDistance = Infinity;
|
||||
|
||||
for (const city of CITY_COORDINATES) {
|
||||
const distance = getDistance(lat, lng, city.lat, city.lng);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
nearestCity = city.name;
|
||||
}
|
||||
}
|
||||
|
||||
return nearestCity;
|
||||
};
|
||||
|
||||
// 날씨 아이콘 반환
|
||||
const getWeatherIcon = (weatherMain: string) => {
|
||||
switch (weatherMain.toLowerCase()) {
|
||||
case "clear":
|
||||
return <Sun className="h-4 w-4 text-yellow-500" />;
|
||||
case "rain":
|
||||
return <CloudRain className="h-4 w-4 text-blue-500" />;
|
||||
case "snow":
|
||||
return <CloudSnow className="h-4 w-4 text-blue-300" />;
|
||||
case "clouds":
|
||||
return <Cloud className="h-4 w-4 text-gray-400" />;
|
||||
default:
|
||||
return <Wind className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 특보 심각도별 색상 반환
|
||||
const getAlertColor = (severity: string): string => {
|
||||
switch (severity) {
|
||||
case "high":
|
||||
return "#ef4444"; // 빨강 (경보)
|
||||
case "medium":
|
||||
return "#f59e0b"; // 주황 (주의보)
|
||||
case "low":
|
||||
return "#eab308"; // 노랑 (약한 주의보)
|
||||
default:
|
||||
return "#6b7280"; // 회색
|
||||
}
|
||||
};
|
||||
|
||||
// 지역명 정규화 (특보 API 지역명 → GeoJSON 지역명)
|
||||
const normalizeRegionName = (location: string): string => {
|
||||
// 기상청 특보는 "강릉시", "속초시", "인제군" 등으로 옴
|
||||
// GeoJSON도 같은 형식이므로 그대로 반환
|
||||
return location;
|
||||
|
||||
return tableTranslations[name.toLowerCase()] ||
|
||||
tableTranslations[name.replace(/_/g, '').toLowerCase()] ||
|
||||
name;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -287,147 +69,21 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tableName, setTableName] = useState<string | null>(null);
|
||||
const [weatherCache, setWeatherCache] = useState<Map<string, WeatherData>>(new Map());
|
||||
const [weatherAlerts, setWeatherAlerts] = useState<WeatherAlert[]>([]);
|
||||
const [geoJsonData, setGeoJsonData] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("🗺️ MapSummaryWidget 초기화");
|
||||
console.log("🗺️ showWeatherAlerts:", element.chartConfig?.showWeatherAlerts);
|
||||
|
||||
// GeoJSON 데이터 로드
|
||||
loadGeoJsonData();
|
||||
|
||||
// 기상특보 로드 (showWeatherAlerts가 활성화된 경우)
|
||||
if (element.chartConfig?.showWeatherAlerts) {
|
||||
console.log("🚨 기상특보 로드 시작...");
|
||||
loadWeatherAlerts();
|
||||
} else {
|
||||
console.log("⚠️ 기상특보 표시 옵션이 꺼져있습니다");
|
||||
}
|
||||
|
||||
if (element?.dataSource?.query) {
|
||||
loadMapData();
|
||||
}
|
||||
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
const interval = setInterval(() => {
|
||||
if (element?.dataSource?.query) {
|
||||
loadMapData();
|
||||
}
|
||||
if (element.chartConfig?.showWeatherAlerts) {
|
||||
loadWeatherAlerts();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [element.id, element.dataSource?.query, element.chartConfig?.showWeather, element.chartConfig?.showWeatherAlerts]);
|
||||
|
||||
// GeoJSON 데이터 로드 (시/군/구 단위)
|
||||
const loadGeoJsonData = async () => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// 기상특보 로드
|
||||
const loadWeatherAlerts = async () => {
|
||||
try {
|
||||
const alerts = await getWeatherAlerts();
|
||||
console.log("🚨 기상특보 로드 완료:", alerts.length, "건");
|
||||
console.log("🚨 특보 목록:", alerts);
|
||||
setWeatherAlerts(alerts);
|
||||
} catch (err) {
|
||||
console.error("❌ 기상특보 로드 실패:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// 마커들의 날씨 정보 로드 (배치 처리 + 딜레이)
|
||||
const loadWeatherForMarkers = async (markerData: MarkerData[]) => {
|
||||
try {
|
||||
// 각 마커의 가장 가까운 도시 찾기
|
||||
const citySet = new Set<string>();
|
||||
markerData.forEach((marker) => {
|
||||
const nearestCity = findNearestCity(marker.lat, marker.lng);
|
||||
citySet.add(nearestCity);
|
||||
});
|
||||
|
||||
// 캐시에 없는 도시만 날씨 조회
|
||||
const citiesToFetch = Array.from(citySet).filter((city) => !weatherCache.has(city));
|
||||
|
||||
console.log(`🌤️ 날씨 로드: 총 ${citySet.size}개 도시, 캐시 미스 ${citiesToFetch.length}개`);
|
||||
|
||||
if (citiesToFetch.length > 0) {
|
||||
// 배치 처리: 5개씩 나눠서 호출
|
||||
const BATCH_SIZE = 5;
|
||||
const newCache = new Map(weatherCache);
|
||||
|
||||
for (let i = 0; i < citiesToFetch.length; i += BATCH_SIZE) {
|
||||
const batch = citiesToFetch.slice(i, i + BATCH_SIZE);
|
||||
console.log(`📦 배치 ${Math.floor(i / BATCH_SIZE) + 1}: ${batch.join(", ")}`);
|
||||
|
||||
// 배치 내에서는 병렬 호출
|
||||
const batchPromises = batch.map(async (city) => {
|
||||
try {
|
||||
const weather = await getWeather(city);
|
||||
return { city, weather };
|
||||
} catch (err) {
|
||||
console.error(`❌ ${city} 날씨 로드 실패:`, err);
|
||||
return { city, weather: null };
|
||||
}
|
||||
});
|
||||
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
|
||||
// 캐시 업데이트
|
||||
batchResults.forEach(({ city, weather }) => {
|
||||
if (weather) {
|
||||
newCache.set(city, weather);
|
||||
}
|
||||
});
|
||||
|
||||
// 다음 배치 전 1초 대기 (서버 부하 방지)
|
||||
if (i + BATCH_SIZE < citiesToFetch.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
setWeatherCache(newCache);
|
||||
|
||||
// 마커에 날씨 정보 추가
|
||||
const updatedMarkers = markerData.map((marker) => {
|
||||
const nearestCity = findNearestCity(marker.lat, marker.lng);
|
||||
return {
|
||||
...marker,
|
||||
weather: newCache.get(nearestCity) || null,
|
||||
};
|
||||
});
|
||||
setMarkers(updatedMarkers);
|
||||
console.log("✅ 날씨 로드 완료!");
|
||||
} else {
|
||||
// 캐시에서 날씨 정보 가져오기
|
||||
const updatedMarkers = markerData.map((marker) => {
|
||||
const nearestCity = findNearestCity(marker.lat, marker.lng);
|
||||
return {
|
||||
...marker,
|
||||
weather: weatherCache.get(nearestCity) || null,
|
||||
};
|
||||
});
|
||||
setMarkers(updatedMarkers);
|
||||
console.log("✅ 캐시에서 날씨 로드 완료!");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("❌ 날씨 정보 로드 실패:", err);
|
||||
// 날씨 로드 실패해도 마커는 표시
|
||||
setMarkers(markerData);
|
||||
}
|
||||
};
|
||||
}, [element]);
|
||||
|
||||
const loadMapData = async () => {
|
||||
if (!element?.dataSource?.query) {
|
||||
|
|
@ -449,7 +105,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
setTableName(extractedTableName);
|
||||
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
|
||||
const response = await fetch("/api/dashboards/execute-query", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -468,38 +124,11 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
|
||||
if (result.success && result.data?.rows) {
|
||||
const rows = result.data.rows;
|
||||
|
||||
|
||||
// 위도/경도 컬럼 찾기
|
||||
const latCol = element.chartConfig?.latitudeColumn || "latitude";
|
||||
const lngCol = element.chartConfig?.longitudeColumn || "longitude";
|
||||
|
||||
// 마커 색상 결정 함수
|
||||
const getMarkerColor = (row: any): string => {
|
||||
const colorMode = element.chartConfig?.markerColorMode || "single";
|
||||
|
||||
if (colorMode === "single") {
|
||||
// 단일 색상 모드
|
||||
return element.chartConfig?.markerDefaultColor || "#3b82f6";
|
||||
} else {
|
||||
// 조건부 색상 모드
|
||||
const colorColumn = element.chartConfig?.markerColorColumn;
|
||||
const colorRules = element.chartConfig?.markerColorRules || [];
|
||||
const defaultColor = element.chartConfig?.markerDefaultColor || "#6b7280";
|
||||
|
||||
if (!colorColumn || colorRules.length === 0) {
|
||||
return defaultColor;
|
||||
}
|
||||
|
||||
// 컬럼 값 가져오기
|
||||
const columnValue = String(row[colorColumn] || "");
|
||||
|
||||
// 색상 규칙 매칭
|
||||
const matchedRule = colorRules.find((rule) => String(rule.value) === columnValue);
|
||||
|
||||
return matchedRule ? matchedRule.color : defaultColor;
|
||||
}
|
||||
};
|
||||
|
||||
// 유효한 좌표 필터링 및 마커 데이터 생성
|
||||
const markerData = rows
|
||||
.filter((row: any) => row[latCol] && row[lngCol])
|
||||
|
|
@ -508,16 +137,9 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
lng: parseFloat(row[lngCol]),
|
||||
name: row.name || row.vehicle_number || row.warehouse_name || row.customer_name || "알 수 없음",
|
||||
info: row,
|
||||
weather: null,
|
||||
markerColor: getMarkerColor(row), // 마커 색상 추가
|
||||
}));
|
||||
|
||||
setMarkers(markerData);
|
||||
|
||||
// 날씨 정보 로드 (showWeather가 활성화된 경우만)
|
||||
if (element.chartConfig?.showWeather) {
|
||||
loadWeatherForMarkers(markerData);
|
||||
}
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
|
@ -540,12 +162,12 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
{element?.dataSource?.query ? (
|
||||
<p className="text-xs text-gray-500">총 {markers.length.toLocaleString()}개 마커</p>
|
||||
) : (
|
||||
<p className="text-xs text-orange-500">데이터를 연결하세요</p>
|
||||
<p className="text-xs text-orange-500">⚙️ 톱니바퀴 버튼을 눌러 데이터를 연결하세요</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={loadMapData}
|
||||
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs disabled:opacity-50"
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-white p-0 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={loading || !element?.dataSource?.query}
|
||||
>
|
||||
{loading ? "⏳" : "🔄"}
|
||||
|
|
@ -560,7 +182,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
)}
|
||||
|
||||
{/* 지도 (항상 표시) */}
|
||||
<div className="relative z-0 flex-1 overflow-hidden rounded border border-gray-300 bg-white">
|
||||
<div className="relative flex-1 rounded border border-gray-300 bg-white overflow-hidden z-0">
|
||||
<MapContainer
|
||||
key={`map-${element.id}`}
|
||||
center={[36.5, 127.5]}
|
||||
|
|
@ -581,248 +203,24 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
keepBuffer={2}
|
||||
/>
|
||||
|
||||
{/* 기상특보 영역 표시 (육지 - GeoJSON 레이어) */}
|
||||
{element.chartConfig?.showWeatherAlerts && geoJsonData && weatherAlerts && weatherAlerts.length > 0 && (
|
||||
<GeoJSON
|
||||
key={`alerts-${weatherAlerts.length}`}
|
||||
data={geoJsonData}
|
||||
style={(feature) => {
|
||||
// 해당 지역에 특보가 있는지 확인
|
||||
const regionName = feature?.properties?.name;
|
||||
const alert = weatherAlerts.find((a) => normalizeRegionName(a.location) === regionName);
|
||||
|
||||
if (alert) {
|
||||
return {
|
||||
fillColor: getAlertColor(alert.severity),
|
||||
fillOpacity: 0.3,
|
||||
color: getAlertColor(alert.severity),
|
||||
weight: 2,
|
||||
};
|
||||
}
|
||||
|
||||
// 특보가 없는 지역은 투명하게
|
||||
return {
|
||||
fillOpacity: 0,
|
||||
color: "transparent",
|
||||
weight: 0,
|
||||
};
|
||||
}}
|
||||
onEachFeature={(feature, layer) => {
|
||||
const regionName = feature?.properties?.name;
|
||||
const regionAlerts = weatherAlerts.filter((a) => normalizeRegionName(a.location) === regionName);
|
||||
|
||||
if (regionAlerts.length > 0) {
|
||||
const popupContent = `
|
||||
<div style="min-width: 200px;">
|
||||
<div style="font-weight: bold; font-size: 14px; margin-bottom: 8px; display: flex; align-items: center; gap: 4px;">
|
||||
<span style="color: ${getAlertColor(regionAlerts[0].severity)};">⚠️</span>
|
||||
${regionName}
|
||||
</div>
|
||||
${regionAlerts
|
||||
.map(
|
||||
(alert) => `
|
||||
<div style="margin-bottom: 8px; padding: 8px; background: #f9fafb; border-radius: 4px; border-left: 3px solid ${getAlertColor(alert.severity)};">
|
||||
<div style="font-weight: 600; font-size: 12px; color: ${getAlertColor(alert.severity)};">
|
||||
${alert.title}
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #6b7280; margin-top: 4px;">
|
||||
${alert.description}
|
||||
</div>
|
||||
<div style="font-size: 10px; color: #9ca3af; margin-top: 4px;">
|
||||
${new Date(alert.timestamp).toLocaleString("ko-KR")}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`;
|
||||
layer.bindPopup(popupContent);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 기상특보 영역 표시 (해상 - Polygon 레이어) - 개별 표시 */}
|
||||
{element.chartConfig?.showWeatherAlerts &&
|
||||
weatherAlerts &&
|
||||
weatherAlerts.length > 0 &&
|
||||
weatherAlerts
|
||||
.filter((alert) => MARITIME_ZONES[alert.location])
|
||||
.map((alert, idx) => {
|
||||
const coordinates = MARITIME_ZONES[alert.location];
|
||||
const alertColor = getAlertColor(alert.severity);
|
||||
|
||||
return (
|
||||
<Polygon
|
||||
key={`maritime-${idx}`}
|
||||
positions={coordinates}
|
||||
pathOptions={{
|
||||
fillColor: alertColor,
|
||||
fillOpacity: 0.15,
|
||||
color: alertColor,
|
||||
weight: 2,
|
||||
opacity: 0.9,
|
||||
dashArray: "5, 5",
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
}}
|
||||
eventHandlers={{
|
||||
mouseover: (e) => {
|
||||
const layer = e.target;
|
||||
layer.setStyle({
|
||||
fillOpacity: 0.3,
|
||||
weight: 3,
|
||||
});
|
||||
},
|
||||
mouseout: (e) => {
|
||||
const layer = e.target;
|
||||
layer.setStyle({
|
||||
fillOpacity: 0.15,
|
||||
weight: 2,
|
||||
});
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div style={{ minWidth: "180px" }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: "13px",
|
||||
marginBottom: "6px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: alertColor }}>⚠️</span>
|
||||
{alert.location}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: "6px",
|
||||
background: "#f9fafb",
|
||||
borderRadius: "4px",
|
||||
borderLeft: `3px solid ${alertColor}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: "600", fontSize: "11px", color: alertColor }}>{alert.title}</div>
|
||||
<div style={{ fontSize: "10px", color: "#6b7280", marginTop: "3px" }}>
|
||||
{alert.description}
|
||||
</div>
|
||||
<div style={{ fontSize: "9px", color: "#9ca3af", marginTop: "3px" }}>
|
||||
{new Date(alert.timestamp).toLocaleString("ko-KR")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Polygon>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 마커 표시 */}
|
||||
{markers.map((marker, idx) => {
|
||||
// Leaflet 커스텀 아이콘 생성 (클라이언트 사이드에서만)
|
||||
let customIcon;
|
||||
if (typeof window !== "undefined") {
|
||||
const L = require("leaflet");
|
||||
customIcon = L.divIcon({
|
||||
className: "custom-marker",
|
||||
html: `
|
||||
<div style="
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-color: ${marker.markerColor || "#3b82f6"};
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translate(-50%, -50%);
|
||||
"></div>
|
||||
`,
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Marker key={idx} position={[marker.lat, marker.lng]} icon={customIcon}>
|
||||
<Popup>
|
||||
<div className="min-w-[200px] text-xs">
|
||||
{/* 마커 정보 */}
|
||||
<div className="mb-2 border-b pb-2">
|
||||
<div className="mb-1 text-sm font-bold">{marker.name}</div>
|
||||
{Object.entries(marker.info)
|
||||
.filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase()))
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="text-xs">
|
||||
<strong>{key}:</strong> {String(value)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 날씨 정보 */}
|
||||
{marker.weather && (
|
||||
<div className="space-y-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
{getWeatherIcon(marker.weather.weatherMain)}
|
||||
<span className="text-xs font-semibold">현재 날씨</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">{marker.weather.weatherDescription}</div>
|
||||
<div className="mt-2 space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">온도</span>
|
||||
<span className="font-medium">{marker.weather.temperature}°C</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">체감온도</span>
|
||||
<span className="font-medium">{marker.weather.feelsLike}°C</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">습도</span>
|
||||
<span className="font-medium">{marker.weather.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">풍속</span>
|
||||
<span className="font-medium">{marker.weather.windSpeed} m/s</span>
|
||||
</div>
|
||||
</div>
|
||||
{markers.map((marker, idx) => (
|
||||
<Marker key={idx} position={[marker.lat, marker.lng]}>
|
||||
<Popup>
|
||||
<div className="text-xs">
|
||||
<div className="mb-1 text-sm font-bold">{marker.name}</div>
|
||||
{Object.entries(marker.info)
|
||||
.filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase()))
|
||||
.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<strong>{key}:</strong> {String(value)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
|
||||
{/* 범례 (특보가 있을 때만 표시) */}
|
||||
{element.chartConfig?.showWeatherAlerts && weatherAlerts && weatherAlerts.length > 0 && (
|
||||
<div className="absolute right-4 bottom-4 z-10 rounded-lg border bg-white p-3 shadow-lg">
|
||||
<div className="mb-2 flex items-center gap-1 text-xs font-semibold">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
기상특보
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded" style={{ backgroundColor: getAlertColor("high") }}></div>
|
||||
<span>경보</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded" style={{ backgroundColor: getAlertColor("medium") }}></div>
|
||||
<span>주의보</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded" style={{ backgroundColor: getAlertColor("low") }}></div>
|
||||
<span>약한 주의보</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 border-t pt-2 text-[10px] text-gray-500">총 {weatherAlerts.length}건 발효 중</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,586 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { RefreshCw, AlertTriangle, Cloud, Construction, Database as DatabaseIcon } from "lucide-react";
|
||||
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
|
||||
|
||||
type AlertType = "accident" | "weather" | "construction" | "system" | "security" | "other";
|
||||
|
||||
interface Alert {
|
||||
id: string;
|
||||
type: AlertType;
|
||||
severity: "high" | "medium" | "low";
|
||||
title: string;
|
||||
location?: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface RiskAlertTestWidgetProps {
|
||||
element: DashboardElement;
|
||||
}
|
||||
|
||||
export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProps) {
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<AlertType | "all">("all");
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||
|
||||
const dataSources = useMemo(() => {
|
||||
return element?.dataSources || element?.chartConfig?.dataSources;
|
||||
}, [element?.dataSources, element?.chartConfig?.dataSources]);
|
||||
|
||||
const parseTextData = (text: string): any[] => {
|
||||
// XML 형식 감지
|
||||
if (text.trim().startsWith("<?xml") || text.trim().startsWith("<result>")) {
|
||||
console.log("📄 XML 형식 데이터 감지");
|
||||
return parseXmlData(text);
|
||||
}
|
||||
|
||||
// CSV 형식 (기상청 특보)
|
||||
console.log("📄 CSV 형식 데이터 감지");
|
||||
const lines = text.split("\n").filter((line) => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed && !trimmed.startsWith("#") && trimmed !== "=";
|
||||
});
|
||||
|
||||
return lines.map((line) => {
|
||||
const values = line.split(",");
|
||||
const obj: any = {};
|
||||
|
||||
if (values.length >= 11) {
|
||||
obj.code = values[0];
|
||||
obj.region = values[1];
|
||||
obj.subCode = values[2];
|
||||
obj.subRegion = values[3];
|
||||
obj.tmFc = values[4];
|
||||
obj.tmEf = values[5];
|
||||
obj.warning = values[6];
|
||||
obj.level = values[7];
|
||||
obj.status = values[8];
|
||||
obj.period = values[9];
|
||||
obj.name = obj.subRegion || obj.region || obj.code;
|
||||
} else {
|
||||
values.forEach((value, index) => {
|
||||
obj[`field_${index}`] = value;
|
||||
});
|
||||
}
|
||||
|
||||
return obj;
|
||||
});
|
||||
};
|
||||
|
||||
const parseXmlData = (xmlText: string): any[] => {
|
||||
try {
|
||||
// 간단한 XML 파싱 (DOMParser 사용)
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
|
||||
|
||||
const records = xmlDoc.getElementsByTagName("record");
|
||||
const results: any[] = [];
|
||||
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const record = records[i];
|
||||
const obj: any = {};
|
||||
|
||||
// 모든 자식 노드를 객체로 변환
|
||||
for (let j = 0; j < record.children.length; j++) {
|
||||
const child = record.children[j];
|
||||
obj[child.tagName] = child.textContent || "";
|
||||
}
|
||||
|
||||
results.push(obj);
|
||||
}
|
||||
|
||||
console.log(`✅ XML 파싱 완료: ${results.length}개 레코드`);
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error("❌ XML 파싱 실패:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadRestApiData = useCallback(async (source: ChartDataSource) => {
|
||||
if (!source.endpoint) {
|
||||
throw new Error("API endpoint가 없습니다.");
|
||||
}
|
||||
|
||||
// 쿼리 파라미터 처리
|
||||
const queryParamsObj: Record<string, string> = {};
|
||||
if (source.queryParams && Array.isArray(source.queryParams)) {
|
||||
source.queryParams.forEach((param) => {
|
||||
if (param.key && param.value) {
|
||||
queryParamsObj[param.key] = param.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 헤더 처리
|
||||
const headersObj: Record<string, string> = {};
|
||||
if (source.headers && Array.isArray(source.headers)) {
|
||||
source.headers.forEach((header) => {
|
||||
if (header.key && header.value) {
|
||||
headersObj[header.key] = header.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log("🌐 API 호출 준비:", {
|
||||
endpoint: source.endpoint,
|
||||
queryParams: queryParamsObj,
|
||||
headers: headersObj,
|
||||
});
|
||||
console.log("🔍 원본 source.queryParams:", source.queryParams);
|
||||
console.log("🔍 원본 source.headers:", source.headers);
|
||||
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: source.endpoint,
|
||||
method: "GET",
|
||||
headers: headersObj,
|
||||
queryParams: queryParamsObj,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("🌐 API 응답 상태:", response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "API 호출 실패");
|
||||
}
|
||||
|
||||
let apiData = result.data;
|
||||
|
||||
console.log("🔍 API 응답 데이터 타입:", typeof apiData);
|
||||
console.log("🔍 API 응답 데이터 (처음 500자):", typeof apiData === "string" ? apiData.substring(0, 500) : JSON.stringify(apiData).substring(0, 500));
|
||||
|
||||
// 백엔드가 {text: "XML..."} 형태로 감싼 경우 처리
|
||||
if (apiData && typeof apiData === "object" && apiData.text && typeof apiData.text === "string") {
|
||||
console.log("📦 백엔드가 text 필드로 감싼 데이터 감지");
|
||||
apiData = parseTextData(apiData.text);
|
||||
console.log("✅ 파싱 성공:", apiData.length, "개 행");
|
||||
} else if (typeof apiData === "string") {
|
||||
console.log("📄 텍스트 형식 데이터 감지, 파싱 시도");
|
||||
apiData = parseTextData(apiData);
|
||||
console.log("✅ 파싱 성공:", apiData.length, "개 행");
|
||||
} else if (Array.isArray(apiData)) {
|
||||
console.log("✅ 이미 배열 형태의 데이터입니다.");
|
||||
} else {
|
||||
console.log("⚠️ 예상치 못한 데이터 형식입니다. 배열로 변환 시도.");
|
||||
apiData = [apiData];
|
||||
}
|
||||
|
||||
// JSON Path 적용
|
||||
if (source.jsonPath && typeof apiData === "object" && !Array.isArray(apiData)) {
|
||||
const paths = source.jsonPath.split(".");
|
||||
for (const path of paths) {
|
||||
if (apiData && typeof apiData === "object" && path in apiData) {
|
||||
apiData = apiData[path];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows = Array.isArray(apiData) ? apiData : [apiData];
|
||||
return convertToAlerts(rows, source.name || source.id || "API");
|
||||
}, []);
|
||||
|
||||
const loadDatabaseData = useCallback(async (source: ChartDataSource) => {
|
||||
if (!source.query) {
|
||||
throw new Error("SQL 쿼리가 없습니다.");
|
||||
}
|
||||
|
||||
if (source.connectionType === "external" && source.externalConnectionId) {
|
||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||
const externalResult = await ExternalDbConnectionAPI.executeQuery(
|
||||
parseInt(source.externalConnectionId),
|
||||
source.query
|
||||
);
|
||||
if (!externalResult.success || !externalResult.data) {
|
||||
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
||||
}
|
||||
const resultData = externalResult.data as unknown as { rows: Record<string, unknown>[] };
|
||||
return convertToAlerts(resultData.rows, source.name || source.id || "Database");
|
||||
} else {
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(source.query);
|
||||
return convertToAlerts(result.rows, source.name || source.id || "Database");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const convertToAlerts = useCallback((rows: any[], sourceName: string): Alert[] => {
|
||||
console.log("🔄 convertToAlerts 호출:", rows.length, "개 행");
|
||||
|
||||
return rows.map((row: any, index: number) => {
|
||||
// 타입 결정 (UTIC XML 기준)
|
||||
let type: AlertType = "other";
|
||||
|
||||
// incidenteTypeCd: 1=사고, 2=공사, 3=행사, 4=기타
|
||||
if (row.incidenteTypeCd) {
|
||||
const typeCode = String(row.incidenteTypeCd);
|
||||
if (typeCode === "1") {
|
||||
type = "accident";
|
||||
} else if (typeCode === "2") {
|
||||
type = "construction";
|
||||
}
|
||||
}
|
||||
// 기상 특보 데이터 (warning 필드가 있으면 무조건 날씨)
|
||||
else if (row.warning) {
|
||||
type = "weather";
|
||||
}
|
||||
// 일반 데이터
|
||||
else if (row.type || row.타입 || row.alert_type) {
|
||||
type = (row.type || row.타입 || row.alert_type) as AlertType;
|
||||
}
|
||||
|
||||
// 심각도 결정
|
||||
let severity: "high" | "medium" | "low" = "medium";
|
||||
|
||||
if (type === "accident") {
|
||||
severity = "high"; // 사고는 항상 높음
|
||||
} else if (type === "construction") {
|
||||
severity = "medium"; // 공사는 중간
|
||||
} else if (row.level === "경보") {
|
||||
severity = "high";
|
||||
} else if (row.level === "주의" || row.level === "주의보") {
|
||||
severity = "medium";
|
||||
} else if (row.severity || row.심각도 || row.priority) {
|
||||
severity = (row.severity || row.심각도 || row.priority) as "high" | "medium" | "low";
|
||||
}
|
||||
|
||||
// 제목 생성 (UTIC XML 기준)
|
||||
let title = "";
|
||||
|
||||
if (type === "accident") {
|
||||
// incidenteSubTypeCd: 1=추돌, 2=접촉, 3=전복, 4=추락, 5=화재, 6=침수, 7=기타
|
||||
const subType = row.incidenteSubTypeCd;
|
||||
const subTypeMap: { [key: string]: string } = {
|
||||
"1": "추돌사고", "2": "접촉사고", "3": "전복사고",
|
||||
"4": "추락사고", "5": "화재사고", "6": "침수사고", "7": "기타사고"
|
||||
};
|
||||
title = subTypeMap[String(subType)] || "교통사고";
|
||||
} else if (type === "construction") {
|
||||
title = "도로공사";
|
||||
} else if (type === "weather" && row.warning && row.level) {
|
||||
// 날씨 특보: 공백 제거
|
||||
const warning = String(row.warning).trim();
|
||||
const level = String(row.level).trim();
|
||||
title = `${warning} ${level}`;
|
||||
} else {
|
||||
title = row.title || row.제목 || row.name || "알림";
|
||||
}
|
||||
|
||||
// 위치 정보 (UTIC XML 기준) - 공백 제거
|
||||
let location = row.addressJibun || row.addressNew ||
|
||||
row.roadName || row.linkName ||
|
||||
row.subRegion || row.region ||
|
||||
row.location || row.위치 || undefined;
|
||||
|
||||
if (location && typeof location === "string") {
|
||||
location = location.trim();
|
||||
}
|
||||
|
||||
// 설명 생성 (간결하게)
|
||||
let description = "";
|
||||
|
||||
if (row.incidentMsg) {
|
||||
description = row.incidentMsg;
|
||||
} else if (row.eventContent) {
|
||||
description = row.eventContent;
|
||||
} else if (row.period) {
|
||||
description = `발효 기간: ${row.period}`;
|
||||
} else if (row.description || row.설명 || row.content) {
|
||||
description = row.description || row.설명 || row.content;
|
||||
} else {
|
||||
// 설명이 없으면 위치 정보만 표시
|
||||
description = location || "상세 정보 없음";
|
||||
}
|
||||
|
||||
// 타임스탬프
|
||||
const timestamp = row.startDate || row.eventDate ||
|
||||
row.tmFc || row.tmEf ||
|
||||
row.timestamp || row.created_at ||
|
||||
new Date().toISOString();
|
||||
|
||||
const alert: Alert = {
|
||||
id: row.id || row.alert_id || row.incidentId || row.eventId ||
|
||||
row.code || row.subCode || `${sourceName}-${index}-${Date.now()}`,
|
||||
type,
|
||||
severity,
|
||||
title,
|
||||
location,
|
||||
description,
|
||||
timestamp,
|
||||
source: sourceName,
|
||||
};
|
||||
|
||||
console.log(` ✅ Alert ${index}:`, alert);
|
||||
return alert;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadMultipleDataSources = useCallback(async () => {
|
||||
if (!dataSources || dataSources.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log("🔄 RiskAlertTestWidget 데이터 로딩 시작:", dataSources.length, "개 소스");
|
||||
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
dataSources.map(async (source, index) => {
|
||||
console.log(`📡 데이터 소스 ${index + 1} 로딩 중:`, source.name, source.type);
|
||||
if (source.type === "api") {
|
||||
const alerts = await loadRestApiData(source);
|
||||
console.log(`✅ 데이터 소스 ${index + 1} 완료:`, alerts.length, "개 알림");
|
||||
return alerts;
|
||||
} else {
|
||||
const alerts = await loadDatabaseData(source);
|
||||
console.log(`✅ 데이터 소스 ${index + 1} 완료:`, alerts.length, "개 알림");
|
||||
return alerts;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const allAlerts: Alert[] = [];
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === "fulfilled") {
|
||||
console.log(`✅ 결과 ${index + 1} 병합:`, result.value.length, "개 알림");
|
||||
allAlerts.push(...result.value);
|
||||
} else {
|
||||
console.error(`❌ 결과 ${index + 1} 실패:`, result.reason);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("✅ 총", allAlerts.length, "개 알림 로딩 완료");
|
||||
allAlerts.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
setAlerts(allAlerts);
|
||||
setLastRefreshTime(new Date());
|
||||
} catch (err: any) {
|
||||
console.error("❌ 데이터 로딩 실패:", err);
|
||||
setError(err.message || "데이터 로딩 실패");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dataSources, loadRestApiData, loadDatabaseData]);
|
||||
|
||||
// 수동 새로고침 핸들러
|
||||
const handleManualRefresh = useCallback(() => {
|
||||
console.log("🔄 수동 새로고침 버튼 클릭");
|
||||
loadMultipleDataSources();
|
||||
}, [loadMultipleDataSources]);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
if (dataSources && dataSources.length > 0) {
|
||||
loadMultipleDataSources();
|
||||
}
|
||||
}, [dataSources, loadMultipleDataSources]);
|
||||
|
||||
// 자동 새로고침
|
||||
useEffect(() => {
|
||||
if (!dataSources || dataSources.length === 0) return;
|
||||
|
||||
// 모든 데이터 소스 중 가장 짧은 refreshInterval 찾기
|
||||
const intervals = dataSources
|
||||
.map((ds) => ds.refreshInterval)
|
||||
.filter((interval): interval is number => typeof interval === "number" && interval > 0);
|
||||
|
||||
if (intervals.length === 0) return;
|
||||
|
||||
const minInterval = Math.min(...intervals);
|
||||
console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
console.log("🔄 자동 새로고침 실행");
|
||||
loadMultipleDataSources();
|
||||
}, minInterval * 1000);
|
||||
|
||||
return () => {
|
||||
console.log("⏹️ 자동 새로고침 정리");
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [dataSources, loadMultipleDataSources]);
|
||||
|
||||
const getTypeIcon = (type: AlertType) => {
|
||||
switch (type) {
|
||||
case "accident": return <AlertTriangle className="h-4 w-4" />;
|
||||
case "weather": return <Cloud className="h-4 w-4" />;
|
||||
case "construction": return <Construction className="h-4 w-4" />;
|
||||
default: return <AlertTriangle className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: "high" | "medium" | "low") => {
|
||||
switch (severity) {
|
||||
case "high": return "bg-red-500";
|
||||
case "medium": return "bg-yellow-500";
|
||||
case "low": return "bg-blue-500";
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAlerts = filter === "all" ? alerts : alerts.filter(a => a.type === filter);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="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-gray-500">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-sm">⚠️ {error}</p>
|
||||
<button
|
||||
onClick={loadMultipleDataSources}
|
||||
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dataSources || dataSources.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-3">
|
||||
<div className="max-w-xs space-y-2 text-center">
|
||||
<div className="text-3xl">🚨</div>
|
||||
<h3 className="text-sm font-bold text-gray-900">리스크/알림</h3>
|
||||
<div className="space-y-1.5 text-xs text-gray-600">
|
||||
<p className="font-medium">다중 데이터 소스 지원</p>
|
||||
<ul className="space-y-0.5 text-left">
|
||||
<li>• 여러 REST API 동시 연결</li>
|
||||
<li>• 여러 Database 동시 연결</li>
|
||||
<li>• REST API + Database 혼합 가능</li>
|
||||
<li>• 알림 타입별 필터링</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
|
||||
<p className="font-medium">⚙️ 설정 방법</p>
|
||||
<p>데이터 소스를 추가하고 저장하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-red-50 to-orange-50">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b bg-white/80 p-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">
|
||||
{element?.customTitle || "리스크/알림"}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dataSources?.length || 0}개 데이터 소스 • {alerts.length}개 알림
|
||||
{lastRefreshTime && (
|
||||
<span className="ml-2">
|
||||
• {lastRefreshTime.toLocaleTimeString("ko-KR")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleManualRefresh}
|
||||
disabled={loading}
|
||||
className="h-8 gap-2 text-xs"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div className="flex-1 overflow-hidden p-2">
|
||||
<div className="mb-2 flex gap-1 overflow-x-auto">
|
||||
<Button
|
||||
variant={filter === "all" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setFilter("all")}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
전체 ({alerts.length})
|
||||
</Button>
|
||||
{["accident", "weather", "construction"].map((type) => {
|
||||
const count = alerts.filter(a => a.type === type).length;
|
||||
return (
|
||||
<Button
|
||||
key={type}
|
||||
variant={filter === type ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setFilter(type as AlertType)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{type === "accident" && "사고"}
|
||||
{type === "weather" && "날씨"}
|
||||
{type === "construction" && "공사"}
|
||||
{" "}({count})
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-1.5 overflow-y-auto">
|
||||
{filteredAlerts.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-gray-500">
|
||||
<p className="text-sm">알림이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredAlerts.map((alert) => (
|
||||
<Card key={alert.id} className="border-l-4 p-2" style={{ borderLeftColor: alert.severity === "high" ? "#ef4444" : alert.severity === "medium" ? "#f59e0b" : "#3b82f6" }}>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className={`mt-0.5 rounded-full p-1 ${alert.severity === "high" ? "bg-red-100 text-red-600" : alert.severity === "medium" ? "bg-yellow-100 text-yellow-600" : "bg-blue-100 text-blue-600"}`}>
|
||||
{getTypeIcon(alert.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h4 className="text-xs font-semibold truncate">{alert.title}</h4>
|
||||
<Badge variant={alert.severity === "high" ? "destructive" : "secondary"} className="h-4 text-[10px]">
|
||||
{alert.severity === "high" && "긴급"}
|
||||
{alert.severity === "medium" && "주의"}
|
||||
{alert.severity === "low" && "정보"}
|
||||
</Badge>
|
||||
</div>
|
||||
{alert.location && (
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">📍 {alert.location}</p>
|
||||
)}
|
||||
<p className="text-[10px] text-gray-600 mt-0.5 line-clamp-2">{alert.description}</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-[9px] text-gray-400">
|
||||
<span>{new Date(alert.timestamp).toLocaleString("ko-KR")}</span>
|
||||
{alert.source && <span>· {alert.source}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -13,12 +13,14 @@ import { useReactFlow } from "reactflow";
|
|||
import { SaveConfirmDialog } from "./dialogs/SaveConfirmDialog";
|
||||
import { validateFlow, summarizeValidations } from "@/lib/utils/flowValidation";
|
||||
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface FlowToolbarProps {
|
||||
validations?: FlowValidation[];
|
||||
}
|
||||
|
||||
export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
|
||||
const { toast } = useToast();
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
const {
|
||||
flowName,
|
||||
|
|
@ -56,9 +58,17 @@ export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
|
|||
const performSave = async () => {
|
||||
const result = await saveFlow();
|
||||
if (result.success) {
|
||||
alert(`✅ ${result.message}\nFlow ID: ${result.flowId}`);
|
||||
toast({
|
||||
title: "✅ 플로우 저장 완료",
|
||||
description: `${result.message}\nFlow ID: ${result.flowId}`,
|
||||
variant: "default",
|
||||
});
|
||||
} else {
|
||||
alert(`❌ 저장 실패\n\n${result.message}`);
|
||||
toast({
|
||||
title: "❌ 저장 실패",
|
||||
description: result.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
setShowSaveDialog(false);
|
||||
};
|
||||
|
|
@ -72,18 +82,30 @@ export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
|
|||
a.download = `${flowName || "flow"}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
alert("✅ JSON 파일로 내보내기 완료!");
|
||||
toast({
|
||||
title: "✅ 내보내기 완료",
|
||||
description: "JSON 파일로 저장되었습니다.",
|
||||
variant: "default",
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedNodes.length === 0) {
|
||||
alert("삭제할 노드를 선택해주세요.");
|
||||
toast({
|
||||
title: "⚠️ 선택된 노드 없음",
|
||||
description: "삭제할 노드를 선택해주세요.",
|
||||
variant: "default",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`선택된 ${selectedNodes.length}개 노드를 삭제하시겠습니까?`)) {
|
||||
removeNodes(selectedNodes);
|
||||
alert(`✅ ${selectedNodes.length}개 노드가 삭제되었습니다.`);
|
||||
toast({
|
||||
title: "✅ 노드 삭제 완료",
|
||||
description: `${selectedNodes.length}개 노드가 삭제되었습니다.`,
|
||||
variant: "default",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -18,189 +18,178 @@ interface ValidationNotificationProps {
|
|||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const ValidationNotification = memo(
|
||||
({ validations, onNodeClick, onClose }: ValidationNotificationProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const summary = summarizeValidations(validations);
|
||||
export const ValidationNotification = memo(({ validations, onNodeClick, onClose }: ValidationNotificationProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const summary = summarizeValidations(validations);
|
||||
|
||||
if (validations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (validations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
"parallel-conflict": "병렬 실행 충돌",
|
||||
"missing-where": "WHERE 조건 누락",
|
||||
"circular-reference": "순환 참조",
|
||||
"data-source-mismatch": "데이터 소스 불일치",
|
||||
"parallel-table-access": "병렬 테이블 접근",
|
||||
};
|
||||
return labels[type] || type;
|
||||
const getTypeLabel = (type: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
"disconnected-node": "연결되지 않은 노드",
|
||||
"parallel-conflict": "병렬 실행 충돌",
|
||||
"missing-where": "WHERE 조건 누락",
|
||||
"circular-reference": "순환 참조",
|
||||
"data-source-mismatch": "데이터 소스 불일치",
|
||||
"parallel-table-access": "병렬 테이블 접근",
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
// 타입별로 그룹화
|
||||
const groupedValidations = validations.reduce((acc, validation) => {
|
||||
// 타입별로 그룹화
|
||||
const groupedValidations = validations.reduce(
|
||||
(acc, validation) => {
|
||||
if (!acc[validation.type]) {
|
||||
acc[validation.type] = [];
|
||||
}
|
||||
acc[validation.type].push(validation);
|
||||
return acc;
|
||||
}, {} as Record<string, FlowValidation[]>);
|
||||
},
|
||||
{} as Record<string, FlowValidation[]>,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed right-4 top-4 z-50 w-80 animate-in slide-in-from-right-5 duration-300">
|
||||
return (
|
||||
<div className="animate-in slide-in-from-right-5 fixed top-4 right-4 z-50 w-80 duration-300">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border-2 bg-white shadow-2xl",
|
||||
summary.hasBlockingIssues
|
||||
? "border-red-500"
|
||||
: summary.warningCount > 0
|
||||
? "border-yellow-500"
|
||||
: "border-blue-500",
|
||||
)}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border-2 bg-white shadow-2xl",
|
||||
summary.hasBlockingIssues
|
||||
? "border-red-500"
|
||||
: summary.warningCount > 0
|
||||
? "border-yellow-500"
|
||||
: "border-blue-500"
|
||||
"flex cursor-pointer items-center justify-between p-3",
|
||||
summary.hasBlockingIssues ? "bg-red-50" : summary.warningCount > 0 ? "bg-yellow-50" : "bg-blue-50",
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-between p-3",
|
||||
summary.hasBlockingIssues
|
||||
? "bg-red-50"
|
||||
: summary.warningCount > 0
|
||||
? "bg-yellow-50"
|
||||
: "bg-blue-50"
|
||||
<div className="flex items-center gap-2">
|
||||
{summary.hasBlockingIssues ? (
|
||||
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||
) : summary.warningCount > 0 ? (
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
||||
) : (
|
||||
<Info className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{summary.hasBlockingIssues ? (
|
||||
<AlertCircle className="h-5 w-5 text-red-600" />
|
||||
) : summary.warningCount > 0 ? (
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
||||
) : (
|
||||
<Info className="h-5 w-5 text-blue-600" />
|
||||
)}
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
플로우 검증
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{summary.errorCount > 0 && (
|
||||
<Badge variant="destructive" className="h-5 text-[10px]">
|
||||
{summary.errorCount}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.warningCount > 0 && (
|
||||
<Badge className="h-5 bg-yellow-500 text-[10px] hover:bg-yellow-600">
|
||||
{summary.warningCount}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.infoCount > 0 && (
|
||||
<Badge variant="secondary" className="h-5 text-[10px]">
|
||||
{summary.infoCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-900">플로우 검증</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
{summary.errorCount > 0 && (
|
||||
<Badge variant="destructive" className="h-5 text-[10px]">
|
||||
{summary.errorCount}
|
||||
</Badge>
|
||||
)}
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="h-6 w-6 p-0 hover:bg-white/50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{summary.warningCount > 0 && (
|
||||
<Badge className="h-5 bg-yellow-500 text-[10px] hover:bg-yellow-600">{summary.warningCount}</Badge>
|
||||
)}
|
||||
{summary.infoCount > 0 && (
|
||||
<Badge variant="secondary" className="h-5 text-[10px]">
|
||||
{summary.infoCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 확장된 내용 */}
|
||||
{isExpanded && (
|
||||
<div className="max-h-[60vh] overflow-y-auto border-t">
|
||||
<div className="p-2 space-y-2">
|
||||
{Object.entries(groupedValidations).map(([type, typeValidations]) => {
|
||||
const firstValidation = typeValidations[0];
|
||||
const Icon =
|
||||
firstValidation.severity === "error"
|
||||
? AlertCircle
|
||||
: firstValidation.severity === "warning"
|
||||
? AlertTriangle
|
||||
: Info;
|
||||
|
||||
return (
|
||||
<div key={type}>
|
||||
{/* 타입 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"mb-1 flex items-center gap-2 rounded-md px-2 py-1 text-xs font-medium",
|
||||
firstValidation.severity === "error"
|
||||
? "bg-red-100 text-red-700"
|
||||
: firstValidation.severity === "warning"
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: "bg-blue-100 text-blue-700"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{getTypeLabel(type)}
|
||||
<span className="ml-auto">
|
||||
{typeValidations.length}개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 검증 항목들 */}
|
||||
<div className="space-y-1 pl-5">
|
||||
{typeValidations.map((validation, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group cursor-pointer rounded-md border border-gray-200 bg-gray-50 p-2 text-xs transition-all hover:border-gray-300 hover:bg-white hover:shadow-sm"
|
||||
onClick={() => onNodeClick?.(validation.nodeId)}
|
||||
>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
{validation.message}
|
||||
</p>
|
||||
{validation.affectedNodes && validation.affectedNodes.length > 1 && (
|
||||
<div className="mt-1 text-[10px] text-gray-500">
|
||||
영향받는 노드: {validation.affectedNodes.length}개
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] text-blue-600 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
클릭하여 노드 보기 →
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 요약 메시지 (닫혀있을 때) */}
|
||||
{!isExpanded && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<p className="text-xs text-gray-600">
|
||||
{summary.hasBlockingIssues
|
||||
? "⛔ 오류를 해결해야 저장할 수 있습니다"
|
||||
: summary.warningCount > 0
|
||||
? "⚠️ 경고 사항을 확인하세요"
|
||||
: "ℹ️ 정보를 확인하세요"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="h-6 w-6 p-0 hover:bg-white/50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 확장된 내용 */}
|
||||
{isExpanded && (
|
||||
<div className="max-h-[60vh] overflow-y-auto border-t">
|
||||
<div className="space-y-2 p-2">
|
||||
{Object.entries(groupedValidations).map(([type, typeValidations]) => {
|
||||
const firstValidation = typeValidations[0];
|
||||
const Icon =
|
||||
firstValidation.severity === "error"
|
||||
? AlertCircle
|
||||
: firstValidation.severity === "warning"
|
||||
? AlertTriangle
|
||||
: Info;
|
||||
|
||||
return (
|
||||
<div key={type}>
|
||||
{/* 타입 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"mb-1 flex items-center gap-2 rounded-md px-2 py-1 text-xs font-medium",
|
||||
firstValidation.severity === "error"
|
||||
? "bg-red-100 text-red-700"
|
||||
: firstValidation.severity === "warning"
|
||||
? "bg-yellow-100 text-yellow-700"
|
||||
: "bg-blue-100 text-blue-700",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{getTypeLabel(type)}
|
||||
<span className="ml-auto">{typeValidations.length}개</span>
|
||||
</div>
|
||||
|
||||
{/* 검증 항목들 */}
|
||||
<div className="space-y-1 pl-5">
|
||||
{typeValidations.map((validation, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="group cursor-pointer rounded-md border border-gray-200 bg-gray-50 p-2 text-xs transition-all hover:border-gray-300 hover:bg-white hover:shadow-sm"
|
||||
onClick={() => onNodeClick?.(validation.nodeId)}
|
||||
>
|
||||
<p className="leading-relaxed text-gray-700">{validation.message}</p>
|
||||
{validation.affectedNodes && validation.affectedNodes.length > 1 && (
|
||||
<div className="mt-1 text-[10px] text-gray-500">
|
||||
영향받는 노드: {validation.affectedNodes.length}개
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] text-blue-600 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
클릭하여 노드 보기 →
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 요약 메시지 (닫혀있을 때) */}
|
||||
{!isExpanded && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<p className="text-xs text-gray-600">
|
||||
{summary.hasBlockingIssues
|
||||
? "⛔ 오류를 해결해야 저장할 수 있습니다"
|
||||
: summary.warningCount > 0
|
||||
? "⚠️ 경고 사항을 확인하세요"
|
||||
: "ℹ️ 정보를 확인하세요"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ValidationNotification.displayName = "ValidationNotification";
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import { toast } from "sonner";
|
|||
import { FileUpload } from "@/components/screen/widgets/FileUpload";
|
||||
import { AdvancedSearchFilters } from "./filters/AdvancedSearchFilters";
|
||||
import { SaveModal } from "./SaveModal";
|
||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
|
||||
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
|
||||
interface FileInfo {
|
||||
|
|
@ -97,6 +98,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
style = {},
|
||||
onRefresh,
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
||||
|
|
@ -411,6 +413,29 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
async (page: number = 1, searchParams: Record<string, any> = {}) => {
|
||||
if (!component.tableName) return;
|
||||
|
||||
// 프리뷰 모드에서는 샘플 데이터만 표시
|
||||
if (isPreviewMode) {
|
||||
const sampleData = Array.from({ length: 3 }, (_, i) => {
|
||||
const sample: Record<string, any> = { id: i + 1 };
|
||||
component.columns.forEach((col) => {
|
||||
if (col.type === "number") {
|
||||
sample[col.key] = Math.floor(Math.random() * 1000);
|
||||
} else if (col.type === "boolean") {
|
||||
sample[col.key] = i % 2 === 0 ? "Y" : "N";
|
||||
} else {
|
||||
sample[col.key] = `샘플 ${col.label} ${i + 1}`;
|
||||
}
|
||||
});
|
||||
return sample;
|
||||
});
|
||||
setData(sampleData);
|
||||
setTotal(3);
|
||||
setTotalPages(1);
|
||||
setCurrentPage(1);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await tableTypeApi.getTableData(component.tableName, {
|
||||
|
|
@ -1792,21 +1817,53 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
|
||||
{/* CRUD 버튼들 */}
|
||||
{component.enableAdd && (
|
||||
<Button size="sm" onClick={handleAddData} disabled={loading} className="gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (isPreviewMode) {
|
||||
return;
|
||||
}
|
||||
handleAddData();
|
||||
}}
|
||||
disabled={loading || isPreviewMode}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{component.addButtonText || "추가"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{component.enableEdit && selectedRows.size === 1 && (
|
||||
<Button size="sm" onClick={handleEditData} disabled={loading} className="gap-2" variant="outline">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (isPreviewMode) {
|
||||
return;
|
||||
}
|
||||
handleEditData();
|
||||
}}
|
||||
disabled={loading || isPreviewMode}
|
||||
className="gap-2"
|
||||
variant="outline"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
{component.editButtonText || "수정"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{component.enableDelete && selectedRows.size > 0 && (
|
||||
<Button size="sm" variant="destructive" onClick={handleDeleteData} disabled={loading} className="gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (isPreviewMode) {
|
||||
return;
|
||||
}
|
||||
handleDeleteData();
|
||||
}}
|
||||
disabled={loading || isPreviewMode}
|
||||
className="gap-2"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{component.deleteButtonText || "삭제"}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import { UnifiedColumnInfo as ColumnInfo } from "@/types";
|
|||
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||
import { buildGridClasses } from "@/lib/constants/columnSpans";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
|
||||
interface InteractiveScreenViewerProps {
|
||||
component: ComponentData;
|
||||
|
|
@ -86,6 +87,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
return <div className="h-full w-full" />;
|
||||
}
|
||||
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const { userName, user } = useAuth(); // 현재 로그인한 사용자명과 사용자 정보 가져오기
|
||||
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
||||
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
|
||||
|
|
@ -211,6 +213,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
|
||||
// 폼 데이터 업데이트
|
||||
const updateFormData = (fieldName: string, value: any) => {
|
||||
// 프리뷰 모드에서는 데이터 업데이트 하지 않음
|
||||
if (isPreviewMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// console.log(`🔄 updateFormData: ${fieldName} = "${value}" (외부콜백: ${!!onFormDataChange})`);
|
||||
|
||||
// 항상 로컬 상태도 업데이트
|
||||
|
|
@ -837,6 +844,12 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
});
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// 프리뷰 모드에서는 파일 업로드 차단
|
||||
if (isPreviewMode) {
|
||||
e.target.value = ""; // 파일 선택 취소
|
||||
return;
|
||||
}
|
||||
|
||||
const files = e.target.files;
|
||||
const fieldName = widget.columnName || widget.id;
|
||||
|
||||
|
|
@ -1155,6 +1168,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
const config = widget.webTypeConfig as ButtonTypeConfig | undefined;
|
||||
|
||||
const handleButtonClick = async () => {
|
||||
// 프리뷰 모드에서는 버튼 동작 차단
|
||||
if (isPreviewMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actionType = config?.actionType || "save";
|
||||
|
||||
try {
|
||||
|
|
@ -1341,13 +1359,28 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
allComponents.find(c => c.columnName)?.tableName ||
|
||||
"dynamic_form_data"; // 기본값
|
||||
|
||||
// 🆕 자동으로 작성자 정보 추가
|
||||
const writerValue = user?.userId || userName || "unknown";
|
||||
console.log("👤 현재 사용자 정보:", {
|
||||
userId: user?.userId,
|
||||
userName: userName,
|
||||
writerValue: writerValue,
|
||||
});
|
||||
|
||||
const dataWithUserInfo = {
|
||||
...mappedData,
|
||||
writer: writerValue, // 테이블 생성 시 자동 생성되는 컬럼
|
||||
created_by: writerValue,
|
||||
updated_by: writerValue,
|
||||
};
|
||||
|
||||
const saveData: DynamicFormData = {
|
||||
screenId: screenInfo.id,
|
||||
tableName: tableName,
|
||||
data: mappedData,
|
||||
data: dataWithUserInfo,
|
||||
};
|
||||
|
||||
// console.log("🚀 API 저장 요청:", saveData);
|
||||
console.log("🚀 API 저장 요청:", saveData);
|
||||
|
||||
const result = await dynamicFormApi.saveFormData(saveData);
|
||||
|
||||
|
|
@ -1841,12 +1874,12 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
setPopupScreen(null);
|
||||
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
|
||||
}}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
|
||||
<DialogHeader className="px-6 pt-4 pb-2">
|
||||
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto max-h-[60vh] p-2">
|
||||
<div className="overflow-y-auto px-6 pb-6" style={{ maxHeight: "calc(90vh - 80px)" }}>
|
||||
{popupLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-gray-500">화면을 불러오는 중...</div>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/
|
|||
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
|
||||
import { FlowVisibilityConfig } from "@/types/control-management";
|
||||
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
|
||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
|
||||
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
||||
import "@/lib/registry/components/ButtonRenderer";
|
||||
|
|
@ -47,6 +48,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
hideLabel = false,
|
||||
screenInfo,
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const { userName, user } = useAuth();
|
||||
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
||||
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
|
||||
|
|
@ -178,16 +180,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
|
||||
// 버튼 컴포넌트 또는 위젯이 아닌 경우 DynamicComponentRenderer 사용
|
||||
if (comp.type !== "widget") {
|
||||
console.log("🎯 InteractiveScreenViewer - DynamicComponentRenderer 사용:", {
|
||||
componentId: comp.id,
|
||||
componentType: comp.type,
|
||||
isButton: isButtonComponent(comp),
|
||||
componentConfig: comp.componentConfig,
|
||||
style: comp.style,
|
||||
size: comp.size,
|
||||
position: comp.position,
|
||||
});
|
||||
|
||||
return (
|
||||
<DynamicComponentRenderer
|
||||
component={comp}
|
||||
|
|
@ -209,7 +201,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
setFlowSelectedStepId(stepId);
|
||||
}}
|
||||
onRefresh={() => {
|
||||
console.log("🔄 버튼에서 테이블 새로고침 요청됨");
|
||||
// 테이블 컴포넌트는 자체적으로 loadData 호출
|
||||
}}
|
||||
onClose={() => {
|
||||
|
|
@ -405,7 +396,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
await handleCustomAction();
|
||||
break;
|
||||
default:
|
||||
// console.log("🔘 기본 버튼 클릭");
|
||||
// console.log("🔘 기본 버튼 클릭");
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("버튼 액션 오류:", error);
|
||||
|
|
@ -437,9 +428,10 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
const fieldName = comp.columnName || comp.id;
|
||||
|
||||
// 화면 ID 추출 (URL에서)
|
||||
const screenId = screenInfo?.screenId ||
|
||||
(typeof window !== 'undefined' && window.location.pathname.includes('/screens/')
|
||||
? parseInt(window.location.pathname.split('/screens/')[1])
|
||||
const screenId =
|
||||
screenInfo?.screenId ||
|
||||
(typeof window !== "undefined" && window.location.pathname.includes("/screens/")
|
||||
? parseInt(window.location.pathname.split("/screens/")[1])
|
||||
: null);
|
||||
|
||||
return (
|
||||
|
|
@ -455,8 +447,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
disabled: readonly,
|
||||
}}
|
||||
componentStyle={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
className="h-full w-full"
|
||||
isInteractive={true}
|
||||
|
|
@ -465,12 +457,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
screenId, // 🎯 화면 ID 전달
|
||||
// 🎯 백엔드 API가 기대하는 정확한 형식으로 설정
|
||||
autoLink: true, // 자동 연결 활성화
|
||||
linkedTable: 'screen_files', // 연결 테이블
|
||||
linkedTable: "screen_files", // 연결 테이블
|
||||
recordId: screenId, // 레코드 ID
|
||||
columnName: fieldName, // 컬럼명 (중요!)
|
||||
isVirtualFileColumn: true, // 가상 파일 컬럼
|
||||
id: formData.id,
|
||||
...formData
|
||||
...formData,
|
||||
}}
|
||||
onFormDataChange={(data) => {
|
||||
// console.log("📝 실제 화면 파일 업로드 완료:", data);
|
||||
|
|
@ -486,50 +478,54 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
hasUploadedFiles: !!updates.uploadedFiles,
|
||||
filesCount: updates.uploadedFiles?.length || 0,
|
||||
hasLastFileUpdate: !!updates.lastFileUpdate,
|
||||
updates
|
||||
updates,
|
||||
});
|
||||
|
||||
|
||||
// 파일 업로드/삭제 완료 시 formData 업데이터
|
||||
if (updates.uploadedFiles && onFormDataChange) {
|
||||
onFormDataChange(fieldName, updates.uploadedFiles);
|
||||
}
|
||||
|
||||
|
||||
// 🎯 화면설계 모드와 동기화를 위한 전역 이벤트 발생 (업로드/삭제 모두)
|
||||
if (updates.uploadedFiles !== undefined && typeof window !== 'undefined') {
|
||||
if (updates.uploadedFiles !== undefined && typeof window !== "undefined") {
|
||||
// 업로드인지 삭제인지 판단 (lastFileUpdate가 있으면 변경사항 있음)
|
||||
const action = updates.lastFileUpdate ? 'update' : 'sync';
|
||||
|
||||
const action = updates.lastFileUpdate ? "update" : "sync";
|
||||
|
||||
const eventDetail = {
|
||||
componentId: comp.id,
|
||||
files: updates.uploadedFiles,
|
||||
fileCount: updates.uploadedFiles.length,
|
||||
action: action,
|
||||
timestamp: updates.lastFileUpdate || Date.now(),
|
||||
source: 'realScreen' // 실제 화면에서 온 이벤트임을 표시
|
||||
source: "realScreen", // 실제 화면에서 온 이벤트임을 표시
|
||||
};
|
||||
|
||||
|
||||
// console.log("🚀🚀🚀 실제 화면 파일 변경 이벤트 발생:", eventDetail);
|
||||
|
||||
const event = new CustomEvent('globalFileStateChanged', {
|
||||
detail: eventDetail
|
||||
|
||||
const event = new CustomEvent("globalFileStateChanged", {
|
||||
detail: eventDetail,
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
|
||||
// console.log("✅✅✅ 실제 화면 → 화면설계 모드 동기화 이벤트 발생 완료");
|
||||
|
||||
|
||||
// 추가 지연 이벤트들 (화면설계 모드가 열려있을 때를 대비)
|
||||
setTimeout(() => {
|
||||
// console.log("🔄 실제 화면 추가 이벤트 발생 (지연 100ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true }
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("globalFileStateChanged", {
|
||||
detail: { ...eventDetail, delayed: true },
|
||||
}),
|
||||
);
|
||||
}, 100);
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
// console.log("🔄 실제 화면 추가 이벤트 발생 (지연 500ms)");
|
||||
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||
detail: { ...eventDetail, delayed: true, attempt: 2 }
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("globalFileStateChanged", {
|
||||
detail: { ...eventDetail, delayed: true, attempt: 2 },
|
||||
}),
|
||||
);
|
||||
}, 500);
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ interface RealtimePreviewProps {
|
|||
// 버튼 액션을 위한 props
|
||||
screenId?: number;
|
||||
tableName?: string;
|
||||
userId?: string; // 🆕 현재 사용자 ID
|
||||
userName?: string; // 🆕 현재 사용자 이름
|
||||
companyCode?: string; // 🆕 현재 사용자의 회사 코드
|
||||
selectedRowsData?: any[];
|
||||
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
|
||||
flowSelectedData?: any[];
|
||||
|
|
@ -96,6 +99,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
onConfigChange,
|
||||
screenId,
|
||||
tableName,
|
||||
userId, // 🆕 사용자 ID
|
||||
userName, // 🆕 사용자 이름
|
||||
companyCode, // 🆕 회사 코드
|
||||
selectedRowsData,
|
||||
onSelectedRowsChange,
|
||||
flowSelectedData,
|
||||
|
|
@ -291,6 +297,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
onConfigChange={onConfigChange}
|
||||
screenId={screenId}
|
||||
tableName={tableName}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={onSelectedRowsChange}
|
||||
flowSelectedData={flowSelectedData}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { InteractiveScreenViewer } from "./InteractiveScreenViewer";
|
|||
import { screenApi } from "@/lib/api/screen";
|
||||
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
||||
import { ComponentData } from "@/lib/types/screen";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
interface SaveModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -33,6 +34,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
initialData,
|
||||
onSaveSuccess,
|
||||
}) => {
|
||||
const { user, userName } = useAuth(); // 현재 사용자 정보 가져오기
|
||||
const [formData, setFormData] = useState<Record<string, any>>(initialData || {});
|
||||
const [originalData, setOriginalData] = useState<Record<string, any>>(initialData || {});
|
||||
const [screenData, setScreenData] = useState<any>(null);
|
||||
|
|
@ -88,13 +90,13 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
onClose();
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('closeSaveModal', handleCloseSaveModal);
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("closeSaveModal", handleCloseSaveModal);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('closeSaveModal', handleCloseSaveModal);
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("closeSaveModal", handleCloseSaveModal);
|
||||
}
|
||||
};
|
||||
}, [onClose]);
|
||||
|
|
@ -127,16 +129,28 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
// 저장할 데이터 준비
|
||||
const dataToSave = initialData ? changedData : formData;
|
||||
|
||||
// 🆕 자동으로 작성자 정보 추가
|
||||
const writerValue = user?.userId || userName || "unknown";
|
||||
console.log("👤 현재 사용자 정보:", {
|
||||
userId: user?.userId,
|
||||
userName: userName,
|
||||
writerValue: writerValue,
|
||||
});
|
||||
|
||||
const dataWithUserInfo = {
|
||||
...dataToSave,
|
||||
writer: writerValue, // 테이블 생성 시 자동 생성되는 컬럼
|
||||
created_by: writerValue,
|
||||
updated_by: writerValue,
|
||||
};
|
||||
|
||||
// 테이블명 결정
|
||||
const tableName =
|
||||
screenData.tableName ||
|
||||
components.find((c) => c.columnName)?.tableName ||
|
||||
"dynamic_form_data";
|
||||
const tableName = screenData.tableName || components.find((c) => c.columnName)?.tableName || "dynamic_form_data";
|
||||
|
||||
const saveData: DynamicFormData = {
|
||||
screenId: screenId,
|
||||
tableName: tableName,
|
||||
data: dataToSave,
|
||||
data: dataWithUserInfo,
|
||||
};
|
||||
|
||||
console.log("💾 저장 요청 데이터:", saveData);
|
||||
|
|
@ -147,10 +161,10 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
if (result.success) {
|
||||
// ✅ 저장 성공
|
||||
toast.success(initialData ? "수정되었습니다!" : "저장되었습니다!");
|
||||
|
||||
|
||||
// 모달 닫기
|
||||
onClose();
|
||||
|
||||
|
||||
// 테이블 새로고침 콜백 호출
|
||||
if (onSaveSuccess) {
|
||||
setTimeout(() => {
|
||||
|
|
@ -187,19 +201,12 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
|
||||
<DialogContent className={`${modalSizeClasses[modalSize]} max-h-[90vh] p-0 gap-0`}>
|
||||
<DialogHeader className="px-6 py-4 border-b">
|
||||
<DialogContent className={`${modalSizeClasses[modalSize]} max-h-[90vh] gap-0 p-0`}>
|
||||
<DialogHeader className="border-b px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-lg font-semibold">
|
||||
{initialData ? "데이터 수정" : "데이터 등록"}
|
||||
</DialogTitle>
|
||||
<DialogTitle className="text-lg font-semibold">{initialData ? "데이터 수정" : "데이터 등록"}</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Button onClick={handleSave} disabled={isSaving} size="sm" className="gap-2">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
|
@ -212,12 +219,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
<Button onClick={onClose} disabled={isSaving} variant="ghost" size="sm">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -227,7 +229,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
<div className="overflow-auto p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : screenData && components.length > 0 ? (
|
||||
<div
|
||||
|
|
@ -293,13 +295,10 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
화면에 컴포넌트가 없습니다.
|
||||
</div>
|
||||
<div className="text-muted-foreground py-12 text-center">화면에 컴포넌트가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -448,10 +448,10 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
{screens.map((screen) => (
|
||||
<TableRow
|
||||
key={screen.screenId}
|
||||
className={`hover:bg-muted/50 border-b transition-colors ${
|
||||
className={`hover:bg-muted/50 cursor-pointer border-b transition-colors ${
|
||||
selectedScreen?.screenId === screen.screenId ? "border-primary/20 bg-accent" : ""
|
||||
}`}
|
||||
onClick={() => handleScreenSelect(screen)}
|
||||
onClick={() => onDesignScreen(screen)}
|
||||
>
|
||||
<TableCell className="h-16 cursor-pointer">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -28,70 +28,17 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 p-6 ${className}`}>
|
||||
{/* 여백 섹션 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Box className="text-primary h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">여백</h3>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="margin" className="text-xs font-medium">
|
||||
외부 여백
|
||||
</Label>
|
||||
<Input
|
||||
id="margin"
|
||||
type="text"
|
||||
placeholder="10px"
|
||||
value={localStyle.margin || ""}
|
||||
onChange={(e) => handleStyleChange("margin", e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="padding" className="text-xs font-medium">
|
||||
내부 여백
|
||||
</Label>
|
||||
<Input
|
||||
id="padding"
|
||||
type="text"
|
||||
placeholder="10px"
|
||||
value={localStyle.padding || ""}
|
||||
onChange={(e) => handleStyleChange("padding", e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="gap" className="text-xs font-medium">
|
||||
간격
|
||||
</Label>
|
||||
<Input
|
||||
id="gap"
|
||||
type="text"
|
||||
placeholder="10px"
|
||||
value={localStyle.gap || ""}
|
||||
onChange={(e) => handleStyleChange("gap", e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`space-y-4 p-3 ${className}`}>
|
||||
{/* 테두리 섹션 */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Square className="text-primary h-4 w-4" />
|
||||
<Square className="text-primary h-3.5 w-3.5" />
|
||||
<h3 className="text-sm font-semibold">테두리</h3>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Separator className="my-1.5" />
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="borderWidth" className="text-xs font-medium">
|
||||
두께
|
||||
</Label>
|
||||
|
|
@ -101,10 +48,11 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
placeholder="1px"
|
||||
value={localStyle.borderWidth || ""}
|
||||
onChange={(e) => handleStyleChange("borderWidth", e.target.value)}
|
||||
className="h-8"
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="borderStyle" className="text-xs font-medium">
|
||||
스타일
|
||||
</Label>
|
||||
|
|
@ -112,42 +60,52 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
value={localStyle.borderStyle || "solid"}
|
||||
onValueChange={(value) => handleStyleChange("borderStyle", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0" style={{ fontSize: "12px" }}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">실선</SelectItem>
|
||||
<SelectItem value="dashed">파선</SelectItem>
|
||||
<SelectItem value="dotted">점선</SelectItem>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
<SelectItem value="solid" style={{ fontSize: "12px" }}>
|
||||
실선
|
||||
</SelectItem>
|
||||
<SelectItem value="dashed" style={{ fontSize: "12px" }}>
|
||||
파선
|
||||
</SelectItem>
|
||||
<SelectItem value="dotted" style={{ fontSize: "12px" }}>
|
||||
점선
|
||||
</SelectItem>
|
||||
<SelectItem value="none" style={{ fontSize: "12px" }}>
|
||||
없음
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="borderColor" className="text-xs font-medium">
|
||||
색상
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
id="borderColor"
|
||||
type="color"
|
||||
value={localStyle.borderColor || "#000000"}
|
||||
onChange={(e) => handleStyleChange("borderColor", e.target.value)}
|
||||
className="h-8 w-14 p-1"
|
||||
className="h-6 w-12 p-1"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={localStyle.borderColor || "#000000"}
|
||||
onChange={(e) => handleStyleChange("borderColor", e.target.value)}
|
||||
placeholder="#000000"
|
||||
className="h-8 flex-1"
|
||||
className="h-6 flex-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="borderRadius" className="text-xs font-medium">
|
||||
모서리
|
||||
</Label>
|
||||
|
|
@ -157,7 +115,8 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
placeholder="5px"
|
||||
value={localStyle.borderRadius || ""}
|
||||
onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
|
||||
className="h-8"
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -165,38 +124,40 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
</div>
|
||||
|
||||
{/* 배경 섹션 */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="text-primary h-4 w-4" />
|
||||
<Palette className="text-primary h-3.5 w-3.5" />
|
||||
<h3 className="text-sm font-semibold">배경</h3>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Separator className="my-1.5" />
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="backgroundColor" className="text-xs font-medium">
|
||||
배경 색상
|
||||
색상
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
id="backgroundColor"
|
||||
type="color"
|
||||
value={localStyle.backgroundColor || "#ffffff"}
|
||||
onChange={(e) => handleStyleChange("backgroundColor", e.target.value)}
|
||||
className="h-8 w-14 p-1"
|
||||
className="h-6 w-12 p-1"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={localStyle.backgroundColor || "#ffffff"}
|
||||
onChange={(e) => handleStyleChange("backgroundColor", e.target.value)}
|
||||
placeholder="#ffffff"
|
||||
className="h-8 flex-1"
|
||||
className="h-6 flex-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="backgroundImage" className="text-xs font-medium">
|
||||
배경 이미지
|
||||
이미지
|
||||
</Label>
|
||||
<Input
|
||||
id="backgroundImage"
|
||||
|
|
@ -204,43 +165,46 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
placeholder="url('image.jpg')"
|
||||
value={localStyle.backgroundImage || ""}
|
||||
onChange={(e) => handleStyleChange("backgroundImage", e.target.value)}
|
||||
className="h-8"
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 텍스트 섹션 */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Type className="text-primary h-4 w-4" />
|
||||
<Type className="text-primary h-3.5 w-3.5" />
|
||||
<h3 className="text-sm font-semibold">텍스트</h3>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Separator className="my-1.5" />
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="color" className="text-xs font-medium">
|
||||
색상
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
id="color"
|
||||
type="color"
|
||||
value={localStyle.color || "#000000"}
|
||||
onChange={(e) => handleStyleChange("color", e.target.value)}
|
||||
className="h-8 w-14 p-1"
|
||||
className="h-6 w-12 p-1"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={localStyle.color || "#000000"}
|
||||
onChange={(e) => handleStyleChange("color", e.target.value)}
|
||||
placeholder="#000000"
|
||||
className="h-8 flex-1"
|
||||
className="h-6 flex-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="fontSize" className="text-xs font-medium">
|
||||
크기
|
||||
</Label>
|
||||
|
|
@ -250,50 +214,73 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
placeholder="14px"
|
||||
value={localStyle.fontSize || ""}
|
||||
onChange={(e) => handleStyleChange("fontSize", e.target.value)}
|
||||
className="h-8"
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="max-w-[140px] space-y-0.5">
|
||||
<Label htmlFor="fontWeight" className="text-[10px] font-medium">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="fontWeight" className="text-xs font-medium">
|
||||
굵기
|
||||
</Label>
|
||||
<Select
|
||||
value={localStyle.fontWeight || "normal"}
|
||||
onValueChange={(value) => handleStyleChange("fontWeight", value)}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-full text-[10px] px-2">
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0" style={{ fontSize: "12px" }}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal" className="text-[10px]">보통</SelectItem>
|
||||
<SelectItem value="bold" className="text-[10px]">굵게</SelectItem>
|
||||
<SelectItem value="100" className="text-[10px]">100</SelectItem>
|
||||
<SelectItem value="400" className="text-[10px]">400</SelectItem>
|
||||
<SelectItem value="500" className="text-[10px]">500</SelectItem>
|
||||
<SelectItem value="600" className="text-[10px]">600</SelectItem>
|
||||
<SelectItem value="700" className="text-[10px]">700</SelectItem>
|
||||
<SelectItem value="normal" style={{ fontSize: "12px" }}>
|
||||
보통
|
||||
</SelectItem>
|
||||
<SelectItem value="bold" style={{ fontSize: "12px" }}>
|
||||
굵게
|
||||
</SelectItem>
|
||||
<SelectItem value="100" style={{ fontSize: "12px" }}>
|
||||
100
|
||||
</SelectItem>
|
||||
<SelectItem value="400" style={{ fontSize: "12px" }}>
|
||||
400
|
||||
</SelectItem>
|
||||
<SelectItem value="500" style={{ fontSize: "12px" }}>
|
||||
500
|
||||
</SelectItem>
|
||||
<SelectItem value="600" style={{ fontSize: "12px" }}>
|
||||
600
|
||||
</SelectItem>
|
||||
<SelectItem value="700" style={{ fontSize: "12px" }}>
|
||||
700
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="max-w-[140px] space-y-0.5">
|
||||
<Label htmlFor="textAlign" className="text-[10px] font-medium">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="textAlign" className="text-xs font-medium">
|
||||
정렬
|
||||
</Label>
|
||||
<Select
|
||||
value={localStyle.textAlign || "left"}
|
||||
onValueChange={(value) => handleStyleChange("textAlign", value)}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-full text-[10px] px-2">
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0" style={{ fontSize: "12px" }}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left" className="text-[10px]">왼쪽</SelectItem>
|
||||
<SelectItem value="center" className="text-[10px]">가운데</SelectItem>
|
||||
<SelectItem value="right" className="text-[10px]">오른쪽</SelectItem>
|
||||
<SelectItem value="justify" className="text-[10px]">양쪽</SelectItem>
|
||||
<SelectItem value="left" style={{ fontSize: "12px" }}>
|
||||
왼쪽
|
||||
</SelectItem>
|
||||
<SelectItem value="center" style={{ fontSize: "12px" }}>
|
||||
가운데
|
||||
</SelectItem>
|
||||
<SelectItem value="right" style={{ fontSize: "12px" }}>
|
||||
오른쪽
|
||||
</SelectItem>
|
||||
<SelectItem value="justify" style={{ fontSize: "12px" }}>
|
||||
양쪽
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -61,6 +61,24 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
const [displayColumnOpen, setDisplayColumnOpen] = useState(false);
|
||||
const [displayColumnSearch, setDisplayColumnSearch] = useState("");
|
||||
|
||||
// 🎯 플로우 위젯이 화면에 있는지 확인
|
||||
const hasFlowWidget = useMemo(() => {
|
||||
const found = allComponents.some((comp: any) => {
|
||||
// ScreenDesigner에서 저장하는 componentType 속성 확인!
|
||||
const compType = comp.componentType || comp.widgetType || "";
|
||||
|
||||
// "flow-widget" 체크
|
||||
const isFlow = compType === "flow-widget" || compType?.toLowerCase().includes("flow");
|
||||
|
||||
if (isFlow) {
|
||||
console.log("✅ 플로우 위젯 발견!", { id: comp.id, componentType: comp.componentType });
|
||||
}
|
||||
return isFlow;
|
||||
});
|
||||
console.log("🎯 플로우 위젯 존재 여부:", found);
|
||||
return found;
|
||||
}, [allComponents]);
|
||||
|
||||
// 컴포넌트 prop 변경 시 로컬 상태 동기화 (Input만)
|
||||
useEffect(() => {
|
||||
const latestConfig = component.componentConfig || {};
|
||||
|
|
@ -298,7 +316,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={modalScreenOpen}
|
||||
className="h-10 w-full justify-between"
|
||||
className="h-6 w-full justify-between px-2 py-0"
|
||||
style={{ fontSize: "12px" }}
|
||||
disabled={screensLoading}
|
||||
>
|
||||
{config.action?.targetScreenId
|
||||
|
|
@ -372,7 +391,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={modalScreenOpen}
|
||||
className="h-10 w-full justify-between"
|
||||
className="h-6 w-full justify-between px-2 py-0"
|
||||
style={{ fontSize: "12px" }}
|
||||
disabled={screensLoading}
|
||||
>
|
||||
{config.action?.targetScreenId
|
||||
|
|
@ -515,94 +535,64 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
{/* 테이블 이력 보기 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "view_table_history" && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<h4 className="text-sm font-medium">📜 테이블 이력 보기 설정</h4>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
전체 이력 표시 컬럼 (필수) <span className="text-red-600">*</span>
|
||||
</Label>
|
||||
|
||||
{!config.action?.historyTableName && !currentTableName ? (
|
||||
<div className="mt-2 rounded-md border border-yellow-300 bg-yellow-50 p-3">
|
||||
<p className="text-xs text-yellow-800">
|
||||
⚠️ 먼저 <strong>테이블명</strong>을 입력하거나, 현재 화면에 테이블을 연결해주세요.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!config.action?.historyTableName && currentTableName && (
|
||||
<div className="mt-2 rounded-md border border-green-300 bg-green-50 p-2">
|
||||
<p className="text-xs text-green-800">
|
||||
✓ 현재 화면의 테이블 <strong>{currentTableName}</strong>을(를) 자동으로 사용합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Popover open={displayColumnOpen} onOpenChange={setDisplayColumnOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={displayColumnOpen}
|
||||
className="mt-2 h-10 w-full justify-between text-sm"
|
||||
disabled={columnsLoading || tableColumns.length === 0}
|
||||
>
|
||||
{columnsLoading
|
||||
? "로딩 중..."
|
||||
: config.action?.historyDisplayColumn
|
||||
? config.action.historyDisplayColumn
|
||||
: tableColumns.length === 0
|
||||
? "사용 가능한 컬럼이 없습니다"
|
||||
: "컬럼을 선택하세요"}
|
||||
<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-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-sm">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column}
|
||||
value={column}
|
||||
onSelect={(currentValue) => {
|
||||
onUpdateProperty("componentConfig.action.historyDisplayColumn", currentValue);
|
||||
setDisplayColumnOpen(false);
|
||||
}}
|
||||
className="text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.action?.historyDisplayColumn === column ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{column}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<p className="mt-2 text-xs text-gray-700">
|
||||
<strong>전체 테이블 이력</strong>에서 레코드를 구분하기 위한 컬럼입니다.
|
||||
<br />
|
||||
예: <code className="rounded bg-white px-1">device_code</code>를 설정하면 이력에 "DTG-001"로
|
||||
표시됩니다.
|
||||
<br />이 컬럼으로 검색도 가능합니다.
|
||||
</p>
|
||||
|
||||
{tableColumns.length === 0 && !columnsLoading && (
|
||||
<p className="mt-2 text-xs text-red-600">
|
||||
⚠️ ID 및 날짜 타입 컬럼을 제외한 사용 가능한 컬럼이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Popover open={displayColumnOpen} onOpenChange={setDisplayColumnOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={displayColumnOpen}
|
||||
className="mt-2 h-8 w-full justify-between text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
disabled={columnsLoading || tableColumns.length === 0}
|
||||
>
|
||||
{columnsLoading
|
||||
? "로딩 중..."
|
||||
: config.action?.historyDisplayColumn
|
||||
? config.action.historyDisplayColumn
|
||||
: tableColumns.length === 0
|
||||
? "사용 가능한 컬럼이 없습니다"
|
||||
: "컬럼을 선택하세요"}
|
||||
<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" style={{ fontSize: "12px" }} />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs" style={{ fontSize: "12px" }}>
|
||||
컬럼을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column}
|
||||
value={column}
|
||||
onSelect={(currentValue) => {
|
||||
onUpdateProperty("componentConfig.action.historyDisplayColumn", currentValue);
|
||||
setDisplayColumnOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.action?.historyDisplayColumn === column ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{column}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -620,7 +610,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={navScreenOpen}
|
||||
className="h-10 w-full justify-between"
|
||||
className="h-6 w-full justify-between px-2 py-0"
|
||||
style={{ fontSize: "12px" }}
|
||||
disabled={screensLoading}
|
||||
>
|
||||
{config.action?.targetScreenId
|
||||
|
|
@ -693,6 +684,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
setLocalInputs((prev) => ({ ...prev, targetUrl: newValue }));
|
||||
onUpdateProperty("componentConfig.action.targetUrl", newValue);
|
||||
}}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">URL을 입력하면 화면 선택보다 우선 적용됩니다</p>
|
||||
</div>
|
||||
|
|
@ -704,14 +697,16 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
||||
</div>
|
||||
|
||||
{/* 🆕 플로우 단계별 표시 제어 섹션 */}
|
||||
<div className="mt-8 border-t border-gray-200 pt-6">
|
||||
<FlowVisibilityConfigPanel
|
||||
component={component}
|
||||
allComponents={allComponents}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
</div>
|
||||
{/* 🆕 플로우 단계별 표시 제어 섹션 (플로우 위젯이 있을 때만 표시) */}
|
||||
{hasFlowWidget && (
|
||||
<div className="mt-8 border-t border-gray-200 pt-6">
|
||||
<FlowVisibilityConfigPanel
|
||||
component={component}
|
||||
allComponents={allComponents}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
체크박스 설정
|
||||
</CardTitle>
|
||||
|
|
@ -173,7 +173,7 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.label || ""}
|
||||
onChange={(e) => updateConfig("label", e.target.value)}
|
||||
placeholder="체크박스 라벨"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -187,7 +187,7 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.checkedValue || ""}
|
||||
onChange={(e) => updateConfig("checkedValue", e.target.value)}
|
||||
placeholder="Y"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -199,7 +199,7 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.uncheckedValue || ""}
|
||||
onChange={(e) => updateConfig("uncheckedValue", e.target.value)}
|
||||
placeholder="N"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -232,7 +232,7 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.groupLabel || ""}
|
||||
onChange={(e) => updateConfig("groupLabel", e.target.value)}
|
||||
placeholder="체크박스 그룹 제목"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -244,19 +244,19 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={newOptionLabel}
|
||||
onChange={(e) => setNewOptionLabel(e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Input
|
||||
value={newOptionValue}
|
||||
onChange={(e) => setNewOptionValue(e.target.value)}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={addOption}
|
||||
disabled={!newOptionLabel.trim() || !newOptionValue.trim()}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -277,13 +277,13 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={option.label}
|
||||
onChange={(e) => updateOption(index, "label", e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Input
|
||||
value={option.value}
|
||||
onChange={(e) => updateOption(index, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Switch
|
||||
checked={!option.disabled}
|
||||
|
|
@ -361,7 +361,7 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
disabled={localConfig.readonly}
|
||||
required={localConfig.required}
|
||||
defaultChecked={localConfig.defaultChecked}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Label htmlFor="preview-single" className="text-xs">
|
||||
{localConfig.label || "체크박스 라벨"}
|
||||
|
|
@ -380,7 +380,7 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
disabled={localConfig.readonly || option.disabled}
|
||||
required={localConfig.required && index === 0} // 첫 번째에만 required 표시
|
||||
defaultChecked={option.checked}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Label htmlFor={`preview-group-${index}`} className="text-xs">
|
||||
{option.label}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export const CodeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<Code className="h-4 w-4" />
|
||||
코드 에디터 설정
|
||||
</CardTitle>
|
||||
|
|
@ -174,7 +174,7 @@ export const CodeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
step={50}
|
||||
value={localConfig.height || 300}
|
||||
onChange={(e) => updateConfig("height", parseInt(e.target.value))}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>150px</span>
|
||||
|
|
@ -199,7 +199,7 @@ export const CodeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
onChange={(e) => updateConfig("fontSize", parseInt(e.target.value))}
|
||||
min={10}
|
||||
max={24}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -214,7 +214,7 @@ export const CodeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
onChange={(e) => updateConfig("tabSize", parseInt(e.target.value))}
|
||||
min={1}
|
||||
max={8}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -308,7 +308,7 @@ export const CodeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="코드를 입력하세요..."
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -330,7 +330,7 @@ export const CodeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.defaultValue || ""}
|
||||
onChange={(e) => updateConfig("defaultValue", e.target.value)}
|
||||
placeholder="기본 코드 내용"
|
||||
className="font-mono text-xs"
|
||||
className="font-mono text-xs" style={{ fontSize: "12px" }}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export const DateConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<Calendar className="h-4 w-4" />
|
||||
날짜 설정
|
||||
</CardTitle>
|
||||
|
|
@ -95,7 +95,7 @@ export const DateConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="날짜를 선택하세요"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ export const DateConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
type={localConfig.showTime ? "datetime-local" : "date"}
|
||||
value={localConfig.minDate || ""}
|
||||
onChange={(e) => updateConfig("minDate", e.target.value)}
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => setCurrentDate("minDate")} className="text-xs">
|
||||
오늘
|
||||
|
|
@ -167,7 +167,7 @@ export const DateConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
type={localConfig.showTime ? "datetime-local" : "date"}
|
||||
value={localConfig.maxDate || ""}
|
||||
onChange={(e) => updateConfig("maxDate", e.target.value)}
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => setCurrentDate("maxDate")} className="text-xs">
|
||||
오늘
|
||||
|
|
@ -190,7 +190,7 @@ export const DateConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
type={localConfig.showTime ? "datetime-local" : "date"}
|
||||
value={localConfig.defaultValue || ""}
|
||||
onChange={(e) => updateConfig("defaultValue", e.target.value)}
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => setCurrentDate("defaultValue")} className="text-xs">
|
||||
현재
|
||||
|
|
@ -245,7 +245,7 @@ export const DateConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
min={localConfig.minDate}
|
||||
max={localConfig.maxDate}
|
||||
defaultValue={localConfig.defaultValue}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
형식: {localConfig.format}
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<Database className="h-4 w-4" />
|
||||
엔티티 설정
|
||||
</CardTitle>
|
||||
|
|
@ -183,7 +183,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.entityType || ""}
|
||||
onChange={(e) => updateConfig("entityType", e.target.value)}
|
||||
placeholder="user, product, department..."
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -196,7 +196,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => applyEntityType(entity.value)}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
>
|
||||
{entity.label}
|
||||
</Button>
|
||||
|
|
@ -213,7 +213,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.apiEndpoint || ""}
|
||||
onChange={(e) => updateConfig("apiEndpoint", e.target.value)}
|
||||
placeholder="/api/entities/user"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -232,7 +232,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.valueField || ""}
|
||||
onChange={(e) => updateConfig("valueField", e.target.value)}
|
||||
placeholder="id"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -245,7 +245,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.labelField || ""}
|
||||
onChange={(e) => updateConfig("labelField", e.target.value)}
|
||||
placeholder="name"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -263,13 +263,13 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={newFieldName}
|
||||
onChange={(e) => setNewFieldName(e.target.value)}
|
||||
placeholder="필드명"
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Input
|
||||
value={newFieldLabel}
|
||||
onChange={(e) => setNewFieldLabel(e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Select value={newFieldType} onValueChange={setNewFieldType}>
|
||||
<SelectTrigger className="w-24 text-xs">
|
||||
|
|
@ -287,7 +287,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
size="sm"
|
||||
onClick={addDisplayField}
|
||||
disabled={!newFieldName.trim() || !newFieldLabel.trim()}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -308,13 +308,13 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={field.name}
|
||||
onChange={(e) => updateDisplayField(index, "name", e.target.value)}
|
||||
placeholder="필드명"
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Input
|
||||
value={field.label}
|
||||
onChange={(e) => updateDisplayField(index, "label", e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Select value={field.type} onValueChange={(value) => updateDisplayField(index, "type", value)}>
|
||||
<SelectTrigger className="w-24 text-xs">
|
||||
|
|
@ -332,7 +332,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
size="sm"
|
||||
variant={localConfig.searchFields.includes(field.name) ? "default" : "outline"}
|
||||
onClick={() => toggleSearchField(field.name)}
|
||||
className="p-1 text-xs"
|
||||
className="p-1 text-xs" style={{ fontSize: "12px" }}
|
||||
title={localConfig.searchFields.includes(field.name) ? "검색 필드에서 제거" : "검색 필드로 추가"}
|
||||
>
|
||||
<Search className="h-3 w-3" />
|
||||
|
|
@ -341,7 +341,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => removeDisplayField(index)}
|
||||
className="p-1 text-xs"
|
||||
className="p-1 text-xs" style={{ fontSize: "12px" }}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -364,7 +364,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="엔티티를 선택하세요"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -377,7 +377,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.emptyMessage || ""}
|
||||
onChange={(e) => updateConfig("emptyMessage", e.target.value)}
|
||||
placeholder="검색 결과가 없습니다"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -393,7 +393,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
onChange={(e) => updateConfig("minSearchLength", parseInt(e.target.value))}
|
||||
min={0}
|
||||
max={10}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -408,7 +408,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
onChange={(e) => updateConfig("pageSize", parseInt(e.target.value))}
|
||||
min={5}
|
||||
max={100}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -462,7 +462,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
}
|
||||
}}
|
||||
placeholder='{"status": "active", "department": "IT"}'
|
||||
className="font-mono text-xs"
|
||||
className="font-mono text-xs" style={{ fontSize: "12px" }}
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">API 요청에 추가될 필터 조건을 JSON 형태로 입력하세요.</p>
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ export const FileConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<Upload className="h-4 w-4" />
|
||||
파일 업로드 설정
|
||||
</CardTitle>
|
||||
|
|
@ -133,7 +133,7 @@ export const FileConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.uploadText || ""}
|
||||
onChange={(e) => updateConfig("uploadText", e.target.value)}
|
||||
placeholder="파일을 선택하거나 여기에 드래그하세요"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -146,7 +146,7 @@ export const FileConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.browseText || ""}
|
||||
onChange={(e) => updateConfig("browseText", e.target.value)}
|
||||
placeholder="파일 선택"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -196,7 +196,7 @@ export const FileConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
min={0.1}
|
||||
max={1024}
|
||||
step={0.1}
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">MB</span>
|
||||
</div>
|
||||
|
|
@ -214,7 +214,7 @@ export const FileConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
onChange={(e) => updateConfig("maxFiles", parseInt(e.target.value))}
|
||||
min={1}
|
||||
max={100}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -257,7 +257,7 @@ export const FileConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={newFileType}
|
||||
onChange={(e) => setNewFileType(e.target.value)}
|
||||
placeholder=".pdf 또는 pdf"
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Button size="sm" onClick={addFileType} disabled={!newFileType.trim()} className="text-xs">
|
||||
추가
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
||||
import { Workflow, Info, CheckCircle, XCircle, Loader2, ArrowRight, ArrowDown } from "lucide-react";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { FlowVisibilityConfig } from "@/types/control-management";
|
||||
|
|
@ -172,6 +173,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 현재 버튼에 설정 적용 (그룹 설정은 ScreenDesigner에서 자동으로 일괄 적용됨)
|
||||
onUpdateProperty("webTypeConfig.flowVisibilityConfig", config);
|
||||
};
|
||||
|
||||
|
|
@ -234,11 +236,13 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h4 className="flex items-center gap-2 text-sm font-medium">
|
||||
<h4 className="flex items-center gap-2 text-xs font-medium" style={{ fontSize: "12px" }}>
|
||||
<Workflow className="h-4 w-4" />
|
||||
플로우 단계별 표시 설정
|
||||
</h4>
|
||||
<p className="text-muted-foreground text-xs">플로우의 특정 단계에서만 이 버튼을 표시하거나 숨길 수 있습니다</p>
|
||||
<p className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>
|
||||
플로우의 특정 단계에서만 이 버튼을 표시하거나 숨길 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
|
@ -252,7 +256,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="flow-control-enabled" className="text-sm font-medium">
|
||||
<Label htmlFor="flow-control-enabled" className="text-xs font-medium" style={{ fontSize: "12px" }}>
|
||||
플로우 단계에 따라 버튼 표시 제어
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -261,7 +265,9 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
<>
|
||||
{/* 대상 플로우 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">대상 플로우</Label>
|
||||
<Label className="text-xs font-medium" style={{ fontSize: "12px" }}>
|
||||
대상 플로우
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedFlowComponentId || ""}
|
||||
onValueChange={(value) => {
|
||||
|
|
@ -269,7 +275,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectTrigger className="h-6 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue placeholder="플로우 위젯 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -277,7 +283,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
const flowConfig = (fw as any).componentConfig || {};
|
||||
const flowName = flowConfig.flowName || `플로우 ${fw.id}`;
|
||||
return (
|
||||
<SelectItem key={fw.id} value={fw.id}>
|
||||
<SelectItem key={fw.id} value={fw.id} style={{ fontSize: "12px" }}>
|
||||
{flowName}
|
||||
</SelectItem>
|
||||
);
|
||||
|
|
@ -289,251 +295,106 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
{/* 플로우가 선택되면 스텝 목록 표시 */}
|
||||
{selectedFlowComponentId && flowSteps.length > 0 && (
|
||||
<>
|
||||
{/* 모드 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">표시 모드</Label>
|
||||
<RadioGroup
|
||||
value={mode}
|
||||
onValueChange={(value: any) => {
|
||||
setMode(value);
|
||||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="whitelist" id="mode-whitelist" />
|
||||
<Label htmlFor="mode-whitelist" className="text-sm font-normal">
|
||||
선택한 단계에서만 표시
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="all" id="mode-all" />
|
||||
<Label htmlFor="mode-all" className="text-sm font-normal">
|
||||
모든 단계에서 표시
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 단계 선택 (all 모드가 아닐 때만) */}
|
||||
{mode !== "all" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">표시할 단계</Label>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={selectAll} className="h-7 px-2 text-xs">
|
||||
모두 선택
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={selectNone} className="h-7 px-2 text-xs">
|
||||
모두 해제
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={invertSelection} className="h-7 px-2 text-xs">
|
||||
반전
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 스텝 체크박스 목록 */}
|
||||
<div className="bg-muted/30 space-y-2 rounded-lg border p-3">
|
||||
{flowSteps.map((step) => {
|
||||
const isChecked = visibleSteps.includes(step.id);
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`step-${step.id}`}
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleStep(step.id)}
|
||||
/>
|
||||
<Label htmlFor={`step-${step.id}`} className="flex flex-1 items-center gap-2 text-sm">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Step {step.stepOrder}
|
||||
</Badge>
|
||||
<span>{step.stepName}</span>
|
||||
{isChecked && <CheckCircle className="ml-auto h-4 w-4 text-green-500" />}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* 단계 선택 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium" style={{ fontSize: "12px" }}>
|
||||
표시할 단계
|
||||
</Label>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={selectAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
모두 선택
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={selectNone}
|
||||
className="h-7 px-2 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
모두 해제
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={invertSelection}
|
||||
className="h-7 px-2 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
반전
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 레이아웃 옵션 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">레이아웃 동작</Label>
|
||||
<RadioGroup
|
||||
value={layoutBehavior}
|
||||
onValueChange={(value: any) => {
|
||||
setLayoutBehavior(value);
|
||||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="preserve-position" id="layout-preserve" />
|
||||
<Label htmlFor="layout-preserve" className="text-sm font-normal">
|
||||
원래 위치 유지 (빈 공간 가능)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="auto-compact" id="layout-compact" />
|
||||
<Label htmlFor="layout-compact" className="text-sm font-normal">
|
||||
자동 정렬 (빈 공간 제거) ⭐ 권장
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
{/* 스텝 체크박스 목록 */}
|
||||
<div className="bg-muted/30 space-y-2 rounded-lg border p-3">
|
||||
{flowSteps.map((step) => {
|
||||
const isChecked = visibleSteps.includes(step.id);
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`step-${step.id}`}
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleStep(step.id)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`step-${step.id}`}
|
||||
className="flex flex-1 items-center gap-2 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<Badge variant="outline" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
Step {step.stepOrder}
|
||||
</Badge>
|
||||
<span>{step.stepName}</span>
|
||||
{isChecked && <CheckCircle className="ml-auto h-4 w-4 text-green-500" />}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🆕 그룹 설정 (auto-compact 모드일 때만 표시) */}
|
||||
{layoutBehavior === "auto-compact" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
그룹 설정
|
||||
</Badge>
|
||||
<p className="text-muted-foreground text-xs">같은 그룹 ID를 가진 버튼들이 자동으로 정렬됩니다</p>
|
||||
</div>
|
||||
|
||||
{/* 그룹 ID */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-id" className="text-sm font-medium">
|
||||
그룹 ID
|
||||
</Label>
|
||||
<Input
|
||||
id="group-id"
|
||||
value={groupId}
|
||||
onChange={(e) => setGroupId(e.target.value)}
|
||||
placeholder="group-1"
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
같은 그룹 ID를 가진 버튼들이 하나의 그룹으로 묶입니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 정렬 방향 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">정렬 방향</Label>
|
||||
<RadioGroup
|
||||
value={groupDirection}
|
||||
onValueChange={(value: any) => {
|
||||
setGroupDirection(value);
|
||||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="horizontal" id="direction-horizontal" />
|
||||
<Label htmlFor="direction-horizontal" className="flex items-center gap-2 text-sm font-normal">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
가로 정렬
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="vertical" id="direction-vertical" />
|
||||
<Label htmlFor="direction-vertical" className="flex items-center gap-2 text-sm font-normal">
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
세로 정렬
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 버튼 간격 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-gap" className="text-sm font-medium">
|
||||
버튼 간격 (px)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="group-gap"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={groupGap}
|
||||
onChange={(e) => {
|
||||
setGroupGap(Number(e.target.value));
|
||||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
/>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{groupGap}px
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정렬 방식 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-align" className="text-sm font-medium">
|
||||
정렬 방식
|
||||
</Label>
|
||||
<Select
|
||||
value={groupAlign}
|
||||
onValueChange={(value: any) => {
|
||||
setGroupAlign(value);
|
||||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="group-align" className="h-8 text-xs sm:h-9 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="start">시작점 정렬</SelectItem>
|
||||
<SelectItem value="center">중앙 정렬</SelectItem>
|
||||
<SelectItem value="end">끝점 정렬</SelectItem>
|
||||
<SelectItem value="space-between">양 끝 정렬</SelectItem>
|
||||
<SelectItem value="space-around">균등 배분</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 미리보기 */}
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
{mode === "whitelist" && visibleSteps.length > 0 && (
|
||||
<div>
|
||||
<p className="font-medium">표시 단계:</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{visibleSteps.map((stepId) => {
|
||||
const step = flowSteps.find((s) => s.id === stepId);
|
||||
return (
|
||||
<Badge key={stepId} variant="secondary" className="text-xs">
|
||||
{step?.stepName || `Step ${stepId}`}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{mode === "blacklist" && hiddenSteps.length > 0 && (
|
||||
<div>
|
||||
<p className="font-medium">숨김 단계:</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{hiddenSteps.map((stepId) => {
|
||||
const step = flowSteps.find((s) => s.id === stepId);
|
||||
return (
|
||||
<Badge key={stepId} variant="destructive" className="text-xs">
|
||||
{step?.stepName || `Step ${stepId}`}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{mode === "all" && <p>이 버튼은 모든 단계에서 표시됩니다.</p>}
|
||||
{mode === "whitelist" && visibleSteps.length === 0 && <p>표시할 단계를 선택해주세요.</p>}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* 🆕 자동 저장 안내 */}
|
||||
<Alert className="border-green-200 bg-green-50">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription className="text-xs text-green-800">
|
||||
설정이 자동으로 저장됩니다. 화면 저장 시 함께 적용됩니다.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{/* 정렬 방식 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-align" className="text-xs font-medium" style={{ fontSize: "12px" }}>
|
||||
정렬 방식
|
||||
</Label>
|
||||
<Select
|
||||
value={groupAlign}
|
||||
onValueChange={(value: any) => {
|
||||
setGroupAlign(value);
|
||||
onUpdateProperty("webTypeConfig.flowVisibilityConfig.groupAlign", value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="group-align" className="h-6 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="start" style={{ fontSize: "12px" }}>
|
||||
시작점 정렬
|
||||
</SelectItem>
|
||||
<SelectItem value="center" style={{ fontSize: "12px" }}>
|
||||
중앙 정렬
|
||||
</SelectItem>
|
||||
<SelectItem value="end" style={{ fontSize: "12px" }}>
|
||||
끝점 정렬
|
||||
</SelectItem>
|
||||
<SelectItem value="space-between" style={{ fontSize: "12px" }}>
|
||||
양 끝 정렬
|
||||
</SelectItem>
|
||||
<SelectItem value="space-around" style={{ fontSize: "12px" }}>
|
||||
균등 배분
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export function FlowWidgetConfigPanel({ config = {}, onChange }: FlowWidgetConfi
|
|||
{loading ? (
|
||||
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">로딩 중...</span>
|
||||
<span className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export const NumberConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">숫자 설정</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>숫자 설정</CardTitle>
|
||||
<CardDescription className="text-xs">숫자 입력 필드의 세부 설정을 관리합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -73,7 +73,7 @@ export const NumberConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="숫자를 입력하세요"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ export const NumberConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.min ?? ""}
|
||||
onChange={(e) => updateConfig("min", e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
placeholder="0"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -101,7 +101,7 @@ export const NumberConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.max ?? ""}
|
||||
onChange={(e) => updateConfig("max", e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
placeholder="100"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -118,7 +118,7 @@ export const NumberConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
placeholder="1"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">증가/감소 버튼 클릭 시 변경되는 값의 크기</p>
|
||||
</div>
|
||||
|
|
@ -158,7 +158,7 @@ export const NumberConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
placeholder="2"
|
||||
min="0"
|
||||
max="10"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -223,7 +223,7 @@ export const NumberConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
min={localConfig.min}
|
||||
max={localConfig.max}
|
||||
step={localConfig.step}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
{localConfig.format === "currency" && "통화 형식으로 표시됩니다."}
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<Radio className="h-4 w-4" />
|
||||
라디오버튼 설정
|
||||
</CardTitle>
|
||||
|
|
@ -188,7 +188,7 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.groupLabel || ""}
|
||||
onChange={(e) => updateConfig("groupLabel", e.target.value)}
|
||||
placeholder="라디오버튼 그룹 제목"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -201,7 +201,7 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.groupName || ""}
|
||||
onChange={(e) => updateConfig("groupName", e.target.value)}
|
||||
placeholder="자동 생성 (필드명 기반)"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">비워두면 필드명을 기반으로 자동 생성됩니다.</p>
|
||||
</div>
|
||||
|
|
@ -252,19 +252,19 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={newOptionLabel}
|
||||
onChange={(e) => setNewOptionLabel(e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Input
|
||||
value={newOptionValue}
|
||||
onChange={(e) => setNewOptionValue(e.target.value)}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={addOption}
|
||||
disabled={!newOptionLabel.trim() || !newOptionValue.trim()}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -278,7 +278,7 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={bulkOptions}
|
||||
onChange={(e) => setBulkOptions(e.target.value)}
|
||||
placeholder="한 줄당 하나씩 입력하세요. 라벨만 입력하면 값과 동일하게 설정됩니다. 라벨|값 형식으로 입력하면 별도 값을 설정할 수 있습니다. 예시: 서울 부산 대구시|daegu"
|
||||
className="h-20 text-xs"
|
||||
className="h-20 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Button size="sm" onClick={addBulkOptions} disabled={!bulkOptions.trim()} className="text-xs">
|
||||
옵션 추가
|
||||
|
|
@ -295,13 +295,13 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={option.label}
|
||||
onChange={(e) => updateOption(index, "label", e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Input
|
||||
value={option.value}
|
||||
onChange={(e) => updateOption(index, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Switch
|
||||
checked={!option.disabled}
|
||||
|
|
@ -328,7 +328,7 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
id="defaultValue"
|
||||
value={localConfig.defaultValue || ""}
|
||||
onChange={(e) => updateConfig("defaultValue", e.target.value)}
|
||||
className="w-full rounded-md border px-3 py-1 text-xs"
|
||||
className="w-full rounded-md border px-3 py-1 text-xs" style={{ fontSize: "12px" }}
|
||||
>
|
||||
<option value="">선택하지 않음</option>
|
||||
{localConfig.options.map((option, index) => (
|
||||
|
|
@ -390,7 +390,7 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
disabled={localConfig.readonly || option.disabled}
|
||||
required={localConfig.required && index === 0} // 첫 번째에만 required 표시
|
||||
defaultChecked={localConfig.defaultValue === option.value}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Label htmlFor={`preview-radio-${index}`} className="text-xs">
|
||||
{option.label}
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<List className="h-4 w-4" />
|
||||
선택박스 설정
|
||||
</CardTitle>
|
||||
|
|
@ -173,7 +173,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="선택하세요"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -186,7 +186,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.emptyMessage || ""}
|
||||
onChange={(e) => updateConfig("emptyMessage", e.target.value)}
|
||||
placeholder="선택 가능한 옵션이 없습니다"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -247,19 +247,19 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={newOptionLabel}
|
||||
onChange={(e) => setNewOptionLabel(e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Input
|
||||
value={newOptionValue}
|
||||
onChange={(e) => setNewOptionValue(e.target.value)}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={addOption}
|
||||
disabled={!newOptionLabel.trim() || !newOptionValue.trim()}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -273,7 +273,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={bulkOptions}
|
||||
onChange={(e) => setBulkOptions(e.target.value)}
|
||||
placeholder="한 줄당 하나씩 입력하세요. 라벨만 입력하면 값과 동일하게 설정됩니다. 라벨|값 형식으로 입력하면 별도 값을 설정할 수 있습니다. 예시: 서울 부산 대구시|daegu"
|
||||
className="h-20 text-xs"
|
||||
className="h-20 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Button size="sm" onClick={addBulkOptions} disabled={!bulkOptions.trim()} className="text-xs">
|
||||
옵션 추가
|
||||
|
|
@ -290,13 +290,13 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={option.label}
|
||||
onChange={(e) => updateOption(index, "label", e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Input
|
||||
value={option.value}
|
||||
onChange={(e) => updateOption(index, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Switch
|
||||
checked={!option.disabled}
|
||||
|
|
@ -323,7 +323,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
id="defaultValue"
|
||||
value={localConfig.defaultValue || ""}
|
||||
onChange={(e) => updateConfig("defaultValue", e.target.value)}
|
||||
className="w-full rounded-md border px-3 py-1 text-xs"
|
||||
className="w-full rounded-md border px-3 py-1 text-xs" style={{ fontSize: "12px" }}
|
||||
>
|
||||
<option value="">선택하지 않음</option>
|
||||
{localConfig.options.map((option, index) => (
|
||||
|
|
@ -376,7 +376,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
disabled={localConfig.readonly}
|
||||
required={localConfig.required}
|
||||
multiple={localConfig.multiple}
|
||||
className="w-full rounded-md border px-3 py-1 text-xs"
|
||||
className="w-full rounded-md border px-3 py-1 text-xs" style={{ fontSize: "12px" }}
|
||||
defaultValue={localConfig.defaultValue}
|
||||
>
|
||||
<option value="" disabled>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export const TextConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">텍스트 설정</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>텍스트 설정</CardTitle>
|
||||
<CardDescription className="text-xs">텍스트 입력 필드의 세부 설정을 관리합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -72,7 +72,7 @@ export const TextConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="입력 안내 텍스트"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ export const TextConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
onChange={(e) => updateConfig("minLength", e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -102,7 +102,7 @@ export const TextConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
onChange={(e) => updateConfig("maxLength", e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
placeholder="100"
|
||||
min="1"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -141,7 +141,7 @@ export const TextConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.pattern || ""}
|
||||
onChange={(e) => updateConfig("pattern", e.target.value)}
|
||||
placeholder="예: [A-Za-z0-9]+"
|
||||
className="font-mono text-xs"
|
||||
className="font-mono text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">JavaScript 정규식 패턴을 입력하세요.</p>
|
||||
</div>
|
||||
|
|
@ -219,7 +219,7 @@ export const TextConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
minLength={localConfig.minLength}
|
||||
pattern={localConfig.pattern}
|
||||
autoComplete={localConfig.autoComplete}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export const TextareaConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<AlignLeft className="h-4 w-4" />
|
||||
텍스트영역 설정
|
||||
</CardTitle>
|
||||
|
|
@ -88,7 +88,7 @@ export const TextareaConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="내용을 입력하세요"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -101,7 +101,7 @@ export const TextareaConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.defaultValue || ""}
|
||||
onChange={(e) => updateConfig("defaultValue", e.target.value)}
|
||||
placeholder="기본 텍스트 내용"
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
rows={3}
|
||||
/>
|
||||
{localConfig.showCharCount && (
|
||||
|
|
@ -151,7 +151,7 @@ export const TextareaConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
placeholder="자동 (CSS로 제어)"
|
||||
min={10}
|
||||
max={200}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">비워두면 CSS width로 제어됩니다.</p>
|
||||
</div>
|
||||
|
|
@ -203,7 +203,7 @@ export const TextareaConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
}}
|
||||
placeholder="제한 없음"
|
||||
min={0}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -221,7 +221,7 @@ export const TextareaConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
}}
|
||||
placeholder="제한 없음"
|
||||
min={1}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -333,7 +333,7 @@ export const TextareaConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
resize: localConfig.resizable ? "both" : "none",
|
||||
minHeight: localConfig.autoHeight ? "auto" : undefined,
|
||||
}}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
wrap={localConfig.wrap}
|
||||
/>
|
||||
{localConfig.showCharCount && (
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export const FlowButtonGroupDialog: React.FC<FlowButtonGroupDialogProps> = ({
|
|||
max={100}
|
||||
value={gap}
|
||||
onChange={(e) => setGap(Number(e.target.value))}
|
||||
className="h-9 text-sm sm:h-10"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{gap}px
|
||||
|
|
@ -109,7 +109,7 @@ export const FlowButtonGroupDialog: React.FC<FlowButtonGroupDialogProps> = ({
|
|||
정렬 방식
|
||||
</Label>
|
||||
<Select value={align} onValueChange={(value: any) => setAlign(value)}>
|
||||
<SelectTrigger id="align" className="h-9 text-sm sm:h-10">
|
||||
<SelectTrigger id="align" className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -21,14 +21,14 @@ interface ComponentsPanelProps {
|
|||
placedColumns?: Set<string>; // 이미 배치된 컬럼명 집합
|
||||
}
|
||||
|
||||
export function ComponentsPanel({
|
||||
className,
|
||||
tables = [],
|
||||
searchTerm = "",
|
||||
onSearchChange,
|
||||
export function ComponentsPanel({
|
||||
className,
|
||||
tables = [],
|
||||
searchTerm = "",
|
||||
onSearchChange,
|
||||
onTableDragStart,
|
||||
selectedTableName,
|
||||
placedColumns
|
||||
placedColumns,
|
||||
}: ComponentsPanelProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
|
|
@ -162,41 +162,64 @@ export function ComponentsPanel({
|
|||
<p className="text-muted-foreground text-xs">{allComponents.length}개 사용 가능</p>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
{/* 통합 검색 */}
|
||||
<div className="mb-3">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="컴포넌트 검색..."
|
||||
placeholder="컴포넌트, 테이블, 컬럼 검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSearchQuery(value);
|
||||
// 테이블 검색도 함께 업데이트
|
||||
if (onSearchChange) {
|
||||
onSearchChange(value);
|
||||
}
|
||||
}}
|
||||
className="h-8 pl-8 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 탭 */}
|
||||
<Tabs defaultValue="input" className="flex flex-1 flex-col">
|
||||
<TabsList className="mb-3 grid h-8 w-full grid-cols-5">
|
||||
<TabsTrigger value="tables" className="flex items-center gap-1 px-1 text-xs">
|
||||
<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-5 gap-1 p-1">
|
||||
<TabsTrigger
|
||||
value="tables"
|
||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||
title="테이블"
|
||||
>
|
||||
<Database className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">테이블</span>
|
||||
<span className="hidden">테이블</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="input" className="flex items-center gap-1 px-1 text-xs">
|
||||
<TabsTrigger value="input" className="flex items-center justify-center gap-0.5 px-0 text-[10px]" title="입력">
|
||||
<Edit3 className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">입력</span>
|
||||
<span className="hidden">입력</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="action" className="flex items-center gap-1 px-1 text-xs">
|
||||
<TabsTrigger
|
||||
value="action"
|
||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||
title="액션"
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">액션</span>
|
||||
<span className="hidden">액션</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="display" className="flex items-center gap-1 px-1 text-xs">
|
||||
<TabsTrigger
|
||||
value="display"
|
||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||
title="표시"
|
||||
>
|
||||
<BarChart3 className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">표시</span>
|
||||
<span className="hidden">표시</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="layout" className="flex items-center gap-1 px-1 text-xs">
|
||||
<TabsTrigger
|
||||
value="layout"
|
||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
||||
title="레이아웃"
|
||||
>
|
||||
<Layers className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">레이아웃</span>
|
||||
<span className="hidden">레이아웃</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
|
|||
|
|
@ -458,7 +458,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
updateSettings({ options: newOptions });
|
||||
}}
|
||||
placeholder="옵션명"
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -483,7 +483,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
const newOption = { label: "", value: "" };
|
||||
updateSettings({ options: [...(localSettings.options || []), newOption] });
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
옵션 추가
|
||||
|
|
@ -548,7 +548,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={localSettings.min || ""}
|
||||
onChange={(e) => updateSettings({ min: e.target.value ? Number(e.target.value) : undefined })}
|
||||
placeholder="최소값"
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
|
@ -558,7 +558,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={localSettings.max || ""}
|
||||
onChange={(e) => updateSettings({ max: e.target.value ? Number(e.target.value) : undefined })}
|
||||
placeholder="최대값"
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -571,7 +571,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={localSettings.step || "0.01"}
|
||||
onChange={(e) => updateSettings({ step: e.target.value })}
|
||||
placeholder="0.01"
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -589,7 +589,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
type="date"
|
||||
value={localSettings.minDate || ""}
|
||||
onChange={(e) => updateSettings({ minDate: e.target.value })}
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
|
@ -598,7 +598,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
type="date"
|
||||
value={localSettings.maxDate || ""}
|
||||
onChange={(e) => updateSettings({ maxDate: e.target.value })}
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -626,7 +626,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={localSettings.maxLength || ""}
|
||||
onChange={(e) => updateSettings({ maxLength: e.target.value ? Number(e.target.value) : undefined })}
|
||||
placeholder="최대 문자 수"
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
|
@ -635,7 +635,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={localSettings.placeholder || ""}
|
||||
onChange={(e) => updateSettings({ placeholder: e.target.value })}
|
||||
placeholder="입력 안내 텍스트"
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -652,7 +652,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={localSettings.rows || "3"}
|
||||
onChange={(e) => updateSettings({ rows: Number(e.target.value) })}
|
||||
placeholder="3"
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
|
@ -662,7 +662,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={localSettings.maxLength || ""}
|
||||
onChange={(e) => updateSettings({ maxLength: e.target.value ? Number(e.target.value) : undefined })}
|
||||
placeholder="최대 문자 수"
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -678,7 +678,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={localSettings.accept || ""}
|
||||
onChange={(e) => updateSettings({ accept: e.target.value })}
|
||||
placeholder=".jpg,.png,.pdf"
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
|
@ -688,7 +688,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={localSettings.maxSize ? localSettings.maxSize / 1024 / 1024 : "10"}
|
||||
onChange={(e) => updateSettings({ maxSize: Number(e.target.value) * 1024 * 1024 })}
|
||||
placeholder="10"
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
|
|
@ -1132,7 +1132,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
{/* 기본 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-sm">
|
||||
<CardTitle className="flex items-center space-x-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>기본 설정</span>
|
||||
</CardTitle>
|
||||
|
|
@ -1184,7 +1184,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
onUpdateComponent({ enableAdd: checked as boolean });
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="enable-add" className="text-sm">
|
||||
<Label htmlFor="enable-add" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
데이터 추가 기능
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -1198,7 +1198,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
onUpdateComponent({ enableEdit: checked as boolean });
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="enable-edit" className="text-sm">
|
||||
<Label htmlFor="enable-edit" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
데이터 수정 기능
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -1212,7 +1212,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
onUpdateComponent({ enableDelete: checked as boolean });
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="enable-delete" className="text-sm">
|
||||
<Label htmlFor="enable-delete" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
데이터 삭제 기능
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -1220,7 +1220,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="add-button-text" className="text-sm">
|
||||
<Label htmlFor="add-button-text" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
추가 버튼 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -1233,12 +1233,12 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
}}
|
||||
placeholder="추가"
|
||||
disabled={!localValues.enableAdd}
|
||||
className="h-8 text-sm"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-button-text" className="text-sm">
|
||||
<Label htmlFor="edit-button-text" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
수정 버튼 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -1251,12 +1251,12 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
}}
|
||||
placeholder="수정"
|
||||
disabled={!localValues.enableEdit}
|
||||
className="h-8 text-sm"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delete-button-text" className="text-sm">
|
||||
<Label htmlFor="delete-button-text" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
삭제 버튼 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -1269,7 +1269,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
}}
|
||||
placeholder="삭제"
|
||||
disabled={!localValues.enableDelete}
|
||||
className="h-8 text-sm"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1284,7 +1284,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-title" className="text-sm">
|
||||
<Label htmlFor="modal-title" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
모달 제목
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -1298,12 +1298,12 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
placeholder="새 데이터 추가"
|
||||
className="h-8 text-sm"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-width" className="text-sm">
|
||||
<Label htmlFor="modal-width" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
모달 크기
|
||||
</Label>
|
||||
<select
|
||||
|
|
@ -1328,7 +1328,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-description" className="text-sm">
|
||||
<Label htmlFor="modal-description" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
모달 설명
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -1342,13 +1342,13 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
placeholder="모달에 표시될 설명을 입력하세요"
|
||||
className="h-8 text-sm"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-layout" className="text-sm">
|
||||
<Label htmlFor="modal-layout" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
레이아웃
|
||||
</Label>
|
||||
<select
|
||||
|
|
@ -1370,7 +1370,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
|
||||
{localValues.modalLayout === "grid" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-grid-columns" className="text-sm">
|
||||
<Label htmlFor="modal-grid-columns" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
그리드 컬럼 수
|
||||
</Label>
|
||||
<select
|
||||
|
|
@ -1394,7 +1394,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-submit-text" className="text-sm">
|
||||
<Label htmlFor="modal-submit-text" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
제출 버튼 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -1408,12 +1408,12 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
placeholder="추가"
|
||||
className="h-8 text-sm"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-cancel-text" className="text-sm">
|
||||
<Label htmlFor="modal-cancel-text" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
취소 버튼 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -1427,7 +1427,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
placeholder="취소"
|
||||
className="h-8 text-sm"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1441,7 +1441,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-modal-title" className="text-sm">
|
||||
<Label htmlFor="edit-modal-title" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
모달 제목
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -1455,13 +1455,13 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
placeholder="데이터 수정"
|
||||
className="h-8 text-sm"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">비워두면 기본 제목이 표시됩니다</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-modal-description" className="text-sm">
|
||||
<Label htmlFor="edit-modal-description" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
모달 설명
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -1475,7 +1475,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
placeholder="선택한 데이터를 수정합니다"
|
||||
className="h-8 text-sm"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">비워두면 설명이 표시되지 않습니다</p>
|
||||
</div>
|
||||
|
|
@ -1494,7 +1494,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
onUpdateComponent({ showSearchButton: checked as boolean });
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="show-search-button" className="text-sm">
|
||||
<Label htmlFor="show-search-button" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
검색 버튼 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -1509,7 +1509,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
onUpdateComponent({ enableExport: checked as boolean });
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="enable-export" className="text-sm">
|
||||
<Label htmlFor="enable-export" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
내보내기 기능
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -1521,7 +1521,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
<TabsContent value="columns" className="mt-4 max-h-[70vh] overflow-y-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-sm">
|
||||
<CardTitle className="flex items-center space-x-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<Columns className="h-4 w-4" />
|
||||
<span>컬럼 설정</span>
|
||||
</CardTitle>
|
||||
|
|
@ -1535,7 +1535,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* 파일 컬럼 추가 버튼 */}
|
||||
<Button size="sm" variant="outline" onClick={addVirtualFileColumn} className="h-8 text-xs">
|
||||
<Button size="sm" variant="outline" onClick={addVirtualFileColumn} className="h-6 w-full px-2 py-0 text-xs">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="ml-1">파일 컬럼</span>
|
||||
</Button>
|
||||
|
|
@ -1654,7 +1654,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
}
|
||||
}}
|
||||
placeholder="표시명을 입력하세요"
|
||||
className="h-8 text-xs"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -1673,7 +1673,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
updateColumn(column.id, { gridColumns: newGridColumns });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -1861,7 +1861,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -1902,7 +1902,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -1947,7 +1947,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
placeholder="고정값 입력..."
|
||||
className="h-8 text-xs"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1967,7 +1967,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
<TabsContent value="filters" className="mt-4 max-h-[70vh] overflow-y-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-sm">
|
||||
<CardTitle className="flex items-center space-x-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>필터 설정</span>
|
||||
</CardTitle>
|
||||
|
|
@ -1995,7 +1995,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
{component.filters.length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
<Filter className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p className="text-sm">필터가 없습니다</p>
|
||||
<p className="text-xs" style={{ fontSize: "12px" }}>필터가 없습니다</p>
|
||||
<p className="text-xs">컬럼을 추가하면 자동으로 필터가 생성됩니다</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -2073,7 +2073,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
updateFilter(index, { label: newValue });
|
||||
}}
|
||||
placeholder="필터 이름 입력..."
|
||||
className="h-8 text-xs"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
|
|
@ -2112,7 +2112,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -2144,7 +2144,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={filter.gridColumns.toString()}
|
||||
onValueChange={(value) => updateFilter(index, { gridColumns: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -2192,7 +2192,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
<TabsContent value="modal" className="mt-4 max-h-[70vh] overflow-y-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-sm">
|
||||
<CardTitle className="flex items-center space-x-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>모달 및 페이징 설정</span>
|
||||
</CardTitle>
|
||||
|
|
@ -2258,7 +2258,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="show-page-size-selector" className="text-sm">
|
||||
<Label htmlFor="show-page-size-selector" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
페이지 크기 선택기 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -2278,7 +2278,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="show-page-info" className="text-sm">
|
||||
<Label htmlFor="show-page-info" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
페이지 정보 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -2298,7 +2298,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="show-first-last" className="text-sm">
|
||||
<Label htmlFor="show-first-last" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
처음/마지막 버튼 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -148,7 +148,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onUpdateProperty(layoutComponent.id, "zones", newZones);
|
||||
}
|
||||
}}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -185,7 +186,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onUpdateProperty(layoutComponent.id, "zones", newZones);
|
||||
}
|
||||
}}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -199,7 +201,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onChange={(e) =>
|
||||
onUpdateProperty(layoutComponent.id, "layoutConfig.grid.gap", parseInt(e.target.value))
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -243,7 +246,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onUpdateProperty(layoutComponent.id, "zones", updatedZones);
|
||||
}
|
||||
}}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<option value="row">가로 (row)</option>
|
||||
<option value="column">세로 (column)</option>
|
||||
|
|
@ -302,7 +306,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onUpdateProperty(layoutComponent.id, "zones", newZones);
|
||||
}
|
||||
}}
|
||||
className="w-20 rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
className="w-20 rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<span className="text-xs text-gray-500">개</span>
|
||||
</div>
|
||||
|
|
@ -317,7 +322,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onChange={(e) =>
|
||||
onUpdateProperty(layoutComponent.id, "layoutConfig.flexbox.gap", parseInt(e.target.value))
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -332,7 +338,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
<select
|
||||
value={layoutComponent.layoutConfig?.split?.direction || "horizontal"}
|
||||
onChange={(e) => onUpdateProperty(layoutComponent.id, "layoutConfig.split.direction", e.target.value)}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<option value="horizontal">가로 분할</option>
|
||||
<option value="vertical">세로 분할</option>
|
||||
|
|
@ -381,7 +388,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<option value="">컬럼을 선택하세요</option>
|
||||
{currentTable.columns?.map((column) => (
|
||||
|
|
@ -403,7 +411,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<option value="">컬럼을 선택하세요</option>
|
||||
{currentTable.columns?.map((column) => (
|
||||
|
|
@ -425,7 +434,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<option value="">컬럼을 선택하세요</option>
|
||||
{currentTable.columns?.map((column) => (
|
||||
|
|
@ -447,7 +457,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<option value="">컬럼을 선택하세요</option>
|
||||
{currentTable.columns?.map((column) => (
|
||||
|
|
@ -475,6 +486,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
);
|
||||
}}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
+ 컬럼 추가
|
||||
</button>
|
||||
|
|
@ -497,7 +509,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
currentColumns,
|
||||
);
|
||||
}}
|
||||
className="flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
className="flex-1 rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<option value="">컬럼을 선택하세요</option>
|
||||
{currentTable.columns?.map((col) => (
|
||||
|
|
@ -520,6 +533,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
);
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
|
|
@ -554,7 +568,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onChange={(e) =>
|
||||
onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardsPerRow", parseInt(e.target.value))
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -568,7 +583,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onChange={(e) =>
|
||||
onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardSpacing", parseInt(e.target.value))
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -657,7 +673,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
parseInt(e.target.value),
|
||||
)
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -685,6 +702,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onUpdateProperty(layoutComponent.id, `zones.${index}.size.width`, e.target.value)
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
placeholder="100%"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -697,6 +715,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onUpdateProperty(layoutComponent.id, `zones.${index}.size.height`, e.target.value)
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
placeholder="auto"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -909,7 +928,9 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
<h3 className="font-medium text-gray-900">컴포넌트 설정</h3>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<span className="text-muted-foreground text-sm">타입:</span>
|
||||
<span className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>
|
||||
타입:
|
||||
</span>
|
||||
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-800">{componentType}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -957,7 +978,9 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
<h3 className="font-medium text-gray-900">파일 컴포넌트 설정</h3>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<span className="text-muted-foreground text-sm">타입:</span>
|
||||
<span className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>
|
||||
타입:
|
||||
</span>
|
||||
<span className="rounded bg-purple-100 px-2 py-1 text-xs font-medium text-purple-800">파일 업로드</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
|
|
@ -1044,12 +1067,16 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
{/* 컴포넌트 정보 */}
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-muted-foreground text-sm">컴포넌트:</span>
|
||||
<span className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>
|
||||
컴포넌트:
|
||||
</span>
|
||||
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-800">{componentId}</span>
|
||||
</div>
|
||||
{webType && currentBaseInputType && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-muted-foreground text-sm">입력 타입:</span>
|
||||
<span className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>
|
||||
입력 타입:
|
||||
</span>
|
||||
<span className="rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">
|
||||
{currentBaseInputType}
|
||||
</span>
|
||||
|
|
@ -1057,7 +1084,9 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
)}
|
||||
{selectedComponent.columnName && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-muted-foreground text-sm">컬럼:</span>
|
||||
<span className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>
|
||||
컬럼:
|
||||
</span>
|
||||
<span className="text-xs text-gray-700">{selectedComponent.columnName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1137,7 +1166,9 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
<h3 className="font-medium text-gray-900">상세 설정</h3>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<span className="text-muted-foreground text-sm">입력 타입:</span>
|
||||
<span className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>
|
||||
입력 타입:
|
||||
</span>
|
||||
<span className="rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">
|
||||
{currentBaseInputType}
|
||||
</span>
|
||||
|
|
@ -1150,7 +1181,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">세부 타입 선택</label>
|
||||
<Select value={localDetailType} onValueChange={handleDetailTypeChange}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 bg-white text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue placeholder="세부 타입을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export const FlowButtonGroupPanel: React.FC<FlowButtonGroupPanelProps> = ({
|
|||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onSelectGroup(groupInfo.buttons.map((b) => b.id))}
|
||||
className="h-7 px-2 text-xs"
|
||||
className="h-7 px-2 text-xs" style={{ fontSize: "12px" }}
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
|
|
@ -152,7 +152,7 @@ export const FlowButtonGroupPanel: React.FC<FlowButtonGroupPanelProps> = ({
|
|||
{groupInfo.buttons.map((button) => (
|
||||
<div
|
||||
key={button.id}
|
||||
className="flex items-center gap-2 rounded bg-white px-2 py-1.5 text-xs"
|
||||
className="flex items-center gap-2 rounded bg-white px-2 py-1.5 text-xs" style={{ fontSize: "12px" }}
|
||||
>
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<span className="flex-1 truncate font-medium">
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onForceGridUpdate}
|
||||
className="h-7 px-2 text-xs"
|
||||
className="h-7 px-2 text-xs" style={{ fontSize: "12px" }}
|
||||
title="현재 해상도에 맞게 모든 컴포넌트를 격자에 재정렬합니다"
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
|
|
@ -266,7 +266,7 @@ export const GridPanel: React.FC<GridPanelProps> = ({
|
|||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-gray-900">격자 정보</h4>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="space-y-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">해상도:</span>
|
||||
<span className="font-mono">
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ export default function LayoutsPanel({
|
|||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-sm">{layout.name}</CardTitle>
|
||||
<CardTitle className="text-xs" style={{ fontSize: "12px" }}>{layout.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{layout.description && (
|
||||
|
|
|
|||
|
|
@ -551,11 +551,6 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
|
||||
{/* 액션 버튼들 */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button size="sm" variant="outline" onClick={onCopyComponent} className="h-8 px-2.5 text-xs">
|
||||
<Copy className="mr-1 h-3 w-3" />
|
||||
복사
|
||||
</Button>
|
||||
|
||||
{canGroup && (
|
||||
<Button size="sm" variant="outline" onClick={onGroupComponents} className="h-8 px-2.5 text-xs">
|
||||
<Group className="mr-1 h-3 w-3" />
|
||||
|
|
@ -569,11 +564,6 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
해제
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button size="sm" variant="destructive" onClick={onDeleteComponent} className="h-8 px-2.5 text-xs">
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -655,7 +645,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
}}
|
||||
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
|
||||
/>
|
||||
<Label htmlFor="required" className="text-sm">
|
||||
<Label htmlFor="required" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
필수 입력
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -671,7 +661,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
}}
|
||||
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
|
||||
/>
|
||||
<Label htmlFor="readonly" className="text-sm">
|
||||
<Label htmlFor="readonly" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
읽기 전용
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -952,7 +942,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
</>
|
||||
) : (
|
||||
<div className="bg-accent col-span-2 rounded-lg p-3 text-center">
|
||||
<p className="text-primary text-sm">카드 레이아웃은 자동으로 크기가 계산됩니다</p>
|
||||
<p className="text-primary text-xs" style={{ fontSize: "12px" }}>카드 레이아웃은 자동으로 크기가 계산됩니다</p>
|
||||
<p className="mt-1 text-xs text-blue-500">카드 개수와 간격 설정은 상세설정에서 조정하세요</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -82,9 +82,9 @@ const ResolutionPanel: React.FC<ResolutionPanelProps> = ({ currentResolution, on
|
|||
<div className="space-y-4">
|
||||
{/* 프리셋 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">해상도 프리셋</Label>
|
||||
<Label className="text-xs font-medium">해상도 프리셋</Label>
|
||||
<Select value={selectedPreset} onValueChange={handlePresetChange}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0" style={{ fontSize: "12px" }}>
|
||||
<SelectValue placeholder="해상도를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -93,7 +93,7 @@ const ResolutionPanel: React.FC<ResolutionPanelProps> = ({ currentResolution, on
|
|||
{SCREEN_RESOLUTIONS.filter((r) => r.category === "desktop").map((resolution) => (
|
||||
<SelectItem key={resolution.name} value={resolution.name}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Monitor className="h-4 w-4 text-primary" />
|
||||
<Monitor className="text-primary h-4 w-4" />
|
||||
<span>{resolution.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
@ -125,7 +125,7 @@ const ResolutionPanel: React.FC<ResolutionPanelProps> = ({ currentResolution, on
|
|||
<div className="px-2 py-1 text-xs font-medium text-gray-500">사용자 정의</div>
|
||||
<SelectItem value="custom">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
<Settings className="text-muted-foreground h-4 w-4" />
|
||||
<span>사용자 정의</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
@ -139,43 +139,40 @@ const ResolutionPanel: React.FC<ResolutionPanelProps> = ({ currentResolution, on
|
|||
<Label className="text-sm font-medium">사용자 정의 해상도</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">너비 (px)</Label>
|
||||
<Label className="text-muted-foreground text-xs">너비 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={customWidth}
|
||||
onChange={(e) => setCustomWidth(e.target.value)}
|
||||
placeholder="1920"
|
||||
min="1"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">높이 (px)</Label>
|
||||
<Label className="text-muted-foreground text-xs">높이 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={customHeight}
|
||||
onChange={(e) => setCustomHeight(e.target.value)}
|
||||
placeholder="1080"
|
||||
min="1"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleCustomResolution} size="sm" className="w-full">
|
||||
<Button
|
||||
onClick={handleCustomResolution}
|
||||
size="sm"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 해상도 정보 */}
|
||||
<div className="space-y-2 text-xs text-gray-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>화면 비율:</span>
|
||||
<span>{(currentResolution.width / currentResolution.height).toFixed(2)}:1</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>총 픽셀:</span>
|
||||
<span>{(currentResolution.width * currentResolution.height).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export const RowSettingsPanel: React.FC<RowSettingsPanelProps> = ({ row, onUpdat
|
|||
variant={row.gap === preset ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onUpdateRow({ gap: preset })}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
>
|
||||
{GAP_PRESETS[preset].label}
|
||||
</Button>
|
||||
|
|
@ -127,7 +127,7 @@ export const RowSettingsPanel: React.FC<RowSettingsPanelProps> = ({ row, onUpdat
|
|||
variant={row.padding === preset ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onUpdateRow({ padding: preset })}
|
||||
className="text-xs"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
>
|
||||
{GAP_PRESETS[preset].label}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Database,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Type,
|
||||
Hash,
|
||||
Calendar,
|
||||
CheckSquare,
|
||||
List,
|
||||
AlignLeft,
|
||||
Code,
|
||||
Building,
|
||||
File,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { Database, Type, Hash, Calendar, CheckSquare, List, AlignLeft, Code, Building, File } from "lucide-react";
|
||||
import { TableInfo, WebType } from "@/types/screen";
|
||||
|
||||
interface TablesPanelProps {
|
||||
|
|
@ -65,23 +50,9 @@ const getWidgetIcon = (widgetType: WebType) => {
|
|||
export const TablesPanel: React.FC<TablesPanelProps> = ({
|
||||
tables,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
onDragStart,
|
||||
selectedTableName,
|
||||
placedColumns = new Set(),
|
||||
}) => {
|
||||
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleTable = (tableName: string) => {
|
||||
const newExpanded = new Set(expandedTables);
|
||||
if (newExpanded.has(tableName)) {
|
||||
newExpanded.delete(tableName);
|
||||
} else {
|
||||
newExpanded.add(tableName);
|
||||
}
|
||||
setExpandedTables(newExpanded);
|
||||
};
|
||||
|
||||
// 이미 배치된 컬럼을 제외한 테이블 정보 생성
|
||||
const tablesWithAvailableColumns = tables.map((table) => ({
|
||||
...table,
|
||||
|
|
@ -91,137 +62,89 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
|||
}),
|
||||
}));
|
||||
|
||||
// 검색어가 있으면 컬럼 필터링
|
||||
const filteredTables = tablesWithAvailableColumns
|
||||
.filter((table) => table.columns.length > 0) // 사용 가능한 컬럼이 있는 테이블만 표시
|
||||
.filter(
|
||||
(table) =>
|
||||
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
);
|
||||
.map((table) => {
|
||||
if (!searchTerm) {
|
||||
return table;
|
||||
}
|
||||
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
|
||||
// 테이블명이 검색어와 일치하면 모든 컬럼 표시
|
||||
if (
|
||||
table.tableName.toLowerCase().includes(searchLower) ||
|
||||
(table.tableLabel && table.tableLabel.toLowerCase().includes(searchLower))
|
||||
) {
|
||||
return table;
|
||||
}
|
||||
|
||||
// 그렇지 않으면 컬럼명/라벨이 검색어와 일치하는 컬럼만 필터링
|
||||
const filteredColumns = table.columns.filter(
|
||||
(col) =>
|
||||
col.columnName.toLowerCase().includes(searchLower) ||
|
||||
(col.columnLabel && col.columnLabel.toLowerCase().includes(searchLower)),
|
||||
);
|
||||
|
||||
return {
|
||||
...table,
|
||||
columns: filteredColumns,
|
||||
};
|
||||
})
|
||||
.filter((table) => table.columns.length > 0); // 컬럼이 있는 테이블만 표시
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b p-4">
|
||||
{selectedTableName && (
|
||||
<div className="border-primary/20 bg-primary/5 mb-3 rounded-lg border p-3">
|
||||
<div className="text-xs font-semibold">선택된 테이블</div>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<Database className="text-primary h-3 w-3" />
|
||||
<span className="font-mono text-xs font-medium">{selectedTableName}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="테이블명, 컬럼명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="border-input bg-background focus-visible:ring-ring h-8 w-full rounded-md border px-3 pl-8 text-xs focus-visible:ring-1 focus-visible:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-2 text-xs">총 {filteredTables.length}개</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 목록 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-1.5 p-3">
|
||||
{filteredTables.map((table) => {
|
||||
const isExpanded = expandedTables.has(table.tableName);
|
||||
|
||||
return (
|
||||
<div key={table.tableName} className="bg-card rounded-lg border">
|
||||
{/* 테이블 헤더 */}
|
||||
<div
|
||||
className="hover:bg-accent/50 flex cursor-pointer items-center justify-between p-2.5 transition-colors"
|
||||
onClick={() => toggleTable(table.tableName)}
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="text-muted-foreground h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="text-muted-foreground h-3.5 w-3.5" />
|
||||
)}
|
||||
<Database className="text-primary h-3.5 w-3.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-semibold">{table.tableLabel || table.tableName}</div>
|
||||
<div className="text-muted-foreground text-xs">{table.columns.length}개</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, table)}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
드래그
|
||||
</Button>
|
||||
{/* 테이블과 컬럼 평면 목록 */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
<div className="space-y-2">
|
||||
{filteredTables.map((table) => (
|
||||
<div key={table.tableName} className="space-y-1">
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="bg-muted/50 flex items-center justify-between rounded-md p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="text-primary h-3.5 w-3.5" />
|
||||
<span className="text-xs font-semibold">{table.tableLabel || table.tableName}</span>
|
||||
<Badge variant="secondary" className="h-4 px-1.5 text-[10px]">
|
||||
{table.columns.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 목록 */}
|
||||
{isExpanded && (
|
||||
<div className="bg-muted/30 border-t">
|
||||
<div className={`${table.columns.length > 8 ? "max-h-64 overflow-y-auto" : ""}`}>
|
||||
{table.columns.map((column, index) => (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className={`hover:bg-accent/50 flex cursor-grab items-center justify-between p-2 transition-colors ${
|
||||
index < table.columns.length - 1 ? "border-border/50 border-b" : ""
|
||||
}`}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, table, column)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{getWidgetIcon(column.widgetType)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-semibold">
|
||||
{column.columnLabel || column.columnName}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-xs">{column.dataType}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 컬럼 목록 (항상 표시) */}
|
||||
<div className="space-y-1 pl-2">
|
||||
{table.columns.map((column) => (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className="hover:bg-accent/50 flex cursor-grab items-center justify-between rounded-md p-2 transition-colors"
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, table, column)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{getWidgetIcon(column.widgetType)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium">{column.columnLabel || column.columnName}</div>
|
||||
<div className="text-muted-foreground truncate text-[10px]">{column.dataType}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
<Badge variant="secondary" className="h-4 px-1.5 text-xs">
|
||||
{column.widgetType}
|
||||
</Badge>
|
||||
{column.required && (
|
||||
<Badge variant="destructive" className="h-4 px-1.5 text-xs">
|
||||
필수
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 컬럼 수가 많을 때 안내 메시지 */}
|
||||
{table.columns.length > 8 && (
|
||||
<div className="bg-muted sticky bottom-0 p-2 text-center">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
📜 총 {table.columns.length}개 컬럼 (스크롤하여 더 보기)
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
<Badge variant="secondary" className="h-4 px-1.5 text-[10px]">
|
||||
{column.widgetType}
|
||||
</Badge>
|
||||
{column.required && (
|
||||
<Badge variant="destructive" className="h-4 px-1 text-[10px]">
|
||||
필수
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="border-t border-gray-200 bg-gray-50 p-3">
|
||||
<div className="text-muted-foreground text-xs">💡 테이블이나 컬럼을 캔버스로 드래그하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -528,7 +528,7 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
|||
<div className="flex items-center justify-between rounded-xl bg-amber-50/80 border border-amber-200/60 p-3 text-amber-800 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Info className="h-4 w-4" />
|
||||
<span className="text-sm">템플릿 로딩 실패, 기본 템플릿 사용 중</span>
|
||||
<span className="text-xs" style={{ fontSize: "12px" }}>템플릿 로딩 실패, 기본 템플릿 사용 중</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => refetch()} className="border-amber-300 text-amber-700 hover:bg-amber-100">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -201,29 +201,22 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
const area = selectedComponent as AreaComponent;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{/* 컴포넌트 정보 - 간소화 */}
|
||||
<div className="bg-muted flex items-center justify-between rounded px-2 py-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Info className="text-muted-foreground h-2.5 w-2.5" />
|
||||
<span className="text-foreground text-[10px] font-medium">{selectedComponent.type}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-[9px]">{selectedComponent.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 라벨 + 최소 높이 (같은 행) */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[10px]">라벨</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">라벨</Label>
|
||||
<Input
|
||||
value={widget.label || ""}
|
||||
onChange={(e) => handleUpdate("label", e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="h-6 text-[10px]"
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[10px]">높이</Label>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">높이</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selectedComponent.size?.height || 0}
|
||||
|
|
@ -234,136 +227,152 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
}}
|
||||
step={40}
|
||||
placeholder="40"
|
||||
className="h-6 text-[10px]"
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder (widget만) */}
|
||||
{selectedComponent.type === "widget" && (
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[10px]">Placeholder</Label>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Placeholder</Label>
|
||||
<Input
|
||||
value={widget.placeholder || ""}
|
||||
onChange={(e) => handleUpdate("placeholder", e.target.value)}
|
||||
placeholder="입력 안내 텍스트"
|
||||
className="h-6 text-[10px]"
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title (group/area) */}
|
||||
{(selectedComponent.type === "group" || selectedComponent.type === "area") && (
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[10px]">제목</Label>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">제목</Label>
|
||||
<Input
|
||||
value={group.title || area.title || ""}
|
||||
onChange={(e) => handleUpdate("title", e.target.value)}
|
||||
placeholder="제목"
|
||||
className="h-6 text-[10px]"
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description (area만) */}
|
||||
{selectedComponent.type === "area" && (
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[10px]">설명</Label>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">설명</Label>
|
||||
<Input
|
||||
value={area.description || ""}
|
||||
onChange={(e) => handleUpdate("description", e.target.value)}
|
||||
placeholder="설명"
|
||||
className="h-6 text-[10px]"
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid Columns */}
|
||||
{(selectedComponent as any).gridColumns !== undefined && (
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[10px]">Grid Columns</Label>
|
||||
<Select
|
||||
value={((selectedComponent as any).gridColumns || 12).toString()}
|
||||
onValueChange={(value) => handleUpdate("gridColumns", parseInt(value))}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLUMN_NUMBERS.map((span) => (
|
||||
<SelectItem key={span} value={span.toString()}>
|
||||
{span} 컬럼 ({Math.round((span / 12) * 100)}%)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 위치 */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label>X {dragState?.isDragging && <Badge variant="secondary">드래그중</Badge>}</Label>
|
||||
<Input type="number" value={Math.round(currentPosition.x || 0)} disabled />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Y</Label>
|
||||
<Input type="number" value={Math.round(currentPosition.y || 0)} disabled />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Z</Label>
|
||||
{/* Grid Columns + Z-Index (같은 행) */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(selectedComponent as any).gridColumns !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Grid</Label>
|
||||
<Select
|
||||
value={((selectedComponent as any).gridColumns || 12).toString()}
|
||||
onValueChange={(value) => handleUpdate("gridColumns", parseInt(value))}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0" style={{ fontSize: "12px" }}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLUMN_NUMBERS.map((span) => (
|
||||
<SelectItem key={span} value={span.toString()} style={{ fontSize: "12px" }}>
|
||||
{span}열
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Z-Index</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={currentPosition.z || 1}
|
||||
onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 라벨 스타일 */}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg bg-slate-50 p-2 text-sm font-medium hover:bg-slate-100">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg bg-slate-50 p-2 text-xs font-medium hover:bg-slate-100">
|
||||
라벨 스타일
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-2">
|
||||
<div>
|
||||
<Label>라벨 텍스트</Label>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">라벨 텍스트</Label>
|
||||
<Input
|
||||
value={selectedComponent.style?.labelText || selectedComponent.label || ""}
|
||||
onChange={(e) => handleUpdate("style.labelText", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>폰트 크기</Label>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">크기</Label>
|
||||
<Input
|
||||
value={selectedComponent.style?.labelFontSize || "12px"}
|
||||
onChange={(e) => handleUpdate("style.labelFontSize", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>색상</Label>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selectedComponent.style?.labelColor || "#212121"}
|
||||
onChange={(e) => handleUpdate("style.labelColor", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>하단 여백</Label>
|
||||
<Input
|
||||
value={selectedComponent.style?.labelMarginBottom || "4px"}
|
||||
onChange={(e) => handleUpdate("style.labelMarginBottom", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={selectedComponent.style?.labelDisplay !== false}
|
||||
onCheckedChange={(checked) => handleUpdate("style.labelDisplay", checked)}
|
||||
/>
|
||||
<Label>라벨 표시</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">여백</Label>
|
||||
<Input
|
||||
value={selectedComponent.style?.labelMarginBottom || "4px"}
|
||||
onChange={(e) => handleUpdate("style.labelMarginBottom", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 pt-5">
|
||||
<Checkbox
|
||||
checked={selectedComponent.style?.labelDisplay !== false}
|
||||
onCheckedChange={(checked) => handleUpdate("style.labelDisplay", checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label className="text-xs">표시</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
|
@ -375,8 +384,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<Checkbox
|
||||
checked={widget.required === true || selectedComponent.componentConfig?.required === true}
|
||||
onCheckedChange={(checked) => handleUpdate("componentConfig.required", checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label>필수 입력</Label>
|
||||
<Label className="text-xs">필수</Label>
|
||||
</div>
|
||||
)}
|
||||
{widget.readonly !== undefined && (
|
||||
|
|
@ -384,38 +394,12 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<Checkbox
|
||||
checked={widget.readonly === true || selectedComponent.componentConfig?.readonly === true}
|
||||
onCheckedChange={(checked) => handleUpdate("componentConfig.readonly", checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label>읽기 전용</Label>
|
||||
<Label className="text-xs">읽기전용</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<Separator />
|
||||
<div className="flex gap-2">
|
||||
{onCopyComponent && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onCopyComponent(selectedComponent.id)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
복사
|
||||
</Button>
|
||||
)}
|
||||
{onDeleteComponent && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDeleteComponent(selectedComponent.id)}
|
||||
className="flex-1 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -513,7 +497,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<div>
|
||||
<Label>세부 타입</Label>
|
||||
<Select value={localComponentDetailType || webType} onValueChange={handleDetailTypeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue placeholder="세부 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -561,7 +545,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<div>
|
||||
<Label>입력 타입</Label>
|
||||
<Select value={widget.webType} onValueChange={(value) => handleUpdate("webType", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export const WebTypeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({ webType,
|
|||
<>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="multiple" className="text-sm">
|
||||
<Label htmlFor="multiple" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
다중 선택
|
||||
</Label>
|
||||
<Checkbox
|
||||
|
|
@ -121,7 +121,7 @@ export const WebTypeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({ webType,
|
|||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="searchable" className="text-sm">
|
||||
<Label htmlFor="searchable" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
검색 가능
|
||||
</Label>
|
||||
<Checkbox
|
||||
|
|
@ -259,7 +259,7 @@ export const WebTypeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({ webType,
|
|||
|
||||
{baseType === "date" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showTime" className="text-sm">
|
||||
<Label htmlFor="showTime" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
시간 입력 포함
|
||||
</Label>
|
||||
<Checkbox
|
||||
|
|
@ -395,7 +395,7 @@ export const WebTypeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({ webType,
|
|||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="fileMultiple" className="text-sm">
|
||||
<Label htmlFor="fileMultiple" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
다중 파일 선택
|
||||
</Label>
|
||||
<Checkbox
|
||||
|
|
|
|||
|
|
@ -90,11 +90,11 @@ export const CheckboxTypeConfigPanel: React.FC<CheckboxTypeConfigPanelProps> = (
|
|||
|
||||
const newConfig = JSON.parse(JSON.stringify(currentValues));
|
||||
// console.log("☑️ CheckboxTypeConfig 업데이트:", {
|
||||
// key,
|
||||
// value,
|
||||
// oldConfig: safeConfig,
|
||||
// newConfig,
|
||||
// localValues,
|
||||
// key,
|
||||
// value,
|
||||
// oldConfig: safeConfig,
|
||||
// newConfig,
|
||||
// localValues,
|
||||
// });
|
||||
|
||||
setTimeout(() => {
|
||||
|
|
@ -122,7 +122,7 @@ export const CheckboxTypeConfigPanel: React.FC<CheckboxTypeConfigPanelProps> = (
|
|||
라벨 위치
|
||||
</Label>
|
||||
<Select value={localValues.labelPosition} onValueChange={(value) => updateConfig("labelPosition", value)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue placeholder="라벨 위치 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -194,18 +194,18 @@ export const CheckboxTypeConfigPanel: React.FC<CheckboxTypeConfigPanelProps> = (
|
|||
<Label className="text-sm font-medium text-gray-700">미리보기</Label>
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
{localValues.labelPosition === "left" && localValues.checkboxText && (
|
||||
<span className="text-sm">{localValues.checkboxText}</span>
|
||||
<span className="text-xs" style={{ fontSize: "12px" }}>{localValues.checkboxText}</span>
|
||||
)}
|
||||
{localValues.labelPosition === "top" && localValues.checkboxText && (
|
||||
<div className="w-full">
|
||||
<div className="text-sm">{localValues.checkboxText}</div>
|
||||
<div className="text-xs" style={{ fontSize: "12px" }}>{localValues.checkboxText}</div>
|
||||
<Checkbox checked={localValues.defaultChecked} className="mt-1" />
|
||||
</div>
|
||||
)}
|
||||
{(localValues.labelPosition === "right" || localValues.labelPosition === "bottom") && (
|
||||
<>
|
||||
<Checkbox checked={localValues.defaultChecked} />
|
||||
{localValues.checkboxText && <span className="text-sm">{localValues.checkboxText}</span>}
|
||||
{localValues.checkboxText && <span className="text-xs" style={{ fontSize: "12px" }}>{localValues.checkboxText}</span>}
|
||||
</>
|
||||
)}
|
||||
{localValues.labelPosition === "left" && <Checkbox checked={localValues.defaultChecked} />}
|
||||
|
|
@ -218,7 +218,7 @@ export const CheckboxTypeConfigPanel: React.FC<CheckboxTypeConfigPanelProps> = (
|
|||
|
||||
{/* 안내 메시지 */}
|
||||
{localValues.indeterminate && (
|
||||
<div className="rounded-md bg-accent p-3">
|
||||
<div className="bg-accent rounded-md p-3">
|
||||
<div className="text-sm font-medium text-blue-900">불확정 상태</div>
|
||||
<div className="mt-1 text-xs text-blue-800">
|
||||
체크박스가 부분적으로 선택된 상태를 나타낼 수 있습니다. 주로 트리 구조에서 일부 하위 항목만 선택된 경우에
|
||||
|
|
|
|||
|
|
@ -105,10 +105,10 @@ export const CodeTypeConfigPanel: React.FC<CodeTypeConfigPanelProps> = ({ config
|
|||
// 실제 config 업데이트
|
||||
const newConfig = { ...safeConfig, [key]: value };
|
||||
// console.log("💻 CodeTypeConfig 업데이트:", {
|
||||
// key,
|
||||
// value,
|
||||
// oldConfig: safeConfig,
|
||||
// newConfig,
|
||||
// key,
|
||||
// value,
|
||||
// oldConfig: safeConfig,
|
||||
// newConfig,
|
||||
// });
|
||||
onConfigChange(newConfig);
|
||||
};
|
||||
|
|
@ -121,7 +121,7 @@ export const CodeTypeConfigPanel: React.FC<CodeTypeConfigPanelProps> = ({ config
|
|||
프로그래밍 언어
|
||||
</Label>
|
||||
<Select value={localValues.language} onValueChange={(value) => updateConfig("language", value)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue placeholder="언어 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60">
|
||||
|
|
@ -140,7 +140,7 @@ export const CodeTypeConfigPanel: React.FC<CodeTypeConfigPanelProps> = ({ config
|
|||
테마
|
||||
</Label>
|
||||
<Select value={localValues.theme} onValueChange={(value) => updateConfig("theme", value)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue placeholder="테마 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -271,7 +271,7 @@ export const CodeTypeConfigPanel: React.FC<CodeTypeConfigPanelProps> = ({ config
|
|||
</div>
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
<div className="rounded-md bg-accent p-3">
|
||||
<div className="bg-accent rounded-md p-3">
|
||||
<div className="text-sm font-medium text-blue-900">코드 에디터 설정</div>
|
||||
<div className="mt-1 text-xs text-blue-800">
|
||||
• 문법 강조 표시는 선택된 언어에 따라 적용됩니다
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config
|
|||
// 로컬 상태로 실시간 입력 관리
|
||||
const [localValues, setLocalValues] = useState(() => {
|
||||
// console.log("📅 DateTypeConfigPanel 초기 상태 설정:", {
|
||||
// config,
|
||||
// safeConfig,
|
||||
// config,
|
||||
// safeConfig,
|
||||
// });
|
||||
|
||||
return {
|
||||
|
|
@ -47,17 +47,17 @@ export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config
|
|||
const hasValidConfig = config && Object.keys(config).length > 0;
|
||||
|
||||
// console.log("📅 DateTypeConfigPanel config 변경 감지:", {
|
||||
// config,
|
||||
// configExists: !!config,
|
||||
// configKeys: config ? Object.keys(config) : [],
|
||||
// hasValidConfig,
|
||||
// safeConfig,
|
||||
// safeConfigKeys: Object.keys(safeConfig),
|
||||
// currentLocalValues: localValues,
|
||||
// configStringified: JSON.stringify(config),
|
||||
// safeConfigStringified: JSON.stringify(safeConfig),
|
||||
// willUpdateLocalValues: hasValidConfig,
|
||||
// timestamp: new Date().toISOString(),
|
||||
// config,
|
||||
// configExists: !!config,
|
||||
// configKeys: config ? Object.keys(config) : [],
|
||||
// hasValidConfig,
|
||||
// safeConfig,
|
||||
// safeConfigKeys: Object.keys(safeConfig),
|
||||
// currentLocalValues: localValues,
|
||||
// configStringified: JSON.stringify(config),
|
||||
// safeConfigStringified: JSON.stringify(safeConfig),
|
||||
// willUpdateLocalValues: hasValidConfig,
|
||||
// timestamp: new Date().toISOString(),
|
||||
// });
|
||||
|
||||
// config가 없거나 비어있으면 로컬 상태를 유지
|
||||
|
|
@ -85,17 +85,17 @@ export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config
|
|||
localValues.maxDate !== newLocalValues.maxDate;
|
||||
|
||||
// console.log("🔄 로컬 상태 업데이트 검사:", {
|
||||
// oldLocalValues: localValues,
|
||||
// newLocalValues,
|
||||
// hasChanges,
|
||||
// changes: {
|
||||
// format: localValues.format !== newLocalValues.format,
|
||||
// showTime: localValues.showTime !== newLocalValues.showTime,
|
||||
// defaultValue: localValues.defaultValue !== newLocalValues.defaultValue,
|
||||
// placeholder: localValues.placeholder !== newLocalValues.placeholder,
|
||||
// minDate: localValues.minDate !== newLocalValues.minDate,
|
||||
// maxDate: localValues.maxDate !== newLocalValues.maxDate,
|
||||
// },
|
||||
// oldLocalValues: localValues,
|
||||
// newLocalValues,
|
||||
// hasChanges,
|
||||
// changes: {
|
||||
// format: localValues.format !== newLocalValues.format,
|
||||
// showTime: localValues.showTime !== newLocalValues.showTime,
|
||||
// defaultValue: localValues.defaultValue !== newLocalValues.defaultValue,
|
||||
// placeholder: localValues.placeholder !== newLocalValues.placeholder,
|
||||
// minDate: localValues.minDate !== newLocalValues.minDate,
|
||||
// maxDate: localValues.maxDate !== newLocalValues.maxDate,
|
||||
// },
|
||||
// });
|
||||
|
||||
if (hasChanges) {
|
||||
|
|
@ -113,34 +113,34 @@ export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config
|
|||
// 실제 config 업데이트 - 현재 로컬 상태를 기반으로 새 객체 생성 (safeConfig 기본값 덮어쓰기 방지)
|
||||
const newConfig = JSON.parse(JSON.stringify({ ...localValues, [key]: value }));
|
||||
// console.log("📅 DateTypeConfig 업데이트:", {
|
||||
// key,
|
||||
// value,
|
||||
// oldConfig: safeConfig,
|
||||
// newConfig,
|
||||
// localValues,
|
||||
// timestamp: new Date().toISOString(),
|
||||
// changes: {
|
||||
// format: newConfig.format !== safeConfig.format,
|
||||
// showTime: newConfig.showTime !== safeConfig.showTime,
|
||||
// placeholder: newConfig.placeholder !== safeConfig.placeholder,
|
||||
// minDate: newConfig.minDate !== safeConfig.minDate,
|
||||
// maxDate: newConfig.maxDate !== safeConfig.maxDate,
|
||||
// defaultValue: newConfig.defaultValue !== safeConfig.defaultValue,
|
||||
// },
|
||||
// willCallOnConfigChange: true,
|
||||
// key,
|
||||
// value,
|
||||
// oldConfig: safeConfig,
|
||||
// newConfig,
|
||||
// localValues,
|
||||
// timestamp: new Date().toISOString(),
|
||||
// changes: {
|
||||
// format: newConfig.format !== safeConfig.format,
|
||||
// showTime: newConfig.showTime !== safeConfig.showTime,
|
||||
// placeholder: newConfig.placeholder !== safeConfig.placeholder,
|
||||
// minDate: newConfig.minDate !== safeConfig.minDate,
|
||||
// maxDate: newConfig.maxDate !== safeConfig.maxDate,
|
||||
// defaultValue: newConfig.defaultValue !== safeConfig.defaultValue,
|
||||
// },
|
||||
// willCallOnConfigChange: true,
|
||||
// });
|
||||
|
||||
// console.log("🔄 onConfigChange 호출 직전:", {
|
||||
// newConfig,
|
||||
// configStringified: JSON.stringify(newConfig),
|
||||
// newConfig,
|
||||
// configStringified: JSON.stringify(newConfig),
|
||||
// });
|
||||
|
||||
// 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
|
||||
setTimeout(() => {
|
||||
// console.log("✅ onConfigChange 호출 완료:", {
|
||||
// key,
|
||||
// newConfig,
|
||||
// timestamp: new Date().toISOString(),
|
||||
// key,
|
||||
// newConfig,
|
||||
// timestamp: new Date().toISOString(),
|
||||
// });
|
||||
onConfigChange(newConfig);
|
||||
}, 0);
|
||||
|
|
@ -157,9 +157,9 @@ export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config
|
|||
value={localValues.format}
|
||||
onValueChange={(value) => {
|
||||
// console.log("📅 날짜 형식 변경:", {
|
||||
// oldFormat: localValues.format,
|
||||
// newFormat: value,
|
||||
// oldShowTime: localValues.showTime,
|
||||
// oldFormat: localValues.format,
|
||||
// newFormat: value,
|
||||
// oldShowTime: localValues.showTime,
|
||||
// });
|
||||
|
||||
// format 변경 시 showTime도 자동 동기화
|
||||
|
|
@ -175,9 +175,9 @@ export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config
|
|||
);
|
||||
|
||||
// console.log("🔄 format+showTime 동시 업데이트:", {
|
||||
// newFormat: value,
|
||||
// newShowTime: hasTime,
|
||||
// newConfig,
|
||||
// newFormat: value,
|
||||
// newShowTime: hasTime,
|
||||
// newConfig,
|
||||
// });
|
||||
|
||||
// 로컬 상태도 동시 업데이트
|
||||
|
|
@ -193,7 +193,7 @@ export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config
|
|||
}, 0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue placeholder="날짜 형식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -215,9 +215,9 @@ export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config
|
|||
onCheckedChange={(checked) => {
|
||||
const newShowTime = !!checked;
|
||||
// console.log("⏰ 시간 표시 체크박스 변경:", {
|
||||
// oldShowTime: localValues.showTime,
|
||||
// newShowTime,
|
||||
// currentFormat: localValues.format,
|
||||
// oldShowTime: localValues.showTime,
|
||||
// newShowTime,
|
||||
// currentFormat: localValues.format,
|
||||
// });
|
||||
|
||||
// showTime 변경 시 format도 적절히 조정
|
||||
|
|
@ -231,9 +231,9 @@ export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config
|
|||
}
|
||||
|
||||
// console.log("🔄 showTime+format 동시 업데이트:", {
|
||||
// newShowTime,
|
||||
// oldFormat: localValues.format,
|
||||
// newFormat,
|
||||
// newShowTime,
|
||||
// oldFormat: localValues.format,
|
||||
// newFormat,
|
||||
// });
|
||||
|
||||
// 한 번에 두 값을 모두 업데이트 - 현재 로컬 상태 기반으로 생성
|
||||
|
|
|
|||
|
|
@ -92,10 +92,10 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
|||
// 실제 config 업데이트
|
||||
const newConfig = { ...safeConfig, [key]: value };
|
||||
// console.log("🏢 EntityTypeConfig 업데이트:", {
|
||||
// key,
|
||||
// value,
|
||||
// oldConfig: safeConfig,
|
||||
// newConfig,
|
||||
// key,
|
||||
// value,
|
||||
// oldConfig: safeConfig,
|
||||
// newConfig,
|
||||
// });
|
||||
onConfigChange(newConfig);
|
||||
};
|
||||
|
|
@ -233,7 +233,7 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
|||
표시 형식
|
||||
</Label>
|
||||
<Select value={localValues.displayFormat} onValueChange={(value) => updateConfig("displayFormat", value)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue placeholder="형식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -267,7 +267,7 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
|||
{/* 기존 필터 목록 */}
|
||||
<div className="max-h-40 space-y-2 overflow-y-auto">
|
||||
{Object.entries(safeConfig.filters || {}).map(([field, value]) => (
|
||||
<div key={field} className="flex items-center space-x-2 rounded border p-2 text-sm">
|
||||
<div key={field} className="flex items-center space-x-2 rounded border p-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<Input
|
||||
value={field}
|
||||
onChange={(e) => updateFilter(field, e.target.value, value as string)}
|
||||
|
|
@ -317,7 +317,7 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
|||
<div className="mt-2">
|
||||
<div className="flex items-center space-x-2 rounded border bg-white p-2">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground flex-1 text-xs" style={{ fontSize: "12px" }}>
|
||||
{localValues.placeholder || `${localValues.referenceTable || "엔터티"}를 선택하세요`}
|
||||
</div>
|
||||
<Database className="h-4 w-4 text-gray-400" />
|
||||
|
|
@ -334,7 +334,7 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
|||
</div>
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
<div className="rounded-md bg-accent p-3">
|
||||
<div className="bg-accent rounded-md p-3">
|
||||
<div className="text-sm font-medium text-blue-900">엔터티 타입 설정 가이드</div>
|
||||
<div className="mt-1 text-xs text-blue-800">
|
||||
• <strong>참조 테이블</strong>: 데이터를 가져올 다른 테이블 이름
|
||||
|
|
|
|||
|
|
@ -89,12 +89,12 @@ export const NumberTypeConfigPanel: React.FC<NumberTypeConfigPanelProps> = ({ co
|
|||
|
||||
const newConfig = JSON.parse(JSON.stringify(currentValues));
|
||||
// console.log("🔢 NumberTypeConfig 업데이트:", {
|
||||
// key,
|
||||
// value,
|
||||
// oldConfig: safeConfig,
|
||||
// newConfig,
|
||||
// localValues,
|
||||
// timestamp: new Date().toISOString(),
|
||||
// key,
|
||||
// value,
|
||||
// oldConfig: safeConfig,
|
||||
// newConfig,
|
||||
// localValues,
|
||||
// timestamp: new Date().toISOString(),
|
||||
// });
|
||||
|
||||
// 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
|
||||
|
|
@ -111,7 +111,7 @@ export const NumberTypeConfigPanel: React.FC<NumberTypeConfigPanelProps> = ({ co
|
|||
숫자 형식
|
||||
</Label>
|
||||
<Select value={localValues.format} onValueChange={(value) => updateConfig("format", value)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue placeholder="숫자 형식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ export const RadioTypeConfigPanel: React.FC<RadioTypeConfigPanelProps> = ({ conf
|
|||
{(safeConfig.options || []).map((option) => (
|
||||
<div key={option.value} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={option.value} id={`preview-${option.value}`} />
|
||||
<Label htmlFor={`preview-${option.value}`} className="text-sm">
|
||||
<Label htmlFor={`preview-${option.value}`} className="text-xs" style={{ fontSize: "12px" }}>
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -82,11 +82,11 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
|
|||
// 실제 config 업데이트 - 깊은 복사로 새 객체 보장
|
||||
const newConfig = JSON.parse(JSON.stringify({ ...safeConfig, [key]: value }));
|
||||
// console.log("📋 SelectTypeConfig 업데이트:", {
|
||||
// key,
|
||||
// value,
|
||||
// oldConfig: safeConfig,
|
||||
// newConfig,
|
||||
// timestamp: new Date().toISOString(),
|
||||
// key,
|
||||
// value,
|
||||
// oldConfig: safeConfig,
|
||||
// newConfig,
|
||||
// timestamp: new Date().toISOString(),
|
||||
// });
|
||||
|
||||
// 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
|
||||
|
|
@ -101,10 +101,10 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
|
|||
const updatedOptions = [...(safeConfig.options || []), newOptionData];
|
||||
|
||||
// console.log("➕ SelectType 옵션 추가:", {
|
||||
// newOption: newOptionData,
|
||||
// updatedOptions,
|
||||
// currentLocalOptions: localOptions,
|
||||
// timestamp: new Date().toISOString(),
|
||||
// newOption: newOptionData,
|
||||
// updatedOptions,
|
||||
// currentLocalOptions: localOptions,
|
||||
// timestamp: new Date().toISOString(),
|
||||
// });
|
||||
|
||||
// 로컬 상태 즉시 업데이트
|
||||
|
|
@ -128,9 +128,9 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
|
|||
|
||||
const removeOption = (index: number) => {
|
||||
// console.log("➖ SelectType 옵션 삭제:", {
|
||||
// removeIndex: index,
|
||||
// currentOptions: safeConfig.options,
|
||||
// currentLocalOptions: localOptions,
|
||||
// removeIndex: index,
|
||||
// currentOptions: safeConfig.options,
|
||||
// currentLocalOptions: localOptions,
|
||||
// });
|
||||
|
||||
// 로컬 상태 즉시 업데이트
|
||||
|
|
@ -170,7 +170,7 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
|
|||
value={localValues.placeholder}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="옵션을 선택하세요"
|
||||
className="mt-1"
|
||||
className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -254,7 +254,7 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
|
|||
onCheckedChange={(checked) => updateOption(index, "disabled", !!checked)}
|
||||
title="비활성화"
|
||||
/>
|
||||
<Button size="sm" variant="ghost" onClick={() => removeOption(index)} className="h-8 w-8 p-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => removeOption(index)} className="h-6 w-8 p-1">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -279,7 +279,7 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
|
|||
size="sm"
|
||||
onClick={addOption}
|
||||
disabled={!newOption.label.trim() || !newOption.value.trim()}
|
||||
className="h-8 w-8 p-1"
|
||||
className="h-6 w-8 p-1"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -94,11 +94,11 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
|||
|
||||
const newConfig = JSON.parse(JSON.stringify(currentValues));
|
||||
// console.log("📝 TextTypeConfig 업데이트:", {
|
||||
// key,
|
||||
// value,
|
||||
// oldConfig: safeConfig,
|
||||
// newConfig,
|
||||
// localValues,
|
||||
// key,
|
||||
// value,
|
||||
// oldConfig: safeConfig,
|
||||
// newConfig,
|
||||
// localValues,
|
||||
// });
|
||||
|
||||
setTimeout(() => {
|
||||
|
|
@ -114,7 +114,7 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
|||
입력 형식
|
||||
</Label>
|
||||
<Select value={localValues.format} onValueChange={(value) => updateConfig("format", value)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue placeholder="입력 형식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -220,13 +220,13 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
|||
</div>
|
||||
|
||||
{localValues.autoInput && (
|
||||
<div className="space-y-3 border-l-2 border-primary/20 pl-4">
|
||||
<div className="border-primary/20 space-y-3 border-l-2 pl-4">
|
||||
<div>
|
||||
<Label htmlFor="autoValueType" className="text-sm font-medium">
|
||||
자동값 타입
|
||||
</Label>
|
||||
<Select value={localValues.autoValueType} onValueChange={(value) => updateConfig("autoValueType", value)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue placeholder="자동값 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -256,7 +256,7 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md bg-accent p-3">
|
||||
<div className="bg-accent rounded-md p-3">
|
||||
<div className="text-sm font-medium text-blue-900">자동입력 안내</div>
|
||||
<div className="mt-1 text-xs text-blue-800">
|
||||
자동입력이 활성화되면 해당 필드는 읽기 전용이 되며, 설정된 타입에 따라 자동으로 값이 입력됩니다.
|
||||
|
|
@ -280,7 +280,7 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config
|
|||
|
||||
{/* 형식별 안내 메시지 */}
|
||||
{localValues.format !== "none" && (
|
||||
<div className="rounded-md bg-accent p-3">
|
||||
<div className="bg-accent rounded-md p-3">
|
||||
<div className="text-sm font-medium text-blue-900">형식 안내</div>
|
||||
<div className="mt-1 text-xs text-blue-800">
|
||||
{localValues.format === "email" && "유효한 이메일 주소를 입력해야 합니다 (예: user@example.com)"}
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ export const TextareaTypeConfigPanel: React.FC<TextareaTypeConfigPanelProps> = (
|
|||
<Label className="text-sm font-medium text-gray-700">미리보기</Label>
|
||||
<div className="mt-2">
|
||||
<textarea
|
||||
className="w-full rounded border border-gray-300 p-2 text-sm"
|
||||
className="w-full rounded border border-gray-300 p-2 text-xs" style={{ fontSize: "12px" }}
|
||||
rows={localValues.rows}
|
||||
placeholder={localValues.placeholder || "텍스트를 입력하세요..."}
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -71,26 +71,16 @@ export const LeftUnifiedToolbar: React.FC<LeftUnifiedToolbarProps> = ({ buttons,
|
|||
);
|
||||
};
|
||||
|
||||
// 기본 버튼 설정 (컴포넌트와 편집 2개)
|
||||
// 기본 버튼 설정 (통합 패널 1개)
|
||||
export const defaultToolbarButtons: ToolbarButton[] = [
|
||||
// 컴포넌트 그룹 (테이블 + 컴포넌트 탭)
|
||||
// 통합 패널 (컴포넌트 + 편집 탭)
|
||||
{
|
||||
id: "components",
|
||||
label: "컴포넌트",
|
||||
id: "unified",
|
||||
label: "패널",
|
||||
icon: <Layout className="h-5 w-5" />,
|
||||
shortcut: "C",
|
||||
group: "source",
|
||||
panelWidth: 400,
|
||||
},
|
||||
|
||||
// 편집 그룹 (속성 + 스타일 & 해상도 탭)
|
||||
{
|
||||
id: "properties",
|
||||
label: "편집",
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
shortcut: "P",
|
||||
group: "editor",
|
||||
panelWidth: 400,
|
||||
group: "source",
|
||||
panelWidth: 240,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { FlowComponent } from "@/types/screen-management";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertCircle, Loader2, ChevronUp } from "lucide-react";
|
||||
import { AlertCircle, Loader2, ChevronUp, Filter, X } from "lucide-react";
|
||||
import {
|
||||
getFlowById,
|
||||
getAllStepCounts,
|
||||
|
|
@ -27,6 +27,17 @@ import {
|
|||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination";
|
||||
import { useFlowStepStore } from "@/stores/flowStepStore";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
|
||||
interface FlowWidgetProps {
|
||||
component: FlowComponent;
|
||||
|
|
@ -43,6 +54,8 @@ export function FlowWidget({
|
|||
flowRefreshKey,
|
||||
onFlowRefresh,
|
||||
}: FlowWidgetProps) {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
|
||||
// 🆕 전역 상태 관리
|
||||
const setSelectedStep = useFlowStepStore((state) => state.setSelectedStep);
|
||||
const resetFlow = useFlowStepStore((state) => state.resetFlow);
|
||||
|
|
@ -62,6 +75,13 @@ export function FlowWidget({
|
|||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑
|
||||
|
||||
// 🆕 검색 필터 관련 상태
|
||||
const [searchFilterColumns, setSearchFilterColumns] = useState<Set<string>>(new Set()); // 검색 필터로 사용할 컬럼
|
||||
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false); // 필터 설정 다이얼로그
|
||||
const [searchValues, setSearchValues] = useState<Record<string, string>>({}); // 검색 값
|
||||
const [allAvailableColumns, setAllAvailableColumns] = useState<string[]>([]); // 전체 컬럼 목록
|
||||
const [filteredData, setFilteredData] = useState<any[]>([]); // 필터링된 데이터
|
||||
|
||||
/**
|
||||
* 🆕 컬럼 표시 결정 함수
|
||||
* 1순위: 플로우 스텝 기본 설정 (displayConfig)
|
||||
|
|
@ -97,6 +117,117 @@ export function FlowWidget({
|
|||
// 🆕 플로우 컴포넌트 ID (버튼이 이 플로우를 참조할 때 사용)
|
||||
const flowComponentId = component.id;
|
||||
|
||||
// 🆕 localStorage 키 생성
|
||||
const filterSettingKey = useMemo(() => {
|
||||
if (!flowId || selectedStepId === null) return null;
|
||||
return `flowWidget_searchFilters_${flowId}_${selectedStepId}`;
|
||||
}, [flowId, selectedStepId]);
|
||||
|
||||
// 🆕 저장된 필터 설정 불러오기
|
||||
useEffect(() => {
|
||||
if (!filterSettingKey || allAvailableColumns.length === 0) return;
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(filterSettingKey);
|
||||
if (saved) {
|
||||
const savedFilters = JSON.parse(saved);
|
||||
setSearchFilterColumns(new Set(savedFilters));
|
||||
} else {
|
||||
// 초기값: 빈 필터 (사용자가 선택해야 함)
|
||||
setSearchFilterColumns(new Set());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("필터 설정 불러오기 실패:", error);
|
||||
setSearchFilterColumns(new Set());
|
||||
}
|
||||
}, [filterSettingKey, allAvailableColumns]);
|
||||
|
||||
// 🆕 필터 설정 저장
|
||||
const saveFilterSettings = useCallback(() => {
|
||||
if (!filterSettingKey) return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(filterSettingKey, JSON.stringify(Array.from(searchFilterColumns)));
|
||||
setIsFilterSettingOpen(false);
|
||||
toast.success("검색 필터 설정이 저장되었습니다");
|
||||
|
||||
// 검색 값 초기화
|
||||
setSearchValues({});
|
||||
} catch (error) {
|
||||
console.error("필터 설정 저장 실패:", error);
|
||||
toast.error("설정 저장에 실패했습니다");
|
||||
}
|
||||
}, [filterSettingKey, searchFilterColumns]);
|
||||
|
||||
// 🆕 필터 컬럼 토글
|
||||
const toggleFilterColumn = useCallback((columnName: string) => {
|
||||
setSearchFilterColumns((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(columnName)) {
|
||||
newSet.delete(columnName);
|
||||
} else {
|
||||
newSet.add(columnName);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 🆕 전체 선택/해제
|
||||
const toggleAllFilters = useCallback(() => {
|
||||
if (searchFilterColumns.size === allAvailableColumns.length) {
|
||||
// 전체 해제
|
||||
setSearchFilterColumns(new Set());
|
||||
} else {
|
||||
// 전체 선택
|
||||
setSearchFilterColumns(new Set(allAvailableColumns));
|
||||
}
|
||||
}, [searchFilterColumns, allAvailableColumns]);
|
||||
|
||||
// 🆕 검색 초기화
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchValues({});
|
||||
setFilteredData([]);
|
||||
}, []);
|
||||
|
||||
// 🆕 검색 값이 변경될 때마다 자동 검색 (useEffect로 직접 처리)
|
||||
useEffect(() => {
|
||||
if (!stepData || stepData.length === 0) {
|
||||
setFilteredData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 검색 값이 하나라도 있는지 확인
|
||||
const hasSearchValue = Object.values(searchValues).some((val) => val && String(val).trim() !== "");
|
||||
|
||||
if (!hasSearchValue) {
|
||||
// 검색 값이 없으면 필터링 해제
|
||||
setFilteredData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 필터링 실행
|
||||
const filtered = stepData.filter((row) => {
|
||||
// 모든 검색 조건을 만족하는지 확인
|
||||
return Object.entries(searchValues).every(([col, searchValue]) => {
|
||||
if (!searchValue || String(searchValue).trim() === "") return true; // 빈 값은 필터링하지 않음
|
||||
|
||||
const cellValue = row[col];
|
||||
if (cellValue === null || cellValue === undefined) return false;
|
||||
|
||||
// 문자열로 변환하여 대소문자 무시 검색
|
||||
return String(cellValue).toLowerCase().includes(String(searchValue).toLowerCase());
|
||||
});
|
||||
});
|
||||
|
||||
setFilteredData(filtered);
|
||||
console.log("🔍 검색 실행:", {
|
||||
totalRows: stepData.length,
|
||||
filteredRows: filtered.length,
|
||||
searchValues,
|
||||
hasSearchValue,
|
||||
});
|
||||
}, [searchValues, stepData]); // stepData와 searchValues가 변경될 때마다 실행
|
||||
|
||||
// 선택된 스텝의 데이터를 다시 로드하는 함수
|
||||
const refreshStepData = async () => {
|
||||
if (!flowId) return;
|
||||
|
|
@ -149,14 +280,18 @@ export function FlowWidget({
|
|||
// 🆕 컬럼 추출 및 우선순위 적용
|
||||
if (rows.length > 0) {
|
||||
const allColumns = Object.keys(rows[0]);
|
||||
setAllAvailableColumns(allColumns); // 전체 컬럼 목록 저장
|
||||
const visibleColumns = getVisibleColumns(selectedStepId, allColumns);
|
||||
setStepDataColumns(visibleColumns);
|
||||
} else {
|
||||
setAllAvailableColumns([]);
|
||||
setStepDataColumns([]);
|
||||
}
|
||||
|
||||
// 선택 초기화
|
||||
setSelectedRows(new Set());
|
||||
setSearchValues({}); // 검색 값도 초기화
|
||||
setFilteredData([]); // 필터링된 데이터 초기화
|
||||
onSelectedDataChange?.([], selectedStepId);
|
||||
}
|
||||
} catch (err: any) {
|
||||
|
|
@ -180,6 +315,57 @@ export function FlowWidget({
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 프리뷰 모드에서는 샘플 데이터만 표시
|
||||
if (isPreviewMode) {
|
||||
console.log("🔒 프리뷰 모드: 플로우 데이터 로드 차단 - 샘플 데이터 표시");
|
||||
setFlowData({
|
||||
id: flowId || 0,
|
||||
flowName: flowName || "샘플 플로우",
|
||||
description: "프리뷰 모드 샘플",
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as FlowDefinition);
|
||||
|
||||
const sampleSteps: FlowStep[] = [
|
||||
{
|
||||
id: 1,
|
||||
flowId: flowId || 0,
|
||||
stepName: "시작 단계",
|
||||
stepOrder: 1,
|
||||
stepType: "start",
|
||||
stepConfig: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
flowId: flowId || 0,
|
||||
stepName: "진행 중",
|
||||
stepOrder: 2,
|
||||
stepType: "process",
|
||||
stepConfig: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
flowId: flowId || 0,
|
||||
stepName: "완료",
|
||||
stepOrder: 3,
|
||||
stepType: "end",
|
||||
stepConfig: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
setSteps(sampleSteps);
|
||||
setStepCounts({ 1: 5, 2: 3, 3: 2 });
|
||||
setConnections([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 플로우 정보 조회
|
||||
const flowResponse = await getFlowById(flowId!);
|
||||
if (!flowResponse.success || !flowResponse.data) {
|
||||
|
|
@ -242,6 +428,7 @@ export function FlowWidget({
|
|||
setStepData(rows);
|
||||
if (rows.length > 0) {
|
||||
const allColumns = Object.keys(rows[0]);
|
||||
setAllAvailableColumns(allColumns); // 전체 컬럼 목록 저장
|
||||
// sortedSteps를 직접 전달하여 타이밍 이슈 해결
|
||||
const visibleColumns = getVisibleColumns(firstStep.id, allColumns, sortedSteps);
|
||||
setStepDataColumns(visibleColumns);
|
||||
|
|
@ -280,6 +467,11 @@ export function FlowWidget({
|
|||
|
||||
// 🆕 스텝 클릭 핸들러 (전역 상태 업데이트 추가)
|
||||
const handleStepClick = async (stepId: number, stepName: string) => {
|
||||
// 프리뷰 모드에서는 스텝 클릭 차단
|
||||
if (isPreviewMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 외부 콜백 실행
|
||||
if (onStepClick) {
|
||||
onStepClick(stepId, stepName);
|
||||
|
|
@ -335,9 +527,11 @@ export function FlowWidget({
|
|||
// 🆕 컬럼 추출 및 우선순위 적용
|
||||
if (rows.length > 0) {
|
||||
const allColumns = Object.keys(rows[0]);
|
||||
setAllAvailableColumns(allColumns); // 전체 컬럼 목록 저장
|
||||
const visibleColumns = getVisibleColumns(stepId, allColumns);
|
||||
setStepDataColumns(visibleColumns);
|
||||
} else {
|
||||
setAllAvailableColumns([]);
|
||||
setStepDataColumns([]);
|
||||
}
|
||||
} catch (err: any) {
|
||||
|
|
@ -350,6 +544,11 @@ export function FlowWidget({
|
|||
|
||||
// 체크박스 토글
|
||||
const toggleRowSelection = (rowIndex: number) => {
|
||||
// 프리뷰 모드에서는 행 선택 차단
|
||||
if (isPreviewMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSelected = new Set(selectedRows);
|
||||
if (newSelected.has(rowIndex)) {
|
||||
newSelected.delete(rowIndex);
|
||||
|
|
@ -385,9 +584,15 @@ export function FlowWidget({
|
|||
onSelectedDataChange?.(selectedData, selectedStepId);
|
||||
};
|
||||
|
||||
// 🆕 표시할 데이터 결정
|
||||
// - 검색 값이 있으면 → filteredData 사용 (결과가 0건이어도 filteredData 사용)
|
||||
// - 검색 값이 없으면 → stepData 사용 (전체 데이터)
|
||||
const hasSearchValue = Object.values(searchValues).some((val) => val && String(val).trim() !== "");
|
||||
const displayData = hasSearchValue ? filteredData : stepData;
|
||||
|
||||
// 🆕 페이지네이션된 스텝 데이터
|
||||
const paginatedStepData = stepData.slice((stepDataPage - 1) * stepDataPageSize, stepDataPage * stepDataPageSize);
|
||||
const totalStepDataPages = Math.ceil(stepData.length / stepDataPageSize);
|
||||
const paginatedStepData = displayData.slice((stepDataPage - 1) * stepDataPageSize, stepDataPage * stepDataPageSize);
|
||||
const totalStepDataPages = Math.ceil(displayData.length / stepDataPageSize);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -513,15 +718,83 @@ export function FlowWidget({
|
|||
<div className="bg-muted/30 mt-4 flex w-full flex-col rounded-lg border sm:mt-6 lg:mt-8">
|
||||
{/* 헤더 - 자동 높이 */}
|
||||
<div className="bg-background flex-shrink-0 border-b px-4 py-3 sm:px-6 sm:py-4">
|
||||
<h4 className="text-foreground text-base font-semibold sm:text-lg">
|
||||
{steps.find((s) => s.id === selectedStepId)?.stepName}
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">
|
||||
총 {stepData.length}건의 데이터
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="text-primary ml-2 font-medium">({selectedRows.size}건 선택됨)</span>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-foreground text-base font-semibold sm:text-lg">
|
||||
{steps.find((s) => s.id === selectedStepId)?.stepName}
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">
|
||||
총 {stepData.length}건의 데이터
|
||||
{filteredData.length > 0 && (
|
||||
<span className="text-primary ml-2 font-medium">(필터링: {filteredData.length}건)</span>
|
||||
)}
|
||||
{selectedRows.size > 0 && (
|
||||
<span className="text-primary ml-2 font-medium">({selectedRows.size}건 선택됨)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 🆕 필터 설정 버튼 */}
|
||||
{allAvailableColumns.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (isPreviewMode) {
|
||||
return;
|
||||
}
|
||||
setIsFilterSettingOpen(true);
|
||||
}}
|
||||
disabled={isPreviewMode}
|
||||
className="h-8 shrink-0 text-xs sm:text-sm"
|
||||
>
|
||||
<Filter className="mr-2 h-3 w-3 sm:h-4 sm:w-4" />
|
||||
검색 필터 설정
|
||||
{searchFilterColumns.size > 0 && (
|
||||
<Badge variant="secondary" className="ml-2 h-5 px-1.5 text-[10px]">
|
||||
{searchFilterColumns.size}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 🆕 검색 필터 입력 영역 */}
|
||||
{searchFilterColumns.size > 0 && (
|
||||
<div className="bg-muted/30 mt-4 space-y-3 rounded border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-sm font-medium">검색 필터</h5>
|
||||
{Object.keys(searchValues).length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClearSearch} className="h-7 text-xs">
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
초기화
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from(searchFilterColumns).map((col) => (
|
||||
<div key={col} className="space-y-1.5">
|
||||
<Label htmlFor={`search-${col}`} className="text-xs">
|
||||
{columnLabels[col] || col}
|
||||
</Label>
|
||||
<Input
|
||||
id={`search-${col}`}
|
||||
value={searchValues[col] || ""}
|
||||
onChange={(e) =>
|
||||
setSearchValues((prev) => ({
|
||||
...prev,
|
||||
[col]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={`${columnLabels[col] || col} 검색...`}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 데이터 영역 - 고정 높이 + 스크롤 */}
|
||||
|
|
@ -665,7 +938,7 @@ export function FlowWidget({
|
|||
setStepDataPage(1); // 페이지 크기 변경 시 첫 페이지로
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20 text-xs">
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -684,17 +957,29 @@ export function FlowWidget({
|
|||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setStepDataPage((p) => Math.max(1, p - 1))}
|
||||
className={stepDataPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
onClick={() => {
|
||||
if (isPreviewMode) {
|
||||
return;
|
||||
}
|
||||
setStepDataPage((p) => Math.max(1, p - 1));
|
||||
}}
|
||||
className={
|
||||
stepDataPage === 1 || isPreviewMode ? "pointer-events-none opacity-50" : "cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{totalStepDataPages <= 7 ? (
|
||||
Array.from({ length: totalStepDataPages }, (_, i) => i + 1).map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
onClick={() => setStepDataPage(page)}
|
||||
onClick={() => {
|
||||
if (isPreviewMode) {
|
||||
return;
|
||||
}
|
||||
setStepDataPage(page);
|
||||
}}
|
||||
isActive={stepDataPage === page}
|
||||
className="cursor-pointer"
|
||||
className={isPreviewMode ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
|
|
@ -719,9 +1004,14 @@ export function FlowWidget({
|
|||
)}
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
onClick={() => setStepDataPage(page)}
|
||||
onClick={() => {
|
||||
if (isPreviewMode) {
|
||||
return;
|
||||
}
|
||||
setStepDataPage(page);
|
||||
}}
|
||||
isActive={stepDataPage === page}
|
||||
className="cursor-pointer"
|
||||
className={isPreviewMode ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
|
|
@ -732,9 +1022,16 @@ export function FlowWidget({
|
|||
)}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setStepDataPage((p) => Math.min(totalStepDataPages, p + 1))}
|
||||
onClick={() => {
|
||||
if (isPreviewMode) {
|
||||
return;
|
||||
}
|
||||
setStepDataPage((p) => Math.min(totalStepDataPages, p + 1));
|
||||
}}
|
||||
className={
|
||||
stepDataPage === totalStepDataPages ? "pointer-events-none opacity-50" : "cursor-pointer"
|
||||
stepDataPage === totalStepDataPages || isPreviewMode
|
||||
? "pointer-events-none opacity-50"
|
||||
: "cursor-pointer"
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
|
@ -746,6 +1043,76 @@ export function FlowWidget({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🆕 검색 필터 설정 다이얼로그 */}
|
||||
<Dialog open={isFilterSettingOpen} onOpenChange={setIsFilterSettingOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">검색 필터 설정</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
검색 필터로 사용할 컬럼을 선택하세요. 선택한 컬럼의 검색 입력 필드가 표시됩니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* 전체 선택/해제 */}
|
||||
<div className="bg-muted/50 flex items-center gap-3 rounded border p-3">
|
||||
<Checkbox
|
||||
id="select-all-filters"
|
||||
checked={searchFilterColumns.size === allAvailableColumns.length && allAvailableColumns.length > 0}
|
||||
onCheckedChange={toggleAllFilters}
|
||||
/>
|
||||
<Label htmlFor="select-all-filters" className="flex-1 cursor-pointer text-xs font-semibold sm:text-sm">
|
||||
전체 선택/해제
|
||||
</Label>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{searchFilterColumns.size} / {allAvailableColumns.length}개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 목록 */}
|
||||
<div className="max-h-[50vh] space-y-2 overflow-y-auto rounded border p-2">
|
||||
{allAvailableColumns.map((col) => (
|
||||
<div key={col} className="hover:bg-muted/50 flex items-center gap-3 rounded p-2">
|
||||
<Checkbox
|
||||
id={`filter-${col}`}
|
||||
checked={searchFilterColumns.has(col)}
|
||||
onCheckedChange={() => toggleFilterColumn(col)}
|
||||
/>
|
||||
<Label htmlFor={`filter-${col}`} className="flex-1 cursor-pointer text-xs font-normal sm:text-sm">
|
||||
{columnLabels[col] || col}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 선택된 컬럼 개수 안내 */}
|
||||
<div className="text-muted-foreground bg-muted/30 rounded p-3 text-center text-xs">
|
||||
{searchFilterColumns.size === 0 ? (
|
||||
<span>검색 필터를 사용하려면 최소 1개 이상의 컬럼을 선택하세요</span>
|
||||
) : (
|
||||
<span>
|
||||
총 <span className="text-primary font-semibold">{searchFilterColumns.size}개</span>의 검색 필터가
|
||||
표시됩니다
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsFilterSettingOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={saveFilterSettings} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ export default function InputWidget({ widget, value, onChange, className }: Inpu
|
|||
onChange={handleChange}
|
||||
required={widget.required}
|
||||
readOnly={widget.readonly}
|
||||
className={cn("h-9 w-full text-sm", widget.readonly && "bg-muted/50 cursor-not-allowed")}
|
||||
className={cn("h-6 w-full text-xs", widget.readonly && "bg-muted/50 cursor-not-allowed")}
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export default function SelectWidget({ widget, value, onChange, options = [], cl
|
|||
</Label>
|
||||
)}
|
||||
<Select value={value} onValueChange={handleChange} disabled={widget.readonly}>
|
||||
<SelectTrigger className="h-9 w-full text-sm">
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectValue placeholder={widget.placeholder || "선택해주세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -20,18 +20,18 @@ function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.V
|
|||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
size = "xs",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
size?: "xs" | "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-48 items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-48 items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-8 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -51,7 +51,7 @@ function SelectContent({
|
|||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal container={document.querySelector('[data-radix-portal]') || document.body}>
|
||||
<SelectPrimitive.Portal container={document.querySelector("[data-radix-portal]") || document.body}>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import React, { createContext, useContext } from "react";
|
||||
|
||||
interface ScreenPreviewContextType {
|
||||
isPreviewMode: boolean; // true: 화면 관리(디자이너), false: 실제 화면
|
||||
}
|
||||
|
||||
const ScreenPreviewContext = createContext<ScreenPreviewContextType>({
|
||||
isPreviewMode: false,
|
||||
});
|
||||
|
||||
export const useScreenPreview = () => {
|
||||
return useContext(ScreenPreviewContext);
|
||||
};
|
||||
|
||||
interface ScreenPreviewProviderProps {
|
||||
isPreviewMode: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ScreenPreviewProvider: React.FC<ScreenPreviewProviderProps> = ({ isPreviewMode, children }) => {
|
||||
return <ScreenPreviewContext.Provider value={{ isPreviewMode }}>{children}</ScreenPreviewContext.Provider>;
|
||||
};
|
||||
|
|
@ -221,6 +221,12 @@ export const useAuth = () => {
|
|||
|
||||
setAuthStatus(finalAuthStatus);
|
||||
|
||||
console.log("✅ 최종 사용자 상태:", {
|
||||
userId: userInfo?.userId,
|
||||
userName: userInfo?.userName,
|
||||
companyCode: userInfo?.companyCode || userInfo?.company_code,
|
||||
});
|
||||
|
||||
// 디버깅용 로그
|
||||
|
||||
// 로그인되지 않은 상태인 경우 토큰 제거 (리다이렉트는 useEffect에서 처리)
|
||||
|
|
@ -240,8 +246,9 @@ export const useAuth = () => {
|
|||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
|
||||
const tempUser = {
|
||||
userId: payload.userId || "unknown",
|
||||
userName: payload.userName || "사용자",
|
||||
userId: payload.userId || payload.id || "unknown",
|
||||
userName: payload.userName || payload.name || "사용자",
|
||||
companyCode: payload.companyCode || payload.company_code || "",
|
||||
isAdmin: payload.userId === "plm_admin" || payload.userType === "ADMIN",
|
||||
};
|
||||
|
||||
|
|
@ -481,6 +488,7 @@ export const useAuth = () => {
|
|||
isAdmin: authStatus.isAdmin,
|
||||
userId: user?.userId,
|
||||
userName: user?.userName,
|
||||
companyCode: user?.companyCode || user?.company_code, // 🆕 회사 코드
|
||||
|
||||
// 함수
|
||||
login,
|
||||
|
|
|
|||
|
|
@ -141,8 +141,18 @@ export const useLogin = () => {
|
|||
// 쿠키에도 저장 (미들웨어에서 사용)
|
||||
document.cookie = `authToken=${result.data.token}; path=/; max-age=86400; SameSite=Lax`;
|
||||
|
||||
// 로그인 성공
|
||||
router.push(AUTH_CONFIG.ROUTES.MAIN);
|
||||
// 로그인 성공 - 첫 번째 접근 가능한 메뉴로 리다이렉트
|
||||
const firstMenuPath = result.data?.firstMenuPath;
|
||||
|
||||
if (firstMenuPath) {
|
||||
// 접근 가능한 메뉴가 있으면 해당 메뉴로 이동
|
||||
console.log("첫 번째 접근 가능한 메뉴로 이동:", firstMenuPath);
|
||||
router.push(firstMenuPath);
|
||||
} else {
|
||||
// 접근 가능한 메뉴가 없으면 메인 페이지로 이동
|
||||
console.log("접근 가능한 메뉴가 없어 메인 페이지로 이동");
|
||||
router.push(AUTH_CONFIG.ROUTES.MAIN);
|
||||
}
|
||||
} else {
|
||||
// 로그인 실패
|
||||
setError(result.message || FORM_VALIDATION.MESSAGES.LOGIN_FAILED);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ async function apiRequest<T>(
|
|||
const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
const config: RequestInit = {
|
||||
credentials: "include", // ⭐ 세션 쿠키 전송 필수
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export interface ExternalApiConnection {
|
|||
connection_name: string;
|
||||
description?: string;
|
||||
base_url: string;
|
||||
endpoint_path?: string;
|
||||
default_headers: Record<string, string>;
|
||||
auth_type: AuthType;
|
||||
auth_config?: {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface ExternalRestApiConnection {
|
|||
connection_name: string;
|
||||
description?: string;
|
||||
base_url: string;
|
||||
endpoint_path?: string;
|
||||
default_headers: Record<string, string>;
|
||||
auth_type: AuthType;
|
||||
auth_config?: {
|
||||
|
|
|
|||
|
|
@ -101,6 +101,8 @@ export interface WeatherAlert {
|
|||
location: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
polygon?: { lat: number; lng: number }[]; // 폴리곤 경계 좌표
|
||||
center?: { lat: number; lng: number }; // 중심점 좌표
|
||||
}
|
||||
|
||||
export async function getWeatherAlerts(): Promise<WeatherAlert[]> {
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ export interface DynamicComponentRendererProps {
|
|||
// 버튼 액션을 위한 추가 props
|
||||
screenId?: number;
|
||||
tableName?: string;
|
||||
userId?: string; // 🆕 현재 사용자 ID
|
||||
userName?: string; // 🆕 현재 사용자 이름
|
||||
companyCode?: string; // 🆕 현재 사용자의 회사 코드
|
||||
onRefresh?: () => void;
|
||||
onClose?: () => void;
|
||||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||
|
|
@ -176,6 +179,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
onRefresh,
|
||||
onClose,
|
||||
screenId,
|
||||
userId, // 🆕 사용자 ID
|
||||
userName, // 🆕 사용자 이름
|
||||
companyCode, // 🆕 회사 코드
|
||||
mode,
|
||||
isInModal,
|
||||
originalData,
|
||||
|
|
@ -196,7 +202,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
autoGeneration,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
|
||||
// DOM 안전한 props만 필터링
|
||||
const safeProps = filterDOMProps(restProps);
|
||||
|
||||
|
|
@ -229,10 +235,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// 렌더러 props 구성
|
||||
// component.style에서 height 제거 (RealtimePreviewDynamic에서 size.height로 처리)
|
||||
const { height: _height, ...styleWithoutHeight } = component.style || {};
|
||||
|
||||
|
||||
// 숨김 값 추출
|
||||
const hiddenValue = component.hidden || component.componentConfig?.hidden;
|
||||
|
||||
|
||||
const rendererProps = {
|
||||
component,
|
||||
isSelected,
|
||||
|
|
@ -257,6 +263,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
onRefresh,
|
||||
onClose,
|
||||
screenId,
|
||||
userId, // 🆕 사용자 ID
|
||||
userName, // 🆕 사용자 이름
|
||||
companyCode, // 🆕 회사 코드
|
||||
mode,
|
||||
isInModal,
|
||||
readonly: component.readonly,
|
||||
|
|
@ -345,6 +354,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
onFormDataChange: props.onFormDataChange,
|
||||
screenId: props.screenId,
|
||||
tableName: props.tableName,
|
||||
userId: props.userId, // 🆕 사용자 ID
|
||||
userName: props.userName, // 🆕 사용자 이름
|
||||
companyCode: props.companyCode, // 🆕 회사 코드
|
||||
onRefresh: props.onRefresh,
|
||||
onClose: props.onClose,
|
||||
mode: props.mode,
|
||||
|
|
|
|||
|
|
@ -22,12 +22,16 @@ import {
|
|||
import { toast } from "sonner";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { useCurrentFlowStep } from "@/stores/flowStepStore";
|
||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
|
||||
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||
config?: ButtonPrimaryConfig;
|
||||
// 추가 props
|
||||
screenId?: number;
|
||||
tableName?: string;
|
||||
userId?: string; // 🆕 현재 사용자 ID
|
||||
userName?: string; // 🆕 현재 사용자 이름
|
||||
companyCode?: string; // 🆕 현재 사용자의 회사 코드
|
||||
onRefresh?: () => void;
|
||||
onClose?: () => void;
|
||||
onFlowRefresh?: () => void;
|
||||
|
|
@ -64,6 +68,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
onFormDataChange,
|
||||
screenId,
|
||||
tableName,
|
||||
userId, // 🆕 사용자 ID
|
||||
userName, // 🆕 사용자 이름
|
||||
companyCode, // 🆕 회사 코드
|
||||
onRefresh,
|
||||
onClose,
|
||||
onFlowRefresh,
|
||||
|
|
@ -73,6 +80,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
flowSelectedStepId,
|
||||
...props
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
|
||||
// 🔍 디버깅: props 확인
|
||||
|
||||
// 🆕 플로우 단계별 표시 제어
|
||||
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig;
|
||||
const currentStep = useCurrentFlowStep(flowConfig?.targetFlowComponentId);
|
||||
|
|
@ -355,6 +366,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// 프리뷰 모드에서는 버튼 동작 차단
|
||||
if (isPreviewMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 디자인 모드에서는 기본 onClick만 실행
|
||||
if (isDesignMode) {
|
||||
onClick?.();
|
||||
|
|
@ -377,6 +393,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가
|
||||
screenId,
|
||||
tableName,
|
||||
userId, // 🆕 사용자 ID
|
||||
userName, // 🆕 사용자 이름
|
||||
companyCode, // 🆕 회사 코드
|
||||
onFormDataChange,
|
||||
onRefresh,
|
||||
onClose,
|
||||
|
|
|
|||
|
|
@ -45,54 +45,18 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
// 🎯 자동생성 상태 관리
|
||||
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string>("");
|
||||
|
||||
// 🚨 컴포넌트 마운트 확인용 로그
|
||||
console.log("🚨 DateInputComponent 마운트됨!", {
|
||||
componentId: component.id,
|
||||
isInteractive,
|
||||
isDesignMode,
|
||||
autoGeneration,
|
||||
componentAutoGeneration: component.autoGeneration,
|
||||
externalValue,
|
||||
formDataValue: formData?.[component.columnName || ""],
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 🧪 무조건 실행되는 테스트
|
||||
useEffect(() => {
|
||||
console.log("🧪 DateInputComponent 무조건 실행 테스트!");
|
||||
const testDate = "2025-01-19"; // 고정된 테스트 날짜
|
||||
setAutoGeneratedValue(testDate);
|
||||
console.log("🧪 autoGeneratedValue 설정 완료:", testDate);
|
||||
}, []); // 빈 의존성 배열로 한 번만 실행
|
||||
|
||||
// 자동생성 설정 (props 우선, 컴포넌트 설정 폴백)
|
||||
const finalAutoGeneration = autoGeneration || component.autoGeneration;
|
||||
const finalHidden = hidden !== undefined ? hidden : component.hidden;
|
||||
|
||||
// 🧪 테스트용 간단한 자동생성 로직
|
||||
// 자동생성 로직
|
||||
useEffect(() => {
|
||||
console.log("🔍 DateInputComponent useEffect 실행:", {
|
||||
componentId: component.id,
|
||||
finalAutoGeneration,
|
||||
enabled: finalAutoGeneration?.enabled,
|
||||
type: finalAutoGeneration?.type,
|
||||
isInteractive,
|
||||
isDesignMode,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
columnName: component.columnName,
|
||||
currentFormValue: formData?.[component.columnName || ""],
|
||||
});
|
||||
|
||||
// 🧪 테스트: 자동생성이 활성화되어 있으면 무조건 현재 날짜 설정
|
||||
if (finalAutoGeneration?.enabled) {
|
||||
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
console.log("🧪 테스트용 날짜 생성:", today);
|
||||
|
||||
setAutoGeneratedValue(today);
|
||||
|
||||
// 인터랙티브 모드에서 폼 데이터에도 설정
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
console.log("📤 테스트용 폼 데이터 업데이트:", component.columnName, today);
|
||||
onFormDataChange(component.columnName, today);
|
||||
}
|
||||
}
|
||||
|
|
@ -167,17 +131,6 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
rawValue = component.value;
|
||||
}
|
||||
|
||||
console.log("🔍 DateInputComponent 값 디버깅:", {
|
||||
componentId: component.id,
|
||||
fieldName,
|
||||
externalValue,
|
||||
formDataValue: formData?.[component.columnName || ""],
|
||||
componentValue: component.value,
|
||||
rawValue,
|
||||
isInteractive,
|
||||
hasFormData: !!formData,
|
||||
});
|
||||
|
||||
// 날짜 형식 변환 함수 (HTML input[type="date"]는 YYYY-MM-DD 형식만 허용)
|
||||
const formatDateForInput = (dateValue: any): string => {
|
||||
if (!dateValue) return "";
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue