Compare commits

..

No commits in common. "eb27f0161630959f6604cf84875d8bc0754d4dff" and "55cbd8778aec7d9dbc38d71f891364fe788611b1" have entirely different histories.

23 changed files with 532 additions and 1722 deletions

View File

@ -1769,7 +1769,6 @@ export async function getCategoryColumnsByCompany(
let columnsResult; let columnsResult;
// 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회 // 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회
// category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함)
if (companyCode === "*") { if (companyCode === "*") {
const columnsQuery = ` const columnsQuery = `
SELECT DISTINCT SELECT DISTINCT
@ -1789,15 +1788,15 @@ export async function getCategoryColumnsByCompany(
ON ttc.table_name = tl.table_name ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category' WHERE ttc.input_type = 'category'
AND ttc.company_code = '*' AND ttc.company_code = '*'
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name ORDER BY ttc.table_name, ttc.column_name
`; `;
columnsResult = await pool.query(columnsQuery); columnsResult = await pool.query(columnsQuery);
logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료 (참조 제외)", { logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료", {
rowCount: columnsResult.rows.length rowCount: columnsResult.rows.length
}); });
} else { } else {
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
const columnsQuery = ` const columnsQuery = `
SELECT DISTINCT SELECT DISTINCT
ttc.table_name AS "tableName", ttc.table_name AS "tableName",
@ -1816,12 +1815,11 @@ export async function getCategoryColumnsByCompany(
ON ttc.table_name = tl.table_name ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category' WHERE ttc.input_type = 'category'
AND ttc.company_code = $1 AND ttc.company_code = $1
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name ORDER BY ttc.table_name, ttc.column_name
`; `;
columnsResult = await pool.query(columnsQuery, [companyCode]); columnsResult = await pool.query(columnsQuery, [companyCode]);
logger.info("회사별 카테고리 컬럼 조회 완료 (참조 제외)", { logger.info("회사별 카테고리 컬럼 조회 완료", {
companyCode, companyCode,
rowCount: columnsResult.rows.length rowCount: columnsResult.rows.length
}); });
@ -1882,10 +1880,13 @@ export async function getCategoryColumnsByMenu(
const { getPool } = await import("../database/db"); const { getPool } = await import("../database/db");
const pool = getPool(); const pool = getPool();
// table_type_columns에서 input_type = 'category' 컬럼 조회 // 🆕 table_type_columns에서 직접 input_type = 'category'인 컬럼들을 조회
// category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함) // category_column_mapping 대신 table_type_columns 기준으로 조회
logger.info("🔍 table_type_columns 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
let columnsResult; let columnsResult;
// 최고 관리자인 경우 모든 회사의 카테고리 컬럼 조회
if (companyCode === "*") { if (companyCode === "*") {
const columnsQuery = ` const columnsQuery = `
SELECT DISTINCT SELECT DISTINCT
@ -1905,15 +1906,15 @@ export async function getCategoryColumnsByMenu(
ON ttc.table_name = tl.table_name ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category' WHERE ttc.input_type = 'category'
AND ttc.company_code = '*' AND ttc.company_code = '*'
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name ORDER BY ttc.table_name, ttc.column_name
`; `;
columnsResult = await pool.query(columnsQuery); columnsResult = await pool.query(columnsQuery);
logger.info("최고 관리자: 메뉴별 카테고리 컬럼 조회 완료 (참조 제외)", { logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", {
rowCount: columnsResult.rows.length rowCount: columnsResult.rows.length
}); });
} else { } else {
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
const columnsQuery = ` const columnsQuery = `
SELECT DISTINCT SELECT DISTINCT
ttc.table_name AS "tableName", ttc.table_name AS "tableName",
@ -1932,12 +1933,11 @@ export async function getCategoryColumnsByMenu(
ON ttc.table_name = tl.table_name ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category' WHERE ttc.input_type = 'category'
AND ttc.company_code = $1 AND ttc.company_code = $1
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name ORDER BY ttc.table_name, ttc.column_name
`; `;
columnsResult = await pool.query(columnsQuery, [companyCode]); columnsResult = await pool.query(columnsQuery, [companyCode]);
logger.info("회사별 메뉴 카테고리 컬럼 조회 완료 (참조 제외)", { logger.info("회사별 카테고리 컬럼 조회 완료", {
companyCode, companyCode,
rowCount: columnsResult.rows.length rowCount: columnsResult.rows.length
}); });

View File

@ -518,8 +518,8 @@ export class TableManagementService {
table_name, column_name, column_label, input_type, detail_settings, table_name, column_name, column_label, input_type, detail_settings,
code_category, code_value, reference_table, reference_column, code_category, code_value, reference_table, reference_column,
display_column, display_order, is_visible, is_nullable, display_column, display_order, is_visible, is_nullable,
company_code, category_ref, created_date, updated_date company_code, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, $14, NOW(), NOW()) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code) ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET DO UPDATE SET
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label), column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
@ -532,7 +532,6 @@ export class TableManagementService {
display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column), display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column),
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order), display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order),
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible), is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible),
category_ref = EXCLUDED.category_ref,
updated_date = NOW()`, updated_date = NOW()`,
[ [
tableName, tableName,
@ -548,7 +547,6 @@ export class TableManagementService {
settings.displayOrder || 0, settings.displayOrder || 0,
settings.isVisible !== undefined ? settings.isVisible : true, settings.isVisible !== undefined ? settings.isVisible : true,
companyCode, companyCode,
settings.categoryRef || null,
] ]
); );
@ -4555,8 +4553,7 @@ export class TableManagementService {
END as "detailSettings", END as "detailSettings",
ttc.is_nullable as "isNullable", ttc.is_nullable as "isNullable",
ic.data_type as "dataType", ic.data_type as "dataType",
ttc.company_code as "companyCode", ttc.company_code as "companyCode"
ttc.category_ref as "categoryRef"
FROM table_type_columns ttc FROM table_type_columns ttc
LEFT JOIN information_schema.columns ic LEFT JOIN information_schema.columns ic
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
@ -4633,24 +4630,20 @@ export class TableManagementService {
} }
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => { const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => {
const baseInfo: any = { const baseInfo = {
tableName: tableName, tableName: tableName,
columnName: col.columnName, columnName: col.columnName,
displayName: col.displayName, displayName: col.displayName,
dataType: col.dataType || "varchar", dataType: col.dataType || "varchar",
inputType: col.inputType, inputType: col.inputType,
detailSettings: col.detailSettings, detailSettings: col.detailSettings,
description: "", description: "", // 필수 필드 추가
isNullable: col.isNullable === "Y" ? "Y" : "N", isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환
isPrimaryKey: false, isPrimaryKey: false,
displayOrder: 0, displayOrder: 0,
isVisible: true, isVisible: true,
}; };
if (col.categoryRef) {
baseInfo.categoryRef = col.categoryRef;
}
// 카테고리 타입인 경우 categoryMenus 추가 // 카테고리 타입인 경우 categoryMenus 추가
if ( if (
col.inputType === "category" && col.inputType === "category" &&

View File

@ -73,10 +73,9 @@ interface ColumnTypeInfo {
referenceTable?: string; referenceTable?: string;
referenceColumn?: string; referenceColumn?: string;
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명 displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
categoryMenus?: number[]; categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열
hierarchyRole?: "large" | "medium" | "small"; hierarchyRole?: "large" | "medium" | "small"; // 🆕 계층구조 역할
numberingRuleId?: string; numberingRuleId?: string; // 🆕 Numbering 타입: 채번규칙 ID
categoryRef?: string | null;
} }
interface SecondLevelMenu { interface SecondLevelMenu {
@ -389,7 +388,6 @@ export default function TableManagementPage() {
numberingRuleId, numberingRuleId,
categoryMenus: col.categoryMenus || [], categoryMenus: col.categoryMenus || [],
hierarchyRole, hierarchyRole,
categoryRef: col.categoryRef || null,
}; };
}); });
@ -672,16 +670,15 @@ export default function TableManagementPage() {
} }
const columnSetting = { const columnSetting = {
columnName: column.columnName, columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
columnLabel: column.displayName, columnLabel: column.displayName, // 사용자가 입력한 표시명
inputType: column.inputType || "text", inputType: column.inputType || "text",
detailSettings: finalDetailSettings, detailSettings: finalDetailSettings,
codeCategory: column.codeCategory || "", codeCategory: column.codeCategory || "",
codeValue: column.codeValue || "", codeValue: column.codeValue || "",
referenceTable: column.referenceTable || "", referenceTable: column.referenceTable || "",
referenceColumn: column.referenceColumn || "", referenceColumn: column.referenceColumn || "",
displayColumn: column.displayColumn || "", displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
categoryRef: column.categoryRef || null,
}; };
// console.log("저장할 컬럼 설정:", columnSetting); // console.log("저장할 컬럼 설정:", columnSetting);
@ -708,9 +705,9 @@ export default function TableManagementPage() {
length: column.categoryMenus?.length || 0, length: column.categoryMenus?.length || 0,
}); });
if (column.inputType === "category" && !column.categoryRef) { if (column.inputType === "category") {
// 참조가 아닌 자체 카테고리만 메뉴 매핑 처리 // 1. 먼저 기존 매핑 모두 삭제
console.log("기존 카테고리 메뉴 매핑 삭제 시작:", { console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제 시작:", {
tableName: selectedTable, tableName: selectedTable,
columnName: column.columnName, columnName: column.columnName,
}); });
@ -869,8 +866,8 @@ export default function TableManagementPage() {
} }
return { return {
columnName: column.columnName, columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
columnLabel: column.displayName, columnLabel: column.displayName, // 사용자가 입력한 표시명
inputType: column.inputType || "text", inputType: column.inputType || "text",
detailSettings: finalDetailSettings, detailSettings: finalDetailSettings,
description: column.description || "", description: column.description || "",
@ -878,8 +875,7 @@ export default function TableManagementPage() {
codeValue: column.codeValue || "", codeValue: column.codeValue || "",
referenceTable: column.referenceTable || "", referenceTable: column.referenceTable || "",
referenceColumn: column.referenceColumn || "", referenceColumn: column.referenceColumn || "",
displayColumn: column.displayColumn || "", displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
categoryRef: column.categoryRef || null,
}; };
}); });
@ -892,8 +888,8 @@ export default function TableManagementPage() {
); );
if (response.data.success) { if (response.data.success) {
// 자체 카테고리 컬럼만 메뉴 매핑 처리 (참조 컬럼 제외) // 🆕 Category 타입 컬럼들의 메뉴 매핑 처리
const categoryColumns = columns.filter((col) => col.inputType === "category" && !col.categoryRef); const categoryColumns = columns.filter((col) => col.inputType === "category");
console.log("📥 전체 저장: 카테고리 컬럼 확인", { console.log("📥 전체 저장: 카테고리 컬럼 확인", {
totalColumns: columns.length, totalColumns: columns.length,
@ -1695,30 +1691,7 @@ export default function TableManagementPage() {
)} )}
</> </>
)} )}
{/* 카테고리 타입: 참조 설정 */} {/* 카테고리 타입: 메뉴 종속성 제거됨 - 테이블/컬럼 단위로 관리 */}
{column.inputType === "category" && (
<div className="w-56">
<label className="text-muted-foreground mb-1 block text-xs"> ()</label>
<Input
value={column.categoryRef || ""}
onChange={(e) => {
const val = e.target.value || null;
setColumns((prev) =>
prev.map((c) =>
c.columnName === column.columnName
? { ...c, categoryRef: val }
: c
)
);
}}
placeholder="테이블명.컬럼명"
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-0.5 text-[10px]">
</p>
</div>
)}
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.inputType === "entity" && ( {column.inputType === "entity" && (
<> <>

View File

@ -87,12 +87,10 @@ function ScreenViewPage() {
// 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이) // 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이)
const [conditionalContainerHeights, setConditionalContainerHeights] = useState<Record<string, number>>({}); const [conditionalContainerHeights, setConditionalContainerHeights] = useState<Record<string, number>>({});
// 레이어 시스템 지원 // 🆕 레이어 시스템 지원
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]); const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
// 조건부 영역(Zone) 목록 // 🆕 조건부 영역(Zone) 목록
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]); const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
// 데이터 전달에 의해 강제 활성화된 레이어 ID 목록
const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState<string[]>([]);
// 편집 모달 상태 // 편집 모달 상태
const [editModalOpen, setEditModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false);
@ -380,51 +378,11 @@ function ScreenViewPage() {
} }
}); });
// 강제 활성화된 레이어 ID 병합
for (const forcedId of forceActivatedLayerIds) {
if (!newActiveIds.includes(forcedId)) {
newActiveIds.push(forcedId);
}
}
return newActiveIds; return newActiveIds;
}, [formData, conditionalLayers, layout, forceActivatedLayerIds]); }, [formData, conditionalLayers, layout]);
// 데이터 전달에 의한 레이어 강제 활성화 이벤트 리스너 // 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
useEffect(() => { // 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움
const handleActivateLayer = (e: Event) => {
const { componentId, targetLayerId } = (e as CustomEvent).detail || {};
if (!componentId && !targetLayerId) return;
// targetLayerId가 직접 지정된 경우
if (targetLayerId) {
setForceActivatedLayerIds((prev) =>
prev.includes(targetLayerId) ? prev : [...prev, targetLayerId],
);
console.log(`🔓 [레이어 강제 활성화] layerId: ${targetLayerId}`);
return;
}
// componentId로 해당 컴포넌트가 속한 레이어를 찾아 활성화
for (const layer of conditionalLayers) {
const found = layer.components.some((comp) => comp.id === componentId);
if (found) {
setForceActivatedLayerIds((prev) =>
prev.includes(layer.id) ? prev : [...prev, layer.id],
);
console.log(`🔓 [레이어 강제 활성화] componentId: ${componentId} → layerId: ${layer.id}`);
return;
}
}
};
window.addEventListener("activateLayerForComponent", handleActivateLayer);
return () => {
window.removeEventListener("activateLayerForComponent", handleActivateLayer);
};
}, [conditionalLayers]);
// 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
useEffect(() => { useEffect(() => {
const loadMainTableData = async () => { const loadMainTableData = async () => {
if (!screen || !layout || !layout.components || !companyCode) { if (!screen || !layout || !layout.components || !companyCode) {

View File

@ -25,7 +25,6 @@ import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
import { ScreenContextProvider } from "@/contexts/ScreenContext";
interface ScreenModalState { interface ScreenModalState {
isOpen: boolean; isOpen: boolean;
@ -1026,10 +1025,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div> </div>
</div> </div>
) : screenData ? ( ) : screenData ? (
<ScreenContextProvider
screenId={modalState.screenId || undefined}
tableName={screenData.screenInfo?.tableName}
>
<ActiveTabProvider> <ActiveTabProvider>
<TableOptionsProvider> <TableOptionsProvider>
<div <div
@ -1259,7 +1254,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div> </div>
</TableOptionsProvider> </TableOptionsProvider>
</ActiveTabProvider> </ActiveTabProvider>
</ScreenContextProvider>
) : ( ) : (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p> <p className="text-muted-foreground"> .</p>

View File

@ -17,7 +17,6 @@ import { dynamicFormApi } from "@/lib/api/dynamicForm";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
import { ScreenContextProvider } from "@/contexts/ScreenContext";
interface EditModalState { interface EditModalState {
isOpen: boolean; isOpen: boolean;
@ -1155,6 +1154,19 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
if (response.success) { if (response.success) {
const masterRecordId = response.data?.id || formData.id; const masterRecordId = response.data?.id || formData.id;
// 🆕 리피터 데이터 저장 이벤트 발생 (V2Repeater 컴포넌트가 리스닝)
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
masterRecordId,
mainFormData: formData,
tableName: screenData.screenInfo.tableName,
},
}),
);
console.log("📋 [EditModal] repeaterSave 이벤트 발생:", { masterRecordId, tableName: screenData.screenInfo.tableName });
toast.success("데이터가 생성되었습니다."); toast.success("데이터가 생성되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
@ -1202,40 +1214,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
} }
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행)
try {
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
console.log("🟢 [EditModal] INSERT 후 repeaterSave 이벤트 발행:", {
parentId: masterRecordId,
tableName: screenData.screenInfo.tableName,
});
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
tableName: screenData.screenInfo.tableName,
mainFormData: formData,
masterRecordId,
},
}),
);
await repeaterSavePromise;
console.log("✅ [EditModal] INSERT 후 repeaterSave 완료");
} catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
}
handleClose(); handleClose();
} else { } else {
throw new Error(response.message || "생성에 실패했습니다."); throw new Error(response.message || "생성에 실패했습니다.");
@ -1341,40 +1319,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
} }
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행)
try {
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
console.log("🟢 [EditModal] UPDATE 후 repeaterSave 이벤트 발행:", {
parentId: recordId,
tableName: screenData.screenInfo.tableName,
});
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: recordId,
tableName: screenData.screenInfo.tableName,
mainFormData: formData,
masterRecordId: recordId,
},
}),
);
await repeaterSavePromise;
console.log("✅ [EditModal] UPDATE 후 repeaterSave 완료");
} catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
}
handleClose(); handleClose();
} else { } else {
throw new Error(response.message || "수정에 실패했습니다."); throw new Error(response.message || "수정에 실패했습니다.");
@ -1441,16 +1385,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
</div> </div>
</div> </div>
) : screenData ? ( ) : screenData ? (
<ScreenContextProvider
screenId={modalState.screenId || undefined}
tableName={screenData.screenInfo?.tableName}
>
<div <div
data-screen-runtime="true" data-screen-runtime="true"
className="relative bg-white" className="relative bg-white"
style={{ style={{
width: screenDimensions?.width || 800, width: screenDimensions?.width || 800,
// 조건부 레이어가 활성화되면 높이 자동 확장 // 🆕 조건부 레이어가 활성화되면 높이 자동 확장
height: (() => { height: (() => {
const baseHeight = (screenDimensions?.height || 600) + 30; const baseHeight = (screenDimensions?.height || 600) + 30;
if (activeConditionalComponents.length > 0) { if (activeConditionalComponents.length > 0) {
@ -1606,7 +1546,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
); );
})} })}
</div> </div>
</ScreenContextProvider>
) : ( ) : (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p> <p className="text-muted-foreground"> .</p>

View File

@ -571,38 +571,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
return; return;
} }
// 리피터가 화면과 동일 테이블을 사용하는지 감지 (useCustomTable 미설정 = 동일 테이블)
const hasRepeaterOnSameTable = allComponents.some((c: any) => {
const compType = c.componentType || c.overrides?.type;
if (compType !== "v2-repeater") return false;
const compConfig = c.componentConfig || c.overrides || {};
return !compConfig.useCustomTable;
});
if (hasRepeaterOnSameTable) {
// 동일 테이블 리피터: 마스터 저장 스킵, 리피터만 저장
// 리피터가 mainFormData를 각 행에 병합하여 N건 INSERT 처리
try {
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: null,
masterRecordId: null,
mainFormData: formData,
tableName: screenInfo.tableName,
},
}),
);
toast.success("데이터가 성공적으로 저장되었습니다.");
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
}
return;
}
try { try {
// 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장) // 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
// 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함 // 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함
const masterFormData: Record<string, any> = {}; const masterFormData: Record<string, any> = {};
@ -621,8 +591,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
Object.entries(formData).forEach(([key, value]) => { Object.entries(formData).forEach(([key, value]) => {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
// 배열이 아닌 값은 그대로 저장
masterFormData[key] = value; masterFormData[key] = value;
} else if (mediaColumnNames.has(key)) { } else if (mediaColumnNames.has(key)) {
// v2-media 컴포넌트의 배열은 첫 번째 값만 저장 (단일 파일 컬럼 대응)
// 또는 JSON 문자열로 변환하려면 JSON.stringify(value) 사용
masterFormData[key] = value.length > 0 ? value[0] : null; masterFormData[key] = value.length > 0 ? value[0] : null;
console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`); console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`);
} else { } else {
@ -635,6 +608,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
data: masterFormData, data: masterFormData,
}; };
// console.log("💾 저장 액션 실행:", saveData);
const response = await dynamicFormApi.saveData(saveData); const response = await dynamicFormApi.saveData(saveData);
if (response.success) { if (response.success) {
@ -645,7 +619,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
new CustomEvent("repeaterSave", { new CustomEvent("repeaterSave", {
detail: { detail: {
parentId: masterRecordId, parentId: masterRecordId,
masterRecordId, masterRecordId, // 🆕 마스터 레코드 ID (FK 자동 연결용)
mainFormData: formData, mainFormData: formData,
tableName: screenInfo.tableName, tableName: screenInfo.tableName,
}, },
@ -657,6 +631,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
toast.error(response.message || "저장에 실패했습니다."); toast.error(response.message || "저장에 실패했습니다.");
} }
} catch (error) { } catch (error) {
// console.error("저장 오류:", error);
toast.error("저장 중 오류가 발생했습니다."); toast.error("저장 중 오류가 발생했습니다.");
} }
}; };

View File

@ -551,12 +551,9 @@ export default function ScreenDesigner({
originalRegion: null, originalRegion: null,
}); });
// 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용) // 🆕 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용)
const [activeLayerZone, setActiveLayerZone] = useState<import("@/types/screen-management").ConditionalZone | null>(null); const [activeLayerZone, setActiveLayerZone] = useState<import("@/types/screen-management").ConditionalZone | null>(null);
// 다른 레이어의 컴포넌트 메타 정보 캐시 (데이터 전달 타겟 선택용)
const [otherLayerComponents, setOtherLayerComponents] = useState<ComponentData[]>([]);
// 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기 // 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기
useEffect(() => { useEffect(() => {
if (activeLayerId <= 1 || !selectedScreen?.screenId) { if (activeLayerId <= 1 || !selectedScreen?.screenId) {
@ -581,41 +578,6 @@ export default function ScreenDesigner({
findZone(); findZone();
}, [activeLayerId, selectedScreen?.screenId, zones]); }, [activeLayerId, selectedScreen?.screenId, zones]);
// 다른 레이어의 컴포넌트 메타 정보 로드 (데이터 전달 타겟 선택용)
useEffect(() => {
if (!selectedScreen?.screenId) return;
const loadOtherLayerComponents = async () => {
try {
const allLayers = await screenApi.getScreenLayers(selectedScreen.screenId);
const currentLayerId = activeLayerIdRef.current || 1;
const otherLayers = allLayers.filter((l: any) => l.layer_id !== currentLayerId && l.layer_id > 0);
const components: ComponentData[] = [];
for (const layerInfo of otherLayers) {
try {
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, layerInfo.layer_id);
const rawComps = layerData?.components;
if (rawComps && Array.isArray(rawComps)) {
for (const comp of rawComps) {
components.push({
...comp,
_layerName: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`,
_layerId: String(layerInfo.layer_id),
} as any);
}
}
} catch {
// 개별 레이어 로드 실패 무시
}
}
setOtherLayerComponents(components);
} catch {
setOtherLayerComponents([]);
}
};
loadOtherLayerComponents();
}, [selectedScreen?.screenId, activeLayerId]);
// 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시) // 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시)
const visibleComponents = useMemo(() => { const visibleComponents = useMemo(() => {
return layout.components; return layout.components;
@ -6554,8 +6516,8 @@ export default function ScreenDesigner({
updateComponentProperty(selectedComponent.id, "style", style); updateComponentProperty(selectedComponent.id, "style", style);
} }
}} }}
allComponents={[...layout.components, ...otherLayerComponents]} allComponents={layout.components} // 🆕 플로우 위젯 감지용
menuObjid={menuObjid} menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
/> />
)} )}
</TabsContent> </TabsContent>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect, useMemo, useCallback } from "react"; import React, { useState, useEffect, useMemo } from "react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -92,14 +92,13 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태 const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태
const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태 const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태
// 🆕 데이터 전달 필드 매핑용 상태 (멀티 테이블 매핑 지원) // 🆕 데이터 전달 필드 매핑용 상태
const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState<Record<string, Array<{ name: string; label: string }>>>({}); const [mappingSourceColumns, setMappingSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]); const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState<Record<string, boolean>>({}); const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState<Record<number, boolean>>({});
const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState<Record<string, boolean>>({}); const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState<Record<number, boolean>>({});
const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<string, string>>({}); const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<number, string>>({});
const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<string, string>>({}); const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<number, string>>({});
const [activeMappingGroupIndex, setActiveMappingGroupIndex] = useState(0);
// 🆕 openModalWithData 전용 필드 매핑 상태 // 🆕 openModalWithData 전용 필드 매핑 상태
const [modalSourceColumns, setModalSourceColumns] = useState<Array<{ name: string; label: string }>>([]); const [modalSourceColumns, setModalSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
@ -296,57 +295,57 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
} }
}; };
// 멀티 테이블 매핑: 소스/타겟 테이블 컬럼 로드 // 🆕 데이터 전달 소스/타겟 테이블 컬럼 로드
const loadMappingColumns = useCallback(async (tableName: string): Promise<Array<{ name: string; label: string }>> => {
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
if (response.data.success) {
let columnData = response.data.data;
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
if (Array.isArray(columnData)) {
return columnData.map((col: any) => ({
name: col.name || col.columnName,
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
}));
}
}
} catch (error) {
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
}
return [];
}, []);
useEffect(() => { useEffect(() => {
const multiTableMappings: Array<{ sourceTable: string }> = config.action?.dataTransfer?.multiTableMappings || []; const sourceTable = config.action?.dataTransfer?.sourceTable;
const legacySourceTable = config.action?.dataTransfer?.sourceTable;
const targetTable = config.action?.dataTransfer?.targetTable; const targetTable = config.action?.dataTransfer?.targetTable;
const loadAll = async () => { const loadColumns = async () => {
const sourceTableNames = multiTableMappings.map((m) => m.sourceTable).filter(Boolean); if (sourceTable) {
if (legacySourceTable && !sourceTableNames.includes(legacySourceTable)) { try {
sourceTableNames.push(legacySourceTable); const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`);
} if (response.data.success) {
let columnData = response.data.data;
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
const newMap: Record<string, Array<{ name: string; label: string }>> = {}; if (Array.isArray(columnData)) {
for (const tbl of sourceTableNames) { const columns = columnData.map((col: any) => ({
if (!mappingSourceColumnsMap[tbl]) { name: col.name || col.columnName,
newMap[tbl] = await loadMappingColumns(tbl); label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
}));
setMappingSourceColumns(columns);
}
}
} catch (error) {
console.error("소스 테이블 컬럼 로드 실패:", error);
} }
} }
if (Object.keys(newMap).length > 0) {
setMappingSourceColumnsMap((prev) => ({ ...prev, ...newMap }));
}
if (targetTable && mappingTargetColumns.length === 0) { if (targetTable) {
const cols = await loadMappingColumns(targetTable); try {
setMappingTargetColumns(cols); const response = await apiClient.get(`/table-management/tables/${targetTable}/columns`);
if (response.data.success) {
let columnData = response.data.data;
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
if (Array.isArray(columnData)) {
const columns = columnData.map((col: any) => ({
name: col.name || col.columnName,
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
}));
setMappingTargetColumns(columns);
}
}
} catch (error) {
console.error("타겟 테이블 컬럼 로드 실패:", error);
}
} }
}; };
loadAll(); loadColumns();
}, [config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable, loadMappingColumns]); }, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]);
// 🆕 modal 액션: 대상 화면 테이블 조회 및 필드 매핑 로드 // 🆕 modal 액션: 대상 화면 테이블 조회 및 필드 매핑 로드
useEffect(() => { useEffect(() => {
@ -2967,17 +2966,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" /> <SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{/* 자동 탐색 옵션 (레이어별 테이블이 다를 때 유용) */} {/* 데이터 제공 가능한 컴포넌트 필터링 */}
<SelectItem value="__auto__">
<div className="flex items-center gap-2">
<span className="text-xs font-medium"> ( )</span>
<span className="text-muted-foreground text-[10px]">(auto)</span>
</div>
</SelectItem>
{/* 데이터 제공 가능한 컴포넌트 필터링 (모든 레이어 포함) */}
{allComponents {allComponents
.filter((comp: any) => { .filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
// 데이터를 제공할 수 있는 컴포넌트 타입들
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
type.includes(t), type.includes(t),
); );
@ -2985,17 +2978,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
.map((comp: any) => { .map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown"; const compType = comp.componentType || comp.type || "unknown";
const compLabel = comp.label || comp.componentConfig?.title || comp.id; const compLabel = comp.label || comp.componentConfig?.title || comp.id;
const layerName = comp._layerName;
return ( return (
<SelectItem key={comp.id} value={comp.id}> <SelectItem key={comp.id} value={comp.id}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-medium">{compLabel}</span> <span className="text-xs font-medium">{compLabel}</span>
<span className="text-muted-foreground text-[10px]">({compType})</span> <span className="text-muted-foreground text-[10px]">({compType})</span>
{layerName && (
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{layerName}
</span>
)}
</div> </div>
</SelectItem> </SelectItem>
); );
@ -3012,9 +2999,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
)} )}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-muted-foreground mt-1 text-xs"> <p className="text-muted-foreground mt-1 text-xs">, </p>
"자동 탐색"
</p>
</div> </div>
<div> <div>
@ -3052,47 +3037,33 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</Label> </Label>
<Select <Select
value={config.action?.dataTransfer?.targetComponentId || ""} value={config.action?.dataTransfer?.targetComponentId || ""}
onValueChange={(value) => { onValueChange={(value) =>
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value); onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value)
// 선택한 컴포넌트가 다른 레이어에 있으면 targetLayerId도 저장 }
const selectedComp = allComponents.find((c: any) => c.id === value);
if (selectedComp && (selectedComp as any)._layerId) {
onUpdateProperty(
"componentConfig.action.dataTransfer.targetLayerId",
(selectedComp as any)._layerId,
);
} else {
onUpdateProperty("componentConfig.action.dataTransfer.targetLayerId", undefined);
}
}}
> >
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" /> <SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{/* 데이터 수신 가능한 컴포넌트 필터링 (모든 레이어 포함, 소스와 다른 컴포넌트만) */} {/* 데이터 수신 가능한 컴포넌트 필터링 (소스와 다른 컴포넌트만) */}
{allComponents {allComponents
.filter((comp: any) => { .filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
// 데이터를 받을 수 있는 컴포넌트 타입들
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some( const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
(t) => type.includes(t), (t) => type.includes(t),
); );
// 소스와 다른 컴포넌트만
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId; return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
}) })
.map((comp: any) => { .map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown"; const compType = comp.componentType || comp.type || "unknown";
const compLabel = comp.label || comp.componentConfig?.title || comp.id; const compLabel = comp.label || comp.componentConfig?.title || comp.id;
const layerName = comp._layerName;
return ( return (
<SelectItem key={comp.id} value={comp.id}> <SelectItem key={comp.id} value={comp.id}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-medium">{compLabel}</span> <span className="text-xs font-medium">{compLabel}</span>
<span className="text-muted-foreground text-[10px]">({compType})</span> <span className="text-muted-foreground text-[10px]">({compType})</span>
{layerName && (
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{layerName}
</span>
)}
</div> </div>
</SelectItem> </SelectItem>
); );
@ -3290,69 +3261,114 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div> </div>
<div> <div>
<Label htmlFor="additional-field-name" className="text-xs"> <Label htmlFor="additional-field-name" className="text-xs">
() ()
</Label> </Label>
<Input
id="additional-field-name"
placeholder="예: inbound_type (비워두면 전체 데이터)"
value={config.action?.dataTransfer?.additionalSources?.[0]?.fieldName || ""}
onChange={(e) => {
const currentSources = config.action?.dataTransfer?.additionalSources || [];
const newSources = [...currentSources];
if (newSources.length === 0) {
newSources.push({ componentId: "", fieldName: e.target.value });
} else {
newSources[0] = { ...newSources[0], fieldName: e.target.value };
}
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
}}
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
</div>
</div>
{/* 필드 매핑 규칙 */}
<div className="space-y-3">
<Label> </Label>
{/* 소스/타겟 테이블 선택 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
variant="outline" {config.action?.dataTransfer?.sourceTable
role="combobox" ? availableTables.find((t) => t.name === config.action?.dataTransfer?.sourceTable)?.label ||
className="h-8 w-full justify-between text-xs" config.action?.dataTransfer?.sourceTable
> : "테이블 선택"}
{(() => {
const fieldName = config.action?.dataTransfer?.additionalSources?.[0]?.fieldName;
if (!fieldName) return "필드 선택 (비워두면 전체 데이터)";
const cols = mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns;
const found = cols.find((c) => c.name === fieldName);
return found ? `${found.label || found.name}` : fieldName;
})()}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[240px] p-0" align="start"> <PopoverContent className="w-[250px] p-0" align="start">
<Command> <Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" /> <CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList> <CommandList>
<CommandEmpty className="py-2 text-center text-xs"> .</CommandEmpty> <CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup> <CommandGroup>
<CommandItem {availableTables.map((table) => (
value="__none__"
onSelect={() => {
const currentSources = config.action?.dataTransfer?.additionalSources || [];
const newSources = [...currentSources];
if (newSources.length === 0) {
newSources.push({ componentId: "", fieldName: "" });
} else {
newSources[0] = { ...newSources[0], fieldName: "" };
}
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", !config.action?.dataTransfer?.additionalSources?.[0]?.fieldName ? "opacity-100" : "opacity-0")} />
<span className="text-muted-foreground"> ( )</span>
</CommandItem>
{(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => (
<CommandItem <CommandItem
key={col.name} key={table.name}
value={`${col.label || ""} ${col.name}`} value={`${table.label} ${table.name}`}
onSelect={() => { onSelect={() => {
const currentSources = config.action?.dataTransfer?.additionalSources || []; onUpdateProperty("componentConfig.action.dataTransfer.sourceTable", table.name);
const newSources = [...currentSources];
if (newSources.length === 0) {
newSources.push({ componentId: "", fieldName: col.name });
} else {
newSources[0] = { ...newSources[0], fieldName: col.name };
}
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
}} }}
className="text-xs" className="text-xs"
> >
<Check className={cn("mr-2 h-3 w-3", config.action?.dataTransfer?.additionalSources?.[0]?.fieldName === col.name ? "opacity-100" : "opacity-0")} /> <Check
<span className="font-medium">{col.label || col.name}</span> className={cn(
{col.label && col.label !== col.name && ( "mr-2 h-3 w-3",
<span className="text-muted-foreground ml-1 text-[10px]">({col.name})</span> config.action?.dataTransfer?.sourceTable === table.name ? "opacity-100" : "opacity-0",
)} )}
/>
<span className="font-medium">{table.label}</span>
<span className="text-muted-foreground ml-1">({table.name})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
{config.action?.dataTransfer?.targetTable
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
config.action?.dataTransfer?.targetTable
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.name}
value={`${table.label} ${table.name}`}
onSelect={() => {
onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0",
)}
/>
<span className="font-medium">{table.label}</span>
<span className="text-muted-foreground ml-1">({table.name})</span>
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
@ -3360,347 +3376,186 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div> </div>
</div> </div>
</div>
{/* 멀티 테이블 필드 매핑 */} {/* 필드 매핑 규칙 */}
<div className="space-y-3">
<Label> </Label>
{/* 타겟 테이블 (공통) */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
{config.action?.dataTransfer?.targetTable
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
config.action?.dataTransfer?.targetTable
: "타겟 테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.name}
value={`${table.label} ${table.name}`}
onSelect={() => {
onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0",
)}
/>
<span className="font-medium">{table.label}</span>
<span className="text-muted-foreground ml-1">({table.name})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 소스 테이블 매핑 그룹 */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="h-6 text-[10px]" className="h-6 text-[10px]"
onClick={() => { onClick={() => {
const currentMappings = config.action?.dataTransfer?.multiTableMappings || []; const currentRules = config.action?.dataTransfer?.mappingRules || [];
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", [ const newRule = { sourceField: "", targetField: "", transform: "" };
...currentMappings, onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", [...currentRules, newRule]);
{ sourceTable: "", mappingRules: [] },
]);
setActiveMappingGroupIndex(currentMappings.length);
}} }}
disabled={!config.action?.dataTransfer?.targetTable} disabled={!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable}
> >
<Plus className="mr-1 h-3 w-3" /> <Plus className="mr-1 h-3 w-3" />
</Button> </Button>
</div> </div>
<p className="text-muted-foreground text-[10px]"> <p className="text-muted-foreground text-[10px]">
, . . . .
</p> </p>
{!config.action?.dataTransfer?.targetTable ? ( {!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable ? (
<div className="rounded-md border border-dashed p-3 text-center"> <div className="rounded-md border border-dashed p-3 text-center">
<p className="text-muted-foreground text-xs"> .</p> <p className="text-muted-foreground text-xs"> .</p>
</div> </div>
) : !(config.action?.dataTransfer?.multiTableMappings || []).length ? ( ) : (config.action?.dataTransfer?.mappingRules || []).length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-center"> <div className="rounded-md border border-dashed p-3 text-center">
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
. . . .
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{/* 소스 테이블 탭 */} {(config.action?.dataTransfer?.mappingRules || []).map((rule: any, index: number) => (
<div className="flex flex-wrap gap-1"> <div key={index} className="bg-background flex items-center gap-2 rounded-md border p-2">
{(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => ( {/* 소스 필드 선택 (Combobox) */}
<div key={gIdx} className="flex items-center gap-0.5"> <div className="flex-1">
<Button <Popover
type="button" open={mappingSourcePopoverOpen[index] || false}
variant={activeMappingGroupIndex === gIdx ? "default" : "outline"} onOpenChange={(open) => setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
size="sm"
className="h-6 text-[10px]"
onClick={() => setActiveMappingGroupIndex(gIdx)}
> >
{group.sourceTable <PopoverTrigger asChild>
? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable <Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
: `그룹 ${gIdx + 1}`} {rule.sourceField
{group.mappingRules?.length > 0 && ( ? mappingSourceColumns.find((c) => c.name === rule.sourceField)?.label ||
<span className="bg-primary/20 ml-1 rounded-full px-1 text-[9px]"> rule.sourceField
{group.mappingRules.length} : "소스 필드"}
</span> <ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-5 w-5"
onClick={() => {
const mappings = [...(config.action?.dataTransfer?.multiTableMappings || [])];
mappings.splice(gIdx, 1);
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
if (activeMappingGroupIndex >= mappings.length) {
setActiveMappingGroupIndex(Math.max(0, mappings.length - 1));
}
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
{/* 활성 그룹 편집 영역 */}
{(() => {
const multiMappings = config.action?.dataTransfer?.multiTableMappings || [];
const activeGroup = multiMappings[activeMappingGroupIndex];
if (!activeGroup) return null;
const activeSourceTable = activeGroup.sourceTable || "";
const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || [];
const activeRules: any[] = activeGroup.mappingRules || [];
const updateGroupField = (field: string, value: any) => {
const mappings = [...multiMappings];
mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value };
onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
};
return (
<div className="space-y-2 rounded-md border p-3">
{/* 소스 테이블 선택 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
{activeSourceTable
? availableTables.find((t) => t.name === activeSourceTable)?.label || activeSourceTable
: "소스 테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.name}
value={`${table.label} ${table.name}`}
onSelect={async () => {
updateGroupField("sourceTable", table.name);
if (!mappingSourceColumnsMap[table.name]) {
const cols = await loadMappingColumns(table.name);
setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols }));
}
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
activeSourceTable === table.name ? "opacity-100" : "opacity-0",
)}
/>
<span className="font-medium">{table.label}</span>
<span className="text-muted-foreground ml-1">({table.name})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 매핑 규칙 목록 */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Button
type="button"
variant="outline"
size="sm"
className="h-5 text-[10px]"
onClick={() => {
updateGroupField("mappingRules", [...activeRules, { sourceField: "", targetField: "" }]);
}}
disabled={!activeSourceTable}
>
<Plus className="mr-1 h-3 w-3" />
</Button> </Button>
</div> </PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
{!activeSourceTable ? ( <Command>
<p className="text-muted-foreground text-[10px]"> .</p> <CommandInput
) : activeRules.length === 0 ? ( placeholder="컬럼 검색..."
<p className="text-muted-foreground text-[10px]"> ( )</p> className="h-8 text-xs"
) : ( value={mappingSourceSearch[index] || ""}
activeRules.map((rule: any, rIdx: number) => { onValueChange={(value) =>
const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`; setMappingSourceSearch((prev) => ({ ...prev, [index]: value }))
const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`; }
return ( />
<div key={rIdx} className="bg-background flex items-center gap-2 rounded-md border p-2"> <CommandList>
<div className="flex-1"> <CommandEmpty className="py-2 text-center text-xs">
<Popover
open={mappingSourcePopoverOpen[popoverKeyS] || false} </CommandEmpty>
onOpenChange={(open) => <CommandGroup>
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open })) {mappingSourceColumns.map((col) => (
} <CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
rules[index] = { ...rules[index], sourceField: col.name };
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: false }));
}}
className="text-xs"
> >
<PopoverTrigger asChild> <Check
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs"> className={cn(
{rule.sourceField "mr-2 h-3 w-3",
? activeSourceColumns.find((c) => c.name === rule.sourceField)?.label || rule.sourceField rule.sourceField === col.name ? "opacity-100" : "opacity-0",
: "소스 필드"} )}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" /> />
</Button> <span>{col.label}</span>
</PopoverTrigger> {col.label !== col.name && (
<PopoverContent className="w-[200px] p-0" align="start"> <span className="text-muted-foreground ml-1">({col.name})</span>
<Command> )}
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" /> </CommandItem>
<CommandList> ))}
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty> </CommandGroup>
<CommandGroup> </CommandList>
{activeSourceColumns.map((col) => ( </Command>
<CommandItem </PopoverContent>
key={col.name} </Popover>
value={`${col.label} ${col.name}`}
onSelect={() => {
const newRules = [...activeRules];
newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name };
updateGroupField("mappingRules", newRules);
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: false }));
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", rule.sourceField === col.name ? "opacity-100" : "opacity-0")} />
<span>{col.label}</span>
{col.label !== col.name && <span className="text-muted-foreground ml-1">({col.name})</span>}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<span className="text-muted-foreground text-xs"></span>
<div className="flex-1">
<Popover
open={mappingTargetPopoverOpen[popoverKeyT] || false}
onOpenChange={(open) =>
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open }))
}
>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
{rule.targetField
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label || rule.targetField
: "타겟 필드"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{mappingTargetColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const newRules = [...activeRules];
newRules[rIdx] = { ...newRules[rIdx], targetField: col.name };
updateGroupField("mappingRules", newRules);
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: false }));
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", rule.targetField === col.name ? "opacity-100" : "opacity-0")} />
<span>{col.label}</span>
{col.label !== col.name && <span className="text-muted-foreground ml-1">({col.name})</span>}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-7 w-7"
onClick={() => {
const newRules = [...activeRules];
newRules.splice(rIdx, 1);
updateGroupField("mappingRules", newRules);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})
)}
</div>
</div> </div>
);
})()} <span className="text-muted-foreground text-xs"></span>
{/* 타겟 필드 선택 (Combobox) */}
<div className="flex-1">
<Popover
open={mappingTargetPopoverOpen[index] || false}
onOpenChange={(open) => setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
{rule.targetField
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label ||
rule.targetField
: "타겟 필드"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
className="h-8 text-xs"
value={mappingTargetSearch[index] || ""}
onValueChange={(value) =>
setMappingTargetSearch((prev) => ({ ...prev, [index]: value }))
}
/>
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
</CommandEmpty>
<CommandGroup>
{mappingTargetColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
rules[index] = { ...rules[index], targetField: col.name };
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: false }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
rule.targetField === col.name ? "opacity-100" : "opacity-0",
)}
/>
<span>{col.label}</span>
{col.label !== col.name && (
<span className="text-muted-foreground ml-1">({col.name})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-7 w-7"
onClick={() => {
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
rules.splice(index, 1);
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div> </div>
)} )}
</div> </div>
@ -3712,9 +3567,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<br /> <br />
1. 1.
<br /> <br />
2. 2. (: 품번 )
<br /> <br />
3. 3.
</p> </p>
</div> </div>
</div> </div>

View File

@ -72,10 +72,9 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
allColumns = response.data; allColumns = response.data;
} }
// category 타입 중 자체 카테고리만 필터링 (참조 컬럼 제외) // category 타입 컬럼만 필터링
const categoryColumns = allColumns.filter( const categoryColumns = allColumns.filter(
(col: any) => (col.inputType === "category" || col.input_type === "category") (col: any) => col.inputType === "category" || col.input_type === "category"
&& !col.categoryRef && !col.category_ref
); );
console.log("✅ 카테고리 컬럼 필터링 완료:", { console.log("✅ 카테고리 컬럼 필터링 완료:", {

View File

@ -23,9 +23,6 @@ import {
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { allocateNumberingCode } from "@/lib/api/numberingRule"; import { allocateNumberingCode } from "@/lib/api/numberingRule";
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { DataReceivable } from "@/types/data-transfer";
import { toast } from "sonner";
// modal-repeater-table 컴포넌트 재사용 // modal-repeater-table 컴포넌트 재사용
import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable"; import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable";
@ -41,7 +38,6 @@ declare global {
export const V2Repeater: React.FC<V2RepeaterProps> = ({ export const V2Repeater: React.FC<V2RepeaterProps> = ({
config: propConfig, config: propConfig,
componentId,
parentId, parentId,
data: initialData, data: initialData,
onDataChange, onDataChange,
@ -52,12 +48,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}) => { }) => {
// ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용) // ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용)
const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData; const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData;
// componentId 결정: 직접 전달 또는 component 객체에서 추출
const effectiveComponentId = componentId || (restProps as any).component?.id;
// ScreenContext 연동 (DataReceiver 등록, Provider 없으면 null)
const screenContext = useScreenContextOptional();
// 설정 병합 // 설정 병합
const config: V2RepeaterConfig = useMemo( const config: V2RepeaterConfig = useMemo(
() => ({ () => ({
@ -75,119 +65,9 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set()); const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
// 저장 이벤트 핸들러에서 항상 최신 data를 참조하기 위한 ref
const dataRef = useRef<any[]>(data);
useEffect(() => {
dataRef.current = data;
}, [data]);
// 수정 모드에서 로드된 원본 ID 목록 (삭제 추적용)
const loadedIdsRef = useRef<Set<string>>(new Set());
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거 // 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0); const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
// ScreenContext DataReceiver 등록 (데이터 전달 액션 수신)
const onDataChangeRef = useRef(onDataChange);
onDataChangeRef.current = onDataChange;
const handleReceiveData = useCallback(
async (incomingData: any[], configOrMode?: any) => {
console.log("📥 [V2Repeater] 데이터 수신:", { count: incomingData?.length, configOrMode });
if (!incomingData || incomingData.length === 0) {
toast.warning("전달할 데이터가 없습니다");
return;
}
// 데이터 정규화: {0: {...}} 형태 처리 + 소스 테이블 메타 필드 제거
const metaFieldsToStrip = new Set([
"id",
"created_date",
"updated_date",
"created_by",
"updated_by",
"company_code",
]);
const normalizedData = incomingData.map((item: any) => {
let raw = item;
if (item && typeof item === "object" && item[0] && typeof item[0] === "object") {
const { 0: originalData, ...additionalFields } = item;
raw = { ...originalData, ...additionalFields };
}
const cleaned: Record<string, any> = {};
for (const [key, value] of Object.entries(raw)) {
if (!metaFieldsToStrip.has(key)) {
cleaned[key] = value;
}
}
return cleaned;
});
const mode = configOrMode?.mode || configOrMode || "append";
// 카테고리 코드 → 라벨 변환
// allCategoryColumns 또는 fromMainForm 컬럼의 값을 라벨로 변환
const codesToResolve = new Set<string>();
for (const item of normalizedData) {
for (const [key, val] of Object.entries(item)) {
if (key.startsWith("_")) continue;
if (typeof val === "string" && val && !categoryLabelMapRef.current[val]) {
codesToResolve.add(val as string);
}
}
}
if (codesToResolve.size > 0) {
try {
const resp = await apiClient.post("/table-categories/labels-by-codes", {
valueCodes: Array.from(codesToResolve),
});
if (resp.data?.success && resp.data.data) {
const labelData = resp.data.data as Record<string, string>;
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
for (const item of normalizedData) {
for (const key of Object.keys(item)) {
if (key.startsWith("_")) continue;
const val = item[key];
if (typeof val === "string" && labelData[val]) {
item[key] = labelData[val];
}
}
}
}
} catch {
// 변환 실패 시 코드 유지
}
}
setData((prev) => {
const next = mode === "replace" ? normalizedData : [...prev, ...normalizedData];
onDataChangeRef.current?.(next);
return next;
});
toast.success(`${normalizedData.length}개 항목이 추가되었습니다.`);
},
[],
);
useEffect(() => {
if (screenContext && effectiveComponentId) {
const receiver: DataReceivable = {
componentId: effectiveComponentId,
componentType: "v2-repeater",
receiveData: handleReceiveData,
};
console.log("📋 [V2Repeater] ScreenContext에 데이터 수신자 등록:", effectiveComponentId);
screenContext.registerDataReceiver(effectiveComponentId, receiver);
return () => {
screenContext.unregisterDataReceiver(effectiveComponentId);
};
}
}, [screenContext, effectiveComponentId, handleReceiveData]);
// 소스 테이블 컬럼 라벨 매핑 // 소스 테이블 컬럼 라벨 매핑
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({}); const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
@ -196,10 +76,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용) // 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({}); const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
const categoryLabelMapRef = useRef<Record<string, string>>({});
useEffect(() => {
categoryLabelMapRef.current = categoryLabelMap;
}, [categoryLabelMap]);
// 현재 테이블 컬럼 정보 (inputType 매핑용) // 현재 테이블 컬럼 정보 (inputType 매핑용)
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({}); const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
@ -233,54 +109,35 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}; };
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]); }, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
// 저장 이벤트 리스너 (dataRef/categoryLabelMapRef를 사용하여 항상 최신 상태 참조) // 저장 이벤트 리스너
useEffect(() => { useEffect(() => {
const handleSaveEvent = async (event: CustomEvent) => { const handleSaveEvent = async (event: CustomEvent) => {
const currentData = dataRef.current; // 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용
const currentCategoryMap = categoryLabelMapRef.current; const tableName =
const configTableName =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
const tableName = configTableName || event.detail?.tableName; const eventParentId = event.detail?.parentId;
const mainFormData = event.detail?.mainFormData; const mainFormData = event.detail?.mainFormData;
// 🆕 마스터 테이블에서 생성된 ID (FK 연결용)
const masterRecordId = event.detail?.masterRecordId || mainFormData?.id; const masterRecordId = event.detail?.masterRecordId || mainFormData?.id;
console.log("🔵 [V2Repeater] repeaterSave 이벤트 수신:", { if (!tableName || data.length === 0) {
configTableName,
tableName,
masterRecordId,
dataLength: currentData.length,
foreignKeyColumn: config.foreignKeyColumn,
foreignKeySourceColumn: config.foreignKeySourceColumn,
dataSnapshot: currentData.map((r: any) => ({ id: r.id, item_name: r.item_name })),
});
toast.info(`[디버그] V2Repeater 이벤트 수신: ${currentData.length}건, table=${tableName}`);
if (!tableName || currentData.length === 0) {
console.warn("🔴 [V2Repeater] 저장 스킵:", { tableName, dataLength: currentData.length });
toast.warning(`[디버그] V2Repeater 저장 스킵: data=${currentData.length}, table=${tableName}`);
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
return; return;
} }
if (config.foreignKeyColumn) { // V2Repeater 저장 시작
const sourceCol = config.foreignKeySourceColumn; const saveInfo = {
const hasFkSource = sourceCol && mainFormData && mainFormData[sourceCol] !== undefined;
if (!hasFkSource && !masterRecordId) {
console.warn("🔴 [V2Repeater] FK 소스 값/masterRecordId 모두 없어 저장 스킵");
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
return;
}
}
console.log("V2Repeater 저장 시작", {
tableName, tableName,
useCustomTable: config.useCustomTable,
mainTableName: config.mainTableName,
foreignKeyColumn: config.foreignKeyColumn, foreignKeyColumn: config.foreignKeyColumn,
masterRecordId, masterRecordId,
dataLength: currentData.length, dataLength: data.length,
}); };
console.log("V2Repeater 저장 시작", saveInfo);
try { try {
// 테이블 유효 컬럼 조회
let validColumns: Set<string> = new Set(); let validColumns: Set<string> = new Set();
try { try {
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`); const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
@ -291,10 +148,13 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
console.warn("테이블 컬럼 정보 조회 실패"); console.warn("테이블 컬럼 정보 조회 실패");
} }
for (let i = 0; i < currentData.length; i++) { for (let i = 0; i < data.length; i++) {
const row = currentData[i]; const row = data[i];
// 내부 필드 제거
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_"))); const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
// 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함)
let mergedData: Record<string, any>; let mergedData: Record<string, any>;
if (config.useCustomTable && config.mainTableName) { if (config.useCustomTable && config.mainTableName) {
mergedData = { ...cleanRow }; mergedData = { ...cleanRow };
@ -321,83 +181,59 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}; };
} }
// 유효하지 않은 컬럼 제거
const filteredData: Record<string, any> = {}; const filteredData: Record<string, any> = {};
for (const [key, value] of Object.entries(mergedData)) { for (const [key, value] of Object.entries(mergedData)) {
if (validColumns.size === 0 || validColumns.has(key)) { if (validColumns.size === 0 || validColumns.has(key)) {
if (typeof value === "string" && currentCategoryMap[value]) { filteredData[key] = value;
filteredData[key] = currentCategoryMap[value];
} else {
filteredData[key] = value;
}
} }
} }
// 기존 행(id 존재)은 UPDATE, 새 행은 INSERT
const rowId = row.id; const rowId = row.id;
console.log(`🔧 [V2Repeater] 행 ${i} 저장:`, {
rowId,
isUpdate: rowId && typeof rowId === "string" && rowId.includes("-"),
filteredDataKeys: Object.keys(filteredData),
});
if (rowId && typeof rowId === "string" && rowId.includes("-")) { if (rowId && typeof rowId === "string" && rowId.includes("-")) {
// UUID 형태의 id가 있으면 기존 데이터 → UPDATE
const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData; const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData;
await apiClient.put(`/table-management/tables/${tableName}/edit`, { await apiClient.put(`/table-management/tables/${tableName}/edit`, {
originalData: { id: rowId }, originalData: { id: rowId },
updatedData: updateFields, updatedData: updateFields,
}); });
} else { } else {
// 새 행 → INSERT
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData); await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
} }
} }
// 삭제된 행 처리: 원본에는 있었지만 현재 data에 없는 ID를 DELETE
const currentIds = new Set(currentData.map((r) => r.id).filter(Boolean));
const deletedIds = Array.from(loadedIdsRef.current).filter((id) => !currentIds.has(id));
if (deletedIds.length > 0) {
console.log("🗑️ [V2Repeater] 삭제할 행:", deletedIds);
try {
await apiClient.delete(`/table-management/tables/${tableName}/delete`, {
data: deletedIds.map((id) => ({ id })),
});
console.log(`✅ [V2Repeater] ${deletedIds.length}건 삭제 완료`);
} catch (deleteError) {
console.error("❌ [V2Repeater] 삭제 실패:", deleteError);
}
}
// 저장 완료 후 loadedIdsRef 갱신
loadedIdsRef.current = new Set(currentData.map((r) => r.id).filter(Boolean));
toast.success(`V2Repeater ${currentData.length}건 저장 완료`);
} catch (error) { } catch (error) {
console.error("❌ V2Repeater 저장 실패:", error); console.error("❌ V2Repeater 저장 실패:", error);
toast.error(`V2Repeater 저장 실패: ${error}`); throw error;
} finally {
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
} }
}; };
// V2 EventBus 구독
const unsubscribe = v2EventBus.subscribe( const unsubscribe = v2EventBus.subscribe(
V2_EVENTS.REPEATER_SAVE, V2_EVENTS.REPEATER_SAVE,
async (payload) => { async (payload) => {
const configTableName = const tableName =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
if (!configTableName || payload.tableName === configTableName) { if (payload.tableName === tableName) {
await handleSaveEvent({ detail: payload } as CustomEvent); await handleSaveEvent({ detail: payload } as CustomEvent);
} }
}, },
{ componentId: `v2-repeater-${config.dataSource?.tableName || "same-table"}` }, { componentId: `v2-repeater-${config.dataSource?.tableName}` },
); );
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
window.addEventListener("repeaterSave" as any, handleSaveEvent); window.addEventListener("repeaterSave" as any, handleSaveEvent);
return () => { return () => {
unsubscribe(); unsubscribe();
window.removeEventListener("repeaterSave" as any, handleSaveEvent); window.removeEventListener("repeaterSave" as any, handleSaveEvent);
}; };
}, [ }, [
data,
config.dataSource?.tableName, config.dataSource?.tableName,
config.useCustomTable, config.useCustomTable,
config.mainTableName, config.mainTableName,
config.foreignKeyColumn, config.foreignKeyColumn,
config.foreignKeySourceColumn,
parentId, parentId,
]); ]);
@ -465,6 +301,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}); });
// 각 행에 소스 테이블의 표시 데이터 병합 // 각 행에 소스 테이블의 표시 데이터 병합
// RepeaterTable은 isSourceDisplay 컬럼을 `_display_${col.key}` 필드로 렌더링함
rows.forEach((row: any) => { rows.forEach((row: any) => {
const sourceRecord = sourceMap.get(String(row[fkColumn])); const sourceRecord = sourceMap.get(String(row[fkColumn]));
if (sourceRecord) { if (sourceRecord) {
@ -482,50 +319,12 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} }
} }
// DB에서 로드된 데이터 중 CATEGORY_ 코드가 있으면 라벨로 변환
const codesToResolve = new Set<string>();
for (const row of rows) {
for (const val of Object.values(row)) {
if (typeof val === "string" && val.startsWith("CATEGORY_")) {
codesToResolve.add(val);
}
}
}
if (codesToResolve.size > 0) {
try {
const labelResp = await apiClient.post("/table-categories/labels-by-codes", {
valueCodes: Array.from(codesToResolve),
});
if (labelResp.data?.success && labelResp.data.data) {
const labelData = labelResp.data.data as Record<string, string>;
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
for (const row of rows) {
for (const key of Object.keys(row)) {
if (key.startsWith("_")) continue;
const val = row[key];
if (typeof val === "string" && labelData[val]) {
row[key] = labelData[val];
}
}
}
}
} catch {
// 라벨 변환 실패 시 코드 유지
}
}
// 원본 ID 목록 기록 (삭제 추적용)
const ids = rows.map((r: any) => r.id).filter(Boolean);
loadedIdsRef.current = new Set(ids);
console.log("📋 [V2Repeater] 원본 ID 기록:", ids);
setData(rows); setData(rows);
dataLoadedRef.current = true; dataLoadedRef.current = true;
if (onDataChange) onDataChange(rows); if (onDataChange) onDataChange(rows);
} }
} catch (error) { } catch (error) {
console.error("[V2Repeater] 기존 데이터 로드 실패:", error); console.error("❌ [V2Repeater] 기존 데이터 로드 실패:", error);
} }
}; };
@ -547,28 +346,16 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
if (!tableName) return; if (!tableName) return;
try { try {
const [colResponse, typeResponse] = await Promise.all([ const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
apiClient.get(`/table-management/tables/${tableName}/columns`), const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
apiClient.get(`/table-management/tables/${tableName}/web-types`),
]);
const columns = colResponse.data?.data?.columns || colResponse.data?.columns || colResponse.data || [];
const inputTypes = typeResponse.data?.data || [];
// inputType/categoryRef 매핑 생성
const typeMap: Record<string, any> = {};
inputTypes.forEach((t: any) => {
typeMap[t.columnName] = t;
});
const columnMap: Record<string, any> = {}; const columnMap: Record<string, any> = {};
columns.forEach((col: any) => { columns.forEach((col: any) => {
const name = col.columnName || col.column_name || col.name; const name = col.columnName || col.column_name || col.name;
const typeInfo = typeMap[name];
columnMap[name] = { columnMap[name] = {
inputType: typeInfo?.inputType || col.inputType || col.input_type || col.webType || "text", inputType: col.inputType || col.input_type || col.webType || "text",
displayName: col.displayName || col.display_name || col.label || name, displayName: col.displayName || col.display_name || col.label || name,
detailSettings: col.detailSettings || col.detail_settings, detailSettings: col.detailSettings || col.detail_settings,
categoryRef: typeInfo?.categoryRef || null,
}; };
}); });
setCurrentTableColumnInfo(columnMap); setCurrentTableColumnInfo(columnMap);
@ -700,18 +487,14 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
else if (inputType === "code") type = "select"; else if (inputType === "code") type = "select";
else if (inputType === "category") type = "category"; // 🆕 카테고리 타입 else if (inputType === "category") type = "category"; // 🆕 카테고리 타입
// 카테고리 참조 ID 결정 // 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식)
// DB의 category_ref 설정 우선, 없으면 자기 테이블.컬럼명 사용 // category 타입인 경우 현재 테이블명과 컬럼명을 조합
let categoryRef: string | undefined; let categoryRef: string | undefined;
if (inputType === "category") { if (inputType === "category") {
const dbCategoryRef = colInfo?.detailSettings?.categoryRef || colInfo?.categoryRef; // 🆕 소스 표시 컬럼이면 소스 테이블 사용, 아니면 타겟 테이블 사용
if (dbCategoryRef) { const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName;
categoryRef = dbCategoryRef; if (tableName) {
} else { categoryRef = `${tableName}.${col.key}`;
const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName;
if (tableName) {
categoryRef = `${tableName}.${col.key}`;
}
} }
} }
@ -729,79 +512,55 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}); });
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]); }, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
// 리피터 컬럼 설정에서 카테고리 타입 컬럼 자동 감지 // 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용)
// repeaterColumns의 resolved type 사용 (config + DB 메타데이터 모두 반영)
const allCategoryColumns = useMemo(() => {
const fromRepeater = repeaterColumns
.filter((col) => col.type === "category")
.map((col) => col.field.replace(/^_display_/, ""));
const merged = new Set([...sourceCategoryColumns, ...fromRepeater]);
return Array.from(merged);
}, [sourceCategoryColumns, repeaterColumns]);
// CATEGORY_ 코드 배열을 받아 라벨을 일괄 조회하는 함수
const fetchCategoryLabels = useCallback(async (codes: string[]) => {
if (codes.length === 0) return;
try {
const response = await apiClient.post("/table-categories/labels-by-codes", {
valueCodes: codes,
});
if (response.data?.success && response.data.data) {
setCategoryLabelMap((prev) => ({ ...prev, ...response.data.data }));
}
} catch (error) {
console.error("카테고리 라벨 조회 실패:", error);
}
}, []);
// parentFormData(마스터 행)에서 카테고리 코드를 미리 로드
// fromMainForm autoFill에서 참조할 마스터 필드의 라벨을 사전에 확보
useEffect(() => { useEffect(() => {
if (!parentFormData) return; const loadCategoryLabels = async () => {
const codes: string[] = []; if (sourceCategoryColumns.length === 0 || data.length === 0) {
return;
}
// fromMainForm autoFill의 sourceField 값 중 카테고리 컬럼에 해당하는 것만 수집 // 데이터에서 카테고리 컬럼의 모든 고유 코드 수집
for (const col of config.columns) { const allCodes = new Set<string>();
if (col.autoFill?.type === "fromMainForm" && col.autoFill.sourceField) { for (const row of data) {
const val = parentFormData[col.autoFill.sourceField]; for (const col of sourceCategoryColumns) {
if (typeof val === "string" && val && !categoryLabelMap[val]) { // _display_ 접두사가 있는 컬럼과 원본 컬럼 모두 확인
codes.push(val); const val = row[`_display_${col}`] || row[col];
if (val && typeof val === "string") {
const codes = val
.split(",")
.map((c: string) => c.trim())
.filter(Boolean);
for (const code of codes) {
if (!categoryLabelMap[code] && code.startsWith("CATEGORY_")) {
allCodes.add(code);
}
}
}
} }
} }
// receiveFromParent 패턴
if ((col as any).receiveFromParent) { if (allCodes.size === 0) {
const parentField = (col as any).parentFieldName || col.key; return;
const val = parentFormData[parentField];
if (typeof val === "string" && val && !categoryLabelMap[val]) {
codes.push(val);
}
} }
}
if (codes.length > 0) { try {
fetchCategoryLabels(codes); const response = await apiClient.post("/table-categories/labels-by-codes", {
} valueCodes: Array.from(allCodes),
}, [parentFormData, config.columns, fetchCategoryLabels]); });
// 데이터 변경 시 카테고리 라벨 로드 if (response.data?.success && response.data.data) {
useEffect(() => { setCategoryLabelMap((prev) => ({
if (data.length === 0) return; ...prev,
...response.data.data,
const allCodes = new Set<string>(); }));
for (const row of data) {
for (const col of allCategoryColumns) {
const val = row[`_display_${col}`] || row[col];
if (val && typeof val === "string") {
val.split(",").map((c: string) => c.trim()).filter(Boolean).forEach((code: string) => {
if (!categoryLabelMap[code]) allCodes.add(code);
});
} }
} catch (error) {
console.error("카테고리 라벨 조회 실패:", error);
} }
} };
fetchCategoryLabels(Array.from(allCodes)); loadCategoryLabels();
}, [data, allCategoryColumns, fetchCategoryLabels]); }, [data, sourceCategoryColumns]);
// 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능) // 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능)
const applyCalculationRules = useCallback( const applyCalculationRules = useCallback(
@ -918,12 +677,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
case "fromMainForm": case "fromMainForm":
if (col.autoFill.sourceField && mainFormData) { if (col.autoFill.sourceField && mainFormData) {
const rawValue = mainFormData[col.autoFill.sourceField]; return mainFormData[col.autoFill.sourceField];
// categoryLabelMap에 매핑이 있으면 라벨로 변환 (접두사 무관)
if (typeof rawValue === "string" && categoryLabelMap[rawValue]) {
return categoryLabelMap[rawValue];
}
return rawValue;
} }
return ""; return "";
@ -943,7 +697,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
return undefined; return undefined;
} }
}, },
[categoryLabelMap], [],
); );
// 🆕 채번 API 호출 (비동기) // 🆕 채번 API 호출 (비동기)
@ -977,12 +731,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
const row: any = { _id: `grouped_${Date.now()}_${index}` }; const row: any = { _id: `grouped_${Date.now()}_${index}` };
for (const col of config.columns) { for (const col of config.columns) {
let sourceValue = item[(col as any).sourceKey || col.key]; const sourceValue = item[(col as any).sourceKey || col.key];
// 카테고리 코드 → 라벨 변환 (접두사 무관, categoryLabelMap 기반)
if (typeof sourceValue === "string" && categoryLabelMap[sourceValue]) {
sourceValue = categoryLabelMap[sourceValue];
}
if (col.isSourceDisplay) { if (col.isSourceDisplay) {
row[col.key] = sourceValue ?? ""; row[col.key] = sourceValue ?? "";
@ -1003,48 +752,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
return row; return row;
}); });
// 카테고리 컬럼의 코드 → 라벨 변환 (접두사 무관)
const categoryColSet = new Set(allCategoryColumns);
const codesToResolve = new Set<string>();
for (const row of newRows) {
for (const col of config.columns) {
const val = row[col.key] || row[`_display_${col.key}`];
if (typeof val === "string" && val && (categoryColSet.has(col.key) || col.autoFill?.type === "fromMainForm")) {
if (!categoryLabelMap[val]) {
codesToResolve.add(val);
}
}
}
}
if (codesToResolve.size > 0) {
apiClient.post("/table-categories/labels-by-codes", {
valueCodes: Array.from(codesToResolve),
}).then((resp) => {
if (resp.data?.success && resp.data.data) {
const labelData = resp.data.data as Record<string, string>;
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
const convertedRows = newRows.map((row) => {
const updated = { ...row };
for (const col of config.columns) {
const val = updated[col.key];
if (typeof val === "string" && labelData[val]) {
updated[col.key] = labelData[val];
}
const dispKey = `_display_${col.key}`;
const dispVal = updated[dispKey];
if (typeof dispVal === "string" && labelData[dispVal]) {
updated[dispKey] = labelData[dispVal];
}
}
return updated;
});
setData(convertedRows);
onDataChange?.(convertedRows);
}
}).catch(() => {});
}
setData(newRows); setData(newRows);
onDataChange?.(newRows); onDataChange?.(newRows);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -1079,7 +786,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentFormData, config.columns, generateAutoFillValueSync]); }, [parentFormData, config.columns, generateAutoFillValueSync]);
// 행 추가 (inline 모드 또는 모달 열기) // 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경
const handleAddRow = useCallback(async () => { const handleAddRow = useCallback(async () => {
if (isModalMode) { if (isModalMode) {
setModalOpen(true); setModalOpen(true);
@ -1087,10 +794,11 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
const newRow: any = { _id: `new_${Date.now()}` }; const newRow: any = { _id: `new_${Date.now()}` };
const currentRowCount = data.length; const currentRowCount = data.length;
// 동기적 자동 입력 값 적용 // 먼저 동기적 자동 입력 값 적용
for (const col of config.columns) { for (const col of config.columns) {
const autoValue = generateAutoFillValueSync(col, currentRowCount, parentFormData); const autoValue = generateAutoFillValueSync(col, currentRowCount, parentFormData);
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) { if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
// 채번 규칙: 즉시 API 호출
newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId); newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
} else if (autoValue !== undefined) { } else if (autoValue !== undefined) {
newRow[col.key] = autoValue; newRow[col.key] = autoValue;
@ -1099,51 +807,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} }
} }
// fromMainForm 등으로 넘어온 카테고리 코드 → 라벨 변환
// allCategoryColumns에 해당하는 컬럼이거나 categoryLabelMap에 매핑이 있으면 변환
const categoryColSet = new Set(allCategoryColumns);
const unresolvedCodes: string[] = [];
for (const col of config.columns) {
const val = newRow[col.key];
if (typeof val !== "string" || !val) continue;
// 이 컬럼이 카테고리 타입이거나, fromMainForm으로 가져온 값인 경우
const isCategoryCol = categoryColSet.has(col.key);
const isFromMainForm = col.autoFill?.type === "fromMainForm";
if (isCategoryCol || isFromMainForm) {
if (categoryLabelMap[val]) {
newRow[col.key] = categoryLabelMap[val];
} else {
unresolvedCodes.push(val);
}
}
}
if (unresolvedCodes.length > 0) {
try {
const resp = await apiClient.post("/table-categories/labels-by-codes", {
valueCodes: unresolvedCodes,
});
if (resp.data?.success && resp.data.data) {
const labelData = resp.data.data as Record<string, string>;
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
for (const col of config.columns) {
const val = newRow[col.key];
if (typeof val === "string" && labelData[val]) {
newRow[col.key] = labelData[val];
}
}
}
} catch {
// 변환 실패 시 코드 유지
}
}
const newData = [...data, newRow]; const newData = [...data, newRow];
handleDataChange(newData); handleDataChange(newData);
} }
}, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData, categoryLabelMap, allCategoryColumns]); }, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData]);
// 모달에서 항목 선택 - 비동기로 변경 // 모달에서 항목 선택 - 비동기로 변경
const handleSelectItems = useCallback( const handleSelectItems = useCallback(
@ -1168,12 +835,8 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 모든 컬럼 처리 (순서대로) // 모든 컬럼 처리 (순서대로)
for (const col of config.columns) { for (const col of config.columns) {
if (col.isSourceDisplay) { if (col.isSourceDisplay) {
let displayVal = item[col.key] || ""; // 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용)
// 카테고리 컬럼이면 코드→라벨 변환 (접두사 무관) row[`_display_${col.key}`] = item[col.key] || "";
if (typeof displayVal === "string" && categoryLabelMap[displayVal]) {
displayVal = categoryLabelMap[displayVal];
}
row[`_display_${col.key}`] = displayVal;
} else { } else {
// 자동 입력 값 적용 // 자동 입력 값 적용
const autoValue = generateAutoFillValueSync(col, currentRowCount + index, parentFormData); const autoValue = generateAutoFillValueSync(col, currentRowCount + index, parentFormData);
@ -1193,43 +856,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}), }),
); );
// 카테고리/fromMainForm 컬럼에서 미해결 코드 수집 및 변환
const categoryColSet = new Set(allCategoryColumns);
const unresolvedCodes = new Set<string>();
for (const row of newRows) {
for (const col of config.columns) {
const val = row[col.key];
if (typeof val !== "string" || !val) continue;
const isCategoryCol = categoryColSet.has(col.key);
const isFromMainForm = col.autoFill?.type === "fromMainForm";
if ((isCategoryCol || isFromMainForm) && !categoryLabelMap[val]) {
unresolvedCodes.add(val);
}
}
}
if (unresolvedCodes.size > 0) {
try {
const resp = await apiClient.post("/table-categories/labels-by-codes", {
valueCodes: Array.from(unresolvedCodes),
});
if (resp.data?.success && resp.data.data) {
const labelData = resp.data.data as Record<string, string>;
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
for (const row of newRows) {
for (const col of config.columns) {
const val = row[col.key];
if (typeof val === "string" && labelData[val]) {
row[col.key] = labelData[val];
}
}
}
}
} catch {
// 변환 실패 시 코드 유지
}
}
const newData = [...data, ...newRows]; const newData = [...data, ...newRows];
handleDataChange(newData); handleDataChange(newData);
setModalOpen(false); setModalOpen(false);
@ -1243,8 +869,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
generateAutoFillValueSync, generateAutoFillValueSync,
generateNumberingCode, generateNumberingCode,
parentFormData, parentFormData,
categoryLabelMap,
allCategoryColumns,
], ],
); );
@ -1257,6 +881,9 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}, [config.columns]); }, [config.columns]);
// 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환 // 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환
const dataRef = useRef(data);
dataRef.current = data;
useEffect(() => { useEffect(() => {
const handleBeforeFormSave = async (event: Event) => { const handleBeforeFormSave = async (event: Event) => {
const customEvent = event as CustomEvent; const customEvent = event as CustomEvent;
@ -1485,7 +1112,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
selectedRows={selectedRows} selectedRows={selectedRows}
onSelectionChange={setSelectedRows} onSelectionChange={setSelectedRows}
equalizeWidthsTrigger={autoWidthTrigger} equalizeWidthsTrigger={autoWidthTrigger}
categoryColumns={allCategoryColumns} categoryColumns={sourceCategoryColumns}
categoryLabelMap={categoryLabelMap} categoryLabelMap={categoryLabelMap}
/> />
</div> </div>

View File

@ -1214,21 +1214,13 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
<Wand2 className="h-3 w-3 text-purple-500 flex-shrink-0" title="자동 입력" /> <Wand2 className="h-3 w-3 text-purple-500 flex-shrink-0" title="자동 입력" />
)} )}
{/* 편집 가능 토글 */} {/* 편집 가능 체크박스 */}
{!col.isSourceDisplay && ( {!col.isSourceDisplay && (
<button <Checkbox
type="button" checked={col.editable ?? true}
onClick={() => updateColumnProp(col.key, "editable", !(col.editable ?? true))} onCheckedChange={(checked) => updateColumnProp(col.key, "editable", !!checked)}
className={cn( title="편집 가능"
"shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium transition-colors", />
(col.editable ?? true)
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
)}
title={(col.editable ?? true) ? "편집 가능 (클릭하여 읽기 전용으로 변경)" : "읽기 전용 (클릭하여 편집 가능으로 변경)"}
>
{(col.editable ?? true) ? "편집" : "읽기"}
</button>
)} )}
<Button <Button

View File

@ -6,28 +6,17 @@
"use client"; "use client";
import React, { createContext, useContext, useCallback, useRef, useState } from "react"; import React, { createContext, useContext, useCallback, useRef, useState } from "react";
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer"; import type { DataProvidable, DataReceivable } from "@/types/data-transfer";
import { logger } from "@/lib/utils/logger"; import { logger } from "@/lib/utils/logger";
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext"; import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
/**
*
* ( )
*/
export interface PendingTransfer {
targetComponentId: string;
data: any[];
config: DataReceiverConfig;
timestamp: number;
targetLayerId?: string;
}
interface ScreenContextValue { interface ScreenContextValue {
screenId?: number; screenId?: number;
tableName?: string; tableName?: string;
menuObjid?: number; menuObjid?: number; // 메뉴 OBJID (카테고리 값 조회 시 필요)
splitPanelPosition?: SplitPanelPosition; splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
formData: Record<string, any>; formData: Record<string, any>;
updateFormData: (fieldName: string, value: any) => void; updateFormData: (fieldName: string, value: any) => void;
@ -44,11 +33,6 @@ interface ScreenContextValue {
// 모든 컴포넌트 조회 // 모든 컴포넌트 조회
getAllDataProviders: () => Map<string, DataProvidable>; getAllDataProviders: () => Map<string, DataProvidable>;
getAllDataReceivers: () => Map<string, DataReceivable>; getAllDataReceivers: () => Map<string, DataReceivable>;
// 대기 중인 데이터 전달 (레이어 내부 컴포넌트 미마운트 대응)
addPendingTransfer: (transfer: PendingTransfer) => void;
getPendingTransfer: (componentId: string) => PendingTransfer | undefined;
clearPendingTransfer: (componentId: string) => void;
} }
const ScreenContext = createContext<ScreenContextValue | null>(null); const ScreenContext = createContext<ScreenContextValue | null>(null);
@ -73,10 +57,11 @@ export function ScreenContextProvider({
}: ScreenContextProviderProps) { }: ScreenContextProviderProps) {
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map()); const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map()); const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
const pendingTransfersRef = useRef<Map<string, PendingTransfer>>(new Map());
// 🆕 폼 데이터 상태 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
const [formData, setFormData] = useState<Record<string, any>>({}); const [formData, setFormData] = useState<Record<string, any>>({});
// 🆕 폼 데이터 업데이트 함수
const updateFormData = useCallback((fieldName: string, value: any) => { const updateFormData = useCallback((fieldName: string, value: any) => {
setFormData((prev) => { setFormData((prev) => {
const updated = { ...prev, [fieldName]: value }; const updated = { ...prev, [fieldName]: value };
@ -102,25 +87,6 @@ export function ScreenContextProvider({
const registerDataReceiver = useCallback((componentId: string, receiver: DataReceivable) => { const registerDataReceiver = useCallback((componentId: string, receiver: DataReceivable) => {
dataReceiversRef.current.set(componentId, receiver); dataReceiversRef.current.set(componentId, receiver);
logger.debug("데이터 수신자 등록", { componentId, componentType: receiver.componentType }); logger.debug("데이터 수신자 등록", { componentId, componentType: receiver.componentType });
// 대기 중인 데이터 전달이 있으면 즉시 수신 처리
const pending = pendingTransfersRef.current.get(componentId);
if (pending) {
logger.info("대기 중인 데이터 전달 자동 수신", {
componentId,
dataCount: pending.data.length,
waitedMs: Date.now() - pending.timestamp,
});
receiver
.receiveData(pending.data, pending.config)
.then(() => {
pendingTransfersRef.current.delete(componentId);
logger.info("대기 데이터 전달 완료", { componentId });
})
.catch((err) => {
logger.error("대기 데이터 전달 실패", { componentId, error: err });
});
}
}, []); }, []);
const unregisterDataReceiver = useCallback((componentId: string) => { const unregisterDataReceiver = useCallback((componentId: string) => {
@ -144,24 +110,7 @@ export function ScreenContextProvider({
return new Map(dataReceiversRef.current); return new Map(dataReceiversRef.current);
}, []); }, []);
const addPendingTransfer = useCallback((transfer: PendingTransfer) => { // 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
pendingTransfersRef.current.set(transfer.targetComponentId, transfer);
logger.info("데이터 전달 대기열 추가", {
targetComponentId: transfer.targetComponentId,
dataCount: transfer.data.length,
targetLayerId: transfer.targetLayerId,
});
}, []);
const getPendingTransfer = useCallback((componentId: string) => {
return pendingTransfersRef.current.get(componentId);
}, []);
const clearPendingTransfer = useCallback((componentId: string) => {
pendingTransfersRef.current.delete(componentId);
logger.debug("대기 데이터 전달 클리어", { componentId });
}, []);
const value = React.useMemo<ScreenContextValue>( const value = React.useMemo<ScreenContextValue>(
() => ({ () => ({
screenId, screenId,
@ -178,9 +127,6 @@ export function ScreenContextProvider({
getDataReceiver, getDataReceiver,
getAllDataProviders, getAllDataProviders,
getAllDataReceivers, getAllDataReceivers,
addPendingTransfer,
getPendingTransfer,
clearPendingTransfer,
}), }),
[ [
screenId, screenId,
@ -197,9 +143,6 @@ export function ScreenContextProvider({
getDataReceiver, getDataReceiver,
getAllDataProviders, getAllDataProviders,
getAllDataReceivers, getAllDataReceivers,
addPendingTransfer,
getPendingTransfer,
clearPendingTransfer,
], ],
); );

View File

@ -480,20 +480,15 @@ export function RepeaterTable({
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field; const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
const value = row[column.field]; const value = row[column.field];
// 카테고리 라벨 변환 함수 // 🆕 카테고리 라벨 변환 함수
const getCategoryDisplayValue = (val: any): string => { const getCategoryDisplayValue = (val: any): string => {
if (!val || typeof val !== "string") return val || "-"; if (!val || typeof val !== "string") return val || "-";
const fieldName = column.field.replace(/^_display_/, ""); // 카테고리 컬럼이 아니면 그대로 반환
const isCategoryColumn = categoryColumns.includes(fieldName); const fieldName = column.field.replace(/^_display_/, ""); // _display_ 접두사 제거
if (!categoryColumns.includes(fieldName)) return val;
// categoryLabelMap에 직접 매핑이 있으면 바로 변환 (접두사 무관) // 쉼표로 구분된 다중 값 처리
if (categoryLabelMap[val]) return categoryLabelMap[val];
// 카테고리 컬럼이 아니면 원래 값 반환
if (!isCategoryColumn) return val;
// 콤마 구분된 다중 값 처리
const codes = val const codes = val
.split(",") .split(",")
.map((c: string) => c.trim()) .map((c: string) => c.trim())

View File

@ -781,7 +781,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const dataProvider: DataProvidable = { const dataProvider: DataProvidable = {
componentId: component.id, componentId: component.id,
componentType: "table-list", componentType: "table-list",
tableName: tableConfig.selectedTable,
getSelectedData: () => { getSelectedData: () => {
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외) // 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)

View File

@ -554,69 +554,6 @@ export function TableSectionRenderer({
loadCategoryOptions(); loadCategoryOptions();
}, [tableConfig.source.tableName, tableConfig.columns]); }, [tableConfig.source.tableName, tableConfig.columns]);
// receiveFromParent / internal 매핑으로 넘어오는 formData 값의 라벨 사전 로드
useEffect(() => {
if (!formData || Object.keys(formData).length === 0) return;
if (!tableConfig.columns) return;
const codesToResolve: string[] = [];
for (const col of tableConfig.columns) {
// receiveFromParent 컬럼
if ((col as any).receiveFromParent) {
const parentField = (col as any).parentFieldName || col.field;
const val = formData[parentField];
if (typeof val === "string" && val) {
codesToResolve.push(val);
}
}
// internal 매핑 컬럼
const mapping = (col as any).valueMapping;
if (mapping?.type === "internal" && mapping.internalField) {
const val = formData[mapping.internalField];
if (typeof val === "string" && val) {
codesToResolve.push(val);
}
}
}
if (codesToResolve.length === 0) return;
const loadParentLabels = async () => {
try {
const resp = await apiClient.post("/table-categories/labels-by-codes", {
valueCodes: codesToResolve,
});
if (resp.data?.success && resp.data.data) {
const labelData = resp.data.data as Record<string, string>;
// categoryOptionsMap에 추가 (receiveFromParent 컬럼별로)
const newOptionsMap: Record<string, { value: string; label: string }[]> = {};
for (const col of tableConfig.columns) {
let val: string | undefined;
if ((col as any).receiveFromParent) {
const parentField = (col as any).parentFieldName || col.field;
val = formData[parentField] as string;
}
const mapping = (col as any).valueMapping;
if (mapping?.type === "internal" && mapping.internalField) {
val = formData[mapping.internalField] as string;
}
if (val && typeof val === "string" && labelData[val]) {
newOptionsMap[col.field] = [{ value: val, label: labelData[val] }];
}
}
if (Object.keys(newOptionsMap).length > 0) {
setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap }));
}
}
} catch {
// 라벨 조회 실패 시 무시
}
};
loadParentLabels();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formData, tableConfig.columns]);
// 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우) // 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
useEffect(() => { useEffect(() => {
if (!isConditionalMode) return; if (!isConditionalMode) return;
@ -1068,23 +1005,6 @@ export function TableSectionRenderer({
}); });
}, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]); }, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]);
// categoryOptionsMap에서 RepeaterTable용 카테고리 정보 파생
const tableCategoryColumns = useMemo(() => {
return Object.keys(categoryOptionsMap);
}, [categoryOptionsMap]);
const tableCategoryLabelMap = useMemo(() => {
const map: Record<string, string> = {};
for (const options of Object.values(categoryOptionsMap)) {
for (const opt of options) {
if (opt.value && opt.label) {
map[opt.value] = opt.label;
}
}
}
return map;
}, [categoryOptionsMap]);
// 원본 계산 규칙 (조건부 계산 포함) // 원본 계산 규칙 (조건부 계산 포함)
const originalCalculationRules: TableCalculationRule[] = useMemo( const originalCalculationRules: TableCalculationRule[] = useMemo(
() => tableConfig.calculations || [], () => tableConfig.calculations || [],
@ -1392,67 +1312,6 @@ export function TableSectionRenderer({
}), }),
); );
// 카테고리 타입 컬럼의 코드 → 라벨 변환 (categoryOptionsMap 활용)
const categoryFields = (tableConfig.columns || [])
.filter((col) => col.type === "category" || col.type === "select")
.reduce<Record<string, Record<string, string>>>((acc, col) => {
const options = categoryOptionsMap[col.field];
if (options && options.length > 0) {
acc[col.field] = {};
for (const opt of options) {
acc[col.field][opt.value] = opt.label;
}
}
return acc;
}, {});
// receiveFromParent / internal 매핑으로 넘어온 값도 포함하여 변환
if (Object.keys(categoryFields).length > 0) {
for (const item of mappedItems) {
for (const [field, codeToLabel] of Object.entries(categoryFields)) {
const val = item[field];
if (typeof val === "string" && codeToLabel[val]) {
item[field] = codeToLabel[val];
}
}
}
}
// categoryOptionsMap에 없는 경우 API fallback
const unresolvedCodes = new Set<string>();
const categoryColFields = new Set(
(tableConfig.columns || []).filter((col) => col.type === "category").map((col) => col.field),
);
for (const item of mappedItems) {
for (const field of categoryColFields) {
const val = item[field];
if (typeof val === "string" && val && !categoryFields[field]?.[val] && val !== item[field]) {
unresolvedCodes.add(val);
}
}
}
if (unresolvedCodes.size > 0) {
try {
const labelResp = await apiClient.post("/table-categories/labels-by-codes", {
valueCodes: Array.from(unresolvedCodes),
});
if (labelResp.data?.success && labelResp.data.data) {
const labelData = labelResp.data.data as Record<string, string>;
for (const item of mappedItems) {
for (const field of categoryColFields) {
const val = item[field];
if (typeof val === "string" && labelData[val]) {
item[field] = labelData[val];
}
}
}
}
} catch {
// 변환 실패 시 코드 유지
}
}
// 계산 필드 업데이트 // 계산 필드 업데이트
const calculatedItems = calculateAll(mappedItems); const calculatedItems = calculateAll(mappedItems);
@ -1460,7 +1319,7 @@ export function TableSectionRenderer({
const newData = [...tableData, ...calculatedItems]; const newData = [...tableData, ...calculatedItems];
handleDataChange(newData); handleDataChange(newData);
}, },
[tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources, categoryOptionsMap], [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources],
); );
// 컬럼 모드/조회 옵션 변경 핸들러 // 컬럼 모드/조회 옵션 변경 핸들러
@ -1808,31 +1667,6 @@ export function TableSectionRenderer({
}), }),
); );
// 카테고리 타입 컬럼의 코드 → 라벨 변환 (categoryOptionsMap 활용)
const categoryFields = (tableConfig.columns || [])
.filter((col) => col.type === "category" || col.type === "select")
.reduce<Record<string, Record<string, string>>>((acc, col) => {
const options = categoryOptionsMap[col.field];
if (options && options.length > 0) {
acc[col.field] = {};
for (const opt of options) {
acc[col.field][opt.value] = opt.label;
}
}
return acc;
}, {});
if (Object.keys(categoryFields).length > 0) {
for (const item of mappedItems) {
for (const [field, codeToLabel] of Object.entries(categoryFields)) {
const val = item[field];
if (typeof val === "string" && codeToLabel[val]) {
item[field] = codeToLabel[val];
}
}
}
}
// 현재 조건의 데이터에 추가 // 현재 조건의 데이터에 추가
const currentData = conditionalTableData[modalCondition] || []; const currentData = conditionalTableData[modalCondition] || [];
const newData = [...currentData, ...mappedItems]; const newData = [...currentData, ...mappedItems];
@ -2130,8 +1964,6 @@ export function TableSectionRenderer({
[conditionValue]: newSelected, [conditionValue]: newSelected,
})); }));
}} }}
categoryColumns={tableCategoryColumns}
categoryLabelMap={tableCategoryLabelMap}
equalizeWidthsTrigger={widthTrigger} equalizeWidthsTrigger={widthTrigger}
/> />
</TabsContent> </TabsContent>
@ -2223,8 +2055,6 @@ export function TableSectionRenderer({
})); }));
}} }}
equalizeWidthsTrigger={widthTrigger} equalizeWidthsTrigger={widthTrigger}
categoryColumns={tableCategoryColumns}
categoryLabelMap={tableCategoryLabelMap}
/> />
</TabsContent> </TabsContent>
); );
@ -2355,8 +2185,6 @@ export function TableSectionRenderer({
selectedRows={selectedRows} selectedRows={selectedRows}
onSelectionChange={setSelectedRows} onSelectionChange={setSelectedRows}
equalizeWidthsTrigger={widthTrigger} equalizeWidthsTrigger={widthTrigger}
categoryColumns={tableCategoryColumns}
categoryLabelMap={tableCategoryLabelMap}
/> />
{/* 항목 선택 모달 */} {/* 항목 선택 모달 */}

View File

@ -393,7 +393,7 @@ export interface TableModalFilter {
export interface TableColumnConfig { export interface TableColumnConfig {
field: string; // 필드명 (저장할 컬럼명) field: string; // 필드명 (저장할 컬럼명)
label: string; // 컬럼 헤더 라벨 label: string; // 컬럼 헤더 라벨
type: "text" | "number" | "date" | "select" | "category"; // 입력 타입 type: "text" | "number" | "date" | "select"; // 입력 타입
// 소스 필드 매핑 (검색 모달에서 가져올 컬럼명) // 소스 필드 매핑 (검색 모달에서 가져올 컬럼명)
sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일) sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일)

View File

@ -724,28 +724,17 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
try { try {
// 1. 소스 컴포넌트에서 데이터 가져오기 // 1. 소스 컴포넌트에서 데이터 가져오기
let sourceProvider: import("@/types/data-transfer").DataProvidable | undefined; let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
const isAutoSource = // 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색
!dataTransferConfig.sourceComponentId || dataTransferConfig.sourceComponentId === "__auto__"; // (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응)
if (!isAutoSource) {
sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
}
// 자동 탐색 모드이거나, 지정된 소스를 찾지 못한 경우
// 현재 마운트된 DataProvider 중에서 table-list를 자동 탐색
if (!sourceProvider) { if (!sourceProvider) {
if (!isAutoSource) { console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`);
console.log( console.log("🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...");
`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`,
);
}
console.log("🔍 [ButtonPrimary] 현재 활성 DataProvider 자동 탐색...");
const allProviders = screenContext.getAllDataProviders(); const allProviders = screenContext.getAllDataProviders();
// table-list 우선 탐색 // 테이블 리스트 우선 탐색
for (const [id, provider] of allProviders) { for (const [id, provider] of allProviders) {
if (provider.componentType === "table-list") { if (provider.componentType === "table-list") {
sourceProvider = provider; sourceProvider = provider;
@ -754,7 +743,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
} }
// table-list가 없으면 첫 번째 DataProvider 사용 // 테이블 리스트가 없으면 첫 번째 DataProvider 사용
if (!sourceProvider && allProviders.size > 0) { if (!sourceProvider && allProviders.size > 0) {
const firstEntry = allProviders.entries().next().value; const firstEntry = allProviders.entries().next().value;
if (firstEntry) { if (firstEntry) {
@ -795,12 +784,15 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const additionalValues = additionalProvider.getSelectedData(); const additionalValues = additionalProvider.getSelectedData();
if (additionalValues && additionalValues.length > 0) { if (additionalValues && additionalValues.length > 0) {
// 첫 번째 값 사용 (조건부 컨테이너는 항상 1개)
const firstValue = additionalValues[0]; const firstValue = additionalValues[0];
// fieldName이 지정되어 있으면 그 필드만 추출
if (additionalSource.fieldName) { if (additionalSource.fieldName) {
additionalData[additionalSource.fieldName] = additionalData[additionalSource.fieldName] =
firstValue[additionalSource.fieldName] || firstValue.condition || firstValue; firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
} else { } else {
// fieldName이 없으면 전체 객체 병합
additionalData = { ...additionalData, ...firstValue }; additionalData = { ...additionalData, ...firstValue };
} }
@ -810,25 +802,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
value: additionalData[additionalSource.fieldName || "all"], value: additionalData[additionalSource.fieldName || "all"],
}); });
} }
} else if (formData) {
// DataProvider로 등록되지 않은 컴포넌트(v2-select 등)는 formData에서 값을 가져옴
const comp = allComponents?.find((c: any) => c.id === additionalSource.componentId);
const columnName =
comp?.columnName ||
comp?.componentConfig?.columnName ||
comp?.overrides?.columnName;
if (columnName && formData[columnName] !== undefined && formData[columnName] !== "") {
const targetField = additionalSource.fieldName || columnName;
additionalData[targetField] = formData[columnName];
console.log("📦 추가 데이터 수집 (formData 폴백):", {
sourceId: additionalSource.componentId,
columnName,
targetField,
value: formData[columnName],
});
}
} }
} }
} }
@ -897,126 +870,44 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
} }
// 4. 매핑 규칙 결정: 멀티 테이블 매핑 또는 레거시 단일 매핑 // 4. 매핑 규칙 적용 + 추가 데이터 병합
let effectiveMappingRules: any[] = dataTransferConfig.mappingRules || [];
const sourceTableName = sourceProvider?.tableName;
const multiTableMappings: Array<{ sourceTable: string; mappingRules: any[] }> =
dataTransferConfig.multiTableMappings || [];
if (multiTableMappings.length > 0 && sourceTableName) {
const matchedGroup = multiTableMappings.find((g) => g.sourceTable === sourceTableName);
if (matchedGroup) {
effectiveMappingRules = matchedGroup.mappingRules || [];
console.log(`✅ [ButtonPrimary] 멀티 테이블 매핑 적용: ${sourceTableName}`, effectiveMappingRules);
} else {
console.log(`⚠️ [ButtonPrimary] 소스 테이블 ${sourceTableName}에 대한 매핑 없음, 동일 필드명 자동 매핑`);
effectiveMappingRules = [];
}
} else if (multiTableMappings.length > 0 && !sourceTableName) {
console.log("⚠️ [ButtonPrimary] 소스 테이블 미감지, 첫 번째 매핑 그룹 사용");
effectiveMappingRules = multiTableMappings[0]?.mappingRules || [];
}
const mappedData = sourceData.map((row) => { const mappedData = sourceData.map((row) => {
const mappedRow = applyMappingRules(row, effectiveMappingRules); const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []);
// 추가 데이터를 모든 행에 포함
return { return {
...mappedRow, ...mappedRow,
...additionalData, ...additionalData,
}; };
}); });
// 5. targetType / targetComponentId 기본값 및 자동 탐색
const effectiveTargetType = dataTransferConfig.targetType || "component";
let effectiveTargetComponentId = dataTransferConfig.targetComponentId;
// targetComponentId가 없으면 현재 화면에서 DataReceiver 자동 탐색
if (effectiveTargetType === "component" && !effectiveTargetComponentId) {
console.log("🔍 [ButtonPrimary] 타겟 컴포넌트 자동 탐색...");
const allReceivers = screenContext.getAllDataReceivers();
// repeater 계열 우선 탐색
for (const [id, receiver] of allReceivers) {
if (
receiver.componentType === "repeater-field-group" ||
receiver.componentType === "v2-repeater" ||
receiver.componentType === "repeater"
) {
effectiveTargetComponentId = id;
console.log(`✅ [ButtonPrimary] 리피터 자동 발견: ${id} (${receiver.componentType})`);
break;
}
}
// repeater가 없으면 소스가 아닌 첫 번째 DataReceiver 사용
if (!effectiveTargetComponentId) {
for (const [id, receiver] of allReceivers) {
if (receiver.componentType === "table-list" || receiver.componentType === "data-table") {
effectiveTargetComponentId = id;
console.log(`✅ [ButtonPrimary] DataReceiver 자동 발견: ${id} (${receiver.componentType})`);
break;
}
}
}
if (!effectiveTargetComponentId) {
toast.error("데이터를 받을 수 있는 타겟 컴포넌트를 찾을 수 없습니다.");
return;
}
}
console.log("📦 데이터 전달:", { console.log("📦 데이터 전달:", {
sourceData, sourceData,
mappedData, mappedData,
targetType: effectiveTargetType, targetType: dataTransferConfig.targetType,
targetComponentId: effectiveTargetComponentId, targetComponentId: dataTransferConfig.targetComponentId,
targetScreenId: dataTransferConfig.targetScreenId, targetScreenId: dataTransferConfig.targetScreenId,
}); });
// 6. 타겟으로 데이터 전달 // 5. 타겟으로 데이터 전달
if (effectiveTargetType === "component") { if (dataTransferConfig.targetType === "component") {
const targetReceiver = screenContext.getDataReceiver(effectiveTargetComponentId); // 같은 화면의 컴포넌트로 전달
const targetReceiver = screenContext.getDataReceiver(dataTransferConfig.targetComponentId);
const receiverConfig = {
targetComponentId: effectiveTargetComponentId,
targetComponentType: targetReceiver?.componentType || ("table" as const),
mode: dataTransferConfig.mode || ("append" as const),
mappingRules: dataTransferConfig.mappingRules || [],
};
if (!targetReceiver) { if (!targetReceiver) {
// 타겟이 아직 마운트되지 않은 경우 (조건부 레이어 등) toast.error(`타겟 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.targetComponentId}`);
// 버퍼에 저장하고 레이어 활성화 요청
console.log(
`⏳ [ButtonPrimary] 타겟 컴포넌트 미마운트, 대기열에 추가: ${effectiveTargetComponentId}`,
);
screenContext.addPendingTransfer({
targetComponentId: effectiveTargetComponentId,
data: mappedData,
config: receiverConfig,
timestamp: Date.now(),
targetLayerId: dataTransferConfig.targetLayerId,
});
// 레이어 활성화 이벤트 발행 (page.tsx에서 수신)
const activateEvent = new CustomEvent("activateLayerForComponent", {
detail: {
componentId: effectiveTargetComponentId,
targetLayerId: dataTransferConfig.targetLayerId,
},
});
window.dispatchEvent(activateEvent);
toast.info(`타겟 레이어를 활성화하고 데이터 전달을 준비합니다...`);
return; return;
} }
await targetReceiver.receiveData(mappedData, receiverConfig); await targetReceiver.receiveData(mappedData, {
targetComponentId: dataTransferConfig.targetComponentId,
targetComponentType: targetReceiver.componentType,
mode: dataTransferConfig.mode || "append",
mappingRules: dataTransferConfig.mappingRules || [],
});
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`); toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
} else if (effectiveTargetType === "splitPanel") { } else if (dataTransferConfig.targetType === "splitPanel") {
// 🆕 분할 패널의 반대편 화면으로 전달 // 🆕 분할 패널의 반대편 화면으로 전달
if (!splitPanelContext) { if (!splitPanelContext) {
toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요."); toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요.");

View File

@ -97,7 +97,6 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
return ( return (
<V2Repeater <V2Repeater
config={config} config={config}
componentId={component?.id}
parentId={resolvedParentId} parentId={resolvedParentId}
data={Array.isArray(data) ? data : undefined} data={Array.isArray(data) ? data : undefined}
onDataChange={onDataChange} onDataChange={onDataChange}

View File

@ -654,7 +654,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20); const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]); const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
const [columnMeta, setColumnMeta] = useState< const [columnMeta, setColumnMeta] = useState<
Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }> Record<string, { webType?: string; codeCategory?: string; inputType?: string }>
>({}); >({});
// 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType) // 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType)
const [joinedColumnMeta, setJoinedColumnMeta] = useState< const [joinedColumnMeta, setJoinedColumnMeta] = useState<
@ -865,7 +865,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const dataProvider: DataProvidable = { const dataProvider: DataProvidable = {
componentId: component.id, componentId: component.id,
componentType: "table-list", componentType: "table-list",
tableName: tableConfig.selectedTable,
getSelectedData: () => { getSelectedData: () => {
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외) // 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
@ -1234,16 +1233,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const cached = tableColumnCache.get(cacheKey); const cached = tableColumnCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) { if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
const labels: Record<string, string> = {}; const labels: Record<string, string> = {};
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }> = {}; const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
// 캐시된 inputTypes 맵 생성
const inputTypeMap: Record<string, string> = {}; const inputTypeMap: Record<string, string> = {};
const categoryRefMap: Record<string, string> = {};
if (cached.inputTypes) { if (cached.inputTypes) {
cached.inputTypes.forEach((col: any) => { cached.inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType; inputTypeMap[col.columnName] = col.inputType;
if (col.categoryRef) {
categoryRefMap[col.columnName] = col.categoryRef;
}
}); });
} }
@ -1252,8 +1248,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
meta[col.columnName] = { meta[col.columnName] = {
webType: col.webType, webType: col.webType,
codeCategory: col.codeCategory, codeCategory: col.codeCategory,
inputType: inputTypeMap[col.columnName], inputType: inputTypeMap[col.columnName], // 캐시된 inputType 사용!
categoryRef: categoryRefMap[col.columnName],
}; };
}); });
@ -1264,14 +1259,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const columns = await tableTypeApi.getColumns(tableConfig.selectedTable); const columns = await tableTypeApi.getColumns(tableConfig.selectedTable);
// 컬럼 입력 타입 정보 가져오기
const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable); const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable);
const inputTypeMap: Record<string, string> = {}; const inputTypeMap: Record<string, string> = {};
const categoryRefMap: Record<string, string> = {};
inputTypes.forEach((col: any) => { inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType; inputTypeMap[col.columnName] = col.inputType;
if (col.categoryRef) {
categoryRefMap[col.columnName] = col.categoryRef;
}
}); });
tableColumnCache.set(cacheKey, { tableColumnCache.set(cacheKey, {
@ -1281,7 +1273,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}); });
const labels: Record<string, string> = {}; const labels: Record<string, string> = {};
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }> = {}; const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {};
columns.forEach((col: any) => { columns.forEach((col: any) => {
labels[col.columnName] = col.displayName || col.comment || col.columnName; labels[col.columnName] = col.displayName || col.comment || col.columnName;
@ -1289,7 +1281,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
webType: col.webType, webType: col.webType,
codeCategory: col.codeCategory, codeCategory: col.codeCategory,
inputType: inputTypeMap[col.columnName], inputType: inputTypeMap[col.columnName],
categoryRef: categoryRefMap[col.columnName],
}; };
}); });
@ -1364,22 +1355,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
for (const columnName of categoryColumns) { for (const columnName of categoryColumns) {
try { try {
// 🆕 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태인지 확인
let targetTable = tableConfig.selectedTable; let targetTable = tableConfig.selectedTable;
let targetColumn = columnName; let targetColumn = columnName;
// category_ref가 있으면 참조 테이블.컬럼 기준으로 조회 if (columnName.includes(".")) {
const meta = columnMeta[columnName];
if (meta?.categoryRef) {
const refParts = meta.categoryRef.split(".");
if (refParts.length === 2) {
targetTable = refParts[0];
targetColumn = refParts[1];
}
} else if (columnName.includes(".")) {
// 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태
const parts = columnName.split("."); const parts = columnName.split(".");
targetTable = parts[0]; targetTable = parts[0]; // 조인된 테이블명 (예: item_info)
targetColumn = parts[1]; targetColumn = parts[1]; // 실제 컬럼명 (예: material)
} }
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`); const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`);
@ -1580,8 +1563,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
categoryColumns.length, categoryColumns.length,
JSON.stringify(categoryColumns), JSON.stringify(categoryColumns),
JSON.stringify(tableConfig.columns), JSON.stringify(tableConfig.columns),
columnMeta, ]); // 더 명확한 의존성
]);
// ======================================== // ========================================
// 데이터 가져오기 // 데이터 가져오기

View File

@ -559,7 +559,6 @@ export class ButtonActionExecutor {
} }
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
// EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림
if (onSave) { if (onSave) {
try { try {
await onSave(); await onSave();
@ -627,7 +626,6 @@ export class ButtonActionExecutor {
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
// 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리) // 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리)
// EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림
if (onSave && !hasTableSectionData) { if (onSave && !hasTableSectionData) {
try { try {
await onSave(); await onSave();
@ -1496,24 +1494,13 @@ export class ButtonActionExecutor {
// @ts-ignore - window에 동적 속성 사용 // @ts-ignore - window에 동적 속성 사용
const v2RepeaterTables = Array.from(window.__v2RepeaterInstances || []); const v2RepeaterTables = Array.from(window.__v2RepeaterInstances || []);
// V2Repeater가 동일 테이블에 존재하는지 allComponents로 감지
// (useCustomTable 미설정 = 화면 테이블에 직접 저장하는 리피터)
const hasRepeaterOnSameTable = context.allComponents?.some((c: any) => {
const compType = c.componentType || c.overrides?.type;
if (compType !== "v2-repeater") return false;
const compConfig = c.componentConfig || c.overrides || {};
return !compConfig.useCustomTable;
}) || false;
// 메인 저장 건너뛰기 조건: // 메인 저장 건너뛰기 조건:
// 1. RepeatScreenModal 또는 RepeaterFieldGroup에서 같은 테이블 처리 // 1. RepeatScreenModal 또는 RepeaterFieldGroup에서 같은 테이블 처리
// 2. V2Repeater가 같은 테이블에 존재 (리피터 데이터에 메인 폼 데이터 병합되어 저장됨) // 2. V2Repeater가 같은 테이블에 존재 (리피터 데이터에 메인 폼 데이터 병합되어 저장됨)
// 3. allComponents에서 useCustomTable 미설정 V2Repeater 감지 (글로벌 등록 없는 경우)
const shouldSkipMainSave = const shouldSkipMainSave =
repeatScreenModalTables.includes(tableName) || repeatScreenModalTables.includes(tableName) ||
repeaterFieldGroupTables.includes(tableName) || repeaterFieldGroupTables.includes(tableName) ||
v2RepeaterTables.includes(tableName) || v2RepeaterTables.includes(tableName);
hasRepeaterOnSameTable;
if (shouldSkipMainSave) { if (shouldSkipMainSave) {
saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal/V2Repeater에서 처리" }; saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal/V2Repeater에서 처리" };
@ -1792,7 +1779,16 @@ export class ButtonActionExecutor {
throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)"); throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
} }
// V2Repeater 저장 이벤트 발생 (모달 닫기 전에 실행해야 V2Repeater가 이벤트를 수신할 수 있음) // 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
context.onRefresh?.();
context.onFlowRefresh?.();
// 저장 성공 후 이벤트 발생
window.dispatchEvent(new CustomEvent("closeEditModal")); // EditModal 닫기
window.dispatchEvent(new CustomEvent("saveSuccessInModal")); // ScreenModal 연속 등록 모드 처리
// V2Repeater 저장 이벤트 발생 (메인 폼 데이터 + 리피터 데이터 병합 저장)
// 🔧 formData를 리피터에 전달하여 각 행에 병합 저장
const savedId = saveResult?.data?.id || saveResult?.data?.data?.id || formData.id || context.formData?.id; const savedId = saveResult?.data?.id || saveResult?.data?.data?.id || formData.id || context.formData?.id;
// _deferSave 데이터 처리 (마스터-디테일 순차 저장: 레벨별 저장 + temp→real ID 매핑) // _deferSave 데이터 처리 (마스터-디테일 순차 저장: 레벨별 저장 + temp→real ID 매핑)
@ -1870,45 +1866,17 @@ export class ButtonActionExecutor {
} }
} }
console.log("🟢 [buttonActions] repeaterSave 이벤트 발행:", {
parentId: savedId,
tableName: context.tableName,
masterRecordId: savedId,
mainFormDataKeys: Object.keys(mainFormData),
});
// V2Repeater 저장 완료를 기다리기 위한 Promise
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("repeaterSave", { new CustomEvent("repeaterSave", {
detail: { detail: {
parentId: savedId, parentId: savedId,
tableName: context.tableName, tableName: context.tableName,
mainFormData, mainFormData, // 🆕 메인 폼 데이터 전달
masterRecordId: savedId, masterRecordId: savedId, // 🆕 마스터 레코드 ID (FK 자동 연결용)
}, },
}), }),
); );
await repeaterSavePromise;
// 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
context.onRefresh?.();
context.onFlowRefresh?.();
// 저장 성공 후 모달 닫기 이벤트 발생
window.dispatchEvent(new CustomEvent("closeEditModal"));
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
return true; return true;
} catch (error) { } catch (error) {
console.error("저장 오류:", error); console.error("저장 오류:", error);
@ -1916,50 +1884,6 @@ export class ButtonActionExecutor {
} }
} }
/**
* V2Repeater (onSave )
*/
private static async dispatchRepeaterSave(context: ButtonActionContext): Promise<void> {
const formData = context.formData || {};
const savedId = formData.id;
if (!savedId) {
console.log("⚠️ [dispatchRepeaterSave] savedId(formData.id) 없음 - 스킵");
return;
}
console.log("🟢 [dispatchRepeaterSave] repeaterSave 이벤트 발행:", {
parentId: savedId,
tableName: context.tableName,
masterRecordId: savedId,
formDataKeys: Object.keys(formData),
});
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: savedId,
tableName: context.tableName,
mainFormData: formData,
masterRecordId: savedId,
},
}),
);
await repeaterSavePromise;
console.log("✅ [dispatchRepeaterSave] repeaterSave 완료");
}
/** /**
* DB에서 formData에서 * DB에서 formData에서
* @param formData * @param formData
@ -6363,14 +6287,7 @@ export class ButtonActionExecutor {
const { targetType, targetComponentId, targetScreenId, mappingRules, receiveMode } = dataTransfer; const { targetType, targetComponentId, targetScreenId, mappingRules, receiveMode } = dataTransfer;
if (targetType === "component" && targetComponentId) { if (targetType === "component" && targetComponentId) {
// 같은 화면 내 컴포넌트로 전달 + 레이어 활성화 이벤트 병행 // 같은 화면 내 컴포넌트로 전달
const activateEvent = new CustomEvent("activateLayerForComponent", {
detail: {
componentId: targetComponentId,
targetLayerId: (dataTransfer as any).targetLayerId,
},
});
window.dispatchEvent(activateEvent);
const transferEvent = new CustomEvent("componentDataTransfer", { const transferEvent = new CustomEvent("componentDataTransfer", {
detail: { detail: {

View File

@ -57,15 +57,6 @@ export interface MappingRule {
required?: boolean; // 필수 여부 required?: boolean; // 필수 여부
} }
/**
*
*
*/
export interface MultiTableMappingGroup {
sourceTable: string;
mappingRules: MappingRule[];
}
/** /**
* *
* *
@ -164,7 +155,6 @@ export interface DataReceivable {
export interface DataProvidable { export interface DataProvidable {
componentId: string; componentId: string;
componentType: string; componentType: string;
tableName?: string;
/** /**
* *

View File

@ -181,7 +181,6 @@ export interface V2RepeaterConfig {
// 컴포넌트 Props // 컴포넌트 Props
export interface V2RepeaterProps { export interface V2RepeaterProps {
config: V2RepeaterConfig; config: V2RepeaterConfig;
componentId?: string; // ScreenContext DataReceiver 등록용
parentId?: string | number; // 부모 레코드 ID parentId?: string | number; // 부모 레코드 ID
data?: any[]; // 초기 데이터 (없으면 API로 로드) data?: any[]; // 초기 데이터 (없으면 API로 로드)
onDataChange?: (data: any[]) => void; onDataChange?: (data: any[]) => void;