Merge pull request 'feature/screen-management' (#318) from feature/screen-management into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/318
This commit is contained in:
kjs 2025-12-30 14:19:44 +09:00
commit 4421ccaa71
8 changed files with 1171 additions and 694 deletions

View File

@ -3081,6 +3081,79 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
/> />
)} )}
{/* 🆕 행 선택 시에만 활성화 설정 */}
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground"> </h4>
<p className="text-xs text-muted-foreground">
.
</p>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> </Label>
<p className="text-xs text-muted-foreground">
.
</p>
</div>
<Switch
checked={component.componentConfig?.action?.requireRowSelection || false}
onCheckedChange={(checked) => {
onUpdateProperty("componentConfig.action.requireRowSelection", checked);
}}
/>
</div>
{component.componentConfig?.action?.requireRowSelection && (
<div className="space-y-3 pl-4 border-l-2 border-primary/20">
<div>
<Label htmlFor="row-selection-source"> </Label>
<Select
value={component.componentConfig?.action?.rowSelectionSource || "auto"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.rowSelectionSource", value);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="데이터 소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ()</SelectItem>
<SelectItem value="tableList"> </SelectItem>
<SelectItem value="splitPanelLeft"> </SelectItem>
<SelectItem value="flowWidget"> </SelectItem>
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
감지: 테이블, ,
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label> </Label>
<p className="text-xs text-muted-foreground">
(기본: 1개 )
</p>
</div>
<Switch
checked={component.componentConfig?.action?.allowMultiRowSelection ?? true}
onCheckedChange={(checked) => {
onUpdateProperty("componentConfig.action.allowMultiRowSelection", checked);
}}
/>
</div>
{!(component.componentConfig?.action?.allowMultiRowSelection ?? true) && (
<div className="rounded-md bg-yellow-50 p-2 dark:bg-yellow-950/20">
<p className="text-xs text-yellow-800 dark:text-yellow-200">
1 .
</p>
</div>
)}
</div>
)}
</div>
{/* 제어 기능 섹션 */} {/* 제어 기능 섹션 */}
<div className="mt-8 border-t border-border pt-6"> <div className="mt-8 border-t border-border pt-6">
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} /> <ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />

View File

