refactor: 코드 정리 및 불필요한 로그 제거

- scheduleService.ts에서 스케줄 생성 로직을 간소화하고, 불필요한 줄바꿈을 제거하여 가독성을 향상시켰습니다.
- v2-sales-order-modal-layout.json에서 JSON 포맷을 정리하여 일관성을 유지했습니다.
- page.tsx, ScreenModal.tsx, ScreenDesigner.tsx, V2Input.tsx, V2Select.tsx, V2SelectConfigPanel.tsx, SimpleRepeaterTableComponent.tsx, ButtonPrimaryComponent.tsx, FileUploadComponent.tsx 등 여러 파일에서 디버깅 로그를 제거하여 코드의 깔끔함을 유지했습니다.
- 전반적으로 코드의 가독성을 높이고, 불필요한 로그를 제거하여 유지보수성을 개선했습니다.
This commit is contained in:
kjs 2026-02-05 17:35:13 +09:00
parent 34202be843
commit 73d05b991c
21 changed files with 1023 additions and 1478 deletions

View File

@ -119,17 +119,14 @@ export class ScheduleService {
companyCode
);
toCreate.push(...schedules);
totalQty += schedules.reduce(
(sum, s) => sum + (s.plan_qty || 0),
0
);
totalQty += schedules.reduce((sum, s) => sum + (s.plan_qty || 0), 0);
}
// 3. 기존 스케줄 조회 (삭제 대상)
// 그룹 키에서 리소스 ID만 추출 ("리소스ID|날짜" 형식에서 "리소스ID"만)
const resourceIds = [...new Set(
Object.keys(groupedData).map((key) => key.split("|")[0])
)];
const resourceIds = [
...new Set(Object.keys(groupedData).map((key) => key.split("|")[0])),
];
const toDelete = await this.getExistingSchedules(
config.scheduleType,
resourceIds,
@ -369,7 +366,9 @@ export class ScheduleService {
let groupKey = resourceId;
if (dueDateField && item[dueDateField]) {
// 날짜를 YYYY-MM-DD 형식으로 정규화
const dueDate = new Date(item[dueDateField]).toISOString().split("T")[0];
const dueDate = new Date(item[dueDateField])
.toISOString()
.split("T")[0];
groupKey = `${resourceId}|${dueDate}`;
}
@ -403,8 +402,7 @@ export class ScheduleService {
// 그룹 키에서 리소스ID와 기준일 분리
const [resourceId, groupDueDate] = groupKey.split("|");
const resourceName =
items[0]?.[config.resource.nameField] || resourceId;
const resourceName = items[0]?.[config.resource.nameField] || resourceId;
// 총 수량 계산
const totalQty = items.reduce((sum, item) => {
@ -469,7 +467,9 @@ export class ScheduleService {
plan_qty: totalQty,
status: "PLANNED",
source_table: config.source.tableName,
source_id: items.map((i) => i.id || i.order_no || i.sales_order_no).join(","),
source_id: items
.map((i) => i.id || i.order_no || i.sales_order_no)
.join(","),
source_group_key: resourceId,
metadata: {
sourceCount: items.length,

View File

@ -302,13 +302,29 @@
{ "field": "spec", "header": "규격", "width": 100 },
{ "field": "unit", "header": "단위", "width": 80 },
{ "field": "qty", "header": "수량", "width": 100, "editable": true },
{ "field": "unit_price", "header": "단가", "width": 100, "editable": true },
{
"field": "unit_price",
"header": "단가",
"width": 100,
"editable": true
},
{ "field": "amount", "header": "금액", "width": 100 },
{ "field": "due_date", "header": "납기일", "width": 120, "editable": true }
{
"field": "due_date",
"header": "납기일",
"width": 120,
"editable": true
}
],
"modal": {
"sourceTable": "item_info",
"sourceColumns": ["part_code", "part_name", "spec", "material", "unit_price"],
"sourceColumns": [
"part_code",
"part_name",
"spec",
"material",
"unit_price"
],
"filterCondition": {}
},
"features": {

View File

@ -161,7 +161,6 @@ function ScreenViewPage() {
// V2 레이아웃: Zod 기반 변환 (기본값 병합)
const convertedLayout = convertV2ToLegacy(v2Response);
if (convertedLayout) {
console.log("📦 V2 레이아웃 로드 (Zod 기반):", v2Response.components?.length || 0, "개 컴포넌트");
setLayout({
...convertedLayout,
screenResolution: v2Response.screenResolution || convertedLayout.screenResolution,
@ -227,7 +226,6 @@ function ScreenViewPage() {
);
if (hasTableWidget) {
console.log("📋 테이블 위젯이 있어 자동 로드 건너뜀 (행 선택으로 데이터 로드)");
return;
}

View File

@ -372,7 +372,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// V2 레이아웃이 없으면 기존 API로 fallback
if (!layoutData) {
console.log("📦 V2 레이아웃 없음, 기존 API로 fallback");
layoutData = await screenApi.getLayout(screenId);
}
@ -385,8 +384,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const groupByColumnsParam = urlParams.get("groupByColumns");
const primaryKeyColumn = urlParams.get("primaryKeyColumn"); // 🆕 Primary Key 컬럼명
console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam, primaryKeyColumn });
// 수정 모드이고 editId가 있으면 해당 레코드 조회
if (mode === "edit" && editId && tableName) {
try {
@ -411,14 +408,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 🆕 Primary Key 컬럼명 전달 (백엔드 자동 감지 실패 시 사용)
if (primaryKeyColumn) {
params.primaryKeyColumn = primaryKeyColumn;
console.log("✅ [ScreenModal] primaryKeyColumn을 params에 추가:", primaryKeyColumn);
}
console.log("📡 [ScreenModal] 실제 API 요청:", {
url: `/data/${tableName}/${editId}`,
params,
});
const apiResponse = await apiClient.get(`/data/${tableName}/${editId}`, { params });
const response = apiResponse.data;
@ -751,9 +742,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
.map(([bottom, gap]) => ({ bottom, gap }))
.sort((a, b) => a.bottom - b.bottom);
console.log('🔍 [Y조정] visibleRanges:', visibleRanges.filter(r => r.bottom - r.y > 50).map(r => `${r.y}~${r.bottom}`));
console.log('🔍 [Y조정] hiddenGaps:', sortedGaps);
// 각 컴포넌트의 y 조정값 계산 함수
const getYOffset = (compY: number, compId?: string) => {
let offset = 0;
@ -763,9 +751,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
offset += gap;
}
}
if (offset > 0 && compId) {
console.log(`🔍 [Y조정] ${compId}: y=${compY}${compY - offset} (offset=${offset})`);
}
return offset;
};

View File

@ -17,7 +17,11 @@ import {
SCREEN_RESOLUTIONS,
} from "@/types/screen";
import { generateComponentId } from "@/lib/utils/generateId";
import { getComponentIdFromWebType, createV2ConfigFromColumn, getV2ConfigFromWebType } from "@/lib/utils/webTypeMapping";
import {
getComponentIdFromWebType,
createV2ConfigFromColumn,
getV2ConfigFromWebType,
} from "@/lib/utils/webTypeMapping";
import {
createGroupComponent,
calculateBoundingBox,
@ -215,7 +219,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
comp: any,
// 🆕 중첩 구조용: 부모 분할 패널 정보 (선택적)
parentSplitPanelId?: string | null,
parentPanelSide?: "left" | "right" | null
parentPanelSide?: "left" | "right" | null,
) => {
if (!compId) {
// 탭 영역 빈 공간 클릭 시 선택 해제
@ -303,7 +307,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
};
}, [openPanel]);
// 클립보드 상태
const [clipboard, setClipboard] = useState<ComponentData[]>([]);
@ -585,7 +588,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
[panelKey]: {
...panelConfig,
components: panelComponents.map((pc: any) =>
pc.id === tabsComponentId ? updatedTabsComponent : pc
pc.id === tabsComponentId ? updatedTabsComponent : pc,
),
},
},
@ -604,9 +607,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
newLayout = {
...prevLayout,
components: prevLayout.components.map((c) =>
c.id === tabsComponentId ? updatedTabsComponent : c
),
components: prevLayout.components.map((c) => (c.id === tabsComponentId ? updatedTabsComponent : c)),
};
}
@ -616,9 +617,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
.find((t: any) => t.id === tabId)
?.components?.find((c: any) => c.id === componentId);
if (updatedComp) {
setSelectedTabComponentInfo((prev) =>
prev ? { ...prev, component: updatedComp } : null
);
setSelectedTabComponentInfo((prev) => (prev ? { ...prev, component: updatedComp } : null));
}
}
@ -1120,7 +1119,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// 1. dataSourceType이 "restapi"인 경우
// 2. tableName이 restapi_ 또는 _restapi_로 시작하는 경우
// 3. restApiConnectionId가 있는 경우
const isRestApi = selectedScreen?.dataSourceType === "restapi" ||
const isRestApi =
selectedScreen?.dataSourceType === "restapi" ||
selectedScreen?.tableName?.startsWith("restapi_") ||
selectedScreen?.tableName?.startsWith("_restapi_") ||
!!selectedScreen?.restApiConnectionId;
@ -1171,7 +1171,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
tableName: tableInfo.tableName,
tableLabel: tableInfo.tableLabel,
columnsCount: columns.length,
columns: columns.map(c => c.columnName),
columns: columns.map((c) => c.columnName),
});
setTables([tableInfo]);
@ -1278,10 +1278,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
};
loadScreenDataSource();
}, [selectedScreen?.tableName, selectedScreen?.screenName, selectedScreen?.dataSourceType, selectedScreen?.restApiConnectionId, selectedScreen?.restApiEndpoint, selectedScreen?.restApiJsonPath]);
}, [
selectedScreen?.tableName,
selectedScreen?.screenName,
selectedScreen?.dataSourceType,
selectedScreen?.restApiConnectionId,
selectedScreen?.restApiEndpoint,
selectedScreen?.restApiJsonPath,
]);
// 테이블 선택 핸들러 - 사이드바에서 테이블 선택 시 호출
const handleTableSelect = useCallback(async (tableName: string) => {
const handleTableSelect = useCallback(
async (tableName: string) => {
console.log("📊 테이블 선택:", tableName);
try {
@ -1353,7 +1361,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
});
if (newComponents.length < prev.components.length) {
toast.info(`이전 테이블(${tables[0].tableName})의 컴포넌트가 ${prev.components.length - newComponents.length}개 제거되었습니다.`);
toast.info(
`이전 테이블(${tables[0].tableName})의 컴포넌트가 ${prev.components.length - newComponents.length}개 제거되었습니다.`,
);
}
return {
@ -1366,7 +1376,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
console.error("테이블 정보 로드 실패:", error);
toast.error("테이블 정보를 불러오는데 실패했습니다.");
}
}, [tables]);
},
[tables],
);
// 화면 레이아웃 로드
useEffect(() => {
@ -1393,37 +1405,21 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const v2Response = await screenApi.getLayoutV2(selectedScreen.screenId);
// 🐛 디버깅: API 응답에서 fieldMapping.id 확인
const splitPanelInV2 = v2Response?.components?.find((c: any) =>
c.url?.includes("v2-split-panel-layout")
);
const splitPanelInV2 = v2Response?.components?.find((c: any) => c.url?.includes("v2-split-panel-layout"));
const finishedTimelineInV2 = splitPanelInV2?.overrides?.rightPanel?.components?.find(
(c: any) => c.id === "finished_timeline"
(c: any) => c.id === "finished_timeline",
);
console.log("🐛 [API 응답 RAW] finished_timeline:", JSON.stringify(finishedTimelineInV2, null, 2));
console.log("🐛 [API 응답] finished_timeline fieldMapping:", {
fieldMapping: JSON.stringify(finishedTimelineInV2?.componentConfig?.fieldMapping),
fieldMappingKeys: finishedTimelineInV2?.componentConfig?.fieldMapping ? Object.keys(finishedTimelineInV2?.componentConfig?.fieldMapping) : [],
fieldMappingKeys: finishedTimelineInV2?.componentConfig?.fieldMapping
? Object.keys(finishedTimelineInV2?.componentConfig?.fieldMapping)
: [],
hasId: !!finishedTimelineInV2?.componentConfig?.fieldMapping?.id,
idValue: finishedTimelineInV2?.componentConfig?.fieldMapping?.id,
});
response = v2Response ? convertV2ToLegacy(v2Response) : null;
// 🐛 디버깅: convertV2ToLegacy 후 fieldMapping.id 확인
const splitPanelInLegacy = response?.components?.find((c: any) =>
c.componentType === "v2-split-panel-layout"
);
const finishedTimelineInLegacy = splitPanelInLegacy?.componentConfig?.rightPanel?.components?.find(
(c: any) => c.id === "finished_timeline"
);
console.log("🐛 [변환 후] finished_timeline fieldMapping:", {
fieldMapping: JSON.stringify(finishedTimelineInLegacy?.componentConfig?.fieldMapping),
fieldMappingKeys: finishedTimelineInLegacy?.componentConfig?.fieldMapping ? Object.keys(finishedTimelineInLegacy?.componentConfig?.fieldMapping) : [],
hasId: !!finishedTimelineInLegacy?.componentConfig?.fieldMapping?.id,
idValue: finishedTimelineInLegacy?.componentConfig?.fieldMapping?.id,
});
console.log("📦 V2 레이아웃 로드:", v2Response?.components?.length || 0, "개 컴포넌트");
} else {
response = await screenApi.getLayout(selectedScreen.screenId);
}
@ -1469,15 +1465,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
}
// 🔍 디버깅: 로드된 버튼 컴포넌트의 action 확인
const buttonComponents = layoutWithDefaultGrid.components.filter(
(c: any) => c.componentType?.startsWith("button")
const buttonComponents = layoutWithDefaultGrid.components.filter((c: any) =>
c.componentType?.startsWith("button"),
);
console.log("🔍 [로드] 버튼 컴포넌트 action 확인:", buttonComponents.map((c: any) => ({
console.log(
"🔍 [로드] 버튼 컴포넌트 action 확인:",
buttonComponents.map((c: any) => ({
id: c.id,
type: c.componentType,
actionType: c.componentConfig?.action?.type,
fullAction: c.componentConfig?.action,
})));
})),
);
setLayout(layoutWithDefaultGrid);
setHistory([layoutWithDefaultGrid]);
@ -1503,8 +1502,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
if (
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement ||
activeElement?.getAttribute('contenteditable') === 'true' ||
activeElement?.getAttribute('role') === 'textbox'
activeElement?.getAttribute("contenteditable") === "true" ||
activeElement?.getAttribute("role") === "textbox"
) {
return;
}
@ -1530,8 +1529,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
if (
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement ||
activeElement?.getAttribute('contenteditable') === 'true' ||
activeElement?.getAttribute('role') === 'textbox'
activeElement?.getAttribute("contenteditable") === "true" ||
activeElement?.getAttribute("role") === "textbox"
) {
return;
}
@ -1682,7 +1681,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
setLayout(updatedLayout);
saveToHistory(updatedLayout);
toast.success(`해상도가 변경되었습니다.`, {
toast.success("해상도가 변경되었습니다.", {
description: `${oldWidth}×${oldHeight}${newWidth}×${newHeight}`,
});
@ -2541,9 +2540,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const newLayout = {
...layout,
components: layout.components.map((c) =>
c.id === containerId ? updatedComponent : c
),
components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)),
};
setLayout(newLayout);
@ -2670,7 +2667,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
[panelKey]: {
...panelConfig,
components: panelComponents.map((pc: any) =>
pc.id === containerId ? updatedTabsComponent : pc
pc.id === containerId ? updatedTabsComponent : pc,
),
},
},
@ -2684,9 +2681,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// 일반 구조: 최상위 탭 업데이트
newLayout = {
...layout,
components: layout.components.map((c) =>
c.id === containerId ? updatedTabsComponent : c
),
components: layout.components.map((c) => (c.id === containerId ? updatedTabsComponent : c)),
};
toast.success("컴포넌트가 탭에 추가되었습니다");
}
@ -2751,9 +2746,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const newLayout = {
...layout,
components: layout.components.map((c) =>
c.id === containerId ? updatedComponent : c
),
components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)),
};
setLayout(newLayout);
@ -3136,9 +3129,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const newLayout = {
...layout,
components: layout.components.map((c) =>
c.id === containerId ? updatedComponent : c
),
components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)),
};
setLayout(newLayout);
@ -3290,7 +3281,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
[panelKey]: {
...panelConfig,
components: panelComponents.map((pc: any) =>
pc.id === containerId ? updatedTabsComponent : pc
pc.id === containerId ? updatedTabsComponent : pc,
),
},
},
@ -3304,9 +3295,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// 일반 구조: 최상위 탭 업데이트
newLayout = {
...layout,
components: layout.components.map((c) =>
c.id === containerId ? updatedTabsComponent : c
),
components: layout.components.map((c) => (c.id === containerId ? updatedTabsComponent : c)),
};
toast.success("컬럼이 탭에 추가되었습니다");
}
@ -3404,9 +3393,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const newLayout = {
...layout,
components: layout.components.map((c) =>
c.id === containerId ? updatedComponent : c
),
components: layout.components.map((c) => (c.id === containerId ? updatedComponent : c)),
};
setLayout(newLayout);
@ -4030,7 +4017,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
);
// 드래그 종료
const endDrag = useCallback((mouseEvent?: MouseEvent) => {
const endDrag = useCallback(
(mouseEvent?: MouseEvent) => {
if (dragState.isDragging && dragState.draggedComponent) {
// 🎯 탭 컨테이너로의 드롭 처리 (기존 컴포넌트 이동, 중첩 구조 지원)
if (mouseEvent) {
@ -4080,10 +4068,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const compType = (targetComponent as any)?.componentType;
// 자기 자신을 자신에게 드롭하는 것 방지
if (targetComponent &&
if (
targetComponent &&
(compType === "tabs-widget" || compType === "v2-tabs-widget") &&
dragState.draggedComponent !== containerId) {
dragState.draggedComponent !== containerId
) {
const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent);
if (draggedComponent) {
const currentConfig = (targetComponent as any).componentConfig || {};
@ -4146,7 +4135,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
[panelKey]: {
...panelConfig,
components: panelComponents.map((pc: any) =>
pc.id === containerId ? updatedTabsComponent : pc
pc.id === containerId ? updatedTabsComponent : pc,
),
},
},
@ -4228,7 +4217,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
snapToGrid: layout.gridSettings.snapToGrid || false,
},
);
}
// 스냅으로 인한 추가 이동 거리 계산
@ -4345,7 +4333,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
justFinishedDrag: false,
}));
}, 100);
}, [dragState, layout, saveToHistory]);
},
[dragState, layout, saveToHistory],
);
// 드래그 선택 시작
const startSelectionDrag = useCallback(
@ -5426,8 +5416,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
<div className="flex flex-1 overflow-hidden">
{/* 통합 패널 - 좌측 사이드바 제거 후 너비 300px로 확장 */}
{panelStates.v2?.isOpen && (
<div className="border-border bg-card flex h-full w-[300px] flex-col border-r shadow-sm overflow-hidden">
<div className="border-border flex items-center justify-between border-b px-4 py-3 shrink-0">
<div className="border-border bg-card flex h-full w-[300px] flex-col overflow-hidden border-r shadow-sm">
<div className="border-border flex shrink-0 items-center justify-between border-b px-4 py-3">
<h3 className="text-foreground text-sm font-semibold"></h3>
<button
onClick={() => closePanel("v2")}
@ -5487,9 +5477,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// 탭 내부 컴포넌트용 속성 업데이트 핸들러 (중첩 구조 지원)
const updateTabComponentProperty = (componentId: string, path: string, value: any) => {
const { tabsComponentId, tabId, parentSplitPanelId, parentPanelSide } = selectedTabComponentInfo;
const { tabsComponentId, tabId, parentSplitPanelId, parentPanelSide } =
selectedTabComponentInfo;
console.log("🔧 updateTabComponentProperty 호출:", { componentId, path, value, parentSplitPanelId, parentPanelSide });
console.log("🔧 updateTabComponentProperty 호출:", {
componentId,
path,
value,
parentSplitPanelId,
parentPanelSide,
});
// 🆕 안전한 깊은 경로 업데이트 헬퍼 함수
const setNestedValue = (obj: any, pathStr: string, val: any): any => {
@ -5552,7 +5549,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const panelConfig = splitConfig[panelKey] || {};
const panelComponents = panelConfig.components || [];
const tabsComponent = panelComponents.find((pc: any) => pc.id === tabsComponentId);
const tabsComponent = panelComponents.find(
(pc: any) => pc.id === tabsComponentId,
);
if (!tabsComponent) return c;
const updatedTabsComponent = updateTabsComponent(tabsComponent);
@ -5565,7 +5564,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
[panelKey]: {
...panelConfig,
components: panelComponents.map((pc: any) =>
pc.id === tabsComponentId ? updatedTabsComponent : pc
pc.id === tabsComponentId ? updatedTabsComponent : pc,
),
},
},
@ -5585,7 +5584,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
newLayout = {
...prevLayout,
components: prevLayout.components.map((c) =>
c.id === tabsComponentId ? updatedTabsComponent : c
c.id === tabsComponentId ? updatedTabsComponent : c,
),
};
}
@ -5597,7 +5596,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
?.components?.find((c: any) => c.id === componentId);
if (updatedComp) {
setSelectedTabComponentInfo((prev) =>
prev ? { ...prev, component: updatedComp } : null
prev ? { ...prev, component: updatedComp } : null,
);
}
}
@ -5608,7 +5607,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// 탭 내부 컴포넌트 삭제 핸들러 (중첩 구조 지원)
const deleteTabComponent = (componentId: string) => {
const { tabsComponentId, tabId, parentSplitPanelId, parentPanelSide } = selectedTabComponentInfo;
const { tabsComponentId, tabId, parentSplitPanelId, parentPanelSide } =
selectedTabComponentInfo;
// 탭 컴포넌트에서 특정 컴포넌트 삭제
const updateTabsComponentForDelete = (tabsComponent: any) => {
@ -5645,7 +5645,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const panelConfig = splitConfig[panelKey] || {};
const panelComponents = panelConfig.components || [];
const tabsComponent = panelComponents.find((pc: any) => pc.id === tabsComponentId);
const tabsComponent = panelComponents.find(
(pc: any) => pc.id === tabsComponentId,
);
if (!tabsComponent) return c;
const updatedTabsComponent = updateTabsComponentForDelete(tabsComponent);
@ -5657,7 +5659,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
[panelKey]: {
...panelConfig,
components: panelComponents.map((pc: any) =>
pc.id === tabsComponentId ? updatedTabsComponent : pc
pc.id === tabsComponentId ? updatedTabsComponent : pc,
),
},
},
@ -5676,7 +5678,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
newLayout = {
...prevLayout,
components: prevLayout.components.map((c) =>
c.id === tabsComponentId ? updatedTabsComponent : c
c.id === tabsComponentId ? updatedTabsComponent : c,
),
};
}
@ -5689,9 +5691,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2">
<span className="text-xs text-muted-foreground"> </span>
<span className="text-muted-foreground text-xs"> </span>
<button
className="text-xs text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground text-xs"
onClick={() => setSelectedTabComponentInfo(null)}
>
@ -5738,7 +5740,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const { splitPanelId, panelSide } = selectedPanelComponentInfo;
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
console.log("🔧 updatePanelComponentProperty 호출:", { componentId, path, value, splitPanelId, panelSide });
console.log("🔧 updatePanelComponentProperty 호출:", {
componentId,
path,
value,
splitPanelId,
panelSide,
});
// 🆕 안전한 깊은 경로 업데이트 헬퍼 함수
const setNestedValue = (obj: any, pathStr: string, val: any): any => {
@ -5771,7 +5779,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// 🆕 안전한 깊은 경로 업데이트 사용
const targetComp = components[targetCompIndex];
const updatedComp = path === "style"
const updatedComp =
path === "style"
? { ...targetComp, style: value }
: setNestedValue(targetComp, path, value);
@ -5795,14 +5804,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
};
// selectedPanelComponentInfo 업데이트
setSelectedPanelComponentInfo(prev =>
prev ? { ...prev, component: updatedComp } : null
setSelectedPanelComponentInfo((prev) =>
prev ? { ...prev, component: updatedComp } : null,
);
return {
...prevLayout,
components: prevLayout.components.map((c) =>
c.id === splitPanelId ? updatedComponent : c
c.id === splitPanelId ? updatedComponent : c,
),
};
});
@ -5839,7 +5848,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
return {
...prevLayout,
components: prevLayout.components.map((c) =>
c.id === splitPanelId ? updatedComponent : c
c.id === splitPanelId ? updatedComponent : c,
),
};
});
@ -5848,11 +5857,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2">
<span className="text-xs text-muted-foreground">
({selectedPanelComponentInfo.panelSide === "left" ? "좌측" : "우측"})
<span className="text-muted-foreground text-xs">
({selectedPanelComponentInfo.panelSide === "left" ? "좌측" : "우측"})
</span>
<button
className="text-xs text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground text-xs"
onClick={() => setSelectedPanelComponentInfo(null)}
>
@ -6181,7 +6191,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
const componentFiles = (component as any).uploadedFiles || [];
const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`;
// 🆕 style 변경 시 리렌더링을 위한 key 추가
const styleKey = component.style?.labelDisplay !== undefined ? `label-${component.style.labelDisplay}` : "";
const styleKey =
component.style?.labelDisplay !== undefined
? `label-${component.style.labelDisplay}`
: "";
const fullKey = `${component.id}-${fileStateKey}-${styleKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`;
// 🔧 v2-input 계열 컴포넌트 key 변경 로그 (디버그 완료 - 주석 처리)
@ -6244,7 +6257,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// 🆕 컴포넌트 전체 업데이트 핸들러 (탭 내부 컴포넌트 위치 조정 등)
onUpdateComponent={(updatedComponent) => {
const updatedComponents = layout.components.map((comp) =>
comp.id === updatedComponent.id ? updatedComponent : comp
comp.id === updatedComponent.id ? updatedComponent : comp,
);
const newLayout = {
@ -6259,9 +6272,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
onResize={(componentId, newSize) => {
setLayout((prevLayout) => {
const updatedComponents = prevLayout.components.map((comp) =>
comp.id === componentId
? { ...comp, size: newSize }
: comp
comp.id === componentId ? { ...comp, size: newSize } : comp,
);
const newLayout = {
@ -6386,9 +6397,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
onResize={(componentId, newSize) => {
setLayout((prevLayout) => {
const updatedComponents = prevLayout.components.map((comp) =>
comp.id === componentId
? { ...comp, size: newSize }
: comp
comp.id === componentId ? { ...comp, size: newSize } : comp,
);
const newLayout = {

View File

@ -9,7 +9,9 @@ import { WidgetComponent } from "@/types/screen";
import { toast } from "sonner";
import { apiClient, getFullImageUrl } from "@/lib/api/client";
export const ImageWidget: React.FC<WebTypeComponentProps & { size?: { width?: number; height?: number }; style?: React.CSSProperties }> = ({
export const ImageWidget: React.FC<
WebTypeComponentProps & { size?: { width?: number; height?: number }; style?: React.CSSProperties }
> = ({
component,
value,
onChange,
@ -134,7 +136,7 @@ export const ImageWidget: React.FC<WebTypeComponentProps & { size?: { width?: nu
{imageUrl ? (
// 이미지 표시 모드
<div
className="group relative flex-1 w-full overflow-hidden rounded-lg border border-gray-200 bg-gray-50 shadow-sm transition-all hover:shadow-md"
className="group relative w-full flex-1 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 shadow-sm transition-all hover:shadow-md"
style={filteredStyle}
>
<img
@ -142,19 +144,15 @@ export const ImageWidget: React.FC<WebTypeComponentProps & { size?: { width?: nu
alt="업로드된 이미지"
className="h-full w-full object-contain"
onError={(e) => {
e.currentTarget.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23f3f4f6'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='14' fill='%239ca3af'%3E이미지 로드 실패%3C/text%3E%3C/svg%3E";
e.currentTarget.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect width='100' height='100' fill='%23f3f4f6'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' font-family='sans-serif' font-size='14' fill='%239ca3af'%3E이미지 로드 실패%3C/text%3E%3C/svg%3E";
}}
/>
{/* 호버 시 제거 버튼 */}
{!readonly && !isDesignMode && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
<Button
size="sm"
variant="destructive"
onClick={handleRemove}
className="gap-2"
>
<Button size="sm" variant="destructive" onClick={handleRemove} className="gap-2">
<X className="h-4 w-4" />
</Button>
@ -164,7 +162,7 @@ export const ImageWidget: React.FC<WebTypeComponentProps & { size?: { width?: nu
) : (
// 업로드 영역
<div
className={`group relative flex flex-1 w-full flex-col items-center justify-center rounded-lg border-2 border-dashed p-3 text-center shadow-sm transition-all duration-300 ${
className={`group relative flex w-full flex-1 flex-col items-center justify-center rounded-lg border-2 border-dashed p-3 text-center shadow-sm transition-all duration-300 ${
isDesignMode
? "cursor-default border-gray-200 bg-gray-50"
: "cursor-pointer border-gray-300 bg-white hover:border-blue-400 hover:bg-blue-50/50 hover:shadow-md"
@ -199,9 +197,7 @@ export const ImageWidget: React.FC<WebTypeComponentProps & { size?: { width?: nu
/>
{/* 필수 필드 경고 */}
{required && !imageUrl && (
<div className="text-xs text-red-500">* </div>
)}
{required && !imageUrl && <div className="text-xs text-red-500">* </div>}
</div>
);
};

View File

@ -551,10 +551,6 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
// 🆕 채번 규칙 ID를 formData에 저장 (저장 시 allocateCode 호출을 위해)
if (parsed.numberingRuleId && onFormDataChange && columnName) {
onFormDataChange(`${columnName}_numberingRuleId`, parsed.numberingRuleId);
console.log("🔧 채번 규칙 ID를 formData에 저장:", {
key: `${columnName}_numberingRuleId`,
value: parsed.numberingRuleId,
});
}
} catch {
// JSON 파싱 실패
@ -571,11 +567,6 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
// 채번 코드 생성 (formDataRef.current 사용하여 최신 formData 전달)
const currentFormData = formDataRef.current;
console.log("🔍 [V2Input] 채번 미리보기 호출:", {
numberingRuleId,
formDataKeys: Object.keys(currentFormData),
materialValue: currentFormData.material // 재질 값 로깅
});
const previewResponse = await previewNumberingCode(numberingRuleId, currentFormData);
if (previewResponse.success && previewResponse.data?.generatedCode) {
@ -655,11 +646,6 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
// formData에 직접 주입
if (event.detail?.formData && columnName) {
event.detail.formData[columnName] = currentValue;
console.log("🔧 [V2Input] beforeFormSave에서 채번 값 주입:", {
columnName,
manualInputValue,
currentValue,
});
}
};

View File

@ -758,16 +758,6 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
// 🔍 디버깅: 높이값 확인 (warn으로 변경하여 캡처되도록)
console.warn("🔍 [V2Select] 높이 디버깅:", {
id,
"size?.height": size?.height,
"style?.height": style?.height,
componentHeight,
size,
style,
});
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;

View File

@ -27,11 +27,7 @@ interface V2SelectConfigPanelProps {
inputType?: string;
}
export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
config,
onChange,
inputType,
}) => {
export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config, onChange, inputType }) => {
// 엔티티 타입인지 확인
const isEntityType = inputType === "entity";
// 엔티티 테이블의 컬럼 목록
@ -107,10 +103,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
{/* 선택 모드 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.mode || "dropdown"}
onValueChange={(value) => updateConfig("mode", value)}
>
<Select value={config.mode || "dropdown"} onValueChange={(value) => updateConfig("mode", value)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="모드 선택" />
</SelectTrigger>
@ -130,10 +123,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
{/* 데이터 소스 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.source || "static"}
onValueChange={(value) => updateConfig("source", value)}
>
<Select value={config.source || "static"} onValueChange={(value) => updateConfig("source", value)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 선택" />
</SelectTrigger>
@ -151,59 +141,51 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={addOption}
className="h-6 px-2 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
<Button type="button" variant="ghost" size="sm" onClick={addOption} className="h-6 px-2 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="space-y-2 max-h-40 overflow-y-auto">
<div className="max-h-40 space-y-2 overflow-y-auto">
{options.map((option: any, index: number) => (
<div key={index} className="flex items-center gap-2">
<Input
value={option.value || ""}
onChange={(e) => updateOption(index, "value", e.target.value)}
placeholder="값"
className="h-7 text-xs flex-1"
className="h-7 flex-1 text-xs"
/>
<Input
value={option.label || ""}
onChange={(e) => updateOption(index, "label", e.target.value)}
placeholder="표시 텍스트"
className="h-7 text-xs flex-1"
className="h-7 flex-1 text-xs"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeOption(index)}
className="h-7 w-7 p-0 text-destructive"
className="text-destructive h-7 w-7 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{options.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-2">
</p>
<p className="text-muted-foreground py-2 text-center text-xs"> </p>
)}
</div>
{/* 기본값 설정 */}
{options.length > 0 && (
<div className="mt-3 pt-2 border-t">
<div className="mt-3 border-t pt-2">
<Label className="text-xs font-medium"></Label>
<Select
value={config.defaultValue || "_none_"}
onValueChange={(value) => updateConfig("defaultValue", value === "_none_" ? "" : value)}
>
<SelectTrigger className="h-8 text-xs mt-1">
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="기본값 선택" />
</SelectTrigger>
<SelectContent>
@ -215,9 +197,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground mt-1">
</p>
<p className="text-muted-foreground mt-1 text-[10px]"> </p>
</div>
)}
</div>
@ -228,16 +208,13 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
<div className="space-y-1">
<Label className="text-xs font-medium"> </Label>
{config.codeGroup ? (
<p className="text-sm font-medium text-foreground">{config.codeGroup}</p>
<p className="text-foreground text-sm font-medium">{config.codeGroup}</p>
) : (
<p className="text-xs text-amber-600">
</p>
<p className="text-xs text-amber-600"> </p>
)}
</div>
)}
{/* 엔티티(참조 테이블) 설정 */}
{config.source === "entity" && (
<div className="space-y-3">
@ -248,16 +225,16 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
readOnly
disabled
placeholder="테이블 타입 관리에서 설정"
className="h-8 text-xs bg-muted"
className="bg-muted h-8 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
<p className="text-muted-foreground text-[10px]">
( )
</p>
</div>
{/* 컬럼 로딩 중 표시 */}
{loadingColumns && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
@ -291,7 +268,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
className="h-8 text-xs"
/>
)}
<p className="text-[10px] text-muted-foreground"> </p>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
@ -319,7 +296,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
className="h-8 text-xs"
/>
)}
<p className="text-[10px] text-muted-foreground"> </p>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
</div>
@ -344,7 +321,9 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
checked={config.multiple || false}
onCheckedChange={(checked) => updateConfig("multiple", checked)}
/>
<label htmlFor="multiple" className="text-xs"> </label>
<label htmlFor="multiple" className="text-xs">
</label>
</div>
<div className="flex items-center space-x-2">
@ -353,7 +332,9 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
checked={config.searchable || false}
onCheckedChange={(checked) => updateConfig("searchable", checked)}
/>
<label htmlFor="searchable" className="text-xs"> </label>
<label htmlFor="searchable" className="text-xs">
</label>
</div>
<div className="flex items-center space-x-2">
@ -362,7 +343,9 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
checked={config.allowClear !== false}
onCheckedChange={(checked) => updateConfig("allowClear", checked)}
/>
<label htmlFor="allowClear" className="text-xs"> </label>
<label htmlFor="allowClear" className="text-xs">
</label>
</div>
</div>

View File

@ -31,10 +31,7 @@ export class ComponentRegistry {
throw new Error(`컴포넌트 등록 실패 (${definition.id}): ${validation.errors.join(", ")}`);
}
// 중복 등록 체크
if (this.components.has(definition.id)) {
console.warn(`⚠️ 컴포넌트 중복 등록: ${definition.id} - 기존 정의를 덮어씁니다.`);
}
// 중복 등록 체크 (기존 정의를 덮어씀)
// 타임스탬프 추가
const enhancedDefinition = {
@ -64,7 +61,6 @@ export class ComponentRegistry {
static unregisterComponent(id: string): void {
const definition = this.components.get(id);
if (!definition) {
console.warn(`⚠️ 등록되지 않은 컴포넌트 해제 시도: ${id}`);
return;
}
@ -76,8 +72,6 @@ export class ComponentRegistry {
data: definition,
timestamp: new Date(),
});
console.log(`🗑️ 컴포넌트 해제: ${id}`);
}
/**
@ -355,7 +349,6 @@ export class ComponentRegistry {
},
force: async () => {
// hotReload 기능 비활성화 (불필요)
console.log("⚠️ 강제 Hot Reload는 더 이상 필요하지 않습니다");
},
},

View File

@ -654,11 +654,17 @@ export function RepeaterTable({
<thead className="sticky top-0 z-20 bg-gray-50">
<tr>
{/* 드래그 핸들 헤더 - 좌측 고정 */}
<th key="header-drag" className="sticky left-0 z-30 w-8 border-r border-b border-gray-200 bg-gray-50 px-1 py-2 text-center font-medium text-gray-700">
<th
key="header-drag"
className="sticky left-0 z-30 w-8 border-r border-b border-gray-200 bg-gray-50 px-1 py-2 text-center font-medium text-gray-700"
>
<span className="sr-only"></span>
</th>
{/* 체크박스 헤더 - 좌측 고정 */}
<th key="header-checkbox" className="sticky left-8 z-30 w-10 border-r border-b border-gray-200 bg-gray-50 px-3 py-2 text-center font-medium text-gray-700">
<th
key="header-checkbox"
className="sticky left-8 z-30 w-10 border-r border-b border-gray-200 bg-gray-50 px-3 py-2 text-center font-medium text-gray-700"
>
<Checkbox
checked={isAllSelected}
// @ts-expect-error - indeterminate는 HTML 속성

View File

@ -149,14 +149,11 @@ export function SimpleRepeaterTableComponent({
}
// API 호출
const response = await apiClient.post(
`/table-management/tables/${initialConfig.sourceTable}/data`,
{
const response = await apiClient.post(`/table-management/tables/${initialConfig.sourceTable}/data`, {
search: filters,
page: 1,
size: 1000, // 대량 조회
}
);
});
if (response.data.success && response.data.data?.data) {
const loadedData = response.data.data.data;
@ -182,7 +179,7 @@ export function SimpleRepeaterTableComponent({
// 2. 조인 데이터 처리
const joinColumns = columns.filter(
(col) => col.sourceConfig?.type === "join" && col.sourceConfig.joinTable && col.sourceConfig.joinKey
(col) => col.sourceConfig?.type === "join" && col.sourceConfig.joinTable && col.sourceConfig.joinKey,
);
if (joinColumns.length > 0) {
@ -208,25 +205,20 @@ export function SimpleRepeaterTableComponent({
const [tableName] = groupKey.split(":");
// 조인 키 값 수집 (중복 제거)
const keyValues = Array.from(new Set(
baseMappedData
.map((row: any) => row[key])
.filter((v: any) => v !== undefined && v !== null)
));
const keyValues = Array.from(
new Set(baseMappedData.map((row: any) => row[key]).filter((v: any) => v !== undefined && v !== null)),
);
if (keyValues.length === 0) return;
try {
// 조인 테이블 조회
// refKey(타겟 테이블 컬럼)로 검색
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
{
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
search: { [refKey]: keyValues }, // { id: [1, 2, 3] }
page: 1,
size: 1000,
}
);
});
if (response.data.success && response.data.data?.data) {
const joinedRows = response.data.data.data;
@ -251,7 +243,7 @@ export function SimpleRepeaterTableComponent({
console.error(`조인 실패 (${tableName}):`, error);
// 실패 시 무시하고 진행 (값은 undefined)
}
})
}),
);
}
@ -399,9 +391,12 @@ export function SimpleRepeaterTableComponent({
// 기존 onFormDataChange도 호출 (호환성)
if (onFormDataChange && columnName) {
// 테이블별 데이터를 통합하여 전달
onFormDataChange(columnName, Object.entries(dataByTable).flatMap(([table, rows]) =>
rows.map((row: any) => ({ ...row, _targetTable: table }))
));
onFormDataChange(
columnName,
Object.entries(dataByTable).flatMap(([table, rows]) =>
rows.map((row: any) => ({ ...row, _targetTable: table })),
),
);
}
};
@ -543,24 +538,14 @@ export function SimpleRepeaterTableComponent({
if (!allowAdd || readOnly || value.length >= maxRows) return null;
return (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddRow}
className="h-8 text-xs"
>
<Plus className="h-3.5 w-3.5 mr-1" />
<Button type="button" variant="outline" size="sm" onClick={handleAddRow} className="h-8 text-xs">
<Plus className="mr-1 h-3.5 w-3.5" />
{addButtonText}
</Button>
);
};
const renderCell = (
row: any,
column: SimpleRepeaterColumnConfig,
rowIndex: number
) => {
const renderCell = (row: any, column: SimpleRepeaterColumnConfig, rowIndex: number) => {
const cellValue = row[column.field];
// 계산 필드는 편집 불가
@ -583,9 +568,7 @@ export function SimpleRepeaterTableComponent({
<Input
type="number"
value={cellValue || ""}
onChange={(e) =>
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
}
onChange={(e) => handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)}
className="h-7 text-xs"
/>
);
@ -604,15 +587,15 @@ export function SimpleRepeaterTableComponent({
return (
<Select
value={cellValue || ""}
onValueChange={(newValue) =>
handleCellEdit(rowIndex, column.field, newValue)
}
onValueChange={(newValue) => handleCellEdit(rowIndex, column.field, newValue)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{column.selectOptions?.filter((option) => option.value && option.value !== "").map((option) => (
{column.selectOptions
?.filter((option) => option.value && option.value !== "")
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
@ -636,11 +619,11 @@ export function SimpleRepeaterTableComponent({
// 로딩 중일 때
if (isLoading) {
return (
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
<div className={cn("bg-background overflow-hidden rounded-md border", className)}>
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
<p className="text-sm text-muted-foreground"> ...</p>
<Loader2 className="text-primary mx-auto mb-2 h-8 w-8 animate-spin" />
<p className="text-muted-foreground text-sm"> ...</p>
</div>
</div>
</div>
@ -650,14 +633,14 @@ export function SimpleRepeaterTableComponent({
// 에러 발생 시
if (loadError) {
return (
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
<div className={cn("bg-background overflow-hidden rounded-md border", className)}>
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
<div className="text-center">
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-2">
<X className="h-6 w-6 text-destructive" />
<div className="bg-destructive/10 mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full">
<X className="text-destructive h-6 w-6" />
</div>
<p className="text-sm font-medium text-destructive mb-1"> </p>
<p className="text-xs text-muted-foreground">{loadError}</p>
<p className="text-destructive mb-1 text-sm font-medium"> </p>
<p className="text-muted-foreground text-xs">{loadError}</p>
</div>
</div>
</div>
@ -668,30 +651,27 @@ export function SimpleRepeaterTableComponent({
const totalColumns = columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0);
return (
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
<div className={cn("bg-background overflow-hidden rounded-md border", className)}>
{/* 상단 행 추가 버튼 */}
{allowAdd && addButtonPosition !== "bottom" && (
<div className="p-2 border-b bg-muted/50">
<div className="bg-muted/50 border-b p-2">
<AddRowButton />
</div>
)}
<div
className="overflow-x-auto overflow-y-auto"
style={{ maxHeight }}
>
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight }}>
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted sticky top-0 z-10">
<tr>
{showRowNumber && (
<th key="header-rownum" className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
<th key="header-rownum" className="text-muted-foreground w-12 px-4 py-2 text-left font-medium">
#
</th>
)}
{columns.map((col) => (
<th
key={`header-${col.field}`}
className="px-4 py-2 text-left font-medium text-muted-foreground"
className="text-muted-foreground px-4 py-2 text-left font-medium"
style={{ width: col.width }}
>
{col.label}
@ -699,7 +679,7 @@ export function SimpleRepeaterTableComponent({
</th>
))}
{!readOnly && allowDelete && (
<th key="header-delete" className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
<th key="header-delete" className="text-muted-foreground w-20 px-4 py-2 text-left font-medium">
</th>
)}
@ -708,11 +688,7 @@ export function SimpleRepeaterTableComponent({
<tbody className="bg-background">
{value.length === 0 ? (
<tr key="empty-row">
<td
key="empty-cell"
colSpan={totalColumns}
className="px-4 py-8 text-center text-muted-foreground"
>
<td key="empty-cell" colSpan={totalColumns} className="text-muted-foreground px-4 py-8 text-center">
{allowAdd ? (
<div className="flex flex-col items-center gap-2">
<span> </span>
@ -725,9 +701,9 @@ export function SimpleRepeaterTableComponent({
</tr>
) : (
value.map((row, rowIndex) => (
<tr key={`row-${rowIndex}`} className="border-t hover:bg-accent/50">
<tr key={`row-${rowIndex}`} className="hover:bg-accent/50 border-t">
{showRowNumber && (
<td key={`rownum-${rowIndex}`} className="px-4 py-2 text-center text-muted-foreground">
<td key={`rownum-${rowIndex}`} className="text-muted-foreground px-4 py-2 text-center">
{rowIndex + 1}
</td>
)}
@ -743,7 +719,7 @@ export function SimpleRepeaterTableComponent({
size="sm"
onClick={() => handleRowDelete(rowIndex)}
disabled={value.length <= minRows}
className="h-7 w-7 p-0 text-destructive hover:text-destructive disabled:opacity-50"
className="text-destructive hover:text-destructive h-7 w-7 p-0 disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
</Button>
@ -758,35 +734,29 @@ export function SimpleRepeaterTableComponent({
{/* 합계 표시 */}
{summaryConfig?.enabled && summaryValues && (
<div className={cn(
"border-t bg-muted/30 p-3",
summaryConfig.position === "bottom-right" && "flex justify-end"
)}>
<div className={cn(
summaryConfig.position === "bottom-right" ? "w-auto min-w-[200px]" : "w-full"
)}>
<div
className={cn("bg-muted/30 border-t p-3", summaryConfig.position === "bottom-right" && "flex justify-end")}
>
<div className={cn(summaryConfig.position === "bottom-right" ? "w-auto min-w-[200px]" : "w-full")}>
{summaryConfig.title && (
<div className="text-xs font-medium text-muted-foreground mb-2">
{summaryConfig.title}
</div>
<div className="text-muted-foreground mb-2 text-xs font-medium">{summaryConfig.title}</div>
)}
<div className={cn(
<div
className={cn(
"grid gap-2",
summaryConfig.position === "bottom-right" ? "grid-cols-1" : "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4"
)}>
summaryConfig.position === "bottom-right" ? "grid-cols-1" : "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4",
)}
>
{summaryConfig.fields.map((field) => (
<div
key={field.field}
className={cn(
"flex justify-between items-center px-3 py-1.5 rounded",
field.highlight ? "bg-primary/10 font-semibold" : "bg-background"
"flex items-center justify-between rounded px-3 py-1.5",
field.highlight ? "bg-primary/10 font-semibold" : "bg-background",
)}
>
<span className="text-xs text-muted-foreground">{field.label}</span>
<span className={cn(
"text-sm font-medium",
field.highlight && "text-primary"
)}>
<span className="text-muted-foreground text-xs">{field.label}</span>
<span className={cn("text-sm font-medium", field.highlight && "text-primary")}>
{formatSummaryValue(field, summaryValues[field.field] || 0)}
</span>
</div>
@ -798,10 +768,10 @@ export function SimpleRepeaterTableComponent({
{/* 하단 행 추가 버튼 */}
{allowAdd && addButtonPosition !== "top" && value.length > 0 && (
<div className="p-2 border-t bg-muted/50 flex justify-between items-center">
<div className="bg-muted/50 flex items-center justify-between border-t p-2">
<AddRowButton />
{maxRows !== Infinity && (
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
{value.length} / {maxRows}
</span>
)}
@ -810,4 +780,3 @@ export function SimpleRepeaterTableComponent({
</div>
);
}

View File

@ -1098,28 +1098,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const screenContextFormData = screenContext?.formData || {};
const propsFormData = formData || {};
// 🔧 디버그: formData 소스 확인
console.log("🔍 [v2-button-primary] formData 소스 확인:", {
propsFormDataKeys: Object.keys(propsFormData),
screenContextFormDataKeys: Object.keys(screenContextFormData),
propsHasCompanyImage: "company_image" in propsFormData,
propsHasCompanyLogo: "company_logo" in propsFormData,
screenHasCompanyImage: "company_image" in screenContextFormData,
screenHasCompanyLogo: "company_logo" in screenContextFormData,
});
// 병합: splitPanelParentData를 기본으로, props.formData, screenContext.formData 순으로 오버라이드
// (일반 폼 필드는 props.formData, RepeaterFieldGroup은 screenContext.formData에 있음)
let effectiveFormData = { ...propsFormData, ...screenContextFormData };
console.log("🔍 [v2-button-primary] effectiveFormData 병합 결과:", {
keys: Object.keys(effectiveFormData),
hasCompanyImage: "company_image" in effectiveFormData,
hasCompanyLogo: "company_logo" in effectiveFormData,
companyImageValue: effectiveFormData.company_image,
companyLogoValue: effectiveFormData.company_logo,
});
// 분할 패널 우측이고 formData가 비어있으면 splitPanelParentData 사용
if (splitPanelPosition === "right" && Object.keys(effectiveFormData).length === 0 && splitPanelParentData) {
effectiveFormData = { ...splitPanelParentData };
@ -1289,15 +1271,13 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 🔧 component.style에서 background/backgroundColor 충돌 방지 (width/height는 허용)
const userStyle = component.style
? Object.fromEntries(
Object.entries(component.style).filter(
([key]) => !["background", "backgroundColor"].includes(key),
),
Object.entries(component.style).filter(([key]) => !["background", "backgroundColor"].includes(key)),
)
: {};
// 🔧 사용자가 설정한 크기 우선 사용, 없으면 100%
const buttonWidth = component.size?.width ? `${component.size.width}px` : (style?.width || "100%");
const buttonHeight = component.size?.height ? `${component.size.height}px` : (style?.height || "100%");
const buttonWidth = component.size?.width ? `${component.size.width}px` : style?.width || "100%";
const buttonHeight = component.size?.height ? `${component.size.height}px` : style?.height || "100%";
const buttonElementStyle: React.CSSProperties = {
width: buttonWidth,

View File

@ -123,34 +123,16 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}, [isRecordMode, recordTableName, recordId, columnName]);
// 🔑 레코드별 고유 키 생성 (localStorage, 전역 상태용)
// 🆕 columnName을 포함하여 같은 화면의 여러 파일 업로드 컴포넌트 구분
const getUniqueKey = useCallback(() => {
if (isRecordMode && recordTableName && recordId) {
// 레코드 모드: 테이블명:레코드ID:컴포넌트ID 형태로 고유 키 생성
return `fileUpload_${recordTableName}_${recordId}_${component.id}`;
// 레코드 모드: 테이블명:레코드ID:컴포넌트ID:컬럼명 형태로 고유 키 생성
return `fileUpload_${recordTableName}_${recordId}_${component.id}_${columnName}`;
}
// 기본 모드: 컴포넌트 ID 사용
return `fileUpload_${component.id}`;
}, [isRecordMode, recordTableName, recordId, component.id]);
// 기본 모드: 컴포넌트 ID + 컬럼명 사용
return `fileUpload_${component.id}_${columnName}`;
}, [isRecordMode, recordTableName, recordId, component.id, columnName]);
// 🔍 디버깅: 레코드 모드 상태 로깅
useEffect(() => {
console.log("📎 [FileUploadComponent] 모드 확인:", {
isRecordMode,
recordTableName,
recordId,
columnName,
targetObjid: getRecordTargetObjid(),
uniqueKey: getUniqueKey(),
formDataKeys: formData ? Object.keys(formData) : [],
// 🔍 추가 디버깅: formData.id 확인 (수정 모드 판단에 사용됨)
"formData.id": formData?.id,
"formData.tableName": formData?.tableName,
"formData.image": formData?.image,
"component.tableName": component.tableName,
"component.columnName": component.columnName,
"component.id": component.id,
});
}, [isRecordMode, recordTableName, recordId, columnName, getRecordTargetObjid, getUniqueKey, formData, component.tableName, component.columnName, component.id]);
// 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드
const prevRecordIdRef = useRef<any>(null);
@ -160,19 +142,12 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const modeChanged = prevIsRecordModeRef.current !== null && prevIsRecordModeRef.current !== isRecordMode;
if (recordIdChanged || modeChanged) {
console.log("📎 [FileUploadComponent] 레코드 상태 변경 감지:", {
prevRecordId: prevRecordIdRef.current,
currentRecordId: recordId,
prevIsRecordMode: prevIsRecordModeRef.current,
currentIsRecordMode: isRecordMode,
});
prevRecordIdRef.current = recordId;
prevIsRecordModeRef.current = isRecordMode;
// 레코드 ID가 변경되거나 등록 모드(isRecordMode=false)로 전환되면 파일 목록 초기화
// 등록 모드에서는 항상 빈 상태로 시작해야 함
if (isRecordMode || !recordId) {
console.log("📎 [FileUploadComponent] 파일 목록 초기화 (새 레코드 또는 레코드 변경)");
setUploadedFiles([]);
setRepresentativeImageUrl(null);
}
@ -189,7 +164,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 등록 모드(새 레코드)인 경우 파일 복원 스킵 - 빈 상태 유지
if (!isRecordMode || !recordId) {
console.log("📎 [FileUploadComponent] 등록 모드: 파일 복원 스킵 (빈 상태 유지)");
return;
}
@ -200,13 +174,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
if (backupFiles) {
const parsedFiles = JSON.parse(backupFiles);
if (parsedFiles.length > 0) {
console.log("🚀 컴포넌트 마운트 시 파일 즉시 복원:", {
uniqueKey: backupKey,
componentId: component.id,
recordId: recordId,
restoredFiles: parsedFiles.length,
files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
});
setUploadedFiles(parsedFiles);
// 전역 상태에도 복원 (레코드별 고유 키 사용)
@ -224,26 +191,20 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행
// 🔑 수정 모드: formData[columnName]에 저장된 objid로 이미지 로드
// 이 로직은 isRecordMode와 상관없이 formData에 이미지 objid가 있으면 표시
useEffect(() => {
const imageObjid = formData?.[columnName];
// 🆕 formData 전체가 아닌 특정 컬럼 값만 의존하도록 수정 (다른 컴포넌트 영향 방지)
const imageObjidFromFormData = formData?.[columnName];
useEffect(() => {
// 이미지 objid가 있고, 숫자 문자열인 경우에만 처리
if (imageObjid && /^\d+$/.test(String(imageObjid))) {
console.log("🖼️ [FileUploadComponent] formData에서 이미지 objid 발견:", {
columnName,
imageObjid,
currentFilesCount: uploadedFiles.length,
});
if (imageObjidFromFormData && /^\d+$/.test(String(imageObjidFromFormData))) {
const objidStr = String(imageObjidFromFormData);
// 이미 같은 objid의 파일이 로드되어 있으면 스킵
const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === String(imageObjid));
const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === objidStr);
if (alreadyLoaded) {
console.log("🖼️ [FileUploadComponent] 이미 로드된 이미지, 스킵");
return;
}
const objidStr = String(imageObjid);
const previewUrl = `/api/files/preview/${objidStr}`;
// 🔑 실제 파일 정보 조회
@ -254,12 +215,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
if (fileInfoResponse.success && fileInfoResponse.data) {
const { realFileName, fileSize, fileExt, regdate, isRepresentative } = fileInfoResponse.data;
console.log("🖼️ [FileUploadComponent] 파일 정보 조회 성공:", {
objid: objidStr,
realFileName,
fileExt,
});
const fileInfo = {
objid: objidStr,
realFileName: realFileName,
@ -296,46 +251,39 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}
})();
}
}, [formData, columnName, uploadedFiles]);
}, [imageObjidFromFormData, columnName, component.id]); // 🆕 formData 대신 특정 컬럼 값만 의존
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
// 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
useEffect(() => {
const handleDesignModeFileChange = (event: CustomEvent) => {
console.log("🎯🎯🎯 FileUploadComponent 화면설계 모드 파일 변경 이벤트 수신:", {
eventComponentId: event.detail.componentId,
currentComponentId: component.id,
isMatch: event.detail.componentId === component.id,
filesCount: event.detail.files?.length || 0,
action: event.detail.action,
source: event.detail.source,
eventDetail: event.detail,
});
const eventColumnName = event.detail.eventColumnName || event.detail.columnName;
// 현재 컴포넌트와 일치하고 화면설계 모드에서 온 이벤트인 경우
if (event.detail.componentId === component.id && event.detail.source === "designMode") {
// 🆕 고유 키 또는 (컴포넌트ID + 컬럼명) 조합으로 체크
const isForThisComponent =
(event.detail.uniqueKey && event.detail.uniqueKey === currentUniqueKey) ||
(event.detail.componentId === component.id && eventColumnName === columnName) ||
(event.detail.componentId === component.id && !eventColumnName); // 이전 호환성
// 🆕 현재 컴포넌트와 일치하고 화면설계 모드에서 온 이벤트인 경우
if (isForThisComponent && event.detail.source === "designMode") {
// 파일 상태 업데이트
const newFiles = event.detail.files || [];
setUploadedFiles(newFiles);
// localStorage 백업 업데이트 (레코드별 고유 키 사용)
try {
const backupKey = getUniqueKey();
const backupKey = currentUniqueKey;
localStorage.setItem(backupKey, JSON.stringify(newFiles));
console.log("💾 화면설계 모드 동기화 후 localStorage 백업 업데이트:", {
uniqueKey: backupKey,
componentId: component.id,
recordId: recordId,
fileCount: newFiles.length,
});
} catch (e) {
console.warn("localStorage 백업 업데이트 실패:", e);
}
// 전역 상태 업데이트
// 전역 상태 업데이트 (🆕 고유 키 사용)
if (typeof window !== "undefined") {
(window as any).globalFileState = {
...(window as any).globalFileState,
[component.id]: newFiles,
[currentUniqueKey]: newFiles,
};
}
@ -346,11 +294,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
lastFileUpdate: event.detail.timestamp,
});
}
console.log("🎉🎉🎉 화면설계 모드 → 실제 화면 동기화 완료:", {
componentId: component.id,
finalFileCount: newFiles.length,
});
}
};
@ -369,25 +312,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 🔑 등록 모드(새 레코드)인 경우 파일 조회 스킵 - 빈 상태 유지
if (!isRecordMode || !recordId) {
console.log("📂 [FileUploadComponent] 등록 모드: 파일 조회 스킵 (빈 상태 유지)", {
isRecordMode,
recordId,
componentId: component.id,
});
return false;
}
try {
// 🔑 레코드 모드: 해당 행의 파일만 조회
if (isRecordMode && recordTableName && recordId) {
console.log("📂 [FileUploadComponent] 레코드 모드 파일 조회:", {
tableName: recordTableName,
recordId: recordId,
columnName: columnName,
targetObjid: getRecordTargetObjid(),
});
}
// 1. formData에서 screenId 가져오기
let screenId = formData?.screenId;
@ -424,8 +352,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
columnName: columnName, // 🔑 레코드 모드에서 사용하는 columnName
};
console.log("📂 [FileUploadComponent] 파일 조회 파라미터:", params);
const response = await getComponentFiles(params);
if (response.success) {
@ -457,12 +383,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const additionalFiles = parsedBackupFiles.filter((f: any) => !serverObjIds.has(f.objid));
finalFiles = [...formattedFiles, ...additionalFiles];
console.log("📂 [FileUploadComponent] 파일 병합 완료:", {
uniqueKey,
serverFiles: formattedFiles.length,
localFiles: parsedBackupFiles.length,
finalFiles: finalFiles.length,
});
}
} catch (e) {
console.warn("파일 병합 중 오류:", e);
@ -505,16 +425,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const componentFiles = (component as any)?.uploadedFiles || [];
const lastUpdate = (component as any)?.lastFileUpdate;
console.log("🔄 FileUploadComponent 파일 동기화 시작:", {
componentId: component.id,
componentFiles: componentFiles.length,
formData: formData,
screenId: formData?.screenId,
tableName: formData?.tableName, // 🔍 테이블명 확인
recordId: formData?.id, // 🔍 레코드 ID 확인
currentUploadedFiles: uploadedFiles.length,
});
// 🔒 항상 DB에서 최신 파일 목록을 조회 (멀티테넌시 격리)
loadComponentFiles().then((dbLoadSuccess) => {
if (dbLoadSuccess) {
@ -523,9 +433,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
// 전역 상태에서 최신 파일 정보 가져오기
// 전역 상태에서 최신 파일 정보 가져오기 (🆕 고유 키 사용)
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
const globalFiles = globalFileState[component.id] || [];
const uniqueKeyForFallback = getUniqueKey();
const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || [];
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
@ -540,36 +451,27 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}, [loadComponentFiles, component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate]);
// 전역 상태 변경 감지 (모든 파일 컴포넌트 동기화 + 화면 복원)
// 🆕 columnName을 포함한 고유 키로 구분하여 다른 파일 업로드 컴포넌트에 영향 방지
const currentUniqueKey = getUniqueKey();
useEffect(() => {
const handleGlobalFileStateChange = (event: CustomEvent) => {
const { componentId, files, fileCount, timestamp, isRestore } = event.detail;
const { componentId, files, fileCount, timestamp, isRestore, uniqueKey: eventUniqueKey, eventColumnName } = event.detail;
console.log("🔄 FileUploadComponent 전역 상태 변경 감지:", {
currentComponentId: component.id,
eventComponentId: componentId,
isForThisComponent: componentId === component.id,
newFileCount: fileCount,
currentFileCount: uploadedFiles.length,
timestamp,
isRestore: !!isRestore,
});
// 🆕 고유 키 또는 (컴포넌트ID + 컬럼명) 조합으로 체크
const isForThisComponent =
(eventUniqueKey && eventUniqueKey === currentUniqueKey) ||
(componentId === component.id && eventColumnName === columnName);
// 같은 컴포넌트 ID인 경우에만 업데이트
if (componentId === component.id) {
const logMessage = isRestore ? "🔄 화면 복원으로 파일 상태 동기화" : "✅ 파일 상태 동기화 적용";
console.log(logMessage, {
componentId: component.id,
이전파일수: uploadedFiles?.length || 0,
새파일수: files?.length || 0,
files: files?.map((f: any) => ({ objid: f.objid, name: f.realFileName })) || [],
});
// 🆕 같은 고유 키인 경우에만 업데이트 (componentId + columnName 조합)
if (isForThisComponent) {
setUploadedFiles(files);
setForceUpdate((prev) => prev + 1);
// localStorage 백업도 업데이트 (레코드별 고유 키 사용)
try {
const backupKey = getUniqueKey();
const backupKey = currentUniqueKey;
localStorage.setItem(backupKey, JSON.stringify(files));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
@ -584,7 +486,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
};
}
}, [component.id, uploadedFiles.length]);
}, [component.id, columnName, currentUniqueKey, uploadedFiles.length]);
// 파일 업로드 설정 - componentConfig가 undefined일 수 있으므로 안전하게 처리
const safeComponentConfig = componentConfig || {};
@ -598,18 +500,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 파일 선택 핸들러
const handleFileSelect = useCallback(() => {
console.log("🎯 handleFileSelect 호출됨:", {
hasFileInputRef: !!fileInputRef.current,
fileInputRef: fileInputRef.current,
fileInputType: fileInputRef.current?.type,
fileInputHidden: fileInputRef.current?.className,
});
if (fileInputRef.current) {
console.log("✅ fileInputRef.current.click() 호출");
fileInputRef.current.click();
} else {
console.log("❌ fileInputRef.current가 null입니다");
}
}, []);
@ -680,34 +572,17 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) {
// 🎯 레코드 모드: 특정 행에 파일 연결
targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`;
console.log("📁 [레코드 모드] 파일 업로드:", {
targetObjid,
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
});
} else if (screenId) {
// 🔑 템플릿 파일 (백엔드 조회 형식과 동일하게)
targetObjid = `screen_files:${screenId}:${component.id}:${effectiveColumnName}`;
console.log("📝 [템플릿 모드] 파일 업로드:", targetObjid);
} else {
// 기본값 (화면관리에서 사용)
targetObjid = `temp_${component.id}`;
console.log("📝 [기본 모드] 파일 업로드:", targetObjid);
}
// 🔒 현재 사용자의 회사 코드 가져오기 (멀티테넌시 격리)
const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode;
console.log("📤 [FileUploadComponent] 파일 업로드 준비:", {
userCompanyCode,
isRecordMode: effectiveIsRecordMode,
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid,
});
// 🔑 레코드 모드일 때는 effectiveTableName을 우선 사용
// formData.linkedTable이 'screen_files' 같은 기본값일 수 있으므로 레코드 모드에서는 무시
const finalLinkedTable = effectiveIsRecordMode
@ -732,27 +607,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
isRecordMode: effectiveIsRecordMode,
};
console.log("📤 [FileUploadComponent] uploadData 최종:", {
isRecordMode: effectiveIsRecordMode,
linkedTable: finalLinkedTable,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid,
});
console.log("🚀 [FileUploadComponent] uploadFiles API 호출 직전:", {
filesCount: filesToUpload.length,
uploadData,
});
const response = await uploadFiles({
files: filesToUpload,
...uploadData,
});
console.log("📥 [FileUploadComponent] uploadFiles API 응답:", response);
if (response.success) {
// FileUploadResponse 타입에 맞게 files 배열 사용
const fileData = response.files || (response as any).data || [];
@ -811,9 +670,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
});
// 모든 파일 컴포넌트에 동기화 이벤트 발생
// 🆕 columnName 추가하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
const syncEvent = new CustomEvent("globalFileStateChanged", {
detail: {
componentId: component.id,
eventColumnName: columnName, // 🆕 컬럼명 추가
uniqueKey: uniqueKey, // 🆕 고유 키 추가
recordId: recordId, // 🆕 레코드 ID 추가
files: updatedFiles,
@ -822,25 +683,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
},
});
window.dispatchEvent(syncEvent);
console.log("🌐 전역 파일 상태 업데이트 및 동기화 이벤트 발생:", {
componentId: component.id,
fileCount: updatedFiles.length,
globalState: Object.keys(globalFileState).map((id) => ({
id,
fileCount: globalFileState[id]?.length || 0,
})),
});
}
// 컴포넌트 업데이트
if (onUpdate) {
const timestamp = Date.now();
console.log("🔄 onUpdate 호출:", {
componentId: component.id,
uploadedFiles: updatedFiles.length,
timestamp: timestamp,
});
onUpdate({
uploadedFiles: updatedFiles,
lastFileUpdate: timestamp,
@ -858,15 +705,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
? fileObjids.join(',') // 복수 파일: 콤마 구분
: (fileObjids[0] || ''); // 단일 파일: 첫 번째 파일 ID
console.log("📎 [파일 업로드] 컬럼 데이터 동기화:", {
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
columnValue,
fileCount: updatedFiles.length,
isMultiple: fileConfig.multiple,
});
// onFormDataChange를 (fieldName, value) 형태로 호출 (SaveModal 호환)
onFormDataChange(effectiveColumnName, columnValue);
}
@ -883,13 +721,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
},
});
window.dispatchEvent(refreshEvent);
console.log("🔄 그리드 파일 상태 새로고침 이벤트 발생:", {
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid,
fileCount: updatedFiles.length,
});
}
// 컴포넌트 설정 콜백
@ -972,9 +803,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
(window as any).globalFileState = globalFileState;
// 모든 파일 컴포넌트에 동기화 이벤트 발생
// 🆕 columnName 추가하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
const syncEvent = new CustomEvent("globalFileStateChanged", {
detail: {
componentId: component.id,
eventColumnName: columnName, // 🆕 컬럼명 추가
uniqueKey: uniqueKey, // 🆕 고유 키 추가
recordId: recordId, // 🆕 레코드 ID 추가
files: updatedFiles,
@ -985,12 +818,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
},
});
window.dispatchEvent(syncEvent);
console.log("🗑️ 파일 삭제 후 전역 상태 동기화:", {
componentId: component.id,
deletedFile: fileName,
remainingFiles: updatedFiles.length,
});
}
// 컴포넌트 업데이트
@ -1010,14 +837,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
? fileObjids.join(',')
: (fileObjids[0] || '');
console.log("📎 [파일 삭제] 컬럼 데이터 동기화:", {
tableName: recordTableName,
recordId: recordId,
columnName: columnName,
columnValue,
remainingFiles: updatedFiles.length,
});
// onFormDataChange를 (fieldName, value) 형태로 호출 (SaveModal 호환)
onFormDataChange(columnName, columnValue);
}
@ -1053,16 +872,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 🔑 이미 previewUrl이 설정된 경우 바로 사용 (API 호출 스킵)
if (file.previewUrl) {
console.log("🖼️ 대표 이미지: previewUrl 사용:", file.previewUrl);
setRepresentativeImageUrl(file.previewUrl);
return;
}
console.log("🖼️ 대표 이미지 로드 시작:", {
objid: file.objid,
fileName: file.realFileName,
});
// API 클라이언트를 통해 Blob으로 다운로드 (인증 토큰 포함)
// 🔑 download 대신 preview 사용 (공개 접근)
const response = await apiClient.get(`/files/preview/${file.objid}`, {
@ -1082,7 +895,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}
setRepresentativeImageUrl(url);
console.log("✅ 대표 이미지 로드 성공:", url);
} catch (error: any) {
console.error("❌ 대표 이미지 로드 실패:", {
file: file.realFileName,
@ -1113,12 +925,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 대표 이미지 로드
loadRepresentativeImage(file);
console.log("✅ 대표 파일 설정 완료:", {
componentId: component.id,
representativeFile: file.realFileName,
objid: file.objid,
});
} catch (e) {
console.error("❌ 대표 파일 설정 실패:", e);
}
@ -1146,22 +952,13 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 드래그 앤 드롭 핸들러
const handleDragOver = useCallback(
(e: React.DragEvent) => {
console.log("🎯 드래그 오버 이벤트 감지:", {
readonly: safeComponentConfig.readonly,
disabled: safeComponentConfig.disabled,
dragOver: dragOver,
});
e.preventDefault();
e.stopPropagation();
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
setDragOver(true);
console.log("✅ 드래그 오버 활성화");
} else {
console.log("❌ 드래그 차단됨: readonly 또는 disabled");
}
},
[safeComponentConfig.readonly, safeComponentConfig.disabled, dragOver],
[safeComponentConfig.readonly, safeComponentConfig.disabled],
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
@ -1189,19 +986,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 클릭 핸들러
const handleClick = useCallback(
(e: React.MouseEvent) => {
console.log("🖱️ 파일 업로드 영역 클릭:", {
readonly: safeComponentConfig.readonly,
disabled: safeComponentConfig.disabled,
hasHandleFileSelect: !!handleFileSelect,
});
e.preventDefault();
e.stopPropagation();
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
console.log("✅ 파일 선택 함수 호출");
handleFileSelect();
} else {
console.log("❌ 클릭 차단됨: readonly 또는 disabled");
}
onClick?.();
},

View File

@ -25,7 +25,13 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
let currentValue = formData?.[columnName] ?? component.value ?? "";
// 🆕 formData에 값이 없고 기본값이 설정된 경우, 기본값 적용
if ((currentValue === "" || currentValue === undefined || currentValue === null) && defaultValue && isInteractive && onFormDataChange && columnName) {
if (
(currentValue === "" || currentValue === undefined || currentValue === null) &&
defaultValue &&
isInteractive &&
onFormDataChange &&
columnName
) {
// 초기 렌더링 시 기본값을 formData에 설정
setTimeout(() => {
if (!formData?.[columnName]) {

View File

@ -1033,7 +1033,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// localStorage에 저장된 정렬이 없으면 defaultSort 설정 적용
if (tableConfig.defaultSort?.columnName) {
console.log("📊 기본 정렬 설정 적용:", tableConfig.defaultSort);
setSortColumn(tableConfig.defaultSort.columnName);
setSortDirection(tableConfig.defaultSort.direction || "asc");
hasInitializedSort.current = true;
@ -1139,16 +1138,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
}
// 🔍 디버깅: 캐시 사용 시 로그
console.log("📊 [TableListComponent] 캐시에서 inputTypes 로드:", {
tableName: tableConfig.selectedTable,
cacheKey: cacheKey,
hasInputTypes: !!cached.inputTypes,
inputTypesLength: cached.inputTypes?.length || 0,
imageInputType: inputTypeMap["image"],
cacheAge: Date.now() - cached.timestamp,
});
cached.columns.forEach((col: any) => {
labels[col.columnName] = col.displayName || col.comment || col.columnName;
meta[col.columnName] = {
@ -1172,14 +1161,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
inputTypeMap[col.columnName] = col.inputType;
});
// 🔍 디버깅: inputTypes 확인
console.log("📊 [TableListComponent] inputTypes 조회 결과:", {
tableName: tableConfig.selectedTable,
inputTypes: inputTypes,
inputTypeMap: inputTypeMap,
imageColumn: inputTypes.find((col: any) => col.columnName === "image"),
});
tableColumnCache.set(cacheKey, {
columns,
inputTypes,
@ -4079,17 +4060,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
const inputType = meta?.inputType || column.inputType;
// 🔍 디버깅: image 컬럼인 경우 로그 출력
if (column.columnName === "image") {
console.log("🖼️ [formatCellValue] image 컬럼 처리:", {
columnName: column.columnName,
value: value,
meta: meta,
inputType: inputType,
columnInputType: column.inputType,
});
}
// 🖼️ 이미지 타입: 작은 썸네일 표시
if (inputType === "image" && value) {
// value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용

View File

@ -5,32 +5,10 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
@ -52,10 +30,7 @@ interface ColumnInfo {
displayName: string;
}
export function TimelineSchedulerConfigPanel({
config,
onChange,
}: TimelineSchedulerConfigPanelProps) {
export function TimelineSchedulerConfigPanel({ config, onChange }: TimelineSchedulerConfigPanelProps) {
const [tables, setTables] = useState<TableInfo[]>([]);
const [sourceColumns, setSourceColumns] = useState<ColumnInfo[]>([]);
const [resourceColumns, setResourceColumns] = useState<ColumnInfo[]>([]);
@ -74,7 +49,7 @@ export function TimelineSchedulerConfigPanel({
tableList.map((t: any) => ({
tableName: t.table_name || t.tableName,
displayName: t.display_name || t.displayName || t.table_name || t.tableName,
}))
})),
);
}
} catch (err) {
@ -100,7 +75,7 @@ export function TimelineSchedulerConfigPanel({
columns.map((col: any) => ({
columnName: col.column_name || col.columnName,
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
}))
})),
);
}
} catch (err) {
@ -125,7 +100,7 @@ export function TimelineSchedulerConfigPanel({
columns.map((col: any) => ({
columnName: col.column_name || col.columnName,
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
}))
})),
);
}
} catch (err) {
@ -168,11 +143,9 @@ export function TimelineSchedulerConfigPanel({
<Accordion type="multiple" defaultValue={["source", "resource", "display"]}>
{/* 소스 데이터 설정 (스케줄 생성 기준) */}
<AccordionItem value="source">
<AccordionTrigger className="text-sm font-medium">
</AccordionTrigger>
<AccordionTrigger className="text-sm font-medium"> </AccordionTrigger>
<AccordionContent className="space-y-3 pt-2">
<p className="text-[10px] text-muted-foreground mb-2">
<p className="text-muted-foreground mb-2 text-[10px]">
(저장: schedule_mng)
</p>
@ -208,20 +181,14 @@ export function TimelineSchedulerConfigPanel({
className="h-8 w-full justify-between text-xs"
disabled={loading}
>
{config.sourceConfig?.tableName ? (
tables.find((t) => t.tableName === config.sourceConfig?.tableName)
?.displayName || config.sourceConfig.tableName
) : (
"소스 테이블 선택..."
)}
{config.sourceConfig?.tableName
? tables.find((t) => t.tableName === config.sourceConfig?.tableName)?.displayName ||
config.sourceConfig.tableName
: "소스 테이블 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</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
filter={(value, search) => {
const lowerSearch = search.toLowerCase();
@ -233,9 +200,7 @@ export function TimelineSchedulerConfigPanel({
>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs">
.
</CommandEmpty>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
@ -250,16 +215,12 @@ export function TimelineSchedulerConfigPanel({
<Check
className={cn(
"mr-2 h-3 w-3",
config.sourceConfig?.tableName === table.tableName
? "opacity-100"
: "opacity-0"
config.sourceConfig?.tableName === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
<span className="text-[10px] text-muted-foreground">
{table.tableName}
</span>
<span className="text-muted-foreground text-[10px]">{table.tableName}</span>
</div>
</CommandItem>
))}
@ -272,11 +233,11 @@ export function TimelineSchedulerConfigPanel({
{/* 소스 필드 매핑 */}
{config.sourceConfig?.tableName && (
<div className="space-y-2 mt-2">
<div className="mt-2 space-y-2">
<Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2">
{/* 기준일 필드 */}
<div className="space-y-1 col-span-2">
<div className="col-span-2 space-y-1">
<Label className="text-[10px]"> (/) *</Label>
<Select
value={config.sourceConfig?.dueDateField || ""}
@ -293,9 +254,7 @@ export function TimelineSchedulerConfigPanel({
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
</p>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
{/* 수량 필드 */}
@ -339,7 +298,7 @@ export function TimelineSchedulerConfigPanel({
</div>
{/* 그룹명 필드 */}
<div className="space-y-1 col-span-2">
<div className="col-span-2 space-y-1">
<Label className="text-[10px]"> ()</Label>
<Select
value={config.sourceConfig?.groupNameField || ""}
@ -365,21 +324,14 @@ export function TimelineSchedulerConfigPanel({
{/* 리소스 설정 */}
<AccordionItem value="resource">
<AccordionTrigger className="text-sm font-medium">
(/)
</AccordionTrigger>
<AccordionTrigger className="text-sm font-medium"> (/)</AccordionTrigger>
<AccordionContent className="space-y-3 pt-2">
<p className="text-[10px] text-muted-foreground mb-2">
Y축에 (, )
</p>
<p className="text-muted-foreground mb-2 text-[10px]"> Y축에 (, )</p>
{/* 리소스 테이블 선택 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover
open={resourceTableSelectOpen}
onOpenChange={setResourceTableSelectOpen}
>
<Popover open={resourceTableSelectOpen} onOpenChange={setResourceTableSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
@ -388,20 +340,13 @@ export function TimelineSchedulerConfigPanel({
className="h-8 w-full justify-between text-xs"
disabled={loading}
>
{config.resourceTable ? (
tables.find((t) => t.tableName === config.resourceTable)
?.displayName || config.resourceTable
) : (
"리소스 테이블 선택..."
)}
{config.resourceTable
? tables.find((t) => t.tableName === config.resourceTable)?.displayName || config.resourceTable
: "리소스 테이블 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</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
filter={(value, search) => {
const lowerSearch = search.toLowerCase();
@ -413,9 +358,7 @@ export function TimelineSchedulerConfigPanel({
>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs">
.
</CommandEmpty>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
@ -430,16 +373,12 @@ export function TimelineSchedulerConfigPanel({
<Check
className={cn(
"mr-2 h-3 w-3",
config.resourceTable === table.tableName
? "opacity-100"
: "opacity-0"
config.resourceTable === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
<span className="text-[10px] text-muted-foreground">
{table.tableName}
</span>
<span className="text-muted-foreground text-[10px]">{table.tableName}</span>
</div>
</CommandItem>
))}
@ -452,7 +391,7 @@ export function TimelineSchedulerConfigPanel({
{/* 리소스 필드 매핑 */}
{config.resourceTable && (
<div className="space-y-2 mt-2">
<div className="mt-2 space-y-2">
<Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2">
{/* ID 필드 */}
@ -502,18 +441,14 @@ export function TimelineSchedulerConfigPanel({
{/* 표시 설정 */}
<AccordionItem value="display">
<AccordionTrigger className="text-sm font-medium">
</AccordionTrigger>
<AccordionTrigger className="text-sm font-medium"> </AccordionTrigger>
<AccordionContent className="space-y-3 pt-2">
{/* 기본 줌 레벨 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.defaultZoomLevel || "day"}
onValueChange={(v) =>
updateConfig({ defaultZoomLevel: v as any })
}
onValueChange={(v) => updateConfig({ defaultZoomLevel: v as any })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
@ -534,9 +469,7 @@ export function TimelineSchedulerConfigPanel({
<Input
type="number"
value={config.height || 500}
onChange={(e) =>
updateConfig({ height: parseInt(e.target.value) || 500 })
}
onChange={(e) => updateConfig({ height: parseInt(e.target.value) || 500 })}
className="h-8 text-xs"
/>
</div>
@ -547,9 +480,7 @@ export function TimelineSchedulerConfigPanel({
<Input
type="number"
value={config.rowHeight || 50}
onChange={(e) =>
updateConfig({ rowHeight: parseInt(e.target.value) || 50 })
}
onChange={(e) => updateConfig({ rowHeight: parseInt(e.target.value) || 50 })}
className="h-8 text-xs"
/>
</div>
@ -558,26 +489,17 @@ export function TimelineSchedulerConfigPanel({
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.editable ?? true}
onCheckedChange={(v) => updateConfig({ editable: v })}
/>
<Switch checked={config.editable ?? true} onCheckedChange={(v) => updateConfig({ editable: v })} />
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.draggable ?? true}
onCheckedChange={(v) => updateConfig({ draggable: v })}
/>
<Switch checked={config.draggable ?? true} onCheckedChange={(v) => updateConfig({ draggable: v })} />
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"></Label>
<Switch
checked={config.resizable ?? true}
onCheckedChange={(v) => updateConfig({ resizable: v })}
/>
<Switch checked={config.resizable ?? true} onCheckedChange={(v) => updateConfig({ resizable: v })} />
</div>
<div className="flex items-center justify-between">

View File

@ -3,13 +3,7 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { apiClient } from "@/lib/api/client";
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
import {
TimelineSchedulerConfig,
ScheduleItem,
Resource,
ZoomLevel,
UseTimelineDataResult,
} from "../types";
import { TimelineSchedulerConfig, ScheduleItem, Resource, ZoomLevel, UseTimelineDataResult } from "../types";
import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config";
// schedule_mng 테이블 고정 (공통 스케줄 테이블)
@ -37,16 +31,14 @@ const addDays = (date: Date, days: number): Date => {
export function useTimelineData(
config: TimelineSchedulerConfig,
externalSchedules?: ScheduleItem[],
externalResources?: Resource[]
externalResources?: Resource[],
): UseTimelineDataResult {
// 상태
const [schedules, setSchedules] = useState<ScheduleItem[]>([]);
const [resources, setResources] = useState<Resource[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [zoomLevel, setZoomLevel] = useState<ZoomLevel>(
config.defaultZoomLevel || "day"
);
const [zoomLevel, setZoomLevel] = useState<ZoomLevel>(config.defaultZoomLevel || "day");
const [viewStartDate, setViewStartDate] = useState<Date>(() => {
if (config.initialDate) {
return new Date(config.initialDate);
@ -69,9 +61,7 @@ export function useTimelineData(
}, [viewStartDate, zoomLevel]);
// 테이블명: 기본적으로 schedule_mng 사용, 커스텀 테이블 설정 시 해당 테이블 사용
const tableName = config.useCustomTable && config.customTableName
? config.customTableName
: SCHEDULE_TABLE;
const tableName = config.useCustomTable && config.customTableName ? config.customTableName : SCHEDULE_TABLE;
const resourceTableName = config.resourceTable;
@ -134,17 +124,13 @@ export function useTimelineData(
sourceKeys: currentSourceKeys,
});
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
{
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
page: 1,
size: 10000,
autoFilter: true,
}
);
});
const responseData =
response.data?.data?.data || response.data?.data || [];
const responseData = response.data?.data?.data || response.data?.data || [];
let rawData = Array.isArray(responseData) ? responseData : [];
// 클라이언트 측 필터링 적용 (schedule_mng 테이블인 경우)
@ -156,9 +142,7 @@ export function useTimelineData(
// 선택된 품목 필터 (source_group_key 기준)
if (currentSourceKeys.length > 0) {
rawData = rawData.filter((row: any) =>
currentSourceKeys.includes(row.source_group_key)
);
rawData = rawData.filter((row: any) => currentSourceKeys.includes(row.source_group_key));
}
console.log("[useTimelineData] 필터링 후 스케줄:", rawData.length, "건");
@ -194,9 +178,7 @@ export function useTimelineData(
title: String(row[effectiveMapping.title] || ""),
startDate: row[effectiveMapping.startDate] || "",
endDate: row[effectiveMapping.endDate] || "",
status: effectiveMapping.status
? row[effectiveMapping.status] || "planned"
: "planned",
status: effectiveMapping.status ? row[effectiveMapping.status] || "planned" : "planned",
progress,
color: fieldMapping.color ? row[fieldMapping.color] : undefined,
data: row,
@ -228,26 +210,20 @@ export function useTimelineData(
}
try {
const response = await apiClient.post(
`/table-management/tables/${resourceTableName}/data`,
{
const response = await apiClient.post(`/table-management/tables/${resourceTableName}/data`, {
page: 1,
size: 1000,
autoFilter: true,
}
);
});
const responseData =
response.data?.data?.data || response.data?.data || [];
const responseData = response.data?.data?.data || response.data?.data || [];
const rawData = Array.isArray(responseData) ? responseData : [];
// 데이터를 Resource 형태로 변환
const mappedResources: Resource[] = rawData.map((row: any) => ({
id: String(row[resourceFieldMapping.id] || ""),
name: String(row[resourceFieldMapping.name] || ""),
group: resourceFieldMapping.group
? row[resourceFieldMapping.group]
: undefined,
group: resourceFieldMapping.group ? row[resourceFieldMapping.group] : undefined,
}));
setResources(mappedResources);
@ -270,9 +246,7 @@ export function useTimelineData(
// 이벤트 버스 리스너 - 테이블 선택 변경 (품목 선택 시 해당 스케줄만 표시)
useEffect(() => {
const unsubscribeSelection = v2EventBus.subscribe(
V2_EVENTS.TABLE_SELECTION_CHANGE,
(payload) => {
const unsubscribeSelection = v2EventBus.subscribe(V2_EVENTS.TABLE_SELECTION_CHANGE, (payload) => {
console.log("[useTimelineData] TABLE_SELECTION_CHANGE 수신:", {
tableName: payload.tableName,
selectedCount: payload.selectedCount,
@ -306,8 +280,7 @@ export function useTimelineData(
// 상태 업데이트 및 ref 동기화
selectedSourceKeysRef.current = sourceKeys;
setSelectedSourceKeys(sourceKeys);
}
);
});
return () => {
unsubscribeSelection();
@ -325,27 +298,21 @@ export function useTimelineData(
// 이벤트 버스 리스너 - 스케줄 생성 완료 및 테이블 새로고침
useEffect(() => {
// TABLE_REFRESH 이벤트 수신 - 스케줄 새로고침
const unsubscribeRefresh = v2EventBus.subscribe(
V2_EVENTS.TABLE_REFRESH,
(payload) => {
const unsubscribeRefresh = v2EventBus.subscribe(V2_EVENTS.TABLE_REFRESH, (payload) => {
// schedule_mng 또는 해당 테이블에 대한 새로고침
if (payload.tableName === tableName || payload.tableName === SCHEDULE_TABLE) {
console.log("[useTimelineData] TABLE_REFRESH 수신, 스케줄 새로고침:", payload);
fetchSchedules();
}
}
);
});
// SCHEDULE_GENERATE_COMPLETE 이벤트 수신 - 스케줄 자동 생성 완료 시 새로고침
const unsubscribeComplete = v2EventBus.subscribe(
V2_EVENTS.SCHEDULE_GENERATE_COMPLETE,
(payload) => {
const unsubscribeComplete = v2EventBus.subscribe(V2_EVENTS.SCHEDULE_GENERATE_COMPLETE, (payload) => {
if (payload.success) {
console.log("[useTimelineData] SCHEDULE_GENERATE_COMPLETE 수신, 스케줄 새로고침:", payload);
fetchSchedules();
}
}
);
});
return () => {
unsubscribeRefresh();
@ -390,23 +357,20 @@ export function useTimelineData(
if (updates.endDate) updateData[fieldMapping.endDate] = updates.endDate;
if (updates.resourceId) updateData[fieldMapping.resourceId] = updates.resourceId;
if (updates.title) updateData[fieldMapping.title] = updates.title;
if (updates.status && fieldMapping.status)
updateData[fieldMapping.status] = updates.status;
if (updates.status && fieldMapping.status) updateData[fieldMapping.status] = updates.status;
if (updates.progress !== undefined && fieldMapping.progress)
updateData[fieldMapping.progress] = updates.progress;
await apiClient.put(`/table-management/tables/${tableName}/data/${id}`, updateData);
// 로컬 상태 업데이트
setSchedules((prev) =>
prev.map((s) => (s.id === id ? { ...s, ...updates } : s))
);
setSchedules((prev) => prev.map((s) => (s.id === id ? { ...s, ...updates } : s)));
} catch (err: any) {
console.error("스케줄 업데이트 오류:", err);
throw err;
}
},
[tableName, fieldMapping, config.editable]
[tableName, fieldMapping, config.editable],
);
// 스케줄 추가
@ -427,10 +391,7 @@ export function useTimelineData(
if (fieldMapping.progress && schedule.progress !== undefined)
insertData[fieldMapping.progress] = schedule.progress;
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
insertData
);
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, insertData);
const newId = response.data?.data?.id || Date.now().toString();
@ -441,7 +402,7 @@ export function useTimelineData(
throw err;
}
},
[tableName, fieldMapping, config.editable]
[tableName, fieldMapping, config.editable],
);
// 스케줄 삭제
@ -459,7 +420,7 @@ export function useTimelineData(
throw err;
}
},
[tableName, config.editable]
[tableName, config.editable],
);
// 새로고침

View File

@ -10,12 +10,7 @@ export type ZoomLevel = "day" | "week" | "month";
/**
*
*/
export type ScheduleStatus =
| "planned"
| "in_progress"
| "completed"
| "delayed"
| "cancelled";
export type ScheduleStatus = "planned" | "in_progress" | "completed" | "delayed" | "cancelled";
/**
* ( )

View File

@ -49,7 +49,7 @@ function applyDefaultsToNestedComponents(components: any[]): any[] {
}
// 결과 객체 초기화 (원본 복사)
let result = { ...nestedComp };
const result = { ...nestedComp };
// 🆕 탭 위젯인 경우 재귀적으로 탭 내부 컴포넌트도 처리
if (nestedComponentType === "v2-tabs-widget") {
@ -75,14 +75,18 @@ function applyDefaultsToNestedComponents(components: any[]): any[] {
const config = result.componentConfig || {};
result.componentConfig = {
...config,
leftPanel: config.leftPanel ? {
leftPanel: config.leftPanel
? {
...config.leftPanel,
components: applyDefaultsToNestedComponents(config.leftPanel.components || []),
} : config.leftPanel,
rightPanel: config.rightPanel ? {
}
: config.leftPanel,
rightPanel: config.rightPanel
? {
...config.rightPanel,
components: applyDefaultsToNestedComponents(config.rightPanel.components || []),
} : config.rightPanel,
}
: config.rightPanel,
};
}

View File

@ -10,11 +10,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { v2EventBus } from "../events/EventBus";
import { V2_EVENTS } from "../events/types";
import type {
ScheduleType,
V2ScheduleGenerateRequestEvent,
V2ScheduleGenerateApplyEvent,
} from "../events/types";
import type { ScheduleType, V2ScheduleGenerateRequestEvent, V2ScheduleGenerateApplyEvent } from "../events/types";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
@ -122,13 +118,10 @@ function getDefaultPeriod(): { start: string; end: string } {
* const { showConfirmDialog, previewResult, handleConfirm } = useScheduleGenerator(config);
* ```
*/
export function useScheduleGenerator(
scheduleConfig?: ScheduleGenerationConfig | null
): UseScheduleGeneratorReturn {
export function useScheduleGenerator(scheduleConfig?: ScheduleGenerationConfig | null): UseScheduleGeneratorReturn {
// 상태
const [selectedData, setSelectedData] = useState<any[]>([]);
const [previewResult, setPreviewResult] =
useState<SchedulePreviewResult | null>(null);
const [previewResult, setPreviewResult] = useState<SchedulePreviewResult | null>(null);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const currentRequestIdRef = useRef<string>("");
@ -136,9 +129,7 @@ export function useScheduleGenerator(
// 1. 테이블 선택 데이터 추적 (TABLE_SELECTION_CHANGE 이벤트 수신)
useEffect(() => {
const unsubscribe = v2EventBus.subscribe(
V2_EVENTS.TABLE_SELECTION_CHANGE,
(payload) => {
const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_SELECTION_CHANGE, (payload) => {
// scheduleConfig가 있으면 해당 테이블만, 없으면 모든 테이블의 선택 데이터 저장
if (scheduleConfig?.source?.tableName) {
if (payload.tableName === scheduleConfig.source.tableName) {
@ -150,22 +141,20 @@ export function useScheduleGenerator(
setSelectedData(payload.selectedRows);
console.log("[useScheduleGenerator] 선택 데이터 업데이트 (모든 테이블):", payload.selectedCount, "건");
}
}
);
});
return unsubscribe;
}, [scheduleConfig?.source?.tableName]);
// 2. 스케줄 생성 요청 처리 (SCHEDULE_GENERATE_REQUEST 수신)
useEffect(() => {
console.log("[useScheduleGenerator] 이벤트 구독 시작");
const unsubscribe = v2EventBus.subscribe(
V2_EVENTS.SCHEDULE_GENERATE_REQUEST,
async (payload: V2ScheduleGenerateRequestEvent) => {
console.log("[useScheduleGenerator] SCHEDULE_GENERATE_REQUEST 수신:", payload);
// 이벤트에서 config가 오면 사용, 없으면 기존 scheduleConfig 또는 기본 config 사용
const configToUse = (payload as any).config || scheduleConfig || {
const configToUse = (payload as any).config ||
scheduleConfig || {
// 기본 설정 (생산계획 화면용)
scheduleType: payload.scheduleType || "PRODUCTION",
source: {
@ -250,7 +239,7 @@ export function useScheduleGenerator(
} finally {
setIsLoading(false);
}
}
},
);
return unsubscribe;
}, [selectedData, scheduleConfig]);
@ -299,10 +288,9 @@ export function useScheduleGenerator(
tableName: configToUse?.target?.tableName || "schedule_mng",
});
toast.success(
`${response.data.applied?.created || 0}건의 스케줄이 생성되었습니다.`,
{ id: "schedule-apply" }
);
toast.success(`${response.data.applied?.created || 0}건의 스케줄이 생성되었습니다.`, {
id: "schedule-apply",
});
setShowConfirmDialog(false);
setPreviewResult(null);
} catch (error: any) {
@ -311,7 +299,7 @@ export function useScheduleGenerator(
} finally {
setIsLoading(false);
}
}
},
);
return unsubscribe;
}, [previewResult, scheduleConfig]);