버튼별로 데이터 필터링기능

This commit is contained in:
kjs 2025-12-16 18:02:08 +09:00
parent a73b37f558
commit d6f40f3cd3
12 changed files with 909 additions and 113 deletions

View File

@ -311,6 +311,41 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
};
}, [currentPage, searchValues, loadData, component.tableName]);
// 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링)
const [relatedButtonFilter, setRelatedButtonFilter] = useState<{
filterColumn: string;
filterValue: any;
} | null>(null);
useEffect(() => {
const handleRelatedButtonSelect = (event: CustomEvent) => {
const { targetTable, filterColumn, filterValue } = event.detail || {};
// 이 테이블이 대상 테이블인지 확인
if (targetTable === component.tableName) {
console.log("📌 [InteractiveDataTable] RelatedDataButtons 필터 적용:", {
tableName: component.tableName,
filterColumn,
filterValue,
});
setRelatedButtonFilter({ filterColumn, filterValue });
}
};
window.addEventListener("related-button-select" as any, handleRelatedButtonSelect);
return () => {
window.removeEventListener("related-button-select" as any, handleRelatedButtonSelect);
};
}, [component.tableName]);
// relatedButtonFilter 변경 시 데이터 다시 로드
useEffect(() => {
if (relatedButtonFilter) {
loadData(1, searchValues);
}
}, [relatedButtonFilter]);
// 카테고리 타입 컬럼의 값 매핑 로드
useEffect(() => {
const loadCategoryMappings = async () => {
@ -705,10 +740,17 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
return;
}
// 🆕 RelatedDataButtons 필터 적용
let relatedButtonFilterValues: Record<string, any> = {};
if (relatedButtonFilter) {
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue;
}
// 검색 파라미터와 연결 필터 병합
const mergedSearchParams = {
...searchParams,
...linkedFilterValues,
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
};
console.log("🔍 데이터 조회 시작:", {
@ -716,6 +758,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
page,
pageSize,
linkedFilterValues,
relatedButtonFilterValues,
mergedSearchParams,
});
@ -822,7 +865,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
setLoading(false);
}
},
[component.tableName, pageSize, component.autoFilter, splitPanelContext?.selectedLeftData], // 🆕 autoFilter, 연결필터 추가
[component.tableName, pageSize, component.autoFilter, splitPanelContext?.selectedLeftData, relatedButtonFilter], // 🆕 autoFilter, 연결필터, RelatedDataButtons 필터 추가
);
// 현재 사용자 정보 로드

View File

@ -101,6 +101,46 @@ export const SaveModal: React.FC<SaveModalProps> = ({
};
}, [onClose]);
// 필수 항목 검증
const validateRequiredFields = (): { isValid: boolean; missingFields: string[] } => {
const missingFields: string[] = [];
components.forEach((component) => {
// 컴포넌트의 required 속성 확인 (여러 위치에서 체크)
const isRequired =
component.required === true ||
component.style?.required === true ||
component.componentConfig?.required === true;
const columnName = component.columnName || component.style?.columnName;
const label = component.label || component.style?.label || columnName;
console.log("🔍 필수 항목 검증:", {
componentId: component.id,
columnName,
label,
isRequired,
"component.required": component.required,
"style.required": component.style?.required,
"componentConfig.required": component.componentConfig?.required,
value: formData[columnName || ""],
});
if (isRequired && columnName) {
const value = formData[columnName];
// 값이 비어있는지 확인 (null, undefined, 빈 문자열, 공백만 있는 문자열)
if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) {
missingFields.push(label || columnName);
}
}
});
return {
isValid: missingFields.length === 0,
missingFields,
};
};
// 저장 핸들러
const handleSave = async () => {
if (!screenData || !screenId) return;
@ -111,6 +151,13 @@ export const SaveModal: React.FC<SaveModalProps> = ({
return;
}
// ✅ 필수 항목 검증
const validation = validateRequiredFields();
if (!validation.isValid) {
toast.error(`필수 항목을 입력해주세요: ${validation.missingFields.join(", ")}`);
return;
}
try {
setIsSaving(true);

View File

@ -645,6 +645,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="transferData"> </SelectItem>
<SelectItem value="openModalWithData"> + </SelectItem>
<SelectItem value="openRelatedModal"> </SelectItem>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="quickInsert"> </SelectItem>
<SelectItem value="control"> </SelectItem>

View File

@ -943,6 +943,18 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<Label className="text-xs"></Label>
</div>
)}
{/* 숨김 옵션 */}
<div className="flex items-center space-x-2">
<Checkbox
checked={selectedComponent.hidden === true || selectedComponent.componentConfig?.hidden === true}
onCheckedChange={(checked) => {
handleUpdate("hidden", checked);
handleUpdate("componentConfig.hidden", checked);
}}
className="h-4 w-4"
/>
<Label className="text-xs"></Label>
</div>
</div>
</div>
);

View File

