feature/screen-management #295

Merged
kjs merged 5 commits from feature/screen-management into main 2025-12-17 15:00:54 +09:00
24 changed files with 1360 additions and 586 deletions

View File

@ -3245,6 +3245,7 @@ export const resetUserPassword = async (
/**
* ( )
* column_labels
*/
export async function getTableSchema(
req: AuthenticatedRequest,
@ -3264,20 +3265,25 @@ export async function getTableSchema(
logger.info("테이블 스키마 조회", { tableName, companyCode });
// information_schema에서 컬럼 정보 가져오기
// information_schema와 column_labels를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기
const schemaQuery = `
SELECT
column_name,
data_type,
is_nullable,
column_default,
character_maximum_length,
numeric_precision,
numeric_scale
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
ORDER BY ordinal_position
ic.column_name,
ic.data_type,
ic.is_nullable,
ic.column_default,
ic.character_maximum_length,
ic.numeric_precision,
ic.numeric_scale,
cl.column_label,
cl.display_order
FROM information_schema.columns ic
LEFT JOIN column_labels cl
ON cl.table_name = ic.table_name
AND cl.column_name = ic.column_name
WHERE ic.table_schema = 'public'
AND ic.table_name = $1
ORDER BY COALESCE(cl.display_order, ic.ordinal_position), ic.ordinal_position
`;
const columns = await query<any>(schemaQuery, [tableName]);
@ -3290,9 +3296,10 @@ export async function getTableSchema(
return;
}
// 컬럼 정보를 간단한 형태로 변환
// 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함)
const columnList = columns.map((col: any) => ({
name: col.column_name,
label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용
type: col.data_type,
nullable: col.is_nullable === "YES",
default: col.column_default,

View File

@ -18,10 +18,11 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 🆕 컴포넌트 간 통신
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 🆕 분할 패널 리사이즈
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 테이블 옵션
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 높이 관리
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 컴포넌트 간 통신
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 분할 패널 리사이즈
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리
function ScreenViewPage() {
const params = useParams();
@ -307,7 +308,8 @@ function ScreenViewPage() {
return (
<ScreenPreviewProvider isPreviewMode={false}>
<TableOptionsProvider>
<ActiveTabProvider>
<TableOptionsProvider>
<div ref={containerRef} className="bg-background h-full w-full overflow-auto p-3">
{/* 레이아웃 준비 중 로딩 표시 */}
{!layoutReady && (
@ -786,7 +788,8 @@ function ScreenViewPage() {
}}
/>
</div>
</TableOptionsProvider>
</TableOptionsProvider>
</ActiveTabProvider>
</ScreenPreviewProvider>
);
}

View File

@ -29,7 +29,6 @@ import {
Plus,
Minus,
ArrowRight,
Save,
Zap,
} from "lucide-react";
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
@ -52,12 +51,6 @@ interface ColumnMapping {
systemColumn: string | null;
}
interface UploadConfig {
name: string;
type: string;
mappings: ColumnMapping[];
}
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
open,
onOpenChange,
@ -88,8 +81,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const [excelColumns, setExcelColumns] = useState<string[]>([]);
const [systemColumns, setSystemColumns] = useState<TableColumn[]>([]);
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
const [configName, setConfigName] = useState<string>("");
const [configType, setConfigType] = useState<string>("");
// 4단계: 확인
const [isUploading, setIsUploading] = useState(false);
@ -114,7 +105,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const data = await importFromExcel(selectedFile, sheets[0]);
setAllData(data);
setDisplayData(data.slice(0, 10));
setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능)
if (data.length > 0) {
const columns = Object.keys(data[0]);
@ -139,7 +130,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
try {
const data = await importFromExcel(file, sheetName);
setAllData(data);
setDisplayData(data.slice(0, 10));
setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능)
if (data.length > 0) {
const columns = Object.keys(data[0]);
@ -236,13 +227,23 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
};
// 자동 매핑
// 자동 매핑 - 컬럼명과 라벨 모두 비교
const handleAutoMapping = () => {
const newMappings = excelColumns.map((excelCol) => {
const matchedSystemCol = systemColumns.find(
(sysCol) => sysCol.name.toLowerCase() === excelCol.toLowerCase()
const normalizedExcelCol = excelCol.toLowerCase().trim();
// 1. 먼저 라벨로 매칭 시도
let matchedSystemCol = systemColumns.find(
(sysCol) => sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol
);
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
if (!matchedSystemCol) {
matchedSystemCol = systemColumns.find(
(sysCol) => sysCol.name.toLowerCase().trim() === normalizedExcelCol
);
}
return {
excelColumn: excelCol,
systemColumn: matchedSystemCol ? matchedSystemCol.name : null,
@ -265,28 +266,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
);
};
// 설정 저장
const handleSaveConfig = () => {
if (!configName.trim()) {
toast.error("거래처명을 입력해주세요.");
return;
}
const config: UploadConfig = {
name: configName,
type: configType,
mappings: columnMappings,
};
const savedConfigs = JSON.parse(
localStorage.getItem("excelUploadConfigs") || "[]"
);
savedConfigs.push(config);
localStorage.setItem("excelUploadConfigs", JSON.stringify(savedConfigs));
toast.success("설정이 저장되었습니다.");
};
// 다음 단계
const handleNext = () => {
if (currentStep === 1 && !file) {
@ -317,7 +296,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
setIsUploading(true);
try {
const mappedData = displayData.map((row) => {
// allData를 사용하여 전체 데이터 업로드 (displayData는 미리보기용 10개만)
const mappedData = allData.map((row) => {
const mappedRow: Record<string, any> = {};
columnMappings.forEach((mapping) => {
if (mapping.systemColumn) {
@ -379,8 +359,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
setExcelColumns([]);
setSystemColumns([]);
setColumnMappings([]);
setConfigName("");
setConfigType("");
}
}, [open]);
@ -689,27 +667,25 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
</div>
)}
{/* 3단계: 컬럼 매핑 - 3단 레이아웃 */}
{/* 3단계: 컬럼 매핑 */}
{currentStep === 3 && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[2fr_3fr_2fr]">
{/* 왼쪽: 컬럼 매핑 설정 제목 + 자동 매핑 버튼 */}
<div className="space-y-4">
<div>
<h3 className="mb-3 text-sm font-semibold sm:text-base"> </h3>
<Button
type="button"
variant="default"
size="sm"
onClick={handleAutoMapping}
className="h-8 w-full text-xs sm:h-9 sm:text-sm"
>
<Zap className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="space-y-4">
{/* 상단: 제목 + 자동 매핑 버튼 */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold sm:text-base"> </h3>
<Button
type="button"
variant="default"
size="sm"
onClick={handleAutoMapping}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Zap className="mr-2 h-4 w-4" />
</Button>
</div>
{/* 중앙: 매핑 리스트 */}
{/* 매핑 리스트 */}
<div className="space-y-2">
<div className="grid grid-cols-[1fr_auto_1fr] gap-2 text-[10px] font-medium text-muted-foreground sm:text-xs">
<div> </div>
@ -734,7 +710,14 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="매핑 안함" />
<SelectValue placeholder="매핑 안함">
{mapping.systemColumn
? (() => {
const col = systemColumns.find(c => c.name === mapping.systemColumn);
return col?.label || mapping.systemColumn;
})()
: "매핑 안함"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-xs sm:text-sm">
@ -746,7 +729,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
value={col.name}
className="text-xs sm:text-sm"
>
{col.name} ({col.type})
{col.label || col.name} ({col.type})
</SelectItem>
))}
</SelectContent>
@ -755,50 +738,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
))}
</div>
</div>
{/* 오른쪽: 현재 설정 저장 */}
<div className="rounded-md border border-border bg-muted/30 p-4">
<div className="mb-4 flex items-center gap-2">
<Save className="h-4 w-4" />
<h3 className="text-sm font-semibold sm:text-base"> </h3>
</div>
<div className="space-y-3">
<div>
<Label htmlFor="config-name" className="text-[10px] sm:text-xs">
*
</Label>
<Input
id="config-name"
value={configName}
onChange={(e) => setConfigName(e.target.value)}
placeholder="거래처 선택"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="config-type" className="text-[10px] sm:text-xs">
</Label>
<Input
id="config-type"
value={configType}
onChange={(e) => setConfigType(e.target.value)}
placeholder="유형을 입력하세요 (예: 원자재)"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<Button
type="button"
variant="default"
size="sm"
onClick={handleSaveConfig}
className="h-8 w-full text-xs sm:h-9 sm:text-sm"
>
<Save className="mr-2 h-3 w-3" />
</Button>
</div>
</div>
</div>
)}
@ -815,7 +754,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
<span className="font-medium">:</span> {selectedSheet}
</p>
<p>
<span className="font-medium"> :</span> {displayData.length}
<span className="font-medium"> :</span> {allData.length}
</p>
<p>
<span className="font-medium">:</span> {tableName}

View File

@ -12,6 +12,7 @@ import { useAuth } from "@/hooks/useAuth";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
interface ScreenModalState {
isOpen: boolean;
@ -666,6 +667,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div>
</div>
) : screenData ? (
<ActiveTabProvider>
<TableOptionsProvider>
<div
className="relative mx-auto bg-white"
@ -738,6 +740,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
})}
</div>
</TableOptionsProvider>
</ActiveTabProvider>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p>

View File

@ -51,6 +51,7 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
/**
* 🔗
@ -2103,7 +2104,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
return (
<SplitPanelProvider>
<TableOptionsProvider>
<ActiveTabProvider>
<TableOptionsProvider>
<div className="flex h-full flex-col">
{/* 테이블 옵션 툴바 */}
<TableOptionsToolbar />
@ -2210,7 +2212,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
</div>
</DialogContent>
</Dialog>
</TableOptionsProvider>
</TableOptionsProvider>
</ActiveTabProvider>
</SplitPanelProvider>
);
};

View File

@ -39,22 +39,25 @@ interface InteractiveScreenViewerProps {
id: number;
tableName?: string;
};
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
menuObjid?: number; // 메뉴 OBJID (코드 스코프용)
onSave?: () => Promise<void>;
onRefresh?: () => void;
onFlowRefresh?: () => void;
// 🆕 외부에서 전달받는 사용자 정보 (ScreenModal 등에서 사용)
// 외부에서 전달받는 사용자 정보 (ScreenModal 등에서 사용)
userId?: string;
userName?: string;
companyCode?: string;
// 🆕 그룹 데이터 (EditModal에서 전달)
// 그룹 데이터 (EditModal에서 전달)
groupedData?: Record<string, any>[];
// 🆕 비활성화할 필드 목록 (EditModal에서 전달)
// 비활성화할 필드 목록 (EditModal에서 전달)
disabledFields?: string[];
// 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
// EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
isInModal?: boolean;
// 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
// 원본 데이터 (수정 모드에서 UPDATE 판단용)
originalData?: Record<string, any> | null;
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
parentTabId?: string; // 부모 탭 ID
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
}
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
@ -74,7 +77,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
groupedData,
disabledFields = [],
isInModal = false,
originalData, // 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
originalData,
parentTabId,
parentTabsComponentId,
}) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { userName: authUserName, user: authUser } = useAuth();
@ -359,43 +364,43 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
component={comp}
isInteractive={true}
formData={formData}
originalData={originalData || undefined} // 🆕 원본 데이터 전달 (UPDATE 판단용)
originalData={originalData || undefined}
onFormDataChange={handleFormDataChange}
screenId={screenInfo?.id}
tableName={screenInfo?.tableName}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
userId={user?.userId} // ✅ 사용자 ID 전달
userName={user?.userName} // ✅ 사용자 이름 전달
companyCode={user?.companyCode} // ✅ 회사 코드 전달
onSave={onSave} // 🆕 EditModal의 handleSave 콜백 전달
allComponents={allComponents} // 🆕 같은 화면의 모든 컴포넌트 전달 (TableList 자동 감지용)
menuObjid={menuObjid}
userId={user?.userId}
userName={user?.userName}
companyCode={user?.companyCode}
onSave={onSave}
allComponents={allComponents}
selectedRowsData={selectedRowsData}
onSelectedRowsChange={(selectedRows, selectedData) => {
console.log("🔍 테이블에서 선택된 행 데이터:", selectedData);
console.log("테이블에서 선택된 행 데이터:", selectedData);
setSelectedRowsData(selectedData);
}}
// 🆕 그룹 데이터 전달 (EditModal → ModalRepeaterTable)
groupedData={groupedData}
// 🆕 비활성화 필드 전달 (EditModal → 각 컴포넌트)
disabledFields={disabledFields}
flowSelectedData={flowSelectedData}
flowSelectedStepId={flowSelectedStepId}
onFlowSelectedDataChange={(selectedData, stepId) => {
console.log("🔍 플로우에서 선택된 데이터:", { selectedData, stepId });
console.log("플로우에서 선택된 데이터:", { selectedData, stepId });
setFlowSelectedData(selectedData);
setFlowSelectedStepId(stepId);
}}
onRefresh={
onRefresh ||
(() => {
// 부모로부터 전달받은 onRefresh 또는 기본 동작
console.log("🔄 InteractiveScreenViewerDynamic onRefresh 호출");
console.log("InteractiveScreenViewerDynamic onRefresh 호출");
})
}
onFlowRefresh={onFlowRefresh}
onClose={() => {
// buttonActions.ts가 이미 처리함
}}
// 탭 관련 정보 전달
parentTabId={parentTabId}
parentTabsComponentId={parentTabsComponentId}
/>
);
}

View File

@ -958,6 +958,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
codeCategory: col.codeCategory || col.code_category,
codeValue: col.codeValue || col.code_value,
// 엔티티 타입용 참조 테이블 정보
referenceTable: col.referenceTable || col.reference_table,
referenceColumn: col.referenceColumn || col.reference_column,
displayColumn: col.displayColumn || col.display_column,
};
});

