엔티티 즉시저장기능 추가

This commit is contained in:
kjs 2025-12-16 14:38:03 +09:00
parent d8329d31e4
commit f7e3c1924c
17 changed files with 1969 additions and 34 deletions

View File

@ -1751,7 +1751,7 @@ export class ScreenManagementService {
// 기타
label: "text-display",
code: "select-basic",
entity: "select-basic",
entity: "entity-search-input", // 엔티티는 entity-search-input 사용
category: "select-basic",
};

View File

@ -0,0 +1,345 @@
# 즉시 저장(quickInsert) 버튼 액션 구현 계획서
## 1. 개요
### 1.1 목적
화면에서 entity 타입 선택박스로 데이터를 선택한 후, 버튼 클릭 시 특정 테이블에 즉시 INSERT하는 기능 구현
### 1.2 사용 사례
- **공정별 설비 관리**: 좌측에서 공정 선택 → 우측에서 설비 선택 → "설비 추가" 버튼 클릭 → `process_equipment` 테이블에 즉시 저장
### 1.3 화면 구성 예시
```
┌─────────────────────────────────────────────────────────────┐
│ [entity 선택박스] [버튼: quickInsert] │
│ ┌─────────────────────────────┐ ┌──────────────┐ │
│ │ MCT-01 - 머시닝센터 #1 ▼ │ │ + 설비 추가 │ │
│ └─────────────────────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## 2. 기술 설계
### 2.1 버튼 액션 타입 추가
```typescript
// types/screen-management.ts
type ButtonActionType =
| "save"
| "cancel"
| "delete"
| "edit"
| "add"
| "search"
| "reset"
| "submit"
| "close"
| "popup"
| "navigate"
| "custom"
| "quickInsert" // 🆕 즉시 저장
```
### 2.2 quickInsert 설정 구조
```typescript
interface QuickInsertColumnMapping {
targetColumn: string; // 저장할 테이블의 컬럼명
sourceType: "component" | "leftPanel" | "fixed" | "currentUser";
// sourceType별 추가 설정
sourceComponentId?: string; // component: 값을 가져올 컴포넌트 ID
sourceColumn?: string; // leftPanel: 좌측 선택 데이터의 컬럼명
fixedValue?: any; // fixed: 고정값
userField?: string; // currentUser: 사용자 정보 필드 (userId, userName, companyCode)
}
interface QuickInsertConfig {
targetTable: string; // 저장할 테이블명
columnMappings: QuickInsertColumnMapping[];
// 저장 후 동작
afterInsert?: {
refreshRightPanel?: boolean; // 우측 패널 새로고침
clearComponents?: string[]; // 초기화할 컴포넌트 ID 목록
showSuccessMessage?: boolean; // 성공 메시지 표시
successMessage?: string; // 커스텀 성공 메시지
};
// 중복 체크 (선택사항)
duplicateCheck?: {
enabled: boolean;
columns: string[]; // 중복 체크할 컬럼들
errorMessage?: string; // 중복 시 에러 메시지
};
}
interface ButtonComponentConfig {
// 기존 설정들...
actionType: ButtonActionType;
// 🆕 quickInsert 전용 설정
quickInsertConfig?: QuickInsertConfig;
}
```
### 2.3 데이터 흐름
```
1. 사용자가 entity 선택박스에서 설비 선택
└─ equipment_code = "EQ-001" (내부값)
└─ 표시: "MCT-01 - 머시닝센터 #1"
2. 사용자가 "설비 추가" 버튼 클릭
3. quickInsert 핸들러 실행
├─ columnMappings 순회
│ ├─ equipment_code: component에서 값 가져오기 → "EQ-001"
│ └─ process_code: leftPanel에서 값 가져오기 → "PRC-001"
└─ INSERT 데이터 구성
{
equipment_code: "EQ-001",
process_code: "PRC-001",
company_code: "COMPANY_7", // 자동 추가
writer: "wace" // 자동 추가
}
4. API 호출: POST /api/table-management/tables/process_equipment/add
5. 성공 시
├─ 성공 메시지 표시
├─ 우측 패널(카드/테이블) 새로고침
└─ 선택박스 초기화
```
---
## 3. 구현 계획
### 3.1 Phase 1: 타입 정의 및 설정 UI
| 작업 | 파일 | 설명 |
|------|------|------|
| 1-1 | `frontend/types/screen-management.ts` | QuickInsertConfig 타입 추가 |
| 1-2 | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | quickInsert 설정 UI 추가 |
### 3.2 Phase 2: 버튼 액션 핸들러 구현
| 작업 | 파일 | 설명 |
|------|------|------|
| 2-1 | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | quickInsert 핸들러 추가 |
| 2-2 | 컴포넌트 값 수집 로직 | 같은 화면의 다른 컴포넌트에서 값 가져오기 |
### 3.3 Phase 3: 테스트 및 검증
| 작업 | 설명 |
|------|------|
| 3-1 | 공정별 설비 화면에서 테스트 |
| 3-2 | 중복 저장 방지 테스트 |
| 3-3 | 에러 처리 테스트 |
---
## 4. 상세 구현
### 4.1 ButtonConfigPanel 설정 UI
```
┌─────────────────────────────────────────────────────────────┐
│ 버튼 액션 타입 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 즉시 저장 (quickInsert) ▼ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ─────────────── 즉시 저장 설정 ─────────────── │
│ │
│ 대상 테이블 * │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ process_equipment ▼ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 컬럼 매핑 [+ 추가] │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 매핑 #1 [삭제] │ │
│ │ 대상 컬럼: equipment_code │ │
│ │ 값 소스: 컴포넌트 선택 │ │
│ │ 컴포넌트: [equipment-select ▼] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 매핑 #2 [삭제] │ │
│ │ 대상 컬럼: process_code │ │
│ │ 값 소스: 좌측 패널 데이터 │ │
│ │ 소스 컬럼: process_code │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ─────────────── 저장 후 동작 ─────────────── │
│ │
│ ☑ 우측 패널 새로고침 │
│ ☑ 선택박스 초기화 │
│ ☑ 성공 메시지 표시 │
│ │
│ ─────────────── 중복 체크 (선택) ─────────────── │
│ │
│ ☐ 중복 체크 활성화 │
│ 체크 컬럼: equipment_code, process_code │
│ 에러 메시지: 이미 등록된 설비입니다. │
└─────────────────────────────────────────────────────────────┘
```
### 4.2 핸들러 구현 (의사 코드)
```typescript
const handleQuickInsert = async (config: QuickInsertConfig) => {
// 1. 컬럼 매핑에서 값 수집
const insertData: Record<string, any> = {};
for (const mapping of config.columnMappings) {
let value: any;
switch (mapping.sourceType) {
case "component":
// 같은 화면의 컴포넌트에서 값 가져오기
value = getComponentValue(mapping.sourceComponentId);
break;
case "leftPanel":
// 분할 패널 좌측 선택 데이터에서 값 가져오기
value = splitPanelContext?.selectedLeftData?.[mapping.sourceColumn];
break;
case "fixed":
value = mapping.fixedValue;
break;
case "currentUser":
value = user?.[mapping.userField];
break;
}
if (value !== undefined && value !== null && value !== "") {
insertData[mapping.targetColumn] = value;
}
}
// 2. 필수값 검증
if (Object.keys(insertData).length === 0) {
toast.error("저장할 데이터가 없습니다.");
return;
}
// 3. 중복 체크 (설정된 경우)
if (config.duplicateCheck?.enabled) {
const isDuplicate = await checkDuplicate(
config.targetTable,
config.duplicateCheck.columns,
insertData
);
if (isDuplicate) {
toast.error(config.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다.");
return;
}
}
// 4. API 호출
try {
await tableTypeApi.addTableData(config.targetTable, insertData);
// 5. 성공 후 동작
if (config.afterInsert?.showSuccessMessage) {
toast.success(config.afterInsert.successMessage || "저장되었습니다.");
}
if (config.afterInsert?.refreshRightPanel) {
// 우측 패널 새로고침 트리거
onRefresh?.();
}
if (config.afterInsert?.clearComponents) {
// 지정된 컴포넌트 초기화
for (const componentId of config.afterInsert.clearComponents) {
clearComponentValue(componentId);
}
}
} catch (error) {
toast.error("저장에 실패했습니다.");
}
};
```
---
## 5. 컴포넌트 간 통신 방안
### 5.1 문제점
- 버튼 컴포넌트에서 같은 화면의 entity 선택박스 값을 가져와야 함
- 현재는 각 컴포넌트가 독립적으로 동작
### 5.2 해결 방안: formData 활용
현재 `InteractiveScreenViewerDynamic`에서 `formData` 상태로 모든 입력값을 관리하고 있음.
```typescript
// InteractiveScreenViewerDynamic.tsx
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
// entity 선택박스에서 값 변경 시
const handleFormDataChange = (fieldName: string, value: any) => {
setLocalFormData(prev => ({ ...prev, [fieldName]: value }));
};
// 버튼 클릭 시 formData에서 값 가져오기
const getComponentValue = (componentId: string) => {
// componentId로 컴포넌트의 columnName 찾기
const component = allComponents.find(c => c.id === componentId);
if (component?.columnName) {
return formData[component.columnName];
}
return undefined;
};
```
---
## 6. 테스트 시나리오
### 6.1 정상 케이스
1. 좌측 테이블에서 공정 "PRC-001" 선택
2. 우측 설비 선택박스에서 "MCT-01" 선택
3. "설비 추가" 버튼 클릭
4. `process_equipment` 테이블에 데이터 저장 확인
5. 우측 카드/테이블에 새 항목 표시 확인
### 6.2 에러 케이스
1. 좌측 미선택 상태에서 버튼 클릭 → "좌측에서 항목을 선택해주세요" 메시지
2. 설비 미선택 상태에서 버튼 클릭 → "설비를 선택해주세요" 메시지
3. 중복 데이터 저장 시도 → "이미 등록된 설비입니다" 메시지
### 6.3 엣지 케이스
1. 동일 설비 연속 추가 시도
2. 네트워크 오류 시 재시도
3. 권한 없는 사용자의 저장 시도
---
## 7. 일정
| Phase | 작업 | 예상 시간 |
|-------|------|----------|
| Phase 1 | 타입 정의 및 설정 UI | 1시간 |
| Phase 2 | 버튼 액션 핸들러 구현 | 1시간 |
| Phase 3 | 테스트 및 검증 | 30분 |
| **합계** | | **2시간 30분** |
---
## 8. 향후 확장 가능성
1. **다중 행 추가**: 여러 설비를 한 번에 선택하여 추가
2. **수정 모드**: 기존 데이터 수정 기능
3. **조건부 저장**: 특정 조건 만족 시에만 저장
4. **연쇄 저장**: 한 번의 클릭으로 여러 테이블에 저장

View File

@ -584,6 +584,219 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
}
};
// 🆕 즉시 저장(quickInsert) 액션 핸들러
const handleQuickInsertAction = async () => {
// componentConfig에서 quickInsertConfig 가져오기
const quickInsertConfig = (comp as any).componentConfig?.action?.quickInsertConfig;
if (!quickInsertConfig?.targetTable) {
toast.error("대상 테이블이 설정되지 않았습니다.");
return;
}
// 1. 대상 테이블의 컬럼 목록 조회 (자동 매핑용)
let targetTableColumns: string[] = [];
try {
const { default: apiClient } = await import("@/lib/api/client");
const columnsResponse = await apiClient.get(
`/table-management/tables/${quickInsertConfig.targetTable}/columns`
);
if (columnsResponse.data?.success && columnsResponse.data?.data) {
const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data;
targetTableColumns = columnsData.map((col: any) => col.columnName || col.column_name || col.name);
console.log("📍 대상 테이블 컬럼 목록:", targetTableColumns);
}
} catch (error) {
console.error("대상 테이블 컬럼 조회 실패:", error);
}
// 2. 컬럼 매핑에서 값 수집
const insertData: Record<string, any> = {};
const columnMappings = quickInsertConfig.columnMappings || [];
for (const mapping of columnMappings) {
let value: any;
switch (mapping.sourceType) {
case "component":
// 같은 화면의 컴포넌트에서 값 가져오기
// 방법1: sourceColumnName 사용
if (mapping.sourceColumnName && formData[mapping.sourceColumnName] !== undefined) {
value = formData[mapping.sourceColumnName];
console.log(`📍 컴포넌트 값 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`);
}
// 방법2: sourceComponentId로 컴포넌트 찾아서 columnName 사용
else if (mapping.sourceComponentId) {
const sourceComp = allComponents.find((c: any) => c.id === mapping.sourceComponentId);
if (sourceComp) {
const fieldName = (sourceComp as any).columnName || sourceComp.id;
value = formData[fieldName];
console.log(`📍 컴포넌트 값 (컴포넌트 조회): ${fieldName} = ${value}`);
}
}
break;
case "leftPanel":
// 분할 패널 좌측 선택 데이터에서 값 가져오기
if (mapping.sourceColumn && splitPanelContext?.selectedLeftData) {
value = splitPanelContext.selectedLeftData[mapping.sourceColumn];
}
break;
case "fixed":
value = mapping.fixedValue;
break;
case "currentUser":
if (mapping.userField) {
switch (mapping.userField) {
case "userId":
value = user?.userId;
break;
case "userName":
value = userName;
break;
case "companyCode":
value = user?.companyCode;
break;
case "deptCode":
value = authUser?.deptCode;
break;
}
}
break;
}
if (value !== undefined && value !== null && value !== "") {
insertData[mapping.targetColumn] = value;
}
}
// 3. 좌측 패널 선택 데이터에서 자동 매핑 (컬럼명이 같고 대상 테이블에 있는 경우)
if (splitPanelContext?.selectedLeftData && targetTableColumns.length > 0) {
const leftData = splitPanelContext.selectedLeftData;
console.log("📍 좌측 패널 자동 매핑 시작:", leftData);
for (const [key, val] of Object.entries(leftData)) {
// 이미 매핑된 컬럼은 스킵
if (insertData[key] !== undefined) {
continue;
}
// 대상 테이블에 해당 컬럼이 없으면 스킵
if (!targetTableColumns.includes(key)) {
continue;
}
// 시스템 컬럼 제외
const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name'];
if (systemColumns.includes(key)) {
continue;
}
// _label, _name 으로 끝나는 표시용 컬럼 제외
if (key.endsWith('_label') || key.endsWith('_name')) {
continue;
}
// 값이 있으면 자동 추가
if (val !== undefined && val !== null && val !== '') {
insertData[key] = val;
console.log(`📍 자동 매핑 추가: ${key} = ${val}`);
}
}
}
console.log("🚀 quickInsert 최종 데이터:", insertData);
// 4. 필수값 검증
if (Object.keys(insertData).length === 0) {
toast.error("저장할 데이터가 없습니다. 값을 선택해주세요.");
return;
}
// 5. 중복 체크 (설정된 경우)
if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) {
try {
const { default: apiClient } = await import("@/lib/api/client");
// 중복 체크를 위한 검색 조건 구성
const searchConditions: Record<string, any> = {};
for (const col of quickInsertConfig.duplicateCheck.columns) {
if (insertData[col] !== undefined) {
searchConditions[col] = { value: insertData[col], operator: "equals" };
}
}
console.log("📍 중복 체크 조건:", searchConditions);
// 기존 데이터 조회
const checkResponse = await apiClient.post(
`/table-management/tables/${quickInsertConfig.targetTable}/data`,
{
page: 1,
pageSize: 1,
search: searchConditions,
}
);
console.log("📍 중복 체크 응답:", checkResponse.data);
// data 배열이 있고 길이가 0보다 크면 중복
const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || [];
if (Array.isArray(existingData) && existingData.length > 0) {
toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다.");
return;
}
} catch (error) {
console.error("중복 체크 오류:", error);
// 중복 체크 실패 시 계속 진행
}
}
// 6. API 호출
try {
const { default: apiClient } = await import("@/lib/api/client");
const response = await apiClient.post(
`/table-management/tables/${quickInsertConfig.targetTable}/add`,
insertData
);
if (response.data?.success) {
// 7. 성공 후 동작
if (quickInsertConfig.afterInsert?.showSuccessMessage !== false) {
toast.success(quickInsertConfig.afterInsert?.successMessage || "저장되었습니다.");
}
// 데이터 새로고침 (테이블리스트, 카드 디스플레이)
if (quickInsertConfig.afterInsert?.refreshData !== false) {
console.log("📍 데이터 새로고침 이벤트 발송");
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("refreshTable"));
window.dispatchEvent(new CustomEvent("refreshCardDisplay"));
}
}
// 지정된 컴포넌트 초기화
if (quickInsertConfig.afterInsert?.clearComponents?.length > 0) {
for (const componentId of quickInsertConfig.afterInsert.clearComponents) {
const targetComp = allComponents.find((c: any) => c.id === componentId);
if (targetComp) {
const fieldName = (targetComp as any).columnName || targetComp.id;
onFormDataChange?.(fieldName, "");
}
}
}
} else {
toast.error(response.data?.message || "저장에 실패했습니다.");
}
} catch (error: any) {
console.error("quickInsert 오류:", error);
toast.error(error.response?.data?.message || error.message || "저장 중 오류가 발생했습니다.");
}
};
const handleClick = async () => {
try {
const actionType = config?.actionType || "save";
@ -604,6 +817,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
case "custom":
await handleCustomAction();
break;
case "quickInsert":
await handleQuickInsertAction();
break;
default:
// console.log("🔘 기본 버튼 클릭");
}

View File

@ -16,6 +16,7 @@ import { apiClient } from "@/lib/api/client";
import { ButtonDataflowConfigPanel } from "./ButtonDataflowConfigPanel";
import { ImprovedButtonControlConfigPanel } from "./ImprovedButtonControlConfigPanel";
import { FlowVisibilityConfigPanel } from "./FlowVisibilityConfigPanel";
import { QuickInsertConfigSection } from "./QuickInsertConfigSection";
// 🆕 제목 블록 타입
interface TitleBlock {
@ -642,9 +643,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectItem value="edit"></SelectItem>
<SelectItem value="copy"> ( )</SelectItem>
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="transferData">📦 </SelectItem>
<SelectItem value="openModalWithData"> + 🆕</SelectItem>
<SelectItem value="transferData"> </SelectItem>
<SelectItem value="openModalWithData"> + </SelectItem>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="quickInsert"> </SelectItem>
<SelectItem value="control"> </SelectItem>
<SelectItem value="view_table_history"> </SelectItem>
<SelectItem value="excel_download"> </SelectItem>
@ -3068,6 +3070,16 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
)}
{/* 🆕 즉시 저장(quickInsert) 액션 설정 */}
{component.componentConfig?.action?.type === "quickInsert" && (
<QuickInsertConfigSection
component={component}
onUpdateProperty={onUpdateProperty}
allComponents={allComponents}
currentTableName={currentTableName}
/>
)}
{/* 제어 기능 섹션 */}
<div className="mt-8 border-t border-border pt-6">
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />

View File

@ -189,6 +189,33 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
{/* UI 모드 선택 */}
<div className="space-y-2">
<Label htmlFor="uiMode" className="text-xs">
UI
</Label>
<Select
value={(localConfig as any).uiMode || "combo"}
onValueChange={(value) => updateConfig("uiMode" as any, value)}
>
<SelectTrigger className="text-xs">
<SelectValue placeholder="모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="select"> (Select)</SelectItem>
<SelectItem value="modal"> (Modal)</SelectItem>
<SelectItem value="combo"> + (Combo)</SelectItem>
<SelectItem value="autocomplete"> (Autocomplete)</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
{(localConfig as any).uiMode === "select" && "검색 가능한 드롭다운 형태로 표시됩니다."}
{(localConfig as any).uiMode === "modal" && "모달 팝업에서 데이터를 검색하고 선택합니다."}
{((localConfig as any).uiMode === "combo" || !(localConfig as any).uiMode) && "입력 필드와 검색 버튼이 함께 표시됩니다."}
{(localConfig as any).uiMode === "autocomplete" && "입력하면서 자동완성 목록이 표시됩니다."}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="entityType" className="text-xs">

View File

@ -0,0 +1,658 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Check, ChevronsUpDown, Plus, X, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { ComponentData } from "@/types/screen";
import { QuickInsertConfig, QuickInsertColumnMapping } from "@/types/screen-management";
import { apiClient } from "@/lib/api/client";
interface QuickInsertConfigSectionProps {
component: ComponentData;
onUpdateProperty: (path: string, value: any) => void;
allComponents?: ComponentData[];
currentTableName?: string;
}
interface TableOption {
name: string;
label: string;
}
interface ColumnOption {
name: string;
label: string;
}
export const QuickInsertConfigSection: React.FC<QuickInsertConfigSectionProps> = ({
component,
onUpdateProperty,
allComponents = [],
currentTableName,
}) => {
// 현재 설정 가져오기
const config: QuickInsertConfig = component.componentConfig?.action?.quickInsertConfig || {
targetTable: "",
columnMappings: [],
afterInsert: {
refreshData: true,
clearComponents: [],
showSuccessMessage: true,
successMessage: "저장되었습니다.",
},
duplicateCheck: {
enabled: false,
columns: [],
errorMessage: "이미 존재하는 데이터입니다.",
},
};
// 테이블 목록 상태
const [tables, setTables] = useState<TableOption[]>([]);
const [tablesLoading, setTablesLoading] = useState(false);
const [tablePopoverOpen, setTablePopoverOpen] = useState(false);
const [tableSearch, setTableSearch] = useState("");
// 대상 테이블 컬럼 목록 상태
const [targetColumns, setTargetColumns] = useState<ColumnOption[]>([]);
const [targetColumnsLoading, setTargetColumnsLoading] = useState(false);
// 매핑별 Popover 상태
const [targetColumnPopoverOpen, setTargetColumnPopoverOpen] = useState<Record<number, boolean>>({});
const [targetColumnSearch, setTargetColumnSearch] = useState<Record<number, string>>({});
const [sourceComponentPopoverOpen, setSourceComponentPopoverOpen] = useState<Record<number, boolean>>({});
const [sourceComponentSearch, setSourceComponentSearch] = useState<Record<number, string>>({});
// 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setTablesLoading(true);
try {
const response = await apiClient.get("/table-management/tables");
if (response.data?.success && response.data?.data) {
setTables(
response.data.data.map((t: any) => ({
name: t.tableName,
label: t.displayName || t.tableName,
}))
);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setTablesLoading(false);
}
};
loadTables();
}, []);
// 대상 테이블 선택 시 컬럼 로드
useEffect(() => {
const loadTargetColumns = async () => {
if (!config.targetTable) {
setTargetColumns([]);
return;
}
setTargetColumnsLoading(true);
try {
const response = await apiClient.get(`/table-management/tables/${config.targetTable}/columns`);
if (response.data?.success && response.data?.data) {
// columns가 배열인지 확인 (data.columns 또는 data 직접)
const columns = response.data.data.columns || response.data.data;
setTargetColumns(
(Array.isArray(columns) ? columns : []).map((col: any) => ({
name: col.columnName || col.column_name,
label: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
}))
);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
setTargetColumns([]);
} finally {
setTargetColumnsLoading(false);
}
};
loadTargetColumns();
}, [config.targetTable]);
// 설정 업데이트 헬퍼
const updateConfig = useCallback(
(updates: Partial<QuickInsertConfig>) => {
const newConfig = { ...config, ...updates };
onUpdateProperty("componentConfig.action.quickInsertConfig", newConfig);
},
[config, onUpdateProperty]
);
// 컬럼 매핑 추가
const addMapping = () => {
const newMapping: QuickInsertColumnMapping = {
targetColumn: "",
sourceType: "component",
sourceComponentId: "",
};
updateConfig({
columnMappings: [...(config.columnMappings || []), newMapping],
});
};
// 컬럼 매핑 삭제
const removeMapping = (index: number) => {
const newMappings = [...(config.columnMappings || [])];
newMappings.splice(index, 1);
updateConfig({ columnMappings: newMappings });
};
// 컬럼 매핑 업데이트
const updateMapping = (index: number, updates: Partial<QuickInsertColumnMapping>) => {
const newMappings = [...(config.columnMappings || [])];
newMappings[index] = { ...newMappings[index], ...updates };
updateConfig({ columnMappings: newMappings });
};
// 필터링된 테이블 목록
const filteredTables = tables.filter(
(t) =>
t.name.toLowerCase().includes(tableSearch.toLowerCase()) ||
t.label.toLowerCase().includes(tableSearch.toLowerCase())
);
// 컴포넌트 목록 (entity 타입 우선)
const availableComponents = allComponents.filter((comp: any) => {
// entity 타입 또는 select 타입 컴포넌트 필터링
const widgetType = comp.widgetType || comp.componentType || "";
return widgetType === "entity" || widgetType === "select" || widgetType === "text";
});
return (
<div className="mt-4 space-y-4 rounded-lg border bg-green-50 p-4 dark:bg-green-950/20">
<h4 className="text-sm font-medium text-foreground"> </h4>
<p className="text-xs text-muted-foreground">
.
</p>
{/* 대상 테이블 선택 */}
<div>
<Label> *</Label>
<Popover open={tablePopoverOpen} onOpenChange={setTablePopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tablePopoverOpen}
className="h-8 w-full justify-between text-xs"
disabled={tablesLoading}
>
{config.targetTable
? tables.find((t) => t.name === config.targetTable)?.label || config.targetTable
: "테이블을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command>
<CommandInput
placeholder="테이블 검색..."
value={tableSearch}
onValueChange={setTableSearch}
className="text-xs"
/>
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{filteredTables.map((table) => (
<CommandItem
key={table.name}
value={`${table.label} ${table.name}`}
onSelect={() => {
updateConfig({ targetTable: table.name, columnMappings: [] });
setTablePopoverOpen(false);
setTableSearch("");
}}
className="text-xs"
>
<Check
className={cn("mr-2 h-4 w-4", config.targetTable === table.name ? "opacity-100" : "opacity-0")}
/>
<div className="flex flex-col">
<span className="font-medium">{table.label}</span>
<span className="text-[10px] text-muted-foreground">{table.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 컬럼 매핑 */}
{config.targetTable && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label> </Label>
<Button type="button" variant="outline" size="sm" onClick={addMapping} className="h-6 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(config.columnMappings || []).length === 0 ? (
<div className="rounded border-2 border-dashed py-4 text-center text-xs text-muted-foreground">
</div>
) : (
<div className="space-y-2">
{(config.columnMappings || []).map((mapping, index) => (
<Card key={index} className="p-3">
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium"> #{index + 1}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeMapping(index)}
className="h-5 w-5 p-0 text-destructive hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
</div>
{/* 대상 컬럼 */}
<div>
<Label className="text-xs"> ( )</Label>
<Popover
open={targetColumnPopoverOpen[index] || false}
onOpenChange={(open) => setTargetColumnPopoverOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
disabled={targetColumnsLoading}
>
{mapping.targetColumn
? targetColumns.find((c) => c.name === mapping.targetColumn)?.label || mapping.targetColumn
: "컬럼 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command>
<CommandInput
placeholder="컬럼 검색..."
value={targetColumnSearch[index] || ""}
onValueChange={(v) => setTargetColumnSearch((prev) => ({ ...prev, [index]: v }))}
className="text-xs"
/>
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{targetColumns
.filter(
(c) =>
c.name.toLowerCase().includes((targetColumnSearch[index] || "").toLowerCase()) ||
c.label.toLowerCase().includes((targetColumnSearch[index] || "").toLowerCase())
)
.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
updateMapping(index, { targetColumn: col.name });
setTargetColumnPopoverOpen((prev) => ({ ...prev, [index]: false }));
setTargetColumnSearch((prev) => ({ ...prev, [index]: "" }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mapping.targetColumn === col.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{col.label}</span>
<span className="text-[10px] text-muted-foreground">{col.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 값 소스 타입 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={mapping.sourceType}
onValueChange={(value: "component" | "leftPanel" | "fixed" | "currentUser") => {
updateMapping(index, {
sourceType: value,
sourceComponentId: undefined,
sourceColumn: undefined,
fixedValue: undefined,
userField: undefined,
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="component" className="text-xs">
</SelectItem>
<SelectItem value="leftPanel" className="text-xs">
</SelectItem>
<SelectItem value="fixed" className="text-xs">
</SelectItem>
<SelectItem value="currentUser" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 소스 타입별 추가 설정 */}
{mapping.sourceType === "component" && (
<div>
<Label className="text-xs"> </Label>
<Popover
open={sourceComponentPopoverOpen[index] || false}
onOpenChange={(open) => setSourceComponentPopoverOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
{mapping.sourceComponentId
? (() => {
const comp = allComponents.find((c: any) => c.id === mapping.sourceComponentId);
return comp?.label || comp?.columnName || mapping.sourceComponentId;
})()
: "컴포넌트 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command>
<CommandInput
placeholder="컴포넌트 검색..."
value={sourceComponentSearch[index] || ""}
onValueChange={(v) => setSourceComponentSearch((prev) => ({ ...prev, [index]: v }))}
className="text-xs"
/>
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{availableComponents
.filter((comp: any) => {
const search = (sourceComponentSearch[index] || "").toLowerCase();
const label = (comp.label || "").toLowerCase();
const colName = (comp.columnName || "").toLowerCase();
return label.includes(search) || colName.includes(search);
})
.map((comp: any) => (
<CommandItem
key={comp.id}
value={comp.id}
onSelect={() => {
// sourceComponentId와 함께 sourceColumnName도 저장 (formData 접근용)
updateMapping(index, {
sourceComponentId: comp.id,
sourceColumnName: comp.columnName || undefined,
});
setSourceComponentPopoverOpen((prev) => ({ ...prev, [index]: false }));
setSourceComponentSearch((prev) => ({ ...prev, [index]: "" }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mapping.sourceComponentId === comp.id ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{comp.label || comp.columnName || comp.id}</span>
<span className="text-[10px] text-muted-foreground">
{comp.widgetType || comp.componentType}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{mapping.sourceType === "leftPanel" && (
<div>
<Label className="text-xs"> </Label>
<Input
placeholder="예: process_code"
value={mapping.sourceColumn || ""}
onChange={(e) => updateMapping(index, { sourceColumn: e.target.value })}
className="h-7 text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
</p>
</div>
)}
{mapping.sourceType === "fixed" && (
<div>
<Label className="text-xs"></Label>
<Input
placeholder="고정값 입력"
value={mapping.fixedValue || ""}
onChange={(e) => updateMapping(index, { fixedValue: e.target.value })}
className="h-7 text-xs"
/>
</div>
)}
{mapping.sourceType === "currentUser" && (
<div>
<Label className="text-xs"> </Label>
<Select
value={mapping.userField || ""}
onValueChange={(value: "userId" | "userName" | "companyCode" | "deptCode") => {
updateMapping(index, { userField: value });
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="필드 선택..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="userId" className="text-xs">
ID
</SelectItem>
<SelectItem value="userName" className="text-xs">
</SelectItem>
<SelectItem value="companyCode" className="text-xs">
</SelectItem>
<SelectItem value="deptCode" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
</Card>
))}
</div>
)}
</div>
)}
{/* 저장 후 동작 설정 */}
{config.targetTable && (
<div className="space-y-3 rounded border bg-background p-3">
<Label className="text-xs font-medium"> </Label>
<div className="flex items-center justify-between">
<Label className="text-xs font-normal"> </Label>
<Switch
checked={config.afterInsert?.refreshData ?? true}
onCheckedChange={(checked) => {
updateConfig({
afterInsert: { ...config.afterInsert, refreshData: checked },
});
}}
/>
</div>
<p className="text-[10px] text-muted-foreground -mt-2">
,
</p>
<div className="flex items-center justify-between">
<Label className="text-xs font-normal"> </Label>
<Switch
checked={config.afterInsert?.showSuccessMessage ?? true}
onCheckedChange={(checked) => {
updateConfig({
afterInsert: { ...config.afterInsert, showSuccessMessage: checked },
});
}}
/>
</div>
{config.afterInsert?.showSuccessMessage && (
<div>
<Label className="text-xs"> </Label>
<Input
placeholder="저장되었습니다."
value={config.afterInsert?.successMessage || ""}
onChange={(e) => {
updateConfig({
afterInsert: { ...config.afterInsert, successMessage: e.target.value },
});
}}
className="h-7 text-xs"
/>
</div>
)}
</div>
)}
{/* 중복 체크 설정 */}
{config.targetTable && (
<div className="space-y-3 rounded border bg-background p-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Switch
checked={config.duplicateCheck?.enabled ?? false}
onCheckedChange={(checked) => {
updateConfig({
duplicateCheck: { ...config.duplicateCheck, enabled: checked },
});
}}
/>
</div>
{config.duplicateCheck?.enabled && (
<>
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 max-h-40 overflow-y-auto rounded border bg-background p-2">
{targetColumns.length === 0 ? (
<p className="text-[10px] text-muted-foreground"> ...</p>
) : (
<div className="space-y-1">
{targetColumns.map((col) => {
const isChecked = (config.duplicateCheck?.columns || []).includes(col.name);
return (
<div
key={col.name}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 hover:bg-muted"
onClick={() => {
const currentColumns = config.duplicateCheck?.columns || [];
const newColumns = isChecked
? currentColumns.filter((c) => c !== col.name)
: [...currentColumns, col.name];
updateConfig({
duplicateCheck: { ...config.duplicateCheck, columns: newColumns },
});
}}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => {}}
className="h-3 w-3 flex-shrink-0"
/>
<span className="flex-1 text-xs whitespace-nowrap">
{col.label}{col.label !== col.name && ` (${col.name})`}
</span>
</div>
);
})}
</div>
)}
</div>
<p className="mt-1 text-[10px] text-muted-foreground">
</p>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
placeholder="이미 존재하는 데이터입니다."
value={config.duplicateCheck?.errorMessage || ""}
onChange={(e) => {
updateConfig({
duplicateCheck: { ...config.duplicateCheck, errorMessage: e.target.value },
});
}}
className="h-7 text-xs"
/>
</div>
</>
)}
</div>
)}
{/* 사용 안내 */}
<div className="rounded-md bg-green-100 p-3 dark:bg-green-900/30">
<p className="text-xs text-green-900 dark:text-green-100">
<strong> :</strong>
<br />
1.
<br />
2.
<br />
3.
</p>
</div>
</div>
);
};
export default QuickInsertConfigSection;

View File

@ -365,7 +365,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
userId, // 🆕 사용자 ID
userName, // 🆕 사용자 이름
companyCode, // 🆕 회사 코드
mode,
// 🆕 화면 모드 (edit/view)와 컴포넌트 UI 모드 구분
screenMode: mode,
// componentConfig.mode가 있으면 유지 (entity-search-input의 UI 모드)
mode: component.componentConfig?.mode || mode,
isInModal,
readonly: component.readonly,
// 🆕 disabledFields 체크 또는 기존 readonly

View File

@ -964,6 +964,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
componentConfigs,
// 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터)
splitPanelParentData,
// 🆕 분할 패널 컨텍스트 (quickInsert 등에서 좌측 패널 데이터 접근용)
splitPanelContext: splitPanelContext ? {
selectedLeftData: splitPanelContext.selectedLeftData,
refreshRightPanel: splitPanelContext.refreshRightPanel,
} : undefined,
} as ButtonActionContext;
// 확인이 필요한 액션인지 확인

View File

@ -68,6 +68,23 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 필터 상태 (검색 필터 위젯에서 전달받은 필터)
const [filters, setFiltersInternal] = useState<TableFilter[]>([]);
// 새로고침 트리거 (refreshCardDisplay 이벤트 수신 시 증가)
const [refreshKey, setRefreshKey] = useState(0);
// refreshCardDisplay 이벤트 리스너
useEffect(() => {
const handleRefreshCardDisplay = () => {
console.log("📍 [CardDisplay] refreshCardDisplay 이벤트 수신 - 데이터 새로고침");
setRefreshKey((prev) => prev + 1);
};
window.addEventListener("refreshCardDisplay", handleRefreshCardDisplay);
return () => {
window.removeEventListener("refreshCardDisplay", handleRefreshCardDisplay);
};
}, []);
// 필터 상태 변경 래퍼
const setFilters = useCallback((newFilters: TableFilter[]) => {
setFiltersInternal(newFilters);
@ -357,7 +374,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
};
loadTableData();
}, [isDesignMode, tableName, component.componentConfig?.tableName, splitPanelContext?.selectedLeftData, splitPanelPosition]);
}, [isDesignMode, tableName, component.componentConfig?.tableName, splitPanelContext?.selectedLeftData, splitPanelPosition, refreshKey]);
// 컴포넌트 설정 (기본값 보장)
const componentConfig = {

View File

@ -3,17 +3,32 @@
import React, { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, X } from "lucide-react";
import { Search, X, Check, ChevronsUpDown } from "lucide-react";
import { EntitySearchModal } from "./EntitySearchModal";
import { EntitySearchInputProps, EntitySearchResult } from "./types";
import { cn } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { dynamicFormApi } from "@/lib/api/dynamicForm";
export function EntitySearchInputComponent({
tableName,
displayField,
valueField,
searchFields = [displayField],
mode = "combo",
mode: modeProp,
uiMode, // EntityConfigPanel에서 저장되는 값
placeholder = "검색...",
disabled = false,
filterCondition = {},
@ -24,31 +39,99 @@ export function EntitySearchInputComponent({
showAdditionalInfo = false,
additionalFields = [],
className,
}: EntitySearchInputProps) {
style,
// 🆕 추가 props
component,
isInteractive,
onFormDataChange,
}: EntitySearchInputProps & {
uiMode?: string;
component?: any;
isInteractive?: boolean;
onFormDataChange?: (fieldName: string, value: any) => void;
}) {
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
const [modalOpen, setModalOpen] = useState(false);
const [selectOpen, setSelectOpen] = useState(false);
const [displayValue, setDisplayValue] = useState("");
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
const [options, setOptions] = useState<EntitySearchResult[]>([]);
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
const [optionsLoaded, setOptionsLoaded] = useState(false);
// filterCondition을 문자열로 변환하여 비교 (객체 참조 문제 해결)
const filterConditionKey = JSON.stringify(filterCondition || {});
// select 모드일 때 옵션 로드 (한 번만)
useEffect(() => {
if (mode === "select" && tableName && !optionsLoaded) {
loadOptions();
setOptionsLoaded(true);
}
}, [mode, tableName, filterConditionKey, optionsLoaded]);
const loadOptions = async () => {
if (!tableName) return;
setIsLoadingOptions(true);
try {
const response = await dynamicFormApi.getTableData(tableName, {
page: 1,
pageSize: 100, // 최대 100개까지 로드
filters: filterCondition,
});
if (response.success && response.data) {
setOptions(response.data);
}
} catch (error) {
console.error("옵션 로드 실패:", error);
} finally {
setIsLoadingOptions(false);
}
};
// value가 변경되면 표시값 업데이트
useEffect(() => {
if (value && selectedData) {
setDisplayValue(selectedData[displayField] || "");
} else {
} else if (value && mode === "select" && options.length > 0) {
// select 모드에서 value가 있고 options가 로드된 경우
const found = options.find(opt => opt[valueField] === value);
if (found) {
setSelectedData(found);
setDisplayValue(found[displayField] || "");
}
} else if (!value) {
setDisplayValue("");
setSelectedData(null);
}
}, [value, displayField]);
}, [value, displayField, options, mode, valueField]);
const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
setSelectedData(fullData);
setDisplayValue(fullData[displayField] || "");
onChange?.(newValue, fullData);
// 🆕 onFormDataChange 호출 (formData에 값 저장)
if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, newValue);
console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue);
}
};
const handleClear = () => {
setDisplayValue("");
setSelectedData(null);
onChange?.(null, null);
// 🆕 onFormDataChange 호출 (formData에서 값 제거)
if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, null);
console.log("📤 EntitySearchInput -> onFormDataChange (clear):", component.columnName, null);
}
};
const handleOpenModal = () => {
@ -57,10 +140,105 @@ export function EntitySearchInputComponent({
}
};
const handleSelectOption = (option: EntitySearchResult) => {
handleSelect(option[valueField], option);
setSelectOpen(false);
};
// 높이 계산 (style에서 height가 있으면 사용, 없으면 기본값)
const componentHeight = style?.height;
const inputStyle: React.CSSProperties = componentHeight
? { height: componentHeight }
: {};
// select 모드: 검색 가능한 드롭다운
if (mode === "select") {
return (
<div className={cn("flex flex-col", className)} style={style}>
<Popover open={selectOpen} onOpenChange={setSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={selectOpen}
disabled={disabled || isLoadingOptions}
className={cn(
"w-full justify-between font-normal",
!componentHeight && "h-8 text-xs sm:h-10 sm:text-sm",
!value && "text-muted-foreground"
)}
style={inputStyle}
>
{isLoadingOptions
? "로딩 중..."
: displayValue || placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput
placeholder={`${displayField} 검색...`}
className="text-xs sm:text-sm"
/>
<CommandList>
<CommandEmpty className="text-xs sm:text-sm py-4 text-center">
.
</CommandEmpty>
<CommandGroup>
{options.map((option, index) => (
<CommandItem
key={option[valueField] || index}
value={`${option[displayField] || ""}-${option[valueField] || ""}`}
onSelect={() => handleSelectOption(option)}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option[valueField] ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{option[displayField]}</span>
{valueField !== displayField && (
<span className="text-[10px] text-muted-foreground">
{option[valueField]}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 추가 정보 표시 */}
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
<div className="text-xs text-muted-foreground space-y-1 px-2 mt-1">
{additionalFields.map((field) => (
<div key={field} className="flex gap-2">
<span className="font-medium">{field}:</span>
<span>{selectedData[field] || "-"}</span>
</div>
))}
</div>
)}
</div>
);
}
// modal, combo, autocomplete 모드
return (
<div className={cn("space-y-2", className)}>
<div className={cn("flex flex-col", className)} style={style}>
{/* 입력 필드 */}
<div className="flex gap-2">
<div className="flex gap-2 h-full">
<div className="relative flex-1">
<Input
value={displayValue}
@ -68,7 +246,8 @@ export function EntitySearchInputComponent({
placeholder={placeholder}
disabled={disabled}
readOnly={mode === "modal" || mode === "combo"}
className="h-8 text-xs sm:h-10 sm:text-sm pr-8"
className={cn("w-full pr-8", !componentHeight && "h-8 text-xs sm:h-10 sm:text-sm")}
style={inputStyle}
/>
{displayValue && !disabled && (
<Button
@ -83,12 +262,14 @@ export function EntitySearchInputComponent({
)}
</div>
{/* 모달 버튼: modal 또는 combo 모드일 때만 표시 */}
{(mode === "modal" || mode === "combo") && (
<Button
type="button"
onClick={handleOpenModal}
disabled={disabled}
className="h-8 text-xs sm:h-10 sm:text-sm"
className={cn(!componentHeight && "h-8 text-xs sm:h-10 sm:text-sm")}
style={inputStyle}
>
<Search className="h-4 w-4" />
</Button>
@ -97,7 +278,7 @@ export function EntitySearchInputComponent({
{/* 추가 정보 표시 */}
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
<div className="text-xs text-muted-foreground space-y-1 px-2">
<div className="text-xs text-muted-foreground space-y-1 px-2 mt-1">
{additionalFields.map((field) => (
<div key={field} className="flex gap-2">
<span className="font-medium">{field}:</span>
@ -107,19 +288,21 @@ export function EntitySearchInputComponent({
</div>
)}
{/* 검색 모달 */}
<EntitySearchModal
open={modalOpen}
onOpenChange={setModalOpen}
tableName={tableName}
displayField={displayField}
valueField={valueField}
searchFields={searchFields}
filterCondition={filterCondition}
modalTitle={modalTitle}
modalColumns={modalColumns}
onSelect={handleSelect}
/>
{/* 검색 모달: modal 또는 combo 모드일 때만 렌더링 */}
{(mode === "modal" || mode === "combo") && (
<EntitySearchModal
open={modalOpen}
onOpenChange={setModalOpen}
tableName={tableName}
displayField={displayField}
valueField={valueField}
searchFields={searchFields}
filterCondition={filterCondition}
modalTitle={modalTitle}
modalColumns={modalColumns}
onSelect={handleSelect}
/>
)}
</div>
);
}

View File

@ -302,7 +302,7 @@ export function EntitySearchInputConfigPanel({
<Label className="text-xs sm:text-sm">UI </Label>
<Select
value={localConfig.mode || "combo"}
onValueChange={(value: "autocomplete" | "modal" | "combo") =>
onValueChange={(value: "select" | "autocomplete" | "modal" | "combo") =>
updateConfig({ mode: value })
}
>
@ -310,11 +310,18 @@ export function EntitySearchInputConfigPanel({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="select"> ( )</SelectItem>
<SelectItem value="combo"> ( + )</SelectItem>
<SelectItem value="modal"></SelectItem>
<SelectItem value="autocomplete"></SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
{localConfig.mode === "select" && "검색 가능한 드롭다운 형태로 표시됩니다."}
{localConfig.mode === "modal" && "모달 팝업에서 데이터를 검색하고 선택합니다."}
{(localConfig.mode === "combo" || !localConfig.mode) && "입력 필드와 검색 버튼이 함께 표시됩니다."}
{localConfig.mode === "autocomplete" && "입력하면서 자동완성 목록이 표시됩니다."}
</p>
</div>
<div className="space-y-2">

View File

@ -4,7 +4,7 @@ export interface EntitySearchInputConfig {
valueField: string;
searchFields?: string[];
filterCondition?: Record<string, any>;
mode?: "autocomplete" | "modal" | "combo";
mode?: "select" | "autocomplete" | "modal" | "combo";
placeholder?: string;
modalTitle?: string;
modalColumns?: string[];

View File

@ -11,7 +11,11 @@ export interface EntitySearchInputProps {
searchFields?: string[]; // 검색 대상 필드들 (기본: [displayField])
// UI 모드
mode?: "autocomplete" | "modal" | "combo"; // 기본: "combo"
// - select: 드롭다운 선택 (검색 가능한 콤보박스)
// - modal: 모달 팝업에서 선택
// - combo: 입력 + 모달 버튼 (기본)
// - autocomplete: 입력하면서 자동완성
mode?: "select" | "autocomplete" | "modal" | "combo"; // 기본: "combo"
placeholder?: string;
disabled?: boolean;
@ -33,6 +37,7 @@ export interface EntitySearchInputProps {
// 스타일
className?: string;
style?: React.CSSProperties;
}
export interface EntitySearchResult {

View File

@ -28,7 +28,8 @@ export type ButtonActionType =
// | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합
| "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적)
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
| "transferData"; // 데이터 전달 (컴포넌트 간 or 화면 간)
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
| "quickInsert"; // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
/**
*
@ -211,6 +212,31 @@ export interface ButtonActionConfig {
maxSelection?: number; // 최대 선택 개수
};
};
// 즉시 저장 (Quick Insert) 관련
quickInsertConfig?: {
targetTable: string; // 저장할 테이블명
columnMappings: Array<{
targetColumn: string; // 대상 테이블의 컬럼명
sourceType: "component" | "leftPanel" | "fixed" | "currentUser"; // 값 소스 타입
sourceComponentId?: string; // 컴포넌트에서 값을 가져올 경우 컴포넌트 ID
sourceColumnName?: string; // 컴포넌트의 columnName (formData 접근용)
sourceColumn?: string; // 좌측 패널 또는 컴포넌트의 특정 컬럼
fixedValue?: any; // 고정값
userField?: "userId" | "userName" | "companyCode"; // currentUser 타입일 때 사용할 필드
}>;
duplicateCheck?: {
enabled: boolean; // 중복 체크 활성화 여부
columns?: string[]; // 중복 체크할 컬럼들
errorMessage?: string; // 중복 시 에러 메시지
};
afterInsert?: {
refreshData?: boolean; // 저장 후 데이터 새로고침 (테이블리스트, 카드 디스플레이)
clearComponents?: boolean; // 저장 후 컴포넌트 값 초기화
showSuccessMessage?: boolean; // 성공 메시지 표시 여부 (기본: true)
successMessage?: string; // 성공 메시지
};
};
}
/**
@ -265,6 +291,12 @@ export interface ButtonActionContext {
// 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터)
splitPanelParentData?: Record<string, any>;
// 🆕 분할 패널 컨텍스트 (quickInsert 등에서 좌측 패널 데이터 접근용)
splitPanelContext?: {
selectedLeftData?: Record<string, any>;
refreshRightPanel?: () => void;
};
}
/**
@ -365,6 +397,9 @@ export class ButtonActionExecutor {
case "swap_fields":
return await this.handleSwapFields(config, context);
case "quickInsert":
return await this.handleQuickInsert(config, context);
default:
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
return false;
@ -5190,6 +5225,313 @@ export class ButtonActionExecutor {
}
}
/**
* (Quick Insert)
*
*/
private static async handleQuickInsert(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("⚡ Quick Insert 액션 실행:", { config, context });
const quickInsertConfig = config.quickInsertConfig;
if (!quickInsertConfig?.targetTable) {
toast.error("대상 테이블이 설정되지 않았습니다.");
return false;
}
const { formData, splitPanelContext, userId, userName, companyCode } = context;
console.log("⚡ Quick Insert 상세 정보:", {
targetTable: quickInsertConfig.targetTable,
columnMappings: quickInsertConfig.columnMappings,
formData: formData,
formDataKeys: Object.keys(formData || {}),
splitPanelContext: splitPanelContext,
selectedLeftData: splitPanelContext?.selectedLeftData,
allComponents: context.allComponents,
userId,
userName,
companyCode,
});
// 컬럼 매핑에 따라 저장할 데이터 구성
const insertData: Record<string, any> = {};
const columnMappings = quickInsertConfig.columnMappings || [];
for (const mapping of columnMappings) {
console.log(`📍 매핑 처리 시작:`, mapping);
if (!mapping.targetColumn) {
console.log(`📍 targetColumn 없음, 스킵`);
continue;
}
let value: any = undefined;
switch (mapping.sourceType) {
case "component":
console.log(`📍 component 타입 처리:`, {
sourceComponentId: mapping.sourceComponentId,
sourceColumnName: mapping.sourceColumnName,
targetColumn: mapping.targetColumn,
});
// 컴포넌트의 현재 값
if (mapping.sourceComponentId) {
// 1. sourceColumnName이 있으면 직접 사용 (가장 확실한 방법)
if (mapping.sourceColumnName) {
value = formData?.[mapping.sourceColumnName];
console.log(`📍 방법1 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`);
}
// 2. 없으면 컴포넌트 ID로 직접 찾기
if (value === undefined) {
value = formData?.[mapping.sourceComponentId];
console.log(`📍 방법2 (sourceComponentId): ${mapping.sourceComponentId} = ${value}`);
}
// 3. 없으면 allComponents에서 컴포넌트를 찾아 columnName으로 시도
if (value === undefined && context.allComponents) {
const comp = context.allComponents.find((c: any) => c.id === mapping.sourceComponentId);
console.log(`📍 방법3 찾은 컴포넌트:`, comp);
if (comp?.columnName) {
value = formData?.[comp.columnName];
console.log(`📍 방법3 (allComponents): ${mapping.sourceComponentId}${comp.columnName} = ${value}`);
}
}
// 4. targetColumn과 같은 이름의 키가 formData에 있으면 사용 (폴백)
if (value === undefined && mapping.targetColumn && formData?.[mapping.targetColumn] !== undefined) {
value = formData[mapping.targetColumn];
console.log(`📍 방법4 (targetColumn 폴백): ${mapping.targetColumn} = ${value}`);
}
// 5. 그래도 없으면 formData의 모든 키를 확인하고 로깅
if (value === undefined) {
console.log("📍 방법5: formData에서 값을 찾지 못함. formData 키들:", Object.keys(formData || {}));
}
// sourceColumn이 지정된 경우 해당 속성 추출
if (mapping.sourceColumn && value && typeof value === "object") {
value = value[mapping.sourceColumn];
console.log(`📍 sourceColumn 추출: ${mapping.sourceColumn} = ${value}`);
}
}
break;
case "leftPanel":
console.log(`📍 leftPanel 타입 처리:`, {
sourceColumn: mapping.sourceColumn,
selectedLeftData: splitPanelContext?.selectedLeftData,
});
// 좌측 패널 선택 데이터
if (mapping.sourceColumn && splitPanelContext?.selectedLeftData) {
value = splitPanelContext.selectedLeftData[mapping.sourceColumn];
console.log(`📍 leftPanel 값: ${mapping.sourceColumn} = ${value}`);
}
break;
case "fixed":
console.log(`📍 fixed 타입 처리: fixedValue = ${mapping.fixedValue}`);
// 고정값
value = mapping.fixedValue;
break;
case "currentUser":
console.log(`📍 currentUser 타입 처리: userField = ${mapping.userField}`);
// 현재 사용자 정보
switch (mapping.userField) {
case "userId":
value = userId;
break;
case "userName":
value = userName;
break;
case "companyCode":
value = companyCode;
break;
}
console.log(`📍 currentUser 값: ${value}`);
break;
default:
console.log(`📍 알 수 없는 sourceType: ${mapping.sourceType}`);
}
console.log(`📍 매핑 결과: targetColumn=${mapping.targetColumn}, value=${value}, type=${typeof value}`);
if (value !== undefined && value !== null && value !== "") {
insertData[mapping.targetColumn] = value;
console.log(`📍 insertData에 추가됨: ${mapping.targetColumn} = ${value}`);
} else {
console.log(`📍 값이 비어있어서 insertData에 추가 안됨`);
}
}
// 🆕 좌측 패널 선택 데이터에서 자동 매핑 (대상 테이블에 존재하는 컬럼만)
if (splitPanelContext?.selectedLeftData) {
const leftData = splitPanelContext.selectedLeftData;
console.log("📍 좌측 패널 자동 매핑 시작:", leftData);
// 대상 테이블의 컬럼 목록 조회
let targetTableColumns: string[] = [];
try {
const columnsResponse = await apiClient.get(
`/table-management/tables/${quickInsertConfig.targetTable}/columns`
);
if (columnsResponse.data?.success && columnsResponse.data?.data) {
const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data;
targetTableColumns = columnsData.map((col: any) => col.columnName || col.column_name || col.name);
console.log("📍 대상 테이블 컬럼 목록:", targetTableColumns);
}
} catch (error) {
console.error("대상 테이블 컬럼 조회 실패:", error);
}
for (const [key, val] of Object.entries(leftData)) {
// 이미 매핑된 컬럼은 스킵
if (insertData[key] !== undefined) {
console.log(`📍 자동 매핑 스킵 (이미 존재): ${key}`);
continue;
}
// 대상 테이블에 해당 컬럼이 없으면 스킵
if (targetTableColumns.length > 0 && !targetTableColumns.includes(key)) {
console.log(`📍 자동 매핑 스킵 (대상 테이블에 없는 컬럼): ${key}`);
continue;
}
// 시스템 컬럼 제외 (id, created_date, updated_date, writer 등)
const systemColumns = ['id', 'created_date', 'updated_date', 'writer', 'writer_name'];
if (systemColumns.includes(key)) {
console.log(`📍 자동 매핑 스킵 (시스템 컬럼): ${key}`);
continue;
}
// _label, _name 으로 끝나는 표시용 컬럼 제외
if (key.endsWith('_label') || key.endsWith('_name')) {
console.log(`📍 자동 매핑 스킵 (표시용 컬럼): ${key}`);
continue;
}
// 값이 있으면 자동 추가
if (val !== undefined && val !== null && val !== '') {
insertData[key] = val;
console.log(`📍 자동 매핑 추가: ${key} = ${val}`);
}
}
}
console.log("⚡ Quick Insert 최종 데이터:", insertData, "키 개수:", Object.keys(insertData).length);
// 필수 데이터 검증
if (Object.keys(insertData).length === 0) {
toast.error("저장할 데이터가 없습니다.");
return false;
}
// 중복 체크
console.log("📍 중복 체크 설정:", {
enabled: quickInsertConfig.duplicateCheck?.enabled,
columns: quickInsertConfig.duplicateCheck?.columns,
});
if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) {
const duplicateCheckData: Record<string, any> = {};
for (const col of quickInsertConfig.duplicateCheck.columns) {
if (insertData[col] !== undefined) {
// 백엔드가 { value, operator } 형식을 기대하므로 변환
duplicateCheckData[col] = { value: insertData[col], operator: "equals" };
}
}
console.log("📍 중복 체크 조건:", duplicateCheckData);
if (Object.keys(duplicateCheckData).length > 0) {
try {
const checkResponse = await apiClient.post(
`/table-management/tables/${quickInsertConfig.targetTable}/data`,
{
page: 1,
pageSize: 1,
search: duplicateCheckData,
}
);
console.log("📍 중복 체크 응답:", checkResponse.data);
// 응답 구조: { success: true, data: { data: [...], total: N } } 또는 { success: true, data: [...] }
const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || [];
console.log("📍 기존 데이터:", existingData, "길이:", Array.isArray(existingData) ? existingData.length : 0);
if (Array.isArray(existingData) && existingData.length > 0) {
toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다.");
return false;
}
} catch (error) {
console.error("중복 체크 오류:", error);
// 중복 체크 실패해도 저장은 시도
}
}
} else {
console.log("📍 중복 체크 비활성화 또는 컬럼 미설정");
}
// 데이터 저장
const response = await apiClient.post(
`/table-management/tables/${quickInsertConfig.targetTable}/add`,
insertData
);
if (response.data?.success) {
console.log("✅ Quick Insert 저장 성공");
// 저장 후 동작 설정 로그
console.log("📍 afterInsert 설정:", quickInsertConfig.afterInsert);
// 🆕 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트 새로고침)
// refreshData가 명시적으로 false가 아니면 기본적으로 새로고침 실행
const shouldRefresh = quickInsertConfig.afterInsert?.refreshData !== false;
console.log("📍 데이터 새로고침 여부:", shouldRefresh);
if (shouldRefresh) {
console.log("📍 데이터 새로고침 이벤트 발송");
// 전역 이벤트로 테이블/카드 컴포넌트들에게 새로고침 알림
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("refreshTable"));
window.dispatchEvent(new CustomEvent("refreshCardDisplay"));
console.log("✅ refreshTable, refreshCardDisplay 이벤트 발송 완료");
}
}
// 컴포넌트 값 초기화
if (quickInsertConfig.afterInsert?.clearComponents && context.onFormDataChange) {
for (const mapping of columnMappings) {
if (mapping.sourceType === "component" && mapping.sourceComponentId) {
// sourceColumnName이 있으면 그것을 사용, 없으면 sourceComponentId 사용
const fieldName = mapping.sourceColumnName || mapping.sourceComponentId;
context.onFormDataChange(fieldName, null);
console.log(`📍 컴포넌트 값 초기화: ${fieldName}`);
}
}
}
if (quickInsertConfig.afterInsert?.showSuccessMessage !== false) {
toast.success(quickInsertConfig.afterInsert?.successMessage || "저장되었습니다.");
}
return true;
} else {
toast.error(response.data?.message || "저장에 실패했습니다.");
return false;
}
} catch (error: any) {
console.error("❌ Quick Insert 오류:", error);
toast.error(error.response?.data?.message || "저장 중 오류가 발생했습니다.");
return false;
}
}
/**
* (: status를 active로 )
* 🆕
@ -5643,4 +5985,9 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
successMessage: "필드 값이 교환되었습니다.",
errorMessage: "필드 값 교환 중 오류가 발생했습니다.",
},
quickInsert: {
type: "quickInsert",
successMessage: "저장되었습니다.",
errorMessage: "저장 중 오류가 발생했습니다.",
},
};

View File

@ -54,7 +54,7 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
// 기타
label: "text-display",
code: "select-basic", // 코드 타입은 선택상자 사용
entity: "select-basic", // 엔티티 타입은 선택상자 사용
entity: "entity-search-input", // 엔티티 타입은 전용 검색 입력 사용
category: "select-basic", // 카테고리 타입은 선택상자 사용
};

View File

@ -363,6 +363,8 @@ export interface EntityTypeConfig {
placeholder?: string;
displayFormat?: "simple" | "detailed" | "custom"; // 표시 형식
separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ')
// UI 모드
uiMode?: "select" | "modal" | "combo" | "autocomplete"; // 기본: "combo"
}
/**
@ -428,6 +430,111 @@ export interface ButtonTypeConfig {
// ButtonActionType과 관련된 설정은 control-management.ts에서 정의
}
// ===== 즉시 저장(quickInsert) 설정 =====
/**
*
*
*/
export interface QuickInsertColumnMapping {
/** 저장할 테이블의 대상 컬럼명 */
targetColumn: string;
/** 값 소스 타입 */
sourceType: "component" | "leftPanel" | "fixed" | "currentUser";
// sourceType별 추가 설정
/** component: 값을 가져올 컴포넌트 ID */
sourceComponentId?: string;
/** component: 컴포넌트의 columnName (formData 접근용) */
sourceColumnName?: string;
/** leftPanel: 좌측 선택 데이터의 컬럼명 */
sourceColumn?: string;
/** fixed: 고정값 */
fixedValue?: any;
/** currentUser: 사용자 정보 필드 */
userField?: "userId" | "userName" | "companyCode" | "deptCode";
}
/**
*
*/
export interface QuickInsertAfterAction {
/** 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트) */
refreshData?: boolean;
/** 초기화할 컴포넌트 ID 목록 */
clearComponents?: string[];
/** 성공 메시지 표시 여부 */
showSuccessMessage?: boolean;
/** 커스텀 성공 메시지 */
successMessage?: string;
}
/**
*
*/
export interface QuickInsertDuplicateCheck {
/** 중복 체크 활성화 */
enabled: boolean;
/** 중복 체크할 컬럼들 */
columns: string[];
/** 중복 시 에러 메시지 */
errorMessage?: string;
}
/**
* (quickInsert)
*
* entity ,
* INSERT하는
*
* @example
* ```typescript
* const config: QuickInsertConfig = {
* targetTable: "process_equipment",
* columnMappings: [
* {
* targetColumn: "equipment_code",
* sourceType: "component",
* sourceComponentId: "equipment-select"
* },
* {
* targetColumn: "process_code",
* sourceType: "leftPanel",
* sourceColumn: "process_code"
* }
* ],
* afterInsert: {
* refreshData: true,
* clearComponents: ["equipment-select"],
* showSuccessMessage: true
* }
* };
* ```
*/
export interface QuickInsertConfig {
/** 저장할 대상 테이블명 */
targetTable: string;
/** 컬럼 매핑 설정 */
columnMappings: QuickInsertColumnMapping[];
/** 저장 후 동작 설정 */
afterInsert?: QuickInsertAfterAction;
/** 중복 체크 설정 (선택사항) */
duplicateCheck?: QuickInsertDuplicateCheck;
}
/**
*
*

View File

@ -71,7 +71,9 @@ export type ButtonActionType =
// 제어관리 전용
| "control"
// 데이터 전달
| "transferData"; // 선택된 데이터를 다른 컴포넌트/화면으로 전달
| "transferData" // 선택된 데이터를 다른 컴포넌트/화면으로 전달
// 즉시 저장
| "quickInsert"; // 선택한 데이터를 특정 테이블에 즉시 INSERT
/**
*
@ -328,6 +330,7 @@ export const isButtonActionType = (value: string): value is ButtonActionType =>
"newWindow",
"control",
"transferData",
"quickInsert",
];
return actionTypes.includes(value as ButtonActionType);
};