@ -319,6 +319,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 숨김 값 추출
const hiddenValue = component.hidden || component.componentConfig?.hidden;
// 숨김 처리: 인터랙티브 모드(실제 뷰)에서만 숨김, 디자인 모드에서는 표시
if (hiddenValue && isInteractive) {
return null;
}
// size.width와 size.height를 style.width와 style.height로 변환
const finalStyle: React.CSSProperties = {
...component.style,

View File

@ -7,19 +7,8 @@ import { Search, X, Check, ChevronsUpDown } from "lucide-react";
import { EntitySearchModal } from "./EntitySearchModal";
import { EntitySearchInputProps, EntitySearchResult } from "./types";
import { cn } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { dynamicFormApi } from "@/lib/api/dynamicForm";
export function EntitySearchInputComponent({
@ -44,7 +33,7 @@ export function EntitySearchInputComponent({
component,
isInteractive,
onFormDataChange,
}: EntitySearchInputProps & {
}: EntitySearchInputProps & {
uiMode?: string;
component?: any;
isInteractive?: boolean;
@ -52,7 +41,7 @@ export function EntitySearchInputComponent({
}) {
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
const [modalOpen, setModalOpen] = useState(false);
const [selectOpen, setSelectOpen] = useState(false);
const [displayValue, setDisplayValue] = useState("");
@ -74,7 +63,7 @@ export function EntitySearchInputComponent({
const loadOptions = async () => {
if (!tableName) return;
setIsLoadingOptions(true);
try {
const response = await dynamicFormApi.getTableData(tableName, {
@ -82,7 +71,7 @@ export function EntitySearchInputComponent({
pageSize: 100, // 최대 100개까지 로드
filters: filterCondition,
});
if (response.success && response.data) {
setOptions(response.data);
}
@ -93,28 +82,73 @@ export function EntitySearchInputComponent({
}
};
// value가 변경되면 표시값 업데이트
// value가 변경되면 표시값 업데이트 (외래키 값으로 데이터 조회)
useEffect(() => {
if (value && selectedData) {
setDisplayValue(selectedData[displayField] || "");
} else if (value && mode === "select" && options.length > 0) {
// select 모드에서 value가 있고 options가 로드된 경우
const found = options.find(opt => opt[valueField] === value);
if (found) {
setSelectedData(found);
setDisplayValue(found[displayField] || "");
const loadDisplayValue = async () => {
if (value && selectedData) {
// 이미 selectedData가 있으면 표시값만 업데이트
setDisplayValue(selectedData[displayField] || "");
} else if (value && mode === "select" && options.length > 0) {
// select 모드에서 value가 있고 options가 로드된 경우
const found = options.find((opt) => opt[valueField] === value);
if (found) {
setSelectedData(found);
setDisplayValue(found[displayField] || "");
}
} else if (value && !selectedData && tableName) {
// value는 있지만 selectedData가 없는 경우 (초기 로드 시)
// API로 해당 데이터 조회
try {
console.log("🔍 [EntitySearchInput] 초기값 조회:", { value, tableName, valueField });
const response = await dynamicFormApi.getTableData(tableName, {
filters: { [valueField]: value },
pageSize: 1,
});
if (response.success && response.data) {
// 데이터 추출 (중첩 구조 처리)
const responseData = response.data as any;
const dataArray = Array.isArray(responseData)
? responseData
: responseData?.data
? Array.isArray(responseData.data)
? responseData.data
: [responseData.data]
: [];
if (dataArray.length > 0) {
const foundData = dataArray[0];
setSelectedData(foundData);
setDisplayValue(foundData[displayField] || "");
console.log("✅ [EntitySearchInput] 초기값 로드 완료:", foundData);
} else {
// 데이터를 찾지 못한 경우 value 자체를 표시
console.log("⚠️ [EntitySearchInput] 초기값 데이터 없음, value 표시:", value);
setDisplayValue(String(value));
}
} else {
console.log("⚠️ [EntitySearchInput] API 응답 실패, value 표시:", value);
setDisplayValue(String(value));
}
} catch (error) {
console.error("❌ [EntitySearchInput] 초기값 조회 실패:", error);
// 에러 시 value 자체를 표시
setDisplayValue(String(value));
}
} else if (!value) {
setDisplayValue("");
setSelectedData(null);
}
} else if (!value) {
setDisplayValue("");
setSelectedData(null);
}
}, [value, displayField, options, mode, valueField]);
};
loadDisplayValue();
}, [value, displayField, options, mode, valueField, tableName, selectedData]);
const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
setSelectedData(fullData);
setDisplayValue(fullData[displayField] || "");
onChange?.(newValue, fullData);
// 🆕 onFormDataChange 호출 (formData에 값 저장)
if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, newValue);
@ -126,7 +160,7 @@ export function EntitySearchInputComponent({
setDisplayValue("");
setSelectedData(null);
onChange?.(null, null);
// 🆕 onFormDataChange 호출 (formData에서 값 제거)
if (isInteractive && onFormDataChange && component?.columnName) {
onFormDataChange(component.columnName, null);
@ -147,14 +181,19 @@ export function EntitySearchInputComponent({
// 높이 계산 (style에서 height가 있으면 사용, 없으면 기본값)
const componentHeight = style?.height;
const inputStyle: React.CSSProperties = componentHeight
? { height: componentHeight }
: {};
const inputStyle: React.CSSProperties = componentHeight ? { height: componentHeight } : {};
// select 모드: 검색 가능한 드롭다운
if (mode === "select") {
return (
<div className={cn("flex flex-col", className)} style={style}>
<div className={cn("relative flex flex-col", className)} style={style}>
{/* 라벨 렌더링 */}
{component?.label && component?.style?.labelDisplay !== false && (
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
{component.label}
{component.required && <span className="text-destructive">*</span>}
</label>
)}
<Popover open={selectOpen} onOpenChange={setSelectOpen}>
<PopoverTrigger asChild>
<Button
@ -165,30 +204,19 @@ export function EntitySearchInputComponent({
className={cn(
"w-full justify-between font-normal",
!componentHeight && "h-8 text-xs sm:h-10 sm:text-sm",
!value && "text-muted-foreground"
!value && "text-muted-foreground",
)}
style={inputStyle}
>
{isLoadingOptions
? "로딩 중..."
: displayValue || placeholder}
{isLoadingOptions ? "로딩 중..." : displayValue || placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput
placeholder={`${displayField} 검색...`}
className="text-xs sm:text-sm"
/>
<CommandInput placeholder={`${displayField} 검색...`} className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm py-4 text-center">
.
</CommandEmpty>
<CommandEmpty className="py-4 text-center text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{options.map((option, index) => (
<CommandItem
@ -198,17 +226,12 @@ export function EntitySearchInputComponent({
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option[valueField] ? "opacity-100" : "opacity-0"
)}
className={cn("mr-2 h-4 w-4", value === option[valueField] ? "opacity-100" : "opacity-0")}
/>
<div className="flex flex-col">
<span className="font-medium">{option[displayField]}</span>
{valueField !== displayField && (
<span className="text-[10px] text-muted-foreground">
{option[valueField]}
</span>
<span className="text-muted-foreground text-[10px]">{option[valueField]}</span>
)}
</div>
</CommandItem>
@ -221,7 +244,7 @@ export function EntitySearchInputComponent({
{/* 추가 정보 표시 */}
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
<div className="text-xs text-muted-foreground space-y-1 px-2 mt-1">
<div className="text-muted-foreground mt-1 space-y-1 px-2 text-xs">
{additionalFields.map((field) => (
<div key={field} className="flex gap-2">
<span className="font-medium">{field}:</span>
@ -236,9 +259,16 @@ export function EntitySearchInputComponent({
// modal, combo, autocomplete 모드
return (
<div className={cn("flex flex-col", className)} style={style}>
<div className={cn("relative flex flex-col", className)} style={style}>
{/* 라벨 렌더링 */}
{component?.label && component?.style?.labelDisplay !== false && (
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
{component.label}
{component.required && <span className="text-destructive">*</span>}
</label>
)}
{/* 입력 필드 */}
<div className="flex gap-2 h-full">
<div className="flex h-full gap-2">
<div className="relative flex-1">
<Input
value={displayValue}
@ -255,7 +285,7 @@ export function EntitySearchInputComponent({
variant="ghost"
size="sm"
onClick={handleClear}
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
className="absolute top-1/2 right-1 h-6 w-6 -translate-y-1/2 p-0"
>
<X className="h-3 w-3" />
</Button>
@ -278,7 +308,7 @@ export function EntitySearchInputComponent({
{/* 추가 정보 표시 */}
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
<div className="text-xs text-muted-foreground space-y-1 px-2 mt-1">
<div className="text-muted-foreground mt-1 space-y-1 px-2 text-xs">
{additionalFields.map((field) => (
<div key={field} className="flex gap-2">
<span className="font-medium">{field}:</span>
@ -306,4 +336,3 @@ export function EntitySearchInputComponent({
</div>
);
}

View File

@ -1,13 +1,24 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Plus, Star, Loader2 } from "lucide-react";
import { Plus, Star, Loader2, ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { dataApi } from "@/lib/api/data";
import type { RelatedDataButtonsConfig, ButtonItem } from "./types";
// 전역 상태: 현재 선택된 버튼 데이터를 외부에서 접근 가능하게
declare global {
interface Window {
__relatedButtonsSelectedData?: {
selectedItem: ButtonItem | null;
masterData: Record<string, any> | null;
config: RelatedDataButtonsConfig | null;
};
}
}
interface RelatedDataButtonsComponentProps {
config: RelatedDataButtonsConfig;
className?: string;
@ -21,12 +32,27 @@ export const RelatedDataButtonsComponent: React.FC<RelatedDataButtonsComponentPr
}) => {
const [buttons, setButtons] = useState<ButtonItem[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedItem, setSelectedItem] = useState<ButtonItem | null>(null);
const [loading, setLoading] = useState(false);
const [masterData, setMasterData] = useState<Record<string, any> | null>(null);
// SplitPanel Context 연결
const splitPanelContext = useSplitPanelContext();
// 선택된 데이터를 전역 상태에 저장 (외부 버튼에서 접근용)
useEffect(() => {
window.__relatedButtonsSelectedData = {
selectedItem,
masterData,
config,
};
console.log("🔄 [RelatedDataButtons] 전역 상태 업데이트:", {
selectedItem,
hasConfig: !!config,
modalLink: config?.modalLink,
});
}, [selectedItem, masterData, config]);
// 좌측 패널에서 선택된 데이터 감지
useEffect(() => {
if (!splitPanelContext?.selectedLeftData) {
@ -89,6 +115,7 @@ export const RelatedDataButtonsComponent: React.FC<RelatedDataButtonsComponentPr
const defaultItem = items.find(item => item.isDefault);
const targetItem = defaultItem || items[0];
setSelectedId(targetItem.id);
setSelectedItem(targetItem);
emitSelection(targetItem);
}
}
@ -104,6 +131,7 @@ export const RelatedDataButtonsComponent: React.FC<RelatedDataButtonsComponentPr
useEffect(() => {
if (masterData) {
setSelectedId(null); // 마스터 변경 시 선택 초기화
setSelectedItem(null);
loadButtons();
}
}, [masterData, loadButtons]);
@ -134,9 +162,82 @@ export const RelatedDataButtonsComponent: React.FC<RelatedDataButtonsComponentPr
// 버튼 클릭 핸들러
const handleButtonClick = useCallback((item: ButtonItem) => {
setSelectedId(item.id);
setSelectedItem(item);
emitSelection(item);
}, [emitSelection]);
// 모달 열기 (선택된 버튼 데이터 전달)
const openModalWithSelectedData = useCallback((targetScreenId: number) => {
if (!selectedItem) {
console.warn("선택된 버튼이 없습니다.");
return;
}
// 데이터 매핑 적용
const initialData: Record<string, any> = {};
if (config.modalLink?.dataMapping) {
config.modalLink.dataMapping.forEach(mapping => {
if (mapping.sourceField === "value") {
initialData[mapping.targetField] = selectedItem.value;
} else if (mapping.sourceField === "id") {
initialData[mapping.targetField] = selectedItem.id;
} else if (selectedItem.rawData[mapping.sourceField] !== undefined) {
initialData[mapping.targetField] = selectedItem.rawData[mapping.sourceField];
}
});
} else {
// 기본 매핑: id를 routing_version_id로 전달
initialData["routing_version_id"] = selectedItem.value || selectedItem.id;
}
console.log("📤 RelatedDataButtons 모달 열기:", {
targetScreenId,
selectedItem,
initialData,
});
window.dispatchEvent(new CustomEvent("open-screen-modal", {
detail: {
screenId: targetScreenId,
initialData,
onSuccess: () => {
loadButtons(); // 모달 성공 후 새로고침
},
},
}));
}, [selectedItem, config.modalLink, loadButtons]);
// 외부 버튼에서 모달 열기 요청 수신
useEffect(() => {
const handleExternalModalOpen = (event: CustomEvent) => {
const { targetScreenId, componentId } = event.detail || {};
// componentId가 지정되어 있고 현재 컴포넌트가 아니면 무시
if (componentId && componentId !== config.sourceMapping?.sourceTable) {
return;
}
if (targetScreenId && selectedItem) {
openModalWithSelectedData(targetScreenId);
}
};
window.addEventListener("related-buttons-open-modal" as any, handleExternalModalOpen);
return () => {
window.removeEventListener("related-buttons-open-modal" as any, handleExternalModalOpen);
};
}, [selectedItem, config.sourceMapping, openModalWithSelectedData]);
// 내부 모달 링크 버튼 클릭
const handleModalLinkClick = useCallback(() => {
if (!config.modalLink?.targetScreenId) {
console.warn("모달 링크 설정이 없습니다.");
return;
}
openModalWithSelectedData(config.modalLink.targetScreenId);
}, [config.modalLink, openModalWithSelectedData]);
// 추가 버튼 클릭
const handleAddClick = useCallback(() => {
if (!config.addButton?.modalScreenId) return;
@ -177,6 +278,7 @@ export const RelatedDataButtonsComponent: React.FC<RelatedDataButtonsComponentPr
const headerConfig = config.headerDisplay;
const addButtonConfig = config.addButton;
const modalLinkConfig = config.modalLink;
return (
<div className={cn("rounded-lg border bg-card", className)} style={style}>
@ -198,18 +300,34 @@ export const RelatedDataButtonsComponent: React.FC<RelatedDataButtonsComponentPr
)}
</div>
{/* 헤더 위치 추가 버튼 */}
{addButtonConfig?.show && addButtonConfig?.position === "header" && (
<Button
variant="default"
size="sm"
onClick={handleAddClick}
className="bg-blue-600 hover:bg-blue-700"
>
<Plus className="mr-1 h-4 w-4" />
{addButtonConfig.label || "버전 추가"}
</Button>
)}
<div className="flex items-center gap-2">
{/* 모달 링크 버튼 (헤더 위치) */}
{modalLinkConfig?.enabled && modalLinkConfig?.triggerType === "button" && modalLinkConfig?.buttonPosition === "header" && (
<Button
variant="outline"
size="sm"
onClick={handleModalLinkClick}
disabled={!selectedItem}
title={!selectedItem ? "버튼을 먼저 선택하세요" : ""}
>
<ExternalLink className="mr-1 h-4 w-4" />
{modalLinkConfig.buttonLabel || "상세 추가"}
</Button>
)}
{/* 헤더 위치 추가 버튼 */}
{addButtonConfig?.show && addButtonConfig?.position === "header" && (
<Button
variant="default"
size="sm"
onClick={handleAddClick}
className="bg-blue-600 hover:bg-blue-700"
>
<Plus className="mr-1 h-4 w-4" />
{addButtonConfig.label || "버전 추가"}
</Button>
)}
</div>
</div>
)}
@ -258,6 +376,20 @@ export const RelatedDataButtonsComponent: React.FC<RelatedDataButtonsComponentPr
</Button>
))}
{/* 모달 링크 버튼 (인라인 위치) */}
{modalLinkConfig?.enabled && modalLinkConfig?.triggerType === "button" && modalLinkConfig?.buttonPosition !== "header" && (
<Button
variant="outline"
size={config.buttonStyle?.size || "default"}
onClick={handleModalLinkClick}
disabled={!selectedItem}
title={!selectedItem ? "버튼을 먼저 선택하세요" : ""}
>
<ExternalLink className="mr-1 h-4 w-4" />
{modalLinkConfig.buttonLabel || "상세 추가"}
</Button>
)}
{/* 인라인 추가 버튼 */}
{addButtonConfig?.show && addButtonConfig?.position !== "header" && (
<Button

View File

@ -11,8 +11,88 @@ import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
import { screenApi } from "@/lib/api/screen";
import type { RelatedDataButtonsConfig } from "./types";
// 화면 정보 타입
interface ScreenInfo {
screenId: number;
screenName: string;
tableName?: string;
}
// 화면 선택 컴포넌트
interface ScreenSelectorProps {
value?: number;
onChange: (screenId: number | undefined, tableName?: string) => void;
placeholder?: string;
}
const ScreenSelector: React.FC<ScreenSelectorProps> = ({ value, onChange, placeholder = "화면 선택" }) => {
const [open, setOpen] = useState(false);
const [screens, setScreens] = useState<ScreenInfo[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const loadScreens = async () => {
setLoading(true);
try {
const response = await screenApi.getScreens({ size: 500 });
if (response.data) {
setScreens(response.data.map((s: any) => ({
screenId: s.screenId,
screenName: s.screenName || s.name || `화면 ${s.screenId}`,
tableName: s.tableName || s.table_name,
})));
}
} catch (error) {
console.error("화면 목록 로드 실패:", error);
} finally {
setLoading(false);
}
};
loadScreens();
}, []);
const selectedScreen = screens.find(s => s.screenId === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between text-xs h-9">
{loading ? "로딩중..." : selectedScreen ? `${selectedScreen.screenName} (${selectedScreen.screenId})` : placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="화면 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-2 text-center"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{screens.map((screen) => (
<CommandItem
key={screen.screenId}
value={`${screen.screenName} ${screen.screenId}`}
onSelect={() => {
onChange(screen.screenId, screen.tableName);
setOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-4 w-4", value === screen.screenId ? "opacity-100" : "opacity-0")} />
<span className="truncate">{screen.screenName}</span>
<span className="ml-auto text-muted-foreground">({screen.screenId})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
interface TableInfo {
tableName: string;
displayName?: string;
@ -37,6 +117,9 @@ export const RelatedDataButtonsConfigPanel: React.FC<RelatedDataButtonsConfigPan
const [allTables, setAllTables] = useState<TableInfo[]>([]);
const [sourceTableColumns, setSourceTableColumns] = useState<ColumnInfo[]>([]);
const [buttonTableColumns, setButtonTableColumns] = useState<ColumnInfo[]>([]);
const [targetModalTableColumns, setTargetModalTableColumns] = useState<ColumnInfo[]>([]); // 대상 모달 테이블 컬럼
const [targetModalTableName, setTargetModalTableName] = useState<string>(""); // 대상 모달 테이블명
const [eventTargetTableColumns, setEventTargetTableColumns] = useState<ColumnInfo[]>([]); // 하위 테이블 연동 대상 테이블 컬럼
// Popover 상태
const [sourceTableOpen, setSourceTableOpen] = useState(false);
@ -104,6 +187,69 @@ export const RelatedDataButtonsConfigPanel: React.FC<RelatedDataButtonsConfigPan
loadColumns();
}, [config.buttonDataSource?.tableName]);
// 대상 모달 화면의 테이블명 로드 (초기 로드 및 screenId 변경 시)
useEffect(() => {
const loadTargetScreenTable = async () => {
if (!config.modalLink?.targetScreenId) {
setTargetModalTableName("");
return;
}
try {
const screenInfo = await screenApi.getScreen(config.modalLink.targetScreenId);
if (screenInfo?.tableName) {
setTargetModalTableName(screenInfo.tableName);
}
} catch (error) {
console.error("대상 모달 화면 정보 로드 실패:", error);
}
};
loadTargetScreenTable();
}, [config.modalLink?.targetScreenId]);
// 대상 모달 테이블 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!targetModalTableName) {
setTargetModalTableColumns([]);
return;
}
try {
const response = await getTableColumns(targetModalTableName);
if (response.success && response.data?.columns) {
setTargetModalTableColumns(response.data.columns.map((c: any) => ({
columnName: c.columnName || c.column_name,
columnLabel: c.columnLabel || c.column_label || c.displayName,
})));
}
} catch (error) {
console.error("대상 모달 테이블 컬럼 로드 실패:", error);
}
};
loadColumns();
}, [targetModalTableName]);
// 하위 테이블 연동 대상 테이블 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.events?.targetTable) {
setEventTargetTableColumns([]);
return;
}
try {
const response = await getTableColumns(config.events.targetTable);
if (response.success && response.data?.columns) {
setEventTargetTableColumns(response.data.columns.map((c: any) => ({
columnName: c.columnName || c.column_name,
columnLabel: c.columnLabel || c.column_label || c.displayName,
})));
}
} catch (error) {
console.error("하위 테이블 연동 대상 테이블 컬럼 로드 실패:", error);
}
};
loadColumns();
}, [config.events?.targetTable]);
// 설정 업데이트 헬퍼
const updateConfig = useCallback((updates: Partial<RelatedDataButtonsConfig>) => {
onChange({ ...config, ...updates });
@ -151,6 +297,13 @@ export const RelatedDataButtonsConfigPanel: React.FC<RelatedDataButtonsConfigPan
});
}, [config, onChange]);
const updateModalLink = useCallback((updates: Partial<NonNullable<RelatedDataButtonsConfig["modalLink"]>>) => {
onChange({
...config,
modalLink: { ...config.modalLink, ...updates },
});
}, [config, onChange]);
const tables = allTables.length > 0 ? allTables : propTables;
return (
@ -471,11 +624,27 @@ export const RelatedDataButtonsConfigPanel: React.FC<RelatedDataButtonsConfigPan
<div className="space-y-2">
<Label className="text-xs"> ( )</Label>
<Input
<Select
value={config.events?.targetFilterColumn || ""}
onChange={(e) => updateEvents({ targetFilterColumn: e.target.value })}
placeholder="예: routing_version_id"
/>
onValueChange={(value) => updateEvents({ targetFilterColumn: value })}
>
<SelectTrigger>
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{eventTargetTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
{eventTargetTableColumns.length === 0 && config.events?.targetTable && (
<p className="text-xs text-muted-foreground"> ...</p>
)}
{!config.events?.targetTable && (
<p className="text-xs text-muted-foreground"> </p>
)}
</div>
</div>
@ -517,18 +686,165 @@ export const RelatedDataButtonsConfigPanel: React.FC<RelatedDataButtonsConfigPan
</div>
<div className="space-y-1">
<Label className="text-xs"> ID</Label>
<Input
type="number"
value={config.addButton?.modalScreenId || ""}
onChange={(e) => updateAddButton({ modalScreenId: parseInt(e.target.value) || undefined })}
placeholder="화면 ID"
<Label className="text-xs"> </Label>
<ScreenSelector
value={config.addButton?.modalScreenId}
onChange={(screenId) => updateAddButton({ modalScreenId: screenId })}
placeholder="화면 선택"
/>
</div>
</div>
)}
</div>
{/* 모달 연동 설정 (선택된 버튼 데이터를 모달로 전달) */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold"> ( )</Label>
<Switch
checked={config.modalLink?.enabled ?? false}
onCheckedChange={(checked) => updateModalLink({ enabled: checked })}
/>
</div>
{config.modalLink?.enabled && (
<div className="space-y-2 pl-2 border-l-2 border-gray-200">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.modalLink?.triggerType || "external"}
onValueChange={(value: any) => updateModalLink({ triggerType: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="external"> ( )</SelectItem>
<SelectItem value="button"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
{config.modalLink?.triggerType === "button" && (
<>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.modalLink?.buttonLabel || ""}
onChange={(e) => updateModalLink({ buttonLabel: e.target.value })}
placeholder="공정 추가"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.modalLink?.buttonPosition || "header"}
onValueChange={(value: any) => updateModalLink({ buttonPosition: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="header"> </SelectItem>
<SelectItem value="inline"> </SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<ScreenSelector
value={config.modalLink?.targetScreenId}
onChange={(screenId, tableName) => {
updateModalLink({ targetScreenId: screenId });
if (tableName) {
setTargetModalTableName(tableName);
}
}}
placeholder="화면 선택"
/>
{targetModalTableName && (
<p className="text-xs text-muted-foreground mt-1">
: {targetModalTableName}
</p>
)}
</div>
<div className="space-y-2 pt-2 border-t">
<Label className="text-xs font-medium"> </Label>
<p className="text-xs text-muted-foreground">
.
</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.modalLink?.dataMapping?.[0]?.sourceField === "id" ? "__id__" :
config.modalLink?.dataMapping?.[0]?.sourceField === "value" ? "__value__" :
config.modalLink?.dataMapping?.[0]?.sourceField || "__id__"}
onValueChange={(value) => updateModalLink({
dataMapping: [{
sourceField: value === "__id__" ? "id" : value === "__value__" ? "value" : value,
targetField: config.modalLink?.dataMapping?.[0]?.targetField || ""
}]
})}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__id__">ID ( )</SelectItem>
<SelectItem value="__value__"> (valueColumn)</SelectItem>
{buttonTableColumns
.filter(col => col.columnName !== "id") // id 중복 제거
.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.modalLink?.dataMapping?.[0]?.targetField || "__none__"}
onValueChange={(value) => updateModalLink({
dataMapping: [{
sourceField: config.modalLink?.dataMapping?.[0]?.sourceField || "id",
targetField: value === "__none__" ? "" : value
}]
})}
>
<SelectTrigger>
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{targetModalTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
{targetModalTableColumns.length === 0 && targetModalTableName && (
<p className="text-xs text-muted-foreground"> ...</p>
)}
{!targetModalTableName && (
<p className="text-xs text-muted-foreground"> </p>
)}
</div>
</div>
</div>
</div>
)}
</div>
{/* 기타 설정 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>

View File

@ -65,6 +65,22 @@ export interface EventConfig {
customEventName?: string;
}
/**
* ( )
*/
export interface ModalLinkConfig {
enabled?: boolean; // 모달 연동 활성화
targetScreenId?: number; // 열릴 모달 화면 ID
triggerType?: "button" | "external"; // button: 별도 버튼, external: 외부 버튼에서 호출
buttonLabel?: string; // 버튼 텍스트 (triggerType이 button일 때)
buttonPosition?: "header" | "inline"; // 버튼 위치
// 데이터 매핑: 선택된 버튼 데이터 → 모달 초기값
dataMapping?: {
sourceField: string; // 버튼 데이터의 필드명 (예: "id", "value")
targetField: string; // 모달에 전달할 필드명 (예: "routing_version_id")
}[];
}
/**
*
*/
@ -90,6 +106,9 @@ export interface RelatedDataButtonsConfig {
// 이벤트 설정
events?: EventConfig;
// 모달 연동 설정 (선택된 버튼 데이터를 모달로 전달)
modalLink?: ModalLinkConfig;
// 자동 선택
autoSelectFirst?: boolean; // 첫 번째 (또는 기본) 항목 자동 선택

View File

@ -304,6 +304,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 연결된 필터 상태 (다른 컴포넌트 값으로 필터링)
const [linkedFilterValues, setLinkedFilterValues] = useState<Record<string, any>>({});
// 🆕 RelatedDataButtons 컴포넌트에서 발생하는 필터 상태
const [relatedButtonFilter, setRelatedButtonFilter] = useState<{
filterColumn: string;
filterValue: any;
} | null>(null);
// TableOptions Context
const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions();
const [filters, setFilters] = useState<TableFilter[]>([]);
@ -1548,10 +1554,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return;
}
// 검색 필터와 연결 필터 병합
// 🆕 RelatedDataButtons 필터 값 준비
let relatedButtonFilterValues: Record<string, any> = {};
if (relatedButtonFilter) {
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = {
value: relatedButtonFilter.filterValue,
operator: "equals",
};
console.log("🔗 [TableList] RelatedDataButtons 필터 적용:", relatedButtonFilterValues);
}
// 검색 필터, 연결 필터, RelatedDataButtons 필터 병합
const filters = {
...(Object.keys(searchValues).length > 0 ? searchValues : {}),
...linkedFilterValues,
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
};
const hasFilters = Object.keys(filters).length > 0;
@ -1748,6 +1765,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
splitPanelPosition,
currentSplitPosition,
splitPanelContext?.selectedLeftData,
// 🆕 RelatedDataButtons 필터 추가
relatedButtonFilter,
]);
const fetchTableDataDebounced = useCallback(
@ -4764,6 +4783,37 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
}, [tableConfig.selectedTable, isDesignMode]);
// 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링)
useEffect(() => {
const handleRelatedButtonSelect = (event: CustomEvent) => {
const { targetTable, filterColumn, filterValue } = event.detail || {};
// 이 테이블이 대상 테이블인지 확인
if (targetTable === tableConfig.selectedTable) {
console.log("📌 [TableList] RelatedDataButtons 필터 적용:", {
tableName: tableConfig.selectedTable,
filterColumn,
filterValue,
});
setRelatedButtonFilter({ filterColumn, filterValue });
}
};
window.addEventListener("related-button-select" as any, handleRelatedButtonSelect);
return () => {
window.removeEventListener("related-button-select" as any, handleRelatedButtonSelect);
};
}, [tableConfig.selectedTable]);
// 🆕 relatedButtonFilter 변경 시 데이터 다시 로드
useEffect(() => {
if (relatedButtonFilter && !isDesignMode) {
console.log("🔄 [TableList] RelatedDataButtons 필터 변경으로 데이터 새로고침:", relatedButtonFilter);
setRefreshTrigger((prev) => prev + 1);
}
}, [relatedButtonFilter, isDesignMode]);
// 🎯 컬럼 너비 자동 계산 (내용 기반)
const calculateOptimalColumnWidth = useCallback(
(columnName: string, displayName: string): number => {

View File

@ -55,29 +55,11 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
onClick?.();
};
// DOM에 전달하면 안 되는 React-specific props 필터링
const {
selectedScreen,
onZoneComponentDrop,
onZoneClick,
componentConfig: _componentConfig,
component: _component,
isSelected: _isSelected,
onClick: _onClick,
onDragStart: _onDragStart,
onDragEnd: _onDragEnd,
size: _size,
position: _position,
style: _style,
screenId: _screenId,
tableName: _tableName,
onRefresh: _onRefresh,
onClose: _onClose,
...domProps
} = props;
// DOM에 전달하면 안 되는 React-specific props 필터링 - 모든 커스텀 props 제거
// domProps를 사용하지 않고 필요한 props만 명시적으로 전달
return (
<div style={componentStyle} className={className} {...domProps}>
<div style={componentStyle} className={className}>
{/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && (
<label

View File

@ -17,6 +17,7 @@ export type ButtonActionType =
| "edit" // 편집
| "copy" // 복사 (품목코드 초기화)
| "navigate" // 페이지 이동
| "openRelatedModal" // 연관 데이터 버튼의 선택 데이터로 모달 열기
| "openModalWithData" // 데이터를 전달하면서 모달 열기
| "modal" // 모달 열기
| "control" // 제어 흐름
@ -213,6 +214,12 @@ export interface ButtonActionConfig {
};
};
// 연관 데이터 버튼 모달 열기 관련
relatedModalConfig?: {
targetScreenId: number; // 열릴 모달 화면 ID
componentId?: string; // 특정 RelatedDataButtons 컴포넌트 지정 (선택사항)
};
// 즉시 저장 (Quick Insert) 관련
quickInsertConfig?: {
targetTable: string; // 저장할 테이블명
@ -361,6 +368,9 @@ export class ButtonActionExecutor {
case "openModalWithData":
return await this.handleOpenModalWithData(config, context);
case "openRelatedModal":
return await this.handleOpenRelatedModal(config, context);
case "modal":
return await this.handleModal(config, context);
@ -411,6 +421,49 @@ export class ButtonActionExecutor {
}
}
/**
*
*/
private static validateRequiredFields(context: ButtonActionContext): { isValid: boolean; missingFields: string[] } {
const missingFields: string[] = [];
const { formData, allComponents } = context;
if (!allComponents || allComponents.length === 0) {
console.log("⚠️ [validateRequiredFields] allComponents 없음 - 검증 스킵");
return { isValid: true, missingFields: [] };
}
allComponents.forEach((component: any) => {
// 컴포넌트의 required 속성 확인 (여러 위치에서 체크)
const isRequired =
component.required === true ||
component.style?.required === true ||
component.componentConfig?.required === true;
const columnName = component.columnName || component.style?.columnName;
const label = component.label || component.style?.label || columnName;
if (isRequired && columnName) {
const value = formData[columnName];
// 값이 비어있는지 확인 (null, undefined, 빈 문자열, 공백만 있는 문자열)
if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) {
console.log("🔍 [validateRequiredFields] 필수 항목 누락:", {
columnName,
label,
value,
isRequired,
});
missingFields.push(label || columnName);
}
}
});
return {
isValid: missingFields.length === 0,
missingFields,
};
}
/**
* (INSERT/UPDATE - DB )
*/
@ -450,6 +503,19 @@ export class ButtonActionExecutor {
hasOnSave: !!onSave,
});
// ✅ 필수 항목 검증
console.log("🔍 [handleSave] 필수 항목 검증 시작:", {
hasAllComponents: !!context.allComponents,
allComponentsLength: context.allComponents?.length || 0,
});
const requiredValidation = this.validateRequiredFields(context);
if (!requiredValidation.isValid) {
console.log("❌ [handleSave] 필수 항목 누락:", requiredValidation.missingFields);
toast.error(`필수 항목을 입력해주세요: ${requiredValidation.missingFields.join(", ")}`);
return false;
}
console.log("✅ [handleSave] 필수 항목 검증 통과");
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
if (onSave) {
console.log("✅ [handleSave] onSave 콜백 발견 - 콜백 실행");
@ -1813,6 +1879,100 @@ export class ButtonActionExecutor {
return false;
}
/**
*
* RelatedDataButtons
*/
private static async handleOpenRelatedModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
// 버튼 설정에서 targetScreenId 가져오기 (여러 위치에서 확인)
const targetScreenId = config.relatedModalConfig?.targetScreenId || config.targetScreenId;
console.log("🔍 [openRelatedModal] 설정 확인:", {
config,
relatedModalConfig: config.relatedModalConfig,
targetScreenId: config.targetScreenId,
finalTargetScreenId: targetScreenId,
});
if (!targetScreenId) {
console.error("❌ [openRelatedModal] targetScreenId가 설정되지 않았습니다.");
toast.error("모달 화면 ID가 설정되지 않았습니다.");
return false;
}
// RelatedDataButtons에서 선택된 데이터 가져오기
const relatedData = window.__relatedButtonsSelectedData;
console.log("🔍 [openRelatedModal] RelatedDataButtons 데이터:", {
relatedData,
selectedItem: relatedData?.selectedItem,
config: relatedData?.config,
});
if (!relatedData?.selectedItem) {
console.warn("⚠️ [openRelatedModal] 선택된 버튼이 없습니다.");
toast.warning("먼저 버튼을 선택해주세요.");
return false;
}
const { selectedItem, config: relatedConfig } = relatedData;
// 데이터 매핑 적용
const initialData: Record<string, any> = {};
console.log("🔍 [openRelatedModal] 매핑 설정:", {
modalLink: relatedConfig?.modalLink,
dataMapping: relatedConfig?.modalLink?.dataMapping,
});
if (relatedConfig?.modalLink?.dataMapping && relatedConfig.modalLink.dataMapping.length > 0) {
relatedConfig.modalLink.dataMapping.forEach(mapping => {
console.log("🔍 [openRelatedModal] 매핑 처리:", {
mapping,
sourceField: mapping.sourceField,
targetField: mapping.targetField,
selectedItemValue: selectedItem.value,
selectedItemId: selectedItem.id,
rawDataValue: selectedItem.rawData[mapping.sourceField],
});
if (mapping.sourceField === "value") {
initialData[mapping.targetField] = selectedItem.value;
} else if (mapping.sourceField === "id") {
initialData[mapping.targetField] = selectedItem.id;
} else if (selectedItem.rawData[mapping.sourceField] !== undefined) {
initialData[mapping.targetField] = selectedItem.rawData[mapping.sourceField];
}
});
} else {
// 기본 매핑: id를 routing_version_id로 전달
console.log("🔍 [openRelatedModal] 기본 매핑 사용");
initialData["routing_version_id"] = selectedItem.value || selectedItem.id;
}
console.log("📤 [openRelatedModal] 모달 열기:", {
targetScreenId,
selectedItem,
initialData,
});
// 모달 열기 이벤트 발생 (ScreenModal은 editData를 사용)
window.dispatchEvent(new CustomEvent("openScreenModal", {
detail: {
screenId: targetScreenId,
title: config.modalTitle,
description: config.modalDescription,
editData: initialData, // ScreenModal은 editData로 폼 데이터를 받음
onSuccess: () => {
// 성공 후 데이터 새로고침
window.dispatchEvent(new CustomEvent("refreshTableData"));
},
},
}));
return true;
}
/**
*
* ( )