Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream
This commit is contained in:
commit
8e455def0c
1
PLAN.MD
1
PLAN.MD
|
|
@ -25,3 +25,4 @@ Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러
|
|||
## 진행 상태
|
||||
|
||||
- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중
|
||||
|
||||
|
|
|
|||
|
|
@ -55,3 +55,4 @@
|
|||
- `backend-node/src/routes/digitalTwinRoutes.ts`
|
||||
- `db/migrations/042_refactor_digital_twin_hierarchy.sql`
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관
|
|||
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
|
||||
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
||||
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
||||
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
|
||||
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
|
||||
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
||||
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
|
||||
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
|
||||
|
|
@ -222,7 +222,7 @@ app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리
|
|||
app.use("/api/todos", todoRoutes); // To-Do 관리
|
||||
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
|
||||
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
|
||||
app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
|
||||
app.use("/api/yard-layouts", yardLayoutRoutes); // 3D 필드
|
||||
// app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석)
|
||||
app.use("/api/digital-twin", digitalTwinRoutes); // 디지털 트윈 (야드 관제)
|
||||
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { AdminService } from "../services/adminService";
|
|||
import { EncryptUtil } from "../utils/encryptUtil";
|
||||
import { FileSystemManager } from "../utils/fileSystemManager";
|
||||
import { validateBusinessNumber } from "../utils/businessNumberValidator";
|
||||
import { MenuCopyService } from "../services/menuCopyService";
|
||||
|
||||
/**
|
||||
* 관리자 메뉴 목록 조회
|
||||
|
|
@ -1096,7 +1097,11 @@ export async function saveMenu(
|
|||
let requestCompanyCode = menuData.companyCode || menuData.company_code;
|
||||
|
||||
// "none"이나 빈 값은 undefined로 처리하여 사용자 회사 코드 사용
|
||||
if (requestCompanyCode === "none" || requestCompanyCode === "" || !requestCompanyCode) {
|
||||
if (
|
||||
requestCompanyCode === "none" ||
|
||||
requestCompanyCode === "" ||
|
||||
!requestCompanyCode
|
||||
) {
|
||||
requestCompanyCode = undefined;
|
||||
}
|
||||
|
||||
|
|
@ -1251,7 +1256,8 @@ export async function updateMenu(
|
|||
}
|
||||
}
|
||||
|
||||
const requestCompanyCode = menuData.companyCode || menuData.company_code || currentMenu.company_code;
|
||||
const requestCompanyCode =
|
||||
menuData.companyCode || menuData.company_code || currentMenu.company_code;
|
||||
|
||||
// company_code 변경 시도하는 경우 권한 체크
|
||||
if (requestCompanyCode !== currentMenu.company_code) {
|
||||
|
|
@ -1267,7 +1273,10 @@ export async function updateMenu(
|
|||
}
|
||||
}
|
||||
// 회사 관리자는 자기 회사로만 변경 가능
|
||||
else if (userCompanyCode !== "*" && requestCompanyCode !== userCompanyCode) {
|
||||
else if (
|
||||
userCompanyCode !== "*" &&
|
||||
requestCompanyCode !== userCompanyCode
|
||||
) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "해당 회사로 변경할 권한이 없습니다.",
|
||||
|
|
@ -1323,7 +1332,7 @@ export async function updateMenu(
|
|||
if (!menuUrl) {
|
||||
await query(
|
||||
`UPDATE screen_menu_assignments
|
||||
SET is_active = 'N', updated_date = NOW()
|
||||
SET is_active = 'N'
|
||||
WHERE menu_objid = $1 AND company_code = $2`,
|
||||
[Number(menuId), companyCode]
|
||||
);
|
||||
|
|
@ -1492,8 +1501,13 @@ export async function deleteMenusBatch(
|
|||
);
|
||||
|
||||
// 권한 체크: 공통 메뉴 포함 여부 확인
|
||||
const hasCommonMenu = menusToDelete.some((menu: any) => menu.company_code === "*");
|
||||
if (hasCommonMenu && (userCompanyCode !== "*" || userType !== "SUPER_ADMIN")) {
|
||||
const hasCommonMenu = menusToDelete.some(
|
||||
(menu: any) => menu.company_code === "*"
|
||||
);
|
||||
if (
|
||||
hasCommonMenu &&
|
||||
(userCompanyCode !== "*" || userType !== "SUPER_ADMIN")
|
||||
) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공통 메뉴는 최고 관리자만 삭제할 수 있습니다.",
|
||||
|
|
@ -1505,7 +1519,8 @@ export async function deleteMenusBatch(
|
|||
// 회사 관리자는 자기 회사 메뉴만 삭제 가능
|
||||
if (userCompanyCode !== "*") {
|
||||
const unauthorizedMenus = menusToDelete.filter(
|
||||
(menu: any) => menu.company_code !== userCompanyCode && menu.company_code !== "*"
|
||||
(menu: any) =>
|
||||
menu.company_code !== userCompanyCode && menu.company_code !== "*"
|
||||
);
|
||||
if (unauthorizedMenus.length > 0) {
|
||||
res.status(403).json({
|
||||
|
|
@ -2673,7 +2688,10 @@ export const getCompanyByCode = async (
|
|||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("회사 정보 조회 실패", { error, companyCode: req.params.companyCode });
|
||||
logger.error("회사 정보 조회 실패", {
|
||||
error,
|
||||
companyCode: req.params.companyCode,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "회사 정보 조회 중 오류가 발생했습니다.",
|
||||
|
|
@ -2739,7 +2757,9 @@ export const updateCompany = async (
|
|||
// 사업자등록번호 중복 체크 및 유효성 검증 (자기 자신 제외)
|
||||
if (business_registration_number && business_registration_number.trim()) {
|
||||
// 유효성 검증
|
||||
const businessNumberValidation = validateBusinessNumber(business_registration_number.trim());
|
||||
const businessNumberValidation = validateBusinessNumber(
|
||||
business_registration_number.trim()
|
||||
);
|
||||
if (!businessNumberValidation.isValid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
|
|
@ -3253,3 +3273,95 @@ export async function getTableSchema(
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 복사
|
||||
* POST /api/admin/menus/:menuObjid/copy
|
||||
*/
|
||||
export async function copyMenu(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { menuObjid } = req.params;
|
||||
const { targetCompanyCode } = req.body;
|
||||
const userId = req.user!.userId;
|
||||
const userCompanyCode = req.user!.companyCode;
|
||||
const userType = req.user!.userType;
|
||||
const isSuperAdmin = req.user!.isSuperAdmin;
|
||||
|
||||
logger.info(`
|
||||
=== 메뉴 복사 API 호출 ===
|
||||
menuObjid: ${menuObjid}
|
||||
targetCompanyCode: ${targetCompanyCode}
|
||||
userId: ${userId}
|
||||
userCompanyCode: ${userCompanyCode}
|
||||
userType: ${userType}
|
||||
isSuperAdmin: ${isSuperAdmin}
|
||||
`);
|
||||
|
||||
// 권한 체크: 최고 관리자만 가능
|
||||
if (!isSuperAdmin && userType !== "SUPER_ADMIN") {
|
||||
logger.warn(
|
||||
`권한 없음: ${userId} (userType=${userType}, company_code=${userCompanyCode})`
|
||||
);
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "메뉴 복사는 최고 관리자(SUPER_ADMIN)만 가능합니다",
|
||||
error: {
|
||||
code: "FORBIDDEN",
|
||||
details: "Only super admin can copy menus",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 필수 파라미터 검증
|
||||
if (!menuObjid || !targetCompanyCode) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 파라미터가 누락되었습니다",
|
||||
error: {
|
||||
code: "MISSING_PARAMETERS",
|
||||
details: "menuObjid and targetCompanyCode are required",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 화면명 변환 설정 (선택사항)
|
||||
const screenNameConfig = req.body.screenNameConfig
|
||||
? {
|
||||
removeText: req.body.screenNameConfig.removeText,
|
||||
addPrefix: req.body.screenNameConfig.addPrefix,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// 메뉴 복사 실행
|
||||
const menuCopyService = new MenuCopyService();
|
||||
const result = await menuCopyService.copyMenu(
|
||||
parseInt(menuObjid, 10),
|
||||
targetCompanyCode,
|
||||
userId,
|
||||
screenNameConfig
|
||||
);
|
||||
|
||||
logger.info("✅ 메뉴 복사 API 성공");
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "메뉴 복사 완료",
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("❌ 메뉴 복사 API 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "메뉴 복사 중 오류가 발생했습니다",
|
||||
error: {
|
||||
code: "MENU_COPY_ERROR",
|
||||
details: error.message || "Unknown error",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import {
|
||||
DigitalTwinTemplateService,
|
||||
DigitalTwinLayoutTemplate,
|
||||
} from "../services/DigitalTwinTemplateService";
|
||||
|
||||
export const listMappingTemplates = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const externalDbConnectionId = req.query.externalDbConnectionId
|
||||
? Number(req.query.externalDbConnectionId)
|
||||
: undefined;
|
||||
const layoutType =
|
||||
typeof req.query.layoutType === "string"
|
||||
? req.query.layoutType
|
||||
: undefined;
|
||||
|
||||
const result = await DigitalTwinTemplateService.listTemplates(
|
||||
companyCode,
|
||||
{
|
||||
externalDbConnectionId,
|
||||
layoutType,
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: result.message,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data as DigitalTwinLayoutTemplate[],
|
||||
});
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getMappingTemplateById = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await DigitalTwinTemplateService.getTemplateById(
|
||||
companyCode,
|
||||
id,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: result.message || "매핑 템플릿을 찾을 수 없습니다.",
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const createMappingTemplate = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!companyCode || !userId) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
externalDbConnectionId,
|
||||
layoutType,
|
||||
config,
|
||||
} = req.body;
|
||||
|
||||
if (!name || !externalDbConnectionId || !config) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await DigitalTwinTemplateService.createTemplate(
|
||||
companyCode,
|
||||
userId,
|
||||
{
|
||||
name,
|
||||
description,
|
||||
externalDbConnectionId,
|
||||
layoutType,
|
||||
config,
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: result.message || "매핑 템플릿 생성 중 오류가 발생했습니다.",
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "매핑 템플릿 생성 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
@ -165,7 +165,7 @@ export async function createOrder(req: AuthenticatedRequest, res: Response) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 수주 목록 조회 API
|
||||
* 수주 목록 조회 API (마스터 + 품목 JOIN)
|
||||
* GET /api/orders
|
||||
*/
|
||||
export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
||||
|
|
@ -184,14 +184,14 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
|||
|
||||
// 멀티테넌시 (writer 필드에 company_code 포함)
|
||||
if (companyCode !== "*") {
|
||||
whereConditions.push(`writer LIKE $${paramIndex}`);
|
||||
whereConditions.push(`m.writer LIKE $${paramIndex}`);
|
||||
params.push(`%${companyCode}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 검색
|
||||
if (searchText) {
|
||||
whereConditions.push(`objid LIKE $${paramIndex}`);
|
||||
whereConditions.push(`m.objid LIKE $${paramIndex}`);
|
||||
params.push(`%${searchText}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
|
@ -201,16 +201,47 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
|||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 카운트 쿼리
|
||||
const countQuery = `SELECT COUNT(*) as count FROM order_mng_master ${whereClause}`;
|
||||
// 카운트 쿼리 (고유한 수주 개수)
|
||||
const countQuery = `
|
||||
SELECT COUNT(DISTINCT m.objid) as count
|
||||
FROM order_mng_master m
|
||||
${whereClause}
|
||||
`;
|
||||
const countResult = await pool.query(countQuery, params);
|
||||
const total = parseInt(countResult.rows[0]?.count || "0");
|
||||
|
||||
// 데이터 쿼리
|
||||
// 데이터 쿼리 (마스터 + 품목 JOIN)
|
||||
const dataQuery = `
|
||||
SELECT * FROM order_mng_master
|
||||
SELECT
|
||||
m.objid as order_no,
|
||||
m.partner_objid,
|
||||
m.final_delivery_date,
|
||||
m.reason,
|
||||
m.status,
|
||||
m.reg_date,
|
||||
m.writer,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
CASE WHEN s.objid IS NOT NULL THEN
|
||||
json_build_object(
|
||||
'sub_objid', s.objid,
|
||||
'part_objid', s.part_objid,
|
||||
'partner_price', s.partner_price,
|
||||
'partner_qty', s.partner_qty,
|
||||
'delivery_date', s.delivery_date,
|
||||
'status', s.status,
|
||||
'regdate', s.regdate
|
||||
)
|
||||
END
|
||||
ORDER BY s.regdate
|
||||
) FILTER (WHERE s.objid IS NOT NULL),
|
||||
'[]'::json
|
||||
) as items
|
||||
FROM order_mng_master m
|
||||
LEFT JOIN order_mng_sub s ON m.objid = s.order_mng_master_objid
|
||||
${whereClause}
|
||||
ORDER BY reg_date DESC
|
||||
GROUP BY m.objid, m.partner_objid, m.final_delivery_date, m.reason, m.status, m.reg_date, m.writer
|
||||
ORDER BY m.reg_date DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
|
|
@ -219,6 +250,13 @@ export async function getOrders(req: AuthenticatedRequest, res: Response) {
|
|||
|
||||
const dataResult = await pool.query(dataQuery, params);
|
||||
|
||||
logger.info("수주 목록 조회 성공", {
|
||||
companyCode,
|
||||
total,
|
||||
page: parseInt(page as string),
|
||||
itemCount: dataResult.rows.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: dataResult.rows,
|
||||
|
|
|
|||
|
|
@ -148,11 +148,11 @@ export const updateScreenInfo = async (
|
|||
try {
|
||||
const { id } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const { screenName, description, isActive } = req.body;
|
||||
const { screenName, tableName, description, isActive } = req.body;
|
||||
|
||||
await screenManagementService.updateScreenInfo(
|
||||
parseInt(id),
|
||||
{ screenName, description, isActive },
|
||||
{ screenName, tableName, description, isActive },
|
||||
companyCode
|
||||
);
|
||||
res.json({ success: true, message: "화면 정보가 수정되었습니다." });
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
||||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
||||
import {
|
||||
DatabaseConnector,
|
||||
ConnectionConfig,
|
||||
QueryResult,
|
||||
} from "../interfaces/DatabaseConnector";
|
||||
import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
|
||||
// @ts-ignore
|
||||
import * as mysql from 'mysql2/promise';
|
||||
import * as mysql from "mysql2/promise";
|
||||
|
||||
export class MariaDBConnector implements DatabaseConnector {
|
||||
private connection: mysql.Connection | null = null;
|
||||
|
|
@ -20,7 +24,7 @@ export class MariaDBConnector implements DatabaseConnector {
|
|||
password: this.config.password,
|
||||
database: this.config.database,
|
||||
connectTimeout: this.config.connectionTimeoutMillis,
|
||||
ssl: typeof this.config.ssl === 'boolean' ? undefined : this.config.ssl,
|
||||
ssl: typeof this.config.ssl === "boolean" ? undefined : this.config.ssl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -36,7 +40,9 @@ export class MariaDBConnector implements DatabaseConnector {
|
|||
const startTime = Date.now();
|
||||
try {
|
||||
await this.connect();
|
||||
const [rows] = await this.connection!.query("SELECT VERSION() as version");
|
||||
const [rows] = await this.connection!.query(
|
||||
"SELECT VERSION() as version"
|
||||
);
|
||||
const version = (rows as any[])[0]?.version || "Unknown";
|
||||
const responseTime = Date.now() - startTime;
|
||||
await this.disconnect();
|
||||
|
|
@ -89,15 +95,13 @@ export class MariaDBConnector implements DatabaseConnector {
|
|||
ORDER BY TABLE_NAME;
|
||||
`);
|
||||
|
||||
const tables: TableInfo[] = [];
|
||||
for (const row of rows as any[]) {
|
||||
const columns = await this.getColumns(row.table_name);
|
||||
tables.push({
|
||||
table_name: row.table_name,
|
||||
description: row.description || null,
|
||||
columns: columns,
|
||||
});
|
||||
}
|
||||
// 테이블 목록만 반환 (컬럼 정보는 getColumns에서 개별 조회)
|
||||
const tables: TableInfo[] = (rows as any[]).map((row) => ({
|
||||
table_name: row.table_name,
|
||||
description: row.description || null,
|
||||
columns: [],
|
||||
}));
|
||||
|
||||
await this.disconnect();
|
||||
return tables;
|
||||
} catch (error: any) {
|
||||
|
|
@ -111,21 +115,43 @@ export class MariaDBConnector implements DatabaseConnector {
|
|||
console.log(`[MariaDBConnector] getColumns 호출: tableName=${tableName}`);
|
||||
await this.connect();
|
||||
console.log(`[MariaDBConnector] 연결 완료, 쿼리 실행 시작`);
|
||||
|
||||
const [rows] = await this.connection!.query(`
|
||||
|
||||
const [rows] = await this.connection!.query(
|
||||
`
|
||||
SELECT
|
||||
COLUMN_NAME as column_name,
|
||||
DATA_TYPE as data_type,
|
||||
IS_NULLABLE as is_nullable,
|
||||
COLUMN_DEFAULT as column_default
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
`, [tableName]);
|
||||
|
||||
c.COLUMN_NAME AS column_name,
|
||||
c.DATA_TYPE AS data_type,
|
||||
c.IS_NULLABLE AS is_nullable,
|
||||
c.COLUMN_DEFAULT AS column_default,
|
||||
c.COLUMN_COMMENT AS description,
|
||||
CASE
|
||||
WHEN tc.CONSTRAINT_TYPE = 'PRIMARY KEY' THEN 'YES'
|
||||
ELSE 'NO'
|
||||
END AS is_primary_key
|
||||
FROM information_schema.COLUMNS c
|
||||
LEFT JOIN information_schema.KEY_COLUMN_USAGE k
|
||||
ON c.TABLE_SCHEMA = k.TABLE_SCHEMA
|
||||
AND c.TABLE_NAME = k.TABLE_NAME
|
||||
AND c.COLUMN_NAME = k.COLUMN_NAME
|
||||
LEFT JOIN information_schema.TABLE_CONSTRAINTS tc
|
||||
ON k.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA
|
||||
AND k.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
|
||||
AND k.TABLE_SCHEMA = tc.TABLE_SCHEMA
|
||||
AND k.TABLE_NAME = tc.TABLE_NAME
|
||||
AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY'
|
||||
WHERE c.TABLE_SCHEMA = DATABASE()
|
||||
AND c.TABLE_NAME = ?
|
||||
ORDER BY c.ORDINAL_POSITION;
|
||||
`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
console.log(`[MariaDBConnector] 쿼리 결과:`, rows);
|
||||
console.log(`[MariaDBConnector] 결과 개수:`, Array.isArray(rows) ? rows.length : 'not array');
|
||||
|
||||
console.log(
|
||||
`[MariaDBConnector] 결과 개수:`,
|
||||
Array.isArray(rows) ? rows.length : "not array"
|
||||
);
|
||||
|
||||
await this.disconnect();
|
||||
return rows as any[];
|
||||
} catch (error: any) {
|
||||
|
|
|
|||
|
|
@ -210,15 +210,33 @@ export class PostgreSQLConnector implements DatabaseConnector {
|
|||
const result = await tempClient.query(
|
||||
`
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default,
|
||||
col_description(c.oid, a.attnum) as column_comment
|
||||
isc.column_name,
|
||||
isc.data_type,
|
||||
isc.is_nullable,
|
||||
isc.column_default,
|
||||
col_description(c.oid, a.attnum) as column_comment,
|
||||
CASE
|
||||
WHEN tc.constraint_type = 'PRIMARY KEY' THEN 'YES'
|
||||
ELSE 'NO'
|
||||
END AS is_primary_key
|
||||
FROM information_schema.columns isc
|
||||
LEFT JOIN pg_class c ON c.relname = isc.table_name
|
||||
LEFT JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = isc.column_name
|
||||
WHERE isc.table_schema = 'public' AND isc.table_name = $1
|
||||
LEFT JOIN pg_class c
|
||||
ON c.relname = isc.table_name
|
||||
AND c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = isc.table_schema)
|
||||
LEFT JOIN pg_attribute a
|
||||
ON a.attrelid = c.oid
|
||||
AND a.attname = isc.column_name
|
||||
LEFT JOIN information_schema.key_column_usage k
|
||||
ON k.table_name = isc.table_name
|
||||
AND k.table_schema = isc.table_schema
|
||||
AND k.column_name = isc.column_name
|
||||
LEFT JOIN information_schema.table_constraints tc
|
||||
ON tc.constraint_name = k.constraint_name
|
||||
AND tc.table_schema = k.table_schema
|
||||
AND tc.table_name = k.table_name
|
||||
AND tc.constraint_type = 'PRIMARY KEY'
|
||||
WHERE isc.table_schema = 'public'
|
||||
AND isc.table_name = $1
|
||||
ORDER BY isc.ordinal_position;
|
||||
`,
|
||||
[tableName]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
deleteMenu, // 메뉴 삭제
|
||||
deleteMenusBatch, // 메뉴 일괄 삭제
|
||||
toggleMenuStatus, // 메뉴 상태 토글
|
||||
copyMenu, // 메뉴 복사
|
||||
getUserList,
|
||||
getUserInfo, // 사용자 상세 조회
|
||||
getUserHistory, // 사용자 변경이력 조회
|
||||
|
|
@ -39,6 +40,7 @@ router.get("/menus", getAdminMenus);
|
|||
router.get("/user-menus", getUserMenus);
|
||||
router.get("/menus/:menuId", getMenuInfo);
|
||||
router.post("/menus", saveMenu); // 메뉴 추가
|
||||
router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!)
|
||||
router.put("/menus/:menuId", updateMenu); // 메뉴 수정
|
||||
router.put("/menus/:menuId/toggle", toggleMenuStatus); // 메뉴 상태 토글
|
||||
router.delete("/menus/batch", deleteMenusBatch); // 메뉴 일괄 삭제 (순서 중요!)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { query, queryOne } from "../../database/db";
|
|||
import { logger } from "../../utils/logger";
|
||||
import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService";
|
||||
import { AuthenticatedRequest } from "../../types/auth";
|
||||
import { authenticateToken } from "../../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
|
@ -217,19 +218,29 @@ router.delete("/:flowId", async (req: Request, res: Response) => {
|
|||
* 플로우 실행
|
||||
* POST /api/dataflow/node-flows/:flowId/execute
|
||||
*/
|
||||
router.post("/:flowId/execute", async (req: Request, res: Response) => {
|
||||
router.post("/:flowId/execute", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
const contextData = req.body;
|
||||
|
||||
logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, {
|
||||
contextDataKeys: Object.keys(contextData),
|
||||
userId: req.user?.userId,
|
||||
companyCode: req.user?.companyCode,
|
||||
});
|
||||
|
||||
// 사용자 정보를 contextData에 추가
|
||||
const enrichedContextData = {
|
||||
...contextData,
|
||||
userId: req.user?.userId,
|
||||
userName: req.user?.userName,
|
||||
companyCode: req.user?.companyCode,
|
||||
};
|
||||
|
||||
// 플로우 실행
|
||||
const result = await NodeFlowExecutionService.executeFlow(
|
||||
parseInt(flowId, 10),
|
||||
contextData
|
||||
enrichedContextData
|
||||
);
|
||||
|
||||
return res.json({
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ import {
|
|||
updateLayout,
|
||||
deleteLayout,
|
||||
} from "../controllers/digitalTwinLayoutController";
|
||||
import {
|
||||
listMappingTemplates,
|
||||
getMappingTemplateById,
|
||||
createMappingTemplate,
|
||||
} from "../controllers/digitalTwinTemplateController";
|
||||
|
||||
// 외부 DB 데이터 조회
|
||||
import {
|
||||
|
|
@ -27,11 +32,16 @@ const router = express.Router();
|
|||
router.use(authenticateToken);
|
||||
|
||||
// ========== 레이아웃 관리 API ==========
|
||||
router.get("/layouts", getLayouts); // 레이아웃 목록
|
||||
router.get("/layouts/:id", getLayoutById); // 레이아웃 상세
|
||||
router.post("/layouts", createLayout); // 레이아웃 생성
|
||||
router.put("/layouts/:id", updateLayout); // 레이아웃 수정
|
||||
router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제
|
||||
router.get("/layouts", getLayouts); // 레이아웃 목록
|
||||
router.get("/layouts/:id", getLayoutById); // 레이아웃 상세
|
||||
router.post("/layouts", createLayout); // 레이아웃 생성
|
||||
router.put("/layouts/:id", updateLayout); // 레이아웃 수정
|
||||
router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제
|
||||
|
||||
// ========== 매핑 템플릿 API ==========
|
||||
router.get("/mapping-templates", listMappingTemplates);
|
||||
router.get("/mapping-templates/:id", getMappingTemplateById);
|
||||
router.post("/mapping-templates", createMappingTemplate);
|
||||
|
||||
// ========== 외부 DB 데이터 조회 API ==========
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
import { pool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
export interface DigitalTwinLayoutTemplate {
|
||||
id: string;
|
||||
company_code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
external_db_connection_id: number;
|
||||
layout_type: string;
|
||||
config: any;
|
||||
created_by: string;
|
||||
created_at: Date;
|
||||
updated_by: string;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
interface ServiceResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class DigitalTwinTemplateService {
|
||||
static async listTemplates(
|
||||
companyCode: string,
|
||||
options: { externalDbConnectionId?: number; layoutType?: string } = {},
|
||||
): Promise<ServiceResponse<DigitalTwinLayoutTemplate[]>> {
|
||||
try {
|
||||
const params: any[] = [companyCode];
|
||||
let paramIndex = 2;
|
||||
|
||||
let query = `
|
||||
SELECT *
|
||||
FROM digital_twin_layout_template
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
|
||||
if (options.layoutType) {
|
||||
query += ` AND layout_type = $${paramIndex++}`;
|
||||
params.push(options.layoutType);
|
||||
}
|
||||
|
||||
if (options.externalDbConnectionId) {
|
||||
query += ` AND external_db_connection_id = $${paramIndex++}`;
|
||||
params.push(options.externalDbConnectionId);
|
||||
}
|
||||
|
||||
query += `
|
||||
ORDER BY updated_at DESC, name ASC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("디지털 트윈 매핑 템플릿 목록 조회", {
|
||||
companyCode,
|
||||
count: result.rowCount,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.rows as DigitalTwinLayoutTemplate[],
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error("디지털 트윈 매핑 템플릿 목록 조회 실패", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static async getTemplateById(
|
||||
companyCode: string,
|
||||
id: string,
|
||||
): Promise<ServiceResponse<DigitalTwinLayoutTemplate>> {
|
||||
try {
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM digital_twin_layout_template
|
||||
WHERE id = $1 AND company_code = $2
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [id, companyCode]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "매핑 템플릿을 찾을 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.rows[0] as DigitalTwinLayoutTemplate,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error("디지털 트윈 매핑 템플릿 조회 실패", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
message: "매핑 템플릿 조회 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static async createTemplate(
|
||||
companyCode: string,
|
||||
userId: string,
|
||||
payload: {
|
||||
name: string;
|
||||
description?: string;
|
||||
externalDbConnectionId: number;
|
||||
layoutType?: string;
|
||||
config: any;
|
||||
},
|
||||
): Promise<ServiceResponse<DigitalTwinLayoutTemplate>> {
|
||||
try {
|
||||
const query = `
|
||||
INSERT INTO digital_twin_layout_template (
|
||||
company_code,
|
||||
name,
|
||||
description,
|
||||
external_db_connection_id,
|
||||
layout_type,
|
||||
config,
|
||||
created_by,
|
||||
created_at,
|
||||
updated_by,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $7, NOW())
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const values = [
|
||||
companyCode,
|
||||
payload.name,
|
||||
payload.description || null,
|
||||
payload.externalDbConnectionId,
|
||||
payload.layoutType || "yard-3d",
|
||||
JSON.stringify(payload.config),
|
||||
userId,
|
||||
];
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
|
||||
logger.info("디지털 트윈 매핑 템플릿 생성", {
|
||||
companyCode,
|
||||
templateId: result.rows[0].id,
|
||||
externalDbConnectionId: payload.externalDbConnectionId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.rows[0] as DigitalTwinLayoutTemplate,
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error("디지털 트윈 매핑 템플릿 생성 실패", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
message: "매핑 템플릿 생성 중 오류가 발생했습니다.",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -320,19 +320,34 @@ export class DynamicFormService {
|
|||
Object.keys(dataToInsert).forEach((key) => {
|
||||
const value = dataToInsert[key];
|
||||
|
||||
// RepeaterInput 데이터인지 확인 (JSON 배열 문자열)
|
||||
if (
|
||||
// 🔥 RepeaterInput 데이터인지 확인 (배열 객체 또는 JSON 문자열)
|
||||
let parsedArray: any[] | null = null;
|
||||
|
||||
// 1️⃣ 이미 배열 객체인 경우 (ModalRepeaterTable, SelectedItemsDetailInput 등)
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
parsedArray = value;
|
||||
console.log(
|
||||
`🔄 배열 객체 Repeater 데이터 감지: ${key}, ${parsedArray.length}개 항목`
|
||||
);
|
||||
}
|
||||
// 2️⃣ JSON 문자열인 경우 (레거시 RepeaterInput)
|
||||
else if (
|
||||
typeof value === "string" &&
|
||||
value.trim().startsWith("[") &&
|
||||
value.trim().endsWith("]")
|
||||
) {
|
||||
try {
|
||||
const parsedArray = JSON.parse(value);
|
||||
if (Array.isArray(parsedArray) && parsedArray.length > 0) {
|
||||
parsedArray = JSON.parse(value);
|
||||
console.log(
|
||||
`🔄 RepeaterInput 데이터 감지: ${key}, ${parsedArray.length}개 항목`
|
||||
`🔄 JSON 문자열 Repeater 데이터 감지: ${key}, ${parsedArray?.length || 0}개 항목`
|
||||
);
|
||||
} catch (parseError) {
|
||||
console.log(`⚠️ JSON 파싱 실패: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 파싱된 배열이 있으면 처리
|
||||
if (parsedArray && Array.isArray(parsedArray) && parsedArray.length > 0) {
|
||||
// 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해)
|
||||
// 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음
|
||||
let targetTable: string | undefined;
|
||||
|
|
@ -352,13 +367,34 @@ export class DynamicFormService {
|
|||
componentId: key,
|
||||
});
|
||||
delete dataToInsert[key]; // 원본 배열 데이터는 제거
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log(`⚠️ JSON 파싱 실패: ${key}`);
|
||||
}
|
||||
|
||||
console.log(`✅ Repeater 데이터 추가: ${key}`, {
|
||||
targetTable: targetTable || "없음 (화면 설계에서 설정 필요)",
|
||||
itemCount: actualData.length,
|
||||
firstItem: actualData[0],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 🔥 Repeater targetTable이 메인 테이블과 같으면 분리해서 저장
|
||||
const separateRepeaterData: typeof repeaterData = [];
|
||||
const mergedRepeaterData: typeof repeaterData = [];
|
||||
|
||||
repeaterData.forEach(repeater => {
|
||||
if (repeater.targetTable && repeater.targetTable !== tableName) {
|
||||
// 다른 테이블: 나중에 별도 저장
|
||||
separateRepeaterData.push(repeater);
|
||||
} else {
|
||||
// 같은 테이블: 메인 INSERT와 병합 (헤더+품목을 한 번에)
|
||||
mergedRepeaterData.push(repeater);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`🔄 Repeater 데이터 분류:`, {
|
||||
separate: separateRepeaterData.length, // 별도 테이블
|
||||
merged: mergedRepeaterData.length, // 메인 테이블과 병합
|
||||
});
|
||||
|
||||
// 존재하지 않는 컬럼 제거
|
||||
Object.keys(dataToInsert).forEach((key) => {
|
||||
if (!tableColumns.includes(key)) {
|
||||
|
|
@ -369,9 +405,6 @@ export class DynamicFormService {
|
|||
}
|
||||
});
|
||||
|
||||
// RepeaterInput 데이터 처리 로직은 메인 저장 후에 처리
|
||||
// (각 Repeater가 다른 테이블에 저장될 수 있으므로)
|
||||
|
||||
console.log("🎯 실제 테이블에 삽입할 데이터:", {
|
||||
tableName,
|
||||
dataToInsert,
|
||||
|
|
@ -452,28 +485,106 @@ export class DynamicFormService {
|
|||
const userId = data.updated_by || data.created_by || "system";
|
||||
const clientIp = ipAddress || "unknown";
|
||||
|
||||
const result = await transaction(async (client) => {
|
||||
// 세션 변수 설정
|
||||
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
||||
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
|
||||
|
||||
// UPSERT 실행
|
||||
const res = await client.query(upsertQuery, values);
|
||||
return res.rows;
|
||||
});
|
||||
|
||||
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
|
||||
let result: any[];
|
||||
|
||||
// 🔥 메인 테이블과 병합할 Repeater가 있으면 각 품목별로 INSERT
|
||||
if (mergedRepeaterData.length > 0) {
|
||||
console.log(`🔄 메인 테이블 병합 모드: ${mergedRepeaterData.length}개 Repeater를 개별 레코드로 저장`);
|
||||
|
||||
result = [];
|
||||
|
||||
for (const repeater of mergedRepeaterData) {
|
||||
for (const item of repeater.data) {
|
||||
// 헤더 + 품목을 병합
|
||||
const rawMergedData = { ...dataToInsert, ...item };
|
||||
|
||||
// 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외)
|
||||
const validColumnNames = columnInfo.map((col) => col.column_name);
|
||||
const mergedData: Record<string, any> = {};
|
||||
|
||||
Object.keys(rawMergedData).forEach((columnName) => {
|
||||
// 실제 테이블 컬럼인지 확인
|
||||
if (validColumnNames.includes(columnName)) {
|
||||
const column = columnInfo.find((col) => col.column_name === columnName);
|
||||
if (column) {
|
||||
// 타입 변환
|
||||
mergedData[columnName] = this.convertValueForPostgreSQL(
|
||||
rawMergedData[columnName],
|
||||
column.data_type
|
||||
);
|
||||
} else {
|
||||
mergedData[columnName] = rawMergedData[columnName];
|
||||
}
|
||||
} else {
|
||||
console.log(`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`);
|
||||
}
|
||||
});
|
||||
|
||||
const mergedColumns = Object.keys(mergedData);
|
||||
const mergedValues: any[] = Object.values(mergedData);
|
||||
const mergedPlaceholders = mergedValues.map((_, index) => `$${index + 1}`).join(", ");
|
||||
|
||||
let mergedUpsertQuery: string;
|
||||
if (primaryKeys.length > 0) {
|
||||
const conflictColumns = primaryKeys.join(", ");
|
||||
const updateSet = mergedColumns
|
||||
.filter((col) => !primaryKeys.includes(col))
|
||||
.map((col) => `${col} = EXCLUDED.${col}`)
|
||||
.join(", ");
|
||||
|
||||
mergedUpsertQuery = updateSet
|
||||
? `INSERT INTO ${tableName} (${mergedColumns.join(", ")})
|
||||
VALUES (${mergedPlaceholders})
|
||||
ON CONFLICT (${conflictColumns})
|
||||
DO UPDATE SET ${updateSet}
|
||||
RETURNING *`
|
||||
: `INSERT INTO ${tableName} (${mergedColumns.join(", ")})
|
||||
VALUES (${mergedPlaceholders})
|
||||
ON CONFLICT (${conflictColumns})
|
||||
DO NOTHING
|
||||
RETURNING *`;
|
||||
} else {
|
||||
mergedUpsertQuery = `INSERT INTO ${tableName} (${mergedColumns.join(", ")})
|
||||
VALUES (${mergedPlaceholders})
|
||||
RETURNING *`;
|
||||
}
|
||||
|
||||
console.log(`📝 병합 INSERT:`, { mergedData });
|
||||
|
||||
const itemResult = await transaction(async (client) => {
|
||||
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
||||
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
|
||||
const res = await client.query(mergedUpsertQuery, mergedValues);
|
||||
return res.rows[0];
|
||||
});
|
||||
|
||||
result.push(itemResult);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ 병합 저장 완료: ${result.length}개 레코드`);
|
||||
} else {
|
||||
// 일반 모드: 헤더만 저장
|
||||
result = await transaction(async (client) => {
|
||||
await client.query(`SET LOCAL app.user_id = '${userId}'`);
|
||||
await client.query(`SET LOCAL app.ip_address = '${clientIp}'`);
|
||||
const res = await client.query(upsertQuery, values);
|
||||
return res.rows;
|
||||
});
|
||||
|
||||
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
|
||||
}
|
||||
|
||||
// 결과를 표준 형식으로 변환
|
||||
const insertedRecord = Array.isArray(result) ? result[0] : result;
|
||||
|
||||
// 📝 RepeaterInput 데이터 저장 (각 Repeater를 해당 테이블에 저장)
|
||||
if (repeaterData.length > 0) {
|
||||
// 📝 별도 테이블 Repeater 데이터 저장
|
||||
if (separateRepeaterData.length > 0) {
|
||||
console.log(
|
||||
`🔄 RepeaterInput 데이터 저장 시작: ${repeaterData.length}개 Repeater`
|
||||
`🔄 별도 테이블 Repeater 저장 시작: ${separateRepeaterData.length}개`
|
||||
);
|
||||
|
||||
for (const repeater of repeaterData) {
|
||||
for (const repeater of separateRepeaterData) {
|
||||
const targetTableName = repeater.targetTable || tableName;
|
||||
console.log(
|
||||
`📝 Repeater "${repeater.componentId}" → 테이블 "${targetTableName}"에 ${repeater.data.length}개 항목 저장`
|
||||
|
|
@ -497,8 +608,13 @@ export class DynamicFormService {
|
|||
created_by,
|
||||
updated_by,
|
||||
regdate: new Date(),
|
||||
// 🔥 멀티테넌시: company_code 필수 추가
|
||||
company_code: data.company_code || company_code,
|
||||
};
|
||||
|
||||
// 🔥 별도 테이블인 경우에만 외래키 추가
|
||||
// (같은 테이블이면 이미 병합 모드에서 처리됨)
|
||||
|
||||
// 대상 테이블에 존재하는 컬럼만 필터링
|
||||
Object.keys(itemData).forEach((key) => {
|
||||
if (!targetColumnNames.includes(key)) {
|
||||
|
|
@ -695,9 +811,39 @@ export class DynamicFormService {
|
|||
const primaryKeyColumn = primaryKeys[0];
|
||||
console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`);
|
||||
|
||||
// 동적 UPDATE SQL 생성 (변경된 필드만)
|
||||
// 🆕 컬럼 타입 조회 (타입 캐스팅용)
|
||||
const columnTypesQuery = `
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = $1 AND table_schema = 'public'
|
||||
`;
|
||||
const columnTypesResult = await query<{ column_name: string; data_type: string }>(
|
||||
columnTypesQuery,
|
||||
[tableName]
|
||||
);
|
||||
const columnTypes: Record<string, string> = {};
|
||||
columnTypesResult.forEach((row) => {
|
||||
columnTypes[row.column_name] = row.data_type;
|
||||
});
|
||||
|
||||
console.log("📊 컬럼 타입 정보:", columnTypes);
|
||||
|
||||
// 🆕 동적 UPDATE SQL 생성 (타입 캐스팅 포함)
|
||||
const setClause = Object.keys(changedFields)
|
||||
.map((key, index) => `${key} = $${index + 1}`)
|
||||
.map((key, index) => {
|
||||
const dataType = columnTypes[key];
|
||||
// 숫자 타입인 경우 명시적 캐스팅
|
||||
if (dataType === 'integer' || dataType === 'bigint' || dataType === 'smallint') {
|
||||
return `${key} = $${index + 1}::integer`;
|
||||
} else if (dataType === 'numeric' || dataType === 'decimal' || dataType === 'real' || dataType === 'double precision') {
|
||||
return `${key} = $${index + 1}::numeric`;
|
||||
} else if (dataType === 'boolean') {
|
||||
return `${key} = $${index + 1}::boolean`;
|
||||
} else {
|
||||
// 문자열 타입은 캐스팅 불필요
|
||||
return `${key} = $${index + 1}`;
|
||||
}
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
const values: any[] = Object.values(changedFields);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -938,6 +938,30 @@ export class NodeFlowExecutionService {
|
|||
insertedData[mapping.targetField] = value;
|
||||
});
|
||||
|
||||
// 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우)
|
||||
const hasWriterMapping = fieldMappings.some((m: any) => m.targetField === "writer");
|
||||
const hasCompanyCodeMapping = fieldMappings.some((m: any) => m.targetField === "company_code");
|
||||
|
||||
// 컨텍스트에서 사용자 정보 추출
|
||||
const userId = context.buttonContext?.userId;
|
||||
const companyCode = context.buttonContext?.companyCode;
|
||||
|
||||
// writer 자동 추가 (매핑에 없고, 컨텍스트에 userId가 있는 경우)
|
||||
if (!hasWriterMapping && userId) {
|
||||
fields.push("writer");
|
||||
values.push(userId);
|
||||
insertedData.writer = userId;
|
||||
console.log(` 🔧 자동 추가: writer = ${userId}`);
|
||||
}
|
||||
|
||||
// company_code 자동 추가 (매핑에 없고, 컨텍스트에 companyCode가 있는 경우)
|
||||
if (!hasCompanyCodeMapping && companyCode && companyCode !== "*") {
|
||||
fields.push("company_code");
|
||||
values.push(companyCode);
|
||||
insertedData.company_code = companyCode;
|
||||
console.log(` 🔧 자동 추가: company_code = ${companyCode}`);
|
||||
}
|
||||
|
||||
const sql = `
|
||||
INSERT INTO ${targetTable} (${fields.join(", ")})
|
||||
VALUES (${fields.map((_, i) => `$${i + 1}`).join(", ")})
|
||||
|
|
|
|||
|
|
@ -300,10 +300,9 @@ class NumberingRuleService {
|
|||
FROM numbering_rules
|
||||
WHERE
|
||||
scope_type = 'global'
|
||||
OR scope_type = 'table'
|
||||
OR (scope_type = 'menu' AND menu_objid = ANY($1))
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ⚠️ 임시: table 스코프도 menu_objid로 필터링
|
||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ⚠️ 임시: 기존 규칙(menu_objid NULL) 포함
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ✅ 메뉴별로 필터링
|
||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
|
||||
|
|
@ -313,9 +312,9 @@ class NumberingRuleService {
|
|||
created_at DESC
|
||||
`;
|
||||
params = [siblingObjids];
|
||||
logger.info("최고 관리자: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { siblingObjids });
|
||||
logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids });
|
||||
} else {
|
||||
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함)
|
||||
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링)
|
||||
query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
|
|
@ -336,10 +335,9 @@ class NumberingRuleService {
|
|||
WHERE company_code = $1
|
||||
AND (
|
||||
scope_type = 'global'
|
||||
OR scope_type = 'table'
|
||||
OR (scope_type = 'menu' AND menu_objid = ANY($2))
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ⚠️ 임시: table 스코프도 menu_objid로 필터링
|
||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ⚠️ 임시: 기존 규칙(menu_objid NULL) 포함
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ✅ 메뉴별로 필터링
|
||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
|
|
@ -350,7 +348,7 @@ class NumberingRuleService {
|
|||
created_at DESC
|
||||
`;
|
||||
params = [companyCode, siblingObjids];
|
||||
logger.info("회사별: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { companyCode, siblingObjids });
|
||||
logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids });
|
||||
}
|
||||
|
||||
logger.info("🔍 채번 규칙 쿼리 실행", {
|
||||
|
|
|
|||
|
|
@ -321,7 +321,7 @@ export class ScreenManagementService {
|
|||
*/
|
||||
async updateScreenInfo(
|
||||
screenId: number,
|
||||
updateData: { screenName: string; description?: string; isActive: string },
|
||||
updateData: { screenName: string; tableName?: string; description?: string; isActive: string },
|
||||
userCompanyCode: string
|
||||
): Promise<void> {
|
||||
// 권한 확인
|
||||
|
|
@ -343,16 +343,18 @@ export class ScreenManagementService {
|
|||
throw new Error("이 화면을 수정할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
// 화면 정보 업데이트
|
||||
// 화면 정보 업데이트 (tableName 포함)
|
||||
await query(
|
||||
`UPDATE screen_definitions
|
||||
SET screen_name = $1,
|
||||
description = $2,
|
||||
is_active = $3,
|
||||
updated_date = $4
|
||||
WHERE screen_id = $5`,
|
||||
table_name = $2,
|
||||
description = $3,
|
||||
is_active = $4,
|
||||
updated_date = $5
|
||||
WHERE screen_id = $6`,
|
||||
[
|
||||
updateData.screenName,
|
||||
updateData.tableName || null,
|
||||
updateData.description || null,
|
||||
updateData.isActive,
|
||||
new Date(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* 수주 관리 타입 정의
|
||||
*/
|
||||
|
||||
/**
|
||||
* 수주 품목 (order_mng_sub)
|
||||
*/
|
||||
export interface OrderItem {
|
||||
sub_objid: string; // 품목 고유 ID (예: ORD-20251121-051_1)
|
||||
part_objid: string; // 품목 코드
|
||||
partner_price: number; // 단가
|
||||
partner_qty: number; // 수량
|
||||
delivery_date: string | null; // 납기일
|
||||
status: string; // 상태
|
||||
regdate: string; // 등록일
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 마스터 (order_mng_master)
|
||||
*/
|
||||
export interface OrderMaster {
|
||||
order_no: string; // 수주 번호 (예: ORD-20251121-051)
|
||||
partner_objid: string; // 거래처 코드
|
||||
final_delivery_date: string | null; // 최종 납품일
|
||||
reason: string | null; // 메모/사유
|
||||
status: string; // 상태
|
||||
reg_date: string; // 등록일
|
||||
writer: string; // 작성자 (userId|companyCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 + 품목 (API 응답)
|
||||
*/
|
||||
export interface OrderWithItems extends OrderMaster {
|
||||
items: OrderItem[]; // 품목 목록
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 등록 요청
|
||||
*/
|
||||
export interface CreateOrderRequest {
|
||||
inputMode: string; // 입력 방식
|
||||
salesType?: string; // 판매 유형 (국내/해외)
|
||||
priceType?: string; // 단가 방식
|
||||
customerCode: string; // 거래처 코드
|
||||
contactPerson?: string; // 담당자
|
||||
deliveryDestination?: string; // 납품처
|
||||
deliveryAddress?: string; // 납품장소
|
||||
deliveryDate?: string; // 납품일
|
||||
items: Array<{
|
||||
item_code?: string; // 품목 코드
|
||||
id?: string; // 품목 ID (item_code 대체)
|
||||
quantity?: number; // 수량
|
||||
unit_price?: number; // 단가
|
||||
selling_price?: number; // 판매가
|
||||
amount?: number; // 금액
|
||||
delivery_date?: string; // 품목별 납기일
|
||||
}>;
|
||||
memo?: string; // 메모
|
||||
tradeInfo?: {
|
||||
// 해외 판매 시
|
||||
incoterms?: string;
|
||||
paymentTerms?: string;
|
||||
currency?: string;
|
||||
portOfLoading?: string;
|
||||
portOfDischarge?: string;
|
||||
hsCode?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 수주 등록 응답
|
||||
*/
|
||||
export interface CreateOrderResponse {
|
||||
orderNo: string; // 생성된 수주 번호
|
||||
masterObjid: string; // 마스터 ID
|
||||
itemCount: number; // 품목 개수
|
||||
totalAmount: number; // 전체 금액
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
# 마이그레이션 1003: source_menu_objid 추가
|
||||
|
||||
## 📋 개요
|
||||
|
||||
메뉴 복사 기능 개선을 위해 `menu_info` 테이블에 `source_menu_objid` 컬럼을 추가합니다.
|
||||
|
||||
## 🎯 목적
|
||||
|
||||
### 이전 방식의 문제점
|
||||
- 메뉴 이름으로만 기존 복사본 판단
|
||||
- 같은 이름의 다른 메뉴도 삭제될 위험
|
||||
- 수동으로 만든 메뉴와 복사된 메뉴 구분 불가
|
||||
|
||||
### 개선 후
|
||||
- 원본 메뉴 ID로 정확히 추적
|
||||
- 같은 원본에서 복사된 메뉴만 덮어쓰기
|
||||
- 수동 메뉴와 복사 메뉴 명확히 구분
|
||||
|
||||
## 🗄️ 스키마 변경
|
||||
|
||||
### 추가되는 컬럼
|
||||
```sql
|
||||
ALTER TABLE menu_info
|
||||
ADD COLUMN source_menu_objid BIGINT;
|
||||
```
|
||||
|
||||
### 인덱스
|
||||
```sql
|
||||
-- 단일 인덱스
|
||||
CREATE INDEX idx_menu_info_source_menu_objid
|
||||
ON menu_info(source_menu_objid);
|
||||
|
||||
-- 복합 인덱스 (회사별 검색 최적화)
|
||||
CREATE INDEX idx_menu_info_source_company
|
||||
ON menu_info(source_menu_objid, company_code);
|
||||
```
|
||||
|
||||
## 📊 데이터 구조
|
||||
|
||||
### 복사된 메뉴의 source_menu_objid 값
|
||||
|
||||
| 메뉴 레벨 | source_menu_objid | 설명 |
|
||||
|-----------|-------------------|------|
|
||||
| 최상위 메뉴 | 원본 메뉴의 objid | 예: 1762407678882 |
|
||||
| 하위 메뉴 | NULL | 최상위 메뉴만 추적 |
|
||||
| 수동 생성 메뉴 | NULL | 복사가 아님 |
|
||||
|
||||
### 예시
|
||||
|
||||
#### 원본 (COMPANY_7)
|
||||
```
|
||||
- 사용자 (objid: 1762407678882)
|
||||
└─ 영업관리 (objid: 1762421877772)
|
||||
└─ 거래처관리 (objid: 1762421920304)
|
||||
```
|
||||
|
||||
#### 복사본 (COMPANY_11)
|
||||
```
|
||||
- 사용자 (objid: 1763688215729, source_menu_objid: 1762407678882) ← 추적
|
||||
└─ 영업관리 (objid: 1763688215739, source_menu_objid: NULL)
|
||||
└─ 거래처관리 (objid: 1763688215743, source_menu_objid: NULL)
|
||||
```
|
||||
|
||||
## 🚀 실행 방법
|
||||
|
||||
### 로컬 PostgreSQL
|
||||
```bash
|
||||
psql -U postgres -d ilshin -f db/migrations/1003_add_source_menu_objid_to_menu_info.sql
|
||||
```
|
||||
|
||||
### Docker 환경
|
||||
```bash
|
||||
# 백엔드 컨테이너를 통해 실행
|
||||
docker exec -i pms-backend-mac bash -c "PGPASSWORD=your_password psql -U postgres -d ilshin" < db/migrations/1003_add_source_menu_objid_to_menu_info.sql
|
||||
```
|
||||
|
||||
### DBeaver / pgAdmin
|
||||
1. `db/migrations/1003_add_source_menu_objid_to_menu_info.sql` 파일 열기
|
||||
2. 전체 스크립트 실행
|
||||
|
||||
## ✅ 확인 방법
|
||||
|
||||
### 1. 컬럼 추가 확인
|
||||
```sql
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'menu_info'
|
||||
AND column_name = 'source_menu_objid';
|
||||
```
|
||||
|
||||
**예상 결과**:
|
||||
```
|
||||
column_name | data_type | is_nullable
|
||||
-------------------|-----------|-------------
|
||||
source_menu_objid | bigint | YES
|
||||
```
|
||||
|
||||
### 2. 인덱스 생성 확인
|
||||
```sql
|
||||
SELECT indexname, indexdef
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'menu_info'
|
||||
AND indexname LIKE '%source%';
|
||||
```
|
||||
|
||||
**예상 결과**:
|
||||
```
|
||||
indexname | indexdef
|
||||
---------------------------------|----------------------------------
|
||||
idx_menu_info_source_menu_objid | CREATE INDEX ...
|
||||
idx_menu_info_source_company | CREATE INDEX ...
|
||||
```
|
||||
|
||||
### 3. 기존 데이터 확인
|
||||
```sql
|
||||
-- 모든 메뉴의 source_menu_objid는 NULL이어야 함 (기존 데이터)
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(source_menu_objid) as with_source
|
||||
FROM menu_info;
|
||||
```
|
||||
|
||||
**예상 결과**:
|
||||
```
|
||||
total | with_source
|
||||
------|-------------
|
||||
114 | 0
|
||||
```
|
||||
|
||||
## 🔄 롤백 (필요 시)
|
||||
|
||||
```sql
|
||||
-- 인덱스 삭제
|
||||
DROP INDEX IF EXISTS idx_menu_info_source_menu_objid;
|
||||
DROP INDEX IF EXISTS idx_menu_info_source_company;
|
||||
|
||||
-- 컬럼 삭제
|
||||
ALTER TABLE menu_info DROP COLUMN IF EXISTS source_menu_objid;
|
||||
```
|
||||
|
||||
## 📝 주의사항
|
||||
|
||||
1. **기존 메뉴는 영향 없음**: 컬럼이 NULL 허용이므로 기존 데이터는 그대로 유지됩니다.
|
||||
2. **복사 기능만 영향**: 메뉴 복사 시에만 `source_menu_objid`가 설정됩니다.
|
||||
3. **백엔드 재시작 필요**: 마이그레이션 후 백엔드를 재시작해야 새 로직이 적용됩니다.
|
||||
|
||||
## 🧪 테스트 시나리오
|
||||
|
||||
### 1. 첫 복사 (source_menu_objid 설정)
|
||||
```
|
||||
원본: 사용자 (objid: 1762407678882, COMPANY_7)
|
||||
복사: 사용자 (objid: 1763688215729, COMPANY_11)
|
||||
source_menu_objid: 1762407678882 ✅
|
||||
```
|
||||
|
||||
### 2. 재복사 (정확한 덮어쓰기)
|
||||
```
|
||||
복사 전 조회:
|
||||
SELECT objid FROM menu_info
|
||||
WHERE source_menu_objid = 1762407678882
|
||||
AND company_code = 'COMPANY_11'
|
||||
→ 1763688215729 발견
|
||||
|
||||
동작:
|
||||
1. objid=1763688215729의 메뉴 트리 전체 삭제
|
||||
2. 새로 복사 (source_menu_objid: 1762407678882)
|
||||
```
|
||||
|
||||
### 3. 다른 메뉴는 영향 없음
|
||||
```
|
||||
수동 메뉴: 관리자 (objid: 1234567890, COMPANY_11)
|
||||
source_menu_objid: NULL ✅
|
||||
|
||||
"사용자" 메뉴 재복사 시:
|
||||
→ 관리자 메뉴는 그대로 유지 ✅
|
||||
```
|
||||
|
||||
## 📚 관련 파일
|
||||
|
||||
- **마이그레이션**: `db/migrations/1003_add_source_menu_objid_to_menu_info.sql`
|
||||
- **백엔드 서비스**: `backend-node/src/services/menuCopyService.ts`
|
||||
- `deleteExistingCopy()`: source_menu_objid로 기존 복사본 찾기
|
||||
- `copyMenus()`: 복사 시 source_menu_objid 저장
|
||||
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
# 마이그레이션 1003 실행 가이드
|
||||
|
||||
## ❌ 현재 에러
|
||||
```
|
||||
column "source_menu_objid" does not exist
|
||||
```
|
||||
|
||||
**원인**: `menu_info` 테이블에 `source_menu_objid` 컬럼이 아직 추가되지 않음
|
||||
|
||||
## ✅ 해결 방법
|
||||
|
||||
### 방법 1: psql 직접 실행 (권장)
|
||||
|
||||
```bash
|
||||
# 1. PostgreSQL 접속 정보 확인
|
||||
# - Host: localhost (또는 실제 DB 호스트)
|
||||
# - Port: 5432 (기본값)
|
||||
# - Database: ilshin
|
||||
# - User: postgres
|
||||
|
||||
# 2. 마이그레이션 실행
|
||||
cd /Users/kimjuseok/ERP-node
|
||||
psql -h localhost -U postgres -d ilshin -f db/migrations/1003_add_source_menu_objid_to_menu_info.sql
|
||||
|
||||
# 또는 대화형으로
|
||||
psql -h localhost -U postgres -d ilshin
|
||||
# 그 다음 파일 내용 붙여넣기
|
||||
```
|
||||
|
||||
### 방법 2: DBeaver / pgAdmin에서 실행
|
||||
|
||||
1. DBeaver 또는 pgAdmin 실행
|
||||
2. `ilshin` 데이터베이스 연결
|
||||
3. SQL 편집기 열기
|
||||
4. 아래 SQL 복사하여 실행:
|
||||
|
||||
```sql
|
||||
-- source_menu_objid 컬럼 추가
|
||||
ALTER TABLE menu_info
|
||||
ADD COLUMN IF NOT EXISTS source_menu_objid BIGINT;
|
||||
|
||||
-- 인덱스 생성 (검색 성능 향상)
|
||||
CREATE INDEX IF NOT EXISTS idx_menu_info_source_menu_objid
|
||||
ON menu_info(source_menu_objid);
|
||||
|
||||
-- 복합 인덱스: 회사별 원본 메뉴 검색
|
||||
CREATE INDEX IF NOT EXISTS idx_menu_info_source_company
|
||||
ON menu_info(source_menu_objid, company_code);
|
||||
|
||||
-- 컬럼 설명 추가
|
||||
COMMENT ON COLUMN menu_info.source_menu_objid IS '원본 메뉴 ID (복사된 경우만 값 존재)';
|
||||
|
||||
-- 확인
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'menu_info'
|
||||
AND column_name = 'source_menu_objid';
|
||||
```
|
||||
|
||||
### 방법 3: Docker를 통한 실행
|
||||
|
||||
Docker Compose 설정 확인 후:
|
||||
|
||||
```bash
|
||||
# Docker Compose에 DB 서비스가 있는 경우
|
||||
docker-compose exec db psql -U postgres -d ilshin -f /path/to/migration.sql
|
||||
|
||||
# 또는 SQL을 직접 실행
|
||||
docker-compose exec db psql -U postgres -d ilshin -c "
|
||||
ALTER TABLE menu_info ADD COLUMN IF NOT EXISTS source_menu_objid BIGINT;
|
||||
CREATE INDEX IF NOT EXISTS idx_menu_info_source_menu_objid ON menu_info(source_menu_objid);
|
||||
CREATE INDEX IF NOT EXISTS idx_menu_info_source_company ON menu_info(source_menu_objid, company_code);
|
||||
"
|
||||
```
|
||||
|
||||
## ✅ 실행 후 확인
|
||||
|
||||
### 1. 컬럼이 추가되었는지 확인
|
||||
```sql
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'menu_info'
|
||||
AND column_name = 'source_menu_objid';
|
||||
```
|
||||
|
||||
**예상 결과**:
|
||||
```
|
||||
column_name | data_type | is_nullable
|
||||
-------------------|-----------|-------------
|
||||
source_menu_objid | bigint | YES
|
||||
```
|
||||
|
||||
### 2. 인덱스 확인
|
||||
```sql
|
||||
SELECT indexname
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'menu_info'
|
||||
AND indexname LIKE '%source%';
|
||||
```
|
||||
|
||||
**예상 결과**:
|
||||
```
|
||||
indexname
|
||||
---------------------------------
|
||||
idx_menu_info_source_menu_objid
|
||||
idx_menu_info_source_company
|
||||
```
|
||||
|
||||
### 3. 메뉴 복사 재시도
|
||||
마이그레이션 완료 후 프론트엔드에서 메뉴 복사를 다시 실행하세요.
|
||||
|
||||
## 🔍 DB 접속 정보 찾기
|
||||
|
||||
### 환경 변수 확인
|
||||
```bash
|
||||
# .env 파일 확인
|
||||
cat backend-node/.env | grep DB
|
||||
|
||||
# Docker Compose 확인
|
||||
cat docker-compose*.yml | grep -A 10 postgres
|
||||
```
|
||||
|
||||
### 일반적인 접속 정보
|
||||
- **Host**: localhost 또는 127.0.0.1
|
||||
- **Port**: 5432 (기본값)
|
||||
- **Database**: ilshin
|
||||
- **User**: postgres
|
||||
- **Password**: (환경 설정 파일에서 확인)
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
1. **백업 권장**: 마이그레이션 실행 전 DB 백업 권장
|
||||
2. **권한 확인**: ALTER TABLE 권한이 필요합니다
|
||||
3. **백엔드 재시작 불필요**: 컬럼 추가만으로 즉시 작동합니다
|
||||
|
||||
## 📞 문제 해결
|
||||
|
||||
### "permission denied" 에러
|
||||
→ postgres 사용자 또는 superuser 권한으로 실행 필요
|
||||
|
||||
### "relation does not exist" 에러
|
||||
→ 올바른 데이터베이스(ilshin)에 접속했는지 확인
|
||||
|
||||
### "already exists" 에러
|
||||
→ 이미 실행됨. 무시하고 진행 가능
|
||||
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
# COMPANY_11 테스트 데이터 정리 가이드
|
||||
|
||||
## 📋 개요
|
||||
|
||||
메뉴 복사 기능을 재테스트하기 위해 COMPANY_11의 복사된 데이터를 삭제하는 스크립트입니다.
|
||||
|
||||
## ⚠️ 중요 사항
|
||||
|
||||
- **보존되는 데이터**: 권한 그룹(`authority_master`, `authority_sub_user`), 사용자 정보(`user_info`)
|
||||
- **삭제되는 데이터**: 메뉴, 화면, 레이아웃, 플로우, 코드
|
||||
- **안전 모드**: `cleanup_company_11_for_test.sql`은 ROLLBACK으로 테스트만 가능
|
||||
- **실행 모드**: `cleanup_company_11_execute.sql`은 즉시 COMMIT
|
||||
|
||||
## 🚀 실행 방법
|
||||
|
||||
### 방법 1: Docker 컨테이너에서 직접 실행 (권장)
|
||||
|
||||
```bash
|
||||
# 1. 테스트 실행 (롤백 - 실제 삭제 안 됨)
|
||||
cd /Users/kimjuseok/ERP-node
|
||||
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_for_test.sql
|
||||
|
||||
# 2. 실제 삭제 실행
|
||||
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql
|
||||
```
|
||||
|
||||
### 방법 2: DBeaver 또는 pgAdmin에서 실행
|
||||
|
||||
1. `db/scripts/cleanup_company_11_for_test.sql` 파일 열기
|
||||
2. 전체 스크립트 실행 (롤백되어 안전)
|
||||
3. 결과 확인 후 `cleanup_company_11_execute.sql` 실행
|
||||
|
||||
### 방법 3: psql 직접 접속
|
||||
|
||||
```bash
|
||||
# 1. 컨테이너 접속
|
||||
docker exec -it erp-node-db-1 psql -U postgres -d ilshin
|
||||
|
||||
# 2. SQL 복사 붙여넣기
|
||||
# (cleanup_company_11_execute.sql 내용 복사)
|
||||
```
|
||||
|
||||
## 📊 삭제 대상
|
||||
|
||||
| 항목 | 테이블명 | 삭제 여부 |
|
||||
|------|----------|-----------|
|
||||
| 메뉴 | `menu_info` | ✅ 삭제 |
|
||||
| 메뉴 권한 | `rel_menu_auth` | ✅ 삭제 |
|
||||
| 화면 정의 | `screen_definitions` | ✅ 삭제 |
|
||||
| 화면 레이아웃 | `screen_layouts` | ✅ 삭제 |
|
||||
| 화면-메뉴 할당 | `screen_menu_assignments` | ✅ 삭제 |
|
||||
| 플로우 정의 | `flow_definition` | ✅ 삭제 |
|
||||
| 플로우 스텝 | `flow_step` | ✅ 삭제 |
|
||||
| 플로우 연결 | `flow_step_connection` | ✅ 삭제 |
|
||||
| 코드 카테고리 | `code_category` | ✅ 삭제 |
|
||||
| 코드 정보 | `code_info` | ✅ 삭제 |
|
||||
| **권한 그룹** | `authority_master` | ❌ **보존** |
|
||||
| **권한 멤버** | `authority_sub_user` | ❌ **보존** |
|
||||
| **사용자** | `user_info` | ❌ **보존** |
|
||||
|
||||
## 🔍 삭제 순서 (외래키 제약 고려)
|
||||
|
||||
```
|
||||
1. screen_layouts (화면 레이아웃)
|
||||
2. screen_menu_assignments (화면-메뉴 할당)
|
||||
3. screen_definitions (화면 정의)
|
||||
4. rel_menu_auth (메뉴 권한)
|
||||
5. menu_info (메뉴)
|
||||
6. flow_step (플로우 스텝)
|
||||
7. flow_step_connection (플로우 연결)
|
||||
8. flow_definition (플로우 정의)
|
||||
9. code_info (코드 정보)
|
||||
10. code_category (코드 카테고리)
|
||||
```
|
||||
|
||||
## ✅ 실행 후 확인
|
||||
|
||||
스크립트 실행 후 다음과 같이 표시됩니다:
|
||||
|
||||
```
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
✅ 삭제 완료!
|
||||
|
||||
남은 데이터:
|
||||
- 메뉴: 0 개
|
||||
- 화면: 0 개
|
||||
- 권한 그룹: 1 개 (보존됨)
|
||||
- 사용자: 1 개 (보존됨)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
✨ 정리 완료! 메뉴 복사 테스트 준비됨
|
||||
```
|
||||
|
||||
## 🧪 테스트 시나리오
|
||||
|
||||
1. **데이터 정리**
|
||||
```bash
|
||||
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql
|
||||
```
|
||||
|
||||
2. **메뉴 복사 실행**
|
||||
- 프론트엔드에서 원본 메뉴 선택
|
||||
- "복사" 버튼 클릭
|
||||
- 대상 회사: COMPANY_11 선택
|
||||
- 복사 실행
|
||||
|
||||
3. **복사 결과 확인**
|
||||
- COMPANY_11 사용자(copy)로 로그인
|
||||
- 사용자 메뉴에 복사된 메뉴 표시 확인
|
||||
- 버튼 클릭 시 모달 화면 정상 열림 확인
|
||||
- 플로우 기능 정상 작동 확인
|
||||
|
||||
## 🔄 재테스트
|
||||
|
||||
재테스트가 필요하면 다시 정리 스크립트를 실행하세요:
|
||||
|
||||
```bash
|
||||
# 빠른 재테스트
|
||||
docker exec -i erp-node-db-1 psql -U postgres -d ilshin < db/scripts/cleanup_company_11_execute.sql
|
||||
```
|
||||
|
||||
## 📝 참고
|
||||
|
||||
- **백업**: 중요한 데이터가 있다면 먼저 백업하세요
|
||||
- **권한**: 사용자 `copy`와 권한 그룹 `복사권한`은 보존됩니다
|
||||
- **로그**: 백엔드 로그에서 복사 진행 상황을 실시간으로 확인할 수 있습니다
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge";
|
|||
*/
|
||||
export default function MainPage() {
|
||||
return (
|
||||
<div className="space-y-6 px-4 pt-10">
|
||||
<div className="space-y-6 p-4">
|
||||
{/* 메인 컨텐츠 */}
|
||||
{/* Welcome Message */}
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export default function MainHomePage() {
|
||||
return (
|
||||
<div className="space-y-6 px-4 pt-10">
|
||||
<div className="space-y-6 p-4">
|
||||
{/* 대시보드 컨텐츠 */}
|
||||
<div className="rounded-lg border bg-background p-6 shadow-sm">
|
||||
<h3 className="mb-4 text-lg font-semibold">WACE 솔루션에 오신 것을 환영합니다!</h3>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,344 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { menuApi, MenuCopyResult } from "@/lib/api/menu";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface MenuCopyDialogProps {
|
||||
menuObjid: number | null;
|
||||
menuName: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCopyComplete?: () => void;
|
||||
}
|
||||
|
||||
interface Company {
|
||||
company_code: string;
|
||||
company_name: string;
|
||||
}
|
||||
|
||||
export function MenuCopyDialog({
|
||||
menuObjid,
|
||||
menuName,
|
||||
open,
|
||||
onOpenChange,
|
||||
onCopyComplete,
|
||||
}: MenuCopyDialogProps) {
|
||||
const [targetCompanyCode, setTargetCompanyCode] = useState("");
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [result, setResult] = useState<MenuCopyResult | null>(null);
|
||||
const [loadingCompanies, setLoadingCompanies] = useState(false);
|
||||
|
||||
// 화면명 일괄 변경 설정
|
||||
const [useBulkRename, setUseBulkRename] = useState(false);
|
||||
const [removeText, setRemoveText] = useState("");
|
||||
const [addPrefix, setAddPrefix] = useState("");
|
||||
|
||||
// 회사 목록 로드
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadCompanies();
|
||||
// 다이얼로그가 열릴 때마다 초기화
|
||||
setTargetCompanyCode("");
|
||||
setResult(null);
|
||||
setUseBulkRename(false);
|
||||
setRemoveText("");
|
||||
setAddPrefix("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const loadCompanies = async () => {
|
||||
try {
|
||||
setLoadingCompanies(true);
|
||||
const response = await apiClient.get("/admin/companies/db");
|
||||
if (response.data.success && response.data.data) {
|
||||
// 최고 관리자(*) 회사 제외
|
||||
const filteredCompanies = response.data.data.filter(
|
||||
(company: Company) => company.company_code !== "*"
|
||||
);
|
||||
setCompanies(filteredCompanies);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("회사 목록 조회 실패:", error);
|
||||
toast.error("회사 목록을 불러올 수 없습니다");
|
||||
} finally {
|
||||
setLoadingCompanies(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!menuObjid) {
|
||||
toast.error("메뉴를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetCompanyCode) {
|
||||
toast.error("대상 회사를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
setCopying(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
// 화면명 변환 설정 (사용 중일 때만 전달)
|
||||
const screenNameConfig =
|
||||
useBulkRename && (removeText.trim() || addPrefix.trim())
|
||||
? {
|
||||
removeText: removeText.trim() || undefined,
|
||||
addPrefix: addPrefix.trim() || undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const response = await menuApi.copyMenu(
|
||||
menuObjid,
|
||||
targetCompanyCode,
|
||||
screenNameConfig
|
||||
);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setResult(response.data);
|
||||
toast.success("메뉴 복사 완료!");
|
||||
|
||||
// 경고 메시지 표시
|
||||
if (response.data.warnings && response.data.warnings.length > 0) {
|
||||
response.data.warnings.forEach((warning) => {
|
||||
toast.warning(warning);
|
||||
});
|
||||
}
|
||||
|
||||
// 복사 완료 콜백
|
||||
if (onCopyComplete) {
|
||||
onCopyComplete();
|
||||
}
|
||||
} else {
|
||||
toast.error(response.message || "메뉴 복사 실패");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("메뉴 복사 오류:", error);
|
||||
toast.error(error.message || "메뉴 복사 중 오류가 발생했습니다");
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!copying) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
메뉴 복사
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
"{menuName}" 메뉴와 관련된 모든 리소스를 다른 회사로 복사합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* 회사 선택 */}
|
||||
{!result && (
|
||||
<div>
|
||||
<Label htmlFor="company" className="text-xs sm:text-sm">
|
||||
대상 회사 *
|
||||
</Label>
|
||||
<Select
|
||||
value={targetCompanyCode}
|
||||
onValueChange={setTargetCompanyCode}
|
||||
disabled={copying || loadingCompanies}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="company"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="회사 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{loadingCompanies ? (
|
||||
<div className="flex items-center justify-center p-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
로딩 중...
|
||||
</div>
|
||||
) : companies.length === 0 ? (
|
||||
<div className="p-2 text-xs text-muted-foreground text-center">
|
||||
사용 가능한 회사가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
companies.map((company) => (
|
||||
<SelectItem
|
||||
key={company.company_code}
|
||||
value={company.company_code}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{company.company_name} ({company.company_code})
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 화면명 일괄 변경 설정 */}
|
||||
{!result && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="useBulkRename"
|
||||
checked={useBulkRename}
|
||||
onCheckedChange={(checked) => setUseBulkRename(checked as boolean)}
|
||||
disabled={copying}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="useBulkRename"
|
||||
className="text-xs sm:text-sm font-medium cursor-pointer"
|
||||
>
|
||||
화면명 일괄 변경 사용
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{useBulkRename && (
|
||||
<div className="space-y-3 pl-6 border-l-2">
|
||||
<div>
|
||||
<Label htmlFor="removeText" className="text-xs sm:text-sm">
|
||||
제거할 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
id="removeText"
|
||||
value={removeText}
|
||||
onChange={(e) => setRemoveText(e.target.value)}
|
||||
placeholder="예: 탑씰"
|
||||
disabled={copying}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
화면명에서 이 텍스트를 제거합니다 (예: "탑씰 회사정보" → "회사정보")
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="addPrefix" className="text-xs sm:text-sm">
|
||||
추가할 접두사
|
||||
</Label>
|
||||
<Input
|
||||
id="addPrefix"
|
||||
value={addPrefix}
|
||||
onChange={(e) => setAddPrefix(e.target.value)}
|
||||
placeholder="예: 한신"
|
||||
disabled={copying}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
화면명 앞에 이 텍스트를 추가합니다 (예: "회사정보" → "한신 회사정보")
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 복사 항목 안내 */}
|
||||
{!result && (
|
||||
<div className="rounded-md border p-3 text-xs">
|
||||
<p className="font-medium mb-2">복사되는 항목:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>메뉴 구조 (하위 메뉴 포함)</li>
|
||||
<li>화면 + 레이아웃 (모달, 조건부 컨테이너)</li>
|
||||
<li>플로우 제어 (스텝, 연결)</li>
|
||||
<li>코드 카테고리 + 코드</li>
|
||||
<li>카테고리 설정 + 채번 규칙</li>
|
||||
</ul>
|
||||
<p className="mt-2 text-warning">
|
||||
⚠️ 실제 데이터는 복사되지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 복사 결과 */}
|
||||
{result && (
|
||||
<div className="rounded-md border border-success bg-success/10 p-3 text-xs space-y-2">
|
||||
<p className="font-medium text-success">✅ 복사 완료!</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">메뉴:</span>{" "}
|
||||
<span className="font-medium">{result.copiedMenus}개</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">화면:</span>{" "}
|
||||
<span className="font-medium">{result.copiedScreens}개</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">플로우:</span>{" "}
|
||||
<span className="font-medium">{result.copiedFlows}개</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">코드 카테고리:</span>{" "}
|
||||
<span className="font-medium">{result.copiedCategories}개</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">코드:</span>{" "}
|
||||
<span className="font-medium">{result.copiedCodes}개</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={copying}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{result ? "닫기" : "취소"}
|
||||
</Button>
|
||||
{!result && (
|
||||
<Button
|
||||
onClick={handleCopy}
|
||||
disabled={copying || !targetCompanyCode || loadingCompanies}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{copying ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
복사 중...
|
||||
</>
|
||||
) : (
|
||||
"복사 시작"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -5,6 +5,7 @@ import { menuApi } from "@/lib/api/menu";
|
|||
import type { MenuItem } from "@/lib/api/menu";
|
||||
import { MenuTable } from "./MenuTable";
|
||||
import { MenuFormModal } from "./MenuFormModal";
|
||||
import { MenuCopyDialog } from "./MenuCopyDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner, LoadingOverlay } from "@/components/common/LoadingSpinner";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -25,17 +26,21 @@ import { useMenu } from "@/contexts/MenuContext";
|
|||
import { useMenuManagementText, setTranslationCache, getMenuTextSync } from "@/lib/utils/multilang";
|
||||
import { useMultiLang } from "@/hooks/useMultiLang";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth"; // useAuth 추가
|
||||
|
||||
type MenuType = "admin" | "user";
|
||||
|
||||
export const MenuManagement: React.FC = () => {
|
||||
const { adminMenus, userMenus, refreshMenus } = useMenu();
|
||||
const { user } = useAuth(); // 현재 사용자 정보 가져오기
|
||||
const [selectedMenuType, setSelectedMenuType] = useState<MenuType>("admin");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [formModalOpen, setFormModalOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [copyDialogOpen, setCopyDialogOpen] = useState(false);
|
||||
const [selectedMenuId, setSelectedMenuId] = useState<string>("");
|
||||
const [selectedMenuName, setSelectedMenuName] = useState<string>("");
|
||||
const [selectedMenus, setSelectedMenus] = useState<Set<string>>(new Set());
|
||||
|
||||
// 메뉴 관리 화면용 로컬 상태 (모든 상태의 메뉴 표시)
|
||||
|
|
@ -46,6 +51,9 @@ export const MenuManagement: React.FC = () => {
|
|||
// getMenuText는 더 이상 사용하지 않음 - getUITextSync만 사용
|
||||
const { userLang } = useMultiLang({ companyCode: "*" });
|
||||
|
||||
// SUPER_ADMIN 여부 확인
|
||||
const isSuperAdmin = user?.userType === "SUPER_ADMIN";
|
||||
|
||||
// 다국어 텍스트 상태
|
||||
const [uiTexts, setUiTexts] = useState<Record<string, string>>({});
|
||||
const [uiTextsLoading, setUiTextsLoading] = useState(false);
|
||||
|
|
@ -749,6 +757,18 @@ export const MenuManagement: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleCopyMenu = (menuId: string, menuName: string) => {
|
||||
setSelectedMenuId(menuId);
|
||||
setSelectedMenuName(menuName);
|
||||
setCopyDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCopyComplete = async () => {
|
||||
// 복사 완료 후 메뉴 목록 새로고침
|
||||
await loadMenus(false);
|
||||
toast.success("메뉴 복사가 완료되었습니다");
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (menuId: string) => {
|
||||
try {
|
||||
const response = await menuApi.toggleMenuStatus(menuId);
|
||||
|
|
@ -1062,6 +1082,7 @@ export const MenuManagement: React.FC = () => {
|
|||
title=""
|
||||
onAddMenu={handleAddMenu}
|
||||
onEditMenu={handleEditMenu}
|
||||
onCopyMenu={handleCopyMenu}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
selectedMenus={selectedMenus}
|
||||
onMenuSelectionChange={handleMenuSelectionChange}
|
||||
|
|
@ -1069,6 +1090,7 @@ export const MenuManagement: React.FC = () => {
|
|||
expandedMenus={expandedMenus}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
uiTexts={uiTexts}
|
||||
isSuperAdmin={isSuperAdmin} // SUPER_ADMIN 여부 전달
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1101,6 +1123,14 @@ export const MenuManagement: React.FC = () => {
|
|||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<MenuCopyDialog
|
||||
menuObjid={selectedMenuId ? parseInt(selectedMenuId, 10) : null}
|
||||
menuName={selectedMenuName}
|
||||
open={copyDialogOpen}
|
||||
onOpenChange={setCopyDialogOpen}
|
||||
onCopyComplete={handleCopyComplete}
|
||||
/>
|
||||
</LoadingOverlay>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ interface MenuTableProps {
|
|||
title: string;
|
||||
onAddMenu: (parentId: string, menuType: string, level: number) => void;
|
||||
onEditMenu: (menuId: string) => void;
|
||||
onCopyMenu: (menuId: string, menuName: string) => void; // 복사 추가
|
||||
onToggleStatus: (menuId: string) => void;
|
||||
selectedMenus: Set<string>;
|
||||
onMenuSelectionChange: (menuId: string, checked: boolean) => void;
|
||||
|
|
@ -22,6 +23,7 @@ interface MenuTableProps {
|
|||
onToggleExpand: (menuId: string) => void;
|
||||
// 다국어 텍스트 props 추가
|
||||
uiTexts: Record<string, string>;
|
||||
isSuperAdmin?: boolean; // SUPER_ADMIN 여부 추가
|
||||
}
|
||||
|
||||
export const MenuTable: React.FC<MenuTableProps> = ({
|
||||
|
|
@ -29,6 +31,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
|||
title,
|
||||
onAddMenu,
|
||||
onEditMenu,
|
||||
onCopyMenu,
|
||||
onToggleStatus,
|
||||
selectedMenus,
|
||||
onMenuSelectionChange,
|
||||
|
|
@ -36,6 +39,7 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
|||
expandedMenus,
|
||||
onToggleExpand,
|
||||
uiTexts,
|
||||
isSuperAdmin = false, // 기본값 false
|
||||
}) => {
|
||||
// 다국어 텍스트 가져오기 함수
|
||||
const getText = (key: string, fallback?: string): string => {
|
||||
|
|
@ -281,14 +285,26 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
|||
<TableCell className="h-16">
|
||||
<div className="flex flex-nowrap gap-1">
|
||||
{lev === 1 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-w-[40px] px-1 py-1 text-xs"
|
||||
onClick={() => onAddMenu(objid, menuType, lev)}
|
||||
>
|
||||
{getText(MENU_MANAGEMENT_KEYS.BUTTON_ADD)}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-w-[40px] px-1 py-1 text-xs"
|
||||
onClick={() => onAddMenu(objid, menuType, lev)}
|
||||
>
|
||||
{getText(MENU_MANAGEMENT_KEYS.BUTTON_ADD)}
|
||||
</Button>
|
||||
{isSuperAdmin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-w-[40px] px-1 py-1 text-xs"
|
||||
onClick={() => onCopyMenu(objid, menuNameKor || "메뉴")}
|
||||
>
|
||||
복사
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{lev === 2 && (
|
||||
<>
|
||||
|
|
@ -308,17 +324,39 @@ export const MenuTable: React.FC<MenuTableProps> = ({
|
|||
>
|
||||
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
|
||||
</Button>
|
||||
{isSuperAdmin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-w-[40px] px-1 py-1 text-xs"
|
||||
onClick={() => onCopyMenu(objid, menuNameKor || "메뉴")}
|
||||
>
|
||||
복사
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{lev > 2 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-w-[40px] px-1 py-1 text-xs"
|
||||
onClick={() => onEditMenu(objid)}
|
||||
>
|
||||
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-w-[40px] px-1 py-1 text-xs"
|
||||
onClick={() => onEditMenu(objid)}
|
||||
>
|
||||
{getText(MENU_MANAGEMENT_KEYS.BUTTON_EDIT)}
|
||||
</Button>
|
||||
{isSuperAdmin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-w-[40px] px-1 py-1 text-xs"
|
||||
onClick={() => onCopyMenu(objid, menuNameKor || "메뉴")}
|
||||
>
|
||||
복사
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ import { ListWidget } from "./widgets/ListWidget";
|
|||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// 야드 관리 3D 위젯
|
||||
// 3D 필드 위젯
|
||||
const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
|
|
@ -312,6 +312,24 @@ export function CanvasElement({
|
|||
return;
|
||||
}
|
||||
|
||||
// 위젯 테두리(바깥쪽 영역)를 클릭한 경우에만 선택/드래그 허용
|
||||
// - 내용 영역을 클릭해도 대시보드 설정 사이드바가 튀어나오지 않도록 하기 위함
|
||||
const container = elementRef.current;
|
||||
if (container) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const BORDER_HIT_WIDTH = 8; // px, 테두리로 인식할 범위
|
||||
const isOnBorder =
|
||||
e.clientX <= rect.left + BORDER_HIT_WIDTH ||
|
||||
e.clientX >= rect.right - BORDER_HIT_WIDTH ||
|
||||
e.clientY <= rect.top + BORDER_HIT_WIDTH ||
|
||||
e.clientY >= rect.bottom - BORDER_HIT_WIDTH;
|
||||
|
||||
if (!isOnBorder) {
|
||||
// 테두리가 아닌 내부 클릭은 선택/드래그 처리하지 않음
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 선택되지 않은 경우에만 선택 처리
|
||||
if (!isSelected) {
|
||||
onSelect(element.id);
|
||||
|
|
@ -1067,7 +1085,7 @@ export function CanvasElement({
|
|||
<ListWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "yard-management-3d" ? (
|
||||
// 야드 관리 3D 위젯 렌더링
|
||||
// 3D 필드 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<YardManagement3DWidget
|
||||
isEditMode={true}
|
||||
|
|
|
|||
|
|
@ -749,7 +749,7 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
|
|||
case "document":
|
||||
return "문서 위젯";
|
||||
case "yard-management-3d":
|
||||
return "야드 관리 3D";
|
||||
return "3D 필드";
|
||||
case "work-history":
|
||||
return "작업 이력";
|
||||
case "transport-stats":
|
||||
|
|
|
|||
|
|
@ -362,7 +362,7 @@ export function DashboardTopMenu({
|
|||
<SelectItem value="list-v2">리스트</SelectItem>
|
||||
<SelectItem value="custom-metric-v2">통계 카드</SelectItem>
|
||||
<SelectItem value="risk-alert-v2">리스크/알림</SelectItem>
|
||||
<SelectItem value="yard-management-3d">야드 관리 3D</SelectItem>
|
||||
<SelectItem value="yard-management-3d">3D 필드</SelectItem>
|
||||
{/* <SelectItem value="transport-stats">커스텀 통계 카드</SelectItem> */}
|
||||
{/* <SelectItem value="status-summary">커스텀 상태 카드</SelectItem> */}
|
||||
</SelectGroup>
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ const getWidgetTitle = (subtype: ElementSubtype): string => {
|
|||
chart: "차트",
|
||||
"map-summary-v2": "지도",
|
||||
"risk-alert-v2": "리스크 알림",
|
||||
"yard-management-3d": "야드 관리 3D",
|
||||
"yard-management-3d": "3D 필드",
|
||||
weather: "날씨 위젯",
|
||||
exchange: "환율 위젯",
|
||||
calculator: "계산기",
|
||||
|
|
@ -449,7 +449,7 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 레이아웃 선택 (야드 관리 3D 위젯 전용) */}
|
||||
{/* 레이아웃 선택 (3D 필드 위젯 전용) */}
|
||||
{element.subtype === "yard-management-3d" && (
|
||||
<div className="bg-background rounded-lg p-3 shadow-sm">
|
||||
<Label htmlFor="layout-id" className="mb-2 block text-xs font-semibold">
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export type ElementSubtype =
|
|||
| "maintenance"
|
||||
| "document"
|
||||
// | "list" // (구버전 - 주석 처리: 2025-10-28, list-v2로 대체)
|
||||
| "yard-management-3d" // 야드 관리 3D 위젯
|
||||
| "yard-management-3d" // 3D 필드 위젯
|
||||
| "work-history" // 작업 이력 위젯
|
||||
| "transport-stats"; // 커스텀 통계 카드 위젯
|
||||
// | "custom-metric"; // (구버전 - 주석 처리: 2025-10-28, custom-metric-v2로 대체)
|
||||
|
|
@ -116,7 +116,7 @@ export interface DashboardElement {
|
|||
calendarConfig?: CalendarConfig; // 달력 설정
|
||||
driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정
|
||||
listConfig?: ListWidgetConfig; // 리스트 위젯 설정
|
||||
yardConfig?: YardManagementConfig; // 야드 관리 3D 설정
|
||||
yardConfig?: YardManagementConfig; // 3D 필드 설정
|
||||
customMetricConfig?: CustomMetricConfig; // 사용자 커스텀 카드 설정
|
||||
}
|
||||
|
||||
|
|
@ -385,7 +385,7 @@ export interface ListColumn {
|
|||
visible?: boolean; // 표시 여부 (기본: true)
|
||||
}
|
||||
|
||||
// 야드 관리 3D 설정
|
||||
// 3D 필드 설정
|
||||
export interface YardManagementConfig {
|
||||
layoutId: number; // 선택된 야드 레이아웃 ID
|
||||
layoutName?: string; // 레이아웃 이름 (표시용)
|
||||
|
|
|
|||
|
|
@ -42,14 +42,16 @@ export default function YardManagement3DWidget({
|
|||
setIsLoading(true);
|
||||
const response = await getLayouts();
|
||||
if (response.success && response.data) {
|
||||
setLayouts(response.data.map((layout: any) => ({
|
||||
id: layout.id,
|
||||
name: layout.layout_name,
|
||||
description: layout.description || "",
|
||||
placement_count: layout.object_count || 0,
|
||||
created_at: layout.created_at,
|
||||
updated_at: layout.updated_at,
|
||||
})));
|
||||
setLayouts(
|
||||
response.data.map((layout: any) => ({
|
||||
id: layout.id,
|
||||
name: layout.layout_name,
|
||||
description: layout.description || "",
|
||||
placement_count: layout.object_count || 0,
|
||||
created_at: layout.created_at,
|
||||
updated_at: layout.updated_at,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("야드 레이아웃 목록 조회 실패:", error);
|
||||
|
|
@ -145,12 +147,14 @@ export default function YardManagement3DWidget({
|
|||
// 편집 모드: 편집 중인 경우 DigitalTwinEditor 표시
|
||||
if (isEditMode && editingLayout) {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<DigitalTwinEditor
|
||||
layoutId={editingLayout.id}
|
||||
layoutName={editingLayout.name}
|
||||
onBack={handleEditComplete}
|
||||
/>
|
||||
// 대시보드 위젯 선택/사이드바 오픈과 독립적으로 동작해야 하므로
|
||||
// widget-interactive-area 클래스를 부여하고, 마우스 이벤트 전파를 막아준다.
|
||||
<div
|
||||
className="widget-interactive-area h-full w-full"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DigitalTwinEditor layoutId={editingLayout.id} layoutName={editingLayout.name} onBack={handleEditComplete} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -158,30 +162,31 @@ export default function YardManagement3DWidget({
|
|||
// 편집 모드: 레이아웃 선택 UI
|
||||
if (isEditMode) {
|
||||
return (
|
||||
<div className="widget-interactive-area flex h-full w-full flex-col bg-background">
|
||||
<div className="widget-interactive-area bg-background flex h-full w-full flex-col">
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">야드 레이아웃 선택</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 야드 레이아웃을 선택하세요"}
|
||||
<h3 className="text-foreground text-sm font-semibold">3D 필드 선택</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 3D필드를 선택하세요"}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setIsCreateModalOpen(true)} size="sm">
|
||||
<Plus className="mr-1 h-4 w-4" />새 야드 생성
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
새로운 3D필드 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
||||
<div className="text-muted-foreground text-sm">로딩 중...</div>
|
||||
</div>
|
||||
) : layouts.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">🏗️</div>
|
||||
<div className="text-sm text-foreground">생성된 야드 레이아웃이 없습니다</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">먼저 야드 레이아웃을 생성하세요</div>
|
||||
<div className="text-foreground text-sm">생성된 3D필드가 없습니다</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">먼저 3D필드가 생성하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -196,11 +201,11 @@ export default function YardManagement3DWidget({
|
|||
<div className="flex items-start justify-between gap-3">
|
||||
<button onClick={() => handleSelectLayout(layout)} className="flex-1 text-left hover:opacity-80">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">{layout.name}</span>
|
||||
{config?.layoutId === layout.id && <Check className="h-4 w-4 text-primary" />}
|
||||
<span className="text-foreground font-medium">{layout.name}</span>
|
||||
{config?.layoutId === layout.id && <Check className="text-primary h-4 w-4" />}
|
||||
</div>
|
||||
{layout.description && <p className="mt-1 text-xs text-muted-foreground">{layout.description}</p>}
|
||||
<div className="mt-2 text-xs text-muted-foreground">배치된 자재: {layout.placement_count}개</div>
|
||||
{layout.description && <p className="text-muted-foreground mt-1 text-xs">{layout.description}</p>}
|
||||
<div className="text-muted-foreground mt-2 text-xs">배치된 자재: {layout.placement_count}개</div>
|
||||
</button>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
|
|
@ -251,12 +256,12 @@ export default function YardManagement3DWidget({
|
|||
<DialogTitle>야드 레이아웃 삭제</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-foreground">
|
||||
<p className="text-foreground text-sm">
|
||||
이 야드 레이아웃을 삭제하시겠습니까?
|
||||
<br />
|
||||
레이아웃 내의 모든 배치 정보도 함께 삭제됩니다.
|
||||
<br />
|
||||
<span className="font-semibold text-destructive">이 작업은 되돌릴 수 없습니다.</span>
|
||||
<span className="text-destructive font-semibold">이 작업은 되돌릴 수 없습니다.</span>
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setDeleteLayoutId(null)}>
|
||||
|
|
@ -277,14 +282,12 @@ export default function YardManagement3DWidget({
|
|||
if (!config?.layoutId) {
|
||||
console.warn("⚠️ 야드관리 위젯: layoutId가 설정되지 않음", { config, isEditMode });
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||||
<div className="bg-muted flex h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">🏗️</div>
|
||||
<div className="text-sm font-medium text-foreground">야드 레이아웃이 설정되지 않았습니다</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">대시보드 편집에서 레이아웃을 선택하세요</div>
|
||||
<div className="mt-2 text-xs text-destructive">
|
||||
디버그: config={JSON.stringify(config)}
|
||||
</div>
|
||||
<div className="text-foreground text-sm font-medium">3D필드가 설정되지 않았습니다</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">대시보드 편집에서 3D필드를 선택하세요</div>
|
||||
<div className="text-destructive mt-2 text-xs">디버그: config={JSON.stringify(config)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,12 +20,24 @@ import {
|
|||
getMaterials,
|
||||
getHierarchyData,
|
||||
getChildrenData,
|
||||
getMappingTemplates,
|
||||
createMappingTemplate,
|
||||
type HierarchyData,
|
||||
type DigitalTwinMappingTemplate,
|
||||
} from "@/lib/api/digitalTwin";
|
||||
import type { MaterialData } from "@/types/digitalTwin";
|
||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
import HierarchyConfigPanel, { HierarchyConfig } from "./HierarchyConfigPanel";
|
||||
import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants";
|
||||
import { validateSpatialContainment, updateChildrenPositions, getAllDescendants } from "./spatialContainment";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
// 백엔드 DB 객체 타입 (snake_case)
|
||||
interface DbObject {
|
||||
|
|
@ -73,6 +85,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
const [draggedTool, setDraggedTool] = useState<ToolType | null>(null);
|
||||
const [draggedAreaData, setDraggedAreaData] = useState<Area | null>(null); // 드래그 중인 Area 정보
|
||||
const [draggedLocationData, setDraggedLocationData] = useState<Location | null>(null); // 드래그 중인 Location 정보
|
||||
const [previewPosition, setPreviewPosition] = useState<{ x: number; z: number } | null>(null); // 드래그 프리뷰 위치
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
|
@ -92,9 +105,17 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
const [loadingMaterials, setLoadingMaterials] = useState(false);
|
||||
const [showMaterialPanel, setShowMaterialPanel] = useState(false);
|
||||
|
||||
// 매핑 템플릿
|
||||
const [mappingTemplates, setMappingTemplates] = useState<DigitalTwinMappingTemplate[]>([]);
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
||||
const [loadingTemplates, setLoadingTemplates] = useState(false);
|
||||
const [isSaveTemplateDialogOpen, setIsSaveTemplateDialogOpen] = useState(false);
|
||||
const [newTemplateName, setNewTemplateName] = useState("");
|
||||
const [newTemplateDescription, setNewTemplateDescription] = useState("");
|
||||
|
||||
// 동적 계층 구조 설정
|
||||
const [hierarchyConfig, setHierarchyConfig] = useState<HierarchyConfig | null>(null);
|
||||
const [availableTables, setAvailableTables] = useState<string[]>([]);
|
||||
const [availableTables, setAvailableTables] = useState<Array<{ table_name: string; description?: string }>>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
|
||||
// 레거시: 테이블 매핑 (구 Area/Location 방식 호환용)
|
||||
|
|
@ -164,6 +185,36 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
}));
|
||||
}, [placedObjects, layoutId]);
|
||||
|
||||
// 외부 DB 또는 레이아웃 타입이 변경될 때 템플릿 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTemplates = async () => {
|
||||
if (!selectedDbConnection) {
|
||||
setMappingTemplates([]);
|
||||
setSelectedTemplateId("");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingTemplates(true);
|
||||
const response = await getMappingTemplates({
|
||||
externalDbConnectionId: selectedDbConnection,
|
||||
layoutType: "yard-3d",
|
||||
});
|
||||
if (response.success && response.data) {
|
||||
setMappingTemplates(response.data);
|
||||
} else {
|
||||
setMappingTemplates([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("매핑 템플릿 목록 조회 실패:", error);
|
||||
} finally {
|
||||
setLoadingTemplates(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTemplates();
|
||||
}, [selectedDbConnection]);
|
||||
|
||||
// 외부 DB 연결 목록 로드
|
||||
useEffect(() => {
|
||||
const loadExternalDbConnections = async () => {
|
||||
|
|
@ -206,12 +257,23 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
const loadTables = async () => {
|
||||
try {
|
||||
setLoadingTables(true);
|
||||
const { getTables } = await import("@/lib/api/digitalTwin");
|
||||
const response = await getTables(selectedDbConnection);
|
||||
// 외부 DB 메타데이터 API 사용 (테이블 + 설명)
|
||||
const response = await ExternalDbConnectionAPI.getTables(selectedDbConnection);
|
||||
if (response.success && response.data) {
|
||||
const tableNames = response.data.map((t) => t.table_name);
|
||||
setAvailableTables(tableNames);
|
||||
console.log("📋 테이블 목록:", tableNames);
|
||||
const rawTables = response.data as any[];
|
||||
const normalized = rawTables.map((t: any) =>
|
||||
typeof t === "string"
|
||||
? { table_name: t }
|
||||
: {
|
||||
table_name: t.table_name || t.TABLE_NAME || String(t),
|
||||
description: t.description || t.table_description || undefined,
|
||||
},
|
||||
);
|
||||
setAvailableTables(normalized);
|
||||
console.log("📋 테이블 목록:", normalized);
|
||||
} else {
|
||||
setAvailableTables([]);
|
||||
console.warn("테이블 목록 조회 실패:", response.message || response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 실패:", error);
|
||||
|
|
@ -656,13 +718,13 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
};
|
||||
|
||||
// 캔버스에 드롭
|
||||
const handleCanvasDrop = (x: number, z: number) => {
|
||||
const handleCanvasDrop = async (x: number, z: number) => {
|
||||
if (!draggedTool) return;
|
||||
|
||||
const defaults = getToolDefaults(draggedTool);
|
||||
|
||||
// Area는 바닥(y=0.05)에, 다른 객체는 중앙 정렬
|
||||
const yPosition = draggedTool === "area" ? 0.05 : (defaults.size?.y || 1) / 2;
|
||||
let yPosition = draggedTool === "area" ? 0.05 : (defaults.size?.y || 1) / 2;
|
||||
|
||||
// 외부 DB 데이터에서 드래그한 경우 해당 정보 사용
|
||||
let objectName = defaults.name || "새 객체";
|
||||
|
|
@ -696,13 +758,52 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
externalKey = draggedLocationData.LOCAKEY;
|
||||
}
|
||||
|
||||
// 기본 크기 설정
|
||||
let objectSize = defaults.size || { x: 5, y: 5, z: 5 };
|
||||
|
||||
// Location 배치 시 자재 개수에 따라 높이 자동 설정
|
||||
if (
|
||||
(draggedTool === "location-bed" ||
|
||||
draggedTool === "location-stp" ||
|
||||
draggedTool === "location-temp" ||
|
||||
draggedTool === "location-dest") &&
|
||||
locaKey &&
|
||||
selectedDbConnection &&
|
||||
hierarchyConfig?.material
|
||||
) {
|
||||
try {
|
||||
// 해당 Location의 자재 개수 조회
|
||||
const countsResponse = await getMaterialCounts(selectedDbConnection, hierarchyConfig.material.tableName, [
|
||||
locaKey,
|
||||
]);
|
||||
|
||||
if (countsResponse.success && countsResponse.data && countsResponse.data.length > 0) {
|
||||
const materialCount = countsResponse.data[0].count;
|
||||
|
||||
// 자재 개수에 비례해서 높이(Y축) 설정 (최소 5, 최대 30)
|
||||
// 자재 1개 = 높이 5, 자재 10개 = 높이 15, 자재 50개 = 높이 30
|
||||
const calculatedHeight = Math.min(30, Math.max(5, 5 + materialCount * 0.5));
|
||||
|
||||
objectSize = {
|
||||
...objectSize,
|
||||
y: calculatedHeight, // Y축이 높이!
|
||||
};
|
||||
|
||||
// 높이가 높아진 만큼 Y 위치도 올려서 바닥을 뚫지 않게 조정
|
||||
yPosition = calculatedHeight / 2;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("자재 개수 조회 실패, 기본 높이 사용:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const newObject: PlacedObject = {
|
||||
id: nextObjectId,
|
||||
type: draggedTool,
|
||||
name: objectName,
|
||||
position: { x, y: yPosition, z },
|
||||
size: defaults.size || { x: 5, y: 5, z: 5 },
|
||||
color: defaults.color || "#9ca3af",
|
||||
size: objectSize,
|
||||
color: OBJECT_COLORS[draggedTool] || DEFAULT_COLOR, // 타입별 기본 색상
|
||||
areaKey,
|
||||
locaKey,
|
||||
locType,
|
||||
|
|
@ -739,9 +840,32 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
return;
|
||||
}
|
||||
|
||||
// 부모 ID 설정
|
||||
// 부모 ID 설정 및 논리적 유효성 검사
|
||||
if (validation.parent) {
|
||||
// 1. 부모 객체 찾기
|
||||
const parentObj = placedObjects.find((obj) => obj.id === validation.parent!.id);
|
||||
|
||||
// 2. 논리적 키 검사 (DB에서 가져온 데이터인 경우)
|
||||
if (parentObj && parentObj.externalKey && newObject.parentKey) {
|
||||
if (parentObj.externalKey !== newObject.parentKey) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "배치 오류",
|
||||
description: `이 Location은 '${newObject.parentKey}' Area에만 배치할 수 있습니다. (현재 선택된 Area: ${parentObj.externalKey})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
newObject.parentId = validation.parent.id;
|
||||
} else if (newObject.parentKey) {
|
||||
// DB 데이터인데 부모 영역 위에 놓이지 않은 경우
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "배치 오류",
|
||||
description: `이 Location은 '${newObject.parentKey}' Area 내부에 배치해야 합니다.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -770,7 +894,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
|
||||
// Location의 자재 목록 로드
|
||||
const loadMaterialsForLocation = async (locaKey: string) => {
|
||||
console.log("🔍 자재 조회 시작:", { locaKey, selectedDbConnection, material: hierarchyConfig?.material });
|
||||
|
||||
if (!selectedDbConnection || !hierarchyConfig?.material) {
|
||||
console.error("❌ 설정 누락:", { selectedDbConnection, material: hierarchyConfig?.material });
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "자재 조회 실패",
|
||||
|
|
@ -782,10 +909,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
try {
|
||||
setLoadingMaterials(true);
|
||||
setShowMaterialPanel(true);
|
||||
const response = await getMaterials(selectedDbConnection, {
|
||||
|
||||
const materialConfig = {
|
||||
...hierarchyConfig.material,
|
||||
locaKey: locaKey,
|
||||
});
|
||||
};
|
||||
console.log("📡 API 호출:", { externalDbConnectionId: selectedDbConnection, materialConfig });
|
||||
|
||||
const response = await getMaterials(selectedDbConnection, materialConfig);
|
||||
console.log("📦 API 응답:", response);
|
||||
if (response.success && response.data) {
|
||||
// layerColumn이 있으면 정렬
|
||||
const sortedMaterials = hierarchyConfig.material.layerColumn
|
||||
|
|
@ -904,6 +1036,110 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
}
|
||||
};
|
||||
|
||||
// 매핑 템플릿 적용
|
||||
const handleApplyTemplate = (templateId: string) => {
|
||||
if (!templateId) return;
|
||||
const template = mappingTemplates.find((t) => t.id === templateId);
|
||||
if (!template) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "템플릿 적용 실패",
|
||||
description: "선택한 템플릿을 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const config = template.config as HierarchyConfig;
|
||||
setHierarchyConfig(config);
|
||||
|
||||
// 선택된 테이블 정보 동기화
|
||||
const newSelectedTables: any = {
|
||||
warehouse: config.warehouse?.tableName || "",
|
||||
area: "",
|
||||
location: "",
|
||||
material: "",
|
||||
};
|
||||
|
||||
if (config.levels && config.levels.length > 0) {
|
||||
// 레벨 1 = Area
|
||||
if (config.levels[0]?.tableName) {
|
||||
newSelectedTables.area = config.levels[0].tableName;
|
||||
}
|
||||
// 레벨 2 = Location
|
||||
if (config.levels[1]?.tableName) {
|
||||
newSelectedTables.location = config.levels[1].tableName;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.material?.tableName) {
|
||||
newSelectedTables.material = config.material.tableName;
|
||||
}
|
||||
|
||||
setSelectedTables(newSelectedTables);
|
||||
setSelectedWarehouse(config.warehouseKey || null);
|
||||
setHasUnsavedChanges(true);
|
||||
|
||||
toast({
|
||||
title: "템플릿 적용 완료",
|
||||
description: `"${template.name}" 템플릿이 적용되었습니다.`,
|
||||
});
|
||||
};
|
||||
|
||||
// 매핑 템플릿 저장
|
||||
const handleSaveTemplate = async () => {
|
||||
if (!selectedDbConnection || !hierarchyConfig) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "템플릿 저장 불가",
|
||||
description: "외부 DB와 계층 설정을 먼저 완료해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newTemplateName.trim()) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "템플릿 이름 필요",
|
||||
description: "템플릿 이름을 입력해주세요.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await createMappingTemplate({
|
||||
name: newTemplateName.trim(),
|
||||
description: newTemplateDescription.trim() || undefined,
|
||||
externalDbConnectionId: selectedDbConnection,
|
||||
layoutType: "yard-3d",
|
||||
config: hierarchyConfig,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
setMappingTemplates((prev) => [response.data!, ...prev]);
|
||||
setIsSaveTemplateDialogOpen(false);
|
||||
setNewTemplateName("");
|
||||
setNewTemplateDescription("");
|
||||
toast({
|
||||
title: "템플릿 저장 완료",
|
||||
description: `"${response.data.name}" 템플릿이 저장되었습니다.`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "템플릿 저장 실패",
|
||||
description: response.error || "템플릿을 저장하지 못했습니다.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("매핑 템플릿 저장 실패:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "템플릿 저장 실패",
|
||||
description: "템플릿을 저장하는 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 객체 이동
|
||||
const handleObjectMove = (objectId: number, newX: number, newZ: number, newY?: number) => {
|
||||
setPlacedObjects((prev) => {
|
||||
|
|
@ -925,7 +1161,59 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
return obj;
|
||||
});
|
||||
|
||||
// 2. 그룹 이동: 자식 객체들도 함께 이동
|
||||
// 2. 하위 계층 객체 이동 시 논리적 키 검증
|
||||
if (hierarchyConfig && targetObj.hierarchyLevel && targetObj.hierarchyLevel > 1) {
|
||||
const spatialObjects = updatedObjects.map((obj) => ({
|
||||
id: obj.id,
|
||||
position: obj.position,
|
||||
size: obj.size,
|
||||
hierarchyLevel: obj.hierarchyLevel || 1,
|
||||
parentId: obj.parentId,
|
||||
}));
|
||||
|
||||
const targetSpatialObj = spatialObjects.find((obj) => obj.id === objectId);
|
||||
if (targetSpatialObj) {
|
||||
const validation = validateSpatialContainment(
|
||||
targetSpatialObj,
|
||||
spatialObjects.filter((obj) => obj.id !== objectId),
|
||||
);
|
||||
|
||||
// 새로운 부모 영역 찾기
|
||||
if (validation.parent) {
|
||||
const newParentObj = prev.find((obj) => obj.id === validation.parent!.id);
|
||||
|
||||
// DB에서 가져온 데이터인 경우 논리적 키 검증
|
||||
if (newParentObj && newParentObj.externalKey && targetObj.parentKey) {
|
||||
if (newParentObj.externalKey !== targetObj.parentKey) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "이동 불가",
|
||||
description: `이 Location은 '${targetObj.parentKey}' Area에만 배치할 수 있습니다. (현재 선택된 Area: ${newParentObj.externalKey})`,
|
||||
});
|
||||
return prev; // 이동 취소
|
||||
}
|
||||
}
|
||||
|
||||
// 부모 ID 업데이트
|
||||
updatedObjects = updatedObjects.map((obj) => {
|
||||
if (obj.id === objectId) {
|
||||
return { ...obj, parentId: validation.parent!.id };
|
||||
}
|
||||
return obj;
|
||||
});
|
||||
} else if (targetObj.parentKey) {
|
||||
// DB 데이터인데 부모 영역 밖으로 이동하려는 경우
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "이동 불가",
|
||||
description: `이 Location은 '${targetObj.parentKey}' Area 내부에 있어야 합니다.`,
|
||||
});
|
||||
return prev; // 이동 취소
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 그룹 이동: 자식 객체들도 함께 이동
|
||||
const spatialObjects = updatedObjects.map((obj) => ({
|
||||
id: obj.id,
|
||||
position: obj.position,
|
||||
|
|
@ -1164,13 +1452,14 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 도구 팔레트 */}
|
||||
{/* 도구 팔레트 (현재 숨김 처리 - 나중에 재사용 가능) */}
|
||||
{/*
|
||||
<div className="bg-muted flex items-center justify-center gap-2 border-b p-4">
|
||||
<span className="text-muted-foreground text-sm font-medium">도구:</span>
|
||||
{[
|
||||
{ type: "area" as ToolType, label: "영역", icon: Grid3x3, color: "text-blue-500" },
|
||||
{ type: "location-bed" as ToolType, label: "베드", icon: Package, color: "text-emerald-500" },
|
||||
{ type: "location-stp" as ToolType, label: "정차", icon: Move, color: "text-orange-500" },
|
||||
{ type: "location-bed" as ToolType, label: "베드", icon: Package, color: "text-blue-600" },
|
||||
{ type: "location-stp" as ToolType, label: "정차", icon: Move, color: "text-gray-500" },
|
||||
// { type: "crane-gantry" as ToolType, label: "겐트리", icon: Combine, color: "text-green-500" },
|
||||
{ type: "crane-mobile" as ToolType, label: "크레인", icon: Truck, color: "text-yellow-500" },
|
||||
{ type: "rack" as ToolType, label: "랙", icon: Box, color: "text-purple-500" },
|
||||
|
|
@ -1190,6 +1479,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
*/}
|
||||
|
||||
{/* 메인 영역 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
|
|
@ -1205,6 +1495,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
onValueChange={(value) => {
|
||||
setSelectedDbConnection(parseInt(value));
|
||||
setSelectedWarehouse(null);
|
||||
setSelectedTemplateId("");
|
||||
setHasUnsavedChanges(true);
|
||||
}}
|
||||
>
|
||||
|
|
@ -1219,55 +1510,66 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 창고 테이블 및 컬럼 매핑 */}
|
||||
{selectedDbConnection && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">창고 선택</Label>
|
||||
|
||||
{/* 이 레이아웃의 창고 선택 */}
|
||||
{hierarchyConfig?.warehouse?.tableName && hierarchyConfig?.warehouse?.keyColumn && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground mb-1 block text-xs">이 레이아웃의 창고</Label>
|
||||
{loadingWarehouses ? (
|
||||
<div className="flex h-9 items-center justify-center rounded-md border">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedWarehouse || ""}
|
||||
onValueChange={(value) => {
|
||||
setSelectedWarehouse(value);
|
||||
// hierarchyConfig 업데이트 (없으면 새로 생성)
|
||||
setHierarchyConfig((prev) => ({
|
||||
warehouseKey: value,
|
||||
levels: prev?.levels || [],
|
||||
material: prev?.material,
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue placeholder="창고 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((wh: any) => {
|
||||
const keyCol = hierarchyConfig.warehouse!.keyColumn;
|
||||
const nameCol = hierarchyConfig.warehouse!.nameColumn;
|
||||
return (
|
||||
<SelectItem key={wh[keyCol]} value={wh[keyCol]} className="text-xs">
|
||||
{wh[nameCol] || wh[keyCol]}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* 매핑 템플릿 선택/저장 */}
|
||||
{selectedDbConnection && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-semibold">매핑 템플릿</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setNewTemplateName(layoutName || "");
|
||||
setIsSaveTemplateDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
템플릿 저장
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={selectedTemplateId}
|
||||
onValueChange={(val) => setSelectedTemplateId(val)}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-xs">
|
||||
<SelectValue placeholder={loadingTemplates ? "로딩 중..." : "템플릿 선택..."} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mappingTemplates.length === 0 ? (
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs">
|
||||
사용 가능한 템플릿이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
mappingTemplates.map((tpl) => (
|
||||
<SelectItem key={tpl.id} value={tpl.id} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{tpl.name}</span>
|
||||
{tpl.description && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{tpl.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
disabled={!selectedTemplateId}
|
||||
onClick={() => handleApplyTemplate(selectedTemplateId)}
|
||||
>
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 계층 설정 패널 (신규) */}
|
||||
{selectedDbConnection && (
|
||||
|
|
@ -1311,12 +1613,21 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
}}
|
||||
onLoadColumns={async (tableName: string) => {
|
||||
try {
|
||||
const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName);
|
||||
const response = await ExternalDbConnectionAPI.getTableColumns(
|
||||
selectedDbConnection,
|
||||
tableName,
|
||||
);
|
||||
if (response.success && response.data) {
|
||||
// 객체 배열을 문자열 배열로 변환
|
||||
return response.data.map((col: any) =>
|
||||
typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col),
|
||||
);
|
||||
// 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그)
|
||||
return response.data.map((col: any) => ({
|
||||
column_name:
|
||||
typeof col === "string"
|
||||
? col
|
||||
: col.column_name || col.COLUMN_NAME || String(col),
|
||||
data_type: col.data_type || col.DATA_TYPE,
|
||||
description: col.description || col.COLUMN_COMMENT || undefined,
|
||||
is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
|
|
@ -1327,6 +1638,53 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* 창고 선택 (HierarchyConfigPanel 아래로 이동) */}
|
||||
{selectedDbConnection && hierarchyConfig?.warehouse?.tableName && hierarchyConfig?.warehouse?.keyColumn && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">창고 선택</Label>
|
||||
|
||||
<div>
|
||||
<Label className="text-muted-foreground mb-1 block text-xs">배치할 창고를 선택하세요</Label>
|
||||
{loadingWarehouses ? (
|
||||
<div className="flex h-9 items-center justify-center rounded-md border">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedWarehouse || ""}
|
||||
onValueChange={(value) => {
|
||||
setSelectedWarehouse(value);
|
||||
// hierarchyConfig 업데이트
|
||||
setHierarchyConfig((prev) => ({
|
||||
...prev,
|
||||
warehouseKey: value,
|
||||
levels: prev?.levels || [],
|
||||
material: prev?.material,
|
||||
warehouse: prev?.warehouse,
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue placeholder="창고 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((wh: any) => {
|
||||
const keyCol = hierarchyConfig.warehouse!.keyColumn;
|
||||
const nameCol = hierarchyConfig.warehouse!.nameColumn;
|
||||
return (
|
||||
<SelectItem key={wh[keyCol]} value={wh[keyCol]} className="text-xs">
|
||||
{wh[nameCol] || wh[keyCol]}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Area 목록 */}
|
||||
{selectedDbConnection && selectedWarehouse && (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -1452,77 +1810,185 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 배치된 객체 목록 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{/* 배치된 객체 목록 (계층 구조) */}
|
||||
<div className="flex-1 overflow-y-auto border-t p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">배치된 객체 ({placedObjects.length})</h3>
|
||||
|
||||
{placedObjects.length === 0 ? (
|
||||
<div className="text-muted-foreground text-center text-sm">상단 도구를 드래그하여 배치하세요</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{placedObjects.map((obj) => (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
selectedObject?.id === obj.id ? "border-primary bg-primary/10" : "hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{obj.name}</span>
|
||||
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: obj.color }} />
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
위치: ({obj.position.x.toFixed(1)}, {obj.position.z.toFixed(1)})
|
||||
</p>
|
||||
{obj.areaKey && <p className="text-muted-foreground mt-1 text-xs">Area: {obj.areaKey}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{/* Area별로 그룹핑 */}
|
||||
{(() => {
|
||||
// Area 객체들
|
||||
const areaObjects = placedObjects.filter((obj) => obj.type === "area");
|
||||
|
||||
// Area가 없으면 기존 방식으로 표시
|
||||
if (areaObjects.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{placedObjects.map((obj) => (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
className={`cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
selectedObject?.id === obj.id ? "border-primary bg-primary/10" : "hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{obj.name}</span>
|
||||
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: obj.color }} />
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
위치: ({obj.position.x.toFixed(1)}, {obj.position.z.toFixed(1)})
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Area별로 Location들을 그룹핑
|
||||
return areaObjects.map((areaObj) => {
|
||||
// 이 Area의 자식 Location들 찾기
|
||||
const childLocations = placedObjects.filter(
|
||||
(obj) =>
|
||||
obj.type !== "area" &&
|
||||
obj.areaKey === areaObj.areaKey &&
|
||||
(obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey),
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionItem key={areaObj.id} value={`area-${areaObj.id}`} className="border-b">
|
||||
<AccordionTrigger className="px-2 py-3 hover:no-underline">
|
||||
<div
|
||||
className={`flex w-full items-center justify-between pr-2 ${
|
||||
selectedObject?.id === areaObj.id ? "text-primary font-semibold" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleObjectClick(areaObj.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Grid3x3 className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{areaObj.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">({childLocations.length})</span>
|
||||
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: areaObj.color }} />
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-2 pb-3">
|
||||
{childLocations.length === 0 ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">Location이 없습니다</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{childLocations.map((locationObj) => (
|
||||
<div
|
||||
key={locationObj.id}
|
||||
onClick={() => handleObjectClick(locationObj.id)}
|
||||
className={`cursor-pointer rounded-lg border p-2 transition-all ${
|
||||
selectedObject?.id === locationObj.id
|
||||
? "border-primary bg-primary/10"
|
||||
: "hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-3 w-3" />
|
||||
<span className="text-xs font-medium">{locationObj.name}</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: locationObj.color }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)})
|
||||
</p>
|
||||
{locationObj.locaKey && (
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
Key: {locationObj.locaKey}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 3D 캔버스 */}
|
||||
<div
|
||||
className="h-full flex-1 bg-gray-100"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const rawX = ((e.clientX - rect.left) / rect.width - 0.5) * 100;
|
||||
const rawZ = ((e.clientY - rect.top) / rect.height - 0.5) * 100;
|
||||
|
||||
// 그리드 크기 (5 단위)
|
||||
const gridSize = 5;
|
||||
|
||||
// 그리드에 스냅
|
||||
// Area(20x20)는 그리드 교차점에, 다른 객체(5x5)는 타일 중앙에
|
||||
let snappedX = Math.round(rawX / gridSize) * gridSize;
|
||||
let snappedZ = Math.round(rawZ / gridSize) * gridSize;
|
||||
|
||||
// 5x5 객체는 타일 중앙으로 오프셋 (Area는 제외)
|
||||
if (draggedTool !== "area") {
|
||||
snappedX += gridSize / 2;
|
||||
snappedZ += gridSize / 2;
|
||||
}
|
||||
|
||||
handleCanvasDrop(snappedX, snappedZ);
|
||||
}}
|
||||
>
|
||||
<div className="relative h-full flex-1 bg-gray-100">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Yard3DCanvas
|
||||
placements={placements}
|
||||
selectedPlacementId={selectedObject?.id || null}
|
||||
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
|
||||
onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)}
|
||||
focusOnPlacementId={null}
|
||||
onCollisionDetected={() => {}}
|
||||
/>
|
||||
<>
|
||||
<Yard3DCanvas
|
||||
placements={placements}
|
||||
selectedPlacementId={selectedObject?.id || null}
|
||||
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
|
||||
onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)}
|
||||
focusOnPlacementId={null}
|
||||
onCollisionDetected={() => {}}
|
||||
previewTool={draggedTool}
|
||||
previewPosition={previewPosition}
|
||||
onPreviewPositionUpdate={setPreviewPosition}
|
||||
/>
|
||||
{/* 드래그 중일 때 Canvas 위에 투명한 오버레이 (프리뷰 및 드롭 이벤트 캐치용) */}
|
||||
{draggedTool && (
|
||||
<div
|
||||
className="pointer-events-auto absolute inset-0"
|
||||
style={{ zIndex: 10 }}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const rawX = ((e.clientX - rect.left) / rect.width - 0.5) * 100;
|
||||
const rawZ = ((e.clientY - rect.top) / rect.height - 0.5) * 100;
|
||||
|
||||
// 그리드 크기 (5 단위)
|
||||
const gridSize = 5;
|
||||
|
||||
// 그리드에 스냅
|
||||
let snappedX = Math.round(rawX / gridSize) * gridSize;
|
||||
let snappedZ = Math.round(rawZ / gridSize) * gridSize;
|
||||
|
||||
// 5x5 객체는 타일 중앙으로 오프셋 (Area는 제외)
|
||||
if (draggedTool !== "area") {
|
||||
snappedX += gridSize / 2;
|
||||
snappedZ += gridSize / 2;
|
||||
}
|
||||
|
||||
setPreviewPosition({ x: snappedX, z: snappedZ });
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
setPreviewPosition(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (previewPosition) {
|
||||
handleCanvasDrop(previewPosition.x, previewPosition.z);
|
||||
setPreviewPosition(null);
|
||||
}
|
||||
setDraggedTool(null);
|
||||
setDraggedAreaData(null);
|
||||
setDraggedLocationData(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -1605,7 +2071,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
</Label>
|
||||
<Input
|
||||
id="object-name"
|
||||
value={selectedObject.name}
|
||||
value={selectedObject.name || ""}
|
||||
onChange={(e) => handleObjectUpdate({ name: e.target.value })}
|
||||
className="mt-1.5 h-9 text-sm"
|
||||
/>
|
||||
|
|
@ -1622,7 +2088,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Input
|
||||
id="pos-x"
|
||||
type="number"
|
||||
value={selectedObject.position.x.toFixed(1)}
|
||||
value={(selectedObject.position?.x || 0).toFixed(1)}
|
||||
onChange={(e) =>
|
||||
handleObjectUpdate({
|
||||
position: {
|
||||
|
|
@ -1641,7 +2107,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Input
|
||||
id="pos-z"
|
||||
type="number"
|
||||
value={selectedObject.position.z.toFixed(1)}
|
||||
value={(selectedObject.position?.z || 0).toFixed(1)}
|
||||
onChange={(e) =>
|
||||
handleObjectUpdate({
|
||||
position: {
|
||||
|
|
@ -1669,7 +2135,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
type="number"
|
||||
step="5"
|
||||
min="5"
|
||||
value={selectedObject.size.x}
|
||||
value={selectedObject.size?.x || 5}
|
||||
onChange={(e) =>
|
||||
handleObjectUpdate({
|
||||
size: {
|
||||
|
|
@ -1688,7 +2154,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Input
|
||||
id="size-y"
|
||||
type="number"
|
||||
value={selectedObject.size.y}
|
||||
value={selectedObject.size?.y || 5}
|
||||
onChange={(e) =>
|
||||
handleObjectUpdate({
|
||||
size: {
|
||||
|
|
@ -1709,7 +2175,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
type="number"
|
||||
step="5"
|
||||
min="5"
|
||||
value={selectedObject.size.z}
|
||||
value={selectedObject.size?.z || 5}
|
||||
onChange={(e) =>
|
||||
handleObjectUpdate({
|
||||
size: {
|
||||
|
|
@ -1732,7 +2198,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Input
|
||||
id="object-color"
|
||||
type="color"
|
||||
value={selectedObject.color}
|
||||
value={selectedObject.color || "#3b82f6"}
|
||||
onChange={(e) => handleObjectUpdate({ color: e.target.value })}
|
||||
className="mt-1.5 h-9"
|
||||
/>
|
||||
|
|
@ -1751,6 +2217,58 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 매핑 템플릿 저장 다이얼로그 */}
|
||||
<Dialog open={isSaveTemplateDialogOpen} onOpenChange={setIsSaveTemplateDialogOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">매핑 템플릿 저장</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
현재 창고/계층/자재 설정을 템플릿으로 저장하여 다른 레이아웃에서 재사용할 수 있습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="template-name" className="text-xs sm:text-sm">
|
||||
템플릿 이름 *
|
||||
</Label>
|
||||
<Input
|
||||
id="template-name"
|
||||
value={newTemplateName}
|
||||
onChange={(e) => setNewTemplateName(e.target.value)}
|
||||
placeholder="예: 동연 야드 표준 매핑"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="template-desc" className="text-xs sm:text-sm">
|
||||
설명 (선택)
|
||||
</Label>
|
||||
<Input
|
||||
id="template-desc"
|
||||
value={newTemplateDescription}
|
||||
onChange={(e) => setNewTemplateDescription(e.target.value)}
|
||||
placeholder="이 템플릿에 대한 설명을 입력하세요"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsSaveTemplateDialogOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveTemplate}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Loader2, Search, Filter, X } from "lucide-react";
|
||||
import { Loader2, Search, X, Grid3x3, Package } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -10,6 +10,8 @@ import dynamic from "next/dynamic";
|
|||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { PlacedObject, MaterialData } from "@/types/digitalTwin";
|
||||
import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin";
|
||||
import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
|
||||
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
||||
ssr: false,
|
||||
|
|
@ -81,7 +83,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
z: parseFloat(obj.size_z),
|
||||
},
|
||||
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
|
||||
color: getObjectColor(objectType), // 타입별 기본 색상 사용
|
||||
color: getObjectColor(objectType, obj.color), // 저장된 색상 우선, 없으면 타입별 기본 색상
|
||||
areaKey: obj.area_key,
|
||||
locaKey: obj.loca_key,
|
||||
locType: obj.loc_type,
|
||||
|
|
@ -93,6 +95,9 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
displayOrder: obj.display_order,
|
||||
locked: obj.locked,
|
||||
visible: obj.visible !== false,
|
||||
hierarchyLevel: obj.hierarchy_level,
|
||||
parentKey: obj.parent_key,
|
||||
externalKey: obj.external_key,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -225,17 +230,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
|
||||
// 객체 타입별 기본 색상 (useMemo로 최적화)
|
||||
const getObjectColor = useMemo(() => {
|
||||
return (type: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
area: "#3b82f6", // 파란색
|
||||
"location-bed": "#2563eb", // 진한 파란색
|
||||
"location-stp": "#6b7280", // 회색
|
||||
"location-temp": "#f59e0b", // 주황색
|
||||
"location-dest": "#10b981", // 초록색
|
||||
"crane-mobile": "#8b5cf6", // 보라색
|
||||
rack: "#ef4444", // 빨간색
|
||||
};
|
||||
return colorMap[type] || "#3b82f6";
|
||||
return (type: string, savedColor?: string): string => {
|
||||
// 저장된 색상이 있으면 우선 사용
|
||||
if (savedColor) return savedColor;
|
||||
// 없으면 타입별 기본 색상 사용
|
||||
return OBJECT_COLORS[type] || DEFAULT_COLOR;
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -357,61 +356,154 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
{searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredObjects.map((obj) => {
|
||||
// 타입별 레이블
|
||||
let typeLabel = obj.type;
|
||||
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||
else if (obj.type === "area") typeLabel = "Area";
|
||||
else if (obj.type === "rack") typeLabel = "랙";
|
||||
(() => {
|
||||
// Area 객체가 있는 경우 계층 트리 아코디언 적용
|
||||
const areaObjects = filteredObjects.filter((obj) => obj.type === "area");
|
||||
|
||||
// Area가 없으면 기존 평면 리스트 유지
|
||||
if (areaObjects.length === 0) {
|
||||
return (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{obj.name}</p>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: getObjectColor(obj.type) }}
|
||||
/>
|
||||
<span>{typeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{filteredObjects.map((obj) => {
|
||||
let typeLabel = obj.type;
|
||||
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||
else if (obj.type === "area") typeLabel = "Area";
|
||||
else if (obj.type === "rack") typeLabel = "랙";
|
||||
|
||||
{/* 추가 정보 */}
|
||||
<div className="mt-2 space-y-1">
|
||||
{obj.areaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Area: <span className="font-medium">{obj.areaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.locaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Location: <span className="font-medium">{obj.locaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.materialCount !== undefined && obj.materialCount > 0 && (
|
||||
<p className="text-xs text-yellow-600">
|
||||
자재: <span className="font-semibold">{obj.materialCount}개</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{obj.name}</p>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: obj.color }}
|
||||
/>
|
||||
<span>{typeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{obj.areaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Area: <span className="font-medium">{obj.areaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.locaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Location: <span className="font-medium">{obj.locaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.materialCount !== undefined && obj.materialCount > 0 && (
|
||||
<p className="text-xs text-yellow-600">
|
||||
자재: <span className="font-semibold">{obj.materialCount}개</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
|
||||
// Area가 있는 경우: Area → Location 계층 아코디언
|
||||
return (
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{areaObjects.map((areaObj) => {
|
||||
const childLocations = filteredObjects.filter(
|
||||
(obj) =>
|
||||
obj.type !== "area" &&
|
||||
obj.areaKey === areaObj.areaKey &&
|
||||
(obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey),
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionItem key={areaObj.id} value={`area-${areaObj.id}`} className="border-b">
|
||||
<AccordionTrigger className="px-2 py-3 hover:no-underline">
|
||||
<div
|
||||
className={`flex w-full items-center justify-between pr-2 ${
|
||||
selectedObject?.id === areaObj.id ? "text-primary font-semibold" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleObjectClick(areaObj.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Grid3x3 className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{areaObj.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">({childLocations.length})</span>
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: areaObj.color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-2 pb-3">
|
||||
{childLocations.length === 0 ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">Location이 없습니다</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{childLocations.map((locationObj) => (
|
||||
<div
|
||||
key={locationObj.id}
|
||||
onClick={() => handleObjectClick(locationObj.id)}
|
||||
className={`cursor-pointer rounded-lg border p-2 transition-all ${
|
||||
selectedObject?.id === locationObj.id
|
||||
? "border-primary bg-primary/10"
|
||||
: "hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-3 w-3" />
|
||||
<span className="text-xs font-medium">{locationObj.name}</span>
|
||||
</div>
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: locationObj.color }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
위치: ({locationObj.position.x.toFixed(1)},{" "}
|
||||
{locationObj.position.z.toFixed(1)})
|
||||
</p>
|
||||
{locationObj.locaKey && (
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
Location: <span className="font-medium">{locationObj.locaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{locationObj.materialCount !== undefined && locationObj.materialCount > 0 && (
|
||||
<p className="mt-0.5 text-[10px] text-yellow-600">
|
||||
자재: <span className="font-semibold">{locationObj.materialCount}개</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -408,3 +408,4 @@ const handleObjectMove = (
|
|||
**작성일**: 2025-11-20
|
||||
**작성자**: AI Assistant
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -40,13 +40,26 @@ export interface HierarchyConfig {
|
|||
};
|
||||
}
|
||||
|
||||
interface TableInfo {
|
||||
table_name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
column_name: string;
|
||||
data_type?: string;
|
||||
description?: string;
|
||||
// 백엔드에서 내려주는 Primary Key 플래그 ("YES"/"NO" 또는 boolean)
|
||||
is_primary_key?: string | boolean;
|
||||
}
|
||||
|
||||
interface HierarchyConfigPanelProps {
|
||||
externalDbConnectionId: number | null;
|
||||
hierarchyConfig: HierarchyConfig | null;
|
||||
onHierarchyConfigChange: (config: HierarchyConfig) => void;
|
||||
availableTables: string[];
|
||||
availableTables: TableInfo[];
|
||||
onLoadTables: () => Promise<void>;
|
||||
onLoadColumns: (tableName: string) => Promise<string[]>;
|
||||
onLoadColumns: (tableName: string) => Promise<ColumnInfo[]>;
|
||||
}
|
||||
|
||||
export default function HierarchyConfigPanel({
|
||||
|
|
@ -65,28 +78,156 @@ export default function HierarchyConfigPanel({
|
|||
);
|
||||
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: string[] }>({});
|
||||
const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: ColumnInfo[] }>({});
|
||||
|
||||
// 외부에서 변경된 경우 동기화
|
||||
// 동일한 column_name 이 여러 번 내려오는 경우(조인 중복 등) 제거
|
||||
const normalizeColumns = (columns: ColumnInfo[]): ColumnInfo[] => {
|
||||
const map = new Map<string, ColumnInfo>();
|
||||
for (const col of columns) {
|
||||
const key = col.column_name;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, col);
|
||||
}
|
||||
}
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
// 외부에서 변경된 경우 동기화 및 컬럼 자동 로드
|
||||
// 외부에서 변경된 경우 동기화 및 컬럼 자동 로드
|
||||
useEffect(() => {
|
||||
if (hierarchyConfig) {
|
||||
setLocalConfig(hierarchyConfig);
|
||||
}
|
||||
}, [hierarchyConfig]);
|
||||
|
||||
// 테이블 선택 시 컬럼 로드
|
||||
// 저장된 설정의 테이블들에 대한 컬럼 자동 로드
|
||||
const loadSavedColumns = async () => {
|
||||
const tablesToLoad: string[] = [];
|
||||
|
||||
// 창고 테이블
|
||||
if (hierarchyConfig.warehouse?.tableName) {
|
||||
tablesToLoad.push(hierarchyConfig.warehouse.tableName);
|
||||
}
|
||||
|
||||
// 계층 레벨 테이블들
|
||||
hierarchyConfig.levels?.forEach((level) => {
|
||||
if (level.tableName) {
|
||||
tablesToLoad.push(level.tableName);
|
||||
}
|
||||
});
|
||||
|
||||
// 자재 테이블
|
||||
if (hierarchyConfig.material?.tableName) {
|
||||
tablesToLoad.push(hierarchyConfig.material.tableName);
|
||||
}
|
||||
|
||||
// 중복 제거 후, 아직 캐시에 없는 테이블만 병렬로 로드
|
||||
const uniqueTables = [...new Set(tablesToLoad)];
|
||||
const tablesToFetch = uniqueTables.filter((tableName) => !columnsCache[tableName]);
|
||||
|
||||
if (tablesToFetch.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
await Promise.all(
|
||||
tablesToFetch.map(async (tableName) => {
|
||||
try {
|
||||
const columns = await onLoadColumns(tableName);
|
||||
const normalized = normalizeColumns(columns);
|
||||
setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
|
||||
} catch (error) {
|
||||
console.error(`컬럼 로드 실패 (${tableName}):`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (externalDbConnectionId) {
|
||||
loadSavedColumns();
|
||||
}
|
||||
}
|
||||
}, [hierarchyConfig, externalDbConnectionId]);
|
||||
|
||||
// 지정된 컬럼이 Primary Key 인지 여부
|
||||
const isPrimaryKey = (col: ColumnInfo): boolean => {
|
||||
if (col.is_primary_key === true) return true;
|
||||
if (typeof col.is_primary_key === "string") {
|
||||
const v = col.is_primary_key.toUpperCase();
|
||||
return v === "YES" || v === "Y" || v === "TRUE" || v === "PK";
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 테이블 선택 시 컬럼 로드 + PK 기반 기본값 설정
|
||||
const handleTableChange = async (tableName: string, type: "warehouse" | "material" | number) => {
|
||||
if (columnsCache[tableName]) return; // 이미 로드된 경우 스킵
|
||||
let loadedColumns = columnsCache[tableName];
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const columns = await onLoadColumns(tableName);
|
||||
setColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
|
||||
} catch (error) {
|
||||
console.error("컬럼 로드 실패:", error);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
// 아직 캐시에 없으면 먼저 컬럼 조회
|
||||
if (!loadedColumns) {
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const fetched = await onLoadColumns(tableName);
|
||||
loadedColumns = normalizeColumns(fetched);
|
||||
setColumnsCache((prev) => ({ ...prev, [tableName]: loadedColumns! }));
|
||||
} catch (error) {
|
||||
console.error("컬럼 로드 실패:", error);
|
||||
loadedColumns = [];
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = loadedColumns || [];
|
||||
|
||||
// PK 기반으로 keyColumn 기본값 자동 설정 (이미 값이 있으면 건드리지 않음)
|
||||
// PK 정보가 없으면 첫 번째 컬럼을 기본값으로 사용
|
||||
setLocalConfig((prev) => {
|
||||
const next = { ...prev };
|
||||
const primaryColumns = columns.filter((col) => isPrimaryKey(col));
|
||||
const pkName = (primaryColumns[0] || columns[0])?.column_name;
|
||||
|
||||
if (!pkName) {
|
||||
return next;
|
||||
}
|
||||
|
||||
if (type === "warehouse") {
|
||||
const wh = {
|
||||
...(next.warehouse || { tableName }),
|
||||
tableName: next.warehouse?.tableName || tableName,
|
||||
};
|
||||
if (!wh.keyColumn) {
|
||||
wh.keyColumn = pkName;
|
||||
}
|
||||
next.warehouse = wh;
|
||||
} else if (type === "material") {
|
||||
const material = {
|
||||
...(next.material || { tableName }),
|
||||
tableName: next.material?.tableName || tableName,
|
||||
};
|
||||
if (!material.keyColumn) {
|
||||
material.keyColumn = pkName;
|
||||
}
|
||||
next.material = material as NonNullable<HierarchyConfig["material"]>;
|
||||
} else if (typeof type === "number") {
|
||||
// 계층 레벨
|
||||
next.levels = next.levels.map((lvl) => {
|
||||
if (lvl.level !== type) return lvl;
|
||||
const updated: HierarchyLevel = {
|
||||
...lvl,
|
||||
tableName: lvl.tableName || tableName,
|
||||
};
|
||||
if (!updated.keyColumn) {
|
||||
updated.keyColumn = pkName;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 창고 키 변경 (제거됨 - 상위 컴포넌트에서 처리)
|
||||
|
|
@ -187,12 +328,22 @@ export default function HierarchyConfigPanel({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table} value={table} className="text-[10px]">
|
||||
{table}
|
||||
<SelectItem key={table.table_name} value={table.table_name} className="text-[10px]">
|
||||
<div className="flex flex-col">
|
||||
<span>{table.table_name}</span>
|
||||
{table.description && (
|
||||
<span className="text-muted-foreground text-[9px]">{table.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!localConfig.warehouse?.tableName && (
|
||||
<p className="text-muted-foreground mt-1 text-[9px]">
|
||||
ℹ️ 창고 테이블을 선택하고 "설정 적용"을 눌러주세요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 창고 컬럼 매핑 */}
|
||||
|
|
@ -208,11 +359,22 @@ export default function HierarchyConfigPanel({
|
|||
<SelectValue placeholder="선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[localConfig.warehouse.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-[10px]">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
{columnsCache[localConfig.warehouse.tableName].map((col) => {
|
||||
const pk = isPrimaryKey(col);
|
||||
return (
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-[10px]">
|
||||
<div className="flex flex-col">
|
||||
<span>
|
||||
{col.column_name}
|
||||
{pk && <span className="text-amber-500 ml-1 text-[8px]">PK</span>}
|
||||
</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[8px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -228,8 +390,13 @@ export default function HierarchyConfigPanel({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[localConfig.warehouse.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-[10px]">
|
||||
{col}
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-[10px]">
|
||||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[8px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -237,6 +404,15 @@ export default function HierarchyConfigPanel({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localConfig.warehouse?.tableName &&
|
||||
!columnsCache[localConfig.warehouse.tableName] &&
|
||||
loadingColumns && (
|
||||
<div className="flex items-center gap-2 pt-2 text-[10px] text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>컬럼 정보를 불러오는 중입니다...</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
@ -257,7 +433,7 @@ export default function HierarchyConfigPanel({
|
|||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="text-muted-foreground h-4 w-4" />
|
||||
<Input
|
||||
value={level.name}
|
||||
value={level.name || ""}
|
||||
onChange={(e) => handleLevelChange(level.level, "name", e.target.value)}
|
||||
className="h-7 w-32 text-xs"
|
||||
placeholder="레벨명"
|
||||
|
|
@ -276,7 +452,7 @@ export default function HierarchyConfigPanel({
|
|||
<div>
|
||||
<Label className="text-[10px]">테이블</Label>
|
||||
<Select
|
||||
value={level.tableName}
|
||||
value={level.tableName || ""}
|
||||
onValueChange={(val) => {
|
||||
handleLevelChange(level.level, "tableName", val);
|
||||
handleTableChange(val, level.level);
|
||||
|
|
@ -287,8 +463,13 @@ export default function HierarchyConfigPanel({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table} value={table} className="text-xs">
|
||||
{table}
|
||||
<SelectItem key={table.table_name} value={table.table_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{table.table_name}</span>
|
||||
{table.description && (
|
||||
<span className="text-muted-foreground text-[10px]">{table.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -297,48 +478,64 @@ export default function HierarchyConfigPanel({
|
|||
|
||||
{level.tableName && columnsCache[level.tableName] && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-[10px]">ID 컬럼</Label>
|
||||
<Select
|
||||
value={level.keyColumn}
|
||||
onValueChange={(val) => handleLevelChange(level.level, "keyColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[level.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
<div>
|
||||
<Label className="text-[10px]">ID 컬럼</Label>
|
||||
<Select
|
||||
value={level.keyColumn || ""}
|
||||
onValueChange={(val) => handleLevelChange(level.level, "keyColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[level.tableName].map((col) => {
|
||||
const pk = isPrimaryKey(col);
|
||||
return (
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>
|
||||
{col.column_name}
|
||||
{pk && <span className="text-amber-500 ml-1 text-[9px]">PK</span>}
|
||||
</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">이름 컬럼</Label>
|
||||
<Select
|
||||
value={level.nameColumn}
|
||||
onValueChange={(val) => handleLevelChange(level.level, "nameColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[level.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div>
|
||||
<Label className="text-[10px]">이름 컬럼</Label>
|
||||
<Select
|
||||
value={level.nameColumn || ""}
|
||||
onValueChange={(val) => handleLevelChange(level.level, "nameColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[level.tableName].map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">부모 키 컬럼</Label>
|
||||
<Select
|
||||
value={level.parentKeyColumn}
|
||||
value={level.parentKeyColumn || ""}
|
||||
onValueChange={(val) => handleLevelChange(level.level, "parentKeyColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
|
|
@ -346,8 +543,13 @@ export default function HierarchyConfigPanel({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[level.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -368,8 +570,13 @@ export default function HierarchyConfigPanel({
|
|||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{columnsCache[level.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -377,6 +584,13 @@ export default function HierarchyConfigPanel({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{level.tableName && !columnsCache[level.tableName] && loadingColumns && (
|
||||
<div className="flex items-center gap-2 pt-2 text-[10px] text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>컬럼 정보를 불러오는 중입니다...</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
|
@ -409,8 +623,13 @@ export default function HierarchyConfigPanel({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table} value={table} className="text-xs">
|
||||
{table}
|
||||
<SelectItem key={table.table_name} value={table.table_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{table.table_name}</span>
|
||||
{table.description && (
|
||||
<span className="text-muted-foreground text-[10px]">{table.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -419,82 +638,108 @@ export default function HierarchyConfigPanel({
|
|||
|
||||
{localConfig.material?.tableName && columnsCache[localConfig.material.tableName] && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-[10px]">ID 컬럼</Label>
|
||||
<Select
|
||||
value={localConfig.material.keyColumn}
|
||||
onValueChange={(val) => handleMaterialChange("keyColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">위치 키 컬럼</Label>
|
||||
<Select
|
||||
value={localConfig.material.locationKeyColumn}
|
||||
onValueChange={(val) => handleMaterialChange("locationKeyColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div>
|
||||
<Label className="text-[10px]">ID 컬럼</Label>
|
||||
<Select
|
||||
value={localConfig.material.keyColumn || ""}
|
||||
onValueChange={(val) => handleMaterialChange("keyColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => {
|
||||
const pk = isPrimaryKey(col);
|
||||
return (
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>
|
||||
{col.column_name}
|
||||
{pk && <span className="text-amber-500 ml-1 text-[9px]">PK</span>}
|
||||
</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">레이어 컬럼 (선택)</Label>
|
||||
<Select
|
||||
<div>
|
||||
<Label className="text-[10px]">위치 키 컬럼</Label>
|
||||
<Select
|
||||
value={localConfig.material.locationKeyColumn || ""}
|
||||
onValueChange={(val) => handleMaterialChange("locationKeyColumn", val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">레이어 컬럼 (선택)</Label>
|
||||
<Select
|
||||
value={localConfig.material.layerColumn || "__none__"}
|
||||
onValueChange={(val) => handleMaterialChange("layerColumn", val === "__none__" ? undefined : val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="레이어 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="레이어 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">수량 컬럼 (선택)</Label>
|
||||
<Select
|
||||
<div>
|
||||
<Label className="text-[10px]">수량 컬럼 (선택)</Label>
|
||||
<Select
|
||||
value={localConfig.material.quantityColumn || "__none__"}
|
||||
onValueChange={(val) => handleMaterialChange("quantityColumn", val === "__none__" ? undefined : val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="수량 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px]">
|
||||
<SelectValue placeholder="수량 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{columnsCache[localConfig.material.tableName].map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[9px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
|
@ -507,30 +752,35 @@ export default function HierarchyConfigPanel({
|
|||
</p>
|
||||
<div className="max-h-60 space-y-2 overflow-y-auto rounded border p-2">
|
||||
{columnsCache[localConfig.material.tableName].map((col) => {
|
||||
const displayItem = localConfig.material?.displayColumns?.find((d) => d.column === col);
|
||||
const displayItem = localConfig.material?.displayColumns?.find((d) => d.column === col.column_name);
|
||||
const isSelected = !!displayItem;
|
||||
return (
|
||||
<div key={col} className="flex items-center gap-2">
|
||||
<div key={col.column_name} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
const currentDisplay = localConfig.material?.displayColumns || [];
|
||||
const newDisplay = e.target.checked
|
||||
? [...currentDisplay, { column: col, label: col }]
|
||||
: currentDisplay.filter((d) => d.column !== col);
|
||||
? [...currentDisplay, { column: col.column_name, label: col.column_name }]
|
||||
: currentDisplay.filter((d) => d.column !== col.column_name);
|
||||
handleMaterialChange("displayColumns", newDisplay);
|
||||
}}
|
||||
className="h-3 w-3 shrink-0"
|
||||
/>
|
||||
<span className="w-20 shrink-0 text-[10px]">{col}</span>
|
||||
<div className="flex w-24 shrink-0 flex-col">
|
||||
<span className="text-[10px]">{col.column_name}</span>
|
||||
{col.description && (
|
||||
<span className="text-muted-foreground text-[8px]">{col.description}</span>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Input
|
||||
value={displayItem?.label || col}
|
||||
value={displayItem?.label ?? ""}
|
||||
onChange={(e) => {
|
||||
const currentDisplay = localConfig.material?.displayColumns || [];
|
||||
const newDisplay = currentDisplay.map((d) =>
|
||||
d.column === col ? { ...d, label: e.target.value } : d,
|
||||
d.column === col.column_name ? { ...d, label: e.target.value } : d,
|
||||
);
|
||||
handleMaterialChange("displayColumns", newDisplay);
|
||||
}}
|
||||
|
|
@ -545,6 +795,15 @@ export default function HierarchyConfigPanel({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{localConfig.material?.tableName &&
|
||||
!columnsCache[localConfig.material.tableName] &&
|
||||
loadingColumns && (
|
||||
<div className="flex items-center gap-2 pt-2 text-[10px] text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>컬럼 정보를 불러오는 중입니다...</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ interface Yard3DCanvasProps {
|
|||
gridSize?: number; // 그리드 크기 (기본값: 5)
|
||||
onCollisionDetected?: () => void; // 충돌 감지 시 콜백
|
||||
focusOnPlacementId?: number | null; // 카메라가 포커스할 요소 ID
|
||||
previewTool?: string | null; // 드래그 중인 도구 타입
|
||||
previewPosition?: { x: number; z: number } | null; // 프리뷰 위치
|
||||
onPreviewPositionUpdate?: (position: { x: number; z: number } | null) => void;
|
||||
}
|
||||
|
||||
// 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일)
|
||||
|
|
@ -442,20 +445,79 @@ function MaterialBox({
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Area 이름 텍스트 */}
|
||||
{/* Area 이름 텍스트 - 위쪽 (바닥) */}
|
||||
{placement.name && (
|
||||
<Text
|
||||
position={[0, 0.15, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.2}
|
||||
color={placement.color}
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.05}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{placement.name}
|
||||
</Text>
|
||||
<>
|
||||
<Text
|
||||
position={[0, 0.15, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.2}
|
||||
color={placement.color}
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.05}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{placement.name}
|
||||
</Text>
|
||||
|
||||
{/* 4면에 텍스트 표시 */}
|
||||
{/* 앞면 (+Z) */}
|
||||
<Text
|
||||
position={[0, boxHeight / 2, boxDepth / 2 + 0.01]}
|
||||
rotation={[0, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxHeight) * 0.3}
|
||||
color="#ffffff"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.08}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{placement.name}
|
||||
</Text>
|
||||
|
||||
{/* 뒷면 (-Z) */}
|
||||
<Text
|
||||
position={[0, boxHeight / 2, -boxDepth / 2 - 0.01]}
|
||||
rotation={[0, Math.PI, 0]}
|
||||
fontSize={Math.min(boxWidth, boxHeight) * 0.3}
|
||||
color="#ffffff"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.08}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{placement.name}
|
||||
</Text>
|
||||
|
||||
{/* 왼쪽면 (-X) */}
|
||||
<Text
|
||||
position={[-boxWidth / 2 - 0.01, boxHeight / 2, 0]}
|
||||
rotation={[0, -Math.PI / 2, 0]}
|
||||
fontSize={Math.min(boxDepth, boxHeight) * 0.3}
|
||||
color="#ffffff"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.08}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{placement.name}
|
||||
</Text>
|
||||
|
||||
{/* 오른쪽면 (+X) */}
|
||||
<Text
|
||||
position={[boxWidth / 2 + 0.01, boxHeight / 2, 0]}
|
||||
rotation={[0, Math.PI / 2, 0]}
|
||||
fontSize={Math.min(boxDepth, boxHeight) * 0.3}
|
||||
color="#ffffff"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.08}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{placement.name}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
@ -1007,10 +1069,26 @@ function Scene({
|
|||
gridSize = 5,
|
||||
onCollisionDetected,
|
||||
focusOnPlacementId,
|
||||
previewTool,
|
||||
previewPosition,
|
||||
}: Yard3DCanvasProps) {
|
||||
const [isDraggingAny, setIsDraggingAny] = useState(false);
|
||||
const orbitControlsRef = useRef<any>(null);
|
||||
|
||||
// 프리뷰 박스 크기 계산
|
||||
const getPreviewSize = (tool: string) => {
|
||||
if (tool === "area") return { x: 20, y: 0.1, z: 20 };
|
||||
return { x: 5, y: 5, z: 5 };
|
||||
};
|
||||
|
||||
// 프리뷰 박스 색상
|
||||
const getPreviewColor = (tool: string) => {
|
||||
if (tool === "area") return "#3b82f6";
|
||||
if (tool === "location-bed") return "#10b981";
|
||||
if (tool === "location-stp") return "#f59e0b";
|
||||
return "#9ca3af";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 카메라 포커스 컨트롤러 */}
|
||||
|
|
@ -1069,6 +1147,30 @@ function Scene({
|
|||
/>
|
||||
))}
|
||||
|
||||
{/* 드래그 프리뷰 박스 */}
|
||||
{previewTool && previewPosition && (
|
||||
<Box
|
||||
args={[
|
||||
getPreviewSize(previewTool).x,
|
||||
getPreviewSize(previewTool).y,
|
||||
getPreviewSize(previewTool).z,
|
||||
]}
|
||||
position={[
|
||||
previewPosition.x,
|
||||
previewTool === "area" ? 0.05 : getPreviewSize(previewTool).y / 2,
|
||||
previewPosition.z,
|
||||
]}
|
||||
>
|
||||
<meshStandardMaterial
|
||||
color={getPreviewColor(previewTool)}
|
||||
transparent
|
||||
opacity={0.4}
|
||||
roughness={0.5}
|
||||
metalness={0.1}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 카메라 컨트롤 */}
|
||||
<OrbitControls
|
||||
ref={orbitControlsRef}
|
||||
|
|
@ -1095,6 +1197,9 @@ export default function Yard3DCanvas({
|
|||
gridSize = 5,
|
||||
onCollisionDetected,
|
||||
focusOnPlacementId,
|
||||
previewTool,
|
||||
previewPosition,
|
||||
onPreviewPositionUpdate,
|
||||
}: Yard3DCanvasProps) {
|
||||
const handleCanvasClick = (e: any) => {
|
||||
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
|
||||
|
|
@ -1123,6 +1228,8 @@ export default function Yard3DCanvas({
|
|||
gridSize={gridSize}
|
||||
onCollisionDetected={onCollisionDetected}
|
||||
focusOnPlacementId={focusOnPlacementId}
|
||||
previewTool={previewTool}
|
||||
previewPosition={previewPosition}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
|
|
|
|||
|
|
@ -68,15 +68,15 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
|
|||
<ResizableDialogContent className="sm:max-w-[500px]" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<ResizableDialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<ResizableDialogTitle>새 야드 생성</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>야드 이름을 입력하세요</ResizableDialogDescription>
|
||||
<ResizableDialogTitle>새로운 3D필드 생성</ResizableDialogTitle>
|
||||
<ResizableDialogDescription>필드 이름을 입력하세요</ResizableDialogDescription>
|
||||
</div>
|
||||
</ResizableDialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="yard-name">
|
||||
야드 이름 <span className="text-destructive">*</span>
|
||||
필드 이름 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="yard-name"
|
||||
|
|
@ -86,7 +86,7 @@ export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: Yar
|
|||
setError("");
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="예: A구역, 1번 야드"
|
||||
placeholder="예: A 공장"
|
||||
disabled={isCreating}
|
||||
autoFocus
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* 디지털 트윈 3D 야드 - 공통 상수
|
||||
*/
|
||||
|
||||
// 객체 타입별 색상 매핑 (HEX 코드)
|
||||
export const OBJECT_COLORS: Record<string, string> = {
|
||||
area: "#3b82f6", // 파란색
|
||||
"location-bed": "#2563eb", // 진한 파란색
|
||||
"location-stp": "#6b7280", // 회색
|
||||
"location-temp": "#f59e0b", // 주황색
|
||||
"location-dest": "#10b981", // 초록색
|
||||
"crane-mobile": "#8b5cf6", // 보라색
|
||||
rack: "#ef4444", // 빨간색
|
||||
};
|
||||
|
||||
// Tailwind 색상 클래스 매핑 (아이콘용)
|
||||
export const OBJECT_COLOR_CLASSES: Record<string, string> = {
|
||||
area: "text-blue-500",
|
||||
"location-bed": "text-blue-600",
|
||||
"location-stp": "text-gray-500",
|
||||
"location-temp": "text-orange-500",
|
||||
"location-dest": "text-emerald-500",
|
||||
"crane-mobile": "text-purple-500",
|
||||
rack: "text-red-500",
|
||||
};
|
||||
|
||||
// 기본 색상
|
||||
export const DEFAULT_COLOR = "#3b82f6";
|
||||
export const DEFAULT_COLOR_CLASS = "text-blue-500";
|
||||
|
||||
|
|
@ -163,3 +163,4 @@ export function getAllDescendants(
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// 야드 관리 3D - 타입 정의
|
||||
// 3D 필드 - 타입 정의
|
||||
|
||||
import { ChartDataSource } from "../../types";
|
||||
|
||||
|
|
|
|||
|
|
@ -57,16 +57,18 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 폼 데이터 상태 추가
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 연속 등록 모드 상태 (localStorage에 저장하여 리렌더링에 영향받지 않도록)
|
||||
const continuousModeRef = useRef(false);
|
||||
const [, setForceUpdate] = useState(0); // 강제 리렌더링용 (값은 사용하지 않음)
|
||||
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
|
||||
const [continuousMode, setContinuousMode] = useState(false);
|
||||
|
||||
// 화면 리셋 키 (컴포넌트 강제 리마운트용)
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
|
||||
// localStorage에서 연속 모드 상태 복원
|
||||
useEffect(() => {
|
||||
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
||||
if (savedMode === "true") {
|
||||
continuousModeRef.current = true;
|
||||
// console.log("🔄 연속 모드 복원: true");
|
||||
setContinuousMode(true);
|
||||
console.log("🔄 연속 모드 복원: true");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -162,29 +164,39 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
});
|
||||
setScreenData(null);
|
||||
setFormData({});
|
||||
continuousModeRef.current = false;
|
||||
setContinuousMode(false);
|
||||
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
|
||||
// console.log("🔄 연속 모드 초기화: false");
|
||||
console.log("🔄 연속 모드 초기화: false");
|
||||
};
|
||||
|
||||
// 저장 성공 이벤트 처리 (연속 등록 모드 지원)
|
||||
const handleSaveSuccess = () => {
|
||||
const isContinuousMode = continuousModeRef.current;
|
||||
// console.log("💾 저장 성공 이벤트 수신");
|
||||
// console.log("📌 현재 연속 모드 상태 (ref):", isContinuousMode);
|
||||
// console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode"));
|
||||
const isContinuousMode = continuousMode;
|
||||
console.log("💾 저장 성공 이벤트 수신");
|
||||
console.log("📌 현재 연속 모드 상태:", isContinuousMode);
|
||||
console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode"));
|
||||
|
||||
if (isContinuousMode) {
|
||||
// 연속 모드: 폼만 초기화하고 모달은 유지
|
||||
// console.log("✅ 연속 모드 활성화 - 폼만 초기화");
|
||||
console.log("✅ 연속 모드 활성화 - 폼 초기화 및 화면 리셋");
|
||||
|
||||
// 폼만 초기화 (연속 모드 상태는 localStorage에 저장되어 있으므로 유지됨)
|
||||
// 1. 폼 데이터 초기화
|
||||
setFormData({});
|
||||
|
||||
// 2. 리셋 키 변경 (컴포넌트 강제 리마운트)
|
||||
setResetKey(prev => prev + 1);
|
||||
console.log("🔄 resetKey 증가 - 컴포넌트 리마운트");
|
||||
|
||||
// 3. 화면 데이터 다시 로드 (채번 규칙 새로 생성)
|
||||
if (modalState.screenId) {
|
||||
console.log("🔄 화면 데이터 다시 로드:", modalState.screenId);
|
||||
loadScreenData(modalState.screenId);
|
||||
}
|
||||
|
||||
toast.success("저장되었습니다. 계속 입력하세요.");
|
||||
} else {
|
||||
// 일반 모드: 모달 닫기
|
||||
// console.log("❌ 일반 모드 - 모달 닫기");
|
||||
console.log("❌ 일반 모드 - 모달 닫기");
|
||||
handleCloseModal();
|
||||
}
|
||||
};
|
||||
|
|
@ -198,7 +210,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
window.removeEventListener("closeSaveModal", handleCloseModal);
|
||||
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
|
||||
};
|
||||
}, []); // 의존성 제거 (ref 사용으로 최신 상태 참조)
|
||||
}, [continuousMode]); // continuousMode 의존성 추가
|
||||
|
||||
// 화면 데이터 로딩
|
||||
useEffect(() => {
|
||||
|
|
@ -415,18 +427,21 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
setFormData({}); // 폼 데이터 초기화
|
||||
};
|
||||
|
||||
// 모달 크기 설정 - 화면 내용에 맞게 동적 조정
|
||||
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터
|
||||
const getModalStyle = () => {
|
||||
if (!screenDimensions) {
|
||||
return {
|
||||
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
|
||||
style: {},
|
||||
style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용
|
||||
};
|
||||
}
|
||||
|
||||
// 헤더 높이를 최소화 (제목 영역만)
|
||||
const headerHeight = 60; // DialogHeader 최소 높이 (타이틀 + 최소 패딩)
|
||||
const totalHeight = screenDimensions.height + headerHeight;
|
||||
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스
|
||||
const headerHeight = 60; // DialogHeader (타이틀 + 패딩)
|
||||
const footerHeight = 52; // 연속 등록 모드 체크박스 영역
|
||||
|
||||
const totalHeight = screenDimensions.height + headerHeight + footerHeight;
|
||||
|
||||
return {
|
||||
className: "overflow-hidden p-0",
|
||||
|
|
@ -504,7 +519,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
<ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<ResizableDialogContent
|
||||
className={`${modalStyle.className} ${className || ""}`}
|
||||
style={modalStyle.style}
|
||||
{...(modalStyle.style && { style: modalStyle.style })} // undefined일 때는 prop 자체를 전달하지 않음
|
||||
defaultWidth={600}
|
||||
defaultHeight={800}
|
||||
minWidth={500}
|
||||
|
|
@ -530,7 +545,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</ResizableDialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -568,7 +583,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
key={`${component.id}-${resetKey}`}
|
||||
component={adjustedComponent}
|
||||
allComponents={screenData.components}
|
||||
formData={formData}
|
||||
|
|
@ -607,13 +622,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="continuous-mode"
|
||||
checked={continuousModeRef.current}
|
||||
checked={continuousMode}
|
||||
onCheckedChange={(checked) => {
|
||||
const isChecked = checked === true;
|
||||
continuousModeRef.current = isChecked;
|
||||
setContinuousMode(isChecked);
|
||||
localStorage.setItem("screenModal_continuousMode", String(isChecked));
|
||||
setForceUpdate((prev) => prev + 1); // 체크박스 UI 업데이트를 위한 강제 리렌더링
|
||||
// console.log("🔄 연속 모드 변경:", isChecked);
|
||||
console.log("🔄 연속 모드 변경:", isChecked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="continuous-mode" className="cursor-pointer text-sm font-normal select-none">
|
||||
|
|
|
|||
|
|
@ -470,7 +470,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
|
||||
{/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
|
||||
<main className="h-[calc(100vh-3.5rem)] min-w-0 flex-1 overflow-auto bg-white">
|
||||
<div className="h-full w-full p-4">{children}</div>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
export const INPUT_MODE = {
|
||||
CUSTOMER_FIRST: "customer_first",
|
||||
QUOTATION: "quotation",
|
||||
UNIT_PRICE: "unit_price",
|
||||
} as const;
|
||||
|
||||
export type InputMode = (typeof INPUT_MODE)[keyof typeof INPUT_MODE];
|
||||
|
||||
export const SALES_TYPE = {
|
||||
DOMESTIC: "domestic",
|
||||
EXPORT: "export",
|
||||
} as const;
|
||||
|
||||
export type SalesType = (typeof SALES_TYPE)[keyof typeof SALES_TYPE];
|
||||
|
||||
export const PRICE_TYPE = {
|
||||
STANDARD: "standard",
|
||||
CUSTOMER: "customer",
|
||||
} as const;
|
||||
|
||||
export type PriceType = (typeof PRICE_TYPE)[keyof typeof PRICE_TYPE];
|
||||
|
|
@ -24,6 +24,8 @@ interface EditModalState {
|
|||
modalSize: "sm" | "md" | "lg" | "xl";
|
||||
editData: Record<string, any>;
|
||||
onSave?: () => void;
|
||||
groupByColumns?: string[]; // 🆕 그룹핑 컬럼 (예: ["order_no"])
|
||||
tableName?: string; // 🆕 테이블명 (그룹 조회용)
|
||||
}
|
||||
|
||||
interface EditModalProps {
|
||||
|
|
@ -40,6 +42,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
modalSize: "md",
|
||||
editData: {},
|
||||
onSave: undefined,
|
||||
groupByColumns: undefined,
|
||||
tableName: undefined,
|
||||
});
|
||||
|
||||
const [screenData, setScreenData] = useState<{
|
||||
|
|
@ -58,6 +62,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// 폼 데이터 상태 (편집 데이터로 초기화됨)
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [originalData, setOriginalData] = useState<Record<string, any>>({});
|
||||
|
||||
// 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목)
|
||||
const [groupData, setGroupData] = useState<Record<string, any>[]>([]);
|
||||
const [originalGroupData, setOriginalGroupData] = useState<Record<string, any>[]>([]);
|
||||
|
||||
// 화면의 실제 크기 계산 함수 (ScreenModal과 동일)
|
||||
const calculateScreenDimensions = (components: ComponentData[]) => {
|
||||
|
|
@ -92,25 +100,25 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
const contentWidth = maxX - minX;
|
||||
const contentHeight = maxY - minY;
|
||||
|
||||
// 적절한 여백 추가
|
||||
const paddingX = 40;
|
||||
const paddingY = 40;
|
||||
// 적절한 여백 추가 (주석처리 - 사용자 설정 크기 그대로 사용)
|
||||
// const paddingX = 40;
|
||||
// const paddingY = 40;
|
||||
|
||||
const finalWidth = Math.max(contentWidth + paddingX, 400);
|
||||
const finalHeight = Math.max(contentHeight + paddingY, 300);
|
||||
const finalWidth = Math.max(contentWidth, 400); // padding 제거
|
||||
const finalHeight = Math.max(contentHeight, 300); // padding 제거
|
||||
|
||||
return {
|
||||
width: Math.min(finalWidth, window.innerWidth * 0.95),
|
||||
height: Math.min(finalHeight, window.innerHeight * 0.9),
|
||||
offsetX: Math.max(0, minX - paddingX / 2),
|
||||
offsetY: Math.max(0, minY - paddingY / 2),
|
||||
offsetX: Math.max(0, minX), // paddingX 제거
|
||||
offsetY: Math.max(0, minY), // paddingY 제거
|
||||
};
|
||||
};
|
||||
|
||||
// 전역 모달 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleOpenEditModal = (event: CustomEvent) => {
|
||||
const { screenId, title, description, modalSize, editData, onSave } = event.detail;
|
||||
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName } = event.detail;
|
||||
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
|
|
@ -120,6 +128,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
modalSize: modalSize || "lg",
|
||||
editData: editData || {},
|
||||
onSave,
|
||||
groupByColumns, // 🆕 그룹핑 컬럼
|
||||
tableName, // 🆕 테이블명
|
||||
});
|
||||
|
||||
// 편집 데이터로 폼 데이터 초기화
|
||||
|
|
@ -154,9 +164,78 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
useEffect(() => {
|
||||
if (modalState.isOpen && modalState.screenId) {
|
||||
loadScreenData(modalState.screenId);
|
||||
|
||||
// 🆕 그룹 데이터 조회 (groupByColumns가 있는 경우)
|
||||
if (modalState.groupByColumns && modalState.groupByColumns.length > 0 && modalState.tableName) {
|
||||
loadGroupData();
|
||||
}
|
||||
}
|
||||
}, [modalState.isOpen, modalState.screenId]);
|
||||
|
||||
// 🆕 그룹 데이터 조회 함수
|
||||
const loadGroupData = async () => {
|
||||
if (!modalState.tableName || !modalState.groupByColumns || modalState.groupByColumns.length === 0) {
|
||||
console.warn("테이블명 또는 그룹핑 컬럼이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("🔍 그룹 데이터 조회 시작:", {
|
||||
tableName: modalState.tableName,
|
||||
groupByColumns: modalState.groupByColumns,
|
||||
editData: modalState.editData,
|
||||
});
|
||||
|
||||
// 그룹핑 컬럼 값 추출 (예: order_no = "ORD-20251124-001")
|
||||
const groupValues: Record<string, any> = {};
|
||||
modalState.groupByColumns.forEach((column) => {
|
||||
if (modalState.editData[column]) {
|
||||
groupValues[column] = modalState.editData[column];
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(groupValues).length === 0) {
|
||||
console.warn("그룹핑 컬럼 값이 없습니다:", modalState.groupByColumns);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("🔍 그룹 조회 요청:", {
|
||||
tableName: modalState.tableName,
|
||||
groupValues,
|
||||
});
|
||||
|
||||
// 같은 그룹의 모든 레코드 조회 (entityJoinApi 사용)
|
||||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||
const response = await entityJoinApi.getTableDataWithJoins(modalState.tableName, {
|
||||
page: 1,
|
||||
size: 1000,
|
||||
search: groupValues, // search 파라미터로 전달 (백엔드에서 WHERE 조건으로 처리)
|
||||
enableEntityJoin: true,
|
||||
});
|
||||
|
||||
console.log("🔍 그룹 조회 응답:", response);
|
||||
|
||||
// entityJoinApi는 배열 또는 { data: [] } 형식으로 반환
|
||||
const dataArray = Array.isArray(response) ? response : response?.data || [];
|
||||
|
||||
if (dataArray.length > 0) {
|
||||
console.log("✅ 그룹 데이터 조회 성공:", dataArray);
|
||||
setGroupData(dataArray);
|
||||
setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy
|
||||
toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`);
|
||||
} else {
|
||||
console.warn("그룹 데이터가 없습니다:", response);
|
||||
setGroupData([modalState.editData]); // 기본값: 선택된 행만
|
||||
setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("❌ 그룹 데이터 조회 오류:", error);
|
||||
toast.error("관련 데이터를 불러오는 중 오류가 발생했습니다.");
|
||||
setGroupData([modalState.editData]); // 기본값: 선택된 행만
|
||||
setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]);
|
||||
}
|
||||
};
|
||||
|
||||
const loadScreenData = async (screenId: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
|
@ -208,10 +287,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
modalSize: "md",
|
||||
editData: {},
|
||||
onSave: undefined,
|
||||
groupByColumns: undefined,
|
||||
tableName: undefined,
|
||||
});
|
||||
setScreenData(null);
|
||||
setFormData({});
|
||||
setOriginalData({});
|
||||
setGroupData([]); // 🆕
|
||||
setOriginalGroupData([]); // 🆕
|
||||
};
|
||||
|
||||
// 저장 버튼 클릭 시 - UPDATE 액션 실행
|
||||
|
|
@ -222,7 +305,214 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
|
||||
try {
|
||||
// 변경된 필드만 추출
|
||||
// 🆕 그룹 데이터가 있는 경우: 모든 품목 일괄 처리 (추가/수정/삭제)
|
||||
if (groupData.length > 0 || originalGroupData.length > 0) {
|
||||
console.log("🔄 그룹 데이터 일괄 처리 시작:", {
|
||||
groupDataLength: groupData.length,
|
||||
originalGroupDataLength: originalGroupData.length,
|
||||
groupData,
|
||||
originalGroupData,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
screenId: modalState.screenId,
|
||||
});
|
||||
|
||||
let insertedCount = 0;
|
||||
let updatedCount = 0;
|
||||
let deletedCount = 0;
|
||||
|
||||
// 1️⃣ 신규 품목 추가 (id가 없는 항목)
|
||||
for (const currentData of groupData) {
|
||||
if (!currentData.id) {
|
||||
console.log("➕ 신규 품목 추가:", currentData);
|
||||
console.log("📋 [신규 품목] currentData 키 목록:", Object.keys(currentData));
|
||||
|
||||
// 🆕 모든 데이터를 포함 (id 제외)
|
||||
const insertData: Record<string, any> = { ...currentData };
|
||||
console.log("📦 [신규 품목] 복사 직후 insertData:", insertData);
|
||||
console.log("📋 [신규 품목] insertData 키 목록:", Object.keys(insertData));
|
||||
|
||||
delete insertData.id; // id는 자동 생성되므로 제거
|
||||
|
||||
// 🆕 groupByColumns의 값을 강제로 포함 (order_no 등)
|
||||
if (modalState.groupByColumns && modalState.groupByColumns.length > 0) {
|
||||
modalState.groupByColumns.forEach((colName) => {
|
||||
// 기존 품목(originalGroupData[0])에서 groupByColumns 값 가져오기
|
||||
const referenceData = originalGroupData[0] || groupData.find((item) => item.id);
|
||||
if (referenceData && referenceData[colName]) {
|
||||
insertData[colName] = referenceData[colName];
|
||||
console.log(`🔑 [신규 품목] ${colName} 값 추가:`, referenceData[colName]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 🆕 공통 필드 추가 (거래처, 담당자, 납품처, 메모 등)
|
||||
// formData에서 품목별 필드가 아닌 공통 필드를 복사
|
||||
const commonFields = [
|
||||
'partner_id', // 거래처
|
||||
'manager_id', // 담당자
|
||||
'delivery_partner_id', // 납품처
|
||||
'delivery_address', // 납품장소
|
||||
'memo', // 메모
|
||||
'order_date', // 주문일
|
||||
'due_date', // 납기일
|
||||
'shipping_method', // 배송방법
|
||||
'status', // 상태
|
||||
'sales_type', // 영업유형
|
||||
];
|
||||
|
||||
commonFields.forEach((fieldName) => {
|
||||
// formData에 값이 있으면 추가
|
||||
if (formData[fieldName] !== undefined && formData[fieldName] !== null) {
|
||||
insertData[fieldName] = formData[fieldName];
|
||||
console.log(`🔗 [공통 필드] ${fieldName} 값 추가:`, formData[fieldName]);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("📦 [신규 품목] 최종 insertData:", insertData);
|
||||
console.log("📋 [신규 품목] 최종 insertData 키 목록:", Object.keys(insertData));
|
||||
|
||||
try {
|
||||
const response = await dynamicFormApi.saveFormData({
|
||||
screenId: modalState.screenId || 0,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
data: insertData,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
insertedCount++;
|
||||
console.log("✅ 신규 품목 추가 성공:", response.data);
|
||||
} else {
|
||||
console.error("❌ 신규 품목 추가 실패:", response.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("❌ 신규 품목 추가 오류:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2️⃣ 기존 품목 수정 (id가 있는 항목)
|
||||
for (const currentData of groupData) {
|
||||
if (currentData.id) {
|
||||
// id 기반 매칭 (인덱스 기반 X)
|
||||
const originalItemData = originalGroupData.find(
|
||||
(orig) => orig.id === currentData.id
|
||||
);
|
||||
|
||||
if (!originalItemData) {
|
||||
console.warn(`원본 데이터를 찾을 수 없습니다 (id: ${currentData.id})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 🆕 값 정규화 함수 (타입 통일)
|
||||
const normalizeValue = (val: any): any => {
|
||||
if (val === null || val === undefined || val === "") return null;
|
||||
if (typeof val === "string" && !isNaN(Number(val))) {
|
||||
// 숫자로 변환 가능한 문자열은 숫자로
|
||||
return Number(val);
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
// 변경된 필드만 추출 (id 제외)
|
||||
const changedData: Record<string, any> = {};
|
||||
Object.keys(currentData).forEach((key) => {
|
||||
// id는 변경 불가
|
||||
if (key === "id") {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 타입 정규화 후 비교
|
||||
const currentValue = normalizeValue(currentData[key]);
|
||||
const originalValue = normalizeValue(originalItemData[key]);
|
||||
|
||||
// 값이 변경된 경우만 포함
|
||||
if (currentValue !== originalValue) {
|
||||
console.log(`🔍 [품목 수정 감지] ${key}: ${originalValue} → ${currentValue}`);
|
||||
changedData[key] = currentData[key]; // 원본 값 사용 (문자열 그대로)
|
||||
}
|
||||
});
|
||||
|
||||
// 변경사항이 없으면 스킵
|
||||
if (Object.keys(changedData).length === 0) {
|
||||
console.log(`변경사항 없음 (id: ${currentData.id})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// UPDATE 실행
|
||||
try {
|
||||
const response = await dynamicFormApi.updateFormDataPartial(
|
||||
currentData.id,
|
||||
originalItemData,
|
||||
changedData,
|
||||
screenData.screenInfo.tableName,
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
updatedCount++;
|
||||
console.log(`✅ 품목 수정 성공 (id: ${currentData.id})`);
|
||||
} else {
|
||||
console.error(`❌ 품목 수정 실패 (id: ${currentData.id}):`, response.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`❌ 품목 수정 오류 (id: ${currentData.id}):`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3️⃣ 삭제된 품목 제거 (원본에는 있지만 현재 데이터에는 없는 항목)
|
||||
const currentIds = new Set(groupData.map((item) => item.id).filter(Boolean));
|
||||
const deletedItems = originalGroupData.filter(
|
||||
(orig) => orig.id && !currentIds.has(orig.id)
|
||||
);
|
||||
|
||||
for (const deletedItem of deletedItems) {
|
||||
console.log("🗑️ 품목 삭제:", deletedItem);
|
||||
|
||||
try {
|
||||
const response = await dynamicFormApi.deleteFormDataFromTable(
|
||||
deletedItem.id,
|
||||
screenData.screenInfo.tableName
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
deletedCount++;
|
||||
console.log(`✅ 품목 삭제 성공 (id: ${deletedItem.id})`);
|
||||
} else {
|
||||
console.error(`❌ 품목 삭제 실패 (id: ${deletedItem.id}):`, response.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`❌ 품목 삭제 오류 (id: ${deletedItem.id}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 결과 메시지
|
||||
const messages: string[] = [];
|
||||
if (insertedCount > 0) messages.push(`${insertedCount}개 추가`);
|
||||
if (updatedCount > 0) messages.push(`${updatedCount}개 수정`);
|
||||
if (deletedCount > 0) messages.push(`${deletedCount}개 삭제`);
|
||||
|
||||
if (messages.length > 0) {
|
||||
toast.success(`품목이 저장되었습니다 (${messages.join(", ")})`);
|
||||
|
||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||
if (modalState.onSave) {
|
||||
try {
|
||||
modalState.onSave();
|
||||
} catch (callbackError) {
|
||||
console.error("⚠️ onSave 콜백 에러:", callbackError);
|
||||
}
|
||||
}
|
||||
|
||||
handleClose();
|
||||
} else {
|
||||
toast.info("변경된 내용이 없습니다.");
|
||||
handleClose();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 로직: 단일 레코드 수정
|
||||
const changedData: Record<string, any> = {};
|
||||
Object.keys(formData).forEach((key) => {
|
||||
if (formData[key] !== originalData[key]) {
|
||||
|
|
@ -269,16 +559,18 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
};
|
||||
|
||||
// 모달 크기 설정 - ScreenModal과 동일
|
||||
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더
|
||||
const getModalStyle = () => {
|
||||
if (!screenDimensions) {
|
||||
return {
|
||||
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
|
||||
style: {},
|
||||
style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용
|
||||
};
|
||||
}
|
||||
|
||||
const headerHeight = 60;
|
||||
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더
|
||||
const headerHeight = 60; // DialogHeader
|
||||
const totalHeight = screenDimensions.height + headerHeight;
|
||||
|
||||
return {
|
||||
|
|
@ -339,6 +631,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
maxHeight: "100%",
|
||||
}}
|
||||
>
|
||||
{/* 🆕 그룹 데이터가 있으면 안내 메시지 표시 */}
|
||||
{groupData.length > 1 && (
|
||||
<div className="absolute left-4 top-4 z-10 rounded-md bg-blue-50 px-3 py-2 text-xs text-blue-700 shadow-sm">
|
||||
{groupData.length}개의 관련 품목을 함께 수정합니다
|
||||
</div>
|
||||
)}
|
||||
|
||||
{screenData.components.map((component) => {
|
||||
// 컴포넌트 위치를 offset만큼 조정
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
|
|
@ -353,23 +652,54 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
},
|
||||
};
|
||||
|
||||
// 🔍 디버깅: 컴포넌트 렌더링 시점의 groupData 확인
|
||||
if (component.id === screenData.components[0]?.id) {
|
||||
console.log("🔍 [EditModal] InteractiveScreenViewerDynamic props:", {
|
||||
componentId: component.id,
|
||||
groupDataLength: groupData.length,
|
||||
groupData: groupData,
|
||||
formData: groupData.length > 0 ? groupData[0] : formData,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
component={adjustedComponent}
|
||||
allComponents={screenData.components}
|
||||
formData={formData}
|
||||
formData={groupData.length > 0 ? groupData[0] : formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
// 🆕 그룹 데이터가 있으면 처리
|
||||
if (groupData.length > 0) {
|
||||
// ModalRepeaterTable의 경우 배열 전체를 받음
|
||||
if (Array.isArray(value)) {
|
||||
setGroupData(value);
|
||||
} else {
|
||||
// 일반 필드는 모든 항목에 동일하게 적용
|
||||
setGroupData((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
[fieldName]: value,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
onSave={handleSave}
|
||||
isInModal={true}
|
||||
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
|
||||
groupedData={groupData.length > 0 ? groupData : undefined}
|
||||
// 🆕 수정 모달에서 읽기 전용 필드 지정 (수주번호, 거래처)
|
||||
disabledFields={["order_no", "partner_id"]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -346,6 +346,14 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
|
||||
// 실제 사용 가능한 위젯 렌더링
|
||||
const renderInteractiveWidget = (comp: ComponentData) => {
|
||||
console.log("🎯 renderInteractiveWidget 호출:", {
|
||||
type: comp.type,
|
||||
id: comp.id,
|
||||
componentId: (comp as any).componentId,
|
||||
hasComponentConfig: !!(comp as any).componentConfig,
|
||||
componentConfig: (comp as any).componentConfig,
|
||||
});
|
||||
|
||||
// 데이터 테이블 컴포넌트 처리
|
||||
if (comp.type === "datatable") {
|
||||
return (
|
||||
|
|
@ -397,6 +405,39 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
);
|
||||
}
|
||||
|
||||
// 탭 컴포넌트 처리
|
||||
const componentType = (comp as any).componentType || (comp as any).componentId;
|
||||
if (comp.type === "tabs" || (comp.type === "component" && componentType === "tabs-widget")) {
|
||||
const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget;
|
||||
|
||||
// componentConfig에서 탭 정보 추출
|
||||
const tabsConfig = comp.componentConfig || {};
|
||||
const tabsComponent = {
|
||||
...comp,
|
||||
type: "tabs" as const,
|
||||
tabs: tabsConfig.tabs || [],
|
||||
defaultTab: tabsConfig.defaultTab,
|
||||
orientation: tabsConfig.orientation || "horizontal",
|
||||
variant: tabsConfig.variant || "default",
|
||||
allowCloseable: tabsConfig.allowCloseable || false,
|
||||
persistSelection: tabsConfig.persistSelection || false,
|
||||
};
|
||||
|
||||
console.log("🔍 탭 컴포넌트 렌더링:", {
|
||||
originalType: comp.type,
|
||||
componentType,
|
||||
componentId: (comp as any).componentId,
|
||||
tabs: tabsComponent.tabs,
|
||||
tabsConfig,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<TabsWidget component={tabsComponent as any} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { widgetType, label, placeholder, required, readonly, columnName } = comp;
|
||||
const fieldName = columnName || comp.id;
|
||||
const currentValue = formData[fieldName] || "";
|
||||
|
|
|
|||
|
|
@ -46,6 +46,12 @@ interface InteractiveScreenViewerProps {
|
|||
userId?: string;
|
||||
userName?: string;
|
||||
companyCode?: string;
|
||||
// 🆕 그룹 데이터 (EditModal에서 전달)
|
||||
groupedData?: Record<string, any>[];
|
||||
// 🆕 비활성화할 필드 목록 (EditModal에서 전달)
|
||||
disabledFields?: string[];
|
||||
// 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
|
||||
isInModal?: boolean;
|
||||
}
|
||||
|
||||
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
|
||||
|
|
@ -61,6 +67,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
userId: externalUserId,
|
||||
userName: externalUserName,
|
||||
companyCode: externalCompanyCode,
|
||||
groupedData,
|
||||
disabledFields = [],
|
||||
isInModal = false,
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const { userName: authUserName, user: authUser } = useAuth();
|
||||
|
|
@ -326,12 +335,17 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
userId={user?.userId} // ✅ 사용자 ID 전달
|
||||
userName={user?.userName} // ✅ 사용자 이름 전달
|
||||
companyCode={user?.companyCode} // ✅ 회사 코드 전달
|
||||
onSave={onSave} // 🆕 EditModal의 handleSave 콜백 전달
|
||||
allComponents={allComponents} // 🆕 같은 화면의 모든 컴포넌트 전달 (TableList 자동 감지용)
|
||||
selectedRowsData={selectedRowsData}
|
||||
onSelectedRowsChange={(selectedRows, selectedData) => {
|
||||
console.log("🔍 테이블에서 선택된 행 데이터:", selectedData);
|
||||
setSelectedRowsData(selectedData);
|
||||
}}
|
||||
// 🆕 그룹 데이터 전달 (EditModal → ModalRepeaterTable)
|
||||
groupedData={groupedData}
|
||||
// 🆕 비활성화 필드 전달 (EditModal → 각 컴포넌트)
|
||||
disabledFields={disabledFields}
|
||||
flowSelectedData={flowSelectedData}
|
||||
flowSelectedStepId={flowSelectedStepId}
|
||||
onFlowSelectedDataChange={(selectedData, stepId) => {
|
||||
|
|
@ -396,6 +410,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
required: required,
|
||||
placeholder: placeholder,
|
||||
className: "w-full h-full",
|
||||
isInModal: isInModal, // 🆕 EditModal 내부 여부 전달
|
||||
onSave: onSave, // 🆕 EditModal의 handleSave 콜백 전달
|
||||
}}
|
||||
config={widget.webTypeConfig}
|
||||
onEvent={(event: string, data: any) => {
|
||||
|
|
|
|||
|
|
@ -554,6 +554,73 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
);
|
||||
})()}
|
||||
|
||||
{/* 탭 컴포넌트 타입 */}
|
||||
{(type === "tabs" || (type === "component" && ((component as any).componentType === "tabs-widget" || (component as any).componentId === "tabs-widget"))) &&
|
||||
(() => {
|
||||
console.log("🎯 탭 컴포넌트 조건 충족:", {
|
||||
type,
|
||||
componentType: (component as any).componentType,
|
||||
componentId: (component as any).componentId,
|
||||
isDesignMode,
|
||||
});
|
||||
|
||||
if (isDesignMode) {
|
||||
// 디자인 모드: 미리보기 표시
|
||||
const tabsComponent = component as any;
|
||||
const tabs = tabsComponent.componentConfig?.tabs || tabsComponent.tabs || [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center">
|
||||
<Folder className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2 text-sm font-medium">탭 컴포넌트</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{tabs.length > 0
|
||||
? `${tabs.length}개의 탭 (실제 화면에서 표시됩니다)`
|
||||
: "탭이 없습니다. 설정 패널에서 탭을 추가하세요"}
|
||||
</p>
|
||||
{tabs.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-1">
|
||||
{tabs.map((tab: any, index: number) => (
|
||||
<Badge key={tab.id} variant="outline" className="text-xs">
|
||||
{tab.label || `탭 ${index + 1}`}
|
||||
{tab.screenName && (
|
||||
<span className="ml-1 text-[10px] text-gray-400">
|
||||
({tab.screenName})
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// 실제 화면: TabsWidget 렌더링
|
||||
const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget;
|
||||
const tabsConfig = (component as any).componentConfig || {};
|
||||
const tabsComponent = {
|
||||
...component,
|
||||
type: "tabs" as const,
|
||||
tabs: tabsConfig.tabs || [],
|
||||
defaultTab: tabsConfig.defaultTab,
|
||||
orientation: tabsConfig.orientation || "horizontal",
|
||||
variant: tabsConfig.variant || "default",
|
||||
allowCloseable: tabsConfig.allowCloseable || false,
|
||||
persistSelection: tabsConfig.persistSelection || false,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<TabsWidget component={tabsComponent as any} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
|
||||
{/* 그룹 타입 */}
|
||||
{type === "group" && (
|
||||
<div className="relative h-full w-full">
|
||||
|
|
|
|||
|
|
@ -35,9 +35,9 @@ export const ResponsiveDesignerContainer: React.FC<ResponsiveDesignerContainerPr
|
|||
|
||||
switch (viewMode) {
|
||||
case "fit":
|
||||
// 컨테이너에 맞춰 비율 유지하며 조정 (여백 허용)
|
||||
const scaleX = (containerSize.width - 40) / designWidth;
|
||||
const scaleY = (containerSize.height - 40) / designHeight;
|
||||
// 컨테이너에 맞춰 비율 유지하며 조정 (좌우 여백 16px씩 유지)
|
||||
const scaleX = (containerSize.width - 32) / designWidth;
|
||||
const scaleY = (containerSize.height - 64) / designHeight;
|
||||
return Math.min(scaleX, scaleY, 2); // 최대 2배까지 허용
|
||||
|
||||
case "custom":
|
||||
|
|
@ -154,7 +154,7 @@ export const ResponsiveDesignerContainer: React.FC<ResponsiveDesignerContainerPr
|
|||
{/* 디자인 영역 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-auto p-8"
|
||||
className="flex-1 overflow-auto px-4 py-8"
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "flex-start",
|
||||
|
|
|
|||
|
|
@ -216,10 +216,16 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
return y + height;
|
||||
}));
|
||||
|
||||
const padding = 40;
|
||||
// 컨텐츠 영역 크기 (화면관리 설정 크기)
|
||||
const contentWidth = Math.max(maxX, 400);
|
||||
const contentHeight = Math.max(maxY, 300);
|
||||
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더
|
||||
const headerHeight = 60; // DialogHeader
|
||||
|
||||
return {
|
||||
width: Math.max(maxX + padding, 400),
|
||||
height: Math.max(maxY + padding, 300),
|
||||
width: contentWidth,
|
||||
height: contentHeight + headerHeight, // 헤더 높이 포함
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -229,8 +235,12 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
<ResizableDialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
|
||||
<ResizableDialogContent
|
||||
modalId={`save-modal-${screenId}`}
|
||||
defaultWidth={dynamicSize.width + 48}
|
||||
defaultHeight={dynamicSize.height + 120}
|
||||
style={{
|
||||
width: `${dynamicSize.width}px`,
|
||||
height: `${dynamicSize.height}px`, // 화면관리 설정 크기 그대로 사용
|
||||
}}
|
||||
defaultWidth={600} // 폴백용 기본값
|
||||
defaultHeight={400} // 폴백용 기본값
|
||||
minWidth={400}
|
||||
minHeight={300}
|
||||
className="gap-0 p-0"
|
||||
|
|
@ -337,12 +347,22 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
|||
formData={formData}
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
console.log("📝 SaveModal - formData 변경:", {
|
||||
fieldName,
|
||||
value,
|
||||
componentType: component.type,
|
||||
componentId: component.id,
|
||||
});
|
||||
setFormData((prev) => {
|
||||
const newData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
console.log("📦 새 formData:", newData);
|
||||
return newData;
|
||||
});
|
||||
}}
|
||||
mode={initialData ? "edit" : "create"}
|
||||
mode="edit"
|
||||
isInModal={true}
|
||||
isInteractive={true}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -981,7 +981,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// 스페이스바 키 이벤트 처리 (Pan 모드) + 전역 마우스 이벤트
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// 입력 필드에서는 스페이스바 무시
|
||||
// 입력 필드에서는 스페이스바 무시 (activeElement로 정확하게 체크)
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
activeElement?.getAttribute('contenteditable') === 'true' ||
|
||||
activeElement?.getAttribute('role') === 'textbox'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// e.target도 함께 체크 (이중 방어)
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -997,6 +1008,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
// 입력 필드에서는 스페이스바 무시
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
activeElement?.getAttribute('contenteditable') === 'true' ||
|
||||
activeElement?.getAttribute('role') === 'textbox'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.code === "Space") {
|
||||
e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단
|
||||
setIsPanMode(false);
|
||||
|
|
|
|||
|
|
@ -35,7 +35,10 @@ import {
|
|||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } 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 { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import CreateScreenModal from "./CreateScreenModal";
|
||||
|
|
@ -127,8 +130,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
isActive: "Y",
|
||||
tableName: "",
|
||||
});
|
||||
const [tables, setTables] = useState<string[]>([]);
|
||||
const [tables, setTables] = useState<Array<{ tableName: string; tableLabel: string }>>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
||||
|
||||
// 미리보기 관련 상태
|
||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
|
||||
|
|
@ -279,9 +283,12 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
// tableName만 추출 (camelCase)
|
||||
const tableNames = response.data.map((table: any) => table.tableName);
|
||||
setTables(tableNames);
|
||||
// tableName과 displayName 매핑 (백엔드에서 displayName으로 라벨을 반환함)
|
||||
const tableList = response.data.map((table: any) => ({
|
||||
tableName: table.tableName,
|
||||
tableLabel: table.displayName || table.tableName,
|
||||
}));
|
||||
setTables(tableList);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 실패:", error);
|
||||
|
|
@ -297,6 +304,10 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
// 화면 정보 업데이트 API 호출
|
||||
await screenApi.updateScreenInfo(screenToEdit.screenId, editFormData);
|
||||
|
||||
// 선택된 테이블의 라벨 찾기
|
||||
const selectedTable = tables.find((t) => t.tableName === editFormData.tableName);
|
||||
const tableLabel = selectedTable?.tableLabel || editFormData.tableName;
|
||||
|
||||
// 목록에서 해당 화면 정보 업데이트
|
||||
setScreens((prev) =>
|
||||
prev.map((s) =>
|
||||
|
|
@ -304,6 +315,8 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
? {
|
||||
...s,
|
||||
screenName: editFormData.screenName,
|
||||
tableName: editFormData.tableName,
|
||||
tableLabel: tableLabel,
|
||||
description: editFormData.description,
|
||||
isActive: editFormData.isActive,
|
||||
}
|
||||
|
|
@ -1202,22 +1215,62 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-tableName">테이블 *</Label>
|
||||
<Select
|
||||
value={editFormData.tableName}
|
||||
onValueChange={(value) => setEditFormData({ ...editFormData, tableName: value })}
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<SelectTrigger id="edit-tableName">
|
||||
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블을 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((tableName) => (
|
||||
<SelectItem key={tableName} value={tableName}>
|
||||
{tableName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboboxOpen}
|
||||
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
||||
disabled={loadingTables}
|
||||
>
|
||||
{loadingTables
|
||||
? "로딩 중..."
|
||||
: editFormData.tableName
|
||||
? tables.find((table) => table.tableName === editFormData.tableName)?.tableLabel || editFormData.tableName
|
||||
: "테이블을 선택하세요"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">
|
||||
테이블을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableName} ${table.tableLabel}`}
|
||||
onSelect={() => {
|
||||
setEditFormData({ ...editFormData, tableName: table.tableName });
|
||||
setTableComboboxOpen(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
editFormData.tableName === table.tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.tableLabel}</span>
|
||||
<span className="text-[10px] text-gray-500">{table.tableName}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-description">설명</Label>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
value={localStyle.borderWidth || ""}
|
||||
onChange={(e) => handleStyleChange("borderWidth", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
|
@ -60,20 +59,20 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
value={localStyle.borderStyle || "solid"}
|
||||
onValueChange={(value) => handleStyleChange("borderStyle", value)}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className=" text-xsh-6 w-full px-2 py-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="solid" className="text-xs">
|
||||
실선
|
||||
</SelectItem>
|
||||
<SelectItem value="dashed" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="dashed" className="text-xs">
|
||||
파선
|
||||
</SelectItem>
|
||||
<SelectItem value="dotted" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="dotted" className="text-xs">
|
||||
점선
|
||||
</SelectItem>
|
||||
<SelectItem value="none" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="none" className="text-xs">
|
||||
없음
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
|
|
@ -93,7 +92,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
value={localStyle.borderColor || "#000000"}
|
||||
onChange={(e) => handleStyleChange("borderColor", e.target.value)}
|
||||
className="h-6 w-12 p-1"
|
||||
style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
|
|
@ -101,7 +100,6 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
onChange={(e) => handleStyleChange("borderColor", e.target.value)}
|
||||
placeholder="#000000"
|
||||
className="h-6 flex-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -116,7 +114,6 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
value={localStyle.borderRadius || ""}
|
||||
onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -142,7 +139,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
value={localStyle.backgroundColor || "#ffffff"}
|
||||
onChange={(e) => handleStyleChange("backgroundColor", e.target.value)}
|
||||
className="h-6 w-12 p-1"
|
||||
style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
|
|
@ -150,7 +147,6 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
onChange={(e) => handleStyleChange("backgroundColor", e.target.value)}
|
||||
placeholder="#ffffff"
|
||||
className="h-6 flex-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -166,7 +162,6 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
value={localStyle.backgroundImage || ""}
|
||||
onChange={(e) => handleStyleChange("backgroundImage", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
위젯 배경 꾸미기용 (고급 사용자 전용)
|
||||
|
|
@ -195,7 +190,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
value={localStyle.color || "#000000"}
|
||||
onChange={(e) => handleStyleChange("color", e.target.value)}
|
||||
className="h-6 w-12 p-1"
|
||||
style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
|
|
@ -203,7 +198,6 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
onChange={(e) => handleStyleChange("color", e.target.value)}
|
||||
placeholder="#000000"
|
||||
className="h-6 flex-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -218,7 +212,6 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
value={localStyle.fontSize || ""}
|
||||
onChange={(e) => handleStyleChange("fontSize", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -232,29 +225,29 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
value={localStyle.fontWeight || "normal"}
|
||||
onValueChange={(value) => handleStyleChange("fontWeight", value)}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className=" text-xsh-6 w-full px-2 py-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="normal" className="text-xs">
|
||||
보통
|
||||
</SelectItem>
|
||||
<SelectItem value="bold" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="bold" className="text-xs">
|
||||
굵게
|
||||
</SelectItem>
|
||||
<SelectItem value="100" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="100" className="text-xs">
|
||||
100
|
||||
</SelectItem>
|
||||
<SelectItem value="400" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="400" className="text-xs">
|
||||
400
|
||||
</SelectItem>
|
||||
<SelectItem value="500" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="500" className="text-xs">
|
||||
500
|
||||
</SelectItem>
|
||||
<SelectItem value="600" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="600" className="text-xs">
|
||||
600
|
||||
</SelectItem>
|
||||
<SelectItem value="700" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="700" className="text-xs">
|
||||
700
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
|
|
@ -268,20 +261,20 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
value={localStyle.textAlign || "left"}
|
||||
onValueChange={(value) => handleStyleChange("textAlign", value)}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className=" text-xsh-6 w-full px-2 py-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="left" className="text-xs">
|
||||
왼쪽
|
||||
</SelectItem>
|
||||
<SelectItem value="center" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="center" className="text-xs">
|
||||
가운데
|
||||
</SelectItem>
|
||||
<SelectItem value="right" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="right" className="text-xs">
|
||||
오른쪽
|
||||
</SelectItem>
|
||||
<SelectItem value="justify" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="justify" className="text-xs">
|
||||
양쪽
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
|
|
|
|||
|
|
@ -509,7 +509,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
role="combobox"
|
||||
aria-expanded={modalScreenOpen}
|
||||
className="h-6 w-full justify-between px-2 py-0"
|
||||
style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
disabled={screensLoading}
|
||||
>
|
||||
{config.action?.targetScreenId
|
||||
|
|
@ -900,7 +900,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
role="combobox"
|
||||
aria-expanded={modalScreenOpen}
|
||||
className="h-6 w-full justify-between px-2 py-0"
|
||||
style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
disabled={screensLoading}
|
||||
>
|
||||
{config.action?.targetScreenId
|
||||
|
|
@ -978,7 +978,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
role="combobox"
|
||||
aria-expanded={modalScreenOpen}
|
||||
className="h-6 w-full justify-between px-2 py-0"
|
||||
style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
disabled={screensLoading}
|
||||
>
|
||||
{config.action?.targetScreenId
|
||||
|
|
@ -1132,7 +1132,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
role="combobox"
|
||||
aria-expanded={modalScreenOpen}
|
||||
className="h-6 w-full justify-between px-2 py-0"
|
||||
style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
disabled={screensLoading}
|
||||
>
|
||||
{config.action?.targetScreenId
|
||||
|
|
@ -1286,7 +1286,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
role="combobox"
|
||||
aria-expanded={displayColumnOpen}
|
||||
className="mt-2 h-8 w-full justify-between text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
disabled={columnsLoading || tableColumns.length === 0}
|
||||
>
|
||||
{columnsLoading
|
||||
|
|
@ -1301,9 +1300,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" style={{ fontSize: "12px" }} />
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<CommandEmpty className="text-xs">
|
||||
컬럼을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
|
|
@ -1316,7 +1315,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
setDisplayColumnOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
|
|
@ -1350,7 +1348,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
role="combobox"
|
||||
aria-expanded={navScreenOpen}
|
||||
className="h-6 w-full justify-between px-2 py-0"
|
||||
style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
disabled={screensLoading}
|
||||
>
|
||||
{config.action?.targetScreenId
|
||||
|
|
@ -1424,7 +1422,6 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
onUpdateProperty("componentConfig.action.targetUrl", newValue);
|
||||
}}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">URL을 입력하면 화면 선택보다 우선 적용됩니다</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<CardTitle className="flex items-center gap-2 text-xs">
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
체크박스 설정
|
||||
</CardTitle>
|
||||
|
|
@ -173,7 +173,7 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.label || ""}
|
||||
onChange={(e) => updateConfig("label", e.target.value)}
|
||||
placeholder="체크박스 라벨"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -187,7 +187,7 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.checkedValue || ""}
|
||||
onChange={(e) => updateConfig("checkedValue", e.target.value)}
|
||||
placeholder="Y"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -199,7 +199,7 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.uncheckedValue || ""}
|
||||
onChange={(e) => updateConfig("uncheckedValue", e.target.value)}
|
||||
placeholder="N"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -232,7 +232,7 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.groupLabel || ""}
|
||||
onChange={(e) => updateConfig("groupLabel", e.target.value)}
|
||||
placeholder="체크박스 그룹 제목"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -244,19 +244,19 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={newOptionLabel}
|
||||
onChange={(e) => setNewOptionLabel(e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={newOptionValue}
|
||||
onChange={(e) => setNewOptionValue(e.target.value)}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={addOption}
|
||||
disabled={!newOptionLabel.trim() || !newOptionValue.trim()}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -277,13 +277,13 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={option.label}
|
||||
onChange={(e) => updateOption(index, "label", e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={option.value}
|
||||
onChange={(e) => updateOption(index, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Switch
|
||||
checked={!option.disabled}
|
||||
|
|
@ -361,7 +361,7 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
disabled={localConfig.readonly}
|
||||
required={localConfig.required}
|
||||
defaultChecked={localConfig.defaultChecked}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Label htmlFor="preview-single" className="text-xs">
|
||||
{localConfig.label || "체크박스 라벨"}
|
||||
|
|
@ -380,7 +380,7 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
disabled={localConfig.readonly || option.disabled}
|
||||
required={localConfig.required && index === 0} // 첫 번째에만 required 표시
|
||||
defaultChecked={option.checked}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Label htmlFor={`preview-group-${index}`} className="text-xs">
|
||||
{option.label}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export const CodeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<CardTitle className="flex items-center gap-2 text-xs">
|
||||
<Code className="h-4 w-4" />
|
||||
코드 에디터 설정
|
||||
</CardTitle>
|
||||
|
|
@ -174,7 +174,7 @@ export const CodeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
step={50}
|
||||
value={localConfig.height || 300}
|
||||
onChange={(e) => updateConfig("height", parseInt(e.target.value))}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>150px</span>
|
||||
|
|
@ -199,7 +199,7 @@ export const CodeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
onChange={(e) => updateConfig("fontSize", parseInt(e.target.value))}
|
||||
min={10}
|
||||
max={24}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -214,7 +214,7 @@ export const CodeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
onChange={(e) => updateConfig("tabSize", parseInt(e.target.value))}
|
||||
min={1}
|
||||
max={8}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -308,7 +308,7 @@ export const CodeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="코드를 입력하세요..."
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -330,7 +330,7 @@ export const CodeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.defaultValue || ""}
|
||||
onChange={(e) => updateConfig("defaultValue", e.target.value)}
|
||||
placeholder="기본 코드 내용"
|
||||
className="font-mono text-xs" style={{ fontSize: "12px" }}
|
||||
className="font-mono text-xs"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ export const DateConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<CardTitle className="flex items-center gap-2 text-xs">
|
||||
<Calendar className="h-4 w-4" />
|
||||
날짜 설정
|
||||
</CardTitle>
|
||||
|
|
@ -95,7 +95,7 @@ export const DateConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="날짜를 선택하세요"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ export const DateConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
type={localConfig.showTime ? "datetime-local" : "date"}
|
||||
value={localConfig.minDate || ""}
|
||||
onChange={(e) => updateConfig("minDate", e.target.value)}
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => setCurrentDate("minDate")} className="text-xs">
|
||||
오늘
|
||||
|
|
@ -167,7 +167,7 @@ export const DateConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
type={localConfig.showTime ? "datetime-local" : "date"}
|
||||
value={localConfig.maxDate || ""}
|
||||
onChange={(e) => updateConfig("maxDate", e.target.value)}
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => setCurrentDate("maxDate")} className="text-xs">
|
||||
오늘
|
||||
|
|
@ -190,7 +190,7 @@ export const DateConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
type={localConfig.showTime ? "datetime-local" : "date"}
|
||||
value={localConfig.defaultValue || ""}
|
||||
onChange={(e) => updateConfig("defaultValue", e.target.value)}
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => setCurrentDate("defaultValue")} className="text-xs">
|
||||
현재
|
||||
|
|
@ -245,7 +245,7 @@ export const DateConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
min={localConfig.minDate}
|
||||
max={localConfig.maxDate}
|
||||
defaultValue={localConfig.defaultValue}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
형식: {localConfig.format}
|
||||
|
|
|
|||
|
|
@ -51,37 +51,52 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
const [newFieldName, setNewFieldName] = useState("");
|
||||
const [newFieldLabel, setNewFieldLabel] = useState("");
|
||||
const [newFieldType, setNewFieldType] = useState("string");
|
||||
const [isUserEditing, setIsUserEditing] = useState(false);
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화 (사용자가 입력 중이 아닐 때만)
|
||||
useEffect(() => {
|
||||
const currentConfig = (widget.webTypeConfig as EntityTypeConfig) || {};
|
||||
setLocalConfig({
|
||||
entityType: currentConfig.entityType || "",
|
||||
displayFields: currentConfig.displayFields || [],
|
||||
searchFields: currentConfig.searchFields || [],
|
||||
valueField: currentConfig.valueField || "id",
|
||||
labelField: currentConfig.labelField || "name",
|
||||
multiple: currentConfig.multiple || false,
|
||||
searchable: currentConfig.searchable !== false,
|
||||
placeholder: currentConfig.placeholder || "엔티티를 선택하세요",
|
||||
emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다",
|
||||
pageSize: currentConfig.pageSize || 20,
|
||||
minSearchLength: currentConfig.minSearchLength || 1,
|
||||
defaultValue: currentConfig.defaultValue || "",
|
||||
required: currentConfig.required || false,
|
||||
readonly: currentConfig.readonly || false,
|
||||
apiEndpoint: currentConfig.apiEndpoint || "",
|
||||
filters: currentConfig.filters || {},
|
||||
});
|
||||
}, [widget.webTypeConfig]);
|
||||
if (!isUserEditing) {
|
||||
const currentConfig = (widget.webTypeConfig as EntityTypeConfig) || {};
|
||||
setLocalConfig({
|
||||
entityType: currentConfig.entityType || "",
|
||||
displayFields: currentConfig.displayFields || [],
|
||||
searchFields: currentConfig.searchFields || [],
|
||||
valueField: currentConfig.valueField || "id",
|
||||
labelField: currentConfig.labelField || "name",
|
||||
multiple: currentConfig.multiple || false,
|
||||
searchable: currentConfig.searchable !== false,
|
||||
placeholder: currentConfig.placeholder || "엔티티를 선택하세요",
|
||||
emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다",
|
||||
pageSize: currentConfig.pageSize || 20,
|
||||
minSearchLength: currentConfig.minSearchLength || 1,
|
||||
defaultValue: currentConfig.defaultValue || "",
|
||||
required: currentConfig.required || false,
|
||||
readonly: currentConfig.readonly || false,
|
||||
apiEndpoint: currentConfig.apiEndpoint || "",
|
||||
filters: currentConfig.filters || {},
|
||||
});
|
||||
}
|
||||
}, [widget.webTypeConfig, isUserEditing]);
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
// 설정 업데이트 핸들러 (즉시 부모에게 전달 - 드롭다운, 체크박스 등)
|
||||
const updateConfig = (field: keyof EntityTypeConfig, value: any) => {
|
||||
const newConfig = { ...localConfig, [field]: value };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
};
|
||||
|
||||
// 입력 필드용 업데이트 (로컬 상태만)
|
||||
const updateConfigLocal = (field: keyof EntityTypeConfig, value: any) => {
|
||||
setIsUserEditing(true);
|
||||
setLocalConfig({ ...localConfig, [field]: value });
|
||||
};
|
||||
|
||||
// 입력 완료 시 부모에게 전달
|
||||
const handleInputBlur = () => {
|
||||
setIsUserEditing(false);
|
||||
onUpdateProperty("webTypeConfig", localConfig);
|
||||
};
|
||||
|
||||
// 필드 추가
|
||||
const addDisplayField = () => {
|
||||
if (!newFieldName.trim() || !newFieldLabel.trim()) return;
|
||||
|
|
@ -106,11 +121,18 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
updateConfig("displayFields", newFields);
|
||||
};
|
||||
|
||||
// 필드 업데이트
|
||||
// 필드 업데이트 (입력 중)
|
||||
const updateDisplayField = (index: number, field: keyof EntityField, value: any) => {
|
||||
setIsUserEditing(true);
|
||||
const newFields = [...localConfig.displayFields];
|
||||
newFields[index] = { ...newFields[index], [field]: value };
|
||||
updateConfig("displayFields", newFields);
|
||||
setLocalConfig({ ...localConfig, displayFields: newFields });
|
||||
};
|
||||
|
||||
// 필드 업데이트 완료 (onBlur)
|
||||
const handleFieldBlur = () => {
|
||||
setIsUserEditing(false);
|
||||
onUpdateProperty("webTypeConfig", localConfig);
|
||||
};
|
||||
|
||||
// 검색 필드 토글
|
||||
|
|
@ -163,7 +185,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<CardTitle className="flex items-center gap-2 text-xs">
|
||||
<Database className="h-4 w-4" />
|
||||
엔티티 설정
|
||||
</CardTitle>
|
||||
|
|
@ -181,9 +203,10 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
<Input
|
||||
id="entityType"
|
||||
value={localConfig.entityType || ""}
|
||||
onChange={(e) => updateConfig("entityType", e.target.value)}
|
||||
onChange={(e) => updateConfigLocal("entityType", e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder="user, product, department..."
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -196,7 +219,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => applyEntityType(entity.value)}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
>
|
||||
{entity.label}
|
||||
</Button>
|
||||
|
|
@ -211,9 +234,10 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
<Input
|
||||
id="apiEndpoint"
|
||||
value={localConfig.apiEndpoint || ""}
|
||||
onChange={(e) => updateConfig("apiEndpoint", e.target.value)}
|
||||
onChange={(e) => updateConfigLocal("apiEndpoint", e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder="/api/entities/user"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -230,9 +254,10 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
<Input
|
||||
id="valueField"
|
||||
value={localConfig.valueField || ""}
|
||||
onChange={(e) => updateConfig("valueField", e.target.value)}
|
||||
onChange={(e) => updateConfigLocal("valueField", e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder="id"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -243,9 +268,10 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
<Input
|
||||
id="labelField"
|
||||
value={localConfig.labelField || ""}
|
||||
onChange={(e) => updateConfig("labelField", e.target.value)}
|
||||
onChange={(e) => updateConfigLocal("labelField", e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder="name"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -263,13 +289,13 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={newFieldName}
|
||||
onChange={(e) => setNewFieldName(e.target.value)}
|
||||
placeholder="필드명"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={newFieldLabel}
|
||||
onChange={(e) => setNewFieldLabel(e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Select value={newFieldType} onValueChange={setNewFieldType}>
|
||||
<SelectTrigger className="w-24 text-xs">
|
||||
|
|
@ -287,7 +313,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
size="sm"
|
||||
onClick={addDisplayField}
|
||||
disabled={!newFieldName.trim() || !newFieldLabel.trim()}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -302,19 +328,24 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
<div key={index} className="flex items-center gap-2 rounded border p-2">
|
||||
<Switch
|
||||
checked={field.visible}
|
||||
onCheckedChange={(checked) => updateDisplayField(index, "visible", checked)}
|
||||
onCheckedChange={(checked) => {
|
||||
updateDisplayField(index, "visible", checked);
|
||||
handleFieldBlur();
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={field.name}
|
||||
onChange={(e) => updateDisplayField(index, "name", e.target.value)}
|
||||
onBlur={handleFieldBlur}
|
||||
placeholder="필드명"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={field.label}
|
||||
onChange={(e) => updateDisplayField(index, "label", e.target.value)}
|
||||
onBlur={handleFieldBlur}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Select value={field.type} onValueChange={(value) => updateDisplayField(index, "type", value)}>
|
||||
<SelectTrigger className="w-24 text-xs">
|
||||
|
|
@ -332,7 +363,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
size="sm"
|
||||
variant={localConfig.searchFields.includes(field.name) ? "default" : "outline"}
|
||||
onClick={() => toggleSearchField(field.name)}
|
||||
className="p-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="p-1 text-xs"
|
||||
title={localConfig.searchFields.includes(field.name) ? "검색 필드에서 제거" : "검색 필드로 추가"}
|
||||
>
|
||||
<Search className="h-3 w-3" />
|
||||
|
|
@ -341,7 +372,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => removeDisplayField(index)}
|
||||
className="p-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="p-1 text-xs"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -362,9 +393,10 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
<Input
|
||||
id="placeholder"
|
||||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
onChange={(e) => updateConfigLocal("placeholder", e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder="엔티티를 선택하세요"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -375,9 +407,10 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
<Input
|
||||
id="emptyMessage"
|
||||
value={localConfig.emptyMessage || ""}
|
||||
onChange={(e) => updateConfig("emptyMessage", e.target.value)}
|
||||
onChange={(e) => updateConfigLocal("emptyMessage", e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder="검색 결과가 없습니다"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -393,7 +426,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
onChange={(e) => updateConfig("minSearchLength", parseInt(e.target.value))}
|
||||
min={0}
|
||||
max={10}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -408,7 +441,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
onChange={(e) => updateConfig("pageSize", parseInt(e.target.value))}
|
||||
min={5}
|
||||
max={100}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -462,7 +495,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
}
|
||||
}}
|
||||
placeholder='{"status": "active", "department": "IT"}'
|
||||
className="font-mono text-xs" style={{ fontSize: "12px" }}
|
||||
className="font-mono text-xs"
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">API 요청에 추가될 필터 조건을 JSON 형태로 입력하세요.</p>
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ export const FileConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<CardTitle className="flex items-center gap-2 text-xs">
|
||||
<Upload className="h-4 w-4" />
|
||||
파일 업로드 설정
|
||||
</CardTitle>
|
||||
|
|
@ -133,7 +133,7 @@ export const FileConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.uploadText || ""}
|
||||
onChange={(e) => updateConfig("uploadText", e.target.value)}
|
||||
placeholder="파일을 선택하거나 여기에 드래그하세요"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -146,7 +146,7 @@ export const FileConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.browseText || ""}
|
||||
onChange={(e) => updateConfig("browseText", e.target.value)}
|
||||
placeholder="파일 선택"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -196,7 +196,7 @@ export const FileConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
min={0.1}
|
||||
max={1024}
|
||||
step={0.1}
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">MB</span>
|
||||
</div>
|
||||
|
|
@ -214,7 +214,7 @@ export const FileConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
onChange={(e) => updateConfig("maxFiles", parseInt(e.target.value))}
|
||||
min={1}
|
||||
max={100}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -257,7 +257,7 @@ export const FileConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={newFileType}
|
||||
onChange={(e) => setNewFileType(e.target.value)}
|
||||
placeholder=".pdf 또는 pdf"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Button size="sm" onClick={addFileType} disabled={!newFileType.trim()} className="text-xs">
|
||||
추가
|
||||
|
|
|
|||
|
|
@ -236,11 +236,11 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h4 className="flex items-center gap-2 text-xs font-medium" style={{ fontSize: "12px" }}>
|
||||
<h4 className="flex items-center gap-2 text-xs font-medium">
|
||||
<Workflow className="h-4 w-4" />
|
||||
플로우 단계별 표시 설정
|
||||
</h4>
|
||||
<p className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
플로우의 특정 단계에서만 이 버튼을 표시하거나 숨길 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -256,7 +256,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="flow-control-enabled" className="text-xs font-medium" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="flow-control-enabled" className="text-xs font-medium">
|
||||
플로우 단계에 따라 버튼 표시 제어
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -265,7 +265,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
<>
|
||||
{/* 대상 플로우 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium" style={{ fontSize: "12px" }}>
|
||||
<Label className="text-xs font-medium">
|
||||
대상 플로우
|
||||
</Label>
|
||||
<Select
|
||||
|
|
@ -275,7 +275,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
setTimeout(() => applyConfig(), 0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className="h-6 text-xs">
|
||||
<SelectValue placeholder="플로우 위젯 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -283,7 +283,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
const flowConfig = (fw as any).componentConfig || {};
|
||||
const flowName = flowConfig.flowName || `플로우 ${fw.id}`;
|
||||
return (
|
||||
<SelectItem key={fw.id} value={fw.id} style={{ fontSize: "12px" }}>
|
||||
<SelectItem key={fw.id} value={fw.id} className="text-xs">
|
||||
{flowName}
|
||||
</SelectItem>
|
||||
);
|
||||
|
|
@ -298,7 +298,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
{/* 단계 선택 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium" style={{ fontSize: "12px" }}>
|
||||
<Label className="text-xs font-medium">
|
||||
표시할 단계
|
||||
</Label>
|
||||
<div className="flex gap-1">
|
||||
|
|
@ -307,7 +307,6 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
size="sm"
|
||||
onClick={selectAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
모두 선택
|
||||
</Button>
|
||||
|
|
@ -316,7 +315,6 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
size="sm"
|
||||
onClick={selectNone}
|
||||
className="h-7 px-2 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
모두 해제
|
||||
</Button>
|
||||
|
|
@ -325,7 +323,6 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
size="sm"
|
||||
onClick={invertSelection}
|
||||
className="h-7 px-2 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
반전
|
||||
</Button>
|
||||
|
|
@ -347,9 +344,8 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
<Label
|
||||
htmlFor={`step-${step.id}`}
|
||||
className="flex flex-1 items-center gap-2 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<Badge variant="outline" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Step {step.stepOrder}
|
||||
</Badge>
|
||||
<span>{step.stepName}</span>
|
||||
|
|
@ -363,7 +359,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
|
||||
{/* 정렬 방식 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-align" className="text-xs font-medium" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="group-align" className="text-xs font-medium">
|
||||
정렬 방식
|
||||
</Label>
|
||||
<Select
|
||||
|
|
@ -373,23 +369,23 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
|
|||
onUpdateProperty("webTypeConfig.flowVisibilityConfig.groupAlign", value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="group-align" className="h-6 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger id="group-align" className="h-6 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="start" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="start" className="text-xs">
|
||||
시작점 정렬
|
||||
</SelectItem>
|
||||
<SelectItem value="center" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="center" className="text-xs">
|
||||
중앙 정렬
|
||||
</SelectItem>
|
||||
<SelectItem value="end" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="end" className="text-xs">
|
||||
끝점 정렬
|
||||
</SelectItem>
|
||||
<SelectItem value="space-between" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="space-between" className="text-xs">
|
||||
양 끝 정렬
|
||||
</SelectItem>
|
||||
<SelectItem value="space-around" style={{ fontSize: "12px" }}>
|
||||
<SelectItem value="space-around" className="text-xs">
|
||||
균등 배분
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export function FlowWidgetConfigPanel({ config = {}, onChange }: FlowWidgetConfi
|
|||
{loading ? (
|
||||
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>로딩 중...</span>
|
||||
<span className="text-muted-foreground text-xs">로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export const NumberConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>숫자 설정</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2 text-xs">숫자 설정</CardTitle>
|
||||
<CardDescription className="text-xs">숫자 입력 필드의 세부 설정을 관리합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -73,7 +73,7 @@ export const NumberConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="숫자를 입력하세요"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ export const NumberConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.min ?? ""}
|
||||
onChange={(e) => updateConfig("min", e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
placeholder="0"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -101,7 +101,7 @@ export const NumberConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.max ?? ""}
|
||||
onChange={(e) => updateConfig("max", e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
placeholder="100"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -118,7 +118,7 @@ export const NumberConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
placeholder="1"
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">증가/감소 버튼 클릭 시 변경되는 값의 크기</p>
|
||||
</div>
|
||||
|
|
@ -158,7 +158,7 @@ export const NumberConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
placeholder="2"
|
||||
min="0"
|
||||
max="10"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -223,7 +223,7 @@ export const NumberConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
min={localConfig.min}
|
||||
max={localConfig.max}
|
||||
step={localConfig.step}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
{localConfig.format === "currency" && "통화 형식으로 표시됩니다."}
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<CardTitle className="flex items-center gap-2 text-xs">
|
||||
<Radio className="h-4 w-4" />
|
||||
라디오버튼 설정
|
||||
</CardTitle>
|
||||
|
|
@ -188,7 +188,7 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.groupLabel || ""}
|
||||
onChange={(e) => updateConfig("groupLabel", e.target.value)}
|
||||
placeholder="라디오버튼 그룹 제목"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -201,7 +201,7 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.groupName || ""}
|
||||
onChange={(e) => updateConfig("groupName", e.target.value)}
|
||||
placeholder="자동 생성 (필드명 기반)"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">비워두면 필드명을 기반으로 자동 생성됩니다.</p>
|
||||
</div>
|
||||
|
|
@ -252,19 +252,19 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={newOptionLabel}
|
||||
onChange={(e) => setNewOptionLabel(e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={newOptionValue}
|
||||
onChange={(e) => setNewOptionValue(e.target.value)}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={addOption}
|
||||
disabled={!newOptionLabel.trim() || !newOptionValue.trim()}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -278,7 +278,7 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={bulkOptions}
|
||||
onChange={(e) => setBulkOptions(e.target.value)}
|
||||
placeholder="한 줄당 하나씩 입력하세요. 라벨만 입력하면 값과 동일하게 설정됩니다. 라벨|값 형식으로 입력하면 별도 값을 설정할 수 있습니다. 예시: 서울 부산 대구시|daegu"
|
||||
className="h-20 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-20 text-xs"
|
||||
/>
|
||||
<Button size="sm" onClick={addBulkOptions} disabled={!bulkOptions.trim()} className="text-xs">
|
||||
옵션 추가
|
||||
|
|
@ -295,13 +295,13 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={option.label}
|
||||
onChange={(e) => updateOption(index, "label", e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={option.value}
|
||||
onChange={(e) => updateOption(index, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Switch
|
||||
checked={!option.disabled}
|
||||
|
|
@ -328,7 +328,7 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
id="defaultValue"
|
||||
value={localConfig.defaultValue || ""}
|
||||
onChange={(e) => updateConfig("defaultValue", e.target.value)}
|
||||
className="w-full rounded-md border px-3 py-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="w-full rounded-md border px-3 py-1 text-xs"
|
||||
>
|
||||
<option value="">선택하지 않음</option>
|
||||
{localConfig.options.map((option, index) => (
|
||||
|
|
@ -390,7 +390,7 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
disabled={localConfig.readonly || option.disabled}
|
||||
required={localConfig.required && index === 0} // 첫 번째에만 required 표시
|
||||
defaultChecked={localConfig.defaultValue === option.value}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
<Label htmlFor={`preview-radio-${index}`} className="text-xs">
|
||||
{option.label}
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<CardTitle className="flex items-center gap-2 text-xs">
|
||||
<List className="h-4 w-4" />
|
||||
선택박스 설정
|
||||
</CardTitle>
|
||||
|
|
@ -173,7 +173,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="선택하세요"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -186,7 +186,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.emptyMessage || ""}
|
||||
onChange={(e) => updateConfig("emptyMessage", e.target.value)}
|
||||
placeholder="선택 가능한 옵션이 없습니다"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -247,19 +247,19 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={newOptionLabel}
|
||||
onChange={(e) => setNewOptionLabel(e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={newOptionValue}
|
||||
onChange={(e) => setNewOptionValue(e.target.value)}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={addOption}
|
||||
disabled={!newOptionLabel.trim() || !newOptionValue.trim()}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -273,7 +273,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={bulkOptions}
|
||||
onChange={(e) => setBulkOptions(e.target.value)}
|
||||
placeholder="한 줄당 하나씩 입력하세요. 라벨만 입력하면 값과 동일하게 설정됩니다. 라벨|값 형식으로 입력하면 별도 값을 설정할 수 있습니다. 예시: 서울 부산 대구시|daegu"
|
||||
className="h-20 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-20 text-xs"
|
||||
/>
|
||||
<Button size="sm" onClick={addBulkOptions} disabled={!bulkOptions.trim()} className="text-xs">
|
||||
옵션 추가
|
||||
|
|
@ -290,13 +290,13 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={option.label}
|
||||
onChange={(e) => updateOption(index, "label", e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={option.value}
|
||||
onChange={(e) => updateOption(index, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Switch
|
||||
checked={!option.disabled}
|
||||
|
|
@ -323,7 +323,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
id="defaultValue"
|
||||
value={localConfig.defaultValue || ""}
|
||||
onChange={(e) => updateConfig("defaultValue", e.target.value)}
|
||||
className="w-full rounded-md border px-3 py-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="w-full rounded-md border px-3 py-1 text-xs"
|
||||
>
|
||||
<option value="">선택하지 않음</option>
|
||||
{localConfig.options.map((option, index) => (
|
||||
|
|
@ -376,7 +376,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
disabled={localConfig.readonly}
|
||||
required={localConfig.required}
|
||||
multiple={localConfig.multiple}
|
||||
className="w-full rounded-md border px-3 py-1 text-xs" style={{ fontSize: "12px" }}
|
||||
className="w-full rounded-md border px-3 py-1 text-xs"
|
||||
defaultValue={localConfig.defaultValue}
|
||||
>
|
||||
<option value="" disabled>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,404 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Check, ChevronsUpDown, Plus, X, GripVertical, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TabItem, TabsComponent } from "@/types/screen-management";
|
||||
|
||||
interface TabsConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (config: any) => void;
|
||||
}
|
||||
|
||||
interface ScreenInfo {
|
||||
screenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) {
|
||||
const [screens, setScreens] = useState<ScreenInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [localTabs, setLocalTabs] = useState<TabItem[]>(config.tabs || []);
|
||||
|
||||
// 화면 목록 로드
|
||||
useEffect(() => {
|
||||
const loadScreens = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// API 클라이언트 동적 import (named export 사용)
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
|
||||
// 전체 화면 목록 조회 (페이징 사이즈 크게)
|
||||
const response = await apiClient.get("/screen-management/screens", {
|
||||
params: { size: 1000 }
|
||||
});
|
||||
|
||||
console.log("화면 목록 조회 성공:", response.data);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
setScreens(response.data.data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to load screens:", error);
|
||||
console.error("Error response:", error.response?.data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadScreens();
|
||||
}, []);
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화 (초기화만, 입력 중에는 동기화하지 않음)
|
||||
const [isUserEditing, setIsUserEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 사용자가 입력 중이 아닐 때만 동기화
|
||||
if (!isUserEditing) {
|
||||
setLocalTabs(config.tabs || []);
|
||||
}
|
||||
}, [config.tabs, isUserEditing]);
|
||||
|
||||
// 탭 추가
|
||||
const handleAddTab = () => {
|
||||
const newTab: TabItem = {
|
||||
id: `tab-${Date.now()}`,
|
||||
label: `새 탭 ${localTabs.length + 1}`,
|
||||
order: localTabs.length,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
const updatedTabs = [...localTabs, newTab];
|
||||
setLocalTabs(updatedTabs);
|
||||
onChange({ ...config, tabs: updatedTabs });
|
||||
};
|
||||
|
||||
// 탭 제거
|
||||
const handleRemoveTab = (tabId: string) => {
|
||||
const updatedTabs = localTabs.filter((tab) => tab.id !== tabId);
|
||||
setLocalTabs(updatedTabs);
|
||||
onChange({ ...config, tabs: updatedTabs });
|
||||
};
|
||||
|
||||
// 탭 라벨 변경 (입력 중)
|
||||
const handleLabelChange = (tabId: string, label: string) => {
|
||||
setIsUserEditing(true);
|
||||
const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, label } : tab));
|
||||
setLocalTabs(updatedTabs);
|
||||
// onChange는 onBlur에서 호출
|
||||
};
|
||||
|
||||
// 탭 라벨 변경 완료 (포커스 아웃 시)
|
||||
const handleLabelBlur = () => {
|
||||
setIsUserEditing(false);
|
||||
onChange({ ...config, tabs: localTabs });
|
||||
};
|
||||
|
||||
// 탭 화면 선택
|
||||
const handleScreenSelect = (tabId: string, screenId: number, screenName: string) => {
|
||||
const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, screenId, screenName } : tab));
|
||||
setLocalTabs(updatedTabs);
|
||||
onChange({ ...config, tabs: updatedTabs });
|
||||
};
|
||||
|
||||
// 탭 비활성화 토글
|
||||
const handleDisabledToggle = (tabId: string, disabled: boolean) => {
|
||||
const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, disabled } : tab));
|
||||
setLocalTabs(updatedTabs);
|
||||
onChange({ ...config, tabs: updatedTabs });
|
||||
};
|
||||
|
||||
// 탭 순서 변경
|
||||
const handleMoveTab = (tabId: string, direction: "up" | "down") => {
|
||||
const index = localTabs.findIndex((tab) => tab.id === tabId);
|
||||
if (
|
||||
(direction === "up" && index === 0) ||
|
||||
(direction === "down" && index === localTabs.length - 1)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTabs = [...localTabs];
|
||||
const targetIndex = direction === "up" ? index - 1 : index + 1;
|
||||
[newTabs[index], newTabs[targetIndex]] = [newTabs[targetIndex], newTabs[index]];
|
||||
|
||||
// order 값 재조정
|
||||
const updatedTabs = newTabs.map((tab, idx) => ({ ...tab, order: idx }));
|
||||
setLocalTabs(updatedTabs);
|
||||
onChange({ ...config, tabs: updatedTabs });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">탭 설정</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 탭 방향 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">탭 방향</Label>
|
||||
<Select
|
||||
value={config.orientation || "horizontal"}
|
||||
onValueChange={(value: "horizontal" | "vertical") =>
|
||||
onChange({ ...config, orientation: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="horizontal">가로</SelectItem>
|
||||
<SelectItem value="vertical">세로</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 탭 스타일 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">탭 스타일</Label>
|
||||
<Select
|
||||
value={config.variant || "default"}
|
||||
onValueChange={(value: "default" | "pills" | "underline") =>
|
||||
onChange({ ...config, variant: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">기본</SelectItem>
|
||||
<SelectItem value="pills">알약형</SelectItem>
|
||||
<SelectItem value="underline">밑줄</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 선택 상태 유지 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">선택 상태 유지</Label>
|
||||
<p className="text-muted-foreground text-[10px] sm:text-xs">
|
||||
페이지 새로고침 후에도 선택한 탭이 유지됩니다
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.persistSelection || false}
|
||||
onCheckedChange={(checked) => onChange({ ...config, persistSelection: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 탭 닫기 버튼 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">탭 닫기 버튼</Label>
|
||||
<p className="text-muted-foreground text-[10px] sm:text-xs">
|
||||
각 탭에 닫기 버튼을 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.allowCloseable || false}
|
||||
onCheckedChange={(checked) => onChange({ ...config, allowCloseable: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">탭 목록</h3>
|
||||
<Button
|
||||
onClick={handleAddTab}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||
탭 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{localTabs.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||
<p className="text-muted-foreground text-sm">탭이 없습니다</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
탭 추가 버튼을 클릭하여 새 탭을 생성하세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{localTabs.map((tab, index) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className="rounded-lg border bg-card p-3 shadow-sm"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">탭 {index + 1}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
onClick={() => handleMoveTab(tab.id, "up")}
|
||||
disabled={index === 0}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
↑
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleMoveTab(tab.id, "down")}
|
||||
disabled={index === localTabs.length - 1}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleRemoveTab(tab.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 탭 라벨 */}
|
||||
<div>
|
||||
<Label className="text-xs">탭 라벨</Label>
|
||||
<Input
|
||||
value={tab.label}
|
||||
onChange={(e) => handleLabelChange(tab.id, e.target.value)}
|
||||
onBlur={handleLabelBlur}
|
||||
placeholder="탭 이름"
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 화면 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">연결된 화면</Label>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span className="text-muted-foreground text-xs">로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<ScreenSelectCombobox
|
||||
screens={screens}
|
||||
selectedScreenId={tab.screenId}
|
||||
onSelect={(screenId, screenName) =>
|
||||
handleScreenSelect(tab.id, screenId, screenName)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{tab.screenName && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
선택된 화면: {tab.screenName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 비활성화 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">비활성화</Label>
|
||||
<Switch
|
||||
checked={tab.disabled || false}
|
||||
onCheckedChange={(checked) => handleDisabledToggle(tab.id, checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 화면 선택 Combobox 컴포넌트
|
||||
function ScreenSelectCombobox({
|
||||
screens,
|
||||
selectedScreenId,
|
||||
onSelect,
|
||||
}: {
|
||||
screens: ScreenInfo[];
|
||||
selectedScreenId?: number;
|
||||
onSelect: (screenId: number, screenName: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedScreen = screens.find((s) => s.screenId === selectedScreenId);
|
||||
|
||||
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 sm:h-9 sm:text-sm"
|
||||
>
|
||||
{selectedScreen ? selectedScreen.screenName : "화면 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="화면 검색..." className="text-xs sm:text-sm" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">
|
||||
화면을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{screens.map((screen) => (
|
||||
<CommandItem
|
||||
key={screen.screenId}
|
||||
value={screen.screenName}
|
||||
onSelect={() => {
|
||||
onSelect(screen.screenId, screen.screenName);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3 sm:h-4 sm:w-4",
|
||||
selectedScreenId === 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} | 테이블: {screen.tableName}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ export const TextConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>텍스트 설정</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2 text-xs">텍스트 설정</CardTitle>
|
||||
<CardDescription className="text-xs">텍스트 입력 필드의 세부 설정을 관리합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -72,7 +72,7 @@ export const TextConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="입력 안내 텍스트"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ export const TextConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
onChange={(e) => updateConfig("minLength", e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -102,7 +102,7 @@ export const TextConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
onChange={(e) => updateConfig("maxLength", e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
placeholder="100"
|
||||
min="1"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -141,7 +141,7 @@ export const TextConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.pattern || ""}
|
||||
onChange={(e) => updateConfig("pattern", e.target.value)}
|
||||
placeholder="예: [A-Za-z0-9]+"
|
||||
className="font-mono text-xs" style={{ fontSize: "12px" }}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">JavaScript 정규식 패턴을 입력하세요.</p>
|
||||
</div>
|
||||
|
|
@ -219,7 +219,7 @@ export const TextConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
minLength={localConfig.minLength}
|
||||
pattern={localConfig.pattern}
|
||||
autoComplete={localConfig.autoComplete}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export const TextareaConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<CardTitle className="flex items-center gap-2 text-xs">
|
||||
<AlignLeft className="h-4 w-4" />
|
||||
텍스트영역 설정
|
||||
</CardTitle>
|
||||
|
|
@ -88,7 +88,7 @@ export const TextareaConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="내용을 입력하세요"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -101,7 +101,7 @@ export const TextareaConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
value={localConfig.defaultValue || ""}
|
||||
onChange={(e) => updateConfig("defaultValue", e.target.value)}
|
||||
placeholder="기본 텍스트 내용"
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
rows={3}
|
||||
/>
|
||||
{localConfig.showCharCount && (
|
||||
|
|
@ -151,7 +151,7 @@ export const TextareaConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
placeholder="자동 (CSS로 제어)"
|
||||
min={10}
|
||||
max={200}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">비워두면 CSS width로 제어됩니다.</p>
|
||||
</div>
|
||||
|
|
@ -203,7 +203,7 @@ export const TextareaConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
}}
|
||||
placeholder="제한 없음"
|
||||
min={0}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -221,7 +221,7 @@ export const TextareaConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
}}
|
||||
placeholder="제한 없음"
|
||||
min={1}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -333,7 +333,7 @@ export const TextareaConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
resize: localConfig.resizable ? "both" : "none",
|
||||
minHeight: localConfig.autoHeight ? "auto" : undefined,
|
||||
}}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
wrap={localConfig.wrap}
|
||||
/>
|
||||
{localConfig.showCharCount && (
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export const FlowButtonGroupDialog: React.FC<FlowButtonGroupDialogProps> = ({
|
|||
max={100}
|
||||
value={gap}
|
||||
onChange={(e) => setGap(Number(e.target.value))}
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{gap}px
|
||||
|
|
@ -109,7 +109,7 @@ export const FlowButtonGroupDialog: React.FC<FlowButtonGroupDialogProps> = ({
|
|||
정렬 방식
|
||||
</Label>
|
||||
<Select value={align} onValueChange={(value: any) => setAlign(value)}>
|
||||
<SelectTrigger id="align" className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger id="align" className="h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ export function ComponentsPanel({
|
|||
onSearchChange(value);
|
||||
}
|
||||
}}
|
||||
className="h-8 pl-8 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -458,7 +458,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
updateSettings({ options: newOptions });
|
||||
}}
|
||||
placeholder="옵션명"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -483,7 +483,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
const newOption = { label: "", value: "" };
|
||||
updateSettings({ options: [...(localSettings.options || []), newOption] });
|
||||
}}
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
옵션 추가
|
||||
|
|
@ -548,7 +548,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={localSettings.min || ""}
|
||||
onChange={(e) => updateSettings({ min: e.target.value ? Number(e.target.value) : undefined })}
|
||||
placeholder="최소값"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
|
@ -558,7 +558,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={localSettings.max || ""}
|
||||
onChange={(e) => updateSettings({ max: e.target.value ? Number(e.target.value) : undefined })}
|
||||
placeholder="최대값"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -571,7 +571,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={localSettings.step || "0.01"}
|
||||
onChange={(e) => updateSettings({ step: e.target.value })}
|
||||
placeholder="0.01"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -589,7 +589,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
type="date"
|
||||
value={localSettings.minDate || ""}
|
||||
onChange={(e) => updateSettings({ minDate: e.target.value })}
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
|
@ -598,7 +598,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
type="date"
|
||||
value={localSettings.maxDate || ""}
|
||||
onChange={(e) => updateSettings({ maxDate: e.target.value })}
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -626,7 +626,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={localSettings.maxLength || ""}
|
||||
onChange={(e) => updateSettings({ maxLength: e.target.value ? Number(e.target.value) : undefined })}
|
||||
placeholder="최대 문자 수"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
|
@ -635,7 +635,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={localSettings.placeholder || ""}
|
||||
onChange={(e) => updateSettings({ placeholder: e.target.value })}
|
||||
placeholder="입력 안내 텍스트"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -652,7 +652,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={localSettings.rows || "3"}
|
||||
onChange={(e) => updateSettings({ rows: Number(e.target.value) })}
|
||||
placeholder="3"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
|
@ -662,7 +662,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={localSettings.maxLength || ""}
|
||||
onChange={(e) => updateSettings({ maxLength: e.target.value ? Number(e.target.value) : undefined })}
|
||||
placeholder="최대 문자 수"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -678,7 +678,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={localSettings.accept || ""}
|
||||
onChange={(e) => updateSettings({ accept: e.target.value })}
|
||||
placeholder=".jpg,.png,.pdf"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
|
@ -688,7 +688,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
value={localSettings.maxSize ? localSettings.maxSize / 1024 / 1024 : "10"}
|
||||
onChange={(e) => updateSettings({ maxSize: Number(e.target.value) * 1024 * 1024 })}
|
||||
placeholder="10"
|
||||
className="h-7 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
|
|
@ -1132,7 +1132,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
{/* 기본 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<CardTitle className="flex items-center space-x-2 text-xs">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>기본 설정</span>
|
||||
</CardTitle>
|
||||
|
|
@ -1184,7 +1184,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
onUpdateComponent({ enableAdd: checked as boolean });
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="enable-add" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="enable-add" className="text-xs">
|
||||
데이터 추가 기능
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -1198,7 +1198,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
onUpdateComponent({ enableEdit: checked as boolean });
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="enable-edit" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="enable-edit" className="text-xs">
|
||||
데이터 수정 기능
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -1212,7 +1212,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
onUpdateComponent({ enableDelete: checked as boolean });
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="enable-delete" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="enable-delete" className="text-xs">
|
||||
데이터 삭제 기능
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -1220,7 +1220,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="add-button-text" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="add-button-text" className="text-xs">
|
||||
추가 버튼 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -1233,12 +1233,12 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
}}
|
||||
placeholder="추가"
|
||||
disabled={!localValues.enableAdd}
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-button-text" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="edit-button-text" className="text-xs">
|
||||
수정 버튼 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -1251,12 +1251,12 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
}}
|
||||
placeholder="수정"
|
||||
disabled={!localValues.enableEdit}
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delete-button-text" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="delete-button-text" className="text-xs">
|
||||
삭제 버튼 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -1269,7 +1269,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
}}
|
||||
placeholder="삭제"
|
||||
disabled={!localValues.enableDelete}
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1284,7 +1284,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-title" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="modal-title" className="text-xs">
|
||||
모달 제목
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -1298,12 +1298,12 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
placeholder="새 데이터 추가"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-width" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="modal-width" className="text-xs">
|
||||
모달 크기
|
||||
</Label>
|
||||
<select
|
||||
|
|
@ -1328,7 +1328,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-description" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="modal-description" className="text-xs">
|
||||
모달 설명
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -1342,13 +1342,13 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
placeholder="모달에 표시될 설명을 입력하세요"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-layout" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="modal-layout" className="text-xs">
|
||||
레이아웃
|
||||
</Label>
|
||||
<select
|
||||
|
|
@ -1370,7 +1370,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
|
||||
{localValues.modalLayout === "grid" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-grid-columns" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="modal-grid-columns" className="text-xs">
|
||||
그리드 컬럼 수
|
||||
</Label>
|
||||
<select
|
||||
|
|
@ -1394,7 +1394,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-submit-text" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="modal-submit-text" className="text-xs">
|
||||
제출 버튼 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -1408,12 +1408,12 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
placeholder="추가"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-cancel-text" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="modal-cancel-text" className="text-xs">
|
||||
취소 버튼 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -1427,7 +1427,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
placeholder="취소"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1441,7 +1441,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-modal-title" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="edit-modal-title" className="text-xs">
|
||||
모달 제목
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -1455,13 +1455,13 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
placeholder="데이터 수정"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">비워두면 기본 제목이 표시됩니다</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-modal-description" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="edit-modal-description" className="text-xs">
|
||||
모달 설명
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -1475,7 +1475,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
placeholder="선택한 데이터를 수정합니다"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">비워두면 설명이 표시되지 않습니다</p>
|
||||
</div>
|
||||
|
|
@ -1494,7 +1494,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
onUpdateComponent({ showSearchButton: checked as boolean });
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="show-search-button" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="show-search-button" className="text-xs">
|
||||
검색 버튼 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -1509,7 +1509,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
onUpdateComponent({ enableExport: checked as boolean });
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="enable-export" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="enable-export" className="text-xs">
|
||||
내보내기 기능
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -1521,7 +1521,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
<TabsContent value="columns" className="mt-4 max-h-[70vh] overflow-y-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<CardTitle className="flex items-center space-x-2 text-xs">
|
||||
<Columns className="h-4 w-4" />
|
||||
<span>컬럼 설정</span>
|
||||
</CardTitle>
|
||||
|
|
@ -1654,7 +1654,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
}
|
||||
}}
|
||||
placeholder="표시명을 입력하세요"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -1947,7 +1947,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
placeholder="고정값 입력..."
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1967,7 +1967,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
<TabsContent value="filters" className="mt-4 max-h-[70vh] overflow-y-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<CardTitle className="flex items-center space-x-2 text-xs">
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>필터 설정</span>
|
||||
</CardTitle>
|
||||
|
|
@ -1995,7 +1995,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
{component.filters.length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
<Filter className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p className="text-xs" style={{ fontSize: "12px" }}>필터가 없습니다</p>
|
||||
<p className="text-xs">필터가 없습니다</p>
|
||||
<p className="text-xs">컬럼을 추가하면 자동으로 필터가 생성됩니다</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -2073,7 +2073,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
updateFilter(index, { label: newValue });
|
||||
}}
|
||||
placeholder="필터 이름 입력..."
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
|
|
@ -2192,7 +2192,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
<TabsContent value="modal" className="mt-4 max-h-[70vh] overflow-y-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<CardTitle className="flex items-center space-x-2 text-xs">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span>모달 및 페이징 설정</span>
|
||||
</CardTitle>
|
||||
|
|
@ -2342,7 +2342,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="show-page-size-selector" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="show-page-size-selector" className="text-xs">
|
||||
페이지 크기 선택기 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -2362,7 +2362,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="show-page-info" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="show-page-info" className="text-xs">
|
||||
페이지 정보 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -2382,7 +2382,7 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="show-first-last" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="show-first-last" className="text-xs">
|
||||
처음/마지막 버튼 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -158,7 +158,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
}
|
||||
}}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -196,7 +195,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
}
|
||||
}}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -211,7 +209,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onUpdateProperty(layoutComponent.id, "layoutConfig.grid.gap", parseInt(e.target.value))
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -256,7 +253,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
}
|
||||
}}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<option value="row">가로 (row)</option>
|
||||
<option value="column">세로 (column)</option>
|
||||
|
|
@ -316,7 +312,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
}
|
||||
}}
|
||||
className="w-20 rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<span className="text-xs text-gray-500">개</span>
|
||||
</div>
|
||||
|
|
@ -332,7 +327,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onUpdateProperty(layoutComponent.id, "layoutConfig.flexbox.gap", parseInt(e.target.value))
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -348,7 +342,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
value={layoutComponent.layoutConfig?.split?.direction || "horizontal"}
|
||||
onChange={(e) => onUpdateProperty(layoutComponent.id, "layoutConfig.split.direction", e.target.value)}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<option value="horizontal">가로 분할</option>
|
||||
<option value="vertical">세로 분할</option>
|
||||
|
|
@ -398,7 +391,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
)
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<option value="">컬럼을 선택하세요</option>
|
||||
{currentTable.columns?.map((column) => (
|
||||
|
|
@ -421,7 +413,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
)
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<option value="">컬럼을 선택하세요</option>
|
||||
{currentTable.columns?.map((column) => (
|
||||
|
|
@ -444,7 +435,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
)
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<option value="">컬럼을 선택하세요</option>
|
||||
{currentTable.columns?.map((column) => (
|
||||
|
|
@ -467,7 +457,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
)
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<option value="">컬럼을 선택하세요</option>
|
||||
{currentTable.columns?.map((column) => (
|
||||
|
|
@ -495,7 +484,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
);
|
||||
}}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
+ 컬럼 추가
|
||||
</button>
|
||||
|
|
@ -519,7 +507,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
);
|
||||
}}
|
||||
className="flex-1 rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
<option value="">컬럼을 선택하세요</option>
|
||||
{currentTable.columns?.map((col) => (
|
||||
|
|
@ -542,7 +529,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
);
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
|
|
@ -578,7 +564,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardsPerRow", parseInt(e.target.value))
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -593,7 +578,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onUpdateProperty(layoutComponent.id, "layoutConfig.card.cardSpacing", parseInt(e.target.value))
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -683,7 +667,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
)
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -711,7 +694,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onUpdateProperty(layoutComponent.id, `zones.${index}.size.width`, e.target.value)
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
placeholder="100%"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -724,7 +706,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
onUpdateProperty(layoutComponent.id, `zones.${index}.size.height`, e.target.value)
|
||||
}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
placeholder="auto"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1007,7 +988,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
<h3 className="font-medium text-gray-900">컴포넌트 설정</h3>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<span className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
타입:
|
||||
</span>
|
||||
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-800">{componentType}</span>
|
||||
|
|
@ -1057,7 +1038,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
<h3 className="font-medium text-gray-900">파일 컴포넌트 설정</h3>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<span className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
타입:
|
||||
</span>
|
||||
<span className="rounded bg-purple-100 px-2 py-1 text-xs font-medium text-purple-800">파일 업로드</span>
|
||||
|
|
@ -1146,14 +1127,14 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
{/* 컴포넌트 정보 */}
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
컴포넌트:
|
||||
</span>
|
||||
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-800">{componentId}</span>
|
||||
</div>
|
||||
{webType && currentBaseInputType && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
입력 타입:
|
||||
</span>
|
||||
<span className="rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">
|
||||
|
|
@ -1163,7 +1144,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
)}
|
||||
{selectedComponent.columnName && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
컬럼:
|
||||
</span>
|
||||
<span className="text-xs text-gray-700">{selectedComponent.columnName}</span>
|
||||
|
|
@ -1375,7 +1356,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
<h3 className="font-medium text-gray-900">상세 설정</h3>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<span className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
입력 타입:
|
||||
</span>
|
||||
<span className="rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">
|
||||
|
|
@ -1390,7 +1371,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">세부 타입 선택</label>
|
||||
<Select value={localDetailType} onValueChange={handleDetailTypeChange}>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 bg-white text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 bg-white text-xs">
|
||||
<SelectValue placeholder="세부 타입을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export const FlowButtonGroupPanel: React.FC<FlowButtonGroupPanelProps> = ({
|
|||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onSelectGroup(groupInfo.buttons.map((b) => b.id))}
|
||||
className="h-7 px-2 text-xs" style={{ fontSize: "12px" }}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
|
|
@ -152,7 +152,7 @@ export const FlowButtonGroupPanel: React.FC<FlowButtonGroupPanelProps> = ({
|
|||
{groupInfo.buttons.map((button) => (
|
||||
<div
|
||||
key={button.id}
|
||||
className="flex items-center gap-2 rounded bg-white px-2 py-1.5 text-xs" style={{ fontSize: "12px" }}
|
||||
className="flex items-center gap-2 rounded bg-white px-2 py-1.5 text-xs"
|
||||
>
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<span className="flex-1 truncate font-medium">
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ export default function LayoutsPanel({
|
|||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-xs" style={{ fontSize: "12px" }}>{layout.name}</CardTitle>
|
||||
<CardTitle className="text-xs">{layout.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{layout.description && (
|
||||
|
|
|
|||
|
|
@ -652,7 +652,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
}}
|
||||
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
|
||||
/>
|
||||
<Label htmlFor="required" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="required" className="text-xs">
|
||||
필수 입력
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -668,7 +668,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
}}
|
||||
className="border-input bg-background text-primary focus:ring-ring h-4 w-4 rounded border focus:ring-2 focus:ring-offset-2"
|
||||
/>
|
||||
<Label htmlFor="readonly" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="readonly" className="text-xs">
|
||||
읽기 전용
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -990,7 +990,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
|||
</>
|
||||
) : (
|
||||
<div className="bg-accent col-span-2 rounded-lg p-3 text-center">
|
||||
<p className="text-primary text-xs" style={{ fontSize: "12px" }}>카드 레이아웃은 자동으로 크기가 계산됩니다</p>
|
||||
<p className="text-primary text-xs">카드 레이아웃은 자동으로 크기가 계산됩니다</p>
|
||||
<p className="mt-1 text-xs text-blue-500">카드 개수와 간격 설정은 상세설정에서 조정하세요</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ const ResolutionPanel: React.FC<ResolutionPanelProps> = ({ currentResolution, on
|
|||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">해상도 프리셋</Label>
|
||||
<Select value={selectedPreset} onValueChange={handlePresetChange}>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className=" text-xsh-6 w-full px-2 py-0">
|
||||
<SelectValue placeholder="해상도를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -147,8 +147,7 @@ const ResolutionPanel: React.FC<ResolutionPanelProps> = ({ currentResolution, on
|
|||
placeholder="1920"
|
||||
min="1"
|
||||
step="1"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
|
@ -160,16 +159,14 @@ const ResolutionPanel: React.FC<ResolutionPanelProps> = ({ currentResolution, on
|
|||
placeholder="1080"
|
||||
min="1"
|
||||
step="1"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCustomResolution}
|
||||
size="sm"
|
||||
className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
>
|
||||
적용
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export const RowSettingsPanel: React.FC<RowSettingsPanelProps> = ({ row, onUpdat
|
|||
variant={row.gap === preset ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onUpdateRow({ gap: preset })}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
>
|
||||
{GAP_PRESETS[preset].label}
|
||||
</Button>
|
||||
|
|
@ -130,7 +130,7 @@ export const RowSettingsPanel: React.FC<RowSettingsPanelProps> = ({ row, onUpdat
|
|||
variant={row.padding === preset ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onUpdateRow({ padding: preset })}
|
||||
className="text-xs" style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
>
|
||||
{GAP_PRESETS[preset].label}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -528,7 +528,7 @@ export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) =
|
|||
<div className="flex items-center justify-between rounded-xl bg-amber-50/80 border border-amber-200/60 p-3 text-amber-800 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Info className="h-4 w-4" />
|
||||
<span className="text-xs" style={{ fontSize: "12px" }}>템플릿 로딩 실패, 기본 템플릿 사용 중</span>
|
||||
<span className="text-xs">템플릿 로딩 실패, 기본 템플릿 사용 중</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => refetch()} className="border-amber-300 text-amber-700 hover:bg-amber-100">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, Suspense } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -119,6 +119,25 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
const [localHeight, setLocalHeight] = useState<string>("");
|
||||
const [localWidth, setLocalWidth] = useState<string>("");
|
||||
|
||||
// 🆕 전체 테이블 목록 (selected-items-detail-input 등에서 사용)
|
||||
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName?: string }>>([]);
|
||||
|
||||
// 🆕 전체 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadAllTables = async () => {
|
||||
try {
|
||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setAllTables(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("전체 테이블 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadAllTables();
|
||||
}, []);
|
||||
|
||||
// 새로운 컴포넌트 시스템의 webType 동기화
|
||||
useEffect(() => {
|
||||
if (selectedComponent?.type === "component") {
|
||||
|
|
@ -279,14 +298,18 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
};
|
||||
|
||||
const handleConfigChange = (newConfig: any) => {
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
|
||||
// 기존 config와 병합하여 다른 속성 유지
|
||||
const currentConfig = selectedComponent.componentConfig?.config || {};
|
||||
const mergedConfig = { ...currentConfig, ...newConfig };
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig.config", mergedConfig);
|
||||
};
|
||||
|
||||
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도
|
||||
const componentId =
|
||||
selectedComponent.componentType || // ⭐ section-card 등
|
||||
selectedComponent.componentConfig?.type ||
|
||||
selectedComponent.componentConfig?.id;
|
||||
selectedComponent.componentConfig?.id ||
|
||||
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
|
||||
|
||||
if (componentId) {
|
||||
const definition = ComponentRegistry.getComponent(componentId);
|
||||
|
|
@ -318,7 +341,20 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<Settings className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||
</div>
|
||||
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-sm text-muted-foreground">설정 패널 로딩 중...</div>
|
||||
</div>
|
||||
}>
|
||||
<ConfigPanelComponent
|
||||
config={config}
|
||||
onChange={handleConfigChange}
|
||||
tables={tables} // 테이블 정보 전달
|
||||
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
||||
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -682,8 +718,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
onChange={(e) => handleUpdate("label", e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
|
@ -719,7 +753,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
step={1}
|
||||
placeholder="10"
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -733,8 +766,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
onChange={(e) => handleUpdate("placeholder", e.target.value)}
|
||||
placeholder="입력 안내 텍스트"
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -748,8 +779,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
onChange={(e) => handleUpdate("title", e.target.value)}
|
||||
placeholder="제목"
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -763,8 +792,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
onChange={(e) => handleUpdate("description", e.target.value)}
|
||||
placeholder="설명"
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -806,7 +833,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
}
|
||||
}}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -818,8 +844,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
value={currentPosition.z || 1}
|
||||
onChange={(e) => handleUpdate("position.z", parseInt(e.target.value) || 1)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -837,8 +862,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
value={selectedComponent.style?.labelText || selectedComponent.label || ""}
|
||||
onChange={(e) => handleUpdate("style.labelText", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
|
@ -848,8 +872,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
value={selectedComponent.style?.labelFontSize || "12px"}
|
||||
onChange={(e) => handleUpdate("style.labelFontSize", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
|
|
@ -859,8 +882,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
value={selectedComponent.style?.labelColor || "#212121"}
|
||||
onChange={(e) => handleUpdate("style.labelColor", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -871,8 +893,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
value={selectedComponent.style?.labelMarginBottom || "4px"}
|
||||
onChange={(e) => handleUpdate("style.labelMarginBottom", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 pt-5">
|
||||
|
|
@ -994,6 +1015,16 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// 🆕 ComponentRegistry에서 전용 ConfigPanel이 있는지 먼저 확인
|
||||
const definition = ComponentRegistry.getComponent(componentId);
|
||||
if (definition?.configPanel) {
|
||||
// 전용 ConfigPanel이 있으면 renderComponentConfigPanel 호출
|
||||
const configPanelContent = renderComponentConfigPanel();
|
||||
if (configPanelContent) {
|
||||
return configPanelContent;
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 웹타입의 기본 입력 타입 추출
|
||||
const currentBaseInputType = webType ? getBaseInputType(webType as any) : null;
|
||||
|
||||
|
|
@ -1013,7 +1044,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<div>
|
||||
<Label>세부 타입</Label>
|
||||
<Select value={localComponentDetailType || webType} onValueChange={handleDetailTypeChange}>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue placeholder="세부 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -1220,7 +1251,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<div>
|
||||
<Label>입력 타입</Label>
|
||||
<Select value={widget.webType} onValueChange={(value) => handleUpdate("webType", value)}>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export const WebTypeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({ webType,
|
|||
<>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="multiple" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="multiple" className="text-xs">
|
||||
다중 선택
|
||||
</Label>
|
||||
<Checkbox
|
||||
|
|
@ -121,7 +121,7 @@ export const WebTypeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({ webType,
|
|||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="searchable" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="searchable" className="text-xs">
|
||||
검색 가능
|
||||
</Label>
|
||||
<Checkbox
|
||||
|
|
@ -259,7 +259,7 @@ export const WebTypeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({ webType,
|
|||
|
||||
{baseType === "date" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="showTime" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="showTime" className="text-xs">
|
||||
시간 입력 포함
|
||||
</Label>
|
||||
<Checkbox
|
||||
|
|
@ -395,7 +395,7 @@ export const WebTypeConfigPanel: React.FC<WebTypeConfigPanelProps> = ({ webType,
|
|||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="fileMultiple" className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor="fileMultiple" className="text-xs">
|
||||
다중 파일 선택
|
||||
</Label>
|
||||
<Checkbox
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ export const CheckboxTypeConfigPanel: React.FC<CheckboxTypeConfigPanelProps> = (
|
|||
라벨 위치
|
||||
</Label>
|
||||
<Select value={localValues.labelPosition} onValueChange={(value) => updateConfig("labelPosition", value)}>
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue placeholder="라벨 위치 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -194,18 +194,18 @@ export const CheckboxTypeConfigPanel: React.FC<CheckboxTypeConfigPanelProps> = (
|
|||
<Label className="text-sm font-medium text-gray-700">미리보기</Label>
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
{localValues.labelPosition === "left" && localValues.checkboxText && (
|
||||
<span className="text-xs" style={{ fontSize: "12px" }}>{localValues.checkboxText}</span>
|
||||
<span className="text-xs">{localValues.checkboxText}</span>
|
||||
)}
|
||||
{localValues.labelPosition === "top" && localValues.checkboxText && (
|
||||
<div className="w-full">
|
||||
<div className="text-xs" style={{ fontSize: "12px" }}>{localValues.checkboxText}</div>
|
||||
<div className="text-xs">{localValues.checkboxText}</div>
|
||||
<Checkbox checked={localValues.defaultChecked} className="mt-1" />
|
||||
</div>
|
||||
)}
|
||||
{(localValues.labelPosition === "right" || localValues.labelPosition === "bottom") && (
|
||||
<>
|
||||
<Checkbox checked={localValues.defaultChecked} />
|
||||
{localValues.checkboxText && <span className="text-xs" style={{ fontSize: "12px" }}>{localValues.checkboxText}</span>}
|
||||
{localValues.checkboxText && <span className="text-xs">{localValues.checkboxText}</span>}
|
||||
</>
|
||||
)}
|
||||
{localValues.labelPosition === "left" && <Checkbox checked={localValues.defaultChecked} />}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ export const CodeTypeConfigPanel: React.FC<CodeTypeConfigPanelProps> = ({ config
|
|||
프로그래밍 언어
|
||||
</Label>
|
||||
<Select value={localValues.language} onValueChange={(value) => updateConfig("language", value)}>
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue placeholder="언어 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60">
|
||||
|
|
@ -140,7 +140,7 @@ export const CodeTypeConfigPanel: React.FC<CodeTypeConfigPanelProps> = ({ config
|
|||
테마
|
||||
</Label>
|
||||
<Select value={localValues.theme} onValueChange={(value) => updateConfig("theme", value)}>
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue placeholder="테마 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config
|
|||
}, 0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue placeholder="날짜 형식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
|||
표시 형식
|
||||
</Label>
|
||||
<Select value={localValues.displayFormat} onValueChange={(value) => updateConfig("displayFormat", value)}>
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue placeholder="형식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -267,7 +267,7 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
|||
{/* 기존 필터 목록 */}
|
||||
<div className="max-h-40 space-y-2 overflow-y-auto">
|
||||
{Object.entries(safeConfig.filters || {}).map(([field, value]) => (
|
||||
<div key={field} className="flex items-center space-x-2 rounded border p-2 text-xs" style={{ fontSize: "12px" }}>
|
||||
<div key={field} className="flex items-center space-x-2 rounded border p-2 text-xs">
|
||||
<Input
|
||||
value={field}
|
||||
onChange={(e) => updateFilter(field, e.target.value, value as string)}
|
||||
|
|
@ -317,7 +317,7 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
|||
<div className="mt-2">
|
||||
<div className="flex items-center space-x-2 rounded border bg-white p-2">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
<div className="text-muted-foreground flex-1 text-xs" style={{ fontSize: "12px" }}>
|
||||
<div className="text-muted-foreground flex-1 text-xs">
|
||||
{localValues.placeholder || `${localValues.referenceTable || "엔터티"}를 선택하세요`}
|
||||
</div>
|
||||
<Database className="h-4 w-4 text-gray-400" />
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ export const NumberTypeConfigPanel: React.FC<NumberTypeConfigPanelProps> = ({ co
|
|||
숫자 형식
|
||||
</Label>
|
||||
<Select value={localValues.format} onValueChange={(value) => updateConfig("format", value)}>
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue placeholder="숫자 형식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ export const RadioTypeConfigPanel: React.FC<RadioTypeConfigPanelProps> = ({ conf
|
|||
{(safeConfig.options || []).map((option) => (
|
||||
<div key={option.value} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={option.value} id={`preview-${option.value}`} />
|
||||
<Label htmlFor={`preview-${option.value}`} className="text-xs" style={{ fontSize: "12px" }}>
|
||||
<Label htmlFor={`preview-${option.value}`} className="text-xs">
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ co
|
|||
value={localValues.placeholder}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
placeholder="옵션을 선택하세요"
|
||||
className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} style={{ fontSize: "12px" }}
|
||||
className="mt-1 h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
|
|||
입력 형식
|
||||
</Label>
|
||||
<Select value={localValues.format} onValueChange={(value) => updateConfig("format", value)}>
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue placeholder="입력 형식 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -332,7 +332,7 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
|
|||
자동값 타입
|
||||
</Label>
|
||||
<Select value={localValues.autoValueType} onValueChange={(value) => updateConfig("autoValueType", value)}>
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue placeholder="자동값 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ export const TextareaTypeConfigPanel: React.FC<TextareaTypeConfigPanelProps> = (
|
|||
<Label className="text-sm font-medium text-gray-700">미리보기</Label>
|
||||
<div className="mt-2">
|
||||
<textarea
|
||||
className="w-full rounded border border-gray-300 p-2 text-xs" style={{ fontSize: "12px" }}
|
||||
className="w-full rounded border border-gray-300 p-2 text-xs"
|
||||
rows={localValues.rows}
|
||||
placeholder={localValues.placeholder || "텍스트를 입력하세요..."}
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ interface Props {
|
|||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onFiltersApplied?: (filters: TableFilter[]) => void; // 필터 적용 시 콜백
|
||||
screenId?: number; // 화면 ID 추가
|
||||
}
|
||||
|
||||
// 필터 타입별 연산자
|
||||
|
|
@ -69,7 +70,7 @@ interface ColumnFilterConfig {
|
|||
selectOptions?: Array<{ label: string; value: string }>;
|
||||
}
|
||||
|
||||
export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied }) => {
|
||||
export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied, screenId }) => {
|
||||
const { getTable, selectedTableId } = useTableOptions();
|
||||
const table = selectedTableId ? getTable(selectedTableId) : undefined;
|
||||
|
||||
|
|
@ -79,7 +80,10 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
|||
// localStorage에서 저장된 필터 설정 불러오기
|
||||
useEffect(() => {
|
||||
if (table?.columns && table?.tableName) {
|
||||
const storageKey = `table_filters_${table.tableName}`;
|
||||
// 화면별로 독립적인 필터 설정 저장
|
||||
const storageKey = screenId
|
||||
? `table_filters_${table.tableName}_screen_${screenId}`
|
||||
: `table_filters_${table.tableName}`;
|
||||
const savedFilters = localStorage.getItem(storageKey);
|
||||
|
||||
let filters: ColumnFilterConfig[];
|
||||
|
|
@ -192,9 +196,11 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
|||
width: cf.width || 200, // 너비 포함 (기본 200px)
|
||||
}));
|
||||
|
||||
// localStorage에 저장
|
||||
// localStorage에 저장 (화면별로 독립적)
|
||||
if (table?.tableName) {
|
||||
const storageKey = `table_filters_${table.tableName}`;
|
||||
const storageKey = screenId
|
||||
? `table_filters_${table.tableName}_screen_${screenId}`
|
||||
: `table_filters_${table.tableName}`;
|
||||
localStorage.setItem(storageKey, JSON.stringify(columnFilters));
|
||||
}
|
||||
|
||||
|
|
@ -216,9 +222,11 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
|
|||
setColumnFilters(clearedFilters);
|
||||
setSelectAll(false);
|
||||
|
||||
// localStorage에서 제거
|
||||
// localStorage에서 제거 (화면별로 독립적)
|
||||
if (table?.tableName) {
|
||||
const storageKey = `table_filters_${table.tableName}`;
|
||||
const storageKey = screenId
|
||||
? `table_filters_${table.tableName}_screen_${screenId}`
|
||||
: `table_filters_${table.tableName}`;
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1194,7 +1194,7 @@ export function FlowWidget({
|
|||
setStepDataPage(1); // 페이지 크기 변경 시 첫 페이지로
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export default function InputWidget({ widget, value, onChange, className }: Inpu
|
|||
required={widget.required}
|
||||
readOnly={widget.readonly}
|
||||
className={cn("h-6 w-full text-xs", widget.readonly && "bg-muted/50 cursor-not-allowed")}
|
||||
style={{ fontSize: "12px" }}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export default function SelectWidget({ widget, value, onChange, options = [], cl
|
|||
</Label>
|
||||
)}
|
||||
<Select value={value} onValueChange={handleChange} disabled={widget.readonly}>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
|
||||
<SelectTrigger className="h-6 w-full px-2 py-0 text-xs">
|
||||
<SelectValue placeholder={widget.placeholder || "선택해주세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -1,210 +1,258 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { TabsComponent, TabItem, ScreenDefinition } from "@/types";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2, FileQuestion } from "lucide-react";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Loader2 } from "lucide-react";
|
||||
import type { TabsComponent, TabItem } from "@/types/screen-management";
|
||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||
|
||||
interface TabsWidgetProps {
|
||||
component: TabsComponent;
|
||||
isPreview?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 위젯 컴포넌트
|
||||
* 각 탭에 다른 화면을 표시할 수 있습니다
|
||||
*/
|
||||
export const TabsWidget: React.FC<TabsWidgetProps> = ({ component, isPreview = false }) => {
|
||||
// componentConfig에서 설정 읽기 (새 컴포넌트 시스템)
|
||||
const config = (component as any).componentConfig || component;
|
||||
const { tabs = [], defaultTab, orientation = "horizontal", variant = "default" } = config;
|
||||
|
||||
// console.log("🔍 TabsWidget 렌더링:", {
|
||||
// component,
|
||||
// componentConfig: (component as any).componentConfig,
|
||||
// tabs,
|
||||
// tabsLength: tabs.length
|
||||
// });
|
||||
export function TabsWidget({ component, className, style }: TabsWidgetProps) {
|
||||
const {
|
||||
tabs = [],
|
||||
defaultTab,
|
||||
orientation = "horizontal",
|
||||
variant = "default",
|
||||
allowCloseable = false,
|
||||
persistSelection = false,
|
||||
} = component;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(defaultTab || tabs[0]?.id || "");
|
||||
const [loadedScreens, setLoadedScreens] = useState<Record<string, any>>({});
|
||||
console.log("🎨 TabsWidget 렌더링:", {
|
||||
componentId: component.id,
|
||||
tabs,
|
||||
tabsLength: tabs.length,
|
||||
component,
|
||||
});
|
||||
|
||||
const storageKey = `tabs-${component.id}-selected`;
|
||||
|
||||
// 초기 선택 탭 결정
|
||||
const getInitialTab = () => {
|
||||
if (persistSelection && typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved && tabs.some((t) => t.id === saved)) {
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
return defaultTab || tabs[0]?.id || "";
|
||||
};
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState<string>(getInitialTab());
|
||||
const [visibleTabs, setVisibleTabs] = useState<TabItem[]>(tabs);
|
||||
const [loadingScreens, setLoadingScreens] = useState<Record<string, boolean>>({});
|
||||
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
|
||||
const [screenLayouts, setScreenLayouts] = useState<Record<number, any>>({});
|
||||
|
||||
// 탭 변경 시 화면 로드
|
||||
// 컴포넌트 탭 목록 변경 시 동기화
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
setVisibleTabs(tabs.filter((tab) => !tab.disabled));
|
||||
}, [tabs]);
|
||||
|
||||
const currentTab = tabs.find((tab) => tab.id === activeTab);
|
||||
if (!currentTab || !currentTab.screenId) return;
|
||||
// 선택된 탭 변경 시 localStorage에 저장
|
||||
useEffect(() => {
|
||||
if (persistSelection && typeof window !== "undefined") {
|
||||
localStorage.setItem(storageKey, selectedTab);
|
||||
}
|
||||
}, [selectedTab, persistSelection, storageKey]);
|
||||
|
||||
// 이미 로드된 화면이면 스킵
|
||||
if (loadedScreens[activeTab]) return;
|
||||
// 초기 로드 시 선택된 탭의 화면 불러오기
|
||||
useEffect(() => {
|
||||
const currentTab = visibleTabs.find((t) => t.id === selectedTab);
|
||||
console.log("🔄 초기 탭 로드:", {
|
||||
selectedTab,
|
||||
currentTab,
|
||||
hasScreenId: !!currentTab?.screenId,
|
||||
screenId: currentTab?.screenId,
|
||||
});
|
||||
|
||||
if (currentTab && currentTab.screenId && !screenLayouts[currentTab.screenId]) {
|
||||
console.log("📥 초기 화면 로딩 시작:", currentTab.screenId);
|
||||
loadScreenLayout(currentTab.screenId);
|
||||
}
|
||||
}, [selectedTab, visibleTabs]);
|
||||
|
||||
// 이미 로딩 중이면 스킵
|
||||
if (loadingScreens[activeTab]) return;
|
||||
// 화면 레이아웃 로드
|
||||
const loadScreenLayout = async (screenId: number) => {
|
||||
if (screenLayouts[screenId]) {
|
||||
console.log("✅ 이미 로드된 화면:", screenId);
|
||||
return; // 이미 로드됨
|
||||
}
|
||||
|
||||
// 화면 로드 시작
|
||||
loadScreen(activeTab, currentTab.screenId);
|
||||
}, [activeTab, tabs]);
|
||||
|
||||
const loadScreen = async (tabId: string, screenId: number) => {
|
||||
setLoadingScreens((prev) => ({ ...prev, [tabId]: true }));
|
||||
setScreenErrors((prev) => ({ ...prev, [tabId]: "" }));
|
||||
console.log("📥 화면 레이아웃 로딩 시작:", screenId);
|
||||
setLoadingScreens((prev) => ({ ...prev, [screenId]: true }));
|
||||
|
||||
try {
|
||||
const layoutData = await screenApi.getLayout(screenId);
|
||||
|
||||
if (layoutData) {
|
||||
setLoadedScreens((prev) => ({
|
||||
...prev,
|
||||
[tabId]: {
|
||||
screenId,
|
||||
layout: layoutData,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
setScreenErrors((prev) => ({
|
||||
...prev,
|
||||
[tabId]: "화면을 불러올 수 없습니다",
|
||||
}));
|
||||
}
|
||||
} catch (error: any) {
|
||||
setScreenErrors((prev) => ({
|
||||
...prev,
|
||||
[tabId]: error.message || "화면 로드 중 오류가 발생했습니다",
|
||||
}));
|
||||
} finally {
|
||||
setLoadingScreens((prev) => ({ ...prev, [tabId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 탭 콘텐츠 렌더링
|
||||
const renderTabContent = (tab: TabItem) => {
|
||||
const isLoading = loadingScreens[tab.id];
|
||||
const error = screenErrors[tab.id];
|
||||
const screenData = loadedScreens[tab.id];
|
||||
|
||||
// 로딩 중
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground text-sm">화면을 불러오는 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 발생
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4">
|
||||
<FileQuestion className="h-12 w-12 text-destructive" />
|
||||
<div className="text-center">
|
||||
<p className="mb-2 font-medium text-destructive">화면 로드 실패</p>
|
||||
<p className="text-muted-foreground text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 화면 ID가 없는 경우
|
||||
if (!tab.screenId) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4">
|
||||
<FileQuestion className="text-muted-foreground h-12 w-12" />
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground mb-2 text-sm">화면이 할당되지 않았습니다</p>
|
||||
<p className="text-xs text-gray-400">상세설정에서 화면을 선택하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 화면 렌더링 - 원본 화면의 모든 컴포넌트를 그대로 렌더링
|
||||
if (screenData && screenData.layout && screenData.layout.components) {
|
||||
const components = screenData.layout.components;
|
||||
const screenResolution = screenData.layout.screenResolution || { width: 1920, height: 1080 };
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`);
|
||||
console.log("📦 API 응답:", { screenId, success: response.data.success, hasData: !!response.data.data });
|
||||
|
||||
return (
|
||||
<div className="bg-white" style={{ width: `${screenResolution.width}px`, height: '100%' }}>
|
||||
<div className="relative h-full">
|
||||
{components.map((comp) => (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={comp.id}
|
||||
component={comp}
|
||||
allComponents={components}
|
||||
screenInfo={{ id: tab.screenId }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (response.data.success && response.data.data) {
|
||||
console.log("✅ 화면 레이아웃 로드 완료:", screenId);
|
||||
setScreenLayouts((prev) => ({ ...prev, [screenId]: response.data.data }));
|
||||
} else {
|
||||
console.error("❌ 화면 레이아웃 로드 실패 - success false");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 화면 레이아웃 로드 실패 ${screenId}:`, error);
|
||||
} finally {
|
||||
setLoadingScreens((prev) => ({ ...prev, [screenId]: false }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4">
|
||||
<FileQuestion className="text-muted-foreground h-12 w-12" />
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground text-sm">화면 데이터를 불러올 수 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 빈 탭 목록
|
||||
if (tabs.length === 0) {
|
||||
// 탭 변경 핸들러
|
||||
const handleTabChange = (tabId: string) => {
|
||||
console.log("🔄 탭 변경:", tabId);
|
||||
setSelectedTab(tabId);
|
||||
|
||||
// 해당 탭의 화면 로드
|
||||
const tab = visibleTabs.find((t) => t.id === tabId);
|
||||
console.log("🔍 선택된 탭 정보:", { tab, hasScreenId: !!tab?.screenId, screenId: tab?.screenId });
|
||||
|
||||
if (tab && tab.screenId && !screenLayouts[tab.screenId]) {
|
||||
console.log("📥 탭 변경 시 화면 로딩:", tab.screenId);
|
||||
loadScreenLayout(tab.screenId);
|
||||
}
|
||||
};
|
||||
|
||||
// 탭 닫기 핸들러
|
||||
const handleCloseTab = (tabId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const updatedTabs = visibleTabs.filter((tab) => tab.id !== tabId);
|
||||
setVisibleTabs(updatedTabs);
|
||||
|
||||
// 닫은 탭이 선택된 탭이었다면 다음 탭 선택
|
||||
if (selectedTab === tabId && updatedTabs.length > 0) {
|
||||
setSelectedTab(updatedTabs[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
// 탭 스타일 클래스
|
||||
const getTabsListClass = () => {
|
||||
const baseClass = orientation === "vertical" ? "flex-col" : "";
|
||||
const variantClass =
|
||||
variant === "pills"
|
||||
? "bg-muted p-1 rounded-lg"
|
||||
: variant === "underline"
|
||||
? "border-b"
|
||||
: "bg-muted p-1";
|
||||
return `${baseClass} ${variantClass}`;
|
||||
};
|
||||
|
||||
if (visibleTabs.length === 0) {
|
||||
console.log("⚠️ 보이는 탭이 없음");
|
||||
return (
|
||||
<Card className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground text-sm">탭이 없습니다</p>
|
||||
<p className="text-xs text-gray-400">상세설정에서 탭을 추가하세요</p>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<p className="text-muted-foreground text-sm">탭이 없습니다</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
console.log("🎨 TabsWidget 최종 렌더링:", {
|
||||
visibleTabsCount: visibleTabs.length,
|
||||
selectedTab,
|
||||
screenLayoutsKeys: Object.keys(screenLayouts),
|
||||
loadingScreensKeys: Object.keys(loadingScreens),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto">
|
||||
<div className="flex h-full w-full flex-col pt-4" style={style}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
value={selectedTab}
|
||||
onValueChange={handleTabChange}
|
||||
orientation={orientation}
|
||||
className="flex h-full w-full flex-col"
|
||||
>
|
||||
<TabsList className={orientation === "horizontal" ? "justify-start shrink-0" : "flex-col shrink-0"}>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
disabled={tab.disabled}
|
||||
className={orientation === "horizontal" ? "" : "w-full justify-start"}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{tab.screenName && (
|
||||
<Badge variant="secondary" className="ml-2 text-[10px]">
|
||||
{tab.screenName}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<div className="relative z-10">
|
||||
<TabsList className={getTabsListClass()}>
|
||||
{visibleTabs.map((tab) => (
|
||||
<div key={tab.id} className="relative">
|
||||
<TabsTrigger value={tab.id} disabled={tab.disabled} className="relative pr-8">
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
{allowCloseable && (
|
||||
<Button
|
||||
onClick={(e) => handleCloseTab(tab.id, e)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 p-0 hover:bg-destructive/10"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="flex-1 mt-0 data-[state=inactive]:hidden"
|
||||
>
|
||||
{renderTabContent(tab)}
|
||||
</TabsContent>
|
||||
))}
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
{visibleTabs.map((tab) => (
|
||||
<TabsContent key={tab.id} value={tab.id} className="h-full">
|
||||
{tab.screenId ? (
|
||||
loadingScreens[tab.screenId] ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="text-muted-foreground ml-2">화면 로딩 중...</span>
|
||||
</div>
|
||||
) : screenLayouts[tab.screenId] ? (
|
||||
(() => {
|
||||
const layoutData = screenLayouts[tab.screenId];
|
||||
const { components = [], screenResolution } = layoutData;
|
||||
|
||||
console.log("🎯 렌더링할 화면 데이터:", {
|
||||
screenId: tab.screenId,
|
||||
componentsCount: components.length,
|
||||
screenResolution,
|
||||
});
|
||||
|
||||
const designWidth = screenResolution?.width || 1920;
|
||||
const designHeight = screenResolution?.height || 1080;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full w-full overflow-auto bg-background"
|
||||
style={{
|
||||
minHeight: `${designHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
width: `${designWidth}px`,
|
||||
height: `${designHeight}px`,
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
{components.map((component: any) => (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
component={component}
|
||||
allComponents={components}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">화면을 불러올 수 없습니다</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<p className="text-muted-foreground text-sm">연결된 화면이 없습니다</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,10 @@ const ResizableDialogContent = React.forwardRef<
|
|||
|
||||
// 1순위: userStyle에서 크기 추출 (화면관리에서 지정한 크기 - 항상 초기값으로 사용)
|
||||
if (userStyle) {
|
||||
console.log("🔍 userStyle 감지:", userStyle);
|
||||
console.log("🔍 userStyle.width 타입:", typeof userStyle.width, "값:", userStyle.width);
|
||||
console.log("🔍 userStyle.height 타입:", typeof userStyle.height, "값:", userStyle.height);
|
||||
|
||||
const styleWidth = typeof userStyle.width === 'string'
|
||||
? parseInt(userStyle.width)
|
||||
: userStyle.width;
|
||||
|
|
@ -129,24 +133,41 @@ const ResizableDialogContent = React.forwardRef<
|
|||
? parseInt(userStyle.height)
|
||||
: userStyle.height;
|
||||
|
||||
console.log("📏 파싱된 크기:", {
|
||||
styleWidth,
|
||||
styleHeight,
|
||||
"styleWidth truthy?": !!styleWidth,
|
||||
"styleHeight truthy?": !!styleHeight,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
minHeight,
|
||||
maxHeight
|
||||
});
|
||||
|
||||
if (styleWidth && styleHeight) {
|
||||
return {
|
||||
const finalSize = {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, styleWidth)),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, styleHeight)),
|
||||
};
|
||||
console.log("✅ userStyle 크기 사용:", finalSize);
|
||||
return finalSize;
|
||||
} else {
|
||||
console.log("❌ styleWidth 또는 styleHeight가 falsy:", { styleWidth, styleHeight });
|
||||
}
|
||||
}
|
||||
|
||||
// 2순위: 현재 렌더링된 크기 사용
|
||||
if (contentRef.current) {
|
||||
const rect = contentRef.current.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
return {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, rect.width)),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, rect.height)),
|
||||
};
|
||||
}
|
||||
}
|
||||
console.log("⚠️ userStyle 없음, defaultWidth/defaultHeight 사용:", { defaultWidth, defaultHeight });
|
||||
|
||||
// 2순위: 현재 렌더링된 크기 사용 (주석처리 - 모달이 열린 후 늘어나는 현상 방지)
|
||||
// if (contentRef.current) {
|
||||
// const rect = contentRef.current.getBoundingClientRect();
|
||||
// if (rect.width > 0 && rect.height > 0) {
|
||||
// return {
|
||||
// width: Math.max(minWidth, Math.min(maxWidth, rect.width)),
|
||||
// height: Math.max(minHeight, Math.min(maxHeight, rect.height)),
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
|
||||
// 3순위: defaultWidth/defaultHeight 사용
|
||||
return { width: defaultWidth, height: defaultHeight };
|
||||
|
|
@ -156,6 +177,58 @@ const ResizableDialogContent = React.forwardRef<
|
|||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const [resizeDirection, setResizeDirection] = React.useState<string>("");
|
||||
const [isInitialized, setIsInitialized] = React.useState(false);
|
||||
|
||||
// userStyle이 변경되면 크기 업데이트 (화면 데이터 로딩 완료 시)
|
||||
React.useEffect(() => {
|
||||
// 1. localStorage에서 사용자가 리사이징한 크기 확인
|
||||
let savedSize: { width: number; height: number; userResized: boolean } | null = null;
|
||||
|
||||
if (effectiveModalId && typeof window !== 'undefined') {
|
||||
try {
|
||||
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.userResized) {
|
||||
savedSize = {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, parsed.width)),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
|
||||
userResized: true,
|
||||
};
|
||||
console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 모달 크기 복원 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 우선순위: 사용자 리사이징 > userStyle > 기본값
|
||||
if (savedSize && savedSize.userResized) {
|
||||
// 사용자가 리사이징한 크기 우선
|
||||
setSize({ width: savedSize.width, height: savedSize.height });
|
||||
setUserResized(true);
|
||||
console.log("✅ 사용자 리사이징 크기 적용:", savedSize);
|
||||
} else if (userStyle && userStyle.width && userStyle.height) {
|
||||
// 화면관리에서 설정한 크기
|
||||
const styleWidth = typeof userStyle.width === 'string'
|
||||
? parseInt(userStyle.width)
|
||||
: userStyle.width;
|
||||
const styleHeight = typeof userStyle.height === 'string'
|
||||
? parseInt(userStyle.height)
|
||||
: userStyle.height;
|
||||
|
||||
if (styleWidth && styleHeight) {
|
||||
const newSize = {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, styleWidth)),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, styleHeight)),
|
||||
};
|
||||
console.log("🔄 userStyle 크기 적용:", newSize);
|
||||
setSize(newSize);
|
||||
}
|
||||
}
|
||||
}, [userStyle, minWidth, maxWidth, minHeight, maxHeight, effectiveModalId, userId]);
|
||||
const [lastModalId, setLastModalId] = React.useState<string | null>(null);
|
||||
const [userResized, setUserResized] = React.useState(false); // 사용자가 실제로 리사이징했는지 추적
|
||||
|
||||
|
|
@ -192,97 +265,98 @@ const ResizableDialogContent = React.forwardRef<
|
|||
}, [effectiveModalId, lastModalId, isInitialized]);
|
||||
|
||||
// 모달이 열릴 때 초기 크기 설정 (localStorage와 내용 크기 중 큰 값 사용)
|
||||
React.useEffect(() => {
|
||||
// console.log("🔍 초기 크기 설정 useEffect 실행:", { isInitialized, hasContentRef: !!contentRef.current, effectiveModalId });
|
||||
|
||||
if (!isInitialized) {
|
||||
// 내용의 실제 크기 측정 (약간의 지연 후, contentRef가 준비될 때까지 대기)
|
||||
// 여러 번 시도하여 contentRef가 준비될 때까지 대기
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
|
||||
const measureContent = () => {
|
||||
attempts++;
|
||||
|
||||
// scrollHeight/scrollWidth를 사용하여 실제 내용 크기 측정 (스크롤 포함)
|
||||
let contentWidth = defaultWidth;
|
||||
let contentHeight = defaultHeight;
|
||||
|
||||
if (contentRef.current) {
|
||||
// scrollHeight/scrollWidth 그대로 사용 (여유 공간 제거)
|
||||
contentWidth = contentRef.current.scrollWidth || defaultWidth;
|
||||
contentHeight = contentRef.current.scrollHeight || defaultHeight;
|
||||
|
||||
// console.log("📏 모달 내용 크기 측정:", { attempt: attempts, scrollWidth: contentRef.current.scrollWidth, scrollHeight: contentRef.current.scrollHeight, clientWidth: contentRef.current.clientWidth, clientHeight: contentRef.current.clientHeight, contentWidth, contentHeight });
|
||||
} else {
|
||||
// console.log("⚠️ contentRef 없음, 재시도:", { attempt: attempts, maxAttempts, defaultWidth, defaultHeight });
|
||||
|
||||
// contentRef가 아직 없으면 재시도
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(measureContent, 100);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 패딩 추가 (p-6 * 2 = 48px)
|
||||
const paddingAndMargin = 48;
|
||||
const initialSize = getInitialSize();
|
||||
|
||||
// 내용 크기 기반 최소 크기 계산
|
||||
const contentBasedSize = {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, Math.max(contentWidth + paddingAndMargin, initialSize.width))),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, Math.max(contentHeight + paddingAndMargin, initialSize.height))),
|
||||
};
|
||||
|
||||
// console.log("📐 내용 기반 크기:", contentBasedSize);
|
||||
|
||||
// localStorage에서 저장된 크기 확인
|
||||
let finalSize = contentBasedSize;
|
||||
|
||||
if (effectiveModalId && typeof window !== 'undefined') {
|
||||
try {
|
||||
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
|
||||
// console.log("📦 localStorage 확인:", { effectiveModalId, userId, storageKey, saved: saved ? "있음" : "없음" });
|
||||
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
|
||||
// userResized 플래그 확인
|
||||
if (parsed.userResized) {
|
||||
const savedSize = {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, parsed.width)),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
|
||||
};
|
||||
|
||||
// console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
|
||||
|
||||
// ✅ 중요: 사용자가 명시적으로 리사이징한 경우, 사용자 크기를 우선 사용
|
||||
// (사용자가 의도적으로 작게 만든 것을 존중)
|
||||
finalSize = savedSize;
|
||||
setUserResized(true);
|
||||
|
||||
// console.log("✅ 최종 크기 (사용자가 설정한 크기 우선 적용):", { savedSize, contentBasedSize, finalSize, note: "사용자가 리사이징한 크기를 그대로 사용합니다" });
|
||||
} else {
|
||||
// console.log("ℹ️ 자동 계산된 크기는 무시, 내용 크기 사용");
|
||||
}
|
||||
} else {
|
||||
// console.log("ℹ️ localStorage에 저장된 크기 없음, 내용 크기 사용");
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("❌ 모달 크기 복원 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setSize(finalSize);
|
||||
setIsInitialized(true);
|
||||
};
|
||||
|
||||
// 첫 시도는 300ms 후에 시작
|
||||
setTimeout(measureContent, 300);
|
||||
}
|
||||
}, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight, defaultWidth, defaultHeight]);
|
||||
// 주석처리 - 사용자가 설정한 크기(userStyle)만 사용하도록 변경
|
||||
// React.useEffect(() => {
|
||||
// // console.log("🔍 초기 크기 설정 useEffect 실행:", { isInitialized, hasContentRef: !!contentRef.current, effectiveModalId });
|
||||
//
|
||||
// if (!isInitialized) {
|
||||
// // 내용의 실제 크기 측정 (약간의 지연 후, contentRef가 준비될 때까지 대기)
|
||||
// // 여러 번 시도하여 contentRef가 준비될 때까지 대기
|
||||
// let attempts = 0;
|
||||
// const maxAttempts = 10;
|
||||
//
|
||||
// const measureContent = () => {
|
||||
// attempts++;
|
||||
//
|
||||
// // scrollHeight/scrollWidth를 사용하여 실제 내용 크기 측정 (스크롤 포함)
|
||||
// let contentWidth = defaultWidth;
|
||||
// let contentHeight = defaultHeight;
|
||||
//
|
||||
// // if (contentRef.current) {
|
||||
// // // scrollHeight/scrollWidth 그대로 사용 (여유 공간 제거)
|
||||
// // contentWidth = contentRef.current.scrollWidth || defaultWidth;
|
||||
// // contentHeight = contentRef.current.scrollHeight || defaultHeight;
|
||||
// //
|
||||
// // // console.log("📏 모달 내용 크기 측정:", { attempt: attempts, scrollWidth: contentRef.current.scrollWidth, scrollHeight: contentRef.current.scrollHeight, clientWidth: contentRef.current.clientWidth, clientHeight: contentRef.current.clientHeight, contentWidth, contentHeight });
|
||||
// // } else {
|
||||
// // // console.log("⚠️ contentRef 없음, 재시도:", { attempt: attempts, maxAttempts, defaultWidth, defaultHeight });
|
||||
// //
|
||||
// // // contentRef가 아직 없으면 재시도
|
||||
// // if (attempts < maxAttempts) {
|
||||
// // setTimeout(measureContent, 100);
|
||||
// // return;
|
||||
// // }
|
||||
// // }
|
||||
//
|
||||
// // 패딩 추가 (p-6 * 2 = 48px)
|
||||
// const paddingAndMargin = 48;
|
||||
// const initialSize = getInitialSize();
|
||||
//
|
||||
// // 내용 크기 기반 최소 크기 계산
|
||||
// const contentBasedSize = {
|
||||
// width: Math.max(minWidth, Math.min(maxWidth, Math.max(contentWidth + paddingAndMargin, initialSize.width))),
|
||||
// height: Math.max(minHeight, Math.min(maxHeight, Math.max(contentHeight + paddingAndMargin, initialSize.height))),
|
||||
// };
|
||||
//
|
||||
// // console.log("📐 내용 기반 크기:", contentBasedSize);
|
||||
//
|
||||
// // localStorage에서 저장된 크기 확인
|
||||
// let finalSize = contentBasedSize;
|
||||
//
|
||||
// if (effectiveModalId && typeof window !== 'undefined') {
|
||||
// try {
|
||||
// const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
||||
// const saved = localStorage.getItem(storageKey);
|
||||
//
|
||||
// // console.log("📦 localStorage 확인:", { effectiveModalId, userId, storageKey, saved: saved ? "있음" : "없음" });
|
||||
//
|
||||
// if (saved) {
|
||||
// const parsed = JSON.parse(saved);
|
||||
//
|
||||
// // userResized 플래그 확인
|
||||
// if (parsed.userResized) {
|
||||
// const savedSize = {
|
||||
// width: Math.max(minWidth, Math.min(maxWidth, parsed.width)),
|
||||
// height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
|
||||
// };
|
||||
//
|
||||
// // console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
|
||||
//
|
||||
// // ✅ 중요: 사용자가 명시적으로 리사이징한 경우, 사용자 크기를 우선 사용
|
||||
// // (사용자가 의도적으로 작게 만든 것을 존중)
|
||||
// finalSize = savedSize;
|
||||
// setUserResized(true);
|
||||
//
|
||||
// // console.log("✅ 최종 크기 (사용자가 설정한 크기 우선 적용):", { savedSize, contentBasedSize, finalSize, note: "사용자가 리사이징한 크기를 그대로 사용합니다" });
|
||||
// } else {
|
||||
// // console.log("ℹ️ 자동 계산된 크기는 무시, 내용 크기 사용");
|
||||
// }
|
||||
// } else {
|
||||
// // console.log("ℹ️ localStorage에 저장된 크기 없음, 내용 크기 사용");
|
||||
// }
|
||||
// } catch (error) {
|
||||
// // console.error("❌ 모달 크기 복원 실패:", error);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// setSize(finalSize);
|
||||
// setIsInitialized(true);
|
||||
// };
|
||||
//
|
||||
// // 첫 시도는 300ms 후에 시작
|
||||
// setTimeout(measureContent, 300);
|
||||
// }
|
||||
// }, [isInitialized, getInitialSize, effectiveModalId, userId, minWidth, maxWidth, minHeight, maxHeight, defaultWidth, defaultHeight]);
|
||||
|
||||
const startResize = (direction: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -433,6 +507,37 @@ const ResizableDialogContent = React.forwardRef<
|
|||
onMouseDown={startResize("nw")}
|
||||
/>
|
||||
|
||||
{/* 리셋 버튼 (사용자가 리사이징한 경우만 표시) */}
|
||||
{userResized && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// localStorage에서 저장된 크기 삭제
|
||||
if (effectiveModalId && typeof window !== 'undefined') {
|
||||
const storageKey = `modal_size_${effectiveModalId}_${userId}`;
|
||||
localStorage.removeItem(storageKey);
|
||||
console.log("🗑️ 저장된 모달 크기 삭제:", storageKey);
|
||||
}
|
||||
|
||||
// 화면관리 설정 크기로 복원
|
||||
const initialSize = getInitialSize();
|
||||
setSize(initialSize);
|
||||
setUserResized(false);
|
||||
console.log("🔄 기본 크기로 리셋:", initialSize);
|
||||
}}
|
||||
className="absolute right-12 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
style={{ zIndex: 20 }}
|
||||
title="기본 크기로 리셋"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
|
||||
<path d="M21 3v5h-5"/>
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
|
||||
<path d="M3 21v-5h5"/>
|
||||
</svg>
|
||||
<span className="sr-only">기본 크기로 리셋</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<DialogPrimitive.Close
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
||||
style={{ zIndex: 20 }}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,21 @@ interface ApiResponse<T> {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
// 매핑 템플릿 타입
|
||||
export interface DigitalTwinMappingTemplate {
|
||||
id: string;
|
||||
company_code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
external_db_connection_id: number;
|
||||
layout_type: string;
|
||||
config: any;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_by: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ========== 레이아웃 관리 API ==========
|
||||
|
||||
// 레이아웃 목록 조회
|
||||
|
|
@ -281,3 +296,60 @@ export const getChildrenData = async (
|
|||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 매핑 템플릿 API ==========
|
||||
|
||||
// 템플릿 목록 조회 (회사 단위, 현재 사용자 기준)
|
||||
export const getMappingTemplates = async (params?: {
|
||||
externalDbConnectionId?: number;
|
||||
layoutType?: string;
|
||||
}): Promise<ApiResponse<DigitalTwinMappingTemplate[]>> => {
|
||||
try {
|
||||
const response = await apiClient.get("/digital-twin/mapping-templates", {
|
||||
params: {
|
||||
externalDbConnectionId: params?.externalDbConnectionId,
|
||||
layoutType: params?.layoutType,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 템플릿 생성
|
||||
export const createMappingTemplate = async (data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
externalDbConnectionId: number;
|
||||
layoutType?: string;
|
||||
config: any;
|
||||
}): Promise<ApiResponse<DigitalTwinMappingTemplate>> => {
|
||||
try {
|
||||
const response = await apiClient.post("/digital-twin/mapping-templates", data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 템플릿 단건 조회
|
||||
export const getMappingTemplateById = async (
|
||||
id: string,
|
||||
): Promise<ApiResponse<DigitalTwinMappingTemplate>> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/digital-twin/mapping-templates/${id}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -290,8 +290,13 @@ export class ExternalDbConnectionAPI {
|
|||
static async getTableColumns(connectionId: number, tableName: string): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
console.log("컬럼 정보 API 요청:", `${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`);
|
||||
// 컬럼 메타데이터 조회는 외부 DB 성능/네트워크 영향으로 오래 걸릴 수 있으므로
|
||||
// 기본 30초보다 넉넉한 타임아웃을 사용
|
||||
const response = await apiClient.get<ApiResponse<any[]>>(
|
||||
`${this.BASE_PATH}/${connectionId}/tables/${tableName}/columns`,
|
||||
{
|
||||
timeout: 120000, // 120초
|
||||
},
|
||||
);
|
||||
console.log("컬럼 정보 API 응답:", response.data);
|
||||
return response.data;
|
||||
|
|
|
|||
|
|
@ -162,4 +162,47 @@ export const menuApi = {
|
|||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 메뉴 복사
|
||||
copyMenu: async (
|
||||
menuObjid: number,
|
||||
targetCompanyCode: string,
|
||||
screenNameConfig?: {
|
||||
removeText?: string;
|
||||
addPrefix?: string;
|
||||
}
|
||||
): Promise<ApiResponse<MenuCopyResult>> => {
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`/admin/menus/${menuObjid}/copy`,
|
||||
{
|
||||
targetCompanyCode,
|
||||
screenNameConfig
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("❌ 메뉴 복사 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || "메뉴 복사 중 오류가 발생했습니다",
|
||||
errorCode: error.response?.data?.error?.code || "MENU_COPY_ERROR",
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 메뉴 복사 결과
|
||||
*/
|
||||
export interface MenuCopyResult {
|
||||
copiedMenus: number;
|
||||
copiedScreens: number;
|
||||
copiedFlows: number;
|
||||
copiedCategories: number;
|
||||
copiedCodes: number;
|
||||
menuIdMap: Record<number, number>;
|
||||
screenIdMap: Record<number, number>;
|
||||
flowIdMap: Record<number, number>;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue