jskim-node #413

Merged
kjs merged 6 commits from jskim-node into main 2026-03-12 14:25:57 +09:00
14 changed files with 462 additions and 142 deletions
Showing only changes of commit b1e50f2e0a - Show all commits

View File

@ -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)]
);

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

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

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

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

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

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

@ -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 }) => {