jskim-node #390

Merged
kjs merged 11 commits from jskim-node into main 2026-02-23 12:17:52 +09:00
6 changed files with 147 additions and 219 deletions
Showing only changes of commit a466e523d9 - Show all commits

View File

@ -18,45 +18,6 @@ import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool impo
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성
/**
* (password)
* - table_type_columns에서 input_type = 'password'
* -
*/
async function maskPasswordColumns(tableName: string, data: any): Promise<any> {
try {
const passwordCols = await query<{ column_name: string }>(
`SELECT DISTINCT column_name FROM table_type_columns
WHERE table_name = $1 AND input_type = 'password'`,
[tableName]
);
if (passwordCols.length === 0) return data;
const passwordColumnNames = new Set(passwordCols.map(c => c.column_name));
// 단일 객체 처리
const maskRow = (row: any) => {
if (!row || typeof row !== "object") return row;
const masked = { ...row };
for (const col of passwordColumnNames) {
if (col in masked) {
masked[col] = ""; // 해시값 대신 빈 문자열
}
}
return masked;
};
if (Array.isArray(data)) {
return data.map(maskRow);
}
return maskRow(data);
} catch (error) {
// 마스킹 실패해도 원본 데이터 반환 (서비스 중단 방지)
console.warn("⚠️ password 컬럼 마스킹 실패:", error);
return data;
}
}
interface GetTableDataParams {
tableName: string;
limit?: number;
@ -661,14 +622,14 @@ class DataService {
return {
success: true,
data: await maskPasswordColumns(tableName, normalizedGroupRows), // 🔧 배열로 반환! + password 마스킹
data: normalizedGroupRows, // 🔧 배열로 반환!
};
}
}
return {
success: true,
data: await maskPasswordColumns(tableName, normalizedRows[0]), // 그룹핑 없으면 단일 레코드 + password 마스킹
data: normalizedRows[0], // 그룹핑 없으면 단일 레코드
};
}
}
@ -687,7 +648,7 @@ class DataService {
return {
success: true,
data: await maskPasswordColumns(tableName, result[0]), // password 마스킹
data: result[0],
};
} catch (error) {
console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error);

View File

