버튼별로 데이터 필터링기능
This commit is contained in:
parent
a73b37f558
commit
d6f40f3cd3
|
|
@ -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 필터 추가
|
||||
);
|
||||
|
||||
// 현재 사용자 정보 로드
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; // 첫 번째 (또는 기본) 항목 자동 선택
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 액션 처리
|
||||
* 선택된 데이터가 있으면 함께 전달 (출하계획 등에서 사용)
|
||||
|
|
|
|||
Loading…
Reference in New Issue