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

@ -2030,14 +2030,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
className="border-border flex flex-shrink-0 flex-col border-r" className="border-border flex flex-shrink-0 flex-col border-r"
> >
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}> <Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
<CardHeader <CardHeader
className="flex-shrink-0 border-b" className="flex-shrink-0 border-b"
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">
@ -2521,14 +2521,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
className="flex flex-shrink-0 flex-col" className="flex flex-shrink-0 flex-col"
> >
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}> <Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
<CardHeader <CardHeader
className="flex-shrink-0 border-b" className="flex-shrink-0 border-b"
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

@ -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,35 +1063,51 @@ 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;
// 필드 타입 섹션의 필드들 처리 // 필드 타입 섹션의 필드들 처리
if (otherSection.type !== "table" && otherSection.fields) { if (otherSection.type !== "table" && otherSection.fields) {
for (const field of otherSection.fields) { for (const field of otherSection.fields) {
// 필드별 오버라이드 확인 // 필드별 오버라이드 확인
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName); const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode; const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
// 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용 // 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용
if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) { if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) {
commonFieldsData[field.columnName] = formData[field.columnName]; commonFieldsData[field.columnName] = formData[field.columnName];
}
}
}
// 🆕 선택적 필드 그룹 (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) {
// 공통 필드 병합 + 개별 품목 데이터 // 공통 필드 병합 + 개별 품목 데이터
const itemToSave = { ...commonFieldsData, ...item }; const itemToSave = { ...commonFieldsData, ...item };
@ -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="grid grid-cols-2 gap-4">
<div> {/* 버튼 표시 설정 */}
<Label className="text-xs"> </Label> <div className="space-y-2 p-3 bg-muted/30 rounded-lg">
<Select <Label className="text-xs font-medium"> </Label>
value={tableConfig.uiConfig?.addButtonType || "search"} <p className="text-[10px] text-muted-foreground mb-2">
onValueChange={(value) => updateUiConfig({ addButtonType: value as "search" | "addRow" })} .
> </p>
<SelectTrigger className="h-8 text-xs mt-1"> <div className="grid grid-cols-2 gap-4">
<SelectValue placeholder="버튼 동작 선택" /> <div className="flex items-center gap-2">
</SelectTrigger> <Switch
<SelectContent> checked={tableConfig.uiConfig?.showSearchButton ?? true}
<SelectItem value="search"> onCheckedChange={(checked) => updateUiConfig({ showSearchButton: checked })}
<div className="flex flex-col"> className="scale-75"
<span> </span> />
<span className="text-[10px] text-muted-foreground"> </span> <div>
</div> <span className="text-xs font-medium"> </span>
</SelectItem> <p className="text-[10px] text-muted-foreground"> </p>
<SelectItem value="addRow"> </div>
<div className="flex flex-col"> </div>
<span> </span> <div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground"> </span> <Switch
</div> checked={tableConfig.uiConfig?.showAddRowButton ?? false}
</SelectItem> onCheckedChange={(checked) => updateUiConfig({ showAddRowButton: checked })}
</SelectContent> className="scale-75"
</Select> />
<div>
<span className="text-xs font-medium"> </span>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
</div>
</div> </div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 검색 버튼 텍스트 */}
<div> <div>
<Label className="text-xs"> </Label> <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,
@ -1514,7 +1515,66 @@ export class ButtonActionExecutor {
console.log("🎯 [handleUniversalFormModalTableSectionSave] Universal Form Modal 감지:", universalFormModalKey); console.log("🎯 [handleUniversalFormModalTableSectionSave] Universal Form Modal 감지:", universalFormModalKey);
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,17 +2270,20 @@ 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;
console.log("🔍 [openRelatedModal] 설정 확인:", { console.log("🔍 [openRelatedModal] 설정 확인:", {
config, config,
relatedModalConfig: config.relatedModalConfig, relatedModalConfig: config.relatedModalConfig,
targetScreenId: config.targetScreenId, targetScreenId: config.targetScreenId,
finalTargetScreenId: targetScreenId, finalTargetScreenId: targetScreenId,
}); });
if (!targetScreenId) { if (!targetScreenId) {
console.error("❌ [openRelatedModal] targetScreenId가 설정되지 않았습니다."); console.error("❌ [openRelatedModal] targetScreenId가 설정되지 않았습니다.");
toast.error("모달 화면 ID가 설정되지 않았습니다."); toast.error("모달 화면 ID가 설정되지 않았습니다.");
@ -2164,13 +2292,13 @@ export class ButtonActionExecutor {
// RelatedDataButtons에서 선택된 데이터 가져오기 // RelatedDataButtons에서 선택된 데이터 가져오기
const relatedData = window.__relatedButtonsSelectedData; const relatedData = window.__relatedButtonsSelectedData;
console.log("🔍 [openRelatedModal] RelatedDataButtons 데이터:", { console.log("🔍 [openRelatedModal] RelatedDataButtons 데이터:", {
relatedData, relatedData,
selectedItem: relatedData?.selectedItem, selectedItem: relatedData?.selectedItem,
config: relatedData?.config, config: relatedData?.config,
}); });
if (!relatedData?.selectedItem) { if (!relatedData?.selectedItem) {
console.warn("⚠️ [openRelatedModal] 선택된 버튼이 없습니다."); console.warn("⚠️ [openRelatedModal] 선택된 버튼이 없습니다.");
toast.warning("먼저 버튼을 선택해주세요."); toast.warning("먼저 버튼을 선택해주세요.");
@ -2181,14 +2309,14 @@ export class ButtonActionExecutor {
// 데이터 매핑 적용 // 데이터 매핑 적용
const initialData: Record<string, any> = {}; const initialData: Record<string, any> = {};
console.log("🔍 [openRelatedModal] 매핑 설정:", { console.log("🔍 [openRelatedModal] 매핑 설정:", {
modalLink: relatedConfig?.modalLink, modalLink: relatedConfig?.modalLink,
dataMapping: relatedConfig?.modalLink?.dataMapping, dataMapping: relatedConfig?.modalLink?.dataMapping,
}); });
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,
@ -2197,7 +2325,7 @@ export class ButtonActionExecutor {
selectedItemId: selectedItem.id, selectedItemId: selectedItem.id,
rawDataValue: selectedItem.rawData[mapping.sourceField], rawDataValue: selectedItem.rawData[mapping.sourceField],
}); });
if (mapping.sourceField === "value") { if (mapping.sourceField === "value") {
initialData[mapping.targetField] = selectedItem.value; initialData[mapping.targetField] = selectedItem.value;
} else if (mapping.sourceField === "id") { } else if (mapping.sourceField === "id") {
@ -2219,18 +2347,20 @@ export class ButtonActionExecutor {
}); });
// 모달 열기 이벤트 발생 (ScreenModal은 editData를 사용) // 모달 열기 이벤트 발생 (ScreenModal은 editData를 사용)
window.dispatchEvent(new CustomEvent("openScreenModal", { window.dispatchEvent(
detail: { new CustomEvent("openScreenModal", {
screenId: targetScreenId, detail: {
title: config.modalTitle, screenId: targetScreenId,
description: config.modalDescription, title: config.modalTitle,
editData: initialData, // ScreenModal은 editData로 폼 데이터를 받음 description: config.modalDescription,
onSuccess: () => { editData: initialData, // ScreenModal은 editData로 폼 데이터를 받음
// 성공 후 데이터 새로고침 onSuccess: () => {
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,
@ -4742,7 +4869,7 @@ export class ButtonActionExecutor {
// 추적 중인지 확인 (새로고침 후에도 DB 상태 기반 종료 가능하도록 수정) // 추적 중인지 확인 (새로고침 후에도 DB 상태 기반 종료 가능하도록 수정)
const isTrackingActive = !!this.trackingIntervalId; const isTrackingActive = !!this.trackingIntervalId;
if (!isTrackingActive) { if (!isTrackingActive) {
// 추적 중이 아니어도 DB 상태 변경은 진행 (새로고침 후 종료 지원) // 추적 중이 아니어도 DB 상태 변경은 진행 (새로고침 후 종료 지원)
console.log("⚠️ [handleTrackingStop] trackingIntervalId 없음 - DB 상태 기반 종료 진행"); console.log("⚠️ [handleTrackingStop] trackingIntervalId 없음 - DB 상태 기반 종료 진행");
@ -4758,25 +4885,26 @@ export class ButtonActionExecutor {
let dbDeparture: string | null = null; let dbDeparture: string | null = null;
let dbArrival: string | null = null; let dbArrival: string | null = null;
let dbVehicleId: string | null = null; let dbVehicleId: string | null = null;
const userId = context.userId || this.trackingUserId; const userId = context.userId || this.trackingUserId;
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,
{ size: 1,
page: 1, search: { [keyField]: userId },
size: 1, autoFilter: true,
search: { [keyField]: userId }, });
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) {
dbDeparture = vehicleData.departure || null; dbDeparture = vehicleData.departure || null;
@ -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,12 +5824,12 @@ 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,
}); });
// 컴포넌트의 현재 값 // 컴포넌트의 현재 값
if (mapping.sourceComponentId) { if (mapping.sourceComponentId) {
// 1. sourceColumnName이 있으면 직접 사용 (가장 확실한 방법) // 1. sourceColumnName이 있으면 직접 사용 (가장 확실한 방법)
@ -5705,34 +5837,34 @@ export class ButtonActionExecutor {
value = formData?.[mapping.sourceColumnName]; value = formData?.[mapping.sourceColumnName];
console.log(`📍 방법1 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`); console.log(`📍 방법1 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`);
} }
// 2. 없으면 컴포넌트 ID로 직접 찾기 // 2. 없으면 컴포넌트 ID로 직접 찾기
if (value === undefined) { if (value === undefined) {
value = formData?.[mapping.sourceComponentId]; value = formData?.[mapping.sourceComponentId];
console.log(`📍 방법2 (sourceComponentId): ${mapping.sourceComponentId} = ${value}`); console.log(`📍 방법2 (sourceComponentId): ${mapping.sourceComponentId} = ${value}`);
} }
// 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}`);
} }
} }
// 4. targetColumn과 같은 이름의 키가 formData에 있으면 사용 (폴백) // 4. targetColumn과 같은 이름의 키가 formData에 있으면 사용 (폴백)
if (value === undefined && mapping.targetColumn && formData?.[mapping.targetColumn] !== undefined) { if (value === undefined && mapping.targetColumn && formData?.[mapping.targetColumn] !== undefined) {
value = formData[mapping.targetColumn]; value = formData[mapping.targetColumn];
console.log(`📍 방법4 (targetColumn 폴백): ${mapping.targetColumn} = ${value}`); console.log(`📍 방법4 (targetColumn 폴백): ${mapping.targetColumn} = ${value}`);
} }
// 5. 그래도 없으면 formData의 모든 키를 확인하고 로깅 // 5. 그래도 없으면 formData의 모든 키를 확인하고 로깅
if (value === undefined) { if (value === undefined) {
console.log("📍 방법5: formData에서 값을 찾지 못함. formData 키들:", Object.keys(formData || {})); console.log("📍 방법5: formData에서 값을 찾지 못함. formData 키들:", Object.keys(formData || {}));
} }
// sourceColumn이 지정된 경우 해당 속성 추출 // sourceColumn이 지정된 경우 해당 속성 추출
if (mapping.sourceColumn && value && typeof value === "object") { if (mapping.sourceColumn && value && typeof value === "object") {
value = value[mapping.sourceColumn]; value = value[mapping.sourceColumn];
@ -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,
}); });
@ -5775,18 +5907,18 @@ export class ButtonActionExecutor {
} }
console.log(`📍 currentUser 값: ${value}`); console.log(`📍 currentUser 값: ${value}`);
break; break;
default: default:
console.log(`📍 알 수 없는 sourceType: ${mapping.sourceType}`); console.log(`📍 알 수 없는 sourceType: ${mapping.sourceType}`);
} }
console.log(`📍 매핑 결과: targetColumn=${mapping.targetColumn}, value=${value}, type=${typeof value}`); console.log(`📍 매핑 결과: targetColumn=${mapping.targetColumn}, value=${value}, type=${typeof value}`);
if (value !== undefined && value !== null && value !== "") { if (value !== undefined && value !== null && value !== "") {
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에 추가 안됨");
} }
} }
@ -5794,12 +5926,12 @@ export class ButtonActionExecutor {
if (splitPanelContext?.selectedLeftData) { if (splitPanelContext?.selectedLeftData) {
const leftData = splitPanelContext.selectedLeftData; const leftData = splitPanelContext.selectedLeftData;
console.log("📍 좌측 패널 자동 매핑 시작:", leftData); console.log("📍 좌측 패널 자동 매핑 시작:", leftData);
// 대상 테이블의 컬럼 목록 조회 // 대상 테이블의 컬럼 목록 조회
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;
@ -5809,35 +5941,35 @@ export class ButtonActionExecutor {
} catch (error) { } catch (error) {
console.error("대상 테이블 컬럼 조회 실패:", error); console.error("대상 테이블 컬럼 조회 실패:", error);
} }
for (const [key, val] of Object.entries(leftData)) { for (const [key, val] of Object.entries(leftData)) {
// 이미 매핑된 컬럼은 스킵 // 이미 매핑된 컬럼은 스킵
if (insertData[key] !== undefined) { if (insertData[key] !== undefined) {
console.log(`📍 자동 매핑 스킵 (이미 존재): ${key}`); console.log(`📍 자동 매핑 스킵 (이미 존재): ${key}`);
continue; continue;
} }
// 대상 테이블에 해당 컬럼이 없으면 스킵 // 대상 테이블에 해당 컬럼이 없으면 스킵
if (targetTableColumns.length > 0 && !targetTableColumns.includes(key)) { if (targetTableColumns.length > 0 && !targetTableColumns.includes(key)) {
console.log(`📍 자동 매핑 스킵 (대상 테이블에 없는 컬럼): ${key}`); console.log(`📍 자동 매핑 스킵 (대상 테이블에 없는 컬럼): ${key}`);
continue; continue;
} }
// 시스템 컬럼 제외 (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}`);
} }
@ -5857,7 +5989,7 @@ export class ButtonActionExecutor {
enabled: quickInsertConfig.duplicateCheck?.enabled, enabled: quickInsertConfig.duplicateCheck?.enabled,
columns: quickInsertConfig.duplicateCheck?.columns, columns: quickInsertConfig.duplicateCheck?.columns,
}); });
if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) { if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) {
const duplicateCheckData: Record<string, any> = {}; const duplicateCheckData: Record<string, any> = {};
for (const col of quickInsertConfig.duplicateCheck.columns) { for (const col of quickInsertConfig.duplicateCheck.columns) {
@ -5877,15 +6009,20 @@ 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 || "이미 존재하는 데이터입니다.");
return false; return false;
@ -5902,20 +6039,20 @@ 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) {
console.log("✅ Quick Insert 저장 성공"); console.log("✅ Quick Insert 저장 성공");
// 저장 후 동작 설정 로그 // 저장 후 동작 설정 로그
console.log("📍 afterInsert 설정:", quickInsertConfig.afterInsert); console.log("📍 afterInsert 설정:", quickInsertConfig.afterInsert);
// 🆕 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트 새로고침) // 🆕 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트 새로고침)
// refreshData가 명시적으로 false가 아니면 기본적으로 새로고침 실행 // refreshData가 명시적으로 false가 아니면 기본적으로 새로고침 실행
const shouldRefresh = quickInsertConfig.afterInsert?.refreshData !== false; const shouldRefresh = quickInsertConfig.afterInsert?.refreshData !== false;
console.log("📍 데이터 새로고침 여부:", shouldRefresh); console.log("📍 데이터 새로고침 여부:", shouldRefresh);
if (shouldRefresh) { if (shouldRefresh) {
console.log("📍 데이터 새로고침 이벤트 발송"); console.log("📍 데이터 새로고침 이벤트 발송");
// 전역 이벤트로 테이블/카드 컴포넌트들에게 새로고침 알림 // 전역 이벤트로 테이블/카드 컴포넌트들에게 새로고침 알림