Merge pull request 'jskim-node' (#413) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/413
This commit is contained in:
commit
d239c9e88e
|
|
@ -1854,7 +1854,7 @@ export async function toggleMenuStatus(
|
||||||
|
|
||||||
// 현재 상태 및 회사 코드 조회
|
// 현재 상태 및 회사 코드 조회
|
||||||
const currentMenu = await queryOne<any>(
|
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)]
|
[Number(menuId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||||
import { auditLogService } from "../services/auditLogService";
|
import { auditLogService, getClientIp, AuditAction, AuditResourceType } from "../services/auditLogService";
|
||||||
import { query } from "../database/db";
|
import { query } from "../database/db";
|
||||||
import logger from "../utils/logger";
|
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 { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import { auditLogService, getClientIp } from "../services/auditLogService";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -16,6 +17,7 @@ router.use(authenticateToken);
|
||||||
interface AuthenticatedRequest extends Request {
|
interface AuthenticatedRequest extends Request {
|
||||||
user?: {
|
user?: {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
userName: string;
|
||||||
companyCode: string;
|
companyCode: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -157,6 +159,21 @@ router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
|
||||||
const value = await categoryTreeService.createCategoryValue(companyCode, input, createdBy);
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: value,
|
data: value,
|
||||||
|
|
@ -182,6 +199,7 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
const updatedBy = req.user?.userId;
|
const updatedBy = req.user?.userId;
|
||||||
|
|
||||||
|
const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
|
||||||
const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy);
|
const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy);
|
||||||
|
|
||||||
if (!value) {
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: value,
|
data: value,
|
||||||
|
|
@ -239,6 +275,7 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
|
||||||
const { valueId } = req.params;
|
const { valueId } = req.params;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
|
||||||
const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId));
|
const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId));
|
||||||
|
|
||||||
if (!success) {
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "삭제되었습니다",
|
message: "삭제되었습니다",
|
||||||
|
|
|
||||||
|
|
@ -396,6 +396,20 @@ export class CommonCodeController {
|
||||||
companyCode
|
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({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: code,
|
data: code,
|
||||||
|
|
@ -440,6 +454,19 @@ export class CommonCodeController {
|
||||||
companyCode
|
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({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "코드 삭제 성공",
|
message: "코드 삭제 성공",
|
||||||
|
|
|
||||||
|
|
@ -438,6 +438,19 @@ export class DDLController {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.success) {
|
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({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: result.message,
|
message: result.message,
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,7 @@ router.post(
|
||||||
auditLogService.log({
|
auditLogService.log({
|
||||||
companyCode,
|
companyCode,
|
||||||
userId,
|
userId,
|
||||||
|
userName: req.user?.userName,
|
||||||
action: "CREATE",
|
action: "CREATE",
|
||||||
resourceType: "NUMBERING_RULE",
|
resourceType: "NUMBERING_RULE",
|
||||||
resourceId: String(newRule.ruleId),
|
resourceId: String(newRule.ruleId),
|
||||||
|
|
@ -243,6 +244,7 @@ router.put(
|
||||||
auditLogService.log({
|
auditLogService.log({
|
||||||
companyCode,
|
companyCode,
|
||||||
userId: req.user?.userId || "",
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName,
|
||||||
action: "UPDATE",
|
action: "UPDATE",
|
||||||
resourceType: "NUMBERING_RULE",
|
resourceType: "NUMBERING_RULE",
|
||||||
resourceId: ruleId,
|
resourceId: ruleId,
|
||||||
|
|
@ -285,6 +287,7 @@ router.delete(
|
||||||
auditLogService.log({
|
auditLogService.log({
|
||||||
companyCode,
|
companyCode,
|
||||||
userId: req.user?.userId || "",
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName,
|
||||||
action: "DELETE",
|
action: "DELETE",
|
||||||
resourceType: "NUMBERING_RULE",
|
resourceType: "NUMBERING_RULE",
|
||||||
resourceId: ruleId,
|
resourceId: ruleId,
|
||||||
|
|
@ -521,6 +524,56 @@ router.post(
|
||||||
companyCode,
|
companyCode,
|
||||||
userId
|
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 });
|
return res.json({ success: true, data: savedRule });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
|
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
|
||||||
|
|
@ -535,10 +588,25 @@ router.delete(
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
async (req: AuthenticatedRequest, res: Response) => {
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
|
const userId = req.user!.userId;
|
||||||
const { ruleId } = req.params;
|
const { ruleId } = req.params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
|
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({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "테스트 채번 규칙이 삭제되었습니다",
|
message: "테스트 채번 규칙이 삭제되었습니다",
|
||||||
|
|
|
||||||
|
|
@ -614,20 +614,6 @@ export const copyScreenWithModals = async (
|
||||||
modalScreens: modalScreens || [],
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: result,
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: copiedScreen,
|
data: copiedScreen,
|
||||||
|
|
|
||||||
|
|
@ -963,6 +963,15 @@ export async function addTableData(
|
||||||
|
|
||||||
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
|
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({
|
auditLogService.log({
|
||||||
companyCode: req.user?.companyCode || "",
|
companyCode: req.user?.companyCode || "",
|
||||||
userId: req.user?.userId || "",
|
userId: req.user?.userId || "",
|
||||||
|
|
@ -973,7 +982,7 @@ export async function addTableData(
|
||||||
resourceName: tableName,
|
resourceName: tableName,
|
||||||
tableName,
|
tableName,
|
||||||
summary: `${tableName} 데이터 추가`,
|
summary: `${tableName} 데이터 추가`,
|
||||||
changes: { after: data },
|
changes: { after: auditData },
|
||||||
ipAddress: getClientIp(req),
|
ipAddress: getClientIp(req),
|
||||||
requestPath: req.originalUrl,
|
requestPath: req.originalUrl,
|
||||||
});
|
});
|
||||||
|
|
@ -1096,10 +1105,14 @@ export async function editTableData(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 변경된 필드만 추출
|
const systemFieldsForEdit = new Set([
|
||||||
|
"id", "created_date", "updated_date", "writer", "company_code",
|
||||||
|
"createdDate", "updatedDate", "companyCode",
|
||||||
|
]);
|
||||||
const changedBefore: Record<string, any> = {};
|
const changedBefore: Record<string, any> = {};
|
||||||
const changedAfter: Record<string, any> = {};
|
const changedAfter: Record<string, any> = {};
|
||||||
for (const key of Object.keys(updatedData)) {
|
for (const key of Object.keys(updatedData)) {
|
||||||
|
if (systemFieldsForEdit.has(key)) continue;
|
||||||
if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) {
|
if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) {
|
||||||
changedBefore[key] = originalData[key];
|
changedBefore[key] = originalData[key];
|
||||||
changedAfter[key] = updatedData[key];
|
changedAfter[key] = updatedData[key];
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import { getAuditLogs, getAuditLogStats, getAuditLogUsers } from "../controllers/auditLogController";
|
import { getAuditLogs, getAuditLogStats, getAuditLogUsers, createAuditLog } from "../controllers/auditLogController";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get("/", authenticateToken, getAuditLogs);
|
router.get("/", authenticateToken, getAuditLogs);
|
||||||
router.get("/stats", authenticateToken, getAuditLogStats);
|
router.get("/stats", authenticateToken, getAuditLogStats);
|
||||||
router.get("/users", authenticateToken, getAuditLogUsers);
|
router.get("/users", authenticateToken, getAuditLogUsers);
|
||||||
|
router.post("/", authenticateToken, createAuditLog);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ export interface AuditLogParams {
|
||||||
export interface AuditLogEntry {
|
export interface AuditLogEntry {
|
||||||
id: number;
|
id: number;
|
||||||
company_code: string;
|
company_code: string;
|
||||||
|
company_name: string | null;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
user_name: string | null;
|
user_name: string | null;
|
||||||
action: string;
|
action: string;
|
||||||
|
|
@ -107,6 +108,7 @@ class AuditLogService {
|
||||||
*/
|
*/
|
||||||
async log(params: AuditLogParams): Promise<void> {
|
async log(params: AuditLogParams): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
logger.info(`[AuditLog] 기록 시도: ${params.resourceType} / ${params.action} / ${params.resourceName || params.resourceId || "N/A"}`);
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO system_audit_log
|
`INSERT INTO system_audit_log
|
||||||
(company_code, user_id, user_name, action, resource_type,
|
(company_code, user_id, user_name, action, resource_type,
|
||||||
|
|
@ -128,8 +130,9 @@ class AuditLogService {
|
||||||
params.requestPath || null,
|
params.requestPath || null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
} catch (error) {
|
logger.info(`[AuditLog] 기록 성공: ${params.resourceType} / ${params.action}`);
|
||||||
logger.error("감사 로그 기록 실패 (무시됨)", { error, params });
|
} catch (error: any) {
|
||||||
|
logger.error(`[AuditLog] 기록 실패: ${params.resourceType} / ${params.action} - ${error?.message}`, { error, params });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,40 +189,40 @@ class AuditLogService {
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
if (!isSuperAdmin && filters.companyCode) {
|
if (!isSuperAdmin && filters.companyCode) {
|
||||||
conditions.push(`company_code = $${paramIndex++}`);
|
conditions.push(`sal.company_code = $${paramIndex++}`);
|
||||||
params.push(filters.companyCode);
|
params.push(filters.companyCode);
|
||||||
} else if (isSuperAdmin && filters.companyCode) {
|
} else if (isSuperAdmin && filters.companyCode) {
|
||||||
conditions.push(`company_code = $${paramIndex++}`);
|
conditions.push(`sal.company_code = $${paramIndex++}`);
|
||||||
params.push(filters.companyCode);
|
params.push(filters.companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.userId) {
|
if (filters.userId) {
|
||||||
conditions.push(`user_id = $${paramIndex++}`);
|
conditions.push(`sal.user_id = $${paramIndex++}`);
|
||||||
params.push(filters.userId);
|
params.push(filters.userId);
|
||||||
}
|
}
|
||||||
if (filters.resourceType) {
|
if (filters.resourceType) {
|
||||||
conditions.push(`resource_type = $${paramIndex++}`);
|
conditions.push(`sal.resource_type = $${paramIndex++}`);
|
||||||
params.push(filters.resourceType);
|
params.push(filters.resourceType);
|
||||||
}
|
}
|
||||||
if (filters.action) {
|
if (filters.action) {
|
||||||
conditions.push(`action = $${paramIndex++}`);
|
conditions.push(`sal.action = $${paramIndex++}`);
|
||||||
params.push(filters.action);
|
params.push(filters.action);
|
||||||
}
|
}
|
||||||
if (filters.tableName) {
|
if (filters.tableName) {
|
||||||
conditions.push(`table_name = $${paramIndex++}`);
|
conditions.push(`sal.table_name = $${paramIndex++}`);
|
||||||
params.push(filters.tableName);
|
params.push(filters.tableName);
|
||||||
}
|
}
|
||||||
if (filters.dateFrom) {
|
if (filters.dateFrom) {
|
||||||
conditions.push(`created_at >= $${paramIndex++}::timestamptz`);
|
conditions.push(`sal.created_at >= $${paramIndex++}::timestamptz`);
|
||||||
params.push(filters.dateFrom);
|
params.push(filters.dateFrom);
|
||||||
}
|
}
|
||||||
if (filters.dateTo) {
|
if (filters.dateTo) {
|
||||||
conditions.push(`created_at <= $${paramIndex++}::timestamptz`);
|
conditions.push(`sal.created_at <= $${paramIndex++}::timestamptz`);
|
||||||
params.push(filters.dateTo);
|
params.push(filters.dateTo);
|
||||||
}
|
}
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
conditions.push(
|
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}%`);
|
params.push(`%${filters.search}%`);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
|
@ -233,14 +236,17 @@ class AuditLogService {
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
const countResult = await query<{ count: string }>(
|
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
|
params
|
||||||
);
|
);
|
||||||
const total = parseInt(countResult[0].count, 10);
|
const total = parseInt(countResult[0].count, 10);
|
||||||
|
|
||||||
const data = await query<AuditLogEntry>(
|
const data = await query<AuditLogEntry>(
|
||||||
`SELECT * FROM system_audit_log ${whereClause}
|
`SELECT sal.*, ci.company_name
|
||||||
ORDER BY created_at DESC
|
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++}`,
|
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||||
[...params, limit, offset]
|
[...params, limit, offset]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -77,14 +77,12 @@ const RESOURCE_TYPE_CONFIG: Record<
|
||||||
NODE_FLOW: { label: "플로우 제어", icon: GitBranch, color: "bg-teal-100 text-teal-700" },
|
NODE_FLOW: { label: "플로우 제어", icon: GitBranch, color: "bg-teal-100 text-teal-700" },
|
||||||
USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" },
|
USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" },
|
||||||
ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
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" },
|
COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
|
||||||
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||||
CODE: { 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" },
|
DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" },
|
||||||
TABLE: { 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" },
|
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 }> = {
|
const ACTION_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
|
|
@ -817,7 +815,7 @@ export default function AuditLogPage() {
|
||||||
</Badge>
|
</Badge>
|
||||||
{entry.company_code && entry.company_code !== "*" && (
|
{entry.company_code && entry.company_code !== "*" && (
|
||||||
<span className="text-muted-foreground text-[10px]">
|
<span className="text-muted-foreground text-[10px]">
|
||||||
[{entry.company_code}]
|
[{entry.company_name || entry.company_code}]
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -862,9 +860,11 @@ export default function AuditLogPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-muted-foreground text-xs">
|
<label className="text-muted-foreground text-xs">
|
||||||
회사코드
|
회사
|
||||||
</label>
|
</label>
|
||||||
<p className="font-medium">{selectedEntry.company_code}</p>
|
<p className="font-medium">
|
||||||
|
{selectedEntry.company_name || selectedEntry.company_code}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-muted-foreground text-xs">
|
<label className="text-muted-foreground text-xs">
|
||||||
|
|
|
||||||
|
|
@ -98,10 +98,43 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
||||||
if (savedMode === "true") {
|
if (savedMode === "true") {
|
||||||
setContinuousMode(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[]) => {
|
const calculateScreenDimensions = (components: ComponentData[]) => {
|
||||||
if (components.length === 0) {
|
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(
|
toast.success(
|
||||||
`그룹 복제가 완료되었습니다! (그룹 ${stats.groups}개 + 화면 ${stats.screens}개)`
|
`그룹 복제가 완료되었습니다! (그룹 ${stats.groups}개 + 화면 ${stats.screens}개)`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ interface RealtimePreviewProps {
|
||||||
selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID
|
selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID
|
||||||
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; // 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; // 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||||
selectedPanelComponentId?: string; // 🆕 선택된 분할 패널 컴포넌트 ID
|
selectedPanelComponentId?: string; // 🆕 선택된 분할 패널 컴포넌트 ID
|
||||||
|
onNestedPanelSelect?: (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => void;
|
||||||
onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백
|
onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백
|
||||||
|
|
||||||
// 버튼 액션을 위한 props
|
// 버튼 액션을 위한 props
|
||||||
|
|
@ -150,6 +151,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||||
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
|
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
|
||||||
onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||||
selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID
|
selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID
|
||||||
|
onNestedPanelSelect,
|
||||||
onResize, // 🆕 리사이즈 콜백
|
onResize, // 🆕 리사이즈 콜백
|
||||||
}) => {
|
}) => {
|
||||||
// 🆕 화면 다국어 컨텍스트
|
// 🆕 화면 다국어 컨텍스트
|
||||||
|
|
@ -768,6 +770,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||||
selectedTabComponentId={selectedTabComponentId}
|
selectedTabComponentId={selectedTabComponentId}
|
||||||
onSelectPanelComponent={onSelectPanelComponent}
|
onSelectPanelComponent={onSelectPanelComponent}
|
||||||
selectedPanelComponentId={selectedPanelComponentId}
|
selectedPanelComponentId={selectedPanelComponentId}
|
||||||
|
onNestedPanelSelect={onNestedPanelSelect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,8 @@ interface ProcessedRow {
|
||||||
mainComponent?: ComponentData;
|
mainComponent?: ComponentData;
|
||||||
overlayComps: ComponentData[];
|
overlayComps: ComponentData[];
|
||||||
normalComps: ComponentData[];
|
normalComps: ComponentData[];
|
||||||
|
rowMinY?: number;
|
||||||
|
rowMaxBottom?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FullWidthOverlayRow({
|
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) {
|
if (fullWidthComps.length > 0 && normalComps.length > 0) {
|
||||||
for (const fwComp of fullWidthComps) {
|
for (const fwComp of fullWidthComps) {
|
||||||
processedRows.push({
|
processedRows.push({
|
||||||
|
|
@ -234,6 +240,8 @@ export function ResponsiveGridRenderer({
|
||||||
mainComponent: fwComp,
|
mainComponent: fwComp,
|
||||||
overlayComps: normalComps,
|
overlayComps: normalComps,
|
||||||
normalComps: [],
|
normalComps: [],
|
||||||
|
rowMinY,
|
||||||
|
rowMaxBottom,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (fullWidthComps.length > 0) {
|
} else if (fullWidthComps.length > 0) {
|
||||||
|
|
@ -243,6 +251,8 @@ export function ResponsiveGridRenderer({
|
||||||
mainComponent: fwComp,
|
mainComponent: fwComp,
|
||||||
overlayComps: [],
|
overlayComps: [],
|
||||||
normalComps: [],
|
normalComps: [],
|
||||||
|
rowMinY,
|
||||||
|
rowMaxBottom,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -250,6 +260,8 @@ export function ResponsiveGridRenderer({
|
||||||
type: "normal",
|
type: "normal",
|
||||||
overlayComps: [],
|
overlayComps: [],
|
||||||
normalComps,
|
normalComps,
|
||||||
|
rowMinY,
|
||||||
|
rowMaxBottom,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -261,15 +273,26 @@ export function ResponsiveGridRenderer({
|
||||||
style={{ minHeight: "200px" }}
|
style={{ minHeight: "200px" }}
|
||||||
>
|
>
|
||||||
{processedRows.map((processedRow, rowIndex) => {
|
{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) {
|
if (processedRow.type === "fullwidth" && processedRow.mainComponent) {
|
||||||
return (
|
return (
|
||||||
|
<div key={`row-${rowIndex}`} style={{ marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}>
|
||||||
<FullWidthOverlayRow
|
<FullWidthOverlayRow
|
||||||
key={`row-${rowIndex}`}
|
|
||||||
main={processedRow.mainComponent}
|
main={processedRow.mainComponent}
|
||||||
overlayComps={processedRow.overlayComps}
|
overlayComps={processedRow.overlayComps}
|
||||||
canvasWidth={canvasWidth}
|
canvasWidth={canvasWidth}
|
||||||
renderComponent={renderComponent}
|
renderComponent={renderComponent}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,7 +313,7 @@ export function ResponsiveGridRenderer({
|
||||||
allButtons && "justify-end px-2 py-1",
|
allButtons && "justify-end px-2 py-1",
|
||||||
hasFlexHeightComp ? "min-h-0 flex-1" : "flex-shrink-0"
|
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) => {
|
{normalComps.map((component) => {
|
||||||
const typeId = getComponentTypeId(component);
|
const typeId = getComponentTypeId(component);
|
||||||
|
|
@ -337,10 +360,10 @@ export function ResponsiveGridRenderer({
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
minWidth: isMobile ? "100%" : undefined,
|
minWidth: isMobile ? "100%" : undefined,
|
||||||
minHeight: useFlexHeight ? "300px" : undefined,
|
minHeight: useFlexHeight ? "300px" : (component.size?.height
|
||||||
height: useFlexHeight ? "100%" : (component.size?.height
|
|
||||||
? `${component.size.height}px`
|
? `${component.size.height}px`
|
||||||
: "auto"),
|
: undefined),
|
||||||
|
height: useFlexHeight ? "100%" : "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderComponent(component)}
|
{renderComponent(component)}
|
||||||
|
|
|
||||||
|
|
@ -2861,9 +2861,190 @@ export default function ScreenDesigner({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 탭 컨테이너 내부 드롭 처리 (중첩 구조 지원)
|
// 🎯 컨테이너 드롭 우선순위: 가장 안쪽(innermost) 컨테이너 우선
|
||||||
|
// 분할패널과 탭 둘 다 감지될 경우, DOM 트리에서 더 가까운 쪽을 우선 처리
|
||||||
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
|
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 containerId = tabsContainer.getAttribute("data-component-id");
|
||||||
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
|
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
|
||||||
if (containerId && activeTabId) {
|
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();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
|
|
@ -3378,15 +3496,12 @@ export default function ScreenDesigner({
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const dragData = e.dataTransfer.getData("application/json");
|
const dragData = e.dataTransfer.getData("application/json");
|
||||||
// console.log("🎯 드롭 이벤트:", { dragData });
|
|
||||||
if (!dragData) {
|
if (!dragData) {
|
||||||
// console.log("❌ 드래그 데이터가 없습니다");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsedData = JSON.parse(dragData);
|
const parsedData = JSON.parse(dragData);
|
||||||
// console.log("📋 파싱된 데이터:", parsedData);
|
|
||||||
|
|
||||||
// 템플릿 드래그인 경우
|
// 템플릿 드래그인 경우
|
||||||
if (parsedData.type === "template") {
|
if (parsedData.type === "template") {
|
||||||
|
|
@ -3480,9 +3595,225 @@ export default function ScreenDesigner({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원)
|
// 🎯 컨테이너 감지: innermost 우선 (분할패널 > 탭)
|
||||||
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
|
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 containerId = tabsContainer.getAttribute("data-component-id");
|
||||||
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
|
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
|
||||||
if (containerId && activeTabId) {
|
if (containerId && activeTabId) {
|
||||||
|
|
@ -3648,9 +3979,8 @@ export default function ScreenDesigner({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 분할 패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리
|
// 🎯 분할 패널 커스텀 모드 (탭 밖 최상위) 컬럼 드롭 처리
|
||||||
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
if (splitPanelContainer && !splitPanelFirst && type === "column" && column) {
|
||||||
if (splitPanelContainer && type === "column" && column) {
|
|
||||||
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
||||||
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
|
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
|
||||||
if (containerId && panelSide) {
|
if (containerId && panelSide) {
|
||||||
|
|
@ -3662,12 +3992,11 @@ export default function ScreenDesigner({
|
||||||
const panelConfig = currentConfig[panelKey] || {};
|
const panelConfig = currentConfig[panelKey] || {};
|
||||||
const currentComponents = panelConfig.components || [];
|
const currentComponents = panelConfig.components || [];
|
||||||
|
|
||||||
// 드롭 위치 계산
|
|
||||||
const panelRect = splitPanelContainer.getBoundingClientRect();
|
const panelRect = splitPanelContainer.getBoundingClientRect();
|
||||||
const dropX = (e.clientX - panelRect.left) / zoomLevel;
|
const cs2 = window.getComputedStyle(splitPanelContainer);
|
||||||
const dropY = (e.clientY - panelRect.top) / zoomLevel;
|
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({
|
const v2Mapping = createV2ConfigFromColumn({
|
||||||
widgetType: column.widgetType,
|
widgetType: column.widgetType,
|
||||||
columnName: column.columnName,
|
columnName: column.columnName,
|
||||||
|
|
@ -6415,15 +6744,6 @@ export default function ScreenDesigner({
|
||||||
const { splitPanelId, panelSide } = selectedPanelComponentInfo;
|
const { splitPanelId, panelSide } = selectedPanelComponentInfo;
|
||||||
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
|
||||||
console.log("🔧 updatePanelComponentProperty 호출:", {
|
|
||||||
componentId,
|
|
||||||
path,
|
|
||||||
value,
|
|
||||||
splitPanelId,
|
|
||||||
panelSide,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🆕 안전한 깊은 경로 업데이트 헬퍼 함수
|
|
||||||
const setNestedValue = (obj: any, pathStr: string, val: any): any => {
|
const setNestedValue = (obj: any, pathStr: string, val: any): any => {
|
||||||
const result = JSON.parse(JSON.stringify(obj));
|
const result = JSON.parse(JSON.stringify(obj));
|
||||||
const parts = pathStr.split(".");
|
const parts = pathStr.split(".");
|
||||||
|
|
@ -6440,9 +6760,27 @@ export default function ScreenDesigner({
|
||||||
return result;
|
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) => {
|
setLayout((prevLayout) => {
|
||||||
const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId);
|
const result = findSplitPanelInLayout(prevLayout.components);
|
||||||
if (!splitPanelComponent) return prevLayout;
|
if (!result) return prevLayout;
|
||||||
|
const splitPanelComponent = result.found;
|
||||||
|
|
||||||
const currentConfig = (splitPanelComponent as any).componentConfig || {};
|
const currentConfig = (splitPanelComponent as any).componentConfig || {};
|
||||||
const panelConfig = currentConfig[panelKey] || {};
|
const panelConfig = currentConfig[panelKey] || {};
|
||||||
|
|
@ -6478,17 +6816,37 @@ export default function ScreenDesigner({
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// selectedPanelComponentInfo 업데이트
|
|
||||||
setSelectedPanelComponentInfo((prev) =>
|
setSelectedPanelComponentInfo((prev) =>
|
||||||
prev ? { ...prev, component: updatedComp } : null,
|
prev ? { ...prev, component: updatedComp } : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 중첩 구조 반영
|
||||||
|
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 {
|
return {
|
||||||
...prevLayout,
|
...layout,
|
||||||
components: prevLayout.components.map((c) =>
|
components: layout.components.map((c: any) => {
|
||||||
c.id === splitPanelId ? updatedComponent : c,
|
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";
|
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
|
||||||
setLayout((prevLayout) => {
|
setLayout((prevLayout) => {
|
||||||
const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId);
|
const findResult = (() => {
|
||||||
if (!splitPanelComponent) return prevLayout;
|
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 currentConfig = (splitPanelComponent as any).componentConfig || {};
|
||||||
const panelConfig = currentConfig[panelKey] || {};
|
const panelConfig = currentConfig[panelKey] || {};
|
||||||
|
|
@ -6520,11 +6893,27 @@ export default function ScreenDesigner({
|
||||||
|
|
||||||
setSelectedPanelComponentInfo(null);
|
setSelectedPanelComponentInfo(null);
|
||||||
|
|
||||||
|
if (findResult.path === "top") {
|
||||||
|
return { ...prevLayout, components: prevLayout.components.map((c: any) => c.id === splitPanelId ? updatedComponent : c) };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...prevLayout,
|
...prevLayout,
|
||||||
components: prevLayout.components.map((c) =>
|
components: prevLayout.components.map((c: any) => {
|
||||||
c.id === splitPanelId ? updatedComponent : c,
|
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) =>
|
onSelectPanelComponent={(panelSide, compId, comp) =>
|
||||||
handleSelectPanelComponent(component.id, panelSide, compId, comp)
|
handleSelectPanelComponent(component.id, panelSide, compId, comp)
|
||||||
}
|
}
|
||||||
|
onNestedPanelSelect={handleSelectPanelComponent}
|
||||||
selectedPanelComponentId={
|
selectedPanelComponentId={
|
||||||
selectedPanelComponentInfo?.splitPanelId === component.id
|
selectedPanelComponentInfo?.splitPanelId === component.id
|
||||||
? selectedPanelComponentInfo.componentId
|
? selectedPanelComponentInfo.componentId
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,9 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
extraProps.currentTableName = currentTableName;
|
extraProps.currentTableName = currentTableName;
|
||||||
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||||||
}
|
}
|
||||||
|
if (componentId === "v2-input") {
|
||||||
|
extraProps.allComponents = allComponents;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={selectedComponent.id} className="space-y-4">
|
<div key={selectedComponent.id} className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,11 @@ import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||||
interface V2InputConfigPanelProps {
|
interface V2InputConfigPanelProps {
|
||||||
config: Record<string, any>;
|
config: Record<string, any>;
|
||||||
onChange: (config: Record<string, any>) => void;
|
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 [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||||
const [loadingRules, setLoadingRules] = useState(false);
|
const [loadingRules, setLoadingRules] = useState(false);
|
||||||
|
|
@ -483,6 +484,90 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
|
|
||||||
{/* 데이터 바인딩 설정 */}
|
{/* 데이터 바인딩 설정 */}
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
|
<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="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|
@ -490,9 +575,10 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
checked={!!config.dataBinding?.sourceComponentId}
|
checked={!!config.dataBinding?.sourceComponentId}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
|
const firstTable = tableListComponents[0];
|
||||||
updateConfig("dataBinding", {
|
updateConfig("dataBinding", {
|
||||||
sourceComponentId: config.dataBinding?.sourceComponentId || "",
|
sourceComponentId: firstTable?.id || "",
|
||||||
sourceColumn: config.dataBinding?.sourceColumn || "",
|
sourceColumn: "",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
updateConfig("dataBinding", undefined);
|
updateConfig("dataBinding", undefined);
|
||||||
|
|
@ -507,27 +593,52 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
{config.dataBinding && (
|
{config.dataBinding && (
|
||||||
<div className="space-y-2 rounded border p-2">
|
<div className="space-y-2 rounded border p-2">
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
v2-table-list에서 행 선택 시 해당 컬럼 값이 자동으로 채워집니다
|
테이블에서 행 선택 시 해당 컬럼 값이 자동으로 채워집니다
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* 소스 테이블 컴포넌트 선택 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs font-medium">소스 컴포넌트 ID</Label>
|
<Label className="text-xs font-medium">소스 테이블</Label>
|
||||||
<Input
|
{tableListComponents.length === 0 ? (
|
||||||
|
<p className="text-[10px] text-amber-500">이 화면에 v2-table-list 컴포넌트가 없습니다</p>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
value={config.dataBinding?.sourceComponentId || ""}
|
value={config.dataBinding?.sourceComponentId || ""}
|
||||||
onChange={(e) => {
|
onValueChange={(value) => {
|
||||||
updateConfig("dataBinding", {
|
updateConfig("dataBinding", {
|
||||||
...config.dataBinding,
|
...config.dataBinding,
|
||||||
sourceComponentId: e.target.value,
|
sourceComponentId: value,
|
||||||
|
sourceColumn: "",
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="예: tbl_items"
|
>
|
||||||
className="h-7 text-xs"
|
<SelectTrigger className="h-7 text-xs">
|
||||||
/>
|
<SelectValue placeholder="테이블 선택" />
|
||||||
<p className="text-[10px] text-muted-foreground">
|
</SelectTrigger>
|
||||||
같은 화면 내 v2-table-list 컴포넌트의 ID
|
<SelectContent>
|
||||||
</p>
|
{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>
|
</div>
|
||||||
|
|
||||||
|
{/* 소스 컬럼 선택 */}
|
||||||
|
{config.dataBinding?.sourceComponentId && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs font-medium">소스 컬럼명</Label>
|
<Label className="text-xs font-medium">가져올 컬럼</Label>
|
||||||
|
{loadingColumns ? (
|
||||||
|
<p className="text-[10px] text-muted-foreground">컬럼 로딩 중...</p>
|
||||||
|
) : tableColumns.length === 0 ? (
|
||||||
|
<>
|
||||||
<Input
|
<Input
|
||||||
value={config.dataBinding?.sourceColumn || ""}
|
value={config.dataBinding?.sourceColumn || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|
@ -536,20 +647,39 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
||||||
sourceColumn: e.target.value,
|
sourceColumn: e.target.value,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="예: item_number"
|
placeholder="컬럼명 직접 입력"
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">컬럼 정보를 불러올 수 없어 직접 입력</p>
|
||||||
선택된 행에서 가져올 컬럼명
|
</>
|
||||||
</p>
|
) : (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
V2InputConfigPanel.displayName = "V2InputConfigPanel";
|
|
||||||
|
|
||||||
export default V2InputConfigPanel;
|
export default V2InputConfigPanel;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { apiClient } from "./client";
|
||||||
export interface AuditLogEntry {
|
export interface AuditLogEntry {
|
||||||
id: number;
|
id: number;
|
||||||
company_code: string;
|
company_code: string;
|
||||||
|
company_name: string | null;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
user_name: string | null;
|
user_name: string | null;
|
||||||
action: string;
|
action: string;
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,8 @@ export interface DynamicComponentRendererProps {
|
||||||
// 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
// 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||||
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void;
|
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void;
|
||||||
selectedPanelComponentId?: string;
|
selectedPanelComponentId?: string;
|
||||||
|
// 중첩된 분할패널 내부 컴포넌트 선택 콜백 (탭 안의 분할패널)
|
||||||
|
onNestedPanelSelect?: (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => void;
|
||||||
flowSelectedStepId?: number | null;
|
flowSelectedStepId?: number | null;
|
||||||
onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
|
onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
|
||||||
// 테이블 새로고침 키
|
// 테이블 새로고침 키
|
||||||
|
|
@ -868,6 +870,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
// 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||||
onSelectPanelComponent: props.onSelectPanelComponent,
|
onSelectPanelComponent: props.onSelectPanelComponent,
|
||||||
selectedPanelComponentId: props.selectedPanelComponentId,
|
selectedPanelComponentId: props.selectedPanelComponentId,
|
||||||
|
onNestedPanelSelect: props.onNestedPanelSelect,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 렌더러가 클래스인지 함수인지 확인
|
// 렌더러가 클래스인지 함수인지 확인
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import {
|
||||||
GeneratedLocation,
|
GeneratedLocation,
|
||||||
RackStructureContext,
|
RackStructureContext,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN } from "./patternUtils";
|
|
||||||
|
|
||||||
// 기존 위치 데이터 타입
|
// 기존 위치 데이터 타입
|
||||||
interface ExistingLocation {
|
interface ExistingLocation {
|
||||||
|
|
@ -513,27 +512,23 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
return { totalLocations, totalRows, maxLevel };
|
return { totalLocations, totalRows, maxLevel };
|
||||||
}, [conditions]);
|
}, [conditions]);
|
||||||
|
|
||||||
// 위치 코드 생성 (패턴 기반)
|
// 위치 코드 생성
|
||||||
const generateLocationCode = useCallback(
|
const generateLocationCode = useCallback(
|
||||||
(row: number, level: number): { code: string; name: string } => {
|
(row: number, level: number): { code: string; name: string } => {
|
||||||
const vars = {
|
const warehouseCode = context?.warehouseCode || "WH001";
|
||||||
warehouse: context?.warehouseCode || "WH001",
|
const floor = context?.floor || "1";
|
||||||
warehouseName: context?.warehouseName || "",
|
const zone = context?.zone || "A";
|
||||||
floor: context?.floor || "1",
|
|
||||||
zone: context?.zone || "A",
|
|
||||||
row,
|
|
||||||
level,
|
|
||||||
};
|
|
||||||
|
|
||||||
const codePattern = config.codePattern || DEFAULT_CODE_PATTERN;
|
// 코드 생성 (예: WH001-1층D구역-01-1)
|
||||||
const namePattern = config.namePattern || DEFAULT_NAME_PATTERN;
|
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
|
||||||
|
|
||||||
return {
|
// 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용
|
||||||
code: applyLocationPattern(codePattern, vars),
|
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
|
||||||
name: applyLocationPattern(namePattern, vars),
|
const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}단`;
|
||||||
};
|
|
||||||
|
return { code, name };
|
||||||
},
|
},
|
||||||
[context, config.codePattern, config.namePattern],
|
[context],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 미리보기 생성
|
// 미리보기 생성
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
@ -12,47 +12,6 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { RackStructureComponentConfig, FieldMapping } from "./types";
|
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 {
|
interface RackStructureConfigPanelProps {
|
||||||
config: RackStructureComponentConfig;
|
config: RackStructureComponentConfig;
|
||||||
|
|
@ -246,61 +205,6 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||||
</div>
|
</div>
|
||||||
</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="space-y-3 border-t pt-3">
|
||||||
<div className="text-sm font-medium text-foreground">제한 설정</div>
|
<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 { V2InputDefinition } from "./index";
|
||||||
import { V2Input } from "@/components/v2/V2Input";
|
import { V2Input } from "@/components/v2/V2Input";
|
||||||
import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer";
|
import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer";
|
||||||
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* dataBinding이 설정된 v2-input을 위한 wrapper
|
* dataBinding이 설정된 v2-input을 위한 wrapper
|
||||||
* v2-table-list의 TABLE_DATA_CHANGE 이벤트를 구독하여
|
* v2-table-list의 선택 이벤트를 window CustomEvent로 수신하여
|
||||||
* 선택된 행의 특정 컬럼 값을 자동으로 formData에 반영
|
* 선택된 행의 특정 컬럼 값을 자동으로 formData에 반영
|
||||||
*/
|
*/
|
||||||
function DataBindingWrapper({
|
function DataBindingWrapper({
|
||||||
dataBinding,
|
dataBinding,
|
||||||
columnName,
|
columnName,
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
isInteractive,
|
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
dataBinding: { sourceComponentId: string; sourceColumn: string };
|
dataBinding: { sourceComponentId: string; sourceColumn: string };
|
||||||
columnName: string;
|
columnName: string;
|
||||||
onFormDataChange?: (field: string, value: any) => void;
|
onFormDataChange?: (field: string, value: any) => void;
|
||||||
isInteractive?: boolean;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const lastBoundValueRef = useRef<any>(null);
|
const lastBoundValueRef = useRef<any>(null);
|
||||||
|
const onFormDataChangeRef = useRef(onFormDataChange);
|
||||||
|
onFormDataChangeRef.current = onFormDataChange;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dataBinding?.sourceComponentId || !dataBinding?.sourceColumn) return;
|
if (!dataBinding?.sourceComponentId || !dataBinding?.sourceColumn) return;
|
||||||
|
|
||||||
console.log("[DataBinding] 구독 시작:", {
|
const handler = (e: Event) => {
|
||||||
sourceComponentId: dataBinding.sourceComponentId,
|
const detail = (e as CustomEvent).detail;
|
||||||
sourceColumn: dataBinding.sourceColumn,
|
if (!detail || detail.source !== dataBinding.sourceComponentId) return;
|
||||||
targetColumn: columnName,
|
|
||||||
isInteractive,
|
|
||||||
hasOnFormDataChange: !!onFormDataChange,
|
|
||||||
});
|
|
||||||
|
|
||||||
const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_DATA_CHANGE, (payload: any) => {
|
const selectedRow = detail.data?.[0];
|
||||||
console.log("[DataBinding] TABLE_DATA_CHANGE 수신:", {
|
const value = selectedRow?.[dataBinding.sourceColumn] ?? "";
|
||||||
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) {
|
if (value !== lastBoundValueRef.current) {
|
||||||
lastBoundValueRef.current = value;
|
lastBoundValueRef.current = value;
|
||||||
if (onFormDataChange && columnName) {
|
onFormDataChangeRef.current?.(columnName, value);
|
||||||
onFormDataChange(columnName, value ?? "");
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
} else {
|
|
||||||
if (lastBoundValueRef.current !== null) {
|
|
||||||
lastBoundValueRef.current = null;
|
|
||||||
if (onFormDataChange && columnName) {
|
|
||||||
onFormDataChange(columnName, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => unsubscribe();
|
window.addEventListener("v2-table-selection", handler);
|
||||||
}, [dataBinding?.sourceComponentId, dataBinding?.sourceColumn, columnName, onFormDataChange, isInteractive]);
|
return () => window.removeEventListener("v2-table-selection", handler);
|
||||||
|
}, [dataBinding?.sourceComponentId, dataBinding?.sourceColumn, columnName]);
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
@ -102,18 +76,6 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
|
||||||
const dataBinding = config.dataBinding || (component as any).dataBinding || config.componentConfig?.dataBinding;
|
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 = (
|
const inputElement = (
|
||||||
<V2Input
|
<V2Input
|
||||||
id={component.id}
|
id={component.id}
|
||||||
|
|
@ -153,7 +115,6 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
|
||||||
dataBinding={dataBinding}
|
dataBinding={dataBinding}
|
||||||
columnName={columnName}
|
columnName={columnName}
|
||||||
onFormDataChange={onFormDataChange}
|
onFormDataChange={onFormDataChange}
|
||||||
isInteractive={isInteractive}
|
|
||||||
>
|
>
|
||||||
{inputElement}
|
{inputElement}
|
||||||
</DataBindingWrapper>
|
</DataBindingWrapper>
|
||||||
|
|
|
||||||
|
|
@ -96,12 +96,12 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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>
|
<span className="font-medium">조건 {index + 1}</span>
|
||||||
{!readonly && (
|
{!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" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -112,7 +112,7 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
|
||||||
{/* 열 범위 */}
|
{/* 열 범위 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1">
|
<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>
|
열 범위 <span className="text-destructive">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -140,7 +140,7 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-20">
|
<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>
|
단 수 <span className="text-destructive">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -157,7 +157,7 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
|
||||||
</div>
|
</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 ? (
|
{locationCount > 0 ? (
|
||||||
<>
|
<>
|
||||||
{localValues.startRow}열 ~ {localValues.endRow}열 x {localValues.levels}단 ={" "}
|
{localValues.startRow}열 ~ {localValues.endRow}열 x {localValues.levels}단 ={" "}
|
||||||
|
|
@ -627,7 +627,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<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>
|
</CardTitle>
|
||||||
{!readonly && (
|
{!readonly && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -720,8 +720,8 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
|
|
||||||
{/* 기존 데이터 존재 알림 */}
|
{/* 기존 데이터 존재 알림 */}
|
||||||
{!isCheckingDuplicates && existingLocations.length > 0 && !hasDuplicateWithExisting && (
|
{!isCheckingDuplicates && existingLocations.length > 0 && !hasDuplicateWithExisting && (
|
||||||
<Alert className="mb-4 border-primary/20 bg-primary/10">
|
<Alert className="border-primary/20 bg-primary/10 mb-4">
|
||||||
<AlertCircle className="h-4 w-4 text-primary" />
|
<AlertCircle className="text-primary h-4 w-4" />
|
||||||
<AlertDescription className="text-primary">
|
<AlertDescription className="text-primary">
|
||||||
해당 창고/층/구역에 <strong>{existingLocations.length}개</strong>의 위치가 이미 등록되어 있습니다.
|
해당 창고/층/구역에 <strong>{existingLocations.length}개</strong>의 위치가 이미 등록되어 있습니다.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|
@ -730,9 +730,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
|
|
||||||
{/* 현재 매핑된 값 표시 */}
|
{/* 현재 매핑된 값 표시 */}
|
||||||
{(context.warehouseCode || context.warehouseName || context.floor || context.zone) && (
|
{(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) && (
|
{(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.warehouseName && context.warehouseCode && ` (${context.warehouseCode})`}
|
{context.warehouseName && context.warehouseCode && ` (${context.warehouseCode})`}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -749,28 +749,28 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{context.status && (
|
{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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 안내 메시지 */}
|
{/* 안내 메시지 */}
|
||||||
<div className="mb-4 rounded-lg bg-primary/10 p-4">
|
<div className="bg-primary/10 mb-4 rounded-lg p-4">
|
||||||
<ol className="space-y-1 text-sm text-primary">
|
<ol className="text-primary space-y-1 text-sm">
|
||||||
<li className="flex items-start gap-2">
|
<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
|
1
|
||||||
</span>
|
</span>
|
||||||
조건 추가 버튼을 클릭하여 렉 라인 조건을 생성하세요
|
조건 추가 버튼을 클릭하여 렉 라인 조건을 생성하세요
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<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
|
2
|
||||||
</span>
|
</span>
|
||||||
각 조건마다 열 범위와 단 수를 입력하세요
|
각 조건마다 열 범위와 단 수를 입력하세요
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-2">
|
<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
|
3
|
||||||
</span>
|
</span>
|
||||||
예시: 조건1(1~3열, 3단), 조건2(4~6열, 5단)
|
예시: 조건1(1~3열, 3단), 조건2(4~6열, 5단)
|
||||||
|
|
@ -780,9 +780,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
|
|
||||||
{/* 조건 목록 또는 빈 상태 */}
|
{/* 조건 목록 또는 빈 상태 */}
|
||||||
{conditions.length === 0 ? (
|
{conditions.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border py-12">
|
<div className="border-border flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-12">
|
||||||
<div className="mb-4 text-6xl text-muted-foreground/50">📦</div>
|
<div className="text-muted-foreground/50 mb-4 text-6xl">📦</div>
|
||||||
<p className="mb-4 text-muted-foreground">조건을 추가하여 렉 구조를 설정하세요</p>
|
<p className="text-muted-foreground mb-4">조건을 추가하여 렉 구조를 설정하세요</p>
|
||||||
{!readonly && (
|
{!readonly && (
|
||||||
<Button onClick={addCondition} className="gap-1">
|
<Button onClick={addCondition} className="gap-1">
|
||||||
<Plus className="h-4 w-4" />첫 번째 조건 추가
|
<Plus className="h-4 w-4" />첫 번째 조건 추가
|
||||||
|
|
@ -833,15 +833,15 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
{config.showStatistics && (
|
{config.showStatistics && (
|
||||||
<div className="mb-4 grid grid-cols-3 gap-4">
|
<div className="mb-4 grid grid-cols-3 gap-4">
|
||||||
<div className="rounded-lg border bg-white p-4 text-center">
|
<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 className="text-2xl font-bold">{statistics.totalLocations}개</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border bg-white p-4 text-center">
|
<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 className="text-2xl font-bold">{statistics.totalRows}개</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border bg-white p-4 text-center">
|
<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 className="text-2xl font-bold">{statistics.maxLevel}단</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -852,7 +852,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
<div className="rounded-lg border">
|
<div className="rounded-lg border">
|
||||||
<ScrollArea className="h-[400px]">
|
<ScrollArea className="h-[400px]">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 bg-muted">
|
<TableHeader className="bg-muted sticky top-0">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-12 text-center">No</TableHead>
|
<TableHead className="w-12 text-center">No</TableHead>
|
||||||
<TableHead>위치코드</TableHead>
|
<TableHead>위치코드</TableHead>
|
||||||
|
|
@ -884,8 +884,8 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border py-8 text-muted-foreground">
|
<div className="border-border text-muted-foreground flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-8">
|
||||||
<Eye className="mb-2 h-8 w-8 text-muted-foreground/50" />
|
<Eye className="text-muted-foreground/50 mb-2 h-8 w-8" />
|
||||||
<p>미리보기 생성 버튼을 클릭하여 결과를 확인하세요</p>
|
<p>미리보기 생성 버튼을 클릭하여 결과를 확인하세요</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -932,16 +932,16 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
{/* 템플릿 목록 */}
|
{/* 템플릿 목록 */}
|
||||||
{templates.length > 0 ? (
|
{templates.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<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]">
|
<ScrollArea className="h-[200px]">
|
||||||
{templates.map((template) => (
|
{templates.map((template) => (
|
||||||
<div
|
<div
|
||||||
key={template.id}
|
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>
|
||||||
<div className="font-medium">{template.name}</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>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={() => loadTemplate(template)}>
|
<Button variant="outline" size="sm" onClick={() => loadTemplate(template)}>
|
||||||
|
|
@ -956,7 +956,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-8 text-center text-muted-foreground">저장된 템플릿이 없습니다</div>
|
<div className="text-muted-foreground py-8 text-center">저장된 템플릿이 없습니다</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { RackStructureComponentConfig, FieldMapping, FormatSegment } from "./types";
|
import { RackStructureComponentConfig, FieldMapping, FormatSegment } from "./types";
|
||||||
import { defaultFormatConfig, SAMPLE_VALUES } from "./config";
|
import { defaultFormatConfig, SAMPLE_VALUES } from "./config";
|
||||||
import { FormatSegmentEditor } from "./FormatSegmentEditor";
|
import { FormatSegmentEditor } from "./FormatSegmentEditor";
|
||||||
|
|
@ -36,9 +30,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||||
tables = [],
|
tables = [],
|
||||||
}) => {
|
}) => {
|
||||||
// 사용 가능한 컬럼 목록 추출
|
// 사용 가능한 컬럼 목록 추출
|
||||||
const [availableColumns, setAvailableColumns] = useState<
|
const [availableColumns, setAvailableColumns] = useState<Array<{ value: string; label: string }>>([]);
|
||||||
Array<{ value: string; label: string }>
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 모든 테이블의 컬럼을 플랫하게 추출
|
// 모든 테이블의 컬럼을 플랫하게 추출
|
||||||
|
|
@ -73,10 +65,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||||
|
|
||||||
const formatConfig = config.formatConfig || defaultFormatConfig;
|
const formatConfig = config.formatConfig || defaultFormatConfig;
|
||||||
|
|
||||||
const handleFormatChange = (
|
const handleFormatChange = (key: "codeSegments" | "nameSegments", segments: FormatSegment[]) => {
|
||||||
key: "codeSegments" | "nameSegments",
|
|
||||||
segments: FormatSegment[],
|
|
||||||
) => {
|
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
formatConfig: {
|
formatConfig: {
|
||||||
|
|
@ -90,10 +79,8 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 필드 매핑 섹션 */}
|
{/* 필드 매핑 섹션 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-sm font-medium text-foreground">필드 매핑</div>
|
<div className="text-foreground text-sm font-medium">필드 매핑</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">상위 폼에 배치된 필드 중 어떤 필드를 사용할지 선택하세요</p>
|
||||||
상위 폼에 배치된 필드 중 어떤 필드를 사용할지 선택하세요
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 창고 코드 필드 */}
|
{/* 창고 코드 필드 */}
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -222,64 +209,9 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||||
</div>
|
</div>
|
||||||
</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="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>
|
<div>
|
||||||
<Label className="text-xs">최대 조건 수</Label>
|
<Label className="text-xs">최대 조건 수</Label>
|
||||||
|
|
@ -320,7 +252,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||||
|
|
||||||
{/* UI 설정 */}
|
{/* UI 설정 */}
|
||||||
<div className="space-y-3 border-t pt-3">
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs">템플릿 기능</Label>
|
<Label className="text-xs">템플릿 기능</Label>
|
||||||
|
|
@ -348,10 +280,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs">읽기 전용</Label>
|
<Label className="text-xs">읽기 전용</Label>
|
||||||
<Switch
|
<Switch checked={config.readonly ?? false} onCheckedChange={(checked) => handleChange("readonly", checked)} />
|
||||||
checked={config.readonly ?? false}
|
|
||||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -359,8 +288,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
|
||||||
<div className="space-y-3 border-t pt-3">
|
<div className="space-y-3 border-t pt-3">
|
||||||
<div className="text-sm font-medium text-gray-700">포맷 설정</div>
|
<div className="text-sm font-medium text-gray-700">포맷 설정</div>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고,
|
위치코드와 위치명의 구성 요소를 드래그로 순서 변경하고, 구분자/라벨을 편집할 수 있습니다
|
||||||
구분자/라벨을 편집할 수 있습니다
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<FormatSegmentEditor
|
<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";
|
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 컴포넌트
|
* SplitPanelLayout 컴포넌트
|
||||||
* 마스터-디테일 패턴의 좌우 분할 레이아웃
|
* 마스터-디테일 패턴의 좌우 분할 레이아웃
|
||||||
|
|
@ -271,8 +368,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
|
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [resizingCompId, setResizingCompId] = useState<string | null>(null);
|
const [resizingCompId, setResizingCompId] = useState<string | null>(null);
|
||||||
const [resizeSize, setResizeSize] = useState<{ width: number; height: number } | null>(null);
|
const [resizeSize, setResizeSize] = useState<{ width: number; height: number } | null>(null);
|
||||||
// 🆕 외부에서 전달받은 선택 상태 사용 (탭 컴포넌트와 동일 구조)
|
// 내부 선택 상태 (외부 prop 없을 때 fallback)
|
||||||
const selectedPanelComponentId = externalSelectedPanelComponentId || null;
|
const [internalSelectedCompId, setInternalSelectedCompId] = useState<string | null>(null);
|
||||||
|
const selectedPanelComponentId = externalSelectedPanelComponentId ?? internalSelectedCompId;
|
||||||
// 🆕 커스텀 모드: 분할패널 내 탭 컴포넌트의 선택 상태 관리
|
// 🆕 커스텀 모드: 분할패널 내 탭 컴포넌트의 선택 상태 관리
|
||||||
const [nestedTabSelectedCompId, setNestedTabSelectedCompId] = useState<string | undefined>(undefined);
|
const [nestedTabSelectedCompId, setNestedTabSelectedCompId] = useState<string | undefined>(undefined);
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
@ -719,22 +817,21 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}, [leftData, leftGroupSumConfig]);
|
}, [leftData, leftGroupSumConfig]);
|
||||||
|
|
||||||
// 컴포넌트 스타일
|
// 컴포넌트 스타일
|
||||||
// height 처리: 이미 px 단위면 그대로, 숫자면 px 추가
|
// height: component.size?.height 우선, 없으면 component.style?.height, 기본 600px
|
||||||
const getHeightValue = () => {
|
const getHeightValue = () => {
|
||||||
|
const sizeH = component.size?.height;
|
||||||
|
if (sizeH && typeof sizeH === "number" && sizeH > 0) return `${sizeH}px`;
|
||||||
const height = component.style?.height;
|
const height = component.style?.height;
|
||||||
if (!height) return "600px";
|
if (!height) return "600px";
|
||||||
if (typeof height === "string") return height; // 이미 '540px' 형태
|
if (typeof height === "string") return height;
|
||||||
return `${height}px`; // 숫자면 px 추가
|
return `${height}px`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const componentStyle: React.CSSProperties = isDesignMode
|
const componentStyle: React.CSSProperties = isDesignMode
|
||||||
? {
|
? {
|
||||||
position: "absolute",
|
|
||||||
left: `${component.style?.positionX || 0}px`,
|
|
||||||
top: `${component.style?.positionY || 0}px`,
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: getHeightValue(),
|
height: "100%",
|
||||||
zIndex: component.style?.positionZ || 1,
|
minHeight: getHeightValue(),
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb",
|
border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb",
|
||||||
}
|
}
|
||||||
|
|
@ -3144,9 +3241,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</div>
|
</div>
|
||||||
</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" ? (
|
{componentConfig.leftPanel?.displayMode === "custom" ? (
|
||||||
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
|
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
|
||||||
<div
|
<div
|
||||||
|
|
@ -3158,45 +3261,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
{/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */}
|
{/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */}
|
||||||
{componentConfig.leftPanel?.components && componentConfig.leftPanel.components.length > 0 ? (
|
{componentConfig.leftPanel?.components && componentConfig.leftPanel.components.length > 0 ? (
|
||||||
!isDesignMode ? (
|
!isDesignMode ? (
|
||||||
// 런타임: ResponsiveGridRenderer로 반응형 렌더링
|
<ScaledCustomPanel
|
||||||
(() => {
|
components={componentConfig.leftPanel!.components}
|
||||||
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={{}}
|
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) => {
|
onFormDataChange={(data: any) => {
|
||||||
if (data?.selectedRowsData && data.selectedRowsData.length > 0) {
|
if (data?.selectedRowsData && data.selectedRowsData.length > 0) {
|
||||||
setCustomLeftSelectedData(data.selectedRowsData[0]);
|
setCustomLeftSelectedData(data.selectedRowsData[0]);
|
||||||
|
|
@ -3206,11 +3273,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
setSelectedLeftItem(null);
|
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%" }}>
|
<div className="relative h-full w-full" style={{ minHeight: "100%", minWidth: "100%" }}>
|
||||||
{componentConfig.leftPanel.components.map((comp: PanelInlineComponent) => {
|
{componentConfig.leftPanel.components.map((comp: PanelInlineComponent) => {
|
||||||
|
|
@ -3250,10 +3322,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// 패널 컴포넌트 선택 시 탭 내 선택 해제
|
|
||||||
if (comp.componentType !== "v2-tabs-widget") {
|
if (comp.componentType !== "v2-tabs-widget") {
|
||||||
setNestedTabSelectedCompId(undefined);
|
setNestedTabSelectedCompId(undefined);
|
||||||
}
|
}
|
||||||
|
setInternalSelectedCompId(comp.id);
|
||||||
onSelectPanelComponent?.("left", comp.id, comp);
|
onSelectPanelComponent?.("left", comp.id, comp);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -3501,7 +3573,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
{hasGroupedLeftActions && (
|
{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>
|
</th>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -3537,7 +3609,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
{hasGroupedLeftActions && (
|
{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">
|
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
{(componentConfig.leftPanel?.showEdit !== false) && (
|
{(componentConfig.leftPanel?.showEdit !== false) && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -3599,7 +3671,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
{hasLeftTableActions && (
|
{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>
|
</th>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -3635,7 +3707,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
{hasLeftTableActions && (
|
{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">
|
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
{(componentConfig.leftPanel?.showEdit !== false) && (
|
{(componentConfig.leftPanel?.showEdit !== false) && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -4033,7 +4105,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{activeTabIndex > 0 ? (
|
||||||
(() => {
|
(() => {
|
||||||
|
|
@ -4316,35 +4395,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
{/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */}
|
{/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */}
|
||||||
{componentConfig.rightPanel?.components && componentConfig.rightPanel.components.length > 0 ? (
|
{componentConfig.rightPanel?.components && componentConfig.rightPanel.components.length > 0 ? (
|
||||||
!isDesignMode ? (
|
!isDesignMode ? (
|
||||||
// 런타임: ResponsiveGridRenderer로 반응형 렌더링
|
<ScaledCustomPanel
|
||||||
(() => {
|
components={componentConfig.rightPanel!.components}
|
||||||
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}
|
formData={customLeftSelectedData}
|
||||||
onFormDataChange={(fieldName: string, value: any) => {
|
onFormDataChange={(fieldName: string, value: any) => {
|
||||||
setCustomLeftSelectedData((prev: Record<string, any>) => ({ ...prev, [fieldName]: value }));
|
setCustomLeftSelectedData((prev: Record<string, any>) => ({ ...prev, [fieldName]: value }));
|
||||||
|
|
@ -4359,10 +4411,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
selectedRowsData={localSelectedRowsData}
|
selectedRowsData={localSelectedRowsData}
|
||||||
onSelectedRowsChange={handleLocalSelectedRowsChange}
|
onSelectedRowsChange={handleLocalSelectedRowsChange}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
) : (
|
) : (
|
||||||
<div className="relative h-full w-full" style={{ minHeight: "100%", minWidth: "100%" }}>
|
<div className="relative h-full w-full" style={{ minHeight: "100%", minWidth: "100%" }}>
|
||||||
{componentConfig.rightPanel.components.map((comp: PanelInlineComponent) => {
|
{componentConfig.rightPanel.components.map((comp: PanelInlineComponent) => {
|
||||||
|
|
@ -4405,6 +4453,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
if (comp.componentType !== "v2-tabs-widget") {
|
if (comp.componentType !== "v2-tabs-widget") {
|
||||||
setNestedTabSelectedCompId(undefined);
|
setNestedTabSelectedCompId(undefined);
|
||||||
}
|
}
|
||||||
|
setInternalSelectedCompId(comp.id);
|
||||||
onSelectPanelComponent?.("right", comp.id, comp);
|
onSelectPanelComponent?.("right", comp.id, comp);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ function SortableColumnRow({
|
||||||
onLabelChange,
|
onLabelChange,
|
||||||
onWidthChange,
|
onWidthChange,
|
||||||
onFormatChange,
|
onFormatChange,
|
||||||
|
onSuffixChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
onShowInSummaryChange,
|
onShowInSummaryChange,
|
||||||
onShowInDetailChange,
|
onShowInDetailChange,
|
||||||
|
|
@ -87,6 +88,7 @@ function SortableColumnRow({
|
||||||
onLabelChange: (value: string) => void;
|
onLabelChange: (value: string) => void;
|
||||||
onWidthChange: (value: number) => void;
|
onWidthChange: (value: number) => void;
|
||||||
onFormatChange: (checked: boolean) => void;
|
onFormatChange: (checked: boolean) => void;
|
||||||
|
onSuffixChange?: (value: string) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onShowInSummaryChange?: (checked: boolean) => void;
|
onShowInSummaryChange?: (checked: boolean) => void;
|
||||||
onShowInDetailChange?: (checked: boolean) => void;
|
onShowInDetailChange?: (checked: boolean) => void;
|
||||||
|
|
@ -177,6 +179,7 @@ function SortableColumnRow({
|
||||||
className="h-6 w-14 shrink-0 text-xs"
|
className="h-6 w-14 shrink-0 text-xs"
|
||||||
/>
|
/>
|
||||||
{isNumeric && (
|
{isNumeric && (
|
||||||
|
<>
|
||||||
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
|
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -186,6 +189,14 @@ function SortableColumnRow({
|
||||||
/>
|
/>
|
||||||
,
|
,
|
||||||
</label>
|
</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]"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{/* 헤더/상세 표시 토글 */}
|
{/* 헤더/상세 표시 토글 */}
|
||||||
{onShowInSummaryChange && (
|
{onShowInSummaryChange && (
|
||||||
|
|
@ -818,6 +829,18 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
||||||
};
|
};
|
||||||
updateTab({ columns: newColumns });
|
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) })}
|
onRemove={() => updateTab({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||||
onShowInSummaryChange={(checked) => {
|
onShowInSummaryChange={(checked) => {
|
||||||
const newColumns = [...selectedColumns];
|
const newColumns = [...selectedColumns];
|
||||||
|
|
@ -2330,6 +2353,18 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
};
|
};
|
||||||
updateLeftPanel({ columns: newColumns });
|
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={() =>
|
onRemove={() =>
|
||||||
updateLeftPanel({ columns: selectedColumns.filter((_, i) => i !== index) })
|
updateLeftPanel({ columns: selectedColumns.filter((_, i) => i !== index) })
|
||||||
}
|
}
|
||||||
|
|
@ -2988,6 +3023,18 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
};
|
};
|
||||||
updateRightPanel({ columns: newColumns });
|
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={() =>
|
onRemove={() =>
|
||||||
updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })
|
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 } };
|
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
|
||||||
updateLeftPanel({ columns: newColumns });
|
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) })}
|
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 } };
|
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
|
||||||
updateRightPanel({ columns: newColumns });
|
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) })}
|
onRemove={() => updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||||
onShowInSummaryChange={(checked) => {
|
onShowInSummaryChange={(checked) => {
|
||||||
const newColumns = [...selectedColumns];
|
const newColumns = [...selectedColumns];
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { Check, ChevronsUpDown, GripVertical, Link2, X } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function SortableColumnRow({
|
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;
|
id: string;
|
||||||
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean };
|
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean };
|
||||||
|
|
@ -23,6 +23,7 @@ export function SortableColumnRow({
|
||||||
onLabelChange: (value: string) => void;
|
onLabelChange: (value: string) => void;
|
||||||
onWidthChange: (value: number) => void;
|
onWidthChange: (value: number) => void;
|
||||||
onFormatChange: (checked: boolean) => void;
|
onFormatChange: (checked: boolean) => void;
|
||||||
|
onSuffixChange?: (value: string) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onShowInSummaryChange?: (checked: boolean) => void;
|
onShowInSummaryChange?: (checked: boolean) => void;
|
||||||
onShowInDetailChange?: (checked: boolean) => void;
|
onShowInDetailChange?: (checked: boolean) => void;
|
||||||
|
|
@ -61,6 +62,7 @@ export function SortableColumnRow({
|
||||||
className="h-6 w-14 shrink-0 text-xs"
|
className="h-6 w-14 shrink-0 text-xs"
|
||||||
/>
|
/>
|
||||||
{isNumeric && (
|
{isNumeric && (
|
||||||
|
<>
|
||||||
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
|
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -70,6 +72,14 @@ export function SortableColumnRow({
|
||||||
/>
|
/>
|
||||||
,
|
,
|
||||||
</label>
|
</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]"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{onShowInSummaryChange && (
|
{onShowInSummaryChange && (
|
||||||
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="테이블 헤더에 표시">
|
<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",
|
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)
|
// 🆕 modalDataStore에 선택된 데이터 자동 저장 (테이블명 기반 dataSourceId)
|
||||||
if (tableConfig.selectedTable && selectedRowsData.length > 0) {
|
if (tableConfig.selectedTable && selectedRowsData.length > 0) {
|
||||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ const TabsDesignEditor: React.FC<{
|
||||||
onUpdateComponent?: (updatedComponent: any) => void;
|
onUpdateComponent?: (updatedComponent: any) => void;
|
||||||
onSelectTabComponent?: (tabId: string, compId: string, comp: TabInlineComponent) => void;
|
onSelectTabComponent?: (tabId: string, compId: string, comp: TabInlineComponent) => void;
|
||||||
selectedTabComponentId?: string;
|
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 [activeTabId, setActiveTabId] = useState<string>(tabs[0]?.id || "");
|
||||||
const [draggingCompId, setDraggingCompId] = useState<string | null>(null);
|
const [draggingCompId, setDraggingCompId] = useState<string | null>(null);
|
||||||
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | 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 isDragging = draggingCompId === comp.id;
|
||||||
const isResizing = resizingCompId === comp.id;
|
const isResizing = resizingCompId === comp.id;
|
||||||
|
|
||||||
// 드래그/리사이즈 중 표시할 크기
|
|
||||||
// resizeSize가 있고 해당 컴포넌트이면 resizeSize 우선 사용 (레이아웃 업데이트 반영 전까지)
|
|
||||||
const compWidth = comp.size?.width || 200;
|
const compWidth = comp.size?.width || 200;
|
||||||
const compHeight = comp.size?.height || 100;
|
const compHeight = comp.size?.height || 100;
|
||||||
const isResizingThis = (resizingCompId === comp.id || lastResizedCompId === comp.id) && resizeSize;
|
const isResizingThis = (resizingCompId === comp.id || lastResizedCompId === comp.id) && resizeSize;
|
||||||
const displayWidth = isResizingThis ? resizeSize!.width : compWidth;
|
const displayWidth = isResizingThis ? resizeSize!.width : compWidth;
|
||||||
const displayHeight = isResizingThis ? resizeSize!.height : compHeight;
|
const displayHeight = isResizingThis ? resizeSize!.height : compHeight;
|
||||||
|
|
||||||
// 컴포넌트 데이터를 DynamicComponentRenderer 형식으로 변환
|
|
||||||
const componentData = {
|
const componentData = {
|
||||||
id: comp.id,
|
id: comp.id,
|
||||||
type: "component" as const,
|
type: "component" as const,
|
||||||
|
|
@ -344,7 +342,6 @@ const TabsDesignEditor: React.FC<{
|
||||||
style: comp.style || {},
|
style: comp.style || {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 드래그 중인 컴포넌트는 dragPosition 사용, 아니면 저장된 position 사용
|
|
||||||
const displayX = isDragging && dragPosition ? dragPosition.x : (comp.position?.x || 0);
|
const displayX = isDragging && dragPosition ? dragPosition.x : (comp.position?.x || 0);
|
||||||
const displayY = isDragging && dragPosition ? dragPosition.y : (comp.position?.y || 0);
|
const displayY = isDragging && dragPosition ? dragPosition.y : (comp.position?.y || 0);
|
||||||
|
|
||||||
|
|
@ -417,12 +414,43 @@ const TabsDesignEditor: React.FC<{
|
||||||
width: displayWidth,
|
width: displayWidth,
|
||||||
height: displayHeight,
|
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
|
<DynamicComponentRenderer
|
||||||
component={componentData as any}
|
component={componentData as any}
|
||||||
isDesignMode={true}
|
isDesignMode={true}
|
||||||
formData={{}}
|
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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -483,6 +511,7 @@ const TabsWidgetWrapper: React.FC<any> = (props) => {
|
||||||
onUpdateComponent,
|
onUpdateComponent,
|
||||||
onSelectTabComponent,
|
onSelectTabComponent,
|
||||||
selectedTabComponentId,
|
selectedTabComponentId,
|
||||||
|
onNestedPanelSelect,
|
||||||
...restProps
|
...restProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
|
@ -499,6 +528,7 @@ const TabsWidgetWrapper: React.FC<any> = (props) => {
|
||||||
onUpdateComponent={onUpdateComponent}
|
onUpdateComponent={onUpdateComponent}
|
||||||
onSelectTabComponent={onSelectTabComponent}
|
onSelectTabComponent={onSelectTabComponent}
|
||||||
selectedTabComponentId={selectedTabComponentId}
|
selectedTabComponentId={selectedTabComponentId}
|
||||||
|
onNestedPanelSelect={onNestedPanelSelect}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue