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:
kjs 2026-03-12 14:25:56 +09:00
commit d239c9e88e
35 changed files with 1415 additions and 759 deletions

View File

@ -1854,7 +1854,7 @@ export async function toggleMenuStatus(
// 현재 상태 및 회사 코드 조회
const currentMenu = await queryOne<any>(
`SELECT objid, status, company_code FROM menu_info WHERE objid = $1`,
`SELECT objid, status, company_code, menu_name_kor FROM menu_info WHERE objid = $1`,
[Number(menuId)]
);

View File

@ -1,6 +1,6 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
import { auditLogService } from "../services/auditLogService";
import { auditLogService, getClientIp, AuditAction, AuditResourceType } from "../services/auditLogService";
import { query } from "../database/db";
import logger from "../utils/logger";
@ -137,3 +137,40 @@ export const getAuditLogUsers = async (
});
}
};
/**
* ( )
*/
export const createAuditLog = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { action, resourceType, resourceId, resourceName, tableName, summary, changes } = req.body;
if (!action || !resourceType) {
res.status(400).json({ success: false, message: "action, resourceType은 필수입니다." });
return;
}
await auditLogService.log({
companyCode: req.user?.companyCode || "",
userId: req.user?.userId || "",
userName: req.user?.userName || "",
action: action as AuditAction,
resourceType: resourceType as AuditResourceType,
resourceId: resourceId || undefined,
resourceName: resourceName || undefined,
tableName: tableName || undefined,
summary: summary || undefined,
changes: changes || undefined,
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({ success: true });
} catch (error: any) {
logger.error("감사 로그 기록 실패", { error: error.message });
res.status(500).json({ success: false, message: "감사 로그 기록 실패" });
}
};

View File

