jskim-node #413
|
|
@ -1854,7 +1854,7 @@ export async function toggleMenuStatus(
|
|||
|
||||
// 현재 상태 및 회사 코드 조회
|
||||
const currentMenu = await queryOne<any>(
|
||||
`SELECT objid, status, company_code FROM menu_info WHERE objid = $1`,
|
||||
`SELECT objid, status, company_code, menu_name_kor FROM menu_info WHERE objid = $1`,
|
||||
[Number(menuId)]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import { auditLogService } from "../services/auditLogService";
|
||||
import { auditLogService, getClientIp, AuditAction, AuditResourceType } from "../services/auditLogService";
|
||||
import { query } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
|
|
@ -137,3 +137,40 @@ export const getAuditLogUsers = async (
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 프론트엔드에서 직접 감사 로그 기록 (그룹 복제 등 프론트 오케스트레이션 작업용)
|
||||
*/
|
||||
export const createAuditLog = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { action, resourceType, resourceId, resourceName, tableName, summary, changes } = req.body;
|
||||
|
||||
if (!action || !resourceType) {
|
||||
res.status(400).json({ success: false, message: "action, resourceType은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
await auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: action as AuditAction,
|
||||
resourceType: resourceType as AuditResourceType,
|
||||
resourceId: resourceId || undefined,
|
||||
resourceName: resourceName || undefined,
|
||||
tableName: tableName || undefined,
|
||||
summary: summary || undefined,
|
||||
changes: changes || undefined,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("감사 로그 기록 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "감사 로그 기록 실패" });
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Router, Request, Response } from "express";
|
|||
import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService";
|
||||
import { logger } from "../utils/logger";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { auditLogService, getClientIp } from "../services/auditLogService";
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ router.use(authenticateToken);
|
|||
interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: string;
|
||||
userName: string;
|
||||
companyCode: string;
|
||||
};
|
||||
}
|
||||
|
|
@ -157,6 +159,21 @@ router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => {
|
|||
|
||||
const value = await categoryTreeService.createCategoryValue(companyCode, input, createdBy);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "CREATE",
|
||||
resourceType: "CODE_CATEGORY",
|
||||
resourceId: String(value.valueId),
|
||||
resourceName: input.valueLabel,
|
||||
tableName: "category_values",
|
||||
summary: `카테고리 값 "${input.valueLabel}" 생성 (${input.tableName}.${input.columnName})`,
|
||||
changes: { after: { tableName: input.tableName, columnName: input.columnName, valueCode: input.valueCode, valueLabel: input.valueLabel } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: value,
|
||||
|
|
@ -182,6 +199,7 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
|
|||
const companyCode = req.user?.companyCode || "*";
|
||||
const updatedBy = req.user?.userId;
|
||||
|
||||
const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
|
||||
const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy);
|
||||
|
||||
if (!value) {
|
||||
|
|
@ -191,6 +209,24 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
|
|||
});
|
||||
}
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "UPDATE",
|
||||
resourceType: "CODE_CATEGORY",
|
||||
resourceId: valueId,
|
||||
resourceName: value.valueLabel,
|
||||
tableName: "category_values",
|
||||
summary: `카테고리 값 "${value.valueLabel}" 수정 (${value.tableName}.${value.columnName})`,
|
||||
changes: {
|
||||
before: beforeValue ? { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode } : undefined,
|
||||
after: input,
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: value,
|
||||
|
|
@ -239,6 +275,7 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
|
|||
const { valueId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
|
||||
const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId));
|
||||
|
||||
if (!success) {
|
||||
|
|
@ -248,6 +285,21 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
|
|||
});
|
||||
}
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "DELETE",
|
||||
resourceType: "CODE_CATEGORY",
|
||||
resourceId: valueId,
|
||||
resourceName: beforeValue?.valueLabel || valueId,
|
||||
tableName: "category_values",
|
||||
summary: `카테고리 값 "${beforeValue?.valueLabel || valueId}" 삭제 (${beforeValue?.tableName || ""}.${beforeValue?.columnName || ""})`,
|
||||
changes: beforeValue ? { before: { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode, tableName: beforeValue.tableName, columnName: beforeValue.columnName } } : undefined,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "삭제되었습니다",
|
||||
|
|
|
|||
|
|
@ -396,6 +396,20 @@ export class CommonCodeController {
|
|||
companyCode
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: companyCode || "",
|
||||
userId: userId || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "CODE",
|
||||
resourceId: codeValue,
|
||||
resourceName: codeData.codeName || codeValue,
|
||||
tableName: "code_info",
|
||||
summary: `코드 "${categoryCode}.${codeValue}" 수정`,
|
||||
changes: { after: codeData },
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: code,
|
||||
|
|
@ -440,6 +454,19 @@ export class CommonCodeController {
|
|||
companyCode
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
action: "DELETE",
|
||||
resourceType: "CODE",
|
||||
resourceId: codeValue,
|
||||
tableName: "code_info",
|
||||
summary: `코드 "${categoryCode}.${codeValue}" 삭제`,
|
||||
changes: { before: { categoryCode, codeValue } },
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "코드 삭제 성공",
|
||||
|
|
|
|||
|
|
@ -438,6 +438,19 @@ export class DDLController {
|
|||
);
|
||||
|
||||
if (result.success) {
|
||||
auditLogService.log({
|
||||
companyCode: userCompanyCode || "",
|
||||
userId,
|
||||
action: "DELETE",
|
||||
resourceType: "TABLE",
|
||||
resourceId: tableName,
|
||||
resourceName: tableName,
|
||||
tableName,
|
||||
summary: `테이블 "${tableName}" 삭제`,
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ router.post(
|
|||
auditLogService.log({
|
||||
companyCode,
|
||||
userId,
|
||||
userName: req.user?.userName,
|
||||
action: "CREATE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: String(newRule.ruleId),
|
||||
|
|
@ -243,6 +244,7 @@ router.put(
|
|||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "UPDATE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: ruleId,
|
||||
|
|
@ -285,6 +287,7 @@ router.delete(
|
|||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "DELETE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: ruleId,
|
||||
|
|
@ -521,6 +524,56 @@ router.post(
|
|||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
const isUpdate = !!ruleConfig.ruleId;
|
||||
|
||||
const resetPeriodLabel: Record<string, string> = {
|
||||
none: "초기화 안함", daily: "일별", monthly: "월별", yearly: "연별",
|
||||
};
|
||||
const partTypeLabel: Record<string, string> = {
|
||||
sequence: "순번", number: "숫자", date: "날짜", text: "문자", category: "카테고리", reference: "참조",
|
||||
};
|
||||
const partsDescription = (ruleConfig.parts || [])
|
||||
.sort((a: any, b: any) => (a.order || 0) - (b.order || 0))
|
||||
.map((p: any) => {
|
||||
const type = partTypeLabel[p.partType] || p.partType;
|
||||
if (p.partType === "text" && p.autoConfig?.textValue) return `${type}("${p.autoConfig.textValue}")`;
|
||||
if (p.partType === "sequence" && p.autoConfig?.sequenceLength) return `${type}(${p.autoConfig.sequenceLength}자리)`;
|
||||
if (p.partType === "date" && p.autoConfig?.dateFormat) return `${type}(${p.autoConfig.dateFormat})`;
|
||||
if (p.partType === "category") return `${type}(${p.autoConfig?.categoryKey || ""})`;
|
||||
if (p.partType === "reference") return `${type}(${p.autoConfig?.referenceColumnName || ""})`;
|
||||
return type;
|
||||
})
|
||||
.join(` ${ruleConfig.separator || "-"} `);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId,
|
||||
userName: req.user?.userName,
|
||||
action: isUpdate ? "UPDATE" : "CREATE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: String(savedRule.ruleId),
|
||||
resourceName: ruleConfig.ruleName,
|
||||
tableName: "numbering_rules",
|
||||
summary: isUpdate
|
||||
? `채번 규칙 "${ruleConfig.ruleName}" 수정`
|
||||
: `채번 규칙 "${ruleConfig.ruleName}" 생성`,
|
||||
changes: {
|
||||
after: {
|
||||
규칙명: ruleConfig.ruleName,
|
||||
적용테이블: ruleConfig.tableName || "(미지정)",
|
||||
적용컬럼: ruleConfig.columnName || "(미지정)",
|
||||
구분자: ruleConfig.separator || "-",
|
||||
리셋주기: resetPeriodLabel[ruleConfig.resetPeriod] || ruleConfig.resetPeriod || "초기화 안함",
|
||||
적용범위: ruleConfig.scopeType === "menu" ? "메뉴별" : "전역",
|
||||
코드구성: partsDescription || "(파트 없음)",
|
||||
파트수: (ruleConfig.parts || []).length,
|
||||
},
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: savedRule });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
|
||||
|
|
@ -535,10 +588,25 @@ router.delete(
|
|||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId,
|
||||
userName: req.user?.userName,
|
||||
action: "DELETE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: ruleId,
|
||||
tableName: "numbering_rules",
|
||||
summary: `채번 규칙(ID:${ruleId}) 삭제`,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "테스트 채번 규칙이 삭제되었습니다",
|
||||
|
|
|
|||
|
|
@ -614,20 +614,6 @@ export const copyScreenWithModals = async (
|
|||
modalScreens: modalScreens || [],
|
||||
});
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: targetCompanyCode || companyCode,
|
||||
userId: userId || "",
|
||||
userName: (req.user as any)?.userName || "",
|
||||
action: "COPY",
|
||||
resourceType: "SCREEN",
|
||||
resourceId: id,
|
||||
resourceName: mainScreen?.screenName,
|
||||
summary: `화면 일괄 복사 (메인 1개 + 모달 ${result.modalScreens.length}개, 원본 ID:${id})`,
|
||||
changes: { after: { sourceScreenId: id, targetCompanyCode, mainScreenName: mainScreen?.screenName } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
|
|
@ -663,20 +649,6 @@ export const copyScreen = async (
|
|||
}
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: userId || "",
|
||||
userName: (req.user as any)?.userName || "",
|
||||
action: "COPY",
|
||||
resourceType: "SCREEN",
|
||||
resourceId: String(copiedScreen?.screenId || ""),
|
||||
resourceName: screenName,
|
||||
summary: `화면 "${screenName}" 복사 (원본 ID:${id})`,
|
||||
changes: { after: { sourceScreenId: id, screenName, screenCode } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: copiedScreen,
|
||||
|
|
|
|||
|
|
@ -963,6 +963,15 @@ export async function addTableData(
|
|||
|
||||
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
|
||||
|
||||
const systemFields = new Set([
|
||||
"id", "created_date", "updated_date", "writer", "company_code",
|
||||
"createdDate", "updatedDate", "companyCode",
|
||||
]);
|
||||
const auditData: Record<string, any> = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (!systemFields.has(k)) auditData[k] = v;
|
||||
}
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
|
|
@ -973,7 +982,7 @@ export async function addTableData(
|
|||
resourceName: tableName,
|
||||
tableName,
|
||||
summary: `${tableName} 데이터 추가`,
|
||||
changes: { after: data },
|
||||
changes: { after: auditData },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
|
@ -1096,10 +1105,14 @@ export async function editTableData(
|
|||
return;
|
||||
}
|
||||
|
||||
// 변경된 필드만 추출
|
||||
const systemFieldsForEdit = new Set([
|
||||
"id", "created_date", "updated_date", "writer", "company_code",
|
||||
"createdDate", "updatedDate", "companyCode",
|
||||
]);
|
||||
const changedBefore: Record<string, any> = {};
|
||||
const changedAfter: Record<string, any> = {};
|
||||
for (const key of Object.keys(updatedData)) {
|
||||
if (systemFieldsForEdit.has(key)) continue;
|
||||
if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) {
|
||||
changedBefore[key] = originalData[key];
|
||||
changedAfter[key] = updatedData[key];
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { getAuditLogs, getAuditLogStats, getAuditLogUsers } from "../controllers/auditLogController";
|
||||
import { getAuditLogs, getAuditLogStats, getAuditLogUsers, createAuditLog } from "../controllers/auditLogController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", authenticateToken, getAuditLogs);
|
||||
router.get("/stats", authenticateToken, getAuditLogStats);
|
||||
router.get("/users", authenticateToken, getAuditLogUsers);
|
||||
router.post("/", authenticateToken, createAuditLog);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export interface AuditLogParams {
|
|||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
company_code: string;
|
||||
company_name: string | null;
|
||||
user_id: string;
|
||||
user_name: string | null;
|
||||
action: string;
|
||||
|
|
@ -107,6 +108,7 @@ class AuditLogService {
|
|||
*/
|
||||
async log(params: AuditLogParams): Promise<void> {
|
||||
try {
|
||||
logger.info(`[AuditLog] 기록 시도: ${params.resourceType} / ${params.action} / ${params.resourceName || params.resourceId || "N/A"}`);
|
||||
await query(
|
||||
`INSERT INTO system_audit_log
|
||||
(company_code, user_id, user_name, action, resource_type,
|
||||
|
|
@ -128,8 +130,9 @@ class AuditLogService {
|
|||
params.requestPath || null,
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("감사 로그 기록 실패 (무시됨)", { error, params });
|
||||
logger.info(`[AuditLog] 기록 성공: ${params.resourceType} / ${params.action}`);
|
||||
} catch (error: any) {
|
||||
logger.error(`[AuditLog] 기록 실패: ${params.resourceType} / ${params.action} - ${error?.message}`, { error, params });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -186,40 +189,40 @@ class AuditLogService {
|
|||
let paramIndex = 1;
|
||||
|
||||
if (!isSuperAdmin && filters.companyCode) {
|
||||
conditions.push(`company_code = $${paramIndex++}`);
|
||||
conditions.push(`sal.company_code = $${paramIndex++}`);
|
||||
params.push(filters.companyCode);
|
||||
} else if (isSuperAdmin && filters.companyCode) {
|
||||
conditions.push(`company_code = $${paramIndex++}`);
|
||||
conditions.push(`sal.company_code = $${paramIndex++}`);
|
||||
params.push(filters.companyCode);
|
||||
}
|
||||
|
||||
if (filters.userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
conditions.push(`sal.user_id = $${paramIndex++}`);
|
||||
params.push(filters.userId);
|
||||
}
|
||||
if (filters.resourceType) {
|
||||
conditions.push(`resource_type = $${paramIndex++}`);
|
||||
conditions.push(`sal.resource_type = $${paramIndex++}`);
|
||||
params.push(filters.resourceType);
|
||||
}
|
||||
if (filters.action) {
|
||||
conditions.push(`action = $${paramIndex++}`);
|
||||
conditions.push(`sal.action = $${paramIndex++}`);
|
||||
params.push(filters.action);
|
||||
}
|
||||
if (filters.tableName) {
|
||||
conditions.push(`table_name = $${paramIndex++}`);
|
||||
conditions.push(`sal.table_name = $${paramIndex++}`);
|
||||
params.push(filters.tableName);
|
||||
}
|
||||
if (filters.dateFrom) {
|
||||
conditions.push(`created_at >= $${paramIndex++}::timestamptz`);
|
||||
conditions.push(`sal.created_at >= $${paramIndex++}::timestamptz`);
|
||||
params.push(filters.dateFrom);
|
||||
}
|
||||
if (filters.dateTo) {
|
||||
conditions.push(`created_at <= $${paramIndex++}::timestamptz`);
|
||||
conditions.push(`sal.created_at <= $${paramIndex++}::timestamptz`);
|
||||
params.push(filters.dateTo);
|
||||
}
|
||||
if (filters.search) {
|
||||
conditions.push(
|
||||
`(summary ILIKE $${paramIndex} OR resource_name ILIKE $${paramIndex} OR user_name ILIKE $${paramIndex})`
|
||||
`(sal.summary ILIKE $${paramIndex} OR sal.resource_name ILIKE $${paramIndex} OR sal.user_name ILIKE $${paramIndex})`
|
||||
);
|
||||
params.push(`%${filters.search}%`);
|
||||
paramIndex++;
|
||||
|
|
@ -233,14 +236,17 @@ class AuditLogService {
|
|||
const offset = (page - 1) * limit;
|
||||
|
||||
const countResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM system_audit_log ${whereClause}`,
|
||||
`SELECT COUNT(*) as count FROM system_audit_log sal ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = parseInt(countResult[0].count, 10);
|
||||
|
||||
const data = await query<AuditLogEntry>(
|
||||
`SELECT * FROM system_audit_log ${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
`SELECT sal.*, ci.company_name
|
||||
FROM system_audit_log sal
|
||||
LEFT JOIN company_mng ci ON sal.company_code = ci.company_code
|
||||
${whereClause}
|
||||
ORDER BY sal.created_at DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||
[...params, limit, offset]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -77,14 +77,12 @@ const RESOURCE_TYPE_CONFIG: Record<
|
|||
NODE_FLOW: { label: "플로우 제어", icon: GitBranch, color: "bg-teal-100 text-teal-700" },
|
||||
USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" },
|
||||
ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
||||
PERMISSION: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
||||
COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
|
||||
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||
CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||
DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" },
|
||||
TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" },
|
||||
NUMBERING_RULE: { label: "채번 규칙", icon: FileText, color: "bg-amber-100 text-amber-700" },
|
||||
BATCH: { label: "배치", icon: RefreshCw, color: "bg-teal-100 text-teal-700" },
|
||||
};
|
||||
|
||||
const ACTION_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
|
|
@ -817,7 +815,7 @@ export default function AuditLogPage() {
|
|||
</Badge>
|
||||
{entry.company_code && entry.company_code !== "*" && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
[{entry.company_code}]
|
||||
[{entry.company_name || entry.company_code}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -862,9 +860,11 @@ export default function AuditLogPage() {
|
|||
</div>
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
회사코드
|
||||
회사
|
||||
</label>
|
||||
<p className="font-medium">{selectedEntry.company_code}</p>
|
||||
<p className="font-medium">
|
||||
{selectedEntry.company_name || selectedEntry.company_code}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
|
|
|
|||
|
|
@ -98,10 +98,43 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
||||
if (savedMode === "true") {
|
||||
setContinuousMode(true);
|
||||
// console.log("🔄 연속 모드 복원: true");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// dataBinding: 테이블 선택 시 바인딩된 input의 formData를 자동 업데이트
|
||||
useEffect(() => {
|
||||
if (!modalState.isOpen || !screenData?.components?.length) return;
|
||||
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail?.source || !detail?.data) return;
|
||||
|
||||
const bindingUpdates: Record<string, any> = {};
|
||||
for (const comp of screenData.components) {
|
||||
const db =
|
||||
comp.componentConfig?.dataBinding ||
|
||||
(comp as any).dataBinding;
|
||||
if (!db?.sourceComponentId || !db?.sourceColumn) continue;
|
||||
if (db.sourceComponentId !== detail.source) continue;
|
||||
|
||||
const colName = (comp as any).columnName || comp.componentConfig?.columnName;
|
||||
if (!colName) continue;
|
||||
|
||||
const selectedRow = detail.data[0];
|
||||
const value = selectedRow?.[db.sourceColumn] ?? "";
|
||||
bindingUpdates[colName] = value;
|
||||
}
|
||||
|
||||
if (Object.keys(bindingUpdates).length > 0) {
|
||||
setFormData((prev) => ({ ...prev, ...bindingUpdates }));
|
||||
formDataChangedRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("v2-table-selection", handler);
|
||||
return () => window.removeEventListener("v2-table-selection", handler);
|
||||
}, [modalState.isOpen, screenData?.components]);
|
||||
|
||||
// 화면의 실제 크기 계산 함수
|
||||
const calculateScreenDimensions = (components: ComponentData[]) => {
|
||||
if (components.length === 0) {
|
||||
|
|
|
|||
|
|
@ -1165,6 +1165,28 @@ export default function CopyScreenModal({
|
|||
}
|
||||
}
|
||||
|
||||
// 그룹 복제 요약 감사 로그 1건 기록
|
||||
try {
|
||||
await apiClient.post("/audit-log", {
|
||||
action: "COPY",
|
||||
resourceType: "SCREEN",
|
||||
resourceId: String(sourceGroup.id),
|
||||
resourceName: sourceGroup.group_name,
|
||||
summary: `그룹 "${sourceGroup.group_name}" → "${rootGroupName}" 복제 (그룹 ${stats.groups}개, 화면 ${stats.screens}개)${finalCompanyCode !== sourceGroup.company_code ? ` [${sourceGroup.company_code} → ${finalCompanyCode}]` : ""}`,
|
||||
changes: {
|
||||
after: {
|
||||
원본그룹: sourceGroup.group_name,
|
||||
대상그룹: rootGroupName,
|
||||
복제그룹수: stats.groups,
|
||||
복제화면수: stats.screens,
|
||||
대상회사: finalCompanyCode,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (auditError) {
|
||||
console.warn("그룹 복제 감사 로그 기록 실패 (무시):", auditError);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`그룹 복제가 완료되었습니다! (그룹 ${stats.groups}개 + 화면 ${stats.screens}개)`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ interface RealtimePreviewProps {
|
|||
selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID
|
||||
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; // 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||
selectedPanelComponentId?: string; // 🆕 선택된 분할 패널 컴포넌트 ID
|
||||
onNestedPanelSelect?: (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => void;
|
||||
onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백
|
||||
|
||||
// 버튼 액션을 위한 props
|
||||
|
|
@ -150,6 +151,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
|
||||
onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||
selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID
|
||||
onNestedPanelSelect,
|
||||
onResize, // 🆕 리사이즈 콜백
|
||||
}) => {
|
||||
// 🆕 화면 다국어 컨텍스트
|
||||
|
|
@ -768,6 +770,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
selectedTabComponentId={selectedTabComponentId}
|
||||
onSelectPanelComponent={onSelectPanelComponent}
|
||||
selectedPanelComponentId={selectedPanelComponentId}
|
||||
onNestedPanelSelect={onNestedPanelSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@ interface ProcessedRow {
|
|||
mainComponent?: ComponentData;
|
||||
overlayComps: ComponentData[];
|
||||
normalComps: ComponentData[];
|
||||
rowMinY?: number;
|
||||
rowMaxBottom?: number;
|
||||
}
|
||||
|
||||
function FullWidthOverlayRow({
|
||||
|
|
@ -227,6 +229,10 @@ export function ResponsiveGridRenderer({
|
|||
}
|
||||
}
|
||||
|
||||
const allComps = [...fullWidthComps, ...normalComps];
|
||||
const rowMinY = allComps.length > 0 ? Math.min(...allComps.map(c => c.position.y)) : 0;
|
||||
const rowMaxBottom = allComps.length > 0 ? Math.max(...allComps.map(c => c.position.y + (c.size?.height || 40))) : 0;
|
||||
|
||||
if (fullWidthComps.length > 0 && normalComps.length > 0) {
|
||||
for (const fwComp of fullWidthComps) {
|
||||
processedRows.push({
|
||||
|
|
@ -234,6 +240,8 @@ export function ResponsiveGridRenderer({
|
|||
mainComponent: fwComp,
|
||||
overlayComps: normalComps,
|
||||
normalComps: [],
|
||||
rowMinY,
|
||||
rowMaxBottom,
|
||||
});
|
||||
}
|
||||
} else if (fullWidthComps.length > 0) {
|
||||
|
|
@ -243,6 +251,8 @@ export function ResponsiveGridRenderer({
|
|||
mainComponent: fwComp,
|
||||
overlayComps: [],
|
||||
normalComps: [],
|
||||
rowMinY,
|
||||
rowMaxBottom,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
|
@ -250,6 +260,8 @@ export function ResponsiveGridRenderer({
|
|||
type: "normal",
|
||||
overlayComps: [],
|
||||
normalComps,
|
||||
rowMinY,
|
||||
rowMaxBottom,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -261,15 +273,26 @@ export function ResponsiveGridRenderer({
|
|||
style={{ minHeight: "200px" }}
|
||||
>
|
||||
{processedRows.map((processedRow, rowIndex) => {
|
||||
const rowMarginTop = (() => {
|
||||
if (rowIndex === 0) return 0;
|
||||
const prevRow = processedRows[rowIndex - 1];
|
||||
const prevBottom = prevRow.rowMaxBottom ?? 0;
|
||||
const currTop = processedRow.rowMinY ?? 0;
|
||||
const designGap = currTop - prevBottom;
|
||||
if (designGap <= 0) return 0;
|
||||
return Math.min(Math.max(Math.round(designGap * 0.5), 4), 48);
|
||||
})();
|
||||
|
||||
if (processedRow.type === "fullwidth" && processedRow.mainComponent) {
|
||||
return (
|
||||
<FullWidthOverlayRow
|
||||
key={`row-${rowIndex}`}
|
||||
main={processedRow.mainComponent}
|
||||
overlayComps={processedRow.overlayComps}
|
||||
canvasWidth={canvasWidth}
|
||||
renderComponent={renderComponent}
|
||||
/>
|
||||
<div key={`row-${rowIndex}`} style={{ marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}>
|
||||
<FullWidthOverlayRow
|
||||
main={processedRow.mainComponent}
|
||||
overlayComps={processedRow.overlayComps}
|
||||
canvasWidth={canvasWidth}
|
||||
renderComponent={renderComponent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -290,7 +313,7 @@ export function ResponsiveGridRenderer({
|
|||
allButtons && "justify-end px-2 py-1",
|
||||
hasFlexHeightComp ? "min-h-0 flex-1" : "flex-shrink-0"
|
||||
)}
|
||||
style={{ gap: `${gap}px` }}
|
||||
style={{ gap: `${gap}px`, marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}
|
||||
>
|
||||
{normalComps.map((component) => {
|
||||
const typeId = getComponentTypeId(component);
|
||||
|
|
@ -337,10 +360,10 @@ export function ResponsiveGridRenderer({
|
|||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
minWidth: isMobile ? "100%" : undefined,
|
||||
minHeight: useFlexHeight ? "300px" : undefined,
|
||||
height: useFlexHeight ? "100%" : (component.size?.height
|
||||
minHeight: useFlexHeight ? "300px" : (component.size?.height
|
||||
? `${component.size.height}px`
|
||||
: "auto"),
|
||||
: undefined),
|
||||
height: useFlexHeight ? "100%" : "auto",
|
||||
}}
|
||||
>
|
||||
{renderComponent(component)}
|
||||
|
|
|
|||
|
|
@ -2861,9 +2861,190 @@ export default function ScreenDesigner({
|
|||
}
|
||||
}
|
||||
|
||||
// 🎯 탭 컨테이너 내부 드롭 처리 (중첩 구조 지원)
|
||||
// 🎯 컨테이너 드롭 우선순위: 가장 안쪽(innermost) 컨테이너 우선
|
||||
// 분할패널과 탭 둘 다 감지될 경우, DOM 트리에서 더 가까운 쪽을 우선 처리
|
||||
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
|
||||
if (tabsContainer) {
|
||||
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
||||
|
||||
// 분할패널이 탭보다 안쪽에 있으면 분할패널 우선 처리
|
||||
const splitPanelFirst =
|
||||
splitPanelContainer &&
|
||||
(!tabsContainer || tabsContainer.contains(splitPanelContainer));
|
||||
|
||||
if (splitPanelFirst && splitPanelContainer) {
|
||||
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
||||
const panelSide = splitPanelContainer.getAttribute("data-panel-side");
|
||||
if (containerId && panelSide) {
|
||||
// 분할 패널을 최상위 또는 중첩(탭 안)에서 찾기
|
||||
let targetComponent: any = layout.components.find((c) => c.id === containerId);
|
||||
let parentTabsId: string | null = null;
|
||||
let parentTabId: string | null = null;
|
||||
let parentSplitId: string | null = null;
|
||||
let parentSplitSide: string | null = null;
|
||||
|
||||
if (!targetComponent) {
|
||||
// 탭 안에 중첩된 분할패널 찾기
|
||||
// top-level: overrides.type / overrides.tabs
|
||||
// nested: componentType / componentConfig.tabs
|
||||
for (const comp of layout.components) {
|
||||
const compType = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||
const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||
|
||||
if (compType === "tabs-widget" || compType === "v2-tabs-widget") {
|
||||
const tabs = compConfig.tabs || [];
|
||||
for (const tab of tabs) {
|
||||
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||
if (found) {
|
||||
targetComponent = found;
|
||||
parentTabsId = comp.id;
|
||||
parentTabId = tab.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetComponent) break;
|
||||
}
|
||||
|
||||
if (compType === "split-panel-layout" || compType === "v2-split-panel-layout") {
|
||||
for (const side of ["leftPanel", "rightPanel"] as const) {
|
||||
const panelComps = compConfig[side]?.components || [];
|
||||
for (const pc of panelComps) {
|
||||
const pct = pc.componentType || pc.overrides?.type;
|
||||
if (pct === "tabs-widget" || pct === "v2-tabs-widget") {
|
||||
const tabs = (pc.componentConfig || pc.overrides || {}).tabs || [];
|
||||
for (const tab of tabs) {
|
||||
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||
if (found) {
|
||||
targetComponent = found;
|
||||
parentSplitId = comp.id;
|
||||
parentSplitSide = side === "leftPanel" ? "left" : "right";
|
||||
parentTabsId = pc.id;
|
||||
parentTabId = tab.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetComponent) break;
|
||||
}
|
||||
}
|
||||
if (targetComponent) break;
|
||||
}
|
||||
if (targetComponent) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const compType = (targetComponent as any)?.componentType;
|
||||
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
|
||||
const currentConfig = (targetComponent as any).componentConfig || {};
|
||||
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||
const panelConfig = currentConfig[panelKey] || {};
|
||||
const currentComponents = panelConfig.components || [];
|
||||
|
||||
const panelRect = splitPanelContainer.getBoundingClientRect();
|
||||
const cs1 = window.getComputedStyle(splitPanelContainer);
|
||||
const dropX = (e.clientX - panelRect.left - (parseFloat(cs1.paddingLeft) || 0)) / zoomLevel;
|
||||
const dropY = (e.clientY - panelRect.top - (parseFloat(cs1.paddingTop) || 0)) / zoomLevel;
|
||||
|
||||
const componentType = component.id || component.componentType || "v2-text-display";
|
||||
|
||||
const newPanelComponent = {
|
||||
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
componentType: componentType,
|
||||
label: component.name || component.label || "새 컴포넌트",
|
||||
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
|
||||
size: component.defaultSize || { width: 200, height: 100 },
|
||||
componentConfig: component.defaultConfig || {},
|
||||
};
|
||||
|
||||
const updatedPanelConfig = {
|
||||
...panelConfig,
|
||||
components: [...currentComponents, newPanelComponent],
|
||||
};
|
||||
|
||||
const updatedSplitPanel = {
|
||||
...targetComponent,
|
||||
componentConfig: {
|
||||
...currentConfig,
|
||||
[panelKey]: updatedPanelConfig,
|
||||
},
|
||||
};
|
||||
|
||||
let newLayout;
|
||||
if (parentTabsId && parentTabId) {
|
||||
// 중첩: (최상위 분할패널 →) 탭 → 분할패널
|
||||
const updateTabsComponent = (tabsComp: any) => {
|
||||
const ck = tabsComp.componentConfig ? "componentConfig" : "overrides";
|
||||
const cfg = tabsComp[ck] || {};
|
||||
const tabs = cfg.tabs || [];
|
||||
return {
|
||||
...tabsComp,
|
||||
[ck]: {
|
||||
...cfg,
|
||||
tabs: tabs.map((tab: any) =>
|
||||
tab.id === parentTabId
|
||||
? {
|
||||
...tab,
|
||||
components: (tab.components || []).map((c: any) =>
|
||||
c.id === containerId ? updatedSplitPanel : c,
|
||||
),
|
||||
}
|
||||
: tab,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
if (parentSplitId && parentSplitSide) {
|
||||
// 최상위 분할패널 → 탭 → 분할패널
|
||||
const pKey = parentSplitSide === "left" ? "leftPanel" : "rightPanel";
|
||||
newLayout = {
|
||||
...layout,
|
||||
components: layout.components.map((c) => {
|
||||
if (c.id === parentSplitId) {
|
||||
const sc = (c as any).componentConfig || {};
|
||||
return {
|
||||
...c,
|
||||
componentConfig: {
|
||||
...sc,
|
||||
[pKey]: {
|
||||
...sc[pKey],
|
||||
components: (sc[pKey]?.components || []).map((pc: any) =>
|
||||
pc.id === parentTabsId ? updateTabsComponent(pc) : pc,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return c;
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
// 최상위 탭 → 분할패널
|
||||
newLayout = {
|
||||
...layout,
|
||||
components: layout.components.map((c) =>
|
||||
c.id === parentTabsId ? updateTabsComponent(c) : c,
|
||||
),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// 최상위 분할패널
|
||||
newLayout = {
|
||||
...layout,
|
||||
components: layout.components.map((c) => (c.id === containerId ? updatedSplitPanel : c)),
|
||||
};
|
||||
}
|
||||
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tabsContainer && !splitPanelFirst) {
|
||||
const containerId = tabsContainer.getAttribute("data-component-id");
|
||||
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
|
||||
if (containerId && activeTabId) {
|
||||
|
|
@ -3004,69 +3185,6 @@ export default function ScreenDesigner({
|
|||
}
|
||||
}
|
||||
|
||||
// 🎯 분할 패널 커스텀 모드 컨테이너 내부 드롭 처리
|
||||
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
||||
if (splitPanelContainer) {
|
||||
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
||||
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
|
||||
if (containerId && panelSide) {
|
||||
const targetComponent = layout.components.find((c) => c.id === containerId);
|
||||
const compType = (targetComponent as any)?.componentType;
|
||||
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
|
||||
const currentConfig = (targetComponent as any).componentConfig || {};
|
||||
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||
const panelConfig = currentConfig[panelKey] || {};
|
||||
const currentComponents = panelConfig.components || [];
|
||||
|
||||
// 드롭 위치 계산
|
||||
const panelRect = splitPanelContainer.getBoundingClientRect();
|
||||
const dropX = (e.clientX - panelRect.left) / zoomLevel;
|
||||
const dropY = (e.clientY - panelRect.top) / zoomLevel;
|
||||
|
||||
// 새 컴포넌트 생성
|
||||
const componentType = component.id || component.componentType || "v2-text-display";
|
||||
|
||||
console.log("🎯 분할 패널에 컴포넌트 드롭:", {
|
||||
componentId: component.id,
|
||||
componentType: componentType,
|
||||
panelSide: panelSide,
|
||||
dropPosition: { x: dropX, y: dropY },
|
||||
});
|
||||
|
||||
const newPanelComponent = {
|
||||
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
componentType: componentType,
|
||||
label: component.name || component.label || "새 컴포넌트",
|
||||
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
|
||||
size: component.defaultSize || { width: 200, height: 100 },
|
||||
componentConfig: component.defaultConfig || {},
|
||||
};
|
||||
|
||||
const updatedPanelConfig = {
|
||||
...panelConfig,
|
||||
components: [...currentComponents, newPanelComponent],
|
||||
};
|
||||
|
||||
const updatedComponent = {
|
||||
...targetComponent,
|
||||
componentConfig: {
|
||||
...currentConfig,
|
||||
[panelKey]: updatedPanelConfig,
|
||||
},
|
||||
};
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)),
|
||||
};
|
||||
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`);
|
||||
return; // 분할 패널 처리 완료
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
|
@ -3378,15 +3496,12 @@ export default function ScreenDesigner({
|
|||
e.preventDefault();
|
||||
|
||||
const dragData = e.dataTransfer.getData("application/json");
|
||||
// console.log("🎯 드롭 이벤트:", { dragData });
|
||||
if (!dragData) {
|
||||
// console.log("❌ 드래그 데이터가 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedData = JSON.parse(dragData);
|
||||
// console.log("📋 파싱된 데이터:", parsedData);
|
||||
|
||||
// 템플릿 드래그인 경우
|
||||
if (parsedData.type === "template") {
|
||||
|
|
@ -3480,9 +3595,225 @@ export default function ScreenDesigner({
|
|||
}
|
||||
}
|
||||
|
||||
// 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원)
|
||||
// 🎯 컨테이너 감지: innermost 우선 (분할패널 > 탭)
|
||||
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
|
||||
if (tabsContainer && type === "column" && column) {
|
||||
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
||||
|
||||
// 분할패널이 탭 안에 있으면 분할패널이 innermost → 분할패널 우선
|
||||
const splitPanelFirst =
|
||||
splitPanelContainer &&
|
||||
(!tabsContainer || tabsContainer.contains(splitPanelContainer));
|
||||
|
||||
// 🎯 분할패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리 (우선 처리)
|
||||
if (splitPanelFirst && splitPanelContainer && type === "column" && column) {
|
||||
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
||||
let panelSide = splitPanelContainer.getAttribute("data-panel-side");
|
||||
|
||||
// panelSide가 없으면 드롭 좌표와 splitRatio로 좌/우 판별
|
||||
if (!panelSide) {
|
||||
const splitRatio = parseInt(splitPanelContainer.getAttribute("data-split-ratio") || "40", 10);
|
||||
const containerRect = splitPanelContainer.getBoundingClientRect();
|
||||
const relativeX = e.clientX - containerRect.left;
|
||||
const splitPoint = containerRect.width * (splitRatio / 100);
|
||||
panelSide = relativeX < splitPoint ? "left" : "right";
|
||||
}
|
||||
|
||||
if (containerId && panelSide) {
|
||||
// 최상위에서 찾기
|
||||
let targetComponent: any = layout.components.find((c) => c.id === containerId);
|
||||
let parentTabsId: string | null = null;
|
||||
let parentTabId: string | null = null;
|
||||
let parentSplitId: string | null = null;
|
||||
let parentSplitSide: string | null = null;
|
||||
|
||||
if (!targetComponent) {
|
||||
// 탭 안 중첩 분할패널 찾기
|
||||
// top-level 컴포넌트: overrides.type / overrides.tabs
|
||||
// nested 컴포넌트: componentType / componentConfig.tabs
|
||||
for (const comp of layout.components) {
|
||||
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||
const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||
|
||||
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
|
||||
const tabs = compConfig.tabs || [];
|
||||
for (const tab of tabs) {
|
||||
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||
if (found) {
|
||||
targetComponent = found;
|
||||
parentTabsId = comp.id;
|
||||
parentTabId = tab.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetComponent) break;
|
||||
}
|
||||
// 분할패널 → 탭 → 분할패널 중첩
|
||||
if (ct === "split-panel-layout" || ct === "v2-split-panel-layout") {
|
||||
for (const side of ["leftPanel", "rightPanel"] as const) {
|
||||
const panelComps = compConfig[side]?.components || [];
|
||||
for (const pc of panelComps) {
|
||||
const pct = pc.componentType || pc.overrides?.type;
|
||||
if (pct === "tabs-widget" || pct === "v2-tabs-widget") {
|
||||
const tabs = (pc.componentConfig || pc.overrides || {}).tabs || [];
|
||||
for (const tab of tabs) {
|
||||
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||
if (found) {
|
||||
targetComponent = found;
|
||||
parentSplitId = comp.id;
|
||||
parentSplitSide = side === "leftPanel" ? "left" : "right";
|
||||
parentTabsId = pc.id;
|
||||
parentTabId = tab.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetComponent) break;
|
||||
}
|
||||
}
|
||||
if (targetComponent) break;
|
||||
}
|
||||
if (targetComponent) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const compType = (targetComponent as any)?.componentType;
|
||||
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
|
||||
const currentConfig = (targetComponent as any).componentConfig || {};
|
||||
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||
const panelConfig = currentConfig[panelKey] || {};
|
||||
const currentComponents = panelConfig.components || [];
|
||||
|
||||
const panelRect = splitPanelContainer.getBoundingClientRect();
|
||||
const computedStyle = window.getComputedStyle(splitPanelContainer);
|
||||
const padLeft = parseFloat(computedStyle.paddingLeft) || 0;
|
||||
const padTop = parseFloat(computedStyle.paddingTop) || 0;
|
||||
const dropX = (e.clientX - panelRect.left - padLeft) / zoomLevel;
|
||||
const dropY = (e.clientY - panelRect.top - padTop) / zoomLevel;
|
||||
|
||||
const v2Mapping = createV2ConfigFromColumn({
|
||||
widgetType: column.widgetType,
|
||||
columnName: column.columnName,
|
||||
columnLabel: column.columnLabel,
|
||||
codeCategory: column.codeCategory,
|
||||
inputType: column.inputType,
|
||||
required: column.required,
|
||||
detailSettings: column.detailSettings,
|
||||
referenceTable: column.referenceTable,
|
||||
referenceColumn: column.referenceColumn,
|
||||
displayColumn: column.displayColumn,
|
||||
});
|
||||
|
||||
const newPanelComponent = {
|
||||
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
componentType: v2Mapping.componentType,
|
||||
label: column.columnLabel || column.columnName,
|
||||
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
|
||||
size: { width: 200, height: 36 },
|
||||
inputType: column.inputType || column.widgetType,
|
||||
widgetType: column.widgetType,
|
||||
componentConfig: {
|
||||
...v2Mapping.componentConfig,
|
||||
columnName: column.columnName,
|
||||
tableName: column.tableName,
|
||||
inputType: column.inputType || column.widgetType,
|
||||
},
|
||||
};
|
||||
|
||||
const updatedSplitPanel = {
|
||||
...targetComponent,
|
||||
componentConfig: {
|
||||
...currentConfig,
|
||||
[panelKey]: {
|
||||
...panelConfig,
|
||||
displayMode: "custom",
|
||||
components: [...currentComponents, newPanelComponent],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let newLayout;
|
||||
|
||||
if (parentSplitId && parentSplitSide && parentTabsId && parentTabId) {
|
||||
// 분할패널 → 탭 → 분할패널 3중 중첩
|
||||
newLayout = {
|
||||
...layout,
|
||||
components: layout.components.map((c) => {
|
||||
if (c.id !== parentSplitId) return c;
|
||||
const sc = (c as any).componentConfig || {};
|
||||
const pk = parentSplitSide === "left" ? "leftPanel" : "rightPanel";
|
||||
return {
|
||||
...c,
|
||||
componentConfig: {
|
||||
...sc,
|
||||
[pk]: {
|
||||
...sc[pk],
|
||||
components: (sc[pk]?.components || []).map((pc: any) => {
|
||||
if (pc.id !== parentTabsId) return pc;
|
||||
return {
|
||||
...pc,
|
||||
componentConfig: {
|
||||
...pc.componentConfig,
|
||||
tabs: (pc.componentConfig?.tabs || []).map((tab: any) => {
|
||||
if (tab.id !== parentTabId) return tab;
|
||||
return {
|
||||
...tab,
|
||||
components: (tab.components || []).map((tc: any) =>
|
||||
tc.id === containerId ? updatedSplitPanel : tc,
|
||||
),
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
} else if (parentTabsId && parentTabId) {
|
||||
// 탭 → 분할패널 2중 중첩
|
||||
newLayout = {
|
||||
...layout,
|
||||
components: layout.components.map((c) => {
|
||||
if (c.id !== parentTabsId) return c;
|
||||
// top-level은 overrides, nested는 componentConfig
|
||||
const configKey = (c as any).componentConfig ? "componentConfig" : "overrides";
|
||||
const tabsConfig = (c as any)[configKey] || {};
|
||||
return {
|
||||
...c,
|
||||
[configKey]: {
|
||||
...tabsConfig,
|
||||
tabs: (tabsConfig.tabs || []).map((tab: any) => {
|
||||
if (tab.id !== parentTabId) return tab;
|
||||
return {
|
||||
...tab,
|
||||
components: (tab.components || []).map((tc: any) =>
|
||||
tc.id === containerId ? updatedSplitPanel : tc,
|
||||
),
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
// 최상위 분할패널
|
||||
newLayout = {
|
||||
...layout,
|
||||
components: layout.components.map((c) => (c.id === containerId ? updatedSplitPanel : c)),
|
||||
};
|
||||
}
|
||||
|
||||
toast.success("컬럼이 분할패널에 추가되었습니다");
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원)
|
||||
if (tabsContainer && !splitPanelFirst && type === "column" && column) {
|
||||
const containerId = tabsContainer.getAttribute("data-component-id");
|
||||
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
|
||||
if (containerId && activeTabId) {
|
||||
|
|
@ -3648,9 +3979,8 @@ export default function ScreenDesigner({
|
|||
}
|
||||
}
|
||||
|
||||
// 🎯 분할 패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리
|
||||
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
||||
if (splitPanelContainer && type === "column" && column) {
|
||||
// 🎯 분할 패널 커스텀 모드 (탭 밖 최상위) 컬럼 드롭 처리
|
||||
if (splitPanelContainer && !splitPanelFirst && type === "column" && column) {
|
||||
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
||||
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
|
||||
if (containerId && panelSide) {
|
||||
|
|
@ -3662,12 +3992,11 @@ export default function ScreenDesigner({
|
|||
const panelConfig = currentConfig[panelKey] || {};
|
||||
const currentComponents = panelConfig.components || [];
|
||||
|
||||
// 드롭 위치 계산
|
||||
const panelRect = splitPanelContainer.getBoundingClientRect();
|
||||
const dropX = (e.clientX - panelRect.left) / zoomLevel;
|
||||
const dropY = (e.clientY - panelRect.top) / zoomLevel;
|
||||
const cs2 = window.getComputedStyle(splitPanelContainer);
|
||||
const dropX = (e.clientX - panelRect.left - (parseFloat(cs2.paddingLeft) || 0)) / zoomLevel;
|
||||
const dropY = (e.clientY - panelRect.top - (parseFloat(cs2.paddingTop) || 0)) / zoomLevel;
|
||||
|
||||
// V2 컴포넌트 매핑 사용
|
||||
const v2Mapping = createV2ConfigFromColumn({
|
||||
widgetType: column.widgetType,
|
||||
columnName: column.columnName,
|
||||
|
|
@ -6415,15 +6744,6 @@ export default function ScreenDesigner({
|
|||
const { splitPanelId, panelSide } = selectedPanelComponentInfo;
|
||||
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||
|
||||
console.log("🔧 updatePanelComponentProperty 호출:", {
|
||||
componentId,
|
||||
path,
|
||||
value,
|
||||
splitPanelId,
|
||||
panelSide,
|
||||
});
|
||||
|
||||
// 🆕 안전한 깊은 경로 업데이트 헬퍼 함수
|
||||
const setNestedValue = (obj: any, pathStr: string, val: any): any => {
|
||||
const result = JSON.parse(JSON.stringify(obj));
|
||||
const parts = pathStr.split(".");
|
||||
|
|
@ -6440,9 +6760,27 @@ export default function ScreenDesigner({
|
|||
return result;
|
||||
};
|
||||
|
||||
// 중첩 구조 포함 분할패널 찾기 헬퍼
|
||||
const findSplitPanelInLayout = (components: any[]): { found: any; path: "top" | "nested"; parentTabId?: string; parentTabTabId?: string } | null => {
|
||||
const direct = components.find((c) => c.id === splitPanelId);
|
||||
if (direct) return { found: direct, path: "top" };
|
||||
for (const comp of components) {
|
||||
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||
const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
|
||||
for (const tab of (cfg.tabs || [])) {
|
||||
const nested = (tab.components || []).find((c: any) => c.id === splitPanelId);
|
||||
if (nested) return { found: nested, path: "nested", parentTabId: comp.id, parentTabTabId: tab.id };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
setLayout((prevLayout) => {
|
||||
const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId);
|
||||
if (!splitPanelComponent) return prevLayout;
|
||||
const result = findSplitPanelInLayout(prevLayout.components);
|
||||
if (!result) return prevLayout;
|
||||
const splitPanelComponent = result.found;
|
||||
|
||||
const currentConfig = (splitPanelComponent as any).componentConfig || {};
|
||||
const panelConfig = currentConfig[panelKey] || {};
|
||||
|
|
@ -6478,17 +6816,37 @@ export default function ScreenDesigner({
|
|||
},
|
||||
};
|
||||
|
||||
// selectedPanelComponentInfo 업데이트
|
||||
setSelectedPanelComponentInfo((prev) =>
|
||||
prev ? { ...prev, component: updatedComp } : null,
|
||||
);
|
||||
|
||||
return {
|
||||
...prevLayout,
|
||||
components: prevLayout.components.map((c) =>
|
||||
c.id === splitPanelId ? updatedComponent : c,
|
||||
),
|
||||
// 중첩 구조 반영
|
||||
const applyUpdatedSplitPanel = (layout: any, updated: any, info: any) => {
|
||||
if (info.path === "top") {
|
||||
return { ...layout, components: layout.components.map((c: any) => c.id === splitPanelId ? updated : c) };
|
||||
}
|
||||
return {
|
||||
...layout,
|
||||
components: layout.components.map((c: any) => {
|
||||
if (c.id !== info.parentTabId) return c;
|
||||
const cfgKey = c.componentConfig?.tabs ? "componentConfig" : "overrides";
|
||||
const cfg = c[cfgKey] || {};
|
||||
return {
|
||||
...c,
|
||||
[cfgKey]: {
|
||||
...cfg,
|
||||
tabs: (cfg.tabs || []).map((t: any) =>
|
||||
t.id === info.parentTabTabId
|
||||
? { ...t, components: (t.components || []).map((tc: any) => tc.id === splitPanelId ? updated : tc) }
|
||||
: t,
|
||||
),
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
return applyUpdatedSplitPanel(prevLayout, updatedComponent, result);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -6498,8 +6856,23 @@ export default function ScreenDesigner({
|
|||
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||
|
||||
setLayout((prevLayout) => {
|
||||
const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId);
|
||||
if (!splitPanelComponent) return prevLayout;
|
||||
const findResult = (() => {
|
||||
const direct = prevLayout.components.find((c: any) => c.id === splitPanelId);
|
||||
if (direct) return { found: direct, path: "top" as const };
|
||||
for (const comp of prevLayout.components) {
|
||||
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||
const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
|
||||
for (const tab of (cfg.tabs || [])) {
|
||||
const nested = (tab.components || []).find((c: any) => c.id === splitPanelId);
|
||||
if (nested) return { found: nested, path: "nested" as const, parentTabId: comp.id, parentTabTabId: tab.id };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
if (!findResult) return prevLayout;
|
||||
const splitPanelComponent = findResult.found;
|
||||
|
||||
const currentConfig = (splitPanelComponent as any).componentConfig || {};
|
||||
const panelConfig = currentConfig[panelKey] || {};
|
||||
|
|
@ -6520,11 +6893,27 @@ export default function ScreenDesigner({
|
|||
|
||||
setSelectedPanelComponentInfo(null);
|
||||
|
||||
if (findResult.path === "top") {
|
||||
return { ...prevLayout, components: prevLayout.components.map((c: any) => c.id === splitPanelId ? updatedComponent : c) };
|
||||
}
|
||||
return {
|
||||
...prevLayout,
|
||||
components: prevLayout.components.map((c) =>
|
||||
c.id === splitPanelId ? updatedComponent : c,
|
||||
),
|
||||
components: prevLayout.components.map((c: any) => {
|
||||
if (c.id !== findResult.parentTabId) return c;
|
||||
const cfgKey = c.componentConfig?.tabs ? "componentConfig" : "overrides";
|
||||
const cfg = c[cfgKey] || {};
|
||||
return {
|
||||
...c,
|
||||
[cfgKey]: {
|
||||
...cfg,
|
||||
tabs: (cfg.tabs || []).map((t: any) =>
|
||||
t.id === findResult.parentTabTabId
|
||||
? { ...t, components: (t.components || []).map((tc: any) => tc.id === splitPanelId ? updatedComponent : tc) }
|
||||
: t,
|
||||
),
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
|
@ -7128,6 +7517,7 @@ export default function ScreenDesigner({
|
|||
onSelectPanelComponent={(panelSide, compId, comp) =>
|
||||
handleSelectPanelComponent(component.id, panelSide, compId, comp)
|
||||
}
|
||||
onNestedPanelSelect={handleSelectPanelComponent}
|
||||
selectedPanelComponentId={
|
||||
selectedPanelComponentInfo?.splitPanelId === component.id
|
||||
? selectedPanelComponentInfo.componentId
|
||||
|
|
|
|||
|
|
@ -247,6 +247,9 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
extraProps.currentTableName = currentTableName;
|
||||
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||||
}
|
||||
if (componentId === "v2-input") {
|
||||
extraProps.allComponents = allComponents;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={selectedComponent.id} className="space-y-4">
|
||||
|
|
|
|||
|
|
@ -430,28 +430,28 @@ export function TabsWidget({
|
|||
|
||||
return (
|
||||
<ResponsiveGridRenderer
|
||||
components={componentDataList}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
renderComponent={(comp) => (
|
||||
<DynamicComponentRenderer
|
||||
{...restProps}
|
||||
component={comp}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
menuObjid={menuObjid}
|
||||
isDesignMode={false}
|
||||
isInteractive={true}
|
||||
selectedRowsData={localSelectedRowsData}
|
||||
onSelectedRowsChange={handleSelectedRowsChange}
|
||||
parentTabId={tab.id}
|
||||
parentTabsComponentId={component.id}
|
||||
{...(screenInfoMap[tab.id]
|
||||
? { tableName: screenInfoMap[tab.id].tableName, screenId: screenInfoMap[tab.id].id }
|
||||
: {})}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
components={componentDataList}
|
||||
canvasWidth={canvasWidth}
|
||||
canvasHeight={canvasHeight}
|
||||
renderComponent={(comp) => (
|
||||
<DynamicComponentRenderer
|
||||
{...restProps}
|
||||
component={comp}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
menuObjid={menuObjid}
|
||||
isDesignMode={false}
|
||||
isInteractive={true}
|
||||
selectedRowsData={localSelectedRowsData}
|
||||
onSelectedRowsChange={handleSelectedRowsChange}
|
||||
parentTabId={tab.id}
|
||||
parentTabsComponentId={component.id}
|
||||
{...(screenInfoMap[tab.id]
|
||||
? { tableName: screenInfoMap[tab.id].tableName, screenId: screenInfoMap[tab.id].id }
|
||||
: {})}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -19,10 +19,11 @@ import { NumberingRuleConfig } from "@/types/numbering-rule";
|
|||
interface V2InputConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
menuObjid?: number; // 메뉴 OBJID (채번 규칙 필터링용)
|
||||
menuObjid?: number;
|
||||
allComponents?: any[];
|
||||
}
|
||||
|
||||
export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config, onChange, menuObjid }) => {
|
||||
export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config, onChange, menuObjid, allComponents = [] }) => {
|
||||
// 채번 규칙 목록 상태
|
||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [loadingRules, setLoadingRules] = useState(false);
|
||||
|
|
@ -483,73 +484,202 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
|||
|
||||
{/* 데이터 바인딩 설정 */}
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="dataBindingEnabled"
|
||||
checked={!!config.dataBinding?.sourceComponentId}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
updateConfig("dataBinding", {
|
||||
sourceComponentId: config.dataBinding?.sourceComponentId || "",
|
||||
sourceColumn: config.dataBinding?.sourceColumn || "",
|
||||
});
|
||||
} else {
|
||||
updateConfig("dataBinding", undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="dataBindingEnabled" className="text-xs font-semibold">
|
||||
테이블 선택 데이터 바인딩
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{config.dataBinding && (
|
||||
<div className="space-y-2 rounded border p-2">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
v2-table-list에서 행 선택 시 해당 컬럼 값이 자동으로 채워집니다
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">소스 컴포넌트 ID</Label>
|
||||
<Input
|
||||
value={config.dataBinding?.sourceComponentId || ""}
|
||||
onChange={(e) => {
|
||||
updateConfig("dataBinding", {
|
||||
...config.dataBinding,
|
||||
sourceComponentId: e.target.value,
|
||||
});
|
||||
}}
|
||||
placeholder="예: tbl_items"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
같은 화면 내 v2-table-list 컴포넌트의 ID
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">소스 컬럼명</Label>
|
||||
<Input
|
||||
value={config.dataBinding?.sourceColumn || ""}
|
||||
onChange={(e) => {
|
||||
updateConfig("dataBinding", {
|
||||
...config.dataBinding,
|
||||
sourceColumn: e.target.value,
|
||||
});
|
||||
}}
|
||||
placeholder="예: item_number"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
선택된 행에서 가져올 컬럼명
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DataBindingSection config={config} onChange={onChange} allComponents={allComponents} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
V2InputConfigPanel.displayName = "V2InputConfigPanel";
|
||||
|
||||
/**
|
||||
* 데이터 바인딩 설정 섹션
|
||||
* 같은 화면의 v2-table-list 컴포넌트를 자동 감지하여 드롭다운으로 표시
|
||||
*/
|
||||
function DataBindingSection({
|
||||
config,
|
||||
onChange,
|
||||
allComponents,
|
||||
}: {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
allComponents: any[];
|
||||
}) {
|
||||
const [tableColumns, setTableColumns] = useState<string[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
|
||||
// 같은 화면의 v2-table-list 컴포넌트만 필터링
|
||||
const tableListComponents = React.useMemo(() => {
|
||||
return allComponents.filter((comp) => {
|
||||
const type =
|
||||
comp.componentType ||
|
||||
comp.widgetType ||
|
||||
comp.componentConfig?.type ||
|
||||
(comp.url && comp.url.split("/").pop());
|
||||
return type === "v2-table-list";
|
||||
});
|
||||
}, [allComponents]);
|
||||
|
||||
// 선택된 테이블 컴포넌트의 테이블명 추출
|
||||
const selectedTableComponent = React.useMemo(() => {
|
||||
if (!config.dataBinding?.sourceComponentId) return null;
|
||||
return tableListComponents.find((comp) => comp.id === config.dataBinding.sourceComponentId);
|
||||
}, [tableListComponents, config.dataBinding?.sourceComponentId]);
|
||||
|
||||
const selectedTableName = React.useMemo(() => {
|
||||
if (!selectedTableComponent) return null;
|
||||
return (
|
||||
selectedTableComponent.componentConfig?.selectedTable ||
|
||||
selectedTableComponent.selectedTable ||
|
||||
null
|
||||
);
|
||||
}, [selectedTableComponent]);
|
||||
|
||||
// 선택된 테이블의 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
if (!selectedTableName) {
|
||||
setTableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadColumns = async () => {
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const { tableTypeApi } = await import("@/lib/api/screen");
|
||||
const response = await tableTypeApi.getTableTypeColumns(selectedTableName);
|
||||
if (response.success && response.data) {
|
||||
const cols = response.data.map((col: any) => col.column_name).filter(Boolean);
|
||||
setTableColumns(cols);
|
||||
}
|
||||
} catch {
|
||||
// 컬럼 정보를 못 가져오면 테이블 컴포넌트의 columns에서 추출
|
||||
const configColumns = selectedTableComponent?.componentConfig?.columns;
|
||||
if (Array.isArray(configColumns)) {
|
||||
setTableColumns(configColumns.map((c: any) => c.columnName).filter(Boolean));
|
||||
}
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [selectedTableName, selectedTableComponent]);
|
||||
|
||||
const updateConfig = (field: string, value: any) => {
|
||||
onChange({ ...config, [field]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="dataBindingEnabled"
|
||||
checked={!!config.dataBinding?.sourceComponentId}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
const firstTable = tableListComponents[0];
|
||||
updateConfig("dataBinding", {
|
||||
sourceComponentId: firstTable?.id || "",
|
||||
sourceColumn: "",
|
||||
});
|
||||
} else {
|
||||
updateConfig("dataBinding", undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="dataBindingEnabled" className="text-xs font-semibold">
|
||||
테이블 선택 데이터 바인딩
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{config.dataBinding && (
|
||||
<div className="space-y-2 rounded border p-2">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
테이블에서 행 선택 시 해당 컬럼 값이 자동으로 채워집니다
|
||||
</p>
|
||||
|
||||
{/* 소스 테이블 컴포넌트 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">소스 테이블</Label>
|
||||
{tableListComponents.length === 0 ? (
|
||||
<p className="text-[10px] text-amber-500">이 화면에 v2-table-list 컴포넌트가 없습니다</p>
|
||||
) : (
|
||||
<Select
|
||||
value={config.dataBinding?.sourceComponentId || ""}
|
||||
onValueChange={(value) => {
|
||||
updateConfig("dataBinding", {
|
||||
...config.dataBinding,
|
||||
sourceComponentId: value,
|
||||
sourceColumn: "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableListComponents.map((comp) => {
|
||||
const tblName =
|
||||
comp.componentConfig?.selectedTable || comp.selectedTable || "";
|
||||
const label = comp.componentConfig?.label || comp.label || comp.id;
|
||||
return (
|
||||
<SelectItem key={comp.id} value={comp.id}>
|
||||
{label} ({tblName || comp.id})
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 소스 컬럼 선택 */}
|
||||
{config.dataBinding?.sourceComponentId && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">가져올 컬럼</Label>
|
||||
{loadingColumns ? (
|
||||
<p className="text-[10px] text-muted-foreground">컬럼 로딩 중...</p>
|
||||
) : tableColumns.length === 0 ? (
|
||||
<>
|
||||
<Input
|
||||
value={config.dataBinding?.sourceColumn || ""}
|
||||
onChange={(e) => {
|
||||
updateConfig("dataBinding", {
|
||||
...config.dataBinding,
|
||||
sourceColumn: e.target.value,
|
||||
});
|
||||
}}
|
||||
placeholder="컬럼명 직접 입력"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">컬럼 정보를 불러올 수 없어 직접 입력</p>
|
||||
</>
|
||||
) : (
|
||||
<Select
|
||||
value={config.dataBinding?.sourceColumn || ""}
|
||||
onValueChange={(value) => {
|
||||
updateConfig("dataBinding", {
|
||||
...config.dataBinding,
|
||||
sourceColumn: value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col} value={col}>
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default V2InputConfigPanel;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { apiClient } from "./client";
|
|||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
company_code: string;
|
||||
company_name: string | null;
|
||||
user_id: string;
|
||||
user_name: string | null;
|
||||
action: string;
|
||||
|
|
|
|||
|
|
@ -235,6 +235,8 @@ export interface DynamicComponentRendererProps {
|
|||
// 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void;
|
||||
selectedPanelComponentId?: string;
|
||||
// 중첩된 분할패널 내부 컴포넌트 선택 콜백 (탭 안의 분할패널)
|
||||
onNestedPanelSelect?: (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => void;
|
||||
flowSelectedStepId?: number | null;
|
||||
onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
|
||||
// 테이블 새로고침 키
|
||||
|
|
@ -868,6 +870,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||
onSelectPanelComponent: props.onSelectPanelComponent,
|
||||
selectedPanelComponentId: props.selectedPanelComponentId,
|
||||
onNestedPanelSelect: props.onNestedPanelSelect,
|
||||
};
|
||||
|
||||
// 렌더러가 클래스인지 함수인지 확인
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import {
|
|||
GeneratedLocation,
|
||||
RackStructureContext,
|
||||
} from "./types";
|
||||
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN } from "./patternUtils";
|
||||
|
||||
// 기존 위치 데이터 타입
|
||||
interface ExistingLocation {
|
||||
|
|
@ -513,27 +512,23 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
return { totalLocations, totalRows, maxLevel };
|
||||
}, [conditions]);
|
||||
|
||||
// 위치 코드 생성 (패턴 기반)
|
||||
// 위치 코드 생성
|
||||
const generateLocationCode = useCallback(
|
||||
(row: number, level: number): { code: string; name: string } => {
|
||||
const vars = {
|
||||
warehouse: context?.warehouseCode || "WH001",
|
||||
warehouseName: context?.warehouseName || "",
|
||||
floor: context?.floor || "1",
|
||||
zone: context?.zone || "A",
|
||||
row,
|
||||
level,
|
||||
};
|
||||
const warehouseCode = context?.warehouseCode || "WH001";
|
||||
const floor = context?.floor || "1";
|
||||
const zone = context?.zone || "A";
|
||||
|
||||
const codePattern = config.codePattern || DEFAULT_CODE_PATTERN;
|
||||
const namePattern = config.namePattern || DEFAULT_NAME_PATTERN;
|
||||
// 코드 생성 (예: WH001-1층D구역-01-1)
|
||||
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
|
||||
|
||||
return {
|
||||
code: applyLocationPattern(codePattern, vars),
|
||||
name: applyLocationPattern(namePattern, vars),
|
||||
};
|
||||
// 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용
|
||||
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
|
||||
const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`;
|
||||
|
||||
return { code, name };
|
||||
},
|
||||
[context, config.codePattern, config.namePattern],
|
||||
[context],
|
||||
);
|
||||
|
||||
// 미리보기 생성
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
|
@ -12,47 +12,6 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RackStructureComponentConfig, FieldMapping } from "./types";
|
||||
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN, PATTERN_VARIABLES } from "./patternUtils";
|
||||
|
||||
// 패턴 미리보기 서브 컴포넌트
|
||||
const PatternPreview: React.FC<{
|
||||
codePattern?: string;
|
||||
namePattern?: string;
|
||||
}> = ({ codePattern, namePattern }) => {
|
||||
const sampleVars = {
|
||||
warehouse: "WH002",
|
||||
warehouseName: "2창고",
|
||||
floor: "2층",
|
||||
zone: "A구역",
|
||||
row: 1,
|
||||
level: 3,
|
||||
};
|
||||
|
||||
const previewCode = useMemo(
|
||||
() => applyLocationPattern(codePattern || DEFAULT_CODE_PATTERN, sampleVars),
|
||||
[codePattern],
|
||||
);
|
||||
const previewName = useMemo(
|
||||
() => applyLocationPattern(namePattern || DEFAULT_NAME_PATTERN, sampleVars),
|
||||
[namePattern],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-primary/20 bg-primary/5 p-2.5">
|
||||
<div className="mb-1.5 text-[10px] font-medium text-primary">미리보기 (2창고 / 2층 / A구역 / 1열 / 3단)</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-14 shrink-0 text-muted-foreground">위치코드:</span>
|
||||
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground">{previewCode}</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-14 shrink-0 text-muted-foreground">위치명:</span>
|
||||
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground">{previewName}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface RackStructureConfigPanelProps {
|
||||
config: RackStructureComponentConfig;
|
||||
|
|
@ -246,61 +205,6 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 위치코드 패턴 설정 */}
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="text-sm font-medium text-foreground">위치코드/위치명 패턴</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
변수를 조합하여 위치코드와 위치명 생성 규칙을 설정하세요
|
||||
</p>
|
||||
|
||||
{/* 위치코드 패턴 */}
|
||||
<div>
|
||||
<Label className="text-xs">위치코드 패턴</Label>
|
||||
<Input
|
||||
value={config.codePattern || ""}
|
||||
onChange={(e) => handleChange("codePattern", e.target.value || undefined)}
|
||||
placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}"
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
비워두면 기본값: {"{warehouse}-{floor}{zone}-{row:02}-{level}"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 위치명 패턴 */}
|
||||
<div>
|
||||
<Label className="text-xs">위치명 패턴</Label>
|
||||
<Input
|
||||
value={config.namePattern || ""}
|
||||
onChange={(e) => handleChange("namePattern", e.target.value || undefined)}
|
||||
placeholder="{zone}-{row:02}열-{level}단"
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
비워두면 기본값: {"{zone}-{row:02}열-{level}단"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 실시간 미리보기 */}
|
||||
<PatternPreview
|
||||
codePattern={config.codePattern}
|
||||
namePattern={config.namePattern}
|
||||
/>
|
||||
|
||||
{/* 사용 가능한 변수 목록 */}
|
||||
<div className="rounded-md border bg-muted/50 p-2">
|
||||
<div className="mb-1 text-[10px] font-medium text-foreground">사용 가능한 변수</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5">
|
||||
{PATTERN_VARIABLES.map((v) => (
|
||||
<div key={v.token} className="flex items-center gap-1 text-[10px]">
|
||||
<code className="rounded bg-primary/10 px-1 font-mono text-primary">{v.token}</code>
|
||||
<span className="text-muted-foreground">{v.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제한 설정 */}
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="text-sm font-medium text-foreground">제한 설정</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
// rack-structure는 v2-rack-structure의 patternUtils를 재사용
|
||||
export {
|
||||
applyLocationPattern,
|
||||
DEFAULT_CODE_PATTERN,
|
||||
DEFAULT_NAME_PATTERN,
|
||||
PATTERN_VARIABLES,
|
||||
} from "../v2-rack-structure/patternUtils";
|
||||
|
|
@ -5,71 +5,45 @@ import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponent
|
|||
import { V2InputDefinition } from "./index";
|
||||
import { V2Input } from "@/components/v2/V2Input";
|
||||
import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer";
|
||||
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
||||
|
||||
/**
|
||||
* dataBinding이 설정된 v2-input을 위한 wrapper
|
||||
* v2-table-list의 TABLE_DATA_CHANGE 이벤트를 구독하여
|
||||
* v2-table-list의 선택 이벤트를 window CustomEvent로 수신하여
|
||||
* 선택된 행의 특정 컬럼 값을 자동으로 formData에 반영
|
||||
*/
|
||||
function DataBindingWrapper({
|
||||
dataBinding,
|
||||
columnName,
|
||||
onFormDataChange,
|
||||
isInteractive,
|
||||
children,
|
||||
}: {
|
||||
dataBinding: { sourceComponentId: string; sourceColumn: string };
|
||||
columnName: string;
|
||||
onFormDataChange?: (field: string, value: any) => void;
|
||||
isInteractive?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const lastBoundValueRef = useRef<any>(null);
|
||||
const onFormDataChangeRef = useRef(onFormDataChange);
|
||||
onFormDataChangeRef.current = onFormDataChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!dataBinding?.sourceComponentId || !dataBinding?.sourceColumn) return;
|
||||
|
||||
console.log("[DataBinding] 구독 시작:", {
|
||||
sourceComponentId: dataBinding.sourceComponentId,
|
||||
sourceColumn: dataBinding.sourceColumn,
|
||||
targetColumn: columnName,
|
||||
isInteractive,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
});
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail || detail.source !== dataBinding.sourceComponentId) return;
|
||||
|
||||
const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_DATA_CHANGE, (payload: any) => {
|
||||
console.log("[DataBinding] TABLE_DATA_CHANGE 수신:", {
|
||||
payloadSource: payload.source,
|
||||
expectedSource: dataBinding.sourceComponentId,
|
||||
dataLength: payload.data?.length,
|
||||
match: payload.source === dataBinding.sourceComponentId,
|
||||
});
|
||||
|
||||
if (payload.source !== dataBinding.sourceComponentId) return;
|
||||
|
||||
const selectedData = payload.data;
|
||||
if (selectedData && selectedData.length > 0) {
|
||||
const value = selectedData[0][dataBinding.sourceColumn];
|
||||
console.log("[DataBinding] 바인딩 값:", { column: dataBinding.sourceColumn, value, columnName });
|
||||
if (value !== lastBoundValueRef.current) {
|
||||
lastBoundValueRef.current = value;
|
||||
if (onFormDataChange && columnName) {
|
||||
onFormDataChange(columnName, value ?? "");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (lastBoundValueRef.current !== null) {
|
||||
lastBoundValueRef.current = null;
|
||||
if (onFormDataChange && columnName) {
|
||||
onFormDataChange(columnName, "");
|
||||
}
|
||||
}
|
||||
const selectedRow = detail.data?.[0];
|
||||
const value = selectedRow?.[dataBinding.sourceColumn] ?? "";
|
||||
if (value !== lastBoundValueRef.current) {
|
||||
lastBoundValueRef.current = value;
|
||||
onFormDataChangeRef.current?.(columnName, value);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [dataBinding?.sourceComponentId, dataBinding?.sourceColumn, columnName, onFormDataChange, isInteractive]);
|
||||
window.addEventListener("v2-table-selection", handler);
|
||||
return () => window.removeEventListener("v2-table-selection", handler);
|
||||
}, [dataBinding?.sourceComponentId, dataBinding?.sourceColumn, columnName]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -102,18 +76,6 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
|
|||
|
||||
const dataBinding = config.dataBinding || (component as any).dataBinding || config.componentConfig?.dataBinding;
|
||||
|
||||
if (dataBinding || (config as any).dataBinding || (component as any).dataBinding) {
|
||||
console.log("[V2InputRenderer] dataBinding 탐색:", {
|
||||
componentId: component.id,
|
||||
columnName,
|
||||
configKeys: Object.keys(config),
|
||||
configDataBinding: config.dataBinding,
|
||||
componentDataBinding: (component as any).dataBinding,
|
||||
nestedDataBinding: config.componentConfig?.dataBinding,
|
||||
finalDataBinding: dataBinding,
|
||||
});
|
||||
}
|
||||
|
||||
const inputElement = (
|
||||
<V2Input
|
||||
id={component.id}
|
||||
|
|
@ -153,7 +115,6 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
|
|||
dataBinding={dataBinding}
|
||||
columnName={columnName}
|
||||
onFormDataChange={onFormDataChange}
|
||||
isInteractive={isInteractive}
|
||||
>
|
||||
{inputElement}
|
||||
</DataBindingWrapper>
|
||||
|
|
|
|||
|
|
@ -96,12 +96,12 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="relative rounded-lg border border-border bg-white shadow-sm">
|
||||
<div className="border-border relative rounded-lg border bg-white shadow-sm">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between rounded-t-lg bg-primary px-4 py-2 text-white">
|
||||
<div className="bg-primary flex items-center justify-between rounded-t-lg px-4 py-2 text-white">
|
||||
<span className="font-medium">조건 {index + 1}</span>
|
||||
{!readonly && (
|
||||
<button onClick={() => onRemove(condition.id)} className="rounded p-1 transition-colors hover:bg-primary/90">
|
||||
<button onClick={() => onRemove(condition.id)} className="hover:bg-primary/90 rounded p-1 transition-colors">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -112,7 +112,7 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
|
|||
{/* 열 범위 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs font-medium text-foreground">
|
||||
<label className="text-foreground mb-1 block text-xs font-medium">
|
||||
열 범위 <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -140,7 +140,7 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
<div className="w-20">
|
||||
<label className="mb-1 block text-xs font-medium text-foreground">
|
||||
<label className="text-foreground mb-1 block text-xs font-medium">
|
||||
단 수 <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
|
|
@ -157,7 +157,7 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 계산 결과 */}
|
||||
<div className="rounded-md bg-primary/10 px-3 py-2 text-center text-sm text-primary">
|
||||
<div className="bg-primary/10 text-primary rounded-md px-3 py-2 text-center text-sm">
|
||||
{locationCount > 0 ? (
|
||||
<>
|
||||
{localValues.startRow}열 ~ {localValues.endRow}열 x {localValues.levels}단 ={" "}
|
||||
|
|
@ -627,7 +627,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<div className="h-4 w-1 rounded bg-gradient-to-b from-green-500 to-primary/50" />렉 라인 구조 설정
|
||||
<div className="to-primary/50 h-4 w-1 rounded bg-gradient-to-b from-green-500" />렉 라인 구조 설정
|
||||
</CardTitle>
|
||||
{!readonly && (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -720,8 +720,8 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
|
||||
{/* 기존 데이터 존재 알림 */}
|
||||
{!isCheckingDuplicates && existingLocations.length > 0 && !hasDuplicateWithExisting && (
|
||||
<Alert className="mb-4 border-primary/20 bg-primary/10">
|
||||
<AlertCircle className="h-4 w-4 text-primary" />
|
||||
<Alert className="border-primary/20 bg-primary/10 mb-4">
|
||||
<AlertCircle className="text-primary h-4 w-4" />
|
||||
<AlertDescription className="text-primary">
|
||||
해당 창고/층/구역에 <strong>{existingLocations.length}개</strong>의 위치가 이미 등록되어 있습니다.
|
||||
</AlertDescription>
|
||||
|
|
@ -730,9 +730,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
|
||||
{/* 현재 매핑된 값 표시 */}
|
||||
{(context.warehouseCode || context.warehouseName || context.floor || context.zone) && (
|
||||
<div className="mb-4 flex flex-wrap gap-2 rounded-lg bg-muted p-3">
|
||||
<div className="bg-muted mb-4 flex flex-wrap gap-2 rounded-lg p-3">
|
||||
{(context.warehouseCode || context.warehouseName) && (
|
||||
<span className="rounded bg-primary/10 px-2 py-1 text-xs text-primary">
|
||||
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs">
|
||||
창고: {context.warehouseName || context.warehouseCode}
|
||||
{context.warehouseName && context.warehouseCode && ` (${context.warehouseCode})`}
|
||||
</span>
|
||||
|
|
@ -749,28 +749,28 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
</span>
|
||||
)}
|
||||
{context.status && (
|
||||
<span className="rounded bg-muted/80 px-2 py-1 text-xs text-foreground">상태: {context.status}</span>
|
||||
<span className="bg-muted/80 text-foreground rounded px-2 py-1 text-xs">상태: {context.status}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
<div className="mb-4 rounded-lg bg-primary/10 p-4">
|
||||
<ol className="space-y-1 text-sm text-primary">
|
||||
<div className="bg-primary/10 mb-4 rounded-lg p-4">
|
||||
<ol className="text-primary space-y-1 text-sm">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary text-xs font-bold text-white">
|
||||
<span className="bg-primary flex h-5 w-5 shrink-0 items-center justify-center rounded text-xs font-bold text-white">
|
||||
1
|
||||
</span>
|
||||
조건 추가 버튼을 클릭하여 렉 라인 조건을 생성하세요
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary text-xs font-bold text-white">
|
||||
<span className="bg-primary flex h-5 w-5 shrink-0 items-center justify-center rounded text-xs font-bold text-white">
|
||||
2
|
||||
</span>
|
||||
각 조건마다 열 범위와 단 수를 입력하세요
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary text-xs font-bold text-white">
|
||||
<span className="bg-primary flex h-5 w-5 shrink-0 items-center justify-center rounded text-xs font-bold text-white">
|
||||
3
|
||||
</span>
|
||||
예시: 조건1(1~3열, 3단), 조건2(4~6열, 5단)
|
||||
|
|
@ -780,9 +780,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
|
||||
{/* 조건 목록 또는 빈 상태 */}
|
||||
{conditions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border py-12">
|
||||
<div className="mb-4 text-6xl text-muted-foreground/50">📦</div>
|
||||
<p className="mb-4 text-muted-foreground">조건을 추가하여 렉 구조를 설정하세요</p>
|
||||
<div className="border-border flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-12">
|
||||
<div className="text-muted-foreground/50 mb-4 text-6xl">📦</div>
|
||||
<p className="text-muted-foreground mb-4">조건을 추가하여 렉 구조를 설정하세요</p>
|
||||
{!readonly && (
|
||||
<Button onClick={addCondition} className="gap-1">
|
||||
<Plus className="h-4 w-4" />첫 번째 조건 추가
|
||||
|
|
@ -833,15 +833,15 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
{config.showStatistics && (
|
||||
<div className="mb-4 grid grid-cols-3 gap-4">
|
||||
<div className="rounded-lg border bg-white p-4 text-center">
|
||||
<div className="text-sm text-muted-foreground">총 위치</div>
|
||||
<div className="text-muted-foreground text-sm">총 위치</div>
|
||||
<div className="text-2xl font-bold">{statistics.totalLocations}개</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-white p-4 text-center">
|
||||
<div className="text-sm text-muted-foreground">열 수</div>
|
||||
<div className="text-muted-foreground text-sm">열 수</div>
|
||||
<div className="text-2xl font-bold">{statistics.totalRows}개</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-white p-4 text-center">
|
||||
<div className="text-sm text-muted-foreground">최대 단</div>
|
||||
<div className="text-muted-foreground text-sm">최대 단</div>
|
||||
<div className="text-2xl font-bold">{statistics.maxLevel}단</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -852,7 +852,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
<div className="rounded-lg border">
|
||||
<ScrollArea className="h-[400px]">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted">
|
||||
<TableHeader className="bg-muted sticky top-0">
|
||||
<TableRow>
|
||||
<TableHead className="w-12 text-center">No</TableHead>
|
||||
<TableHead>위치코드</TableHead>
|
||||
|
|
@ -884,8 +884,8 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
</ScrollArea>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border py-8 text-muted-foreground">
|
||||
<Eye className="mb-2 h-8 w-8 text-muted-foreground/50" />
|
||||
<div className="border-border text-muted-foreground flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-8">
|
||||
<Eye className="text-muted-foreground/50 mb-2 h-8 w-8" />
|
||||
<p>미리보기 생성 버튼을 클릭하여 결과를 확인하세요</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -932,16 +932,16 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
{/* 템플릿 목록 */}
|
||||
{templates.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-foreground">저장된 템플릿</div>
|
||||
<div className="text-foreground text-sm font-medium">저장된 템플릿</div>
|
||||
<ScrollArea className="h-[200px]">
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted"
|
||||
className="hover:bg-muted flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{template.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{template.conditions.length}개 조건</div>
|
||||
<div className="text-muted-foreground text-xs">{template.conditions.length}개 조건</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => loadTemplate(template)}>
|
||||
|
|
@ -956,7 +956,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
|||
</ScrollArea>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-muted-foreground">저장된 템플릿이 없습니다</div>
|
||||
<div className="text-muted-foreground py-8 text-center">저장된 템플릿이 없습니다</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { RackStructureComponentConfig, FieldMapping, FormatSegment } from "./types";
|
||||
import { defaultFormatConfig, SAMPLE_VALUES } from "./config";
|
||||
import { FormatSegmentEditor } from "./FormatSegmentEditor";
|
||||
|
|
@ -36,9 +30,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
|||
tables = [],
|
||||
}) => {
|
||||
// 사용 가능한 컬럼 목록 추출
|
||||
const [availableColumns, setAvailableColumns] = useState<
|
||||
Array<{ value: string; label: string }>
|
||||
>([]);
|
||||
const [availableColumns, setAvailableColumns] = useState<Array<{ value: string; label: string }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// 모든 테이블의 컬럼을 플랫하게 추출
|
||||
|
|
@ -73,10 +65,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
|||
|
||||
const formatConfig = config.formatConfig || defaultFormatConfig;
|
||||
|
||||
const handleFormatChange = (
|
||||
key: "codeSegments" | "nameSegments",
|
||||
segments: FormatSegment[],
|
||||
) => {
|
||||
const handleFormatChange = (key: "codeSegments" | "nameSegments", segments: FormatSegment[]) => {
|
||||
onChange({
|
||||
...config,
|
||||
formatConfig: {
|
||||
|
|
@ -90,10 +79,8 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
|||
<div className="space-y-4">
|
||||
{/* 필드 매핑 섹션 */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-foreground">필드 매핑</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
상위 폼에 배치된 필드 중 어떤 필드를 사용할지 선택하세요
|
||||
</p>
|
||||
<div className="text-foreground text-sm font-medium">필드 매핑</div>
|
||||
<p className="text-muted-foreground text-xs">상위 폼에 배치된 필드 중 어떤 필드를 사용할지 선택하세요</p>
|
||||
|
||||
{/* 창고 코드 필드 */}
|
||||
<div>
|
||||
|
|
@ -222,64 +209,9 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 위치코드 패턴 설정 */}
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="text-sm font-medium text-foreground">위치코드/위치명 패턴</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
변수를 조합하여 위치코드와 위치명 생성 규칙을 설정하세요
|
||||
</p>
|
||||
|
||||
{/* 위치코드 패턴 */}
|
||||
<div>
|
||||
<Label className="text-xs">위치코드 패턴</Label>
|
||||
<Input
|
||||
value={config.codePattern || ""}
|
||||
onChange={(e) => handleChange("codePattern", e.target.value || undefined)}
|
||||
placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}"
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
비워두면 기본값: {"{warehouse}-{floor}{zone}-{row:02}-{level}"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 위치명 패턴 */}
|
||||
<div>
|
||||
<Label className="text-xs">위치명 패턴</Label>
|
||||
<Input
|
||||
value={config.namePattern || ""}
|
||||
onChange={(e) => handleChange("namePattern", e.target.value || undefined)}
|
||||
placeholder="{zone}-{row:02}열-{level}단"
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
비워두면 기본값: {"{zone}-{row:02}열-{level}단"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 실시간 미리보기 */}
|
||||
<PatternPreview
|
||||
codePattern={config.codePattern}
|
||||
namePattern={config.namePattern}
|
||||
/>
|
||||
|
||||
{/* 사용 가능한 변수 목록 */}
|
||||
<div className="rounded-md border bg-muted/50 p-2">
|
||||
<div className="mb-1 text-[10px] font-medium text-foreground">사용 가능한 변수</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5">
|
||||
{PATTERN_VARIABLES.map((v) => (
|
||||
<div key={v.token} className="flex items-center gap-1 text-[10px]">
|
||||
<code className="rounded bg-primary/10 px-1 font-mono text-primary">{v.token}</code>
|
||||
<span className="text-muted-foreground">{v.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 제한 설정 */}
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="text-sm font-medium text-foreground">제한 설정</div>
|
||||
<div className="text-foreground text-sm font-medium">제한 설정</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">최대 조건 수</Label>
|
||||
|
|
@ -320,7 +252,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
|||
|
||||
{/* UI 설정 */}
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="text-sm font-medium text-foreground">UI 설정</div>
|
||||
<div className="text-foreground text-sm font-medium">UI 설정</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">템플릿 기능</Label>
|
||||
|
|
@ -348,10 +280,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
|||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">읽기 전용</Label>
|
||||
<Switch
|
||||
checked={config.readonly ?? false}
|
||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||
/>
|
||||
<Switch checked={config.readonly ?? false} onCheckedChange={(checked) => handleChange("readonly", checked)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -359,8 +288,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
|||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="text-sm font-medium text-gray-700">포맷 설정</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고,
|
||||
구분자/라벨을 편집할 수 있습니다
|
||||
위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고, 구분자/라벨을 편집할 수 있습니다
|
||||
</p>
|
||||
|
||||
<FormatSegmentEditor
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
/**
|
||||
* 위치코드/위치명 패턴 변환 유틸리티
|
||||
*
|
||||
* 사용 가능한 변수:
|
||||
* {warehouse} - 창고 코드 (예: WH002)
|
||||
* {warehouseName} - 창고명 (예: 2창고)
|
||||
* {floor} - 층 (예: 2층)
|
||||
* {zone} - 구역 (예: A구역)
|
||||
* {row} - 열 번호 (예: 1)
|
||||
* {row:02} - 열 번호 2자리 (예: 01)
|
||||
* {row:03} - 열 번호 3자리 (예: 001)
|
||||
* {level} - 단 번호 (예: 1)
|
||||
* {level:02} - 단 번호 2자리 (예: 01)
|
||||
* {level:03} - 단 번호 3자리 (예: 001)
|
||||
*/
|
||||
|
||||
interface PatternVariables {
|
||||
warehouse?: string;
|
||||
warehouseName?: string;
|
||||
floor?: string;
|
||||
zone?: string;
|
||||
row: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
// 기본 패턴 (하드코딩 대체)
|
||||
export const DEFAULT_CODE_PATTERN = "{warehouse}-{floor}{zone}-{row:02}-{level}";
|
||||
export const DEFAULT_NAME_PATTERN = "{zone}-{row:02}열-{level}단";
|
||||
|
||||
/**
|
||||
* 패턴 문자열에서 변수를 치환하여 결과 문자열 반환
|
||||
*/
|
||||
export function applyLocationPattern(pattern: string, vars: PatternVariables): string {
|
||||
let result = pattern;
|
||||
|
||||
// zone에 "구역" 포함 여부에 따른 처리 없이 있는 그대로 치환
|
||||
const simpleVars: Record<string, string | undefined> = {
|
||||
warehouse: vars.warehouse,
|
||||
warehouseName: vars.warehouseName,
|
||||
floor: vars.floor,
|
||||
zone: vars.zone,
|
||||
};
|
||||
|
||||
// 단순 문자열 변수 치환
|
||||
for (const [key, value] of Object.entries(simpleVars)) {
|
||||
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value || "");
|
||||
}
|
||||
|
||||
// 숫자 변수 (row, level) - zero-pad 지원
|
||||
const numericVars: Record<string, number> = {
|
||||
row: vars.row,
|
||||
level: vars.level,
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(numericVars)) {
|
||||
// {row:02}, {level:03} 같은 zero-pad 패턴
|
||||
const padRegex = new RegExp(`\\{${key}:(\\d+)\\}`, "g");
|
||||
result = result.replace(padRegex, (_, padWidth) => {
|
||||
return value.toString().padStart(parseInt(padWidth), "0");
|
||||
});
|
||||
|
||||
// {row}, {level} 같은 단순 패턴
|
||||
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value.toString());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 패턴에서 사용 가능한 변수 목록
|
||||
export const PATTERN_VARIABLES = [
|
||||
{ token: "{warehouse}", description: "창고 코드", example: "WH002" },
|
||||
{ token: "{warehouseName}", description: "창고명", example: "2창고" },
|
||||
{ token: "{floor}", description: "층", example: "2층" },
|
||||
{ token: "{zone}", description: "구역", example: "A구역" },
|
||||
{ token: "{row}", description: "열 번호", example: "1" },
|
||||
{ token: "{row:02}", description: "열 번호 (2자리)", example: "01" },
|
||||
{ token: "{row:03}", description: "열 번호 (3자리)", example: "001" },
|
||||
{ token: "{level}", description: "단 번호", example: "1" },
|
||||
{ token: "{level:02}", description: "단 번호 (2자리)", example: "01" },
|
||||
{ token: "{level:03}", description: "단 번호 (3자리)", example: "001" },
|
||||
];
|
||||
|
|
@ -91,6 +91,103 @@ const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value })
|
|||
});
|
||||
SplitPanelCellImage.displayName = "SplitPanelCellImage";
|
||||
|
||||
/**
|
||||
* 커스텀 모드 런타임: 디자이너 좌표를 비례 스케일링하여 렌더링
|
||||
*/
|
||||
const ScaledCustomPanel: React.FC<{
|
||||
components: PanelInlineComponent[];
|
||||
formData: Record<string, any>;
|
||||
onFormDataChange: (fieldName: string, value: any) => void;
|
||||
tableName?: string;
|
||||
menuObjid?: number;
|
||||
screenId?: number;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
companyCode?: string;
|
||||
allComponents?: any;
|
||||
selectedRowsData?: any[];
|
||||
onSelectedRowsChange?: any;
|
||||
}> = ({ components, formData, onFormDataChange, tableName, ...restProps }) => {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width;
|
||||
if (w && w > 0) setContainerWidth(w);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const canvasW = Math.max(
|
||||
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
|
||||
400,
|
||||
);
|
||||
const canvasH = Math.max(
|
||||
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
|
||||
200,
|
||||
);
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full" style={{ height: `${canvasH}px` }}>
|
||||
{containerWidth > 0 &&
|
||||
components.map((comp) => {
|
||||
const x = comp.position?.x || 0;
|
||||
const y = comp.position?.y || 0;
|
||||
const w = comp.size?.width || 200;
|
||||
const h = comp.size?.height || 36;
|
||||
|
||||
const componentData = {
|
||||
id: comp.id,
|
||||
type: "component" as const,
|
||||
componentType: comp.componentType,
|
||||
label: comp.label,
|
||||
position: { x, y },
|
||||
size: { width: undefined, height: h },
|
||||
componentConfig: comp.componentConfig || {},
|
||||
style: { ...(comp.style || {}), width: "100%", height: "100%" },
|
||||
tableName: comp.componentConfig?.tableName,
|
||||
columnName: comp.componentConfig?.columnName,
|
||||
webType: comp.componentConfig?.webType,
|
||||
inputType: (comp as any).inputType || comp.componentConfig?.inputType,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: `${(x / canvasW) * 100}%`,
|
||||
top: `${y}px`,
|
||||
width: `${(w / canvasW) * 100}%`,
|
||||
minHeight: `${h}px`,
|
||||
}}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={componentData as any}
|
||||
isDesignMode={false}
|
||||
isInteractive={true}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
tableName={tableName}
|
||||
menuObjid={restProps.menuObjid}
|
||||
screenId={restProps.screenId}
|
||||
userId={restProps.userId}
|
||||
userName={restProps.userName}
|
||||
companyCode={restProps.companyCode}
|
||||
allComponents={restProps.allComponents}
|
||||
selectedRowsData={restProps.selectedRowsData}
|
||||
onSelectedRowsChange={restProps.onSelectedRowsChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* SplitPanelLayout 컴포넌트
|
||||
* 마스터-디테일 패턴의 좌우 분할 레이아웃
|
||||
|
|
@ -271,8 +368,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [resizingCompId, setResizingCompId] = useState<string | null>(null);
|
||||
const [resizeSize, setResizeSize] = useState<{ width: number; height: number } | null>(null);
|
||||
// 🆕 외부에서 전달받은 선택 상태 사용 (탭 컴포넌트와 동일 구조)
|
||||
const selectedPanelComponentId = externalSelectedPanelComponentId || null;
|
||||
// 내부 선택 상태 (외부 prop 없을 때 fallback)
|
||||
const [internalSelectedCompId, setInternalSelectedCompId] = useState<string | null>(null);
|
||||
const selectedPanelComponentId = externalSelectedPanelComponentId ?? internalSelectedCompId;
|
||||
// 🆕 커스텀 모드: 분할패널 내 탭 컴포넌트의 선택 상태 관리
|
||||
const [nestedTabSelectedCompId, setNestedTabSelectedCompId] = useState<string | undefined>(undefined);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
|
@ -719,22 +817,21 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}, [leftData, leftGroupSumConfig]);
|
||||
|
||||
// 컴포넌트 스타일
|
||||
// height 처리: 이미 px 단위면 그대로, 숫자면 px 추가
|
||||
// height: component.size?.height 우선, 없으면 component.style?.height, 기본 600px
|
||||
const getHeightValue = () => {
|
||||
const sizeH = component.size?.height;
|
||||
if (sizeH && typeof sizeH === "number" && sizeH > 0) return `${sizeH}px`;
|
||||
const height = component.style?.height;
|
||||
if (!height) return "600px";
|
||||
if (typeof height === "string") return height; // 이미 '540px' 형태
|
||||
return `${height}px`; // 숫자면 px 추가
|
||||
if (typeof height === "string") return height;
|
||||
return `${height}px`;
|
||||
};
|
||||
|
||||
const componentStyle: React.CSSProperties = isDesignMode
|
||||
? {
|
||||
position: "absolute",
|
||||
left: `${component.style?.positionX || 0}px`,
|
||||
top: `${component.style?.positionY || 0}px`,
|
||||
width: "100%",
|
||||
height: getHeightValue(),
|
||||
zIndex: component.style?.positionZ || 1,
|
||||
height: "100%",
|
||||
minHeight: getHeightValue(),
|
||||
cursor: "pointer",
|
||||
border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb",
|
||||
}
|
||||
|
|
@ -3144,9 +3241,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="flex-1 overflow-auto p-4">
|
||||
<CardContent
|
||||
className="flex-1 overflow-auto p-4"
|
||||
{...(isDesignMode ? {
|
||||
"data-split-panel-container": "true",
|
||||
"data-component-id": component.id,
|
||||
"data-panel-side": "left",
|
||||
} : {})}
|
||||
>
|
||||
{/* 좌측 데이터 목록/테이블/커스텀 */}
|
||||
{console.log("🔍 [SplitPanel] 왼쪽 패널 displayMode:", componentConfig.leftPanel?.displayMode, "isDesignMode:", isDesignMode)}
|
||||
{componentConfig.leftPanel?.displayMode === "custom" ? (
|
||||
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
|
||||
<div
|
||||
|
|
@ -3158,59 +3261,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
{/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */}
|
||||
{componentConfig.leftPanel?.components && componentConfig.leftPanel.components.length > 0 ? (
|
||||
!isDesignMode ? (
|
||||
// 런타임: ResponsiveGridRenderer로 반응형 렌더링
|
||||
(() => {
|
||||
const leftComps = componentConfig.leftPanel!.components;
|
||||
const canvasW = Math.max(...leftComps.map((c: PanelInlineComponent) => (c.position?.x || 0) + (c.size?.width || 200)), 800);
|
||||
const canvasH = Math.max(...leftComps.map((c: PanelInlineComponent) => (c.position?.y || 0) + (c.size?.height || 100)), 400);
|
||||
const compDataList = leftComps.map((c: PanelInlineComponent) => ({
|
||||
id: c.id,
|
||||
type: "component" as const,
|
||||
componentType: c.componentType,
|
||||
label: c.label,
|
||||
position: c.position || { x: 0, y: 0 },
|
||||
size: c.size || { width: 400, height: 300 },
|
||||
componentConfig: c.componentConfig || {},
|
||||
style: c.style || {},
|
||||
tableName: c.componentConfig?.tableName,
|
||||
columnName: c.componentConfig?.columnName,
|
||||
webType: c.componentConfig?.webType,
|
||||
inputType: (c as any).inputType || c.componentConfig?.inputType,
|
||||
})) as any;
|
||||
return (
|
||||
<ResponsiveGridRenderer
|
||||
components={compDataList}
|
||||
canvasWidth={canvasW}
|
||||
canvasHeight={canvasH}
|
||||
renderComponent={(comp) => (
|
||||
<DynamicComponentRenderer
|
||||
component={comp as any}
|
||||
isDesignMode={false}
|
||||
isInteractive={true}
|
||||
formData={{}}
|
||||
tableName={componentConfig.leftPanel?.tableName}
|
||||
menuObjid={(props as any).menuObjid}
|
||||
screenId={(props as any).screenId}
|
||||
userId={(props as any).userId}
|
||||
userName={(props as any).userName}
|
||||
companyCode={companyCode}
|
||||
allComponents={(props as any).allComponents}
|
||||
selectedRowsData={localSelectedRowsData}
|
||||
onSelectedRowsChange={handleLocalSelectedRowsChange}
|
||||
onFormDataChange={(data: any) => {
|
||||
if (data?.selectedRowsData && data.selectedRowsData.length > 0) {
|
||||
setCustomLeftSelectedData(data.selectedRowsData[0]);
|
||||
setSelectedLeftItem(data.selectedRowsData[0]);
|
||||
} else if (data?.selectedRowsData && data.selectedRowsData.length === 0) {
|
||||
setCustomLeftSelectedData({});
|
||||
setSelectedLeftItem(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
<ScaledCustomPanel
|
||||
components={componentConfig.leftPanel!.components}
|
||||
formData={{}}
|
||||
onFormDataChange={(data: any) => {
|
||||
if (data?.selectedRowsData && data.selectedRowsData.length > 0) {
|
||||
setCustomLeftSelectedData(data.selectedRowsData[0]);
|
||||
setSelectedLeftItem(data.selectedRowsData[0]);
|
||||
} else if (data?.selectedRowsData && data.selectedRowsData.length === 0) {
|
||||
setCustomLeftSelectedData({});
|
||||
setSelectedLeftItem(null);
|
||||
}
|
||||
}}
|
||||
tableName={componentConfig.leftPanel?.tableName}
|
||||
menuObjid={(props as any).menuObjid}
|
||||
screenId={(props as any).screenId}
|
||||
userId={(props as any).userId}
|
||||
userName={(props as any).userName}
|
||||
companyCode={companyCode}
|
||||
allComponents={(props as any).allComponents}
|
||||
selectedRowsData={localSelectedRowsData}
|
||||
onSelectedRowsChange={handleLocalSelectedRowsChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative h-full w-full" style={{ minHeight: "100%", minWidth: "100%" }}>
|
||||
{componentConfig.leftPanel.components.map((comp: PanelInlineComponent) => {
|
||||
|
|
@ -3250,10 +3322,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 패널 컴포넌트 선택 시 탭 내 선택 해제
|
||||
if (comp.componentType !== "v2-tabs-widget") {
|
||||
setNestedTabSelectedCompId(undefined);
|
||||
}
|
||||
setInternalSelectedCompId(comp.id);
|
||||
onSelectPanelComponent?.("left", comp.id, comp);
|
||||
}}
|
||||
>
|
||||
|
|
@ -3501,7 +3573,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</th>
|
||||
))}
|
||||
{hasGroupedLeftActions && (
|
||||
<th className="px-3 py-2 text-right text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}>
|
||||
<th className="bg-muted sticky right-0 z-10 px-3 py-2 text-right text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
|
|
@ -3537,7 +3609,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</td>
|
||||
))}
|
||||
{hasGroupedLeftActions && (
|
||||
<td className="px-3 py-2 text-right">
|
||||
<td className="bg-card sticky right-0 z-10 px-3 py-2 text-right group-hover:bg-accent">
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{(componentConfig.leftPanel?.showEdit !== false) && (
|
||||
<button
|
||||
|
|
@ -3599,7 +3671,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</th>
|
||||
))}
|
||||
{hasLeftTableActions && (
|
||||
<th className="px-3 py-2 text-right text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}>
|
||||
<th className="bg-muted sticky right-0 z-10 px-3 py-2 text-right text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
|
|
@ -3635,7 +3707,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</td>
|
||||
))}
|
||||
{hasLeftTableActions && (
|
||||
<td className="px-3 py-2 text-right">
|
||||
<td className="bg-card sticky right-0 z-10 px-3 py-2 text-right group-hover:bg-accent">
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{(componentConfig.leftPanel?.showEdit !== false) && (
|
||||
<button
|
||||
|
|
@ -4033,7 +4105,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="flex-1 overflow-auto p-4">
|
||||
<CardContent
|
||||
className="flex-1 overflow-auto p-4"
|
||||
{...(isDesignMode ? {
|
||||
"data-split-panel-container": "true",
|
||||
"data-component-id": component.id,
|
||||
"data-panel-side": "right",
|
||||
} : {})}
|
||||
>
|
||||
{/* 추가 탭 컨텐츠 */}
|
||||
{activeTabIndex > 0 ? (
|
||||
(() => {
|
||||
|
|
@ -4316,53 +4395,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
{/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */}
|
||||
{componentConfig.rightPanel?.components && componentConfig.rightPanel.components.length > 0 ? (
|
||||
!isDesignMode ? (
|
||||
// 런타임: ResponsiveGridRenderer로 반응형 렌더링
|
||||
(() => {
|
||||
const rightComps = componentConfig.rightPanel!.components;
|
||||
const canvasW = Math.max(...rightComps.map((c: PanelInlineComponent) => (c.position?.x || 0) + (c.size?.width || 200)), 800);
|
||||
const canvasH = Math.max(...rightComps.map((c: PanelInlineComponent) => (c.position?.y || 0) + (c.size?.height || 100)), 400);
|
||||
const compDataList = rightComps.map((c: PanelInlineComponent) => ({
|
||||
id: c.id,
|
||||
type: "component" as const,
|
||||
componentType: c.componentType,
|
||||
label: c.label,
|
||||
position: c.position || { x: 0, y: 0 },
|
||||
size: c.size || { width: 400, height: 300 },
|
||||
componentConfig: c.componentConfig || {},
|
||||
style: c.style || {},
|
||||
tableName: c.componentConfig?.tableName,
|
||||
columnName: c.componentConfig?.columnName,
|
||||
webType: c.componentConfig?.webType,
|
||||
inputType: (c as any).inputType || c.componentConfig?.inputType,
|
||||
})) as any;
|
||||
return (
|
||||
<ResponsiveGridRenderer
|
||||
components={compDataList}
|
||||
canvasWidth={canvasW}
|
||||
canvasHeight={canvasH}
|
||||
renderComponent={(comp) => (
|
||||
<DynamicComponentRenderer
|
||||
component={comp as any}
|
||||
isDesignMode={false}
|
||||
isInteractive={true}
|
||||
formData={customLeftSelectedData}
|
||||
onFormDataChange={(fieldName: string, value: any) => {
|
||||
setCustomLeftSelectedData((prev: Record<string, any>) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
tableName={componentConfig.rightPanel?.tableName || componentConfig.leftPanel?.tableName}
|
||||
menuObjid={(props as any).menuObjid}
|
||||
screenId={(props as any).screenId}
|
||||
userId={(props as any).userId}
|
||||
userName={(props as any).userName}
|
||||
companyCode={companyCode}
|
||||
allComponents={(props as any).allComponents}
|
||||
selectedRowsData={localSelectedRowsData}
|
||||
onSelectedRowsChange={handleLocalSelectedRowsChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
<ScaledCustomPanel
|
||||
components={componentConfig.rightPanel!.components}
|
||||
formData={customLeftSelectedData}
|
||||
onFormDataChange={(fieldName: string, value: any) => {
|
||||
setCustomLeftSelectedData((prev: Record<string, any>) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
tableName={componentConfig.rightPanel?.tableName || componentConfig.leftPanel?.tableName}
|
||||
menuObjid={(props as any).menuObjid}
|
||||
screenId={(props as any).screenId}
|
||||
userId={(props as any).userId}
|
||||
userName={(props as any).userName}
|
||||
companyCode={companyCode}
|
||||
allComponents={(props as any).allComponents}
|
||||
selectedRowsData={localSelectedRowsData}
|
||||
onSelectedRowsChange={handleLocalSelectedRowsChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative h-full w-full" style={{ minHeight: "100%", minWidth: "100%" }}>
|
||||
{componentConfig.rightPanel.components.map((comp: PanelInlineComponent) => {
|
||||
|
|
@ -4405,6 +4453,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
if (comp.componentType !== "v2-tabs-widget") {
|
||||
setNestedTabSelectedCompId(undefined);
|
||||
}
|
||||
setInternalSelectedCompId(comp.id);
|
||||
onSelectPanelComponent?.("right", comp.id, comp);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ function SortableColumnRow({
|
|||
onLabelChange,
|
||||
onWidthChange,
|
||||
onFormatChange,
|
||||
onSuffixChange,
|
||||
onRemove,
|
||||
onShowInSummaryChange,
|
||||
onShowInDetailChange,
|
||||
|
|
@ -87,6 +88,7 @@ function SortableColumnRow({
|
|||
onLabelChange: (value: string) => void;
|
||||
onWidthChange: (value: number) => void;
|
||||
onFormatChange: (checked: boolean) => void;
|
||||
onSuffixChange?: (value: string) => void;
|
||||
onRemove: () => void;
|
||||
onShowInSummaryChange?: (checked: boolean) => void;
|
||||
onShowInDetailChange?: (checked: boolean) => void;
|
||||
|
|
@ -177,15 +179,24 @@ function SortableColumnRow({
|
|||
className="h-6 w-14 shrink-0 text-xs"
|
||||
/>
|
||||
{isNumeric && (
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.format?.thousandSeparator ?? false}
|
||||
onChange={(e) => onFormatChange(e.target.checked)}
|
||||
className="h-3 w-3"
|
||||
<>
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.format?.thousandSeparator ?? false}
|
||||
onChange={(e) => onFormatChange(e.target.checked)}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
,
|
||||
</label>
|
||||
<Input
|
||||
value={col.format?.suffix || ""}
|
||||
onChange={(e) => onSuffixChange?.(e.target.value)}
|
||||
placeholder="단위"
|
||||
title="값 뒤에 붙는 단위 (예: mm, kg, %)"
|
||||
className="h-6 w-10 shrink-0 text-[10px]"
|
||||
/>
|
||||
,
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{/* 헤더/상세 표시 토글 */}
|
||||
{onShowInSummaryChange && (
|
||||
|
|
@ -818,6 +829,18 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
|||
};
|
||||
updateTab({ columns: newColumns });
|
||||
}}
|
||||
onSuffixChange={(value) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = {
|
||||
...newColumns[index],
|
||||
format: {
|
||||
...newColumns[index].format,
|
||||
type: "number",
|
||||
suffix: value || undefined,
|
||||
},
|
||||
};
|
||||
updateTab({ columns: newColumns });
|
||||
}}
|
||||
onRemove={() => updateTab({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||
onShowInSummaryChange={(checked) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
|
|
@ -2330,6 +2353,18 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
};
|
||||
updateLeftPanel({ columns: newColumns });
|
||||
}}
|
||||
onSuffixChange={(value) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = {
|
||||
...newColumns[index],
|
||||
format: {
|
||||
...newColumns[index].format,
|
||||
type: "number",
|
||||
suffix: value || undefined,
|
||||
},
|
||||
};
|
||||
updateLeftPanel({ columns: newColumns });
|
||||
}}
|
||||
onRemove={() =>
|
||||
updateLeftPanel({ columns: selectedColumns.filter((_, i) => i !== index) })
|
||||
}
|
||||
|
|
@ -2988,6 +3023,18 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
};
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
onSuffixChange={(value) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = {
|
||||
...newColumns[index],
|
||||
format: {
|
||||
...newColumns[index].format,
|
||||
type: "number",
|
||||
suffix: value || undefined,
|
||||
},
|
||||
};
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
onRemove={() =>
|
||||
updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -315,6 +315,11 @@ export const LeftPanelConfigTab: React.FC<LeftPanelConfigTabProps> = ({
|
|||
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
|
||||
updateLeftPanel({ columns: newColumns });
|
||||
}}
|
||||
onSuffixChange={(value) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", suffix: value || undefined } };
|
||||
updateLeftPanel({ columns: newColumns });
|
||||
}}
|
||||
onRemove={() => updateLeftPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -305,6 +305,11 @@ export const RightPanelConfigTab: React.FC<RightPanelConfigTabProps> = ({
|
|||
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
onSuffixChange={(value) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", suffix: value || undefined } };
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
onRemove={() => updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||
onShowInSummaryChange={(checked) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { Check, ChevronsUpDown, GripVertical, Link2, X } from "lucide-react";
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SortableColumnRow({
|
||||
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
|
||||
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onSuffixChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
|
||||
}: {
|
||||
id: string;
|
||||
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean };
|
||||
|
|
@ -23,6 +23,7 @@ export function SortableColumnRow({
|
|||
onLabelChange: (value: string) => void;
|
||||
onWidthChange: (value: number) => void;
|
||||
onFormatChange: (checked: boolean) => void;
|
||||
onSuffixChange?: (value: string) => void;
|
||||
onRemove: () => void;
|
||||
onShowInSummaryChange?: (checked: boolean) => void;
|
||||
onShowInDetailChange?: (checked: boolean) => void;
|
||||
|
|
@ -61,15 +62,24 @@ export function SortableColumnRow({
|
|||
className="h-6 w-14 shrink-0 text-xs"
|
||||
/>
|
||||
{isNumeric && (
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.format?.thousandSeparator ?? false}
|
||||
onChange={(e) => onFormatChange(e.target.checked)}
|
||||
className="h-3 w-3"
|
||||
<>
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.format?.thousandSeparator ?? false}
|
||||
onChange={(e) => onFormatChange(e.target.checked)}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
,
|
||||
</label>
|
||||
<Input
|
||||
value={col.format?.suffix || ""}
|
||||
onChange={(e) => onSuffixChange?.(e.target.value)}
|
||||
placeholder="단위"
|
||||
title="값 뒤에 붙는 단위 (예: mm, kg, %)"
|
||||
className="h-6 w-10 shrink-0 text-[10px]"
|
||||
/>
|
||||
,
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{onShowInSummaryChange && (
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="테이블 헤더에 표시">
|
||||
|
|
|
|||
|
|
@ -2148,6 +2148,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
source: component.id || "table-list",
|
||||
});
|
||||
|
||||
// dataBinding 연동용 window CustomEvent
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("v2-table-selection", {
|
||||
detail: {
|
||||
tableName: tableConfig.selectedTable || "",
|
||||
data: selectedRowsData,
|
||||
source: component.id || "table-list",
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 🆕 modalDataStore에 선택된 데이터 자동 저장 (테이블명 기반 dataSourceId)
|
||||
if (tableConfig.selectedTable && selectedRowsData.length > 0) {
|
||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ const TabsDesignEditor: React.FC<{
|
|||
onUpdateComponent?: (updatedComponent: any) => void;
|
||||
onSelectTabComponent?: (tabId: string, compId: string, comp: TabInlineComponent) => void;
|
||||
selectedTabComponentId?: string;
|
||||
}> = ({ component, tabs, onUpdateComponent, onSelectTabComponent, selectedTabComponentId }) => {
|
||||
onNestedPanelSelect?: (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => void;
|
||||
}> = ({ component, tabs, onUpdateComponent, onSelectTabComponent, selectedTabComponentId, onNestedPanelSelect }) => {
|
||||
const [activeTabId, setActiveTabId] = useState<string>(tabs[0]?.id || "");
|
||||
const [draggingCompId, setDraggingCompId] = useState<string | null>(null);
|
||||
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
|
@ -324,15 +325,12 @@ const TabsDesignEditor: React.FC<{
|
|||
const isDragging = draggingCompId === comp.id;
|
||||
const isResizing = resizingCompId === comp.id;
|
||||
|
||||
// 드래그/리사이즈 중 표시할 크기
|
||||
// resizeSize가 있고 해당 컴포넌트이면 resizeSize 우선 사용 (레이아웃 업데이트 반영 전까지)
|
||||
const compWidth = comp.size?.width || 200;
|
||||
const compHeight = comp.size?.height || 100;
|
||||
const isResizingThis = (resizingCompId === comp.id || lastResizedCompId === comp.id) && resizeSize;
|
||||
const displayWidth = isResizingThis ? resizeSize!.width : compWidth;
|
||||
const displayHeight = isResizingThis ? resizeSize!.height : compHeight;
|
||||
|
||||
// 컴포넌트 데이터를 DynamicComponentRenderer 형식으로 변환
|
||||
const componentData = {
|
||||
id: comp.id,
|
||||
type: "component" as const,
|
||||
|
|
@ -344,7 +342,6 @@ const TabsDesignEditor: React.FC<{
|
|||
style: comp.style || {},
|
||||
};
|
||||
|
||||
// 드래그 중인 컴포넌트는 dragPosition 사용, 아니면 저장된 position 사용
|
||||
const displayX = isDragging && dragPosition ? dragPosition.x : (comp.position?.x || 0);
|
||||
const displayY = isDragging && dragPosition ? dragPosition.y : (comp.position?.y || 0);
|
||||
|
||||
|
|
@ -417,12 +414,43 @@ const TabsDesignEditor: React.FC<{
|
|||
width: displayWidth,
|
||||
height: displayHeight,
|
||||
}}
|
||||
|
||||
>
|
||||
<div className="h-full w-full pointer-events-none">
|
||||
<div className={cn(
|
||||
"h-full w-full",
|
||||
comp.componentType !== "v2-split-panel-layout" && comp.componentType !== "split-panel-layout" && "pointer-events-none"
|
||||
)}>
|
||||
<DynamicComponentRenderer
|
||||
component={componentData as any}
|
||||
isDesignMode={true}
|
||||
formData={{}}
|
||||
{...(comp.componentType === "v2-split-panel-layout" || comp.componentType === "split-panel-layout" ? {
|
||||
onUpdateComponent: (updated: any) => {
|
||||
if (!onUpdateComponent) return;
|
||||
const updatedTabs = tabs.map((t) => {
|
||||
if (t.id !== activeTabId) return t;
|
||||
return {
|
||||
...t,
|
||||
components: (t.components || []).map((c) =>
|
||||
c.id === comp.id ? { ...c, componentConfig: updated.componentConfig || updated.overrides || c.componentConfig } : c
|
||||
),
|
||||
};
|
||||
});
|
||||
const configKey = component.componentConfig ? "componentConfig" : "overrides";
|
||||
const existingConfig = component[configKey] || {};
|
||||
onUpdateComponent({
|
||||
...component,
|
||||
[configKey]: { ...existingConfig, tabs: updatedTabs },
|
||||
});
|
||||
},
|
||||
onSelectPanelComponent: (panelSide: string, compId: string, panelComp: any) => {
|
||||
if (onNestedPanelSelect) {
|
||||
onNestedPanelSelect(comp.id, panelSide as "left" | "right", compId, panelComp);
|
||||
} else {
|
||||
onSelectTabComponent?.(activeTabId, comp.id, comp);
|
||||
}
|
||||
},
|
||||
} : {})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -483,6 +511,7 @@ const TabsWidgetWrapper: React.FC<any> = (props) => {
|
|||
onUpdateComponent,
|
||||
onSelectTabComponent,
|
||||
selectedTabComponentId,
|
||||
onNestedPanelSelect,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
|
|
@ -499,6 +528,7 @@ const TabsWidgetWrapper: React.FC<any> = (props) => {
|
|||
onUpdateComponent={onUpdateComponent}
|
||||
onSelectTabComponent={onSelectTabComponent}
|
||||
selectedTabComponentId={selectedTabComponentId}
|
||||
onNestedPanelSelect={onNestedPanelSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue