jskim-node #390

Merged
kjs merged 11 commits from jskim-node into main 2026-02-23 12:17:52 +09:00
22 changed files with 1561 additions and 1258 deletions

View File

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

View File

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

View File

@ -5177,8 +5177,18 @@ export class ScreenManagementService {
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다."); throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
} }
// 화면의 기본 테이블 업데이트 (테이블이 선택된 경우)
const mainTableName = layoutData.mainTableName;
if (mainTableName) {
await query(
`UPDATE screen_definitions SET table_name = $1, updated_date = NOW() WHERE screen_id = $2`,
[mainTableName, screenId],
);
console.log(`✅ [saveLayoutV2] 화면 기본 테이블 업데이트: ${mainTableName}`);
}
// 저장할 layout_data에서 레이어 메타 정보 제거 (순수 레이아웃만 저장) // 저장할 layout_data에서 레이어 메타 정보 제거 (순수 레이아웃만 저장)
const { layerId: _lid, layerName: _ln, conditionConfig: _cc, ...pureLayoutData } = layoutData; const { layerId: _lid, layerName: _ln, conditionConfig: _cc, mainTableName: _mtn, ...pureLayoutData } = layoutData;
const dataToSave = { const dataToSave = {
version: "2.0", version: "2.0",
...pureLayoutData, ...pureLayoutData,

View File

@ -554,6 +554,16 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 화면 관리에서 설정한 해상도 사용 (우선순위) // 화면 관리에서 설정한 해상도 사용 (우선순위)
const screenResolution = (layoutData as any).screenResolution || (screenInfo as any).screenResolution; const screenResolution = (layoutData as any).screenResolution || (screenInfo as any).screenResolution;
console.log("🔍 [ScreenModal] 해상도 디버그:", {
screenId,
v2ScreenResolution: v2LayoutData?.screenResolution,
layoutScreenResolution: (layoutData as any).screenResolution,
screenInfoResolution: (screenInfo as any).screenResolution,
finalScreenResolution: screenResolution,
hasWidth: screenResolution?.width,
hasHeight: screenResolution?.height,
});
let dimensions; let dimensions;
if (screenResolution && screenResolution.width && screenResolution.height) { if (screenResolution && screenResolution.width && screenResolution.height) {
// 화면 관리에서 설정한 해상도 사용 // 화면 관리에서 설정한 해상도 사용
@ -563,9 +573,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
offsetX: 0, offsetX: 0,
offsetY: 0, offsetY: 0,
}; };
console.log("✅ [ScreenModal] 화면관리 해상도 적용:", dimensions);
} else { } else {
// 해상도 정보가 없으면 자동 계산 // 해상도 정보가 없으면 자동 계산
dimensions = calculateScreenDimensions(components); dimensions = calculateScreenDimensions(components);
console.log("⚠️ [ScreenModal] 해상도 없음 - 자동 계산:", dimensions);
} }
setScreenDimensions(dimensions); setScreenDimensions(dimensions);
@ -869,16 +881,24 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터 // 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터
const getModalStyle = () => { const getModalStyle = () => {
if (!screenDimensions) { if (!screenDimensions) {
console.log("⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용");
return { return {
className: "w-fit min-w-[400px] max-w-4xl overflow-hidden", className: "w-fit min-w-[400px] max-w-4xl overflow-hidden",
style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" }, style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" },
}; };
} }
const finalWidth = Math.min(screenDimensions.width, window.innerWidth * 0.98);
console.log("✅ [ScreenModal] getModalStyle: 해상도 적용됨", {
screenDimensions,
finalWidth: `${finalWidth}px`,
viewportWidth: window.innerWidth,
});
return { return {
className: "overflow-hidden", className: "overflow-hidden",
style: { style: {
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`, width: `${finalWidth}px`,
// CSS가 알아서 처리: 뷰포트 안에 들어가면 auto-height, 넘치면 max-height로 제한 // CSS가 알아서 처리: 뷰포트 안에 들어가면 auto-height, 넘치면 max-height로 제한
maxHeight: "calc(100dvh - 8px)", maxHeight: "calc(100dvh - 8px)",
maxWidth: "98vw", maxWidth: "98vw",

View File

@ -565,12 +565,32 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
return newActiveIds; return newActiveIds;
}, [formData, groupData, conditionalLayers, screenData?.components]); }, [formData, groupData, conditionalLayers, screenData?.components]);
// 🆕 활성화된 조건부 레이어의 컴포넌트 가져오기 // 활성화된 조건부 레이어의 컴포넌트 가져오기 (Zone 오프셋 적용)
const activeConditionalComponents = useMemo(() => { const activeConditionalComponents = useMemo(() => {
return conditionalLayers return conditionalLayers
.filter((layer) => activeConditionalLayerIds.includes(layer.id)) .filter((layer) => activeConditionalLayerIds.includes(layer.id))
.flatMap((layer) => (layer as LayerDefinition & { components: ComponentData[] }).components || []); .flatMap((layer) => {
}, [conditionalLayers, activeConditionalLayerIds]); const layerWithComps = layer as LayerDefinition & { components: ComponentData[] };
const comps = layerWithComps.components || [];
// Zone 오프셋 적용: 조건부 레이어 컴포넌트는 Zone 내부 상대 좌표로 저장되므로
// Zone의 절대 좌표를 더해줘야 EditModal에서 올바른 위치에 렌더링됨
const associatedZone = zones.find((z) => z.zone_id === (layer as any).zoneId);
if (!associatedZone) return comps;
const zoneOffsetX = associatedZone.x || 0;
const zoneOffsetY = associatedZone.y || 0;
return comps.map((comp) => ({
...comp,
position: {
...comp.position,
x: parseFloat(comp.position?.x?.toString() || "0") + zoneOffsetX,
y: parseFloat(comp.position?.y?.toString() || "0") + zoneOffsetY,
},
}));
});
}, [conditionalLayers, activeConditionalLayerIds, zones]);
const handleClose = () => { const handleClose = () => {
setModalState({ setModalState({
@ -881,14 +901,31 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
} }
} }
// V2Repeater 저장 이벤트 발생 (디테일 테이블 데이터 저장)
const hasRepeaterInstances = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
if (hasRepeaterInstances) {
const masterRecordId = groupData[0]?.id || formData.id;
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
masterRecordId,
mainFormData: formData,
tableName: screenData.screenInfo.tableName,
},
}),
);
console.log("📋 [EditModal] 그룹 저장 후 repeaterSave 이벤트 발생:", { masterRecordId });
}
// 결과 메시지 // 결과 메시지
const messages: string[] = []; const messages: string[] = [];
if (insertedCount > 0) messages.push(`${insertedCount}개 추가`); if (insertedCount > 0) messages.push(`${insertedCount}개 추가`);
if (updatedCount > 0) messages.push(`${updatedCount}개 수정`); if (updatedCount > 0) messages.push(`${updatedCount}개 수정`);
if (deletedCount > 0) messages.push(`${deletedCount}개 삭제`); if (deletedCount > 0) messages.push(`${deletedCount}개 삭제`);
if (messages.length > 0) { if (messages.length > 0 || hasRepeaterInstances) {
toast.success(`품목이 저장되었습니다 (${messages.join(", ")})`); toast.success(messages.length > 0 ? `품목이 저장되었습니다 (${messages.join(", ")})` : "저장되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) { if (modalState.onSave) {

View File

@ -2231,11 +2231,20 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
} }
: component; : component;
// 🆕 모든 레이어의 컴포넌트를 통합 (조건부 레이어 내 컴포넌트가 기본 레이어 formData 참조 가능하도록) // 모든 레이어의 컴포넌트 통합 (조건 평가용 - 트리거 컴포넌트 검색에 필요)
const allLayerComponents = useMemo(() => { const allLayerComponents = useMemo(() => {
return layers.flatMap((layer) => layer.components); return layers.flatMap((layer) => layer.components);
}, [layers]); }, [layers]);
// 🔧 활성 레이어 컴포넌트만 통합 (저장/데이터 수집용)
// 기본 레이어(base) + 현재 활성화된 조건부 레이어만 포함
// 비활성 레이어의 중복 columnName 컴포넌트가 저장 데이터를 오염시키는 문제 해결
const visibleLayerComponents = useMemo(() => {
return layers
.filter((layer) => layer.type === "base" || activeLayerIds.includes(layer.id))
.flatMap((layer) => layer.components);
}, [layers, activeLayerIds]);
// 🆕 레이어별 컴포넌트 렌더링 함수 // 🆕 레이어별 컴포넌트 렌더링 함수
const renderLayerComponents = useCallback((layer: LayerDefinition) => { const renderLayerComponents = useCallback((layer: LayerDefinition) => {
// 활성화되지 않은 레이어는 렌더링하지 않음 // 활성화되지 않은 레이어는 렌더링하지 않음
@ -2272,7 +2281,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
> >
<InteractiveScreenViewer <InteractiveScreenViewer
component={comp} component={comp}
allComponents={allLayerComponents} allComponents={visibleLayerComponents}
formData={externalFormData} formData={externalFormData}
onFormDataChange={onFormDataChange} onFormDataChange={onFormDataChange}
screenInfo={screenInfo} screenInfo={screenInfo}
@ -2344,7 +2353,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
> >
<InteractiveScreenViewer <InteractiveScreenViewer
component={comp} component={comp}
allComponents={allLayerComponents} allComponents={visibleLayerComponents}
formData={externalFormData} formData={externalFormData}
onFormDataChange={onFormDataChange} onFormDataChange={onFormDataChange}
screenInfo={screenInfo} screenInfo={screenInfo}
@ -2387,7 +2396,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
> >
<InteractiveScreenViewer <InteractiveScreenViewer
component={comp} component={comp}
allComponents={allLayerComponents} allComponents={visibleLayerComponents}
formData={externalFormData} formData={externalFormData}
onFormDataChange={onFormDataChange} onFormDataChange={onFormDataChange}
screenInfo={screenInfo} screenInfo={screenInfo}
@ -2423,7 +2432,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
> >
<InteractiveScreenViewer <InteractiveScreenViewer
component={comp} component={comp}
allComponents={allLayerComponents} allComponents={visibleLayerComponents}
formData={externalFormData} formData={externalFormData}
onFormDataChange={onFormDataChange} onFormDataChange={onFormDataChange}
screenInfo={screenInfo} screenInfo={screenInfo}
@ -2433,7 +2442,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
})} })}
</div> </div>
); );
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents, layers]); }, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, visibleLayerComponents, layers]);
return ( return (
<SplitPanelProvider> <SplitPanelProvider>
@ -2485,7 +2494,13 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
setPopupScreen(null); setPopupScreen(null);
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화 setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
}}> }}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0"> <DialogContent
className="max-w-none w-auto max-h-[90vh] overflow-hidden p-0"
style={popupScreenResolution ? {
width: `${Math.min(popupScreenResolution.width + 48, window.innerWidth * 0.98)}px`,
maxWidth: "98vw",
} : { maxWidth: "56rem" }}
>
<DialogHeader className="px-6 pt-4 pb-2"> <DialogHeader className="px-6 pt-4 pb-2">
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle> <DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
</DialogHeader> </DialogHeader>

View File

@ -2062,6 +2062,7 @@ export default function ScreenDesigner({
await screenApi.saveLayoutV2(selectedScreen.screenId, { await screenApi.saveLayoutV2(selectedScreen.screenId, {
...v2Layout, ...v2Layout,
layerId: currentLayerId, layerId: currentLayerId,
mainTableName: currentMainTableName, // 화면의 기본 테이블 (DB 업데이트용)
}); });
} else { } else {
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
@ -5555,8 +5556,12 @@ export default function ScreenDesigner({
return false; return false;
} }
// 6. 삭제 (단일/다중 선택 지원) // 6. 삭제 (단일/다중 선택 지원) - Delete 또는 Backspace(Mac)
if (e.key === "Delete" && (selectedComponent || groupState.selectedComponents.length > 0)) { const isInputFocused = document.activeElement instanceof HTMLInputElement ||
document.activeElement instanceof HTMLTextAreaElement ||
document.activeElement instanceof HTMLSelectElement ||
(document.activeElement as HTMLElement)?.isContentEditable;
if ((e.key === "Delete" || (e.key === "Backspace" && !isInputFocused)) && (selectedComponent || groupState.selectedComponents.length > 0)) {
// console.log("🗑️ 컴포넌트 삭제 (단축키)"); // console.log("🗑️ 컴포넌트 삭제 (단축키)");
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -7418,7 +7423,7 @@ export default function ScreenDesigner({
</p> </p>
<p> <p>
<span className="font-medium">:</span> Ctrl+C(), Ctrl+V(), Ctrl+S(), <span className="font-medium">:</span> Ctrl+C(), Ctrl+V(), Ctrl+S(),
Ctrl+Z(), Delete() Ctrl+Z(), Delete/Backspace()
</p> </p>
<p className="text-warning flex items-center justify-center gap-2"> <p className="text-warning flex items-center justify-center gap-2">
<span></span> <span></span>

View File

@ -114,8 +114,7 @@ export function ComponentsPanel({
"image-display", // → v2-media (image) "image-display", // → v2-media (image)
// 공통코드관리로 통합 예정 // 공통코드관리로 통합 예정
"category-manager", // → 공통코드관리 기능으로 통합 예정 "category-manager", // → 공통코드관리 기능으로 통합 예정
// 분할 패널 정리 (split-panel-layout v1 유지) // 분할 패널 정리
"split-panel-layout2", // → split-panel-layout로 통합
"screen-split-panel", // 화면 임베딩 방식은 사용하지 않음 "screen-split-panel", // 화면 임베딩 방식은 사용하지 않음
// 미완성/미사용 컴포넌트 (기존 화면 호환성 유지, 새 추가만 막음) // 미완성/미사용 컴포넌트 (기존 화면 호환성 유지, 새 추가만 막음)
"accordion-basic", // 아코디언 컴포넌트 "accordion-basic", // 아코디언 컴포넌트

View File

@ -44,6 +44,11 @@ interface EntityJoinTable {
tableName: string; tableName: string;
currentDisplayColumn: string; currentDisplayColumn: string;
availableColumns: EntityJoinColumn[]; availableColumns: EntityJoinColumn[];
// 같은 테이블이 여러 FK로 조인될 수 있으므로 소스 컬럼으로 구분
joinConfig?: {
sourceColumn: string;
[key: string]: unknown;
};
} }
interface TablesPanelProps { interface TablesPanelProps {
@ -414,7 +419,11 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
</Badge> </Badge>
</div> </div>
{entityJoinTables.map((joinTable) => { {entityJoinTables.map((joinTable, idx) => {
// 같은 테이블이 여러 FK로 조인될 수 있으므로 sourceColumn으로 고유 키 생성
const uniqueKey = joinTable.joinConfig?.sourceColumn
? `entity-join-${joinTable.tableName}-${joinTable.joinConfig.sourceColumn}`
: `entity-join-${joinTable.tableName}-${idx}`;
const isExpanded = expandedJoinTables.has(joinTable.tableName); const isExpanded = expandedJoinTables.has(joinTable.tableName);
// 검색어로 필터링 // 검색어로 필터링
const filteredColumns = searchTerm const filteredColumns = searchTerm
@ -431,8 +440,7 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
} }
return ( return (
// 엔티티 조인 테이블에 고유 접두사 추가 (메인 테이블과 키 중복 방지) <div key={uniqueKey} className="space-y-1">
<div key={`entity-join-${joinTable.tableName}`} className="space-y-1">
{/* 조인 테이블 헤더 */} {/* 조인 테이블 헤더 */}
<div <div
className="flex cursor-pointer items-center justify-between rounded-md bg-cyan-50 p-2 hover:bg-cyan-100" className="flex cursor-pointer items-center justify-between rounded-md bg-cyan-50 p-2 hover:bg-cyan-100"

View File

@ -135,8 +135,27 @@ export function TabsWidget({
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({}); const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({}); const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({}); const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
// 탭별 화면 정보 (screenId, tableName) 저장 // 탭별 화면 정보 (screenId, tableName) - 인라인 컴포넌트의 테이블 설정에서 추출
const [screenInfoMap, setScreenInfoMap] = useState<Record<string, { id: number; tableName?: string }>>({}); const screenInfoMap = React.useMemo(() => {
const map: Record<string, { id?: number; tableName?: string }> = {};
for (const tab of tabs as ExtendedTabItem[]) {
const inlineComponents = tab.components || [];
if (inlineComponents.length > 0) {
// 인라인 컴포넌트에서 테이블 컴포넌트의 selectedTable 추출
const tableComp = inlineComponents.find(
(c) => c.componentType === "v2-table-list" || c.componentType === "table-list",
);
const selectedTable = tableComp?.componentConfig?.selectedTable;
if (selectedTable || tab.screenId) {
map[tab.id] = {
id: tab.screenId,
tableName: selectedTable,
};
}
}
}
return map;
}, [tabs]);
// 컴포넌트 탭 목록 변경 시 동기화 // 컴포넌트 탭 목록 변경 시 동기화
useEffect(() => { useEffect(() => {
@ -157,21 +176,10 @@ export function TabsWidget({
) { ) {
setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: true })); setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: true }));
try { try {
// 레이아웃과 화면 정보를 병렬로 로드 const layoutData = await screenApi.getLayout(extTab.screenId);
const [layoutData, screenDef] = await Promise.all([
screenApi.getLayout(extTab.screenId),
screenApi.getScreen(extTab.screenId),
]);
if (layoutData && layoutData.components) { if (layoutData && layoutData.components) {
setScreenLayouts((prev) => ({ ...prev, [tab.id]: layoutData.components })); setScreenLayouts((prev) => ({ ...prev, [tab.id]: layoutData.components }));
} }
// 탭의 화면 정보 저장 (tableName 포함)
if (screenDef) {
setScreenInfoMap((prev) => ({
...prev,
[tab.id]: { id: extTab.screenId!, tableName: screenDef.tableName },
}));
}
} catch (error) { } catch (error) {
console.error(`탭 "${tab.label}" 화면 로드 실패:`, error); console.error(`탭 "${tab.label}" 화면 로드 실패:`, error);
setScreenErrors((prev) => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." })); setScreenErrors((prev) => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." }));
@ -185,31 +193,6 @@ export function TabsWidget({
loadScreenLayouts(); loadScreenLayouts();
}, [visibleTabs, screenLayouts, screenLoadingStates]); }, [visibleTabs, screenLayouts, screenLoadingStates]);
// screenInfoMap이 없는 탭의 화면 정보 보충 로드
// screenId가 있지만 screenInfoMap에 아직 없는 탭의 화면 정보를 로드
useEffect(() => {
const loadMissingScreenInfo = async () => {
for (const tab of visibleTabs) {
const extTab = tab as ExtendedTabItem;
// screenId가 있고 screenInfoMap에 아직 없는 경우 로드
if (extTab.screenId && !screenInfoMap[tab.id]) {
try {
const screenDef = await screenApi.getScreen(extTab.screenId);
if (screenDef) {
setScreenInfoMap((prev) => ({
...prev,
[tab.id]: { id: extTab.screenId!, tableName: screenDef.tableName },
}));
}
} catch (error) {
console.error(`탭 "${tab.label}" 화면 정보 로드 실패:`, error);
}
}
}
};
loadMissingScreenInfo();
}, [visibleTabs, screenInfoMap]);
// 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트 // 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트
useEffect(() => { useEffect(() => {
if (persistSelection && typeof window !== "undefined") { if (persistSelection && typeof window !== "undefined") {

View File

@ -25,8 +25,22 @@ import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { V2MediaProps } from "@/types/v2-components"; import { V2MediaProps } from "@/types/v2-components";
import { import {
Upload, X, File, Image as ImageIcon, Video, Music, Eye, Download, Trash2, Plus, Upload,
FileText, Archive, Presentation, FileImage, FileVideo, FileAudio X,
File,
Image as ImageIcon,
Video,
Music,
Eye,
Download,
Trash2,
Plus,
FileText,
Archive,
Presentation,
FileImage,
FileVideo,
FileAudio,
} from "lucide-react"; } from "lucide-react";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { toast } from "sonner"; import { toast } from "sonner";
@ -77,8 +91,7 @@ const getFileIcon = (extension: string) => {
/** /**
* V2 ( ) * V2 ( )
*/ */
export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>( export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>((props, ref) => {
(props, ref) => {
const { const {
id, id,
label, label,
@ -121,11 +134,11 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// 레코드 모드 판단 // 레코드 모드 판단
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_')); const isRecordMode = !!(formData?.id && !String(formData.id).startsWith("temp_"));
const recordTableName = formData?.tableName || tableName; const recordTableName = formData?.tableName || tableName;
const recordId = formData?.id; const recordId = formData?.id;
// 🔑 columnName 우선 사용 (실제 DB 컬럼명), 없으면 id, 최후에 attachments // 🔑 columnName 우선 사용 (실제 DB 컬럼명), 없으면 id, 최후에 attachments
const effectiveColumnName = columnName || id || 'attachments'; const effectiveColumnName = columnName || id || "attachments";
// 레코드용 targetObjid 생성 // 레코드용 targetObjid 생성
const getRecordTargetObjid = useCallback(() => { const getRecordTargetObjid = useCallback(() => {
@ -269,7 +282,20 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
console.error("파일 조회 오류:", error); console.error("파일 조회 오류:", error);
} }
return false; return false;
}, [id, tableName, columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, effectiveColumnName, isDesignMode]); }, [
id,
tableName,
columnName,
formData?.screenId,
formData?.tableName,
formData?.id,
getUniqueKey,
recordId,
isRecordMode,
recordTableName,
effectiveColumnName,
isDesignMode,
]);
// 파일 동기화 // 파일 동기화
useEffect(() => { useEffect(() => {
@ -344,7 +370,8 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
} }
let targetObjid; let targetObjid;
const effectiveIsRecordMode = isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith('temp_')); const effectiveIsRecordMode =
isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith("temp_"));
if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) { if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) {
targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`; targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`;
@ -358,7 +385,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
const finalLinkedTable = effectiveIsRecordMode const finalLinkedTable = effectiveIsRecordMode
? effectiveTableName ? effectiveTableName
: (formData?.linkedTable || effectiveTableName); : formData?.linkedTable || effectiveTableName;
const uploadData = { const uploadData = {
autoLink: formData?.autoLink || true, autoLink: formData?.autoLink || true,
@ -474,9 +501,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
if (onFormDataChange && targetColumn) { if (onFormDataChange && targetColumn) {
// 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값) // 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값)
// 복수 파일: 콤마 구분 문자열로 전달 // 복수 파일: 콤마 구분 문자열로 전달
const formValue = config.multiple const formValue = config.multiple ? fileIds.join(",") : fileIds[0] || "";
? fileIds.join(',')
: (fileIds[0] || '');
console.log("📝 [V2Media] formData 업데이트:", { console.log("📝 [V2Media] formData 업데이트:", {
columnName: targetColumn, columnName: targetColumn,
@ -515,7 +540,22 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
toast.error(`업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`); toast.error(`업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`);
} }
}, },
[config, uploadedFiles, onChange, id, getUniqueKey, recordId, isRecordMode, recordTableName, effectiveColumnName, tableName, onUpdate, onFormDataChange, user, columnName], [
config,
uploadedFiles,
onChange,
id,
getUniqueKey,
recordId,
isRecordMode,
recordTableName,
effectiveColumnName,
tableName,
onUpdate,
onFormDataChange,
user,
columnName,
],
); );
// 파일 뷰어 열기/닫기 // 파일 뷰어 열기/닫기
@ -612,9 +652,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
if (onFormDataChange && targetColumn) { if (onFormDataChange && targetColumn) {
// 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값) // 🔑 단일 파일: 첫 번째 objid만 전달 (DB 컬럼에 저장될 값)
// 복수 파일: 콤마 구분 문자열로 전달 // 복수 파일: 콤마 구분 문자열로 전달
const formValue = config.multiple const formValue = config.multiple ? fileIds.join(",") : fileIds[0] || "";
? fileIds.join(',')
: (fileIds[0] || '');
console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", { console.log("🗑️ [V2Media] 삭제 후 formData 업데이트:", {
columnName: targetColumn, columnName: targetColumn,
@ -631,7 +669,20 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
toast.error("파일 삭제 실패"); toast.error("파일 삭제 실패");
} }
}, },
[uploadedFiles, onUpdate, id, isRecordMode, onFormDataChange, recordTableName, recordId, effectiveColumnName, getUniqueKey, onChange, config.multiple, columnName], [
uploadedFiles,
onUpdate,
id,
isRecordMode,
onFormDataChange,
recordTableName,
recordId,
effectiveColumnName,
getUniqueKey,
onChange,
config.multiple,
columnName,
],
); );
// 대표 이미지 로드 // 대표 이미지 로드
@ -639,7 +690,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
async (file: FileInfo) => { async (file: FileInfo) => {
try { try {
const isImage = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes( const isImage = ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
file.fileExt.toLowerCase().replace(".", "") file.fileExt.toLowerCase().replace(".", ""),
); );
if (!isImage) { if (!isImage) {
@ -691,12 +742,12 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
console.error("대표 파일 설정 실패:", e); console.error("대표 파일 설정 실패:", e);
} }
}, },
[uploadedFiles, loadRepresentativeImage] [uploadedFiles, loadRepresentativeImage],
); );
// uploadedFiles 변경 시 대표 이미지 로드 // uploadedFiles 변경 시 대표 이미지 로드
useEffect(() => { useEffect(() => {
const representativeFile = uploadedFiles.find(f => f.isRepresentative) || uploadedFiles[0]; const representativeFile = uploadedFiles.find((f) => f.isRepresentative) || uploadedFiles[0];
if (representativeFile) { if (representativeFile) {
loadRepresentativeImage(representativeFile); loadRepresentativeImage(representativeFile);
} else { } else {
@ -711,13 +762,16 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
}, [uploadedFiles]); }, [uploadedFiles]);
// 드래그 앤 드롭 핸들러 // 드래그 앤 드롭 핸들러
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (!readonly && !disabled) { if (!readonly && !disabled) {
setDragOver(true); setDragOver(true);
} }
}, [readonly, disabled]); },
[readonly, disabled],
);
const handleDragLeave = useCallback((e: React.DragEvent) => { const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@ -725,7 +779,8 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
setDragOver(false); setDragOver(false);
}, []); }, []);
const handleDrop = useCallback((e: React.DragEvent) => { const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setDragOver(false); setDragOver(false);
@ -736,7 +791,9 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
handleFileUpload(files); handleFileUpload(files);
} }
} }
}, [readonly, disabled, handleFileUpload]); },
[readonly, disabled, handleFileUpload],
);
// 파일 선택 // 파일 선택
const handleFileSelect = useCallback(() => { const handleFileSelect = useCallback(() => {
@ -745,13 +802,16 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
} }
}, []); }, []);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
if (files.length > 0) { if (files.length > 0) {
handleFileUpload(files); handleFileUpload(files);
} }
e.target.value = ''; e.target.value = "";
}, [handleFileUpload]); },
[handleFileUpload],
);
// 파일 설정 // 파일 설정
const fileConfig: FileUploadConfig = { const fileConfig: FileUploadConfig = {
@ -767,12 +827,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
const componentHeight = size?.height || style?.height; const componentHeight = size?.height || style?.height;
return ( return (
<div <div ref={ref} id={id} className="flex w-full flex-col" style={{ width: componentWidth }}>
ref={ref}
id={id}
className="flex w-full flex-col"
style={{ width: componentWidth }}
>
{/* 라벨 */} {/* 라벨 */}
{showLabel && ( {showLabel && (
<Label <Label
@ -783,20 +838,17 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
fontWeight: style?.labelFontWeight, fontWeight: style?.labelFontWeight,
marginBottom: style?.labelMarginBottom, marginBottom: style?.labelMarginBottom,
}} }}
className="text-sm font-medium shrink-0" className="shrink-0 text-sm font-medium"
> >
{label} {label}
{required && <span className="text-orange-500 ml-0.5">*</span>} {required && <span className="ml-0.5 text-orange-500">*</span>}
</Label> </Label>
)} )}
{/* 메인 컨테이너 */} {/* 메인 컨테이너 */}
<div className="min-h-0" style={{ height: componentHeight }}>
<div <div
className="min-h-0" className="border-border bg-card relative flex h-full w-full flex-col overflow-hidden rounded-lg border"
style={{ height: componentHeight }}
>
<div
className="border-border bg-card relative flex h-full w-full flex-col rounded-lg border overflow-hidden"
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
@ -813,16 +865,19 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
/> />
{/* 파일이 있는 경우: 대표 이미지/파일 표시 */} {/* 파일이 있는 경우: 대표 이미지/파일 표시 */}
{uploadedFiles.length > 0 ? (() => { {uploadedFiles.length > 0 ? (
const representativeFile = uploadedFiles.find(f => f.isRepresentative) || uploadedFiles[0]; (() => {
const isImage = representativeFile && ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes( const representativeFile = uploadedFiles.find((f) => f.isRepresentative) || uploadedFiles[0];
representativeFile.fileExt.toLowerCase().replace(".", "") const isImage =
representativeFile &&
["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(
representativeFile.fileExt.toLowerCase().replace(".", ""),
); );
return ( return (
<> <>
{isImage && representativeImageUrl ? ( {isImage && representativeImageUrl ? (
<div className="relative h-full w-full flex items-center justify-center bg-muted/10"> <div className="bg-muted/10 relative flex h-full w-full items-center justify-center">
<img <img
src={representativeImageUrl} src={representativeImageUrl}
alt={representativeFile.realFileName} alt={representativeFile.realFileName}
@ -831,15 +886,13 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
</div> </div>
) : isImage && !representativeImageUrl ? ( ) : isImage && !representativeImageUrl ? (
<div className="flex h-full w-full flex-col items-center justify-center"> <div className="flex h-full w-full flex-col items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div> <div className="border-primary mb-2 h-8 w-8 animate-spin rounded-full border-b-2"></div>
<p className="text-sm text-muted-foreground"> ...</p> <p className="text-muted-foreground text-sm"> ...</p>
</div> </div>
) : ( ) : (
<div className="flex h-full w-full flex-col items-center justify-center"> <div className="flex h-full w-full flex-col items-center justify-center">
{getFileIcon(representativeFile.fileExt)} {getFileIcon(representativeFile.fileExt)}
<p className="mt-3 text-sm font-medium text-center px-4"> <p className="mt-3 px-4 text-center text-sm font-medium">{representativeFile.realFileName}</p>
{representativeFile.realFileName}
</p>
<Badge variant="secondary" className="mt-2"> <Badge variant="secondary" className="mt-2">
</Badge> </Badge>
@ -847,7 +900,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
)} )}
{/* 우측 하단 자세히보기 버튼 */} {/* 우측 하단 자세히보기 버튼 */}
<div className="absolute bottom-3 right-3"> <div className="absolute right-3 bottom-3">
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
@ -859,19 +912,20 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
</div> </div>
</> </>
); );
})() : ( })()
) : (
// 파일이 없는 경우: 업로드 안내 // 파일이 없는 경우: 업로드 안내
<div <div
className={cn( className={cn(
"flex h-full w-full flex-col items-center justify-center text-muted-foreground cursor-pointer", "text-muted-foreground flex h-full w-full cursor-pointer flex-col items-center justify-center",
dragOver && "border-primary bg-primary/5", dragOver && "border-primary bg-primary/5",
(disabled || readonly) && "opacity-50 cursor-not-allowed" (disabled || readonly) && "cursor-not-allowed opacity-50",
)} )}
onClick={() => !disabled && !readonly && handleFileSelect()} onClick={() => !disabled && !readonly && handleFileSelect()}
> >
<Upload className="mb-3 h-12 w-12" /> <Upload className="mb-3 h-12 w-12" />
<p className="text-sm font-medium"> </p> <p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1 text-xs">
{formatFileSize(config.maxSize || 10 * 1024 * 1024)} {formatFileSize(config.maxSize || 10 * 1024 * 1024)}
{config.accept && config.accept !== "*/*" && ` (${config.accept})`} {config.accept && config.accept !== "*/*" && ` (${config.accept})`}
</p> </p>
@ -916,8 +970,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>(
/> />
</div> </div>
); );
} });
);
V2Media.displayName = "V2Media"; V2Media.displayName = "V2Media";

View File

@ -43,6 +43,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
onDataChange, onDataChange,
onRowClick, onRowClick,
className, className,
formData: parentFormData,
}) => { }) => {
// 설정 병합 // 설정 병합
const config: V2RepeaterConfig = useMemo( const config: V2RepeaterConfig = useMemo(
@ -153,21 +154,15 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함) // 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함)
let mergedData: Record<string, any>; let mergedData: Record<string, any>;
if (config.useCustomTable && config.mainTableName) { if (config.useCustomTable && config.mainTableName) {
// 커스텀 테이블: 리피터 데이터만 저장
mergedData = { ...cleanRow }; mergedData = { ...cleanRow };
// 🆕 FK 자동 연결 - foreignKeySourceColumn이 설정된 경우 해당 컬럼 값 사용
if (config.foreignKeyColumn) { if (config.foreignKeyColumn) {
// foreignKeySourceColumn이 있으면 mainFormData에서 해당 컬럼 값 사용
// 없으면 마스터 레코드 ID 사용 (기존 동작)
const sourceColumn = config.foreignKeySourceColumn; const sourceColumn = config.foreignKeySourceColumn;
let fkValue: any; let fkValue: any;
if (sourceColumn && mainFormData && mainFormData[sourceColumn] !== undefined) { if (sourceColumn && mainFormData && mainFormData[sourceColumn] !== undefined) {
// mainFormData에서 참조 컬럼 값 가져오기
fkValue = mainFormData[sourceColumn]; fkValue = mainFormData[sourceColumn];
} else { } else {
// 기본: 마스터 레코드 ID 사용
fkValue = masterRecordId; fkValue = masterRecordId;
} }
@ -176,7 +171,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} }
} }
} else { } else {
// 기존 방식: 메인 폼 데이터 병합
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {}; const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
mergedData = { mergedData = {
...mainFormDataWithoutId, ...mainFormDataWithoutId,
@ -192,8 +186,20 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} }
} }
// 기존 행(id 존재)은 UPDATE, 새 행은 INSERT
const rowId = row.id;
if (rowId && typeof rowId === "string" && rowId.includes("-")) {
// UUID 형태의 id가 있으면 기존 데이터 → UPDATE
const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData;
await apiClient.put(`/table-management/tables/${tableName}/edit`, {
originalData: { id: rowId },
updatedData: updateFields,
});
} else {
// 새 행 → INSERT
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData); await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
} }
}
} catch (error) { } catch (error) {
console.error("❌ V2Repeater 저장 실패:", error); console.error("❌ V2Repeater 저장 실패:", error);
throw error; throw error;
@ -228,6 +234,108 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
parentId, parentId,
]); ]);
// 수정 모드: useCustomTable + FK 기반으로 기존 디테일 데이터 자동 로드
const dataLoadedRef = useRef(false);
useEffect(() => {
if (dataLoadedRef.current) return;
if (!config.useCustomTable || !config.mainTableName || !config.foreignKeyColumn) return;
if (!parentFormData) return;
const fkSourceColumn = config.foreignKeySourceColumn || config.foreignKeyColumn;
const fkValue = parentFormData[fkSourceColumn];
if (!fkValue) return;
// 이미 데이터가 있으면 로드하지 않음
if (data.length > 0) return;
const loadExistingData = async () => {
try {
console.log("📥 [V2Repeater] 수정 모드 데이터 로드:", {
tableName: config.mainTableName,
fkColumn: config.foreignKeyColumn,
fkValue,
});
const response = await apiClient.post(
`/table-management/tables/${config.mainTableName}/data`,
{
page: 1,
size: 1000,
search: { [config.foreignKeyColumn]: fkValue },
autoFilter: true,
}
);
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
if (Array.isArray(rows) && rows.length > 0) {
console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`);
// isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강
const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay);
const sourceTable = config.dataSource?.sourceTable;
const fkColumn = config.dataSource?.foreignKey;
const refKey = config.dataSource?.referenceKey || "id";
if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) {
try {
const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean);
const uniqueValues = [...new Set(fkValues)];
if (uniqueValues.length > 0) {
// FK 값 기반으로 소스 테이블에서 해당 레코드만 조회
const sourcePromises = uniqueValues.map((val) =>
apiClient.post(`/table-management/tables/${sourceTable}/data`, {
page: 1, size: 1,
search: { [refKey]: val },
autoFilter: true,
}).then((r) => r.data?.data?.data || r.data?.data?.rows || [])
.catch(() => [])
);
const sourceResults = await Promise.all(sourcePromises);
const sourceMap = new Map<string, any>();
sourceResults.flat().forEach((sr: any) => {
if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr);
});
// 각 행에 소스 테이블의 표시 데이터 병합
// RepeaterTable은 isSourceDisplay 컬럼을 `_display_${col.key}` 필드로 렌더링함
rows.forEach((row: any) => {
const sourceRecord = sourceMap.get(String(row[fkColumn]));
if (sourceRecord) {
sourceDisplayColumns.forEach((col) => {
const displayValue = sourceRecord[col.key] ?? null;
row[col.key] = displayValue;
row[`_display_${col.key}`] = displayValue;
});
}
});
console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료");
}
} catch (sourceError) {
console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError);
}
}
setData(rows);
dataLoadedRef.current = true;
if (onDataChange) onDataChange(rows);
}
} catch (error) {
console.error("❌ [V2Repeater] 기존 데이터 로드 실패:", error);
}
};
loadExistingData();
}, [
config.useCustomTable,
config.mainTableName,
config.foreignKeyColumn,
config.foreignKeySourceColumn,
parentFormData,
data.length,
onDataChange,
]);
// 현재 테이블 컬럼 정보 로드 // 현재 테이블 컬럼 정보 로드
useEffect(() => { useEffect(() => {
const loadCurrentTableColumnInfo = async () => { const loadCurrentTableColumnInfo = async () => {
@ -451,58 +559,71 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
loadCategoryLabels(); loadCategoryLabels();
}, [data, sourceCategoryColumns]); }, [data, sourceCategoryColumns]);
// 데이터 변경 핸들러 // 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능)
const handleDataChange = useCallback( const applyCalculationRules = useCallback(
(newData: any[]) => { (row: any): any => {
setData(newData); const rules = config.calculationRules;
if (!rules || rules.length === 0) return row;
// 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용) const updatedRow = { ...row };
if (onDataChange) { for (const rule of rules) {
if (!rule.targetColumn || !rule.formula) continue;
try {
let formula = rule.formula;
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
for (const field of fieldMatches) {
if (field === rule.targetColumn) continue;
// 직접 필드 → _display_* 필드 순으로 값 탐색
const raw = updatedRow[field] ?? updatedRow[`_display_${field}`];
const value = parseFloat(raw) || 0;
formula = formula.replace(new RegExp(`\\b${field}\\b`, "g"), value.toString());
}
updatedRow[rule.targetColumn] = new Function(`return ${formula}`)();
} catch {
updatedRow[rule.targetColumn] = 0;
}
}
return updatedRow;
},
[config.calculationRules],
);
// _targetTable 메타데이터 포함하여 onDataChange 호출
const notifyDataChange = useCallback(
(newData: any[]) => {
if (!onDataChange) return;
const targetTable = const targetTable =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
if (targetTable) { if (targetTable) {
// 각 행에 _targetTable 추가 onDataChange(newData.map((row) => ({ ...row, _targetTable: targetTable })));
const dataWithTarget = newData.map((row) => ({
...row,
_targetTable: targetTable,
}));
onDataChange(dataWithTarget);
} else { } else {
onDataChange(newData); onDataChange(newData);
} }
}
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정
setAutoWidthTrigger((prev) => prev + 1);
}, },
[onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName], [onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
); );
// 데이터 변경 핸들러
const handleDataChange = useCallback(
(newData: any[]) => {
const calculated = newData.map(applyCalculationRules);
setData(calculated);
notifyDataChange(calculated);
setAutoWidthTrigger((prev) => prev + 1);
},
[applyCalculationRules, notifyDataChange],
);
// 행 변경 핸들러 // 행 변경 핸들러
const handleRowChange = useCallback( const handleRowChange = useCallback(
(index: number, newRow: any) => { (index: number, newRow: any) => {
const calculated = applyCalculationRules(newRow);
const newData = [...data]; const newData = [...data];
newData[index] = newRow; newData[index] = calculated;
setData(newData); setData(newData);
notifyDataChange(newData);
// 🆕 _targetTable 메타데이터 포함
if (onDataChange) {
const targetTable =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
if (targetTable) {
const dataWithTarget = newData.map((row) => ({
...row,
_targetTable: targetTable,
}));
onDataChange(dataWithTarget);
} else {
onDataChange(newData);
}
}
}, },
[data, onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName], [data, applyCalculationRules, notifyDataChange],
); );
// 행 삭제 핸들러 // 행 삭제 핸들러

View File

@ -58,16 +58,24 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
}, ref) => { }, ref) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// 현재 선택된 값 존재 여부
const hasValue = useMemo(() => {
if (!value) return false;
if (Array.isArray(value)) return value.length > 0;
return value !== "";
}, [value]);
// 단일 선택 + 검색 불가능 → 기본 Select 사용 // 단일 선택 + 검색 불가능 → 기본 Select 사용
if (!searchable && !multiple) { if (!searchable && !multiple) {
return ( return (
<div className="relative w-full group">
<Select <Select
value={typeof value === "string" ? value : value?.[0] ?? ""} value={typeof value === "string" ? value : value?.[0] ?? ""}
onValueChange={(v) => onChange?.(v)} onValueChange={(v) => onChange?.(v)}
disabled={disabled} disabled={disabled}
> >
{/* SelectTrigger에 style로 직접 height 전달 (Radix Select.Root는 DOM 없어서 h-full 체인 끊김) */} {/* SelectTrigger에 style로 직접 height 전달 (Radix Select.Root는 DOM 없어서 h-full 체인 끊김) */}
<SelectTrigger ref={ref} className={cn("w-full", className)} style={style}> <SelectTrigger ref={ref} className={cn("w-full", allowClear && hasValue ? "pr-8" : "", className)} style={style}>
<SelectValue placeholder={placeholder} /> <SelectValue placeholder={placeholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -80,6 +88,26 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{/* 초기화 버튼 (값이 있을 때만 표시) */}
{allowClear && hasValue && !disabled && (
<span
role="button"
tabIndex={-1}
className="absolute right-7 top-1/2 -translate-y-1/2 z-10 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onChange?.("");
}}
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<X className="h-3.5 w-3.5 opacity-40 hover:opacity-100 transition-opacity" />
</span>
)}
</div>
); );
} }
@ -142,10 +170,18 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
</span> </span>
<div className="flex items-center gap-1 ml-2"> <div className="flex items-center gap-1 ml-2">
{allowClear && selectedValues.length > 0 && ( {allowClear && selectedValues.length > 0 && (
<X <span
className="h-4 w-4 opacity-50 hover:opacity-100" role="button"
tabIndex={-1}
onClick={handleClear} onClick={handleClear}
/> onPointerDown={(e) => {
// Radix Popover가 onPointerDown으로 팝오버를 여는 것을 방지
e.stopPropagation();
e.preventDefault();
}}
>
<X className="h-4 w-4 opacity-50 hover:opacity-100" />
</span>
)} )}
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div> </div>
@ -153,13 +189,11 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start"> <PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command <Command
filter={(value, search) => { filter={(itemValue, search) => {
// value는 CommandItem의 value (라벨)
// search는 검색어
if (!search) return 1; if (!search) return 1;
const normalizedValue = value.toLowerCase(); const option = options.find((o) => o.value === itemValue);
const normalizedSearch = search.toLowerCase(); const label = (option?.label || option?.value || "").toLowerCase();
if (normalizedValue.includes(normalizedSearch)) return 1; if (label.includes(search.toLowerCase())) return 1;
return 0; return 0;
}} }}
> >
@ -172,7 +206,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
return ( return (
<CommandItem <CommandItem
key={option.value} key={option.value}
value={displayLabel} value={option.value}
onSelect={() => handleSelect(option.value)} onSelect={() => handleSelect(option.value)}
> >
<Check <Check

View File

@ -136,7 +136,9 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
const [currentTableColumns, setCurrentTableColumns] = useState<ColumnOption[]>([]); // 현재 테이블 컬럼 const [currentTableColumns, setCurrentTableColumns] = useState<ColumnOption[]>([]); // 현재 테이블 컬럼
const [entityColumns, setEntityColumns] = useState<EntityColumnOption[]>([]); // 엔티티 타입 컬럼 const [entityColumns, setEntityColumns] = useState<EntityColumnOption[]>([]); // 엔티티 타입 컬럼
const [sourceTableColumns, setSourceTableColumns] = useState<ColumnOption[]>([]); // 소스(엔티티) 테이블 컬럼 const [sourceTableColumns, setSourceTableColumns] = useState<ColumnOption[]>([]); // 소스(엔티티) 테이블 컬럼
const [calculationRules, setCalculationRules] = useState<CalculationRule[]>([]); const [calculationRules, setCalculationRules] = useState<CalculationRule[]>(
config.calculationRules || []
);
const [loadingColumns, setLoadingColumns] = useState(false); const [loadingColumns, setLoadingColumns] = useState(false);
const [loadingSourceColumns, setLoadingSourceColumns] = useState(false); const [loadingSourceColumns, setLoadingSourceColumns] = useState(false);
@ -553,26 +555,56 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
updateConfig({ columns: newColumns }); updateConfig({ columns: newColumns });
}; };
// 계산 규칙을 config에 반영하는 헬퍼
const syncCalculationRules = (rules: CalculationRule[]) => {
setCalculationRules(rules);
updateConfig({ calculationRules: rules });
};
// 계산 규칙 추가 // 계산 규칙 추가
const addCalculationRule = () => { const addCalculationRule = () => {
setCalculationRules(prev => [ const newRules = [
...prev, ...calculationRules,
{ id: `calc_${Date.now()}`, targetColumn: "", formula: "" } { id: `calc_${Date.now()}`, targetColumn: "", formula: "" }
]); ];
syncCalculationRules(newRules);
}; };
// 계산 규칙 삭제 // 계산 규칙 삭제
const removeCalculationRule = (id: string) => { const removeCalculationRule = (id: string) => {
setCalculationRules(prev => prev.filter(r => r.id !== id)); syncCalculationRules(calculationRules.filter(r => r.id !== id));
}; };
// 계산 규칙 업데이트 // 계산 규칙 업데이트
const updateCalculationRule = (id: string, field: keyof CalculationRule, value: string) => { const updateCalculationRule = (id: string, field: keyof CalculationRule, value: string) => {
setCalculationRules(prev => syncCalculationRules(
prev.map(r => r.id === id ? { ...r, [field]: value } : r) calculationRules.map(r => r.id === id ? { ...r, [field]: value } : r)
); );
}; };
// 수식 입력 필드에 컬럼명 삽입
const insertColumnToFormula = (ruleId: string, columnKey: string) => {
const rule = calculationRules.find(r => r.id === ruleId);
if (!rule) return;
const newFormula = rule.formula ? `${rule.formula} ${columnKey}` : columnKey;
updateCalculationRule(ruleId, "formula", newFormula);
};
// 수식의 영어 컬럼명을 한글 제목으로 변환
const formulaToKorean = (formula: string): string => {
if (!formula) return "";
let result = formula;
const allCols = config.columns || [];
// 긴 컬럼명부터 치환 (부분 매칭 방지)
const sorted = [...allCols].sort((a, b) => b.key.length - a.key.length);
for (const col of sorted) {
if (col.title && col.key) {
result = result.replace(new RegExp(`\\b${col.key}\\b`, "g"), col.title);
}
}
return result;
};
// 엔티티 컬럼 선택 시 소스 테이블 자동 설정 // 엔티티 컬럼 선택 시 소스 테이블 자동 설정
const handleEntityColumnSelect = (columnName: string) => { const handleEntityColumnSelect = (columnName: string) => {
const selectedEntity = entityColumns.find(c => c.columnName === columnName); const selectedEntity = entityColumns.find(c => c.columnName === columnName);
@ -1374,7 +1406,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
{(isModalMode || isInlineMode) && config.columns.length > 0 && ( {(isModalMode || isInlineMode) && config.columns.length > 0 && (
<> <>
<Separator /> <Separator />
<div className="space-y-2"> <div className="space-y-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>
<Button type="button" variant="outline" size="sm" onClick={addCalculationRule} className="h-6 text-xs"> <Button type="button" variant="outline" size="sm" onClick={addCalculationRule} className="h-6 text-xs">
@ -1382,52 +1414,100 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
</Button> </Button>
</div> </div>
<p className="text-[10px] text-muted-foreground">
: 금액 = *
</p>
<div className="max-h-32 space-y-2 overflow-y-auto"> <div className="space-y-2">
{calculationRules.map((rule) => ( {calculationRules.map((rule) => (
<div key={rule.id} className="flex items-center gap-2 rounded border p-2"> <div key={rule.id} className="space-y-1 rounded border p-1.5">
<div className="flex items-center gap-1">
<Select <Select
value={rule.targetColumn} value={rule.targetColumn}
onValueChange={(value) => updateCalculationRule(rule.id, "targetColumn", value)} onValueChange={(value) => updateCalculationRule(rule.id, "targetColumn", value)}
> >
<SelectTrigger className="h-7 w-24 text-xs"> <SelectTrigger className="h-6 w-20 text-[10px]">
<SelectValue placeholder="결과" /> <SelectValue placeholder="결과" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{config.columns.map((col) => ( {config.columns.filter(col => !col.isSourceDisplay).map((col) => (
<SelectItem key={col.key} value={col.key}> <SelectItem key={col.key} value={col.key} className="text-xs">
{col.title} {col.title || col.key}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<span className="text-[10px]">=</span>
<span className="text-xs">=</span>
<Input <Input
value={rule.formula} value={rule.formula}
onChange={(e) => updateCalculationRule(rule.id, "formula", e.target.value)} onChange={(e) => updateCalculationRule(rule.id, "formula", e.target.value)}
placeholder="quantity * unit_price" placeholder="컬럼 클릭 또는 직접 입력"
className="h-7 flex-1 text-xs" className="h-6 flex-1 font-mono text-[10px]"
/> />
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => removeCalculationRule(rule.id)} onClick={() => removeCalculationRule(rule.id)}
className="h-7 w-7 p-0 text-destructive" className="h-6 w-6 p-0 text-destructive"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
</div> </div>
{/* 한글 수식 미리보기 */}
{rule.formula && (
<p className="truncate rounded bg-muted/50 px-1.5 py-0.5 text-[10px] text-muted-foreground">
{config.columns.find(c => c.key === rule.targetColumn)?.title || rule.targetColumn || "결과"} = {formulaToKorean(rule.formula)}
</p>
)}
{/* 컬럼 칩: 디테일 컬럼 + 소스(품목) 컬럼 + 연산자 */}
<div className="flex flex-wrap gap-0.5">
{config.columns
.filter(col => col.key !== rule.targetColumn && !col.isSourceDisplay)
.map((col) => (
<Button
key={col.key}
type="button"
variant="secondary"
size="sm"
onClick={() => insertColumnToFormula(rule.id, col.key)}
className="h-4 px-1 text-[9px]"
>
{col.title || col.key}
</Button>
))}
{config.columns
.filter(col => col.isSourceDisplay)
.map((col) => (
<Button
key={col.key}
type="button"
variant="outline"
size="sm"
onClick={() => insertColumnToFormula(rule.id, col.key)}
className="h-4 border-dashed px-1 text-[9px] text-blue-600"
title="품목 정보 컬럼"
>
{col.title || col.key}
</Button>
))}
{["+", "-", "*", "/", "(", ")"].map((op) => (
<Button
key={op}
type="button"
variant="outline"
size="sm"
onClick={() => insertColumnToFormula(rule.id, op)}
className="h-4 w-4 p-0 font-mono text-[9px]"
>
{op}
</Button>
))}
</div>
</div>
))} ))}
{calculationRules.length === 0 && ( {calculationRules.length === 0 && (
<p className="text-muted-foreground py-2 text-center text-xs"> <p className="text-muted-foreground py-1 text-center text-[10px]">
</p> </p>
)} )}

View File

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

View File

@ -21,7 +21,7 @@ import {
MODAL_SIZE_OPTIONS, MODAL_SIZE_OPTIONS,
SECTION_TYPE_OPTIONS, SECTION_TYPE_OPTIONS,
} from "./types"; } from "./types";
import { defaultSectionConfig, defaultTableSectionConfig, generateSectionId } from "./config"; import { defaultConfig, defaultSectionConfig, defaultTableSectionConfig, generateSectionId } from "./config";
// 모달 import // 모달 import
import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal"; import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal";
@ -43,10 +43,20 @@ interface AvailableParentField {
} }
export function UniversalFormModalConfigPanel({ export function UniversalFormModalConfigPanel({
config, config: rawConfig,
onChange, onChange,
allComponents = [], allComponents = [],
}: UniversalFormModalConfigPanelProps) { }: UniversalFormModalConfigPanelProps) {
// config가 불완전할 수 있으므로 defaultConfig와 병합하여 안전하게 사용
const config: UniversalFormModalConfig = {
...defaultConfig,
...rawConfig,
modal: { ...defaultConfig.modal, ...rawConfig?.modal },
sections: rawConfig?.sections ?? defaultConfig.sections,
saveConfig: { ...defaultConfig.saveConfig, ...rawConfig?.saveConfig },
editMode: { ...defaultConfig.editMode, ...rawConfig?.editMode },
};
// 테이블 목록 // 테이블 목록
const [tables, setTables] = useState<{ name: string; label: string }[]>([]); const [tables, setTables] = useState<{ name: string; label: string }[]>([]);
const [tableColumns, setTableColumns] = useState<{ const [tableColumns, setTableColumns] = useState<{
@ -255,10 +265,10 @@ export function UniversalFormModalConfigPanel({
// 저장 테이블 변경 시 컬럼 로드 // 저장 테이블 변경 시 컬럼 로드
useEffect(() => { useEffect(() => {
if (config.saveConfig.tableName) { if (config.saveConfig?.tableName) {
loadTableColumns(config.saveConfig.tableName); loadTableColumns(config.saveConfig.tableName);
} }
}, [config.saveConfig.tableName]); }, [config.saveConfig?.tableName]);
const loadTables = async () => { const loadTables = async () => {
try { try {
@ -564,9 +574,9 @@ export function UniversalFormModalConfigPanel({
<div className="w-full min-w-0 space-y-3"> <div className="w-full min-w-0 space-y-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Label className="mb-1.5 block text-xs font-medium"> </Label> <Label className="mb-1.5 block text-xs font-medium"> </Label>
<p className="text-muted-foreground text-sm">{config.saveConfig.tableName || "(미설정)"}</p> <p className="text-muted-foreground text-sm">{config.saveConfig?.tableName || "(미설정)"}</p>
{config.saveConfig.customApiSave?.enabled && {config.saveConfig?.customApiSave?.enabled &&
config.saveConfig.customApiSave?.multiTable?.enabled && ( config.saveConfig?.customApiSave?.multiTable?.enabled && (
<Badge variant="secondary" className="mt-2 px-2 py-0.5 text-xs"> <Badge variant="secondary" className="mt-2 px-2 py-0.5 text-xs">
</Badge> </Badge>
@ -816,9 +826,9 @@ export function UniversalFormModalConfigPanel({
setSelectedField(field); setSelectedField(field);
setFieldDetailModalOpen(true); setFieldDetailModalOpen(true);
}} }}
tableName={config.saveConfig.tableName} tableName={config.saveConfig?.tableName}
tableColumns={ tableColumns={
tableColumns[config.saveConfig.tableName || ""]?.map((col) => ({ tableColumns[config.saveConfig?.tableName || ""]?.map((col) => ({
name: col.name, name: col.name,
type: col.type, type: col.type,
label: col.label || col.name, label: col.label || col.name,

View File

@ -68,22 +68,22 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
const getFileIcon = (fileExt: string) => { const getFileIcon = (fileExt: string) => {
const ext = fileExt.toLowerCase(); const ext = fileExt.toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) { if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) {
return <ImageIcon className="w-5 h-5 text-blue-500" />; return <ImageIcon className="h-5 w-5 text-blue-500" />;
} else if (['pdf', 'doc', 'docx', 'txt', 'rtf'].includes(ext)) { } else if (["pdf", "doc", "docx", "txt", "rtf"].includes(ext)) {
return <FileText className="w-5 h-5 text-red-500" />; return <FileText className="h-5 w-5 text-red-500" />;
} else if (['xls', 'xlsx', 'csv'].includes(ext)) { } else if (["xls", "xlsx", "csv"].includes(ext)) {
return <FileText className="w-5 h-5 text-green-500" />; return <FileText className="h-5 w-5 text-green-500" />;
} else if (['ppt', 'pptx'].includes(ext)) { } else if (["ppt", "pptx"].includes(ext)) {
return <Presentation className="w-5 h-5 text-orange-500" />; return <Presentation className="h-5 w-5 text-orange-500" />;
} else if (['mp4', 'avi', 'mov', 'webm'].includes(ext)) { } else if (["mp4", "avi", "mov", "webm"].includes(ext)) {
return <Video className="w-5 h-5 text-purple-500" />; return <Video className="h-5 w-5 text-purple-500" />;
} else if (['mp3', 'wav', 'ogg'].includes(ext)) { } else if (["mp3", "wav", "ogg"].includes(ext)) {
return <Music className="w-5 h-5 text-pink-500" />; return <Music className="h-5 w-5 text-pink-500" />;
} else if (['zip', 'rar', '7z'].includes(ext)) { } else if (["zip", "rar", "7z"].includes(ext)) {
return <Archive className="w-5 h-5 text-yellow-500" />; return <Archive className="h-5 w-5 text-yellow-500" />;
} else { } else {
return <File className="w-5 h-5 text-gray-500" />; return <File className="h-5 w-5 text-gray-500" />;
} }
}; };
@ -95,12 +95,12 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
try { try {
const fileArray = Array.from(files); const fileArray = Array.from(files);
await onFileUpload(fileArray); await onFileUpload(fileArray);
console.log('✅ FileManagerModal: 파일 업로드 완료'); console.log("✅ FileManagerModal: 파일 업로드 완료");
} catch (error) { } catch (error) {
console.error('❌ FileManagerModal: 파일 업로드 오류:', error); console.error("❌ FileManagerModal: 파일 업로드 오류:", error);
} finally { } finally {
setUploading(false); setUploading(false);
console.log('🔄 FileManagerModal: 업로드 상태 초기화'); console.log("🔄 FileManagerModal: 업로드 상태 초기화");
} }
}; };
@ -137,7 +137,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
handleFileUpload(files); handleFileUpload(files);
} }
// 입력값 초기화 // 입력값 초기화
e.target.value = ''; e.target.value = "";
}; };
// 파일 뷰어 핸들러 // 파일 뷰어 핸들러
@ -159,8 +159,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
// 이미지 파일인 경우 미리보기 로드 // 이미지 파일인 경우 미리보기 로드
// 🔑 점(.)을 제거하고 확장자만 비교 // 🔑 점(.)을 제거하고 확장자만 비교
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']; const imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
const ext = file.fileExt.toLowerCase().replace('.', ''); const ext = file.fileExt.toLowerCase().replace(".", "");
if (imageExtensions.includes(ext) || file.isImage) { if (imageExtensions.includes(ext) || file.isImage) {
try { try {
// 이전 Blob URL 해제 // 이전 Blob URL 해제
@ -171,7 +171,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
// 🔑 항상 apiClient를 통해 Blob 다운로드 (Docker 환경에서 상대 경로 문제 방지) // 🔑 항상 apiClient를 통해 Blob 다운로드 (Docker 환경에서 상대 경로 문제 방지)
const { apiClient } = await import("@/lib/api/client"); const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/files/preview/${file.objid}`, { const response = await apiClient.get(`/files/preview/${file.objid}`, {
responseType: 'blob' responseType: "blob",
}); });
const blob = new Blob([response.data]); const blob = new Blob([response.data]);
@ -238,32 +238,19 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
return ( return (
<> <>
<Dialog open={isOpen} onOpenChange={() => {}}> <Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent className="max-w-[95vw] w-[1400px] max-h-[90vh] overflow-hidden [&>button]:hidden"> <DialogContent className="max-h-[90vh] w-[1400px] max-w-[95vw] overflow-hidden [&>button]:hidden">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<DialogTitle className="text-lg font-semibold"> <DialogTitle className="text-lg font-semibold"> ({uploadedFiles.length})</DialogTitle>
({uploadedFiles.length}) <Button variant="ghost" size="sm" className="h-8 w-8 p-0 hover:bg-gray-100" onClick={onClose} title="닫기">
</DialogTitle> <X className="h-4 w-4" />
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:bg-gray-100"
onClick={onClose}
title="닫기"
>
<X className="w-4 h-4" />
</Button> </Button>
</DialogHeader> </DialogHeader>
<div className="flex flex-col space-y-3 h-[75vh]"> <div className="flex h-[75vh] flex-col space-y-3">
{/* 파일 업로드 영역 - 높이 축소 */} {/* 파일 업로드 영역 - 높이 축소 */}
{!isDesignMode && ( {!isDesignMode && (
<div <div
className={` className={`cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors ${dragOver ? "border-blue-400 bg-blue-50" : "border-gray-300"} ${config.disabled ? "cursor-not-allowed opacity-50" : "hover:border-gray-400"} ${uploading ? "opacity-75" : ""} `}
border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
${config.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
${uploading ? 'opacity-75' : ''}
`}
onClick={() => { onClick={() => {
if (!config.disabled && !isDesignMode) { if (!config.disabled && !isDesignMode) {
fileInputRef.current?.click(); fileInputRef.current?.click();
@ -285,44 +272,40 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
{uploading ? ( {uploading ? (
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div> <div className="h-5 w-5 animate-spin rounded-full border-b-2 border-blue-600"></div>
<span className="text-sm text-blue-600 font-medium"> ...</span> <span className="text-sm font-medium text-blue-600"> ...</span>
</div> </div>
) : ( ) : (
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">
<Upload className="h-6 w-6 text-gray-400" /> <Upload className="h-6 w-6 text-gray-400" />
<p className="text-sm font-medium text-gray-700"> <p className="text-sm font-medium text-gray-700"> </p>
</p>
</div> </div>
)} )}
</div> </div>
)} )}
{/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */} {/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
<div className="flex-1 flex gap-4 min-h-0"> <div className="flex min-h-0 flex-1 gap-4">
{/* 좌측: 이미지 미리보기 (확대/축소 가능) */} {/* 좌측: 이미지 미리보기 (확대/축소 가능) */}
<div className="flex-1 border border-gray-200 rounded-lg bg-gray-900 flex flex-col overflow-hidden relative"> <div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 bg-gray-900">
{/* 확대/축소 컨트롤 */} {/* 확대/축소 컨트롤 */}
{selectedFile && previewImageUrl && ( {selectedFile && previewImageUrl && (
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 bg-black/60 rounded-lg p-1"> <div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-lg bg-black/60 p-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-white hover:bg-white/20" className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setZoomLevel(prev => Math.max(0.25, prev - 0.25))} onClick={() => setZoomLevel((prev) => Math.max(0.25, prev - 0.25))}
disabled={zoomLevel <= 0.25} disabled={zoomLevel <= 0.25}
> >
<ZoomOut className="h-4 w-4" /> <ZoomOut className="h-4 w-4" />
</Button> </Button>
<span className="text-white text-xs min-w-[50px] text-center"> <span className="min-w-[50px] text-center text-xs text-white">{Math.round(zoomLevel * 100)}%</span>
{Math.round(zoomLevel * 100)}%
</span>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-white hover:bg-white/20" className="h-8 w-8 text-white hover:bg-white/20"
onClick={() => setZoomLevel(prev => Math.min(4, prev + 0.25))} onClick={() => setZoomLevel((prev) => Math.min(4, prev + 0.25))}
disabled={zoomLevel >= 4} disabled={zoomLevel >= 4}
> >
<ZoomIn className="h-4 w-4" /> <ZoomIn className="h-4 w-4" />
@ -341,14 +324,14 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
{/* 이미지 미리보기 영역 - 마우스 휠로 확대/축소, 드래그로 이동 */} {/* 이미지 미리보기 영역 - 마우스 휠로 확대/축소, 드래그로 이동 */}
<div <div
ref={imageContainerRef} ref={imageContainerRef}
className={`flex-1 flex items-center justify-center overflow-hidden p-4 ${ className={`flex flex-1 items-center justify-center overflow-hidden p-4 ${
zoomLevel > 1 ? (isDragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in' zoomLevel > 1 ? (isDragging ? "cursor-grabbing" : "cursor-grab") : "cursor-zoom-in"
}`} }`}
onWheel={(e) => { onWheel={(e) => {
if (selectedFile && previewImageUrl) { if (selectedFile && previewImageUrl) {
e.preventDefault(); e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1; const delta = e.deltaY > 0 ? -0.1 : 0.1;
setZoomLevel(prev => Math.min(4, Math.max(0.25, prev + delta))); setZoomLevel((prev) => Math.min(4, Math.max(0.25, prev + delta)));
} }
}} }}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
@ -363,7 +346,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
className="transition-transform duration-100 select-none" className="transition-transform duration-100 select-none"
style={{ style={{
transform: `translate(${imagePosition.x}px, ${imagePosition.y}px) scale(${zoomLevel})`, transform: `translate(${imagePosition.x}px, ${imagePosition.y}px) scale(${zoomLevel})`,
transformOrigin: 'center center', transformOrigin: "center center",
}} }}
draggable={false} draggable={false}
/> />
@ -374,7 +357,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center text-gray-400"> <div className="flex flex-col items-center text-gray-400">
<ImageIcon className="w-16 h-16 mb-2" /> <ImageIcon className="mb-2 h-16 w-16" />
<p className="text-sm"> </p> <p className="text-sm"> </p>
</div> </div>
)} )}
@ -382,19 +365,17 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
{/* 파일 정보 바 */} {/* 파일 정보 바 */}
{selectedFile && ( {selectedFile && (
<div className="bg-black/60 text-white text-xs px-3 py-2 text-center truncate"> <div className="truncate bg-black/60 px-3 py-2 text-center text-xs text-white">
{selectedFile.realFileName} {selectedFile.realFileName}
</div> </div>
)} )}
</div> </div>
{/* 우측: 파일 목록 (고정 너비) */} {/* 우측: 파일 목록 (고정 너비) */}
<div className="w-[400px] shrink-0 border border-gray-200 rounded-lg overflow-hidden flex flex-col"> <div className="flex w-[400px] shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200">
<div className="p-3 border-b border-gray-200 bg-gray-50"> <div className="border-b border-gray-200 bg-gray-50 p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700"> <h3 className="text-sm font-medium text-gray-700"> </h3>
</h3>
{uploadedFiles.length > 0 && ( {uploadedFiles.length > 0 && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))} {formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
@ -409,20 +390,13 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
{uploadedFiles.map((file) => ( {uploadedFiles.map((file) => (
<div <div
key={file.objid} key={file.objid}
className={` className={`flex cursor-pointer items-center space-x-3 rounded-lg p-2 transition-colors ${selectedFile?.objid === file.objid ? "border border-blue-200 bg-blue-50" : "bg-gray-50 hover:bg-gray-100"} `}
flex items-center space-x-3 p-2 rounded-lg transition-colors cursor-pointer
${selectedFile?.objid === file.objid ? 'bg-blue-50 border border-blue-200' : 'bg-gray-50 hover:bg-gray-100'}
`}
onClick={() => handleFileClick(file)} onClick={() => handleFileClick(file)}
> >
<div className="flex-shrink-0"> <div className="flex-shrink-0">{getFileIcon(file.fileExt)}</div>
{getFileIcon(file.fileExt)} <div className="min-w-0 flex-1">
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 truncate"> <span className="truncate text-sm font-medium text-gray-900">{file.realFileName}</span>
{file.realFileName}
</span>
{file.isRepresentative && ( {file.isRepresentative && (
<Badge variant="default" className="h-5 px-1.5 text-xs"> <Badge variant="default" className="h-5 px-1.5 text-xs">
@ -445,7 +419,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
}} }}
title={file.isRepresentative ? "현재 대표 파일" : "대표 파일로 설정"} title={file.isRepresentative ? "현재 대표 파일" : "대표 파일로 설정"}
> >
<Star className={`w-3 h-3 ${file.isRepresentative ? "fill-white" : ""}`} /> <Star className={`h-3 w-3 ${file.isRepresentative ? "fill-white" : ""}`} />
</Button> </Button>
)} )}
<Button <Button
@ -458,7 +432,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
}} }}
title="미리보기" title="미리보기"
> >
<Eye className="w-3 h-3" /> <Eye className="h-3 w-3" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@ -470,7 +444,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
}} }}
title="다운로드" title="다운로드"
> >
<Download className="w-3 h-3" /> <Download className="h-3 w-3" />
</Button> </Button>
{!isDesignMode && ( {!isDesignMode && (
<Button <Button
@ -483,7 +457,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
}} }}
title="삭제" title="삭제"
> >
<Trash2 className="w-3 h-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
)} )}
</div> </div>
@ -492,10 +466,12 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center py-8 text-gray-500"> <div className="flex flex-col items-center justify-center py-8 text-gray-500">
<File className="w-12 h-12 mb-3 text-gray-300" /> <File className="mb-3 h-12 w-12 text-gray-300" />
<p className="text-sm font-medium text-gray-600"> </p> <p className="text-sm font-medium text-gray-600"> </p>
<p className="text-xs text-gray-500 mt-1"> <p className="mt-1 text-xs text-gray-500">
{isDesignMode ? '디자인 모드에서는 파일을 업로드할 수 없습니다' : '위의 영역에 파일을 업로드하세요'} {isDesignMode
? "디자인 모드에서는 파일을 업로드할 수 없습니다"
: "위의 영역에 파일을 업로드하세요"}
</p> </p>
</div> </div>
)} )}

View File

@ -20,6 +20,7 @@ interface V2RepeaterRendererProps {
onRowClick?: (row: any) => void; onRowClick?: (row: any) => void;
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void; onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
parentId?: string | number; parentId?: string | number;
formData?: Record<string, any>;
} }
const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
@ -31,6 +32,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
onRowClick, onRowClick,
onButtonClick, onButtonClick,
parentId, parentId,
formData,
}) => { }) => {
// component.componentConfig 또는 component.config에서 V2RepeaterConfig 추출 // component.componentConfig 또는 component.config에서 V2RepeaterConfig 추출
const config: V2RepeaterConfig = React.useMemo(() => { const config: V2RepeaterConfig = React.useMemo(() => {
@ -101,6 +103,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
onRowClick={onRowClick} onRowClick={onRowClick}
onButtonClick={onButtonClick} onButtonClick={onButtonClick}
className={component?.className} className={component?.className}
formData={formData}
/> />
); );
}; };

View File

@ -70,18 +70,18 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
} }
// 🆕 formData에 값이 없고 기본값이 설정된 경우, 기본값 적용 // 🆕 formData에 값이 없고 기본값이 설정된 경우, 기본값 적용
// 단, formData에 해당 키가 이미 존재하면(사용자가 명시적으로 초기화한 경우) 기본값을 재적용하지 않음
const hasKeyInFormData = formData !== undefined && formData !== null && columnName in (formData || {});
if ( if (
(currentValue === "" || currentValue === undefined || currentValue === null) && (currentValue === "" || currentValue === undefined || currentValue === null) &&
defaultValue && defaultValue &&
isInteractive && isInteractive &&
onFormDataChange && onFormDataChange &&
columnName columnName &&
!hasKeyInFormData // formData에 키 자체가 없을 때만 기본값 적용 (초기 렌더링)
) { ) {
// 초기 렌더링 시 기본값을 formData에 설정
setTimeout(() => { setTimeout(() => {
if (!formData?.[columnName]) {
onFormDataChange(columnName, defaultValue); onFormDataChange(columnName, defaultValue);
}
}, 0); }, 0);
currentValue = defaultValue; currentValue = defaultValue;
} }

View File

@ -1183,31 +1183,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
} }
// leftItem이 null이면 join 모드 이외에는 데이터 로드 불가 // leftItem이 null이면 join 모드 이외에는 데이터 로드 불가
// detail 모드: 선택 안 하면 아무것도 안 뜸, 선택하면 필터링
// join 모드: 선택 안 하면 전체, 선택하면 필터링
if (!leftItem) return; if (!leftItem) return;
setIsLoadingRight(true); setIsLoadingRight(true);
try { try {
if (relationshipType === "detail") { // detail / join 모두 동일한 필터링 로직 사용
// 상세 모드: 동일 테이블의 상세 정보 (엔티티 조인 활성화) // (차이점: 초기 로드 여부만 다름 - detail은 초기 로드 안 함)
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0]; {
// 🆕 엔티티 조인 API 사용
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const rightDetailJoinColumns = extractAdditionalJoinColumns(
componentConfig.rightPanel?.columns,
rightTableName,
);
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
search: { id: primaryKey },
enableEntityJoin: true,
size: 1,
companyCodeOverride: companyCode,
additionalJoinColumns: rightDetailJoinColumns, // 🆕 Entity 조인 컬럼 전달
});
const detail = result.items && result.items.length > 0 ? result.items[0] : null;
setRightData(detail);
} else if (relationshipType === "join") {
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개) // 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
const keys = componentConfig.rightPanel?.relation?.keys; const keys = componentConfig.rightPanel?.relation?.keys;
const leftTable = componentConfig.leftPanel?.tableName; const leftTable = componentConfig.leftPanel?.tableName;
@ -1443,8 +1427,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 탭의 dataFilter (API 전달용) // 탭의 dataFilter (API 전달용)
const tabDataFilterForApi = (tabConfig as any).dataFilter; const tabDataFilterForApi = (tabConfig as any).dataFilter;
// 탭의 relation type 확인 (detail이면 초기 전체 로드 안 함)
const tabRelationType = tabConfig.relation?.type || "join";
if (!leftItem) { if (!leftItem) {
// 좌측 미선택: 전체 데이터 로드 (dataFilter는 API에 전달) if (tabRelationType === "detail") {
// detail 모드: 선택 안 하면 아무것도 안 뜸
resultData = [];
} else {
// join 모드: 좌측 미선택 시 전체 데이터 로드 (dataFilter는 API에 전달)
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
enableEntityJoin: true, enableEntityJoin: true,
size: 1000, size: 1000,
@ -1453,6 +1444,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
dataFilter: tabDataFilterForApi, dataFilter: tabDataFilterForApi,
}); });
resultData = result.data || []; resultData = result.data || [];
}
} else if (leftColumn && rightColumn) { } else if (leftColumn && rightColumn) {
const searchConditions: Record<string, any> = {}; const searchConditions: Record<string, any> = {};
@ -1534,22 +1526,30 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
[componentConfig.rightPanel?.additionalTabs, isDesignMode, toast], [componentConfig.rightPanel?.additionalTabs, isDesignMode, toast],
); );
// 탭 변경 핸들러 (좌측 미선택 시에도 전체 데이터 로드) // 탭 변경 핸들러
const handleTabChange = useCallback( const handleTabChange = useCallback(
(newTabIndex: number) => { (newTabIndex: number) => {
setActiveTabIndex(newTabIndex); setActiveTabIndex(newTabIndex);
// 메인 패널이 "detail"(선택 시 표시)이면 좌측 미선택 시 데이터 로드하지 않음
const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail";
const requireSelection = mainRelationType === "detail";
if (newTabIndex === 0) { if (newTabIndex === 0) {
if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) { if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) {
if (!requireSelection || selectedLeftItem) {
loadRightData(selectedLeftItem); loadRightData(selectedLeftItem);
} }
}
} else { } else {
if (!tabsData[newTabIndex]) { if (!tabsData[newTabIndex]) {
if (!requireSelection || selectedLeftItem) {
loadTabData(newTabIndex, selectedLeftItem); loadTabData(newTabIndex, selectedLeftItem);
} }
} }
}
}, },
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData], [selectedLeftItem, rightData, tabsData, loadRightData, loadTabData, componentConfig.rightPanel?.relation?.type],
); );
// 좌측 항목 선택 핸들러 (동일 항목 재클릭 시 선택 해제 → 전체 데이터 표시) // 좌측 항목 선택 핸들러 (동일 항목 재클릭 시 선택 해제 → 전체 데이터 표시)
@ -1562,17 +1562,23 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
selectedLeftItem[leftPk] === item[leftPk]; selectedLeftItem[leftPk] === item[leftPk];
if (isSameItem) { if (isSameItem) {
// 선택 해제 → 전체 데이터 로드 // 선택 해제
setSelectedLeftItem(null); setSelectedLeftItem(null);
setCustomLeftSelectedData({}); // 커스텀 모드 우측 폼 데이터 초기화 setCustomLeftSelectedData({});
setExpandedRightItems(new Set()); setExpandedRightItems(new Set());
setTabsData({}); setTabsData({});
const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail";
if (mainRelationType === "detail") {
// "선택 시 표시" 모드: 선택 해제 시 데이터 비움
setRightData(null);
} else {
// "연관 목록" 모드: 선택 해제 시 전체 데이터 로드
if (activeTabIndex === 0) { if (activeTabIndex === 0) {
loadRightData(null); loadRightData(null);
} else { } else {
loadTabData(activeTabIndex, null); loadTabData(activeTabIndex, null);
} }
// 추가 탭들도 전체 데이터 로드
const tabs = componentConfig.rightPanel?.additionalTabs; const tabs = componentConfig.rightPanel?.additionalTabs;
if (tabs && tabs.length > 0) { if (tabs && tabs.length > 0) {
tabs.forEach((_: any, idx: number) => { tabs.forEach((_: any, idx: number) => {
@ -1581,6 +1587,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
} }
}); });
} }
}
return; return;
} }
@ -2781,14 +2788,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (!isDesignMode && componentConfig.autoLoad !== false) { if (!isDesignMode && componentConfig.autoLoad !== false) {
loadLeftData(); loadLeftData();
// 좌측 미선택 상태에서 우측 전체 데이터 기본 로드 // 좌측 미선택 상태에서 우측 전체 데이터 기본 로드
// join 모드: 초기 전체 로드 / detail 모드: 초기 로드 안 함
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail"; const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
if (relationshipType === "join") { if (relationshipType === "join") {
loadRightData(null); loadRightData(null);
// 추가 탭도 전체 데이터 로드 }
// 추가 탭: 메인 패널이 "detail"(선택 시 표시)이면 추가 탭도 초기 로드하지 않음
if (relationshipType !== "detail") {
const tabs = componentConfig.rightPanel?.additionalTabs; const tabs = componentConfig.rightPanel?.additionalTabs;
if (tabs && tabs.length > 0) { if (tabs && tabs.length > 0) {
tabs.forEach((_: any, idx: number) => { tabs.forEach((tab: any, idx: number) => {
const tabRelType = tab.relation?.type || "join";
if (tabRelType === "join") {
loadTabData(idx + 1, null); loadTabData(idx + 1, null);
}
}); });
} }
} }
@ -3738,6 +3751,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const currentTabData = tabsData[activeTabIndex] || []; const currentTabData = tabsData[activeTabIndex] || [];
const isTabLoading = tabsLoading[activeTabIndex]; const isTabLoading = tabsLoading[activeTabIndex];
// 메인 패널이 "detail"(선택 시 표시)이면 좌측 미선택 시 안내 메시지
const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail";
if (mainRelationType === "detail" && !selectedLeftItem && !isDesignMode) {
return (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 py-12 text-sm">
<p> </p>
<p className="text-xs"> </p>
</div>
);
}
if (isTabLoading) { if (isTabLoading) {
return ( return (
<div className="flex h-32 items-center justify-center"> <div className="flex h-32 items-center justify-center">
@ -4645,7 +4669,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
) : ( ) : (
<> <>
<p className="mb-2"> </p> <p className="mb-2"> </p>
<p className="text-xs"> </p> <p className="text-xs"> </p>
</> </>
)} )}
</div> </div>

View File

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

View File

@ -180,7 +180,9 @@ export interface V2RepeaterProps {
data?: any[]; // 초기 데이터 (없으면 API로 로드) data?: any[]; // 초기 데이터 (없으면 API로 로드)
onDataChange?: (data: any[]) => void; onDataChange?: (data: any[]) => void;
onRowClick?: (row: any) => void; onRowClick?: (row: any) => void;
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
className?: string; className?: string;
formData?: Record<string, any>; // 수정 모드에서 FK 기반 데이터 로드용
} }
// 기본 설정값 // 기본 설정값