Merge pull request 'feature/screen-management' (#341) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/341
This commit is contained in:
commit
9821afe9cd
|
|
@ -282,3 +282,175 @@ export async function previewCodeMerge(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 값 기반 코드 병합 - 모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경
|
||||
* 컬럼명에 상관없이 oldValue를 가진 모든 곳을 newValue로 변경
|
||||
*/
|
||||
export async function mergeCodeByValue(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const { oldValue, newValue } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
try {
|
||||
// 입력값 검증
|
||||
if (!oldValue || !newValue) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (oldValue, newValue)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyCode) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 값으로 병합 시도 방지
|
||||
if (oldValue === newValue) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "기존 값과 새 값이 동일합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("값 기반 코드 병합 시작", {
|
||||
oldValue,
|
||||
newValue,
|
||||
companyCode,
|
||||
userId: req.user?.userId,
|
||||
});
|
||||
|
||||
// PostgreSQL 함수 호출
|
||||
const result = await pool.query(
|
||||
"SELECT * FROM merge_code_by_value($1, $2, $3)",
|
||||
[oldValue, newValue, companyCode]
|
||||
);
|
||||
|
||||
// 결과 처리
|
||||
const affectedData = Array.isArray(result) ? result : ((result as any).rows || []);
|
||||
const totalRows = affectedData.reduce(
|
||||
(sum: number, row: any) => sum + parseInt(row.out_rows_updated || 0),
|
||||
0
|
||||
);
|
||||
|
||||
logger.info("값 기반 코드 병합 완료", {
|
||||
oldValue,
|
||||
newValue,
|
||||
affectedTablesCount: affectedData.length,
|
||||
totalRowsUpdated: totalRows,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `코드 병합 완료: ${oldValue} → ${newValue}`,
|
||||
data: {
|
||||
oldValue,
|
||||
newValue,
|
||||
affectedData: affectedData.map((row: any) => ({
|
||||
tableName: row.out_table_name,
|
||||
columnName: row.out_column_name,
|
||||
rowsUpdated: parseInt(row.out_rows_updated),
|
||||
})),
|
||||
totalRowsUpdated: totalRows,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("값 기반 코드 병합 실패:", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
oldValue,
|
||||
newValue,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "코드 병합 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "CODE_MERGE_BY_VALUE_ERROR",
|
||||
details: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 값 기반 코드 병합 미리보기
|
||||
* 컬럼명에 상관없이 해당 값을 가진 모든 테이블/컬럼 조회
|
||||
*/
|
||||
export async function previewMergeCodeByValue(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
const { oldValue } = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
try {
|
||||
if (!oldValue) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (oldValue)",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyCode) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: "인증 정보가 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("값 기반 코드 병합 미리보기", { oldValue, companyCode });
|
||||
|
||||
// PostgreSQL 함수 호출
|
||||
const result = await pool.query(
|
||||
"SELECT * FROM preview_merge_code_by_value($1, $2)",
|
||||
[oldValue, companyCode]
|
||||
);
|
||||
|
||||
const preview = Array.isArray(result) ? result : ((result as any).rows || []);
|
||||
const totalRows = preview.reduce(
|
||||
(sum: number, row: any) => sum + parseInt(row.out_affected_rows || 0),
|
||||
0
|
||||
);
|
||||
|
||||
logger.info("값 기반 코드 병합 미리보기 완료", {
|
||||
tablesCount: preview.length,
|
||||
totalRows,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "코드 병합 미리보기 완료",
|
||||
data: {
|
||||
oldValue,
|
||||
preview: preview.map((row: any) => ({
|
||||
tableName: row.out_table_name,
|
||||
columnName: row.out_column_name,
|
||||
affectedRows: parseInt(row.out_affected_rows),
|
||||
})),
|
||||
totalAffectedRows: totalRows,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("값 기반 코드 병합 미리보기 실패:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "코드 병합 미리보기 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "PREVIEW_BY_VALUE_ERROR",
|
||||
details: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import {
|
|||
mergeCodeAllTables,
|
||||
getTablesWithColumn,
|
||||
previewCodeMerge,
|
||||
mergeCodeByValue,
|
||||
previewMergeCodeByValue,
|
||||
} from "../controllers/codeMergeController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
|
|
@ -13,7 +15,7 @@ router.use(authenticateToken);
|
|||
|
||||
/**
|
||||
* POST /api/code-merge/merge-all-tables
|
||||
* 코드 병합 실행 (모든 관련 테이블에 적용)
|
||||
* 코드 병합 실행 (모든 관련 테이블에 적용 - 같은 컬럼명만)
|
||||
* Body: { columnName, oldValue, newValue }
|
||||
*/
|
||||
router.post("/merge-all-tables", mergeCodeAllTables);
|
||||
|
|
@ -26,10 +28,24 @@ router.get("/tables-with-column/:columnName", getTablesWithColumn);
|
|||
|
||||
/**
|
||||
* POST /api/code-merge/preview
|
||||
* 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인)
|
||||
* 코드 병합 미리보기 (같은 컬럼명 기준)
|
||||
* Body: { columnName, oldValue }
|
||||
*/
|
||||
router.post("/preview", previewCodeMerge);
|
||||
|
||||
/**
|
||||
* POST /api/code-merge/merge-by-value
|
||||
* 값 기반 코드 병합 (모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경)
|
||||
* Body: { oldValue, newValue }
|
||||
*/
|
||||
router.post("/merge-by-value", mergeCodeByValue);
|
||||
|
||||
/**
|
||||
* POST /api/code-merge/preview-by-value
|
||||
* 값 기반 코드 병합 미리보기 (컬럼명 상관없이 값으로 검색)
|
||||
* Body: { oldValue }
|
||||
*/
|
||||
router.post("/preview-by-value", previewMergeCodeByValue);
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
|
|||
|
|
@ -1369,58 +1369,25 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
}
|
||||
|
||||
case "entity": {
|
||||
// DynamicWebTypeRenderer로 위임하여 EntitySearchInputWrapper 사용
|
||||
const widget = comp as WidgetComponent;
|
||||
const config = widget.webTypeConfig as EntityTypeConfig | undefined;
|
||||
|
||||
console.log("🏢 InteractiveScreenViewer - Entity 위젯:", {
|
||||
componentId: widget.id,
|
||||
widgetType: widget.widgetType,
|
||||
config,
|
||||
appliedSettings: {
|
||||
entityName: config?.entityName,
|
||||
displayField: config?.displayField,
|
||||
valueField: config?.valueField,
|
||||
multiple: config?.multiple,
|
||||
defaultValue: config?.defaultValue,
|
||||
},
|
||||
});
|
||||
|
||||
const finalPlaceholder = config?.placeholder || "엔티티를 선택하세요...";
|
||||
const defaultOptions = [
|
||||
{ label: "사용자", value: "user" },
|
||||
{ label: "제품", value: "product" },
|
||||
{ label: "주문", value: "order" },
|
||||
{ label: "카테고리", value: "category" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={currentValue || config?.defaultValue || ""}
|
||||
onValueChange={(value) => updateFormData(fieldName, value)}
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
style={{ height: "100%" }}
|
||||
style={{
|
||||
...comp.style,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<SelectValue placeholder={finalPlaceholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaultOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{config?.displayFormat
|
||||
? config.displayFormat.replace("{label}", option.label).replace("{value}", option.value)
|
||||
: option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>,
|
||||
return applyStyles(
|
||||
<DynamicWebTypeRenderer
|
||||
webType="entity"
|
||||
config={widget.webTypeConfig}
|
||||
props={{
|
||||
component: widget,
|
||||
value: currentValue,
|
||||
onChange: (value: any) => updateFormData(fieldName, value),
|
||||
onFormDataChange: updateFormData,
|
||||
formData: formData,
|
||||
readonly: readonly,
|
||||
required: required,
|
||||
placeholder: widget.placeholder || "엔티티를 선택하세요",
|
||||
isInteractive: true,
|
||||
className: "w-full h-full",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Search, Database, Link, X, Plus } from "lucide-react";
|
||||
import { EntityTypeConfig } from "@/types/screen";
|
||||
|
|
@ -26,6 +27,8 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
|||
placeholder: "",
|
||||
displayFormat: "simple",
|
||||
separator: " - ",
|
||||
multiple: false, // 다중 선택
|
||||
uiMode: "select", // UI 모드: select, combo, modal, autocomplete
|
||||
...config,
|
||||
};
|
||||
|
||||
|
|
@ -38,6 +41,8 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
|||
placeholder: safeConfig.placeholder,
|
||||
displayFormat: safeConfig.displayFormat,
|
||||
separator: safeConfig.separator,
|
||||
multiple: safeConfig.multiple,
|
||||
uiMode: safeConfig.uiMode,
|
||||
});
|
||||
|
||||
const [newFilter, setNewFilter] = useState({ field: "", operator: "=", value: "" });
|
||||
|
|
@ -74,6 +79,8 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
|||
placeholder: safeConfig.placeholder,
|
||||
displayFormat: safeConfig.displayFormat,
|
||||
separator: safeConfig.separator,
|
||||
multiple: safeConfig.multiple,
|
||||
uiMode: safeConfig.uiMode,
|
||||
});
|
||||
}, [
|
||||
safeConfig.referenceTable,
|
||||
|
|
@ -83,8 +90,18 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
|||
safeConfig.placeholder,
|
||||
safeConfig.displayFormat,
|
||||
safeConfig.separator,
|
||||
safeConfig.multiple,
|
||||
safeConfig.uiMode,
|
||||
]);
|
||||
|
||||
// UI 모드 옵션
|
||||
const uiModes = [
|
||||
{ value: "select", label: "드롭다운 선택" },
|
||||
{ value: "combo", label: "입력 + 모달 버튼" },
|
||||
{ value: "modal", label: "모달 팝업" },
|
||||
{ value: "autocomplete", label: "자동완성" },
|
||||
];
|
||||
|
||||
const updateConfig = (key: keyof EntityTypeConfig, value: any) => {
|
||||
// 로컬 상태 즉시 업데이트
|
||||
setLocalValues((prev) => ({ ...prev, [key]: value }));
|
||||
|
|
@ -260,6 +277,46 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* UI 모드 */}
|
||||
<div>
|
||||
<Label htmlFor="uiMode" className="text-sm font-medium">
|
||||
UI 모드
|
||||
</Label>
|
||||
<Select value={localValues.uiMode || "select"} onValueChange={(value) => updateConfig("uiMode", value)}>
|
||||
<SelectTrigger className="mt-1 h-8 w-full text-xs">
|
||||
<SelectValue placeholder="모드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uiModes.map((mode) => (
|
||||
<SelectItem key={mode.value} value={mode.value}>
|
||||
{mode.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{localValues.uiMode === "select" && "검색 가능한 드롭다운 형태로 표시됩니다."}
|
||||
{localValues.uiMode === "combo" && "입력 필드와 검색 버튼이 함께 표시됩니다."}
|
||||
{localValues.uiMode === "modal" && "모달 팝업에서 데이터를 검색하고 선택합니다."}
|
||||
{localValues.uiMode === "autocomplete" && "입력하면서 자동완성 목록이 표시됩니다."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 다중 선택 */}
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="multiple" className="text-sm font-medium">
|
||||
다중 선택
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">여러 항목을 선택할 수 있습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="multiple"
|
||||
checked={localValues.multiple || false}
|
||||
onCheckedChange={(checked) => updateConfig("multiple", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 필터 관리 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">데이터 필터</Label>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ export function EntitySearchInputComponent({
|
|||
parentValue: parentValueProp,
|
||||
parentFieldId,
|
||||
formData,
|
||||
// 🆕 추가 props
|
||||
// 다중선택 props
|
||||
multiple: multipleProp,
|
||||
// 추가 props
|
||||
component,
|
||||
isInteractive,
|
||||
onFormDataChange,
|
||||
|
|
@ -49,8 +51,11 @@ export function EntitySearchInputComponent({
|
|||
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
|
||||
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
|
||||
|
||||
// 연쇄관계 설정 추출 (webTypeConfig 또는 component.componentConfig에서)
|
||||
const config = component?.componentConfig || {};
|
||||
// 다중선택 및 연쇄관계 설정 (props > webTypeConfig > componentConfig 순서)
|
||||
const config = component?.componentConfig || component?.webTypeConfig || {};
|
||||
const isMultiple = multipleProp ?? config.multiple ?? false;
|
||||
|
||||
// 연쇄관계 설정 추출
|
||||
const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode;
|
||||
// cascadingParentField: ConfigPanel에서 저장되는 필드명
|
||||
const effectiveParentFieldId = parentFieldId || config.cascadingParentField || config.parentFieldId;
|
||||
|
|
@ -68,11 +73,27 @@ export function EntitySearchInputComponent({
|
|||
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
|
||||
const [optionsLoaded, setOptionsLoaded] = useState(false);
|
||||
|
||||
// 다중선택 상태 (콤마로 구분된 값들)
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
const [selectedDataList, setSelectedDataList] = useState<EntitySearchResult[]>([]);
|
||||
|
||||
// 연쇄관계 상태
|
||||
const [cascadingOptions, setCascadingOptions] = useState<EntitySearchResult[]>([]);
|
||||
const [isCascadingLoading, setIsCascadingLoading] = useState(false);
|
||||
const previousParentValue = useRef<any>(null);
|
||||
|
||||
// 다중선택 초기값 설정
|
||||
useEffect(() => {
|
||||
if (isMultiple && value) {
|
||||
const vals =
|
||||
typeof value === "string" ? value.split(",").filter(Boolean) : Array.isArray(value) ? value : [value];
|
||||
setSelectedValues(vals.map(String));
|
||||
} else if (isMultiple && !value) {
|
||||
setSelectedValues([]);
|
||||
setSelectedDataList([]);
|
||||
}
|
||||
}, [isMultiple, value]);
|
||||
|
||||
// 부모 필드 값 결정 (직접 전달 또는 formData에서 추출) - 자식 역할일 때만 필요
|
||||
const parentValue = isChildRole
|
||||
? (parentValueProp ?? (effectiveParentFieldId && formData ? formData[effectiveParentFieldId] : undefined))
|
||||
|
|
@ -249,23 +270,75 @@ export function EntitySearchInputComponent({
|
|||
}, [value, displayField, effectiveOptions, mode, valueField, tableName, selectedData]);
|
||||
|
||||
const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
|
||||
setSelectedData(fullData);
|
||||
setDisplayValue(fullData[displayField] || "");
|
||||
onChange?.(newValue, fullData);
|
||||
if (isMultiple) {
|
||||
// 다중선택 모드
|
||||
const valueStr = String(newValue);
|
||||
const isAlreadySelected = selectedValues.includes(valueStr);
|
||||
|
||||
let newSelectedValues: string[];
|
||||
let newSelectedDataList: EntitySearchResult[];
|
||||
|
||||
if (isAlreadySelected) {
|
||||
// 이미 선택된 항목이면 제거
|
||||
newSelectedValues = selectedValues.filter((v) => v !== valueStr);
|
||||
newSelectedDataList = selectedDataList.filter((d) => String(d[valueField]) !== valueStr);
|
||||
} else {
|
||||
// 선택되지 않은 항목이면 추가
|
||||
newSelectedValues = [...selectedValues, valueStr];
|
||||
newSelectedDataList = [...selectedDataList, fullData];
|
||||
}
|
||||
|
||||
setSelectedValues(newSelectedValues);
|
||||
setSelectedDataList(newSelectedDataList);
|
||||
|
||||
const joinedValue = newSelectedValues.join(",");
|
||||
onChange?.(joinedValue, newSelectedDataList);
|
||||
|
||||
if (isInteractive && onFormDataChange && component?.columnName) {
|
||||
onFormDataChange(component.columnName, joinedValue);
|
||||
console.log("📤 EntitySearchInput (multiple) -> onFormDataChange:", component.columnName, joinedValue);
|
||||
}
|
||||
} else {
|
||||
// 단일선택 모드
|
||||
setSelectedData(fullData);
|
||||
setDisplayValue(fullData[displayField] || "");
|
||||
onChange?.(newValue, fullData);
|
||||
|
||||
if (isInteractive && onFormDataChange && component?.columnName) {
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 다중선택 모드에서 개별 항목 제거
|
||||
const handleRemoveValue = (valueToRemove: string) => {
|
||||
const newSelectedValues = selectedValues.filter((v) => v !== valueToRemove);
|
||||
const newSelectedDataList = selectedDataList.filter((d) => String(d[valueField]) !== valueToRemove);
|
||||
|
||||
setSelectedValues(newSelectedValues);
|
||||
setSelectedDataList(newSelectedDataList);
|
||||
|
||||
const joinedValue = newSelectedValues.join(",");
|
||||
onChange?.(joinedValue || null, newSelectedDataList);
|
||||
|
||||
// 🆕 onFormDataChange 호출 (formData에 값 저장)
|
||||
if (isInteractive && onFormDataChange && component?.columnName) {
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue);
|
||||
onFormDataChange(component.columnName, joinedValue || null);
|
||||
console.log("📤 EntitySearchInput (remove) -> onFormDataChange:", component.columnName, joinedValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setDisplayValue("");
|
||||
setSelectedData(null);
|
||||
onChange?.(null, null);
|
||||
if (isMultiple) {
|
||||
setSelectedValues([]);
|
||||
setSelectedDataList([]);
|
||||
onChange?.(null, []);
|
||||
} else {
|
||||
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);
|
||||
|
|
@ -280,7 +353,10 @@ export function EntitySearchInputComponent({
|
|||
|
||||
const handleSelectOption = (option: EntitySearchResult) => {
|
||||
handleSelect(option[valueField], option);
|
||||
setSelectOpen(false);
|
||||
// 다중선택이 아닌 경우에만 드롭다운 닫기
|
||||
if (!isMultiple) {
|
||||
setSelectOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 높이 계산 (style에서 height가 있으면 사용, 없으면 기본값)
|
||||
|
|
@ -289,6 +365,111 @@ export function EntitySearchInputComponent({
|
|||
|
||||
// select 모드: 검색 가능한 드롭다운
|
||||
if (mode === "select") {
|
||||
// 다중선택 모드
|
||||
if (isMultiple) {
|
||||
return (
|
||||
<div className={cn("relative flex flex-col", className)} style={style}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component?.label && component?.style?.labelDisplay !== false && (
|
||||
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||
{component.label}
|
||||
{component.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 선택된 항목들 표시 (태그 형식) */}
|
||||
<div
|
||||
className={cn(
|
||||
"box-border flex min-h-[40px] w-full flex-wrap items-center gap-2 rounded-md border bg-white px-3 py-2",
|
||||
!disabled && "hover:border-primary/50",
|
||||
disabled && "cursor-not-allowed bg-gray-100 opacity-60",
|
||||
)}
|
||||
onClick={() => !disabled && !isLoading && setSelectOpen(true)}
|
||||
style={{ cursor: disabled ? "not-allowed" : "pointer" }}
|
||||
>
|
||||
{selectedValues.length > 0 ? (
|
||||
selectedValues.map((val) => {
|
||||
const opt = effectiveOptions.find((o) => String(o[valueField]) === val);
|
||||
const label = opt?.[displayField] || val;
|
||||
return (
|
||||
<span
|
||||
key={val}
|
||||
className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800"
|
||||
>
|
||||
{label}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveValue(val);
|
||||
}}
|
||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{isLoading
|
||||
? "로딩 중..."
|
||||
: shouldApplyCascading && !parentValue
|
||||
? "상위 항목을 먼저 선택하세요"
|
||||
: placeholder}
|
||||
</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
|
||||
</div>
|
||||
|
||||
{/* 옵션 드롭다운 */}
|
||||
{selectOpen && !disabled && (
|
||||
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-white shadow-md">
|
||||
<Command>
|
||||
<CommandInput placeholder={`${displayField} 검색...`} className="text-xs sm:text-sm" />
|
||||
<CommandList className="max-h-60">
|
||||
<CommandEmpty className="py-4 text-center text-xs sm:text-sm">항목을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{effectiveOptions.map((option, index) => {
|
||||
const isSelected = selectedValues.includes(String(option[valueField]));
|
||||
return (
|
||||
<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", isSelected ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{option[displayField]}</span>
|
||||
{valueField !== displayField && (
|
||||
<span className="text-muted-foreground text-[10px]">{option[valueField]}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
{/* 닫기 버튼 */}
|
||||
<div className="border-t p-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setSelectOpen(false)} className="w-full text-xs">
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 외부 클릭 시 닫기 */}
|
||||
{selectOpen && <div className="fixed inset-0 z-40" onClick={() => setSelectOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 단일선택 모드 (기존 로직)
|
||||
return (
|
||||
<div className={cn("relative flex flex-col", className)} style={style}>
|
||||
{/* 라벨 렌더링 */}
|
||||
|
|
@ -366,6 +547,95 @@ export function EntitySearchInputComponent({
|
|||
}
|
||||
|
||||
// modal, combo, autocomplete 모드
|
||||
// 다중선택 모드
|
||||
if (isMultiple) {
|
||||
return (
|
||||
<div className={cn("relative flex flex-col", className)} style={style}>
|
||||
{/* 라벨 렌더링 */}
|
||||
{component?.label && component?.style?.labelDisplay !== false && (
|
||||
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||
{component.label}
|
||||
{component.required && <span className="text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 선택된 항목들 표시 (태그 형식) + 검색 버튼 */}
|
||||
<div className="flex h-full gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"box-border flex min-h-[40px] flex-1 flex-wrap items-center gap-2 rounded-md border bg-white px-3 py-2",
|
||||
!disabled && "hover:border-primary/50",
|
||||
disabled && "cursor-not-allowed bg-gray-100 opacity-60",
|
||||
)}
|
||||
>
|
||||
{selectedValues.length > 0 ? (
|
||||
selectedValues.map((val) => {
|
||||
// selectedDataList에서 먼저 찾고, 없으면 effectiveOptions에서 찾기
|
||||
const dataFromList = selectedDataList.find((d) => String(d[valueField]) === val);
|
||||
const opt = dataFromList || effectiveOptions.find((o) => String(o[valueField]) === val);
|
||||
const label = opt?.[displayField] || val;
|
||||
return (
|
||||
<span
|
||||
key={val}
|
||||
className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800"
|
||||
>
|
||||
{label}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveValue(val);
|
||||
}}
|
||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 모달 버튼: modal 또는 combo 모드일 때만 표시 */}
|
||||
{(mode === "modal" || mode === "combo") && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleOpenModal}
|
||||
disabled={disabled}
|
||||
className={cn(!componentHeight && "h-8 text-xs sm:h-10 sm:text-sm")}
|
||||
style={inputStyle}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 검색 모달: 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}
|
||||
multiple={isMultiple}
|
||||
selectedValues={selectedValues}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 단일선택 모드 (기존 로직)
|
||||
return (
|
||||
<div className={cn("relative flex flex-col", className)} style={style}>
|
||||
{/* 라벨 렌더링 */}
|
||||
|
|
|
|||
|
|
@ -747,6 +747,23 @@ export function EntitySearchInputConfigPanel({
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">다중 선택</Label>
|
||||
<Switch
|
||||
checked={localConfig.multiple || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ multiple: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{localConfig.multiple
|
||||
? "여러 항목을 선택할 수 있습니다. 값은 콤마로 구분됩니다."
|
||||
: "하나의 항목만 선택할 수 있습니다."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">플레이스홀더</Label>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { EntitySearchInputComponent } from "./EntitySearchInputComponent";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
|
||||
/**
|
||||
* EntitySearchInput 래퍼 컴포넌트
|
||||
* WebTypeRegistry에서 사용하기 위한 래퍼로,
|
||||
* 레지스트리 props를 EntitySearchInputComponent에 맞게 변환합니다.
|
||||
*/
|
||||
export const EntitySearchInputWrapper: React.FC<WebTypeComponentProps> = ({
|
||||
component,
|
||||
value,
|
||||
onChange,
|
||||
readonly = false,
|
||||
...props
|
||||
}) => {
|
||||
// component에서 필요한 설정 추출
|
||||
const widget = component as any;
|
||||
const webTypeConfig = widget?.webTypeConfig || {};
|
||||
const componentConfig = widget?.componentConfig || {};
|
||||
|
||||
// 설정 우선순위: webTypeConfig > componentConfig > component 직접 속성
|
||||
const config = { ...componentConfig, ...webTypeConfig };
|
||||
|
||||
// 테이블 타입 관리에서 설정된 참조 테이블 정보 사용
|
||||
const tableName = config.referenceTable || widget?.referenceTable || "";
|
||||
const displayField = config.labelField || config.displayColumn || config.displayField || "name";
|
||||
const valueField = config.valueField || config.referenceColumn || "id";
|
||||
|
||||
// UI 모드: uiMode > mode 순서
|
||||
const uiMode = config.uiMode || config.mode || "select";
|
||||
|
||||
// 다중선택 설정
|
||||
const multiple = config.multiple ?? false;
|
||||
|
||||
// placeholder
|
||||
const placeholder = config.placeholder || widget?.placeholder || "항목을 선택하세요";
|
||||
|
||||
console.log("🏢 EntitySearchInputWrapper 렌더링:", {
|
||||
tableName,
|
||||
displayField,
|
||||
valueField,
|
||||
uiMode,
|
||||
multiple,
|
||||
value,
|
||||
config,
|
||||
});
|
||||
|
||||
// 테이블 정보가 없으면 안내 메시지 표시
|
||||
if (!tableName) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-full w-full items-center rounded-md border border-dashed px-3 py-2 text-sm">
|
||||
테이블 타입 관리에서 참조 테이블을 설정해주세요
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EntitySearchInputComponent
|
||||
tableName={tableName}
|
||||
displayField={displayField}
|
||||
valueField={valueField}
|
||||
uiMode={uiMode}
|
||||
placeholder={placeholder}
|
||||
disabled={readonly}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
multiple={multiple}
|
||||
component={component}
|
||||
isInteractive={props.isInteractive}
|
||||
onFormDataChange={props.onFormDataChange}
|
||||
formData={props.formData}
|
||||
className="h-full w-full"
|
||||
style={widget?.style}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
EntitySearchInputWrapper.displayName = "EntitySearchInputWrapper";
|
||||
|
||||
|
|
@ -11,7 +11,9 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search, Loader2 } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Search, Loader2, Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEntitySearch } from "./useEntitySearch";
|
||||
import { EntitySearchResult } from "./types";
|
||||
|
||||
|
|
@ -26,6 +28,9 @@ interface EntitySearchModalProps {
|
|||
modalTitle?: string;
|
||||
modalColumns?: string[];
|
||||
onSelect: (value: any, fullData: EntitySearchResult) => void;
|
||||
// 다중선택 관련
|
||||
multiple?: boolean;
|
||||
selectedValues?: string[]; // 이미 선택된 값들
|
||||
}
|
||||
|
||||
export function EntitySearchModal({
|
||||
|
|
@ -39,6 +44,8 @@ export function EntitySearchModal({
|
|||
modalTitle = "검색",
|
||||
modalColumns = [],
|
||||
onSelect,
|
||||
multiple = false,
|
||||
selectedValues = [],
|
||||
}: EntitySearchModalProps) {
|
||||
const [localSearchText, setLocalSearchText] = useState("");
|
||||
const {
|
||||
|
|
@ -71,7 +78,15 @@ export function EntitySearchModal({
|
|||
|
||||
const handleSelect = (item: EntitySearchResult) => {
|
||||
onSelect(item[valueField], item);
|
||||
onOpenChange(false);
|
||||
// 다중선택이 아닌 경우에만 모달 닫기
|
||||
if (!multiple) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 항목이 선택되어 있는지 확인
|
||||
const isItemSelected = (item: EntitySearchResult): boolean => {
|
||||
return selectedValues.includes(String(item[valueField]));
|
||||
};
|
||||
|
||||
// 표시할 컬럼 결정
|
||||
|
|
@ -123,10 +138,16 @@ export function EntitySearchModal({
|
|||
|
||||
{/* 검색 결과 테이블 */}
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
|
||||
<table className="w-full text-xs sm:text-sm">
|
||||
<thead className="bg-muted">
|
||||
<thead className="bg-muted sticky top-0">
|
||||
<tr>
|
||||
{/* 다중선택 시 체크박스 컬럼 */}
|
||||
{multiple && (
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
|
||||
선택
|
||||
</th>
|
||||
)}
|
||||
{displayColumns.map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
|
|
@ -135,54 +156,72 @@ export function EntitySearchModal({
|
|||
{col}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-24">
|
||||
선택
|
||||
</th>
|
||||
{!multiple && (
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-24">
|
||||
선택
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && results.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={displayColumns.length + 1} className="px-4 py-8 text-center">
|
||||
<td colSpan={displayColumns.length + (multiple ? 1 : 2)} className="px-4 py-8 text-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
||||
<p className="mt-2 text-muted-foreground">검색 중...</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : results.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={displayColumns.length + 1} className="px-4 py-8 text-center text-muted-foreground">
|
||||
<td colSpan={displayColumns.length + (multiple ? 1 : 2)} className="px-4 py-8 text-center text-muted-foreground">
|
||||
검색 결과가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
results.map((item, index) => {
|
||||
const uniqueKey = item[valueField] !== undefined ? `${item[valueField]}` : `row-${index}`;
|
||||
const isSelected = isItemSelected(item);
|
||||
return (
|
||||
<tr
|
||||
<tr
|
||||
key={uniqueKey}
|
||||
className="border-t hover:bg-accent cursor-pointer transition-colors"
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
className={cn(
|
||||
"border-t cursor-pointer transition-colors",
|
||||
isSelected ? "bg-blue-50 hover:bg-blue-100" : "hover:bg-accent"
|
||||
)}
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
{/* 다중선택 시 체크박스 */}
|
||||
{multiple && (
|
||||
<td className="px-4 py-2">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleSelect(item)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{displayColumns.map((col) => (
|
||||
<td key={`${uniqueKey}-${col}`} className="px-4 py-2">
|
||||
{item[col] || "-"}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-4 py-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(item);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
{item[col] || "-"}
|
||||
</td>
|
||||
))}
|
||||
{!multiple && (
|
||||
<td className="px-4 py-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(item);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
선택
|
||||
</Button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
|
|
@ -211,12 +250,18 @@ export function EntitySearchModal({
|
|||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
{/* 다중선택 시 선택된 항목 수 표시 */}
|
||||
{multiple && selectedValues.length > 0 && (
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{selectedValues.length}개 항목 선택됨
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
{multiple ? "완료" : "취소"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ export interface EntitySearchInputConfig {
|
|||
showAdditionalInfo?: boolean;
|
||||
additionalFields?: string[];
|
||||
|
||||
// 다중 선택 설정
|
||||
multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false)
|
||||
|
||||
// 연쇄관계 설정 (cascading_relation 테이블과 연동)
|
||||
cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등)
|
||||
cascadingRole?: "parent" | "child"; // 역할 (부모/자식)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export type { EntitySearchInputConfig } from "./config";
|
|||
|
||||
// 컴포넌트 내보내기
|
||||
export { EntitySearchInputComponent } from "./EntitySearchInputComponent";
|
||||
export { EntitySearchInputWrapper } from "./EntitySearchInputWrapper";
|
||||
export { EntitySearchInputRenderer } from "./EntitySearchInputRenderer";
|
||||
export { EntitySearchModal } from "./EntitySearchModal";
|
||||
export { useEntitySearch } from "./useEntitySearch";
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ export interface EntitySearchInputProps {
|
|||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
|
||||
// 다중선택
|
||||
multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false)
|
||||
|
||||
// 필터링
|
||||
filterCondition?: Record<string, any>; // 추가 WHERE 조건
|
||||
companyCode?: string; // 멀티테넌시
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { CheckboxWidget } from "@/components/screen/widgets/types/CheckboxWidget
|
|||
import { RadioWidget } from "@/components/screen/widgets/types/RadioWidget";
|
||||
import { FileWidget } from "@/components/screen/widgets/types/FileWidget";
|
||||
import { CodeWidget } from "@/components/screen/widgets/types/CodeWidget";
|
||||
import { EntityWidget } from "@/components/screen/widgets/types/EntityWidget";
|
||||
import { EntitySearchInputWrapper } from "@/lib/registry/components/entity-search-input/EntitySearchInputWrapper";
|
||||
import { ButtonWidget } from "@/components/screen/widgets/types/ButtonWidget";
|
||||
|
||||
// 개별적으로 설정 패널들을 import
|
||||
|
|
@ -352,7 +352,7 @@ export function initializeWebTypeRegistry() {
|
|||
name: "엔티티 선택",
|
||||
category: "input",
|
||||
description: "데이터베이스 엔티티 선택 필드",
|
||||
component: EntityWidget,
|
||||
component: EntitySearchInputWrapper,
|
||||
configPanel: EntityConfigPanel,
|
||||
defaultConfig: {
|
||||
entityType: "",
|
||||
|
|
|
|||
|
|
@ -4934,26 +4934,35 @@ export class ButtonActionExecutor {
|
|||
|
||||
const { oldValue, newValue } = confirmed;
|
||||
|
||||
// 미리보기 표시 (옵션)
|
||||
// 미리보기 표시 (값 기반 검색 - 모든 테이블의 모든 컬럼에서 검색)
|
||||
if (config.mergeShowPreview !== false) {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
|
||||
const previewResponse = await apiClient.post("/code-merge/preview", {
|
||||
columnName,
|
||||
toast.loading("영향받는 데이터 검색 중...", { duration: Infinity });
|
||||
|
||||
const previewResponse = await apiClient.post("/code-merge/preview-by-value", {
|
||||
oldValue,
|
||||
});
|
||||
|
||||
toast.dismiss();
|
||||
|
||||
if (previewResponse.data.success) {
|
||||
const preview = previewResponse.data.data;
|
||||
const totalRows = preview.totalAffectedRows;
|
||||
|
||||
// 상세 정보 생성
|
||||
const detailList = preview.preview
|
||||
.map((p: any) => ` - ${p.tableName}.${p.columnName}: ${p.affectedRows}건`)
|
||||
.join("\n");
|
||||
|
||||
const confirmMerge = confirm(
|
||||
"⚠️ 코드 병합 확인\n\n" +
|
||||
"코드 병합 확인\n\n" +
|
||||
`${oldValue} → ${newValue}\n\n` +
|
||||
"영향받는 데이터:\n" +
|
||||
`- 테이블 수: ${preview.preview.length}개\n` +
|
||||
`- 테이블/컬럼 수: ${preview.preview.length}개\n` +
|
||||
`- 총 행 수: ${totalRows}개\n\n` +
|
||||
`데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` +
|
||||
(preview.preview.length <= 10 ? `상세:\n${detailList}\n\n` : "") +
|
||||
"모든 테이블에서 해당 값이 변경됩니다.\n\n" +
|
||||
"계속하시겠습니까?",
|
||||
);
|
||||
|
||||
|
|
@ -4963,13 +4972,12 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
// 병합 실행
|
||||
// 병합 실행 (값 기반 - 모든 테이블의 모든 컬럼)
|
||||
toast.loading("코드 병합 중...", { duration: Infinity });
|
||||
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
|
||||
const response = await apiClient.post("/code-merge/merge-all-tables", {
|
||||
columnName,
|
||||
const response = await apiClient.post("/code-merge/merge-by-value", {
|
||||
oldValue,
|
||||
newValue,
|
||||
});
|
||||
|
|
@ -4978,10 +4986,18 @@ export class ButtonActionExecutor {
|
|||
|
||||
if (response.data.success) {
|
||||
const data = response.data.data;
|
||||
|
||||
// 변경된 테이블/컬럼 목록 생성
|
||||
const changedList = data.affectedData
|
||||
.map((d: any) => `${d.tableName}.${d.columnName}: ${d.rowsUpdated}건`)
|
||||
.join(", ");
|
||||
|
||||
toast.success(
|
||||
"코드 병합 완료!\n" + `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`,
|
||||
`코드 병합 완료! ${data.affectedData.length}개 테이블/컬럼, ${data.totalRowsUpdated}개 행 업데이트`,
|
||||
);
|
||||
|
||||
console.log("코드 병합 결과:", data.affectedData);
|
||||
|
||||
// 화면 새로고침
|
||||
context.onRefresh?.();
|
||||
context.onFlowRefresh?.();
|
||||
|
|
|
|||
|
|
@ -365,6 +365,8 @@ export interface EntityTypeConfig {
|
|||
separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ')
|
||||
// UI 모드
|
||||
uiMode?: "select" | "modal" | "combo" | "autocomplete"; // 기본: "combo"
|
||||
// 다중 선택
|
||||
multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue