jskim-node #390
|
|
@ -18,45 +18,6 @@ import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool impo
|
||||||
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
|
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
|
||||||
import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성
|
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 {
|
interface GetTableDataParams {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|
@ -661,14 +622,14 @@ class DataService {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: await maskPasswordColumns(tableName, normalizedGroupRows), // 🔧 배열로 반환! + password 마스킹
|
data: normalizedGroupRows, // 🔧 배열로 반환!
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: await maskPasswordColumns(tableName, normalizedRows[0]), // 그룹핑 없으면 단일 레코드 + password 마스킹
|
data: normalizedRows[0], // 그룹핑 없으면 단일 레코드
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -687,7 +648,7 @@ class DataService {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: await maskPasswordColumns(tableName, result[0]), // password 마스킹
|
data: result[0],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error);
|
console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { query, queryOne, transaction, getPool } from "../database/db";
|
||||||
import { EventTriggerService } from "./eventTriggerService";
|
import { EventTriggerService } from "./eventTriggerService";
|
||||||
import { DataflowControlService } from "./dataflowControlService";
|
import { DataflowControlService } from "./dataflowControlService";
|
||||||
import tableCategoryValueService from "./tableCategoryValueService";
|
import tableCategoryValueService from "./tableCategoryValueService";
|
||||||
import { PasswordUtils } from "../utils/passwordUtils";
|
|
||||||
|
|
||||||
export interface FormDataResult {
|
export interface FormDataResult {
|
||||||
id: number;
|
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) {
|
if (Object.keys(changedFields).length === 0) {
|
||||||
console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다.");
|
console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다.");
|
||||||
|
|
|
||||||
|
|
@ -5177,8 +5177,18 @@ export class ScreenManagementService {
|
||||||
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
|
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 화면의 기본 테이블 업데이트 (테이블이 선택된 경우)
|
||||||
|
const mainTableName = layoutData.mainTableName;
|
||||||
|
if (mainTableName) {
|
||||||
|
await query(
|
||||||
|
`UPDATE screen_definitions SET table_name = $1, updated_date = NOW() WHERE screen_id = $2`,
|
||||||
|
[mainTableName, screenId],
|
||||||
|
);
|
||||||
|
console.log(`✅ [saveLayoutV2] 화면 기본 테이블 업데이트: ${mainTableName}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 저장할 layout_data에서 레이어 메타 정보 제거 (순수 레이아웃만 저장)
|
// 저장할 layout_data에서 레이어 메타 정보 제거 (순수 레이아웃만 저장)
|
||||||
const { layerId: _lid, layerName: _ln, conditionConfig: _cc, ...pureLayoutData } = layoutData;
|
const { layerId: _lid, layerName: _ln, conditionConfig: _cc, mainTableName: _mtn, ...pureLayoutData } = layoutData;
|
||||||
const dataToSave = {
|
const dataToSave = {
|
||||||
version: "2.0",
|
version: "2.0",
|
||||||
...pureLayoutData,
|
...pureLayoutData,
|
||||||
|
|
|
||||||
|
|
@ -554,6 +554,16 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
// 화면 관리에서 설정한 해상도 사용 (우선순위)
|
// 화면 관리에서 설정한 해상도 사용 (우선순위)
|
||||||
const screenResolution = (layoutData as any).screenResolution || (screenInfo as any).screenResolution;
|
const screenResolution = (layoutData as any).screenResolution || (screenInfo as any).screenResolution;
|
||||||
|
|
||||||
|
console.log("🔍 [ScreenModal] 해상도 디버그:", {
|
||||||
|
screenId,
|
||||||
|
v2ScreenResolution: v2LayoutData?.screenResolution,
|
||||||
|
layoutScreenResolution: (layoutData as any).screenResolution,
|
||||||
|
screenInfoResolution: (screenInfo as any).screenResolution,
|
||||||
|
finalScreenResolution: screenResolution,
|
||||||
|
hasWidth: screenResolution?.width,
|
||||||
|
hasHeight: screenResolution?.height,
|
||||||
|
});
|
||||||
|
|
||||||
let dimensions;
|
let dimensions;
|
||||||
if (screenResolution && screenResolution.width && screenResolution.height) {
|
if (screenResolution && screenResolution.width && screenResolution.height) {
|
||||||
// 화면 관리에서 설정한 해상도 사용
|
// 화면 관리에서 설정한 해상도 사용
|
||||||
|
|
@ -563,9 +573,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
offsetX: 0,
|
offsetX: 0,
|
||||||
offsetY: 0,
|
offsetY: 0,
|
||||||
};
|
};
|
||||||
|
console.log("✅ [ScreenModal] 화면관리 해상도 적용:", dimensions);
|
||||||
} else {
|
} else {
|
||||||
// 해상도 정보가 없으면 자동 계산
|
// 해상도 정보가 없으면 자동 계산
|
||||||
dimensions = calculateScreenDimensions(components);
|
dimensions = calculateScreenDimensions(components);
|
||||||
|
console.log("⚠️ [ScreenModal] 해상도 없음 - 자동 계산:", dimensions);
|
||||||
}
|
}
|
||||||
|
|
||||||
setScreenDimensions(dimensions);
|
setScreenDimensions(dimensions);
|
||||||
|
|
@ -869,16 +881,24 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터
|
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터
|
||||||
const getModalStyle = () => {
|
const getModalStyle = () => {
|
||||||
if (!screenDimensions) {
|
if (!screenDimensions) {
|
||||||
|
console.log("⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용");
|
||||||
return {
|
return {
|
||||||
className: "w-fit min-w-[400px] max-w-4xl overflow-hidden",
|
className: "w-fit min-w-[400px] max-w-4xl overflow-hidden",
|
||||||
style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" },
|
style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const finalWidth = Math.min(screenDimensions.width, window.innerWidth * 0.98);
|
||||||
|
console.log("✅ [ScreenModal] getModalStyle: 해상도 적용됨", {
|
||||||
|
screenDimensions,
|
||||||
|
finalWidth: `${finalWidth}px`,
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
className: "overflow-hidden",
|
className: "overflow-hidden",
|
||||||
style: {
|
style: {
|
||||||
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`,
|
width: `${finalWidth}px`,
|
||||||
// CSS가 알아서 처리: 뷰포트 안에 들어가면 auto-height, 넘치면 max-height로 제한
|
// CSS가 알아서 처리: 뷰포트 안에 들어가면 auto-height, 넘치면 max-height로 제한
|
||||||
maxHeight: "calc(100dvh - 8px)",
|
maxHeight: "calc(100dvh - 8px)",
|
||||||
maxWidth: "98vw",
|
maxWidth: "98vw",
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -2231,11 +2231,20 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
}
|
}
|
||||||
: component;
|
: component;
|
||||||
|
|
||||||
// 🆕 모든 레이어의 컴포넌트를 통합 (조건부 레이어 내 컴포넌트가 기본 레이어 formData 참조 가능하도록)
|
// 모든 레이어의 컴포넌트 통합 (조건 평가용 - 트리거 컴포넌트 검색에 필요)
|
||||||
const allLayerComponents = useMemo(() => {
|
const allLayerComponents = useMemo(() => {
|
||||||
return layers.flatMap((layer) => layer.components);
|
return layers.flatMap((layer) => layer.components);
|
||||||
}, [layers]);
|
}, [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) => {
|
const renderLayerComponents = useCallback((layer: LayerDefinition) => {
|
||||||
// 활성화되지 않은 레이어는 렌더링하지 않음
|
// 활성화되지 않은 레이어는 렌더링하지 않음
|
||||||
|
|
@ -2272,7 +2281,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
>
|
>
|
||||||
<InteractiveScreenViewer
|
<InteractiveScreenViewer
|
||||||
component={comp}
|
component={comp}
|
||||||
allComponents={allLayerComponents}
|
allComponents={visibleLayerComponents}
|
||||||
formData={externalFormData}
|
formData={externalFormData}
|
||||||
onFormDataChange={onFormDataChange}
|
onFormDataChange={onFormDataChange}
|
||||||
screenInfo={screenInfo}
|
screenInfo={screenInfo}
|
||||||
|
|
@ -2344,7 +2353,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
>
|
>
|
||||||
<InteractiveScreenViewer
|
<InteractiveScreenViewer
|
||||||
component={comp}
|
component={comp}
|
||||||
allComponents={allLayerComponents}
|
allComponents={visibleLayerComponents}
|
||||||
formData={externalFormData}
|
formData={externalFormData}
|
||||||
onFormDataChange={onFormDataChange}
|
onFormDataChange={onFormDataChange}
|
||||||
screenInfo={screenInfo}
|
screenInfo={screenInfo}
|
||||||
|
|
@ -2387,7 +2396,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
>
|
>
|
||||||
<InteractiveScreenViewer
|
<InteractiveScreenViewer
|
||||||
component={comp}
|
component={comp}
|
||||||
allComponents={allLayerComponents}
|
allComponents={visibleLayerComponents}
|
||||||
formData={externalFormData}
|
formData={externalFormData}
|
||||||
onFormDataChange={onFormDataChange}
|
onFormDataChange={onFormDataChange}
|
||||||
screenInfo={screenInfo}
|
screenInfo={screenInfo}
|
||||||
|
|
@ -2423,7 +2432,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
>
|
>
|
||||||
<InteractiveScreenViewer
|
<InteractiveScreenViewer
|
||||||
component={comp}
|
component={comp}
|
||||||
allComponents={allLayerComponents}
|
allComponents={visibleLayerComponents}
|
||||||
formData={externalFormData}
|
formData={externalFormData}
|
||||||
onFormDataChange={onFormDataChange}
|
onFormDataChange={onFormDataChange}
|
||||||
screenInfo={screenInfo}
|
screenInfo={screenInfo}
|
||||||
|
|
@ -2433,7 +2442,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents, layers]);
|
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, visibleLayerComponents, layers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SplitPanelProvider>
|
<SplitPanelProvider>
|
||||||
|
|
@ -2485,7 +2494,13 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
setPopupScreen(null);
|
setPopupScreen(null);
|
||||||
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
|
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
|
||||||
}}>
|
}}>
|
||||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
|
<DialogContent
|
||||||
|
className="max-w-none w-auto max-h-[90vh] overflow-hidden p-0"
|
||||||
|
style={popupScreenResolution ? {
|
||||||
|
width: `${Math.min(popupScreenResolution.width + 48, window.innerWidth * 0.98)}px`,
|
||||||
|
maxWidth: "98vw",
|
||||||
|
} : { maxWidth: "56rem" }}
|
||||||
|
>
|
||||||
<DialogHeader className="px-6 pt-4 pb-2">
|
<DialogHeader className="px-6 pt-4 pb-2">
|
||||||
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
|
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
|
||||||
|
|
@ -2062,6 +2062,7 @@ export default function ScreenDesigner({
|
||||||
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
||||||
...v2Layout,
|
...v2Layout,
|
||||||
layerId: currentLayerId,
|
layerId: currentLayerId,
|
||||||
|
mainTableName: currentMainTableName, // 화면의 기본 테이블 (DB 업데이트용)
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||||
|
|
@ -5555,8 +5556,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 +7423,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>
|
||||||
|
|
|
||||||
|
|
@ -114,8 +114,7 @@ export function ComponentsPanel({
|
||||||
"image-display", // → v2-media (image)
|
"image-display", // → v2-media (image)
|
||||||
// 공통코드관리로 통합 예정
|
// 공통코드관리로 통합 예정
|
||||||
"category-manager", // → 공통코드관리 기능으로 통합 예정
|
"category-manager", // → 공통코드관리 기능으로 통합 예정
|
||||||
// 분할 패널 정리 (split-panel-layout v1 유지)
|
// 분할 패널 정리
|
||||||
"split-panel-layout2", // → split-panel-layout로 통합
|
|
||||||
"screen-split-panel", // 화면 임베딩 방식은 사용하지 않음
|
"screen-split-panel", // 화면 임베딩 방식은 사용하지 않음
|
||||||
// 미완성/미사용 컴포넌트 (기존 화면 호환성 유지, 새 추가만 막음)
|
// 미완성/미사용 컴포넌트 (기존 화면 호환성 유지, 새 추가만 막음)
|
||||||
"accordion-basic", // 아코디언 컴포넌트
|
"accordion-basic", // 아코디언 컴포넌트
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,11 @@ interface EntityJoinTable {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
currentDisplayColumn: string;
|
currentDisplayColumn: string;
|
||||||
availableColumns: EntityJoinColumn[];
|
availableColumns: EntityJoinColumn[];
|
||||||
|
// 같은 테이블이 여러 FK로 조인될 수 있으므로 소스 컬럼으로 구분
|
||||||
|
joinConfig?: {
|
||||||
|
sourceColumn: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TablesPanelProps {
|
interface TablesPanelProps {
|
||||||
|
|
@ -414,7 +419,11 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{entityJoinTables.map((joinTable) => {
|
{entityJoinTables.map((joinTable, idx) => {
|
||||||
|
// 같은 테이블이 여러 FK로 조인될 수 있으므로 sourceColumn으로 고유 키 생성
|
||||||
|
const uniqueKey = joinTable.joinConfig?.sourceColumn
|
||||||
|
? `entity-join-${joinTable.tableName}-${joinTable.joinConfig.sourceColumn}`
|
||||||
|
: `entity-join-${joinTable.tableName}-${idx}`;
|
||||||
const isExpanded = expandedJoinTables.has(joinTable.tableName);
|
const isExpanded = expandedJoinTables.has(joinTable.tableName);
|
||||||
// 검색어로 필터링
|
// 검색어로 필터링
|
||||||
const filteredColumns = searchTerm
|
const filteredColumns = searchTerm
|
||||||
|
|
@ -431,8 +440,7 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// 엔티티 조인 테이블에 고유 접두사 추가 (메인 테이블과 키 중복 방지)
|
<div key={uniqueKey} className="space-y-1">
|
||||||
<div key={`entity-join-${joinTable.tableName}`} className="space-y-1">
|
|
||||||
{/* 조인 테이블 헤더 */}
|
{/* 조인 테이블 헤더 */}
|
||||||
<div
|
<div
|
||||||
className="flex cursor-pointer items-center justify-between rounded-md bg-cyan-50 p-2 hover:bg-cyan-100"
|
className="flex cursor-pointer items-center justify-between rounded-md bg-cyan-50 p-2 hover:bg-cyan-100"
|
||||||
|
|
|
||||||
|
|
@ -135,8 +135,27 @@ export function TabsWidget({
|
||||||
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
|
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
|
||||||
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
|
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
|
||||||
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
|
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
|
||||||
// 탭별 화면 정보 (screenId, tableName) 저장
|
// 탭별 화면 정보 (screenId, tableName) - 인라인 컴포넌트의 테이블 설정에서 추출
|
||||||
const [screenInfoMap, setScreenInfoMap] = useState<Record<string, { id: number; tableName?: string }>>({});
|
const screenInfoMap = React.useMemo(() => {
|
||||||
|
const map: Record<string, { id?: number; tableName?: string }> = {};
|
||||||
|
for (const tab of tabs as ExtendedTabItem[]) {
|
||||||
|
const inlineComponents = tab.components || [];
|
||||||
|
if (inlineComponents.length > 0) {
|
||||||
|
// 인라인 컴포넌트에서 테이블 컴포넌트의 selectedTable 추출
|
||||||
|
const tableComp = inlineComponents.find(
|
||||||
|
(c) => c.componentType === "v2-table-list" || c.componentType === "table-list",
|
||||||
|
);
|
||||||
|
const selectedTable = tableComp?.componentConfig?.selectedTable;
|
||||||
|
if (selectedTable || tab.screenId) {
|
||||||
|
map[tab.id] = {
|
||||||
|
id: tab.screenId,
|
||||||
|
tableName: selectedTable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [tabs]);
|
||||||
|
|
||||||
// 컴포넌트 탭 목록 변경 시 동기화
|
// 컴포넌트 탭 목록 변경 시 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -157,21 +176,10 @@ export function TabsWidget({
|
||||||
) {
|
) {
|
||||||
setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: true }));
|
setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: true }));
|
||||||
try {
|
try {
|
||||||
// 레이아웃과 화면 정보를 병렬로 로드
|
const layoutData = await screenApi.getLayout(extTab.screenId);
|
||||||
const [layoutData, screenDef] = await Promise.all([
|
|
||||||
screenApi.getLayout(extTab.screenId),
|
|
||||||
screenApi.getScreen(extTab.screenId),
|
|
||||||
]);
|
|
||||||
if (layoutData && layoutData.components) {
|
if (layoutData && layoutData.components) {
|
||||||
setScreenLayouts((prev) => ({ ...prev, [tab.id]: layoutData.components }));
|
setScreenLayouts((prev) => ({ ...prev, [tab.id]: layoutData.components }));
|
||||||
}
|
}
|
||||||
// 탭의 화면 정보 저장 (tableName 포함)
|
|
||||||
if (screenDef) {
|
|
||||||
setScreenInfoMap((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[tab.id]: { id: extTab.screenId!, tableName: screenDef.tableName },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`탭 "${tab.label}" 화면 로드 실패:`, error);
|
console.error(`탭 "${tab.label}" 화면 로드 실패:`, error);
|
||||||
setScreenErrors((prev) => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." }));
|
setScreenErrors((prev) => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." }));
|
||||||
|
|
@ -185,31 +193,6 @@ export function TabsWidget({
|
||||||
loadScreenLayouts();
|
loadScreenLayouts();
|
||||||
}, [visibleTabs, screenLayouts, screenLoadingStates]);
|
}, [visibleTabs, screenLayouts, screenLoadingStates]);
|
||||||
|
|
||||||
// screenInfoMap이 없는 탭의 화면 정보 보충 로드
|
|
||||||
// screenId가 있지만 screenInfoMap에 아직 없는 탭의 화면 정보를 로드
|
|
||||||
useEffect(() => {
|
|
||||||
const loadMissingScreenInfo = async () => {
|
|
||||||
for (const tab of visibleTabs) {
|
|
||||||
const extTab = tab as ExtendedTabItem;
|
|
||||||
// screenId가 있고 screenInfoMap에 아직 없는 경우 로드
|
|
||||||
if (extTab.screenId && !screenInfoMap[tab.id]) {
|
|
||||||
try {
|
|
||||||
const screenDef = await screenApi.getScreen(extTab.screenId);
|
|
||||||
if (screenDef) {
|
|
||||||
setScreenInfoMap((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[tab.id]: { id: extTab.screenId!, tableName: screenDef.tableName },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`탭 "${tab.label}" 화면 정보 로드 실패:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadMissingScreenInfo();
|
|
||||||
}, [visibleTabs, screenInfoMap]);
|
|
||||||
|
|
||||||
// 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트
|
// 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (persistSelection && typeof window !== "undefined") {
|
if (persistSelection && typeof window !== "undefined") {
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,22 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { V2MediaProps } from "@/types/v2-components";
|
import { V2MediaProps } from "@/types/v2-components";
|
||||||
import {
|
import {
|
||||||
Upload, X, File, Image as ImageIcon, Video, Music, Eye, Download, Trash2, Plus,
|
Upload,
|
||||||
FileText, Archive, Presentation, FileImage, FileVideo, FileAudio
|
X,
|
||||||
|
File,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Video,
|
||||||
|
Music,
|
||||||
|
Eye,
|
||||||
|
Download,
|
||||||
|
Trash2,
|
||||||
|
Plus,
|
||||||
|
FileText,
|
||||||
|
Archive,
|
||||||
|
Presentation,
|
||||||
|
FileImage,
|
||||||
|
FileVideo,
|
||||||
|
FileAudio,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -77,8 +91,7 @@ const getFileIcon = (extension: string) => {
|
||||||
/**
|
/**
|
||||||
* V2 미디어 컴포넌트 (레거시 기능 통합)
|
* V2 미디어 컴포넌트 (레거시 기능 통합)
|
||||||
*/
|
*/
|
||||||
export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>((props, ref) => {
|
||||||
(props, ref) => {
|
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
|
|
@ -121,11 +134,11 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// 레코드 모드 판단
|
// 레코드 모드 판단
|
||||||
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
|
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith("temp_"));
|
||||||
const recordTableName = formData?.tableName || tableName;
|
const recordTableName = formData?.tableName || tableName;
|
||||||
const recordId = formData?.id;
|
const recordId = formData?.id;
|
||||||
// 🔑 columnName 우선 사용 (실제 DB 컬럼명), 없으면 id, 최후에 attachments
|
// 🔑 columnName 우선 사용 (실제 DB 컬럼명), 없으면 id, 최후에 attachments
|
||||||
const effectiveColumnName = columnName || id || 'attachments';
|
const effectiveColumnName = columnName || id || "attachments";
|
||||||
|
|
||||||
// 레코드용 targetObjid 생성
|
// 레코드용 targetObjid 생성
|
||||||
const getRecordTargetObjid = useCallback(() => {
|
const getRecordTargetObjid = useCallback(() => {
|
||||||
|
|
@ -269,7 +282,20 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
console.error("파일 조회 오류:", error);
|
console.error("파일 조회 오류:", error);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}, [id, tableName, columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, effectiveColumnName, isDesignMode]);
|
}, [
|
||||||
|
id,
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
formData?.screenId,
|
||||||
|
formData?.tableName,
|
||||||
|
formData?.id,
|
||||||
|
getUniqueKey,
|
||||||
|
recordId,
|
||||||
|
isRecordMode,
|
||||||
|
recordTableName,
|
||||||
|
effectiveColumnName,
|
||||||
|
isDesignMode,
|
||||||
|
]);
|
||||||
|
|
||||||
// 파일 동기화
|
// 파일 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -344,7 +370,8 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetObjid;
|
let targetObjid;
|
||||||
const effectiveIsRecordMode = isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith('temp_'));
|
const effectiveIsRecordMode =
|
||||||
|
isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith("temp_"));
|
||||||
|
|
||||||
if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) {
|
if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) {
|
||||||
targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`;
|
targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`;
|
||||||
|
|
@ -358,7 +385,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
|
|
||||||
const finalLinkedTable = effectiveIsRecordMode
|
const finalLinkedTable = effectiveIsRecordMode
|
||||||
? effectiveTableName
|
? effectiveTableName
|
||||||
: (formData?.linkedTable || effectiveTableName);
|
: formData?.linkedTable || effectiveTableName;
|
||||||
|
|
||||||
const uploadData = {
|
const uploadData = {
|
||||||
autoLink: formData?.autoLink || true,
|
autoLink: formData?.autoLink || true,
|
||||||
|
|
@ -474,9 +501,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
if (onFormDataChange && targetColumn) {
|
if (onFormDataChange && targetColumn) {
|
||||||
// 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값)
|
// 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값)
|
||||||
// 복수 파일: 콤마 구분 문자열로 전달
|
// 복수 파일: 콤마 구분 문자열로 전달
|
||||||
const formValue = config.multiple
|
const formValue = config.multiple ? fileIds.join(",") : fileIds[0] || "";
|
||||||
? fileIds.join(',')
|
|
||||||
: (fileIds[0] || '');
|
|
||||||
|
|
||||||
console.log("📝 [V2Media] formData 업데이트:", {
|
console.log("📝 [V2Media] formData 업데이트:", {
|
||||||
columnName: targetColumn,
|
columnName: targetColumn,
|
||||||
|
|
@ -515,7 +540,22 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
toast.error(`업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`);
|
toast.error(`업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[config, uploadedFiles, onChange, id, getUniqueKey, recordId, isRecordMode, recordTableName, effectiveColumnName, tableName, onUpdate, onFormDataChange, user, columnName],
|
[
|
||||||
|
config,
|
||||||
|
uploadedFiles,
|
||||||
|
onChange,
|
||||||
|
id,
|
||||||
|
getUniqueKey,
|
||||||
|
recordId,
|
||||||
|
isRecordMode,
|
||||||
|
recordTableName,
|
||||||
|
effectiveColumnName,
|
||||||
|
tableName,
|
||||||
|
onUpdate,
|
||||||
|
onFormDataChange,
|
||||||
|
user,
|
||||||
|
columnName,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 파일 뷰어 열기/닫기
|
// 파일 뷰어 열기/닫기
|
||||||
|
|
@ -612,9 +652,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
if (onFormDataChange && targetColumn) {
|
if (onFormDataChange && targetColumn) {
|
||||||
// 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값)
|
// 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값)
|
||||||
// 복수 파일: 콤마 구분 문자열로 전달
|
// 복수 파일: 콤마 구분 문자열로 전달
|
||||||
const formValue = config.multiple
|
const formValue = config.multiple ? fileIds.join(",") : fileIds[0] || "";
|
||||||
? fileIds.join(',')
|
|
||||||
: (fileIds[0] || '');
|
|
||||||
|
|
||||||
console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", {
|
console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", {
|
||||||
columnName: targetColumn,
|
columnName: targetColumn,
|
||||||
|
|
@ -631,7 +669,20 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
toast.error("파일 삭제 실패");
|
toast.error("파일 삭제 실패");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[uploadedFiles, onUpdate, id, isRecordMode, onFormDataChange, recordTableName, recordId, effectiveColumnName, getUniqueKey, onChange, config.multiple, columnName],
|
[
|
||||||
|
uploadedFiles,
|
||||||
|
onUpdate,
|
||||||
|
id,
|
||||||
|
isRecordMode,
|
||||||
|
onFormDataChange,
|
||||||
|
recordTableName,
|
||||||
|
recordId,
|
||||||
|
effectiveColumnName,
|
||||||
|
getUniqueKey,
|
||||||
|
onChange,
|
||||||
|
config.multiple,
|
||||||
|
columnName,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 대표 이미지 로드
|
// 대표 이미지 로드
|
||||||
|
|
@ -639,7 +690,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
async (file: FileInfo) => {
|
async (file: FileInfo) => {
|
||||||
try {
|
try {
|
||||||
const isImage = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
|
const isImage = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
|
||||||
file.fileExt.toLowerCase().replace(".", "")
|
file.fileExt.toLowerCase().replace(".", ""),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isImage) {
|
if (!isImage) {
|
||||||
|
|
@ -691,12 +742,12 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
console.error("대표 파일 설정 실패:", e);
|
console.error("대표 파일 설정 실패:", e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[uploadedFiles, loadRepresentativeImage]
|
[uploadedFiles, loadRepresentativeImage],
|
||||||
);
|
);
|
||||||
|
|
||||||
// uploadedFiles 변경 시 대표 이미지 로드
|
// uploadedFiles 변경 시 대표 이미지 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const representativeFile = uploadedFiles.find(f => f.isRepresentative) || uploadedFiles[0];
|
const representativeFile = uploadedFiles.find((f) => f.isRepresentative) || uploadedFiles[0];
|
||||||
if (representativeFile) {
|
if (representativeFile) {
|
||||||
loadRepresentativeImage(representativeFile);
|
loadRepresentativeImage(representativeFile);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -711,13 +762,16 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
}, [uploadedFiles]);
|
}, [uploadedFiles]);
|
||||||
|
|
||||||
// 드래그 앤 드롭 핸들러
|
// 드래그 앤 드롭 핸들러
|
||||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
const handleDragOver = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!readonly && !disabled) {
|
if (!readonly && !disabled) {
|
||||||
setDragOver(true);
|
setDragOver(true);
|
||||||
}
|
}
|
||||||
}, [readonly, disabled]);
|
},
|
||||||
|
[readonly, disabled],
|
||||||
|
);
|
||||||
|
|
||||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -725,7 +779,8 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
|
|
@ -736,7 +791,9 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
handleFileUpload(files);
|
handleFileUpload(files);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [readonly, disabled, handleFileUpload]);
|
},
|
||||||
|
[readonly, disabled, handleFileUpload],
|
||||||
|
);
|
||||||
|
|
||||||
// 파일 선택
|
// 파일 선택
|
||||||
const handleFileSelect = useCallback(() => {
|
const handleFileSelect = useCallback(() => {
|
||||||
|
|
@ -745,13 +802,16 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
handleFileUpload(files);
|
handleFileUpload(files);
|
||||||
}
|
}
|
||||||
e.target.value = '';
|
e.target.value = "";
|
||||||
}, [handleFileUpload]);
|
},
|
||||||
|
[handleFileUpload],
|
||||||
|
);
|
||||||
|
|
||||||
// 파일 설정
|
// 파일 설정
|
||||||
const fileConfig: FileUploadConfig = {
|
const fileConfig: FileUploadConfig = {
|
||||||
|
|
@ -767,12 +827,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
const componentHeight = size?.height || style?.height;
|
const componentHeight = size?.height || style?.height;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={ref} id={id} className="flex w-full flex-col" style={{ width: componentWidth }}>
|
||||||
ref={ref}
|
|
||||||
id={id}
|
|
||||||
className="flex w-full flex-col"
|
|
||||||
style={{ width: componentWidth }}
|
|
||||||
>
|
|
||||||
{/* 라벨 */}
|
{/* 라벨 */}
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<Label
|
<Label
|
||||||
|
|
@ -783,20 +838,17 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
fontWeight: style?.labelFontWeight,
|
fontWeight: style?.labelFontWeight,
|
||||||
marginBottom: style?.labelMarginBottom,
|
marginBottom: style?.labelMarginBottom,
|
||||||
}}
|
}}
|
||||||
className="text-sm font-medium shrink-0"
|
className="shrink-0 text-sm font-medium"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 메인 컨테이너 */}
|
{/* 메인 컨테이너 */}
|
||||||
|
<div className="min-h-0" style={{ height: componentHeight }}>
|
||||||
<div
|
<div
|
||||||
className="min-h-0"
|
className="border-border bg-card relative flex h-full w-full flex-col overflow-hidden rounded-lg border"
|
||||||
style={{ height: componentHeight }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="border-border bg-card relative flex h-full w-full flex-col rounded-lg border overflow-hidden"
|
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
|
|
@ -813,16 +865,19 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 파일이 있는 경우: 대표 이미지/파일 표시 */}
|
{/* 파일이 있는 경우: 대표 이미지/파일 표시 */}
|
||||||
{uploadedFiles.length > 0 ? (() => {
|
{uploadedFiles.length > 0 ? (
|
||||||
const representativeFile = uploadedFiles.find(f => f.isRepresentative) || uploadedFiles[0];
|
(() => {
|
||||||
const isImage = representativeFile && ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
|
const representativeFile = uploadedFiles.find((f) => f.isRepresentative) || uploadedFiles[0];
|
||||||
representativeFile.fileExt.toLowerCase().replace(".", "")
|
const isImage =
|
||||||
|
representativeFile &&
|
||||||
|
["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
|
||||||
|
representativeFile.fileExt.toLowerCase().replace(".", ""),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isImage && representativeImageUrl ? (
|
{isImage && representativeImageUrl ? (
|
||||||
<div className="relative h-full w-full flex items-center justify-center bg-muted/10">
|
<div className="bg-muted/10 relative flex h-full w-full items-center justify-center">
|
||||||
<img
|
<img
|
||||||
src={representativeImageUrl}
|
src={representativeImageUrl}
|
||||||
alt={representativeFile.realFileName}
|
alt={representativeFile.realFileName}
|
||||||
|
|
@ -831,15 +886,13 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
</div>
|
</div>
|
||||||
) : isImage && !representativeImageUrl ? (
|
) : isImage && !representativeImageUrl ? (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
|
<div className="border-primary mb-2 h-8 w-8 animate-spin rounded-full border-b-2"></div>
|
||||||
<p className="text-sm text-muted-foreground">이미지 로딩 중...</p>
|
<p className="text-muted-foreground text-sm">이미지 로딩 중...</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||||
{getFileIcon(representativeFile.fileExt)}
|
{getFileIcon(representativeFile.fileExt)}
|
||||||
<p className="mt-3 text-sm font-medium text-center px-4">
|
<p className="mt-3 px-4 text-center text-sm font-medium">{representativeFile.realFileName}</p>
|
||||||
{representativeFile.realFileName}
|
|
||||||
</p>
|
|
||||||
<Badge variant="secondary" className="mt-2">
|
<Badge variant="secondary" className="mt-2">
|
||||||
대표 파일
|
대표 파일
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -847,7 +900,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 우측 하단 자세히보기 버튼 */}
|
{/* 우측 하단 자세히보기 버튼 */}
|
||||||
<div className="absolute bottom-3 right-3">
|
<div className="absolute right-3 bottom-3">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -859,19 +912,20 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})() : (
|
})()
|
||||||
|
) : (
|
||||||
// 파일이 없는 경우: 업로드 안내
|
// 파일이 없는 경우: 업로드 안내
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full flex-col items-center justify-center text-muted-foreground cursor-pointer",
|
"text-muted-foreground flex h-full w-full cursor-pointer flex-col items-center justify-center",
|
||||||
dragOver && "border-primary bg-primary/5",
|
dragOver && "border-primary bg-primary/5",
|
||||||
(disabled || readonly) && "opacity-50 cursor-not-allowed"
|
(disabled || readonly) && "cursor-not-allowed opacity-50",
|
||||||
)}
|
)}
|
||||||
onClick={() => !disabled && !readonly && handleFileSelect()}
|
onClick={() => !disabled && !readonly && handleFileSelect()}
|
||||||
>
|
>
|
||||||
<Upload className="mb-3 h-12 w-12" />
|
<Upload className="mb-3 h-12 w-12" />
|
||||||
<p className="text-sm font-medium">파일을 드래그하거나 클릭하세요</p>
|
<p className="text-sm font-medium">파일을 드래그하거나 클릭하세요</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
최대 {formatFileSize(config.maxSize || 10 * 1024 * 1024)}
|
최대 {formatFileSize(config.maxSize || 10 * 1024 * 1024)}
|
||||||
{config.accept && config.accept !== "*/*" && ` (${config.accept})`}
|
{config.accept && config.accept !== "*/*" && ` (${config.accept})`}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -916,8 +970,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
V2Media.displayName = "V2Media";
|
V2Media.displayName = "V2Media";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,8 +186,20 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 기존 행(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);
|
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ V2Repeater 저장 실패:", error);
|
console.error("❌ V2Repeater 저장 실패:", error);
|
||||||
throw error;
|
throw 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 handleDataChange = useCallback(
|
const applyCalculationRules = useCallback(
|
||||||
(newData: any[]) => {
|
(row: any): any => {
|
||||||
setData(newData);
|
const rules = config.calculationRules;
|
||||||
|
if (!rules || rules.length === 0) return row;
|
||||||
|
|
||||||
// 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용)
|
const updatedRow = { ...row };
|
||||||
if (onDataChange) {
|
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 =
|
const targetTable =
|
||||||
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
||||||
|
|
||||||
if (targetTable) {
|
if (targetTable) {
|
||||||
// 각 행에 _targetTable 추가
|
onDataChange(newData.map((row) => ({ ...row, _targetTable: targetTable })));
|
||||||
const dataWithTarget = newData.map((row) => ({
|
|
||||||
...row,
|
|
||||||
_targetTable: targetTable,
|
|
||||||
}));
|
|
||||||
onDataChange(dataWithTarget);
|
|
||||||
} else {
|
} else {
|
||||||
onDataChange(newData);
|
onDataChange(newData);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정
|
|
||||||
setAutoWidthTrigger((prev) => prev + 1);
|
|
||||||
},
|
},
|
||||||
[onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
|
[onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 데이터 변경 핸들러
|
||||||
|
const handleDataChange = useCallback(
|
||||||
|
(newData: any[]) => {
|
||||||
|
const calculated = newData.map(applyCalculationRules);
|
||||||
|
setData(calculated);
|
||||||
|
notifyDataChange(calculated);
|
||||||
|
setAutoWidthTrigger((prev) => prev + 1);
|
||||||
|
},
|
||||||
|
[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],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 행 삭제 핸들러
|
// 행 삭제 핸들러
|
||||||
|
|
|
||||||
|
|
@ -58,16 +58,24 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// 현재 선택된 값 존재 여부
|
||||||
|
const hasValue = useMemo(() => {
|
||||||
|
if (!value) return false;
|
||||||
|
if (Array.isArray(value)) return value.length > 0;
|
||||||
|
return value !== "";
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
// 단일 선택 + 검색 불가능 → 기본 Select 사용
|
// 단일 선택 + 검색 불가능 → 기본 Select 사용
|
||||||
if (!searchable && !multiple) {
|
if (!searchable && !multiple) {
|
||||||
return (
|
return (
|
||||||
|
<div className="relative w-full group">
|
||||||
<Select
|
<Select
|
||||||
value={typeof value === "string" ? value : value?.[0] ?? ""}
|
value={typeof value === "string" ? value : value?.[0] ?? ""}
|
||||||
onValueChange={(v) => onChange?.(v)}
|
onValueChange={(v) => onChange?.(v)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{/* SelectTrigger에 style로 직접 height 전달 (Radix Select.Root는 DOM 없어서 h-full 체인 끊김) */}
|
{/* SelectTrigger에 style로 직접 height 전달 (Radix Select.Root는 DOM 없어서 h-full 체인 끊김) */}
|
||||||
<SelectTrigger ref={ref} className={cn("w-full", className)} style={style}>
|
<SelectTrigger ref={ref} className={cn("w-full", allowClear && hasValue ? "pr-8" : "", className)} style={style}>
|
||||||
<SelectValue placeholder={placeholder} />
|
<SelectValue placeholder={placeholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -80,6 +88,26 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{/* 초기화 버튼 (값이 있을 때만 표시) */}
|
||||||
|
{allowClear && hasValue && !disabled && (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="absolute right-7 top-1/2 -translate-y-1/2 z-10 cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onChange?.("");
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5 opacity-40 hover:opacity-100 transition-opacity" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,10 +170,18 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1 ml-2">
|
<div className="flex items-center gap-1 ml-2">
|
||||||
{allowClear && selectedValues.length > 0 && (
|
{allowClear && selectedValues.length > 0 && (
|
||||||
<X
|
<span
|
||||||
className="h-4 w-4 opacity-50 hover:opacity-100"
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
/>
|
onPointerDown={(e) => {
|
||||||
|
// Radix Popover가 onPointerDown으로 팝오버를 여는 것을 방지
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 opacity-50 hover:opacity-100" />
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -153,13 +189,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 +206,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">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<Select
|
<Select
|
||||||
value={rule.targetColumn}
|
value={rule.targetColumn}
|
||||||
onValueChange={(value) => updateCalculationRule(rule.id, "targetColumn", value)}
|
onValueChange={(value) => updateCalculationRule(rule.id, "targetColumn", value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 w-24 text-xs">
|
<SelectTrigger className="h-6 w-20 text-[10px]">
|
||||||
<SelectValue placeholder="결과" />
|
<SelectValue placeholder="결과" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{config.columns.map((col) => (
|
{config.columns.filter(col => !col.isSourceDisplay).map((col) => (
|
||||||
<SelectItem key={col.key} value={col.key}>
|
<SelectItem key={col.key} value={col.key} className="text-xs">
|
||||||
{col.title}
|
{col.title || col.key}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<span className="text-[10px]">=</span>
|
||||||
<span className="text-xs">=</span>
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
value={rule.formula}
|
value={rule.formula}
|
||||||
onChange={(e) => updateCalculationRule(rule.id, "formula", e.target.value)}
|
onChange={(e) => updateCalculationRule(rule.id, "formula", e.target.value)}
|
||||||
placeholder="quantity * unit_price"
|
placeholder="컬럼 클릭 또는 직접 입력"
|
||||||
className="h-7 flex-1 text-xs"
|
className="h-6 flex-1 font-mono text-[10px]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => removeCalculationRule(rule.id)}
|
onClick={() => removeCalculationRule(rule.id)}
|
||||||
className="h-7 w-7 p-0 text-destructive"
|
className="h-6 w-6 p-0 text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 한글 수식 미리보기 */}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컬럼 칩: 디테일 컬럼 + 소스(품목) 컬럼 + 연산자 */}
|
||||||
|
<div className="flex flex-wrap gap-0.5">
|
||||||
|
{config.columns
|
||||||
|
.filter(col => col.key !== rule.targetColumn && !col.isSourceDisplay)
|
||||||
|
.map((col) => (
|
||||||
|
<Button
|
||||||
|
key={col.key}
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => insertColumnToFormula(rule.id, col.key)}
|
||||||
|
className="h-4 px-1 text-[9px]"
|
||||||
|
>
|
||||||
|
{col.title || col.key}
|
||||||
|
</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>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,10 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
[config, component.config, component.id],
|
[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
|
// 🆕 dataSourceId 우선순위: URL 파라미터 > 컴포넌트 설정 > component.id
|
||||||
const dataSourceId = useMemo(
|
const dataSourceId = useMemo(
|
||||||
() => urlDataSourceId || componentConfig.dataSourceId || component.id || "default",
|
() => 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 editTableName = new URLSearchParams(window.location.search).get("tableName");
|
||||||
const allTableData: Record<string, Record<string, any>[]> = {};
|
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 {
|
try {
|
||||||
const { dataApi } = await import("@/lib/api/data");
|
const { dataApi } = await import("@/lib/api/data");
|
||||||
// 모든 sourceTable의 데이터를 API로 전체 로드 (중복 테이블 제거)
|
// 모든 sourceTable의 데이터를 API로 전체 로드 (중복 테이블 제거)
|
||||||
|
|
@ -238,10 +256,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
|
|
||||||
for (const table of allTables) {
|
for (const table of allTables) {
|
||||||
const response = await dataApi.getTableData(table, {
|
const response = await dataApi.getTableData(table, {
|
||||||
filters: {
|
filters: editFilters,
|
||||||
customer_id: firstRecord.customer_id,
|
|
||||||
item_id: firstRecord.item_id,
|
|
||||||
},
|
|
||||||
sortBy: "created_date",
|
sortBy: "created_date",
|
||||||
sortOrder: "desc",
|
sortOrder: "desc",
|
||||||
});
|
});
|
||||||
|
|
@ -350,8 +365,8 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
}
|
}
|
||||||
|
|
||||||
const newItem: ItemData = {
|
const newItem: ItemData = {
|
||||||
// 수정 모드: item_id를 우선 사용 (id는 가격레코드의 PK일 수 있음)
|
// 수정 모드: sourceKeyField를 우선 사용 (id는 가격레코드의 PK일 수 있음)
|
||||||
id: String(firstRecord.item_id || firstRecord.id || "edit"),
|
id: String(firstRecord[sourceKeyField] || firstRecord.id || "edit"),
|
||||||
originalData: firstRecord,
|
originalData: firstRecord,
|
||||||
fieldGroups: mainFieldGroups,
|
fieldGroups: mainFieldGroups,
|
||||||
};
|
};
|
||||||
|
|
@ -635,39 +650,39 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
const mainGroups = groupsByTable.get(mainTable) || [];
|
const mainGroups = groupsByTable.get(mainTable) || [];
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
// item_id 추출: originalData.item_id를 최우선 사용
|
// sourceKeyField 값 추출 (예: item_id 또는 customer_id)
|
||||||
// (수정 모드에서 autoFillFrom:"id"가 가격 레코드 PK를 반환하는 문제 방지)
|
// (수정 모드에서 autoFillFrom:"id"가 가격 레코드 PK를 반환하는 문제 방지)
|
||||||
let itemId: string | null = null;
|
let sourceKeyValue: string | null = null;
|
||||||
|
|
||||||
// 1순위: originalData에 item_id가 직접 있으면 사용 (수정 모드에서 정확한 값)
|
// 1순위: originalData에 sourceKeyField가 직접 있으면 사용 (수정 모드에서 정확한 값)
|
||||||
if (item.originalData && item.originalData.item_id) {
|
if (item.originalData && item.originalData[sourceKeyField]) {
|
||||||
itemId = item.originalData.item_id;
|
sourceKeyValue = item.originalData[sourceKeyField];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2순위: autoFillFrom 로직 (신규 등록 모드에서 사용)
|
// 2순위: autoFillFrom 로직 (신규 등록 모드에서 사용)
|
||||||
if (!itemId) {
|
if (!sourceKeyValue) {
|
||||||
mainGroups.forEach((group) => {
|
mainGroups.forEach((group) => {
|
||||||
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
|
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
|
||||||
groupFields.forEach((field) => {
|
groupFields.forEach((field) => {
|
||||||
if (field.name === "item_id" && field.autoFillFrom && item.originalData) {
|
if (field.name === sourceKeyField && field.autoFillFrom && item.originalData) {
|
||||||
itemId = item.originalData[field.autoFillFrom] || null;
|
sourceKeyValue = item.originalData[field.autoFillFrom] || null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3순위: fallback (최후의 수단)
|
// 3순위: fallback (최후의 수단)
|
||||||
if (!itemId && item.originalData) {
|
if (!sourceKeyValue && item.originalData) {
|
||||||
itemId = item.originalData.id || null;
|
sourceKeyValue = item.originalData.id || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!itemId) {
|
if (!sourceKeyValue) {
|
||||||
console.error("❌ [2단계 저장] item_id를 찾을 수 없음:", item);
|
console.error(`❌ [2단계 저장] ${sourceKeyField}를 찾을 수 없음:`, item);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// upsert 공통 parentKeys: customer_id + item_id (정확한 매칭)
|
// upsert 공통 parentKeys: parentMapping 키 + sourceKeyField (정확한 매칭)
|
||||||
const itemParentKeys = { ...parentKeys, item_id: itemId };
|
const itemParentKeys = { ...parentKeys, [sourceKeyField]: sourceKeyValue };
|
||||||
|
|
||||||
// === Step 1: 메인 테이블(customer_item_mapping) 저장 ===
|
// === Step 1: 메인 테이블(customer_item_mapping) 저장 ===
|
||||||
// 여러 개의 매핑 레코드 지원 (거래처 품번/품명이 다중일 수 있음)
|
// 여러 개의 매핑 레코드 지원 (거래처 품번/품명이 다중일 수 있음)
|
||||||
|
|
@ -688,11 +703,11 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
if (entry._dbRecordId) {
|
if (entry._dbRecordId) {
|
||||||
record.id = entry._dbRecordId;
|
record.id = entry._dbRecordId;
|
||||||
}
|
}
|
||||||
// item_id는 정확한 itemId 변수 사용 (autoFillFrom:"id" 오작동 방지)
|
// sourceKeyField는 정확한 sourceKeyValue 변수 사용 (autoFillFrom:"id" 오작동 방지)
|
||||||
record.item_id = itemId;
|
record[sourceKeyField] = sourceKeyValue;
|
||||||
// 나머지 autoFillFrom 필드 처리
|
// 나머지 autoFillFrom 필드 처리
|
||||||
groupFields.forEach((field) => {
|
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];
|
const value = item.originalData[field.autoFillFrom];
|
||||||
if (value !== undefined && value !== null && !record[field.name]) {
|
if (value !== undefined && value !== null && !record[field.name]) {
|
||||||
record[field.name] = value;
|
record[field.name] = value;
|
||||||
|
|
@ -1700,7 +1715,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
// 디자인 모드: 샘플 데이터로 미리보기 표시
|
// 디자인 모드: 샘플 데이터로 미리보기 표시
|
||||||
if (isDesignMode) {
|
if (isDesignMode) {
|
||||||
const sampleDisplayCols = componentConfig.displayColumns || [];
|
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 sampleGroups = componentConfig.fieldGroups || [{ id: "default", title: "입력 정보", order: 0 }];
|
||||||
const gridCols = sampleGroups.length === 1 ? "grid-cols-1" : "grid-cols-2";
|
const gridCols = sampleGroups.length === 1 ? "grid-cols-1" : "grid-cols-2";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import {
|
||||||
MODAL_SIZE_OPTIONS,
|
MODAL_SIZE_OPTIONS,
|
||||||
SECTION_TYPE_OPTIONS,
|
SECTION_TYPE_OPTIONS,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { defaultSectionConfig, defaultTableSectionConfig, generateSectionId } from "./config";
|
import { defaultConfig, defaultSectionConfig, defaultTableSectionConfig, generateSectionId } from "./config";
|
||||||
|
|
||||||
// 모달 import
|
// 모달 import
|
||||||
import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal";
|
import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal";
|
||||||
|
|
@ -43,10 +43,20 @@ interface AvailableParentField {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UniversalFormModalConfigPanel({
|
export function UniversalFormModalConfigPanel({
|
||||||
config,
|
config: rawConfig,
|
||||||
onChange,
|
onChange,
|
||||||
allComponents = [],
|
allComponents = [],
|
||||||
}: UniversalFormModalConfigPanelProps) {
|
}: UniversalFormModalConfigPanelProps) {
|
||||||
|
// config가 불완전할 수 있으므로 defaultConfig와 병합하여 안전하게 사용
|
||||||
|
const config: UniversalFormModalConfig = {
|
||||||
|
...defaultConfig,
|
||||||
|
...rawConfig,
|
||||||
|
modal: { ...defaultConfig.modal, ...rawConfig?.modal },
|
||||||
|
sections: rawConfig?.sections ?? defaultConfig.sections,
|
||||||
|
saveConfig: { ...defaultConfig.saveConfig, ...rawConfig?.saveConfig },
|
||||||
|
editMode: { ...defaultConfig.editMode, ...rawConfig?.editMode },
|
||||||
|
};
|
||||||
|
|
||||||
// 테이블 목록
|
// 테이블 목록
|
||||||
const [tables, setTables] = useState<{ name: string; label: string }[]>([]);
|
const [tables, setTables] = useState<{ name: string; label: string }[]>([]);
|
||||||
const [tableColumns, setTableColumns] = useState<{
|
const [tableColumns, setTableColumns] = useState<{
|
||||||
|
|
@ -255,10 +265,10 @@ export function UniversalFormModalConfigPanel({
|
||||||
|
|
||||||
// 저장 테이블 변경 시 컬럼 로드
|
// 저장 테이블 변경 시 컬럼 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config.saveConfig.tableName) {
|
if (config.saveConfig?.tableName) {
|
||||||
loadTableColumns(config.saveConfig.tableName);
|
loadTableColumns(config.saveConfig.tableName);
|
||||||
}
|
}
|
||||||
}, [config.saveConfig.tableName]);
|
}, [config.saveConfig?.tableName]);
|
||||||
|
|
||||||
const loadTables = async () => {
|
const loadTables = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -564,9 +574,9 @@ export function UniversalFormModalConfigPanel({
|
||||||
<div className="w-full min-w-0 space-y-3">
|
<div className="w-full min-w-0 space-y-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<Label className="mb-1.5 block text-xs font-medium">저장 테이블</Label>
|
<Label className="mb-1.5 block text-xs font-medium">저장 테이블</Label>
|
||||||
<p className="text-muted-foreground text-sm">{config.saveConfig.tableName || "(미설정)"}</p>
|
<p className="text-muted-foreground text-sm">{config.saveConfig?.tableName || "(미설정)"}</p>
|
||||||
{config.saveConfig.customApiSave?.enabled &&
|
{config.saveConfig?.customApiSave?.enabled &&
|
||||||
config.saveConfig.customApiSave?.multiTable?.enabled && (
|
config.saveConfig?.customApiSave?.multiTable?.enabled && (
|
||||||
<Badge variant="secondary" className="mt-2 px-2 py-0.5 text-xs">
|
<Badge variant="secondary" className="mt-2 px-2 py-0.5 text-xs">
|
||||||
다중 테이블 모드
|
다중 테이블 모드
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -816,9 +826,9 @@ export function UniversalFormModalConfigPanel({
|
||||||
setSelectedField(field);
|
setSelectedField(field);
|
||||||
setFieldDetailModalOpen(true);
|
setFieldDetailModalOpen(true);
|
||||||
}}
|
}}
|
||||||
tableName={config.saveConfig.tableName}
|
tableName={config.saveConfig?.tableName}
|
||||||
tableColumns={
|
tableColumns={
|
||||||
tableColumns[config.saveConfig.tableName || ""]?.map((col) => ({
|
tableColumns[config.saveConfig?.tableName || ""]?.map((col) => ({
|
||||||
name: col.name,
|
name: col.name,
|
||||||
type: col.type,
|
type: col.type,
|
||||||
label: col.label || col.name,
|
label: col.label || col.name,
|
||||||
|
|
|
||||||
|
|
@ -68,22 +68,22 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
const getFileIcon = (fileExt: string) => {
|
const getFileIcon = (fileExt: string) => {
|
||||||
const ext = fileExt.toLowerCase();
|
const ext = fileExt.toLowerCase();
|
||||||
|
|
||||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) {
|
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) {
|
||||||
return <ImageIcon className="w-5 h-5 text-blue-500" />;
|
return <ImageIcon className="h-5 w-5 text-blue-500" />;
|
||||||
} else if (['pdf', 'doc', 'docx', 'txt', 'rtf'].includes(ext)) {
|
} else if (["pdf", "doc", "docx", "txt", "rtf"].includes(ext)) {
|
||||||
return <FileText className="w-5 h-5 text-red-500" />;
|
return <FileText className="h-5 w-5 text-red-500" />;
|
||||||
} else if (['xls', 'xlsx', 'csv'].includes(ext)) {
|
} else if (["xls", "xlsx", "csv"].includes(ext)) {
|
||||||
return <FileText className="w-5 h-5 text-green-500" />;
|
return <FileText className="h-5 w-5 text-green-500" />;
|
||||||
} else if (['ppt', 'pptx'].includes(ext)) {
|
} else if (["ppt", "pptx"].includes(ext)) {
|
||||||
return <Presentation className="w-5 h-5 text-orange-500" />;
|
return <Presentation className="h-5 w-5 text-orange-500" />;
|
||||||
} else if (['mp4', 'avi', 'mov', 'webm'].includes(ext)) {
|
} else if (["mp4", "avi", "mov", "webm"].includes(ext)) {
|
||||||
return <Video className="w-5 h-5 text-purple-500" />;
|
return <Video className="h-5 w-5 text-purple-500" />;
|
||||||
} else if (['mp3', 'wav', 'ogg'].includes(ext)) {
|
} else if (["mp3", "wav", "ogg"].includes(ext)) {
|
||||||
return <Music className="w-5 h-5 text-pink-500" />;
|
return <Music className="h-5 w-5 text-pink-500" />;
|
||||||
} else if (['zip', 'rar', '7z'].includes(ext)) {
|
} else if (["zip", "rar", "7z"].includes(ext)) {
|
||||||
return <Archive className="w-5 h-5 text-yellow-500" />;
|
return <Archive className="h-5 w-5 text-yellow-500" />;
|
||||||
} else {
|
} else {
|
||||||
return <File className="w-5 h-5 text-gray-500" />;
|
return <File className="h-5 w-5 text-gray-500" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -95,12 +95,12 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
try {
|
try {
|
||||||
const fileArray = Array.from(files);
|
const fileArray = Array.from(files);
|
||||||
await onFileUpload(fileArray);
|
await onFileUpload(fileArray);
|
||||||
console.log('✅ FileManagerModal: 파일 업로드 완료');
|
console.log("✅ FileManagerModal: 파일 업로드 완료");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ FileManagerModal: 파일 업로드 오류:', error);
|
console.error("❌ FileManagerModal: 파일 업로드 오류:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
console.log('🔄 FileManagerModal: 업로드 상태 초기화');
|
console.log("🔄 FileManagerModal: 업로드 상태 초기화");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -137,7 +137,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
handleFileUpload(files);
|
handleFileUpload(files);
|
||||||
}
|
}
|
||||||
// 입력값 초기화
|
// 입력값 초기화
|
||||||
e.target.value = '';
|
e.target.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
// 파일 뷰어 핸들러
|
// 파일 뷰어 핸들러
|
||||||
|
|
@ -159,8 +159,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
|
|
||||||
// 이미지 파일인 경우 미리보기 로드
|
// 이미지 파일인 경우 미리보기 로드
|
||||||
// 🔑 점(.)을 제거하고 확장자만 비교
|
// 🔑 점(.)을 제거하고 확장자만 비교
|
||||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
|
const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
|
||||||
const ext = file.fileExt.toLowerCase().replace('.', '');
|
const ext = file.fileExt.toLowerCase().replace(".", "");
|
||||||
if (imageExtensions.includes(ext) || file.isImage) {
|
if (imageExtensions.includes(ext) || file.isImage) {
|
||||||
try {
|
try {
|
||||||
// 이전 Blob URL 해제
|
// 이전 Blob URL 해제
|
||||||
|
|
@ -171,7 +171,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
// 🔑 항상 apiClient를 통해 Blob 다운로드 (Docker 환경에서 상대 경로 문제 방지)
|
// 🔑 항상 apiClient를 통해 Blob 다운로드 (Docker 환경에서 상대 경로 문제 방지)
|
||||||
const { apiClient } = await import("@/lib/api/client");
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
const response = await apiClient.get(`/files/preview/${file.objid}`, {
|
const response = await apiClient.get(`/files/preview/${file.objid}`, {
|
||||||
responseType: 'blob'
|
responseType: "blob",
|
||||||
});
|
});
|
||||||
|
|
||||||
const blob = new Blob([response.data]);
|
const blob = new Blob([response.data]);
|
||||||
|
|
@ -238,32 +238,19 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={isOpen} onOpenChange={() => {}}>
|
<Dialog open={isOpen} onOpenChange={() => {}}>
|
||||||
<DialogContent className="max-w-[95vw] w-[1400px] max-h-[90vh] overflow-hidden [&>button]:hidden">
|
<DialogContent className="max-h-[90vh] w-[1400px] max-w-[95vw] overflow-hidden [&>button]:hidden">
|
||||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<DialogTitle className="text-lg font-semibold">
|
<DialogTitle className="text-lg font-semibold">파일 관리 ({uploadedFiles.length}개)</DialogTitle>
|
||||||
파일 관리 ({uploadedFiles.length}개)
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0 hover:bg-gray-100" onClick={onClose} title="닫기">
|
||||||
</DialogTitle>
|
<X className="h-4 w-4" />
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0 hover:bg-gray-100"
|
|
||||||
onClick={onClose}
|
|
||||||
title="닫기"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col space-y-3 h-[75vh]">
|
<div className="flex h-[75vh] flex-col space-y-3">
|
||||||
{/* 파일 업로드 영역 - 높이 축소 */}
|
{/* 파일 업로드 영역 - 높이 축소 */}
|
||||||
{!isDesignMode && (
|
{!isDesignMode && (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors ${dragOver ? "border-blue-400 bg-blue-50" : "border-gray-300"} ${config.disabled ? "cursor-not-allowed opacity-50" : "hover:border-gray-400"} ${uploading ? "opacity-75" : ""} `}
|
||||||
border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors
|
|
||||||
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
|
|
||||||
${config.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
|
|
||||||
${uploading ? 'opacity-75' : ''}
|
|
||||||
`}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!config.disabled && !isDesignMode) {
|
if (!config.disabled && !isDesignMode) {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
|
|
@ -285,44 +272,40 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
|
|
||||||
{uploading ? (
|
{uploading ? (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
|
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||||
<span className="text-sm text-blue-600 font-medium">업로드 중...</span>
|
<span className="text-sm font-medium text-blue-600">업로드 중...</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
<Upload className="h-6 w-6 text-gray-400" />
|
<Upload className="h-6 w-6 text-gray-400" />
|
||||||
<p className="text-sm font-medium text-gray-700">
|
<p className="text-sm font-medium text-gray-700">파일을 드래그하거나 클릭하여 업로드하세요</p>
|
||||||
파일을 드래그하거나 클릭하여 업로드하세요
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
|
{/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
|
||||||
<div className="flex-1 flex gap-4 min-h-0">
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
{/* 좌측: 이미지 미리보기 (확대/축소 가능) */}
|
{/* 좌측: 이미지 미리보기 (확대/축소 가능) */}
|
||||||
<div className="flex-1 border border-gray-200 rounded-lg bg-gray-900 flex flex-col overflow-hidden relative">
|
<div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 bg-gray-900">
|
||||||
{/* 확대/축소 컨트롤 */}
|
{/* 확대/축소 컨트롤 */}
|
||||||
{selectedFile && previewImageUrl && (
|
{selectedFile && previewImageUrl && (
|
||||||
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 bg-black/60 rounded-lg p-1">
|
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-lg bg-black/60 p-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-white hover:bg-white/20"
|
className="h-8 w-8 text-white hover:bg-white/20"
|
||||||
onClick={() => setZoomLevel(prev => Math.max(0.25, prev - 0.25))}
|
onClick={() => setZoomLevel((prev) => Math.max(0.25, prev - 0.25))}
|
||||||
disabled={zoomLevel <= 0.25}
|
disabled={zoomLevel <= 0.25}
|
||||||
>
|
>
|
||||||
<ZoomOut className="h-4 w-4" />
|
<ZoomOut className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-white text-xs min-w-[50px] text-center">
|
<span className="min-w-[50px] text-center text-xs text-white">{Math.round(zoomLevel * 100)}%</span>
|
||||||
{Math.round(zoomLevel * 100)}%
|
|
||||||
</span>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-white hover:bg-white/20"
|
className="h-8 w-8 text-white hover:bg-white/20"
|
||||||
onClick={() => setZoomLevel(prev => Math.min(4, prev + 0.25))}
|
onClick={() => setZoomLevel((prev) => Math.min(4, prev + 0.25))}
|
||||||
disabled={zoomLevel >= 4}
|
disabled={zoomLevel >= 4}
|
||||||
>
|
>
|
||||||
<ZoomIn className="h-4 w-4" />
|
<ZoomIn className="h-4 w-4" />
|
||||||
|
|
@ -341,14 +324,14 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
{/* 이미지 미리보기 영역 - 마우스 휠로 확대/축소, 드래그로 이동 */}
|
{/* 이미지 미리보기 영역 - 마우스 휠로 확대/축소, 드래그로 이동 */}
|
||||||
<div
|
<div
|
||||||
ref={imageContainerRef}
|
ref={imageContainerRef}
|
||||||
className={`flex-1 flex items-center justify-center overflow-hidden p-4 ${
|
className={`flex flex-1 items-center justify-center overflow-hidden p-4 ${
|
||||||
zoomLevel > 1 ? (isDragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in'
|
zoomLevel > 1 ? (isDragging ? "cursor-grabbing" : "cursor-grab") : "cursor-zoom-in"
|
||||||
}`}
|
}`}
|
||||||
onWheel={(e) => {
|
onWheel={(e) => {
|
||||||
if (selectedFile && previewImageUrl) {
|
if (selectedFile && previewImageUrl) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||||
setZoomLevel(prev => Math.min(4, Math.max(0.25, prev + delta)));
|
setZoomLevel((prev) => Math.min(4, Math.max(0.25, prev + delta)));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
|
|
@ -363,7 +346,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
className="transition-transform duration-100 select-none"
|
className="transition-transform duration-100 select-none"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${imagePosition.x}px, ${imagePosition.y}px) scale(${zoomLevel})`,
|
transform: `translate(${imagePosition.x}px, ${imagePosition.y}px) scale(${zoomLevel})`,
|
||||||
transformOrigin: 'center center',
|
transformOrigin: "center center",
|
||||||
}}
|
}}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
|
@ -374,7 +357,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center text-gray-400">
|
<div className="flex flex-col items-center text-gray-400">
|
||||||
<ImageIcon className="w-16 h-16 mb-2" />
|
<ImageIcon className="mb-2 h-16 w-16" />
|
||||||
<p className="text-sm">파일을 선택하면 미리보기가 표시됩니다</p>
|
<p className="text-sm">파일을 선택하면 미리보기가 표시됩니다</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -382,19 +365,17 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
|
|
||||||
{/* 파일 정보 바 */}
|
{/* 파일 정보 바 */}
|
||||||
{selectedFile && (
|
{selectedFile && (
|
||||||
<div className="bg-black/60 text-white text-xs px-3 py-2 text-center truncate">
|
<div className="truncate bg-black/60 px-3 py-2 text-center text-xs text-white">
|
||||||
{selectedFile.realFileName}
|
{selectedFile.realFileName}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 우측: 파일 목록 (고정 너비) */}
|
{/* 우측: 파일 목록 (고정 너비) */}
|
||||||
<div className="w-[400px] shrink-0 border border-gray-200 rounded-lg overflow-hidden flex flex-col">
|
<div className="flex w-[400px] shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200">
|
||||||
<div className="p-3 border-b border-gray-200 bg-gray-50">
|
<div className="border-b border-gray-200 bg-gray-50 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-gray-700">
|
<h3 className="text-sm font-medium text-gray-700">업로드된 파일</h3>
|
||||||
업로드된 파일
|
|
||||||
</h3>
|
|
||||||
{uploadedFiles.length > 0 && (
|
{uploadedFiles.length > 0 && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
총 {formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
|
총 {formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
|
||||||
|
|
@ -409,20 +390,13 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
{uploadedFiles.map((file) => (
|
{uploadedFiles.map((file) => (
|
||||||
<div
|
<div
|
||||||
key={file.objid}
|
key={file.objid}
|
||||||
className={`
|
className={`flex cursor-pointer items-center space-x-3 rounded-lg p-2 transition-colors ${selectedFile?.objid === file.objid ? "border border-blue-200 bg-blue-50" : "bg-gray-50 hover:bg-gray-100"} `}
|
||||||
flex items-center space-x-3 p-2 rounded-lg transition-colors cursor-pointer
|
|
||||||
${selectedFile?.objid === file.objid ? 'bg-blue-50 border border-blue-200' : 'bg-gray-50 hover:bg-gray-100'}
|
|
||||||
`}
|
|
||||||
onClick={() => handleFileClick(file)}
|
onClick={() => handleFileClick(file)}
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">{getFileIcon(file.fileExt)}</div>
|
||||||
{getFileIcon(file.fileExt)}
|
<div className="min-w-0 flex-1">
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-gray-900 truncate">
|
<span className="truncate text-sm font-medium text-gray-900">{file.realFileName}</span>
|
||||||
{file.realFileName}
|
|
||||||
</span>
|
|
||||||
{file.isRepresentative && (
|
{file.isRepresentative && (
|
||||||
<Badge variant="default" className="h-5 px-1.5 text-xs">
|
<Badge variant="default" className="h-5 px-1.5 text-xs">
|
||||||
대표
|
대표
|
||||||
|
|
@ -445,7 +419,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
}}
|
}}
|
||||||
title={file.isRepresentative ? "현재 대표 파일" : "대표 파일로 설정"}
|
title={file.isRepresentative ? "현재 대표 파일" : "대표 파일로 설정"}
|
||||||
>
|
>
|
||||||
<Star className={`w-3 h-3 ${file.isRepresentative ? "fill-white" : ""}`} />
|
<Star className={`h-3 w-3 ${file.isRepresentative ? "fill-white" : ""}`} />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -458,7 +432,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
}}
|
}}
|
||||||
title="미리보기"
|
title="미리보기"
|
||||||
>
|
>
|
||||||
<Eye className="w-3 h-3" />
|
<Eye className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -470,7 +444,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
}}
|
}}
|
||||||
title="다운로드"
|
title="다운로드"
|
||||||
>
|
>
|
||||||
<Download className="w-3 h-3" />
|
<Download className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
{!isDesignMode && (
|
{!isDesignMode && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -483,7 +457,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
}}
|
}}
|
||||||
title="삭제"
|
title="삭제"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -492,10 +466,12 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||||
<File className="w-12 h-12 mb-3 text-gray-300" />
|
<File className="mb-3 h-12 w-12 text-gray-300" />
|
||||||
<p className="text-sm font-medium text-gray-600">업로드된 파일이 없습니다</p>
|
<p className="text-sm font-medium text-gray-600">업로드된 파일이 없습니다</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
{isDesignMode ? '디자인 모드에서는 파일을 업로드할 수 없습니다' : '위의 영역에 파일을 업로드하세요'}
|
{isDesignMode
|
||||||
|
? "디자인 모드에서는 파일을 업로드할 수 없습니다"
|
||||||
|
: "위의 영역에 파일을 업로드하세요"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -70,18 +70,18 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 formData에 값이 없고 기본값이 설정된 경우, 기본값 적용
|
// 🆕 formData에 값이 없고 기본값이 설정된 경우, 기본값 적용
|
||||||
|
// 단, formData에 해당 키가 이미 존재하면(사용자가 명시적으로 초기화한 경우) 기본값을 재적용하지 않음
|
||||||
|
const hasKeyInFormData = formData !== undefined && formData !== null && columnName in (formData || {});
|
||||||
if (
|
if (
|
||||||
(currentValue === "" || currentValue === undefined || currentValue === null) &&
|
(currentValue === "" || currentValue === undefined || currentValue === null) &&
|
||||||
defaultValue &&
|
defaultValue &&
|
||||||
isInteractive &&
|
isInteractive &&
|
||||||
onFormDataChange &&
|
onFormDataChange &&
|
||||||
columnName
|
columnName &&
|
||||||
|
!hasKeyInFormData // formData에 키 자체가 없을 때만 기본값 적용 (초기 렌더링)
|
||||||
) {
|
) {
|
||||||
// 초기 렌더링 시 기본값을 formData에 설정
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!formData?.[columnName]) {
|
|
||||||
onFormDataChange(columnName, defaultValue);
|
onFormDataChange(columnName, defaultValue);
|
||||||
}
|
|
||||||
}, 0);
|
}, 0);
|
||||||
currentValue = defaultValue;
|
currentValue = defaultValue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1183,31 +1183,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}
|
}
|
||||||
|
|
||||||
// leftItem이 null이면 join 모드 이외에는 데이터 로드 불가
|
// leftItem이 null이면 join 모드 이외에는 데이터 로드 불가
|
||||||
|
// detail 모드: 선택 안 하면 아무것도 안 뜸, 선택하면 필터링
|
||||||
|
// join 모드: 선택 안 하면 전체, 선택하면 필터링
|
||||||
if (!leftItem) return;
|
if (!leftItem) return;
|
||||||
|
|
||||||
setIsLoadingRight(true);
|
setIsLoadingRight(true);
|
||||||
try {
|
try {
|
||||||
if (relationshipType === "detail") {
|
// detail / join 모두 동일한 필터링 로직 사용
|
||||||
// 상세 모드: 동일 테이블의 상세 정보 (엔티티 조인 활성화)
|
// (차이점: 초기 로드 여부만 다름 - 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") {
|
|
||||||
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
|
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
|
||||||
const keys = componentConfig.rightPanel?.relation?.keys;
|
const keys = componentConfig.rightPanel?.relation?.keys;
|
||||||
const leftTable = componentConfig.leftPanel?.tableName;
|
const leftTable = componentConfig.leftPanel?.tableName;
|
||||||
|
|
@ -1443,8 +1427,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 탭의 dataFilter (API 전달용)
|
// 탭의 dataFilter (API 전달용)
|
||||||
const tabDataFilterForApi = (tabConfig as any).dataFilter;
|
const tabDataFilterForApi = (tabConfig as any).dataFilter;
|
||||||
|
|
||||||
|
// 탭의 relation type 확인 (detail이면 초기 전체 로드 안 함)
|
||||||
|
const tabRelationType = tabConfig.relation?.type || "join";
|
||||||
|
|
||||||
if (!leftItem) {
|
if (!leftItem) {
|
||||||
// 좌측 미선택: 전체 데이터 로드 (dataFilter는 API에 전달)
|
if (tabRelationType === "detail") {
|
||||||
|
// detail 모드: 선택 안 하면 아무것도 안 뜸
|
||||||
|
resultData = [];
|
||||||
|
} else {
|
||||||
|
// join 모드: 좌측 미선택 시 전체 데이터 로드 (dataFilter는 API에 전달)
|
||||||
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
||||||
enableEntityJoin: true,
|
enableEntityJoin: true,
|
||||||
size: 1000,
|
size: 1000,
|
||||||
|
|
@ -1453,6 +1444,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
dataFilter: tabDataFilterForApi,
|
dataFilter: tabDataFilterForApi,
|
||||||
});
|
});
|
||||||
resultData = result.data || [];
|
resultData = result.data || [];
|
||||||
|
}
|
||||||
} else if (leftColumn && rightColumn) {
|
} else if (leftColumn && rightColumn) {
|
||||||
const searchConditions: Record<string, any> = {};
|
const searchConditions: Record<string, any> = {};
|
||||||
|
|
||||||
|
|
@ -1534,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)) {
|
||||||
|
if (!requireSelection || selectedLeftItem) {
|
||||||
loadRightData(selectedLeftItem);
|
loadRightData(selectedLeftItem);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!tabsData[newTabIndex]) {
|
if (!tabsData[newTabIndex]) {
|
||||||
|
if (!requireSelection || selectedLeftItem) {
|
||||||
loadTabData(newTabIndex, selectedLeftItem);
|
loadTabData(newTabIndex, selectedLeftItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData],
|
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData, componentConfig.rightPanel?.relation?.type],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 좌측 항목 선택 핸들러 (동일 항목 재클릭 시 선택 해제 → 전체 데이터 표시)
|
// 좌측 항목 선택 핸들러 (동일 항목 재클릭 시 선택 해제 → 전체 데이터 표시)
|
||||||
|
|
@ -1562,17 +1562,23 @@ 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({});
|
||||||
|
|
||||||
|
const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail";
|
||||||
|
if (mainRelationType === "detail") {
|
||||||
|
// "선택 시 표시" 모드: 선택 해제 시 데이터 비움
|
||||||
|
setRightData(null);
|
||||||
|
} else {
|
||||||
|
// "연관 목록" 모드: 선택 해제 시 전체 데이터 로드
|
||||||
if (activeTabIndex === 0) {
|
if (activeTabIndex === 0) {
|
||||||
loadRightData(null);
|
loadRightData(null);
|
||||||
} else {
|
} else {
|
||||||
loadTabData(activeTabIndex, null);
|
loadTabData(activeTabIndex, null);
|
||||||
}
|
}
|
||||||
// 추가 탭들도 전체 데이터 로드
|
|
||||||
const tabs = componentConfig.rightPanel?.additionalTabs;
|
const tabs = componentConfig.rightPanel?.additionalTabs;
|
||||||
if (tabs && tabs.length > 0) {
|
if (tabs && tabs.length > 0) {
|
||||||
tabs.forEach((_: any, idx: number) => {
|
tabs.forEach((_: any, idx: number) => {
|
||||||
|
|
@ -1581,6 +1587,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2781,14 +2788,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
||||||
loadLeftData();
|
loadLeftData();
|
||||||
// 좌측 미선택 상태에서 우측 전체 데이터 기본 로드
|
// 좌측 미선택 상태에서 우측 전체 데이터 기본 로드
|
||||||
|
// join 모드: 초기 전체 로드 / detail 모드: 초기 로드 안 함
|
||||||
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
|
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
|
||||||
if (relationshipType === "join") {
|
if (relationshipType === "join") {
|
||||||
loadRightData(null);
|
loadRightData(null);
|
||||||
// 추가 탭도 전체 데이터 로드
|
}
|
||||||
|
// 추가 탭: 메인 패널이 "detail"(선택 시 표시)이면 추가 탭도 초기 로드하지 않음
|
||||||
|
if (relationshipType !== "detail") {
|
||||||
const tabs = componentConfig.rightPanel?.additionalTabs;
|
const tabs = componentConfig.rightPanel?.additionalTabs;
|
||||||
if (tabs && tabs.length > 0) {
|
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);
|
loadTabData(idx + 1, null);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3738,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">
|
||||||
|
|
@ -4645,7 +4669,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="mb-2">좌측에서 항목을 선택하세요</p>
|
<p className="mb-2">좌측에서 항목을 선택하세요</p>
|
||||||
<p className="text-xs">선택한 항목의 상세 정보가 여기에 표시됩니다</p>
|
<p className="text-xs">선택한 항목의 관련 데이터가 여기에 표시됩니다</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1542,13 +1542,10 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
return leftTableName ? loadedTableColumns[leftTableName] || [] : [];
|
return leftTableName ? loadedTableColumns[leftTableName] || [] : [];
|
||||||
}, [loadedTableColumns, leftTableName]);
|
}, [loadedTableColumns, leftTableName]);
|
||||||
|
|
||||||
// 우측 테이블명 (상세 모드에서는 좌측과 동일)
|
// 우측 테이블명
|
||||||
const rightTableName = useMemo(() => {
|
const rightTableName = useMemo(() => {
|
||||||
if (relationshipType === "detail") {
|
|
||||||
return leftTableName; // 상세 모드에서는 좌측과 동일
|
|
||||||
}
|
|
||||||
return config.rightPanel?.tableName || "";
|
return config.rightPanel?.tableName || "";
|
||||||
}, [relationshipType, leftTableName, config.rightPanel?.tableName]);
|
}, [config.rightPanel?.tableName]);
|
||||||
|
|
||||||
// 우측 테이블 컬럼 (로드된 컬럼 사용)
|
// 우측 테이블 컬럼 (로드된 컬럼 사용)
|
||||||
const rightTableColumns = useMemo(() => {
|
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("📊 분할패널 테이블 목록 상태:");
|
||||||
console.log(" - relationshipType:", relationshipType);
|
console.log(" - relationshipType:", relationshipType);
|
||||||
|
|
@ -1584,7 +1581,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
{
|
{
|
||||||
id: "basic",
|
id: "basic",
|
||||||
title: "기본 설정",
|
title: "기본 설정",
|
||||||
desc: `${relationshipType === "detail" ? "1건 상세보기" : "연관 목록"} | 비율 ${config.splitRatio || 30}%`,
|
desc: `${relationshipType === "detail" ? "선택 시 표시" : "연관 목록"} | 비율 ${config.splitRatio || 30}%`,
|
||||||
icon: Settings2,
|
icon: Settings2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1638,35 +1635,27 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
<Select
|
<Select
|
||||||
value={relationshipType}
|
value={relationshipType}
|
||||||
onValueChange={(value: "join" | "detail") => {
|
onValueChange={(value: "join" | "detail") => {
|
||||||
// 상세 모드로 변경 시 우측 테이블을 현재 화면 테이블로 설정
|
|
||||||
if (value === "detail" && screenTableName) {
|
|
||||||
updateRightPanel({
|
|
||||||
relation: { ...config.rightPanel?.relation, type: value },
|
|
||||||
tableName: screenTableName,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
updateRightPanel({
|
updateRightPanel({
|
||||||
relation: { ...config.rightPanel?.relation, type: value },
|
relation: { ...config.rightPanel?.relation, type: value },
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-10 bg-white">
|
<SelectTrigger className="h-10 bg-white">
|
||||||
<SelectValue placeholder="표시 방식 선택">
|
<SelectValue placeholder="표시 방식 선택">
|
||||||
{relationshipType === "detail" ? "1건 상세보기" : "연관 목록"}
|
{relationshipType === "detail" ? "선택 시 표시" : "연관 목록"}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="detail">
|
<SelectItem value="detail">
|
||||||
<div className="flex flex-col py-1">
|
<div className="flex flex-col py-1">
|
||||||
<span className="text-sm font-medium">1건 상세보기</span>
|
<span className="text-sm font-medium">선택 시 표시</span>
|
||||||
<span className="text-xs text-gray-500">좌측 클릭 시 해당 항목의 상세 정보 표시 (같은 테이블)</span>
|
<span className="text-xs text-gray-500">좌측 선택 시에만 우측 데이터 표시 / 미선택 시 빈 화면</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="join">
|
<SelectItem value="join">
|
||||||
<div className="flex flex-col py-1">
|
<div className="flex flex-col py-1">
|
||||||
<span className="text-sm font-medium">연관 목록</span>
|
<span className="text-sm font-medium">연관 목록</span>
|
||||||
<span className="text-xs text-gray-500">좌측 클릭 시 연관된 데이터 목록 표시 / 미선택 시 전체 표시</span>
|
<span className="text-xs text-gray-500">미선택 시 전체 표시 / 좌측 선택 시 필터링</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -2305,7 +2294,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 우측 패널 설정 */}
|
{/* 우측 패널 설정 */}
|
||||||
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-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">
|
<div className="space-y-2">
|
||||||
<Label>패널 제목</Label>
|
<Label>패널 제목</Label>
|
||||||
|
|
@ -2338,21 +2327,8 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</div> */}
|
</div> */}
|
||||||
|
|
||||||
{/* 관계 타입에 따라 테이블 선택 UI 변경 */}
|
{/* 관계 타입에 따라 테이블 선택 UI 변경 */}
|
||||||
{relationshipType === "detail" ? (
|
|
||||||
// 상세 모드: 좌측과 동일한 테이블 (자동 설정)
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>테이블 (좌측과 동일)</Label>
|
<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>
|
|
||||||
<Popover open={rightTableOpen} onOpenChange={setRightTableOpen}>
|
<Popover open={rightTableOpen} onOpenChange={setRightTableOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -2394,7 +2370,6 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>표시 모드</Label>
|
<Label>표시 모드</Label>
|
||||||
|
|
|
||||||
|
|
@ -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