Merge pull request 'jskim-node' (#402) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/402
This commit is contained in:
commit
f7bd2f6fa3
|
|
@ -3690,6 +3690,8 @@ export async function copyMenu(
|
||||||
? {
|
? {
|
||||||
removeText: req.body.screenNameConfig.removeText,
|
removeText: req.body.screenNameConfig.removeText,
|
||||||
addPrefix: req.body.screenNameConfig.addPrefix,
|
addPrefix: req.body.screenNameConfig.addPrefix,
|
||||||
|
replaceFrom: req.body.screenNameConfig.replaceFrom,
|
||||||
|
replaceTo: req.body.screenNameConfig.replaceTo,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
// Phase 2-1B: 핵심 인증 API 구현
|
// Phase 2-1B: 핵심 인증 API 구현
|
||||||
|
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { checkAuthStatus } from "../middleware/authMiddleware";
|
|
||||||
import { AuthController } from "../controllers/authController";
|
import { AuthController } from "../controllers/authController";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
@ -12,7 +11,7 @@ const router = Router();
|
||||||
* 인증 상태 확인 API
|
* 인증 상태 확인 API
|
||||||
* 기존 Java ApiLoginController.checkAuthStatus() 포팅
|
* 기존 Java ApiLoginController.checkAuthStatus() 포팅
|
||||||
*/
|
*/
|
||||||
router.get("/status", checkAuthStatus);
|
router.get("/status", AuthController.checkAuthStatus);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/auth/login
|
* POST /api/auth/login
|
||||||
|
|
|
||||||
|
|
@ -373,7 +373,8 @@ export class MenuCopyService {
|
||||||
private async collectScreens(
|
private async collectScreens(
|
||||||
menuObjids: number[],
|
menuObjids: number[],
|
||||||
sourceCompanyCode: string,
|
sourceCompanyCode: string,
|
||||||
client: PoolClient
|
client: PoolClient,
|
||||||
|
menus?: Menu[]
|
||||||
): Promise<Set<number>> {
|
): Promise<Set<number>> {
|
||||||
logger.info(
|
logger.info(
|
||||||
`📄 화면 수집 시작: ${menuObjids.length}개 메뉴, company=${sourceCompanyCode}`
|
`📄 화면 수집 시작: ${menuObjids.length}개 메뉴, company=${sourceCompanyCode}`
|
||||||
|
|
@ -394,9 +395,25 @@ export class MenuCopyService {
|
||||||
screenIds.add(assignment.screen_id);
|
screenIds.add(assignment.screen_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`📌 직접 할당 화면: ${screenIds.size}개`);
|
// 1.5) menu_url에서 참조되는 화면 수집 (/screens/{screenId} 패턴)
|
||||||
|
if (menus) {
|
||||||
|
const screenIdPattern = /\/screens\/(\d+)/;
|
||||||
|
for (const menu of menus) {
|
||||||
|
if (menu.menu_url) {
|
||||||
|
const match = menu.menu_url.match(screenIdPattern);
|
||||||
|
if (match) {
|
||||||
|
const urlScreenId = parseInt(match[1], 10);
|
||||||
|
if (!isNaN(urlScreenId) && urlScreenId > 0) {
|
||||||
|
screenIds.add(urlScreenId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2) 화면 내부에서 참조되는 화면 (재귀)
|
logger.info(`📌 직접 할당 + menu_url 화면: ${screenIds.size}개`);
|
||||||
|
|
||||||
|
// 2) 화면 내부에서 참조되는 화면 (재귀) - V1 + V2 레이아웃 모두 탐색
|
||||||
const queue = Array.from(screenIds);
|
const queue = Array.from(screenIds);
|
||||||
|
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
|
|
@ -405,17 +422,29 @@ export class MenuCopyService {
|
||||||
if (visited.has(screenId)) continue;
|
if (visited.has(screenId)) continue;
|
||||||
visited.add(screenId);
|
visited.add(screenId);
|
||||||
|
|
||||||
// 화면 레이아웃 조회
|
const referencedScreens: number[] = [];
|
||||||
|
|
||||||
|
// V1 레이아웃에서 참조 화면 추출
|
||||||
const layoutsResult = await client.query<ScreenLayout>(
|
const layoutsResult = await client.query<ScreenLayout>(
|
||||||
`SELECT * FROM screen_layouts WHERE screen_id = $1`,
|
`SELECT * FROM screen_layouts WHERE screen_id = $1`,
|
||||||
[screenId]
|
[screenId]
|
||||||
);
|
);
|
||||||
|
referencedScreens.push(
|
||||||
// 참조 화면 추출
|
...this.extractReferencedScreens(layoutsResult.rows)
|
||||||
const referencedScreens = this.extractReferencedScreens(
|
|
||||||
layoutsResult.rows
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// V2 레이아웃에서 참조 화면 추출
|
||||||
|
const layoutsV2Result = await client.query<{ layout_data: any }>(
|
||||||
|
`SELECT layout_data FROM screen_layouts_v2
|
||||||
|
WHERE screen_id = $1 AND company_code = $2`,
|
||||||
|
[screenId, sourceCompanyCode]
|
||||||
|
);
|
||||||
|
for (const row of layoutsV2Result.rows) {
|
||||||
|
if (row.layout_data) {
|
||||||
|
this.extractScreenIdsFromObject(row.layout_data, referencedScreens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (referencedScreens.length > 0) {
|
if (referencedScreens.length > 0) {
|
||||||
logger.info(
|
logger.info(
|
||||||
` 📎 화면 ${screenId}에서 참조 화면 발견: ${referencedScreens.join(", ")}`
|
` 📎 화면 ${screenId}에서 참조 화면 발견: ${referencedScreens.join(", ")}`
|
||||||
|
|
@ -897,6 +926,8 @@ export class MenuCopyService {
|
||||||
screenNameConfig?: {
|
screenNameConfig?: {
|
||||||
removeText?: string;
|
removeText?: string;
|
||||||
addPrefix?: string;
|
addPrefix?: string;
|
||||||
|
replaceFrom?: string;
|
||||||
|
replaceTo?: string;
|
||||||
},
|
},
|
||||||
additionalCopyOptions?: AdditionalCopyOptions
|
additionalCopyOptions?: AdditionalCopyOptions
|
||||||
): Promise<MenuCopyResult> {
|
): Promise<MenuCopyResult> {
|
||||||
|
|
@ -939,7 +970,8 @@ export class MenuCopyService {
|
||||||
const screenIds = await this.collectScreens(
|
const screenIds = await this.collectScreens(
|
||||||
menus.map((m) => m.objid),
|
menus.map((m) => m.objid),
|
||||||
sourceCompanyCode,
|
sourceCompanyCode,
|
||||||
client
|
client,
|
||||||
|
menus
|
||||||
);
|
);
|
||||||
|
|
||||||
const flowIds = await this.collectFlows(screenIds, client);
|
const flowIds = await this.collectFlows(screenIds, client);
|
||||||
|
|
@ -1095,6 +1127,16 @@ export class MenuCopyService {
|
||||||
logger.info("\n🔄 [6.5단계] 메뉴 URL 화면 ID 재매핑");
|
logger.info("\n🔄 [6.5단계] 메뉴 URL 화면 ID 재매핑");
|
||||||
await this.updateMenuUrls(menuIdMap, screenIdMap, client);
|
await this.updateMenuUrls(menuIdMap, screenIdMap, client);
|
||||||
|
|
||||||
|
// === 6.7단계: screen_group_screens 복제 ===
|
||||||
|
logger.info("\n🏷️ [6.7단계] screen_group_screens 복제");
|
||||||
|
await this.copyScreenGroupScreens(
|
||||||
|
screenIds,
|
||||||
|
screenIdMap,
|
||||||
|
sourceCompanyCode,
|
||||||
|
targetCompanyCode,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
|
||||||
// === 7단계: 테이블 타입 설정 복사 ===
|
// === 7단계: 테이블 타입 설정 복사 ===
|
||||||
if (additionalCopyOptions?.copyTableTypeColumns) {
|
if (additionalCopyOptions?.copyTableTypeColumns) {
|
||||||
logger.info("\n📦 [7단계] 테이블 타입 설정 복사");
|
logger.info("\n📦 [7단계] 테이블 타입 설정 복사");
|
||||||
|
|
@ -1419,6 +1461,8 @@ export class MenuCopyService {
|
||||||
screenNameConfig?: {
|
screenNameConfig?: {
|
||||||
removeText?: string;
|
removeText?: string;
|
||||||
addPrefix?: string;
|
addPrefix?: string;
|
||||||
|
replaceFrom?: string;
|
||||||
|
replaceTo?: string;
|
||||||
},
|
},
|
||||||
numberingRuleIdMap?: Map<string, string>,
|
numberingRuleIdMap?: Map<string, string>,
|
||||||
menuIdMap?: Map<number, number>
|
menuIdMap?: Map<number, number>
|
||||||
|
|
@ -1518,6 +1562,13 @@ export class MenuCopyService {
|
||||||
// 3) 화면명 변환 적용
|
// 3) 화면명 변환 적용
|
||||||
let transformedScreenName = screenDef.screen_name;
|
let transformedScreenName = screenDef.screen_name;
|
||||||
if (screenNameConfig) {
|
if (screenNameConfig) {
|
||||||
|
if (screenNameConfig.replaceFrom?.trim()) {
|
||||||
|
transformedScreenName = transformedScreenName.replace(
|
||||||
|
new RegExp(screenNameConfig.replaceFrom.trim(), "g"),
|
||||||
|
screenNameConfig.replaceTo?.trim() || ""
|
||||||
|
);
|
||||||
|
transformedScreenName = transformedScreenName.trim();
|
||||||
|
}
|
||||||
if (screenNameConfig.removeText?.trim()) {
|
if (screenNameConfig.removeText?.trim()) {
|
||||||
transformedScreenName = transformedScreenName.replace(
|
transformedScreenName = transformedScreenName.replace(
|
||||||
new RegExp(screenNameConfig.removeText.trim(), "g"),
|
new RegExp(screenNameConfig.removeText.trim(), "g"),
|
||||||
|
|
@ -2067,6 +2118,26 @@ export class MenuCopyService {
|
||||||
|
|
||||||
logger.info(`📂 메뉴 복사 중: ${menus.length}개`);
|
logger.info(`📂 메뉴 복사 중: ${menus.length}개`);
|
||||||
|
|
||||||
|
// screen_group_id 재매핑 맵 생성 (source company → target company)
|
||||||
|
const screenGroupIdMap = new Map<number, number>();
|
||||||
|
const sourceGroupIds = [...new Set(menus.map(m => m.screen_group_id).filter(Boolean))] as number[];
|
||||||
|
if (sourceGroupIds.length > 0) {
|
||||||
|
const sourceGroups = await client.query<{ id: number; group_name: string }>(
|
||||||
|
`SELECT id, group_name FROM screen_groups WHERE id = ANY($1)`,
|
||||||
|
[sourceGroupIds]
|
||||||
|
);
|
||||||
|
for (const sg of sourceGroups.rows) {
|
||||||
|
const targetGroup = await client.query<{ id: number }>(
|
||||||
|
`SELECT id FROM screen_groups WHERE group_name = $1 AND company_code = $2 LIMIT 1`,
|
||||||
|
[sg.group_name, targetCompanyCode]
|
||||||
|
);
|
||||||
|
if (targetGroup.rows.length > 0) {
|
||||||
|
screenGroupIdMap.set(sg.id, targetGroup.rows[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info(`🏷️ screen_group 매핑: ${screenGroupIdMap.size}/${sourceGroupIds.length}개`);
|
||||||
|
}
|
||||||
|
|
||||||
// 위상 정렬 (부모 먼저 삽입)
|
// 위상 정렬 (부모 먼저 삽입)
|
||||||
const sortedMenus = this.topologicalSortMenus(menus);
|
const sortedMenus = this.topologicalSortMenus(menus);
|
||||||
|
|
||||||
|
|
@ -2202,7 +2273,7 @@ export class MenuCopyService {
|
||||||
menu.menu_url,
|
menu.menu_url,
|
||||||
menu.menu_desc,
|
menu.menu_desc,
|
||||||
userId,
|
userId,
|
||||||
'active',
|
menu.status || 'active',
|
||||||
menu.system_name,
|
menu.system_name,
|
||||||
targetCompanyCode,
|
targetCompanyCode,
|
||||||
menu.lang_key,
|
menu.lang_key,
|
||||||
|
|
@ -2211,7 +2282,7 @@ export class MenuCopyService {
|
||||||
menu.menu_code,
|
menu.menu_code,
|
||||||
sourceMenuObjid,
|
sourceMenuObjid,
|
||||||
menu.menu_icon,
|
menu.menu_icon,
|
||||||
menu.screen_group_id,
|
menu.screen_group_id ? (screenGroupIdMap.get(menu.screen_group_id) || menu.screen_group_id) : null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -2332,8 +2403,9 @@ export class MenuCopyService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메뉴 URL 업데이트 (화면 ID 재매핑)
|
* 메뉴 URL + screen_code 업데이트 (화면 ID 재매핑)
|
||||||
* menu_url에 포함된 /screens/{screenId} 형식의 화면 ID를 복제된 화면 ID로 교체
|
* menu_url에 포함된 /screens/{screenId} 형식의 화면 ID를 복제된 화면 ID로 교체
|
||||||
|
* menu_info.screen_code도 복제된 screen_definitions.screen_code로 교체
|
||||||
*/
|
*/
|
||||||
private async updateMenuUrls(
|
private async updateMenuUrls(
|
||||||
menuIdMap: Map<number, number>,
|
menuIdMap: Map<number, number>,
|
||||||
|
|
@ -2341,56 +2413,197 @@ export class MenuCopyService {
|
||||||
client: PoolClient
|
client: PoolClient
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (menuIdMap.size === 0 || screenIdMap.size === 0) {
|
if (menuIdMap.size === 0 || screenIdMap.size === 0) {
|
||||||
logger.info("📭 메뉴 URL 업데이트 대상 없음");
|
logger.info("📭 메뉴 URL/screen_code 업데이트 대상 없음");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newMenuObjids = Array.from(menuIdMap.values());
|
const newMenuObjids = Array.from(menuIdMap.values());
|
||||||
|
|
||||||
// 복제된 메뉴 중 menu_url이 있는 것 조회
|
// 복제된 메뉴 조회
|
||||||
const menusWithUrl = await client.query<{
|
const menusToUpdate = await client.query<{
|
||||||
objid: number;
|
objid: number;
|
||||||
menu_url: string;
|
menu_url: string | null;
|
||||||
|
screen_code: string | null;
|
||||||
}>(
|
}>(
|
||||||
`SELECT objid, menu_url FROM menu_info
|
`SELECT objid, menu_url, screen_code FROM menu_info
|
||||||
WHERE objid = ANY($1) AND menu_url IS NOT NULL AND menu_url != ''`,
|
WHERE objid = ANY($1)`,
|
||||||
[newMenuObjids]
|
[newMenuObjids]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (menusWithUrl.rows.length === 0) {
|
if (menusToUpdate.rows.length === 0) {
|
||||||
logger.info("📭 menu_url 업데이트 대상 없음");
|
logger.info("📭 메뉴 URL/screen_code 업데이트 대상 없음");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let updatedCount = 0;
|
// screenIdMap의 역방향: 원본 screen_id → 새 screen_id의 screen_code 조회
|
||||||
const screenIdPattern = /\/screens\/(\d+)/;
|
const newScreenIds = Array.from(screenIdMap.values());
|
||||||
|
const screenCodeMap = new Map<string, string>();
|
||||||
for (const menu of menusWithUrl.rows) {
|
if (newScreenIds.length > 0) {
|
||||||
const match = menu.menu_url.match(screenIdPattern);
|
const screenCodesResult = await client.query<{
|
||||||
if (!match) continue;
|
screen_id: number;
|
||||||
|
screen_code: string;
|
||||||
const originalScreenId = parseInt(match[1], 10);
|
source_screen_id: number;
|
||||||
const newScreenId = screenIdMap.get(originalScreenId);
|
}>(
|
||||||
|
`SELECT sd_new.screen_id, sd_new.screen_code, sd_new.source_screen_id
|
||||||
if (newScreenId && newScreenId !== originalScreenId) {
|
FROM screen_definitions sd_new
|
||||||
const newMenuUrl = menu.menu_url.replace(
|
WHERE sd_new.screen_id = ANY($1) AND sd_new.screen_code IS NOT NULL`,
|
||||||
`/screens/${originalScreenId}`,
|
[newScreenIds]
|
||||||
`/screens/${newScreenId}`
|
);
|
||||||
);
|
for (const row of screenCodesResult.rows) {
|
||||||
|
if (row.source_screen_id) {
|
||||||
await client.query(
|
// 원본의 screen_code 조회
|
||||||
`UPDATE menu_info SET menu_url = $1 WHERE objid = $2`,
|
const origResult = await client.query<{ screen_code: string }>(
|
||||||
[newMenuUrl, menu.objid]
|
`SELECT screen_code FROM screen_definitions WHERE screen_id = $1`,
|
||||||
);
|
[row.source_screen_id]
|
||||||
|
);
|
||||||
logger.info(
|
if (origResult.rows[0]?.screen_code) {
|
||||||
` 🔗 메뉴 URL 업데이트: ${menu.menu_url} → ${newMenuUrl}`
|
screenCodeMap.set(origResult.rows[0].screen_code, row.screen_code);
|
||||||
);
|
}
|
||||||
updatedCount++;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`✅ 메뉴 URL 업데이트 완료: ${updatedCount}개`);
|
let updatedUrlCount = 0;
|
||||||
|
let updatedCodeCount = 0;
|
||||||
|
const screenIdPattern = /\/screens\/(\d+)/;
|
||||||
|
|
||||||
|
for (const menu of menusToUpdate.rows) {
|
||||||
|
let newMenuUrl = menu.menu_url;
|
||||||
|
let newScreenCode = menu.screen_code;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
// menu_url 재매핑
|
||||||
|
if (menu.menu_url) {
|
||||||
|
const match = menu.menu_url.match(screenIdPattern);
|
||||||
|
if (match) {
|
||||||
|
const originalScreenId = parseInt(match[1], 10);
|
||||||
|
const newScreenId = screenIdMap.get(originalScreenId);
|
||||||
|
if (newScreenId && newScreenId !== originalScreenId) {
|
||||||
|
newMenuUrl = menu.menu_url.replace(
|
||||||
|
`/screens/${originalScreenId}`,
|
||||||
|
`/screens/${newScreenId}`
|
||||||
|
);
|
||||||
|
changed = true;
|
||||||
|
updatedUrlCount++;
|
||||||
|
logger.info(
|
||||||
|
` 🔗 메뉴 URL 업데이트: ${menu.menu_url} → ${newMenuUrl}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// /screen/{screen_code} 형식도 처리
|
||||||
|
const screenCodeUrlMatch = menu.menu_url.match(/\/screen\/(.+)/);
|
||||||
|
if (screenCodeUrlMatch && !menu.menu_url.match(/\/screens\//)) {
|
||||||
|
const origCode = screenCodeUrlMatch[1];
|
||||||
|
const newCode = screenCodeMap.get(origCode);
|
||||||
|
if (newCode && newCode !== origCode) {
|
||||||
|
newMenuUrl = `/screen/${newCode}`;
|
||||||
|
changed = true;
|
||||||
|
updatedUrlCount++;
|
||||||
|
logger.info(
|
||||||
|
` 🔗 메뉴 URL(코드) 업데이트: ${menu.menu_url} → ${newMenuUrl}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// screen_code 재매핑
|
||||||
|
if (menu.screen_code) {
|
||||||
|
const mappedCode = screenCodeMap.get(menu.screen_code);
|
||||||
|
if (mappedCode && mappedCode !== menu.screen_code) {
|
||||||
|
newScreenCode = mappedCode;
|
||||||
|
changed = true;
|
||||||
|
updatedCodeCount++;
|
||||||
|
logger.info(
|
||||||
|
` 🏷️ screen_code 업데이트: ${menu.screen_code} → ${newScreenCode}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE menu_info SET menu_url = $1, screen_code = $2 WHERE objid = $3`,
|
||||||
|
[newMenuUrl, newScreenCode, menu.objid]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`✅ 메뉴 URL 업데이트: ${updatedUrlCount}개, screen_code 업데이트: ${updatedCodeCount}개`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* screen_group_screens 복제 (화면-스크린그룹 매핑)
|
||||||
|
*/
|
||||||
|
private async copyScreenGroupScreens(
|
||||||
|
screenIds: Set<number>,
|
||||||
|
screenIdMap: Map<number, number>,
|
||||||
|
sourceCompanyCode: string,
|
||||||
|
targetCompanyCode: string,
|
||||||
|
client: PoolClient
|
||||||
|
): Promise<void> {
|
||||||
|
if (screenIds.size === 0 || screenIdMap.size === 0) {
|
||||||
|
logger.info("📭 screen_group_screens 복제 대상 없음");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 COMPANY_10의 screen_group_screens 삭제 (깨진 이전 데이터 정리)
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM screen_group_screens WHERE company_code = $1`,
|
||||||
|
[targetCompanyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 소스 회사의 screen_group_screens 조회
|
||||||
|
const sourceScreenIds = Array.from(screenIds);
|
||||||
|
const sourceResult = await client.query<{
|
||||||
|
group_id: number;
|
||||||
|
screen_id: number;
|
||||||
|
screen_role: string;
|
||||||
|
display_order: number;
|
||||||
|
is_default: string;
|
||||||
|
}>(
|
||||||
|
`SELECT group_id, screen_id, screen_role, display_order, is_default
|
||||||
|
FROM screen_group_screens
|
||||||
|
WHERE company_code = $1 AND screen_id = ANY($2)`,
|
||||||
|
[sourceCompanyCode, sourceScreenIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sourceResult.rows.length === 0) {
|
||||||
|
logger.info("📭 소스에 screen_group_screens 없음");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// screen_group ID 매핑 (source group_name → target group_id)
|
||||||
|
const sourceGroupIds = [...new Set(sourceResult.rows.map(r => r.group_id))];
|
||||||
|
const sourceGroups = await client.query<{ id: number; group_name: string }>(
|
||||||
|
`SELECT id, group_name FROM screen_groups WHERE id = ANY($1)`,
|
||||||
|
[sourceGroupIds]
|
||||||
|
);
|
||||||
|
const groupIdMap = new Map<number, number>();
|
||||||
|
for (const sg of sourceGroups.rows) {
|
||||||
|
const targetGroup = await client.query<{ id: number }>(
|
||||||
|
`SELECT id FROM screen_groups WHERE group_name = $1 AND company_code = $2 LIMIT 1`,
|
||||||
|
[sg.group_name, targetCompanyCode]
|
||||||
|
);
|
||||||
|
if (targetGroup.rows.length > 0) {
|
||||||
|
groupIdMap.set(sg.id, targetGroup.rows[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let insertedCount = 0;
|
||||||
|
for (const row of sourceResult.rows) {
|
||||||
|
const newGroupId = groupIdMap.get(row.group_id);
|
||||||
|
const newScreenId = screenIdMap.get(row.screen_id);
|
||||||
|
if (!newGroupId || !newScreenId) continue;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO screen_group_screens (group_id, screen_id, screen_role, display_order, is_default, company_code, writer)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'system')
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[newGroupId, newScreenId, row.screen_role, row.display_order, row.is_default, targetCompanyCode]
|
||||||
|
);
|
||||||
|
insertedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`✅ screen_group_screens 복제: ${insertedCount}개`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -3482,8 +3482,74 @@ export class ScreenManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`✅ screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`,
|
`✅ V1 screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// V2 레이아웃(screen_layouts_v2)도 동일하게 처리
|
||||||
|
const v2LayoutsResult = await client.query(
|
||||||
|
`SELECT screen_id, layer_id, company_code, layout_data
|
||||||
|
FROM screen_layouts_v2
|
||||||
|
WHERE screen_id IN (${placeholders})
|
||||||
|
AND layout_data::text ~ '"(screenId|targetScreenId|modalScreenId|leftScreenId|rightScreenId|addModalScreenId|editModalScreenId)"'`,
|
||||||
|
targetScreenIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`🔍 V2 참조 업데이트 대상 레이아웃: ${v2LayoutsResult.rows.length}개`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let v2Updated = 0;
|
||||||
|
for (const v2Layout of v2LayoutsResult.rows) {
|
||||||
|
let layoutData = v2Layout.layout_data;
|
||||||
|
if (!layoutData) continue;
|
||||||
|
|
||||||
|
let v2HasChanges = false;
|
||||||
|
|
||||||
|
const updateV2References = (obj: any): void => {
|
||||||
|
if (!obj || typeof obj !== "object") return;
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
for (const item of obj) updateV2References(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
const value = obj[key];
|
||||||
|
if (
|
||||||
|
(key === "screenId" || key === "targetScreenId" || key === "modalScreenId" ||
|
||||||
|
key === "leftScreenId" || key === "rightScreenId" ||
|
||||||
|
key === "addModalScreenId" || key === "editModalScreenId")
|
||||||
|
) {
|
||||||
|
const numVal = typeof value === "number" ? value : parseInt(value);
|
||||||
|
if (!isNaN(numVal) && numVal > 0) {
|
||||||
|
const newId = screenMap.get(numVal);
|
||||||
|
if (newId) {
|
||||||
|
obj[key] = typeof value === "number" ? newId : String(newId);
|
||||||
|
v2HasChanges = true;
|
||||||
|
console.log(`🔗 V2 ${key} 매핑: ${numVal} → ${newId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value === "object" && value !== null) {
|
||||||
|
updateV2References(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateV2References(layoutData);
|
||||||
|
|
||||||
|
if (v2HasChanges) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW()
|
||||||
|
WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`,
|
||||||
|
[JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code],
|
||||||
|
);
|
||||||
|
v2Updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✅ V2 참조 업데이트 완료: ${v2Updated}개 레이아웃`,
|
||||||
|
);
|
||||||
|
result.updated += v2Updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -4610,9 +4676,60 @@ export class ScreenManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`,
|
`✅ V1: ${updateCount}개 레이아웃 업데이트 완료`,
|
||||||
);
|
);
|
||||||
return updateCount;
|
|
||||||
|
// V2 레이아웃(screen_layouts_v2)에서도 targetScreenId 등 재매핑
|
||||||
|
const v2Layouts = await query<any>(
|
||||||
|
`SELECT screen_id, layer_id, company_code, layout_data
|
||||||
|
FROM screen_layouts_v2
|
||||||
|
WHERE screen_id = $1
|
||||||
|
AND layout_data IS NOT NULL`,
|
||||||
|
[screenId],
|
||||||
|
);
|
||||||
|
|
||||||
|
let v2UpdateCount = 0;
|
||||||
|
for (const v2Layout of v2Layouts) {
|
||||||
|
const layoutData = v2Layout.layout_data;
|
||||||
|
if (!layoutData?.components) continue;
|
||||||
|
|
||||||
|
let v2Changed = false;
|
||||||
|
const updateV2Refs = (obj: any): void => {
|
||||||
|
if (!obj || typeof obj !== "object") return;
|
||||||
|
if (Array.isArray(obj)) { for (const item of obj) updateV2Refs(item); return; }
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
const value = obj[key];
|
||||||
|
if (
|
||||||
|
(key === "targetScreenId" || key === "screenId" || key === "modalScreenId" ||
|
||||||
|
key === "leftScreenId" || key === "rightScreenId" ||
|
||||||
|
key === "addModalScreenId" || key === "editModalScreenId")
|
||||||
|
) {
|
||||||
|
const numVal = typeof value === "number" ? value : parseInt(value);
|
||||||
|
if (!isNaN(numVal) && screenIdMapping.has(numVal)) {
|
||||||
|
obj[key] = typeof value === "number" ? screenIdMapping.get(numVal)! : screenIdMapping.get(numVal)!.toString();
|
||||||
|
v2Changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value === "object" && value !== null) updateV2Refs(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
updateV2Refs(layoutData);
|
||||||
|
|
||||||
|
if (v2Changed) {
|
||||||
|
await query(
|
||||||
|
`UPDATE screen_layouts_v2 SET layout_data = $1, updated_at = NOW()
|
||||||
|
WHERE screen_id = $2 AND layer_id = $3 AND company_code = $4`,
|
||||||
|
[JSON.stringify(layoutData), v2Layout.screen_id, v2Layout.layer_id, v2Layout.company_code],
|
||||||
|
);
|
||||||
|
v2UpdateCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = updateCount + v2UpdateCount;
|
||||||
|
console.log(
|
||||||
|
`✅ 총 ${total}개 레이아웃 업데이트 완료 (V1: ${updateCount}, V2: ${v2UpdateCount})`,
|
||||||
|
);
|
||||||
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
- DATABASE_URL=postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor
|
||||||
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
||||||
- JWT_EXPIRES_IN=24h
|
- JWT_EXPIRES_IN=24h
|
||||||
- CORS_ORIGIN=http://localhost:9771
|
- CORS_ORIGIN=http://localhost:9771
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export interface ActionTabProps {
|
||||||
|
config: any;
|
||||||
|
onChange: (key: string, value: any) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동작 탭: 클릭 이벤트, 네비게이션, 모달 열기, 확인 다이얼로그 등 동작 설정
|
||||||
|
* 실제 UI는 메인 ButtonConfigPanel에서 렌더링 후 children으로 전달
|
||||||
|
*/
|
||||||
|
export const ActionTab: React.FC<ActionTabProps> = ({ children }) => {
|
||||||
|
return <div className="space-y-4">{children}</div>;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export interface BasicTabProps {
|
||||||
|
config: any;
|
||||||
|
onChange: (key: string, value: any) => void;
|
||||||
|
localText?: string;
|
||||||
|
onTextChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BasicTab: React.FC<BasicTabProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
localText,
|
||||||
|
onTextChange,
|
||||||
|
}) => {
|
||||||
|
const text = localText !== undefined ? localText : (config.text !== undefined ? config.text : "버튼");
|
||||||
|
|
||||||
|
const handleChange = (newValue: string) => {
|
||||||
|
onTextChange?.(newValue);
|
||||||
|
onChange("componentConfig.text", newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="button-text">버튼 텍스트</Label>
|
||||||
|
<Input
|
||||||
|
id="button-text"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
placeholder="버튼 텍스트를 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,872 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { QuickInsertConfigSection } from "../QuickInsertConfigSection";
|
||||||
|
import { ComponentData } from "@/types/screen";
|
||||||
|
|
||||||
|
export interface DataTabProps {
|
||||||
|
config: any;
|
||||||
|
onChange: (key: string, value: any) => void;
|
||||||
|
component: ComponentData;
|
||||||
|
allComponents: ComponentData[];
|
||||||
|
currentTableName?: string;
|
||||||
|
availableTables: Array<{ name: string; label: string }>;
|
||||||
|
mappingTargetColumns: Array<{ name: string; label: string }>;
|
||||||
|
mappingSourceColumnsMap: Record<string, Array<{ name: string; label: string }>>;
|
||||||
|
currentTableColumns: Array<{ name: string; label: string }>;
|
||||||
|
mappingSourcePopoverOpen: Record<string, boolean>;
|
||||||
|
setMappingSourcePopoverOpen: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||||
|
mappingTargetPopoverOpen: Record<string, boolean>;
|
||||||
|
setMappingTargetPopoverOpen: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||||
|
activeMappingGroupIndex: number;
|
||||||
|
setActiveMappingGroupIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
loadMappingColumns: (tableName: string) => Promise<Array<{ name: string; label: string }>>;
|
||||||
|
setMappingSourceColumnsMap: React.Dispatch<
|
||||||
|
React.SetStateAction<Record<string, Array<{ name: string; label: string }>>>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DataTab: React.FC<DataTabProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
component,
|
||||||
|
allComponents,
|
||||||
|
currentTableName,
|
||||||
|
availableTables,
|
||||||
|
mappingTargetColumns,
|
||||||
|
mappingSourceColumnsMap,
|
||||||
|
currentTableColumns,
|
||||||
|
mappingSourcePopoverOpen,
|
||||||
|
setMappingSourcePopoverOpen,
|
||||||
|
mappingTargetPopoverOpen,
|
||||||
|
setMappingTargetPopoverOpen,
|
||||||
|
activeMappingGroupIndex,
|
||||||
|
setActiveMappingGroupIndex,
|
||||||
|
loadMappingColumns,
|
||||||
|
setMappingSourceColumnsMap,
|
||||||
|
}) => {
|
||||||
|
const actionType = config.action?.type;
|
||||||
|
const onUpdateProperty = (path: string, value: any) => onChange(path, value);
|
||||||
|
|
||||||
|
if (actionType === "quickInsert") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<QuickInsertConfigSection
|
||||||
|
component={component}
|
||||||
|
onUpdateProperty={onUpdateProperty}
|
||||||
|
allComponents={allComponents}
|
||||||
|
currentTableName={currentTableName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionType !== "transferData") {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
|
데이터 전달 또는 즉시 저장 액션을 선택하면 설정할 수 있습니다.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-muted/50 space-y-4 rounded-lg border p-4">
|
||||||
|
<h4 className="text-foreground text-sm font-medium">데이터 전달 설정</h4>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>
|
||||||
|
소스 컴포넌트 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.dataTransfer?.sourceComponentId || ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.sourceComponentId", value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__auto__">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium">자동 탐색 (현재 활성 테이블)</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">(auto)</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
{allComponents
|
||||||
|
.filter((comp: any) => {
|
||||||
|
const type = comp.componentType || comp.type || "";
|
||||||
|
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
||||||
|
type.includes(t),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((comp: any) => {
|
||||||
|
const compType = comp.componentType || comp.type || "unknown";
|
||||||
|
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||||||
|
const layerName = comp._layerName;
|
||||||
|
return (
|
||||||
|
<SelectItem key={comp.id} value={comp.id}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium">{compLabel}</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||||||
|
{layerName && (
|
||||||
|
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||||
|
{layerName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{allComponents.filter((comp: any) => {
|
||||||
|
const type = comp.componentType || comp.type || "";
|
||||||
|
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
||||||
|
type.includes(t),
|
||||||
|
);
|
||||||
|
}).length === 0 && (
|
||||||
|
<SelectItem value="__none__" disabled>
|
||||||
|
데이터 제공 가능한 컴포넌트가 없습니다
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
레이어별로 다른 테이블이 있을 경우 "자동 탐색"을 선택하면 현재 활성화된 레이어의 테이블을 자동으로 사용합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="target-type">
|
||||||
|
타겟 타입 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.dataTransfer?.targetType || "component"}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.targetType", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="component">같은 화면의 컴포넌트</SelectItem>
|
||||||
|
<SelectItem value="splitPanel">분할 패널 반대편 화면</SelectItem>
|
||||||
|
<SelectItem value="screen" disabled>
|
||||||
|
다른 화면 (구현 예정)
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{config.action?.dataTransfer?.targetType === "splitPanel" && (
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
|
이 버튼이 분할 패널 내부에 있어야 합니다. 좌측 화면에서 우측으로, 또는 우측에서 좌측으로 데이터가 전달됩니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.action?.dataTransfer?.targetType === "component" && (
|
||||||
|
<div>
|
||||||
|
<Label>
|
||||||
|
타겟 컴포넌트 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.dataTransfer?.targetComponentId || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value);
|
||||||
|
const selectedComp = allComponents.find((c: any) => c.id === value);
|
||||||
|
if (selectedComp && (selectedComp as any)._layerId) {
|
||||||
|
onUpdateProperty(
|
||||||
|
"componentConfig.action.dataTransfer.targetLayerId",
|
||||||
|
(selectedComp as any)._layerId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.targetLayerId", undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{allComponents
|
||||||
|
.filter((comp: any) => {
|
||||||
|
const type = comp.componentType || comp.type || "";
|
||||||
|
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||||
|
(t) => type.includes(t),
|
||||||
|
);
|
||||||
|
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||||||
|
})
|
||||||
|
.map((comp: any) => {
|
||||||
|
const compType = comp.componentType || comp.type || "unknown";
|
||||||
|
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||||||
|
const layerName = comp._layerName;
|
||||||
|
return (
|
||||||
|
<SelectItem key={comp.id} value={comp.id}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium">{compLabel}</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||||||
|
{layerName && (
|
||||||
|
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||||
|
{layerName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{allComponents.filter((comp: any) => {
|
||||||
|
const type = comp.componentType || comp.type || "";
|
||||||
|
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||||
|
(t) => type.includes(t),
|
||||||
|
);
|
||||||
|
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||||||
|
}).length === 0 && (
|
||||||
|
<SelectItem value="__none__" disabled>
|
||||||
|
데이터 수신 가능한 컴포넌트가 없습니다
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">테이블, 반복 필드 그룹 등 데이터를 받는 컴포넌트</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.action?.dataTransfer?.targetType === "splitPanel" && (
|
||||||
|
<div>
|
||||||
|
<Label>타겟 컴포넌트 ID (선택사항)</Label>
|
||||||
|
<Input
|
||||||
|
value={config.action?.dataTransfer?.targetComponentId || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="비워두면 첫 번째 수신 가능 컴포넌트로 전달"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
반대편 화면의 특정 컴포넌트 ID를 지정하거나, 비워두면 자동으로 첫 번째 수신 가능 컴포넌트로 전달됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="transfer-mode">데이터 전달 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.dataTransfer?.mode || "append"}
|
||||||
|
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.mode", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="append">추가 (Append)</SelectItem>
|
||||||
|
<SelectItem value="replace">교체 (Replace)</SelectItem>
|
||||||
|
<SelectItem value="merge">병합 (Merge)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">기존 데이터를 어떻게 처리할지 선택</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="clear-after-transfer">전달 후 소스 선택 초기화</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">데이터 전달 후 소스의 선택을 해제합니다</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="clear-after-transfer"
|
||||||
|
checked={config.action?.dataTransfer?.clearAfterTransfer === true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="confirm-before-transfer">전달 전 확인 메시지</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">데이터 전달 전 확인 다이얼로그를 표시합니다</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="confirm-before-transfer"
|
||||||
|
checked={config.action?.dataTransfer?.confirmBeforeTransfer === true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.action?.dataTransfer?.confirmBeforeTransfer && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="confirm-message">확인 메시지</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm-message"
|
||||||
|
placeholder="선택한 항목을 전달하시겠습니까?"
|
||||||
|
value={config.action?.dataTransfer?.confirmMessage || ""}
|
||||||
|
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.confirmMessage", e.target.value)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>검증 설정</Label>
|
||||||
|
<div className="space-y-2 rounded-md border p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="min-selection" className="text-xs">
|
||||||
|
최소 선택 개수
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="min-selection"
|
||||||
|
type="number"
|
||||||
|
placeholder="0"
|
||||||
|
value={config.action?.dataTransfer?.validation?.minSelection || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateProperty(
|
||||||
|
"componentConfig.action.dataTransfer.validation.minSelection",
|
||||||
|
parseInt(e.target.value) || 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-8 w-20 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="max-selection" className="text-xs">
|
||||||
|
최대 선택 개수
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="max-selection"
|
||||||
|
type="number"
|
||||||
|
placeholder="제한없음"
|
||||||
|
value={config.action?.dataTransfer?.validation?.maxSelection || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateProperty(
|
||||||
|
"componentConfig.action.dataTransfer.validation.maxSelection",
|
||||||
|
parseInt(e.target.value) || undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-8 w-20 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>추가 데이터 소스 (선택사항)</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
조건부 컨테이너의 카테고리 값 등 추가 데이터를 함께 전달할 수 있습니다
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 rounded-md border p-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">추가 컴포넌트</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.dataTransfer?.additionalSources?.[0]?.componentId || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||||
|
const newSources = [...currentSources];
|
||||||
|
if (newSources.length === 0) {
|
||||||
|
newSources.push({ componentId: value, fieldName: "" });
|
||||||
|
} else {
|
||||||
|
newSources[0] = { ...newSources[0], componentId: value };
|
||||||
|
}
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="추가 데이터 컴포넌트 선택 (선택사항)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__clear__">
|
||||||
|
<span className="text-muted-foreground">선택 안 함</span>
|
||||||
|
</SelectItem>
|
||||||
|
{allComponents
|
||||||
|
.filter((comp: any) => {
|
||||||
|
const type = comp.componentType || comp.type || "";
|
||||||
|
return ["conditional-container", "select-basic", "select", "combobox"].some((t) =>
|
||||||
|
type.includes(t),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((comp: any) => {
|
||||||
|
const compType = comp.componentType || comp.type || "unknown";
|
||||||
|
const compLabel = comp.label || comp.componentConfig?.controlLabel || comp.id;
|
||||||
|
return (
|
||||||
|
<SelectItem key={comp.id} value={comp.id}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium">{compLabel}</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">({compType})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
조건부 컨테이너, 셀렉트박스 등 (카테고리 값 전달용)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="additional-field-name" className="text-xs">
|
||||||
|
타겟 필드명 (선택사항)
|
||||||
|
</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||||
|
{(() => {
|
||||||
|
const fieldName = config.action?.dataTransfer?.additionalSources?.[0]?.fieldName;
|
||||||
|
if (!fieldName) return "필드 선택 (비워두면 전체 데이터)";
|
||||||
|
const cols = mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns;
|
||||||
|
const found = cols.find((c) => c.name === fieldName);
|
||||||
|
return found ? `${found.label || found.name}` : fieldName;
|
||||||
|
})()}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[240px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
value="__none__"
|
||||||
|
onSelect={() => {
|
||||||
|
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||||
|
const newSources = [...currentSources];
|
||||||
|
if (newSources.length === 0) {
|
||||||
|
newSources.push({ componentId: "", fieldName: "" });
|
||||||
|
} else {
|
||||||
|
newSources[0] = { ...newSources[0], fieldName: "" };
|
||||||
|
}
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
!config.action?.dataTransfer?.additionalSources?.[0]?.fieldName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">선택 안 함 (전체 데이터 병합)</span>
|
||||||
|
</CommandItem>
|
||||||
|
{(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.name}
|
||||||
|
value={`${col.label || ""} ${col.name}`}
|
||||||
|
onSelect={() => {
|
||||||
|
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||||
|
const newSources = [...currentSources];
|
||||||
|
if (newSources.length === 0) {
|
||||||
|
newSources.push({ componentId: "", fieldName: col.name });
|
||||||
|
} else {
|
||||||
|
newSources[0] = { ...newSources[0], fieldName: col.name };
|
||||||
|
}
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
config.action?.dataTransfer?.additionalSources?.[0]?.fieldName === col.name
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{col.label || col.name}</span>
|
||||||
|
{col.label && col.label !== col.name && (
|
||||||
|
<span className="text-muted-foreground ml-1 text-[10px]">({col.name})</span>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">추가 데이터가 저장될 타겟 테이블 컬럼</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>필드 매핑 설정</Label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">타겟 테이블</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||||
|
{config.action?.dataTransfer?.targetTable
|
||||||
|
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
|
||||||
|
config.action?.dataTransfer?.targetTable
|
||||||
|
: "타겟 테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[250px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{availableTables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.name}
|
||||||
|
value={`${table.label} ${table.name}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{table.label}</span>
|
||||||
|
<span className="text-muted-foreground ml-1">({table.name})</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">소스 테이블별 매핑</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-[10px]"
|
||||||
|
onClick={() => {
|
||||||
|
const currentMappings = config.action?.dataTransfer?.multiTableMappings || [];
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", [
|
||||||
|
...currentMappings,
|
||||||
|
{ sourceTable: "", mappingRules: [] },
|
||||||
|
]);
|
||||||
|
setActiveMappingGroupIndex(currentMappings.length);
|
||||||
|
}}
|
||||||
|
disabled={!config.action?.dataTransfer?.targetTable}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
소스 테이블 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
여러 소스 테이블에서 데이터를 전달할 때, 각 테이블별로 매핑 규칙을 설정합니다. 런타임에 소스 테이블을 자동 감지합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!config.action?.dataTransfer?.targetTable ? (
|
||||||
|
<div className="rounded-md border border-dashed p-3 text-center">
|
||||||
|
<p className="text-muted-foreground text-xs">먼저 타겟 테이블을 선택하세요.</p>
|
||||||
|
</div>
|
||||||
|
) : !(config.action?.dataTransfer?.multiTableMappings || []).length ? (
|
||||||
|
<div className="rounded-md border border-dashed p-3 text-center">
|
||||||
|
<p className="text-muted-foreground text-xs">매핑 그룹이 없습니다. 소스 테이블을 추가하세요.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => (
|
||||||
|
<div key={gIdx} className="flex items-center gap-0.5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={activeMappingGroupIndex === gIdx ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-[10px]"
|
||||||
|
onClick={() => setActiveMappingGroupIndex(gIdx)}
|
||||||
|
>
|
||||||
|
{group.sourceTable
|
||||||
|
? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable
|
||||||
|
: `그룹 ${gIdx + 1}`}
|
||||||
|
{group.mappingRules?.length > 0 && (
|
||||||
|
<span className="bg-primary/20 ml-1 rounded-full px-1 text-[9px]">
|
||||||
|
{group.mappingRules.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:bg-destructive/10 h-5 w-5"
|
||||||
|
onClick={() => {
|
||||||
|
const mappings = [...(config.action?.dataTransfer?.multiTableMappings || [])];
|
||||||
|
mappings.splice(gIdx, 1);
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
|
||||||
|
if (activeMappingGroupIndex >= mappings.length) {
|
||||||
|
setActiveMappingGroupIndex(Math.max(0, mappings.length - 1));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const multiMappings = config.action?.dataTransfer?.multiTableMappings || [];
|
||||||
|
const activeGroup = multiMappings[activeMappingGroupIndex];
|
||||||
|
if (!activeGroup) return null;
|
||||||
|
|
||||||
|
const activeSourceTable = activeGroup.sourceTable || "";
|
||||||
|
const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || [];
|
||||||
|
const activeRules: any[] = activeGroup.mappingRules || [];
|
||||||
|
|
||||||
|
const updateGroupField = (field: string, value: any) => {
|
||||||
|
const mappings = [...multiMappings];
|
||||||
|
mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value };
|
||||||
|
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded-md border p-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">소스 테이블</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
|
||||||
|
{activeSourceTable
|
||||||
|
? availableTables.find((t) => t.name === activeSourceTable)?.label || activeSourceTable
|
||||||
|
: "소스 테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[250px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{availableTables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.name}
|
||||||
|
value={`${table.label} ${table.name}`}
|
||||||
|
onSelect={async () => {
|
||||||
|
updateGroupField("sourceTable", table.name);
|
||||||
|
if (!mappingSourceColumnsMap[table.name]) {
|
||||||
|
const cols = await loadMappingColumns(table.name);
|
||||||
|
setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
activeSourceTable === table.name ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{table.label}</span>
|
||||||
|
<span className="text-muted-foreground ml-1">({table.name})</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-[10px]">매핑 규칙</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 text-[10px]"
|
||||||
|
onClick={() => {
|
||||||
|
updateGroupField("mappingRules", [...activeRules, { sourceField: "", targetField: "" }]);
|
||||||
|
}}
|
||||||
|
disabled={!activeSourceTable}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!activeSourceTable ? (
|
||||||
|
<p className="text-muted-foreground text-[10px]">소스 테이블을 먼저 선택하세요.</p>
|
||||||
|
) : activeRules.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-[10px]">매핑 없음 (동일 필드명 자동 매핑)</p>
|
||||||
|
) : (
|
||||||
|
activeRules.map((rule: any, rIdx: number) => {
|
||||||
|
const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`;
|
||||||
|
const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`;
|
||||||
|
return (
|
||||||
|
<div key={rIdx} className="bg-background flex items-center gap-2 rounded-md border p-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Popover
|
||||||
|
open={mappingSourcePopoverOpen[popoverKeyS] || false}
|
||||||
|
onOpenChange={(open) =>
|
||||||
|
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="h-7 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{rule.sourceField
|
||||||
|
? activeSourceColumns.find((c) => c.name === rule.sourceField)?.label ||
|
||||||
|
rule.sourceField
|
||||||
|
: "소스 필드"}
|
||||||
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">컬럼 없음</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{activeSourceColumns.map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.name}
|
||||||
|
value={`${col.label} ${col.name}`}
|
||||||
|
onSelect={() => {
|
||||||
|
const newRules = [...activeRules];
|
||||||
|
newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name };
|
||||||
|
updateGroupField("mappingRules", newRules);
|
||||||
|
setMappingSourcePopoverOpen((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[popoverKeyS]: false,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
rule.sourceField === col.name ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>{col.label}</span>
|
||||||
|
{col.label !== col.name && (
|
||||||
|
<span className="text-muted-foreground ml-1">({col.name})</span>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-muted-foreground text-xs">→</span>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<Popover
|
||||||
|
open={mappingTargetPopoverOpen[popoverKeyT] || false}
|
||||||
|
onOpenChange={(open) =>
|
||||||
|
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="h-7 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{rule.targetField
|
||||||
|
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label ||
|
||||||
|
rule.targetField
|
||||||
|
: "타겟 필드"}
|
||||||
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">컬럼 없음</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{mappingTargetColumns.map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.name}
|
||||||
|
value={`${col.label} ${col.name}`}
|
||||||
|
onSelect={() => {
|
||||||
|
const newRules = [...activeRules];
|
||||||
|
newRules[rIdx] = { ...newRules[rIdx], targetField: col.name };
|
||||||
|
updateGroupField("mappingRules", newRules);
|
||||||
|
setMappingTargetPopoverOpen((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[popoverKeyT]: false,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
rule.targetField === col.name ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>{col.label}</span>
|
||||||
|
{col.label !== col.name && (
|
||||||
|
<span className="text-muted-foreground ml-1">({col.name})</span>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:bg-destructive/10 h-7 w-7"
|
||||||
|
onClick={() => {
|
||||||
|
const newRules = [...activeRules];
|
||||||
|
newRules.splice(rIdx, 1);
|
||||||
|
updateGroupField("mappingRules", newRules);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||||
|
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||||
|
<strong>사용 방법:</strong>
|
||||||
|
<br />
|
||||||
|
1. 소스 컴포넌트에서 데이터를 선택합니다
|
||||||
|
<br />
|
||||||
|
2. 소스 테이블별로 필드 매핑 규칙을 설정합니다
|
||||||
|
<br />
|
||||||
|
3. 이 버튼을 클릭하면 소스 테이블을 자동 감지하여 매핑된 데이터가 타겟으로 전달됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -161,13 +161,14 @@ export const useAuth = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const token = TokenManager.getToken();
|
const token = TokenManager.getToken();
|
||||||
if (!token || TokenManager.isTokenExpired(token)) {
|
if (!token) {
|
||||||
AuthLogger.log("AUTH_CHECK_FAIL", `refreshUserData: 토큰 ${!token ? "없음" : "만료됨"}`);
|
AuthLogger.log("AUTH_CHECK_FAIL", "refreshUserData: 토큰 없음");
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 만료된 토큰이라도 apiClient 요청 인터셉터가 자동 갱신하므로 여기서 차단하지 않음
|
||||||
|
|
||||||
AuthLogger.log("AUTH_CHECK_START", "refreshUserData: API로 인증 상태 확인 시작");
|
AuthLogger.log("AUTH_CHECK_START", "refreshUserData: API로 인증 상태 확인 시작");
|
||||||
|
|
||||||
|
|
@ -177,6 +178,10 @@ export const useAuth = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// /auth/me 성공 = 인증 확인 완료. /auth/status는 보조 정보(isAdmin)만 참조
|
||||||
|
// 두 API를 Promise.all로 호출 시, 토큰 만료 타이밍에 따라
|
||||||
|
// /auth/me는 401→갱신→성공, /auth/status는 200 isAuthenticated:false를 반환하는
|
||||||
|
// 레이스 컨디션이 발생할 수 있으므로, isLoggedIn 판단은 /auth/me 성공 여부로 결정
|
||||||
const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]);
|
const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]);
|
||||||
|
|
||||||
if (userInfo) {
|
if (userInfo) {
|
||||||
|
|
@ -184,19 +189,12 @@ export const useAuth = () => {
|
||||||
|
|
||||||
const isAdminFromUser = userInfo.userId === "plm_admin" || userInfo.userType === "ADMIN";
|
const isAdminFromUser = userInfo.userId === "plm_admin" || userInfo.userType === "ADMIN";
|
||||||
const finalAuthStatus = {
|
const finalAuthStatus = {
|
||||||
isLoggedIn: authStatusData.isLoggedIn,
|
isLoggedIn: true,
|
||||||
isAdmin: authStatusData.isAdmin || isAdminFromUser,
|
isAdmin: authStatusData.isAdmin || isAdminFromUser,
|
||||||
};
|
};
|
||||||
|
|
||||||
setAuthStatus(finalAuthStatus);
|
setAuthStatus(finalAuthStatus);
|
||||||
AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.userId}, 인증: ${finalAuthStatus.isLoggedIn}`);
|
AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.userId}, 인증: ${finalAuthStatus.isLoggedIn}`);
|
||||||
|
|
||||||
if (!finalAuthStatus.isLoggedIn) {
|
|
||||||
AuthLogger.log("AUTH_CHECK_FAIL", "API 응답에서 비인증 상태 반환 → 토큰 제거");
|
|
||||||
TokenManager.removeToken();
|
|
||||||
setUser(null);
|
|
||||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 토큰 기반 임시 인증 유지 시도");
|
AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 토큰 기반 임시 인증 유지 시도");
|
||||||
try {
|
try {
|
||||||
|
|
@ -412,18 +410,19 @@ export const useAuth = () => {
|
||||||
|
|
||||||
const token = TokenManager.getToken();
|
const token = TokenManager.getToken();
|
||||||
|
|
||||||
if (token && !TokenManager.isTokenExpired(token)) {
|
if (token) {
|
||||||
AuthLogger.log("AUTH_CHECK_START", `초기 인증 확인: 유효한 토큰 존재 (경로: ${window.location.pathname})`);
|
// 유효/만료 모두 refreshUserData로 처리
|
||||||
|
// apiClient 요청 인터셉터가 만료 토큰을 자동 갱신하므로 여기서 삭제하지 않음
|
||||||
|
const isExpired = TokenManager.isTokenExpired(token);
|
||||||
|
AuthLogger.log(
|
||||||
|
"AUTH_CHECK_START",
|
||||||
|
`초기 인증 확인: 토큰 ${isExpired ? "만료됨 → 갱신 시도" : "유효"} (경로: ${window.location.pathname})`,
|
||||||
|
);
|
||||||
setAuthStatus({
|
setAuthStatus({
|
||||||
isLoggedIn: true,
|
isLoggedIn: true,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
});
|
});
|
||||||
refreshUserData();
|
refreshUserData();
|
||||||
} else if (token && TokenManager.isTokenExpired(token)) {
|
|
||||||
AuthLogger.log("TOKEN_EXPIRED_DETECTED", `초기 확인 시 만료된 토큰 발견 → 정리 (경로: ${window.location.pathname})`);
|
|
||||||
TokenManager.removeToken();
|
|
||||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
|
||||||
setLoading(false);
|
|
||||||
} else {
|
} else {
|
||||||
AuthLogger.log("AUTH_CHECK_FAIL", `초기 확인: 토큰 없음 (경로: ${window.location.pathname})`);
|
AuthLogger.log("AUTH_CHECK_FAIL", `초기 확인: 토큰 없음 (경로: ${window.location.pathname})`);
|
||||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||||
|
|
|
||||||
|
|
@ -329,6 +329,11 @@ apiClient.interceptors.request.use(
|
||||||
const newToken = await refreshToken();
|
const newToken = await refreshToken();
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
config.headers.Authorization = `Bearer ${newToken}`;
|
config.headers.Authorization = `Bearer ${newToken}`;
|
||||||
|
} else {
|
||||||
|
// 갱신 실패 시 인증 없는 요청을 보내면 TOKEN_MISSING 401 → 즉시 redirectToLogin 연쇄 장애
|
||||||
|
// 요청 자체를 차단하여 호출부의 try/catch에서 처리하도록 함
|
||||||
|
authLog("TOKEN_REFRESH_FAIL", `요청 인터셉터에서 갱신 실패 → 요청 차단 (${config.url})`);
|
||||||
|
return Promise.reject(new Error("TOKEN_REFRESH_FAILED"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,264 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
import { ConfigFieldDefinition, ConfigOption } from "./ConfigPanelTypes";
|
||||||
|
|
||||||
|
interface ConfigFieldProps<T = any> {
|
||||||
|
field: ConfigFieldDefinition<T>;
|
||||||
|
value: any;
|
||||||
|
onChange: (key: string, value: any) => void;
|
||||||
|
tableColumns?: ConfigOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigField<T>({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
tableColumns,
|
||||||
|
}: ConfigFieldProps<T>) {
|
||||||
|
const handleChange = (newValue: any) => {
|
||||||
|
onChange(field.key, newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderField = () => {
|
||||||
|
switch (field.type) {
|
||||||
|
case "text":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "number":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange(
|
||||||
|
e.target.value === "" ? undefined : Number(e.target.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
step={field.step}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "switch":
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
checked={!!value}
|
||||||
|
onCheckedChange={handleChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "select":
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value ?? ""}
|
||||||
|
onValueChange={handleChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder={field.placeholder || "선택"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(field.options || []).map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "textarea":
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="text-xs"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "color":
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={value ?? "#000000"}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
className="h-8 w-8 cursor-pointer rounded border"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
placeholder="#000000"
|
||||||
|
className="h-8 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "slider":
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={value ?? field.min ?? 0}
|
||||||
|
onChange={(e) => handleChange(Number(e.target.value))}
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
step={field.step}
|
||||||
|
className="h-8 w-20 text-xs"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-[10px]">
|
||||||
|
{field.min ?? 0} ~ {field.max ?? 100}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "multi-select":
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(field.options || []).map((opt) => {
|
||||||
|
const selected = Array.isArray(value) && value.includes(opt.value);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={opt.value}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 hover:bg-muted"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={() => {
|
||||||
|
const current = Array.isArray(value) ? [...value] : [];
|
||||||
|
if (selected) {
|
||||||
|
handleChange(current.filter((v: string) => v !== opt.value));
|
||||||
|
} else {
|
||||||
|
handleChange([...current, opt.value]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-xs">{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "key-value": {
|
||||||
|
const entries: Array<[string, string]> = Object.entries(
|
||||||
|
(value as Record<string, string>) || {},
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{entries.map(([k, v], idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
value={k}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newObj = { ...(value || {}) };
|
||||||
|
delete newObj[k];
|
||||||
|
newObj[e.target.value] = v;
|
||||||
|
handleChange(newObj);
|
||||||
|
}}
|
||||||
|
placeholder="키"
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={v}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleChange({ ...(value || {}), [k]: e.target.value });
|
||||||
|
}}
|
||||||
|
placeholder="값"
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => {
|
||||||
|
const newObj = { ...(value || {}) };
|
||||||
|
delete newObj[k];
|
||||||
|
handleChange(newObj);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-full text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
handleChange({ ...(value || {}), "": "" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "column-picker": {
|
||||||
|
const options = tableColumns || field.options || [];
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value ?? ""}
|
||||||
|
onValueChange={handleChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder={field.placeholder || "컬럼 선택"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-medium">{field.label}</Label>
|
||||||
|
{field.type === "switch" && renderField()}
|
||||||
|
</div>
|
||||||
|
{field.description && (
|
||||||
|
<p className="text-muted-foreground text-[10px]">{field.description}</p>
|
||||||
|
)}
|
||||||
|
{field.type !== "switch" && renderField()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ConfigPanelBuilderProps } from "./ConfigPanelTypes";
|
||||||
|
import { ConfigSection } from "./ConfigSection";
|
||||||
|
import { ConfigField } from "./ConfigField";
|
||||||
|
|
||||||
|
export function ConfigPanelBuilder<T extends Record<string, any>>({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
sections,
|
||||||
|
presets,
|
||||||
|
tableColumns,
|
||||||
|
children,
|
||||||
|
}: ConfigPanelBuilderProps<T>) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 프리셋 버튼 */}
|
||||||
|
{presets && presets.length > 0 && (
|
||||||
|
<div className="border-b pb-3">
|
||||||
|
<h4 className="mb-2 text-xs font-medium text-muted-foreground">
|
||||||
|
빠른 설정
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{presets.map((preset, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => {
|
||||||
|
Object.entries(preset.values).forEach(([key, value]) => {
|
||||||
|
onChange(key, value);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="rounded-full bg-muted px-2.5 py-1 text-[10px] font-medium text-muted-foreground transition-colors hover:bg-primary hover:text-primary-foreground"
|
||||||
|
title={preset.description}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 섹션 렌더링 */}
|
||||||
|
{sections.map((section) => {
|
||||||
|
if (section.condition && !section.condition(config)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleFields = section.fields.filter(
|
||||||
|
(field) => !field.condition || field.condition(config),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (visibleFields.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigSection key={section.id} section={section}>
|
||||||
|
{visibleFields.map((field) => (
|
||||||
|
<ConfigField
|
||||||
|
key={field.key}
|
||||||
|
field={field}
|
||||||
|
value={(config as any)[field.key]}
|
||||||
|
onChange={onChange}
|
||||||
|
tableColumns={tableColumns}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ConfigSection>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 커스텀 children */}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export type ConfigFieldType =
|
||||||
|
| "text"
|
||||||
|
| "number"
|
||||||
|
| "switch"
|
||||||
|
| "select"
|
||||||
|
| "textarea"
|
||||||
|
| "color"
|
||||||
|
| "slider"
|
||||||
|
| "multi-select"
|
||||||
|
| "key-value"
|
||||||
|
| "column-picker";
|
||||||
|
|
||||||
|
export interface ConfigOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigFieldDefinition<T = any> {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: ConfigFieldType;
|
||||||
|
description?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
options?: ConfigOption[];
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
condition?: (config: T) => boolean;
|
||||||
|
disabled?: (config: T) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigSectionDefinition<T = any> {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
collapsible?: boolean;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
fields: ConfigFieldDefinition<T>[];
|
||||||
|
condition?: (config: T) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigPanelBuilderProps<T = any> {
|
||||||
|
config: T;
|
||||||
|
onChange: (key: string, value: any) => void;
|
||||||
|
sections: ConfigSectionDefinition<T>[];
|
||||||
|
presets?: Array<{
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
values: Partial<T>;
|
||||||
|
}>;
|
||||||
|
tableColumns?: ConfigOption[];
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
import { ConfigSectionDefinition } from "./ConfigPanelTypes";
|
||||||
|
|
||||||
|
interface ConfigSectionProps {
|
||||||
|
section: ConfigSectionDefinition;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigSection({ section, children }: ConfigSectionProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(section.defaultOpen ?? true);
|
||||||
|
|
||||||
|
if (section.collapsible) {
|
||||||
|
return (
|
||||||
|
<div className="border-b pb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="flex w-full items-center gap-1.5 py-1 text-left"
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium">{section.title}</span>
|
||||||
|
{section.description && (
|
||||||
|
<span className="text-muted-foreground ml-auto text-[10px]">
|
||||||
|
{section.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{isOpen && <div className="mt-2 space-y-3">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b pb-3">
|
||||||
|
<div className="mb-2">
|
||||||
|
<h4 className="text-sm font-medium">{section.title}</h4>
|
||||||
|
{section.description && (
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
{section.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { SplitPanelLayoutConfig } from "../types";
|
||||||
|
|
||||||
|
export interface CommonConfigTabProps {
|
||||||
|
config: SplitPanelLayoutConfig;
|
||||||
|
onChange?: (key: string, value: any) => void;
|
||||||
|
updateConfig: (updates: Partial<SplitPanelLayoutConfig>) => void;
|
||||||
|
updateRightPanel: (updates: Partial<SplitPanelLayoutConfig["rightPanel"]>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CommonConfigTab: React.FC<CommonConfigTabProps> = ({
|
||||||
|
config,
|
||||||
|
updateConfig,
|
||||||
|
updateRightPanel,
|
||||||
|
}) => {
|
||||||
|
const relationshipType = config.rightPanel?.relation?.type || "detail";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 관계 타입 선택 */}
|
||||||
|
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||||
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">우측 패널 표시 방식</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">좌측 항목 선택 시 우측에 어떤 형태로 데이터를 보여줄지 설정합니다</p>
|
||||||
|
<Select
|
||||||
|
value={relationshipType}
|
||||||
|
onValueChange={(value: "join" | "detail") => {
|
||||||
|
updateRightPanel({
|
||||||
|
relation: { ...config.rightPanel?.relation, type: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-10 bg-white">
|
||||||
|
<SelectValue placeholder="표시 방식 선택">
|
||||||
|
{relationshipType === "detail" ? "선택 시 표시" : "연관 목록"}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="detail">
|
||||||
|
<div className="flex flex-col py-1">
|
||||||
|
<span className="text-sm font-medium">선택 시 표시</span>
|
||||||
|
<span className="text-xs text-gray-500">좌측 선택 시에만 우측 데이터 표시 / 미선택 시 빈 화면</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="join">
|
||||||
|
<div className="flex flex-col py-1">
|
||||||
|
<span className="text-sm font-medium">연관 목록</span>
|
||||||
|
<span className="text-xs text-gray-500">미선택 시 전체 표시 / 좌측 선택 시 필터링</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 레이아웃 설정 */}
|
||||||
|
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||||
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">레이아웃</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>좌측 패널 너비: {config.splitRatio || 30}%</Label>
|
||||||
|
<Slider
|
||||||
|
value={[config.splitRatio || 30]}
|
||||||
|
onValueChange={(value) => updateConfig({ splitRatio: value[0] })}
|
||||||
|
min={20}
|
||||||
|
max={80}
|
||||||
|
step={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>크기 조절 가능</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.resizable ?? true}
|
||||||
|
onCheckedChange={(checked) => updateConfig({ resizable: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>자동 데이터 로드</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.autoLoad ?? true}
|
||||||
|
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,606 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Check, ChevronsUpDown, ChevronRight, Database, Link2, Move, Trash2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { SplitPanelLayoutConfig } from "../types";
|
||||||
|
import { PanelInlineComponent } from "../types";
|
||||||
|
import { ColumnInfo } from "@/types/screen";
|
||||||
|
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||||
|
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||||
|
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||||
|
import { SortableColumnRow, ScreenSelector } from "./SharedComponents";
|
||||||
|
|
||||||
|
export interface LeftPanelConfigTabProps {
|
||||||
|
config: SplitPanelLayoutConfig;
|
||||||
|
onChange: (config: SplitPanelLayoutConfig) => void;
|
||||||
|
updateLeftPanel: (updates: Partial<SplitPanelLayoutConfig["leftPanel"]>) => void;
|
||||||
|
screenTableName?: string;
|
||||||
|
allTables: any[];
|
||||||
|
leftTableOpen: boolean;
|
||||||
|
setLeftTableOpen: (open: boolean) => void;
|
||||||
|
localTitles: { left: string; right: string };
|
||||||
|
setLocalTitles: (fn: (prev: { left: string; right: string }) => { left: string; right: string }) => void;
|
||||||
|
isUserEditing: boolean;
|
||||||
|
setIsUserEditing: (v: boolean) => void;
|
||||||
|
leftTableColumns: ColumnInfo[];
|
||||||
|
entityJoinColumns: Record<string, {
|
||||||
|
availableColumns: Array<{ tableName: string; columnName: string; columnLabel: string; dataType: string; joinAlias: string; suggestedLabel: string }>;
|
||||||
|
joinTables: Array<{ tableName: string; currentDisplayColumn: string; joinConfig?: any; availableColumns: Array<{ columnName: string; columnLabel: string; dataType: string; inputType?: string; description?: string }> }>;
|
||||||
|
}>;
|
||||||
|
menuObjid?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LeftPanelConfigTab: React.FC<LeftPanelConfigTabProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
updateLeftPanel,
|
||||||
|
screenTableName,
|
||||||
|
allTables,
|
||||||
|
leftTableOpen,
|
||||||
|
setLeftTableOpen,
|
||||||
|
localTitles,
|
||||||
|
setLocalTitles,
|
||||||
|
setIsUserEditing,
|
||||||
|
leftTableColumns,
|
||||||
|
entityJoinColumns,
|
||||||
|
menuObjid,
|
||||||
|
}) => {
|
||||||
|
const dbNumericTypes = ["numeric", "decimal", "integer", "bigint", "double precision", "real", "smallint", "int4", "int8", "float4", "float8"];
|
||||||
|
const inputNumericTypes = ["number", "decimal", "currency", "integer"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||||
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">좌측 패널 설정 (마스터)</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">표시 테이블</Label>
|
||||||
|
<Popover open={leftTableOpen} onOpenChange={setLeftTableOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={leftTableOpen}
|
||||||
|
className="h-9 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 truncate">
|
||||||
|
<Database className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">
|
||||||
|
{config.leftPanel?.tableName
|
||||||
|
? allTables.find((t) => (t.tableName || t.table_name) === config.leftPanel?.tableName)?.tableLabel ||
|
||||||
|
allTables.find((t) => (t.tableName || t.table_name) === config.leftPanel?.tableName)?.displayName ||
|
||||||
|
config.leftPanel?.tableName
|
||||||
|
: "테이블을 선택하세요"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[300px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-3 text-center text-xs text-muted-foreground">
|
||||||
|
테이블을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
{screenTableName && (
|
||||||
|
<CommandGroup heading="기본 (화면 테이블)">
|
||||||
|
<CommandItem
|
||||||
|
value={screenTableName}
|
||||||
|
onSelect={() => {
|
||||||
|
updateLeftPanel({ tableName: screenTableName, columns: [] });
|
||||||
|
setLeftTableOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
config.leftPanel?.tableName === screenTableName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Database className="mr-2 h-3.5 w-3.5 text-blue-500" />
|
||||||
|
{allTables.find((t) => (t.tableName || t.table_name) === screenTableName)?.tableLabel ||
|
||||||
|
allTables.find((t) => (t.tableName || t.table_name) === screenTableName)?.displayName ||
|
||||||
|
screenTableName}
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
<CommandGroup heading="전체 테이블">
|
||||||
|
{allTables
|
||||||
|
.filter((t) => (t.tableName || t.table_name) !== screenTableName)
|
||||||
|
.map((table) => {
|
||||||
|
const tableName = table.tableName || table.table_name;
|
||||||
|
const displayName = table.tableLabel || table.displayName || tableName;
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={tableName}
|
||||||
|
value={tableName}
|
||||||
|
onSelect={() => {
|
||||||
|
updateLeftPanel({ tableName, columns: [] });
|
||||||
|
setLeftTableOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
config.leftPanel?.tableName === tableName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Database className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="truncate">{displayName}</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{config.leftPanel?.tableName && config.leftPanel?.tableName !== screenTableName && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
화면 기본 테이블이 아닌 다른 테이블의 데이터를 표시합니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>패널 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={localTitles.left}
|
||||||
|
onChange={(e) => {
|
||||||
|
setIsUserEditing(true);
|
||||||
|
setLocalTitles((prev) => ({ ...prev, left: e.target.value }));
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setIsUserEditing(false);
|
||||||
|
updateLeftPanel({ title: localTitles.left });
|
||||||
|
}}
|
||||||
|
placeholder="좌측 패널 제목"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>표시 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.leftPanel?.displayMode || "list"}
|
||||||
|
onValueChange={(value: "list" | "table" | "custom") => updateLeftPanel({ displayMode: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-10 bg-white">
|
||||||
|
<SelectValue placeholder="표시 모드 선택">
|
||||||
|
{(config.leftPanel?.displayMode || "list") === "list" ? "목록 (LIST)" : (config.leftPanel?.displayMode || "list") === "table" ? "테이블 (TABLE)" : "커스텀 (CUSTOM)"}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="list">
|
||||||
|
<div className="flex flex-col py-1">
|
||||||
|
<span className="text-sm font-medium">목록 (LIST)</span>
|
||||||
|
<span className="text-xs text-gray-500">클릭 가능한 항목 목록 (기본)</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="table">
|
||||||
|
<div className="flex flex-col py-1">
|
||||||
|
<span className="text-sm font-medium">테이블 (TABLE)</span>
|
||||||
|
<span className="text-xs text-gray-500">컬럼 헤더가 있는 테이블 형식</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="custom">
|
||||||
|
<div className="flex flex-col py-1">
|
||||||
|
<span className="text-sm font-medium">커스텀 (CUSTOM)</span>
|
||||||
|
<span className="text-xs text-gray-500">패널 안에 컴포넌트 자유 배치</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{config.leftPanel?.displayMode === "custom" && (
|
||||||
|
<p className="text-xs text-amber-600">
|
||||||
|
화면 디자이너에서 좌측 패널에 컴포넌트를 드래그하여 배치하세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.leftPanel?.displayMode === "custom" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">배치된 컴포넌트</Label>
|
||||||
|
{!config.leftPanel?.components || config.leftPanel.components.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
|
||||||
|
<Move className="mx-auto mb-2 h-6 w-6 text-muted-foreground" />
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
디자인 화면에서 컴포넌트를 드래그하여 추가하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{config.leftPanel.components.map((comp: PanelInlineComponent) => (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
className="flex items-center justify-between rounded-md border bg-background p-2"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs font-medium">{comp.label || comp.componentType}</p>
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
{comp.componentType} | 위치: ({comp.position?.x || 0}, {comp.position?.y || 0}) | 크기: {comp.size?.width || 0}x{comp.size?.height || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const updatedComponents = (config.leftPanel?.components || []).filter(
|
||||||
|
(c: PanelInlineComponent) => c.id !== comp.id
|
||||||
|
);
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
leftPanel: {
|
||||||
|
...config.leftPanel,
|
||||||
|
components: updatedComponents,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.leftPanel?.displayMode !== "custom" && (() => {
|
||||||
|
const selectedColumns = config.leftPanel?.columns || [];
|
||||||
|
const filteredTableColumns = leftTableColumns.filter((c) => !["company_code", "company_name"].includes(c.columnName));
|
||||||
|
const unselectedColumns = filteredTableColumns.filter((c) => !selectedColumns.some((sc) => sc.name === c.columnName));
|
||||||
|
|
||||||
|
const handleLeftDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
const oldIndex = selectedColumns.findIndex((c) => c.name === active.id);
|
||||||
|
const newIndex = selectedColumns.findIndex((c) => c.name === over.id);
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
updateLeftPanel({ columns: arrayMove([...selectedColumns], oldIndex, newIndex) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">표시할 컬럼 ({selectedColumns.length}개 선택)</Label>
|
||||||
|
<div className="max-h-[400px] overflow-y-auto rounded-md border p-2">
|
||||||
|
{leftTableColumns.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground py-2 text-center text-xs">컬럼 로딩 중...</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{selectedColumns.length > 0 && (
|
||||||
|
<DndContext collisionDetection={closestCenter} onDragEnd={handleLeftDragEnd}>
|
||||||
|
<SortableContext items={selectedColumns.map((c) => c.name)} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{selectedColumns.map((col, index) => {
|
||||||
|
const colInfo = leftTableColumns.find((c) => c.columnName === col.name);
|
||||||
|
const isNumeric = colInfo && (
|
||||||
|
dbNumericTypes.includes(colInfo.dataType?.toLowerCase() || "") ||
|
||||||
|
inputNumericTypes.includes(colInfo.input_type?.toLowerCase() || "") ||
|
||||||
|
inputNumericTypes.includes(colInfo.webType?.toLowerCase() || "")
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<SortableColumnRow
|
||||||
|
key={col.name}
|
||||||
|
id={col.name}
|
||||||
|
col={col}
|
||||||
|
index={index}
|
||||||
|
isNumeric={!!isNumeric}
|
||||||
|
isEntityJoin={!!(col as any).isEntityJoin}
|
||||||
|
onLabelChange={(value) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = { ...newColumns[index], label: value };
|
||||||
|
updateLeftPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
onWidthChange={(value) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = { ...newColumns[index], width: value };
|
||||||
|
updateLeftPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
onFormatChange={(checked) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
|
||||||
|
updateLeftPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
onRemove={() => updateLeftPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedColumns.length > 0 && unselectedColumns.length > 0 && (
|
||||||
|
<div className="border-border/60 my-2 flex items-center gap-2 border-t pt-2">
|
||||||
|
<span className="text-muted-foreground text-[10px]">미선택 컬럼</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{unselectedColumns.map((column) => (
|
||||||
|
<div
|
||||||
|
key={column.columnName}
|
||||||
|
className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1.5"
|
||||||
|
onClick={() => {
|
||||||
|
updateLeftPanel({ columns: [...selectedColumns, { name: column.columnName, label: column.columnLabel || column.columnName, width: 100 }] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="text-muted-foreground truncate text-xs">{column.columnLabel || column.columnName}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const leftTable = config.leftPanel?.tableName || screenTableName;
|
||||||
|
const joinData = leftTable ? entityJoinColumns[leftTable] : null;
|
||||||
|
if (!joinData || joinData.joinTables.length === 0) return null;
|
||||||
|
|
||||||
|
return joinData.joinTables.map((joinTable, tableIndex) => {
|
||||||
|
const joinColumnsToShow = joinTable.availableColumns.filter((column) => {
|
||||||
|
const matchingJoinColumn = joinData.availableColumns.find(
|
||||||
|
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
||||||
|
);
|
||||||
|
if (!matchingJoinColumn) return false;
|
||||||
|
return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias);
|
||||||
|
});
|
||||||
|
const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length;
|
||||||
|
|
||||||
|
if (joinColumnsToShow.length === 0 && addedCount === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details key={`join-${tableIndex}`} className="group">
|
||||||
|
<summary className="border-border/60 my-2 flex cursor-pointer list-none items-center gap-2 border-t pt-2 select-none">
|
||||||
|
<ChevronRight className="h-3 w-3 shrink-0 text-blue-500 transition-transform group-open:rotate-90" />
|
||||||
|
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||||
|
<span className="text-[10px] font-medium text-blue-600">{joinTable.tableName}</span>
|
||||||
|
{addedCount > 0 && (
|
||||||
|
<span className="rounded-full bg-blue-100 px-1.5 text-[9px] font-medium text-blue-600">{addedCount}개 선택</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[9px] text-gray-400">{joinColumnsToShow.length}개 남음</span>
|
||||||
|
</summary>
|
||||||
|
<div className="space-y-0.5 pt-1">
|
||||||
|
{joinColumnsToShow.map((column, colIndex) => {
|
||||||
|
const matchingJoinColumn = joinData.availableColumns.find(
|
||||||
|
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
||||||
|
);
|
||||||
|
if (!matchingJoinColumn) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={colIndex}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-blue-50/60"
|
||||||
|
onClick={() => {
|
||||||
|
updateLeftPanel({
|
||||||
|
columns: [...selectedColumns, {
|
||||||
|
name: matchingJoinColumn.joinAlias,
|
||||||
|
label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel,
|
||||||
|
width: 100,
|
||||||
|
isEntityJoin: true,
|
||||||
|
joinInfo: {
|
||||||
|
sourceTable: leftTable!,
|
||||||
|
sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "",
|
||||||
|
referenceTable: matchingJoinColumn.tableName,
|
||||||
|
joinAlias: matchingJoinColumn.joinAlias,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
|
||||||
|
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||||
|
<span className="truncate text-xs text-blue-700">{column.columnLabel || column.columnName}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{joinColumnsToShow.length === 0 && (
|
||||||
|
<p className="px-2 py-1 text-[10px] text-gray-400">모든 컬럼이 이미 추가되었습니다</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||||
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">좌측 패널 데이터 필터링</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">특정 컬럼 값으로 좌측 패널 데이터를 필터링합니다</p>
|
||||||
|
<DataFilterConfigPanel
|
||||||
|
tableName={config.leftPanel?.tableName || screenTableName}
|
||||||
|
columns={leftTableColumns.map(
|
||||||
|
(col) =>
|
||||||
|
({
|
||||||
|
columnName: col.columnName,
|
||||||
|
columnLabel: col.columnLabel || col.columnName,
|
||||||
|
dataType: col.dataType || "text",
|
||||||
|
input_type: (col as any).input_type,
|
||||||
|
}) as any,
|
||||||
|
)}
|
||||||
|
config={config.leftPanel?.dataFilter}
|
||||||
|
onConfigChange={(dataFilter) => updateLeftPanel({ dataFilter })}
|
||||||
|
menuObjid={menuObjid}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||||
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">좌측 패널 버튼 설정</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
id="left-showSearch"
|
||||||
|
checked={config.leftPanel?.showSearch ?? false}
|
||||||
|
onCheckedChange={(checked) => updateLeftPanel({ showSearch: !!checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor="left-showSearch" className="text-xs">검색</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
id="left-showAdd"
|
||||||
|
checked={config.leftPanel?.showAdd ?? false}
|
||||||
|
onCheckedChange={(checked) => updateLeftPanel({ showAdd: !!checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor="left-showAdd" className="text-xs">추가</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
id="left-showEdit"
|
||||||
|
checked={config.leftPanel?.showEdit ?? true}
|
||||||
|
onCheckedChange={(checked) => updateLeftPanel({ showEdit: !!checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor="left-showEdit" className="text-xs">수정</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
id="left-showDelete"
|
||||||
|
checked={config.leftPanel?.showDelete ?? true}
|
||||||
|
onCheckedChange={(checked) => updateLeftPanel({ showDelete: !!checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor="left-showDelete" className="text-xs">삭제</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.leftPanel?.showAdd && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
|
||||||
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold">추가 버튼 설정</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">추가 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.leftPanel?.addButton?.mode || "auto"}
|
||||||
|
onValueChange={(value: "auto" | "modal") =>
|
||||||
|
updateLeftPanel({
|
||||||
|
addButton: { ...config.leftPanel?.addButton, enabled: true, mode: value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">자동 (내장 폼)</SelectItem>
|
||||||
|
<SelectItem value="modal">모달 화면</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.leftPanel?.addButton?.mode === "modal" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">추가 모달 화면</Label>
|
||||||
|
<ScreenSelector
|
||||||
|
value={config.leftPanel?.addButton?.modalScreenId}
|
||||||
|
onChange={(screenId) =>
|
||||||
|
updateLeftPanel({
|
||||||
|
addButton: { ...config.leftPanel?.addButton, enabled: true, mode: "modal", modalScreenId: screenId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">버튼 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={config.leftPanel?.addButton?.buttonLabel || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLeftPanel({
|
||||||
|
addButton: {
|
||||||
|
...config.leftPanel?.addButton,
|
||||||
|
enabled: true,
|
||||||
|
mode: config.leftPanel?.addButton?.mode || "auto",
|
||||||
|
buttonLabel: e.target.value || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="추가"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(config.leftPanel?.showEdit ?? true) && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
|
||||||
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold">수정 버튼 설정</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">수정 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.leftPanel?.editButton?.mode || "auto"}
|
||||||
|
onValueChange={(value: "auto" | "modal") =>
|
||||||
|
updateLeftPanel({
|
||||||
|
editButton: { ...config.leftPanel?.editButton, enabled: true, mode: value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">자동 (인라인)</SelectItem>
|
||||||
|
<SelectItem value="modal">모달 화면</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.leftPanel?.editButton?.mode === "modal" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">수정 모달 화면</Label>
|
||||||
|
<ScreenSelector
|
||||||
|
value={config.leftPanel?.editButton?.modalScreenId}
|
||||||
|
onChange={(screenId) =>
|
||||||
|
updateLeftPanel({
|
||||||
|
editButton: { ...config.leftPanel?.editButton, enabled: true, mode: "modal", modalScreenId: screenId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">버튼 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={config.leftPanel?.editButton?.buttonLabel || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLeftPanel({
|
||||||
|
editButton: {
|
||||||
|
...config.leftPanel?.editButton,
|
||||||
|
enabled: true,
|
||||||
|
mode: config.leftPanel?.editButton?.mode || "auto",
|
||||||
|
buttonLabel: e.target.value || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="수정"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,801 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Check, ChevronsUpDown, ChevronRight, Link2, Move, Trash2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { SplitPanelLayoutConfig } from "../types";
|
||||||
|
import { PanelInlineComponent } from "../types";
|
||||||
|
import { ColumnInfo, TableInfo } from "@/types/screen";
|
||||||
|
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||||
|
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||||
|
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||||
|
import { SortableColumnRow, ScreenSelector, GroupByColumnsSelector } from "./SharedComponents";
|
||||||
|
|
||||||
|
export interface RightPanelConfigTabProps {
|
||||||
|
config: SplitPanelLayoutConfig;
|
||||||
|
onChange: (config: SplitPanelLayoutConfig) => void;
|
||||||
|
updateRightPanel: (updates: Partial<SplitPanelLayoutConfig["rightPanel"]>) => void;
|
||||||
|
relationshipType: "join" | "detail";
|
||||||
|
localTitles: { left: string; right: string };
|
||||||
|
setLocalTitles: (fn: (prev: { left: string; right: string }) => { left: string; right: string }) => void;
|
||||||
|
setIsUserEditing: (v: boolean) => void;
|
||||||
|
rightTableOpen: boolean;
|
||||||
|
setRightTableOpen: (open: boolean) => void;
|
||||||
|
availableRightTables: TableInfo[];
|
||||||
|
rightTableColumns: ColumnInfo[];
|
||||||
|
entityJoinColumns: Record<string, {
|
||||||
|
availableColumns: Array<{ tableName: string; columnName: string; columnLabel: string; dataType: string; joinAlias: string; suggestedLabel: string }>;
|
||||||
|
joinTables: Array<{ tableName: string; currentDisplayColumn: string; joinConfig?: any; availableColumns: Array<{ columnName: string; columnLabel: string; dataType: string; inputType?: string; description?: string }> }>;
|
||||||
|
}>;
|
||||||
|
menuObjid?: number;
|
||||||
|
renderAdditionalTabs?: () => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RightPanelConfigTab: React.FC<RightPanelConfigTabProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
updateRightPanel,
|
||||||
|
relationshipType,
|
||||||
|
localTitles,
|
||||||
|
setLocalTitles,
|
||||||
|
setIsUserEditing,
|
||||||
|
rightTableOpen,
|
||||||
|
setRightTableOpen,
|
||||||
|
availableRightTables,
|
||||||
|
rightTableColumns,
|
||||||
|
entityJoinColumns,
|
||||||
|
menuObjid,
|
||||||
|
renderAdditionalTabs,
|
||||||
|
}) => {
|
||||||
|
const dbNumericTypes = ["numeric", "decimal", "integer", "bigint", "double precision", "real", "smallint", "int4", "int8", "float4", "float8"];
|
||||||
|
const inputNumericTypes = ["number", "decimal", "currency", "integer"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||||
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">우측 패널 설정 ({relationshipType === "detail" ? "선택 시 표시" : "연관 목록"})</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>패널 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={localTitles.right}
|
||||||
|
onChange={(e) => {
|
||||||
|
setIsUserEditing(true);
|
||||||
|
setLocalTitles((prev) => ({ ...prev, right: e.target.value }));
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setIsUserEditing(false);
|
||||||
|
updateRightPanel({ title: localTitles.right });
|
||||||
|
}}
|
||||||
|
placeholder="우측 패널 제목"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>우측 패널 테이블</Label>
|
||||||
|
<Popover open={rightTableOpen} onOpenChange={setRightTableOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={rightTableOpen}
|
||||||
|
className="w-full justify-between"
|
||||||
|
>
|
||||||
|
{config.rightPanel?.tableName || "테이블을 선택하세요"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." />
|
||||||
|
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{availableRightTables.map((table) => {
|
||||||
|
const tableName = (table as any).tableName || (table as any).table_name;
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={tableName}
|
||||||
|
value={`${(table as any).displayName || ""} ${tableName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
updateRightPanel({ tableName });
|
||||||
|
setRightTableOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
config.rightPanel?.tableName === tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{(table as any).displayName || tableName}
|
||||||
|
{(table as any).displayName && <span className="ml-2 text-xs text-gray-500">({tableName})</span>}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>표시 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.rightPanel?.displayMode || "list"}
|
||||||
|
onValueChange={(value: "list" | "table" | "custom") => updateRightPanel({ displayMode: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-10 bg-white">
|
||||||
|
<SelectValue placeholder="표시 모드 선택">
|
||||||
|
{(config.rightPanel?.displayMode || "list") === "list" ? "목록 (LIST)" : (config.rightPanel?.displayMode || "list") === "table" ? "테이블 (TABLE)" : "커스텀 (CUSTOM)"}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="list">
|
||||||
|
<div className="flex flex-col py-1">
|
||||||
|
<span className="text-sm font-medium">목록 (LIST)</span>
|
||||||
|
<span className="text-xs text-gray-500">클릭 가능한 항목 목록 (기본)</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="table">
|
||||||
|
<div className="flex flex-col py-1">
|
||||||
|
<span className="text-sm font-medium">테이블 (TABLE)</span>
|
||||||
|
<span className="text-xs text-gray-500">컬럼 헤더가 있는 테이블 형식</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="custom">
|
||||||
|
<div className="flex flex-col py-1">
|
||||||
|
<span className="text-sm font-medium">커스텀 (CUSTOM)</span>
|
||||||
|
<span className="text-xs text-gray-500">패널 안에 컴포넌트 자유 배치</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{config.rightPanel?.displayMode === "custom" && (
|
||||||
|
<p className="text-xs text-amber-600">
|
||||||
|
화면 디자이너에서 우측 패널에 컴포넌트를 드래그하여 배치하세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.rightPanel?.displayMode === "custom" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">배치된 컴포넌트</Label>
|
||||||
|
{!config.rightPanel?.components || config.rightPanel.components.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
|
||||||
|
<Move className="mx-auto mb-2 h-6 w-6 text-muted-foreground" />
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
디자인 화면에서 컴포넌트를 드래그하여 추가하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{config.rightPanel.components.map((comp: PanelInlineComponent) => (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
className="flex items-center justify-between rounded-md border bg-background p-2"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs font-medium">{comp.label || comp.componentType}</p>
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
{comp.componentType} | 위치: ({comp.position?.x || 0}, {comp.position?.y || 0}) | 크기: {comp.size?.width || 0}x{comp.size?.height || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const updatedComponents = (config.rightPanel?.components || []).filter(
|
||||||
|
(c: PanelInlineComponent) => c.id !== comp.id
|
||||||
|
);
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
rightPanel: {
|
||||||
|
...config.rightPanel,
|
||||||
|
components: updatedComponents,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(config.rightPanel?.displayMode || "list") === "list" && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||||
|
<Label className="text-sm font-semibold">요약 표시 설정</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">표시할 컬럼 개수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={config.rightPanel?.summaryColumnCount ?? 3}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = parseInt(e.target.value) || 3;
|
||||||
|
updateRightPanel({ summaryColumnCount: value });
|
||||||
|
}}
|
||||||
|
className="bg-white"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">접기 전에 표시할 컬럼 개수 (기본: 3개)</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between space-x-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-xs">라벨 표시</Label>
|
||||||
|
<p className="text-xs text-gray-500">컬럼명 표시 여부</p>
|
||||||
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
checked={config.rightPanel?.summaryShowLabel ?? true}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
updateRightPanel({ summaryShowLabel: checked as boolean });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.rightPanel?.displayMode !== "custom" && (() => {
|
||||||
|
const selectedColumns = config.rightPanel?.columns || [];
|
||||||
|
const filteredTableColumns = rightTableColumns.filter((c) => !["company_code", "company_name"].includes(c.columnName));
|
||||||
|
const unselectedColumns = filteredTableColumns.filter((c) => !selectedColumns.some((sc) => sc.name === c.columnName));
|
||||||
|
|
||||||
|
const handleRightDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
const oldIndex = selectedColumns.findIndex((c) => c.name === active.id);
|
||||||
|
const newIndex = selectedColumns.findIndex((c) => c.name === over.id);
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
updateRightPanel({ columns: arrayMove([...selectedColumns], oldIndex, newIndex) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">표시할 컬럼 ({selectedColumns.length}개 선택)</Label>
|
||||||
|
<div className="max-h-[400px] overflow-y-auto rounded-md border p-2">
|
||||||
|
{rightTableColumns.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground py-2 text-center text-xs">테이블을 선택해주세요</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{selectedColumns.length > 0 && (
|
||||||
|
<DndContext collisionDetection={closestCenter} onDragEnd={handleRightDragEnd}>
|
||||||
|
<SortableContext items={selectedColumns.map((c) => c.name)} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{selectedColumns.map((col, index) => {
|
||||||
|
const colInfo = rightTableColumns.find((c) => c.columnName === col.name);
|
||||||
|
const isNumeric = colInfo && (
|
||||||
|
dbNumericTypes.includes(colInfo.dataType?.toLowerCase() || "") ||
|
||||||
|
inputNumericTypes.includes(colInfo.input_type?.toLowerCase() || "") ||
|
||||||
|
inputNumericTypes.includes(colInfo.webType?.toLowerCase() || "")
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<SortableColumnRow
|
||||||
|
key={col.name}
|
||||||
|
id={col.name}
|
||||||
|
col={col}
|
||||||
|
index={index}
|
||||||
|
isNumeric={!!isNumeric}
|
||||||
|
isEntityJoin={!!(col as any).isEntityJoin}
|
||||||
|
onLabelChange={(value) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = { ...newColumns[index], label: value };
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
onWidthChange={(value) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = { ...newColumns[index], width: value };
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
onFormatChange={(checked) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
onRemove={() => updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||||
|
onShowInSummaryChange={(checked) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = { ...newColumns[index], showInSummary: checked };
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
onShowInDetailChange={(checked) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = { ...newColumns[index], showInDetail: checked };
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedColumns.length > 0 && unselectedColumns.length > 0 && (
|
||||||
|
<div className="border-border/60 my-2 flex items-center gap-2 border-t pt-2">
|
||||||
|
<span className="text-muted-foreground text-[10px]">미선택 컬럼</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{unselectedColumns.map((column) => (
|
||||||
|
<div
|
||||||
|
key={column.columnName}
|
||||||
|
className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1.5"
|
||||||
|
onClick={() => {
|
||||||
|
updateRightPanel({ columns: [...selectedColumns, { name: column.columnName, label: column.columnLabel || column.columnName, width: 100 }] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="text-muted-foreground truncate text-xs">{column.columnLabel || column.columnName}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const rightTable = config.rightPanel?.tableName;
|
||||||
|
const joinData = rightTable ? entityJoinColumns[rightTable] : null;
|
||||||
|
if (!joinData || joinData.joinTables.length === 0) return null;
|
||||||
|
|
||||||
|
return joinData.joinTables.map((joinTable, tableIndex) => {
|
||||||
|
const joinColumnsToShow = joinTable.availableColumns.filter((column) => {
|
||||||
|
const matchingJoinColumn = joinData.availableColumns.find(
|
||||||
|
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
||||||
|
);
|
||||||
|
if (!matchingJoinColumn) return false;
|
||||||
|
return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias);
|
||||||
|
});
|
||||||
|
const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length;
|
||||||
|
|
||||||
|
if (joinColumnsToShow.length === 0 && addedCount === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details key={`join-${tableIndex}`} className="group">
|
||||||
|
<summary className="border-border/60 my-2 flex cursor-pointer list-none items-center gap-2 border-t pt-2 select-none">
|
||||||
|
<ChevronRight className="h-3 w-3 shrink-0 text-blue-500 transition-transform group-open:rotate-90" />
|
||||||
|
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||||
|
<span className="text-[10px] font-medium text-blue-600">{joinTable.tableName}</span>
|
||||||
|
{addedCount > 0 && (
|
||||||
|
<span className="rounded-full bg-blue-100 px-1.5 text-[9px] font-medium text-blue-600">{addedCount}개 선택</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[9px] text-gray-400">{joinColumnsToShow.length}개 남음</span>
|
||||||
|
</summary>
|
||||||
|
<div className="space-y-0.5 pt-1">
|
||||||
|
{joinColumnsToShow.map((column, colIndex) => {
|
||||||
|
const matchingJoinColumn = joinData.availableColumns.find(
|
||||||
|
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
||||||
|
);
|
||||||
|
if (!matchingJoinColumn) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={colIndex}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-blue-50/60"
|
||||||
|
onClick={() => {
|
||||||
|
updateRightPanel({
|
||||||
|
columns: [...selectedColumns, {
|
||||||
|
name: matchingJoinColumn.joinAlias,
|
||||||
|
label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel,
|
||||||
|
width: 100,
|
||||||
|
isEntityJoin: true,
|
||||||
|
joinInfo: {
|
||||||
|
sourceTable: rightTable!,
|
||||||
|
sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "",
|
||||||
|
referenceTable: matchingJoinColumn.tableName,
|
||||||
|
joinAlias: matchingJoinColumn.joinAlias,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
|
||||||
|
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||||
|
<span className="truncate text-xs text-blue-700">{column.columnLabel || column.columnName}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{joinColumnsToShow.length === 0 && (
|
||||||
|
<p className="px-2 py-1 text-[10px] text-gray-400">모든 컬럼이 이미 추가되었습니다</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||||
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">우측 패널 데이터 필터링</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">특정 컬럼 값으로 우측 패널 데이터를 필터링합니다</p>
|
||||||
|
<DataFilterConfigPanel
|
||||||
|
tableName={config.rightPanel?.tableName}
|
||||||
|
columns={rightTableColumns.map(
|
||||||
|
(col) =>
|
||||||
|
({
|
||||||
|
columnName: col.columnName,
|
||||||
|
columnLabel: col.columnLabel || col.columnName,
|
||||||
|
dataType: col.dataType || "text",
|
||||||
|
input_type: (col as any).input_type,
|
||||||
|
}) as any,
|
||||||
|
)}
|
||||||
|
config={config.rightPanel?.dataFilter}
|
||||||
|
onConfigChange={(dataFilter) => updateRightPanel({ dataFilter })}
|
||||||
|
menuObjid={menuObjid}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">중복 데이터 제거</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">같은 값을 가진 데이터를 하나로 통합하여 표시</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.rightPanel?.deduplication?.enabled ?? false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
updateRightPanel({
|
||||||
|
deduplication: {
|
||||||
|
enabled: true,
|
||||||
|
groupByColumn: "",
|
||||||
|
keepStrategy: "latest",
|
||||||
|
sortColumn: "start_date",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateRightPanel({ deduplication: undefined });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.rightPanel?.deduplication?.enabled && (
|
||||||
|
<div className="space-y-3 border-l-2 pl-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">중복 제거 기준 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={config.rightPanel?.deduplication?.groupByColumn || ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateRightPanel({
|
||||||
|
deduplication: { ...config.rightPanel?.deduplication!, groupByColumn: value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="기준 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{rightTableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
|
이 컬럼의 값이 같은 데이터들 중 하나만 표시합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">유지 전략</Label>
|
||||||
|
<Select
|
||||||
|
value={config.rightPanel?.deduplication?.keepStrategy || "latest"}
|
||||||
|
onValueChange={(value: any) =>
|
||||||
|
updateRightPanel({
|
||||||
|
deduplication: { ...config.rightPanel?.deduplication!, keepStrategy: value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="latest">최신 데이터 (가장 최근)</SelectItem>
|
||||||
|
<SelectItem value="earliest">최초 데이터 (가장 오래된)</SelectItem>
|
||||||
|
<SelectItem value="current_date">현재 유효한 데이터 (날짜 기준)</SelectItem>
|
||||||
|
<SelectItem value="base_price">기준단가로 설정된 데이터</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(config.rightPanel?.deduplication?.keepStrategy === "latest" ||
|
||||||
|
config.rightPanel?.deduplication?.keepStrategy === "earliest") && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">정렬 기준 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={config.rightPanel?.deduplication?.sortColumn || ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateRightPanel({
|
||||||
|
deduplication: { ...config.rightPanel?.deduplication!, sortColumn: value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{rightTableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수정 버튼 설정 */}
|
||||||
|
{config.rightPanel?.showEdit && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||||
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">수정 버튼 설정</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">수정 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.rightPanel?.editButton?.mode || "auto"}
|
||||||
|
onValueChange={(value: "auto" | "modal") =>
|
||||||
|
updateRightPanel({
|
||||||
|
editButton: {
|
||||||
|
...config.rightPanel?.editButton,
|
||||||
|
enabled: config.rightPanel?.editButton?.enabled ?? true,
|
||||||
|
mode: value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">자동 (인라인)</SelectItem>
|
||||||
|
<SelectItem value="modal">모달 화면</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.rightPanel?.editButton?.mode === "modal" && (
|
||||||
|
<GroupByColumnsSelector
|
||||||
|
tableName={config.rightPanel?.tableName}
|
||||||
|
selectedColumns={config.rightPanel?.editButton?.groupByColumns || []}
|
||||||
|
onChange={(columns) => {
|
||||||
|
updateRightPanel({
|
||||||
|
editButton: {
|
||||||
|
...config.rightPanel?.editButton!,
|
||||||
|
groupByColumns: columns,
|
||||||
|
enabled: config.rightPanel?.editButton?.enabled ?? true,
|
||||||
|
mode: config.rightPanel?.editButton?.mode || "auto",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.rightPanel?.editButton?.mode === "modal" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">수정 모달 화면</Label>
|
||||||
|
<ScreenSelector
|
||||||
|
value={config.rightPanel?.editButton?.modalScreenId}
|
||||||
|
onChange={(screenId) =>
|
||||||
|
updateRightPanel({
|
||||||
|
editButton: {
|
||||||
|
...config.rightPanel?.editButton!,
|
||||||
|
modalScreenId: screenId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">버튼 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={config.rightPanel?.editButton?.buttonLabel || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateRightPanel({
|
||||||
|
editButton: {
|
||||||
|
...config.rightPanel?.editButton!,
|
||||||
|
buttonLabel: e.target.value || undefined,
|
||||||
|
enabled: config.rightPanel?.editButton?.enabled ?? true,
|
||||||
|
mode: config.rightPanel?.editButton?.mode || "auto",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="수정"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 추가 버튼 설정 */}
|
||||||
|
{config.rightPanel?.showAdd && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||||
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">추가 버튼 설정</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">추가 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.rightPanel?.addButton?.mode || "auto"}
|
||||||
|
onValueChange={(value: "auto" | "modal") =>
|
||||||
|
updateRightPanel({
|
||||||
|
addButton: {
|
||||||
|
...config.rightPanel?.addButton,
|
||||||
|
mode: value,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">자동 (내장 폼)</SelectItem>
|
||||||
|
<SelectItem value="modal">모달 화면</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.rightPanel?.addButton?.mode === "modal" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">모달 화면</Label>
|
||||||
|
<ScreenSelector
|
||||||
|
value={config.rightPanel?.addButton?.modalScreenId}
|
||||||
|
onChange={(screenId) =>
|
||||||
|
updateRightPanel({
|
||||||
|
addButton: {
|
||||||
|
...config.rightPanel?.addButton!,
|
||||||
|
modalScreenId: screenId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">버튼 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={config.rightPanel?.addButton?.buttonLabel || "추가"}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateRightPanel({
|
||||||
|
addButton: {
|
||||||
|
...config.rightPanel?.addButton!,
|
||||||
|
buttonLabel: e.target.value,
|
||||||
|
enabled: true,
|
||||||
|
mode: config.rightPanel?.addButton?.mode || "auto",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="추가"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 삭제 버튼 설정 */}
|
||||||
|
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">삭제 버튼 설정</h3>
|
||||||
|
<Switch
|
||||||
|
checked={config.rightPanel?.deleteButton?.enabled ?? true}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
updateRightPanel({
|
||||||
|
deleteButton: {
|
||||||
|
enabled: checked,
|
||||||
|
buttonLabel: config.rightPanel?.deleteButton?.buttonLabel,
|
||||||
|
buttonVariant: config.rightPanel?.deleteButton?.buttonVariant,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(config.rightPanel?.deleteButton?.enabled ?? true) && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">버튼 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={config.rightPanel?.deleteButton?.buttonLabel || ""}
|
||||||
|
placeholder="삭제"
|
||||||
|
onChange={(e) => {
|
||||||
|
updateRightPanel({
|
||||||
|
deleteButton: {
|
||||||
|
...config.rightPanel?.deleteButton!,
|
||||||
|
buttonLabel: e.target.value || undefined,
|
||||||
|
enabled: config.rightPanel?.deleteButton?.enabled ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">버튼 스타일</Label>
|
||||||
|
<Select
|
||||||
|
value={config.rightPanel?.deleteButton?.buttonVariant || "ghost"}
|
||||||
|
onValueChange={(value: "default" | "outline" | "ghost" | "destructive") => {
|
||||||
|
updateRightPanel({
|
||||||
|
deleteButton: {
|
||||||
|
...config.rightPanel?.deleteButton!,
|
||||||
|
buttonVariant: value,
|
||||||
|
enabled: config.rightPanel?.deleteButton?.enabled ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ghost">Ghost (기본)</SelectItem>
|
||||||
|
<SelectItem value="outline">Outline</SelectItem>
|
||||||
|
<SelectItem value="default">Default</SelectItem>
|
||||||
|
<SelectItem value="destructive">Destructive (빨강)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">삭제 확인 메시지</Label>
|
||||||
|
<Input
|
||||||
|
value={config.rightPanel?.deleteButton?.confirmMessage || ""}
|
||||||
|
placeholder="정말 삭제하시겠습니까?"
|
||||||
|
onChange={(e) => {
|
||||||
|
updateRightPanel({
|
||||||
|
deleteButton: {
|
||||||
|
...config.rightPanel?.deleteButton!,
|
||||||
|
confirmMessage: e.target.value || undefined,
|
||||||
|
enabled: config.rightPanel?.deleteButton?.enabled ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 추가 탭 */}
|
||||||
|
{renderAdditionalTabs && (
|
||||||
|
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||||
|
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">추가 탭</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
우측 패널에 다른 테이블 데이터를 탭으로 추가합니다
|
||||||
|
</p>
|
||||||
|
{renderAdditionalTabs()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,256 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Check, ChevronsUpDown, GripVertical, Link2, X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function SortableColumnRow({
|
||||||
|
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean };
|
||||||
|
index: number;
|
||||||
|
isNumeric: boolean;
|
||||||
|
isEntityJoin?: boolean;
|
||||||
|
onLabelChange: (value: string) => void;
|
||||||
|
onWidthChange: (value: number) => void;
|
||||||
|
onFormatChange: (checked: boolean) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onShowInSummaryChange?: (checked: boolean) => void;
|
||||||
|
onShowInDetailChange?: (checked: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||||
|
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-md border bg-card px-2 py-1.5",
|
||||||
|
isDragging && "z-50 opacity-50 shadow-md",
|
||||||
|
isEntityJoin && "border-blue-200 bg-blue-50/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
|
||||||
|
<GripVertical className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
{isEntityJoin ? (
|
||||||
|
<Link2 className="h-3 w-3 shrink-0 text-blue-500" title="Entity 조인 컬럼" />
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
value={col.label}
|
||||||
|
onChange={(e) => onLabelChange(e.target.value)}
|
||||||
|
placeholder="라벨"
|
||||||
|
className="h-6 min-w-0 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={col.width || ""}
|
||||||
|
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
|
||||||
|
placeholder="너비"
|
||||||
|
className="h-6 w-14 shrink-0 text-xs"
|
||||||
|
/>
|
||||||
|
{isNumeric && (
|
||||||
|
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={col.format?.thousandSeparator ?? false}
|
||||||
|
onChange={(e) => onFormatChange(e.target.checked)}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
,
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{onShowInSummaryChange && (
|
||||||
|
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="테이블 헤더에 표시">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={col.showInSummary !== false}
|
||||||
|
onChange={(e) => onShowInSummaryChange(e.target.checked)}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
헤더
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{onShowInDetailChange && (
|
||||||
|
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="행 클릭 시 상세 정보에 표시">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={col.showInDetail !== false}
|
||||||
|
onChange={(e) => onShowInDetailChange(e.target.checked)}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
상세
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupByColumnsSelector: React.FC<{
|
||||||
|
tableName?: string;
|
||||||
|
selectedColumns: string[];
|
||||||
|
onChange: (columns: string[]) => void;
|
||||||
|
}> = ({ tableName, selectedColumns, onChange }) => {
|
||||||
|
const [columns, setColumns] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tableName) {
|
||||||
|
setColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loadColumns = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
if (response.success && response.data && response.data.columns) {
|
||||||
|
setColumns(response.data.columns);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 정보 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadColumns();
|
||||||
|
}, [tableName]);
|
||||||
|
|
||||||
|
const toggleColumn = (columnName: string) => {
|
||||||
|
const newSelection = selectedColumns.includes(columnName)
|
||||||
|
? selectedColumns.filter((c) => c !== columnName)
|
||||||
|
: [...selectedColumns, columnName];
|
||||||
|
onChange(newSelection);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-dashed p-3">
|
||||||
|
<p className="text-muted-foreground text-center text-xs">먼저 우측 패널의 테이블을 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">그룹핑 기준 컬럼</Label>
|
||||||
|
{loading ? (
|
||||||
|
<div className="rounded-md border p-3">
|
||||||
|
<p className="text-muted-foreground text-center text-xs">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
) : columns.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed p-3">
|
||||||
|
<p className="text-muted-foreground text-center text-xs">컬럼을 찾을 수 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[200px] space-y-1 overflow-y-auto rounded-md border p-3">
|
||||||
|
{columns.map((col) => (
|
||||||
|
<div key={col.columnName} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`groupby-${col.columnName}`}
|
||||||
|
checked={selectedColumns.includes(col.columnName)}
|
||||||
|
onCheckedChange={() => toggleColumn(col.columnName)}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`groupby-${col.columnName}`} className="flex-1 cursor-pointer text-xs">
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
<span className="text-muted-foreground ml-1">({col.columnName})</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
|
선택된 컬럼: {selectedColumns.length > 0 ? selectedColumns.join(", ") : "없음"}
|
||||||
|
<br />
|
||||||
|
같은 값을 가진 모든 레코드를 함께 불러옵니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ScreenSelector: React.FC<{
|
||||||
|
value?: number;
|
||||||
|
onChange: (screenId?: number) => void;
|
||||||
|
}> = ({ value, onChange }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [screens, setScreens] = useState<Array<{ screenId: number; screenName: string; screenCode: string }>>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadScreens = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { screenApi } = await import("@/lib/api/screen");
|
||||||
|
const response = await screenApi.getScreens({ page: 1, size: 1000 });
|
||||||
|
setScreens(
|
||||||
|
response.data.map((s) => ({ screenId: s.screenId, screenName: s.screenName, screenCode: s.screenCode })),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("화면 목록 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadScreens();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedScreen = screens.find((s) => s.screenId === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "로딩 중..." : selectedScreen ? selectedScreen.screenName : "화면 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[400px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="화면 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-6 text-center text-xs">화면을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[300px] overflow-auto">
|
||||||
|
{screens.map((screen) => (
|
||||||
|
<CommandItem
|
||||||
|
key={screen.screenId}
|
||||||
|
value={`${screen.screenName.toLowerCase()} ${screen.screenCode.toLowerCase()} ${screen.screenId}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(screen.screenId === value ? undefined : screen.screenId);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-4 w-4", value === screen.screenId ? "opacity-100" : "opacity-0")} />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{screen.screenName}</span>
|
||||||
|
<span className="text-muted-foreground text-[10px]">{screen.screenCode}</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Table2, Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface BasicConfigPanelProps {
|
||||||
|
config: any;
|
||||||
|
onChange: (key: string, value: any) => void;
|
||||||
|
screenTableName?: string;
|
||||||
|
availableTables: Array<{ tableName: string; displayName: string }>;
|
||||||
|
loadingTables: boolean;
|
||||||
|
targetTableName: string | undefined;
|
||||||
|
tableComboboxOpen: boolean;
|
||||||
|
onTableComboboxOpenChange: (open: boolean) => void;
|
||||||
|
onTableChange: (newTableName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 설정 패널: 테이블 선택, 데이터 소스
|
||||||
|
*/
|
||||||
|
export const BasicConfigPanel: React.FC<BasicConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
screenTableName,
|
||||||
|
availableTables,
|
||||||
|
loadingTables,
|
||||||
|
targetTableName,
|
||||||
|
tableComboboxOpen,
|
||||||
|
onTableComboboxOpenChange,
|
||||||
|
onTableChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">데이터 소스</h3>
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
테이블을 선택하세요. 미선택 시 화면 메인 테이블을 사용합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">테이블 선택</Label>
|
||||||
|
<Popover open={tableComboboxOpen} onOpenChange={onTableComboboxOpenChange}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={tableComboboxOpen}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loadingTables}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 truncate">
|
||||||
|
<Table2 className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{loadingTables
|
||||||
|
? "테이블 로딩 중..."
|
||||||
|
: targetTableName
|
||||||
|
? availableTables.find((t) => t.tableName === targetTableName)?.displayName || targetTableName
|
||||||
|
: "테이블 선택"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 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" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{availableTables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.tableName} ${table.displayName}`}
|
||||||
|
onSelect={() => onTableChange(table.tableName)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
targetTableName === table.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{table.displayName}</span>
|
||||||
|
{table.displayName !== table.tableName && (
|
||||||
|
<span className="text-[10px] text-gray-400">{table.tableName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{screenTableName && targetTableName && targetTableName !== screenTableName && (
|
||||||
|
<div className="flex items-center justify-between rounded bg-amber-50 px-2 py-1">
|
||||||
|
<span className="text-[10px] text-amber-700">
|
||||||
|
화면 기본 테이블({screenTableName})과 다른 테이블을 사용 중
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 px-1.5 text-[10px] text-amber-700 hover:text-amber-900"
|
||||||
|
onClick={() => onTableChange(screenTableName)}
|
||||||
|
>
|
||||||
|
기본으로
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,534 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ColumnConfig } from "../types";
|
||||||
|
import { Database, Link2, GripVertical, X, Check, ChevronsUpDown, Lock, Unlock } from "lucide-react";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||||
|
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
|
function SortableColumnRow({
|
||||||
|
id,
|
||||||
|
col,
|
||||||
|
index,
|
||||||
|
isEntityJoin,
|
||||||
|
onLabelChange,
|
||||||
|
onWidthChange,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
col: ColumnConfig;
|
||||||
|
index: number;
|
||||||
|
isEntityJoin?: boolean;
|
||||||
|
onLabelChange: (value: string) => void;
|
||||||
|
onWidthChange: (value: number) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||||
|
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"bg-card flex items-center gap-1.5 rounded-md border px-2 py-1.5",
|
||||||
|
isDragging && "z-50 opacity-50 shadow-md",
|
||||||
|
isEntityJoin && "border-blue-200 bg-blue-50/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
|
||||||
|
<GripVertical className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
{isEntityJoin ? (
|
||||||
|
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
value={col.displayName || col.columnName}
|
||||||
|
onChange={(e) => onLabelChange(e.target.value)}
|
||||||
|
placeholder="표시명"
|
||||||
|
className="h-6 min-w-0 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={col.width || ""}
|
||||||
|
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
|
||||||
|
placeholder="너비"
|
||||||
|
className="h-6 w-14 shrink-0 text-xs"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnsConfigPanelProps {
|
||||||
|
config: any;
|
||||||
|
onChange: (key: string, value: any) => void;
|
||||||
|
screenTableName?: string;
|
||||||
|
targetTableName: string | undefined;
|
||||||
|
availableColumns: Array<{ columnName: string; dataType: string; label?: string; input_type?: string }>;
|
||||||
|
tableColumns?: any[];
|
||||||
|
entityJoinColumns: {
|
||||||
|
availableColumns: Array<{
|
||||||
|
tableName: string;
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
dataType: string;
|
||||||
|
joinAlias: string;
|
||||||
|
suggestedLabel: string;
|
||||||
|
}>;
|
||||||
|
joinTables: Array<{
|
||||||
|
tableName: string;
|
||||||
|
currentDisplayColumn: string;
|
||||||
|
availableColumns: Array<{
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
dataType: string;
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
entityDisplayConfigs: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
sourceColumns: Array<{ columnName: string; displayName: string; dataType: string }>;
|
||||||
|
joinColumns: Array<{ columnName: string; displayName: string; dataType: string }>;
|
||||||
|
selectedColumns: string[];
|
||||||
|
separator: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
onAddColumn: (columnName: string) => void;
|
||||||
|
onAddEntityColumn: (joinColumn: {
|
||||||
|
tableName: string;
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
dataType: string;
|
||||||
|
joinAlias: string;
|
||||||
|
suggestedLabel: string;
|
||||||
|
}) => void;
|
||||||
|
onRemoveColumn: (columnName: string) => void;
|
||||||
|
onUpdateColumn: (columnName: string, updates: Partial<ColumnConfig>) => void;
|
||||||
|
onToggleEntityDisplayColumn: (columnName: string, selectedColumn: string) => void;
|
||||||
|
onUpdateEntityDisplaySeparator: (columnName: string, separator: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 설정 패널: 컬럼 선택, Entity 조인, DnD 순서 변경
|
||||||
|
*/
|
||||||
|
export const ColumnsConfigPanel: React.FC<ColumnsConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
screenTableName,
|
||||||
|
targetTableName,
|
||||||
|
availableColumns,
|
||||||
|
tableColumns,
|
||||||
|
entityJoinColumns,
|
||||||
|
entityDisplayConfigs,
|
||||||
|
onAddColumn,
|
||||||
|
onAddEntityColumn,
|
||||||
|
onRemoveColumn,
|
||||||
|
onUpdateColumn,
|
||||||
|
onToggleEntityDisplayColumn,
|
||||||
|
onUpdateEntityDisplaySeparator,
|
||||||
|
}) => {
|
||||||
|
const handleChange = (key: string, value: any) => {
|
||||||
|
onChange(key, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 엔티티 컬럼 표시 설정 섹션 */}
|
||||||
|
{config.columns?.some((col: ColumnConfig) => col.isEntityJoin) && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{config.columns
|
||||||
|
?.filter((col: ColumnConfig) => col.isEntityJoin && col.entityDisplayConfig)
|
||||||
|
.map((column: ColumnConfig) => (
|
||||||
|
<div key={column.columnName} className="space-y-2">
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="truncate text-xs font-medium" style={{ fontSize: "12px" }}>
|
||||||
|
{column.displayName || column.columnName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entityDisplayConfigs[column.columnName] ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">구분자</Label>
|
||||||
|
<Input
|
||||||
|
value={entityDisplayConfigs[column.columnName].separator}
|
||||||
|
onChange={(e) => onUpdateEntityDisplaySeparator(column.columnName, e.target.value)}
|
||||||
|
className="h-6 w-full text-xs"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
placeholder=" - "
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">표시할 컬럼 선택</Label>
|
||||||
|
{entityDisplayConfigs[column.columnName].sourceColumns.length === 0 &&
|
||||||
|
entityDisplayConfigs[column.columnName].joinColumns.length === 0 ? (
|
||||||
|
<div className="py-2 text-center text-xs text-gray-400">
|
||||||
|
표시 가능한 컬럼이 없습니다.
|
||||||
|
{!column.entityDisplayConfig?.joinTable && (
|
||||||
|
<p className="mt-1 text-[10px]">
|
||||||
|
테이블 타입 관리에서 참조 테이블을 설정하면 더 많은 컬럼을 선택할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-6 w-full justify-between text-xs"
|
||||||
|
style={{ fontSize: "12px" }}
|
||||||
|
>
|
||||||
|
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0
|
||||||
|
? `${entityDisplayConfigs[column.columnName].selectedColumns.length}개 선택됨`
|
||||||
|
: "컬럼 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
{entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
|
||||||
|
<CommandGroup
|
||||||
|
heading={`기본 테이블: ${column.entityDisplayConfig?.sourceTable || config.selectedTable || screenTableName}`}
|
||||||
|
>
|
||||||
|
{entityDisplayConfigs[column.columnName].sourceColumns.map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`source-${col.columnName}`}
|
||||||
|
onSelect={() => onToggleEntityDisplayColumn(column.columnName, col.columnName)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
entityDisplayConfigs[column.columnName].selectedColumns.includes(col.columnName)
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{col.displayName}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
{entityDisplayConfigs[column.columnName].joinColumns.length > 0 && (
|
||||||
|
<CommandGroup heading={`참조 테이블: ${column.entityDisplayConfig?.joinTable}`}>
|
||||||
|
{entityDisplayConfigs[column.columnName].joinColumns.map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`join-${col.columnName}`}
|
||||||
|
onSelect={() => onToggleEntityDisplayColumn(column.columnName, col.columnName)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
entityDisplayConfigs[column.columnName].selectedColumns.includes(col.columnName)
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{col.displayName}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!column.entityDisplayConfig?.joinTable &&
|
||||||
|
entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
|
||||||
|
<div className="rounded bg-blue-50 p-2 text-[10px] text-blue-600">
|
||||||
|
현재 기본 테이블 컬럼만 표시됩니다. 테이블 타입 관리에서 참조 테이블을 설정하면 조인된
|
||||||
|
테이블의 컬럼도 선택할 수 있습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">미리보기</Label>
|
||||||
|
<div className="flex flex-wrap gap-1 rounded bg-gray-50 p-2 text-xs">
|
||||||
|
{entityDisplayConfigs[column.columnName].selectedColumns.map((colName, idx) => (
|
||||||
|
<React.Fragment key={colName}>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{colName}
|
||||||
|
</Badge>
|
||||||
|
{idx < entityDisplayConfigs[column.columnName].selectedColumns.length - 1 && (
|
||||||
|
<span className="text-gray-400">
|
||||||
|
{entityDisplayConfigs[column.columnName].separator}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-4 text-center text-xs text-gray-400">컬럼 정보 로딩 중...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!targetTableName ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-center text-gray-500">
|
||||||
|
<p>테이블이 선택되지 않았습니다.</p>
|
||||||
|
<p className="text-sm">기본 설정 탭에서 테이블을 선택하세요.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : availableColumns.length === 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-center text-gray-500">
|
||||||
|
<p>컬럼을 추가하려면 먼저 컴포넌트에 테이블을 명시적으로 선택하거나</p>
|
||||||
|
<p className="text-sm">기본 설정 탭에서 테이블을 설정해주세요.</p>
|
||||||
|
<p className="mt-2 text-xs text-blue-600">현재 화면 테이블: {screenTableName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">컬럼 선택</h3>
|
||||||
|
<p className="text-muted-foreground text-[10px]">표시할 컬럼을 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<div className="max-h-48 space-y-0.5 overflow-y-auto rounded-md border p-2">
|
||||||
|
{availableColumns.map((column) => {
|
||||||
|
const isAdded = config.columns?.some((c: ColumnConfig) => c.columnName === column.columnName);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={column.columnName}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
|
||||||
|
isAdded && "bg-primary/10",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (isAdded) {
|
||||||
|
handleChange(
|
||||||
|
"columns",
|
||||||
|
config.columns?.filter((c: ColumnConfig) => c.columnName !== column.columnName) || [],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onAddColumn(column.columnName);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isAdded}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
if (isAdded) {
|
||||||
|
handleChange(
|
||||||
|
"columns",
|
||||||
|
config.columns?.filter((c: ColumnConfig) => c.columnName !== column.columnName) || [],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onAddColumn(column.columnName);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="pointer-events-none h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
||||||
|
<span className="truncate text-xs">{column.label || column.columnName}</span>
|
||||||
|
{isAdded && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={
|
||||||
|
config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName)?.editable === false
|
||||||
|
? "편집 잠금 (클릭하여 해제)"
|
||||||
|
: "편집 가능 (클릭하여 잠금)"
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"ml-auto flex-shrink-0 rounded p-0.5 transition-colors",
|
||||||
|
config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName)?.editable === false
|
||||||
|
? "text-destructive hover:bg-destructive/10"
|
||||||
|
: "text-muted-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const currentCol = config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName);
|
||||||
|
if (currentCol) {
|
||||||
|
onUpdateColumn(column.columnName, {
|
||||||
|
editable: currentCol.editable === false ? undefined : false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName)?.editable === false ? (
|
||||||
|
<Lock className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Unlock className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className={cn("text-[10px] text-gray-400", !isAdded && "ml-auto")}>
|
||||||
|
{column.input_type || column.dataType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entityJoinColumns.joinTables.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">Entity 조인 컬럼</h3>
|
||||||
|
<p className="text-muted-foreground text-[10px]">연관 테이블의 컬럼을 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
{entityJoinColumns.joinTables.map((joinTable, tableIndex) => (
|
||||||
|
<div key={tableIndex} className="space-y-1">
|
||||||
|
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-blue-600">
|
||||||
|
<Link2 className="h-3 w-3" />
|
||||||
|
<span>{joinTable.tableName}</span>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{joinTable.currentDisplayColumn}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-28 space-y-0.5 overflow-y-auto rounded-md border border-blue-200 bg-blue-50/30 p-2">
|
||||||
|
{joinTable.availableColumns.map((column, colIndex) => {
|
||||||
|
const matchingJoinColumn = entityJoinColumns.availableColumns.find(
|
||||||
|
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
||||||
|
);
|
||||||
|
const isAlreadyAdded = config.columns?.some(
|
||||||
|
(col: ColumnConfig) => col.columnName === matchingJoinColumn?.joinAlias,
|
||||||
|
);
|
||||||
|
if (!matchingJoinColumn) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={colIndex}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-blue-100/50",
|
||||||
|
isAlreadyAdded && "bg-blue-100",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (isAlreadyAdded) {
|
||||||
|
handleChange(
|
||||||
|
"columns",
|
||||||
|
config.columns?.filter(
|
||||||
|
(c: ColumnConfig) => c.columnName !== matchingJoinColumn.joinAlias,
|
||||||
|
) || [],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onAddEntityColumn(matchingJoinColumn);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isAlreadyAdded}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
if (isAlreadyAdded) {
|
||||||
|
handleChange(
|
||||||
|
"columns",
|
||||||
|
config.columns?.filter(
|
||||||
|
(c: ColumnConfig) => c.columnName !== matchingJoinColumn.joinAlias,
|
||||||
|
) || [],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onAddEntityColumn(matchingJoinColumn);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="pointer-events-none h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
<Link2 className="h-3 w-3 flex-shrink-0 text-blue-500" />
|
||||||
|
<span className="truncate text-xs">{column.columnLabel}</span>
|
||||||
|
<span className="ml-auto text-[10px] text-blue-400">
|
||||||
|
{column.inputType || column.dataType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.columns && config.columns.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">표시할 컬럼 ({config.columns.length}개 선택)</h3>
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<DndContext
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={(event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const columns = [...(config.columns || [])];
|
||||||
|
const oldIndex = columns.findIndex((c: ColumnConfig) => c.columnName === active.id);
|
||||||
|
const newIndex = columns.findIndex((c: ColumnConfig) => c.columnName === over.id);
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
const reordered = arrayMove(columns, oldIndex, newIndex);
|
||||||
|
reordered.forEach((col: ColumnConfig, idx: number) => {
|
||||||
|
col.order = idx;
|
||||||
|
});
|
||||||
|
handleChange("columns", reordered);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={(config.columns || []).map((c: ColumnConfig) => c.columnName)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(config.columns || []).map((column: ColumnConfig, idx: number) => {
|
||||||
|
const resolvedLabel =
|
||||||
|
column.displayName && column.displayName !== column.columnName
|
||||||
|
? column.displayName
|
||||||
|
: availableColumns.find((c) => c.columnName === column.columnName)?.label ||
|
||||||
|
column.displayName ||
|
||||||
|
column.columnName;
|
||||||
|
const colWithLabel = { ...column, displayName: resolvedLabel };
|
||||||
|
return (
|
||||||
|
<SortableColumnRow
|
||||||
|
key={column.columnName}
|
||||||
|
id={column.columnName}
|
||||||
|
col={colWithLabel}
|
||||||
|
index={idx}
|
||||||
|
isEntityJoin={!!column.isEntityJoin}
|
||||||
|
onLabelChange={(value) => onUpdateColumn(column.columnName, { displayName: value })}
|
||||||
|
onWidthChange={(value) => onUpdateColumn(column.columnName, { width: value })}
|
||||||
|
onRemove={() => onRemoveColumn(column.columnName)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,290 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||||
|
|
||||||
|
export interface OptionsConfigPanelProps {
|
||||||
|
config: any;
|
||||||
|
onChange: (key: string, value: any) => void;
|
||||||
|
onNestedChange: (parentKey: string, childKey: string, value: any) => void;
|
||||||
|
availableColumns: Array<{ columnName: string; dataType: string; label?: string; input_type?: string }>;
|
||||||
|
screenTableName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 옵션 설정 패널: 툴바, 체크박스, 기본 정렬, 가로 스크롤, 데이터 필터링
|
||||||
|
*/
|
||||||
|
export const OptionsConfigPanel: React.FC<OptionsConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
onNestedChange,
|
||||||
|
availableColumns,
|
||||||
|
screenTableName,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 툴바 버튼 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">툴바 버튼 설정</h3>
|
||||||
|
<p className="text-muted-foreground text-[10px]">테이블 상단에 표시할 버튼을 선택합니다</p>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showEditMode"
|
||||||
|
checked={config.toolbar?.showEditMode ?? false}
|
||||||
|
onCheckedChange={(checked) => onNestedChange("toolbar", "showEditMode", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showEditMode" className="text-xs">
|
||||||
|
즉시 저장
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showExcel"
|
||||||
|
checked={config.toolbar?.showExcel ?? false}
|
||||||
|
onCheckedChange={(checked) => onNestedChange("toolbar", "showExcel", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showExcel" className="text-xs">
|
||||||
|
Excel
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showPdf"
|
||||||
|
checked={config.toolbar?.showPdf ?? false}
|
||||||
|
onCheckedChange={(checked) => onNestedChange("toolbar", "showPdf", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showPdf" className="text-xs">
|
||||||
|
PDF
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showCopy"
|
||||||
|
checked={config.toolbar?.showCopy ?? false}
|
||||||
|
onCheckedChange={(checked) => onNestedChange("toolbar", "showCopy", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showCopy" className="text-xs">
|
||||||
|
복사
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showSearch"
|
||||||
|
checked={config.toolbar?.showSearch ?? false}
|
||||||
|
onCheckedChange={(checked) => onNestedChange("toolbar", "showSearch", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showSearch" className="text-xs">
|
||||||
|
검색
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showFilter"
|
||||||
|
checked={config.toolbar?.showFilter ?? false}
|
||||||
|
onCheckedChange={(checked) => onNestedChange("toolbar", "showFilter", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showFilter" className="text-xs">
|
||||||
|
필터
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showRefresh"
|
||||||
|
checked={config.toolbar?.showRefresh ?? false}
|
||||||
|
onCheckedChange={(checked) => onNestedChange("toolbar", "showRefresh", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showRefresh" className="text-xs">
|
||||||
|
새로고침 (상단)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="showPaginationRefresh"
|
||||||
|
checked={config.toolbar?.showPaginationRefresh ?? true}
|
||||||
|
onCheckedChange={(checked) => onNestedChange("toolbar", "showPaginationRefresh", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="showPaginationRefresh" className="text-xs">
|
||||||
|
새로고침 (하단)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 체크박스 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">체크박스 설정</h3>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="checkboxEnabled"
|
||||||
|
checked={config.checkbox?.enabled ?? true}
|
||||||
|
onCheckedChange={(checked) => onNestedChange("checkbox", "enabled", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="checkboxEnabled">체크박스 표시</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.checkbox?.enabled && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="checkboxSelectAll"
|
||||||
|
checked={config.checkbox?.selectAll ?? true}
|
||||||
|
onCheckedChange={(checked) => onNestedChange("checkbox", "selectAll", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="checkboxSelectAll">전체 선택 체크박스 표시</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="checkboxPosition" className="text-xs">
|
||||||
|
체크박스 위치
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="checkboxPosition"
|
||||||
|
value={config.checkbox?.position || "left"}
|
||||||
|
onChange={(e) => onNestedChange("checkbox", "position", e.target.value)}
|
||||||
|
className="h-8 w-full rounded-md border px-2 text-xs"
|
||||||
|
>
|
||||||
|
<option value="left">왼쪽</option>
|
||||||
|
<option value="right">오른쪽</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기본 정렬 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">기본 정렬 설정</h3>
|
||||||
|
<p className="text-muted-foreground text-[10px]">테이블 로드 시 기본 정렬 순서를 지정합니다</p>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="defaultSortColumn" className="text-xs">
|
||||||
|
정렬 컬럼
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="defaultSortColumn"
|
||||||
|
value={config.defaultSort?.columnName || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) {
|
||||||
|
onChange("defaultSort", {
|
||||||
|
columnName: e.target.value,
|
||||||
|
direction: config.defaultSort?.direction || "asc",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onChange("defaultSort", undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-8 w-full rounded-md border px-2 text-xs"
|
||||||
|
>
|
||||||
|
<option value="">정렬 없음</option>
|
||||||
|
{availableColumns.map((col) => (
|
||||||
|
<option key={col.columnName} value={col.columnName}>
|
||||||
|
{col.label || col.columnName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.defaultSort?.columnName && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="defaultSortDirection" className="text-xs">
|
||||||
|
정렬 방향
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="defaultSortDirection"
|
||||||
|
value={config.defaultSort?.direction || "asc"}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange("defaultSort", {
|
||||||
|
...config.defaultSort,
|
||||||
|
columnName: config.defaultSort?.columnName || "",
|
||||||
|
direction: e.target.value as "asc" | "desc",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-8 w-full rounded-md border px-2 text-xs"
|
||||||
|
>
|
||||||
|
<option value="asc">오름차순 (A→Z, 1→9)</option>
|
||||||
|
<option value="desc">내림차순 (Z→A, 9→1)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 가로 스크롤 및 컬럼 고정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">가로 스크롤 및 컬럼 고정</h3>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="horizontalScrollEnabled"
|
||||||
|
checked={config.horizontalScroll?.enabled}
|
||||||
|
onCheckedChange={(checked) => onNestedChange("horizontalScroll", "enabled", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="horizontalScrollEnabled">가로 스크롤 사용</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.horizontalScroll?.enabled && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="maxVisibleColumns" className="text-sm">
|
||||||
|
최대 표시 컬럼 수
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="maxVisibleColumns"
|
||||||
|
type="number"
|
||||||
|
value={config.horizontalScroll?.maxVisibleColumns || 8}
|
||||||
|
onChange={(e) =>
|
||||||
|
onNestedChange("horizontalScroll", "maxVisibleColumns", parseInt(e.target.value) || 8)
|
||||||
|
}
|
||||||
|
min={3}
|
||||||
|
max={20}
|
||||||
|
placeholder="8"
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-gray-500">이 수를 넘는 컬럼이 있으면 가로 스크롤이 생성됩니다</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 필터링 설정 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">데이터 필터링</h3>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">특정 컬럼 값으로 데이터를 필터링합니다</p>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<DataFilterConfigPanel
|
||||||
|
tableName={config.selectedTable || screenTableName}
|
||||||
|
columns={availableColumns.map(
|
||||||
|
(col) =>
|
||||||
|
({
|
||||||
|
columnName: col.columnName,
|
||||||
|
columnLabel: col.label || col.columnName,
|
||||||
|
dataType: col.dataType,
|
||||||
|
input_type: col.input_type,
|
||||||
|
}) as any,
|
||||||
|
)}
|
||||||
|
config={config.dataFilter}
|
||||||
|
onConfigChange={(dataFilter) => onChange("dataFilter", dataFilter)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
export interface StyleConfigPanelProps {
|
||||||
|
config: any;
|
||||||
|
onChange: (key: string, value: any) => void;
|
||||||
|
onNestedChange: (parentKey: string, childKey: string, value: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스타일 설정 패널: 테이블 스타일 (theme, headerStyle, rowHeight 등)
|
||||||
|
*/
|
||||||
|
export const StyleConfigPanel: React.FC<StyleConfigPanelProps> = ({
|
||||||
|
config,
|
||||||
|
onNestedChange,
|
||||||
|
}) => {
|
||||||
|
const tableStyle = config.tableStyle || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">테이블 스타일</h3>
|
||||||
|
<p className="text-muted-foreground text-[10px]">테이블의 시각적 스타일을 설정합니다</p>
|
||||||
|
</div>
|
||||||
|
<hr className="border-border" />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="tableTheme" className="text-xs">
|
||||||
|
테마
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="tableTheme"
|
||||||
|
value={tableStyle.theme || "default"}
|
||||||
|
onChange={(e) =>
|
||||||
|
onNestedChange("tableStyle", "theme", e.target.value as "default" | "striped" | "bordered" | "minimal")
|
||||||
|
}
|
||||||
|
className="h-8 w-full rounded-md border px-2 text-xs"
|
||||||
|
>
|
||||||
|
<option value="default">기본</option>
|
||||||
|
<option value="striped">줄무늬</option>
|
||||||
|
<option value="bordered">테두리</option>
|
||||||
|
<option value="minimal">미니멀</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="headerStyle" className="text-xs">
|
||||||
|
헤더 스타일
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="headerStyle"
|
||||||
|
value={tableStyle.headerStyle || "default"}
|
||||||
|
onChange={(e) =>
|
||||||
|
onNestedChange("tableStyle", "headerStyle", e.target.value as "default" | "dark" | "light")
|
||||||
|
}
|
||||||
|
className="h-8 w-full rounded-md border px-2 text-xs"
|
||||||
|
>
|
||||||
|
<option value="default">기본</option>
|
||||||
|
<option value="dark">다크</option>
|
||||||
|
<option value="light">라이트</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="rowHeight" className="text-xs">
|
||||||
|
행 높이
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="rowHeight"
|
||||||
|
value={tableStyle.rowHeight || "normal"}
|
||||||
|
onChange={(e) =>
|
||||||
|
onNestedChange("tableStyle", "rowHeight", e.target.value as "compact" | "normal" | "comfortable")
|
||||||
|
}
|
||||||
|
className="h-8 w-full rounded-md border px-2 text-xs"
|
||||||
|
>
|
||||||
|
<option value="compact">좁게</option>
|
||||||
|
<option value="normal">보통</option>
|
||||||
|
<option value="comfortable">넓게</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="alternateRows"
|
||||||
|
checked={tableStyle.alternateRows ?? false}
|
||||||
|
onCheckedChange={(checked) => onNestedChange("tableStyle", "alternateRows", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="alternateRows" className="text-xs">
|
||||||
|
교행 색상
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="hoverEffect"
|
||||||
|
checked={tableStyle.hoverEffect ?? true}
|
||||||
|
onCheckedChange={(checked) => onNestedChange("tableStyle", "hoverEffect", checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="hoverEffect" className="text-xs">
|
||||||
|
호버 효과
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="borderStyle" className="text-xs">
|
||||||
|
테두리 스타일
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="borderStyle"
|
||||||
|
value={tableStyle.borderStyle || "light"}
|
||||||
|
onChange={(e) =>
|
||||||
|
onNestedChange("tableStyle", "borderStyle", e.target.value as "none" | "light" | "heavy")
|
||||||
|
}
|
||||||
|
className="h-8 w-full rounded-md border px-2 text-xs"
|
||||||
|
>
|
||||||
|
<option value="none">없음</option>
|
||||||
|
<option value="light">얇게</option>
|
||||||
|
<option value="heavy">굵게</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
/**
|
||||||
|
* 메뉴 복사 자동화 스크립트
|
||||||
|
*
|
||||||
|
* 실행: npx ts-node scripts/menu-copy-automation.ts
|
||||||
|
* 또는: npx playwright test scripts/menu-copy-automation.ts (playwright test 모드)
|
||||||
|
*
|
||||||
|
* 요구사항: playwright 설치 (npm install playwright)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { chromium, type Browser, type Page } from "playwright";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
const BASE_URL = "http://localhost:9771";
|
||||||
|
const SCREENSHOT_DIR = path.join(__dirname, "../screenshots-menu-copy");
|
||||||
|
|
||||||
|
// 스크린샷 저장
|
||||||
|
async function takeScreenshot(page: Page, stepName: string): Promise<string> {
|
||||||
|
if (!fs.existsSync(SCREENSHOT_DIR)) {
|
||||||
|
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
const filename = `${Date.now()}_${stepName}.png`;
|
||||||
|
const filepath = path.join(SCREENSHOT_DIR, filename);
|
||||||
|
await page.screenshot({ path: filepath, fullPage: true });
|
||||||
|
console.log(`[스크린샷] ${stepName} -> ${filepath}`);
|
||||||
|
return filepath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let browser: Browser | null = null;
|
||||||
|
const screenshots: { step: string; path: string }[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("=== 메뉴 복사 자동화 시작 ===\n");
|
||||||
|
|
||||||
|
browser = await chromium.launch({ headless: false });
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1280, height: 900 },
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
// 1. 로그인
|
||||||
|
console.log("1. 로그인 페이지 이동...");
|
||||||
|
await page.goto(`${BASE_URL}/login`, { waitUntil: "networkidle" });
|
||||||
|
await takeScreenshot(page, "01_login_page").then((p) =>
|
||||||
|
screenshots.push({ step: "로그인 페이지", path: p })
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.fill('#userId', "admin");
|
||||||
|
await page.fill('#password', "1234");
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
await takeScreenshot(page, "02_after_login").then((p) =>
|
||||||
|
screenshots.push({ step: "로그인 후", path: p })
|
||||||
|
);
|
||||||
|
|
||||||
|
// 로그인 실패 시 wace 계정 시도 (admin이 DB에 없을 수 있음)
|
||||||
|
const currentUrl = page.url();
|
||||||
|
if (currentUrl.includes("/login")) {
|
||||||
|
console.log("admin 로그인 실패, wace 계정으로 재시도...");
|
||||||
|
await page.fill('#userId', "wace");
|
||||||
|
await page.fill('#password', "1234");
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 메뉴 관리 페이지로 이동
|
||||||
|
console.log("2. 메뉴 관리 페이지 이동...");
|
||||||
|
await page.goto(`${BASE_URL}/admin/menu`, { waitUntil: "networkidle" });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await takeScreenshot(page, "03_menu_page").then((p) =>
|
||||||
|
screenshots.push({ step: "메뉴 관리 페이지", path: p })
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. 회사 선택 - 탑씰 (COMPANY_7)
|
||||||
|
console.log("3. 회사 선택: 탑씰 (COMPANY_7)...");
|
||||||
|
const companyDropdown = page.locator('.company-dropdown button, button:has(svg)').first();
|
||||||
|
await companyDropdown.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const topsealOption = page.getByText("탑씰", { exact: false }).first();
|
||||||
|
await topsealOption.click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await takeScreenshot(page, "04_company_selected").then((p) =>
|
||||||
|
screenshots.push({ step: "탑씰 선택 후", path: p })
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. "사용자" 메뉴 찾기 및 복사 버튼 클릭
|
||||||
|
console.log("4. 사용자 메뉴 찾기 및 복사 버튼 클릭...");
|
||||||
|
const userMenuRow = page.locator('tr').filter({ hasText: "사용자" }).first();
|
||||||
|
await userMenuRow.waitFor({ timeout: 10000 });
|
||||||
|
const copyButton = userMenuRow.getByRole("button", { name: "복사" });
|
||||||
|
await copyButton.click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await takeScreenshot(page, "05_copy_dialog_open").then((p) =>
|
||||||
|
screenshots.push({ step: "복사 다이얼로그", path: p })
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. 대상 회사 선택: 두바이 강정 단단 (COMPANY_18)
|
||||||
|
console.log("5. 대상 회사 선택: 두바이 강정 단단 (COMPANY_18)...");
|
||||||
|
const targetCompanyTrigger = page.locator('[id="company"]').or(page.getByRole("combobox")).first();
|
||||||
|
await targetCompanyTrigger.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const dubaiOption = page.getByText("두바이 강정 단단", { exact: false }).first();
|
||||||
|
await dubaiOption.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await takeScreenshot(page, "06_target_company_selected").then((p) =>
|
||||||
|
screenshots.push({ step: "대상 회사 선택 후", path: p })
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. 복사 시작 버튼 클릭
|
||||||
|
console.log("6. 복사 시작...");
|
||||||
|
const copyStartButton = page.getByRole("button", { name: /복사 시작|확인/ }).first();
|
||||||
|
await copyStartButton.click();
|
||||||
|
|
||||||
|
// 7. 복사 완료 대기 (최대 5분)
|
||||||
|
console.log("7. 복사 완료 대기 (최대 5분)...");
|
||||||
|
try {
|
||||||
|
await page.waitForSelector('text=완료, text=성공, [role="status"]', { timeout: 300000 });
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
} catch {
|
||||||
|
console.log("타임아웃 또는 완료 메시지 대기 중...");
|
||||||
|
}
|
||||||
|
await takeScreenshot(page, "07_copy_result").then((p) =>
|
||||||
|
screenshots.push({ step: "복사 결과", path: p })
|
||||||
|
);
|
||||||
|
|
||||||
|
// 결과 확인
|
||||||
|
const resultText = await page.locator("body").textContent();
|
||||||
|
if (resultText?.includes("완료") || resultText?.includes("성공")) {
|
||||||
|
console.log("\n=== 메뉴 복사 성공 ===");
|
||||||
|
} else if (resultText?.includes("오류") || resultText?.includes("실패") || resultText?.includes("error")) {
|
||||||
|
console.log("\n=== 에러 발생 가능 - 스크린샷 확인 필요 ===");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n=== 스크린샷 목록 ===");
|
||||||
|
screenshots.forEach((s) => console.log(` - ${s.step}: ${s.path}`));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("오류 발생:", error);
|
||||||
|
if (browser) {
|
||||||
|
const pages = (browser as any).contexts?.()?.[0]?.pages?.() || [];
|
||||||
|
for (const p of pages) {
|
||||||
|
try {
|
||||||
|
await takeScreenshot(p, "error_state").then((path) =>
|
||||||
|
screenshots.push({ step: "에러 상태", path })
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (browser) {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
Loading…
Reference in New Issue