jskim-node #390
|
|
@ -565,12 +565,32 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
return newActiveIds;
|
return newActiveIds;
|
||||||
}, [formData, groupData, conditionalLayers, screenData?.components]);
|
}, [formData, groupData, conditionalLayers, screenData?.components]);
|
||||||
|
|
||||||
// 🆕 활성화된 조건부 레이어의 컴포넌트 가져오기
|
// 활성화된 조건부 레이어의 컴포넌트 가져오기 (Zone 오프셋 적용)
|
||||||
const activeConditionalComponents = useMemo(() => {
|
const activeConditionalComponents = useMemo(() => {
|
||||||
return conditionalLayers
|
return conditionalLayers
|
||||||
.filter((layer) => activeConditionalLayerIds.includes(layer.id))
|
.filter((layer) => activeConditionalLayerIds.includes(layer.id))
|
||||||
.flatMap((layer) => (layer as LayerDefinition & { components: ComponentData[] }).components || []);
|
.flatMap((layer) => {
|
||||||
}, [conditionalLayers, activeConditionalLayerIds]);
|
const layerWithComps = layer as LayerDefinition & { components: ComponentData[] };
|
||||||
|
const comps = layerWithComps.components || [];
|
||||||
|
|
||||||
|
// Zone 오프셋 적용: 조건부 레이어 컴포넌트는 Zone 내부 상대 좌표로 저장되므로
|
||||||
|
// Zone의 절대 좌표를 더해줘야 EditModal에서 올바른 위치에 렌더링됨
|
||||||
|
const associatedZone = zones.find((z) => z.zone_id === (layer as any).zoneId);
|
||||||
|
if (!associatedZone) return comps;
|
||||||
|
|
||||||
|
const zoneOffsetX = associatedZone.x || 0;
|
||||||
|
const zoneOffsetY = associatedZone.y || 0;
|
||||||
|
|
||||||
|
return comps.map((comp) => ({
|
||||||
|
...comp,
|
||||||
|
position: {
|
||||||
|
...comp.position,
|
||||||
|
x: parseFloat(comp.position?.x?.toString() || "0") + zoneOffsetX,
|
||||||
|
y: parseFloat(comp.position?.y?.toString() || "0") + zoneOffsetY,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}, [conditionalLayers, activeConditionalLayerIds, zones]);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setModalState({
|
setModalState({
|
||||||
|
|
@ -881,14 +901,31 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// V2Repeater 저장 이벤트 발생 (디테일 테이블 데이터 저장)
|
||||||
|
const hasRepeaterInstances = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
|
||||||
|
if (hasRepeaterInstances) {
|
||||||
|
const masterRecordId = groupData[0]?.id || formData.id;
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("repeaterSave", {
|
||||||
|
detail: {
|
||||||
|
parentId: masterRecordId,
|
||||||
|
masterRecordId,
|
||||||
|
mainFormData: formData,
|
||||||
|
tableName: screenData.screenInfo.tableName,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
console.log("📋 [EditModal] 그룹 저장 후 repeaterSave 이벤트 발생:", { masterRecordId });
|
||||||
|
}
|
||||||
|
|
||||||
// 결과 메시지
|
// 결과 메시지
|
||||||
const messages: string[] = [];
|
const messages: string[] = [];
|
||||||
if (insertedCount > 0) messages.push(`${insertedCount}개 추가`);
|
if (insertedCount > 0) messages.push(`${insertedCount}개 추가`);
|
||||||
if (updatedCount > 0) messages.push(`${updatedCount}개 수정`);
|
if (updatedCount > 0) messages.push(`${updatedCount}개 수정`);
|
||||||
if (deletedCount > 0) messages.push(`${deletedCount}개 삭제`);
|
if (deletedCount > 0) messages.push(`${deletedCount}개 삭제`);
|
||||||
|
|
||||||
if (messages.length > 0) {
|
if (messages.length > 0 || hasRepeaterInstances) {
|
||||||
toast.success(`품목이 저장되었습니다 (${messages.join(", ")})`);
|
toast.success(messages.length > 0 ? `품목이 저장되었습니다 (${messages.join(", ")})` : "저장되었습니다.");
|
||||||
|
|
||||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||||
if (modalState.onSave) {
|
if (modalState.onSave) {
|
||||||
|
|
|
||||||
|
|
@ -5555,8 +5555,12 @@ export default function ScreenDesigner({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 삭제 (단일/다중 선택 지원)
|
// 6. 삭제 (단일/다중 선택 지원) - Delete 또는 Backspace(Mac)
|
||||||
if (e.key === "Delete" && (selectedComponent || groupState.selectedComponents.length > 0)) {
|
const isInputFocused = document.activeElement instanceof HTMLInputElement ||
|
||||||
|
document.activeElement instanceof HTMLTextAreaElement ||
|
||||||
|
document.activeElement instanceof HTMLSelectElement ||
|
||||||
|
(document.activeElement as HTMLElement)?.isContentEditable;
|
||||||
|
if ((e.key === "Delete" || (e.key === "Backspace" && !isInputFocused)) && (selectedComponent || groupState.selectedComponents.length > 0)) {
|
||||||
// console.log("🗑️ 컴포넌트 삭제 (단축키)");
|
// console.log("🗑️ 컴포넌트 삭제 (단축키)");
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -7418,7 +7422,7 @@ export default function ScreenDesigner({
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span className="font-medium">편집:</span> Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장),
|
<span className="font-medium">편집:</span> Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장),
|
||||||
Ctrl+Z(실행취소), Delete(삭제)
|
Ctrl+Z(실행취소), Delete/Backspace(삭제)
|
||||||
</p>
|
</p>
|
||||||
<p className="text-warning flex items-center justify-center gap-2">
|
<p className="text-warning flex items-center justify-center gap-2">
|
||||||
<span>⚠️</span>
|
<span>⚠️</span>
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
onDataChange,
|
onDataChange,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
className,
|
className,
|
||||||
|
formData: parentFormData,
|
||||||
}) => {
|
}) => {
|
||||||
// 설정 병합
|
// 설정 병합
|
||||||
const config: V2RepeaterConfig = useMemo(
|
const config: V2RepeaterConfig = useMemo(
|
||||||
|
|
@ -153,21 +154,15 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
// 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함)
|
// 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함)
|
||||||
let mergedData: Record<string, any>;
|
let mergedData: Record<string, any>;
|
||||||
if (config.useCustomTable && config.mainTableName) {
|
if (config.useCustomTable && config.mainTableName) {
|
||||||
// 커스텀 테이블: 리피터 데이터만 저장
|
|
||||||
mergedData = { ...cleanRow };
|
mergedData = { ...cleanRow };
|
||||||
|
|
||||||
// 🆕 FK 자동 연결 - foreignKeySourceColumn이 설정된 경우 해당 컬럼 값 사용
|
|
||||||
if (config.foreignKeyColumn) {
|
if (config.foreignKeyColumn) {
|
||||||
// foreignKeySourceColumn이 있으면 mainFormData에서 해당 컬럼 값 사용
|
|
||||||
// 없으면 마스터 레코드 ID 사용 (기존 동작)
|
|
||||||
const sourceColumn = config.foreignKeySourceColumn;
|
const sourceColumn = config.foreignKeySourceColumn;
|
||||||
let fkValue: any;
|
let fkValue: any;
|
||||||
|
|
||||||
if (sourceColumn && mainFormData && mainFormData[sourceColumn] !== undefined) {
|
if (sourceColumn && mainFormData && mainFormData[sourceColumn] !== undefined) {
|
||||||
// mainFormData에서 참조 컬럼 값 가져오기
|
|
||||||
fkValue = mainFormData[sourceColumn];
|
fkValue = mainFormData[sourceColumn];
|
||||||
} else {
|
} else {
|
||||||
// 기본: 마스터 레코드 ID 사용
|
|
||||||
fkValue = masterRecordId;
|
fkValue = masterRecordId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,7 +171,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 기존 방식: 메인 폼 데이터 병합
|
|
||||||
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
|
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
|
||||||
mergedData = {
|
mergedData = {
|
||||||
...mainFormDataWithoutId,
|
...mainFormDataWithoutId,
|
||||||
|
|
@ -192,7 +186,19 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
|
// 기존 행(id 존재)은 UPDATE, 새 행은 INSERT
|
||||||
|
const rowId = row.id;
|
||||||
|
if (rowId && typeof rowId === "string" && rowId.includes("-")) {
|
||||||
|
// UUID 형태의 id가 있으면 기존 데이터 → UPDATE
|
||||||
|
const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData;
|
||||||
|
await apiClient.put(`/table-management/tables/${tableName}/edit`, {
|
||||||
|
originalData: { id: rowId },
|
||||||
|
updatedData: updateFields,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 새 행 → INSERT
|
||||||
|
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ V2Repeater 저장 실패:", error);
|
console.error("❌ V2Repeater 저장 실패:", error);
|
||||||
|
|
@ -228,6 +234,108 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
parentId,
|
parentId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 수정 모드: useCustomTable + FK 기반으로 기존 디테일 데이터 자동 로드
|
||||||
|
const dataLoadedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (dataLoadedRef.current) return;
|
||||||
|
if (!config.useCustomTable || !config.mainTableName || !config.foreignKeyColumn) return;
|
||||||
|
if (!parentFormData) return;
|
||||||
|
|
||||||
|
const fkSourceColumn = config.foreignKeySourceColumn || config.foreignKeyColumn;
|
||||||
|
const fkValue = parentFormData[fkSourceColumn];
|
||||||
|
if (!fkValue) return;
|
||||||
|
|
||||||
|
// 이미 데이터가 있으면 로드하지 않음
|
||||||
|
if (data.length > 0) return;
|
||||||
|
|
||||||
|
const loadExistingData = async () => {
|
||||||
|
try {
|
||||||
|
console.log("📥 [V2Repeater] 수정 모드 데이터 로드:", {
|
||||||
|
tableName: config.mainTableName,
|
||||||
|
fkColumn: config.foreignKeyColumn,
|
||||||
|
fkValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${config.mainTableName}/data`,
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
search: { [config.foreignKeyColumn]: fkValue },
|
||||||
|
autoFilter: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
|
||||||
|
if (Array.isArray(rows) && rows.length > 0) {
|
||||||
|
console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`);
|
||||||
|
|
||||||
|
// isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강
|
||||||
|
const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay);
|
||||||
|
const sourceTable = config.dataSource?.sourceTable;
|
||||||
|
const fkColumn = config.dataSource?.foreignKey;
|
||||||
|
const refKey = config.dataSource?.referenceKey || "id";
|
||||||
|
|
||||||
|
if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) {
|
||||||
|
try {
|
||||||
|
const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean);
|
||||||
|
const uniqueValues = [...new Set(fkValues)];
|
||||||
|
|
||||||
|
if (uniqueValues.length > 0) {
|
||||||
|
// FK 값 기반으로 소스 테이블에서 해당 레코드만 조회
|
||||||
|
const sourcePromises = uniqueValues.map((val) =>
|
||||||
|
apiClient.post(`/table-management/tables/${sourceTable}/data`, {
|
||||||
|
page: 1, size: 1,
|
||||||
|
search: { [refKey]: val },
|
||||||
|
autoFilter: true,
|
||||||
|
}).then((r) => r.data?.data?.data || r.data?.data?.rows || [])
|
||||||
|
.catch(() => [])
|
||||||
|
);
|
||||||
|
const sourceResults = await Promise.all(sourcePromises);
|
||||||
|
const sourceMap = new Map<string, any>();
|
||||||
|
sourceResults.flat().forEach((sr: any) => {
|
||||||
|
if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 각 행에 소스 테이블의 표시 데이터 병합
|
||||||
|
// RepeaterTable은 isSourceDisplay 컬럼을 `_display_${col.key}` 필드로 렌더링함
|
||||||
|
rows.forEach((row: any) => {
|
||||||
|
const sourceRecord = sourceMap.get(String(row[fkColumn]));
|
||||||
|
if (sourceRecord) {
|
||||||
|
sourceDisplayColumns.forEach((col) => {
|
||||||
|
const displayValue = sourceRecord[col.key] ?? null;
|
||||||
|
row[col.key] = displayValue;
|
||||||
|
row[`_display_${col.key}`] = displayValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료");
|
||||||
|
}
|
||||||
|
} catch (sourceError) {
|
||||||
|
console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(rows);
|
||||||
|
dataLoadedRef.current = true;
|
||||||
|
if (onDataChange) onDataChange(rows);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [V2Repeater] 기존 데이터 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadExistingData();
|
||||||
|
}, [
|
||||||
|
config.useCustomTable,
|
||||||
|
config.mainTableName,
|
||||||
|
config.foreignKeyColumn,
|
||||||
|
config.foreignKeySourceColumn,
|
||||||
|
parentFormData,
|
||||||
|
data.length,
|
||||||
|
onDataChange,
|
||||||
|
]);
|
||||||
|
|
||||||
// 현재 테이블 컬럼 정보 로드
|
// 현재 테이블 컬럼 정보 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCurrentTableColumnInfo = async () => {
|
const loadCurrentTableColumnInfo = async () => {
|
||||||
|
|
@ -451,58 +559,71 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
loadCategoryLabels();
|
loadCategoryLabels();
|
||||||
}, [data, sourceCategoryColumns]);
|
}, [data, sourceCategoryColumns]);
|
||||||
|
|
||||||
|
// 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능)
|
||||||
|
const applyCalculationRules = useCallback(
|
||||||
|
(row: any): any => {
|
||||||
|
const rules = config.calculationRules;
|
||||||
|
if (!rules || rules.length === 0) return row;
|
||||||
|
|
||||||
|
const updatedRow = { ...row };
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (!rule.targetColumn || !rule.formula) continue;
|
||||||
|
try {
|
||||||
|
let formula = rule.formula;
|
||||||
|
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
|
||||||
|
for (const field of fieldMatches) {
|
||||||
|
if (field === rule.targetColumn) continue;
|
||||||
|
// 직접 필드 → _display_* 필드 순으로 값 탐색
|
||||||
|
const raw = updatedRow[field] ?? updatedRow[`_display_${field}`];
|
||||||
|
const value = parseFloat(raw) || 0;
|
||||||
|
formula = formula.replace(new RegExp(`\\b${field}\\b`, "g"), value.toString());
|
||||||
|
}
|
||||||
|
updatedRow[rule.targetColumn] = new Function(`return ${formula}`)();
|
||||||
|
} catch {
|
||||||
|
updatedRow[rule.targetColumn] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updatedRow;
|
||||||
|
},
|
||||||
|
[config.calculationRules],
|
||||||
|
);
|
||||||
|
|
||||||
|
// _targetTable 메타데이터 포함하여 onDataChange 호출
|
||||||
|
const notifyDataChange = useCallback(
|
||||||
|
(newData: any[]) => {
|
||||||
|
if (!onDataChange) return;
|
||||||
|
const targetTable =
|
||||||
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||||
|
if (targetTable) {
|
||||||
|
onDataChange(newData.map((row) => ({ ...row, _targetTable: targetTable })));
|
||||||
|
} else {
|
||||||
|
onDataChange(newData);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
|
||||||
|
);
|
||||||
|
|
||||||
// 데이터 변경 핸들러
|
// 데이터 변경 핸들러
|
||||||
const handleDataChange = useCallback(
|
const handleDataChange = useCallback(
|
||||||
(newData: any[]) => {
|
(newData: any[]) => {
|
||||||
setData(newData);
|
const calculated = newData.map(applyCalculationRules);
|
||||||
|
setData(calculated);
|
||||||
// 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용)
|
notifyDataChange(calculated);
|
||||||
if (onDataChange) {
|
|
||||||
const targetTable =
|
|
||||||
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
|
||||||
|
|
||||||
if (targetTable) {
|
|
||||||
// 각 행에 _targetTable 추가
|
|
||||||
const dataWithTarget = newData.map((row) => ({
|
|
||||||
...row,
|
|
||||||
_targetTable: targetTable,
|
|
||||||
}));
|
|
||||||
onDataChange(dataWithTarget);
|
|
||||||
} else {
|
|
||||||
onDataChange(newData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정
|
|
||||||
setAutoWidthTrigger((prev) => prev + 1);
|
setAutoWidthTrigger((prev) => prev + 1);
|
||||||
},
|
},
|
||||||
[onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
|
[applyCalculationRules, notifyDataChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 행 변경 핸들러
|
// 행 변경 핸들러
|
||||||
const handleRowChange = useCallback(
|
const handleRowChange = useCallback(
|
||||||
(index: number, newRow: any) => {
|
(index: number, newRow: any) => {
|
||||||
|
const calculated = applyCalculationRules(newRow);
|
||||||
const newData = [...data];
|
const newData = [...data];
|
||||||
newData[index] = newRow;
|
newData[index] = calculated;
|
||||||
setData(newData);
|
setData(newData);
|
||||||
|
notifyDataChange(newData);
|
||||||
// 🆕 _targetTable 메타데이터 포함
|
|
||||||
if (onDataChange) {
|
|
||||||
const targetTable =
|
|
||||||
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
|
||||||
|
|
||||||
if (targetTable) {
|
|
||||||
const dataWithTarget = newData.map((row) => ({
|
|
||||||
...row,
|
|
||||||
_targetTable: targetTable,
|
|
||||||
}));
|
|
||||||
onDataChange(dataWithTarget);
|
|
||||||
} else {
|
|
||||||
onDataChange(newData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[data, onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
|
[data, applyCalculationRules, notifyDataChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 행 삭제 핸들러
|
// 행 삭제 핸들러
|
||||||
|
|
|
||||||
|
|
@ -153,13 +153,11 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
<Command
|
<Command
|
||||||
filter={(value, search) => {
|
filter={(itemValue, search) => {
|
||||||
// value는 CommandItem의 value (라벨)
|
|
||||||
// search는 검색어
|
|
||||||
if (!search) return 1;
|
if (!search) return 1;
|
||||||
const normalizedValue = value.toLowerCase();
|
const option = options.find((o) => o.value === itemValue);
|
||||||
const normalizedSearch = search.toLowerCase();
|
const label = (option?.label || option?.value || "").toLowerCase();
|
||||||
if (normalizedValue.includes(normalizedSearch)) return 1;
|
if (label.includes(search.toLowerCase())) return 1;
|
||||||
return 0;
|
return 0;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -172,7 +170,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={displayLabel}
|
value={option.value}
|
||||||
onSelect={() => handleSelect(option.value)}
|
onSelect={() => handleSelect(option.value)}
|
||||||
>
|
>
|
||||||
<Check
|
<Check
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,9 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||||
const [currentTableColumns, setCurrentTableColumns] = useState<ColumnOption[]>([]); // 현재 테이블 컬럼
|
const [currentTableColumns, setCurrentTableColumns] = useState<ColumnOption[]>([]); // 현재 테이블 컬럼
|
||||||
const [entityColumns, setEntityColumns] = useState<EntityColumnOption[]>([]); // 엔티티 타입 컬럼
|
const [entityColumns, setEntityColumns] = useState<EntityColumnOption[]>([]); // 엔티티 타입 컬럼
|
||||||
const [sourceTableColumns, setSourceTableColumns] = useState<ColumnOption[]>([]); // 소스(엔티티) 테이블 컬럼
|
const [sourceTableColumns, setSourceTableColumns] = useState<ColumnOption[]>([]); // 소스(엔티티) 테이블 컬럼
|
||||||
const [calculationRules, setCalculationRules] = useState<CalculationRule[]>([]);
|
const [calculationRules, setCalculationRules] = useState<CalculationRule[]>(
|
||||||
|
config.calculationRules || []
|
||||||
|
);
|
||||||
|
|
||||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||||
const [loadingSourceColumns, setLoadingSourceColumns] = useState(false);
|
const [loadingSourceColumns, setLoadingSourceColumns] = useState(false);
|
||||||
|
|
@ -553,26 +555,56 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||||
updateConfig({ columns: newColumns });
|
updateConfig({ columns: newColumns });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 계산 규칙을 config에 반영하는 헬퍼
|
||||||
|
const syncCalculationRules = (rules: CalculationRule[]) => {
|
||||||
|
setCalculationRules(rules);
|
||||||
|
updateConfig({ calculationRules: rules });
|
||||||
|
};
|
||||||
|
|
||||||
// 계산 규칙 추가
|
// 계산 규칙 추가
|
||||||
const addCalculationRule = () => {
|
const addCalculationRule = () => {
|
||||||
setCalculationRules(prev => [
|
const newRules = [
|
||||||
...prev,
|
...calculationRules,
|
||||||
{ id: `calc_${Date.now()}`, targetColumn: "", formula: "" }
|
{ id: `calc_${Date.now()}`, targetColumn: "", formula: "" }
|
||||||
]);
|
];
|
||||||
|
syncCalculationRules(newRules);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 계산 규칙 삭제
|
// 계산 규칙 삭제
|
||||||
const removeCalculationRule = (id: string) => {
|
const removeCalculationRule = (id: string) => {
|
||||||
setCalculationRules(prev => prev.filter(r => r.id !== id));
|
syncCalculationRules(calculationRules.filter(r => r.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 계산 규칙 업데이트
|
// 계산 규칙 업데이트
|
||||||
const updateCalculationRule = (id: string, field: keyof CalculationRule, value: string) => {
|
const updateCalculationRule = (id: string, field: keyof CalculationRule, value: string) => {
|
||||||
setCalculationRules(prev =>
|
syncCalculationRules(
|
||||||
prev.map(r => r.id === id ? { ...r, [field]: value } : r)
|
calculationRules.map(r => r.id === id ? { ...r, [field]: value } : r)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 수식 입력 필드에 컬럼명 삽입
|
||||||
|
const insertColumnToFormula = (ruleId: string, columnKey: string) => {
|
||||||
|
const rule = calculationRules.find(r => r.id === ruleId);
|
||||||
|
if (!rule) return;
|
||||||
|
const newFormula = rule.formula ? `${rule.formula} ${columnKey}` : columnKey;
|
||||||
|
updateCalculationRule(ruleId, "formula", newFormula);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 수식의 영어 컬럼명을 한글 제목으로 변환
|
||||||
|
const formulaToKorean = (formula: string): string => {
|
||||||
|
if (!formula) return "";
|
||||||
|
let result = formula;
|
||||||
|
const allCols = config.columns || [];
|
||||||
|
// 긴 컬럼명부터 치환 (부분 매칭 방지)
|
||||||
|
const sorted = [...allCols].sort((a, b) => b.key.length - a.key.length);
|
||||||
|
for (const col of sorted) {
|
||||||
|
if (col.title && col.key) {
|
||||||
|
result = result.replace(new RegExp(`\\b${col.key}\\b`, "g"), col.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
// 엔티티 컬럼 선택 시 소스 테이블 자동 설정
|
// 엔티티 컬럼 선택 시 소스 테이블 자동 설정
|
||||||
const handleEntityColumnSelect = (columnName: string) => {
|
const handleEntityColumnSelect = (columnName: string) => {
|
||||||
const selectedEntity = entityColumns.find(c => c.columnName === columnName);
|
const selectedEntity = entityColumns.find(c => c.columnName === columnName);
|
||||||
|
|
@ -1374,7 +1406,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||||
{(isModalMode || isInlineMode) && config.columns.length > 0 && (
|
{(isModalMode || isInlineMode) && config.columns.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs font-medium">계산 규칙</Label>
|
<Label className="text-xs font-medium">계산 규칙</Label>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={addCalculationRule} className="h-6 text-xs">
|
<Button type="button" variant="outline" size="sm" onClick={addCalculationRule} className="h-6 text-xs">
|
||||||
|
|
@ -1382,52 +1414,100 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||||
추가
|
추가
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
예: 금액 = 수량 * 단가
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="max-h-32 space-y-2 overflow-y-auto">
|
<div className="space-y-2">
|
||||||
{calculationRules.map((rule) => (
|
{calculationRules.map((rule) => (
|
||||||
<div key={rule.id} className="flex items-center gap-2 rounded border p-2">
|
<div key={rule.id} className="space-y-1 rounded border p-1.5">
|
||||||
<Select
|
<div className="flex items-center gap-1">
|
||||||
value={rule.targetColumn}
|
<Select
|
||||||
onValueChange={(value) => updateCalculationRule(rule.id, "targetColumn", value)}
|
value={rule.targetColumn}
|
||||||
>
|
onValueChange={(value) => updateCalculationRule(rule.id, "targetColumn", value)}
|
||||||
<SelectTrigger className="h-7 w-24 text-xs">
|
>
|
||||||
<SelectValue placeholder="결과" />
|
<SelectTrigger className="h-6 w-20 text-[10px]">
|
||||||
</SelectTrigger>
|
<SelectValue placeholder="결과" />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
{config.columns.map((col) => (
|
<SelectContent>
|
||||||
<SelectItem key={col.key} value={col.key}>
|
{config.columns.filter(col => !col.isSourceDisplay).map((col) => (
|
||||||
{col.title}
|
<SelectItem key={col.key} value={col.key} className="text-xs">
|
||||||
</SelectItem>
|
{col.title || col.key}
|
||||||
))}
|
</SelectItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-[10px]">=</span>
|
||||||
|
<Input
|
||||||
|
value={rule.formula}
|
||||||
|
onChange={(e) => updateCalculationRule(rule.id, "formula", e.target.value)}
|
||||||
|
placeholder="컬럼 클릭 또는 직접 입력"
|
||||||
|
className="h-6 flex-1 font-mono text-[10px]"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeCalculationRule(rule.id)}
|
||||||
|
className="h-6 w-6 p-0 text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span className="text-xs">=</span>
|
{/* 한글 수식 미리보기 */}
|
||||||
|
{rule.formula && (
|
||||||
|
<p className="truncate rounded bg-muted/50 px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
{config.columns.find(c => c.key === rule.targetColumn)?.title || rule.targetColumn || "결과"} = {formulaToKorean(rule.formula)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<Input
|
{/* 컬럼 칩: 디테일 컬럼 + 소스(품목) 컬럼 + 연산자 */}
|
||||||
value={rule.formula}
|
<div className="flex flex-wrap gap-0.5">
|
||||||
onChange={(e) => updateCalculationRule(rule.id, "formula", e.target.value)}
|
{config.columns
|
||||||
placeholder="quantity * unit_price"
|
.filter(col => col.key !== rule.targetColumn && !col.isSourceDisplay)
|
||||||
className="h-7 flex-1 text-xs"
|
.map((col) => (
|
||||||
/>
|
<Button
|
||||||
|
key={col.key}
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant="secondary"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
onClick={() => insertColumnToFormula(rule.id, col.key)}
|
||||||
onClick={() => removeCalculationRule(rule.id)}
|
className="h-4 px-1 text-[9px]"
|
||||||
className="h-7 w-7 p-0 text-destructive"
|
>
|
||||||
>
|
{col.title || col.key}
|
||||||
<Trash2 className="h-3 w-3" />
|
</Button>
|
||||||
</Button>
|
))}
|
||||||
|
{config.columns
|
||||||
|
.filter(col => col.isSourceDisplay)
|
||||||
|
.map((col) => (
|
||||||
|
<Button
|
||||||
|
key={col.key}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => insertColumnToFormula(rule.id, col.key)}
|
||||||
|
className="h-4 border-dashed px-1 text-[9px] text-blue-600"
|
||||||
|
title="품목 정보 컬럼"
|
||||||
|
>
|
||||||
|
{col.title || col.key}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{["+", "-", "*", "/", "(", ")"].map((op) => (
|
||||||
|
<Button
|
||||||
|
key={op}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => insertColumnToFormula(rule.id, op)}
|
||||||
|
className="h-4 w-4 p-0 font-mono text-[9px]"
|
||||||
|
>
|
||||||
|
{op}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{calculationRules.length === 0 && (
|
{calculationRules.length === 0 && (
|
||||||
<p className="text-muted-foreground py-2 text-center text-xs">
|
<p className="text-muted-foreground py-1 text-center text-[10px]">
|
||||||
계산 규칙이 없습니다
|
계산 규칙이 없습니다
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ interface V2RepeaterRendererProps {
|
||||||
onRowClick?: (row: any) => void;
|
onRowClick?: (row: any) => void;
|
||||||
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
|
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
|
||||||
parentId?: string | number;
|
parentId?: string | number;
|
||||||
|
formData?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
|
const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
|
||||||
|
|
@ -31,6 +32,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
|
||||||
onRowClick,
|
onRowClick,
|
||||||
onButtonClick,
|
onButtonClick,
|
||||||
parentId,
|
parentId,
|
||||||
|
formData,
|
||||||
}) => {
|
}) => {
|
||||||
// component.componentConfig 또는 component.config에서 V2RepeaterConfig 추출
|
// component.componentConfig 또는 component.config에서 V2RepeaterConfig 추출
|
||||||
const config: V2RepeaterConfig = React.useMemo(() => {
|
const config: V2RepeaterConfig = React.useMemo(() => {
|
||||||
|
|
@ -101,6 +103,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
onButtonClick={onButtonClick}
|
onButtonClick={onButtonClick}
|
||||||
className={component?.className}
|
className={component?.className}
|
||||||
|
formData={formData}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1526,22 +1526,30 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
[componentConfig.rightPanel?.additionalTabs, isDesignMode, toast],
|
[componentConfig.rightPanel?.additionalTabs, isDesignMode, toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 탭 변경 핸들러 (좌측 미선택 시에도 전체 데이터 로드)
|
// 탭 변경 핸들러
|
||||||
const handleTabChange = useCallback(
|
const handleTabChange = useCallback(
|
||||||
(newTabIndex: number) => {
|
(newTabIndex: number) => {
|
||||||
setActiveTabIndex(newTabIndex);
|
setActiveTabIndex(newTabIndex);
|
||||||
|
|
||||||
|
// 메인 패널이 "detail"(선택 시 표시)이면 좌측 미선택 시 데이터 로드하지 않음
|
||||||
|
const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail";
|
||||||
|
const requireSelection = mainRelationType === "detail";
|
||||||
|
|
||||||
if (newTabIndex === 0) {
|
if (newTabIndex === 0) {
|
||||||
if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) {
|
if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) {
|
||||||
loadRightData(selectedLeftItem);
|
if (!requireSelection || selectedLeftItem) {
|
||||||
|
loadRightData(selectedLeftItem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!tabsData[newTabIndex]) {
|
if (!tabsData[newTabIndex]) {
|
||||||
loadTabData(newTabIndex, selectedLeftItem);
|
if (!requireSelection || selectedLeftItem) {
|
||||||
|
loadTabData(newTabIndex, selectedLeftItem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData],
|
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData, componentConfig.rightPanel?.relation?.type],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 좌측 항목 선택 핸들러 (동일 항목 재클릭 시 선택 해제 → 전체 데이터 표시)
|
// 좌측 항목 선택 핸들러 (동일 항목 재클릭 시 선택 해제 → 전체 데이터 표시)
|
||||||
|
|
@ -1554,24 +1562,31 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
selectedLeftItem[leftPk] === item[leftPk];
|
selectedLeftItem[leftPk] === item[leftPk];
|
||||||
|
|
||||||
if (isSameItem) {
|
if (isSameItem) {
|
||||||
// 선택 해제 → 전체 데이터 로드
|
// 선택 해제
|
||||||
setSelectedLeftItem(null);
|
setSelectedLeftItem(null);
|
||||||
setCustomLeftSelectedData({}); // 커스텀 모드 우측 폼 데이터 초기화
|
setCustomLeftSelectedData({});
|
||||||
setExpandedRightItems(new Set());
|
setExpandedRightItems(new Set());
|
||||||
setTabsData({});
|
setTabsData({});
|
||||||
if (activeTabIndex === 0) {
|
|
||||||
loadRightData(null);
|
const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail";
|
||||||
|
if (mainRelationType === "detail") {
|
||||||
|
// "선택 시 표시" 모드: 선택 해제 시 데이터 비움
|
||||||
|
setRightData(null);
|
||||||
} else {
|
} else {
|
||||||
loadTabData(activeTabIndex, null);
|
// "연관 목록" 모드: 선택 해제 시 전체 데이터 로드
|
||||||
}
|
if (activeTabIndex === 0) {
|
||||||
// 추가 탭들도 전체 데이터 로드
|
loadRightData(null);
|
||||||
const tabs = componentConfig.rightPanel?.additionalTabs;
|
} else {
|
||||||
if (tabs && tabs.length > 0) {
|
loadTabData(activeTabIndex, null);
|
||||||
tabs.forEach((_: any, idx: number) => {
|
}
|
||||||
if (idx + 1 !== activeTabIndex) {
|
const tabs = componentConfig.rightPanel?.additionalTabs;
|
||||||
loadTabData(idx + 1, null);
|
if (tabs && tabs.length > 0) {
|
||||||
}
|
tabs.forEach((_: any, idx: number) => {
|
||||||
});
|
if (idx + 1 !== activeTabIndex) {
|
||||||
|
loadTabData(idx + 1, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -2778,15 +2793,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
if (relationshipType === "join") {
|
if (relationshipType === "join") {
|
||||||
loadRightData(null);
|
loadRightData(null);
|
||||||
}
|
}
|
||||||
// 추가 탭: 각 탭의 relation.type에 따라 초기 로드 결정
|
// 추가 탭: 메인 패널이 "detail"(선택 시 표시)이면 추가 탭도 초기 로드하지 않음
|
||||||
const tabs = componentConfig.rightPanel?.additionalTabs;
|
if (relationshipType !== "detail") {
|
||||||
if (tabs && tabs.length > 0) {
|
const tabs = componentConfig.rightPanel?.additionalTabs;
|
||||||
tabs.forEach((tab: any, idx: number) => {
|
if (tabs && tabs.length > 0) {
|
||||||
const tabRelType = tab.relation?.type || "join";
|
tabs.forEach((tab: any, idx: number) => {
|
||||||
if (tabRelType === "join") {
|
const tabRelType = tab.relation?.type || "join";
|
||||||
loadTabData(idx + 1, null);
|
if (tabRelType === "join") {
|
||||||
}
|
loadTabData(idx + 1, null);
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|
@ -3734,6 +3751,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const currentTabData = tabsData[activeTabIndex] || [];
|
const currentTabData = tabsData[activeTabIndex] || [];
|
||||||
const isTabLoading = tabsLoading[activeTabIndex];
|
const isTabLoading = tabsLoading[activeTabIndex];
|
||||||
|
|
||||||
|
// 메인 패널이 "detail"(선택 시 표시)이면 좌측 미선택 시 안내 메시지
|
||||||
|
const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail";
|
||||||
|
if (mainRelationType === "detail" && !selectedLeftItem && !isDesignMode) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 py-12 text-sm">
|
||||||
|
<p>좌측에서 항목을 선택하세요</p>
|
||||||
|
<p className="text-xs">선택한 항목의 관련 데이터가 여기에 표시됩니다</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isTabLoading) {
|
if (isTabLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-32 items-center justify-center">
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,9 @@ export interface V2RepeaterProps {
|
||||||
data?: any[]; // 초기 데이터 (없으면 API로 로드)
|
data?: any[]; // 초기 데이터 (없으면 API로 로드)
|
||||||
onDataChange?: (data: any[]) => void;
|
onDataChange?: (data: any[]) => void;
|
||||||
onRowClick?: (row: any) => void;
|
onRowClick?: (row: any) => void;
|
||||||
|
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
formData?: Record<string, any>; // 수정 모드에서 FK 기반 데이터 로드용
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기본 설정값
|
// 기본 설정값
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue