diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts
index 66e20ccd..00727f1d 100644
--- a/backend-node/src/controllers/entityJoinController.ts
+++ b/backend-node/src/controllers/entityJoinController.ts
@@ -29,6 +29,7 @@ export class EntityJoinController {
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
autoFilter, // 🔒 멀티테넌시 자동 필터
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
+ excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
...otherParams
} = req.query;
@@ -125,6 +126,19 @@ export class EntityJoinController {
}
}
+ // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
+ let parsedExcludeFilter: any = undefined;
+ if (excludeFilter) {
+ try {
+ parsedExcludeFilter =
+ typeof excludeFilter === "string" ? JSON.parse(excludeFilter) : excludeFilter;
+ logger.info("제외 필터 파싱 완료:", parsedExcludeFilter);
+ } catch (error) {
+ logger.warn("제외 필터 파싱 오류:", error);
+ parsedExcludeFilter = undefined;
+ }
+ }
+
const result = await tableManagementService.getTableDataWithEntityJoins(
tableName,
{
@@ -141,6 +155,7 @@ export class EntityJoinController {
additionalJoinColumns: parsedAdditionalJoinColumns,
screenEntityConfigs: parsedScreenEntityConfigs,
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
+ excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
}
);
diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts
index 8e01903b..781a9498 100644
--- a/backend-node/src/services/tableManagementService.ts
+++ b/backend-node/src/services/tableManagementService.ts
@@ -2462,6 +2462,14 @@ export class TableManagementService {
}>;
screenEntityConfigs?: Record; // 화면별 엔티티 설정
dataFilter?: any; // 🆕 데이터 필터
+ excludeFilter?: {
+ enabled: boolean;
+ referenceTable: string;
+ referenceColumn: string;
+ sourceColumn: string;
+ filterColumn?: string;
+ filterValue?: any;
+ }; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
}
): Promise {
const startTime = Date.now();
@@ -2716,6 +2724,44 @@ export class TableManagementService {
}
}
+ // 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외)
+ if (options.excludeFilter && options.excludeFilter.enabled) {
+ const {
+ referenceTable,
+ referenceColumn,
+ sourceColumn,
+ filterColumn,
+ filterValue,
+ } = options.excludeFilter;
+
+ if (referenceTable && referenceColumn && sourceColumn) {
+ // 서브쿼리로 이미 존재하는 데이터 제외
+ let excludeSubquery = `main."${sourceColumn}" NOT IN (
+ SELECT "${referenceColumn}" FROM "${referenceTable}"
+ WHERE "${referenceColumn}" IS NOT NULL`;
+
+ // 추가 필터 조건이 있으면 적용 (예: 특정 거래처의 품목만 제외)
+ if (filterColumn && filterValue !== undefined && filterValue !== null) {
+ excludeSubquery += ` AND "${filterColumn}" = '${String(filterValue).replace(/'/g, "''")}'`;
+ }
+
+ excludeSubquery += ")";
+
+ whereClause = whereClause
+ ? `${whereClause} AND ${excludeSubquery}`
+ : excludeSubquery;
+
+ logger.info(`🚫 제외 필터 적용 (Entity 조인):`, {
+ referenceTable,
+ referenceColumn,
+ sourceColumn,
+ filterColumn,
+ filterValue,
+ excludeSubquery,
+ });
+ }
+ }
+
// ORDER BY 절 구성
const orderBy = options.sortBy
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx
index 21b9c749..0cb6635d 100644
--- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx
+++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx
@@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Save, Edit2, Trash2 } from "lucide-react";
import { toast } from "sonner";
-import { NumberingRuleConfig, NumberingRulePart } from "@/types/numbering-rule";
+import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview } from "./NumberingRulePreview";
import {
@@ -47,6 +47,10 @@ export const NumberingRuleDesigner: React.FC = ({
const [rightTitle, setRightTitle] = useState("규칙 편집");
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
const [editingRightTitle, setEditingRightTitle] = useState(false);
+
+ // 구분자 관련 상태
+ const [separatorType, setSeparatorType] = useState("-");
+ const [customSeparator, setCustomSeparator] = useState("");
useEffect(() => {
loadRules();
@@ -87,6 +91,50 @@ export const NumberingRuleDesigner: React.FC = ({
}
}, [currentRule, onChange]);
+ // currentRule이 변경될 때 구분자 상태 동기화
+ useEffect(() => {
+ if (currentRule) {
+ const sep = currentRule.separator ?? "-";
+ // 빈 문자열이면 "none"
+ if (sep === "") {
+ setSeparatorType("none");
+ setCustomSeparator("");
+ return;
+ }
+ // 미리 정의된 구분자인지 확인 (none, custom 제외)
+ const predefinedOption = SEPARATOR_OPTIONS.find(
+ opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
+ );
+ if (predefinedOption) {
+ setSeparatorType(predefinedOption.value);
+ setCustomSeparator("");
+ } else {
+ // 직접 입력된 구분자
+ setSeparatorType("custom");
+ setCustomSeparator(sep);
+ }
+ }
+ }, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시)
+
+ // 구분자 변경 핸들러
+ const handleSeparatorChange = useCallback((type: SeparatorType) => {
+ setSeparatorType(type);
+ if (type !== "custom") {
+ const option = SEPARATOR_OPTIONS.find(opt => opt.value === type);
+ const newSeparator = option?.displayValue ?? "";
+ setCurrentRule((prev) => prev ? { ...prev, separator: newSeparator } : null);
+ setCustomSeparator("");
+ }
+ }, []);
+
+ // 직접 입력 구분자 변경 핸들러
+ const handleCustomSeparatorChange = useCallback((value: string) => {
+ // 최대 2자 제한
+ const trimmedValue = value.slice(0, 2);
+ setCustomSeparator(trimmedValue);
+ setCurrentRule((prev) => prev ? { ...prev, separator: trimmedValue } : null);
+ }, []);
+
const handleAddPart = useCallback(() => {
if (!currentRule) return;
@@ -373,7 +421,44 @@ export const NumberingRuleDesigner: React.FC = ({
- {/* 두 번째 줄: 자동 감지된 테이블 정보 표시 */}
+ {/* 두 번째 줄: 구분자 설정 */}
+
+
+ 구분자
+ handleSeparatorChange(value as SeparatorType)}
+ >
+
+
+
+
+ {SEPARATOR_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ {separatorType === "custom" && (
+
+ 직접 입력
+ handleCustomSeparatorChange(e.target.value)}
+ className="h-9"
+ placeholder="최대 2자"
+ maxLength={2}
+ />
+
+ )}
+
+ 규칙 사이에 들어갈 문자입니다
+
+
+
+ {/* 세 번째 줄: 자동 감지된 테이블 정보 표시 */}
{currentTableName && (
적용 테이블
diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx
index cde9086c..024f7ac7 100644
--- a/frontend/components/screen/EditModal.tsx
+++ b/frontend/components/screen/EditModal.tsx
@@ -304,7 +304,24 @@ export const EditModal: React.FC
= ({ className }) => {
};
// 저장 버튼 클릭 시 - UPDATE 액션 실행
- const handleSave = async () => {
+ const handleSave = async (saveData?: any) => {
+ // universal-form-modal 등에서 자체 저장 완료 후 호출된 경우 스킵
+ if (saveData?._saveCompleted) {
+ console.log("[EditModal] 자체 저장 완료된 컴포넌트에서 호출됨 - 저장 스킵");
+
+ // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
+ if (modalState.onSave) {
+ try {
+ modalState.onSave();
+ } catch (callbackError) {
+ console.error("onSave 콜백 에러:", callbackError);
+ }
+ }
+
+ handleClose();
+ return;
+ }
+
if (!screenData?.screenInfo?.tableName) {
toast.error("테이블 정보가 없습니다.");
return;
diff --git a/frontend/lib/api/entityJoin.ts b/frontend/lib/api/entityJoin.ts
index a84f3355..a3206df9 100644
--- a/frontend/lib/api/entityJoin.ts
+++ b/frontend/lib/api/entityJoin.ts
@@ -69,6 +69,14 @@ export const entityJoinApi = {
}>;
screenEntityConfigs?: Record; // 🎯 화면별 엔티티 설정
dataFilter?: any; // 🆕 데이터 필터
+ excludeFilter?: {
+ enabled: boolean;
+ referenceTable: string;
+ referenceColumn: string;
+ sourceColumn: string;
+ filterColumn?: string;
+ filterValue?: any;
+ }; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
} = {},
): Promise => {
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
@@ -90,6 +98,7 @@ export const entityJoinApi = {
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터
+ excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터
},
});
return response.data.data;
diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx
index c0e0c87e..8609623b 100644
--- a/frontend/lib/registry/DynamicComponentRenderer.tsx
+++ b/frontend/lib/registry/DynamicComponentRenderer.tsx
@@ -170,8 +170,9 @@ export const DynamicComponentRenderer: React.FC =
}
};
- // 🆕 disabledFields 체크
- const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).readonly;
+ // 🆕 disabledFields 체크 + readonly 체크
+ const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).disabled;
+ const isFieldReadonly = (component as any).readonly || (component as any).componentConfig?.readonly;
return (
=
placeholder={component.componentConfig?.placeholder || "선택하세요"}
required={(component as any).required}
disabled={isFieldDisabled}
+ readonly={isFieldReadonly}
className="w-full"
/>
);
diff --git a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx
index 1c5920f0..7a115ea3 100644
--- a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx
+++ b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx
@@ -42,10 +42,26 @@ export function AutocompleteSearchInputComponent({
// config prop 우선, 없으면 개별 prop 사용
const tableName = config?.tableName || propTableName || "";
const displayField = config?.displayField || propDisplayField || "";
+ const displayFields = config?.displayFields || (displayField ? [displayField] : []); // 다중 표시 필드
+ const displaySeparator = config?.displaySeparator || " → "; // 구분자
const valueField = config?.valueField || propValueField || "";
- const searchFields = config?.searchFields || propSearchFields || [displayField];
+ const searchFields = config?.searchFields || propSearchFields || displayFields; // 검색 필드도 다중 표시 필드 사용
const placeholder = config?.placeholder || propPlaceholder || "검색...";
+ // 다중 필드 값을 조합하여 표시 문자열 생성
+ const getDisplayValue = (item: EntitySearchResult): string => {
+ if (displayFields.length > 1) {
+ // 여러 필드를 구분자로 조합
+ const values = displayFields
+ .map((field) => item[field])
+ .filter((v) => v !== null && v !== undefined && v !== "")
+ .map((v) => String(v));
+ return values.join(displaySeparator);
+ }
+ // 단일 필드
+ return item[displayField] || "";
+ };
+
const [inputValue, setInputValue] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [selectedData, setSelectedData] = useState(null);
@@ -115,7 +131,7 @@ export function AutocompleteSearchInputComponent({
const handleSelect = (item: EntitySearchResult) => {
setSelectedData(item);
- setInputValue(item[displayField] || "");
+ setInputValue(getDisplayValue(item));
console.log("🔍 AutocompleteSearchInput handleSelect:", {
item,
@@ -239,7 +255,7 @@ export function AutocompleteSearchInputComponent({
onClick={() => handleSelect(item)}
className="w-full px-3 py-2 text-left text-xs transition-colors hover:bg-accent sm:text-sm"
>
- {item[displayField]}
+ {getDisplayValue(item)}
))}
diff --git a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx
index d2290c2f..bb0b8175 100644
--- a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx
+++ b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx
@@ -184,52 +184,118 @@ export function AutocompleteSearchInputConfigPanel({
- {/* 2. 표시 필드 선택 */}
+ {/* 2. 표시 필드 선택 (다중 선택 가능) */}
-
2. 표시 필드 *
-
-
-
- {localConfig.displayField
- ? sourceTableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
- : isLoadingSourceColumns ? "로딩 중..." : "사용자에게 보여줄 필드"}
-
-
-
-
-
-
-
- 필드를 찾을 수 없습니다.
-
- {sourceTableColumns.map((column) => (
- {
- updateConfig({ displayField: column.columnName });
- setOpenDisplayFieldCombo(false);
+ 2. 표시 필드 * (여러 개 선택 가능)
+
+ {/* 선택된 필드 표시 */}
+ {(localConfig.displayFields && localConfig.displayFields.length > 0) ? (
+
+ {localConfig.displayFields.map((fieldName) => {
+ const col = sourceTableColumns.find((c) => c.columnName === fieldName);
+ return (
+
+ {col?.displayName || fieldName}
+ {
+ const newFields = localConfig.displayFields?.filter((f) => f !== fieldName) || [];
+ updateConfig({
+ displayFields: newFields,
+ displayField: newFields[0] || "", // 첫 번째 필드를 기본 displayField로
+ });
}}
- className="text-xs sm:text-sm"
+ className="hover:text-destructive"
>
-
-
- {column.displayName || column.columnName}
- {column.displayName && {column.columnName} }
-
-
- ))}
-
-
-
-
-
+
+
+
+ );
+ })}
+
+ ) : (
+
+ 아래에서 표시할 필드를 선택하세요
+
+ )}
+
+ {/* 필드 선택 드롭다운 */}
+
+
+
+ {isLoadingSourceColumns ? "로딩 중..." : "필드 추가..."}
+
+
+
+
+
+
+
+ 필드를 찾을 수 없습니다.
+
+ {sourceTableColumns.map((column) => {
+ const isSelected = localConfig.displayFields?.includes(column.columnName);
+ return (
+ {
+ const currentFields = localConfig.displayFields || [];
+ let newFields: string[];
+ if (isSelected) {
+ newFields = currentFields.filter((f) => f !== column.columnName);
+ } else {
+ newFields = [...currentFields, column.columnName];
+ }
+ updateConfig({
+ displayFields: newFields,
+ displayField: newFields[0] || "", // 첫 번째 필드를 기본 displayField로
+ });
+ }}
+ className="text-xs sm:text-sm"
+ >
+
+
+ {column.displayName || column.columnName}
+ {column.displayName && {column.columnName} }
+
+
+ );
+ })}
+
+
+
+
+
+
+ {/* 구분자 설정 */}
+ {localConfig.displayFields && localConfig.displayFields.length > 1 && (
+
+ 구분자:
+ updateConfig({ displaySeparator: e.target.value })}
+ placeholder=" → "
+ className="h-7 w-20 text-xs text-center"
+ />
+
+ 미리보기: {localConfig.displayFields.map((f) => {
+ const col = sourceTableColumns.find((c) => c.columnName === f);
+ return col?.displayName || f;
+ }).join(localConfig.displaySeparator || " → ")}
+
+
+ )}
+
{/* 3. 저장 대상 테이블 선택 */}
@@ -419,7 +485,9 @@ export function AutocompleteSearchInputConfigPanel({
외부 테이블: {localConfig.tableName}
- 표시 필드: {localConfig.displayField}
+ 표시 필드: {localConfig.displayFields?.length
+ ? localConfig.displayFields.join(localConfig.displaySeparator || " → ")
+ : localConfig.displayField}
저장 테이블: {localConfig.targetTable}
diff --git a/frontend/lib/registry/components/autocomplete-search-input/types.ts b/frontend/lib/registry/components/autocomplete-search-input/types.ts
index 85101e89..ea1c3734 100644
--- a/frontend/lib/registry/components/autocomplete-search-input/types.ts
+++ b/frontend/lib/registry/components/autocomplete-search-input/types.ts
@@ -29,5 +29,8 @@ export interface AutocompleteSearchInputConfig {
fieldMappings?: FieldMapping[]; // 매핑할 필드 목록
// 저장 대상 테이블 (간소화 버전)
targetTable?: string;
+ // 🆕 다중 표시 필드 설정 (여러 컬럼 조합)
+ displayFields?: string[]; // 여러 컬럼을 조합하여 표시
+ displaySeparator?: string; // 구분자 (기본값: " - ")
}
diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
index 0bf8bea2..5816940a 100644
--- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
+++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
@@ -663,9 +663,29 @@ export const ButtonPrimaryComponent: React.FC = ({
return;
}
+ // 🆕 modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
+ let effectiveSelectedRowsData = selectedRowsData;
+ if ((!selectedRowsData || selectedRowsData.length === 0) && effectiveTableName) {
+ try {
+ const { useModalDataStore } = await import("@/stores/modalDataStore");
+ const dataRegistry = useModalDataStore.getState().dataRegistry;
+ const modalData = dataRegistry[effectiveTableName];
+ if (modalData && modalData.length > 0) {
+ effectiveSelectedRowsData = modalData;
+ console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", {
+ tableName: effectiveTableName,
+ count: modalData.length,
+ data: modalData,
+ });
+ }
+ } catch (error) {
+ console.warn("modalDataStore 접근 실패:", error);
+ }
+ }
+
// 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단
const hasDataToDelete =
- (selectedRowsData && selectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0);
+ (effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0);
if (processedConfig.action.type === "delete" && !hasDataToDelete) {
toast.warning("삭제할 항목을 먼저 선택해주세요.");
@@ -724,9 +744,9 @@ export const ButtonPrimaryComponent: React.FC = ({
onClose,
onFlowRefresh, // 플로우 새로고침 콜백 추가
onSave: finalOnSave, // 🆕 EditModal의 handleSave 콜백 (props에서도 추출)
- // 테이블 선택된 행 정보 추가
+ // 테이블 선택된 행 정보 추가 (modalDataStore에서 가져온 데이터 우선)
selectedRows,
- selectedRowsData,
+ selectedRowsData: effectiveSelectedRowsData,
// 테이블 정렬 정보 추가
sortBy, // 🆕 정렬 컬럼
sortOrder, // 🆕 정렬 방향
diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts
index 746e2c2d..2a5d45e4 100644
--- a/frontend/lib/registry/components/index.ts
+++ b/frontend/lib/registry/components/index.ts
@@ -74,6 +74,9 @@ import "./location-swap-selector/LocationSwapSelectorRenderer";
// 🆕 화면 임베딩 및 분할 패널 컴포넌트
import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달)
+// 🆕 범용 폼 모달 컴포넌트
+import "./universal-form-modal/UniversalFormModalRenderer"; // 섹션 기반 폼, 채번규칙, 다중 행 저장 지원
+
/**
* 컴포넌트 초기화 함수
*/
diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx
index 6302e7f9..3a5b43dd 100644
--- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx
+++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx
@@ -193,7 +193,18 @@ export function ModalRepeaterTableComponent({
// ✅ value는 formData[columnName] 우선, 없으면 prop 사용
const columnName = component?.columnName;
- const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
+ const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
+
+ // 🆕 내부 상태로 데이터 관리 (즉시 UI 반영을 위해)
+ const [localValue, setLocalValue] = useState(externalValue);
+
+ // 🆕 외부 값(formData, propValue) 변경 시 내부 상태 동기화
+ useEffect(() => {
+ // 외부 값이 변경되었고, 내부 값과 다른 경우에만 동기화
+ if (JSON.stringify(externalValue) !== JSON.stringify(localValue)) {
+ setLocalValue(externalValue);
+ }
+ }, [externalValue]);
// ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출 + 납기일 일괄 적용)
const handleChange = (newData: any[]) => {
@@ -249,6 +260,9 @@ export function ModalRepeaterTableComponent({
}
}
+ // 🆕 내부 상태 즉시 업데이트 (UI 즉시 반영) - 일괄 적용된 데이터로 업데이트
+ setLocalValue(processedData);
+
// 기존 onChange 콜백 호출 (호환성)
const externalOnChange = componentConfig?.onChange || propOnChange;
if (externalOnChange) {
@@ -321,7 +335,7 @@ export function ModalRepeaterTableComponent({
const handleSaveRequest = async (event: Event) => {
const componentKey = columnName || component?.id || "modal_repeater_data";
- if (value.length === 0) {
+ if (localValue.length === 0) {
console.warn("⚠️ [ModalRepeaterTable] 저장할 데이터 없음");
return;
}
@@ -332,7 +346,7 @@ export function ModalRepeaterTableComponent({
.filter(col => col.mapping?.type === "source" && col.mapping?.sourceField)
.map(col => col.field);
- const filteredData = value.map((item: any) => {
+ const filteredData = localValue.map((item: any) => {
const filtered: Record = {};
Object.keys(item).forEach((key) => {
@@ -389,16 +403,16 @@ export function ModalRepeaterTableComponent({
return () => {
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
};
- }, [value, columnName, component?.id, onFormDataChange, targetTable]);
+ }, [localValue, columnName, component?.id, onFormDataChange, targetTable]);
const { calculateRow, calculateAll } = useCalculation(calculationRules);
// 초기 데이터에 계산 필드 적용
useEffect(() => {
- if (value.length > 0 && calculationRules.length > 0) {
- const calculated = calculateAll(value);
+ if (localValue.length > 0 && calculationRules.length > 0) {
+ const calculated = calculateAll(localValue);
// 값이 실제로 변경된 경우만 업데이트
- if (JSON.stringify(calculated) !== JSON.stringify(value)) {
+ if (JSON.stringify(calculated) !== JSON.stringify(localValue)) {
handleChange(calculated);
}
}
@@ -506,7 +520,7 @@ export function ModalRepeaterTableComponent({
const calculatedItems = calculateAll(mappedItems);
// 기존 데이터에 추가
- const newData = [...value, ...calculatedItems];
+ const newData = [...localValue, ...calculatedItems];
console.log("✅ 최종 데이터:", newData.length, "개 항목");
// ✅ 통합 onChange 호출 (formData 반영 포함)
@@ -518,7 +532,7 @@ export function ModalRepeaterTableComponent({
const calculatedRow = calculateRow(newRow);
// 데이터 업데이트
- const newData = [...value];
+ const newData = [...localValue];
newData[index] = calculatedRow;
// ✅ 통합 onChange 호출 (formData 반영 포함)
@@ -526,7 +540,7 @@ export function ModalRepeaterTableComponent({
};
const handleRowDelete = (index: number) => {
- const newData = value.filter((_, i) => i !== index);
+ const newData = localValue.filter((_, i) => i !== index);
// ✅ 통합 onChange 호출 (formData 반영 포함)
handleChange(newData);
@@ -543,7 +557,7 @@ export function ModalRepeaterTableComponent({
{/* 추가 버튼 */}
- {value.length > 0 && `${value.length}개 항목`}
+ {localValue.length > 0 && `${localValue.length}개 항목`}
setModalOpen(true)}
@@ -557,7 +571,7 @@ export function ModalRepeaterTableComponent({
{/* Repeater 테이블 */}
= ({
menuObjid, // 🆕 메뉴 OBJID
...props
}) => {
+ // 🆕 읽기전용/비활성화 상태 확인
+ const isReadonly = (component as any).readonly || (props as any).readonly || componentConfig?.readonly || false;
+ const isDisabled = (component as any).disabled || (props as any).disabled || componentConfig?.disabled || false;
+ const isFieldDisabled = isDesignMode || isReadonly || isDisabled;
// 화면 컨텍스트 (데이터 제공자로 등록)
const screenContext = useScreenContextOptional();
@@ -327,7 +331,7 @@ const SelectBasicComponent: React.FC = ({
// 클릭 이벤트 핸들러 (React Query로 간소화)
const handleToggle = () => {
- if (isDesignMode) return;
+ if (isFieldDisabled) return; // 🆕 읽기전용/비활성화 상태에서는 토글 불가
// React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요
setIsOpen(!isOpen);
@@ -425,7 +429,7 @@ const SelectBasicComponent: React.FC = ({
value={option.value}
checked={selectedValue === option.value}
onChange={() => handleOptionSelect(option.value, option.label)}
- disabled={isDesignMode}
+ disabled={isFieldDisabled}
className="border-input text-primary focus:ring-ring h-4 w-4"
/>
{option.label}
@@ -456,12 +460,14 @@ const SelectBasicComponent: React.FC = ({
placeholder="코드 또는 코드명 입력..."
className={cn(
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
- !isDesignMode && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
+ !isFieldDisabled && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
isSelected && "ring-2 ring-orange-500",
+ isFieldDisabled && "bg-gray-100 cursor-not-allowed",
)}
- readOnly={isDesignMode}
+ readOnly={isFieldDisabled}
+ disabled={isFieldDisabled}
/>
- {isOpen && !isDesignMode && filteredOptions.length > 0 && (
+ {isOpen && !isFieldDisabled && filteredOptions.length > 0 && (
{filteredOptions.map((option, index) => (
= ({
{selectedLabel || placeholder}
= ({
- {isOpen && !isDesignMode && (
+ {isOpen && !isFieldDisabled && (
{isLoadingCodes ? (
로딩 중...
@@ -538,8 +545,9 @@ const SelectBasicComponent: React.FC
= ({
{selectedValues.map((val, idx) => {
@@ -567,8 +575,9 @@ const SelectBasicComponent: React.FC = ({
type="text"
placeholder={selectedValues.length > 0 ? "" : placeholder}
className="min-w-[100px] flex-1 border-none bg-transparent outline-none"
- onClick={() => setIsOpen(true)}
- readOnly={isDesignMode}
+ onClick={() => !isFieldDisabled && setIsOpen(true)}
+ readOnly={isFieldDisabled}
+ disabled={isFieldDisabled}
/>
@@ -589,19 +598,22 @@ const SelectBasicComponent: React.FC
= ({
type="text"
value={searchQuery}
onChange={(e) => {
+ if (isFieldDisabled) return;
setSearchQuery(e.target.value);
setIsOpen(true);
}}
- onFocus={() => setIsOpen(true)}
+ onFocus={() => !isFieldDisabled && setIsOpen(true)}
placeholder={placeholder}
className={cn(
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
- !isDesignMode && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
+ !isFieldDisabled && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
isSelected && "ring-2 ring-orange-500",
+ isFieldDisabled && "bg-gray-100 cursor-not-allowed",
)}
- readOnly={isDesignMode}
+ readOnly={isFieldDisabled}
+ disabled={isFieldDisabled}
/>
- {isOpen && !isDesignMode && filteredOptions.length > 0 && (
+ {isOpen && !isFieldDisabled && filteredOptions.length > 0 && (
{filteredOptions.map((option, index) => (
= ({
{selectedLabel || placeholder}
= ({
- {isOpen && !isDesignMode && (
+ {isOpen && !isFieldDisabled && (
= ({
!isDesignMode && setIsOpen(true)}
+ onClick={() => !isFieldDisabled && setIsOpen(true)}
style={{
- pointerEvents: isDesignMode ? "none" : "auto",
+ pointerEvents: isFieldDisabled ? "none" : "auto",
height: "100%"
}}
>
@@ -726,7 +740,7 @@ const SelectBasicComponent: React.FC = ({
{placeholder}
)}
- {isOpen && !isDesignMode && (
+ {isOpen && !isFieldDisabled && (
{(isLoadingCodes || isLoadingCategories) ? (
로딩 중...
@@ -789,13 +803,14 @@ const SelectBasicComponent: React.FC
= ({
{selectedLabel || placeholder}
= ({
- {isOpen && !isDesignMode && (
+ {isOpen && !isFieldDisabled && (
{isLoadingCodes ? (
로딩 중...
diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx
index fdaddfc3..ac44eded 100644
--- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx
+++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx
@@ -293,8 +293,17 @@ export const SplitPanelLayoutComponent: React.FC
) => {
if (value === null || value === undefined) return "-";
- // 카테고리 매핑이 있는지 확인
- const mapping = categoryMappings[columnName];
+ // 🆕 카테고리 매핑 찾기 (여러 키 형태 시도)
+ // 1. 전체 컬럼명 (예: "item_info.material")
+ // 2. 컬럼명만 (예: "material")
+ let mapping = categoryMappings[columnName];
+
+ if (!mapping && columnName.includes(".")) {
+ // 조인된 컬럼의 경우 컬럼명만으로 다시 시도
+ const simpleColumnName = columnName.split(".").pop() || columnName;
+ mapping = categoryMappings[simpleColumnName];
+ }
+
if (mapping && mapping[String(value)]) {
const categoryData = mapping[String(value)];
const displayLabel = categoryData.label || String(value);
@@ -690,43 +699,69 @@ export const SplitPanelLayoutComponent: React.FC
loadLeftCategoryMappings();
}, [componentConfig.leftPanel?.tableName, isDesignMode]);
- // 우측 테이블 카테고리 매핑 로드
+ // 우측 테이블 카테고리 매핑 로드 (조인된 테이블 포함)
useEffect(() => {
const loadRightCategoryMappings = async () => {
const rightTableName = componentConfig.rightPanel?.tableName;
if (!rightTableName || isDesignMode) return;
try {
- // 1. 컬럼 메타 정보 조회
- const columnsResponse = await tableTypeApi.getColumns(rightTableName);
- const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category");
-
- if (categoryColumns.length === 0) {
- setRightCategoryMappings({});
- return;
- }
-
- // 2. 각 카테고리 컬럼에 대한 값 조회
const mappings: Record> = {};
- for (const col of categoryColumns) {
- const columnName = col.columnName || col.column_name;
- try {
- const response = await apiClient.get(`/table-categories/${rightTableName}/${columnName}/values`);
+ // 🆕 우측 패널 컬럼 설정에서 조인된 테이블 추출
+ const rightColumns = componentConfig.rightPanel?.columns || [];
+ const tablesToLoad = new Set([rightTableName]);
+
+ // 컬럼명에서 테이블명 추출 (예: "item_info.material" -> "item_info")
+ rightColumns.forEach((col: any) => {
+ const colName = col.name || col.columnName;
+ if (colName && colName.includes(".")) {
+ const joinTableName = colName.split(".")[0];
+ tablesToLoad.add(joinTableName);
+ }
+ });
- if (response.data.success && response.data.data) {
- const valueMap: Record = {};
- response.data.data.forEach((item: any) => {
- valueMap[item.value_code || item.valueCode] = {
- label: item.value_label || item.valueLabel,
- color: item.color,
- };
- });
- mappings[columnName] = valueMap;
- console.log(`✅ 우측 카테고리 매핑 로드 [${columnName}]:`, valueMap);
+ console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad));
+
+ // 각 테이블에 대해 카테고리 매핑 로드
+ for (const tableName of tablesToLoad) {
+ try {
+ // 1. 컬럼 메타 정보 조회
+ const columnsResponse = await tableTypeApi.getColumns(tableName);
+ const categoryColumns = columnsResponse.filter((col: any) => col.inputType === "category");
+
+ // 2. 각 카테고리 컬럼에 대한 값 조회
+ for (const col of categoryColumns) {
+ const columnName = col.columnName || col.column_name;
+ try {
+ const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
+
+ if (response.data.success && response.data.data) {
+ const valueMap: Record = {};
+ response.data.data.forEach((item: any) => {
+ valueMap[item.value_code || item.valueCode] = {
+ label: item.value_label || item.valueLabel,
+ color: item.color,
+ };
+ });
+
+ // 조인된 테이블의 경우 "테이블명.컬럼명" 형태로 저장
+ const mappingKey = tableName === rightTableName ? columnName : `${tableName}.${columnName}`;
+ mappings[mappingKey] = valueMap;
+
+ // 🆕 컬럼명만으로도 접근할 수 있도록 추가 저장 (모든 테이블)
+ // 기존 매핑이 있으면 병합, 없으면 새로 생성
+ mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap };
+
+ console.log(`✅ 우측 카테고리 매핑 로드 [${mappingKey}]:`, valueMap);
+ console.log(`✅ 우측 카테고리 매핑 (컬럼명만) [${columnName}]:`, mappings[columnName]);
+ }
+ } catch (error) {
+ console.error(`우측 카테고리 값 조회 실패 [${tableName}.${columnName}]:`, error);
+ }
}
} catch (error) {
- console.error(`우측 카테고리 값 조회 실패 [${columnName}]:`, error);
+ console.error(`테이블 ${tableName} 컬럼 정보 조회 실패:`, error);
}
}
@@ -737,7 +772,7 @@ export const SplitPanelLayoutComponent: React.FC
};
loadRightCategoryMappings();
- }, [componentConfig.rightPanel?.tableName, isDesignMode]);
+ }, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, isDesignMode]);
// 항목 펼치기/접기 토글
const toggleExpand = useCallback((itemId: any) => {
@@ -2149,9 +2184,12 @@ export const SplitPanelLayoutComponent: React.FC
const format = colConfig?.format;
const boldValue = colConfig?.bold ?? false;
- // 숫자 포맷 적용
- let displayValue = String(value || "-");
- if (value !== null && value !== undefined && value !== "" && format) {
+ // 🆕 카테고리 매핑 적용
+ const formattedValue = formatCellValue(key, value, rightCategoryMappings);
+
+ // 숫자 포맷 적용 (카테고리가 아닌 경우만)
+ let displayValue: React.ReactNode = formattedValue;
+ if (typeof formattedValue === 'string' && value !== null && value !== undefined && value !== "" && format) {
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
if (!isNaN(numValue)) {
displayValue = numValue.toLocaleString('ko-KR', {
@@ -2175,7 +2213,6 @@ export const SplitPanelLayoutComponent: React.FC
)}
{displayValue}
@@ -2240,9 +2277,12 @@ export const SplitPanelLayoutComponent: React.FC
const colConfig = rightColumns?.find(c => c.name === key);
const format = colConfig?.format;
- // 숫자 포맷 적용
- let displayValue = String(value);
- if (value !== null && value !== undefined && value !== "" && format) {
+ // 🆕 카테고리 매핑 적용
+ const formattedValue = formatCellValue(key, value, rightCategoryMappings);
+
+ // 숫자 포맷 적용 (카테고리가 아닌 경우만)
+ let displayValue: React.ReactNode = formattedValue;
+ if (typeof formattedValue === 'string' && value !== null && value !== undefined && value !== "" && format) {
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
if (!isNaN(numValue)) {
displayValue = numValue.toLocaleString('ko-KR', {
diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx
index 8a9d73a7..0dd00543 100644
--- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx
+++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx
@@ -6,10 +6,30 @@ import {
SplitPanelLayout2Config,
ColumnConfig,
DataTransferField,
+ ActionButtonConfig,
} from "./types";
import { defaultConfig } from "./config";
import { cn } from "@/lib/utils";
-import { Search, Plus, ChevronRight, ChevronDown, Edit, Trash2, Users, Building2 } from "lucide-react";
+import { Search, Plus, ChevronRight, ChevronDown, Edit, Trash2, Users, Building2, Check, MoreHorizontal } from "lucide-react";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -59,6 +79,14 @@ export const SplitPanelLayout2Component: React.FC>({});
const [rightColumnLabels, setRightColumnLabels] = useState>({});
+ // 우측 패널 선택 상태 (체크박스용)
+ const [selectedRightItems, setSelectedRightItems] = useState>(new Set());
+
+ // 삭제 확인 다이얼로그 상태
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [itemToDelete, setItemToDelete] = useState(null);
+ const [isBulkDelete, setIsBulkDelete] = useState(false);
+
// 좌측 데이터 로드
const loadLeftData = useCallback(async () => {
@@ -233,6 +261,178 @@ export const SplitPanelLayout2Component: React.FC {
+ return config.rightPanel?.primaryKeyColumn || "id";
+ }, [config.rightPanel?.primaryKeyColumn]);
+
+ // 우측 패널 수정 버튼 클릭
+ const handleEditItem = useCallback((item: any) => {
+ // 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용)
+ const modalScreenId = config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId;
+
+ if (!modalScreenId) {
+ toast.error("연결된 모달 화면이 없습니다.");
+ return;
+ }
+
+ // EditModal 열기 이벤트 발생 (수정 모드)
+ const event = new CustomEvent("openEditModal", {
+ detail: {
+ screenId: modalScreenId,
+ title: "수정",
+ modalSize: "lg",
+ editData: item, // 기존 데이터 전달
+ isCreateMode: false, // 수정 모드
+ onSave: () => {
+ if (selectedLeftItem) {
+ loadRightData(selectedLeftItem);
+ }
+ },
+ },
+ });
+ window.dispatchEvent(event);
+ console.log("[SplitPanelLayout2] 수정 모달 열기:", item);
+ }, [config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, selectedLeftItem, loadRightData]);
+
+ // 우측 패널 삭제 버튼 클릭 (확인 다이얼로그 표시)
+ const handleDeleteClick = useCallback((item: any) => {
+ setItemToDelete(item);
+ setIsBulkDelete(false);
+ setDeleteDialogOpen(true);
+ }, []);
+
+ // 일괄 삭제 버튼 클릭 (확인 다이얼로그 표시)
+ const handleBulkDeleteClick = useCallback(() => {
+ if (selectedRightItems.size === 0) {
+ toast.error("삭제할 항목을 선택해주세요.");
+ return;
+ }
+ setIsBulkDelete(true);
+ setDeleteDialogOpen(true);
+ }, [selectedRightItems.size]);
+
+ // 실제 삭제 실행
+ const executeDelete = useCallback(async () => {
+ if (!config.rightPanel?.tableName) {
+ toast.error("테이블 설정이 없습니다.");
+ return;
+ }
+
+ const pkColumn = getPrimaryKeyColumn();
+
+ try {
+ if (isBulkDelete) {
+ // 일괄 삭제
+ const idsToDelete = Array.from(selectedRightItems);
+ console.log("[SplitPanelLayout2] 일괄 삭제:", idsToDelete);
+
+ for (const id of idsToDelete) {
+ await apiClient.delete(`/table-management/tables/${config.rightPanel.tableName}/data/${id}`);
+ }
+
+ toast.success(`${idsToDelete.length}개 항목이 삭제되었습니다.`);
+ setSelectedRightItems(new Set());
+ } else if (itemToDelete) {
+ // 단일 삭제
+ const itemId = itemToDelete[pkColumn];
+ console.log("[SplitPanelLayout2] 단일 삭제:", itemId);
+
+ await apiClient.delete(`/table-management/tables/${config.rightPanel.tableName}/data/${itemId}`);
+ toast.success("항목이 삭제되었습니다.");
+ }
+
+ // 데이터 새로고침
+ if (selectedLeftItem) {
+ loadRightData(selectedLeftItem);
+ }
+ } catch (error: any) {
+ console.error("[SplitPanelLayout2] 삭제 실패:", error);
+ toast.error(`삭제 실패: ${error.message}`);
+ } finally {
+ setDeleteDialogOpen(false);
+ setItemToDelete(null);
+ setIsBulkDelete(false);
+ }
+ }, [config.rightPanel?.tableName, getPrimaryKeyColumn, isBulkDelete, selectedRightItems, itemToDelete, selectedLeftItem, loadRightData]);
+
+ // 개별 체크박스 선택/해제
+ const handleSelectItem = useCallback((itemId: string | number, checked: boolean) => {
+ setSelectedRightItems((prev) => {
+ const newSet = new Set(prev);
+ if (checked) {
+ newSet.add(itemId);
+ } else {
+ newSet.delete(itemId);
+ }
+ return newSet;
+ });
+ }, []);
+
+ // 액션 버튼 클릭 핸들러
+ const handleActionButton = useCallback((btn: ActionButtonConfig) => {
+ switch (btn.action) {
+ case "add":
+ if (btn.modalScreenId) {
+ // 데이터 전달 필드 설정
+ const initialData: Record = {};
+ if (selectedLeftItem && config.dataTransferFields) {
+ for (const field of config.dataTransferFields) {
+ if (field.sourceColumn && field.targetColumn) {
+ initialData[field.targetColumn] = selectedLeftItem[field.sourceColumn];
+ }
+ }
+ }
+
+ const event = new CustomEvent("openEditModal", {
+ detail: {
+ screenId: btn.modalScreenId,
+ title: btn.label || "추가",
+ modalSize: "lg",
+ editData: initialData,
+ isCreateMode: true,
+ onSave: () => {
+ if (selectedLeftItem) {
+ loadRightData(selectedLeftItem);
+ }
+ },
+ },
+ });
+ window.dispatchEvent(event);
+ }
+ break;
+
+ case "edit":
+ // 선택된 항목이 1개일 때만 수정
+ if (selectedRightItems.size === 1) {
+ const pkColumn = getPrimaryKeyColumn();
+ const selectedId = Array.from(selectedRightItems)[0];
+ const item = rightData.find((d) => d[pkColumn] === selectedId);
+ if (item) {
+ handleEditItem(item);
+ }
+ } else if (selectedRightItems.size > 1) {
+ toast.error("수정할 항목을 1개만 선택해주세요.");
+ } else {
+ toast.error("수정할 항목을 선택해주세요.");
+ }
+ break;
+
+ case "delete":
+ case "bulk-delete":
+ handleBulkDeleteClick();
+ break;
+
+ case "custom":
+ // 커스텀 액션 (추후 확장)
+ console.log("[SplitPanelLayout2] 커스텀 액션:", btn);
+ break;
+
+ default:
+ break;
+ }
+ }, [selectedLeftItem, config.dataTransferFields, loadRightData, selectedRightItems, getPrimaryKeyColumn, rightData, handleEditItem, handleBulkDeleteClick]);
+
// 컬럼 라벨 로드
const loadColumnLabels = useCallback(async (tableName: string, setLabels: (labels: Record) => void) => {
if (!tableName) return;
@@ -366,6 +566,17 @@ export const SplitPanelLayout2Component: React.FC {
+ if (checked) {
+ const pkColumn = getPrimaryKeyColumn();
+ const allIds = new Set(filteredRightData.map((item) => item[pkColumn]));
+ setSelectedRightItems(allIds);
+ } else {
+ setSelectedRightItems(new Set());
+ }
+ }, [filteredRightData, getPrimaryKeyColumn]);
+
// 리사이즈 핸들러
const handleResizeStart = useCallback((e: React.MouseEvent) => {
if (!config.resizable) return;
@@ -564,6 +775,10 @@ export const SplitPanelLayout2Component: React.FC {
const displayColumns = config.rightPanel?.displayColumns || [];
+ const showLabels = config.rightPanel?.showLabels ?? false;
+ const showCheckbox = config.rightPanel?.showCheckbox ?? false;
+ const pkColumn = getPrimaryKeyColumn();
+ const itemId = item[pkColumn];
// displayRow 설정에 따라 컬럼 분류
// displayRow가 "name"이면 이름 행, "info"이면 정보 행 (기본값: 첫 번째는 name, 나머지는 info)
@@ -577,72 +792,113 @@ export const SplitPanelLayout2Component: React.FC
-
+
+ {/* 체크박스 */}
+ {showCheckbox && (
+
handleSelectItem(itemId, !!checked)}
+ className="mt-1"
+ />
+ )}
+
- {/* 이름 행 (Name Row) */}
- {nameRowColumns.length > 0 && (
-
- {nameRowColumns.map((col, idx) => {
- const value = item[col.name];
- if (!value && idx > 0) return null;
-
- // 첫 번째 컬럼은 굵게 표시
- if (idx === 0) {
- return (
-
- {formatValue(value, col.format) || "이름 없음"}
-
- );
- }
- // 나머지는 배지 스타일
- return (
-
- {formatValue(value, col.format)}
-
- );
- })}
+ {/* showLabels가 true이면 라벨: 값 형식으로 가로 배치 */}
+ {showLabels ? (
+
+ {/* 이름 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */}
+ {nameRowColumns.length > 0 && (
+
+ {nameRowColumns.map((col, idx) => {
+ const value = item[col.name];
+ if (value === null || value === undefined) return null;
+ return (
+
+ {col.label || col.name}:
+ {formatValue(value, col.format)}
+
+ );
+ })}
+
+ )}
+ {/* 정보 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */}
+ {infoRowColumns.length > 0 && (
+
+ {infoRowColumns.map((col, idx) => {
+ const value = item[col.name];
+ if (value === null || value === undefined) return null;
+ return (
+
+ {col.label || col.name}:
+ {formatValue(value, col.format)}
+
+ );
+ })}
+
+ )}
- )}
-
- {/* 정보 행 (Info Row) */}
- {infoRowColumns.length > 0 && (
-
- {infoRowColumns.map((col, idx) => {
- const value = item[col.name];
- if (!value) return null;
-
- // 아이콘 결정
- let icon = null;
- const colName = col.name.toLowerCase();
- if (colName.includes("tel") || colName.includes("phone")) {
- icon =
tel ;
- } else if (colName.includes("email")) {
- icon =
@ ;
- } else if (colName.includes("sabun") || colName.includes("id")) {
- icon =
ID ;
- }
-
- return (
-
- {icon}
- {formatValue(value, col.format)}
-
- );
- })}
+ ) : (
+ // showLabels가 false일 때 기존 방식 유지 (라벨 없이 값만)
+
+ {/* 이름 행 */}
+ {nameRowColumns.length > 0 && (
+
+ {nameRowColumns.map((col, idx) => {
+ const value = item[col.name];
+ if (value === null || value === undefined) return null;
+ if (idx === 0) {
+ return (
+
+ {formatValue(value, col.format)}
+
+ );
+ }
+ return (
+
+ {formatValue(value, col.format)}
+
+ );
+ })}
+
+ )}
+ {/* 정보 행 */}
+ {infoRowColumns.length > 0 && (
+
+ {infoRowColumns.map((col, idx) => {
+ const value = item[col.name];
+ if (value === null || value === undefined) return null;
+ return (
+
+ {formatValue(value, col.format)}
+
+ );
+ })}
+
+ )}
)}
- {/* 액션 버튼 */}
+ {/* 액션 버튼 (개별 수정/삭제) */}
{config.rightPanel?.showEditButton && (
-
- 수정
+ handleEditItem(item)}
+ >
+
)}
{config.rightPanel?.showDeleteButton && (
-
- 삭제
+ handleDeleteClick(item)}
+ >
+
)}
@@ -652,6 +908,139 @@ export const SplitPanelLayout2Component: React.FC
{
+ const displayColumns = config.rightPanel?.displayColumns || [];
+ const showCheckbox = config.rightPanel?.showCheckbox ?? true; // 테이블 모드는 기본 체크박스 표시
+ const pkColumn = getPrimaryKeyColumn();
+ const allSelected = filteredRightData.length > 0 &&
+ filteredRightData.every((item) => selectedRightItems.has(item[pkColumn]));
+ const someSelected = filteredRightData.some((item) => selectedRightItems.has(item[pkColumn]));
+
+ return (
+
+
+
+
+ {showCheckbox && (
+
+ {
+ if (el) {
+ (el as any).indeterminate = someSelected && !allSelected;
+ }
+ }}
+ onCheckedChange={handleSelectAll}
+ />
+
+ )}
+ {displayColumns.map((col, idx) => (
+
+ {col.label || col.name}
+
+ ))}
+ {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
+ 작업
+ )}
+
+
+
+ {filteredRightData.length === 0 ? (
+
+
+ 등록된 항목이 없습니다
+
+
+ ) : (
+ filteredRightData.map((item, index) => {
+ const itemId = item[pkColumn];
+ return (
+
+ {showCheckbox && (
+
+ handleSelectItem(itemId, !!checked)}
+ />
+
+ )}
+ {displayColumns.map((col, colIdx) => (
+
+ {formatValue(item[col.name], col.format)}
+
+ ))}
+ {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
+
+
+ {config.rightPanel?.showEditButton && (
+ handleEditItem(item)}
+ >
+
+
+ )}
+ {config.rightPanel?.showDeleteButton && (
+ handleDeleteClick(item)}
+ >
+
+
+ )}
+
+
+ )}
+
+ );
+ })
+ )}
+
+
+
+ );
+ };
+
+ // 액션 버튼 렌더링
+ const renderActionButtons = () => {
+ const actionButtons = config.rightPanel?.actionButtons;
+ if (!actionButtons || actionButtons.length === 0) return null;
+
+ return (
+
+ {actionButtons.map((btn) => (
+
handleActionButton(btn)}
+ disabled={
+ // 일괄 삭제 버튼은 선택된 항목이 없으면 비활성화
+ (btn.action === "bulk-delete" || btn.action === "delete") && selectedRightItems.size === 0
+ }
+ >
+ {btn.icon === "Plus" && }
+ {btn.icon === "Edit" && }
+ {btn.icon === "Trash2" && }
+ {btn.label}
+
+ ))}
+
+ );
+ };
+
// 디자인 모드 렌더링
if (isDesignMode) {
return (
@@ -765,20 +1154,32 @@ export const SplitPanelLayout2Component: React.FC
-
- {selectedLeftItem
- ? config.leftPanel?.displayColumns?.[0]
- ? selectedLeftItem[config.leftPanel.displayColumns[0].name]
- : config.rightPanel?.title || "상세"
- : config.rightPanel?.title || "상세"}
-
-
+
+
+ {selectedLeftItem
+ ? config.leftPanel?.displayColumns?.[0]
+ ? selectedLeftItem[config.leftPanel.displayColumns[0].name]
+ : config.rightPanel?.title || "상세"
+ : config.rightPanel?.title || "상세"}
+
{selectedLeftItem && (
- {rightData.length}명
+ ({rightData.length}건)
)}
- {config.rightPanel?.showAddButton && selectedLeftItem && (
+ {/* 선택된 항목 수 표시 */}
+ {selectedRightItems.size > 0 && (
+
+ {selectedRightItems.size}개 선택됨
+
+ )}
+
+
+ {/* 복수 액션 버튼 (actionButtons 설정 시) */}
+ {selectedLeftItem && renderActionButtons()}
+
+ {/* 기존 단일 추가 버튼 (하위 호환성) */}
+ {config.rightPanel?.showAddButton && selectedLeftItem && !config.rightPanel?.actionButtons?.length && (
{config.rightPanel?.addButtonLabel || "추가"}
@@ -812,18 +1213,50 @@ export const SplitPanelLayout2Component: React.FC
로딩 중...
- ) : filteredRightData.length === 0 ? (
-
-
- 등록된 항목이 없습니다
-
) : (
-
- {filteredRightData.map((item, index) => renderRightCard(item, index))}
-
+ <>
+ {/* displayMode에 따라 카드 또는 테이블 렌더링 */}
+ {config.rightPanel?.displayMode === "table" ? (
+ renderRightTable()
+ ) : filteredRightData.length === 0 ? (
+
+
+ 등록된 항목이 없습니다
+
+ ) : (
+
+ {filteredRightData.map((item, index) => renderRightCard(item, index))}
+
+ )}
+ >
)}
+
+ {/* 삭제 확인 다이얼로그 */}
+
+
+
+ 삭제 확인
+
+ {isBulkDelete
+ ? `선택한 ${selectedRightItems.size}개 항목을 삭제하시겠습니까?`
+ : "이 항목을 삭제하시겠습니까?"}
+
+ 이 작업은 되돌릴 수 없습니다.
+
+
+
+ 취소
+
+ 삭제
+
+
+
+
);
};
diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx
index db3638cb..da520d92 100644
--- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx
+++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx
@@ -530,6 +530,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC
updateDisplayColumn("left", index, "name", value)}
placeholder="컬럼 선택"
/>
+
+ 표시 라벨
+ updateDisplayColumn("left", index, "label", e.target.value)}
+ placeholder="라벨명 (미입력 시 컬럼명 사용)"
+ className="h-8 text-xs"
+ />
+
표시 위치
updateDisplayColumn("right", index, "name", value)}
placeholder="컬럼 선택"
/>
+
+ 표시 라벨
+ updateDisplayColumn("right", index, "label", e.target.value)}
+ placeholder="라벨명 (미입력 시 컬럼명 사용)"
+ className="h-8 text-xs"
+ />
+
표시 위치
>
)}
+
+ {/* 표시 모드 설정 */}
+
+
표시 모드
+
updateConfig("rightPanel.displayMode", value)}
+ >
+
+
+
+
+ 카드형
+ 테이블형
+
+
+
+ 카드형: 카드 형태로 정보 표시, 테이블형: 표 형태로 정보 표시
+
+
+
+ {/* 카드 모드 전용 옵션 */}
+ {(config.rightPanel?.displayMode || "card") === "card" && (
+
+
+
라벨 표시
+
라벨: 값 형식으로 표시
+
+
updateConfig("rightPanel.showLabels", checked)}
+ />
+
+ )}
+
+ {/* 체크박스 표시 */}
+
+
+
체크박스 표시
+
항목 선택 기능 활성화
+
+
updateConfig("rightPanel.showCheckbox", checked)}
+ />
+
+
+ {/* 수정/삭제 버튼 */}
+
+
개별 수정/삭제
+
+
+ 수정 버튼 표시
+ updateConfig("rightPanel.showEditButton", checked)}
+ />
+
+
+ 삭제 버튼 표시
+ updateConfig("rightPanel.showDeleteButton", checked)}
+ />
+
+
+
+
+ {/* 수정 모달 화면 (수정 버튼 활성화 시) */}
+ {config.rightPanel?.showEditButton && (
+
+
수정 모달 화면
+
updateConfig("rightPanel.editModalScreenId", value)}
+ placeholder="수정 모달 화면 선택 (미선택 시 추가 모달 사용)"
+ open={false}
+ onOpenChange={() => {}}
+ />
+
+ 미선택 시 추가 모달 화면을 수정용으로 사용
+
+
+ )}
+
+ {/* 기본키 컬럼 */}
+
+
기본키 컬럼
+
updateConfig("rightPanel.primaryKeyColumn", value)}
+ placeholder="기본키 컬럼 선택 (기본: id)"
+ />
+
+ 수정/삭제 시 사용할 기본키 컬럼 (미선택 시 id 사용)
+
+
+
+ {/* 복수 액션 버튼 설정 */}
+
+
+
액션 버튼 (복수)
+
{
+ const current = config.rightPanel?.actionButtons || [];
+ updateConfig("rightPanel.actionButtons", [
+ ...current,
+ {
+ id: `btn-${Date.now()}`,
+ label: "새 버튼",
+ variant: "default",
+ action: "add",
+ },
+ ]);
+ }}
+ >
+
+ 추가
+
+
+
+ 복수의 버튼을 추가하면 기존 단일 추가 버튼 대신 사용됩니다
+
+
+ {(config.rightPanel?.actionButtons || []).map((btn, index) => (
+
+
+ 버튼 {index + 1}
+ {
+ const current = config.rightPanel?.actionButtons || [];
+ updateConfig(
+ "rightPanel.actionButtons",
+ current.filter((_, i) => i !== index)
+ );
+ }}
+ >
+
+
+
+
+ 버튼 라벨
+ {
+ const current = [...(config.rightPanel?.actionButtons || [])];
+ current[index] = { ...current[index], label: e.target.value };
+ updateConfig("rightPanel.actionButtons", current);
+ }}
+ placeholder="버튼 라벨"
+ className="h-8 text-xs"
+ />
+
+
+ 동작
+ {
+ const current = [...(config.rightPanel?.actionButtons || [])];
+ current[index] = { ...current[index], action: value as any };
+ updateConfig("rightPanel.actionButtons", current);
+ }}
+ >
+
+
+
+
+ 추가 (모달 열기)
+ 수정 (선택 항목)
+ 일괄 삭제 (선택 항목)
+ 커스텀
+
+
+
+
+ 스타일
+ {
+ const current = [...(config.rightPanel?.actionButtons || [])];
+ current[index] = { ...current[index], variant: value as any };
+ updateConfig("rightPanel.actionButtons", current);
+ }}
+ >
+
+
+
+
+ 기본 (Primary)
+ 외곽선
+ 삭제 (빨간색)
+ 투명
+
+
+
+
+ 아이콘
+ {
+ const current = [...(config.rightPanel?.actionButtons || [])];
+ current[index] = { ...current[index], icon: value === "none" ? undefined : value };
+ updateConfig("rightPanel.actionButtons", current);
+ }}
+ >
+
+
+
+
+ 없음
+ + (추가)
+ 수정
+ 삭제
+
+
+
+ {btn.action === "add" && (
+
+ 모달 화면
+ {
+ const current = [...(config.rightPanel?.actionButtons || [])];
+ current[index] = { ...current[index], modalScreenId: value };
+ updateConfig("rightPanel.actionButtons", current);
+ }}
+ placeholder="모달 화면 선택"
+ open={false}
+ onOpenChange={() => {}}
+ />
+
+ )}
+
+ ))}
+ {(config.rightPanel?.actionButtons || []).length === 0 && (
+
+ 액션 버튼을 추가하세요 (선택사항)
+
+ )}
+
+
diff --git a/frontend/lib/registry/components/split-panel-layout2/types.ts b/frontend/lib/registry/components/split-panel-layout2/types.ts
index a5813600..872563df 100644
--- a/frontend/lib/registry/components/split-panel-layout2/types.ts
+++ b/frontend/lib/registry/components/split-panel-layout2/types.ts
@@ -22,6 +22,18 @@ export interface ColumnConfig {
};
}
+/**
+ * 액션 버튼 설정
+ */
+export interface ActionButtonConfig {
+ id: string; // 고유 ID
+ label: string; // 버튼 라벨
+ variant?: "default" | "outline" | "destructive" | "ghost"; // 버튼 스타일
+ icon?: string; // lucide 아이콘명 (예: "Plus", "Edit", "Trash2")
+ modalScreenId?: number; // 연결할 모달 화면 ID
+ action?: "add" | "edit" | "delete" | "bulk-delete" | "custom"; // 버튼 동작 유형
+}
+
/**
* 데이터 전달 필드 설정
*/
@@ -70,12 +82,17 @@ export interface RightPanelConfig {
searchColumn?: string; // 검색 대상 컬럼 (단일, 하위 호환성)
searchColumns?: SearchColumnConfig[]; // 검색 대상 컬럼들 (복수)
showSearch?: boolean; // 검색 표시 여부
- showAddButton?: boolean; // 추가 버튼 표시
- addButtonLabel?: string; // 추가 버튼 라벨
- addModalScreenId?: number; // 추가 모달 화면 ID
- showEditButton?: boolean; // 수정 버튼 표시
- showDeleteButton?: boolean; // 삭제 버튼 표시
- displayMode?: "card" | "list"; // 표시 모드
+ showAddButton?: boolean; // 추가 버튼 표시 (하위 호환성)
+ addButtonLabel?: string; // 추가 버튼 라벨 (하위 호환성)
+ addModalScreenId?: number; // 추가 모달 화면 ID (하위 호환성)
+ showEditButton?: boolean; // 수정 버튼 표시 (하위 호환성)
+ showDeleteButton?: boolean; // 삭제 버튼 표시 (하위 호환성)
+ editModalScreenId?: number; // 수정 모달 화면 ID
+ displayMode?: "card" | "table"; // 표시 모드 (card: 카드형, table: 테이블형)
+ showLabels?: boolean; // 카드 모드에서 라벨 표시 여부 (라벨: 값 형식)
+ showCheckbox?: boolean; // 체크박스 표시 여부 (테이블 모드에서 일괄 선택용)
+ actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열
+ primaryKeyColumn?: string; // 기본키 컬럼명 (수정/삭제용, 기본: id)
emptyMessage?: string; // 데이터 없을 때 메시지
}
@@ -110,4 +127,3 @@ export interface SplitPanelLayout2Config {
// 동작 설정
autoLoad?: boolean; // 자동 데이터 로드
}
-
diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx
index 261fa108..4f78ed23 100644
--- a/frontend/lib/registry/components/table-list/TableListComponent.tsx
+++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx
@@ -179,6 +179,7 @@ export const TableListComponent: React.FC = ({
config,
className,
style,
+ formData: propFormData, // 🆕 부모에서 전달받은 formData
onFormDataChange,
componentConfig,
onSelectedRowsChange,
@@ -1183,13 +1184,74 @@ export const TableListComponent: React.FC = ({
referenceTable: col.additionalJoinInfo!.referenceTable,
}));
- // console.log("🔍 [TableList] API 호출 시작", {
- // tableName: tableConfig.selectedTable,
- // page,
- // pageSize,
- // sortBy,
- // sortOrder,
- // });
+ // 🎯 화면별 엔티티 표시 설정 수집
+ const screenEntityConfigs: Record = {};
+ (tableConfig.columns || [])
+ .filter((col) => col.entityDisplayConfig && col.entityDisplayConfig.displayColumns?.length > 0)
+ .forEach((col) => {
+ screenEntityConfigs[col.columnName] = {
+ displayColumns: col.entityDisplayConfig!.displayColumns,
+ separator: col.entityDisplayConfig!.separator || " - ",
+ sourceTable: col.entityDisplayConfig!.sourceTable || tableConfig.selectedTable,
+ joinTable: col.entityDisplayConfig!.joinTable,
+ };
+ });
+
+ console.log("🎯 [TableList] 화면별 엔티티 설정:", screenEntityConfigs);
+
+ // 🆕 제외 필터 처리 (다른 테이블에 이미 존재하는 데이터 제외)
+ let excludeFilterParam: any = undefined;
+ if (tableConfig.excludeFilter?.enabled) {
+ const excludeConfig = tableConfig.excludeFilter;
+ let filterValue: any = undefined;
+
+ // 필터 값 소스에 따라 값 가져오기 (우선순위: formData > URL > 분할패널)
+ if (excludeConfig.filterColumn && excludeConfig.filterValueField) {
+ const fieldName = excludeConfig.filterValueField;
+
+ // 1순위: props로 전달받은 formData에서 값 가져오기 (모달에서 사용)
+ if (propFormData && propFormData[fieldName]) {
+ filterValue = propFormData[fieldName];
+ console.log("🔗 [TableList] formData에서 excludeFilter 값 가져오기:", {
+ field: fieldName,
+ value: filterValue,
+ });
+ }
+ // 2순위: URL 파라미터에서 값 가져오기
+ else if (typeof window !== "undefined") {
+ const urlParams = new URLSearchParams(window.location.search);
+ filterValue = urlParams.get(fieldName);
+ if (filterValue) {
+ console.log("🔗 [TableList] URL에서 excludeFilter 값 가져오기:", {
+ field: fieldName,
+ value: filterValue,
+ });
+ }
+ }
+ // 3순위: 분할 패널 부모 데이터에서 값 가져오기
+ if (!filterValue && splitPanelContext?.selectedLeftData) {
+ filterValue = splitPanelContext.selectedLeftData[fieldName];
+ if (filterValue) {
+ console.log("🔗 [TableList] 분할패널에서 excludeFilter 값 가져오기:", {
+ field: fieldName,
+ value: filterValue,
+ });
+ }
+ }
+ }
+
+ if (filterValue || !excludeConfig.filterColumn) {
+ excludeFilterParam = {
+ enabled: true,
+ referenceTable: excludeConfig.referenceTable,
+ referenceColumn: excludeConfig.referenceColumn,
+ sourceColumn: excludeConfig.sourceColumn,
+ filterColumn: excludeConfig.filterColumn,
+ filterValue: filterValue,
+ };
+ console.log("🚫 [TableList] 제외 필터 적용:", excludeFilterParam);
+ }
+ }
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
@@ -1200,7 +1262,9 @@ export const TableListComponent: React.FC = ({
search: hasFilters ? filters : undefined,
enableEntityJoin: true,
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
+ screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
+ excludeFilter: excludeFilterParam, // 🆕 제외 필터 전달
});
// 실제 데이터의 item_number만 추출하여 중복 확인
@@ -1756,33 +1820,46 @@ export const TableListComponent: React.FC = ({
const formatCellValue = useCallback(
(value: any, column: ColumnConfig, rowData?: Record) => {
- if (value === null || value === undefined) return "-";
-
- // 🎯 writer 컬럼 자동 변환: user_id -> user_name
- if (column.columnName === "writer" && rowData && rowData.writer_name) {
- return rowData.writer_name;
- }
-
- // 🎯 엔티티 컬럼 표시 설정이 있는 경우
+ // 🎯 엔티티 컬럼 표시 설정이 있는 경우 - value가 null이어도 rowData에서 조합 가능
+ // 이 체크를 가장 먼저 수행 (null 체크보다 앞에)
if (column.entityDisplayConfig && rowData) {
- // displayColumns 또는 selectedColumns 둘 다 체크
- const displayColumns = column.entityDisplayConfig.displayColumns || column.entityDisplayConfig.selectedColumns;
+ const displayColumns = column.entityDisplayConfig.displayColumns || (column.entityDisplayConfig as any).selectedColumns;
const separator = column.entityDisplayConfig.separator;
if (displayColumns && displayColumns.length > 0) {
// 선택된 컬럼들의 값을 구분자로 조합
const values = displayColumns
- .map((colName) => {
- const cellValue = rowData[colName];
+ .map((colName: string) => {
+ // 1. 먼저 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우)
+ let cellValue = rowData[colName];
+
+ // 2. 없으면 ${sourceColumn}_${colName} 형식으로 시도 (조인 테이블 컬럼인 경우)
+ if (cellValue === null || cellValue === undefined) {
+ const joinedKey = `${column.columnName}_${colName}`;
+ cellValue = rowData[joinedKey];
+ }
+
if (cellValue === null || cellValue === undefined) return "";
return String(cellValue);
})
- .filter((v) => v !== ""); // 빈 값 제외
+ .filter((v: string) => v !== ""); // 빈 값 제외
- return values.join(separator || " - ");
+ const result = values.join(separator || " - ");
+ if (result) {
+ return result; // 결과가 있으면 반환
+ }
+ // 결과가 비어있으면 아래로 계속 진행 (원래 값 사용)
}
}
+ // value가 null/undefined면 "-" 반환
+ if (value === null || value === undefined) return "-";
+
+ // 🎯 writer 컬럼 자동 변환: user_id -> user_name
+ if (column.columnName === "writer" && rowData && rowData.writer_name) {
+ return rowData.writer_name;
+ }
+
const meta = columnMeta[column.columnName];
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
@@ -1906,12 +1983,16 @@ export const TableListComponent: React.FC = ({
return "-";
}
- // 숫자 타입 포맷팅
+ // 숫자 타입 포맷팅 (천단위 구분자 설정 확인)
if (inputType === "number" || inputType === "decimal") {
if (value !== null && value !== undefined && value !== "") {
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (!isNaN(numValue)) {
- return numValue.toLocaleString("ko-KR");
+ // thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
+ if (column.thousandSeparator !== false) {
+ return numValue.toLocaleString("ko-KR");
+ }
+ return String(numValue);
}
}
return String(value);
@@ -1922,7 +2003,11 @@ export const TableListComponent: React.FC = ({
if (value !== null && value !== undefined && value !== "") {
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (!isNaN(numValue)) {
- return numValue.toLocaleString("ko-KR");
+ // thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
+ if (column.thousandSeparator !== false) {
+ return numValue.toLocaleString("ko-KR");
+ }
+ return String(numValue);
}
}
return String(value);
@@ -1939,10 +2024,15 @@ export const TableListComponent: React.FC = ({
}
}
return "-";
- case "number":
- return typeof value === "number" ? value.toLocaleString() : value;
case "currency":
- return typeof value === "number" ? `₩${value.toLocaleString()}` : value;
+ if (typeof value === "number") {
+ // thousandSeparator가 false가 아닌 경우(기본값 true) 천단위 구분자 적용
+ if (column.thousandSeparator !== false) {
+ return `₩${value.toLocaleString()}`;
+ }
+ return `₩${value}`;
+ }
+ return value;
case "boolean":
return value ? "예" : "아니오";
default:
diff --git a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx
index 9de2f6d8..209b3d2d 100644
--- a/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx
+++ b/frontend/lib/registry/components/table-list/TableListConfigPanel.tsx
@@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge";
import { TableListConfig, ColumnConfig } from "./types";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { tableTypeApi } from "@/lib/api/screen";
+import { tableManagementApi } from "@/lib/api/tableManagement";
import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
@@ -73,6 +74,12 @@ export const TableListConfigPanel: React.FC = ({
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
+ // 🆕 제외 필터용 참조 테이블 컬럼 목록
+ const [referenceTableColumns, setReferenceTableColumns] = useState<
+ Array<{ columnName: string; dataType: string; label?: string }>
+ >([]);
+ const [loadingReferenceColumns, setLoadingReferenceColumns] = useState(false);
+
// 🔄 외부에서 config가 변경될 때 내부 상태 동기화 (표의 페이지네이션 변경 감지)
useEffect(() => {
// console.log("🔄 TableListConfigPanel - 외부 config 변경 감지:", {
@@ -237,6 +244,42 @@ export const TableListConfigPanel: React.FC = ({
fetchEntityJoinColumns();
}, [config.selectedTable, screenTableName]);
+ // 🆕 제외 필터용 참조 테이블 컬럼 가져오기
+ useEffect(() => {
+ const fetchReferenceColumns = async () => {
+ const refTable = config.excludeFilter?.referenceTable;
+ if (!refTable) {
+ setReferenceTableColumns([]);
+ return;
+ }
+
+ setLoadingReferenceColumns(true);
+ try {
+ console.log("🔗 참조 테이블 컬럼 정보 가져오기:", refTable);
+ const result = await tableManagementApi.getColumnList(refTable);
+ if (result.success && result.data) {
+ // result.data는 { columns: [], total, page, size, totalPages } 형태
+ const columns = result.data.columns || [];
+ setReferenceTableColumns(
+ columns.map((col: any) => ({
+ columnName: col.columnName || col.column_name,
+ dataType: col.dataType || col.data_type || "text",
+ label: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
+ }))
+ );
+ console.log("✅ 참조 테이블 컬럼 로드 완료:", columns.length, "개");
+ }
+ } catch (error) {
+ console.error("❌ 참조 테이블 컬럼 조회 오류:", error);
+ setReferenceTableColumns([]);
+ } finally {
+ setLoadingReferenceColumns(false);
+ }
+ };
+
+ fetchReferenceColumns();
+ }, [config.excludeFilter?.referenceTable]);
+
// 🎯 엔티티 컬럼 자동 로드
useEffect(() => {
const entityColumns = config.columns?.filter((col) => col.isEntityJoin && col.entityDisplayConfig);
@@ -467,42 +510,22 @@ export const TableListConfigPanel: React.FC = ({
// 🎯 엔티티 컬럼의 표시 컬럼 정보 로드
const loadEntityDisplayConfig = async (column: ColumnConfig) => {
- if (!column.isEntityJoin || !column.entityJoinInfo) {
- return;
- }
+ const configKey = `${column.columnName}`;
+
+ // 이미 로드된 경우 스킵
+ if (entityDisplayConfigs[configKey]) return;
- // entityDisplayConfig가 없으면 초기화
- if (!column.entityDisplayConfig) {
- // sourceTable을 결정: entityJoinInfo -> config.selectedTable -> screenTableName 순서
- const initialSourceTable = column.entityJoinInfo?.sourceTable || config.selectedTable || screenTableName;
-
- if (!initialSourceTable) {
- return;
- }
-
- const updatedColumns = config.columns?.map((col) => {
- if (col.columnName === column.columnName) {
- return {
- ...col,
- entityDisplayConfig: {
- displayColumns: [],
- separator: " - ",
- sourceTable: initialSourceTable,
- joinTable: "",
- },
- };
- }
- return col;
- });
-
- if (updatedColumns) {
- handleChange("columns", updatedColumns);
- // 업데이트된 컬럼으로 다시 시도
- const updatedColumn = updatedColumns.find((col) => col.columnName === column.columnName);
- if (updatedColumn) {
- return loadEntityDisplayConfig(updatedColumn);
- }
- }
+ if (!column.isEntityJoin) {
+ // 엔티티 컬럼이 아니면 빈 상태로 설정하여 로딩 상태 해제
+ setEntityDisplayConfigs((prev) => ({
+ ...prev,
+ [configKey]: {
+ sourceColumns: [],
+ joinColumns: [],
+ selectedColumns: [],
+ separator: " - ",
+ },
+ }));
return;
}
@@ -512,32 +535,56 @@ export const TableListConfigPanel: React.FC = ({
// 3. config.selectedTable
// 4. screenTableName
const sourceTable =
- column.entityDisplayConfig.sourceTable ||
+ column.entityDisplayConfig?.sourceTable ||
column.entityJoinInfo?.sourceTable ||
config.selectedTable ||
screenTableName;
- let joinTable = column.entityDisplayConfig.joinTable;
-
- // sourceTable이 여전히 비어있으면 에러
+ // sourceTable이 비어있으면 빈 상태로 설정
if (!sourceTable) {
+ console.warn("⚠️ sourceTable을 찾을 수 없음:", column.columnName);
+ setEntityDisplayConfigs((prev) => ({
+ ...prev,
+ [configKey]: {
+ sourceColumns: [],
+ joinColumns: [],
+ selectedColumns: column.entityDisplayConfig?.displayColumns || [],
+ separator: column.entityDisplayConfig?.separator || " - ",
+ },
+ }));
return;
}
- if (!joinTable && sourceTable) {
- // joinTable이 없으면 tableTypeApi로 조회해서 설정
+ let joinTable = column.entityDisplayConfig?.joinTable;
+
+ // joinTable이 없으면 tableTypeApi로 조회해서 설정
+ if (!joinTable) {
try {
+ console.log("🔍 tableTypeApi로 컬럼 정보 조회:", {
+ tableName: sourceTable,
+ columnName: column.columnName,
+ });
+
const columnList = await tableTypeApi.getColumns(sourceTable);
const columnInfo = columnList.find((col: any) => (col.column_name || col.columnName) === column.columnName);
+ console.log("🔍 컬럼 정보 조회 결과:", {
+ columnInfo: columnInfo,
+ referenceTable: columnInfo?.reference_table || columnInfo?.referenceTable,
+ referenceColumn: columnInfo?.reference_column || columnInfo?.referenceColumn,
+ });
+
if (columnInfo?.reference_table || columnInfo?.referenceTable) {
joinTable = columnInfo.reference_table || columnInfo.referenceTable;
+ console.log("✅ tableTypeApi에서 조인 테이블 정보 찾음:", joinTable);
// entityDisplayConfig 업데이트
const updatedConfig = {
...column.entityDisplayConfig,
sourceTable: sourceTable,
joinTable: joinTable,
+ displayColumns: column.entityDisplayConfig?.displayColumns || [],
+ separator: column.entityDisplayConfig?.separator || " - ",
};
// 컬럼 설정 업데이트
@@ -553,74 +600,27 @@ export const TableListConfigPanel: React.FC = ({
}
} catch (error) {
console.error("tableTypeApi 컬럼 정보 조회 실패:", error);
- console.log("❌ 조회 실패 상세:", { sourceTable, columnName: column.columnName });
}
- } else if (!joinTable) {
- console.warn("⚠️ sourceTable이 없어서 joinTable 조회 불가:", column.columnName);
}
console.log("🔍 최종 추출한 값:", { sourceTable, joinTable });
- const configKey = `${column.columnName}`;
-
- // 이미 로드된 경우 스킵
- if (entityDisplayConfigs[configKey]) return;
-
- // joinTable이 비어있으면 tableTypeApi로 컬럼 정보를 다시 가져와서 referenceTable 정보를 찾기
- let actualJoinTable = joinTable;
- if (!actualJoinTable && sourceTable) {
- try {
- console.log("🔍 tableTypeApi로 컬럼 정보 다시 조회:", {
- tableName: sourceTable,
- columnName: column.columnName,
- });
-
- const columnList = await tableTypeApi.getColumns(sourceTable);
- const columnInfo = columnList.find((col: any) => (col.column_name || col.columnName) === column.columnName);
-
- console.log("🔍 컬럼 정보 조회 결과:", {
- columnInfo: columnInfo,
- referenceTable: columnInfo?.reference_table || columnInfo?.referenceTable,
- referenceColumn: columnInfo?.reference_column || columnInfo?.referenceColumn,
- });
-
- if (columnInfo?.reference_table || columnInfo?.referenceTable) {
- actualJoinTable = columnInfo.reference_table || columnInfo.referenceTable;
- console.log("✅ tableTypeApi에서 조인 테이블 정보 찾음:", actualJoinTable);
-
- // entityDisplayConfig 업데이트
- const updatedConfig = {
- ...column.entityDisplayConfig,
- joinTable: actualJoinTable,
- };
-
- // 컬럼 설정 업데이트
- const updatedColumns = config.columns?.map((col) =>
- col.columnName === column.columnName ? { ...col, entityDisplayConfig: updatedConfig } : col,
- );
-
- if (updatedColumns) {
- handleChange("columns", updatedColumns);
- }
- }
- } catch (error) {
- console.error("tableTypeApi 컬럼 정보 조회 실패:", error);
- }
- }
-
- // sourceTable과 joinTable이 모두 있어야 로드
- if (!sourceTable || !actualJoinTable) {
- return;
- }
try {
- // 기본 테이블과 조인 테이블의 컬럼 정보를 병렬로 로드
- const [sourceResult, joinResult] = await Promise.all([
- entityJoinApi.getReferenceTableColumns(sourceTable),
- entityJoinApi.getReferenceTableColumns(actualJoinTable),
- ]);
-
+ // 기본 테이블 컬럼 정보는 항상 로드
+ const sourceResult = await entityJoinApi.getReferenceTableColumns(sourceTable);
const sourceColumns = sourceResult.columns || [];
- const joinColumns = joinResult.columns || [];
+
+ // joinTable이 있으면 조인 테이블 컬럼도 로드
+ let joinColumns: Array<{ columnName: string; displayName: string; dataType: string }> = [];
+ if (joinTable) {
+ try {
+ const joinResult = await entityJoinApi.getReferenceTableColumns(joinTable);
+ joinColumns = joinResult.columns || [];
+ } catch (joinError) {
+ console.warn("⚠️ 조인 테이블 컬럼 로드 실패:", joinTable, joinError);
+ // 조인 테이블 로드 실패해도 소스 테이블 컬럼은 표시
+ }
+ }
setEntityDisplayConfigs((prev) => ({
...prev,
@@ -633,6 +633,16 @@ export const TableListConfigPanel: React.FC = ({
}));
} catch (error) {
console.error("엔티티 표시 컬럼 정보 로드 실패:", error);
+ // 에러 발생 시에도 빈 상태로 설정하여 로딩 상태 해제
+ setEntityDisplayConfigs((prev) => ({
+ ...prev,
+ [configKey]: {
+ sourceColumns: [],
+ joinColumns: [],
+ selectedColumns: column.entityDisplayConfig?.displayColumns || [],
+ separator: column.entityDisplayConfig?.separator || " - ",
+ },
+ }));
}
};
@@ -873,76 +883,95 @@ export const TableListConfigPanel: React.FC = ({
{/* 표시 컬럼 선택 (다중 선택) */}
표시할 컬럼 선택
-
-
-
- {entityDisplayConfigs[column.columnName].selectedColumns.length > 0
- ? `${entityDisplayConfigs[column.columnName].selectedColumns.length}개 선택됨`
- : "컬럼 선택"}
-
-
-
-
-
-
-
- 컬럼을 찾을 수 없습니다.
- {entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
-
- {entityDisplayConfigs[column.columnName].sourceColumns.map((col) => (
- toggleEntityDisplayColumn(column.columnName, col.columnName)}
- className="text-xs"
- >
-
- {col.displayName}
-
- ))}
-
- )}
- {entityDisplayConfigs[column.columnName].joinColumns.length > 0 && (
-
- {entityDisplayConfigs[column.columnName].joinColumns.map((col) => (
- toggleEntityDisplayColumn(column.columnName, col.columnName)}
- className="text-xs"
- >
-
- {col.displayName}
-
- ))}
-
- )}
-
-
-
-
+ {entityDisplayConfigs[column.columnName].sourceColumns.length === 0 &&
+ entityDisplayConfigs[column.columnName].joinColumns.length === 0 ? (
+
+ 표시 가능한 컬럼이 없습니다.
+ {!column.entityDisplayConfig?.joinTable && (
+
+ 테이블 타입 관리에서 참조 테이블을 설정하면 더 많은 컬럼을 선택할 수 있습니다.
+
+ )}
+
+ ) : (
+
+
+
+ {entityDisplayConfigs[column.columnName].selectedColumns.length > 0
+ ? `${entityDisplayConfigs[column.columnName].selectedColumns.length}개 선택됨`
+ : "컬럼 선택"}
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+ {entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
+
+ {entityDisplayConfigs[column.columnName].sourceColumns.map((col) => (
+ toggleEntityDisplayColumn(column.columnName, col.columnName)}
+ className="text-xs"
+ >
+
+ {col.displayName}
+
+ ))}
+
+ )}
+ {entityDisplayConfigs[column.columnName].joinColumns.length > 0 && (
+
+ {entityDisplayConfigs[column.columnName].joinColumns.map((col) => (
+ toggleEntityDisplayColumn(column.columnName, col.columnName)}
+ className="text-xs"
+ >
+
+ {col.displayName}
+
+ ))}
+
+ )}
+
+
+
+
+ )}
+ {/* 참조 테이블 미설정 안내 */}
+ {!column.entityDisplayConfig?.joinTable && entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
+
+ 현재 기본 테이블 컬럼만 표시됩니다. 테이블 타입 관리에서 참조 테이블을 설정하면 조인된 테이블의 컬럼도 선택할 수 있습니다.
+
+ )}
+
{/* 선택된 컬럼 미리보기 */}
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0 && (
@@ -1074,86 +1103,111 @@ export const TableListConfigPanel: React.FC
= ({
{/* 간결한 리스트 형식 컬럼 설정 */}
- {config.columns?.map((column, index) => (
-
- {/* 컬럼명 */}
-
- {availableColumns.find((col) => col.columnName === column.columnName)?.label ||
- column.displayName ||
- column.columnName}
-
+ {config.columns?.map((column, index) => {
+ // 해당 컬럼의 input_type 확인
+ const columnInfo = availableColumns.find((col) => col.columnName === column.columnName);
+ const isNumberType = columnInfo?.input_type === "number" || columnInfo?.input_type === "decimal";
+
+ return (
+
+
+ {/* 컬럼명 */}
+
+ {columnInfo?.label || column.displayName || column.columnName}
+
+
+ {/* 숫자 타입인 경우 천단위 구분자 설정 */}
+ {isNumberType && (
+
+ {
+ updateColumn(column.columnName, { thousandSeparator: checked as boolean });
+ }}
+ className="h-3 w-3"
+ />
+
+ 천단위 구분자
+
+
+ )}
+
- {/* 필터 체크박스 + 순서 변경 + 삭제 버튼 */}
-
-
f.columnName === column.columnName) || false}
- onCheckedChange={(checked) => {
- const currentFilters = config.filter?.filters || [];
- const columnLabel =
- availableColumns.find((col) => col.columnName === column.columnName)?.label ||
- column.displayName ||
- column.columnName;
+ {/* 필터 체크박스 + 순서 변경 + 삭제 버튼 */}
+
+ f.columnName === column.columnName) || false}
+ onCheckedChange={(checked) => {
+ const currentFilters = config.filter?.filters || [];
+ const columnLabel =
+ columnInfo?.label || column.displayName || column.columnName;
- if (checked) {
- // 필터 추가
- handleChange("filter", {
- ...config.filter,
- enabled: true,
- filters: [
- ...currentFilters,
- {
- columnName: column.columnName,
- label: columnLabel,
- type: "text",
- },
- ],
- });
- } else {
- // 필터 제거
- handleChange("filter", {
- ...config.filter,
- filters: currentFilters.filter((f) => f.columnName !== column.columnName),
- });
- }
- }}
- className="h-3 w-3"
- />
+ if (checked) {
+ // 필터 추가
+ handleChange("filter", {
+ ...config.filter,
+ enabled: true,
+ filters: [
+ ...currentFilters,
+ {
+ columnName: column.columnName,
+ label: columnLabel,
+ type: "text",
+ },
+ ],
+ });
+ } else {
+ // 필터 제거
+ handleChange("filter", {
+ ...config.filter,
+ filters: currentFilters.filter((f) => f.columnName !== column.columnName),
+ });
+ }
+ }}
+ className="h-3 w-3"
+ />
+
+
+ {/* 순서 변경 + 삭제 버튼 */}
+
+
moveColumn(column.columnName, "up")}
+ disabled={index === 0}
+ className="h-6 w-6 p-0"
+ >
+
+
+
moveColumn(column.columnName, "down")}
+ disabled={index === (config.columns?.length || 0) - 1}
+ className="h-6 w-6 p-0"
+ >
+
+
+
removeColumn(column.columnName)}
+ className="h-6 w-6 p-0 text-red-500 hover:text-red-600"
+ >
+
+
+
-
- {/* 순서 변경 + 삭제 버튼 */}
-
-
moveColumn(column.columnName, "up")}
- disabled={index === 0}
- className="h-6 w-6 p-0"
- >
-
-
-
moveColumn(column.columnName, "down")}
- disabled={index === (config.columns?.length || 0) - 1}
- className="h-6 w-6 p-0"
- >
-
-
-
removeColumn(column.columnName)}
- className="h-6 w-6 p-0 text-red-500 hover:text-red-600"
- >
-
-
-
-
- ))}
+ );
+ })}
)}
@@ -1322,6 +1376,298 @@ export const TableListConfigPanel: React.FC = ({
+
+ {/* 🆕 제외 필터 설정 (다른 테이블에 이미 존재하는 데이터 제외) */}
+
+
+
제외 필터
+
+ 다른 테이블에 이미 존재하는 데이터를 목록에서 제외합니다
+
+
+
+
+ {/* 제외 필터 활성화 */}
+
+ {
+ handleChange("excludeFilter", {
+ ...config.excludeFilter,
+ enabled: checked as boolean,
+ });
+ }}
+ />
+
+ 제외 필터 활성화
+
+
+
+ {config.excludeFilter?.enabled && (
+
+ {/* 참조 테이블 선택 */}
+
+
참조 테이블 (매핑 테이블)
+
+
+
+ {config.excludeFilter?.referenceTable || "테이블 선택..."}
+
+
+
+
+
+
+
+ 테이블을 찾을 수 없습니다
+
+ {availableTables.map((table) => (
+ {
+ handleChange("excludeFilter", {
+ ...config.excludeFilter,
+ referenceTable: table.tableName,
+ referenceColumn: undefined,
+ sourceColumn: undefined,
+ filterColumn: undefined,
+ filterValueField: undefined,
+ });
+ }}
+ className="text-xs"
+ >
+
+ {table.displayName || table.tableName}
+
+ ))}
+
+
+
+
+
+
+
+ {config.excludeFilter?.referenceTable && (
+ <>
+ {/* 비교 컬럼 설정 - 한 줄에 두 개 */}
+
+ {/* 참조 컬럼 (매핑 테이블) */}
+
+
비교 컬럼 (매핑)
+
+
+
+ {loadingReferenceColumns
+ ? "..."
+ : config.excludeFilter?.referenceColumn || "선택"}
+
+
+
+
+
+
+
+ 없음
+
+ {referenceTableColumns.map((col) => (
+ {
+ handleChange("excludeFilter", {
+ ...config.excludeFilter,
+ referenceColumn: col.columnName,
+ });
+ }}
+ className="text-xs"
+ >
+
+ {col.label || col.columnName}
+
+ ))}
+
+
+
+
+
+
+
+ {/* 소스 컬럼 (현재 테이블) */}
+
+
비교 컬럼 (현재)
+
+
+
+ {config.excludeFilter?.sourceColumn || "선택"}
+
+
+
+
+
+
+
+ 없음
+
+ {availableColumns.map((col) => (
+ {
+ handleChange("excludeFilter", {
+ ...config.excludeFilter,
+ sourceColumn: col.columnName,
+ });
+ }}
+ className="text-xs"
+ >
+
+ {col.label || col.columnName}
+
+ ))}
+
+
+
+
+
+
+
+
+ {/* 조건 필터 - 특정 조건의 데이터만 제외 */}
+
+
조건 필터 (선택사항)
+
+ 특정 조건의 데이터만 제외하려면 설정하세요 (예: 특정 거래처의 품목만)
+
+
+ {/* 필터 컬럼 (매핑 테이블) */}
+
+
+
+ {loadingReferenceColumns
+ ? "..."
+ : config.excludeFilter?.filterColumn
+ ? `매핑: ${config.excludeFilter.filterColumn}`
+ : "매핑 테이블 컬럼"}
+
+
+
+
+
+
+
+ 없음
+
+ {
+ handleChange("excludeFilter", {
+ ...config.excludeFilter,
+ filterColumn: undefined,
+ filterValueField: undefined,
+ });
+ }}
+ className="text-xs text-muted-foreground"
+ >
+
+ 사용 안함
+
+ {referenceTableColumns.map((col) => (
+ {
+ // 필터 컬럼 선택 시 같은 이름의 필드를 자동으로 설정
+ handleChange("excludeFilter", {
+ ...config.excludeFilter,
+ filterColumn: col.columnName,
+ filterValueField: col.columnName, // 같은 이름으로 자동 설정
+ filterValueSource: "url",
+ });
+ }}
+ className="text-xs"
+ >
+
+ {col.label || col.columnName}
+
+ ))}
+
+
+
+
+
+
+ {/* 필터 값 필드명 (부모 화면에서 전달받는 필드) */}
+
{
+ handleChange("excludeFilter", {
+ ...config.excludeFilter,
+ filterValueField: e.target.value,
+ });
+ }}
+ disabled={!config.excludeFilter?.filterColumn}
+ className="h-8 text-xs"
+ />
+
+
+ >
+ )}
+
+ {/* 설정 요약 */}
+ {config.excludeFilter?.referenceTable && config.excludeFilter?.referenceColumn && config.excludeFilter?.sourceColumn && (
+
+ 설정 요약: {config.selectedTable || screenTableName}.{config.excludeFilter.sourceColumn} 가
+ {" "}{config.excludeFilter.referenceTable}.{config.excludeFilter.referenceColumn} 에
+ {config.excludeFilter.filterColumn && config.excludeFilter.filterValueField && (
+ <> ({config.excludeFilter.filterColumn}=URL의 {config.excludeFilter.filterValueField}일 때)>
+ )}
+ {" "}이미 있으면 제외
+
+ )}
+
+ )}
+
);
diff --git a/frontend/lib/registry/components/table-list/types.ts b/frontend/lib/registry/components/table-list/types.ts
index 0322926b..2475f58f 100644
--- a/frontend/lib/registry/components/table-list/types.ts
+++ b/frontend/lib/registry/components/table-list/types.ts
@@ -59,6 +59,9 @@ export interface ColumnConfig {
isEntityJoin?: boolean; // Entity 조인된 컬럼인지 여부
entityJoinInfo?: EntityJoinInfo; // Entity 조인 상세 정보
+ // 숫자 포맷팅 설정
+ thousandSeparator?: boolean; // 천단위 구분자 사용 여부 (기본: true)
+
// 🎯 엔티티 컬럼 표시 설정 (화면별 동적 설정)
entityDisplayConfig?: {
displayColumns: string[]; // 표시할 컬럼들 (기본 테이블 + 조인 테이블)
@@ -182,6 +185,21 @@ export interface LinkedFilterConfig {
enabled?: boolean; // 활성화 여부 (기본: true)
}
+/**
+ * 제외 필터 설정
+ * 다른 테이블에 이미 존재하는 데이터를 제외하고 표시
+ * 예: 거래처에 이미 등록된 품목을 품목 선택 모달에서 제외
+ */
+export interface ExcludeFilterConfig {
+ enabled: boolean; // 제외 필터 활성화 여부
+ referenceTable: string; // 참조 테이블 (예: customer_item_mapping)
+ referenceColumn: string; // 참조 테이블의 비교 컬럼 (예: item_id)
+ sourceColumn: string; // 현재 테이블의 비교 컬럼 (예: item_number)
+ filterColumn?: string; // 참조 테이블의 필터 컬럼 (예: customer_id)
+ filterValueSource?: "url" | "formData" | "parentData"; // 필터 값 소스 (기본: url)
+ filterValueField?: string; // 필터 값 필드명 (예: customer_code)
+}
+
/**
* TableList 컴포넌트 설정 타입
*/
@@ -246,6 +264,9 @@ export interface TableListConfig extends ComponentConfig {
// 🆕 연결된 필터 (다른 컴포넌트 값으로 필터링)
linkedFilters?: LinkedFilterConfig[];
+ // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
+ excludeFilter?: ExcludeFilterConfig;
+
// 이벤트 핸들러
onRowClick?: (row: any) => void;
onRowDoubleClick?: (row: any) => void;
diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
new file mode 100644
index 00000000..85133424
--- /dev/null
+++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx
@@ -0,0 +1,1086 @@
+"use client";
+
+import React, { useState, useEffect, useCallback, useMemo } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw } from "lucide-react";
+import { toast } from "sonner";
+import { cn } from "@/lib/utils";
+import { apiClient } from "@/lib/api/client";
+import { generateNumberingCode } from "@/lib/api/numberingRule";
+
+import {
+ UniversalFormModalComponentProps,
+ UniversalFormModalConfig,
+ FormSectionConfig,
+ FormFieldConfig,
+ FormDataState,
+ RepeatSectionItem,
+ SelectOptionConfig,
+} from "./types";
+import { defaultConfig, generateUniqueId } from "./config";
+
+/**
+ * 범용 폼 모달 컴포넌트
+ *
+ * 섹션 기반 폼 레이아웃, 채번규칙, 다중 행 저장을 지원합니다.
+ */
+export function UniversalFormModalComponent({
+ component,
+ config: propConfig,
+ isDesignMode = false,
+ isSelected = false,
+ className,
+ style,
+ initialData,
+ onSave,
+ onCancel,
+ onChange,
+}: UniversalFormModalComponentProps) {
+ // 설정 병합
+ const config: UniversalFormModalConfig = useMemo(() => {
+ const componentConfig = component?.config || {};
+ return {
+ ...defaultConfig,
+ ...propConfig,
+ ...componentConfig,
+ modal: {
+ ...defaultConfig.modal,
+ ...propConfig?.modal,
+ ...componentConfig.modal,
+ },
+ saveConfig: {
+ ...defaultConfig.saveConfig,
+ ...propConfig?.saveConfig,
+ ...componentConfig.saveConfig,
+ multiRowSave: {
+ ...defaultConfig.saveConfig.multiRowSave,
+ ...propConfig?.saveConfig?.multiRowSave,
+ ...componentConfig.saveConfig?.multiRowSave,
+ },
+ afterSave: {
+ ...defaultConfig.saveConfig.afterSave,
+ ...propConfig?.saveConfig?.afterSave,
+ ...componentConfig.saveConfig?.afterSave,
+ },
+ },
+ };
+ }, [component?.config, propConfig]);
+
+ // 폼 데이터 상태
+ const [formData, setFormData] = useState({});
+ const [, setOriginalData] = useState>({});
+
+ // 반복 섹션 데이터
+ const [repeatSections, setRepeatSections] = useState<{
+ [sectionId: string]: RepeatSectionItem[];
+ }>({});
+
+ // 섹션 접힘 상태
+ const [collapsedSections, setCollapsedSections] = useState>(new Set());
+
+ // Select 옵션 캐시
+ const [selectOptionsCache, setSelectOptionsCache] = useState<{
+ [key: string]: { value: string; label: string }[];
+ }>({});
+
+ // 로딩 상태
+ const [saving, setSaving] = useState(false);
+
+ // 삭제 확인 다이얼로그
+ const [deleteDialog, setDeleteDialog] = useState<{
+ open: boolean;
+ sectionId: string;
+ itemId: string;
+ }>({ open: false, sectionId: "", itemId: "" });
+
+ // 초기화
+ useEffect(() => {
+ initializeForm();
+ }, [config, initialData]);
+
+ // 폼 초기화
+ const initializeForm = useCallback(async () => {
+ const newFormData: FormDataState = {};
+ const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
+ const newCollapsed = new Set();
+
+ // 섹션별 초기화
+ for (const section of config.sections) {
+ // 접힘 상태 초기화
+ if (section.defaultCollapsed) {
+ newCollapsed.add(section.id);
+ }
+
+ if (section.repeatable) {
+ // 반복 섹션 초기화
+ const minItems = section.repeatConfig?.minItems || 0;
+ const items: RepeatSectionItem[] = [];
+ for (let i = 0; i < minItems; i++) {
+ items.push(createRepeatItem(section, i));
+ }
+ newRepeatSections[section.id] = items;
+ } else {
+ // 일반 섹션 필드 초기화
+ for (const field of section.fields) {
+ // 기본값 설정
+ let value = field.defaultValue ?? "";
+
+ // 부모에서 전달받은 값 적용
+ if (field.receiveFromParent && initialData) {
+ const parentField = field.parentFieldName || field.columnName;
+ if (initialData[parentField] !== undefined) {
+ value = initialData[parentField];
+ }
+ }
+
+ newFormData[field.columnName] = value;
+ }
+ }
+ }
+
+ setFormData(newFormData);
+ setRepeatSections(newRepeatSections);
+ setCollapsedSections(newCollapsed);
+ setOriginalData(initialData || {});
+
+ // 채번규칙 자동 생성
+ await generateNumberingValues(newFormData);
+ }, [config, initialData]);
+
+ // 반복 섹션 아이템 생성
+ const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => {
+ const item: RepeatSectionItem = {
+ _id: generateUniqueId("repeat"),
+ _index: index,
+ };
+
+ for (const field of section.fields) {
+ item[field.columnName] = field.defaultValue ?? "";
+ }
+
+ return item;
+ };
+
+ // 채번규칙 자동 생성
+ const generateNumberingValues = useCallback(
+ async (currentFormData: FormDataState) => {
+ const updatedData = { ...currentFormData };
+ let hasChanges = false;
+
+ for (const section of config.sections) {
+ if (section.repeatable) continue;
+
+ for (const field of section.fields) {
+ if (
+ field.numberingRule?.enabled &&
+ field.numberingRule?.generateOnOpen &&
+ field.numberingRule?.ruleId &&
+ !updatedData[field.columnName]
+ ) {
+ try {
+ const response = await generateNumberingCode(field.numberingRule.ruleId);
+ if (response.success && response.data?.generatedCode) {
+ updatedData[field.columnName] = response.data.generatedCode;
+ hasChanges = true;
+ }
+ } catch (error) {
+ console.error(`채번규칙 생성 실패 (${field.columnName}):`, error);
+ }
+ }
+ }
+ }
+
+ if (hasChanges) {
+ setFormData(updatedData);
+ }
+ },
+ [config],
+ );
+
+ // 필드 값 변경 핸들러
+ const handleFieldChange = useCallback(
+ (columnName: string, value: any) => {
+ setFormData((prev) => {
+ const newData = { ...prev, [columnName]: value };
+ // onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용)
+ if (onChange) {
+ setTimeout(() => onChange(newData), 0);
+ }
+ return newData;
+ });
+ },
+ [onChange],
+ );
+
+ // 반복 섹션 필드 값 변경 핸들러
+ const handleRepeatFieldChange = useCallback((sectionId: string, itemId: string, columnName: string, value: any) => {
+ setRepeatSections((prev) => {
+ const items = prev[sectionId] || [];
+ const newItems = items.map((item) => (item._id === itemId ? { ...item, [columnName]: value } : item));
+ return { ...prev, [sectionId]: newItems };
+ });
+ }, []);
+
+ // 반복 섹션 아이템 추가
+ const handleAddRepeatItem = useCallback(
+ (sectionId: string) => {
+ const section = config.sections.find((s) => s.id === sectionId);
+ if (!section) return;
+
+ const maxItems = section.repeatConfig?.maxItems || 10;
+
+ setRepeatSections((prev) => {
+ const items = prev[sectionId] || [];
+ if (items.length >= maxItems) {
+ toast.error(`최대 ${maxItems}개까지만 추가할 수 있습니다.`);
+ return prev;
+ }
+
+ const newItem = createRepeatItem(section, items.length);
+ return { ...prev, [sectionId]: [...items, newItem] };
+ });
+ },
+ [config],
+ );
+
+ // 반복 섹션 아이템 삭제
+ const handleRemoveRepeatItem = useCallback(
+ (sectionId: string, itemId: string) => {
+ const section = config.sections.find((s) => s.id === sectionId);
+ if (!section) return;
+
+ const minItems = section.repeatConfig?.minItems || 0;
+
+ setRepeatSections((prev) => {
+ const items = prev[sectionId] || [];
+ if (items.length <= minItems) {
+ toast.error(`최소 ${minItems}개는 유지해야 합니다.`);
+ return prev;
+ }
+
+ const newItems = items.filter((item) => item._id !== itemId).map((item, index) => ({ ...item, _index: index }));
+
+ return { ...prev, [sectionId]: newItems };
+ });
+
+ setDeleteDialog({ open: false, sectionId: "", itemId: "" });
+ },
+ [config],
+ );
+
+ // 섹션 접힘 토글
+ const toggleSectionCollapse = useCallback((sectionId: string) => {
+ setCollapsedSections((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(sectionId)) {
+ newSet.delete(sectionId);
+ } else {
+ newSet.add(sectionId);
+ }
+ return newSet;
+ });
+ }, []);
+
+ // Select 옵션 로드
+ const loadSelectOptions = useCallback(
+ async (fieldId: string, optionConfig: SelectOptionConfig): Promise<{ value: string; label: string }[]> => {
+ // 캐시 확인
+ if (selectOptionsCache[fieldId]) {
+ return selectOptionsCache[fieldId];
+ }
+
+ let options: { value: string; label: string }[] = [];
+
+ try {
+ if (optionConfig.type === "static") {
+ options = optionConfig.staticOptions || [];
+ } else if (optionConfig.type === "table" && optionConfig.tableName) {
+ const response = await apiClient.get(`/table-management/tables/${optionConfig.tableName}/data`, {
+ params: { limit: 1000 },
+ });
+ if (response.data?.success && response.data?.data) {
+ options = response.data.data.map((row: any) => ({
+ value: String(row[optionConfig.valueColumn || "id"]),
+ label: String(row[optionConfig.labelColumn || "name"]),
+ }));
+ }
+ } else if (optionConfig.type === "code" && optionConfig.codeCategory) {
+ const response = await apiClient.get(`/common-code/${optionConfig.codeCategory}`);
+ if (response.data?.success && response.data?.data) {
+ options = response.data.data.map((code: any) => ({
+ value: code.code_value || code.codeValue,
+ label: code.code_name || code.codeName,
+ }));
+ }
+ }
+
+ // 캐시 저장
+ setSelectOptionsCache((prev) => ({ ...prev, [fieldId]: options }));
+ } catch (error) {
+ console.error(`Select 옵션 로드 실패 (${fieldId}):`, error);
+ }
+
+ return options;
+ },
+ [selectOptionsCache],
+ );
+
+ // 필수 필드 검증
+ const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => {
+ const missingFields: string[] = [];
+
+ for (const section of config.sections) {
+ if (section.repeatable) continue; // 반복 섹션은 별도 검증
+
+ for (const field of section.fields) {
+ if (field.required && !field.hidden && !field.numberingRule?.hidden) {
+ const value = formData[field.columnName];
+ if (value === undefined || value === null || value === "") {
+ missingFields.push(field.label || field.columnName);
+ }
+ }
+ }
+ }
+
+ return { valid: missingFields.length === 0, missingFields };
+ }, [config.sections, formData]);
+
+ // 저장 처리
+ const handleSave = useCallback(async () => {
+ if (!config.saveConfig.tableName) {
+ toast.error("저장할 테이블이 설정되지 않았습니다.");
+ return;
+ }
+
+ // 필수 필드 검증
+ const { valid, missingFields } = validateRequiredFields();
+ if (!valid) {
+ toast.error(`필수 항목을 입력해주세요: ${missingFields.join(", ")}`);
+ return;
+ }
+
+ setSaving(true);
+
+ try {
+ const { multiRowSave } = config.saveConfig;
+
+ if (multiRowSave?.enabled) {
+ // 다중 행 저장
+ await saveMultipleRows();
+ } else {
+ // 단일 행 저장
+ await saveSingleRow();
+ }
+
+ // 저장 후 동작
+ if (config.saveConfig.afterSave?.showToast) {
+ toast.success("저장되었습니다.");
+ }
+
+ if (config.saveConfig.afterSave?.refreshParent) {
+ window.dispatchEvent(new CustomEvent("refreshParentData"));
+ }
+
+ // onSave 콜백은 저장 완료 알림용으로만 사용
+ // 실제 저장은 이미 위에서 완료됨 (saveSingleRow 또는 saveMultipleRows)
+ // EditModal 등 부모 컴포넌트의 저장 로직이 다시 실행되지 않도록
+ // _saveCompleted 플래그를 포함하여 전달
+ if (onSave) {
+ onSave({ ...formData, _saveCompleted: true });
+ }
+ } catch (error: any) {
+ console.error("저장 실패:", error);
+ toast.error(error.message || "저장에 실패했습니다.");
+ } finally {
+ setSaving(false);
+ }
+ }, [config, formData, repeatSections, onSave, validateRequiredFields]);
+
+ // 단일 행 저장
+ const saveSingleRow = async () => {
+ const dataToSave = { ...formData };
+
+ // 메타데이터 필드 제거
+ Object.keys(dataToSave).forEach((key) => {
+ if (key.startsWith("_")) {
+ delete dataToSave[key];
+ }
+ });
+
+ // 저장 시점 채번규칙 처리
+ for (const section of config.sections) {
+ for (const field of section.fields) {
+ if (
+ field.numberingRule?.enabled &&
+ field.numberingRule?.generateOnSave &&
+ field.numberingRule?.ruleId &&
+ !dataToSave[field.columnName]
+ ) {
+ const response = await generateNumberingCode(field.numberingRule.ruleId);
+ if (response.success && response.data?.generatedCode) {
+ dataToSave[field.columnName] = response.data.generatedCode;
+ }
+ }
+ }
+ }
+
+ const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, dataToSave);
+
+ if (!response.data?.success) {
+ throw new Error(response.data?.message || "저장 실패");
+ }
+ };
+
+ // 다중 행 저장 (겸직 등)
+ const saveMultipleRows = async () => {
+ const { multiRowSave } = config.saveConfig;
+ if (!multiRowSave) return;
+
+ let { commonFields = [], repeatSectionId = "", typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } =
+ multiRowSave;
+
+ // 공통 필드가 설정되지 않은 경우, 기본정보 섹션의 모든 필드를 공통 필드로 사용
+ if (commonFields.length === 0) {
+ const nonRepeatableSections = config.sections.filter((s) => !s.repeatable);
+ commonFields = nonRepeatableSections.flatMap((s) => s.fields.map((f) => f.columnName));
+ console.log("[UniversalFormModal] 공통 필드 자동 설정:", commonFields);
+ }
+
+ // 반복 섹션 ID가 설정되지 않은 경우, 첫 번째 반복 섹션 사용
+ if (!repeatSectionId) {
+ const repeatableSection = config.sections.find((s) => s.repeatable);
+ if (repeatableSection) {
+ repeatSectionId = repeatableSection.id;
+ console.log("[UniversalFormModal] 반복 섹션 자동 설정:", repeatSectionId);
+ }
+ }
+
+ // 디버깅: 설정 확인
+ console.log("[UniversalFormModal] 다중 행 저장 설정:", {
+ commonFields,
+ mainSectionFields,
+ repeatSectionId,
+ typeColumn,
+ mainTypeValue,
+ subTypeValue,
+ });
+ console.log("[UniversalFormModal] 현재 formData:", formData);
+
+ // 공통 필드 데이터 추출
+ const commonData: Record = {};
+ for (const fieldName of commonFields) {
+ if (formData[fieldName] !== undefined) {
+ commonData[fieldName] = formData[fieldName];
+ }
+ }
+ console.log("[UniversalFormModal] 추출된 공통 데이터:", commonData);
+
+ // 메인 섹션 필드 데이터 추출
+ const mainSectionData: Record = {};
+ if (mainSectionFields && mainSectionFields.length > 0) {
+ for (const fieldName of mainSectionFields) {
+ if (formData[fieldName] !== undefined) {
+ mainSectionData[fieldName] = formData[fieldName];
+ }
+ }
+ }
+ console.log("[UniversalFormModal] 추출된 메인 섹션 데이터:", mainSectionData);
+
+ // 저장할 행들 준비
+ const rowsToSave: Record[] = [];
+
+ // 1. 메인 행 생성
+ const mainRow: Record = {
+ ...commonData,
+ ...mainSectionData,
+ };
+ if (typeColumn) {
+ mainRow[typeColumn] = mainTypeValue || "main";
+ }
+ rowsToSave.push(mainRow);
+
+ // 2. 반복 섹션 행들 생성 (겸직 등)
+ const repeatItems = repeatSections[repeatSectionId] || [];
+ for (const item of repeatItems) {
+ const subRow: Record = { ...commonData };
+
+ // 반복 섹션 필드 복사
+ Object.keys(item).forEach((key) => {
+ if (!key.startsWith("_")) {
+ subRow[key] = item[key];
+ }
+ });
+
+ if (typeColumn) {
+ subRow[typeColumn] = subTypeValue || "concurrent";
+ }
+
+ rowsToSave.push(subRow);
+ }
+
+ // 저장 시점 채번규칙 처리 (메인 행만)
+ for (const section of config.sections) {
+ if (section.repeatable) continue;
+
+ for (const field of section.fields) {
+ if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) {
+ const response = await generateNumberingCode(field.numberingRule.ruleId);
+ if (response.success && response.data?.generatedCode) {
+ // 모든 행에 동일한 채번 값 적용 (공통 필드인 경우)
+ if (commonFields.includes(field.columnName)) {
+ rowsToSave.forEach((row) => {
+ row[field.columnName] = response.data?.generatedCode;
+ });
+ } else {
+ rowsToSave[0][field.columnName] = response.data?.generatedCode;
+ }
+ }
+ }
+ }
+ }
+
+ // 모든 행 저장
+ console.log("[UniversalFormModal] 저장할 행들:", rowsToSave);
+ console.log("[UniversalFormModal] 저장 테이블:", config.saveConfig.tableName);
+
+ for (let i = 0; i < rowsToSave.length; i++) {
+ const row = rowsToSave[i];
+ console.log(`[UniversalFormModal] ${i + 1}번째 행 저장 시도:`, row);
+
+ // 빈 객체 체크
+ if (Object.keys(row).length === 0) {
+ console.warn(`[UniversalFormModal] ${i + 1}번째 행이 비어있습니다. 건너뜁니다.`);
+ continue;
+ }
+
+ const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, row);
+
+ if (!response.data?.success) {
+ throw new Error(response.data?.message || `${i + 1}번째 행 저장 실패`);
+ }
+ }
+
+ console.log(`[UniversalFormModal] ${rowsToSave.length}개 행 저장 완료`);
+ };
+
+ // 폼 초기화
+ const handleReset = useCallback(() => {
+ initializeForm();
+ toast.info("폼이 초기화되었습니다.");
+ }, [initializeForm]);
+
+ // 필드 요소 렌더링 (입력 컴포넌트만)
+ const renderFieldElement = (
+ field: FormFieldConfig,
+ value: any,
+ onChangeHandler: (value: any) => void,
+ fieldKey: string,
+ isDisabled: boolean,
+ ) => {
+ return (() => {
+ switch (field.fieldType) {
+ case "textarea":
+ return (
+