View File

@ -6,18 +6,10 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Database, Search, Plus, Trash2 } from "lucide-react";
import { Database, Search, Info } from "lucide-react";
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
import { WidgetComponent, EntityTypeConfig } from "@/types/screen";
interface EntityField {
name: string;
label: string;
type: string;
visible: boolean;
}
import { tableTypeApi } from "@/lib/api/screen";
export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
component,
@ -27,16 +19,31 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
const widget = component as WidgetComponent;
const config = (widget.webTypeConfig as EntityTypeConfig) || {};
// 로컬 상태
// 테이블 타입 관리에서 설정된 참조 테이블 정보
const [referenceInfo, setReferenceInfo] = useState<{
referenceTable: string;
referenceColumn: string;
displayColumn: string;
isLoading: boolean;
error: string | null;
}>({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: true,
error: null,
});
// 로컬 상태 (UI 관련 설정만)
const [localConfig, setLocalConfig] = useState<EntityTypeConfig>({
entityType: config.entityType || "",
displayFields: config.displayFields || [],
searchFields: config.searchFields || [],
valueField: config.valueField || "id",
labelField: config.labelField || "name",
valueField: config.valueField || "",
labelField: config.labelField || "",
multiple: config.multiple || false,
searchable: config.searchable !== false, // 기본값 true
placeholder: config.placeholder || "엔티티를 선택하세요",
searchable: config.searchable !== false,
placeholder: config.placeholder || "항목을 선택하세요",
emptyMessage: config.emptyMessage || "검색 결과가 없습니다",
pageSize: config.pageSize || 20,
minSearchLength: config.minSearchLength || 1,
@ -47,10 +54,95 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
filters: config.filters || {},
});
// 새 필드 추가용 상태
const [newFieldName, setNewFieldName] = useState("");
const [newFieldLabel, setNewFieldLabel] = useState("");
const [newFieldType, setNewFieldType] = useState("string");
// 테이블 타입 관리에서 설정된 참조 테이블 정보 로드
useEffect(() => {
const loadReferenceInfo = async () => {
// 컴포넌트의 테이블명과 컬럼명이 있는 경우에만 조회
const tableName = widget.tableName;
const columnName = widget.columnName;
if (!tableName || !columnName) {
setReferenceInfo({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
error: "테이블 또는 컬럼 정보가 없습니다.",
});
return;
}
try {
// 테이블 타입 관리에서 컬럼 정보 조회
const columns = await tableTypeApi.getColumns(tableName);
const columnInfo = columns.find((col: any) =>
(col.columnName || col.column_name) === columnName
);
if (columnInfo) {
const refTable = columnInfo.referenceTable || columnInfo.reference_table || "";
const refColumn = columnInfo.referenceColumn || columnInfo.reference_column || "";
const dispColumn = columnInfo.displayColumn || columnInfo.display_column || "";
// detailSettings에서도 정보 확인 (JSON 파싱)
let detailSettings: any = {};
if (columnInfo.detailSettings) {
try {
if (typeof columnInfo.detailSettings === 'string') {
detailSettings = JSON.parse(columnInfo.detailSettings);
} else {
detailSettings = columnInfo.detailSettings;
}
} catch {
// JSON 파싱 실패 시 무시
}
}
const finalRefTable = refTable || detailSettings.referenceTable || "";
const finalRefColumn = refColumn || detailSettings.referenceColumn || "";
const finalDispColumn = dispColumn || detailSettings.displayColumn || "";
setReferenceInfo({
referenceTable: finalRefTable,
referenceColumn: finalRefColumn,
displayColumn: finalDispColumn,
isLoading: false,
error: null,
});
// webTypeConfig에 참조 테이블 정보 자동 설정
if (finalRefTable) {
const newConfig = {
...localConfig,
valueField: finalRefColumn || "id",
labelField: finalDispColumn || "name",
};
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
}
} else {
setReferenceInfo({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
error: "컬럼 정보를 찾을 수 없습니다.",
});
}
} catch (error) {
console.error("참조 테이블 정보 로드 실패:", error);
setReferenceInfo({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
error: "참조 테이블 정보 로드 실패",
});
}
};
loadReferenceInfo();
}, [widget.tableName, widget.columnName]);
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
@ -59,11 +151,11 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
entityType: currentConfig.entityType || "",
displayFields: currentConfig.displayFields || [],
searchFields: currentConfig.searchFields || [],
valueField: currentConfig.valueField || "id",
labelField: currentConfig.labelField || "name",
valueField: currentConfig.valueField || referenceInfo.referenceColumn || "",
labelField: currentConfig.labelField || referenceInfo.displayColumn || "",
multiple: currentConfig.multiple || false,
searchable: currentConfig.searchable !== false,
placeholder: currentConfig.placeholder || "엔티티를 선택하세요",
placeholder: currentConfig.placeholder || "항목을 선택하세요",
emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다",
pageSize: currentConfig.pageSize || 20,
minSearchLength: currentConfig.minSearchLength || 1,
@ -73,7 +165,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
apiEndpoint: currentConfig.apiEndpoint || "",
filters: currentConfig.filters || {},
});
}, [widget.webTypeConfig]);
}, [widget.webTypeConfig, referenceInfo.referenceColumn, referenceInfo.displayColumn]);
// 설정 업데이트 핸들러 (즉시 부모에게 전달 - 드롭다운, 체크박스 등)
const updateConfig = (field: keyof EntityTypeConfig, value: any) => {
@ -92,89 +184,6 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
onUpdateProperty("webTypeConfig", localConfig);
};
// 필드 추가
const addDisplayField = () => {
if (!newFieldName.trim() || !newFieldLabel.trim()) return;
const newField: EntityField = {
name: newFieldName.trim(),
label: newFieldLabel.trim(),
type: newFieldType,
visible: true,
};
const newFields = [...localConfig.displayFields, newField];
updateConfig("displayFields", newFields);
setNewFieldName("");
setNewFieldLabel("");
setNewFieldType("string");
};
// 필드 제거
const removeDisplayField = (index: number) => {
const newFields = localConfig.displayFields.filter((_, i) => i !== index);
updateConfig("displayFields", newFields);
};
// 필드 업데이트 (입력 중) - 로컬 상태만 업데이트
const updateDisplayField = (index: number, field: keyof EntityField, value: any) => {
const newFields = [...localConfig.displayFields];
newFields[index] = { ...newFields[index], [field]: value };
setLocalConfig({ ...localConfig, displayFields: newFields });
};
// 필드 업데이트 완료 (onBlur) - 부모에게 전달
const handleFieldBlur = () => {
onUpdateProperty("webTypeConfig", localConfig);
};
// 검색 필드 토글
const toggleSearchField = (fieldName: string) => {
const currentSearchFields = localConfig.searchFields || [];
const newSearchFields = currentSearchFields.includes(fieldName)
? currentSearchFields.filter((f) => f !== fieldName)
: [...currentSearchFields, fieldName];
updateConfig("searchFields", newSearchFields);
};
// 기본 엔티티 타입들
const commonEntityTypes = [
{ value: "user", label: "사용자", fields: ["id", "name", "email", "department"] },
{ value: "department", label: "부서", fields: ["id", "name", "code", "parentId"] },
{ value: "product", label: "제품", fields: ["id", "name", "code", "category", "price"] },
{ value: "customer", label: "고객", fields: ["id", "name", "company", "contact"] },
{ value: "project", label: "프로젝트", fields: ["id", "name", "status", "manager", "startDate"] },
];
// 기본 엔티티 타입 적용
const applyEntityType = (entityType: string) => {
const entityConfig = commonEntityTypes.find((e) => e.value === entityType);
if (!entityConfig) return;
updateConfig("entityType", entityType);
updateConfig("apiEndpoint", `/api/entities/${entityType}`);
const defaultFields: EntityField[] = entityConfig.fields.map((field) => ({
name: field,
label: field.charAt(0).toUpperCase() + field.slice(1),
type: field.includes("Date") ? "date" : field.includes("price") || field.includes("Id") ? "number" : "string",
visible: true,
}));
updateConfig("displayFields", defaultFields);
updateConfig("searchFields", [entityConfig.fields[1] || "name"]); // 두 번째 필드를 기본 검색 필드로
};
// 필드 타입 옵션
const fieldTypes = [
{ value: "string", label: "문자열" },
{ value: "number", label: "숫자" },
{ value: "date", label: "날짜" },
{ value: "boolean", label: "불린" },
{ value: "email", label: "이메일" },
{ value: "url", label: "URL" },
];
return (
<Card>
<CardHeader>
@ -182,12 +191,70 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Database className="h-4 w-4" />
</CardTitle>
<CardDescription className="text-xs"> .</CardDescription>
<CardDescription className="text-xs">
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 기본 설정 */}
{/* 참조 테이블 정보 (테이블 타입 관리에서 설정된 값 - 읽기 전용) */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<h4 className="text-sm font-medium flex items-center gap-2">
<span className="bg-muted text-muted-foreground px-1.5 py-0.5 rounded text-[10px]">
</span>
</h4>
{referenceInfo.isLoading ? (
<div className="bg-muted/50 rounded-md border p-3">
<p className="text-xs text-muted-foreground"> ...</p>
</div>
) : referenceInfo.error ? (
<div className="bg-destructive/10 rounded-md border border-destructive/20 p-3">
<p className="text-xs text-destructive flex items-center gap-1">
<Info className="h-3 w-3" />
{referenceInfo.error}
</p>
<p className="text-[10px] text-muted-foreground mt-1">
.
</p>
</div>
) : !referenceInfo.referenceTable ? (
<div className="bg-amber-500/10 rounded-md border border-amber-500/20 p-3">
<p className="text-xs text-amber-700 flex items-center gap-1">
<Info className="h-3 w-3" />
.
</p>
<p className="text-[10px] text-muted-foreground mt-1">
.
</p>
</div>
) : (
<div className="bg-muted/50 rounded-md border p-3 space-y-2">
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.referenceTable}</div>
</div>
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.referenceColumn || "-"}</div>
</div>
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.displayColumn || "-"}</div>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
.
</p>
</div>
)}
</div>
{/* UI 모드 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium">UI </h4>
{/* UI 모드 선택 */}
<div className="space-y-2">
@ -216,208 +283,6 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</p>
</div>
<div className="space-y-2">
<Label htmlFor="entityType" className="text-xs">
</Label>
<Input
id="entityType"
value={localConfig.entityType || ""}
onChange={(e) => updateConfigLocal("entityType", e.target.value)}
onBlur={handleInputBlur}
placeholder="user, product, department..."
className="text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="grid grid-cols-2 gap-2">
{commonEntityTypes.map((entity) => (
<Button
key={entity.value}
size="sm"
variant="outline"
onClick={() => applyEntityType(entity.value)}
className="text-xs"
>
{entity.label}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="apiEndpoint" className="text-xs">
API
</Label>
<Input
id="apiEndpoint"
value={localConfig.apiEndpoint || ""}
onChange={(e) => updateConfigLocal("apiEndpoint", e.target.value)}
onBlur={handleInputBlur}
placeholder="/api/entities/user"
className="text-xs"
/>
</div>
</div>
{/* 필드 매핑 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label htmlFor="valueField" className="text-xs">
</Label>
<Input
id="valueField"
value={localConfig.valueField || ""}
onChange={(e) => updateConfigLocal("valueField", e.target.value)}
onBlur={handleInputBlur}
placeholder="id"
className="text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="labelField" className="text-xs">
</Label>
<Input
id="labelField"
value={localConfig.labelField || ""}
onChange={(e) => updateConfigLocal("labelField", e.target.value)}
onBlur={handleInputBlur}
placeholder="name"
className="text-xs"
/>
</div>
</div>
</div>
{/* 표시 필드 관리 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
{/* 새 필드 추가 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Input
value={newFieldName}
onChange={(e) => setNewFieldName(e.target.value)}
placeholder="필드명"
className="flex-1 text-xs"
/>
<Input
value={newFieldLabel}
onChange={(e) => setNewFieldLabel(e.target.value)}
placeholder="라벨"
className="flex-1 text-xs"
/>
<Select value={newFieldType} onValueChange={setNewFieldType}>
<SelectTrigger className="w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{fieldTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
onClick={addDisplayField}
disabled={!newFieldName.trim() || !newFieldLabel.trim()}
className="text-xs"
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
{/* 현재 필드 목록 */}
<div className="space-y-2">
<Label className="text-xs"> ({localConfig.displayFields.length})</Label>
<div className="max-h-40 space-y-2 overflow-y-auto">
{localConfig.displayFields.map((field, index) => (
<div key={`${field.name}-${index}`} className="flex items-center gap-2 rounded border p-2">
<Switch
checked={field.visible}
onCheckedChange={(checked) => {
const newFields = [...localConfig.displayFields];
newFields[index] = { ...newFields[index], visible: checked };
const newConfig = { ...localConfig, displayFields: newFields };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
}}
/>
<Input
value={field.name}
onChange={(e) => updateDisplayField(index, "name", e.target.value)}
onBlur={handleFieldBlur}
placeholder="필드명"
className="flex-1 text-xs"
/>
<Input
value={field.label}
onChange={(e) => updateDisplayField(index, "label", e.target.value)}
onBlur={handleFieldBlur}
placeholder="라벨"
className="flex-1 text-xs"
/>
<Select
value={field.type}
onValueChange={(value) => {
const newFields = [...localConfig.displayFields];
newFields[index] = { ...newFields[index], type: value };
const newConfig = { ...localConfig, displayFields: newFields };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
}}
>
<SelectTrigger className="w-24 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{fieldTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant={localConfig.searchFields.includes(field.name) ? "default" : "outline"}
onClick={() => toggleSearchField(field.name)}
className="p-1 text-xs"
title={localConfig.searchFields.includes(field.name) ? "검색 필드에서 제거" : "검색 필드로 추가"}
>
<Search className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => removeDisplayField(index)}
className="p-1 text-xs"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
</div>
{/* 검색 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="space-y-2">
<Label htmlFor="placeholder" className="text-xs">
@ -427,7 +292,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
value={localConfig.placeholder || ""}
onChange={(e) => updateConfigLocal("placeholder", e.target.value)}
onBlur={handleInputBlur}
placeholder="엔티티를 선택하세요"
placeholder="항목을 선택하세요"
className="text-xs"
/>
</div>
@ -445,6 +310,11 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
className="text-xs"
/>
</div>
</div>
{/* 검색 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
@ -483,7 +353,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="searchable" className="text-xs">
</Label>
<p className="text-muted-foreground text-xs"> .</p>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="searchable"
@ -497,7 +367,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="multiple" className="text-xs">
</Label>
<p className="text-muted-foreground text-xs"> .</p>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="multiple"
@ -507,33 +377,6 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</div>
</div>
{/* 필터 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="space-y-2">
<Label htmlFor="filters" className="text-xs">
JSON
</Label>
<Textarea
id="filters"
value={JSON.stringify(localConfig.filters || {}, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
updateConfig("filters", parsed);
} catch {
// 유효하지 않은 JSON은 무시
}
}}
placeholder='{"status": "active", "department": "IT"}'
className="font-mono text-xs"
rows={3}
/>
<p className="text-muted-foreground text-xs">API JSON .</p>
</div>
</div>
{/* 상태 설정 */}
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
@ -543,7 +386,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="required" className="text-xs">
</Label>
<p className="text-muted-foreground text-xs"> .</p>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="required"
@ -557,7 +400,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<Label htmlFor="readonly" className="text-xs">
</Label>
<p className="text-muted-foreground text-xs"> .</p>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Switch
id="readonly"
@ -574,31 +417,18 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
<div className="space-y-2">
<div className="flex items-center gap-2 rounded border bg-white p-2">
<Database className="h-4 w-4 text-gray-400" />
<span className="flex-1 text-xs text-muted-foreground">{localConfig.placeholder || "엔티티를 선택하세요"}</span>
<span className="flex-1 text-xs text-muted-foreground">
{localConfig.placeholder || "항목을 선택하세요"}
</span>
{localConfig.searchable && <Search className="h-4 w-4 text-gray-400" />}
</div>
{localConfig.displayFields.length > 0 && (
<div className="text-muted-foreground text-xs">
<div className="font-medium"> :</div>
<div className="mt-1 flex flex-wrap gap-1">
{localConfig.displayFields
.filter((f) => f.visible)
.map((field, index) => (
<span key={index} className="rounded bg-gray-100 px-2 py-1">
{field.label}
{localConfig.searchFields.includes(field.name) && " 🔍"}
</span>
))}
</div>
</div>
)}
<div className="text-muted-foreground text-xs">
: {localConfig.entityType || "미정"} : {localConfig.valueField} :{" "}
{localConfig.labelField}
{localConfig.multiple && " • 다중선택"}
{localConfig.required && " • 필수"}
<div>: {referenceInfo.referenceTable || "미설정"}</div>
<div> : {localConfig.valueField || referenceInfo.referenceColumn || "-"}</div>
<div> : {localConfig.labelField || referenceInfo.displayColumn || "-"}</div>
{localConfig.multiple && <span> / </span>}
{localConfig.required && <span> / </span>}
</div>
</div>
</div>
@ -609,5 +439,3 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
};
EntityConfigPanel.displayName = "EntityConfigPanel";

View File

@ -46,6 +46,7 @@ interface DetailSettingsPanelProps {
currentTableName?: string; // 현재 화면의 테이블명
tables?: TableInfo[]; // 전체 테이블 목록
currentScreenCompanyCode?: string; // 현재 편집 중인 화면의 회사 코드
components?: ComponentData[]; // 현재 화면의 모든 컴포넌트 (연쇄관계 부모 필드 선택용)
}
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
@ -55,6 +56,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
currentTableName,
tables = [], // 기본값 빈 배열
currentScreenCompanyCode,
components = [], // 기본값 빈 배열
}) => {
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
const { webTypes } = useWebTypes({ active: "Y" });

View File

@ -7,15 +7,18 @@ import { X, Loader2 } from "lucide-react";
import type { TabsComponent, TabItem } from "@/types/screen-management";
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
import { cn } from "@/lib/utils";
import { useActiveTab } from "@/contexts/ActiveTabContext";
interface TabsWidgetProps {
component: TabsComponent;
className?: string;
style?: React.CSSProperties;
menuObjid?: number; // 🆕 부모 화면의 메뉴 OBJID
menuObjid?: number; // 부모 화면의 메뉴 OBJID
}
export function TabsWidget({ component, className, style, menuObjid }: TabsWidgetProps) {
// ActiveTab context 사용
const { setActiveTab, removeTabsComponent } = useActiveTab();
const {
tabs = [],
defaultTab,
@ -51,12 +54,30 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
setVisibleTabs(tabs.filter((tab) => !tab.disabled));
}, [tabs]);
// 선택된 탭 변경 시 localStorage에 저장
// 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트
useEffect(() => {
if (persistSelection && typeof window !== "undefined") {
localStorage.setItem(storageKey, selectedTab);
}
}, [selectedTab, persistSelection, storageKey]);
// ActiveTab Context에 현재 활성 탭 정보 등록
const currentTabInfo = visibleTabs.find(t => t.id === selectedTab);
if (currentTabInfo) {
setActiveTab(component.id, {
tabId: selectedTab,
tabsComponentId: component.id,
screenId: currentTabInfo.screenId,
label: currentTabInfo.label,
});
}
}, [selectedTab, persistSelection, storageKey, component.id, visibleTabs, setActiveTab]);
// 컴포넌트 언마운트 시 ActiveTab Context에서 제거
useEffect(() => {
return () => {
removeTabsComponent(component.id);
};
}, [component.id, removeTabsComponent]);
// 초기 로드 시 선택된 탭의 화면 불러오기
useEffect(() => {
@ -220,16 +241,18 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
margin: "0 auto",
}}
>
{components.map((component: any) => (
{components.map((comp: any) => (
<InteractiveScreenViewerDynamic
key={component.id}
component={component}
key={comp.id}
component={comp}
allComponents={components}
screenInfo={{
id: tab.screenId,
tableName: layoutData.tableName,
}}
menuObjid={menuObjid}
parentTabId={tab.id}
parentTabsComponentId={component.id}
/>
))}
</div>

View File

@ -0,0 +1,139 @@
"use client";
import React, {
createContext,
useContext,
useState,
useCallback,
ReactNode,
} from "react";
/**
*
*/
export interface ActiveTabInfo {
tabId: string; // 탭 고유 ID
tabsComponentId: string; // 부모 탭 컴포넌트 ID
screenId?: number; // 탭에 연결된 화면 ID
label?: string; // 탭 라벨
}
/**
* Context
*/
interface ActiveTabContextValue {
// 현재 활성 탭 정보 (탭 컴포넌트 ID -> 활성 탭 정보)
activeTabs: Map<string, ActiveTabInfo>;
// 활성 탭 설정
setActiveTab: (tabsComponentId: string, tabInfo: ActiveTabInfo) => void;
// 활성 탭 조회
getActiveTab: (tabsComponentId: string) => ActiveTabInfo | undefined;
// 특정 탭 컴포넌트의 활성 탭 ID 조회
getActiveTabId: (tabsComponentId: string) => string | undefined;
// 전체 활성 탭 ID 목록 (모든 탭 컴포넌트에서)
getAllActiveTabIds: () => string[];
// 탭 컴포넌트 제거 시 정리
removeTabsComponent: (tabsComponentId: string) => void;
}
const ActiveTabContext = createContext<ActiveTabContextValue | undefined>(undefined);
export const ActiveTabProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [activeTabs, setActiveTabs] = useState<Map<string, ActiveTabInfo>>(new Map());
/**
*
*/
const setActiveTab = useCallback((tabsComponentId: string, tabInfo: ActiveTabInfo) => {
setActiveTabs((prev) => {
const newMap = new Map(prev);
newMap.set(tabsComponentId, tabInfo);
return newMap;
});
}, []);
/**
*
*/
const getActiveTab = useCallback(
(tabsComponentId: string) => {
return activeTabs.get(tabsComponentId);
},
[activeTabs]
);
/**
* ID
*/
const getActiveTabId = useCallback(
(tabsComponentId: string) => {
return activeTabs.get(tabsComponentId)?.tabId;
},
[activeTabs]
);
/**
* ID
*/
const getAllActiveTabIds = useCallback(() => {
return Array.from(activeTabs.values()).map((info) => info.tabId);
}, [activeTabs]);
/**
*
*/
const removeTabsComponent = useCallback((tabsComponentId: string) => {
setActiveTabs((prev) => {
const newMap = new Map(prev);
newMap.delete(tabsComponentId);
return newMap;
});
}, []);
return (
<ActiveTabContext.Provider
value={{
activeTabs,
setActiveTab,
getActiveTab,
getActiveTabId,
getAllActiveTabIds,
removeTabsComponent,
}}
>
{children}
</ActiveTabContext.Provider>
);
};
/**
* Context Hook
*/
export const useActiveTab = () => {
const context = useContext(ActiveTabContext);
if (!context) {
// Context가 없으면 기본값 반환 (탭이 없는 화면에서 사용 시)
return {
activeTabs: new Map(),
setActiveTab: () => {},
getActiveTab: () => undefined,
getActiveTabId: () => undefined,
getAllActiveTabIds: () => [],
removeTabsComponent: () => {},
};
}
return context;
};
/**
* Optional Context Hook ( undefined )
*/
export const useActiveTabOptional = () => {
return useContext(ActiveTabContext);
};

View File

@ -3,12 +3,14 @@ import React, {
useContext,
useState,
useCallback,
useMemo,
ReactNode,
} from "react";
import {
TableRegistration,
TableOptionsContextValue,
} from "@/types/table-options";
import { useActiveTab } from "./ActiveTabContext";
const TableOptionsContext = createContext<TableOptionsContextValue | undefined>(
undefined
@ -89,6 +91,35 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
});
}, []);
// ActiveTab context 사용 (optional - 에러 방지)
const activeTabContext = useActiveTab();
/**
*
*/
const getActiveTabTables = useCallback(() => {
const allTables = Array.from(registeredTables.values());
const activeTabIds = activeTabContext.getAllActiveTabIds();
// 활성 탭이 없으면 탭에 속하지 않은 테이블만 반환
if (activeTabIds.length === 0) {
return allTables.filter(table => !table.parentTabId);
}
// 활성 탭에 속한 테이블 + 탭에 속하지 않은 테이블
return allTables.filter(table =>
!table.parentTabId || activeTabIds.includes(table.parentTabId)
);
}, [registeredTables, activeTabContext]);
/**
*
*/
const getTablesForTab = useCallback((tabId: string) => {
const allTables = Array.from(registeredTables.values());
return allTables.filter(table => table.parentTabId === tabId);
}, [registeredTables]);
return (
<TableOptionsContext.Provider
value={{
@ -99,6 +130,8 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
updateTableDataCount,
selectedTableId,
setSelectedTableId,
getActiveTabTables,
getTablesForTab,
}}
>
{children}

View File

@ -2,6 +2,7 @@ import { apiClient } from "./client";
export interface TableColumn {
name: string;
label: string; // 컬럼 라벨 (column_labels 테이블에서 가져옴)
type: string;
nullable: boolean;
default: string | null;

View File

@ -132,6 +132,9 @@ export interface DynamicComponentRendererProps {
mode?: "view" | "edit";
// 모달 내에서 렌더링 여부
isInModal?: boolean;
// 탭 관련 정보 (탭 내부의 컴포넌트에서 사용)
parentTabId?: string; // 부모 탭 ID
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
[key: string]: any;
}

View File

@ -823,28 +823,29 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
display: "grid",
gridTemplateColumns: `repeat(${componentConfig.cardsPerRow || 3}, 1fr)`, // 기본값 3 (한 행당 카드 수)
gridAutoRows: "min-content", // 자동 행 생성으로 모든 데이터 표시
gap: `${componentConfig.cardSpacing || 32}px`, // 간격 대폭 증가로 여유로운 느낌
padding: "32px", // 패딩 대폭 증가
gap: `${componentConfig.cardSpacing || 16}px`, // 카드 간격
padding: "16px", // 패딩
width: "100%",
height: "100%",
background: "#f8fafc", // 연한 하늘색 배경 (채도 낮춤)
background: "transparent", // 배경색 제거
overflow: "auto",
borderRadius: "12px", // 컨테이너 자체도 라운드 처리
borderRadius: "0", // 라운드 제거
};
// 카드 스타일 - 컴팩트한 디자인
const cardStyle: React.CSSProperties = {
backgroundColor: "white",
border: "1px solid #e5e7eb",
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "8px",
padding: "16px",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.08)",
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
transition: "all 0.2s ease",
overflow: "hidden",
display: "flex",
flexDirection: "column",
position: "relative",
cursor: isDesignMode ? "pointer" : "default",
width: "100%", // 전체 너비 차지
};
// 텍스트 자르기 함수

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, X, Check, ChevronsUpDown } from "lucide-react";
@ -10,6 +10,7 @@ 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 { dynamicFormApi } from "@/lib/api/dynamicForm";
import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
export function EntitySearchInputComponent({
tableName,
@ -29,6 +30,11 @@ export function EntitySearchInputComponent({
additionalFields = [],
className,
style,
// 연쇄관계 props
cascadingRelationCode,
parentValue: parentValueProp,
parentFieldId,
formData,
// 🆕 추가 props
component,
isInteractive,
@ -38,10 +44,21 @@ export function EntitySearchInputComponent({
component?: any;
isInteractive?: boolean;
onFormDataChange?: (fieldName: string, value: any) => void;
webTypeConfig?: any; // 웹타입 설정 (연쇄관계 등)
}) {
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
// 연쇄관계 설정 추출 (webTypeConfig 또는 component.componentConfig에서)
const config = component?.componentConfig || {};
const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode;
const effectiveParentFieldId = parentFieldId || config.parentFieldId;
const effectiveCascadingRole = config.cascadingRole; // "parent" | "child" | undefined
// 부모 역할이면 연쇄관계 로직 적용 안함 (자식만 부모 값에 따라 필터링됨)
const isChildRole = effectiveCascadingRole === "child";
const shouldApplyCascading = effectiveCascadingRelationCode && isChildRole;
const [modalOpen, setModalOpen] = useState(false);
const [selectOpen, setSelectOpen] = useState(false);
const [displayValue, setDisplayValue] = useState("");
@ -50,16 +67,82 @@ export function EntitySearchInputComponent({
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
const [optionsLoaded, setOptionsLoaded] = useState(false);
// 연쇄관계 상태
const [cascadingOptions, setCascadingOptions] = useState<EntitySearchResult[]>([]);
const [isCascadingLoading, setIsCascadingLoading] = useState(false);
const previousParentValue = useRef<any>(null);
// 부모 필드 값 결정 (직접 전달 또는 formData에서 추출) - 자식 역할일 때만 필요
const parentValue = isChildRole
? (parentValueProp ?? (effectiveParentFieldId && formData ? formData[effectiveParentFieldId] : undefined))
: undefined;
// filterCondition을 문자열로 변환하여 비교 (객체 참조 문제 해결)
const filterConditionKey = JSON.stringify(filterCondition || {});
// select 모드일 때 옵션 로드 (한 번만)
// 연쇄관계가 설정된 경우: 부모 값이 변경되면 자식 옵션 로드 (자식 역할일 때만)
useEffect(() => {
if (mode === "select" && tableName && !optionsLoaded) {
const loadCascadingOptions = async () => {
if (!shouldApplyCascading) return;
// 부모 값이 없으면 옵션 초기화
if (!parentValue) {
setCascadingOptions([]);
// 부모 값이 변경되면 현재 값도 초기화
if (previousParentValue.current !== null && previousParentValue.current !== parentValue) {
handleClear();
}
previousParentValue.current = parentValue;
return;
}
// 부모 값이 동일하면 스킵
if (previousParentValue.current === parentValue) {
return;
}
previousParentValue.current = parentValue;
setIsCascadingLoading(true);
try {
console.log("🔗 연쇄관계 옵션 로드:", { effectiveCascadingRelationCode, parentValue });
const response = await cascadingRelationApi.getOptions(effectiveCascadingRelationCode, String(parentValue));
if (response.success && response.data) {
// 옵션을 EntitySearchResult 형태로 변환
const formattedOptions = response.data.map((opt: any) => ({
[valueField]: opt.value,
[displayField]: opt.label,
...opt, // 추가 필드도 포함
}));
setCascadingOptions(formattedOptions);
console.log("✅ 연쇄관계 옵션 로드 완료:", formattedOptions.length, "개");
// 현재 선택된 값이 새 옵션에 없으면 초기화
if (value && !formattedOptions.find((opt: any) => opt[valueField] === value)) {
handleClear();
}
} else {
setCascadingOptions([]);
}
} catch (error) {
console.error("❌ 연쇄관계 옵션 로드 실패:", error);
setCascadingOptions([]);
} finally {
setIsCascadingLoading(false);
}
};
loadCascadingOptions();
}, [shouldApplyCascading, effectiveCascadingRelationCode, parentValue, valueField, displayField]);
// select 모드일 때 옵션 로드 (연쇄관계가 없거나 부모 역할인 경우)
useEffect(() => {
if (mode === "select" && tableName && !optionsLoaded && !shouldApplyCascading) {
loadOptions();
setOptionsLoaded(true);
}
}, [mode, tableName, filterConditionKey, optionsLoaded]);
}, [mode, tableName, filterConditionKey, optionsLoaded, shouldApplyCascading]);
const loadOptions = async () => {
if (!tableName) return;
@ -82,15 +165,19 @@ export function EntitySearchInputComponent({
}
};
// 실제 사용할 옵션 목록 (자식 역할이고 연쇄관계가 있으면 연쇄 옵션 사용)
const effectiveOptions = shouldApplyCascading ? cascadingOptions : options;
const isLoading = shouldApplyCascading ? isCascadingLoading : isLoadingOptions;
// value가 변경되면 표시값 업데이트 (외래키 값으로 데이터 조회)
useEffect(() => {
const loadDisplayValue = async () => {
if (value && selectedData) {
// 이미 selectedData가 있으면 표시값만 업데이트
setDisplayValue(selectedData[displayField] || "");
} else if (value && mode === "select" && options.length > 0) {
} else if (value && mode === "select" && effectiveOptions.length > 0) {
// select 모드에서 value가 있고 options가 로드된 경우
const found = options.find((opt) => opt[valueField] === value);
const found = effectiveOptions.find((opt) => opt[valueField] === value);
if (found) {
setSelectedData(found);
setDisplayValue(found[displayField] || "");
@ -142,7 +229,7 @@ export function EntitySearchInputComponent({
};
loadDisplayValue();
}, [value, displayField, options, mode, valueField, tableName, selectedData]);
}, [value, displayField, effectiveOptions, mode, valueField, tableName, selectedData]);
const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
setSelectedData(fullData);
@ -200,7 +287,7 @@ export function EntitySearchInputComponent({
variant="outline"
role="combobox"
aria-expanded={selectOpen}
disabled={disabled || isLoadingOptions}
disabled={disabled || isLoading || Boolean(shouldApplyCascading && !parentValue)}
className={cn(
"w-full justify-between font-normal",
!componentHeight && "h-8 text-xs sm:h-10 sm:text-sm",
@ -208,7 +295,11 @@ export function EntitySearchInputComponent({
)}
style={inputStyle}
>
{isLoadingOptions ? "로딩 중..." : displayValue || placeholder}
{isLoading
? "로딩 중..."
: shouldApplyCascading && !parentValue
? "상위 항목을 먼저 선택하세요"
: displayValue || placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@ -218,7 +309,7 @@ export function EntitySearchInputComponent({
<CommandList>
<CommandEmpty className="py-4 text-center text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{options.map((option, index) => (
{effectiveOptions.map((option, index) => (
<CommandItem
key={option[valueField] || index}
value={`${option[displayField] || ""}-${option[valueField] || ""}`}

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -8,19 +8,27 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
import { Plus, X, Check, ChevronsUpDown, Database, Info, Link2, ExternalLink } from "lucide-react";
// allComponents는 현재 사용되지 않지만 향후 확장을 위해 props에 유지
import { EntitySearchInputConfig } from "./config";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { tableTypeApi } from "@/lib/api/screen";
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
import { cn } from "@/lib/utils";
import Link from "next/link";
interface EntitySearchInputConfigPanelProps {
config: EntitySearchInputConfig;
onConfigChange: (config: EntitySearchInputConfig) => void;
currentComponent?: any; // 테이블 패널에서 드래그한 컴포넌트 정보
allComponents?: any[]; // 현재 화면의 모든 컴포넌트 (연쇄 드롭다운 부모 감지용)
}
export function EntitySearchInputConfigPanel({
config,
onConfigChange,
currentComponent,
allComponents = [],
}: EntitySearchInputConfigPanelProps) {
const [localConfig, setLocalConfig] = useState(config);
const [allTables, setAllTables] = useState<any[]>([]);
@ -30,8 +38,152 @@ export function EntitySearchInputConfigPanel({
const [openTableCombo, setOpenTableCombo] = useState(false);
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
const [openValueFieldCombo, setOpenValueFieldCombo] = useState(false);
// 연쇄 드롭다운 설정 상태 (SelectBasicConfigPanel과 동일)
const [cascadingEnabled, setCascadingEnabled] = useState(!!config.cascadingRelationCode);
// 연쇄관계 목록
const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
const [loadingRelations, setLoadingRelations] = useState(false);
// 테이블 타입 관리에서 설정된 참조 테이블 정보
const [referenceInfo, setReferenceInfo] = useState<{
referenceTable: string;
referenceColumn: string;
displayColumn: string;
isLoading: boolean;
isAutoLoaded: boolean; // 자동 로드되었는지 여부
error: string | null;
}>({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
isAutoLoaded: false,
error: null,
});
// 자동 설정 완료 여부 (중복 방지)
const autoConfigApplied = useRef(false);
// 전체 테이블 목록 로드
// 테이블 패널에서 드래그한 컴포넌트인 경우, 참조 테이블 정보 자동 로드
useEffect(() => {
const loadReferenceInfo = async () => {
// currentComponent에서 소스 테이블/컬럼 정보 추출
const sourceTableName = currentComponent?.tableName || currentComponent?.sourceTableName;
const sourceColumnName = currentComponent?.columnName || currentComponent?.sourceColumnName;
if (!sourceTableName || !sourceColumnName) {
return;
}
// 이미 config에 테이블명이 설정되어 있고, 자동 로드가 완료되었다면 스킵
if (config.tableName && autoConfigApplied.current) {
return;
}
setReferenceInfo(prev => ({ ...prev, isLoading: true, error: null }));
try {
// 테이블 타입 관리에서 컬럼 정보 조회
const columns = await tableTypeApi.getColumns(sourceTableName);
const columnInfo = columns.find((col: any) =>
(col.columnName || col.column_name) === sourceColumnName
);
if (columnInfo) {
const refTable = columnInfo.referenceTable || columnInfo.reference_table || "";
const refColumn = columnInfo.referenceColumn || columnInfo.reference_column || "";
const dispColumn = columnInfo.displayColumn || columnInfo.display_column || "";
// detailSettings에서도 정보 확인 (JSON 파싱)
let detailSettings: any = {};
if (columnInfo.detailSettings) {
try {
if (typeof columnInfo.detailSettings === 'string') {
detailSettings = JSON.parse(columnInfo.detailSettings);
} else {
detailSettings = columnInfo.detailSettings;
}
} catch {
// JSON 파싱 실패 시 무시
}
}
const finalRefTable = refTable || detailSettings.referenceTable || "";
const finalRefColumn = refColumn || detailSettings.referenceColumn || "id";
const finalDispColumn = dispColumn || detailSettings.displayColumn || "name";
setReferenceInfo({
referenceTable: finalRefTable,
referenceColumn: finalRefColumn,
displayColumn: finalDispColumn,
isLoading: false,
isAutoLoaded: true,
error: null,
});
// 참조 테이블 정보로 config 자동 설정 (config에 아직 설정이 없는 경우만)
if (finalRefTable && !config.tableName) {
autoConfigApplied.current = true;
const newConfig: EntitySearchInputConfig = {
...localConfig,
tableName: finalRefTable,
valueField: finalRefColumn,
displayField: finalDispColumn,
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
}
} else {
setReferenceInfo({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
isAutoLoaded: false,
error: "컬럼 정보를 찾을 수 없습니다.",
});
}
} catch (error) {
console.error("참조 테이블 정보 로드 실패:", error);
setReferenceInfo({
referenceTable: "",
referenceColumn: "",
displayColumn: "",
isLoading: false,
isAutoLoaded: false,
error: "참조 테이블 정보 로드 실패",
});
}
};
loadReferenceInfo();
}, [currentComponent?.tableName, currentComponent?.columnName, currentComponent?.sourceTableName, currentComponent?.sourceColumnName]);
// 연쇄 관계 목록 로드
useEffect(() => {
if (cascadingEnabled && relationList.length === 0) {
loadRelationList();
}
}, [cascadingEnabled]);
// 연쇄 관계 목록 로드 함수
const loadRelationList = async () => {
setLoadingRelations(true);
try {
const response = await cascadingRelationApi.getList("Y");
if (response.success && response.data) {
setRelationList(response.data);
}
} catch (error) {
console.error("연쇄 관계 목록 로드 실패:", error);
} finally {
setLoadingRelations(false);
}
};
// 전체 테이블 목록 로드 (수동 선택을 위해)
useEffect(() => {
const loadTables = async () => {
setIsLoadingTables(true);
@ -73,8 +225,11 @@ export function EntitySearchInputConfigPanel({
loadColumns();
}, [localConfig.tableName]);
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
setLocalConfig(config);
// 연쇄 드롭다운 설정 동기화
setCascadingEnabled(!!config.cascadingRelationCode);
}, [config]);
const updateConfig = (updates: Partial<EntitySearchInputConfig>) => {
@ -82,6 +237,71 @@ export function EntitySearchInputConfigPanel({
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
// 연쇄 드롭다운 활성화/비활성화
const handleCascadingToggle = (enabled: boolean) => {
setCascadingEnabled(enabled);
if (!enabled) {
// 비활성화 시 관계 설정 제거
const newConfig = {
...localConfig,
cascadingRelationCode: undefined,
cascadingRole: undefined,
cascadingParentField: undefined,
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
} else {
// 활성화 시 관계 목록 로드
loadRelationList();
}
};
// 연쇄 관계 선택 (역할은 별도 선택)
const handleRelationSelect = (code: string) => {
const newConfig = {
...localConfig,
cascadingRelationCode: code || undefined,
cascadingRole: undefined, // 역할은 별도로 선택
cascadingParentField: undefined,
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
// 역할 변경 핸들러
const handleRoleChange = (role: "parent" | "child") => {
const selectedRel = relationList.find(r => r.relation_code === localConfig.cascadingRelationCode);
if (role === "parent" && selectedRel) {
// 부모 역할: 부모 테이블 정보로 설정
const newConfig = {
...localConfig,
cascadingRole: role,
tableName: selectedRel.parent_table,
valueField: selectedRel.parent_value_column,
displayField: selectedRel.parent_label_column || selectedRel.parent_value_column,
cascadingParentField: undefined, // 부모 역할이면 부모 필드 필요 없음
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
} else if (role === "child" && selectedRel) {
// 자식 역할: 자식 테이블 정보로 설정
const newConfig = {
...localConfig,
cascadingRole: role,
tableName: selectedRel.child_table,
valueField: selectedRel.child_value_column,
displayField: selectedRel.child_label_column || selectedRel.child_value_column,
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
}
};
// 선택된 관계 정보
const selectedRelation = relationList.find(r => r.relation_code === localConfig.cascadingRelationCode);
const addSearchField = () => {
const fields = localConfig.searchFields || [];
@ -134,10 +354,213 @@ export function EntitySearchInputConfigPanel({
updateConfig({ additionalFields: fields });
};
// 자동 로드된 참조 테이블 정보가 있는지 확인
const hasAutoReference = referenceInfo.isAutoLoaded && referenceInfo.referenceTable;
return (
<div className="space-y-4 p-4">
{/* 연쇄 드롭다운 설정 - SelectConfigPanel과 동일한 패턴 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4" />
<h4 className="text-sm font-medium"> </h4>
</div>
<Switch
checked={cascadingEnabled}
onCheckedChange={handleCascadingToggle}
/>
</div>
<p className="text-muted-foreground text-xs">
. (: 창고 )
</p>
{cascadingEnabled && (
<div className="space-y-3 rounded-md border p-3 bg-muted/30">
{/* 관계 선택 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={localConfig.cascadingRelationCode || ""}
onValueChange={handleRelationSelect}
>
<SelectTrigger className="text-xs">
<SelectValue placeholder={loadingRelations ? "로딩 중..." : "관계 선택"} />
</SelectTrigger>
<SelectContent>
{relationList.map((relation) => (
<SelectItem key={relation.relation_code} value={relation.relation_code}>
<div className="flex flex-col">
<span>{relation.relation_name}</span>
<span className="text-muted-foreground text-xs">
{relation.parent_table} {relation.child_table}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 역할 선택 */}
{localConfig.cascadingRelationCode && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={localConfig.cascadingRole === "parent" ? "default" : "outline"}
className="flex-1 text-xs"
onClick={() => handleRoleChange("parent")}
>
( )
</Button>
<Button
type="button"
size="sm"
variant={localConfig.cascadingRole === "child" ? "default" : "outline"}
className="flex-1 text-xs"
onClick={() => handleRoleChange("child")}
>
( )
</Button>
</div>
<p className="text-muted-foreground text-xs">
{localConfig.cascadingRole === "parent"
? "이 필드가 상위 선택 역할을 합니다. (예: 창고 선택)"
: localConfig.cascadingRole === "child"
? "이 필드는 상위 필드 값에 따라 옵션이 변경됩니다. (예: 위치 선택)"
: "이 필드의 역할을 선택하세요."}
</p>
</div>
)}
{/* 부모 필드 설정 (자식 역할일 때만) */}
{localConfig.cascadingRelationCode && localConfig.cascadingRole === "child" && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
value={localConfig.cascadingParentField || ""}
onChange={(e) => updateConfig({ cascadingParentField: e.target.value || undefined })}
placeholder="예: warehouse_code"
className="text-xs"
/>
<p className="text-muted-foreground text-xs">
.
</p>
</div>
)}
{/* 선택된 관계 정보 표시 */}
{selectedRelation && localConfig.cascadingRole && (
<div className="bg-background space-y-1 rounded-md p-2 text-xs">
{localConfig.cascadingRole === "parent" ? (
<>
<div className="font-medium text-blue-600"> ( )</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.parent_table}</span>
</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.parent_value_column}</span>
</div>
</>
) : (
<>
<div className="font-medium text-green-600"> ( )</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.child_table}</span>
</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.child_value_column}</span>
</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.child_filter_column}</span>
</div>
</>
)}
</div>
)}
{/* 관계 관리 페이지 링크 */}
<div className="flex justify-end">
<Link href="/admin/cascading-relations" target="_blank">
<Button variant="link" size="sm" className="h-auto p-0 text-xs">
<ExternalLink className="mr-1 h-3 w-3" />
</Button>
</Link>
</div>
</div>
)}
</div>
{/* 구분선 - 연쇄 드롭다운 비활성화 시에만 표시 */}
{!cascadingEnabled && (
<div className="border-t pt-4">
<p className="text-[10px] text-muted-foreground mb-4">
/ .
</p>
</div>
)}
{/* 참조 테이블 자동 로드 정보 표시 */}
{referenceInfo.isLoading && (
<div className="bg-muted/50 rounded-md border p-3">
<p className="text-xs text-muted-foreground"> ...</p>
</div>
)}
{hasAutoReference && !cascadingEnabled && (
<div className="bg-primary/5 rounded-md border border-primary/20 p-3 space-y-2">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-primary" />
<span className="text-xs font-medium text-primary"> </span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.referenceTable}</div>
</div>
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.referenceColumn || "id"}</div>
</div>
<div>
<span className="text-muted-foreground"> :</span>
<div className="font-medium">{referenceInfo.displayColumn || "name"}</div>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
: {currentComponent?.tableName}.{currentComponent?.columnName}
</p>
</div>
)}
{referenceInfo.error && !hasAutoReference && !cascadingEnabled && (
<div className="bg-amber-500/10 rounded-md border border-amber-500/20 p-3">
<p className="text-xs text-amber-700 flex items-center gap-1">
<Info className="h-3 w-3" />
{referenceInfo.error}
</p>
<p className="text-[10px] text-muted-foreground mt-1">
.
</p>
</div>
)}
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Label className="text-xs sm:text-sm">
*
{hasAutoReference && (
<span className="text-[10px] text-muted-foreground ml-2">( )</span>
)}
</Label>
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
<PopoverTrigger asChild>
<Button

View File

@ -10,5 +10,10 @@ export interface EntitySearchInputConfig {
modalColumns?: string[];
showAdditionalInfo?: boolean;
additionalFields?: string[];
// 연쇄관계 설정 (cascading_relation 테이블과 연동)
cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등)
cascadingRole?: "parent" | "child"; // 역할 (부모/자식)
cascadingParentField?: string; // 부모 필드의 컬럼명 (자식 역할일 때만 사용)
}

View File

@ -23,6 +23,13 @@ export interface EntitySearchInputProps {
filterCondition?: Record<string, any>; // 추가 WHERE 조건
companyCode?: string; // 멀티테넌시
// 연쇄관계 설정
cascadingRelationCode?: string; // 연쇄관계 코드 (cascading_relation 테이블)
cascadingRole?: "parent" | "child"; // 역할 (부모/자식)
parentFieldId?: string; // 부모 필드의 컬럼명 (자식 역할일 때, formData에서 값 추출용)
parentValue?: any; // 부모 필드의 현재 값 (직접 전달)
formData?: Record<string, any>; // 전체 폼 데이터 (부모 값 추출용)
// 선택된 값
value?: any;
onChange?: (value: any, fullData?: any) => void;

View File

@ -16,9 +16,16 @@ declare global {
masterData: Record<string, any> | null;
config: RelatedDataButtonsConfig | null;
};
// 🆕 RelatedDataButtons가 대상으로 하는 테이블 목록 (전역 레지스트리)
__relatedButtonsTargetTables?: Set<string>;
}
}
// 전역 레지스트리 초기화
if (typeof window !== "undefined" && !window.__relatedButtonsTargetTables) {
window.__relatedButtonsTargetTables = new Set();
}
interface RelatedDataButtonsComponentProps {
config: RelatedDataButtonsConfig;
className?: string;
@ -59,11 +66,54 @@ export const RelatedDataButtonsComponent: React.FC<RelatedDataButtonsComponentPr
setMasterData(null);
setButtons([]);
setSelectedId(null);
setSelectedItem(null);
// 🆕 좌측 데이터가 없을 때 대상 테이블에 빈 상태 알림
if (config.events?.targetTable) {
window.dispatchEvent(new CustomEvent("related-button-select", {
detail: {
targetTable: config.events.targetTable,
filterColumn: config.events.targetFilterColumn,
filterValue: null, // null로 설정하여 빈 상태 표시
selectedData: null,
},
}));
}
return;
}
setMasterData(splitPanelContext.selectedLeftData);
}, [splitPanelContext?.selectedLeftData]);
}, [splitPanelContext?.selectedLeftData, config.events]);
// 🆕 컴포넌트 마운트 시 대상 테이블에 필터 필요 알림
useEffect(() => {
if (config.events?.targetTable) {
// 전역 레지스트리에 등록
window.__relatedButtonsTargetTables?.add(config.events.targetTable);
// 이벤트도 발생 (이미 마운트된 테이블 컴포넌트를 위해)
window.dispatchEvent(new CustomEvent("related-button-register", {
detail: {
targetTable: config.events.targetTable,
filterColumn: config.events.targetFilterColumn,
},
}));
console.log("📝 [RelatedDataButtons] 대상 테이블에 필터 등록:", config.events.targetTable);
}
return () => {
// 컴포넌트 언마운트 시 등록 해제
if (config.events?.targetTable) {
window.__relatedButtonsTargetTables?.delete(config.events.targetTable);
window.dispatchEvent(new CustomEvent("related-button-unregister", {
detail: {
targetTable: config.events.targetTable,
},
}));
}
};
}, [config.events?.targetTable, config.events?.targetFilterColumn]);
// 버튼 데이터 로드
const loadButtons = useCallback(async () => {

View File

@ -9,6 +9,13 @@ import { codeCache } from "@/lib/caching/codeCache";
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
import { getFullImageUrl } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
// 🆕 RelatedDataButtons 전역 레지스트리 타입 선언
declare global {
interface Window {
__relatedButtonsTargetTables?: Set<string>;
}
}
import {
ChevronLeft,
ChevronRight,
@ -201,6 +208,9 @@ export interface TableListComponentProps {
) => void;
onConfigChange?: (config: any) => void;
refreshKey?: number;
// 탭 관련 정보 (탭 내부의 테이블에서 사용)
parentTabId?: string; // 부모 탭 ID
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
}
// ========================================
@ -217,7 +227,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
config,
className,
style,
formData: propFormData, // 🆕 부모에서 전달받은 formData
formData: propFormData,
onFormDataChange,
componentConfig,
onSelectedRowsChange,
@ -225,7 +235,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
refreshKey,
tableName,
userId,
screenId, // 화면 ID 추출
screenId,
parentTabId,
parentTabsComponentId,
}) => {
// ========================================
// 설정 및 스타일
@ -310,6 +322,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
filterValue: any;
} | null>(null);
// 🆕 RelatedDataButtons가 이 테이블을 대상으로 등록되어 있는지 여부
const [isRelatedButtonTarget, setIsRelatedButtonTarget] = useState(() => {
// 초기값: 전역 레지스트리에서 확인
if (typeof window !== "undefined" && window.__relatedButtonsTargetTables) {
return window.__relatedButtonsTargetTables.has(tableConfig.selectedTable || "");
}
return false;
});
// TableOptions Context
const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions();
const [filters, setFilters] = useState<TableFilter[]>([]);
@ -1000,7 +1021,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onGroupChange: setGrouping,
onColumnVisibilityChange: setColumnVisibility,
getColumnUniqueValues, // 고유 값 조회 함수 등록
onGroupSumChange: setGroupSumConfig, // 🆕 그룹별 합산 설정
onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정
// 탭 관련 정보 (탭 내부의 테이블인 경우)
parentTabId,
parentTabsComponentId,
screenId: screenId ? Number(screenId) : undefined,
};
registerTable(registration);
@ -1554,6 +1579,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return;
}
// 🆕 RelatedDataButtons 대상이지만 아직 버튼이 선택되지 않은 경우
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
if (isRelatedButtonTarget && !relatedButtonFilter) {
console.log("⚠️ [TableList] RelatedDataButtons 대상이지만 버튼 미선택 → 빈 데이터 표시");
setData([]);
setTotalItems(0);
setLoading(false);
return;
}
// 🆕 RelatedDataButtons 필터 값 준비
let relatedButtonFilterValues: Record<string, any> = {};
if (relatedButtonFilter) {
@ -1767,6 +1802,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
splitPanelContext?.selectedLeftData,
// 🆕 RelatedDataButtons 필터 추가
relatedButtonFilter,
isRelatedButtonTarget,
]);
const fetchTableDataDebounced = useCallback(
@ -4783,6 +4819,45 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
}, [tableConfig.selectedTable, isDesignMode]);
// 🆕 테이블명 변경 시 전역 레지스트리에서 확인
useEffect(() => {
if (typeof window !== "undefined" && window.__relatedButtonsTargetTables && tableConfig.selectedTable) {
const isTarget = window.__relatedButtonsTargetTables.has(tableConfig.selectedTable);
if (isTarget) {
console.log("📝 [TableList] 전역 레지스트리에서 RelatedDataButtons 대상 확인:", tableConfig.selectedTable);
setIsRelatedButtonTarget(true);
}
}
}, [tableConfig.selectedTable]);
// 🆕 RelatedDataButtons 등록/해제 이벤트 리스너
useEffect(() => {
const handleRelatedButtonRegister = (event: CustomEvent) => {
const { targetTable } = event.detail || {};
if (targetTable === tableConfig.selectedTable) {
console.log("📝 [TableList] RelatedDataButtons 대상으로 등록됨:", tableConfig.selectedTable);
setIsRelatedButtonTarget(true);
}
};
const handleRelatedButtonUnregister = (event: CustomEvent) => {
const { targetTable } = event.detail || {};
if (targetTable === tableConfig.selectedTable) {
console.log("📝 [TableList] RelatedDataButtons 대상에서 해제됨:", tableConfig.selectedTable);
setIsRelatedButtonTarget(false);
setRelatedButtonFilter(null);
}
};
window.addEventListener("related-button-register" as any, handleRelatedButtonRegister);
window.addEventListener("related-button-unregister" as any, handleRelatedButtonUnregister);
return () => {
window.removeEventListener("related-button-register" as any, handleRelatedButtonRegister);
window.removeEventListener("related-button-unregister" as any, handleRelatedButtonUnregister);
};
}, [tableConfig.selectedTable]);
// 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링)
useEffect(() => {
const handleRelatedButtonSelect = (event: CustomEvent) => {
@ -4790,12 +4865,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 이 테이블이 대상 테이블인지 확인
if (targetTable === tableConfig.selectedTable) {
console.log("📌 [TableList] RelatedDataButtons 필터 적용:", {
tableName: tableConfig.selectedTable,
filterColumn,
filterValue,
});
setRelatedButtonFilter({ filterColumn, filterValue });
// filterValue가 null이면 선택 해제 (빈 상태)
if (filterValue === null || filterValue === undefined) {
console.log("📌 [TableList] RelatedDataButtons 선택 해제 (빈 상태):", tableConfig.selectedTable);
setRelatedButtonFilter(null);
setIsRelatedButtonTarget(true); // 대상으로 등록은 유지
} else {
console.log("📌 [TableList] RelatedDataButtons 필터 적용:", {
tableName: tableConfig.selectedTable,
filterColumn,
filterValue,
});
setRelatedButtonFilter({ filterColumn, filterValue });
setIsRelatedButtonTarget(true);
}
}
};
@ -4808,8 +4891,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 relatedButtonFilter 변경 시 데이터 다시 로드
useEffect(() => {
if (relatedButtonFilter && !isDesignMode) {
console.log("🔄 [TableList] RelatedDataButtons 필터 변경으로 데이터 새로고침:", relatedButtonFilter);
if (!isDesignMode) {
// relatedButtonFilter가 있으면 데이터 로드, null이면 빈 상태 (setRefreshTrigger로 트리거)
console.log("🔄 [TableList] RelatedDataButtons 상태 변경:", {
relatedButtonFilter,
isRelatedButtonTarget
});
setRefreshTrigger((prev) => prev + 1);
}
}, [relatedButtonFilter, isDesignMode]);

View File

@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input";
import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
import { useActiveTab } from "@/contexts/ActiveTabContext";
import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel";
import { FilterPanel } from "@/components/screen/table-options/FilterPanel";
import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
@ -49,8 +50,9 @@ interface TableSearchWidgetProps {
}
export function TableSearchWidget({ component, screenId, onHeightChange }: TableSearchWidgetProps) {
const { registeredTables, selectedTableId, setSelectedTableId, getTable } = useTableOptions();
const { registeredTables, selectedTableId, setSelectedTableId, getTable, getActiveTabTables } = useTableOptions();
const { isPreviewMode } = useScreenPreview(); // 미리보기 모드 확인
const { getAllActiveTabIds, activeTabs } = useActiveTab(); // 활성 탭 정보
// 높이 관리 context (실제 화면에서만 사용)
let setWidgetHeight:
@ -63,6 +65,9 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// Context가 없으면 (디자이너 모드) 무시
setWidgetHeight = undefined;
}
// 탭별 필터 값 저장 (탭 ID -> 필터 값)
const [tabFilterValues, setTabFilterValues] = useState<Record<string, Record<string, any>>>({});
const [columnVisibilityOpen, setColumnVisibilityOpen] = useState(false);
const [filterOpen, setFilterOpen] = useState(false);
@ -88,38 +93,48 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// Map을 배열로 변환
const allTableList = Array.from(registeredTables.values());
// 대상 패널 위치에 따라 테이블 필터링 (tableId 패턴 기반)
// 현재 활성 탭 ID 목록
const activeTabIds = useMemo(() => getAllActiveTabIds(), [activeTabs]);
// 대상 패널 위치 + 활성 탭에 따라 테이블 필터링
const tableList = useMemo(() => {
// "auto"면 모든 테이블 반환
if (targetPanelPosition === "auto") {
return allTableList;
}
// 테이블 ID 패턴으로 필터링
// card-display-XXX: 좌측 패널 (카드 디스플레이)
// datatable-XXX, table-list-XXX: 우측 패널 (테이블 리스트)
const filteredTables = allTableList.filter(table => {
const tableId = table.tableId.toLowerCase();
if (targetPanelPosition === "left") {
// 좌측 패널 대상: card-display만
return tableId.includes("card-display") || tableId.includes("card");
} else if (targetPanelPosition === "right") {
// 우측 패널 대상: datatable, table-list 등 (card-display 제외)
const isCardDisplay = tableId.includes("card-display") || tableId.includes("card");
return !isCardDisplay;
}
return true;
// 1단계: 활성 탭 기반 필터링
// - 활성 탭에 속한 테이블만 표시
// - 탭에 속하지 않은 테이블(parentTabId가 없는)도 포함
let filteredByTab = allTableList.filter(table => {
// 탭에 속하지 않는 테이블은 항상 표시
if (!table.parentTabId) return true;
// 활성 탭에 속한 테이블만 표시
return activeTabIds.includes(table.parentTabId);
});
// 필터링된 결과가 없으면 모든 테이블 반환 (폴백)
if (filteredTables.length === 0) {
return allTableList;
// 2단계: 대상 패널 위치에 따라 추가 필터링
if (targetPanelPosition !== "auto") {
filteredByTab = filteredByTab.filter(table => {
const tableId = table.tableId.toLowerCase();
if (targetPanelPosition === "left") {
// 좌측 패널 대상: card-display만
return tableId.includes("card-display") || tableId.includes("card");
} else if (targetPanelPosition === "right") {
// 우측 패널 대상: datatable, table-list 등 (card-display 제외)
const isCardDisplay = tableId.includes("card-display") || tableId.includes("card");
return !isCardDisplay;
}
return true;
});
}
return filteredTables;
}, [allTableList, targetPanelPosition]);
// 필터링된 결과가 없으면 탭 기반 필터링 결과만 반환
if (filteredByTab.length === 0) {
return allTableList.filter(table =>
!table.parentTabId || activeTabIds.includes(table.parentTabId)
);
}
return filteredByTab;
}, [allTableList, targetPanelPosition, activeTabIds]);
// currentTable은 tableList(필터링된 목록)에서 가져와야 함
const currentTable = useMemo(() => {
@ -151,6 +166,34 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
}
}, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition]);
// 현재 선택된 테이블의 탭 ID (탭별 필터 저장용)
const currentTableTabId = currentTable?.parentTabId;
// 탭별 필터 값 저장 키 생성
const getTabFilterStorageKey = (tableName: string, tabId?: string) => {
const baseKey = screenId
? `table_filter_values_${tableName}_screen_${screenId}`
: `table_filter_values_${tableName}`;
return tabId ? `${baseKey}_tab_${tabId}` : baseKey;
};
// 탭 변경 시 이전 탭의 필터 값 저장 + 새 탭의 필터 값 복원
useEffect(() => {
if (!currentTable?.tableName) return;
// 현재 필터 값이 있으면 탭별로 저장
if (Object.keys(filterValues).length > 0 && currentTableTabId) {
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
localStorage.setItem(storageKey, JSON.stringify(filterValues));
// 메모리 캐시에도 저장
setTabFilterValues(prev => ({
...prev,
[currentTableTabId]: filterValues
}));
}
}, [currentTableTabId, currentTable?.tableName]);
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
useEffect(() => {
if (!currentTable?.tableName) return;
@ -165,14 +208,32 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
width: f.width || 200,
}));
setActiveFilters(activeFiltersList);
// 탭별 저장된 필터 값 복원
if (currentTableTabId) {
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
const savedValues = localStorage.getItem(storageKey);
if (savedValues) {
try {
const parsedValues = JSON.parse(savedValues);
setFilterValues(parsedValues);
// 즉시 필터 적용
setTimeout(() => applyFilters(parsedValues), 100);
} catch {
setFilterValues({});
}
} else {
setFilterValues({});
}
}
return;
}
// 동적 모드: 화면별로 독립적인 필터 설정 불러오기
const storageKey = screenId
? `table_filters_${currentTable.tableName}_screen_${screenId}`
// 동적 모드: 화면별 + 탭별로 독립적인 필터 설정 불러오기
const filterConfigKey = screenId
? `table_filters_${currentTable.tableName}_screen_${screenId}${currentTableTabId ? `_tab_${currentTableTabId}` : ''}`
: `table_filters_${currentTable.tableName}`;
const savedFilters = localStorage.getItem(storageKey);
const savedFilters = localStorage.getItem(filterConfigKey);
if (savedFilters) {
try {
@ -193,16 +254,39 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
operator: "contains",
value: "",
filterType: f.filterType,
width: f.width || 200, // 저장된 너비 포함
width: f.width || 200,
}));
setActiveFilters(activeFiltersList);
// 탭별 저장된 필터 값 복원
if (currentTableTabId) {
const valuesStorageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
const savedValues = localStorage.getItem(valuesStorageKey);
if (savedValues) {
try {
const parsedValues = JSON.parse(savedValues);
setFilterValues(parsedValues);
// 즉시 필터 적용
setTimeout(() => applyFilters(parsedValues), 100);
} catch {
setFilterValues({});
}
} else {
setFilterValues({});
}
} else {
setFilterValues({});
}
} catch (error) {
console.error("저장된 필터 불러오기 실패:", error);
}
} else {
// 필터 설정이 없으면 초기화
setFilterValues({});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTable?.tableName, filterMode, screenId, JSON.stringify(presetFilters)]);
}, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]);
// select 옵션 초기 로드 (한 번만 실행, 이후 유지)
useEffect(() => {
@ -300,6 +384,12 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
setFilterValues(newValues);
// 탭별 필터 값 저장
if (currentTable?.tableName && currentTableTabId) {
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
localStorage.setItem(storageKey, JSON.stringify(newValues));
}
// 실시간 검색: 값 변경 시 즉시 필터 적용
applyFilters(newValues);
};
@ -365,6 +455,12 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
setFilterValues({});
setSelectedLabels({});
currentTable?.onFilterChange([]);
// 탭별 저장된 필터 값도 초기화
if (currentTable?.tableName && currentTableTabId) {
const storageKey = getTabFilterStorageKey(currentTable.tableName, currentTableTabId);
localStorage.removeItem(storageKey);
}
};
// 필터 입력 필드 렌더링

View File

@ -397,7 +397,6 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
// 🆕 수주 등록 관련 컴포넌트들은 간단한 인터페이스 사용
const isSimpleConfigPanel = [
"autocomplete-search-input",
"entity-search-input",
"modal-repeater-table",
"conditional-container",
].includes(componentId);
@ -406,6 +405,19 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
return <ConfigPanelComponent config={config} onConfigChange={onChange} />;
}
// entity-search-input은 currentComponent 정보 필요 (참조 테이블 자동 로드용)
// 그리고 allComponents 필요 (연쇄관계 부모 필드 선택용)
if (componentId === "entity-search-input") {
return (
<ConfigPanelComponent
config={config}
onConfigChange={onChange}
currentComponent={currentComponent}
allComponents={allComponents}
/>
);
}
// 🆕 selected-items-detail-input은 특별한 props 사용
if (componentId === "selected-items-detail-input") {
return (

View File

@ -55,12 +55,17 @@ export interface TableRegistration {
tableName: string; // 실제 DB 테이블명 (예: "item_info")
columns: TableColumn[];
dataCount?: number; // 현재 표시된 데이터 건수
// 탭 관련 정보 (탭 내부에 있는 테이블의 경우)
parentTabId?: string; // 부모 탭 ID
parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID
screenId?: number; // 소속 화면 ID
// 콜백 함수들
onFilterChange: (filters: TableFilter[]) => void;
onGroupChange: (groups: string[]) => void;
onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
onGroupSumChange?: (config: GroupSumConfig | null) => void; // 🆕 그룹별 합산 설정 변경
onGroupSumChange?: (config: GroupSumConfig | null) => void; // 그룹별 합산 설정 변경
// 데이터 조회 함수 (선택 타입 필터용)
getColumnUniqueValues?: (columnName: string) => Promise<Array<{ label: string; value: string }>>;
@ -77,4 +82,8 @@ export interface TableOptionsContextValue {
updateTableDataCount: (tableId: string, count: number) => void; // 데이터 건수 업데이트
selectedTableId: string | null;
setSelectedTableId: (tableId: string | null) => void;
// 활성 탭 기반 필터링
getActiveTabTables: () => TableRegistration[]; // 현재 활성 탭의 테이블만 반환
getTablesForTab: (tabId: string) => TableRegistration[]; // 특정 탭의 테이블만 반환
}