화면간 데이터 전달기능 구현

This commit is contained in:
kjs
2025-12-02 18:03:52 +09:00
parent 44c76d80b7
commit 3b875f20b1
14 changed files with 886 additions and 171 deletions

View File

@@ -15,7 +15,7 @@ import { getTableColumns } from "@/lib/api/tableManagement";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { ParentDataMapping } from "@/contexts/SplitPanelContext";
import type { ParentDataMapping, LinkedFilter } from "@/contexts/SplitPanelContext";
interface ScreenSplitPanelConfigPanelProps {
config: any;
@@ -33,7 +33,15 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
// 좌측 화면의 테이블 컬럼 목록
const [leftScreenColumns, setLeftScreenColumns] = useState<Array<{ columnName: string; columnLabel: string }>>([]);
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
const [isLoadingLeftColumns, setIsLoadingLeftColumns] = useState(false);
// 우측 화면의 테이블 컬럼 목록 (테이블별로 그룹화)
const [rightScreenTables, setRightScreenTables] = useState<Array<{
tableName: string;
screenName: string;
columns: Array<{ columnName: string; columnLabel: string }>
}>>([]);
const [isLoadingRightColumns, setIsLoadingRightColumns] = useState(false);
const [localConfig, setLocalConfig] = useState({
screenId: config.screenId || 0,
@@ -44,6 +52,7 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
buttonLabel: config.buttonLabel || "데이터 전달",
buttonPosition: config.buttonPosition || "center",
parentDataMapping: config.parentDataMapping || [] as ParentDataMapping[],
linkedFilters: config.linkedFilters || [] as LinkedFilter[],
...config,
});
@@ -59,6 +68,7 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
buttonLabel: config.buttonLabel || "데이터 전달",
buttonPosition: config.buttonPosition || "center",
parentDataMapping: config.parentDataMapping || [],
linkedFilters: config.linkedFilters || [],
...config,
});
}, [config]);
@@ -72,7 +82,7 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
}
try {
setIsLoadingColumns(true);
setIsLoadingLeftColumns(true);
// 좌측 화면 정보 조회
const screenData = await screenApi.getScreen(localConfig.leftScreenId);
@@ -96,13 +106,126 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
console.error("좌측 화면 컬럼 로드 실패:", error);
setLeftScreenColumns([]);
} finally {
setIsLoadingColumns(false);
setIsLoadingLeftColumns(false);
}
};
loadLeftScreenColumns();
}, [localConfig.leftScreenId]);
// 우측 화면이 변경되면 해당 화면 및 임베드된 화면들의 테이블 컬럼 로드
useEffect(() => {
const loadRightScreenColumns = async () => {
if (!localConfig.rightScreenId) {
setRightScreenTables([]);
return;
}
try {
setIsLoadingRightColumns(true);
const tables: Array<{ tableName: string; screenName: string; columns: Array<{ columnName: string; columnLabel: string }> }> = [];
// 우측 화면 정보 조회
const screenData = await screenApi.getScreen(localConfig.rightScreenId);
// 1. 메인 화면의 테이블 (있는 경우)
if (screenData?.tableName) {
const columnsResponse = await getTableColumns(screenData.tableName);
if (columnsResponse.success && columnsResponse.data?.columns) {
tables.push({
tableName: screenData.tableName,
screenName: screenData.screenName || "메인 화면",
columns: columnsResponse.data.columns.map((col: any) => ({
columnName: col.column_name || col.columnName,
columnLabel: col.column_label || col.columnLabel || col.column_name || col.columnName,
})),
});
}
}
// 2. 레이아웃에서 임베드된 화면들의 테이블 찾기 (탭, 분할 패널 등)
const layoutData = await screenApi.getLayout(localConfig.rightScreenId);
const components = layoutData?.components || [];
if (components.length > 0) {
const embeddedScreenIds = new Set<number>();
// 컴포넌트에서 임베드된 화면 ID 수집
const findEmbeddedScreens = (comps: any[]) => {
for (const comp of comps) {
const config = comp.componentConfig || {};
// TabsWidget의 탭들
if (comp.componentType === "tabs-widget" && config.tabs) {
for (const tab of config.tabs) {
if (tab.screenId) {
embeddedScreenIds.add(tab.screenId);
console.log("🔍 탭에서 화면 발견:", tab.screenId, tab.screenName);
}
}
}
// ScreenSplitPanel
if (comp.componentType === "screen-split-panel") {
if (config.leftScreenId) embeddedScreenIds.add(config.leftScreenId);
if (config.rightScreenId) embeddedScreenIds.add(config.rightScreenId);
}
// EmbeddedScreen
if (comp.componentType === "embedded-screen" && config.screenId) {
embeddedScreenIds.add(config.screenId);
}
// 중첩된 컴포넌트 검색
if (comp.children) {
findEmbeddedScreens(comp.children);
}
}
};
findEmbeddedScreens(components);
console.log("📋 발견된 임베드 화면 ID:", Array.from(embeddedScreenIds));
// 임베드된 화면들의 테이블 컬럼 로드
for (const embeddedScreenId of embeddedScreenIds) {
try {
const embeddedScreen = await screenApi.getScreen(embeddedScreenId);
if (embeddedScreen?.tableName) {
// 이미 추가된 테이블인지 확인
if (!tables.find(t => t.tableName === embeddedScreen.tableName)) {
const columnsResponse = await getTableColumns(embeddedScreen.tableName);
if (columnsResponse.success && columnsResponse.data?.columns) {
tables.push({
tableName: embeddedScreen.tableName,
screenName: embeddedScreen.screenName || `화면 ${embeddedScreenId}`,
columns: columnsResponse.data.columns.map((col: any) => ({
columnName: col.column_name || col.columnName,
columnLabel: col.column_label || col.columnLabel || col.column_name || col.columnName,
})),
});
console.log("✅ 테이블 추가:", embeddedScreen.tableName);
}
}
}
} catch (err) {
console.warn(`임베드된 화면 ${embeddedScreenId} 로드 실패:`, err);
}
}
}
setRightScreenTables(tables);
console.log("📋 우측 화면 테이블 로드 완료:", tables.map(t => t.tableName));
} catch (error) {
console.error("우측 화면 컬럼 로드 실패:", error);
setRightScreenTables([]);
} finally {
setIsLoadingRightColumns(false);
}
};
loadRightScreenColumns();
}, [localConfig.rightScreenId]);
// 화면 목록 로드
useEffect(() => {
const loadScreens = async () => {
@@ -168,21 +291,51 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
updateConfig("parentDataMapping", newMappings);
};
// 연결 필터 추가
const addLinkedFilter = () => {
const newFilter: LinkedFilter = {
sourceColumn: "",
targetColumn: "",
};
const newFilters = [...(localConfig.linkedFilters || []), newFilter];
updateConfig("linkedFilters", newFilters);
};
// 연결 필터 수정
const updateLinkedFilter = (index: number, field: keyof LinkedFilter, value: string) => {
const newFilters = [...(localConfig.linkedFilters || [])];
newFilters[index] = {
...newFilters[index],
[field]: value,
};
updateConfig("linkedFilters", newFilters);
};
// 연결 필터 삭제
const removeLinkedFilter = (index: number) => {
const newFilters = (localConfig.linkedFilters || []).filter((_: any, i: number) => i !== index);
updateConfig("linkedFilters", newFilters);
};
return (
<div className="space-y-4">
<Tabs defaultValue="layout" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="layout" className="gap-2">
<Layout className="h-4 w-4" />
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="layout" className="gap-1 text-xs">
<Layout className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="screens" className="gap-2">
<Database className="h-4 w-4" />
<TabsTrigger value="screens" className="gap-1 text-xs">
<Database className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="dataMapping" className="gap-2">
<Link2 className="h-4 w-4" />
<TabsTrigger value="linkedFilter" className="gap-1 text-xs">
<Link2 className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="dataMapping" className="gap-1 text-xs">
<ArrowRight className="h-3 w-3" />
</TabsTrigger>
</TabsList>
@@ -385,6 +538,141 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
</Card>
</TabsContent>
{/* 연결 필터 탭 */}
<TabsContent value="linkedFilter" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardDescription className="text-xs">
, .
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!localConfig.leftScreenId || !localConfig.rightScreenId ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="text-xs text-amber-800 dark:text-amber-200">
"화면" / .
</p>
</div>
) : isLoadingLeftColumns || isLoadingRightColumns ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
<span className="text-muted-foreground ml-2 text-xs"> ...</span>
</div>
) : leftScreenColumns.length === 0 || rightScreenTables.length === 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="text-xs text-amber-800 dark:text-amber-200">
{leftScreenColumns.length === 0 && "좌측 화면에 테이블이 설정되지 않았습니다. "}
{rightScreenTables.length === 0 && "우측 화면에 테이블이 설정되지 않았습니다."}
</p>
</div>
) : (
<>
{/* 연결 필터 설명 */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-900 dark:bg-blue-950">
<p className="text-xs text-blue-800 dark:text-blue-200">
: 좌측에서 .
<br />
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded">equipment_code</code>
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded">equipment_code</code>
</p>
</div>
{/* 필터 목록 */}
<div className="space-y-3">
{(localConfig.linkedFilters || []).map((filter: LinkedFilter, index: number) => (
<div key={index} className="rounded-lg border bg-gray-50 p-3 dark:bg-gray-900">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-gray-700"> #{index + 1}</span>
<Button
variant="ghost"
size="sm"
onClick={() => removeLinkedFilter(index)}
className="h-6 w-6 p-0 text-red-500 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
<div>
<Label className="text-xs text-gray-600"> ( )</Label>
<Select
value={filter.sourceColumn}
onValueChange={(value) => updateLinkedFilter(index, "sourceColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{leftScreenColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.columnLabel} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-center">
<ArrowRight className="h-4 w-4 rotate-90 text-gray-400" />
</div>
<div>
<Label className="text-xs text-gray-600"> ( )</Label>
<Select
value={filter.targetColumn}
onValueChange={(value) => updateLinkedFilter(index, "targetColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블.컬럼 선택" />
</SelectTrigger>
<SelectContent>
{rightScreenTables.map((table) => (
<React.Fragment key={table.tableName}>
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500 bg-gray-100 dark:bg-gray-800">
{table.screenName} ({table.tableName})
</div>
{table.columns.map((col) => (
<SelectItem
key={`${table.tableName}.${col.columnName}`}
value={`${table.tableName}.${col.columnName}`}
className="text-xs"
>
{col.columnLabel} ({col.columnName})
</SelectItem>
))}
</React.Fragment>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
))}
</div>
{/* 추가 버튼 */}
<Button
variant="outline"
size="sm"
onClick={addLinkedFilter}
className="w-full text-xs"
>
<Plus className="mr-2 h-3 w-3" />
</Button>
{/* 현재 설정 표시 */}
<Separator />
<div className="text-xs text-muted-foreground">
{(localConfig.linkedFilters || []).length > 0
? `${localConfig.linkedFilters.length}개 필터 설정됨`
: "필터 없음 - 우측 화면에 모든 데이터가 표시됩니다"}
</div>
</>
)}
</CardContent>
</Card>
</TabsContent>
{/* 데이터 전달 탭 */}
<TabsContent value="dataMapping" className="space-y-4">
<Card>
@@ -395,69 +683,105 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!localConfig.leftScreenId ? (
{!localConfig.leftScreenId || !localConfig.rightScreenId ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="text-xs text-amber-800 dark:text-amber-200">
"화면 설정" .
"화면 설정" / .
</p>
</div>
) : isLoadingColumns ? (
) : isLoadingLeftColumns || isLoadingRightColumns ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
<span className="text-muted-foreground ml-2 text-xs"> ...</span>
</div>
) : leftScreenColumns.length === 0 ? (
) : leftScreenColumns.length === 0 || rightScreenTables.length === 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="text-xs text-amber-800 dark:text-amber-200">
.
{leftScreenColumns.length === 0 && "좌측 화면에 테이블이 설정되지 않았습니다. "}
{rightScreenTables.length === 0 && "우측 화면에 테이블이 설정되지 않았습니다."}
</p>
</div>
) : (
<>
{/* 우측 화면 테이블 목록 표시 */}
<div className="rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-900 dark:bg-green-950">
<p className="text-xs font-medium text-green-800 dark:text-green-200 mb-1">
({rightScreenTables.length}):
</p>
<ul className="text-xs text-green-700 dark:text-green-300 space-y-0.5">
{rightScreenTables.map((table) => (
<li key={table.tableName}> {table.screenName}: <code className="bg-green-100 dark:bg-green-900 px-1 rounded">{table.tableName}</code></li>
))}
</ul>
</div>
{/* 매핑 목록 */}
<div className="space-y-3">
{(localConfig.parentDataMapping || []).map((mapping: ParentDataMapping, index: number) => (
<div key={index} className="flex items-center gap-2 rounded-lg border bg-gray-50 p-3 dark:bg-gray-900">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<div className="flex-1">
<Label className="text-xs text-gray-600"> ()</Label>
<Select
value={mapping.sourceColumn}
onValueChange={(value) => updateParentDataMapping(index, "sourceColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{leftScreenColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.columnLabel} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ArrowRight className="mt-5 h-4 w-4 text-gray-400" />
<div className="flex-1">
<Label className="text-xs text-gray-600"> ( )</Label>
<Input
value={mapping.targetColumn}
onChange={(e) => updateParentDataMapping(index, "targetColumn", e.target.value)}
placeholder="저장할 컬럼명"
className="h-8 text-xs"
/>
</div>
<div key={index} className="rounded-lg border bg-gray-50 p-3 dark:bg-gray-900">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-gray-700"> #{index + 1}</span>
<Button
variant="ghost"
size="sm"
onClick={() => removeParentDataMapping(index)}
className="h-6 w-6 p-0 text-red-500 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
<div>
<Label className="text-xs text-gray-600"> ( )</Label>
<Select
value={mapping.sourceColumn}
onValueChange={(value) => updateParentDataMapping(index, "sourceColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{leftScreenColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.columnLabel} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-center">
<ArrowRight className="h-4 w-4 rotate-90 text-gray-400" />
</div>
<div>
<Label className="text-xs text-gray-600"> ( )</Label>
<Select
value={mapping.targetColumn}
onValueChange={(value) => updateParentDataMapping(index, "targetColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블.컬럼 선택" />
</SelectTrigger>
<SelectContent>
{rightScreenTables.map((table) => (
<React.Fragment key={table.tableName}>
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500 bg-gray-100 dark:bg-gray-800">
{table.screenName} ({table.tableName})
</div>
{table.columns.map((col) => (
<SelectItem
key={`${table.tableName}.${col.columnName}`}
value={col.columnName}
className="text-xs pl-4"
>
{col.columnLabel} ({col.columnName})
</SelectItem>
))}
</React.Fragment>
))}
</SelectContent>
</Select>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeParentDataMapping(index)}
className="h-8 w-8 p-0 text-red-500 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
@@ -473,23 +797,23 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
</Button>
{/* 안내 메시지 */}
{/* 자동 매핑 안내 */}
<div className="rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-900 dark:bg-green-950">
<p className="text-xs text-green-800 dark:text-green-200">
<strong> :</strong> .
<br />
(: equipment_code) .
</p>
</div>
{/* 수동 매핑 안내 */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-900 dark:bg-blue-950">
<p className="text-xs text-blue-800 dark:text-blue-200">
<strong> :</strong>
<strong> ():</strong>
<br />
좌측: 설비 (equipment_mng)
.
<br />
우측: 점검항목
<br />
<br />
:
<br />
- 소스: equipment_code 타겟: equipment_code
<br />
<br />
,
equipment_code가 .
: 좌측 <code className="bg-blue-100 px-1 rounded">user_id</code> <code className="bg-blue-100 px-1 rounded">created_by</code>
</p>
</div>
</>