@ -6,6 +6,7 @@ import { Router, Request, Response } from "express";
import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService";
import { logger } from "../utils/logger";
import { authenticateToken } from "../middleware/authMiddleware";
import { auditLogService, getClientIp } from "../services/auditLogService";
const router = Router();
@ -16,6 +17,7 @@ router.use(authenticateToken);
interface AuthenticatedRequest extends Request {
user?: {
userId: string;
userName: string;
companyCode: string;
};
}
@ -157,6 +159,21 @@ router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => {
const value = await categoryTreeService.createCategoryValue(companyCode, input, createdBy);
auditLogService.log({
companyCode,
userId: req.user?.userId || "",
userName: req.user?.userName,
action: "CREATE",
resourceType: "CODE_CATEGORY",
resourceId: String(value.valueId),
resourceName: input.valueLabel,
tableName: "category_values",
summary: `카테고리 값 "${input.valueLabel}" 생성 (${input.tableName}.${input.columnName})`,
changes: { after: { tableName: input.tableName, columnName: input.columnName, valueCode: input.valueCode, valueLabel: input.valueLabel } },
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({
success: true,
data: value,
@ -182,6 +199,7 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
const companyCode = req.user?.companyCode || "*";
const updatedBy = req.user?.userId;
const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy);
if (!value) {
@ -191,6 +209,24 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
});
}
auditLogService.log({
companyCode,
userId: req.user?.userId || "",
userName: req.user?.userName,
action: "UPDATE",
resourceType: "CODE_CATEGORY",
resourceId: valueId,
resourceName: value.valueLabel,
tableName: "category_values",
summary: `카테고리 값 "${value.valueLabel}" 수정 (${value.tableName}.${value.columnName})`,
changes: {
before: beforeValue ? { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode } : undefined,
after: input,
},
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({
success: true,
data: value,
@ -239,6 +275,7 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
const { valueId } = req.params;
const companyCode = req.user?.companyCode || "*";
const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId));
if (!success) {
@ -248,6 +285,21 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
});
}
auditLogService.log({
companyCode,
userId: req.user?.userId || "",
userName: req.user?.userName,
action: "DELETE",
resourceType: "CODE_CATEGORY",
resourceId: valueId,
resourceName: beforeValue?.valueLabel || valueId,
tableName: "category_values",
summary: `카테고리 값 "${beforeValue?.valueLabel || valueId}" 삭제 (${beforeValue?.tableName || ""}.${beforeValue?.columnName || ""})`,
changes: beforeValue ? { before: { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode, tableName: beforeValue.tableName, columnName: beforeValue.columnName } } : undefined,
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({
success: true,
message: "삭제되었습니다",

View File

@ -396,6 +396,20 @@ export class CommonCodeController {
companyCode
);
auditLogService.log({
companyCode: companyCode || "",
userId: userId || "",
action: "UPDATE",
resourceType: "CODE",
resourceId: codeValue,
resourceName: codeData.codeName || codeValue,
tableName: "code_info",
summary: `코드 "${categoryCode}.${codeValue}" 수정`,
changes: { after: codeData },
ipAddress: getClientIp(req as any),
requestPath: req.originalUrl,
});
return res.json({
success: true,
data: code,
@ -440,6 +454,19 @@ export class CommonCodeController {
companyCode
);
auditLogService.log({
companyCode: companyCode || "",
userId: req.user?.userId || "",
action: "DELETE",
resourceType: "CODE",
resourceId: codeValue,
tableName: "code_info",
summary: `코드 "${categoryCode}.${codeValue}" 삭제`,
changes: { before: { categoryCode, codeValue } },
ipAddress: getClientIp(req as any),
requestPath: req.originalUrl,
});
return res.json({
success: true,
message: "코드 삭제 성공",

View File

@ -438,6 +438,19 @@ export class DDLController {
);
if (result.success) {
auditLogService.log({
companyCode: userCompanyCode || "",
userId,
action: "DELETE",
resourceType: "TABLE",
resourceId: tableName,
resourceName: tableName,
tableName,
summary: `테이블 "${tableName}" 삭제`,
ipAddress: getClientIp(req as any),
requestPath: req.originalUrl,
});
res.status(200).json({
success: true,
message: result.message,

View File

@ -193,6 +193,7 @@ router.post(
auditLogService.log({
companyCode,
userId,
userName: req.user?.userName,
action: "CREATE",
resourceType: "NUMBERING_RULE",
resourceId: String(newRule.ruleId),
@ -243,6 +244,7 @@ router.put(
auditLogService.log({
companyCode,
userId: req.user?.userId || "",
userName: req.user?.userName,
action: "UPDATE",
resourceType: "NUMBERING_RULE",
resourceId: ruleId,
@ -285,6 +287,7 @@ router.delete(
auditLogService.log({
companyCode,
userId: req.user?.userId || "",
userName: req.user?.userName,
action: "DELETE",
resourceType: "NUMBERING_RULE",
resourceId: ruleId,
@ -521,6 +524,56 @@ router.post(
companyCode,
userId
);
const isUpdate = !!ruleConfig.ruleId;
const resetPeriodLabel: Record<string, string> = {
none: "초기화 안함", daily: "일별", monthly: "월별", yearly: "연별",
};
const partTypeLabel: Record<string, string> = {
sequence: "순번", number: "숫자", date: "날짜", text: "문자", category: "카테고리", reference: "참조",
};
const partsDescription = (ruleConfig.parts || [])
.sort((a: any, b: any) => (a.order || 0) - (b.order || 0))
.map((p: any) => {
const type = partTypeLabel[p.partType] || p.partType;
if (p.partType === "text" && p.autoConfig?.textValue) return `${type}("${p.autoConfig.textValue}")`;
if (p.partType === "sequence" && p.autoConfig?.sequenceLength) return `${type}(${p.autoConfig.sequenceLength}자리)`;
if (p.partType === "date" && p.autoConfig?.dateFormat) return `${type}(${p.autoConfig.dateFormat})`;
if (p.partType === "category") return `${type}(${p.autoConfig?.categoryKey || ""})`;
if (p.partType === "reference") return `${type}(${p.autoConfig?.referenceColumnName || ""})`;
return type;
})
.join(` ${ruleConfig.separator || "-"} `);
auditLogService.log({
companyCode,
userId,
userName: req.user?.userName,
action: isUpdate ? "UPDATE" : "CREATE",
resourceType: "NUMBERING_RULE",
resourceId: String(savedRule.ruleId),
resourceName: ruleConfig.ruleName,
tableName: "numbering_rules",
summary: isUpdate
? `채번 규칙 "${ruleConfig.ruleName}" 수정`
: `채번 규칙 "${ruleConfig.ruleName}" 생성`,
changes: {
after: {
규칙명: ruleConfig.ruleName,
적용테이블: ruleConfig.tableName || "(미지정)",
적용컬럼: ruleConfig.columnName || "(미지정)",
구분자: ruleConfig.separator || "-",
리셋주기: resetPeriodLabel[ruleConfig.resetPeriod] || ruleConfig.resetPeriod || "초기화 안함",
적용범위: ruleConfig.scopeType === "menu" ? "메뉴별" : "전역",
코드구성: partsDescription || "(파트 없음)",
: (ruleConfig.parts || []).length,
},
},
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
return res.json({ success: true, data: savedRule });
} catch (error: any) {
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
@ -535,10 +588,25 @@ router.delete(
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { ruleId } = req.params;
try {
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
auditLogService.log({
companyCode,
userId,
userName: req.user?.userName,
action: "DELETE",
resourceType: "NUMBERING_RULE",
resourceId: ruleId,
tableName: "numbering_rules",
summary: `채번 규칙(ID:${ruleId}) 삭제`,
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
return res.json({
success: true,
message: "테스트 채번 규칙이 삭제되었습니다",

View File

@ -614,20 +614,6 @@ export const copyScreenWithModals = async (
modalScreens: modalScreens || [],
});
auditLogService.log({
companyCode: targetCompanyCode || companyCode,
userId: userId || "",
userName: (req.user as any)?.userName || "",
action: "COPY",
resourceType: "SCREEN",
resourceId: id,
resourceName: mainScreen?.screenName,
summary: `화면 일괄 복사 (메인 1개 + 모달 ${result.modalScreens.length}개, 원본 ID:${id})`,
changes: { after: { sourceScreenId: id, targetCompanyCode, mainScreenName: mainScreen?.screenName } },
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({
success: true,
data: result,
@ -663,20 +649,6 @@ export const copyScreen = async (
}
);
auditLogService.log({
companyCode,
userId: userId || "",
userName: (req.user as any)?.userName || "",
action: "COPY",
resourceType: "SCREEN",
resourceId: String(copiedScreen?.screenId || ""),
resourceName: screenName,
summary: `화면 "${screenName}" 복사 (원본 ID:${id})`,
changes: { after: { sourceScreenId: id, screenName, screenCode } },
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({
success: true,
data: copiedScreen,

View File

@ -963,6 +963,15 @@ export async function addTableData(
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
const systemFields = new Set([
"id", "created_date", "updated_date", "writer", "company_code",
"createdDate", "updatedDate", "companyCode",
]);
const auditData: Record<string, any> = {};
for (const [k, v] of Object.entries(data)) {
if (!systemFields.has(k)) auditData[k] = v;
}
auditLogService.log({
companyCode: req.user?.companyCode || "",
userId: req.user?.userId || "",
@ -973,7 +982,7 @@ export async function addTableData(
resourceName: tableName,
tableName,
summary: `${tableName} 데이터 추가`,
changes: { after: data },
changes: { after: auditData },
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
@ -1096,10 +1105,14 @@ export async function editTableData(
return;
}
// 변경된 필드만 추출
const systemFieldsForEdit = new Set([
"id", "created_date", "updated_date", "writer", "company_code",
"createdDate", "updatedDate", "companyCode",
]);
const changedBefore: Record<string, any> = {};
const changedAfter: Record<string, any> = {};
for (const key of Object.keys(updatedData)) {
if (systemFieldsForEdit.has(key)) continue;
if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) {
changedBefore[key] = originalData[key];
changedAfter[key] = updatedData[key];

View File

@ -1,11 +1,12 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { getAuditLogs, getAuditLogStats, getAuditLogUsers } from "../controllers/auditLogController";
import { getAuditLogs, getAuditLogStats, getAuditLogUsers, createAuditLog } from "../controllers/auditLogController";
const router = Router();
router.get("/", authenticateToken, getAuditLogs);
router.get("/stats", authenticateToken, getAuditLogStats);
router.get("/users", authenticateToken, getAuditLogUsers);
router.post("/", authenticateToken, createAuditLog);
export default router;

View File

@ -66,6 +66,7 @@ export interface AuditLogParams {
export interface AuditLogEntry {
id: number;
company_code: string;
company_name: string | null;
user_id: string;
user_name: string | null;
action: string;
@ -107,6 +108,7 @@ class AuditLogService {
*/
async log(params: AuditLogParams): Promise<void> {
try {
logger.info(`[AuditLog] 기록 시도: ${params.resourceType} / ${params.action} / ${params.resourceName || params.resourceId || "N/A"}`);
await query(
`INSERT INTO system_audit_log
(company_code, user_id, user_name, action, resource_type,
@ -128,8 +130,9 @@ class AuditLogService {
params.requestPath || null,
]
);
} catch (error) {
logger.error("감사 로그 기록 실패 (무시됨)", { error, params });
logger.info(`[AuditLog] 기록 성공: ${params.resourceType} / ${params.action}`);
} catch (error: any) {
logger.error(`[AuditLog] 기록 실패: ${params.resourceType} / ${params.action} - ${error?.message}`, { error, params });
}
}
@ -186,40 +189,40 @@ class AuditLogService {
let paramIndex = 1;
if (!isSuperAdmin && filters.companyCode) {
conditions.push(`company_code = $${paramIndex++}`);
conditions.push(`sal.company_code = $${paramIndex++}`);
params.push(filters.companyCode);
} else if (isSuperAdmin && filters.companyCode) {
conditions.push(`company_code = $${paramIndex++}`);
conditions.push(`sal.company_code = $${paramIndex++}`);
params.push(filters.companyCode);
}
if (filters.userId) {
conditions.push(`user_id = $${paramIndex++}`);
conditions.push(`sal.user_id = $${paramIndex++}`);
params.push(filters.userId);
}
if (filters.resourceType) {
conditions.push(`resource_type = $${paramIndex++}`);
conditions.push(`sal.resource_type = $${paramIndex++}`);
params.push(filters.resourceType);
}
if (filters.action) {
conditions.push(`action = $${paramIndex++}`);
conditions.push(`sal.action = $${paramIndex++}`);
params.push(filters.action);
}
if (filters.tableName) {
conditions.push(`table_name = $${paramIndex++}`);
conditions.push(`sal.table_name = $${paramIndex++}`);
params.push(filters.tableName);
}
if (filters.dateFrom) {
conditions.push(`created_at >= $${paramIndex++}::timestamptz`);
conditions.push(`sal.created_at >= $${paramIndex++}::timestamptz`);
params.push(filters.dateFrom);
}
if (filters.dateTo) {
conditions.push(`created_at <= $${paramIndex++}::timestamptz`);
conditions.push(`sal.created_at <= $${paramIndex++}::timestamptz`);
params.push(filters.dateTo);
}
if (filters.search) {
conditions.push(
`(summary ILIKE $${paramIndex} OR resource_name ILIKE $${paramIndex} OR user_name ILIKE $${paramIndex})`
`(sal.summary ILIKE $${paramIndex} OR sal.resource_name ILIKE $${paramIndex} OR sal.user_name ILIKE $${paramIndex})`
);
params.push(`%${filters.search}%`);
paramIndex++;
@ -233,14 +236,17 @@ class AuditLogService {
const offset = (page - 1) * limit;
const countResult = await query<{ count: string }>(
`SELECT COUNT(*) as count FROM system_audit_log ${whereClause}`,
`SELECT COUNT(*) as count FROM system_audit_log sal ${whereClause}`,
params
);
const total = parseInt(countResult[0].count, 10);
const data = await query<AuditLogEntry>(
`SELECT * FROM system_audit_log ${whereClause}
ORDER BY created_at DESC
`SELECT sal.*, ci.company_name
FROM system_audit_log sal
LEFT JOIN company_mng ci ON sal.company_code = ci.company_code
${whereClause}
ORDER BY sal.created_at DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
[...params, limit, offset]
);

View File

@ -77,14 +77,12 @@ const RESOURCE_TYPE_CONFIG: Record<
NODE_FLOW: { label: "플로우 제어", icon: GitBranch, color: "bg-teal-100 text-teal-700" },
USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" },
ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
PERMISSION: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" },
TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" },
NUMBERING_RULE: { label: "채번 규칙", icon: FileText, color: "bg-amber-100 text-amber-700" },
BATCH: { label: "배치", icon: RefreshCw, color: "bg-teal-100 text-teal-700" },
};
const ACTION_CONFIG: Record<string, { label: string; color: string }> = {
@ -817,7 +815,7 @@ export default function AuditLogPage() {
</Badge>
{entry.company_code && entry.company_code !== "*" && (
<span className="text-muted-foreground text-[10px]">
[{entry.company_code}]
[{entry.company_name || entry.company_code}]
</span>
)}
</div>
@ -862,9 +860,11 @@ export default function AuditLogPage() {
</div>
<div>
<label className="text-muted-foreground text-xs">
</label>
<p className="font-medium">{selectedEntry.company_code}</p>
<p className="font-medium">
{selectedEntry.company_name || selectedEntry.company_code}
</p>
</div>
<div>
<label className="text-muted-foreground text-xs">

View File

@ -98,10 +98,43 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const savedMode = localStorage.getItem("screenModal_continuousMode");
if (savedMode === "true") {
setContinuousMode(true);
// console.log("🔄 연속 모드 복원: true");
}
}, []);
// dataBinding: 테이블 선택 시 바인딩된 input의 formData를 자동 업데이트
useEffect(() => {
if (!modalState.isOpen || !screenData?.components?.length) return;
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail?.source || !detail?.data) return;
const bindingUpdates: Record<string, any> = {};
for (const comp of screenData.components) {
const db =
comp.componentConfig?.dataBinding ||
(comp as any).dataBinding;
if (!db?.sourceComponentId || !db?.sourceColumn) continue;
if (db.sourceComponentId !== detail.source) continue;
const colName = (comp as any).columnName || comp.componentConfig?.columnName;
if (!colName) continue;
const selectedRow = detail.data[0];
const value = selectedRow?.[db.sourceColumn] ?? "";
bindingUpdates[colName] = value;
}
if (Object.keys(bindingUpdates).length > 0) {
setFormData((prev) => ({ ...prev, ...bindingUpdates }));
formDataChangedRef.current = true;
}
};
window.addEventListener("v2-table-selection", handler);
return () => window.removeEventListener("v2-table-selection", handler);
}, [modalState.isOpen, screenData?.components]);
// 화면의 실제 크기 계산 함수
const calculateScreenDimensions = (components: ComponentData[]) => {
if (components.length === 0) {

View File

@ -1165,6 +1165,28 @@ export default function CopyScreenModal({
}
}
// 그룹 복제 요약 감사 로그 1건 기록
try {
await apiClient.post("/audit-log", {
action: "COPY",
resourceType: "SCREEN",
resourceId: String(sourceGroup.id),
resourceName: sourceGroup.group_name,
summary: `그룹 "${sourceGroup.group_name}" → "${rootGroupName}" 복제 (그룹 ${stats.groups}개, 화면 ${stats.screens}개)${finalCompanyCode !== sourceGroup.company_code ? ` [${sourceGroup.company_code}${finalCompanyCode}]` : ""}`,
changes: {
after: {
원본그룹: sourceGroup.group_name,
대상그룹: rootGroupName,
복제그룹수: stats.groups,
복제화면수: stats.screens,
대상회사: finalCompanyCode,
},
},
});
} catch (auditError) {
console.warn("그룹 복제 감사 로그 기록 실패 (무시):", auditError);
}
toast.success(
`그룹 복제가 완료되었습니다! (그룹 ${stats.groups}개 + 화면 ${stats.screens}개)`
);

View File

@ -47,6 +47,7 @@ interface RealtimePreviewProps {
selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; // 🆕 분할 패널 내부 컴포넌트 선택 콜백
selectedPanelComponentId?: string; // 🆕 선택된 분할 패널 컴포넌트 ID
onNestedPanelSelect?: (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => void;
onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백
// 버튼 액션을 위한 props
@ -150,6 +151,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백
selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID
onNestedPanelSelect,
onResize, // 🆕 리사이즈 콜백
}) => {
// 🆕 화면 다국어 컨텍스트
@ -768,6 +770,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
selectedTabComponentId={selectedTabComponentId}
onSelectPanelComponent={onSelectPanelComponent}
selectedPanelComponentId={selectedPanelComponentId}
onNestedPanelSelect={onNestedPanelSelect}
/>
</div>

View File

@ -109,6 +109,8 @@ interface ProcessedRow {
mainComponent?: ComponentData;
overlayComps: ComponentData[];
normalComps: ComponentData[];
rowMinY?: number;
rowMaxBottom?: number;
}
function FullWidthOverlayRow({
@ -227,6 +229,10 @@ export function ResponsiveGridRenderer({
}
}
const allComps = [...fullWidthComps, ...normalComps];
const rowMinY = allComps.length > 0 ? Math.min(...allComps.map(c => c.position.y)) : 0;
const rowMaxBottom = allComps.length > 0 ? Math.max(...allComps.map(c => c.position.y + (c.size?.height || 40))) : 0;
if (fullWidthComps.length > 0 && normalComps.length > 0) {
for (const fwComp of fullWidthComps) {
processedRows.push({
@ -234,6 +240,8 @@ export function ResponsiveGridRenderer({
mainComponent: fwComp,
overlayComps: normalComps,
normalComps: [],
rowMinY,
rowMaxBottom,
});
}
} else if (fullWidthComps.length > 0) {
@ -243,6 +251,8 @@ export function ResponsiveGridRenderer({
mainComponent: fwComp,
overlayComps: [],
normalComps: [],
rowMinY,
rowMaxBottom,
});
}
} else {
@ -250,6 +260,8 @@ export function ResponsiveGridRenderer({
type: "normal",
overlayComps: [],
normalComps,
rowMinY,
rowMaxBottom,
});
}
}
@ -261,15 +273,26 @@ export function ResponsiveGridRenderer({
style={{ minHeight: "200px" }}
>
{processedRows.map((processedRow, rowIndex) => {
const rowMarginTop = (() => {
if (rowIndex === 0) return 0;
const prevRow = processedRows[rowIndex - 1];
const prevBottom = prevRow.rowMaxBottom ?? 0;
const currTop = processedRow.rowMinY ?? 0;
const designGap = currTop - prevBottom;
if (designGap <= 0) return 0;
return Math.min(Math.max(Math.round(designGap * 0.5), 4), 48);
})();
if (processedRow.type === "fullwidth" && processedRow.mainComponent) {
return (
<FullWidthOverlayRow
key={`row-${rowIndex}`}
main={processedRow.mainComponent}
overlayComps={processedRow.overlayComps}
canvasWidth={canvasWidth}
renderComponent={renderComponent}
/>
<div key={`row-${rowIndex}`} style={{ marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}>
<FullWidthOverlayRow
main={processedRow.mainComponent}
overlayComps={processedRow.overlayComps}
canvasWidth={canvasWidth}
renderComponent={renderComponent}
/>
</div>
);
}
@ -290,7 +313,7 @@ export function ResponsiveGridRenderer({
allButtons && "justify-end px-2 py-1",
hasFlexHeightComp ? "min-h-0 flex-1" : "flex-shrink-0"
)}
style={{ gap: `${gap}px` }}
style={{ gap: `${gap}px`, marginTop: rowMarginTop > 0 ? `${rowMarginTop}px` : undefined }}
>
{normalComps.map((component) => {
const typeId = getComponentTypeId(component);
@ -337,10 +360,10 @@ export function ResponsiveGridRenderer({
flexGrow: 1,
flexShrink: 1,
minWidth: isMobile ? "100%" : undefined,
minHeight: useFlexHeight ? "300px" : undefined,
height: useFlexHeight ? "100%" : (component.size?.height
minHeight: useFlexHeight ? "300px" : (component.size?.height
? `${component.size.height}px`
: "auto"),
: undefined),
height: useFlexHeight ? "100%" : "auto",
}}
>
{renderComponent(component)}

View File

@ -2861,9 +2861,190 @@ export default function ScreenDesigner({
}
}
// 🎯 탭 컨테이너 내부 드롭 처리 (중첩 구조 지원)
// 🎯 컨테이너 드롭 우선순위: 가장 안쪽(innermost) 컨테이너 우선
// 분할패널과 탭 둘 다 감지될 경우, DOM 트리에서 더 가까운 쪽을 우선 처리
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
if (tabsContainer) {
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
// 분할패널이 탭보다 안쪽에 있으면 분할패널 우선 처리
const splitPanelFirst =
splitPanelContainer &&
(!tabsContainer || tabsContainer.contains(splitPanelContainer));
if (splitPanelFirst && splitPanelContainer) {
const containerId = splitPanelContainer.getAttribute("data-component-id");
const panelSide = splitPanelContainer.getAttribute("data-panel-side");
if (containerId && panelSide) {
// 분할 패널을 최상위 또는 중첩(탭 안)에서 찾기
let targetComponent: any = layout.components.find((c) => c.id === containerId);
let parentTabsId: string | null = null;
let parentTabId: string | null = null;
let parentSplitId: string | null = null;
let parentSplitSide: string | null = null;
if (!targetComponent) {
// 탭 안에 중첩된 분할패널 찾기
// top-level: overrides.type / overrides.tabs
// nested: componentType / componentConfig.tabs
for (const comp of layout.components) {
const compType = (comp as any)?.componentType || (comp as any)?.overrides?.type;
const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
if (compType === "tabs-widget" || compType === "v2-tabs-widget") {
const tabs = compConfig.tabs || [];
for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId);
if (found) {
targetComponent = found;
parentTabsId = comp.id;
parentTabId = tab.id;
break;
}
}
if (targetComponent) break;
}
if (compType === "split-panel-layout" || compType === "v2-split-panel-layout") {
for (const side of ["leftPanel", "rightPanel"] as const) {
const panelComps = compConfig[side]?.components || [];
for (const pc of panelComps) {
const pct = pc.componentType || pc.overrides?.type;
if (pct === "tabs-widget" || pct === "v2-tabs-widget") {
const tabs = (pc.componentConfig || pc.overrides || {}).tabs || [];
for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId);
if (found) {
targetComponent = found;
parentSplitId = comp.id;
parentSplitSide = side === "leftPanel" ? "left" : "right";
parentTabsId = pc.id;
parentTabId = tab.id;
break;
}
}
if (targetComponent) break;
}
}
if (targetComponent) break;
}
if (targetComponent) break;
}
}
}
const compType = (targetComponent as any)?.componentType;
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
const currentConfig = (targetComponent as any).componentConfig || {};
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
const panelConfig = currentConfig[panelKey] || {};
const currentComponents = panelConfig.components || [];
const panelRect = splitPanelContainer.getBoundingClientRect();
const cs1 = window.getComputedStyle(splitPanelContainer);
const dropX = (e.clientX - panelRect.left - (parseFloat(cs1.paddingLeft) || 0)) / zoomLevel;
const dropY = (e.clientY - panelRect.top - (parseFloat(cs1.paddingTop) || 0)) / zoomLevel;
const componentType = component.id || component.componentType || "v2-text-display";
const newPanelComponent = {
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
componentType: componentType,
label: component.name || component.label || "새 컴포넌트",
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
size: component.defaultSize || { width: 200, height: 100 },
componentConfig: component.defaultConfig || {},
};
const updatedPanelConfig = {
...panelConfig,
components: [...currentComponents, newPanelComponent],
};
const updatedSplitPanel = {
...targetComponent,
componentConfig: {
...currentConfig,
[panelKey]: updatedPanelConfig,
},
};
let newLayout;
if (parentTabsId && parentTabId) {
// 중첩: (최상위 분할패널 →) 탭 → 분할패널
const updateTabsComponent = (tabsComp: any) => {
const ck = tabsComp.componentConfig ? "componentConfig" : "overrides";
const cfg = tabsComp[ck] || {};
const tabs = cfg.tabs || [];
return {
...tabsComp,
[ck]: {
...cfg,
tabs: tabs.map((tab: any) =>
tab.id === parentTabId
? {
...tab,
components: (tab.components || []).map((c: any) =>
c.id === containerId ? updatedSplitPanel : c,
),
}
: tab,
),
},
};
};
if (parentSplitId && parentSplitSide) {
// 최상위 분할패널 → 탭 → 분할패널
const pKey = parentSplitSide === "left" ? "leftPanel" : "rightPanel";
newLayout = {
...layout,
components: layout.components.map((c) => {
if (c.id === parentSplitId) {
const sc = (c as any).componentConfig || {};
return {
...c,
componentConfig: {
...sc,
[pKey]: {
...sc[pKey],
components: (sc[pKey]?.components || []).map((pc: any) =>
pc.id === parentTabsId ? updateTabsComponent(pc) : pc,
),
},
},
};
}
return c;
}),
};
} else {
// 최상위 탭 → 분할패널
newLayout = {
...layout,
components: layout.components.map((c) =>
c.id === parentTabsId ? updateTabsComponent(c) : c,
),
};
}
} else {
// 최상위 분할패널
newLayout = {
...layout,
components: layout.components.map((c) => (c.id === containerId ? updatedSplitPanel : c)),
};
}
setLayout(newLayout);
saveToHistory(newLayout);
toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`);
return;
}
}
}
if (tabsContainer && !splitPanelFirst) {
const containerId = tabsContainer.getAttribute("data-component-id");
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
if (containerId && activeTabId) {
@ -3004,69 +3185,6 @@ export default function ScreenDesigner({
}
}
// 🎯 분할 패널 커스텀 모드 컨테이너 내부 드롭 처리
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
if (splitPanelContainer) {
const containerId = splitPanelContainer.getAttribute("data-component-id");
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
if (containerId && panelSide) {
const targetComponent = layout.components.find((c) => c.id === containerId);
const compType = (targetComponent as any)?.componentType;
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
const currentConfig = (targetComponent as any).componentConfig || {};
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
const panelConfig = currentConfig[panelKey] || {};
const currentComponents = panelConfig.components || [];
// 드롭 위치 계산
const panelRect = splitPanelContainer.getBoundingClientRect();
const dropX = (e.clientX - panelRect.left) / zoomLevel;
const dropY = (e.clientY - panelRect.top) / zoomLevel;
// 새 컴포넌트 생성
const componentType = component.id || component.componentType || "v2-text-display";
console.log("🎯 분할 패널에 컴포넌트 드롭:", {
componentId: component.id,
componentType: componentType,
panelSide: panelSide,
dropPosition: { x: dropX, y: dropY },
});
const newPanelComponent = {
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
componentType: componentType,
label: component.name || component.label || "새 컴포넌트",
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
size: component.defaultSize || { width: 200, height: 100 },
componentConfig: component.defaultConfig || {},
};
const updatedPanelConfig = {
...panelConfig,
components: [...currentComponents, newPanelComponent],
};
const updatedComponent = {
...targetComponent,
componentConfig: {
...currentConfig,
[panelKey]: updatedPanelConfig,
},
};
const newLayout = {
...layout,
components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)),
};
setLayout(newLayout);
saveToHistory(newLayout);
toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`);
return; // 분할 패널 처리 완료
}
}
}
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
@ -3378,15 +3496,12 @@ export default function ScreenDesigner({
e.preventDefault();
const dragData = e.dataTransfer.getData("application/json");
// console.log("🎯 드롭 이벤트:", { dragData });
if (!dragData) {
// console.log("❌ 드래그 데이터가 없습니다");
return;
}
try {
const parsedData = JSON.parse(dragData);
// console.log("📋 파싱된 데이터:", parsedData);
// 템플릿 드래그인 경우
if (parsedData.type === "template") {
@ -3480,9 +3595,225 @@ export default function ScreenDesigner({
}
}
// 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원)
// 🎯 컨테이너 감지: innermost 우선 (분할패널 > 탭)
const tabsContainer = dropTarget.closest('[data-tabs-container="true"]');
if (tabsContainer && type === "column" && column) {
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
// 분할패널이 탭 안에 있으면 분할패널이 innermost → 분할패널 우선
const splitPanelFirst =
splitPanelContainer &&
(!tabsContainer || tabsContainer.contains(splitPanelContainer));
// 🎯 분할패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리 (우선 처리)
if (splitPanelFirst && splitPanelContainer && type === "column" && column) {
const containerId = splitPanelContainer.getAttribute("data-component-id");
let panelSide = splitPanelContainer.getAttribute("data-panel-side");
// panelSide가 없으면 드롭 좌표와 splitRatio로 좌/우 판별
if (!panelSide) {
const splitRatio = parseInt(splitPanelContainer.getAttribute("data-split-ratio") || "40", 10);
const containerRect = splitPanelContainer.getBoundingClientRect();
const relativeX = e.clientX - containerRect.left;
const splitPoint = containerRect.width * (splitRatio / 100);
panelSide = relativeX < splitPoint ? "left" : "right";
}
if (containerId && panelSide) {
// 최상위에서 찾기
let targetComponent: any = layout.components.find((c) => c.id === containerId);
let parentTabsId: string | null = null;
let parentTabId: string | null = null;
let parentSplitId: string | null = null;
let parentSplitSide: string | null = null;
if (!targetComponent) {
// 탭 안 중첩 분할패널 찾기
// top-level 컴포넌트: overrides.type / overrides.tabs
// nested 컴포넌트: componentType / componentConfig.tabs
for (const comp of layout.components) {
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
const tabs = compConfig.tabs || [];
for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId);
if (found) {
targetComponent = found;
parentTabsId = comp.id;
parentTabId = tab.id;
break;
}
}
if (targetComponent) break;
}
// 분할패널 → 탭 → 분할패널 중첩
if (ct === "split-panel-layout" || ct === "v2-split-panel-layout") {
for (const side of ["leftPanel", "rightPanel"] as const) {
const panelComps = compConfig[side]?.components || [];
for (const pc of panelComps) {
const pct = pc.componentType || pc.overrides?.type;
if (pct === "tabs-widget" || pct === "v2-tabs-widget") {
const tabs = (pc.componentConfig || pc.overrides || {}).tabs || [];
for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId);
if (found) {
targetComponent = found;
parentSplitId = comp.id;
parentSplitSide = side === "leftPanel" ? "left" : "right";
parentTabsId = pc.id;
parentTabId = tab.id;
break;
}
}
if (targetComponent) break;
}
}
if (targetComponent) break;
}
if (targetComponent) break;
}
}
}
const compType = (targetComponent as any)?.componentType;
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
const currentConfig = (targetComponent as any).componentConfig || {};
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
const panelConfig = currentConfig[panelKey] || {};
const currentComponents = panelConfig.components || [];
const panelRect = splitPanelContainer.getBoundingClientRect();
const computedStyle = window.getComputedStyle(splitPanelContainer);
const padLeft = parseFloat(computedStyle.paddingLeft) || 0;
const padTop = parseFloat(computedStyle.paddingTop) || 0;
const dropX = (e.clientX - panelRect.left - padLeft) / zoomLevel;
const dropY = (e.clientY - panelRect.top - padTop) / zoomLevel;
const v2Mapping = createV2ConfigFromColumn({
widgetType: column.widgetType,
columnName: column.columnName,
columnLabel: column.columnLabel,
codeCategory: column.codeCategory,
inputType: column.inputType,
required: column.required,
detailSettings: column.detailSettings,
referenceTable: column.referenceTable,
referenceColumn: column.referenceColumn,
displayColumn: column.displayColumn,
});
const newPanelComponent = {
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
componentType: v2Mapping.componentType,
label: column.columnLabel || column.columnName,
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
size: { width: 200, height: 36 },
inputType: column.inputType || column.widgetType,
widgetType: column.widgetType,
componentConfig: {
...v2Mapping.componentConfig,
columnName: column.columnName,
tableName: column.tableName,
inputType: column.inputType || column.widgetType,
},
};
const updatedSplitPanel = {
...targetComponent,
componentConfig: {
...currentConfig,
[panelKey]: {
...panelConfig,
displayMode: "custom",
components: [...currentComponents, newPanelComponent],
},
},
};
let newLayout;
if (parentSplitId && parentSplitSide && parentTabsId && parentTabId) {
// 분할패널 → 탭 → 분할패널 3중 중첩
newLayout = {
...layout,
components: layout.components.map((c) => {
if (c.id !== parentSplitId) return c;
const sc = (c as any).componentConfig || {};
const pk = parentSplitSide === "left" ? "leftPanel" : "rightPanel";
return {
...c,
componentConfig: {
...sc,
[pk]: {
...sc[pk],
components: (sc[pk]?.components || []).map((pc: any) => {
if (pc.id !== parentTabsId) return pc;
return {
...pc,
componentConfig: {
...pc.componentConfig,
tabs: (pc.componentConfig?.tabs || []).map((tab: any) => {
if (tab.id !== parentTabId) return tab;
return {
...tab,
components: (tab.components || []).map((tc: any) =>
tc.id === containerId ? updatedSplitPanel : tc,
),
};
}),
},
};
}),
},
},
};
}),
};
} else if (parentTabsId && parentTabId) {
// 탭 → 분할패널 2중 중첩
newLayout = {
...layout,
components: layout.components.map((c) => {
if (c.id !== parentTabsId) return c;
// top-level은 overrides, nested는 componentConfig
const configKey = (c as any).componentConfig ? "componentConfig" : "overrides";
const tabsConfig = (c as any)[configKey] || {};
return {
...c,
[configKey]: {
...tabsConfig,
tabs: (tabsConfig.tabs || []).map((tab: any) => {
if (tab.id !== parentTabId) return tab;
return {
...tab,
components: (tab.components || []).map((tc: any) =>
tc.id === containerId ? updatedSplitPanel : tc,
),
};
}),
},
};
}),
};
} else {
// 최상위 분할패널
newLayout = {
...layout,
components: layout.components.map((c) => (c.id === containerId ? updatedSplitPanel : c)),
};
}
toast.success("컬럼이 분할패널에 추가되었습니다");
setLayout(newLayout);
saveToHistory(newLayout);
return;
}
}
}
// 🎯 탭 컨테이너 내부에 컬럼 드롭 시 처리 (중첩 구조 지원)
if (tabsContainer && !splitPanelFirst && type === "column" && column) {
const containerId = tabsContainer.getAttribute("data-component-id");
const activeTabId = tabsContainer.getAttribute("data-active-tab-id");
if (containerId && activeTabId) {
@ -3648,9 +3979,8 @@ export default function ScreenDesigner({
}
}
// 🎯 분할 패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
if (splitPanelContainer && type === "column" && column) {
// 🎯 분할 패널 커스텀 모드 (탭 밖 최상위) 컬럼 드롭 처리
if (splitPanelContainer && !splitPanelFirst && type === "column" && column) {
const containerId = splitPanelContainer.getAttribute("data-component-id");
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
if (containerId && panelSide) {
@ -3662,12 +3992,11 @@ export default function ScreenDesigner({
const panelConfig = currentConfig[panelKey] || {};
const currentComponents = panelConfig.components || [];
// 드롭 위치 계산
const panelRect = splitPanelContainer.getBoundingClientRect();
const dropX = (e.clientX - panelRect.left) / zoomLevel;
const dropY = (e.clientY - panelRect.top) / zoomLevel;
const cs2 = window.getComputedStyle(splitPanelContainer);
const dropX = (e.clientX - panelRect.left - (parseFloat(cs2.paddingLeft) || 0)) / zoomLevel;
const dropY = (e.clientY - panelRect.top - (parseFloat(cs2.paddingTop) || 0)) / zoomLevel;
// V2 컴포넌트 매핑 사용
const v2Mapping = createV2ConfigFromColumn({
widgetType: column.widgetType,
columnName: column.columnName,
@ -6415,15 +6744,6 @@ export default function ScreenDesigner({
const { splitPanelId, panelSide } = selectedPanelComponentInfo;
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
console.log("🔧 updatePanelComponentProperty 호출:", {
componentId,
path,
value,
splitPanelId,
panelSide,
});
// 🆕 안전한 깊은 경로 업데이트 헬퍼 함수
const setNestedValue = (obj: any, pathStr: string, val: any): any => {
const result = JSON.parse(JSON.stringify(obj));
const parts = pathStr.split(".");
@ -6440,9 +6760,27 @@ export default function ScreenDesigner({
return result;
};
// 중첩 구조 포함 분할패널 찾기 헬퍼
const findSplitPanelInLayout = (components: any[]): { found: any; path: "top" | "nested"; parentTabId?: string; parentTabTabId?: string } | null => {
const direct = components.find((c) => c.id === splitPanelId);
if (direct) return { found: direct, path: "top" };
for (const comp of components) {
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
for (const tab of (cfg.tabs || [])) {
const nested = (tab.components || []).find((c: any) => c.id === splitPanelId);
if (nested) return { found: nested, path: "nested", parentTabId: comp.id, parentTabTabId: tab.id };
}
}
}
return null;
};
setLayout((prevLayout) => {
const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId);
if (!splitPanelComponent) return prevLayout;
const result = findSplitPanelInLayout(prevLayout.components);
if (!result) return prevLayout;
const splitPanelComponent = result.found;
const currentConfig = (splitPanelComponent as any).componentConfig || {};
const panelConfig = currentConfig[panelKey] || {};
@ -6478,17 +6816,37 @@ export default function ScreenDesigner({
},
};
// selectedPanelComponentInfo 업데이트
setSelectedPanelComponentInfo((prev) =>
prev ? { ...prev, component: updatedComp } : null,
);
return {
...prevLayout,
components: prevLayout.components.map((c) =>
c.id === splitPanelId ? updatedComponent : c,
),
// 중첩 구조 반영
const applyUpdatedSplitPanel = (layout: any, updated: any, info: any) => {
if (info.path === "top") {
return { ...layout, components: layout.components.map((c: any) => c.id === splitPanelId ? updated : c) };
}
return {
...layout,
components: layout.components.map((c: any) => {
if (c.id !== info.parentTabId) return c;
const cfgKey = c.componentConfig?.tabs ? "componentConfig" : "overrides";
const cfg = c[cfgKey] || {};
return {
...c,
[cfgKey]: {
...cfg,
tabs: (cfg.tabs || []).map((t: any) =>
t.id === info.parentTabTabId
? { ...t, components: (t.components || []).map((tc: any) => tc.id === splitPanelId ? updated : tc) }
: t,
),
},
};
}),
};
};
return applyUpdatedSplitPanel(prevLayout, updatedComponent, result);
});
};
@ -6498,8 +6856,23 @@ export default function ScreenDesigner({
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
setLayout((prevLayout) => {
const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId);
if (!splitPanelComponent) return prevLayout;
const findResult = (() => {
const direct = prevLayout.components.find((c: any) => c.id === splitPanelId);
if (direct) return { found: direct, path: "top" as const };
for (const comp of prevLayout.components) {
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
for (const tab of (cfg.tabs || [])) {
const nested = (tab.components || []).find((c: any) => c.id === splitPanelId);
if (nested) return { found: nested, path: "nested" as const, parentTabId: comp.id, parentTabTabId: tab.id };
}
}
}
return null;
})();
if (!findResult) return prevLayout;
const splitPanelComponent = findResult.found;
const currentConfig = (splitPanelComponent as any).componentConfig || {};
const panelConfig = currentConfig[panelKey] || {};
@ -6520,11 +6893,27 @@ export default function ScreenDesigner({
setSelectedPanelComponentInfo(null);
if (findResult.path === "top") {
return { ...prevLayout, components: prevLayout.components.map((c: any) => c.id === splitPanelId ? updatedComponent : c) };
}
return {
...prevLayout,
components: prevLayout.components.map((c) =>
c.id === splitPanelId ? updatedComponent : c,
),
components: prevLayout.components.map((c: any) => {
if (c.id !== findResult.parentTabId) return c;
const cfgKey = c.componentConfig?.tabs ? "componentConfig" : "overrides";
const cfg = c[cfgKey] || {};
return {
...c,
[cfgKey]: {
...cfg,
tabs: (cfg.tabs || []).map((t: any) =>
t.id === findResult.parentTabTabId
? { ...t, components: (t.components || []).map((tc: any) => tc.id === splitPanelId ? updatedComponent : tc) }
: t,
),
},
};
}),
};
});
};
@ -7128,6 +7517,7 @@ export default function ScreenDesigner({
onSelectPanelComponent={(panelSide, compId, comp) =>
handleSelectPanelComponent(component.id, panelSide, compId, comp)
}
onNestedPanelSelect={handleSelectPanelComponent}
selectedPanelComponentId={
selectedPanelComponentInfo?.splitPanelId === component.id
? selectedPanelComponentInfo.componentId

View File

@ -247,6 +247,9 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
extraProps.currentTableName = currentTableName;
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
}
if (componentId === "v2-input") {
extraProps.allComponents = allComponents;
}
return (
<div key={selectedComponent.id} className="space-y-4">

View File

@ -430,28 +430,28 @@ export function TabsWidget({
return (
<ResponsiveGridRenderer
components={componentDataList}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
renderComponent={(comp) => (
<DynamicComponentRenderer
{...restProps}
component={comp}
formData={formData}
onFormDataChange={onFormDataChange}
menuObjid={menuObjid}
isDesignMode={false}
isInteractive={true}
selectedRowsData={localSelectedRowsData}
onSelectedRowsChange={handleSelectedRowsChange}
parentTabId={tab.id}
parentTabsComponentId={component.id}
{...(screenInfoMap[tab.id]
? { tableName: screenInfoMap[tab.id].tableName, screenId: screenInfoMap[tab.id].id }
: {})}
/>
)}
/>
components={componentDataList}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
renderComponent={(comp) => (
<DynamicComponentRenderer
{...restProps}
component={comp}
formData={formData}
onFormDataChange={onFormDataChange}
menuObjid={menuObjid}
isDesignMode={false}
isInteractive={true}
selectedRowsData={localSelectedRowsData}
onSelectedRowsChange={handleSelectedRowsChange}
parentTabId={tab.id}
parentTabsComponentId={component.id}
{...(screenInfoMap[tab.id]
? { tableName: screenInfoMap[tab.id].tableName, screenId: screenInfoMap[tab.id].id }
: {})}
/>
)}
/>
);
};

View File

@ -19,10 +19,11 @@ import { NumberingRuleConfig } from "@/types/numbering-rule";
interface V2InputConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
menuObjid?: number; // 메뉴 OBJID (채번 규칙 필터링용)
menuObjid?: number;
allComponents?: any[];
}
export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config, onChange, menuObjid }) => {
export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config, onChange, menuObjid, allComponents = [] }) => {
// 채번 규칙 목록 상태
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [loadingRules, setLoadingRules] = useState(false);
@ -483,73 +484,202 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
{/* 데이터 바인딩 설정 */}
<Separator className="my-2" />
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="dataBindingEnabled"
checked={!!config.dataBinding?.sourceComponentId}
onCheckedChange={(checked) => {
if (checked) {
updateConfig("dataBinding", {
sourceComponentId: config.dataBinding?.sourceComponentId || "",
sourceColumn: config.dataBinding?.sourceColumn || "",
});
} else {
updateConfig("dataBinding", undefined);
}
}}
/>
<Label htmlFor="dataBindingEnabled" className="text-xs font-semibold">
</Label>
</div>
{config.dataBinding && (
<div className="space-y-2 rounded border p-2">
<p className="text-[10px] text-muted-foreground">
v2-table-list에서
</p>
<div className="space-y-1">
<Label className="text-xs font-medium"> ID</Label>
<Input
value={config.dataBinding?.sourceComponentId || ""}
onChange={(e) => {
updateConfig("dataBinding", {
...config.dataBinding,
sourceComponentId: e.target.value,
});
}}
placeholder="예: tbl_items"
className="h-7 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
v2-table-list ID
</p>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium"> </Label>
<Input
value={config.dataBinding?.sourceColumn || ""}
onChange={(e) => {
updateConfig("dataBinding", {
...config.dataBinding,
sourceColumn: e.target.value,
});
}}
placeholder="예: item_number"
className="h-7 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
</div>
)}
</div>
<DataBindingSection config={config} onChange={onChange} allComponents={allComponents} />
</div>
);
};
V2InputConfigPanel.displayName = "V2InputConfigPanel";
/**
*
* v2-table-list
*/
function DataBindingSection({
config,
onChange,
allComponents,
}: {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
allComponents: any[];
}) {
const [tableColumns, setTableColumns] = useState<string[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
// 같은 화면의 v2-table-list 컴포넌트만 필터링
const tableListComponents = React.useMemo(() => {
return allComponents.filter((comp) => {
const type =
comp.componentType ||
comp.widgetType ||
comp.componentConfig?.type ||
(comp.url && comp.url.split("/").pop());
return type === "v2-table-list";
});
}, [allComponents]);
// 선택된 테이블 컴포넌트의 테이블명 추출
const selectedTableComponent = React.useMemo(() => {
if (!config.dataBinding?.sourceComponentId) return null;
return tableListComponents.find((comp) => comp.id === config.dataBinding.sourceComponentId);
}, [tableListComponents, config.dataBinding?.sourceComponentId]);
const selectedTableName = React.useMemo(() => {
if (!selectedTableComponent) return null;
return (
selectedTableComponent.componentConfig?.selectedTable ||
selectedTableComponent.selectedTable ||
null
);
}, [selectedTableComponent]);
// 선택된 테이블의 컬럼 목록 로드
useEffect(() => {
if (!selectedTableName) {
setTableColumns([]);
return;
}
const loadColumns = async () => {
setLoadingColumns(true);
try {
const { tableTypeApi } = await import("@/lib/api/screen");
const response = await tableTypeApi.getTableTypeColumns(selectedTableName);
if (response.success && response.data) {
const cols = response.data.map((col: any) => col.column_name).filter(Boolean);
setTableColumns(cols);
}
} catch {
// 컬럼 정보를 못 가져오면 테이블 컴포넌트의 columns에서 추출
const configColumns = selectedTableComponent?.componentConfig?.columns;
if (Array.isArray(configColumns)) {
setTableColumns(configColumns.map((c: any) => c.columnName).filter(Boolean));
}
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [selectedTableName, selectedTableComponent]);
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="dataBindingEnabled"
checked={!!config.dataBinding?.sourceComponentId}
onCheckedChange={(checked) => {
if (checked) {
const firstTable = tableListComponents[0];
updateConfig("dataBinding", {
sourceComponentId: firstTable?.id || "",
sourceColumn: "",
});
} else {
updateConfig("dataBinding", undefined);
}
}}
/>
<Label htmlFor="dataBindingEnabled" className="text-xs font-semibold">
</Label>
</div>
{config.dataBinding && (
<div className="space-y-2 rounded border p-2">
<p className="text-[10px] text-muted-foreground">
</p>
{/* 소스 테이블 컴포넌트 선택 */}
<div className="space-y-1">
<Label className="text-xs font-medium"> </Label>
{tableListComponents.length === 0 ? (
<p className="text-[10px] text-amber-500"> v2-table-list </p>
) : (
<Select
value={config.dataBinding?.sourceComponentId || ""}
onValueChange={(value) => {
updateConfig("dataBinding", {
...config.dataBinding,
sourceComponentId: value,
sourceColumn: "",
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tableListComponents.map((comp) => {
const tblName =
comp.componentConfig?.selectedTable || comp.selectedTable || "";
const label = comp.componentConfig?.label || comp.label || comp.id;
return (
<SelectItem key={comp.id} value={comp.id}>
{label} ({tblName || comp.id})
</SelectItem>
);
})}
</SelectContent>
</Select>
)}
</div>
{/* 소스 컬럼 선택 */}
{config.dataBinding?.sourceComponentId && (
<div className="space-y-1">
<Label className="text-xs font-medium"> </Label>
{loadingColumns ? (
<p className="text-[10px] text-muted-foreground"> ...</p>
) : tableColumns.length === 0 ? (
<>
<Input
value={config.dataBinding?.sourceColumn || ""}
onChange={(e) => {
updateConfig("dataBinding", {
...config.dataBinding,
sourceColumn: e.target.value,
});
}}
placeholder="컬럼명 직접 입력"
className="h-7 text-xs"
/>
<p className="text-[10px] text-muted-foreground"> </p>
</>
) : (
<Select
value={config.dataBinding?.sourceColumn || ""}
onValueChange={(value) => {
updateConfig("dataBinding", {
...config.dataBinding,
sourceColumn: value,
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col} value={col}>
{col}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
</div>
)}
</div>
);
}
export default V2InputConfigPanel;

View File

@ -3,6 +3,7 @@ import { apiClient } from "./client";
export interface AuditLogEntry {
id: number;
company_code: string;
company_name: string | null;
user_id: string;
user_name: string | null;
action: string;

View File

@ -235,6 +235,8 @@ export interface DynamicComponentRendererProps {
// 🆕 분할 패널 내부 컴포넌트 선택 콜백
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void;
selectedPanelComponentId?: string;
// 중첩된 분할패널 내부 컴포넌트 선택 콜백 (탭 안의 분할패널)
onNestedPanelSelect?: (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => void;
flowSelectedStepId?: number | null;
onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
// 테이블 새로고침 키
@ -868,6 +870,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 🆕 분할 패널 내부 컴포넌트 선택 콜백
onSelectPanelComponent: props.onSelectPanelComponent,
selectedPanelComponentId: props.selectedPanelComponentId,
onNestedPanelSelect: props.onNestedPanelSelect,
};
// 렌더러가 클래스인지 함수인지 확인

View File

@ -20,7 +20,6 @@ import {
GeneratedLocation,
RackStructureContext,
} from "./types";
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN } from "./patternUtils";
// 기존 위치 데이터 타입
interface ExistingLocation {
@ -513,27 +512,23 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
return { totalLocations, totalRows, maxLevel };
}, [conditions]);
// 위치 코드 생성 (패턴 기반)
// 위치 코드 생성
const generateLocationCode = useCallback(
(row: number, level: number): { code: string; name: string } => {
const vars = {
warehouse: context?.warehouseCode || "WH001",
warehouseName: context?.warehouseName || "",
floor: context?.floor || "1",
zone: context?.zone || "A",
row,
level,
};
const warehouseCode = context?.warehouseCode || "WH001";
const floor = context?.floor || "1";
const zone = context?.zone || "A";
const codePattern = config.codePattern || DEFAULT_CODE_PATTERN;
const namePattern = config.namePattern || DEFAULT_NAME_PATTERN;
// 코드 생성 (예: WH001-1층D구역-01-1)
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
return {
code: applyLocationPattern(codePattern, vars),
name: applyLocationPattern(namePattern, vars),
};
// 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}`;
return { code, name };
},
[context, config.codePattern, config.namePattern],
[context],
);
// 미리보기 생성

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
@ -12,47 +12,6 @@ import {
SelectValue,
} from "@/components/ui/select";
import { RackStructureComponentConfig, FieldMapping } from "./types";
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN, PATTERN_VARIABLES } from "./patternUtils";
// 패턴 미리보기 서브 컴포넌트
const PatternPreview: React.FC<{
codePattern?: string;
namePattern?: string;
}> = ({ codePattern, namePattern }) => {
const sampleVars = {
warehouse: "WH002",
warehouseName: "2창고",
floor: "2층",
zone: "A구역",
row: 1,
level: 3,
};
const previewCode = useMemo(
() => applyLocationPattern(codePattern || DEFAULT_CODE_PATTERN, sampleVars),
[codePattern],
);
const previewName = useMemo(
() => applyLocationPattern(namePattern || DEFAULT_NAME_PATTERN, sampleVars),
[namePattern],
);
return (
<div className="rounded-md border border-primary/20 bg-primary/5 p-2.5">
<div className="mb-1.5 text-[10px] font-medium text-primary"> (2 / 2 / A구역 / 1 / 3)</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs">
<span className="w-14 shrink-0 text-muted-foreground">:</span>
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground">{previewCode}</code>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="w-14 shrink-0 text-muted-foreground">:</span>
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground">{previewName}</code>
</div>
</div>
</div>
);
};
interface RackStructureConfigPanelProps {
config: RackStructureComponentConfig;
@ -246,61 +205,6 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
</div>
</div>
{/* 위치코드 패턴 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-foreground">/ </div>
<p className="text-xs text-muted-foreground">
</p>
{/* 위치코드 패턴 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={config.codePattern || ""}
onChange={(e) => handleChange("codePattern", e.target.value || undefined)}
placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}"
className="h-8 font-mono text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
: {"{warehouse}-{floor}{zone}-{row:02}-{level}"}
</p>
</div>
{/* 위치명 패턴 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={config.namePattern || ""}
onChange={(e) => handleChange("namePattern", e.target.value || undefined)}
placeholder="{zone}-{row:02}열-{level}단"
className="h-8 font-mono text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
: {"{zone}-{row:02}열-{level}단"}
</p>
</div>
{/* 실시간 미리보기 */}
<PatternPreview
codePattern={config.codePattern}
namePattern={config.namePattern}
/>
{/* 사용 가능한 변수 목록 */}
<div className="rounded-md border bg-muted/50 p-2">
<div className="mb-1 text-[10px] font-medium text-foreground"> </div>
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5">
{PATTERN_VARIABLES.map((v) => (
<div key={v.token} className="flex items-center gap-1 text-[10px]">
<code className="rounded bg-primary/10 px-1 font-mono text-primary">{v.token}</code>
<span className="text-muted-foreground">{v.description}</span>
</div>
))}
</div>
</div>
</div>
{/* 제한 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-foreground"> </div>

View File

@ -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";

View File

@ -5,71 +5,45 @@ import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponent
import { V2InputDefinition } from "./index";
import { V2Input } from "@/components/v2/V2Input";
import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer";
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
/**
* dataBinding이 v2-input을 wrapper
* v2-table-list의 TABLE_DATA_CHANGE
* v2-table-list의 window CustomEvent로
* formData에
*/
function DataBindingWrapper({
dataBinding,
columnName,
onFormDataChange,
isInteractive,
children,
}: {
dataBinding: { sourceComponentId: string; sourceColumn: string };
columnName: string;
onFormDataChange?: (field: string, value: any) => void;
isInteractive?: boolean;
children: React.ReactNode;
}) {
const lastBoundValueRef = useRef<any>(null);
const onFormDataChangeRef = useRef(onFormDataChange);
onFormDataChangeRef.current = onFormDataChange;
useEffect(() => {
if (!dataBinding?.sourceComponentId || !dataBinding?.sourceColumn) return;
console.log("[DataBinding] 구독 시작:", {
sourceComponentId: dataBinding.sourceComponentId,
sourceColumn: dataBinding.sourceColumn,
targetColumn: columnName,
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
});
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (!detail || detail.source !== dataBinding.sourceComponentId) return;
const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_DATA_CHANGE, (payload: any) => {
console.log("[DataBinding] TABLE_DATA_CHANGE 수신:", {
payloadSource: payload.source,
expectedSource: dataBinding.sourceComponentId,
dataLength: payload.data?.length,
match: payload.source === dataBinding.sourceComponentId,
});
if (payload.source !== dataBinding.sourceComponentId) return;
const selectedData = payload.data;
if (selectedData && selectedData.length > 0) {
const value = selectedData[0][dataBinding.sourceColumn];
console.log("[DataBinding] 바인딩 값:", { column: dataBinding.sourceColumn, value, columnName });
if (value !== lastBoundValueRef.current) {
lastBoundValueRef.current = value;
if (onFormDataChange && columnName) {
onFormDataChange(columnName, value ?? "");
}
}
} else {
if (lastBoundValueRef.current !== null) {
lastBoundValueRef.current = null;
if (onFormDataChange && columnName) {
onFormDataChange(columnName, "");
}
}
const selectedRow = detail.data?.[0];
const value = selectedRow?.[dataBinding.sourceColumn] ?? "";
if (value !== lastBoundValueRef.current) {
lastBoundValueRef.current = value;
onFormDataChangeRef.current?.(columnName, value);
}
});
};
return () => unsubscribe();
}, [dataBinding?.sourceComponentId, dataBinding?.sourceColumn, columnName, onFormDataChange, isInteractive]);
window.addEventListener("v2-table-selection", handler);
return () => window.removeEventListener("v2-table-selection", handler);
}, [dataBinding?.sourceComponentId, dataBinding?.sourceColumn, columnName]);
return <>{children}</>;
}
@ -102,18 +76,6 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
const dataBinding = config.dataBinding || (component as any).dataBinding || config.componentConfig?.dataBinding;
if (dataBinding || (config as any).dataBinding || (component as any).dataBinding) {
console.log("[V2InputRenderer] dataBinding 탐색:", {
componentId: component.id,
columnName,
configKeys: Object.keys(config),
configDataBinding: config.dataBinding,
componentDataBinding: (component as any).dataBinding,
nestedDataBinding: config.componentConfig?.dataBinding,
finalDataBinding: dataBinding,
});
}
const inputElement = (
<V2Input
id={component.id}
@ -153,7 +115,6 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer {
dataBinding={dataBinding}
columnName={columnName}
onFormDataChange={onFormDataChange}
isInteractive={isInteractive}
>
{inputElement}
</DataBindingWrapper>

View File

@ -96,12 +96,12 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
};
return (
<div className="relative rounded-lg border border-border bg-white shadow-sm">
<div className="border-border relative rounded-lg border bg-white shadow-sm">
{/* 헤더 */}
<div className="flex items-center justify-between rounded-t-lg bg-primary px-4 py-2 text-white">
<div className="bg-primary flex items-center justify-between rounded-t-lg px-4 py-2 text-white">
<span className="font-medium"> {index + 1}</span>
{!readonly && (
<button onClick={() => onRemove(condition.id)} className="rounded p-1 transition-colors hover:bg-primary/90">
<button onClick={() => onRemove(condition.id)} className="hover:bg-primary/90 rounded p-1 transition-colors">
<X className="h-4 w-4" />
</button>
)}
@ -112,7 +112,7 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
{/* 열 범위 */}
<div className="flex items-center gap-2">
<div className="flex-1">
<label className="mb-1 block text-xs font-medium text-foreground">
<label className="text-foreground mb-1 block text-xs font-medium">
<span className="text-destructive">*</span>
</label>
<div className="flex items-center gap-2">
@ -140,7 +140,7 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
</div>
</div>
<div className="w-20">
<label className="mb-1 block text-xs font-medium text-foreground">
<label className="text-foreground mb-1 block text-xs font-medium">
<span className="text-destructive">*</span>
</label>
<Input
@ -157,7 +157,7 @@ const ConditionCard: React.FC<ConditionCardProps> = ({
</div>
{/* 계산 결과 */}
<div className="rounded-md bg-primary/10 px-3 py-2 text-center text-sm text-primary">
<div className="bg-primary/10 text-primary rounded-md px-3 py-2 text-center text-sm">
{locationCount > 0 ? (
<>
{localValues.startRow} ~ {localValues.endRow} x {localValues.levels} ={" "}
@ -627,7 +627,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<div className="h-4 w-1 rounded bg-gradient-to-b from-green-500 to-primary/50" />
<div className="to-primary/50 h-4 w-1 rounded bg-gradient-to-b from-green-500" />
</CardTitle>
{!readonly && (
<div className="flex items-center gap-2">
@ -720,8 +720,8 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
{/* 기존 데이터 존재 알림 */}
{!isCheckingDuplicates && existingLocations.length > 0 && !hasDuplicateWithExisting && (
<Alert className="mb-4 border-primary/20 bg-primary/10">
<AlertCircle className="h-4 w-4 text-primary" />
<Alert className="border-primary/20 bg-primary/10 mb-4">
<AlertCircle className="text-primary h-4 w-4" />
<AlertDescription className="text-primary">
// <strong>{existingLocations.length}</strong> .
</AlertDescription>
@ -730,9 +730,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
{/* 현재 매핑된 값 표시 */}
{(context.warehouseCode || context.warehouseName || context.floor || context.zone) && (
<div className="mb-4 flex flex-wrap gap-2 rounded-lg bg-muted p-3">
<div className="bg-muted mb-4 flex flex-wrap gap-2 rounded-lg p-3">
{(context.warehouseCode || context.warehouseName) && (
<span className="rounded bg-primary/10 px-2 py-1 text-xs text-primary">
<span className="bg-primary/10 text-primary rounded px-2 py-1 text-xs">
: {context.warehouseName || context.warehouseCode}
{context.warehouseName && context.warehouseCode && ` (${context.warehouseCode})`}
</span>
@ -749,28 +749,28 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
</span>
)}
{context.status && (
<span className="rounded bg-muted/80 px-2 py-1 text-xs text-foreground">: {context.status}</span>
<span className="bg-muted/80 text-foreground rounded px-2 py-1 text-xs">: {context.status}</span>
)}
</div>
)}
{/* 안내 메시지 */}
<div className="mb-4 rounded-lg bg-primary/10 p-4">
<ol className="space-y-1 text-sm text-primary">
<div className="bg-primary/10 mb-4 rounded-lg p-4">
<ol className="text-primary space-y-1 text-sm">
<li className="flex items-start gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary text-xs font-bold text-white">
<span className="bg-primary flex h-5 w-5 shrink-0 items-center justify-center rounded text-xs font-bold text-white">
1
</span>
</li>
<li className="flex items-start gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary text-xs font-bold text-white">
<span className="bg-primary flex h-5 w-5 shrink-0 items-center justify-center rounded text-xs font-bold text-white">
2
</span>
</li>
<li className="flex items-start gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-primary text-xs font-bold text-white">
<span className="bg-primary flex h-5 w-5 shrink-0 items-center justify-center rounded text-xs font-bold text-white">
3
</span>
예시: 조건1(1~3, 3), 2(4~6, 5)
@ -780,9 +780,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
{/* 조건 목록 또는 빈 상태 */}
{conditions.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border py-12">
<div className="mb-4 text-6xl text-muted-foreground/50">📦</div>
<p className="mb-4 text-muted-foreground"> </p>
<div className="border-border flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-12">
<div className="text-muted-foreground/50 mb-4 text-6xl">📦</div>
<p className="text-muted-foreground mb-4"> </p>
{!readonly && (
<Button onClick={addCondition} className="gap-1">
<Plus className="h-4 w-4" />
@ -833,15 +833,15 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
{config.showStatistics && (
<div className="mb-4 grid grid-cols-3 gap-4">
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-sm text-muted-foreground"> </div>
<div className="text-muted-foreground text-sm"> </div>
<div className="text-2xl font-bold">{statistics.totalLocations}</div>
</div>
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-sm text-muted-foreground"> </div>
<div className="text-muted-foreground text-sm"> </div>
<div className="text-2xl font-bold">{statistics.totalRows}</div>
</div>
<div className="rounded-lg border bg-white p-4 text-center">
<div className="text-sm text-muted-foreground"> </div>
<div className="text-muted-foreground text-sm"> </div>
<div className="text-2xl font-bold">{statistics.maxLevel}</div>
</div>
</div>
@ -852,7 +852,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
<div className="rounded-lg border">
<ScrollArea className="h-[400px]">
<Table>
<TableHeader className="sticky top-0 bg-muted">
<TableHeader className="bg-muted sticky top-0">
<TableRow>
<TableHead className="w-12 text-center">No</TableHead>
<TableHead></TableHead>
@ -884,8 +884,8 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
</ScrollArea>
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border py-8 text-muted-foreground">
<Eye className="mb-2 h-8 w-8 text-muted-foreground/50" />
<div className="border-border text-muted-foreground flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-8">
<Eye className="text-muted-foreground/50 mb-2 h-8 w-8" />
<p> </p>
</div>
)}
@ -932,16 +932,16 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
{/* 템플릿 목록 */}
{templates.length > 0 ? (
<div className="space-y-2">
<div className="text-sm font-medium text-foreground"> 릿</div>
<div className="text-foreground text-sm font-medium"> 릿</div>
<ScrollArea className="h-[200px]">
{templates.map((template) => (
<div
key={template.id}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted"
className="hover:bg-muted flex items-center justify-between rounded-lg border p-3"
>
<div>
<div className="font-medium">{template.name}</div>
<div className="text-xs text-muted-foreground">{template.conditions.length} </div>
<div className="text-muted-foreground text-xs">{template.conditions.length} </div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => loadTemplate(template)}>
@ -956,7 +956,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
</ScrollArea>
</div>
) : (
<div className="py-8 text-center text-muted-foreground"> 릿 </div>
<div className="text-muted-foreground py-8 text-center"> 릿 </div>
)}
</div>
)}

View File

@ -1,16 +1,10 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { RackStructureComponentConfig, FieldMapping, FormatSegment } from "./types";
import { defaultFormatConfig, SAMPLE_VALUES } from "./config";
import { FormatSegmentEditor } from "./FormatSegmentEditor";
@ -36,9 +30,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
tables = [],
}) => {
// 사용 가능한 컬럼 목록 추출
const [availableColumns, setAvailableColumns] = useState<
Array<{ value: string; label: string }>
>([]);
const [availableColumns, setAvailableColumns] = useState<Array<{ value: string; label: string }>>([]);
useEffect(() => {
// 모든 테이블의 컬럼을 플랫하게 추출
@ -73,10 +65,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
const formatConfig = config.formatConfig || defaultFormatConfig;
const handleFormatChange = (
key: "codeSegments" | "nameSegments",
segments: FormatSegment[],
) => {
const handleFormatChange = (key: "codeSegments" | "nameSegments", segments: FormatSegment[]) => {
onChange({
...config,
formatConfig: {
@ -90,10 +79,8 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
<div className="space-y-4">
{/* 필드 매핑 섹션 */}
<div className="space-y-3">
<div className="text-sm font-medium text-foreground"> </div>
<p className="text-xs text-muted-foreground">
</p>
<div className="text-foreground text-sm font-medium"> </div>
<p className="text-muted-foreground text-xs"> </p>
{/* 창고 코드 필드 */}
<div>
@ -222,64 +209,9 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
</div>
</div>
{/* 위치코드 패턴 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-foreground">/ </div>
<p className="text-xs text-muted-foreground">
</p>
{/* 위치코드 패턴 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={config.codePattern || ""}
onChange={(e) => handleChange("codePattern", e.target.value || undefined)}
placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}"
className="h-8 font-mono text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
: {"{warehouse}-{floor}{zone}-{row:02}-{level}"}
</p>
</div>
{/* 위치명 패턴 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={config.namePattern || ""}
onChange={(e) => handleChange("namePattern", e.target.value || undefined)}
placeholder="{zone}-{row:02}열-{level}단"
className="h-8 font-mono text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
: {"{zone}-{row:02}열-{level}단"}
</p>
</div>
{/* 실시간 미리보기 */}
<PatternPreview
codePattern={config.codePattern}
namePattern={config.namePattern}
/>
{/* 사용 가능한 변수 목록 */}
<div className="rounded-md border bg-muted/50 p-2">
<div className="mb-1 text-[10px] font-medium text-foreground"> </div>
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5">
{PATTERN_VARIABLES.map((v) => (
<div key={v.token} className="flex items-center gap-1 text-[10px]">
<code className="rounded bg-primary/10 px-1 font-mono text-primary">{v.token}</code>
<span className="text-muted-foreground">{v.description}</span>
</div>
))}
</div>
</div>
</div>
{/* 제한 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-foreground"> </div>
<div className="text-foreground text-sm font-medium"> </div>
<div>
<Label className="text-xs"> </Label>
@ -320,7 +252,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
{/* UI 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-foreground">UI </div>
<div className="text-foreground text-sm font-medium">UI </div>
<div className="flex items-center justify-between">
<Label className="text-xs">릿 </Label>
@ -348,10 +280,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.readonly ?? false}
onCheckedChange={(checked) => handleChange("readonly", checked)}
/>
<Switch checked={config.readonly ?? false} onCheckedChange={(checked) => handleChange("readonly", checked)} />
</div>
</div>
@ -359,8 +288,7 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-gray-700"> </div>
<p className="text-xs text-gray-500">
,
/
, /
</p>
<FormatSegmentEditor

View File

@ -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" },
];

View File

@ -91,6 +91,103 @@ const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value })
});
SplitPanelCellImage.displayName = "SplitPanelCellImage";
/**
* 런타임: 디자이너
*/
const ScaledCustomPanel: React.FC<{
components: PanelInlineComponent[];
formData: Record<string, any>;
onFormDataChange: (fieldName: string, value: any) => void;
tableName?: string;
menuObjid?: number;
screenId?: number;
userId?: string;
userName?: string;
companyCode?: string;
allComponents?: any;
selectedRowsData?: any[];
onSelectedRowsChange?: any;
}> = ({ components, formData, onFormDataChange, tableName, ...restProps }) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = React.useState(0);
React.useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width;
if (w && w > 0) setContainerWidth(w);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const canvasW = Math.max(
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
400,
);
const canvasH = Math.max(
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
200,
);
return (
<div ref={containerRef} className="relative w-full" style={{ height: `${canvasH}px` }}>
{containerWidth > 0 &&
components.map((comp) => {
const x = comp.position?.x || 0;
const y = comp.position?.y || 0;
const w = comp.size?.width || 200;
const h = comp.size?.height || 36;
const componentData = {
id: comp.id,
type: "component" as const,
componentType: comp.componentType,
label: comp.label,
position: { x, y },
size: { width: undefined, height: h },
componentConfig: comp.componentConfig || {},
style: { ...(comp.style || {}), width: "100%", height: "100%" },
tableName: comp.componentConfig?.tableName,
columnName: comp.componentConfig?.columnName,
webType: comp.componentConfig?.webType,
inputType: (comp as any).inputType || comp.componentConfig?.inputType,
};
return (
<div
key={comp.id}
className="absolute"
style={{
left: `${(x / canvasW) * 100}%`,
top: `${y}px`,
width: `${(w / canvasW) * 100}%`,
minHeight: `${h}px`,
}}
>
<DynamicComponentRenderer
component={componentData as any}
isDesignMode={false}
isInteractive={true}
formData={formData}
onFormDataChange={onFormDataChange}
tableName={tableName}
menuObjid={restProps.menuObjid}
screenId={restProps.screenId}
userId={restProps.userId}
userName={restProps.userName}
companyCode={restProps.companyCode}
allComponents={restProps.allComponents}
selectedRowsData={restProps.selectedRowsData}
onSelectedRowsChange={restProps.onSelectedRowsChange}
/>
</div>
);
})}
</div>
);
};
/**
* SplitPanelLayout
* -
@ -271,8 +368,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
const [resizingCompId, setResizingCompId] = useState<string | null>(null);
const [resizeSize, setResizeSize] = useState<{ width: number; height: number } | null>(null);
// 🆕 외부에서 전달받은 선택 상태 사용 (탭 컴포넌트와 동일 구조)
const selectedPanelComponentId = externalSelectedPanelComponentId || null;
// 내부 선택 상태 (외부 prop 없을 때 fallback)
const [internalSelectedCompId, setInternalSelectedCompId] = useState<string | null>(null);
const selectedPanelComponentId = externalSelectedPanelComponentId ?? internalSelectedCompId;
// 🆕 커스텀 모드: 분할패널 내 탭 컴포넌트의 선택 상태 관리
const [nestedTabSelectedCompId, setNestedTabSelectedCompId] = useState<string | undefined>(undefined);
const rafRef = useRef<number | null>(null);
@ -719,22 +817,21 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}, [leftData, leftGroupSumConfig]);
// 컴포넌트 스타일
// height 처리: 이미 px 단위면 그대로, 숫자면 px 추가
// height: component.size?.height 우선, 없으면 component.style?.height, 기본 600px
const getHeightValue = () => {
const sizeH = component.size?.height;
if (sizeH && typeof sizeH === "number" && sizeH > 0) return `${sizeH}px`;
const height = component.style?.height;
if (!height) return "600px";
if (typeof height === "string") return height; // 이미 '540px' 형태
return `${height}px`; // 숫자면 px 추가
if (typeof height === "string") return height;
return `${height}px`;
};
const componentStyle: React.CSSProperties = isDesignMode
? {
position: "absolute",
left: `${component.style?.positionX || 0}px`,
top: `${component.style?.positionY || 0}px`,
width: "100%",
height: getHeightValue(),
zIndex: component.style?.positionZ || 1,
height: "100%",
minHeight: getHeightValue(),
cursor: "pointer",
border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb",
}
@ -3144,9 +3241,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
</div>
)}
<CardContent className="flex-1 overflow-auto p-4">
<CardContent
className="flex-1 overflow-auto p-4"
{...(isDesignMode ? {
"data-split-panel-container": "true",
"data-component-id": component.id,
"data-panel-side": "left",
} : {})}
>
{/* 좌측 데이터 목록/테이블/커스텀 */}
{console.log("🔍 [SplitPanel] 왼쪽 패널 displayMode:", componentConfig.leftPanel?.displayMode, "isDesignMode:", isDesignMode)}
{componentConfig.leftPanel?.displayMode === "custom" ? (
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
<div
@ -3158,59 +3261,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */}
{componentConfig.leftPanel?.components && componentConfig.leftPanel.components.length > 0 ? (
!isDesignMode ? (
// 런타임: ResponsiveGridRenderer로 반응형 렌더링
(() => {
const leftComps = componentConfig.leftPanel!.components;
const canvasW = Math.max(...leftComps.map((c: PanelInlineComponent) => (c.position?.x || 0) + (c.size?.width || 200)), 800);
const canvasH = Math.max(...leftComps.map((c: PanelInlineComponent) => (c.position?.y || 0) + (c.size?.height || 100)), 400);
const compDataList = leftComps.map((c: PanelInlineComponent) => ({
id: c.id,
type: "component" as const,
componentType: c.componentType,
label: c.label,
position: c.position || { x: 0, y: 0 },
size: c.size || { width: 400, height: 300 },
componentConfig: c.componentConfig || {},
style: c.style || {},
tableName: c.componentConfig?.tableName,
columnName: c.componentConfig?.columnName,
webType: c.componentConfig?.webType,
inputType: (c as any).inputType || c.componentConfig?.inputType,
})) as any;
return (
<ResponsiveGridRenderer
components={compDataList}
canvasWidth={canvasW}
canvasHeight={canvasH}
renderComponent={(comp) => (
<DynamicComponentRenderer
component={comp as any}
isDesignMode={false}
isInteractive={true}
formData={{}}
tableName={componentConfig.leftPanel?.tableName}
menuObjid={(props as any).menuObjid}
screenId={(props as any).screenId}
userId={(props as any).userId}
userName={(props as any).userName}
companyCode={companyCode}
allComponents={(props as any).allComponents}
selectedRowsData={localSelectedRowsData}
onSelectedRowsChange={handleLocalSelectedRowsChange}
onFormDataChange={(data: any) => {
if (data?.selectedRowsData && data.selectedRowsData.length > 0) {
setCustomLeftSelectedData(data.selectedRowsData[0]);
setSelectedLeftItem(data.selectedRowsData[0]);
} else if (data?.selectedRowsData && data.selectedRowsData.length === 0) {
setCustomLeftSelectedData({});
setSelectedLeftItem(null);
}
}}
/>
)}
/>
);
})()
<ScaledCustomPanel
components={componentConfig.leftPanel!.components}
formData={{}}
onFormDataChange={(data: any) => {
if (data?.selectedRowsData && data.selectedRowsData.length > 0) {
setCustomLeftSelectedData(data.selectedRowsData[0]);
setSelectedLeftItem(data.selectedRowsData[0]);
} else if (data?.selectedRowsData && data.selectedRowsData.length === 0) {
setCustomLeftSelectedData({});
setSelectedLeftItem(null);
}
}}
tableName={componentConfig.leftPanel?.tableName}
menuObjid={(props as any).menuObjid}
screenId={(props as any).screenId}
userId={(props as any).userId}
userName={(props as any).userName}
companyCode={companyCode}
allComponents={(props as any).allComponents}
selectedRowsData={localSelectedRowsData}
onSelectedRowsChange={handleLocalSelectedRowsChange}
/>
) : (
<div className="relative h-full w-full" style={{ minHeight: "100%", minWidth: "100%" }}>
{componentConfig.leftPanel.components.map((comp: PanelInlineComponent) => {
@ -3250,10 +3322,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}}
onClick={(e) => {
e.stopPropagation();
// 패널 컴포넌트 선택 시 탭 내 선택 해제
if (comp.componentType !== "v2-tabs-widget") {
setNestedTabSelectedCompId(undefined);
}
setInternalSelectedCompId(comp.id);
onSelectPanelComponent?.("left", comp.id, comp);
}}
>
@ -3501,7 +3573,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</th>
))}
{hasGroupedLeftActions && (
<th className="px-3 py-2 text-right text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}>
<th className="bg-muted sticky right-0 z-10 px-3 py-2 text-right text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}>
</th>
)}
</tr>
@ -3537,7 +3609,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</td>
))}
{hasGroupedLeftActions && (
<td className="px-3 py-2 text-right">
<td className="bg-card sticky right-0 z-10 px-3 py-2 text-right group-hover:bg-accent">
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{(componentConfig.leftPanel?.showEdit !== false) && (
<button
@ -3599,7 +3671,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</th>
))}
{hasLeftTableActions && (
<th className="px-3 py-2 text-right text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}>
<th className="bg-muted sticky right-0 z-10 px-3 py-2 text-right text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ width: "80px" }}>
</th>
)}
</tr>
@ -3635,7 +3707,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</td>
))}
{hasLeftTableActions && (
<td className="px-3 py-2 text-right">
<td className="bg-card sticky right-0 z-10 px-3 py-2 text-right group-hover:bg-accent">
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{(componentConfig.leftPanel?.showEdit !== false) && (
<button
@ -4033,7 +4105,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
</div>
)}
<CardContent className="flex-1 overflow-auto p-4">
<CardContent
className="flex-1 overflow-auto p-4"
{...(isDesignMode ? {
"data-split-panel-container": "true",
"data-component-id": component.id,
"data-panel-side": "right",
} : {})}
>
{/* 추가 탭 컨텐츠 */}
{activeTabIndex > 0 ? (
(() => {
@ -4316,53 +4395,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{/* 커스텀 모드: 디자인/실행 모드 분기 렌더링 */}
{componentConfig.rightPanel?.components && componentConfig.rightPanel.components.length > 0 ? (
!isDesignMode ? (
// 런타임: ResponsiveGridRenderer로 반응형 렌더링
(() => {
const rightComps = componentConfig.rightPanel!.components;
const canvasW = Math.max(...rightComps.map((c: PanelInlineComponent) => (c.position?.x || 0) + (c.size?.width || 200)), 800);
const canvasH = Math.max(...rightComps.map((c: PanelInlineComponent) => (c.position?.y || 0) + (c.size?.height || 100)), 400);
const compDataList = rightComps.map((c: PanelInlineComponent) => ({
id: c.id,
type: "component" as const,
componentType: c.componentType,
label: c.label,
position: c.position || { x: 0, y: 0 },
size: c.size || { width: 400, height: 300 },
componentConfig: c.componentConfig || {},
style: c.style || {},
tableName: c.componentConfig?.tableName,
columnName: c.componentConfig?.columnName,
webType: c.componentConfig?.webType,
inputType: (c as any).inputType || c.componentConfig?.inputType,
})) as any;
return (
<ResponsiveGridRenderer
components={compDataList}
canvasWidth={canvasW}
canvasHeight={canvasH}
renderComponent={(comp) => (
<DynamicComponentRenderer
component={comp as any}
isDesignMode={false}
isInteractive={true}
formData={customLeftSelectedData}
onFormDataChange={(fieldName: string, value: any) => {
setCustomLeftSelectedData((prev: Record<string, any>) => ({ ...prev, [fieldName]: value }));
}}
tableName={componentConfig.rightPanel?.tableName || componentConfig.leftPanel?.tableName}
menuObjid={(props as any).menuObjid}
screenId={(props as any).screenId}
userId={(props as any).userId}
userName={(props as any).userName}
companyCode={companyCode}
allComponents={(props as any).allComponents}
selectedRowsData={localSelectedRowsData}
onSelectedRowsChange={handleLocalSelectedRowsChange}
/>
)}
/>
);
})()
<ScaledCustomPanel
components={componentConfig.rightPanel!.components}
formData={customLeftSelectedData}
onFormDataChange={(fieldName: string, value: any) => {
setCustomLeftSelectedData((prev: Record<string, any>) => ({ ...prev, [fieldName]: value }));
}}
tableName={componentConfig.rightPanel?.tableName || componentConfig.leftPanel?.tableName}
menuObjid={(props as any).menuObjid}
screenId={(props as any).screenId}
userId={(props as any).userId}
userName={(props as any).userName}
companyCode={companyCode}
allComponents={(props as any).allComponents}
selectedRowsData={localSelectedRowsData}
onSelectedRowsChange={handleLocalSelectedRowsChange}
/>
) : (
<div className="relative h-full w-full" style={{ minHeight: "100%", minWidth: "100%" }}>
{componentConfig.rightPanel.components.map((comp: PanelInlineComponent) => {
@ -4405,6 +4453,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (comp.componentType !== "v2-tabs-widget") {
setNestedTabSelectedCompId(undefined);
}
setInternalSelectedCompId(comp.id);
onSelectPanelComponent?.("right", comp.id, comp);
}}
>

View File

@ -62,6 +62,7 @@ function SortableColumnRow({
onLabelChange,
onWidthChange,
onFormatChange,
onSuffixChange,
onRemove,
onShowInSummaryChange,
onShowInDetailChange,
@ -87,6 +88,7 @@ function SortableColumnRow({
onLabelChange: (value: string) => void;
onWidthChange: (value: number) => void;
onFormatChange: (checked: boolean) => void;
onSuffixChange?: (value: string) => void;
onRemove: () => void;
onShowInSummaryChange?: (checked: boolean) => void;
onShowInDetailChange?: (checked: boolean) => void;
@ -177,15 +179,24 @@ function SortableColumnRow({
className="h-6 w-14 shrink-0 text-xs"
/>
{isNumeric && (
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
<input
type="checkbox"
checked={col.format?.thousandSeparator ?? false}
onChange={(e) => onFormatChange(e.target.checked)}
className="h-3 w-3"
<>
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
<input
type="checkbox"
checked={col.format?.thousandSeparator ?? false}
onChange={(e) => onFormatChange(e.target.checked)}
className="h-3 w-3"
/>
,
</label>
<Input
value={col.format?.suffix || ""}
onChange={(e) => onSuffixChange?.(e.target.value)}
placeholder="단위"
title="값 뒤에 붙는 단위 (예: mm, kg, %)"
className="h-6 w-10 shrink-0 text-[10px]"
/>
,
</label>
</>
)}
{/* 헤더/상세 표시 토글 */}
{onShowInSummaryChange && (
@ -818,6 +829,18 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
};
updateTab({ columns: newColumns });
}}
onSuffixChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = {
...newColumns[index],
format: {
...newColumns[index].format,
type: "number",
suffix: value || undefined,
},
};
updateTab({ columns: newColumns });
}}
onRemove={() => updateTab({ columns: selectedColumns.filter((_, i) => i !== index) })}
onShowInSummaryChange={(checked) => {
const newColumns = [...selectedColumns];
@ -2330,6 +2353,18 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
};
updateLeftPanel({ columns: newColumns });
}}
onSuffixChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = {
...newColumns[index],
format: {
...newColumns[index].format,
type: "number",
suffix: value || undefined,
},
};
updateLeftPanel({ columns: newColumns });
}}
onRemove={() =>
updateLeftPanel({ columns: selectedColumns.filter((_, i) => i !== index) })
}
@ -2988,6 +3023,18 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
};
updateRightPanel({ columns: newColumns });
}}
onSuffixChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = {
...newColumns[index],
format: {
...newColumns[index].format,
type: "number",
suffix: value || undefined,
},
};
updateRightPanel({ columns: newColumns });
}}
onRemove={() =>
updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })
}

View File

@ -315,6 +315,11 @@ export const LeftPanelConfigTab: React.FC<LeftPanelConfigTabProps> = ({
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
updateLeftPanel({ columns: newColumns });
}}
onSuffixChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", suffix: value || undefined } };
updateLeftPanel({ columns: newColumns });
}}
onRemove={() => updateLeftPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
/>
);

View File

@ -305,6 +305,11 @@ export const RightPanelConfigTab: React.FC<RightPanelConfigTabProps> = ({
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
updateRightPanel({ columns: newColumns });
}}
onSuffixChange={(value) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", suffix: value || undefined } };
updateRightPanel({ columns: newColumns });
}}
onRemove={() => updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
onShowInSummaryChange={(checked) => {
const newColumns = [...selectedColumns];

View File

@ -13,7 +13,7 @@ import { Check, ChevronsUpDown, GripVertical, Link2, X } from "lucide-react";
import { cn } from "@/lib/utils";
export function SortableColumnRow({
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onSuffixChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
}: {
id: string;
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean };
@ -23,6 +23,7 @@ export function SortableColumnRow({
onLabelChange: (value: string) => void;
onWidthChange: (value: number) => void;
onFormatChange: (checked: boolean) => void;
onSuffixChange?: (value: string) => void;
onRemove: () => void;
onShowInSummaryChange?: (checked: boolean) => void;
onShowInDetailChange?: (checked: boolean) => void;
@ -61,15 +62,24 @@ export function SortableColumnRow({
className="h-6 w-14 shrink-0 text-xs"
/>
{isNumeric && (
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
<input
type="checkbox"
checked={col.format?.thousandSeparator ?? false}
onChange={(e) => onFormatChange(e.target.checked)}
className="h-3 w-3"
<>
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
<input
type="checkbox"
checked={col.format?.thousandSeparator ?? false}
onChange={(e) => onFormatChange(e.target.checked)}
className="h-3 w-3"
/>
,
</label>
<Input
value={col.format?.suffix || ""}
onChange={(e) => onSuffixChange?.(e.target.value)}
placeholder="단위"
title="값 뒤에 붙는 단위 (예: mm, kg, %)"
className="h-6 w-10 shrink-0 text-[10px]"
/>
,
</label>
</>
)}
{onShowInSummaryChange && (
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="테이블 헤더에 표시">

View File

@ -2148,6 +2148,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
source: component.id || "table-list",
});
// dataBinding 연동용 window CustomEvent
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("v2-table-selection", {
detail: {
tableName: tableConfig.selectedTable || "",
data: selectedRowsData,
source: component.id || "table-list",
},
})
);
}
// 🆕 modalDataStore에 선택된 데이터 자동 저장 (테이블명 기반 dataSourceId)
if (tableConfig.selectedTable && selectedRowsData.length > 0) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {

View File

@ -15,7 +15,8 @@ const TabsDesignEditor: React.FC<{
onUpdateComponent?: (updatedComponent: any) => void;
onSelectTabComponent?: (tabId: string, compId: string, comp: TabInlineComponent) => void;
selectedTabComponentId?: string;
}> = ({ component, tabs, onUpdateComponent, onSelectTabComponent, selectedTabComponentId }) => {
onNestedPanelSelect?: (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => void;
}> = ({ component, tabs, onUpdateComponent, onSelectTabComponent, selectedTabComponentId, onNestedPanelSelect }) => {
const [activeTabId, setActiveTabId] = useState<string>(tabs[0]?.id || "");
const [draggingCompId, setDraggingCompId] = useState<string | null>(null);
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
@ -324,15 +325,12 @@ const TabsDesignEditor: React.FC<{
const isDragging = draggingCompId === comp.id;
const isResizing = resizingCompId === comp.id;
// 드래그/리사이즈 중 표시할 크기
// resizeSize가 있고 해당 컴포넌트이면 resizeSize 우선 사용 (레이아웃 업데이트 반영 전까지)
const compWidth = comp.size?.width || 200;
const compHeight = comp.size?.height || 100;
const isResizingThis = (resizingCompId === comp.id || lastResizedCompId === comp.id) && resizeSize;
const displayWidth = isResizingThis ? resizeSize!.width : compWidth;
const displayHeight = isResizingThis ? resizeSize!.height : compHeight;
// 컴포넌트 데이터를 DynamicComponentRenderer 형식으로 변환
const componentData = {
id: comp.id,
type: "component" as const,
@ -344,7 +342,6 @@ const TabsDesignEditor: React.FC<{
style: comp.style || {},
};
// 드래그 중인 컴포넌트는 dragPosition 사용, 아니면 저장된 position 사용
const displayX = isDragging && dragPosition ? dragPosition.x : (comp.position?.x || 0);
const displayY = isDragging && dragPosition ? dragPosition.y : (comp.position?.y || 0);
@ -417,12 +414,43 @@ const TabsDesignEditor: React.FC<{
width: displayWidth,
height: displayHeight,
}}
>
<div className="h-full w-full pointer-events-none">
<div className={cn(
"h-full w-full",
comp.componentType !== "v2-split-panel-layout" && comp.componentType !== "split-panel-layout" && "pointer-events-none"
)}>
<DynamicComponentRenderer
component={componentData as any}
isDesignMode={true}
formData={{}}
{...(comp.componentType === "v2-split-panel-layout" || comp.componentType === "split-panel-layout" ? {
onUpdateComponent: (updated: any) => {
if (!onUpdateComponent) return;
const updatedTabs = tabs.map((t) => {
if (t.id !== activeTabId) return t;
return {
...t,
components: (t.components || []).map((c) =>
c.id === comp.id ? { ...c, componentConfig: updated.componentConfig || updated.overrides || c.componentConfig } : c
),
};
});
const configKey = component.componentConfig ? "componentConfig" : "overrides";
const existingConfig = component[configKey] || {};
onUpdateComponent({
...component,
[configKey]: { ...existingConfig, tabs: updatedTabs },
});
},
onSelectPanelComponent: (panelSide: string, compId: string, panelComp: any) => {
if (onNestedPanelSelect) {
onNestedPanelSelect(comp.id, panelSide as "left" | "right", compId, panelComp);
} else {
onSelectTabComponent?.(activeTabId, comp.id, comp);
}
},
} : {})}
/>
</div>
@ -483,6 +511,7 @@ const TabsWidgetWrapper: React.FC<any> = (props) => {
onUpdateComponent,
onSelectTabComponent,
selectedTabComponentId,
onNestedPanelSelect,
...restProps
} = props;
@ -499,6 +528,7 @@ const TabsWidgetWrapper: React.FC<any> = (props) => {
onUpdateComponent={onUpdateComponent}
onSelectTabComponent={onSelectTabComponent}
selectedTabComponentId={selectedTabComponentId}
onNestedPanelSelect={onNestedPanelSelect}
/>
);
}