@ -2,7 +2,6 @@ import { query, queryOne, transaction, getPool } from "../database/db";
import { EventTriggerService } from "./eventTriggerService";
import { DataflowControlService } from "./dataflowControlService";
import tableCategoryValueService from "./tableCategoryValueService";
import { PasswordUtils } from "../utils/passwordUtils";
export interface FormDataResult {
id: number;
@ -860,33 +859,6 @@ export class DynamicFormService {
}
}
// 비밀번호(password) 타입 컬럼 처리
// - 빈 값이면 변경 목록에서 제거 (기존 비밀번호 유지)
// - 값이 있으면 암호화 후 저장
try {
const passwordCols = await query<{ column_name: string }>(
`SELECT DISTINCT column_name FROM table_type_columns
WHERE table_name = $1 AND input_type = 'password'`,
[tableName]
);
for (const { column_name } of passwordCols) {
if (column_name in changedFields) {
const pwValue = changedFields[column_name];
if (!pwValue || pwValue === "") {
// 빈 값 → 기존 비밀번호 유지 (변경 목록에서 제거)
delete changedFields[column_name];
console.log(`🔐 비밀번호 필드 ${column_name}: 빈 값이므로 업데이트 스킵 (기존 유지)`);
} else {
// 값 있음 → 암호화하여 저장
changedFields[column_name] = PasswordUtils.encrypt(pwValue);
console.log(`🔐 비밀번호 필드 ${column_name}: 새 비밀번호 암호화 완료`);
}
}
}
} catch (pwError) {
console.warn("⚠️ 비밀번호 컬럼 처리 중 오류:", pwError);
}
// 변경된 필드가 없으면 업데이트 건너뛰기
if (Object.keys(changedFields).length === 0) {
console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다.");

View File

@ -2231,11 +2231,20 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
: component;
// 🆕 모든 레이어의 컴포넌트를 통합 (조건부 레이어 내 컴포넌트가 기본 레이어 formData 참조 가능하도록)
// 모든 레이어의 컴포넌트 통합 (조건 평가용 - 트리거 컴포넌트 검색에 필요)
const allLayerComponents = useMemo(() => {
return layers.flatMap((layer) => layer.components);
}, [layers]);
// 🔧 활성 레이어 컴포넌트만 통합 (저장/데이터 수집용)
// 기본 레이어(base) + 현재 활성화된 조건부 레이어만 포함
// 비활성 레이어의 중복 columnName 컴포넌트가 저장 데이터를 오염시키는 문제 해결
const visibleLayerComponents = useMemo(() => {
return layers
.filter((layer) => layer.type === "base" || activeLayerIds.includes(layer.id))
.flatMap((layer) => layer.components);
}, [layers, activeLayerIds]);
// 🆕 레이어별 컴포넌트 렌더링 함수
const renderLayerComponents = useCallback((layer: LayerDefinition) => {
// 활성화되지 않은 레이어는 렌더링하지 않음
@ -2272,7 +2281,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
>
<InteractiveScreenViewer
component={comp}
allComponents={allLayerComponents}
allComponents={visibleLayerComponents}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
@ -2344,7 +2353,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
>
<InteractiveScreenViewer
component={comp}
allComponents={allLayerComponents}
allComponents={visibleLayerComponents}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
@ -2387,7 +2396,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
>
<InteractiveScreenViewer
component={comp}
allComponents={allLayerComponents}
allComponents={visibleLayerComponents}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
@ -2423,7 +2432,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
>
<InteractiveScreenViewer
component={comp}
allComponents={allLayerComponents}
allComponents={visibleLayerComponents}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
@ -2433,7 +2442,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
})}
</div>
);
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents, layers]);
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, visibleLayerComponents, layers]);
return (
<SplitPanelProvider>

View File

@ -67,6 +67,10 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
[config, component.config, component.id],
);
// 소스 테이블의 키 필드명 (기본값: "item_id" → 하위 호환)
// 예: item_info 기반이면 "item_id", customer_mng 기반이면 "customer_id"
const sourceKeyField = componentConfig.sourceKeyField || "item_id";
// 🆕 dataSourceId 우선순위: URL 파라미터 > 컴포넌트 설정 > component.id
const dataSourceId = useMemo(
() => urlDataSourceId || componentConfig.dataSourceId || component.id || "default",
@ -228,7 +232,21 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const editTableName = new URLSearchParams(window.location.search).get("tableName");
const allTableData: Record<string, Record<string, any>[]> = {};
if (firstRecord.customer_id && firstRecord.item_id) {
// 동적 필터 구성: parentDataMapping의 targetField + sourceKeyField
const editFilters: Record<string, any> = {};
const parentMappings = componentConfig.parentDataMapping || [];
parentMappings.forEach((mapping: any) => {
if (mapping.targetField && firstRecord[mapping.targetField]) {
editFilters[mapping.targetField] = firstRecord[mapping.targetField];
}
});
if (firstRecord[sourceKeyField]) {
editFilters[sourceKeyField] = firstRecord[sourceKeyField];
}
const hasRequiredKeys = Object.keys(editFilters).length >= 2;
if (hasRequiredKeys) {
try {
const { dataApi } = await import("@/lib/api/data");
// 모든 sourceTable의 데이터를 API로 전체 로드 (중복 테이블 제거)
@ -238,10 +256,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
for (const table of allTables) {
const response = await dataApi.getTableData(table, {
filters: {
customer_id: firstRecord.customer_id,
item_id: firstRecord.item_id,
},
filters: editFilters,
sortBy: "created_date",
sortOrder: "desc",
});
@ -350,8 +365,8 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
}
const newItem: ItemData = {
// 수정 모드: item_id를 우선 사용 (id는 가격레코드의 PK일 수 있음)
id: String(firstRecord.item_id || firstRecord.id || "edit"),
// 수정 모드: sourceKeyField를 우선 사용 (id는 가격레코드의 PK일 수 있음)
id: String(firstRecord[sourceKeyField] || firstRecord.id || "edit"),
originalData: firstRecord,
fieldGroups: mainFieldGroups,
};
@ -635,39 +650,39 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const mainGroups = groupsByTable.get(mainTable) || [];
for (const item of items) {
// item_id 추출: originalData.item_id를 최우선 사용
// sourceKeyField 값 추출 (예: item_id 또는 customer_id)
// (수정 모드에서 autoFillFrom:"id"가 가격 레코드 PK를 반환하는 문제 방지)
let itemId: string | null = null;
let sourceKeyValue: string | null = null;
// 1순위: originalData에 item_id가 직접 있으면 사용 (수정 모드에서 정확한 값)
if (item.originalData && item.originalData.item_id) {
itemId = item.originalData.item_id;
// 1순위: originalData에 sourceKeyField가 직접 있으면 사용 (수정 모드에서 정확한 값)
if (item.originalData && item.originalData[sourceKeyField]) {
sourceKeyValue = item.originalData[sourceKeyField];
}
// 2순위: autoFillFrom 로직 (신규 등록 모드에서 사용)
if (!itemId) {
if (!sourceKeyValue) {
mainGroups.forEach((group) => {
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
groupFields.forEach((field) => {
if (field.name === "item_id" && field.autoFillFrom && item.originalData) {
itemId = item.originalData[field.autoFillFrom] || null;
if (field.name === sourceKeyField && field.autoFillFrom && item.originalData) {
sourceKeyValue = item.originalData[field.autoFillFrom] || null;
}
});
});
}
// 3순위: fallback (최후의 수단)
if (!itemId && item.originalData) {
itemId = item.originalData.id || null;
if (!sourceKeyValue && item.originalData) {
sourceKeyValue = item.originalData.id || null;
}
if (!itemId) {
console.error("❌ [2단계 저장] item_id를 찾을 수 없음:", item);
if (!sourceKeyValue) {
console.error(`❌ [2단계 저장] ${sourceKeyField}를 찾을 수 없음:`, item);
continue;
}
// upsert 공통 parentKeys: customer_id + item_id (정확한 매칭)
const itemParentKeys = { ...parentKeys, item_id: itemId };
// upsert 공통 parentKeys: parentMapping 키 + sourceKeyField (정확한 매칭)
const itemParentKeys = { ...parentKeys, [sourceKeyField]: sourceKeyValue };
// === Step 1: 메인 테이블(customer_item_mapping) 저장 ===
// 여러 개의 매핑 레코드 지원 (거래처 품번/품명이 다중일 수 있음)
@ -688,11 +703,11 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
if (entry._dbRecordId) {
record.id = entry._dbRecordId;
}
// item_id는 정확한 itemId 변수 사용 (autoFillFrom:"id" 오작동 방지)
record.item_id = itemId;
// sourceKeyField는 정확한 sourceKeyValue 변수 사용 (autoFillFrom:"id" 오작동 방지)
record[sourceKeyField] = sourceKeyValue;
// 나머지 autoFillFrom 필드 처리
groupFields.forEach((field) => {
if (field.name !== "item_id" && field.autoFillFrom && item.originalData) {
if (field.name !== sourceKeyField && field.autoFillFrom && item.originalData) {
const value = item.originalData[field.autoFillFrom];
if (value !== undefined && value !== null && !record[field.name]) {
record[field.name] = value;
@ -1700,7 +1715,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
// 디자인 모드: 샘플 데이터로 미리보기 표시
if (isDesignMode) {
const sampleDisplayCols = componentConfig.displayColumns || [];
const sampleFields = (componentConfig.additionalFields || []).filter(f => f.name !== "item_id" && f.width !== "0px");
const sampleFields = (componentConfig.additionalFields || []).filter(f => f.name !== sourceKeyField && f.width !== "0px");
const sampleGroups = componentConfig.fieldGroups || [{ id: "default", title: "입력 정보", order: 0 }];
const gridCols = sampleGroups.length === 1 ? "grid-cols-1" : "grid-cols-2";

View File

@ -1183,31 +1183,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
// leftItem이 null이면 join 모드 이외에는 데이터 로드 불가
// detail 모드: 선택 안 하면 아무것도 안 뜸, 선택하면 필터링
// join 모드: 선택 안 하면 전체, 선택하면 필터링
if (!leftItem) return;
setIsLoadingRight(true);
try {
if (relationshipType === "detail") {
// 상세 모드: 동일 테이블의 상세 정보 (엔티티 조인 활성화)
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
// 🆕 엔티티 조인 API 사용
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const rightDetailJoinColumns = extractAdditionalJoinColumns(
componentConfig.rightPanel?.columns,
rightTableName,
);
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
search: { id: primaryKey },
enableEntityJoin: true,
size: 1,
companyCodeOverride: companyCode,
additionalJoinColumns: rightDetailJoinColumns, // 🆕 Entity 조인 컬럼 전달
});
const detail = result.items && result.items.length > 0 ? result.items[0] : null;
setRightData(detail);
} else if (relationshipType === "join") {
// detail / join 모두 동일한 필터링 로직 사용
// (차이점: 초기 로드 여부만 다름 - detail은 초기 로드 안 함)
{
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
const keys = componentConfig.rightPanel?.relation?.keys;
const leftTable = componentConfig.leftPanel?.tableName;
@ -1443,8 +1427,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 탭의 dataFilter (API 전달용)
const tabDataFilterForApi = (tabConfig as any).dataFilter;
// 탭의 relation type 확인 (detail이면 초기 전체 로드 안 함)
const tabRelationType = tabConfig.relation?.type || "join";
if (!leftItem) {
// 좌측 미선택: 전체 데이터 로드 (dataFilter는 API에 전달)
if (tabRelationType === "detail") {
// detail 모드: 선택 안 하면 아무것도 안 뜸
resultData = [];
} else {
// join 모드: 좌측 미선택 시 전체 데이터 로드 (dataFilter는 API에 전달)
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
enableEntityJoin: true,
size: 1000,
@ -1453,6 +1444,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
dataFilter: tabDataFilterForApi,
});
resultData = result.data || [];
}
} else if (leftColumn && rightColumn) {
const searchConditions: Record<string, any> = {};
@ -2781,16 +2773,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (!isDesignMode && componentConfig.autoLoad !== false) {
loadLeftData();
// 좌측 미선택 상태에서 우측 전체 데이터 기본 로드
// join 모드: 초기 전체 로드 / detail 모드: 초기 로드 안 함
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
if (relationshipType === "join") {
loadRightData(null);
// 추가 탭도 전체 데이터 로드
}
// 추가 탭: 각 탭의 relation.type에 따라 초기 로드 결정
const tabs = componentConfig.rightPanel?.additionalTabs;
if (tabs && tabs.length > 0) {
tabs.forEach((_: any, idx: number) => {
tabs.forEach((tab: any, idx: number) => {
const tabRelType = tab.relation?.type || "join";
if (tabRelType === "join") {
loadTabData(idx + 1, null);
});
}
});
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -4645,7 +4641,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
) : (
<>
<p className="mb-2"> </p>
<p className="text-xs"> </p>
<p className="text-xs"> </p>
</>
)}
</div>

View File

@ -1542,13 +1542,10 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
return leftTableName ? loadedTableColumns[leftTableName] || [] : [];
}, [loadedTableColumns, leftTableName]);
// 우측 테이블명 (상세 모드에서는 좌측과 동일)
// 우측 테이블명
const rightTableName = useMemo(() => {
if (relationshipType === "detail") {
return leftTableName; // 상세 모드에서는 좌측과 동일
}
return config.rightPanel?.tableName || "";
}, [relationshipType, leftTableName, config.rightPanel?.tableName]);
}, [config.rightPanel?.tableName]);
// 우측 테이블 컬럼 (로드된 컬럼 사용)
const rightTableColumns = useMemo(() => {
@ -1567,8 +1564,8 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
);
}
// 조인 모드에서 우측 테이블 선택 시 사용할 테이블 목록
const availableRightTables = relationshipType === "join" ? allTables : tables;
// 우측 테이블 선택 시 사용할 테이블 목록 (모든 모드에서 전체 테이블 선택 가능)
const availableRightTables = allTables;
console.log("📊 분할패널 테이블 목록 상태:");
console.log(" - relationshipType:", relationshipType);
@ -1584,7 +1581,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
{
id: "basic",
title: "기본 설정",
desc: `${relationshipType === "detail" ? "1건 상세보기" : "연관 목록"} | 비율 ${config.splitRatio || 30}%`,
desc: `${relationshipType === "detail" ? "선택 시 표시" : "연관 목록"} | 비율 ${config.splitRatio || 30}%`,
icon: Settings2,
},
{
@ -1638,35 +1635,27 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
<Select
value={relationshipType}
onValueChange={(value: "join" | "detail") => {
// 상세 모드로 변경 시 우측 테이블을 현재 화면 테이블로 설정
if (value === "detail" && screenTableName) {
updateRightPanel({
relation: { ...config.rightPanel?.relation, type: value },
tableName: screenTableName,
});
} else {
updateRightPanel({
relation: { ...config.rightPanel?.relation, type: value },
});
}
}}
>
<SelectTrigger className="h-10 bg-white">
<SelectValue placeholder="표시 방식 선택">
{relationshipType === "detail" ? "1건 상세보기" : "연관 목록"}
{relationshipType === "detail" ? "선택 시 표시" : "연관 목록"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="detail">
<div className="flex flex-col py-1">
<span className="text-sm font-medium">1 </span>
<span className="text-xs text-gray-500"> ( )</span>
<span className="text-sm font-medium"> </span>
<span className="text-xs text-gray-500"> / </span>
</div>
</SelectItem>
<SelectItem value="join">
<div className="flex flex-col py-1">
<span className="text-sm font-medium"> </span>
<span className="text-xs text-gray-500"> / </span>
<span className="text-xs text-gray-500"> / </span>
</div>
</SelectItem>
</SelectContent>
@ -2305,7 +2294,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
<div className="space-y-4">
{/* 우측 패널 설정 */}
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> ({relationshipType === "detail" ? "1건 상세보기" : "연관 목록"})</h3>
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> ({relationshipType === "detail" ? "선택 시 표시" : "연관 목록"})</h3>
<div className="space-y-2">
<Label> </Label>
@ -2338,21 +2327,8 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div> */}
{/* 관계 타입에 따라 테이블 선택 UI 변경 */}
{relationshipType === "detail" ? (
// 상세 모드: 좌측과 동일한 테이블 (자동 설정)
<div className="space-y-2">
<Label> ( )</Label>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
<p className="text-sm font-medium text-gray-900">
{config.leftPanel?.tableName || screenTableName || "테이블이 지정되지 않음"}
</p>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
</div>
) : (
// 조건 필터 모드: 전체 테이블에서 선택 가능
<div className="space-y-2">
<Label> </Label>
<Label> </Label>
<Popover open={rightTableOpen} onOpenChange={setRightTableOpen}>
<PopoverTrigger asChild>
<Button
@ -2394,7 +2370,6 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</PopoverContent>
</Popover>
</div>
)}
<div className="space-y-2">
<Label> </Label>