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:
kjs 2026-01-08 14:49:45 +09:00
commit 9821afe9cd
14 changed files with 763 additions and 111 deletions

View File

@ -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,
},
});
}
}

View File

@ -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;

View File

@ -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%",
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",
}}
>
<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>,
/>,
);
}

View File

@ -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>

View File

@ -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) => {
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);
// 🆕 onFormDataChange 호출 (formData에 값 저장)
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);
if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, joinedValue || null);
console.log("📤 EntitySearchInput (remove) -> onFormDataChange:", component.columnName, joinedValue);
}
};
const handleClear = () => {
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);
// 다중선택이 아닌 경우에만 드롭다운 닫기
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}>
{/* 라벨 렌더링 */}

View File

@ -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

View File

@ -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";

View File

@ -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);
// 다중선택이 아닌 경우에만 모달 닫기
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,39 +156,56 @@ export function EntitySearchModal({
{col}
</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
key={uniqueKey}
className="border-t hover:bg-accent cursor-pointer transition-colors"
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>
))}
{!multiple && (
<td className="px-4 py-2">
<Button
size="sm"
@ -181,6 +219,7 @@ export function EntitySearchModal({
</Button>
</td>
)}
</tr>
);
})
@ -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>

View File

@ -11,6 +11,9 @@ export interface EntitySearchInputConfig {
showAdditionalInfo?: boolean;
additionalFields?: string[];
// 다중 선택 설정
multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false)
// 연쇄관계 설정 (cascading_relation 테이블과 연동)
cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등)
cascadingRole?: "parent" | "child"; // 역할 (부모/자식)

View File

@ -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";

View File

@ -19,6 +19,9 @@ export interface EntitySearchInputProps {
placeholder?: string;
disabled?: boolean;
// 다중선택
multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false)
// 필터링
filterCondition?: Record<string, any>; // 추가 WHERE 조건
companyCode?: string; // 멀티테넌시

View File

@ -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: "",

View File

@ -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?.();

View File

@ -365,6 +365,8 @@ export interface EntityTypeConfig {
separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ')
// UI 모드
uiMode?: "select" | "modal" | "combo" | "autocomplete"; // 기본: "combo"
// 다중 선택
multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false)
}
/**