@ -296,6 +296,145 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
return false; return false;
}, [component.componentConfig?.action, formData, vehicleStatus, statusLoading, component.label]); }, [component.componentConfig?.action, formData, vehicleStatus, statusLoading, component.label]);
// 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨)
const [modalStoreData, setModalStoreData] = useState<Record<string, any[]>>({});
// modalDataStore 상태 구독 (실시간 업데이트)
useEffect(() => {
const actionConfig = component.componentConfig?.action;
if (!actionConfig?.requireRowSelection) return;
// 동적 import로 modalDataStore 구독
let unsubscribe: (() => void) | undefined;
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
// 초기값 설정
setModalStoreData(useModalDataStore.getState().dataRegistry);
// 상태 변경 구독
unsubscribe = useModalDataStore.subscribe((state) => {
setModalStoreData(state.dataRegistry);
});
});
return () => {
unsubscribe?.();
};
}, [component.componentConfig?.action?.requireRowSelection]);
// 🆕 행 선택 기반 비활성화 조건 계산
const isRowSelectionDisabled = useMemo(() => {
const actionConfig = component.componentConfig?.action;
// requireRowSelection이 활성화되어 있지 않으면 비활성화하지 않음
if (!actionConfig?.requireRowSelection) {
return false;
}
const rowSelectionSource = actionConfig.rowSelectionSource || "auto";
const allowMultiRowSelection = actionConfig.allowMultiRowSelection ?? true;
// 선택된 데이터 확인
let hasSelection = false;
let selectionCount = 0;
let selectionSource = "";
// 1. 자동 감지 모드 또는 테이블 리스트 모드
if (rowSelectionSource === "auto" || rowSelectionSource === "tableList") {
// TableList에서 선택된 행 확인 (props로 전달됨)
if (selectedRowsData && selectedRowsData.length > 0) {
hasSelection = true;
selectionCount = selectedRowsData.length;
selectionSource = "tableList (selectedRowsData)";
}
// 또는 selectedRows prop 확인
else if (selectedRows && selectedRows.length > 0) {
hasSelection = true;
selectionCount = selectedRows.length;
selectionSource = "tableList (selectedRows)";
}
}
// 2. 분할 패널 좌측 선택 데이터 확인
if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") {
// SplitPanelContext에서 확인
if (splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0) {
if (!hasSelection) {
hasSelection = true;
selectionCount = 1;
selectionSource = "splitPanelLeft (context)";
}
}
// 🆕 modalDataStore에서도 확인 (SplitPanelLayoutComponent에서 저장)
if (!hasSelection && Object.keys(modalStoreData).length > 0) {
// modalDataStore에서 데이터가 있는지 확인
for (const [sourceId, items] of Object.entries(modalStoreData)) {
if (items && items.length > 0) {
hasSelection = true;
selectionCount = items.length;
selectionSource = `modalDataStore (${sourceId})`;
break;
}
}
}
}
// 3. 플로우 위젯 선택 데이터 확인
if (rowSelectionSource === "auto" || rowSelectionSource === "flowWidget") {
// 플로우 위젯 선택 데이터 확인
if (!hasSelection && flowSelectedData && flowSelectedData.length > 0) {
hasSelection = true;
selectionCount = flowSelectedData.length;
selectionSource = "flowWidget";
}
}
// 디버깅 로그
console.log("🔍 [ButtonPrimary] 행 선택 체크:", component.label, {
rowSelectionSource,
hasSelection,
selectionCount,
selectionSource,
hasSplitPanelContext: !!splitPanelContext,
selectedLeftData: splitPanelContext?.selectedLeftData,
selectedRowsData: selectedRowsData?.length,
selectedRows: selectedRows?.length,
flowSelectedData: flowSelectedData?.length,
modalStoreDataKeys: Object.keys(modalStoreData),
});
// 선택된 데이터가 없으면 비활성화
if (!hasSelection) {
console.log("🚫 [ButtonPrimary] 행 선택 필요 → 비활성화:", component.label);
return true;
}
// 다중 선택 허용하지 않는 경우, 정확히 1개만 선택되어야 함
if (!allowMultiRowSelection && selectionCount !== 1) {
console.log("🚫 [ButtonPrimary] 정확히 1개 행 선택 필요 → 비활성화:", component.label, {
selectionCount,
allowMultiRowSelection,
});
return true;
}
console.log("✅ [ButtonPrimary] 행 선택 조건 충족:", component.label, {
selectionCount,
selectionSource,
});
return false;
}, [
component.componentConfig?.action,
component.label,
selectedRows,
selectedRowsData,
splitPanelContext?.selectedLeftData,
flowSelectedData,
splitPanelContext,
modalStoreData,
]);
// 확인 다이얼로그 상태 // 확인 다이얼로그 상태
const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingAction, setPendingAction] = useState<{ const [pendingAction, setPendingAction] = useState<{
@ -832,7 +971,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
// modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터) // modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && effectiveTableName) { // 단, 모달(modal) 액션은 신규 등록이므로 modalDataStore 데이터를 가져오지 않음
// (다른 화면에서 선택한 데이터가 남아있을 수 있으므로)
const shouldFetchFromModalDataStore =
processedConfig.action.type !== "modal" &&
(!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) &&
effectiveTableName;
if (shouldFetchFromModalDataStore) {
try { try {
const { useModalDataStore } = await import("@/stores/modalDataStore"); const { useModalDataStore } = await import("@/stores/modalDataStore");
const dataRegistry = useModalDataStore.getState().dataRegistry; const dataRegistry = useModalDataStore.getState().dataRegistry;
@ -860,12 +1006,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
return; return;
} }
// 모달 액션인데 선택된 데이터가 있으면 경고 메시지 표시하고 중단 // 🔧 모달 액션 시 선택 데이터 경고 제거
// (신규 등록 모달에서 선택된 데이터가 초기값으로 전달되는 것을 방지) // 이전에는 "신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요" 경고를 표시했으나,
if (processedConfig.action.type === "modal" && effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) { // 다른 화면에서 선택한 데이터가 남아있는 경우 오탐이 발생하여 제거함.
toast.warning("신규 등록 시에는 테이블에서 선택된 항목을 해제해주세요."); // 모달 화면 내부에서 필요 시 자체적으로 선택 데이터를 무시하도록 처리하면 됨.
return;
}
// 수정(edit) 액션 검증 // 수정(edit) 액션 검증
if (processedConfig.action.type === "edit") { if (processedConfig.action.type === "edit") {
@ -1088,17 +1232,26 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
} }
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화) // 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수)
const finalDisabled = componentConfig.disabled || isOperationButtonDisabled || statusLoading; const finalDisabled = componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
// 공통 버튼 스타일 // 공통 버튼 스타일
// 🔧 component.style에서 background/backgroundColor 충돌 방지
const userStyle = component.style
? Object.fromEntries(
Object.entries(component.style).filter(
([key]) => !["width", "height", "background", "backgroundColor"].includes(key)
)
)
: {};
const buttonElementStyle: React.CSSProperties = { const buttonElementStyle: React.CSSProperties = {
width: "100%", width: "100%",
height: "100%", height: "100%",
minHeight: "40px", minHeight: "40px",
border: "none", border: "none",
borderRadius: "0.5rem", borderRadius: "0.5rem",
background: finalDisabled ? "#e5e7eb" : buttonColor, backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, // 🔧 background → backgroundColor로 변경
color: finalDisabled ? "#9ca3af" : "white", color: finalDisabled ? "#9ca3af" : "white",
// 🔧 크기 설정 적용 (sm/md/lg) // 🔧 크기 설정 적용 (sm/md/lg)
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem", fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
@ -1114,10 +1267,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
margin: "0", margin: "0",
lineHeight: "1.25", lineHeight: "1.25",
boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)", boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외) // 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height/background 제외)
...(component.style ...userStyle,
? Object.fromEntries(Object.entries(component.style).filter(([key]) => key !== "width" && key !== "height"))
: {}),
}; };
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼"; const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";

View File

@ -2035,9 +2035,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
style={{ style={{
height: componentConfig.leftPanel?.panelHeaderHeight || 48, height: componentConfig.leftPanel?.panelHeaderHeight || 48,
minHeight: componentConfig.leftPanel?.panelHeaderHeight || 48, minHeight: componentConfig.leftPanel?.panelHeaderHeight || 48,
padding: '0 1rem', padding: "0 1rem",
display: 'flex', display: "flex",
alignItems: 'center' alignItems: "center",
}} }}
> >
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
@ -2526,9 +2526,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
style={{ style={{
height: componentConfig.rightPanel?.panelHeaderHeight || 48, height: componentConfig.rightPanel?.panelHeaderHeight || 48,
minHeight: componentConfig.rightPanel?.panelHeaderHeight || 48, minHeight: componentConfig.rightPanel?.panelHeaderHeight || 48,
padding: '0 1rem', padding: "0 1rem",
display: 'flex', display: "flex",
alignItems: 'center' alignItems: "center",
}} }}
> >
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">

View File

@ -16,12 +16,7 @@ import { ItemSelectionModal } from "../modal-repeater-table/ItemSelectionModal";
import { RepeaterColumnConfig, CalculationRule } from "../modal-repeater-table/types"; import { RepeaterColumnConfig, CalculationRule } from "../modal-repeater-table/types";
// 타입 정의 // 타입 정의
import { import { TableSectionConfig, TableColumnConfig, TableJoinCondition, FormDataState } from "./types";
TableSectionConfig,
TableColumnConfig,
TableJoinCondition,
FormDataState,
} from "./types";
interface TableSectionRendererProps { interface TableSectionRendererProps {
sectionId: string; sectionId: string;
@ -81,11 +76,13 @@ function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig {
// 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우) // 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우)
externalLookup: cond.externalLookup, externalLookup: cond.externalLookup,
// 값 변환 설정 전달 (레거시 호환) // 값 변환 설정 전달 (레거시 호환)
transform: cond.transform?.enabled ? { transform: cond.transform?.enabled
? {
tableName: cond.transform.tableName, tableName: cond.transform.tableName,
matchColumn: cond.transform.matchColumn, matchColumn: cond.transform.matchColumn,
resultColumn: cond.transform.resultColumn, resultColumn: cond.transform.resultColumn,
} : undefined, }
: undefined,
})), })),
}, },
// 조회 유형 정보 추가 // 조회 유형 정보 추가
@ -122,7 +119,11 @@ function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig {
/** /**
* TableCalculationRule을 CalculationRule로 * TableCalculationRule을 CalculationRule로
*/ */
function convertToCalculationRule(calc: { resultField: string; formula: string; dependencies: string[] }): CalculationRule { function convertToCalculationRule(calc: {
resultField: string;
formula: string;
dependencies: string[];
}): CalculationRule {
return { return {
result: calc.resultField, result: calc.resultField,
formula: calc.formula, formula: calc.formula,
@ -136,7 +137,7 @@ function convertToCalculationRule(calc: { resultField: string; formula: string;
*/ */
async function transformValue( async function transformValue(
value: any, value: any,
transform: { tableName: string; matchColumn: string; resultColumn: string } transform: { tableName: string; matchColumn: string; resultColumn: string },
): Promise<any> { ): Promise<any> {
if (!value || !transform.tableName || !transform.matchColumn || !transform.resultColumn) { if (!value || !transform.tableName || !transform.matchColumn || !transform.resultColumn) {
return value; return value;
@ -144,19 +145,16 @@ async function transformValue(
try { try {
// 정확히 일치하는 검색 // 정확히 일치하는 검색
const response = await apiClient.post( const response = await apiClient.post(`/table-management/tables/${transform.tableName}/data`, {
`/table-management/tables/${transform.tableName}/data`,
{
search: { search: {
[transform.matchColumn]: { [transform.matchColumn]: {
value: value, value: value,
operator: "equals" operator: "equals",
} },
}, },
size: 1, size: 1,
page: 1 page: 1,
} });
);
if (response.data.success && response.data.data?.data?.length > 0) { if (response.data.success && response.data.data?.data?.length > 0) {
const transformedValue = response.data.data.data[0][transform.resultColumn]; const transformedValue = response.data.data.data[0][transform.resultColumn];
@ -186,7 +184,7 @@ async function fetchExternalLookupValue(
}, },
rowData: any, rowData: any,
sourceData: any, sourceData: any,
formData: FormDataState formData: FormDataState,
): Promise<any> { ): Promise<any> {
// 1. 비교 값 가져오기 // 1. 비교 값 가져오기
let matchValue: any; let matchValue: any;
@ -199,31 +197,32 @@ async function fetchExternalLookupValue(
} }
if (matchValue === undefined || matchValue === null || matchValue === "") { if (matchValue === undefined || matchValue === null || matchValue === "") {
console.warn(`외부 테이블 조회: 비교 값이 없습니다. (${externalLookup.matchSourceType}.${externalLookup.matchSourceField})`); console.warn(
`외부 테이블 조회: 비교 값이 없습니다. (${externalLookup.matchSourceType}.${externalLookup.matchSourceField})`,
);
return undefined; return undefined;
} }
// 2. 외부 테이블에서 값 조회 (정확히 일치하는 검색) // 2. 외부 테이블에서 값 조회 (정확히 일치하는 검색)
try { try {
const response = await apiClient.post( const response = await apiClient.post(`/table-management/tables/${externalLookup.tableName}/data`, {
`/table-management/tables/${externalLookup.tableName}/data`,
{
search: { search: {
[externalLookup.matchColumn]: { [externalLookup.matchColumn]: {
value: matchValue, value: matchValue,
operator: "equals" operator: "equals",
} },
}, },
size: 1, size: 1,
page: 1 page: 1,
} });
);
if (response.data.success && response.data.data?.data?.length > 0) { if (response.data.success && response.data.data?.data?.length > 0) {
return response.data.data.data[0][externalLookup.resultColumn]; return response.data.data.data[0][externalLookup.resultColumn];
} }
console.warn(`외부 테이블 조회: ${externalLookup.tableName}.${externalLookup.matchColumn} = "${matchValue}" 인 행을 찾을 수 없습니다.`); console.warn(
`외부 테이블 조회: ${externalLookup.tableName}.${externalLookup.matchColumn} = "${matchValue}" 인 행을 찾을 수 없습니다.`,
);
return undefined; return undefined;
} catch (error) { } catch (error) {
console.error("외부 테이블 조회 오류:", error); console.error("외부 테이블 조회 오류:", error);
@ -247,7 +246,7 @@ async function fetchExternalValue(
joinConditions: TableJoinCondition[], joinConditions: TableJoinCondition[],
rowData: any, rowData: any,
sourceData: any, sourceData: any,
formData: FormDataState formData: FormDataState,
): Promise<any> { ): Promise<any> {
if (joinConditions.length === 0) { if (joinConditions.length === 0) {
return undefined; return undefined;
@ -298,15 +297,16 @@ async function fetchExternalValue(
// 정확히 일치하는 검색을 위해 operator: "equals" 사용 // 정확히 일치하는 검색을 위해 operator: "equals" 사용
whereConditions[condition.targetColumn] = { whereConditions[condition.targetColumn] = {
value: convertedValue, value: convertedValue,
operator: "equals" operator: "equals",
}; };
} }
// API 호출 // API 호출
const response = await apiClient.post( const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
`/table-management/tables/${tableName}/data`, search: whereConditions,
{ search: whereConditions, size: 1, page: 1 } size: 1,
); page: 1,
});
if (response.data.success && response.data.data?.data?.length > 0) { if (response.data.success && response.data.data?.data?.length > 0) {
return response.data.data.data[0][valueColumn]; return response.data.data.data[0][valueColumn];
@ -389,14 +389,11 @@ export function TableSectionRenderer({
setDynamicOptionsLoading(true); setDynamicOptionsLoading(true);
try { try {
// DISTINCT 값을 가져오기 위한 API 호출 // DISTINCT 값을 가져오기 위한 API 호출
const response = await apiClient.post( const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
`/table-management/tables/${tableName}/data`,
{
search: filterCondition ? { _raw: filterCondition } : {}, search: filterCondition ? { _raw: filterCondition } : {},
size: 1000, size: 1000,
page: 1, page: 1,
} });
);
if (response.data.success && response.data.data?.data) { if (response.data.success && response.data.data?.data) {
const rows = response.data.data.data; const rows = response.data.data.data;
@ -406,7 +403,7 @@ export function TableSectionRenderer({
for (const row of rows) { for (const row of rows) {
const value = row[valueColumn]; const value = row[valueColumn];
if (value && !uniqueValues.has(value)) { if (value && !uniqueValues.has(value)) {
const label = labelColumn ? (row[labelColumn] || value) : value; const label = labelColumn ? row[labelColumn] || value : value;
uniqueValues.set(value, label); uniqueValues.set(value, label);
} }
} }
@ -448,7 +445,7 @@ export function TableSectionRenderer({
// 동적 Select 옵션이 있는 컬럼 확인 // 동적 Select 옵션이 있는 컬럼 확인
const hasDynamicSelectColumns = useMemo(() => { const hasDynamicSelectColumns = useMemo(() => {
return tableConfig.columns?.some(col => col.dynamicSelectOptions?.enabled); return tableConfig.columns?.some((col) => col.dynamicSelectOptions?.enabled);
}, [tableConfig.columns]); }, [tableConfig.columns]);
// 소스 테이블 데이터 로드 (동적 Select 옵션용) // 소스 테이블 데이터 로드 (동적 Select 옵션용)
@ -467,14 +464,11 @@ export function TableSectionRenderer({
filterCondition[conditionalConfig.sourceFilter.filterColumn] = activeConditionTab; filterCondition[conditionalConfig.sourceFilter.filterColumn] = activeConditionTab;
} }
const response = await apiClient.post( const response = await apiClient.post(`/table-management/tables/${tableConfig.source.tableName}/data`, {
`/table-management/tables/${tableConfig.source.tableName}/data`,
{
search: filterCondition, search: filterCondition,
size: 1000, size: 1000,
page: 1, page: 1,
} });
);
if (response.data.success && response.data.data?.data) { if (response.data.success && response.data.data?.data) {
setSourceDataCache(response.data.data.data); setSourceDataCache(response.data.data.data);
@ -510,14 +504,11 @@ export function TableSectionRenderer({
const filterCondition: Record<string, any> = {}; const filterCondition: Record<string, any> = {};
filterCondition[conditionalConfig.sourceFilter!.filterColumn] = activeConditionTab; filterCondition[conditionalConfig.sourceFilter!.filterColumn] = activeConditionTab;
const response = await apiClient.post( const response = await apiClient.post(`/table-management/tables/${tableConfig.source!.tableName}/data`, {
`/table-management/tables/${tableConfig.source!.tableName}/data`,
{
search: filterCondition, search: filterCondition,
size: 1000, size: 1000,
page: 1, page: 1,
} });
);
if (response.data.success && response.data.data?.data) { if (response.data.success && response.data.data?.data) {
setSourceDataCache(response.data.data.data); setSourceDataCache(response.data.data.data);
@ -534,7 +525,13 @@ export function TableSectionRenderer({
}; };
loadSourceData(); loadSourceData();
}, [activeConditionTab, hasDynamicSelectColumns, conditionalConfig?.sourceFilter?.enabled, conditionalConfig?.sourceFilter?.filterColumn, tableConfig.source?.tableName]); }, [
activeConditionTab,
hasDynamicSelectColumns,
conditionalConfig?.sourceFilter?.enabled,
conditionalConfig?.sourceFilter?.filterColumn,
tableConfig.source?.tableName,
]);
// 컬럼별 동적 Select 옵션 생성 // 컬럼별 동적 Select 옵션 생성
const dynamicSelectOptionsMap = useMemo(() => { const dynamicSelectOptionsMap = useMemo(() => {
@ -562,7 +559,7 @@ export function TableSectionRenderer({
if (distinct && seenValues.has(stringValue)) continue; if (distinct && seenValues.has(stringValue)) continue;
seenValues.add(stringValue); seenValues.add(stringValue);
const label = labelField ? (row[labelField] || stringValue) : stringValue; const label = labelField ? row[labelField] || stringValue : stringValue;
options.push({ value: stringValue, label: String(label) }); options.push({ value: stringValue, label: String(label) });
} }
@ -578,9 +575,7 @@ export function TableSectionRenderer({
let processedData = newData; let processedData = newData;
// 날짜 일괄 적용 로직: batchApply가 활성화된 날짜 컬럼 처리 // 날짜 일괄 적용 로직: batchApply가 활성화된 날짜 컬럼 처리
const batchApplyColumns = tableConfig.columns.filter( const batchApplyColumns = tableConfig.columns.filter((col) => col.type === "date" && col.batchApply === true);
(col) => col.type === "date" && col.batchApply === true
);
for (const dateCol of batchApplyColumns) { for (const dateCol of batchApplyColumns) {
// 이미 일괄 적용된 컬럼은 건너뜀 // 이미 일괄 적용된 컬럼은 건너뜀
@ -608,20 +603,20 @@ export function TableSectionRenderer({
setTableData(processedData); setTableData(processedData);
onTableDataChange(processedData); onTableDataChange(processedData);
}, },
[onTableDataChange, tableConfig.columns, batchAppliedFields] [onTableDataChange, tableConfig.columns, batchAppliedFields],
); );
// 행 선택 모드: 드롭다운 값 변경 시 같은 소스 행의 다른 컬럼들 자동 채움 // 행 선택 모드: 드롭다운 값 변경 시 같은 소스 행의 다른 컬럼들 자동 채움
const handleDynamicSelectChange = useCallback( const handleDynamicSelectChange = useCallback(
(rowIndex: number, columnField: string, selectedValue: string, conditionValue?: string) => { (rowIndex: number, columnField: string, selectedValue: string, conditionValue?: string) => {
const column = tableConfig.columns?.find(col => col.field === columnField); const column = tableConfig.columns?.find((col) => col.field === columnField);
if (!column?.dynamicSelectOptions?.rowSelectionMode?.enabled) { if (!column?.dynamicSelectOptions?.rowSelectionMode?.enabled) {
// 행 선택 모드가 아니면 일반 값 변경만 // 행 선택 모드가 아니면 일반 값 변경만
if (conditionValue && isConditionalMode) { if (conditionValue && isConditionalMode) {
const currentData = conditionalTableData[conditionValue] || []; const currentData = conditionalTableData[conditionValue] || [];
const newData = [...currentData]; const newData = [...currentData];
newData[rowIndex] = { ...newData[rowIndex], [columnField]: selectedValue }; newData[rowIndex] = { ...newData[rowIndex], [columnField]: selectedValue };
setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData })); setConditionalTableData((prev) => ({ ...prev, [conditionValue]: newData }));
onConditionalTableDataChange?.(conditionValue, newData); onConditionalTableDataChange?.(conditionValue, newData);
} else { } else {
const newData = [...tableData]; const newData = [...tableData];
@ -635,7 +630,7 @@ export function TableSectionRenderer({
const { sourceField } = column.dynamicSelectOptions; const { sourceField } = column.dynamicSelectOptions;
const { autoFillColumns, sourceIdColumn, targetIdField } = column.dynamicSelectOptions.rowSelectionMode; const { autoFillColumns, sourceIdColumn, targetIdField } = column.dynamicSelectOptions.rowSelectionMode;
const sourceRow = sourceDataCache.find(row => String(row[sourceField]) === selectedValue); const sourceRow = sourceDataCache.find((row) => String(row[sourceField]) === selectedValue);
if (!sourceRow) { if (!sourceRow) {
console.warn(`[TableSectionRenderer] 소스 행을 찾을 수 없음: ${sourceField} = ${selectedValue}`); console.warn(`[TableSectionRenderer] 소스 행을 찾을 수 없음: ${sourceField} = ${selectedValue}`);
@ -672,7 +667,7 @@ export function TableSectionRenderer({
// 데이터 업데이트 // 데이터 업데이트
if (conditionValue && isConditionalMode) { if (conditionValue && isConditionalMode) {
setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData })); setConditionalTableData((prev) => ({ ...prev, [conditionValue]: newData }));
onConditionalTableDataChange?.(conditionValue, newData); onConditionalTableDataChange?.(conditionValue, newData);
} else { } else {
handleDataChange(newData); handleDataChange(newData);
@ -685,14 +680,23 @@ export function TableSectionRenderer({
updatedRow, updatedRow,
}); });
}, },
[tableConfig.columns, sourceDataCache, tableData, conditionalTableData, isConditionalMode, handleDataChange, onConditionalTableDataChange] [
tableConfig.columns,
sourceDataCache,
tableData,
conditionalTableData,
isConditionalMode,
handleDataChange,
onConditionalTableDataChange,
],
); );
// 참조 컬럼 값 조회 함수 (saveToTarget: false인 컬럼에 대해 소스 테이블 조회) // 참조 컬럼 값 조회 함수 (saveToTarget: false인 컬럼에 대해 소스 테이블 조회)
const loadReferenceColumnValues = useCallback(async (data: any[]) => { const loadReferenceColumnValues = useCallback(
async (data: any[]) => {
// saveToTarget: false이고 referenceDisplay가 설정된 컬럼 찾기 // saveToTarget: false이고 referenceDisplay가 설정된 컬럼 찾기
const referenceColumns = (tableConfig.columns || []).filter( const referenceColumns = (tableConfig.columns || []).filter(
(col) => col.saveConfig?.saveToTarget === false && col.saveConfig?.referenceDisplay (col) => col.saveConfig?.saveToTarget === false && col.saveConfig?.referenceDisplay,
); );
if (referenceColumns.length === 0) return; if (referenceColumns.length === 0) return;
@ -721,14 +725,11 @@ export function TableSectionRenderer({
try { try {
// 소스 테이블에서 참조 ID에 해당하는 데이터 조회 // 소스 테이블에서 참조 ID에 해당하는 데이터 조회
const response = await apiClient.post( const response = await apiClient.post(`/table-management/tables/${sourceTableName}/data`, {
`/table-management/tables/${sourceTableName}/data`,
{
search: { id: Array.from(referenceIdSet) }, // ID 배열로 조회 search: { id: Array.from(referenceIdSet) }, // ID 배열로 조회
size: 1000, size: 1000,
page: 1, page: 1,
} });
);
if (!response.data?.success || !response.data?.data?.data) { if (!response.data?.success || !response.data?.data?.data) {
console.warn("[TableSectionRenderer] 참조 데이터 조회 실패"); console.warn("[TableSectionRenderer] 참조 데이터 조회 실패");
@ -771,7 +772,9 @@ export function TableSectionRenderer({
} catch (error) { } catch (error) {
console.error("[TableSectionRenderer] 참조 데이터 조회 실패:", error); console.error("[TableSectionRenderer] 참조 데이터 조회 실패:", error);
} }
}, [tableConfig.columns, tableConfig.source?.tableName]); },
[tableConfig.columns, tableConfig.source?.tableName],
);
// formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시) // formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시)
useEffect(() => { useEffect(() => {
@ -796,7 +799,7 @@ export function TableSectionRenderer({
// RepeaterColumnConfig로 변환 (동적 Select 옵션 반영) // RepeaterColumnConfig로 변환 (동적 Select 옵션 반영)
const columns: RepeaterColumnConfig[] = useMemo(() => { const columns: RepeaterColumnConfig[] = useMemo(() => {
return (tableConfig.columns || []).map(col => { return (tableConfig.columns || []).map((col) => {
const baseColumn = convertToRepeaterColumn(col); const baseColumn = convertToRepeaterColumn(col);
// 동적 Select 옵션이 있으면 적용 // 동적 Select 옵션이 있으면 적용
@ -840,22 +843,23 @@ export function TableSectionRenderer({
return updatedRow; return updatedRow;
}, },
[calculationRules] [calculationRules],
); );
const calculateAll = useCallback( const calculateAll = useCallback(
(data: any[]): any[] => { (data: any[]): any[] => {
return data.map((row) => calculateRow(row)); return data.map((row) => calculateRow(row));
}, },
[calculateRow] [calculateRow],
); );
// 행 변경 핸들러 (동적 Select 행 선택 모드 지원) // 행 변경 핸들러 (동적 Select 행 선택 모드 지원)
const handleRowChange = useCallback( const handleRowChange = useCallback(
(index: number, newRow: any, conditionValue?: string) => { (index: number, newRow: any, conditionValue?: string) => {
const oldRow = conditionValue && isConditionalMode const oldRow =
? (conditionalTableData[conditionValue]?.[index] || {}) conditionValue && isConditionalMode
: (tableData[index] || {}); ? conditionalTableData[conditionValue]?.[index] || {}
: tableData[index] || {};
// 변경된 필드 찾기 // 변경된 필드 찾기
const changedFields: string[] = []; const changedFields: string[] = [];
@ -867,7 +871,7 @@ export function TableSectionRenderer({
// 동적 Select 컬럼의 행 선택 모드 확인 // 동적 Select 컬럼의 행 선택 모드 확인
for (const changedField of changedFields) { for (const changedField of changedFields) {
const column = tableConfig.columns?.find(col => col.field === changedField); const column = tableConfig.columns?.find((col) => col.field === changedField);
if (column?.dynamicSelectOptions?.rowSelectionMode?.enabled) { if (column?.dynamicSelectOptions?.rowSelectionMode?.enabled) {
// 행 선택 모드 처리 (자동 채움) // 행 선택 모드 처리 (자동 채움)
handleDynamicSelectChange(index, changedField, newRow[changedField], conditionValue); handleDynamicSelectChange(index, changedField, newRow[changedField], conditionValue);
@ -882,7 +886,7 @@ export function TableSectionRenderer({
const currentData = conditionalTableData[conditionValue] || []; const currentData = conditionalTableData[conditionValue] || [];
const newData = [...currentData]; const newData = [...currentData];
newData[index] = calculatedRow; newData[index] = calculatedRow;
setConditionalTableData(prev => ({ ...prev, [conditionValue]: newData })); setConditionalTableData((prev) => ({ ...prev, [conditionValue]: newData }));
onConditionalTableDataChange?.(conditionValue, newData); onConditionalTableDataChange?.(conditionValue, newData);
} else { } else {
const newData = [...tableData]; const newData = [...tableData];
@ -890,7 +894,16 @@ export function TableSectionRenderer({
handleDataChange(newData); handleDataChange(newData);
} }
}, },
[tableData, conditionalTableData, isConditionalMode, tableConfig.columns, calculateRow, handleDataChange, handleDynamicSelectChange, onConditionalTableDataChange] [
tableData,
conditionalTableData,
isConditionalMode,
tableConfig.columns,
calculateRow,
handleDataChange,
handleDynamicSelectChange,
onConditionalTableDataChange,
],
); );
// 행 삭제 핸들러 // 행 삭제 핸들러
@ -899,7 +912,7 @@ export function TableSectionRenderer({
const newData = tableData.filter((_, i) => i !== index); const newData = tableData.filter((_, i) => i !== index);
handleDataChange(newData); handleDataChange(newData);
}, },
[tableData, handleDataChange] [tableData, handleDataChange],
); );
// 선택된 항목 일괄 삭제 // 선택된 항목 일괄 삭제
@ -969,11 +982,13 @@ export function TableSectionRenderer({
// 외부 테이블 조회 설정 // 외부 테이블 조회 설정
externalLookup: cond.externalLookup, externalLookup: cond.externalLookup,
// 값 변환 설정 전달 (레거시 호환) // 값 변환 설정 전달 (레거시 호환)
transform: cond.transform?.enabled ? { transform: cond.transform?.enabled
? {
tableName: cond.transform.tableName, tableName: cond.transform.tableName,
matchColumn: cond.transform.matchColumn, matchColumn: cond.transform.matchColumn,
resultColumn: cond.transform.resultColumn, resultColumn: cond.transform.resultColumn,
} : undefined, }
: undefined,
}; };
}); });
@ -984,7 +999,7 @@ export function TableSectionRenderer({
joinConditions, joinConditions,
{ ...sourceItem, ...newItem }, // rowData (현재 행) { ...sourceItem, ...newItem }, // rowData (현재 행)
sourceItem, // sourceData (소스 테이블 원본) sourceItem, // sourceData (소스 테이블 원본)
formData formData,
); );
if (value !== undefined) { if (value !== undefined) {
@ -1046,7 +1061,7 @@ export function TableSectionRenderer({
joinConditions, joinConditions,
{ ...sourceItem, ...newItem }, // rowData { ...sourceItem, ...newItem }, // rowData
sourceItem, // sourceData sourceItem, // sourceData
formData formData,
); );
if (value !== undefined) { if (value !== undefined) {
newItem[col.field] = value; newItem[col.field] = value;
@ -1070,7 +1085,7 @@ export function TableSectionRenderer({
} }
return newItem; return newItem;
}) }),
); );
// 계산 필드 업데이트 // 계산 필드 업데이트
@ -1080,7 +1095,7 @@ export function TableSectionRenderer({
const newData = [...tableData, ...calculatedItems]; const newData = [...tableData, ...calculatedItems];
handleDataChange(newData); handleDataChange(newData);
}, },
[tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources] [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources],
); );
// 컬럼 모드/조회 옵션 변경 핸들러 // 컬럼 모드/조회 옵션 변경 핸들러
@ -1140,11 +1155,13 @@ export function TableSectionRenderer({
// 외부 테이블 조회 설정 // 외부 테이블 조회 설정
externalLookup: cond.externalLookup, externalLookup: cond.externalLookup,
// 값 변환 설정 전달 (레거시 호환) // 값 변환 설정 전달 (레거시 호환)
transform: cond.transform?.enabled ? { transform: cond.transform?.enabled
? {
tableName: cond.transform.tableName, tableName: cond.transform.tableName,
matchColumn: cond.transform.matchColumn, matchColumn: cond.transform.matchColumn,
resultColumn: cond.transform.resultColumn, resultColumn: cond.transform.resultColumn,
} : undefined, }
: undefined,
}; };
}); });
@ -1156,7 +1173,7 @@ export function TableSectionRenderer({
joinConditions, joinConditions,
row, row,
sourceData, sourceData,
formData formData,
); );
if (value !== undefined) { if (value !== undefined) {
@ -1164,7 +1181,7 @@ export function TableSectionRenderer({
} }
return { ...row, [columnField]: newValue }; return { ...row, [columnField]: newValue };
}) }),
); );
// 계산 필드 업데이트 // 계산 필드 업데이트
@ -1199,14 +1216,14 @@ export function TableSectionRenderer({
} }
return { ...row, [columnField]: newValue }; return { ...row, [columnField]: newValue };
}) }),
); );
// 계산 필드 업데이트 // 계산 필드 업데이트
const calculatedData = calculateAll(updatedData); const calculatedData = calculateAll(updatedData);
handleDataChange(calculatedData); handleDataChange(calculatedData);
}, },
[tableConfig.columns, tableData, formData, calculateAll, handleDataChange] [tableConfig.columns, tableData, formData, calculateAll, handleDataChange],
); );
// 소스 테이블 정보 // 소스 테이블 정보
@ -1216,10 +1233,16 @@ export function TableSectionRenderer({
const sourceSearchFields = source.searchColumns; const sourceSearchFields = source.searchColumns;
const columnLabels = source.columnLabels || {}; const columnLabels = source.columnLabels || {};
const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택"; const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택";
const addButtonType = uiConfig?.addButtonType || "search";
const addButtonText = uiConfig?.addButtonText || (addButtonType === "addRow" ? "항목 추가" : "항목 검색");
const multiSelect = uiConfig?.multiSelect ?? true; const multiSelect = uiConfig?.multiSelect ?? true;
// 버튼 표시 설정 (두 버튼 동시 표시 가능)
// 레거시 호환: 기존 addButtonType 설정이 있으면 그에 맞게 변환
const legacyAddButtonType = uiConfig?.addButtonType;
const showSearchButton = legacyAddButtonType === "addRow" ? false : (uiConfig?.showSearchButton ?? true);
const showAddRowButton = legacyAddButtonType === "addRow" ? true : (uiConfig?.showAddRowButton ?? false);
const searchButtonText = uiConfig?.searchButtonText || uiConfig?.addButtonText || "품목 검색";
const addRowButtonText = uiConfig?.addRowButtonText || "직접 입력";
// 기본 필터 조건 생성 (사전 필터만 - 모달 필터는 ItemSelectionModal에서 처리) // 기본 필터 조건 생성 (사전 필터만 - 모달 필터는 ItemSelectionModal에서 처리)
const baseFilterCondition: Record<string, any> = useMemo(() => { const baseFilterCondition: Record<string, any> = useMemo(() => {
const condition: Record<string, any> = {}; const condition: Record<string, any> = {};
@ -1253,7 +1276,7 @@ export function TableSectionRenderer({
column: filter.column, column: filter.column,
label: filter.label || filter.column, label: filter.label || filter.column,
// category 타입을 select로 변환 (ModalFilterConfig 호환) // category 타입을 select로 변환 (ModalFilterConfig 호환)
type: filter.type === "category" ? "select" as const : filter.type as "text" | "select", type: filter.type === "category" ? ("select" as const) : (filter.type as "text" | "select"),
options: filter.options, options: filter.options,
categoryRef: filter.categoryRef, categoryRef: filter.categoryRef,
defaultValue: filter.defaultValue, defaultValue: filter.defaultValue,
@ -1265,7 +1288,8 @@ export function TableSectionRenderer({
// ============================================ // ============================================
// 조건부 테이블: 조건 체크박스 토글 // 조건부 테이블: 조건 체크박스 토글
const handleConditionToggle = useCallback((conditionValue: string, checked: boolean) => { const handleConditionToggle = useCallback(
(conditionValue: string, checked: boolean) => {
setSelectedConditions((prev) => { setSelectedConditions((prev) => {
if (checked) { if (checked) {
const newConditions = [...prev, conditionValue]; const newConditions = [...prev, conditionValue];
@ -1283,10 +1307,13 @@ export function TableSectionRenderer({
return newConditions; return newConditions;
} }
}); });
}, [activeConditionTab]); },
[activeConditionTab],
);
// 조건부 테이블: 조건별 데이터 변경 // 조건부 테이블: 조건별 데이터 변경
const handleConditionalDataChange = useCallback((conditionValue: string, newData: any[]) => { const handleConditionalDataChange = useCallback(
(conditionValue: string, newData: any[]) => {
setConditionalTableData((prev) => ({ setConditionalTableData((prev) => ({
...prev, ...prev,
[conditionValue]: newData, [conditionValue]: newData,
@ -1315,26 +1342,35 @@ export function TableSectionRenderer({
} }
onTableDataChange(allData); onTableDataChange(allData);
}, [conditionalTableData, conditionalConfig?.conditionColumn, onConditionalTableDataChange, onTableDataChange]); },
[conditionalTableData, conditionalConfig?.conditionColumn, onConditionalTableDataChange, onTableDataChange],
);
// 조건부 테이블: 조건별 행 변경 // 조건부 테이블: 조건별 행 변경
const handleConditionalRowChange = useCallback((conditionValue: string, index: number, newRow: any) => { const handleConditionalRowChange = useCallback(
(conditionValue: string, index: number, newRow: any) => {
const calculatedRow = calculateRow(newRow); const calculatedRow = calculateRow(newRow);
const currentData = conditionalTableData[conditionValue] || []; const currentData = conditionalTableData[conditionValue] || [];
const newData = [...currentData]; const newData = [...currentData];
newData[index] = calculatedRow; newData[index] = calculatedRow;
handleConditionalDataChange(conditionValue, newData); handleConditionalDataChange(conditionValue, newData);
}, [conditionalTableData, calculateRow, handleConditionalDataChange]); },
[conditionalTableData, calculateRow, handleConditionalDataChange],
);
// 조건부 테이블: 조건별 행 삭제 // 조건부 테이블: 조건별 행 삭제
const handleConditionalRowDelete = useCallback((conditionValue: string, index: number) => { const handleConditionalRowDelete = useCallback(
(conditionValue: string, index: number) => {
const currentData = conditionalTableData[conditionValue] || []; const currentData = conditionalTableData[conditionValue] || [];
const newData = currentData.filter((_, i) => i !== index); const newData = currentData.filter((_, i) => i !== index);
handleConditionalDataChange(conditionValue, newData); handleConditionalDataChange(conditionValue, newData);
}, [conditionalTableData, handleConditionalDataChange]); },
[conditionalTableData, handleConditionalDataChange],
);
// 조건부 테이블: 조건별 선택 행 일괄 삭제 // 조건부 테이블: 조건별 선택 행 일괄 삭제
const handleConditionalBulkDelete = useCallback((conditionValue: string) => { const handleConditionalBulkDelete = useCallback(
(conditionValue: string) => {
const selected = conditionalSelectedRows[conditionValue] || new Set(); const selected = conditionalSelectedRows[conditionValue] || new Set();
if (selected.size === 0) return; if (selected.size === 0) return;
@ -1347,10 +1383,13 @@ export function TableSectionRenderer({
...prev, ...prev,
[conditionValue]: new Set(), [conditionValue]: new Set(),
})); }));
}, [conditionalTableData, conditionalSelectedRows, handleConditionalDataChange]); },
[conditionalTableData, conditionalSelectedRows, handleConditionalDataChange],
);
// 조건부 테이블: 아이템 추가 (특정 조건에) // 조건부 테이블: 아이템 추가 (특정 조건에)
const handleConditionalAddItems = useCallback(async (items: any[]) => { const handleConditionalAddItems = useCallback(
async (items: any[]) => {
if (!modalCondition) return; if (!modalCondition) return;
// 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성 // 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성
@ -1387,7 +1426,7 @@ export function TableSectionRenderer({
newItem._sourceData = sourceItem; newItem._sourceData = sourceItem;
return newItem; return newItem;
}) }),
); );
// 현재 조건의 데이터에 추가 // 현재 조건의 데이터에 추가
@ -1396,7 +1435,9 @@ export function TableSectionRenderer({
handleConditionalDataChange(modalCondition, newData); handleConditionalDataChange(modalCondition, newData);
setModalOpen(false); setModalOpen(false);
}, [modalCondition, tableConfig.columns, formData, conditionalTableData, handleConditionalDataChange]); },
[modalCondition, tableConfig.columns, formData, conditionalTableData, handleConditionalDataChange],
);
// 조건부 테이블: 모달 열기 (특정 조건에 대해) // 조건부 테이블: 모달 열기 (특정 조건에 대해)
const openConditionalModal = useCallback((conditionValue: string) => { const openConditionalModal = useCallback((conditionValue: string) => {
@ -1405,7 +1446,8 @@ export function TableSectionRenderer({
}, []); }, []);
// 조건부 테이블: 빈 행 추가 (addRow 모드에서 사용) // 조건부 테이블: 빈 행 추가 (addRow 모드에서 사용)
const addEmptyRowToCondition = useCallback((conditionValue: string) => { const addEmptyRowToCondition = useCallback(
(conditionValue: string) => {
const newRow: Record<string, any> = {}; const newRow: Record<string, any> = {};
// 각 컬럼의 기본값으로 빈 행 생성 // 각 컬럼의 기본값으로 빈 행 생성
@ -1430,20 +1472,25 @@ export function TableSectionRenderer({
const currentData = conditionalTableData[conditionValue] || []; const currentData = conditionalTableData[conditionValue] || [];
const newData = [...currentData, newRow]; const newData = [...currentData, newRow];
handleConditionalDataChange(conditionValue, newData); handleConditionalDataChange(conditionValue, newData);
}, [tableConfig.columns, conditionalConfig?.conditionColumn, conditionalTableData, handleConditionalDataChange]); },
[tableConfig.columns, conditionalConfig?.conditionColumn, conditionalTableData, handleConditionalDataChange],
);
// 버튼 클릭 핸들러 (addButtonType에 따라 다르게 동작) // 검색 버튼 클릭 핸들러
const handleAddButtonClick = useCallback((conditionValue: string) => { const handleSearchButtonClick = useCallback(
const addButtonType = tableConfig.uiConfig?.addButtonType || "search"; (conditionValue: string) => {
if (addButtonType === "addRow") {
// 빈 행 직접 추가
addEmptyRowToCondition(conditionValue);
} else {
// 검색 모달 열기
openConditionalModal(conditionValue); openConditionalModal(conditionValue);
} },
}, [tableConfig.uiConfig?.addButtonType, addEmptyRowToCondition, openConditionalModal]); [openConditionalModal],
);
// 행 추가 버튼 클릭 핸들러
const handleAddRowButtonClick = useCallback(
(conditionValue: string) => {
addEmptyRowToCondition(conditionValue);
},
[addEmptyRowToCondition],
);
// 조건부 테이블: 초기 데이터 로드 (수정 모드) // 조건부 테이블: 초기 데이터 로드 (수정 모드)
useEffect(() => { useEffect(() => {
@ -1498,17 +1545,19 @@ export function TableSectionRenderer({
// 정적 옵션과 동적 옵션 병합 (동적 옵션이 있으면 우선 사용) // 정적 옵션과 동적 옵션 병합 (동적 옵션이 있으면 우선 사용)
// 빈 value를 가진 옵션은 제외 (Select.Item은 빈 문자열 value를 허용하지 않음) // 빈 value를 가진 옵션은 제외 (Select.Item은 빈 문자열 value를 허용하지 않음)
const effectiveOptions = (conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0 const effectiveOptions = (
conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0
? dynamicOptions ? dynamicOptions
: conditionalConfig.options || []).filter(opt => opt.value && opt.value.trim() !== ""); : conditionalConfig.options || []
).filter((opt) => opt.value && opt.value.trim() !== "");
// 로딩 중이면 로딩 표시 // 로딩 중이면 로딩 표시
if (dynamicOptionsLoading) { if (dynamicOptionsLoading) {
return ( return (
<div className={cn("space-y-4", className)}> <div className={cn("space-y-4", className)}>
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground"> <div className="text-muted-foreground flex items-center justify-center py-8 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" /> <div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent" />
... ...
</div> </div>
</div> </div>
@ -1525,7 +1574,7 @@ export function TableSectionRenderer({
{effectiveOptions.map((option) => ( {effectiveOptions.map((option) => (
<label <label
key={option.id} key={option.id}
className="flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 transition-colors hover:bg-muted/50" className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 transition-colors"
> >
<Checkbox <Checkbox
checked={selectedConditions.includes(option.value)} checked={selectedConditions.includes(option.value)}
@ -1542,7 +1591,7 @@ export function TableSectionRenderer({
</div> </div>
{selectedConditions.length > 0 && ( {selectedConditions.length > 0 && (
<div className="text-xs text-muted-foreground"> <div className="text-muted-foreground text-xs">
{selectedConditions.length} , {totalConditionalItems} {selectedConditions.length} , {totalConditionalItems}
</div> </div>
)} )}
@ -1604,7 +1653,7 @@ export function TableSectionRenderer({
{/* 테이블 상단 컨트롤 */} {/* 테이블 상단 컨트롤 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">
{data.length > 0 && `${data.length}개 항목`} {data.length > 0 && `${data.length}개 항목`}
{selected.size > 0 && ` (${selected.size}개 선택됨)`} {selected.size > 0 && ` (${selected.size}개 선택됨)`}
</span> </span>
@ -1642,17 +1691,22 @@ export function TableSectionRenderer({
({selected.size}) ({selected.size})
</Button> </Button>
)} )}
{showSearchButton && (
<Button onClick={() => handleSearchButtonClick(conditionValue)} className="h-8 text-xs">
<Search className="mr-2 h-4 w-4" />
{searchButtonText}
</Button>
)}
{showAddRowButton && (
<Button <Button
onClick={() => handleAddButtonClick(conditionValue)} variant="outline"
onClick={() => handleAddRowButtonClick(conditionValue)}
className="h-8 text-xs" className="h-8 text-xs"
> >
{addButtonType === "addRow" ? (
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
) : ( {addRowButtonText}
<Search className="mr-2 h-4 w-4" />
)}
{addButtonText}
</Button> </Button>
)}
</div> </div>
</div> </div>
@ -1711,7 +1765,7 @@ export function TableSectionRenderer({
<TabsContent key={option.id} value={option.value} className="mt-4 space-y-4"> <TabsContent key={option.id} value={option.value} className="mt-4 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">
{data.length > 0 && `${data.length}개 항목`} {data.length > 0 && `${data.length}개 항목`}
{selected.size > 0 && ` (${selected.size}개 선택됨)`} {selected.size > 0 && ` (${selected.size}개 선택됨)`}
</span> </span>
@ -1728,17 +1782,22 @@ export function TableSectionRenderer({
({selected.size}) ({selected.size})
</Button> </Button>
)} )}
{showSearchButton && (
<Button onClick={() => handleSearchButtonClick(option.value)} className="h-8 text-xs">
<Search className="mr-2 h-4 w-4" />
{searchButtonText}
</Button>
)}
{showAddRowButton && (
<Button <Button
onClick={() => handleAddButtonClick(option.value)} variant="outline"
onClick={() => handleAddRowButtonClick(option.value)}
className="h-8 text-xs" className="h-8 text-xs"
> >
{addButtonType === "addRow" ? (
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
) : ( {addRowButtonText}
<Search className="mr-2 h-4 w-4" />
)}
{addButtonText}
</Button> </Button>
)}
</div> </div>
</div> </div>
@ -1768,10 +1827,8 @@ export function TableSectionRenderer({
{/* 조건이 선택되지 않은 경우 안내 메시지 (checkbox/dropdown 모드에서만) */} {/* 조건이 선택되지 않은 경우 안내 메시지 (checkbox/dropdown 모드에서만) */}
{selectedConditions.length === 0 && triggerType !== "tabs" && ( {selectedConditions.length === 0 && triggerType !== "tabs" && (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12 text-center"> <div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
{triggerType === "checkbox" {triggerType === "checkbox" ? "위에서 유형을 선택하여 검사항목을 추가하세요." : "유형을 선택하세요."}
? "위에서 유형을 선택하여 검사항목을 추가하세요."
: "유형을 선택하세요."}
</p> </p>
</div> </div>
)} )}
@ -1779,9 +1836,7 @@ export function TableSectionRenderer({
{/* 옵션이 없는 경우 안내 메시지 */} {/* 옵션이 없는 경우 안내 메시지 */}
{effectiveOptions.length === 0 && ( {effectiveOptions.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12 text-center"> <div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm"> .</p>
.
</p>
</div> </div>
)} )}
@ -1811,9 +1866,9 @@ export function TableSectionRenderer({
return ( return (
<div className={cn("space-y-4", className)}> <div className={cn("space-y-4", className)}>
{/* 추가 버튼 영역 */} {/* 추가 버튼 영역 */}
<div className="flex justify-between items-center"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"> <span className="text-muted-foreground text-sm">
{tableData.length > 0 && `${tableData.length}개 항목`} {tableData.length > 0 && `${tableData.length}개 항목`}
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`} {selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
</span> </span>
@ -1822,17 +1877,17 @@ export function TableSectionRenderer({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setWidthTrigger((prev) => prev + 1)} onClick={() => setWidthTrigger((prev) => prev + 1)}
className="h-7 text-xs px-2" className="h-7 px-2 text-xs"
title={widthTrigger % 2 === 0 ? "내용에 맞게 자동 조정" : "균등 분배"} title={widthTrigger % 2 === 0 ? "내용에 맞게 자동 조정" : "균등 분배"}
> >
{widthTrigger % 2 === 0 ? ( {widthTrigger % 2 === 0 ? (
<> <>
<AlignJustify className="h-3.5 w-3.5 mr-1" /> <AlignJustify className="mr-1 h-3.5 w-3.5" />
</> </>
) : ( ) : (
<> <>
<Columns className="h-3.5 w-3.5 mr-1" /> <Columns className="mr-1 h-3.5 w-3.5" />
</> </>
)} )}
@ -1841,17 +1896,20 @@ export function TableSectionRenderer({
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{selectedRows.size > 0 && ( {selectedRows.size > 0 && (
<Button <Button variant="destructive" onClick={handleBulkDelete} className="h-8 text-xs sm:h-10 sm:text-sm">
variant="destructive"
onClick={handleBulkDelete}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
({selectedRows.size}) ({selectedRows.size})
</Button> </Button>
)} )}
{showSearchButton && (
<Button onClick={() => setModalOpen(true)} className="h-8 text-xs sm:h-10 sm:text-sm">
<Search className="mr-2 h-4 w-4" />
{searchButtonText}
</Button>
)}
{showAddRowButton && (
<Button <Button
variant="outline"
onClick={() => { onClick={() => {
if (addButtonType === "addRow") {
// 빈 행 추가 // 빈 행 추가
const newRow: Record<string, any> = {}; const newRow: Record<string, any> = {};
for (const col of columns) { for (const col of columns) {
@ -1866,20 +1924,13 @@ export function TableSectionRenderer({
} }
} }
handleDataChange([...tableData, newRow]); handleDataChange([...tableData, newRow]);
} else {
// 검색 모달 열기
setModalOpen(true);
}
}} }}
className="h-8 text-xs sm:h-10 sm:text-sm" className="h-8 text-xs sm:h-10 sm:text-sm"
> >
{addButtonType === "addRow" ? ( <Plus className="mr-2 h-4 w-4" />
<Plus className="h-4 w-4 mr-2" /> {addRowButtonText}
) : (
<Search className="h-4 w-4 mr-2" />
)}
{addButtonText}
</Button> </Button>
)}
</div> </div>
</div> </div>

View File

@ -963,6 +963,13 @@ export function UniversalFormModalComponent({
} }
} }
// 별도 테이블에 저장해야 하는 테이블 섹션 목록
const tableSectionsForSeparateTable = config.sections.filter(
(s) => s.type === "table" &&
s.tableConfig?.saveConfig?.targetTable &&
s.tableConfig.saveConfig.targetTable !== config.saveConfig.tableName
);
// 테이블 섹션이 있고 메인 테이블에 품목별로 저장하는 경우 (공통 + 개별 병합 저장) // 테이블 섹션이 있고 메인 테이블에 품목별로 저장하는 경우 (공통 + 개별 병합 저장)
// targetTable이 없거나 메인 테이블과 같은 경우 // targetTable이 없거나 메인 테이블과 같은 경우
const tableSectionsForMainTable = config.sections.filter( const tableSectionsForMainTable = config.sections.filter(
@ -971,6 +978,12 @@ export function UniversalFormModalComponent({
s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName) s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName)
); );
console.log("[saveSingleRow] 메인 테이블:", config.saveConfig.tableName);
console.log("[saveSingleRow] 메인 테이블에 저장할 테이블 섹션:", tableSectionsForMainTable.map(s => s.id));
console.log("[saveSingleRow] 별도 테이블에 저장할 테이블 섹션:", tableSectionsForSeparateTable.map(s => s.id));
console.log("[saveSingleRow] 테이블 섹션 데이터 키:", Object.keys(tableSectionData));
console.log("[saveSingleRow] dataToSave 키:", Object.keys(dataToSave));
if (tableSectionsForMainTable.length > 0) { if (tableSectionsForMainTable.length > 0) {
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라) // 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
const commonFieldsData: Record<string, any> = {}; const commonFieldsData: Record<string, any> = {};
@ -1050,16 +1063,17 @@ export function UniversalFormModalComponent({
// 메인 레코드 ID가 필요한 경우 (response.data에서 가져오기) // 메인 레코드 ID가 필요한 경우 (response.data에서 가져오기)
const mainRecordId = response.data?.data?.id; const mainRecordId = response.data?.data?.id;
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라) // 공통 저장 필드 수집: 다른 섹션(필드 타입)에서 공통 저장으로 설정된 필드 값
// 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual'
const commonFieldsData: Record<string, any> = {}; const commonFieldsData: Record<string, any> = {};
const { sectionSaveModes } = config.saveConfig; const { sectionSaveModes } = config.saveConfig;
if (sectionSaveModes && sectionSaveModes.length > 0) {
// 다른 섹션에서 공통 저장으로 설정된 필드 값 수집 // 다른 섹션에서 공통 저장으로 설정된 필드 값 수집
for (const otherSection of config.sections) { for (const otherSection of config.sections) {
if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기 if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기
const sectionMode = sectionSaveModes.find((s) => s.sectionId === otherSection.id); const sectionMode = sectionSaveModes?.find((s) => s.sectionId === otherSection.id);
// 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual'
const defaultMode = otherSection.type === "table" ? "individual" : "common"; const defaultMode = otherSection.type === "table" ? "individual" : "common";
const sectionSaveMode = sectionMode?.saveMode || defaultMode; const sectionSaveMode = sectionMode?.saveMode || defaultMode;
@ -1076,8 +1090,23 @@ export function UniversalFormModalComponent({
} }
} }
} }
// 🆕 선택적 필드 그룹 (optionalFieldGroups)도 처리
if (otherSection.optionalFieldGroups && otherSection.optionalFieldGroups.length > 0) {
for (const optGroup of otherSection.optionalFieldGroups) {
if (optGroup.fields) {
for (const field of optGroup.fields) {
// 선택적 필드 그룹은 기본적으로 common 저장
if (formData[field.columnName] !== undefined) {
commonFieldsData[field.columnName] = formData[field.columnName];
} }
} }
}
}
}
}
console.log("[saveSingleRow] 별도 테이블 저장 - 공통 필드:", Object.keys(commonFieldsData));
for (const item of sectionData) { for (const item of sectionData) {
// 공통 필드 병합 + 개별 품목 데이터 // 공통 필드 병합 + 개별 품목 데이터
@ -1091,15 +1120,26 @@ export function UniversalFormModalComponent({
} }
} }
// _sourceData 등 내부 메타데이터 제거
Object.keys(itemToSave).forEach((key) => {
if (key.startsWith("_")) {
delete itemToSave[key];
}
});
// 메인 레코드와 연결이 필요한 경우 // 메인 레코드와 연결이 필요한 경우
if (mainRecordId && config.saveConfig.primaryKeyColumn) { if (mainRecordId && config.saveConfig.primaryKeyColumn) {
itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId; itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId;
} }
await apiClient.post( const saveResponse = await apiClient.post(
`/table-management/tables/${section.tableConfig.saveConfig.targetTable}/add`, `/table-management/tables/${section.tableConfig.saveConfig.targetTable}/add`,
itemToSave itemToSave
); );
if (!saveResponse.data?.success) {
throw new Error(saveResponse.data?.message || `${section.title || "테이블 섹션"} 저장 실패`);
}
} }
} }
} }

View File

@ -2928,54 +2928,74 @@ export function TableSectionSettingsModal({
{/* UI 설정 */} {/* UI 설정 */}
<div className="space-y-3 border rounded-lg p-4"> <div className="space-y-3 border rounded-lg p-4">
<h4 className="text-sm font-medium">UI </h4> <h4 className="text-sm font-medium">UI </h4>
{/* 버튼 표시 설정 */}
<div className="space-y-2 p-3 bg-muted/30 rounded-lg">
<Label className="text-xs font-medium"> </Label>
<p className="text-[10px] text-muted-foreground mb-2">
.
</p>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<Switch
checked={tableConfig.uiConfig?.showSearchButton ?? true}
onCheckedChange={(checked) => updateUiConfig({ showSearchButton: checked })}
className="scale-75"
/>
<div> <div>
<Label className="text-xs"> </Label> <span className="text-xs font-medium"> </span>
<Select <p className="text-[10px] text-muted-foreground"> </p>
value={tableConfig.uiConfig?.addButtonType || "search"}
onValueChange={(value) => updateUiConfig({ addButtonType: value as "search" | "addRow" })}
>
<SelectTrigger className="h-8 text-xs mt-1">
<SelectValue placeholder="버튼 동작 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="search">
<div className="flex flex-col">
<span> </span>
<span className="text-[10px] text-muted-foreground"> </span>
</div> </div>
</SelectItem>
<SelectItem value="addRow">
<div className="flex flex-col">
<span> </span>
<span className="text-[10px] text-muted-foreground"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="flex items-center gap-2">
<Switch
checked={tableConfig.uiConfig?.showAddRowButton ?? false}
onCheckedChange={(checked) => updateUiConfig({ showAddRowButton: checked })}
className="scale-75"
/>
<div> <div>
<Label className="text-xs"> </Label> <span className="text-xs font-medium"> </span>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 검색 버튼 텍스트 */}
<div>
<Label className="text-xs"> </Label>
<Input <Input
value={tableConfig.uiConfig?.addButtonText || ""} value={tableConfig.uiConfig?.searchButtonText || ""}
onChange={(e) => updateUiConfig({ addButtonText: e.target.value })} onChange={(e) => updateUiConfig({ searchButtonText: e.target.value })}
placeholder={tableConfig.uiConfig?.addButtonType === "addRow" ? "항목 추가" : "항목 검색"} placeholder="품목 검색"
className="h-8 text-xs mt-1" className="h-8 text-xs mt-1"
disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)}
/> />
</div> </div>
{/* 행 추가 버튼 텍스트 */}
<div> <div>
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Input
value={tableConfig.uiConfig?.addRowButtonText || ""}
onChange={(e) => updateUiConfig({ addRowButtonText: e.target.value })}
placeholder="직접 입력"
className="h-8 text-xs mt-1"
disabled={!tableConfig.uiConfig?.showAddRowButton}
/>
</div>
{/* 모달 제목 */}
<div>
<Label className="text-xs"> </Label>
<Input <Input
value={tableConfig.uiConfig?.modalTitle || ""} value={tableConfig.uiConfig?.modalTitle || ""}
onChange={(e) => updateUiConfig({ modalTitle: e.target.value })} onChange={(e) => updateUiConfig({ modalTitle: e.target.value })}
placeholder="항목 검색 및 선택" placeholder="항목 검색 및 선택"
className="h-8 text-xs mt-1" className="h-8 text-xs mt-1"
disabled={tableConfig.uiConfig?.addButtonType === "addRow"} disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)}
/> />
{tableConfig.uiConfig?.addButtonType === "addRow" && (
<p className="text-[10px] text-muted-foreground mt-0.5"> </p>
)}
</div> </div>
{/* 테이블 최대 높이 */}
<div> <div>
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Input <Input
@ -2985,13 +3005,14 @@ export function TableSectionSettingsModal({
className="h-8 text-xs mt-1" className="h-8 text-xs mt-1"
/> />
</div> </div>
{/* 다중 선택 허용 */}
<div className="flex items-end"> <div className="flex items-end">
<label className="flex items-center gap-2 text-xs cursor-pointer"> <label className="flex items-center gap-2 text-xs cursor-pointer">
<Switch <Switch
checked={tableConfig.uiConfig?.multiSelect ?? true} checked={tableConfig.uiConfig?.multiSelect ?? true}
onCheckedChange={(checked) => updateUiConfig({ multiSelect: checked })} onCheckedChange={(checked) => updateUiConfig({ multiSelect: checked })}
className="scale-75" className="scale-75"
disabled={tableConfig.uiConfig?.addButtonType === "addRow"} disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)}
/> />
<span> </span> <span> </span>
</label> </label>

View File

@ -253,15 +253,19 @@ export interface TableSectionConfig {
// 6. UI 설정 // 6. UI 설정
uiConfig?: { uiConfig?: {
addButtonText?: string; // 추가 버튼 텍스트 (기본: "품목 검색")
modalTitle?: string; // 모달 제목 (기본: "항목 검색 및 선택") modalTitle?: string; // 모달 제목 (기본: "항목 검색 및 선택")
multiSelect?: boolean; // 다중 선택 허용 (기본: true) multiSelect?: boolean; // 다중 선택 허용 (기본: true)
maxHeight?: string; // 테이블 최대 높이 (기본: "400px") maxHeight?: string; // 테이블 최대 높이 (기본: "400px")
// 추가 버튼 타입 // 버튼 표시 설정 (동시 표시 가능)
// - search: 검색 모달 열기 (기본값) - 기존 데이터에서 선택 showSearchButton?: boolean; // 검색 버튼 표시 (기본: true)
// - addRow: 빈 행 직접 추가 - 새 데이터 직접 입력 showAddRowButton?: boolean; // 행 추가 버튼 표시 (기본: false)
searchButtonText?: string; // 검색 버튼 텍스트 (기본: "품목 검색")
addRowButtonText?: string; // 행 추가 버튼 텍스트 (기본: "직접 입력")
// 레거시 호환용 (deprecated)
addButtonType?: "search" | "addRow"; addButtonType?: "search" | "addRow";
addButtonText?: string;
}; };
// 7. 조건부 테이블 설정 (고급) // 7. 조건부 테이블 설정 (고급)

View File

@ -1491,6 +1491,7 @@ export class ButtonActionExecutor {
* 🆕 Universal Form Modal * 🆕 Universal Form Modal
* _폼_모달 + _tableSection_ * _폼_모달 + _tableSection_
* 모드: INSERT/UPDATE/DELETE * 모드: INSERT/UPDATE/DELETE
* 🆕 (targetTable)
*/ */
private static async handleUniversalFormModalTableSectionSave( private static async handleUniversalFormModalTableSectionSave(
config: ButtonActionConfig, config: ButtonActionConfig,
@ -1515,6 +1516,65 @@ export class ButtonActionExecutor {
const modalData = formData[universalFormModalKey]; const modalData = formData[universalFormModalKey];
// 🆕 universal-form-modal 컴포넌트 설정 가져오기
// 1. componentConfigs에서 컴포넌트 ID로 찾기
// 2. allComponents에서 columnName으로 찾기
// 3. 화면 레이아웃 API에서 가져오기
let modalComponentConfig = context.componentConfigs?.[universalFormModalKey];
// componentConfigs에서 직접 찾지 못한 경우, allComponents에서 columnName으로 찾기
if (!modalComponentConfig && context.allComponents) {
const modalComponent = context.allComponents.find(
(comp: any) =>
comp.columnName === universalFormModalKey || comp.properties?.columnName === universalFormModalKey,
);
if (modalComponent) {
modalComponentConfig = modalComponent.componentConfig || modalComponent.properties?.componentConfig;
console.log("🎯 [handleUniversalFormModalTableSectionSave] allComponents에서 설정 찾음:", modalComponent.id);
}
}
// 🆕 아직도 설정을 찾지 못했으면 화면 레이아웃 API에서 가져오기
if (!modalComponentConfig && screenId) {
try {
console.log("🔍 [handleUniversalFormModalTableSectionSave] 화면 레이아웃 API에서 설정 조회:", screenId);
const { screenApi } = await import("@/lib/api/screen");
const layoutData = await screenApi.getLayout(screenId);
if (layoutData && layoutData.components) {
// 레이아웃에서 universal-form-modal 컴포넌트 찾기
const modalLayout = (layoutData.components as any[]).find(
(comp) =>
comp.properties?.columnName === universalFormModalKey || comp.columnName === universalFormModalKey,
);
if (modalLayout) {
modalComponentConfig = modalLayout.properties?.componentConfig || modalLayout.componentConfig;
console.log(
"🎯 [handleUniversalFormModalTableSectionSave] 화면 레이아웃에서 설정 찾음:",
modalLayout.componentId,
);
}
}
} catch (error) {
console.warn("⚠️ [handleUniversalFormModalTableSectionSave] 화면 레이아웃 조회 실패:", error);
}
}
const sections: any[] = modalComponentConfig?.sections || [];
const saveConfig = modalComponentConfig?.saveConfig || {};
console.log("🎯 [handleUniversalFormModalTableSectionSave] 컴포넌트 설정:", {
hasComponentConfig: !!modalComponentConfig,
sectionsCount: sections.length,
mainTableName: saveConfig.tableName || tableName,
sectionSaveModes: saveConfig.sectionSaveModes,
sectionDetails: sections.map((s: any) => ({
id: s.id,
type: s.type,
targetTable: s.tableConfig?.saveConfig?.targetTable,
})),
});
// _tableSection_ 데이터 추출 // _tableSection_ 데이터 추출
const tableSectionData: Record<string, any[]> = {}; const tableSectionData: Record<string, any[]> = {};
const commonFieldsData: Record<string, any> = {}; const commonFieldsData: Record<string, any> = {};
@ -1564,10 +1624,64 @@ export class ButtonActionExecutor {
let insertedCount = 0; let insertedCount = 0;
let updatedCount = 0; let updatedCount = 0;
let deletedCount = 0; let deletedCount = 0;
let mainRecordId: number | null = null;
// 🆕 먼저 메인 테이블에 공통 데이터 저장 (별도 테이블이 있는 경우에만)
const hasSeparateTargetTable = sections.some(
(s) =>
s.type === "table" &&
s.tableConfig?.saveConfig?.targetTable &&
s.tableConfig.saveConfig.targetTable !== tableName,
);
if (hasSeparateTargetTable && Object.keys(commonFieldsData).length > 0) {
console.log("📦 [handleUniversalFormModalTableSectionSave] 메인 테이블에 공통 데이터 저장:", tableName);
const mainRowToSave = { ...commonFieldsData, ...userInfo };
// 메타데이터 제거
Object.keys(mainRowToSave).forEach((key) => {
if (key.startsWith("_")) {
delete mainRowToSave[key];
}
});
console.log("📦 [handleUniversalFormModalTableSectionSave] 메인 테이블 저장 데이터:", mainRowToSave);
const mainSaveResult = await DynamicFormApi.saveFormData({
screenId: screenId!,
tableName: tableName!,
data: mainRowToSave,
});
if (!mainSaveResult.success) {
throw new Error(mainSaveResult.message || "메인 데이터 저장 실패");
}
mainRecordId = mainSaveResult.data?.id || null;
console.log("✅ [handleUniversalFormModalTableSectionSave] 메인 테이블 저장 완료, ID:", mainRecordId);
}
// 각 테이블 섹션 처리 // 각 테이블 섹션 처리
for (const [sectionId, currentItems] of Object.entries(tableSectionData)) { for (const [sectionId, currentItems] of Object.entries(tableSectionData)) {
console.log(`🔄 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 처리 시작: ${currentItems.length}개 품목`); console.log(
`🔄 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 처리 시작: ${currentItems.length}개 품목`,
);
// 🆕 해당 섹션의 설정 찾기
const sectionConfig = sections.find((s) => s.id === sectionId);
const targetTableName = sectionConfig?.tableConfig?.saveConfig?.targetTable;
// 🆕 실제 저장할 테이블 결정
// - targetTable이 있으면 해당 테이블에 저장
// - targetTable이 없으면 메인 테이블에 저장
const saveTableName = targetTableName || tableName!;
console.log(`📊 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 저장 테이블:`, {
targetTableName,
saveTableName,
isMainTable: saveTableName === tableName,
});
// 1⃣ 신규 품목 INSERT (id가 없는 항목) // 1⃣ 신규 품목 INSERT (id가 없는 항목)
const newItems = currentItems.filter((item) => !item.id); const newItems = currentItems.filter((item) => !item.id);
@ -1581,11 +1695,16 @@ export class ButtonActionExecutor {
} }
}); });
console.log(" [INSERT] 신규 품목:", rowToSave); // 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우)
if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) {
rowToSave[saveConfig.primaryKeyColumn] = mainRecordId;
}
console.log(" [INSERT] 신규 품목:", { tableName: saveTableName, data: rowToSave });
const saveResult = await DynamicFormApi.saveFormData({ const saveResult = await DynamicFormApi.saveFormData({
screenId: screenId!, screenId: screenId!,
tableName: tableName!, tableName: saveTableName,
data: rowToSave, data: rowToSave,
}); });
@ -1612,9 +1731,14 @@ export class ButtonActionExecutor {
}); });
delete rowToSave.id; // id 제거하여 INSERT delete rowToSave.id; // id 제거하여 INSERT
// 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우)
if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) {
rowToSave[saveConfig.primaryKeyColumn] = mainRecordId;
}
const saveResult = await DynamicFormApi.saveFormData({ const saveResult = await DynamicFormApi.saveFormData({
screenId: screenId!, screenId: screenId!,
tableName: tableName!, tableName: saveTableName,
data: rowToSave, data: rowToSave,
}); });
@ -1631,14 +1755,14 @@ export class ButtonActionExecutor {
const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon); const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon);
if (hasChanges) { if (hasChanges) {
console.log(`🔄 [UPDATE] 품목 수정: id=${item.id}`); console.log(`🔄 [UPDATE] 품목 수정: id=${item.id}, tableName=${saveTableName}`);
// 변경된 필드만 추출하여 부분 업데이트 // 변경된 필드만 추출하여 부분 업데이트
const updateResult = await DynamicFormApi.updateFormDataPartial( const updateResult = await DynamicFormApi.updateFormDataPartial(
item.id, item.id,
originalItem, originalItem,
currentDataWithCommon, currentDataWithCommon,
tableName!, saveTableName,
); );
if (!updateResult.success) { if (!updateResult.success) {
@ -1656,9 +1780,9 @@ export class ButtonActionExecutor {
const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(orig.id)); const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(orig.id));
for (const deletedItem of deletedItems) { for (const deletedItem of deletedItems) {
console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}`); console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}, tableName=${saveTableName}`);
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(tableName!, deletedItem.id); const deleteResult = await DynamicFormApi.deleteFormDataFromTable(saveTableName, deletedItem.id);
if (!deleteResult.success) { if (!deleteResult.success) {
throw new Error(deleteResult.message || "품목 삭제 실패"); throw new Error(deleteResult.message || "품목 삭제 실패");
@ -1670,6 +1794,7 @@ export class ButtonActionExecutor {
// 결과 메시지 생성 // 결과 메시지 생성
const resultParts: string[] = []; const resultParts: string[] = [];
if (mainRecordId) resultParts.push("메인 데이터 저장");
if (insertedCount > 0) resultParts.push(`${insertedCount}개 추가`); if (insertedCount > 0) resultParts.push(`${insertedCount}개 추가`);
if (updatedCount > 0) resultParts.push(`${updatedCount}개 수정`); if (updatedCount > 0) resultParts.push(`${updatedCount}개 수정`);
if (deletedCount > 0) resultParts.push(`${deletedCount}개 삭제`); if (deletedCount > 0) resultParts.push(`${deletedCount}개 삭제`);
@ -2145,7 +2270,10 @@ export class ButtonActionExecutor {
* *
* RelatedDataButtons * RelatedDataButtons
*/ */
private static async handleOpenRelatedModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> { private static async handleOpenRelatedModal(
config: ButtonActionConfig,
context: ButtonActionContext,
): Promise<boolean> {
// 버튼 설정에서 targetScreenId 가져오기 (여러 위치에서 확인) // 버튼 설정에서 targetScreenId 가져오기 (여러 위치에서 확인)
const targetScreenId = config.relatedModalConfig?.targetScreenId || config.targetScreenId; const targetScreenId = config.relatedModalConfig?.targetScreenId || config.targetScreenId;
@ -2188,7 +2316,7 @@ export class ButtonActionExecutor {
}); });
if (relatedConfig?.modalLink?.dataMapping && relatedConfig.modalLink.dataMapping.length > 0) { if (relatedConfig?.modalLink?.dataMapping && relatedConfig.modalLink.dataMapping.length > 0) {
relatedConfig.modalLink.dataMapping.forEach(mapping => { relatedConfig.modalLink.dataMapping.forEach((mapping) => {
console.log("🔍 [openRelatedModal] 매핑 처리:", { console.log("🔍 [openRelatedModal] 매핑 처리:", {
mapping, mapping,
sourceField: mapping.sourceField, sourceField: mapping.sourceField,
@ -2219,7 +2347,8 @@ export class ButtonActionExecutor {
}); });
// 모달 열기 이벤트 발생 (ScreenModal은 editData를 사용) // 모달 열기 이벤트 발생 (ScreenModal은 editData를 사용)
window.dispatchEvent(new CustomEvent("openScreenModal", { window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: { detail: {
screenId: targetScreenId, screenId: targetScreenId,
title: config.modalTitle, title: config.modalTitle,
@ -2230,7 +2359,8 @@ export class ButtonActionExecutor {
window.dispatchEvent(new CustomEvent("refreshTableData")); window.dispatchEvent(new CustomEvent("refreshTableData"));
}, },
}, },
})); }),
);
return true; return true;
} }
@ -3296,10 +3426,7 @@ export class ButtonActionExecutor {
* EditModal public으로 * EditModal public으로
* *
*/ */
public static async executeAfterSaveControl( public static async executeAfterSaveControl(config: ButtonActionConfig, context: ButtonActionContext): Promise<void> {
config: ButtonActionConfig,
context: ButtonActionContext,
): Promise<void> {
console.log("🎯 저장 후 제어 실행:", { console.log("🎯 저장 후 제어 실행:", {
enableDataflowControl: config.enableDataflowControl, enableDataflowControl: config.enableDataflowControl,
dataflowConfig: config.dataflowConfig, dataflowConfig: config.dataflowConfig,
@ -4763,19 +4890,20 @@ export class ButtonActionExecutor {
if (userId) { if (userId) {
try { try {
const { apiClient } = await import("@/lib/api/client"); const { apiClient } = await import("@/lib/api/client");
const statusTableName = config.trackingStatusTableName || this.trackingConfig?.trackingStatusTableName || context.tableName || "vehicles"; const statusTableName =
config.trackingStatusTableName ||
this.trackingConfig?.trackingStatusTableName ||
context.tableName ||
"vehicles";
const keyField = config.trackingStatusKeyField || this.trackingConfig?.trackingStatusKeyField || "user_id"; const keyField = config.trackingStatusKeyField || this.trackingConfig?.trackingStatusKeyField || "user_id";
// DB에서 현재 차량 정보 조회 // DB에서 현재 차량 정보 조회
const vehicleResponse = await apiClient.post( const vehicleResponse = await apiClient.post(`/table-management/tables/${statusTableName}/data`, {
`/table-management/tables/${statusTableName}/data`,
{
page: 1, page: 1,
size: 1, size: 1,
search: { [keyField]: userId }, search: { [keyField]: userId },
autoFilter: true, autoFilter: true,
}, });
);
const vehicleData = vehicleResponse.data?.data?.data?.[0] || vehicleResponse.data?.data?.rows?.[0]; const vehicleData = vehicleResponse.data?.data?.data?.[0] || vehicleResponse.data?.data?.rows?.[0];
if (vehicleData) { if (vehicleData) {
@ -4792,14 +4920,18 @@ export class ButtonActionExecutor {
// 마지막 위치 저장 (추적 중이었던 경우에만) // 마지막 위치 저장 (추적 중이었던 경우에만)
if (isTrackingActive) { if (isTrackingActive) {
// DB 값 우선, 없으면 formData 사용 // DB 값 우선, 없으면 formData 사용
const departure = dbDeparture || const departure =
this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null; dbDeparture ||
const arrival = dbArrival || this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] ||
this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null; null;
const arrival =
dbArrival || this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null;
const departureName = this.trackingContext?.formData?.["departure_name"] || null; const departureName = this.trackingContext?.formData?.["departure_name"] || null;
const destinationName = this.trackingContext?.formData?.["destination_name"] || null; const destinationName = this.trackingContext?.formData?.["destination_name"] || null;
const vehicleId = dbVehicleId || const vehicleId =
this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null; dbVehicleId ||
this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] ||
null;
await this.saveLocationToHistory( await this.saveLocationToHistory(
tripId, tripId,
@ -5681,10 +5813,10 @@ export class ButtonActionExecutor {
const columnMappings = quickInsertConfig.columnMappings || []; const columnMappings = quickInsertConfig.columnMappings || [];
for (const mapping of columnMappings) { for (const mapping of columnMappings) {
console.log(`📍 매핑 처리 시작:`, mapping); console.log("📍 매핑 처리 시작:", mapping);
if (!mapping.targetColumn) { if (!mapping.targetColumn) {
console.log(`📍 targetColumn 없음, 스킵`); console.log("📍 targetColumn 없음, 스킵");
continue; continue;
} }
@ -5692,7 +5824,7 @@ export class ButtonActionExecutor {
switch (mapping.sourceType) { switch (mapping.sourceType) {
case "component": case "component":
console.log(`📍 component 타입 처리:`, { console.log("📍 component 타입 처리:", {
sourceComponentId: mapping.sourceComponentId, sourceComponentId: mapping.sourceComponentId,
sourceColumnName: mapping.sourceColumnName, sourceColumnName: mapping.sourceColumnName,
targetColumn: mapping.targetColumn, targetColumn: mapping.targetColumn,
@ -5715,7 +5847,7 @@ export class ButtonActionExecutor {
// 3. 없으면 allComponents에서 컴포넌트를 찾아 columnName으로 시도 // 3. 없으면 allComponents에서 컴포넌트를 찾아 columnName으로 시도
if (value === undefined && context.allComponents) { if (value === undefined && context.allComponents) {
const comp = context.allComponents.find((c: any) => c.id === mapping.sourceComponentId); const comp = context.allComponents.find((c: any) => c.id === mapping.sourceComponentId);
console.log(`📍 방법3 찾은 컴포넌트:`, comp); console.log("📍 방법3 찾은 컴포넌트:", comp);
if (comp?.columnName) { if (comp?.columnName) {
value = formData?.[comp.columnName]; value = formData?.[comp.columnName];
console.log(`📍 방법3 (allComponents): ${mapping.sourceComponentId}${comp.columnName} = ${value}`); console.log(`📍 방법3 (allComponents): ${mapping.sourceComponentId}${comp.columnName} = ${value}`);
@ -5742,7 +5874,7 @@ export class ButtonActionExecutor {
break; break;
case "leftPanel": case "leftPanel":
console.log(`📍 leftPanel 타입 처리:`, { console.log("📍 leftPanel 타입 처리:", {
sourceColumn: mapping.sourceColumn, sourceColumn: mapping.sourceColumn,
selectedLeftData: splitPanelContext?.selectedLeftData, selectedLeftData: splitPanelContext?.selectedLeftData,
}); });
@ -5786,7 +5918,7 @@ export class ButtonActionExecutor {
insertData[mapping.targetColumn] = value; insertData[mapping.targetColumn] = value;
console.log(`📍 insertData에 추가됨: ${mapping.targetColumn} = ${value}`); console.log(`📍 insertData에 추가됨: ${mapping.targetColumn} = ${value}`);
} else { } else {
console.log(`📍 값이 비어있어서 insertData에 추가 안됨`); console.log("📍 값이 비어있어서 insertData에 추가 안됨");
} }
} }
@ -5799,7 +5931,7 @@ export class ButtonActionExecutor {
let targetTableColumns: string[] = []; let targetTableColumns: string[] = [];
try { try {
const columnsResponse = await apiClient.get( const columnsResponse = await apiClient.get(
`/table-management/tables/${quickInsertConfig.targetTable}/columns` `/table-management/tables/${quickInsertConfig.targetTable}/columns`,
); );
if (columnsResponse.data?.success && columnsResponse.data?.data) { if (columnsResponse.data?.success && columnsResponse.data?.data) {
const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data; const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data;
@ -5824,20 +5956,20 @@ export class ButtonActionExecutor {
} }
// 시스템 컬럼 제외 (id, created_date, updated_date, writer 등) // 시스템 컬럼 제외 (id, created_date, updated_date, writer 등)
const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name']; const systemColumns = ["id", "created_date", "updated_date", "writer", "writer_name"];
if (systemColumns.includes(key)) { if (systemColumns.includes(key)) {
console.log(`📍 자동 매핑 스킵 (시스템 컬럼): ${key}`); console.log(`📍 자동 매핑 스킵 (시스템 컬럼): ${key}`);
continue; continue;
} }
// _label, _name 으로 끝나는 표시용 컬럼 제외 // _label, _name 으로 끝나는 표시용 컬럼 제외
if (key.endsWith('_label') || key.endsWith('_name')) { if (key.endsWith("_label") || key.endsWith("_name")) {
console.log(`📍 자동 매핑 스킵 (표시용 컬럼): ${key}`); console.log(`📍 자동 매핑 스킵 (표시용 컬럼): ${key}`);
continue; continue;
} }
// 값이 있으면 자동 추가 // 값이 있으면 자동 추가
if (val !== undefined && val !== null && val !== '') { if (val !== undefined && val !== null && val !== "") {
insertData[key] = val; insertData[key] = val;
console.log(`📍 자동 매핑 추가: ${key} = ${val}`); console.log(`📍 자동 매핑 추가: ${key} = ${val}`);
} }
@ -5877,14 +6009,19 @@ export class ButtonActionExecutor {
page: 1, page: 1,
pageSize: 1, pageSize: 1,
search: duplicateCheckData, search: duplicateCheckData,
} },
); );
console.log("📍 중복 체크 응답:", checkResponse.data); console.log("📍 중복 체크 응답:", checkResponse.data);
// 응답 구조: { success: true, data: { data: [...], total: N } } 또는 { success: true, data: [...] } // 응답 구조: { success: true, data: { data: [...], total: N } } 또는 { success: true, data: [...] }
const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || []; const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || [];
console.log("📍 기존 데이터:", existingData, "길이:", Array.isArray(existingData) ? existingData.length : 0); console.log(
"📍 기존 데이터:",
existingData,
"길이:",
Array.isArray(existingData) ? existingData.length : 0,
);
if (Array.isArray(existingData) && existingData.length > 0) { if (Array.isArray(existingData) && existingData.length > 0) {
toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다."); toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다.");
@ -5902,7 +6039,7 @@ export class ButtonActionExecutor {
// 데이터 저장 // 데이터 저장
const response = await apiClient.post( const response = await apiClient.post(
`/table-management/tables/${quickInsertConfig.targetTable}/add`, `/table-management/tables/${quickInsertConfig.targetTable}/add`,
insertData insertData,
); );
if (response.data?.success) { if (response.data?.success) {