jskim-node #413
|
|
@ -1814,7 +1814,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)]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Router, Request, Response } from "express";
|
|||
import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService";
|
||||
import { logger } from "../utils/logger";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { auditLogService, getClientIp } from "../services/auditLogService";
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ router.use(authenticateToken);
|
|||
interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: string;
|
||||
userName: string;
|
||||
companyCode: string;
|
||||
};
|
||||
}
|
||||
|
|
@ -157,6 +159,21 @@ router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => {
|
|||
|
||||
const value = await categoryTreeService.createCategoryValue(companyCode, input, createdBy);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "CREATE",
|
||||
resourceType: "CODE_CATEGORY",
|
||||
resourceId: String(value.valueId),
|
||||
resourceName: input.valueLabel,
|
||||
tableName: "category_values",
|
||||
summary: `카테고리 값 "${input.valueLabel}" 생성 (${input.tableName}.${input.columnName})`,
|
||||
changes: { after: { tableName: input.tableName, columnName: input.columnName, valueCode: input.valueCode, valueLabel: input.valueLabel } },
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: value,
|
||||
|
|
@ -182,6 +199,7 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
|
|||
const companyCode = req.user?.companyCode || "*";
|
||||
const updatedBy = req.user?.userId;
|
||||
|
||||
const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
|
||||
const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy);
|
||||
|
||||
if (!value) {
|
||||
|
|
@ -191,6 +209,24 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
|
|||
});
|
||||
}
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "UPDATE",
|
||||
resourceType: "CODE_CATEGORY",
|
||||
resourceId: valueId,
|
||||
resourceName: value.valueLabel,
|
||||
tableName: "category_values",
|
||||
summary: `카테고리 값 "${value.valueLabel}" 수정 (${value.tableName}.${value.columnName})`,
|
||||
changes: {
|
||||
before: beforeValue ? { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode } : undefined,
|
||||
after: input,
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: value,
|
||||
|
|
@ -239,6 +275,7 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
|
|||
const { valueId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
const beforeValue = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
|
||||
const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId));
|
||||
|
||||
if (!success) {
|
||||
|
|
@ -248,6 +285,21 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res
|
|||
});
|
||||
}
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "DELETE",
|
||||
resourceType: "CODE_CATEGORY",
|
||||
resourceId: valueId,
|
||||
resourceName: beforeValue?.valueLabel || valueId,
|
||||
tableName: "category_values",
|
||||
summary: `카테고리 값 "${beforeValue?.valueLabel || valueId}" 삭제 (${beforeValue?.tableName || ""}.${beforeValue?.columnName || ""})`,
|
||||
changes: beforeValue ? { before: { valueLabel: beforeValue.valueLabel, valueCode: beforeValue.valueCode, tableName: beforeValue.tableName, columnName: beforeValue.columnName } } : undefined,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "삭제되었습니다",
|
||||
|
|
|
|||
|
|
@ -396,6 +396,20 @@ export class CommonCodeController {
|
|||
companyCode
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: companyCode || "",
|
||||
userId: userId || "",
|
||||
action: "UPDATE",
|
||||
resourceType: "CODE",
|
||||
resourceId: codeValue,
|
||||
resourceName: codeData.codeName || codeValue,
|
||||
tableName: "code_info",
|
||||
summary: `코드 "${categoryCode}.${codeValue}" 수정`,
|
||||
changes: { after: codeData },
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: code,
|
||||
|
|
@ -440,6 +454,19 @@ export class CommonCodeController {
|
|||
companyCode
|
||||
);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode: companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
action: "DELETE",
|
||||
resourceType: "CODE",
|
||||
resourceId: codeValue,
|
||||
tableName: "code_info",
|
||||
summary: `코드 "${categoryCode}.${codeValue}" 삭제`,
|
||||
changes: { before: { categoryCode, codeValue } },
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "코드 삭제 성공",
|
||||
|
|
|
|||
|
|
@ -438,6 +438,19 @@ export class DDLController {
|
|||
);
|
||||
|
||||
if (result.success) {
|
||||
auditLogService.log({
|
||||
companyCode: userCompanyCode || "",
|
||||
userId,
|
||||
action: "DELETE",
|
||||
resourceType: "TABLE",
|
||||
resourceId: tableName,
|
||||
resourceName: tableName,
|
||||
tableName,
|
||||
summary: `테이블 "${tableName}" 삭제`,
|
||||
ipAddress: getClientIp(req as any),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ router.post(
|
|||
auditLogService.log({
|
||||
companyCode,
|
||||
userId,
|
||||
userName: req.user?.userName,
|
||||
action: "CREATE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: String(newRule.ruleId),
|
||||
|
|
@ -243,6 +244,7 @@ router.put(
|
|||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "UPDATE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: ruleId,
|
||||
|
|
@ -285,6 +287,7 @@ router.delete(
|
|||
auditLogService.log({
|
||||
companyCode,
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName,
|
||||
action: "DELETE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: ruleId,
|
||||
|
|
@ -521,6 +524,56 @@ router.post(
|
|||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
const isUpdate = !!ruleConfig.ruleId;
|
||||
|
||||
const resetPeriodLabel: Record<string, string> = {
|
||||
none: "초기화 안함", daily: "일별", monthly: "월별", yearly: "연별",
|
||||
};
|
||||
const partTypeLabel: Record<string, string> = {
|
||||
sequence: "순번", number: "숫자", date: "날짜", text: "문자", category: "카테고리", reference: "참조",
|
||||
};
|
||||
const partsDescription = (ruleConfig.parts || [])
|
||||
.sort((a: any, b: any) => (a.order || 0) - (b.order || 0))
|
||||
.map((p: any) => {
|
||||
const type = partTypeLabel[p.partType] || p.partType;
|
||||
if (p.partType === "text" && p.autoConfig?.textValue) return `${type}("${p.autoConfig.textValue}")`;
|
||||
if (p.partType === "sequence" && p.autoConfig?.sequenceLength) return `${type}(${p.autoConfig.sequenceLength}자리)`;
|
||||
if (p.partType === "date" && p.autoConfig?.dateFormat) return `${type}(${p.autoConfig.dateFormat})`;
|
||||
if (p.partType === "category") return `${type}(${p.autoConfig?.categoryKey || ""})`;
|
||||
if (p.partType === "reference") return `${type}(${p.autoConfig?.referenceColumnName || ""})`;
|
||||
return type;
|
||||
})
|
||||
.join(` ${ruleConfig.separator || "-"} `);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId,
|
||||
userName: req.user?.userName,
|
||||
action: isUpdate ? "UPDATE" : "CREATE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: String(savedRule.ruleId),
|
||||
resourceName: ruleConfig.ruleName,
|
||||
tableName: "numbering_rules",
|
||||
summary: isUpdate
|
||||
? `채번 규칙 "${ruleConfig.ruleName}" 수정`
|
||||
: `채번 규칙 "${ruleConfig.ruleName}" 생성`,
|
||||
changes: {
|
||||
after: {
|
||||
규칙명: ruleConfig.ruleName,
|
||||
적용테이블: ruleConfig.tableName || "(미지정)",
|
||||
적용컬럼: ruleConfig.columnName || "(미지정)",
|
||||
구분자: ruleConfig.separator || "-",
|
||||
리셋주기: resetPeriodLabel[ruleConfig.resetPeriod] || ruleConfig.resetPeriod || "초기화 안함",
|
||||
적용범위: ruleConfig.scopeType === "menu" ? "메뉴별" : "전역",
|
||||
코드구성: partsDescription || "(파트 없음)",
|
||||
파트수: (ruleConfig.parts || []).length,
|
||||
},
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: savedRule });
|
||||
} catch (error: any) {
|
||||
logger.error("[테스트] 채번 규칙 저장 실패", { error: error.message });
|
||||
|
|
@ -535,10 +588,25 @@ router.delete(
|
|||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { ruleId } = req.params;
|
||||
|
||||
try {
|
||||
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
|
||||
|
||||
auditLogService.log({
|
||||
companyCode,
|
||||
userId,
|
||||
userName: req.user?.userName,
|
||||
action: "DELETE",
|
||||
resourceType: "NUMBERING_RULE",
|
||||
resourceId: ruleId,
|
||||
tableName: "numbering_rules",
|
||||
summary: `채번 규칙(ID:${ruleId}) 삭제`,
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "테스트 채번 규칙이 삭제되었습니다",
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export interface AuditLogParams {
|
|||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
company_code: string;
|
||||
company_name: string | null;
|
||||
user_id: string;
|
||||
user_name: string | null;
|
||||
action: string;
|
||||
|
|
@ -107,6 +108,7 @@ class AuditLogService {
|
|||
*/
|
||||
async log(params: AuditLogParams): Promise<void> {
|
||||
try {
|
||||
logger.info(`[AuditLog] 기록 시도: ${params.resourceType} / ${params.action} / ${params.resourceName || params.resourceId || "N/A"}`);
|
||||
await query(
|
||||
`INSERT INTO system_audit_log
|
||||
(company_code, user_id, user_name, action, resource_type,
|
||||
|
|
@ -128,8 +130,9 @@ class AuditLogService {
|
|||
params.requestPath || null,
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("감사 로그 기록 실패 (무시됨)", { error, params });
|
||||
logger.info(`[AuditLog] 기록 성공: ${params.resourceType} / ${params.action}`);
|
||||
} catch (error: any) {
|
||||
logger.error(`[AuditLog] 기록 실패: ${params.resourceType} / ${params.action} - ${error?.message}`, { error, params });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -186,40 +189,40 @@ class AuditLogService {
|
|||
let paramIndex = 1;
|
||||
|
||||
if (!isSuperAdmin && filters.companyCode) {
|
||||
conditions.push(`company_code = $${paramIndex++}`);
|
||||
conditions.push(`sal.company_code = $${paramIndex++}`);
|
||||
params.push(filters.companyCode);
|
||||
} else if (isSuperAdmin && filters.companyCode) {
|
||||
conditions.push(`company_code = $${paramIndex++}`);
|
||||
conditions.push(`sal.company_code = $${paramIndex++}`);
|
||||
params.push(filters.companyCode);
|
||||
}
|
||||
|
||||
if (filters.userId) {
|
||||
conditions.push(`user_id = $${paramIndex++}`);
|
||||
conditions.push(`sal.user_id = $${paramIndex++}`);
|
||||
params.push(filters.userId);
|
||||
}
|
||||
if (filters.resourceType) {
|
||||
conditions.push(`resource_type = $${paramIndex++}`);
|
||||
conditions.push(`sal.resource_type = $${paramIndex++}`);
|
||||
params.push(filters.resourceType);
|
||||
}
|
||||
if (filters.action) {
|
||||
conditions.push(`action = $${paramIndex++}`);
|
||||
conditions.push(`sal.action = $${paramIndex++}`);
|
||||
params.push(filters.action);
|
||||
}
|
||||
if (filters.tableName) {
|
||||
conditions.push(`table_name = $${paramIndex++}`);
|
||||
conditions.push(`sal.table_name = $${paramIndex++}`);
|
||||
params.push(filters.tableName);
|
||||
}
|
||||
if (filters.dateFrom) {
|
||||
conditions.push(`created_at >= $${paramIndex++}::timestamptz`);
|
||||
conditions.push(`sal.created_at >= $${paramIndex++}::timestamptz`);
|
||||
params.push(filters.dateFrom);
|
||||
}
|
||||
if (filters.dateTo) {
|
||||
conditions.push(`created_at <= $${paramIndex++}::timestamptz`);
|
||||
conditions.push(`sal.created_at <= $${paramIndex++}::timestamptz`);
|
||||
params.push(filters.dateTo);
|
||||
}
|
||||
if (filters.search) {
|
||||
conditions.push(
|
||||
`(summary ILIKE $${paramIndex} OR resource_name ILIKE $${paramIndex} OR user_name ILIKE $${paramIndex})`
|
||||
`(sal.summary ILIKE $${paramIndex} OR sal.resource_name ILIKE $${paramIndex} OR sal.user_name ILIKE $${paramIndex})`
|
||||
);
|
||||
params.push(`%${filters.search}%`);
|
||||
paramIndex++;
|
||||
|
|
@ -233,14 +236,17 @@ class AuditLogService {
|
|||
const offset = (page - 1) * limit;
|
||||
|
||||
const countResult = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM system_audit_log ${whereClause}`,
|
||||
`SELECT COUNT(*) as count FROM system_audit_log sal ${whereClause}`,
|
||||
params
|
||||
);
|
||||
const total = parseInt(countResult[0].count, 10);
|
||||
|
||||
const data = await query<AuditLogEntry>(
|
||||
`SELECT * FROM system_audit_log ${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
`SELECT sal.*, ci.company_name
|
||||
FROM system_audit_log sal
|
||||
LEFT JOIN company_mng ci ON sal.company_code = ci.company_code
|
||||
${whereClause}
|
||||
ORDER BY sal.created_at DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||
[...params, limit, offset]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -77,14 +77,12 @@ const RESOURCE_TYPE_CONFIG: Record<
|
|||
NODE_FLOW: { label: "플로우 제어", icon: GitBranch, color: "bg-teal-100 text-teal-700" },
|
||||
USER: { label: "사용자", icon: User, color: "bg-amber-100 text-orange-700" },
|
||||
ROLE: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
||||
PERMISSION: { label: "권한", icon: Shield, color: "bg-destructive/10 text-destructive" },
|
||||
COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" },
|
||||
CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||
CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" },
|
||||
DATA: { label: "데이터", icon: Database, color: "bg-muted text-foreground" },
|
||||
TABLE: { label: "테이블", icon: Database, color: "bg-muted text-foreground" },
|
||||
NUMBERING_RULE: { label: "채번 규칙", icon: FileText, color: "bg-amber-100 text-amber-700" },
|
||||
BATCH: { label: "배치", icon: RefreshCw, color: "bg-teal-100 text-teal-700" },
|
||||
};
|
||||
|
||||
const ACTION_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
|
|
@ -817,7 +815,7 @@ export default function AuditLogPage() {
|
|||
</Badge>
|
||||
{entry.company_code && entry.company_code !== "*" && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
[{entry.company_code}]
|
||||
[{entry.company_name || entry.company_code}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -862,9 +860,11 @@ export default function AuditLogPage() {
|
|||
</div>
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
회사코드
|
||||
회사
|
||||
</label>
|
||||
<p className="font-medium">{selectedEntry.company_code}</p>
|
||||
<p className="font-medium">
|
||||
{selectedEntry.company_name || selectedEntry.company_code}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-muted-foreground text-xs">
|
||||
|
|
|
|||
|
|
@ -98,10 +98,43 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
||||
if (savedMode === "true") {
|
||||
setContinuousMode(true);
|
||||
// console.log("🔄 연속 모드 복원: true");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// dataBinding: 테이블 선택 시 바인딩된 input의 formData를 자동 업데이트
|
||||
useEffect(() => {
|
||||
if (!modalState.isOpen || !screenData?.components?.length) return;
|
||||
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (!detail?.source || !detail?.data) return;
|
||||
|
||||
const bindingUpdates: Record<string, any> = {};
|
||||
for (const comp of screenData.components) {
|
||||
const db =
|
||||
comp.componentConfig?.dataBinding ||
|
||||
(comp as any).dataBinding;
|
||||
if (!db?.sourceComponentId || !db?.sourceColumn) continue;
|
||||
if (db.sourceComponentId !== detail.source) continue;
|
||||
|
||||
const colName = (comp as any).columnName || comp.componentConfig?.columnName;
|
||||
if (!colName) continue;
|
||||
|
||||
const selectedRow = detail.data[0];
|
||||
const value = selectedRow?.[db.sourceColumn] ?? "";
|
||||
bindingUpdates[colName] = value;
|
||||
}
|
||||
|
||||
if (Object.keys(bindingUpdates).length > 0) {
|
||||
setFormData((prev) => ({ ...prev, ...bindingUpdates }));
|
||||
formDataChangedRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("v2-table-selection", handler);
|
||||
return () => window.removeEventListener("v2-table-selection", handler);
|
||||
}, [modalState.isOpen, screenData?.components]);
|
||||
|
||||
// 화면의 실제 크기 계산 함수
|
||||
const calculateScreenDimensions = (components: ComponentData[]) => {
|
||||
if (components.length === 0) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -19,10 +19,11 @@ import { NumberingRuleConfig } from "@/types/numbering-rule";
|
|||
interface V2InputConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
menuObjid?: number; // 메뉴 OBJID (채번 규칙 필터링용)
|
||||
menuObjid?: number;
|
||||
allComponents?: any[];
|
||||
}
|
||||
|
||||
export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config, onChange, menuObjid }) => {
|
||||
export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config, onChange, menuObjid, allComponents = [] }) => {
|
||||
// 채번 규칙 목록 상태
|
||||
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
|
||||
const [loadingRules, setLoadingRules] = useState(false);
|
||||
|
|
@ -483,73 +484,202 @@ export const V2InputConfigPanel: React.FC<V2InputConfigPanelProps> = ({ config,
|
|||
|
||||
{/* 데이터 바인딩 설정 */}
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="dataBindingEnabled"
|
||||
checked={!!config.dataBinding?.sourceComponentId}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
updateConfig("dataBinding", {
|
||||
sourceComponentId: config.dataBinding?.sourceComponentId || "",
|
||||
sourceColumn: config.dataBinding?.sourceColumn || "",
|
||||
});
|
||||
} else {
|
||||
updateConfig("dataBinding", undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="dataBindingEnabled" className="text-xs font-semibold">
|
||||
테이블 선택 데이터 바인딩
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{config.dataBinding && (
|
||||
<div className="space-y-2 rounded border p-2">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
v2-table-list에서 행 선택 시 해당 컬럼 값이 자동으로 채워집니다
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">소스 컴포넌트 ID</Label>
|
||||
<Input
|
||||
value={config.dataBinding?.sourceComponentId || ""}
|
||||
onChange={(e) => {
|
||||
updateConfig("dataBinding", {
|
||||
...config.dataBinding,
|
||||
sourceComponentId: e.target.value,
|
||||
});
|
||||
}}
|
||||
placeholder="예: tbl_items"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
같은 화면 내 v2-table-list 컴포넌트의 ID
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">소스 컬럼명</Label>
|
||||
<Input
|
||||
value={config.dataBinding?.sourceColumn || ""}
|
||||
onChange={(e) => {
|
||||
updateConfig("dataBinding", {
|
||||
...config.dataBinding,
|
||||
sourceColumn: e.target.value,
|
||||
});
|
||||
}}
|
||||
placeholder="예: item_number"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
선택된 행에서 가져올 컬럼명
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DataBindingSection config={config} onChange={onChange} allComponents={allComponents} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
V2InputConfigPanel.displayName = "V2InputConfigPanel";
|
||||
|
||||
/**
|
||||
* 데이터 바인딩 설정 섹션
|
||||
* 같은 화면의 v2-table-list 컴포넌트를 자동 감지하여 드롭다운으로 표시
|
||||
*/
|
||||
function DataBindingSection({
|
||||
config,
|
||||
onChange,
|
||||
allComponents,
|
||||
}: {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
allComponents: any[];
|
||||
}) {
|
||||
const [tableColumns, setTableColumns] = useState<string[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
|
||||
// 같은 화면의 v2-table-list 컴포넌트만 필터링
|
||||
const tableListComponents = React.useMemo(() => {
|
||||
return allComponents.filter((comp) => {
|
||||
const type =
|
||||
comp.componentType ||
|
||||
comp.widgetType ||
|
||||
comp.componentConfig?.type ||
|
||||
(comp.url && comp.url.split("/").pop());
|
||||
return type === "v2-table-list";
|
||||
});
|
||||
}, [allComponents]);
|
||||
|
||||
// 선택된 테이블 컴포넌트의 테이블명 추출
|
||||
const selectedTableComponent = React.useMemo(() => {
|
||||
if (!config.dataBinding?.sourceComponentId) return null;
|
||||
return tableListComponents.find((comp) => comp.id === config.dataBinding.sourceComponentId);
|
||||
}, [tableListComponents, config.dataBinding?.sourceComponentId]);
|
||||
|
||||
const selectedTableName = React.useMemo(() => {
|
||||
if (!selectedTableComponent) return null;
|
||||
return (
|
||||
selectedTableComponent.componentConfig?.selectedTable ||
|
||||
selectedTableComponent.selectedTable ||
|
||||
null
|
||||
);
|
||||
}, [selectedTableComponent]);
|
||||
|
||||
// 선택된 테이블의 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
if (!selectedTableName) {
|
||||
setTableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadColumns = async () => {
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const { tableTypeApi } = await import("@/lib/api/screen");
|
||||
const response = await tableTypeApi.getTableTypeColumns(selectedTableName);
|
||||
if (response.success && response.data) {
|
||||
const cols = response.data.map((col: any) => col.column_name).filter(Boolean);
|
||||
setTableColumns(cols);
|
||||
}
|
||||
} catch {
|
||||
// 컬럼 정보를 못 가져오면 테이블 컴포넌트의 columns에서 추출
|
||||
const configColumns = selectedTableComponent?.componentConfig?.columns;
|
||||
if (Array.isArray(configColumns)) {
|
||||
setTableColumns(configColumns.map((c: any) => c.columnName).filter(Boolean));
|
||||
}
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [selectedTableName, selectedTableComponent]);
|
||||
|
||||
const updateConfig = (field: string, value: any) => {
|
||||
onChange({ ...config, [field]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="dataBindingEnabled"
|
||||
checked={!!config.dataBinding?.sourceComponentId}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
const firstTable = tableListComponents[0];
|
||||
updateConfig("dataBinding", {
|
||||
sourceComponentId: firstTable?.id || "",
|
||||
sourceColumn: "",
|
||||
});
|
||||
} else {
|
||||
updateConfig("dataBinding", undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="dataBindingEnabled" className="text-xs font-semibold">
|
||||
테이블 선택 데이터 바인딩
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{config.dataBinding && (
|
||||
<div className="space-y-2 rounded border p-2">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
테이블에서 행 선택 시 해당 컬럼 값이 자동으로 채워집니다
|
||||
</p>
|
||||
|
||||
{/* 소스 테이블 컴포넌트 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">소스 테이블</Label>
|
||||
{tableListComponents.length === 0 ? (
|
||||
<p className="text-[10px] text-amber-500">이 화면에 v2-table-list 컴포넌트가 없습니다</p>
|
||||
) : (
|
||||
<Select
|
||||
value={config.dataBinding?.sourceComponentId || ""}
|
||||
onValueChange={(value) => {
|
||||
updateConfig("dataBinding", {
|
||||
...config.dataBinding,
|
||||
sourceComponentId: value,
|
||||
sourceColumn: "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableListComponents.map((comp) => {
|
||||
const tblName =
|
||||
comp.componentConfig?.selectedTable || comp.selectedTable || "";
|
||||
const label = comp.componentConfig?.label || comp.label || comp.id;
|
||||
return (
|
||||
<SelectItem key={comp.id} value={comp.id}>
|
||||
{label} ({tblName || comp.id})
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 소스 컬럼 선택 */}
|
||||
{config.dataBinding?.sourceComponentId && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium">가져올 컬럼</Label>
|
||||
{loadingColumns ? (
|
||||
<p className="text-[10px] text-muted-foreground">컬럼 로딩 중...</p>
|
||||
) : tableColumns.length === 0 ? (
|
||||
<>
|
||||
<Input
|
||||
value={config.dataBinding?.sourceColumn || ""}
|
||||
onChange={(e) => {
|
||||
updateConfig("dataBinding", {
|
||||
...config.dataBinding,
|
||||
sourceColumn: e.target.value,
|
||||
});
|
||||
}}
|
||||
placeholder="컬럼명 직접 입력"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">컬럼 정보를 불러올 수 없어 직접 입력</p>
|
||||
</>
|
||||
) : (
|
||||
<Select
|
||||
value={config.dataBinding?.sourceColumn || ""}
|
||||
onValueChange={(value) => {
|
||||
updateConfig("dataBinding", {
|
||||
...config.dataBinding,
|
||||
sourceColumn: value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col} value={col}>
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default V2InputConfigPanel;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { apiClient } from "./client";
|
|||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
company_code: string;
|
||||
company_name: string | null;
|
||||
user_id: string;
|
||||
user_name: string | null;
|
||||
action: string;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -2115,6 +2115,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 }) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue