Compare commits

...

43 Commits

Author SHA1 Message Date
kjs 5cdbd2446b Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-16 10:40:11 +09:00
kjs 6505df8555 feat: enhance v2-timeline-scheduler component functionality
- Updated the v2-timeline-scheduler documentation to reflect the latest implementation status and enhancements.
- Improved the TimelineSchedulerComponent by integrating conflict detection and milestone rendering features.
- Refactored ResourceRow and ScheduleBar components to support new props for handling conflicts and milestones.
- Added visual indicators for conflicts and milestones to enhance user experience and clarity in scheduling.

These changes aim to improve the functionality and usability of the timeline scheduler within the ERP system.

Made-with: Cursor
2026-03-16 10:40:10 +09:00
kjs d3e62912e7 Merge branch 'gbpark-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-03-16 10:39:52 +09:00
DDD1542 6395f4d032 Implement Card Pulse Animation and UI Enhancements
- Added a new pulse animation for screen cards to enhance visual feedback.
- Updated the background of the screen management list for improved aesthetics.
- Refined the search input styling for better integration with the overall UI.
- Enhanced screen card hover effects with dynamic glow based on screen type.
- Adjusted layout spacing for a more consistent user experience.

Made-with: Cursor
2026-03-16 09:17:59 +09:00
DDD1542 cbd47184e7 Enhance Screen Management UI
- Updated the search input to include a clear button for easier user interaction.
- Improved the layout with a muted background for better visibility.
- Enhanced screen card display with dynamic type color and glow effects based on screen type.
- Adjusted text colors for better contrast and readability in dark mode.
- Refined connection indicators and button styles for improved UX.

Made-with: Cursor
2026-03-16 09:17:52 +09:00
DDD1542 fe3c6d3bce [agent-pipeline] rollback to 232650bc 2026-03-15 22:29:56 +09:00
DDD1542 015706b95a [agent-pipeline] pipe-20260315131310-l8kw round-2 2026-03-15 22:29:56 +09:00
DDD1542 232650bc07 [agent-pipeline] pipe-20260315131310-l8kw round-1 2026-03-15 22:19:35 +09:00
DDD1542 8ed7faf517 [agent-pipeline] pipe-20260315121506-3c5c round-2 2026-03-15 21:22:36 +09:00
DDD1542 009607f3f1 [agent-pipeline] pipe-20260315121506-3c5c round-1 2026-03-15 21:18:08 +09:00
DDD1542 3ef8cebf1a [agent-pipeline] pipe-20260315110231-zn60 round-2 2026-03-15 20:14:51 +09:00
DDD1542 558acd1f9b [agent-pipeline] pipe-20260315110231-zn60 round-1 2026-03-15 20:09:41 +09:00
DDD1542 c0be2f3528 feat: 접는 사이드바 구현 (v5 파이프라인 후속)
- sidebarCollapsed 상태 + 조건부 렌더링
- PanelLeftOpen/PanelLeftClose 아이콘 토글
- collapsed 시 아이콘 컬럼 표시

Made-with: Cursor
2026-03-15 19:57:17 +09:00
DDD1542 beb95bf2aa [agent-pipeline] pipe-20260315091327-kxyf round-4 2026-03-15 18:57:12 +09:00
DDD1542 2cb736dac1 [agent-pipeline] pipe-20260315091327-kxyf round-3 2026-03-15 18:46:28 +09:00
DDD1542 ffc7cb7933 [agent-pipeline] rollback to 784dc73a 2026-03-15 18:31:12 +09:00
DDD1542 d542e92021 [agent-pipeline] pipe-20260315091327-kxyf round-2 2026-03-15 18:31:12 +09:00
DDD1542 784dc73abf [agent-pipeline] pipe-20260315091327-kxyf round-1 2026-03-15 18:22:20 +09:00
DDD1542 27ce039fc8 [agent-pipeline] pipe-20260315080636-1tpd round-4 2026-03-15 17:22:24 +09:00
DDD1542 4c19d3a6eb [agent-pipeline] pipe-20260315080636-1tpd round-3 2026-03-15 17:18:18 +09:00
DDD1542 94a95b7dc1 [agent-pipeline] pipe-20260315080636-1tpd round-2 2026-03-15 17:13:37 +09:00
DDD1542 f711506671 [agent-pipeline] pipe-20260315080636-1tpd round-1 2026-03-15 17:10:04 +09:00
DDD1542 24b53b5b33 [agent-pipeline] pipe-20260315072335-zb1m round-5 2026-03-15 16:42:20 +09:00
DDD1542 bf509171db [agent-pipeline] pipe-20260315072335-zb1m round-4 2026-03-15 16:38:00 +09:00
DDD1542 0ac9db45a0 [agent-pipeline] pipe-20260315072335-zb1m round-3 2026-03-15 16:32:53 +09:00
DDD1542 bafc81b2a3 [agent-pipeline] pipe-20260315072335-zb1m round-2 2026-03-15 16:30:14 +09:00
DDD1542 21ca0f3a3c [agent-pipeline] pipe-20260315072335-zb1m round-1 2026-03-15 16:27:14 +09:00
DDD1542 265d79cc5a [agent-pipeline] pipe-20260315065015-rei8 round-5 2026-03-15 16:07:43 +09:00
DDD1542 bad3a002f3 [agent-pipeline] rollback to b1afe1bc 2026-03-15 16:05:07 +09:00
DDD1542 0db57fe01a [agent-pipeline] pipe-20260315065015-rei8 round-4 2026-03-15 16:05:06 +09:00
DDD1542 b1afe1bc8d [agent-pipeline] pipe-20260315065015-rei8 round-3 2026-03-15 16:01:43 +09:00
DDD1542 c4db3fbfd4 [agent-pipeline] pipe-20260315065015-rei8 round-2 2026-03-15 15:58:02 +09:00
DDD1542 015cd2c3ed [agent-pipeline] pipe-20260315065015-rei8 round-1 2026-03-15 15:54:04 +09:00
DDD1542 ea6aa6921c [agent-pipeline] rollback to f375252d 2026-03-15 15:36:53 +09:00
DDD1542 e963129e63 [agent-pipeline] pipe-20260315061036-2tnn round-6 2026-03-15 15:36:53 +09:00
DDD1542 f375252db1 [agent-pipeline] pipe-20260315061036-2tnn round-5 2026-03-15 15:33:19 +09:00
DDD1542 542663e9e6 [agent-pipeline] rollback to 501325e4 2026-03-15 15:30:47 +09:00
DDD1542 245580117e [agent-pipeline] pipe-20260315061036-2tnn round-4 2026-03-15 15:30:47 +09:00
DDD1542 501325e4b4 [agent-pipeline] pipe-20260315061036-2tnn round-3 2026-03-15 15:27:14 +09:00
DDD1542 92cd070749 [agent-pipeline] pipe-20260315061036-2tnn round-2 2026-03-15 15:22:16 +09:00
DDD1542 c3a43179e3 refactor: update color schemes and improve component styling
- Changed color schemes for various screen types and roles to align with the new design guidelines, enhancing visual consistency across the application.
- Updated background colors for components based on their types, such as changing 'bg-slate-400' to 'bg-muted-foreground' and adjusting other color mappings for better clarity.
- Improved the styling of the ScreenNode and V2PropertiesPanel components to ensure a more cohesive user experience.
- Enhanced the DynamicComponentRenderer to support dynamic loading of column metadata with cache invalidation for better performance.

These changes aim to refine the UI and improve the overall aesthetic of the application, ensuring a more modern and user-friendly interface.
2026-03-15 15:15:44 +09:00
DDD1542 b8f5d4be4c Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into refactor/config-panel-redesign
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-03-13 17:46:06 +09:00
DDD1542 8ca1890fc0 .. 2026-03-13 17:45:12 +09:00
19 changed files with 1737 additions and 704 deletions

View File

@ -2058,6 +2058,119 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
});
});
// 6. v2-repeater 컴포넌트에서 selectedTable/foreignKey 추출
const v2RepeaterQuery = `
SELECT DISTINCT
sd.screen_id,
sd.screen_name,
sd.table_name as main_table,
comp->'overrides'->>'type' as component_type,
comp->'overrides'->>'selectedTable' as sub_table,
comp->'overrides'->>'foreignKey' as foreign_key,
comp->'overrides'->>'parentTable' as parent_table
FROM screen_definitions sd
JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id,
jsonb_array_elements(slv2.layout_data->'components') as comp
WHERE sd.screen_id = ANY($1)
AND comp->'overrides'->>'type' = 'v2-repeater'
AND comp->'overrides'->>'selectedTable' IS NOT NULL
`;
const v2RepeaterResult = await pool.query(v2RepeaterQuery, [screenIds]);
v2RepeaterResult.rows.forEach((row: any) => {
const screenId = row.screen_id;
const mainTable = row.main_table;
const subTable = row.sub_table;
const foreignKey = row.foreign_key;
if (!subTable || subTable === mainTable) return;
if (!screenSubTables[screenId]) {
screenSubTables[screenId] = {
screenId,
screenName: row.screen_name,
mainTable: mainTable || '',
subTables: [],
};
}
const exists = screenSubTables[screenId].subTables.some(
(st) => st.tableName === subTable
);
if (!exists) {
screenSubTables[screenId].subTables.push({
tableName: subTable,
componentType: 'v2-repeater',
relationType: 'rightPanelRelation',
fieldMappings: foreignKey ? [{
sourceField: 'id',
targetField: foreignKey,
sourceDisplayName: 'ID',
targetDisplayName: foreignKey,
}] : undefined,
});
}
});
logger.info("v2-repeater 서브 테이블 추출 완료", {
screenIds,
v2RepeaterCount: v2RepeaterResult.rows.length,
});
// 7. rightPanel.components 내부의 componentConfig.detailTable 추출 (v2-bom-tree 등)
const v2DetailTableQuery = `
SELECT DISTINCT
sd.screen_id,
sd.screen_name,
sd.table_name as main_table,
inner_comp->>'type' as component_type,
inner_comp->'componentConfig'->>'detailTable' as sub_table,
inner_comp->'componentConfig'->>'foreignKey' as foreign_key
FROM screen_definitions sd
JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id,
jsonb_array_elements(slv2.layout_data->'components') as comp,
jsonb_array_elements(
COALESCE(
comp->'overrides'->'rightPanel'->'components',
comp->'overrides'->'leftPanel'->'components',
'[]'::jsonb
)
) as inner_comp
WHERE sd.screen_id = ANY($1)
AND inner_comp->'componentConfig'->>'detailTable' IS NOT NULL
`;
const v2DetailTableResult = await pool.query(v2DetailTableQuery, [screenIds]);
v2DetailTableResult.rows.forEach((row: any) => {
const screenId = row.screen_id;
const mainTable = row.main_table;
const subTable = row.sub_table;
const foreignKey = row.foreign_key;
if (!subTable || subTable === mainTable) return;
if (!screenSubTables[screenId]) {
screenSubTables[screenId] = {
screenId,
screenName: row.screen_name,
mainTable: mainTable || '',
subTables: [],
};
}
const exists = screenSubTables[screenId].subTables.some(
(st) => st.tableName === subTable
);
if (!exists) {
screenSubTables[screenId].subTables.push({
tableName: subTable,
componentType: row.component_type || 'v2-bom-tree',
relationType: 'rightPanelRelation',
fieldMappings: foreignKey ? [{
sourceField: 'id',
targetField: foreignKey,
sourceDisplayName: 'ID',
targetDisplayName: foreignKey,
}] : undefined,
});
}
});
logger.info("v2-bom-tree/detailTable 서브 테이블 추출 완료", {
screenIds,
v2DetailTableCount: v2DetailTableResult.rows.length,
});
// ============================================================
// 저장 테이블 정보 추출
// ============================================================

View File

@ -12,7 +12,7 @@ services:
environment:
- NODE_ENV=development
- PORT=8080
- DATABASE_URL=postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
- JWT_EXPIRES_IN=24h
- CORS_ORIGIN=http://localhost:9771

View File

@ -531,7 +531,7 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul
- [x] 레지스트리 등록
- [x] 문서화 (README.md)
#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30)
#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30, 업데이트: 2026-03-13)
- [x] 타입 정의 완료
- [x] 기본 구조 생성
@ -539,12 +539,16 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul
- [x] TimelineGrid (배경)
- [x] ResourceColumn (리소스)
- [x] ScheduleBar 기본 렌더링
- [x] 드래그 이동 (기본)
- [x] 리사이즈 (기본)
- [x] 드래그 이동 (실제 로직: deltaX → 날짜 계산 → API 저장 → toast)
- [x] 리사이즈 (실제 로직: 시작/종료 핸들 → 기간 변경 → API 저장 → toast)
- [x] 줌 레벨 전환
- [x] 날짜 네비게이션
- [ ] 충돌 감지 (향후)
- [ ] 가상 스크롤 (향후)
- [x] 충돌 감지 (같은 리소스 겹침 → ring-destructive + AlertTriangle)
- [x] 마일스톤 표시 (시작일 = 종료일 → 다이아몬드 마커)
- [x] 범례 표시 (TimelineLegend: 상태별 색상 + 마일스톤 + 충돌)
- [x] 반응형 공통 CSS 적용 (text-[10px] sm:text-sm 패턴)
- [x] staticFilters 지원 (커스텀 테이블 필터링)
- [ ] 가상 스크롤 (향후 - 대용량 100+ 리소스)
- [x] 설정 패널 구현
- [x] API 연동
- [x] 레지스트리 등록

View File

@ -1,11 +1,10 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ArrowLeft, Plus, RefreshCw, Search, LayoutGrid, LayoutList, TestTube2 } from "lucide-react";
import ScreenList from "@/components/screen/ScreenList";
import { Plus, RefreshCw, Search, X, LayoutGrid, LayoutList, TestTube2, Database, MoreHorizontal, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import ScreenDesigner from "@/components/screen/ScreenDesigner";
import TemplateManager from "@/components/screen/TemplateManager";
import { ScreenGroupTreeView } from "@/components/screen/ScreenGroupTreeView";
@ -15,11 +14,19 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import CreateScreenModal from "@/components/screen/CreateScreenModal";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
// 단계별 진행을 위한 타입 정의
type Step = "list" | "design" | "template" | "v2-test";
type ViewMode = "tree" | "table";
type ViewMode = "flow" | "card";
export default function ScreenManagementPage() {
const searchParams = useSearchParams();
@ -28,11 +35,15 @@ export default function ScreenManagementPage() {
const [selectedGroup, setSelectedGroup] = useState<{ id: number; name: string; company_code?: string } | null>(null);
const [focusedScreenIdInGroup, setFocusedScreenIdInGroup] = useState<number | null>(null);
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
const [viewMode, setViewMode] = useState<ViewMode>("tree");
const [viewMode, setViewMode] = useState<ViewMode>("flow");
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const tableCount = useMemo(() => new Set(screens.map((s) => s.tableName).filter(Boolean)).size, [screens]);
// 화면 목록 로드
const loadScreens = useCallback(async () => {
@ -102,6 +113,7 @@ export default function ScreenManagementPage() {
// 화면 선택 핸들러 (개별 화면 선택 시 그룹 선택 해제)
const handleScreenSelect = (screen: ScreenDefinition) => {
setSelectedScreen(screen);
setIsDetailOpen(true);
setSelectedGroup(null); // 그룹 선택 해제
};
@ -159,96 +171,126 @@ export default function ScreenManagementPage() {
return (
<div className="flex h-screen flex-col bg-background overflow-hidden">
{/* 페이지 헤더 */}
<div className="flex-shrink-0 border-b bg-background px-6 py-4">
<div className="flex-shrink-0 border-b border-border/50 bg-background/95 backdrop-blur-md px-6 py-3">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p>
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold tracking-tight"> </h1>
<Badge variant="secondary" className="text-xs">{screens.length} </Badge>
</div>
<div className="flex items-center gap-2">
{/* V2 컴포넌트 테스트 버튼 */}
<Button
variant="outline"
onClick={() => goToNextStep("v2-test")}
className="gap-2"
>
<TestTube2 className="h-4 w-4" />
V2
</Button>
{/* 뷰 모드 전환 */}
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
<TabsList className="h-9">
<TabsTrigger value="tree" className="gap-1.5 px-3">
<TabsList className="h-9 bg-muted/50 border border-border/50">
<TabsTrigger value="flow" className="gap-1.5 px-3 text-xs">
<LayoutGrid className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="table" className="gap-1.5 px-3">
<TabsTrigger value="card" className="gap-1.5 px-3 text-xs">
<LayoutList className="h-4 w-4" />
</TabsTrigger>
</TabsList>
</Tabs>
<Button variant="outline" size="icon" onClick={loadScreens}>
<RefreshCw className="h-4 w-4" />
</Button>
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
<Button onClick={() => setIsCreateOpen(true)} className="gap-2 shadow-sm hover:shadow-md transition-shadow">
<Plus className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => goToNextStep("v2-test")}>
<TestTube2 className="h-4 w-4 mr-2" />
V2
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{/* 메인 콘텐츠 */}
{viewMode === "tree" ? (
{viewMode === "flow" ? (
<div className="flex-1 overflow-hidden flex">
{/* 왼쪽: 트리 구조 */}
<div className="w-[350px] min-w-[280px] max-w-[450px] flex flex-col border-r bg-background">
{/* 검색 */}
<div className="flex-shrink-0 p-3 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="화면 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9"
/>
{/* 왼쪽: 트리 구조 (접기/펼기 지원) */}
<div className={`flex flex-col border-r border-border/50 bg-background/80 backdrop-blur-sm transition-all duration-300 ease-in-out ${
sidebarCollapsed ? "w-[48px] min-w-[48px]" : "w-[320px] min-w-[280px] max-w-[400px]"
}`}>
{/* 사이드바 헤더 */}
<div className="flex-shrink-0 flex items-center justify-between p-2 border-b border-border/50">
{!sidebarCollapsed && <span className="text-xs font-medium text-muted-foreground px-1"></span>}
<Button
variant="ghost"
size="icon"
className={`h-7 w-7 ${sidebarCollapsed ? "mx-auto" : "ml-auto"}`}
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
>
{sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</Button>
</div>
{/* 사이드바 접힘 시 아이콘 컬럼 */}
{sidebarCollapsed && (
<div className="flex-1 flex flex-col items-center gap-2 py-3">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setSidebarCollapsed(false)}>
<Search className="h-4 w-4 text-muted-foreground" />
</Button>
<div className="mt-auto pb-2">
<Badge variant="secondary" className="text-[10px] px-1.5">{screens.length}</Badge>
</div>
</div>
</div>
{/* 트리 뷰 */}
<div className="flex-1 overflow-hidden">
<ScreenGroupTreeView
screens={filteredScreens}
selectedScreen={selectedScreen}
onScreenSelect={handleScreenSelect}
onScreenDesign={handleDesignScreen}
searchTerm={searchTerm}
onGroupSelect={(group) => {
setSelectedGroup(group);
setSelectedScreen(null); // 화면 선택 해제
setFocusedScreenIdInGroup(null); // 포커스 초기화
}}
onScreenSelectInGroup={(group, screenId) => {
// 그룹 내 화면 클릭 시
const isNewGroup = selectedGroup?.id !== group.id;
if (isNewGroup) {
// 새 그룹 진입: 포커싱 없이 시작 (첫 진입 시 망가지는 문제 방지)
setSelectedGroup(group);
setFocusedScreenIdInGroup(null);
} else {
// 같은 그룹 내에서 다른 화면 클릭: 포커싱 유지
setFocusedScreenIdInGroup(screenId);
}
setSelectedScreen(null);
}}
/>
</div>
)}
{/* 사이드바 펼침 시 전체 UI */}
{!sidebarCollapsed && (
<>
{/* 검색 */}
<div className="flex-shrink-0 p-3 border-b border-border/50">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="화면 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9 rounded-xl bg-muted/30 border-border/50 focus:bg-background focus:ring-2 focus:ring-primary/30 transition-colors"
/>
</div>
</div>
{/* 트리 뷰 */}
<div className="flex-1 overflow-hidden">
<ScreenGroupTreeView
screens={filteredScreens}
selectedScreen={selectedScreen}
onScreenSelect={handleScreenSelect}
onScreenDesign={handleDesignScreen}
searchTerm={searchTerm}
onGroupSelect={(group) => {
setSelectedGroup(group);
setSelectedScreen(null);
setFocusedScreenIdInGroup(null);
}}
onScreenSelectInGroup={(group, screenId) => {
const isNewGroup = selectedGroup?.id !== group.id;
if (isNewGroup) {
setSelectedGroup(group);
setFocusedScreenIdInGroup(null);
} else {
setFocusedScreenIdInGroup(screenId);
}
setSelectedScreen(null);
}}
/>
</div>
</>
)}
</div>
{/* 오른쪽: 관계 시각화 (React Flow) */}
<div className="flex-1 overflow-hidden">
<div className="flex-1 overflow-hidden bg-muted/10">
<ScreenRelationFlow
screen={selectedScreen}
selectedGroup={selectedGroup}
@ -257,21 +299,150 @@ export default function ScreenManagementPage() {
</div>
</div>
) : (
// 테이블 뷰 (기존 ScreenList 사용)
<div className="flex-1 overflow-auto p-6">
<ScreenList
onScreenSelect={handleScreenSelect}
selectedScreen={selectedScreen}
onDesignScreen={handleDesignScreen}
/>
<div className="flex-1 overflow-auto p-6 bg-muted/30 dark:bg-background">
{/* 카드 뷰 상단: 검색 + 카운트 */}
<div className="flex items-center gap-3 mb-5">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="화면 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9 rounded-xl bg-card dark:bg-card border-border/50 shadow-sm focus:bg-card focus:ring-2 focus:ring-primary/30 transition-colors"
/>
{searchTerm && (
<button
type="button"
onClick={() => setSearchTerm("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="검색어 지우기"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<span className="text-xs text-muted-foreground">{filteredScreens.length} </span>
</div>
<div className="grid grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3">
{filteredScreens.map((screen) => {
const screenType = (screen as { screenType?: string }).screenType || "form";
const isSelected = selectedScreen?.screenId === screen.screenId;
const isRecentlyModified = screen.updatedDate && (Date.now() - new Date(screen.updatedDate).getTime()) < 7 * 24 * 60 * 60 * 1000;
const typeColorClass = screenType === "grid"
? "from-primary to-primary/20"
: screenType === "dashboard"
? "from-warning to-warning/20"
: "from-success to-success/20";
const glowClass = screenType === "grid"
? "hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.1),0_0_20px_hsl(var(--primary)/0.1)] dark:hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.4),0_0_24px_hsl(var(--primary)/0.15)]"
: screenType === "dashboard"
? "hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.1),0_0_20px_hsl(var(--warning)/0.1)] dark:hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.4),0_0_24px_hsl(var(--warning)/0.12)]"
: "hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.1),0_0_20px_hsl(var(--success)/0.1)] dark:hover:shadow-[0_8px_24px_-4px_rgba(0,0,0,0.4),0_0_24px_hsl(var(--success)/0.12)]";
const badgeBgClass = screenType === "grid"
? "bg-primary/8 dark:bg-primary/15 text-primary"
: screenType === "dashboard"
? "bg-warning/8 dark:bg-warning/15 text-warning"
: "bg-success/8 dark:bg-success/15 text-success";
return (
<div
key={screen.screenId}
className={`group relative overflow-hidden rounded-[12px] cursor-pointer transition-all duration-250 ease-[cubic-bezier(0.4,0,0.2,1)] ${
isSelected
? "border border-primary bg-primary/5 dark:bg-primary/8 shadow-[0_0_0_2px_hsl(var(--primary)/0.22),0_1px_3px_rgba(0,0,0,0.06)] dark:shadow-[0_0_0_2px_hsl(var(--primary)/0.3),0_1px_4px_rgba(0,0,0,0.3)]"
: `border border-transparent bg-card shadow-[0_1px_3px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)] dark:shadow-[0_1px_4px_rgba(0,0,0,0.35),0_0_1px_rgba(0,0,0,0.2)] hover:-translate-y-[2px] ${glowClass}`
}`}
onClick={() => handleScreenSelect(screen)}
onDoubleClick={() => handleDesignScreen(screen)}
>
{/* 좌측 그라데이션 액센트 바 */}
<div className={`absolute left-0 top-3 bottom-3 w-[3px] rounded-r-full bg-gradient-to-b ${typeColorClass} transition-all duration-250 group-hover:top-1 group-hover:bottom-1 group-hover:w-[4px]`} />
{isSelected && (
<div className={`absolute left-0 top-0 bottom-0 w-[4px] bg-gradient-to-b ${typeColorClass}`} />
)}
<div className="pl-[14px] pr-4 py-4">
{/* Row 1: 이름 + 타입 뱃지 */}
<div className="flex items-center gap-2 mb-1">
<div className="text-[15px] font-bold leading-snug truncate flex-1 min-w-0 tracking-[-0.3px]">{screen.screenName}</div>
<span className={`text-[11px] font-semibold px-2.5 py-[3px] rounded-md flex-shrink-0 ${badgeBgClass}`}>
{screenType === "grid" ? "그리드" : screenType === "dashboard" ? "대시보드" : "폼"}
</span>
</div>
{/* Row 2: 스크린 코드 */}
<div className="text-[12px] font-mono text-muted-foreground tracking-[-0.3px] truncate mb-3">{screen.screenCode}</div>
{/* Row 3: 테이블 칩 + 메타 */}
<div className="flex items-center gap-1.5 flex-wrap">
<span className="inline-flex items-center gap-1.5 text-[12px] font-medium text-foreground/80 dark:text-foreground/70 px-2.5 py-1 rounded-md bg-muted/60 dark:bg-muted/40">
<Database className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-mono text-[11px]">{screen.tableLabel || screen.tableName || "—"}</span>
</span>
</div>
{/* Row 4: 날짜 + 수정 상태 */}
<div className="flex items-center justify-between mt-3 pt-2.5 border-t border-border/20 dark:border-border/10">
<span className="text-[12px] font-mono text-muted-foreground">
{screen.updatedDate ? new Date(screen.updatedDate).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" }) : screen.createdDate ? new Date(screen.createdDate).toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" }) : ""}
</span>
{isRecentlyModified && (
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span className="relative inline-block w-[6px] h-[6px] rounded-full bg-success screen-card-pulse-dot" />
</span>
)}
</div>
</div>
</div>
);
})}
</div>
{filteredScreens.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Search className="h-8 w-8 mb-3 opacity-30" />
<p className="text-sm"> </p>
</div>
)}
</div>
)}
{/* 화면 디테일 Sheet */}
<Sheet open={isDetailOpen} onOpenChange={setIsDetailOpen}>
<SheetContent className="w-[420px] sm:max-w-[420px]">
<SheetHeader>
<SheetTitle className="text-base">{selectedScreen?.screenName || "화면 상세"}</SheetTitle>
<SheetDescription className="text-xs font-mono">{selectedScreen?.screenCode}</SheetDescription>
</SheetHeader>
{selectedScreen && (
<div className="mt-6 space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs font-mono">{selectedScreen.tableName || "없음"}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground"> ID</span>
<span className="text-xs font-mono">{selectedScreen.screenId}</span>
</div>
</div>
<div className="flex gap-2 pt-4 border-t border-border/50">
<Button className="flex-1" onClick={() => { handleDesignScreen(selectedScreen); setIsDetailOpen(false); }}>
</Button>
<Button variant="outline" onClick={() => setIsDetailOpen(false)}>
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
{/* 화면 생성 모달 */}
<CreateScreenModal
isOpen={isCreateOpen}
onClose={() => setIsCreateOpen(false)}
onSuccess={() => {
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
onCreated={() => {
setIsCreateOpen(false);
loadScreens();
}}

View File

@ -418,6 +418,21 @@ select {
border-spacing: 0 !important;
}
/* ===== 카드 펄스 도트 애니메이션 ===== */
@keyframes screen-card-pulse {
0%, 100% { opacity: 0; transform: scale(1); }
50% { opacity: 0.35; transform: scale(2); }
}
.screen-card-pulse-dot::after {
content: '';
position: absolute;
inset: -3px;
border-radius: 50%;
background: hsl(var(--success));
opacity: 0;
animation: screen-card-pulse 2.5s ease-in-out infinite;
}
/* ===== 저장 테이블 막대기 애니메이션 ===== */
@keyframes saveBarDrop {
0% {

View File

@ -0,0 +1,70 @@
"use client";
import React from "react";
import { BaseEdge, getBezierPath, type EdgeProps } from "@xyflow/react";
// 커스텀 애니메이션 엣지 — bezier 곡선 + 흐르는 파티클 + 글로우 레이어
export function AnimatedFlowEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style,
markerEnd,
data,
}: EdgeProps) {
const [edgePath] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const strokeColor = (style?.stroke as string) || "hsl(var(--primary))";
const strokeW = (style?.strokeWidth as number) || 2;
const isActive = data?.active !== false;
const duration = data?.duration || "3s";
const filterId = `edge-glow-${id}`;
return (
<>
{/* 글로우용 SVG 필터 정의 (엣지별 고유 ID) */}
<defs>
<filter id={filterId} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* 글로우 레이어 */}
<path
d={edgePath}
fill="none"
stroke={strokeColor}
strokeWidth={strokeW + 4}
strokeOpacity={0.12}
filter={`url(#${filterId})`}
/>
{/* 메인 엣지 */}
<BaseEdge id={id} path={edgePath} style={style} markerEnd={markerEnd} />
{/* 흐르는 파티클 */}
{isActive && (
<>
<circle r="3" fill={strokeColor} filter={`url(#${filterId})`}>
<animateMotion dur={duration} repeatCount="indefinite" path={edgePath} />
</circle>
<circle r="1.5" fill="white" opacity="0.85">
<animateMotion dur={duration} repeatCount="indefinite" path={edgePath} />
</circle>
</>
)}
</>
);
}

View File

@ -37,7 +37,8 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/useAuth";
import { getCompanyList, Company } from "@/lib/api/company";
import { getCompanyList } from "@/lib/api/company";
import type { Company } from "@/types/company";
import {
DropdownMenu,
DropdownMenuContent,
@ -1106,7 +1107,7 @@ export function ScreenGroupTreeView({
{/* 그룹 헤더 */}
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"flex items-center gap-2 rounded-lg px-2 py-1.5 cursor-pointer hover:bg-muted/40 transition-colors",
"text-sm font-medium group/item",
isMatching && "bg-primary/5 dark:bg-primary/10" // 검색 일치 하이라이트 (연한 배경)
)}
@ -1119,12 +1120,12 @@ export function ScreenGroupTreeView({
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
{isExpanded ? (
<FolderOpen className="h-4 w-4 shrink-0 text-amber-500" />
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-warning/15"><FolderOpen className="h-3.5 w-3.5 text-warning" /></span>
) : (
<Folder className="h-4 w-4 shrink-0 text-amber-500" />
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-warning/15"><Folder className="h-3.5 w-3.5 text-warning" /></span>
)}
<span className={cn("truncate flex-1", isMatching && "font-medium text-primary/80")}>{group.group_name}</span>
<Badge variant="secondary" className="text-xs">
<Badge variant="secondary" className="text-xs font-mono">
{groupScreens.length}
</Badge>
{/* 그룹 메뉴 버튼 */}
@ -1157,7 +1158,8 @@ export function ScreenGroupTreeView({
{/* 그룹 내 하위 그룹들 */}
{isExpanded && childGroups.length > 0 && (
<div className="ml-6 mt-1 space-y-0.5">
<div className="relative ml-6 mt-1 space-y-0.5">
<div className="absolute left-[14px] top-0 bottom-0 w-px bg-border/40" />
{childGroups.map((childGroup) => {
const childGroupId = String(childGroup.id);
const isChildExpanded = expandedGroups.has(childGroupId) || shouldAutoExpandForSearch.has(childGroup.id); // 검색 시 상위 그룹만 자동 확장
@ -1172,7 +1174,7 @@ export function ScreenGroupTreeView({
{/* 중분류 헤더 */}
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"flex items-center gap-2 rounded-lg px-2 py-1.5 cursor-pointer hover:bg-muted/40 transition-colors",
"text-xs font-medium group/item",
isChildMatching && "bg-primary/5 dark:bg-primary/10"
)}
@ -1185,12 +1187,12 @@ export function ScreenGroupTreeView({
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
)}
{isChildExpanded ? (
<FolderOpen className="h-3 w-3 shrink-0 text-primary" />
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-primary/15"><FolderOpen className="h-3.5 w-3.5 text-primary" /></span>
) : (
<Folder className="h-3 w-3 shrink-0 text-primary" />
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-primary/15"><Folder className="h-3.5 w-3.5 text-primary" /></span>
)}
<span className={cn("truncate flex-1", isChildMatching && "font-medium text-primary/80")}>{childGroup.group_name}</span>
<Badge variant="secondary" className="text-[10px] h-4">
<Badge variant="secondary" className="text-[10px] h-4 font-mono">
{childScreens.length}
</Badge>
<DropdownMenu>
@ -1222,7 +1224,8 @@ export function ScreenGroupTreeView({
{/* 중분류 내 손자 그룹들 (소분류) */}
{isChildExpanded && grandChildGroups.length > 0 && (
<div className="ml-6 mt-1 space-y-0.5">
<div className="relative ml-6 mt-1 space-y-0.5">
<div className="absolute left-[14px] top-0 bottom-0 w-px bg-border/30" />
{grandChildGroups.map((grandChild) => {
const grandChildId = String(grandChild.id);
const isGrandExpanded = expandedGroups.has(grandChildId) || shouldAutoExpandForSearch.has(grandChild.id); // 검색 시 상위 그룹만 자동 확장
@ -1234,7 +1237,7 @@ export function ScreenGroupTreeView({
{/* 소분류 헤더 */}
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"flex items-center gap-2 rounded-lg px-2 py-1.5 cursor-pointer hover:bg-muted/40 transition-colors",
"text-xs group/item",
isGrandMatching && "bg-primary/5 dark:bg-primary/10"
)}
@ -1247,12 +1250,12 @@ export function ScreenGroupTreeView({
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
)}
{isGrandExpanded ? (
<FolderOpen className="h-3 w-3 shrink-0 text-emerald-500" />
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-success/15"><FolderOpen className="h-3.5 w-3.5 text-success" /></span>
) : (
<Folder className="h-3 w-3 shrink-0 text-emerald-500" />
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-success/15"><Folder className="h-3.5 w-3.5 text-success" /></span>
)}
<span className={cn("truncate flex-1", isGrandMatching && "font-medium text-primary/80")}>{grandChild.group_name}</span>
<Badge variant="outline" className="text-[10px] h-4">
<Badge variant="outline" className="text-[10px] h-4 font-mono">
{grandScreens.length}
</Badge>
<DropdownMenu>
@ -1294,9 +1297,9 @@ export function ScreenGroupTreeView({
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-xs hover:bg-accent",
selectedScreen?.screenId === screen.screenId && "bg-accent"
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
"text-xs hover:bg-muted/60",
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
)}
onClick={() => handleScreenClickInGroup(screen, grandChild)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
@ -1330,9 +1333,9 @@ export function ScreenGroupTreeView({
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-xs hover:bg-accent",
selectedScreen?.screenId === screen.screenId && "bg-accent"
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
"text-xs hover:bg-muted/60",
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
)}
onClick={() => handleScreenClickInGroup(screen, childGroup)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
@ -1366,9 +1369,9 @@ export function ScreenGroupTreeView({
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-sm hover:bg-accent group/screen",
selectedScreen?.screenId === screen.screenId && "bg-accent"
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
"text-sm hover:bg-muted/60 group/screen",
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
)}
onClick={() => handleScreenClickInGroup(screen, group)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
@ -1393,7 +1396,7 @@ export function ScreenGroupTreeView({
<div className="mb-1">
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer hover:bg-accent transition-colors",
"flex items-center gap-2 rounded-lg px-2 py-1.5 cursor-pointer hover:bg-muted/40 transition-colors",
"text-sm font-medium text-muted-foreground"
)}
onClick={() => toggleGroup("ungrouped")}
@ -1405,7 +1408,7 @@ export function ScreenGroupTreeView({
)}
<Folder className="h-4 w-4 shrink-0" />
<span className="truncate flex-1"></span>
<Badge variant="outline" className="text-xs">
<Badge variant="outline" className="text-xs font-mono">
{ungroupedScreens.length}
</Badge>
</div>
@ -1416,9 +1419,9 @@ export function ScreenGroupTreeView({
<div
key={screen.screenId}
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors",
"text-sm hover:bg-accent",
selectedScreen?.screenId === screen.screenId && "bg-accent"
"flex items-center gap-2 rounded-md px-2 py-1.5 cursor-pointer transition-colors duration-150",
"text-sm hover:bg-muted/60",
selectedScreen?.screenId === screen.screenId && "bg-primary/10 border-l-2 border-primary"
)}
onClick={() => handleScreenClick(screen)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
@ -2096,15 +2099,15 @@ export function ScreenGroupTreeView({
onClick={() => handleSync("menu-to-screen")}
disabled={isSyncing}
variant="outline"
className="w-full justify-start gap-2 border-emerald-200 bg-emerald-50/50 hover:bg-emerald-100/70 hover:border-emerald-300"
className="w-full justify-start gap-2 border-success/20 bg-success/5 hover:bg-success/10 hover:border-success/30"
>
{isSyncing && syncDirection === "menu-to-screen" ? (
<Loader2 className="h-4 w-4 animate-spin text-emerald-600" />
<Loader2 className="h-4 w-4 animate-spin text-success" />
) : (
<FolderInput className="h-4 w-4 text-emerald-600" />
<FolderInput className="h-4 w-4 text-success" />
)}
<span className="flex-1 text-left text-emerald-700"> </span>
<span className="text-xs text-emerald-500/70">
<span className="flex-1 text-left text-success"> </span>
<span className="text-xs text-success/70">
</span>
</Button>

View File

@ -11,10 +11,25 @@ import {
MousePointer2,
Key,
Link2,
Columns3,
} from "lucide-react";
import { ScreenLayoutSummary } from "@/lib/api/screenGroup";
// 글로우 펄스 애니메이션 CSS 주입
if (typeof document !== "undefined") {
const styleId = "glow-pulse-animation";
if (!document.getElementById(styleId)) {
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
@keyframes glow-pulse {
from { filter: drop-shadow(0 0 4px hsl(var(--primary) / 0.25)) drop-shadow(0 0 10px hsl(var(--primary) / 0.12)); }
to { filter: drop-shadow(0 0 6px hsl(var(--primary) / 0.35)) drop-shadow(0 0 16px hsl(var(--primary) / 0.18)); }
}
`;
document.head.appendChild(style);
}
}
// ========== 타입 정의 ==========
// 화면 노드 데이터 인터페이스
@ -107,42 +122,14 @@ const getScreenTypeIcon = (screenType?: string) => {
}
};
// 화면 타입별 색상 (헤더)
const getScreenTypeColor = (screenType?: string, isMain?: boolean) => {
if (!isMain) return "bg-slate-400";
switch (screenType) {
case "grid":
return "bg-violet-500";
case "dashboard":
return "bg-amber-500";
case "action":
return "bg-rose-500";
default:
return "bg-primary";
}
// 화면 타입별 색상 (헤더) - 더 이상 그라데이션 미사용
const getScreenTypeColor = (_screenType?: string, _isMain?: boolean) => {
return "";
};
// 화면 역할(screenRole)에 따른 색상
const getScreenRoleColor = (screenRole?: string) => {
if (!screenRole) return "bg-slate-400";
// 역할명에 포함된 키워드로 색상 결정
const role = screenRole.toLowerCase();
if (role.includes("그리드") || role.includes("grid") || role.includes("메인") || role.includes("main") || role.includes("list")) {
return "bg-violet-500"; // 보라색 - 메인 그리드
}
if (role.includes("등록") || role.includes("폼") || role.includes("form") || role.includes("register") || role.includes("input")) {
return "bg-primary"; // 파란색 - 등록 폼
}
if (role.includes("액션") || role.includes("action") || role.includes("이벤트") || role.includes("event") || role.includes("클릭")) {
return "bg-rose-500"; // 빨간색 - 액션/이벤트
}
if (role.includes("상세") || role.includes("detail") || role.includes("popup") || role.includes("팝업")) {
return "bg-amber-500"; // 주황색 - 상세/팝업
}
return "bg-slate-400"; // 기본 회색
// 화면 역할(screenRole)에 따른 색상 - 더 이상 그라데이션 미사용
const getScreenRoleColor = (_screenRole?: string) => {
return "";
};
// 화면 타입별 라벨
@ -161,36 +148,26 @@ const getScreenTypeLabel = (screenType?: string) => {
// ========== 화면 노드 (상단) - 미리보기 표시 ==========
export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
const { label, subLabel, isMain, tableName, layoutSummary, isInGroup, isFocused, isFaded, screenRole } = data;
const { label, isMain, tableName, layoutSummary, isFocused, isFaded } = data;
const screenType = layoutSummary?.screenType || "form";
// 그룹 모드에서는 screenRole 기반 색상, 그렇지 않으면 screenType 기반 색상
// isFocused일 때 색상 활성화, isFaded일 때 회색
let headerColor: string;
if (isInGroup) {
if (isFaded) {
headerColor = "bg-muted/60"; // 흑백 처리 - 더 확실한 회색
} else {
// 포커스되었거나 아직 아무것도 선택 안됐을 때: 역할별 색상
headerColor = getScreenRoleColor(screenRole);
}
} else {
headerColor = getScreenTypeColor(screenType, isMain);
}
return (
<div
className={`group relative flex h-[320px] w-[260px] flex-col overflow-hidden rounded-lg border bg-card shadow-md transition-all cursor-pointer ${
className={`group relative flex h-[240px] w-[240px] flex-col overflow-hidden rounded-[10px] border bg-card dark:bg-card/80 backdrop-blur-sm transition-all cursor-pointer ${
isFocused
? "border-2 border-primary ring-4 ring-primary/50 shadow-xl scale-105"
? "border-primary/40 shadow-[0_0_0_1px_hsl(var(--primary)/0.4)] scale-[1.03]"
: isFaded
? "border-border opacity-50"
: "border-border hover:shadow-lg hover:ring-2 hover:ring-primary/20"
? "opacity-40 border-border/40 dark:border-border/10 shadow-[0_4px_24px_-8px_rgba(0,0,0,0.08)] dark:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.5)]"
: "border-border/40 dark:border-border/10 shadow-[0_4px_24px_-8px_rgba(0,0,0,0.08)] dark:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.5)] hover:border-border/50 dark:hover:border-border/20 hover:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.08)] dark:hover:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.5)] hover:-translate-y-0.5"
}`}
style={{
filter: isFaded ? "grayscale(100%)" : "none",
filter: isFaded
? "grayscale(100%)"
: isFocused
? "drop-shadow(0 0 8px hsl(var(--primary) / 0.5)) drop-shadow(0 0 20px hsl(var(--primary) / 0.25))"
: "none",
transition: "all 0.3s ease",
transform: isFocused ? "scale(1.02)" : "scale(1)",
animation: isFocused ? "glow-pulse 2s ease-in-out infinite alternate" : "none",
}}
>
{/* Handles */}
@ -198,78 +175,49 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
type="target"
position={Position.Left}
id="left"
className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:shadow-[0_0_6px_hsl(var(--primary)/0.5)]"
/>
<Handle
type="source"
position={Position.Right}
id="right"
className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:shadow-[0_0_6px_hsl(var(--primary)/0.5)]"
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
className="!h-2 !w-2 !border-2 !border-background !bg-primary opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:shadow-[0_0_6px_hsl(var(--primary)/0.5)]"
/>
{/* 헤더 (컬러) */}
<div className={`flex items-center gap-2 px-3 py-2 text-white ${headerColor} transition-colors duration-300`}>
<Monitor className="h-4 w-4" />
<span className="flex-1 truncate text-xs font-semibold">{label}</span>
{(isMain || isFocused) && <span className="flex h-2 w-2 rounded-full bg-white/80 animate-pulse" />}
{/* 헤더: 그라디언트 제거, 모노크롬 */}
<div className="flex items-center gap-2 border-b border-border/40 dark:border-border/10 bg-muted/50 dark:bg-muted/30 px-3 py-2 transition-colors duration-300">
<div className="flex h-6 w-6 items-center justify-center rounded bg-primary/10 text-primary">
<Monitor className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className="truncate text-xs font-bold text-foreground">{label}</div>
{tableName && <div className="truncate text-[9px] text-muted-foreground font-mono">{tableName}</div>}
</div>
{(isMain || isFocused) && <span className="flex h-2 w-2 rounded-full bg-foreground/[0.12] dark:bg-foreground/8 animate-pulse" />}
</div>
{/* 화면 미리보기 영역 (컴팩트) */}
<div className="h-[140px] overflow-hidden bg-muted/50 p-2">
<div className="h-[110px] overflow-hidden p-2.5">
{layoutSummary ? (
<ScreenPreview layoutSummary={layoutSummary} screenType={screenType} />
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<div className="flex h-full flex-col items-center justify-center text-muted-foreground/70 dark:text-muted-foreground/40">
{getScreenTypeIcon(screenType)}
<span className="mt-1 text-[10px]">: {label}</span>
</div>
)}
</div>
{/* 필드 매핑 영역 */}
<div className="flex-1 overflow-hidden border-t border-border bg-card px-2 py-1.5">
<div className="mb-1 flex items-center gap-1 text-[9px] font-medium text-muted-foreground">
<Columns3 className="h-3 w-3" />
<span> </span>
<span className="ml-auto text-[8px] text-muted-foreground/70">
{layoutSummary?.layoutItems?.filter(i => i.label && !i.componentKind?.includes('button')).length || 0}
</span>
</div>
<div className="flex flex-col gap-0.5 overflow-y-auto" style={{ maxHeight: '80px' }}>
{layoutSummary?.layoutItems
?.filter(item => item.label && !item.componentKind?.includes('button'))
?.slice(0, 6)
?.map((item, idx) => (
<div key={idx} className="flex items-center gap-1 rounded bg-slate-50 px-1.5 py-0.5">
<div className={`h-1.5 w-1.5 rounded-full ${
item.componentKind === 'table-list' ? 'bg-violet-400' :
item.componentKind?.includes('select') ? 'bg-amber-400' :
'bg-slate-400'
}`} />
<span className="flex-1 truncate text-[9px] text-slate-600">{item.label}</span>
<span className="text-[8px] text-slate-400">{item.componentKind?.split('-')[0] || 'field'}</span>
</div>
)) || (
<div className="text-center text-[9px] text-slate-400 py-2"> </div>
)}
</div>
</div>
{/* 푸터 (테이블 정보) */}
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-3 py-1.5">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Database className="h-3 w-3" />
<span className="max-w-[120px] truncate font-mono">{tableName || "No Table"}</span>
</div>
<span className="rounded bg-muted px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground">
{getScreenTypeLabel(screenType)}
</span>
{/* 푸터 (타입 칩 + 컴포넌트 수) */}
<div className="flex items-center justify-between border-t border-border/40 dark:border-border/10 bg-background dark:bg-background/50 px-3 py-1.5">
<span className="text-[9px] font-medium px-[7px] py-[2px] rounded bg-primary/10 text-primary">{getScreenTypeLabel(screenType)}</span>
<span className="text-[9px] text-muted-foreground">{layoutSummary?.totalComponents ?? 0} </span>
</div>
</div>
);
@ -280,33 +228,33 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
const getComponentColor = (componentKind: string) => {
// 테이블/그리드 관련
if (componentKind === "table-list" || componentKind === "data-grid") {
return "bg-violet-200 border-violet-400";
return "bg-primary/20 border-primary/40";
}
// 검색 필터
if (componentKind === "table-search-widget" || componentKind === "search-filter") {
return "bg-pink-200 border-pink-400";
return "bg-destructive/20 border-destructive/40";
}
// 버튼 관련
if (componentKind?.includes("button")) {
return "bg-blue-300 border-primary";
return "bg-primary/30 border-primary";
}
// 입력 필드
if (componentKind?.includes("input") || componentKind?.includes("text")) {
return "bg-slate-200 border-slate-400";
return "bg-muted border-border";
}
// 셀렉트/드롭다운
if (componentKind?.includes("select") || componentKind?.includes("dropdown")) {
return "bg-amber-200 border-amber-400";
return "bg-warning/20 border-warning/40";
}
// 차트
if (componentKind?.includes("chart")) {
return "bg-emerald-200 border-emerald-400";
return "bg-success/20 border-success/40";
}
// 커스텀 위젯
if (componentKind === "custom") {
return "bg-pink-200 border-pink-400";
return "bg-destructive/20 border-destructive/40";
}
return "bg-slate-100 border-slate-300";
return "bg-muted/50 border-border";
};
// ========== 화면 미리보기 컴포넌트 - 화면 타입별 간단한 일러스트 ==========
@ -316,130 +264,114 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
}) => {
const { totalComponents, widgetCounts } = layoutSummary;
// 그리드 화면 일러스트
// 그리드 화면 일러스트 (모노크롬)
if (screenType === "grid") {
return (
<div className="flex h-full flex-col gap-2 rounded-lg border border-border bg-muted/30 p-3">
return (
<div className="flex h-full flex-col gap-2 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 p-3">
{/* 상단 툴바 */}
<div className="flex items-center gap-2">
<div className="h-4 w-16 rounded bg-pink-400/80 shadow-sm" />
<div className="h-4 w-16 rounded bg-foreground/[0.15] dark:bg-foreground/10" />
<div className="flex-1" />
<div className="h-4 w-8 rounded bg-primary shadow-sm" />
<div className="h-4 w-8 rounded bg-primary shadow-sm" />
<div className="h-4 w-8 rounded bg-rose-500 shadow-sm" />
<div className="h-4 w-8 rounded bg-foreground/[0.18] dark:bg-foreground/12" />
<div className="h-4 w-8 rounded bg-foreground/[0.18] dark:bg-foreground/12" />
<div className="h-4 w-8 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
</div>
{/* 테이블 헤더 */}
<div className="flex gap-1 rounded-t-md bg-violet-500 px-2 py-2 shadow-sm">
<div className="flex gap-1 rounded-t-md bg-foreground/[0.18] dark:bg-foreground/12 px-2 py-2">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-2.5 flex-1 rounded bg-white/40" />
<div key={i} className="h-2.5 flex-1 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
))}
</div>
{/* 테이블 행들 */}
<div className="flex flex-1 flex-col gap-1 overflow-hidden">
{[...Array(7)].map((_, i) => (
<div key={i} className={`flex gap-1 rounded px-2 py-1.5 ${i % 2 === 0 ? "bg-muted" : "bg-card"}`}>
<div key={i} className={`flex gap-1 rounded px-2 py-1.5 ${i % 2 === 0 ? "bg-muted/30 dark:bg-muted/10" : "bg-card"}`}>
{[...Array(5)].map((_, j) => (
<div key={j} className="h-2 flex-1 rounded bg-muted-foreground/30" />
<div key={j} className="h-2 flex-1 rounded bg-foreground/[0.1] dark:bg-foreground/6" />
))}
</div>
))}
</div>
{/* 페이지네이션 */}
<div className="flex items-center justify-center gap-2 pt-1">
<div className="h-2.5 w-4 rounded bg-muted-foreground/40" />
<div className="h-2.5 w-4 rounded bg-primary" />
<div className="h-2.5 w-4 rounded bg-muted-foreground/40" />
<div className="h-2.5 w-4 rounded bg-muted-foreground/40" />
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
<div className="h-2.5 w-4 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
<div className="h-2.5 w-4 rounded bg-foreground/[0.18] dark:bg-foreground/12" />
<div className="h-2.5 w-4 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
<div className="h-2.5 w-4 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
</div>
</div>
);
}
// 폼 화면 일러스트
// 폼 화면 일러스트 (모노크롬)
if (screenType === "form") {
return (
<div className="flex h-full flex-col gap-3 rounded-lg border border-border bg-muted/30 p-3">
<div className="flex h-full flex-col gap-3 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 p-3">
{/* 폼 필드들 */}
{[...Array(6)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<div className="h-2.5 w-14 rounded bg-muted-foreground/50" />
<div className="h-5 flex-1 rounded-md border border-border bg-card shadow-sm" />
<div className="h-2.5 w-14 rounded bg-foreground/[0.12] dark:bg-foreground/8" />
<div className="h-5 flex-1 rounded-md border border-border/30 dark:border-border/5 bg-card" />
</div>
))}
{/* 버튼 영역 */}
<div className="mt-auto flex justify-end gap-2 border-t border-border pt-3">
<div className="h-5 w-14 rounded-md bg-muted-foreground/40 shadow-sm" />
<div className="h-5 w-14 rounded-md bg-primary shadow-sm" />
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
<div className="mt-auto flex justify-end gap-2 border-t border-border/30 dark:border-border/5 pt-3">
<div className="h-5 w-14 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
<div className="h-5 w-14 rounded-md bg-foreground/[0.18] dark:bg-foreground/12" />
</div>
</div>
);
}
// 대시보드 화면 일러스트
// 대시보드 화면 일러스트 (모노크롬)
if (screenType === "dashboard") {
return (
<div className="grid h-full grid-cols-2 gap-2 rounded-lg border border-border bg-muted/30 p-3">
<div className="grid h-full grid-cols-2 gap-2 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 p-3">
{/* 카드/차트들 */}
<div className="rounded-lg bg-emerald-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-10 rounded bg-emerald-400" />
<div className="h-10 rounded-md bg-emerald-300/80" />
<div className="rounded-lg bg-foreground/[0.08] dark:bg-foreground/5 p-2">
<div className="mb-2 h-2.5 w-10 rounded bg-foreground/[0.15] dark:bg-foreground/10" />
<div className="h-10 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
</div>
<div className="rounded-lg bg-amber-100 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-10 rounded bg-amber-400" />
<div className="h-10 rounded-md bg-amber-300/80" />
</div>
<div className="col-span-2 rounded-lg bg-primary/10 p-2 shadow-sm">
<div className="mb-2 h-2.5 w-12 rounded bg-primary/70" />
<div className="rounded-lg bg-foreground/[0.08] dark:bg-foreground/5 p-2">
<div className="mb-2 h-2.5 w-10 rounded bg-foreground/[0.15] dark:bg-foreground/10" />
<div className="h-10 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
</div>
<div className="col-span-2 rounded-lg bg-foreground/[0.08] dark:bg-foreground/5 p-2">
<div className="mb-2 h-2.5 w-12 rounded bg-foreground/[0.15] dark:bg-foreground/10" />
<div className="flex h-14 items-end gap-1">
{[...Array(10)].map((_, i) => (
<div
key={i}
className="flex-1 rounded-t bg-primary/70/80"
className="flex-1 rounded-t bg-foreground/[0.15] dark:bg-foreground/10"
style={{ height: `${25 + Math.random() * 75}%` }}
/>
))}
</div>
</div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
);
}
// 액션 화면 일러스트 (버튼 중심)
if (screenType === "action") {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="rounded-full bg-muted p-4 text-muted-foreground">
<MousePointer2 className="h-10 w-10" />
</div>
<div className="flex gap-3">
<div className="h-7 w-16 rounded-md bg-primary shadow-sm" />
<div className="h-7 w-16 rounded-md bg-muted-foreground/40 shadow-sm" />
</div>
<div className="text-xs font-medium text-muted-foreground"> </div>
{/* 컴포넌트 수 */}
<div className="absolute bottom-2 right-2 rounded-md bg-slate-800/80 px-2 py-1 text-[10px] font-medium text-white shadow-sm">
{totalComponents}
</div>
</div>
);
}
// 기본 (알 수 없는 타입)
// 액션 화면 일러스트 (모노크롬)
if (screenType === "action") {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 p-3">
<div className="rounded-full bg-foreground/[0.08] dark:bg-foreground/5 p-4 text-muted-foreground">
<MousePointer2 className="h-10 w-10" />
</div>
<div className="flex gap-3">
<div className="h-7 w-16 rounded-md bg-foreground/[0.18] dark:bg-foreground/12" />
<div className="h-7 w-16 rounded-md bg-foreground/[0.12] dark:bg-foreground/8" />
</div>
<div className="text-xs font-medium text-muted-foreground"> </div>
</div>
);
}
// 기본 (알 수 없는 타입, 모노크롬)
return (
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-slate-200 bg-muted/30 text-slate-400">
<div className="rounded-full bg-slate-100 p-4">
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-lg border border-border/30 dark:border-border/5 bg-muted/30 dark:bg-muted/10 text-muted-foreground">
<div className="rounded-full bg-foreground/[0.08] dark:bg-foreground/5 p-4">
{getScreenTypeIcon(screenType)}
</div>
<span className="text-sm font-medium">{totalComponents} </span>
@ -574,21 +506,21 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
return (
<div
className={`group relative flex w-[260px] flex-col overflow-visible rounded-xl border shadow-md ${
// 1. 필터 테이블 (마스터-디테일의 디테일 테이블): 항상 보라색 테두리
className={`group relative flex w-[260px] flex-col overflow-visible rounded-[10px] border bg-card dark:bg-card/80 backdrop-blur-sm shadow-[0_4px_24px_-8px_rgba(0,0,0,0.08)] dark:shadow-[0_4px_24px_-8px_rgba(0,0,0,0.5)] ${
// 1. 필터 테이블 (마스터-디테일의 디테일 테이블)
isFilterTable
? "border-2 border-violet-500 ring-2 ring-violet-500/20 shadow-lg bg-violet-50/50"
// 2. 필터 관련 테이블 (마스터 또는 디테일) 포커스 시: 진한 보라색
? "border-primary/30 shadow-[0_0_0_1px_hsl(var(--primary)/0.3)]"
// 2. 필터 관련 테이블 포커스 시
: (hasFilterRelation || isFilterSource)
? "border-2 border-violet-500 ring-4 ring-violet-500/30 shadow-xl bg-violet-50"
// 3. 순수 포커스 (필터 관계 없음): 초록색
? "border-primary/30 shadow-[0_0_0_1px_hsl(var(--primary)/0.3),0_0_24px_-8px_hsl(var(--primary)/0.1)]"
// 3. 순수 포커스
: isFocused
? "border-2 border-emerald-500 ring-4 ring-emerald-500/30 shadow-xl bg-card"
? "border-primary/30 shadow-[0_0_0_1px_hsl(var(--primary)/0.3),0_0_24px_-8px_hsl(var(--primary)/0.1)] bg-card"
// 4. 흐리게 처리
: isFaded
? "border-border opacity-60 bg-card"
? "opacity-60 bg-card border-border/40 dark:border-border/10"
// 5. 기본
: "border-border hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20 bg-card"
: "border-border/40 dark:border-border/10 hover:border-border/50 dark:hover:border-border/20"
}`}
style={{
filter: isFaded ? "grayscale(80%)" : "none",
@ -602,7 +534,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
className="absolute -left-1.5 top-1 bottom-1 w-0.5 z-20 rounded-full transition-all duration-500 ease-out"
title={hasSaveTarget ? "저장 대상 테이블" : undefined}
style={{
background: 'linear-gradient(to bottom, transparent 0%, #f472b6 15%, #f472b6 85%, transparent 100%)',
background: `linear-gradient(to bottom, transparent 0%, hsl(var(--destructive)) 15%, hsl(var(--destructive)) 85%, transparent 100%)`,
opacity: hasSaveTarget ? 1 : 0,
transform: hasSaveTarget ? 'scaleY(1)' : 'scaleY(0)',
transformOrigin: 'top',
@ -616,7 +548,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
type="target"
position={Position.Top}
id="top"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* top source: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */}
<Handle
@ -624,25 +556,25 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
position={Position.Top}
id="top_source"
style={{ top: -4 }}
className="!h-2 !w-2 !border-2 !border-background !bg-amber-500 opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="target"
position={Position.Left}
id="left"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="source"
position={Position.Right}
id="right"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
className="!h-2 !w-2 !border-2 !border-background !bg-amber-500 opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* bottom target: 메인테이블 ← 메인테이블 연결용 (아래에서 들어오는 선) */}
<Handle
@ -650,18 +582,18 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
position={Position.Bottom}
id="bottom_target"
style={{ bottom: -4 }}
className="!h-2 !w-2 !border-2 !border-background !bg-amber-500 opacity-0 transition-opacity group-hover:opacity-100"
className="!h-2 !w-2 !border-[1.5px] !border-card !bg-muted-foreground/70 dark:!bg-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* 헤더 (필터 관계: 보라색, 필터 소스: 보라색, 메인: 초록색, 기본: 슬레이트) */}
<div className={`flex items-center gap-2 px-3 py-1.5 text-white rounded-t-xl transition-colors duration-700 ease-in-out ${
isFaded ? "bg-muted-foreground" : (hasFilterRelation || isFilterSource) ? "bg-violet-600" : isMain ? "bg-emerald-600" : "bg-slate-500"
}`}>
<Database className="h-3.5 w-3.5 shrink-0" />
{/* 헤더: 그라디언트 제거, bg-muted/30 + 아이콘 박스 */}
<div className="flex items-center gap-2.5 px-3.5 py-2.5 border-b border-border/40 dark:border-border/10 bg-muted/50 dark:bg-muted/30 rounded-t-[10px] transition-colors duration-700 ease-in-out">
<div className="flex h-7 w-7 items-center justify-center rounded-[7px] bg-cyan-500/10 shrink-0">
<Database className="h-3.5 w-3.5 text-cyan-400" />
</div>
<div className="flex-1 min-w-0">
<div className="truncate text-[11px] font-semibold">{label}</div>
<div className="truncate text-[11px] font-semibold text-foreground font-mono">{label}</div>
{/* 필터 관계에 따른 문구 변경 */}
<div className="truncate text-[9px] opacity-80">
<div className="truncate text-[9px] font-mono text-muted-foreground/70 dark:text-muted-foreground/40 tracking-[-0.3px]">
{isFilterSource
? "마스터 테이블 (필터 소스)"
: hasFilterRelation
@ -670,8 +602,8 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
</div>
</div>
{hasActiveColumns && (
<span className="rounded-full bg-white/20 px-1.5 py-0.5 text-[8px] shrink-0">
{displayColumns.length}
<span className="text-[9px] font-mono text-muted-foreground/70 dark:text-muted-foreground/40 px-1.5 py-0.5 rounded bg-foreground/[0.08] dark:bg-foreground/5 border border-border/40 dark:border-border/10 tracking-[-0.3px] shrink-0">
{displayColumns.length} ref
</span>
)}
</div>
@ -679,7 +611,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환 + 스크롤) */}
{/* 뱃지도 이 영역 안에 포함되어 높이 계산에 반영됨 */}
<div
className="p-1.5 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent"
className="p-1.5 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent"
style={{
height: `${debouncedHeight}px`,
maxHeight: `${MAX_HEIGHT}px`,
@ -699,7 +631,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{/* 필터 뱃지 */}
{filterRefs.length > 0 && (
<span
className="flex items-center gap-1 rounded-full bg-violet-600 px-2 py-px text-white font-semibold shadow-sm"
className="flex items-center gap-1 rounded-full bg-primary px-2 py-px text-primary-foreground font-semibold shadow-sm"
title={`마스터-디테일 필터링\n${filterRefs.map(r => `${r.fromTable}.${r.fromColumn || 'id'}${r.toColumn}`).join('\n')}`}
>
<Link2 className="h-3 w-3" />
@ -707,14 +639,14 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
</span>
)}
{filterRefs.length > 0 && (
<span className="text-violet-700 font-medium truncate">
<span className="text-primary font-medium truncate">
{filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')}
</span>
)}
{/* 참조 뱃지 */}
{lookupRefs.length > 0 && (
<span
className="flex items-center gap-1 rounded-full bg-amber-500 px-2 py-px text-white font-semibold shadow-sm"
className="flex items-center gap-1 rounded-full bg-warning px-2 py-px text-warning-foreground font-semibold shadow-sm"
title={`코드 참조 (lookup)\n${lookupRefs.map(r => `${r.fromTable}${r.toColumn}`).join('\n')}`}
>
{lookupRefs.length}
@ -745,33 +677,37 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
key={col.name}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 transition-all duration-300 ${
isJoinColumn
? "bg-amber-100 border border-orange-300 shadow-sm"
? "bg-warning/10 border border-warning/20 shadow-sm"
: isFilterColumn || isFilterSourceColumn
? "bg-violet-100 border border-violet-300 shadow-sm" // 필터 컬럼/필터 소스: 보라색
? "bg-primary/10 border border-primary/20 shadow-sm" // 필터 컬럼/필터 소스
: isHighlighted
? "bg-primary/10 border border-primary/40 shadow-sm"
: hasActiveColumns
? "bg-slate-100"
: "bg-slate-50 hover:bg-slate-100"
? "bg-muted"
: "bg-muted/50 hover:bg-muted/80 transition-colors"
}`}
style={{
animation: hasActiveColumns ? `fadeIn 0.5s ease-out ${idx * 80}ms forwards` : undefined,
opacity: hasActiveColumns ? 0 : 1,
}}
>
{/* PK/FK/조인/필터 아이콘 */}
{isJoinColumn && <Link2 className="h-2.5 w-2.5 text-amber-500" />}
{(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && <Link2 className="h-2.5 w-2.5 text-violet-500" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && <Key className="h-2.5 w-2.5 text-amber-500" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-2.5 w-2.5 text-primary" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && !col.isPrimaryKey && !col.isForeignKey && <div className="w-2.5" />}
{/* 3px 세로 마커 (PK/FK/조인/필터) */}
<div
className={`w-[3px] h-[14px] rounded-sm flex-shrink-0 ${
isJoinColumn ? "bg-amber-400"
: (isFilterColumn || isFilterSourceColumn) ? "bg-primary opacity-80"
: col.isPrimaryKey ? "bg-amber-400"
: col.isForeignKey ? "bg-primary opacity-80"
: "bg-muted-foreground/20"
}`}
/>
{/* 컬럼명 */}
<span className={`flex-1 truncate font-mono text-[9px] font-medium ${
isJoinColumn ? "text-orange-700"
: (isFilterColumn || isFilterSourceColumn) ? "text-violet-700"
: isHighlighted ? "text-primary"
: "text-slate-700"
isJoinColumn ? "text-amber-400"
: (isFilterColumn || isFilterSourceColumn) ? "text-primary"
: isHighlighted ? "text-primary"
: "text-foreground"
}`}>
{col.name}
</span>
@ -781,63 +717,74 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
<>
{/* 조인 참조 테이블 표시 (joinColumnRefs에서) */}
{joinRefMap.has(colOriginal) && (
<span className="rounded bg-amber-100 px-1 text-[7px] text-amber-600">
<span className="rounded bg-warning/20 px-1 text-[7px] text-warning">
{joinRefMap.get(colOriginal)?.refTableLabel}
</span>
)}
{/* 필드 매핑 참조 표시 (fieldMappingMap에서, joinRefMap에 없는 경우) */}
{!joinRefMap.has(colOriginal) && fieldMappingMap.has(colOriginal) && (
<span className="rounded bg-amber-100 px-1 text-[7px] text-amber-600">
<span className="rounded bg-warning/20 px-1 text-[7px] text-warning">
{fieldMappingMap.get(colOriginal)?.sourceDisplayName}
</span>
)}
<span className="rounded bg-orange-200 px-1 text-[7px] text-orange-700"></span>
<span className="rounded bg-warning/20 px-1 text-[7px] text-warning"></span>
</>
)}
{isFilterColumn && !isJoinColumn && (
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700"></span>
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary"></span>
)}
{/* 메인 테이블에서 필터 소스로 사용되는 컬럼: "필터" + "사용" 둘 다 표시 */}
{isFilterSourceColumn && !isJoinColumn && !isFilterColumn && (
<>
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700"></span>
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary"></span>
{isHighlighted && (
<span className="rounded bg-blue-200 px-1 text-[7px] text-primary"></span>
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary"></span>
)}
</>
)}
{isHighlighted && !isJoinColumn && !isFilterColumn && !isFilterSourceColumn && (
<span className="rounded bg-blue-200 px-1 text-[7px] text-primary"></span>
<span className="rounded bg-primary/20 px-1 text-[7px] text-primary"></span>
)}
{/* 타입 */}
<span className="text-[8px] text-slate-400">{col.type}</span>
<span className="text-[8px] text-muted-foreground/60 dark:text-muted-foreground/30 font-mono tracking-[-0.3px]">{col.type}</span>
</div>
);
})}
{/* 더 많은 컬럼이 있을 경우 표시 */}
{remainingCount > 0 && (
<div className="text-center text-[8px] text-slate-400 py-0.5">
<div className="text-center text-[8px] text-muted-foreground py-0.5">
+ {remainingCount}
</div>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-2 text-muted-foreground">
<Database className="h-4 w-4 text-slate-300" />
<span className="mt-0.5 text-[8px] text-slate-400"> </span>
<Database className="h-4 w-4 text-muted-foreground" />
<span className="mt-0.5 text-[8px] text-muted-foreground"> </span>
</div>
)}
</div>
{/* 푸터 (컴팩트) */}
<div className="flex items-center justify-between border-t border-border bg-muted/30 px-2 py-1">
<span className="text-[9px] text-muted-foreground">PostgreSQL</span>
{columns && (
<span className="text-[9px] text-muted-foreground">
{hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount}
</span>
)}
{/* 푸터: cols + PK/FK 카운트 */}
<div className="flex items-center justify-between border-t border-border/40 dark:border-border/10 px-3.5 py-1.5 bg-background dark:bg-background/50">
<span className="text-[9px] text-muted-foreground/70 dark:text-muted-foreground/40 font-mono tracking-[-0.3px]">
{hasActiveColumns ? `${displayColumns.length}/${totalCount}` : totalCount} cols
</span>
<div className="flex gap-2.5 text-[9px] font-mono tracking-[-0.3px]">
{columns?.some(c => c.isPrimaryKey) && (
<span className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-amber-400" />
<span className="text-muted-foreground/70 dark:text-muted-foreground/40">PK {columns.filter(c => c.isPrimaryKey).length}</span>
</span>
)}
{columns?.some(c => c.isForeignKey) && (
<span className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full bg-primary" />
<span className="text-muted-foreground/70 dark:text-muted-foreground/40">FK {columns.filter(c => c.isForeignKey).length}</span>
</span>
)}
</div>
</div>
{/* CSS 애니메이션 정의 */}
@ -861,10 +808,10 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
export const LegacyScreenNode = ScreenNode;
export const AggregateNode: React.FC<{ data: any }> = ({ data }) => {
return (
<div className="rounded-lg border-2 border-purple-300 bg-card p-3 shadow-lg">
<Handle type="target" position={Position.Left} id="left" className="!h-3 !w-3 !bg-purple-500" />
<Handle type="source" position={Position.Right} id="right" className="!h-3 !w-3 !bg-purple-500" />
<div className="flex items-center gap-2 text-purple-600">
<div className="rounded-lg border-2 border-primary/40 bg-card p-3 shadow-lg">
<Handle type="target" position={Position.Left} id="left" className="!h-3 !w-3 !bg-primary" />
<Handle type="source" position={Position.Right} id="right" className="!h-3 !w-3 !bg-primary" />
<div className="flex items-center gap-2 text-primary">
<Table2 className="h-4 w-4" />
<span className="text-sm font-semibold">{data.label || "Aggregate"}</span>
</div>

View File

@ -4,6 +4,7 @@ import React, { useState, useEffect, useCallback } from "react";
import {
ReactFlow,
Controls,
MiniMap,
Background,
BackgroundVariant,
Node,
@ -34,22 +35,31 @@ import {
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
import { ScreenSettingModal } from "./ScreenSettingModal";
import { TableSettingModal } from "./TableSettingModal";
import { AnimatedFlowEdge } from "./AnimatedFlowEdge";
import { Monitor, Database, FolderOpen } from "lucide-react";
// 관계 유형별 색상 정의
// 관계 유형별 색상 정의 (CSS 변수 기반 - 다크모드 자동 대응)
const RELATION_COLORS: Record<VisualRelationType, { stroke: string; strokeLight: string; label: string }> = {
filter: { stroke: '#8b5cf6', strokeLight: '#c4b5fd', label: '마스터-디테일' }, // 보라색
hierarchy: { stroke: '#06b6d4', strokeLight: '#a5f3fc', label: '계층 구조' }, // 시안색
lookup: { stroke: '#f59e0b', strokeLight: '#fcd34d', label: '코드 참조' }, // 주황색 (기존)
mapping: { stroke: '#10b981', strokeLight: '#6ee7b7', label: '데이터 매핑' }, // 녹색
join: { stroke: '#f97316', strokeLight: '#fdba74', label: '엔티티 조인' }, // orange-500 (기존 주황색)
filter: { stroke: 'hsl(var(--primary))', strokeLight: 'hsl(var(--primary) / 0.4)', label: '마스터-디테일' },
hierarchy: { stroke: 'hsl(var(--info))', strokeLight: 'hsl(var(--info) / 0.4)', label: '계층 구조' },
lookup: { stroke: 'hsl(var(--warning))', strokeLight: 'hsl(var(--warning) / 0.4)', label: '코드 참조' },
mapping: { stroke: 'hsl(var(--success))', strokeLight: 'hsl(var(--success) / 0.4)', label: '데이터 매핑' },
join: { stroke: 'hsl(var(--warning))', strokeLight: 'hsl(var(--warning) / 0.4)', label: '엔티티 조인' },
};
// 엣지 필터 카테고리 (UI 토글용)
type EdgeCategory = 'main' | 'filter' | 'join' | 'lookup' | 'flow';
// 노드 타입 등록
const nodeTypes = {
screenNode: ScreenNode,
tableNode: TableNode,
};
const edgeTypes = {
animatedFlow: AnimatedFlowEdge,
};
// 레이아웃 상수
const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단)
const TABLE_Y = 420; // 메인 테이블 노드 Y 위치 (중단)
@ -89,6 +99,15 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 그룹 내 포커스된 화면 ID (그룹 모드에서만 사용)
const [focusedScreenId, setFocusedScreenId] = useState<number | null>(null);
// 엣지 필터 상태 (유형별 표시/숨김)
const [edgeFilterState, setEdgeFilterState] = useState<Record<EdgeCategory, boolean>>({
main: true,
filter: true,
join: true,
lookup: false,
flow: true,
});
// 노드 설정 모달 상태
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
const [settingModalNode, setSettingModalNode] = useState<{
@ -414,7 +433,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
isFaded = focusedScreenId !== null && !isFocused;
} else {
// 개별 화면 모드: 메인 화면(선택된 화면)만 포커스, 연결 화면은 흐리게
isFocused = isMain;
isFocused = !!isMain;
isFaded = !isMain && screenList.length > 1;
}
@ -426,7 +445,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
label: scr.screenName,
subLabel: selectedGroup ? `${roleLabel} (#${scr.displayOrder || idx + 1})` : (isMain ? "메인 화면" : "연결 화면"),
type: "screen",
isMain: selectedGroup ? idx === 0 : isMain,
isMain: selectedGroup ? idx === 0 : !!isMain,
tableName: scr.tableName,
layoutSummary: summary,
// 화면 포커스 관련 속성 (그룹 모드 & 개별 모드 공통)
@ -687,14 +706,15 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: `screen-${nextScreen.screenId}`,
sourceHandle: "right",
targetHandle: "left",
type: "smoothstep",
type: "animatedFlow",
label: `${i + 1}`,
labelStyle: { fontSize: 11, fill: "#0ea5e9", fontWeight: 600 },
labelStyle: { fontSize: 11, fill: "hsl(var(--info))", fontWeight: 600 },
labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 },
labelBgPadding: [4, 2] as [number, number],
markerEnd: { type: MarkerType.ArrowClosed, color: "#0ea5e9" },
markerEnd: { type: MarkerType.ArrowClosed, color: "hsl(var(--info))" },
animated: true,
style: { stroke: "#0ea5e9", strokeWidth: 2 },
style: { stroke: "hsl(var(--info))", strokeWidth: 2 },
data: { edgeCategory: 'flow' as EdgeCategory },
});
}
}
@ -709,12 +729,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: `table-${scr.tableName}`,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
type: "animatedFlow",
animated: true, // 모든 메인 테이블 연결은 애니메이션
style: {
stroke: "#3b82f6",
stroke: "hsl(var(--primary))",
strokeWidth: 2,
},
data: { edgeCategory: 'main' as EdgeCategory },
});
}
});
@ -748,15 +769,16 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: targetNodeId,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
type: "animatedFlow",
animated: true,
style: {
stroke: "#3b82f6",
stroke: "hsl(var(--primary))",
strokeWidth: 2,
strokeDasharray: "5,5", // 점선으로 필터 관계 표시
},
data: {
sourceScreenId,
edgeCategory: 'filter' as EdgeCategory,
},
});
@ -793,7 +815,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: refTargetNodeId,
sourceHandle: "bottom",
targetHandle: "bottom_target",
type: "smoothstep",
type: "animatedFlow",
animated: false,
style: {
stroke: RELATION_COLORS.join.strokeLight, // 초기값 (연한색)
@ -809,6 +831,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
sourceScreenId,
isFilterJoin: true,
visualRelationType: 'join',
edgeCategory: 'join' as EdgeCategory,
},
});
});
@ -901,7 +924,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: `table-${referencedTable}`, // 참조당하는 테이블
sourceHandle: "bottom", // 하단에서 나감 (서브테이블 구간으로)
targetHandle: "bottom_target", // 하단으로 들어감
type: "smoothstep",
type: "animatedFlow",
animated: false,
style: {
stroke: relationColor.strokeLight, // 관계 유형별 연한 색상
@ -919,6 +942,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
referrerTable,
referencedTable,
visualRelationType, // 관계 유형 저장
edgeCategory: (visualRelationType === 'lookup' ? 'lookup' : 'join') as EdgeCategory,
},
});
}
@ -944,7 +968,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: `subtable-${subTable.tableName}`,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
type: "animatedFlow",
markerEnd: {
type: MarkerType.ArrowClosed,
color: relationColor.strokeLight
@ -959,6 +983,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
data: {
sourceScreenId,
visualRelationType,
edgeCategory: (visualRelationType === 'lookup' ? 'lookup' : 'join') as EdgeCategory,
},
});
});
@ -973,7 +998,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: `table-${join.join_table}`,
sourceHandle: "bottom",
targetHandle: "bottom_target",
type: "smoothstep",
type: "animatedFlow",
markerEnd: {
type: MarkerType.ArrowClosed,
color: RELATION_COLORS.join.strokeLight
@ -985,31 +1010,33 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
strokeDasharray: "8,4",
opacity: 0.5,
},
data: { visualRelationType: 'join' },
data: { visualRelationType: 'join', edgeCategory: 'join' as EdgeCategory },
});
}
});
// 테이블 관계 엣지 (추가 관계)
// 테이블 관계 엣지 (추가 관계) - 참조용 화면(개별 모드: screen, 그룹 모드: screenList[0])
const refScreen = screen ?? screenList[0];
relations.forEach((rel: any, idx: number) => {
if (rel.table_name && rel.table_name !== screen.tableName) {
if (rel.table_name && rel.table_name !== refScreen.tableName) {
// 화면 → 연결 테이블
const edgeExists = newEdges.some(
(e) => e.source === `screen-${screen.screenId}` && e.target === `table-${rel.table_name}`
(e) => e.source === `screen-${refScreen.screenId}` && e.target === `table-${rel.table_name}`
);
if (!edgeExists) {
newEdges.push({
id: `edge-rel-${idx}`,
source: `screen-${screen.screenId}`,
source: `screen-${refScreen.screenId}`,
target: `table-${rel.table_name}`,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
type: "animatedFlow",
label: rel.relation_type === "join" ? "조인" : rel.crud_operations || "",
labelStyle: { fontSize: 9, fill: "#10b981" },
labelStyle: { fontSize: 9, fill: "hsl(var(--success))" },
labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 },
labelBgPadding: [3, 2] as [number, number],
style: { stroke: "#10b981", strokeWidth: 1.5 },
style: { stroke: "hsl(var(--success))", strokeWidth: 1.5 },
data: { edgeCategory: (rel.relation_type === 'lookup' ? 'lookup' : 'join') as EdgeCategory },
});
}
}
@ -1017,23 +1044,24 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 데이터 흐름 엣지 (화면 간)
flows
.filter((flow: any) => flow.source_screen_id === screen.screenId)
.filter((flow: any) => flow.source_screen_id === refScreen.screenId)
.forEach((flow: any, idx: number) => {
if (flow.target_screen_id) {
newEdges.push({
id: `edge-flow-${idx}`,
source: `screen-${screen.screenId}`,
source: `screen-${refScreen.screenId}`,
target: `screen-${flow.target_screen_id}`,
sourceHandle: "right",
targetHandle: "left",
type: "smoothstep",
type: "animatedFlow",
animated: true,
label: flow.flow_label || flow.flow_type || "이동",
labelStyle: { fontSize: 10, fill: "#8b5cf6", fontWeight: 500 },
labelStyle: { fontSize: 10, fill: "hsl(var(--primary))", fontWeight: 500 },
labelBgStyle: { fill: "hsl(var(--card))", stroke: "hsl(var(--border))", strokeWidth: 1 },
labelBgPadding: [4, 2] as [number, number],
markerEnd: { type: MarkerType.ArrowClosed, color: "#8b5cf6" },
style: { stroke: "#8b5cf6", strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: "hsl(var(--primary))" },
style: { stroke: "hsl(var(--primary))", strokeWidth: 2 },
data: { edgeCategory: 'flow' as EdgeCategory },
});
}
});
@ -1134,7 +1162,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 화면 노드 우클릭
if (node.id.startsWith("screen-")) {
const screenId = parseInt(node.id.replace("screen-", ""));
const nodeData = node.data as ScreenNodeData;
const nodeData = node.data as unknown as ScreenNodeData;
const mainTable = screenTableMap[screenId];
// 해당 화면의 서브 테이블 (필터 테이블) 정보
@ -1248,7 +1276,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 메인 테이블 노드 더블클릭
if (node.id.startsWith("table-") && !node.id.startsWith("table-sub-")) {
const tableName = node.id.replace("table-", "");
const nodeData = node.data as TableNodeData;
const nodeData = node.data as unknown as TableNodeData;
// 이 테이블을 사용하는 화면 찾기
const screenId = Object.entries(screenTableMap).find(
@ -1293,7 +1321,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 서브 테이블 노드 더블클릭
if (node.id.startsWith("subtable-")) {
const tableName = node.id.replace("subtable-", "");
const nodeData = node.data as TableNodeData;
const nodeData = node.data as unknown as TableNodeData;
// 이 서브 테이블을 사용하는 화면 찾기
const screenId = Object.entries(screenSubTableMap).find(
@ -1460,6 +1488,32 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
});
}
// lookup 필터 OFF일 때: lookup 연결만 있는 테이블 노드를 dim 처리
const lookupOnlyNodes = new Set<string>();
if (!edgeFilterState.lookup) {
const nodeEdgeCategories = new Map<string, Set<EdgeCategory>>();
edges.forEach((edge) => {
const category = (edge.data as any)?.edgeCategory as EdgeCategory | undefined;
if (!category) return;
[edge.source, edge.target].forEach((nodeId) => {
if (!nodeEdgeCategories.has(nodeId)) {
nodeEdgeCategories.set(nodeId, new Set());
}
nodeEdgeCategories.get(nodeId)!.add(category);
});
});
nodeEdgeCategories.forEach((categories, nodeId) => {
if (nodeId.startsWith("table-") || nodeId.startsWith("subtable-")) {
const hasVisibleCategory = Array.from(categories).some(
(cat) => cat !== "lookup" && edgeFilterState[cat]
);
if (!hasVisibleCategory) {
lookupOnlyNodes.add(nodeId);
}
}
});
}
return nodes.map((node) => {
// 화면 노드 스타일링 (포커스가 있을 때만)
if (node.id.startsWith("screen-")) {
@ -1755,7 +1809,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
...node.data,
isFocused: isFocusedTable,
isRelated: isRelatedTable,
isFaded: focusedScreenId !== null && !isActiveTable,
isFaded: (focusedScreenId !== null && !isActiveTable) || lookupOnlyNodes.has(node.id),
highlightedColumns: isActiveTable ? highlightedColumns : [],
joinColumns: isActiveTable ? joinColumns : [],
joinColumnRefs: focusedJoinColumnRefs.length > 0 ? focusedJoinColumnRefs : undefined, // 조인 컬럼 참조 정보
@ -1798,12 +1852,6 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
}
});
}
// 디버깅 로그
console.log(`서브테이블 ${subTableName} (${subTableInfo?.relationType}):`, {
fieldMappings: subTableInfo?.fieldMappings,
extractedJoinColumns: subTableJoinColumns
});
}
// 서브 테이블의 highlightedColumns도 추가 (화면에서 서브테이블 컬럼을 직접 사용하는 경우)
@ -1872,7 +1920,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
data: {
...node.data,
isFocused: isActiveSubTable,
isFaded: !isActiveSubTable,
isFaded: !isActiveSubTable || lookupOnlyNodes.has(node.id),
highlightedColumns: isActiveSubTable ? subTableHighlightedColumns : [],
joinColumns: isActiveSubTable ? subTableJoinColumns : [],
fieldMappings: isActiveSubTable ? displayFieldMappings : [],
@ -1883,7 +1931,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
return node;
});
}, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap, tableColumns]);
}, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap, tableColumns, edgeFilterState, edges]);
// 포커스에 따른 엣지 스타일링 (그룹 모드 & 개별 화면 모드)
const styledEdges = React.useMemo(() => {
@ -1903,9 +1951,9 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
animated: isConnected,
style: {
...edge.style,
stroke: isConnected ? "#8b5cf6" : "#d1d5db",
strokeWidth: isConnected ? 2 : 1,
opacity: isConnected ? 1 : 0.3,
stroke: isConnected ? "hsl(var(--primary))" : "hsl(var(--border))",
strokeWidth: isConnected ? 2.5 : 1,
opacity: isConnected ? 1 : 0.2,
},
};
}
@ -1920,10 +1968,10 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
animated: isMyConnection,
style: {
...edge.style,
stroke: isMyConnection ? "#3b82f6" : "#d1d5db",
strokeWidth: isMyConnection ? 2 : 1,
stroke: isMyConnection ? "hsl(var(--primary))" : "hsl(var(--border))",
strokeWidth: isMyConnection ? 2.5 : 1,
strokeDasharray: isMyConnection ? undefined : "5,5",
opacity: isMyConnection ? 1 : 0.3,
opacity: isMyConnection ? 1 : 0.2,
},
};
}
@ -1998,11 +2046,11 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
target: targetNodeId,
sourceHandle: 'bottom', // 고정: 서브테이블 구간 통과
targetHandle: 'bottom_target', // 고정: 서브테이블 구간 통과
type: 'smoothstep',
type: "animatedFlow",
animated: true,
style: {
stroke: relationColor.stroke, // 관계 유형별 색상
strokeWidth: 2,
strokeWidth: 2.5,
strokeDasharray: '8,4',
},
markerEnd: {
@ -2040,9 +2088,9 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
animated: isConnected,
style: {
...edge.style,
stroke: isConnected ? "#8b5cf6" : "#d1d5db",
strokeWidth: isConnected ? 2 : 1,
opacity: isConnected ? 1 : 0.3,
stroke: isConnected ? "hsl(var(--primary))" : "hsl(var(--border))",
strokeWidth: isConnected ? 2.5 : 1,
opacity: isConnected ? 1 : 0.2,
},
};
}
@ -2076,8 +2124,8 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
animated: true,
style: {
...edge.style,
stroke: "#3b82f6",
strokeWidth: 2,
stroke: "hsl(var(--primary))",
strokeWidth: 2.5,
strokeDasharray: "5,5",
opacity: 1,
},
@ -2095,10 +2143,10 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
animated: isMyConnection,
style: {
...edge.style,
stroke: isMyConnection ? "#3b82f6" : "#d1d5db",
strokeWidth: isMyConnection ? 2 : 1,
stroke: isMyConnection ? "hsl(var(--primary))" : "hsl(var(--border))",
strokeWidth: isMyConnection ? 2.5 : 1,
strokeDasharray: isMyConnection ? undefined : "5,5",
opacity: isMyConnection ? 1 : 0.3,
opacity: isMyConnection ? 1 : 0.2,
},
};
}
@ -2155,7 +2203,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
stroke: isActive ? relationColor.stroke : relationColor.strokeLight,
strokeWidth: isActive ? 2.5 : 1.5,
strokeDasharray: "8,4",
opacity: isActive ? 1 : 0.3,
opacity: isActive ? 1 : 0.2,
},
markerEnd: {
type: MarkerType.ArrowClosed,
@ -2179,7 +2227,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
stroke: RELATION_COLORS.join.strokeLight,
strokeWidth: 1.5,
strokeDasharray: "6,4",
opacity: 0.3,
opacity: 0.2,
},
markerEnd: {
type: MarkerType.ArrowClosed,
@ -2206,7 +2254,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
style: {
...edge.style,
stroke: RELATION_COLORS.join.stroke,
strokeWidth: 2,
strokeWidth: 2.5,
strokeDasharray: "6,4",
opacity: 1,
},
@ -2282,8 +2330,19 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
});
// 기존 엣지 + 조인 관계 엣지 합치기
return [...styledOriginalEdges, ...joinEdges];
}, [edges, nodes, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap]);
const allEdges = [...styledOriginalEdges, ...joinEdges];
// 엣지 필터 적용 (edgeFilterState에 따라 숨김)
return allEdges.map((edge) => {
const category = (edge.data as any)?.edgeCategory as EdgeCategory | undefined;
if (category && !edgeFilterState[category]) {
return {
...edge,
hidden: true,
};
}
return edge;
});
}, [edges, nodes, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap, edgeFilterState]);
// 그룹의 화면 목록 (데이터 흐름 설정용) - 모든 조건부 return 전에 선언해야 함
const groupScreensList = React.useMemo(() => {
@ -2300,10 +2359,38 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 조건부 렌더링 (모든 훅 선언 후에 위치해야 함)
if (!screen && !selectedGroup) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<div className="text-center">
<p className="text-sm"> </p>
<p className="text-sm"> </p>
<div className="flex h-full flex-col items-center justify-center gap-6 p-8">
<div className="relative">
<div className="flex items-center gap-4 opacity-30">
<div className="h-16 w-24 rounded-lg border-2 border-dashed border-primary/40 flex items-center justify-center">
<Monitor className="h-6 w-6 text-primary/60" />
</div>
<div className="h-px w-12 border-t-2 border-dashed border-border" />
<div className="h-12 w-20 rounded-lg border-2 border-dashed border-info/40 flex items-center justify-center">
<Database className="h-5 w-5 text-info/60" />
</div>
</div>
</div>
<div className="text-center max-w-sm">
<h3 className="text-lg font-semibold mb-2"> </h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<br/>
.
</p>
</div>
<div className="flex gap-8 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-bold">1</span>
<span> </span>
</div>
<div className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-bold">2</span>
<span> </span>
</div>
<div className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-bold">3</span>
<span> </span>
</div>
</div>
</div>
);
@ -2318,10 +2405,60 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
}
return (
<div className="h-full w-full">
<div className="relative h-full w-full">
{/* 선택 정보 바 (캔버스 상단) */}
{(screen || selectedGroup) && (
<div className="absolute top-0 left-0 right-0 z-10 flex items-center gap-3 border-b bg-card dark:bg-card/80 backdrop-blur-sm px-4 py-2">
{selectedGroup && (
<>
<FolderOpen className="h-4 w-4 text-warning" />
<span className="text-sm font-medium">{selectedGroup.name}</span>
</>
)}
{screen && !selectedGroup && (
<>
<Monitor className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">{screen.screenName}</span>
<span className="text-xs text-muted-foreground/80 dark:text-muted-foreground/50 font-mono">{screen.screenCode}</span>
</>
)}
<div className="h-4 w-px bg-border/50 dark:bg-border/30 mx-1" />
<span className="text-[10px] font-medium text-muted-foreground/80 dark:text-muted-foreground/50"></span>
{(
[
{ key: "main" as EdgeCategory, label: "메인", color: "bg-primary", defaultOn: true },
{ key: "filter" as EdgeCategory, label: "마스터-디테일", color: "bg-[hsl(var(--info))]", defaultOn: true },
{ key: "join" as EdgeCategory, label: "엔티티 조인", color: "bg-amber-400", defaultOn: true },
{ key: "lookup" as EdgeCategory, label: "코드 참조", color: "bg-warning", defaultOn: false },
] as const
).map(({ key, label, color, defaultOn }) => {
const isOn = edgeFilterState[key];
const count = edges.filter((e) => (e.data as any)?.edgeCategory === key).length;
return (
<button
key={key}
type="button"
onClick={() => setEdgeFilterState((prev) => ({ ...prev, [key]: !prev[key] }))}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-medium transition-all duration-200 ${
isOn
? "bg-foreground/[0.08] dark:bg-foreground/5 border border-border/40 dark:border-border/20 text-foreground/80"
: `border text-muted-foreground/70 dark:text-muted-foreground/40 ${!defaultOn ? "border-dashed border-border/40 dark:border-border/20" : "border-border/40 dark:border-border/10"}`
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${color} transition-opacity ${isOn ? "opacity-100 shadow-sm" : "opacity-50 dark:opacity-30"}`} />
{label}
<span className="text-[9px] text-muted-foreground/70 dark:text-muted-foreground/40 font-mono">{count}</span>
</button>
);
})}
</div>
)}
{/* isViewReady가 false면 숨김 처리하여 깜빡임 방지 */}
<div className={`h-full w-full transition-opacity duration-0 ${isViewReady ? "opacity-100" : "opacity-0"}`}>
<ReactFlow
className="[&_.react-flow__node]:transition-all [&_.react-flow__node]:duration-300"
nodes={styledNodes}
edges={styledEdges}
onNodesChange={onNodesChange}
@ -2329,12 +2466,42 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
onNodeClick={handleNodeClick}
onNodeContextMenu={handleNodeContextMenu}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
minZoom={0.3}
maxZoom={1.5}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="hsl(var(--border))" />
<Controls position="bottom-right" />
<svg style={{ position: "absolute", width: 0, height: 0 }}>
<defs>
<filter id="edge-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
</svg>
<Background id="bg-dots" variant={BackgroundVariant.Dots} gap={16} size={0.5} color="hsl(var(--border) / 0.3)" />
<Background id="bg-lines" variant={BackgroundVariant.Lines} gap={120} color="hsl(var(--border) / 0.08)" />
<Controls position="top-right" />
<MiniMap
position="bottom-right"
nodeColor={(node) => {
if (node.type === "screenNode") return "hsl(var(--primary))";
if (node.type === "tableNode") return "hsl(var(--warning))";
return "hsl(var(--muted-foreground))";
}}
nodeStrokeWidth={2}
zoomable
pannable
style={{
background: "hsl(var(--card) / 0.8)",
border: "1px solid hsl(var(--border) / 0.5)",
borderRadius: "8px",
marginBottom: "8px",
}}
/>
</ReactFlow>
</div>
@ -2353,7 +2520,6 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
fieldMappings={settingModalNode.existingConfig?.fieldMappings}
componentCount={0}
onSaveSuccess={handleRefreshVisualization}
isPop={isPop}
/>
)}
@ -2367,7 +2533,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
screenId={settingModalNode.screenId}
joinColumnRefs={settingModalNode.existingConfig?.joinColumnRefs}
referencedBy={settingModalNode.existingConfig?.referencedBy}
columns={settingModalNode.existingConfig?.columns}
columns={settingModalNode.existingConfig?.columns?.map((col) => ({
column: col.originalName ?? col.name,
label: col.name,
type: col.type,
isPK: col.isPrimaryKey,
isFK: col.isForeignKey,
}))}
filterColumns={settingModalNode.existingConfig?.filterColumns}
onSaveSuccess={handleRefreshVisualization}
/>

View File

@ -47,6 +47,7 @@ import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent
// ComponentRegistry import (동적 ConfigPanel 가져오기용)
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { columnMetaCache } from "@/lib/registry/DynamicComponentRenderer";
import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel";
import StyleEditor from "../StyleEditor";
import { Slider } from "@/components/ui/slider";
@ -207,28 +208,36 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
onUpdateProperty(selectedComponent.id, "componentConfig", { ...currentConfig, ...newConfig });
};
// 컬럼의 inputType 가져오기 (entity 타입인지 확인용)
const inputType = currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType;
// 현재 화면의 테이블명 가져오기
const currentTableName = tables?.[0]?.tableName;
// DB input_type 가져오기 (columnMetaCache에서 최신값 조회)
const colName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
const tblName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
const dbMeta = colName && tblName && !colName.includes(".") ? columnMetaCache[tblName]?.[colName] : undefined;
const dbInputType = dbMeta ? (() => { const raw = dbMeta.input_type || dbMeta.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })() : undefined;
const inputType = dbInputType || currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType;
// 컴포넌트별 추가 props
const extraProps: Record<string, any> = {};
if (componentId === "v2-select") {
const resolvedTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
const resolvedColumnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
if (componentId === "v2-input" || componentId === "v2-select") {
extraProps.inputType = inputType;
extraProps.tableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
extraProps.columnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
extraProps.tableName = resolvedTableName;
extraProps.columnName = resolvedColumnName;
extraProps.screenTableName = resolvedTableName;
}
if (componentId === "v2-input") {
extraProps.allComponents = allComponents;
}
if (componentId === "v2-list") {
extraProps.currentTableName = currentTableName;
}
if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") {
extraProps.currentTableName = currentTableName;
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
}
if (componentId === "v2-input") {
extraProps.allComponents = allComponents;
extraProps.screenTableName = resolvedTableName;
}
return (

View File

@ -78,7 +78,15 @@ interface CategoryValueOption {
}
// ─── 하위 호환: 기존 config에서 fieldType 추론 ───
function resolveFieldType(config: Record<string, any>, componentType?: string): FieldType {
function resolveFieldType(config: Record<string, any>, componentType?: string, metaInputType?: string): FieldType {
// DB input_type이 전달된 경우 (데이터타입관리에서 변경 시) 우선 적용
if (metaInputType && metaInputType !== "direct" && metaInputType !== "auto") {
const dbType = metaInputType as FieldType;
if (["text", "number", "textarea", "numbering", "select", "category", "entity"].includes(dbType)) {
return dbType;
}
}
if (config.fieldType) return config.fieldType as FieldType;
// v2-select 계열
@ -207,7 +215,7 @@ export const V2FieldConfigPanel: React.FC<V2FieldConfigPanelProps> = ({
inputType: metaInputType,
componentType,
}) => {
const fieldType = resolveFieldType(config, componentType);
const fieldType = resolveFieldType(config, componentType, metaInputType);
const isSelectGroup = ["select", "category", "entity"].includes(fieldType);
// ─── 채번 관련 상태 (테이블 기반) ───

View File

@ -13,13 +13,34 @@ import { apiClient } from "@/lib/api/client";
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
// 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵)
const columnMetaCache: Record<string, Record<string, any>> = {};
export const columnMetaCache: Record<string, Record<string, any>> = {};
const columnMetaLoading: Record<string, Promise<void>> = {};
const columnMetaTimestamp: Record<string, number> = {};
const CACHE_TTL_MS = 5000;
async function loadColumnMeta(tableName: string): Promise<void> {
if (columnMetaCache[tableName]) return;
export function invalidateColumnMetaCache(tableName?: string): void {
if (tableName) {
delete columnMetaCache[tableName];
delete columnMetaLoading[tableName];
delete columnMetaTimestamp[tableName];
} else {
for (const key of Object.keys(columnMetaCache)) delete columnMetaCache[key];
for (const key of Object.keys(columnMetaLoading)) delete columnMetaLoading[key];
for (const key of Object.keys(columnMetaTimestamp)) delete columnMetaTimestamp[key];
}
}
async function loadColumnMeta(tableName: string, forceReload = false): Promise<void> {
const now = Date.now();
const isStale = columnMetaTimestamp[tableName] && (now - columnMetaTimestamp[tableName] > CACHE_TTL_MS);
if (!forceReload && !isStale && columnMetaCache[tableName]) return;
if (forceReload || isStale) {
delete columnMetaCache[tableName];
delete columnMetaLoading[tableName];
}
// 이미 로딩 중이면 해당 Promise를 대기 (race condition 방지)
if (columnMetaLoading[tableName]) {
await columnMetaLoading[tableName];
return;
@ -36,6 +57,7 @@ async function loadColumnMeta(tableName: string): Promise<void> {
if (name) map[name] = col;
}
columnMetaCache[tableName] = map;
columnMetaTimestamp[tableName] = Date.now();
} catch (e) {
console.error(`[columnMeta] ${tableName} 로드 실패:`, e);
columnMetaCache[tableName] = {};
@ -56,43 +78,59 @@ export function isColumnRequiredByMeta(tableName?: string, columnName?: string):
return nullable === "NO" || nullable === "N";
}
// table_type_columns 기반 componentConfig 병합 (기존 설정이 없을 때만 DB 메타데이터로 보완)
// table_type_columns 기반 componentConfig 병합 (DB input_type 우선 적용)
function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any {
if (!tableName || !columnName) return componentConfig;
const meta = columnMetaCache[tableName]?.[columnName];
if (!meta) return componentConfig;
const inputType = meta.input_type || meta.inputType;
if (!inputType) return componentConfig;
// 이미 source가 올바르게 설정된 경우 건드리지 않음
const existingSource = componentConfig?.source;
if (existingSource && existingSource !== "static" && existingSource !== "distinct" && existingSource !== "select") {
return componentConfig;
}
const rawType = meta.input_type || meta.inputType;
const dbInputType = rawType === "direct" || rawType === "auto" ? undefined : rawType;
if (!dbInputType) return componentConfig;
const merged = { ...componentConfig };
const savedFieldType = merged.fieldType;
// source가 미설정/기본값일 때만 DB 메타데이터로 보완
if (inputType === "entity") {
// savedFieldType이 있고 DB와 같으면 변경 불필요
if (savedFieldType && savedFieldType === dbInputType) return merged;
// savedFieldType이 있고 DB와 다르면 — 사용자가 V2FieldConfigPanel에서 설정한 값 존중
if (savedFieldType) return merged;
// savedFieldType이 없으면: DB input_type 기준으로 동기화
// 기존 overrides의 source/inputType이 DB와 불일치하면 덮어씀
if (dbInputType === "entity") {
const refTable = meta.reference_table || meta.referenceTable;
const refColumn = meta.reference_column || meta.referenceColumn;
const displayCol = meta.display_column || meta.displayColumn;
if (refTable && !merged.entityTable) {
if (refTable) {
merged.source = "entity";
merged.entityTable = refTable;
merged.entityValueColumn = refColumn || "id";
merged.entityLabelColumn = displayCol || "name";
merged.fieldType = "entity";
merged.inputType = "entity";
}
} else if (inputType === "category" && !existingSource) {
} else if (dbInputType === "category") {
merged.source = "category";
} else if (inputType === "select" && !existingSource) {
merged.fieldType = "category";
merged.inputType = "category";
} else if (dbInputType === "select") {
if (!merged.source || merged.source === "category" || merged.source === "entity") {
merged.source = "static";
}
const detail =
typeof meta.detail_settings === "string" ? JSON.parse(meta.detail_settings || "{}") : meta.detail_settings || {};
if (detail.options && !merged.options?.length) {
merged.options = detail.options;
}
merged.fieldType = "select";
merged.inputType = "select";
} else {
// text, number, textarea 등 input 계열 — 카테고리 잔류 속성 제거
merged.fieldType = dbInputType;
merged.inputType = dbInputType;
delete merged.source;
}
return merged;
@ -266,15 +304,27 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
children,
...props
}) => {
// 컬럼 메타데이터 로드 트리거 (테이블명이 있으면 비동기 로드)
// 컬럼 메타데이터 로드 트리거 (TTL 기반 자동 갱신)
const screenTableName = props.tableName || (component as any).tableName;
const [, forceUpdate] = React.useState(0);
const [metaVersion, forceUpdate] = React.useState(0);
React.useEffect(() => {
if (screenTableName) {
loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1));
}
}, [screenTableName]);
// table-columns-refresh 이벤트 수신 시 캐시 무효화 후 최신 메타 다시 로드
React.useEffect(() => {
const handler = () => {
if (screenTableName) {
invalidateColumnMetaCache(screenTableName);
loadColumnMeta(screenTableName, true).then(() => forceUpdate((v) => v + 1));
}
};
window.addEventListener("table-columns-refresh", handler);
return () => window.removeEventListener("table-columns-refresh", handler);
}, [screenTableName]);
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
// 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input")
const extractTypeFromUrl = (url: string | undefined): string | undefined => {
@ -306,12 +356,40 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const mappedComponentType = mapToV2ComponentType(rawComponentType);
// fieldType 기반 동적 컴포넌트 전환 (통합 필드 설정 패널에서 설정된 값)
// fieldType 기반 동적 컴포넌트 전환 (사용자 설정 > DB input_type > 기본값)
const componentType = (() => {
const ft = (component as any).componentConfig?.fieldType;
if (!ft) return mappedComponentType;
if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(ft)) return "v2-input";
if (["select", "category", "entity"].includes(ft)) return "v2-select";
const configFieldType = (component as any).componentConfig?.fieldType;
const fieldName = (component as any).columnName || (component as any).componentConfig?.fieldKey || (component as any).componentConfig?.columnName;
const isEntityJoin = fieldName?.includes(".");
const baseCol = isEntityJoin ? undefined : fieldName;
const rawDbType = baseCol && screenTableName
? (columnMetaCache[screenTableName]?.[baseCol]?.input_type || columnMetaCache[screenTableName]?.[baseCol]?.inputType)
: undefined;
const dbInputType = rawDbType === "direct" || rawDbType === "auto" ? undefined : rawDbType;
// 디버그 (division, unit 필드만) - 문제 확인 후 제거
if (baseCol && (baseCol === "division" || baseCol === "unit")) {
const result = configFieldType
? (["text","number","password","textarea","slider","color","numbering"].includes(configFieldType) ? "v2-input" : "v2-select")
: dbInputType
? (["text","number","password","textarea","slider","color","numbering"].includes(dbInputType) ? "v2-input" : "v2-select")
: mappedComponentType;
const skipCat = dbInputType && !["category", "entity", "select"].includes(dbInputType);
console.log(`[DCR] ${baseCol}: dbInputType=${dbInputType}, RESULT=${result}, skipCat=${skipCat}`);
}
// 사용자가 V2FieldConfigPanel에서 명시적으로 설정한 fieldType 최우선
if (configFieldType) {
if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(configFieldType)) return "v2-input";
if (["select", "category", "entity"].includes(configFieldType)) return "v2-select";
}
// componentConfig.fieldType 없으면 DB input_type 참조 (초기 로드 시)
if (dbInputType) {
if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(dbInputType)) return "v2-input";
if (["select", "category", "entity"].includes(dbInputType)) return "v2-select";
}
return mappedComponentType;
})();
@ -376,15 +454,24 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// (v2-input, v2-select, v2-repeat-container 등 모두 동일하게 처리)
// 🎯 카테고리 타입 우선 처리 (inputType 또는 webType 확인)
const inputType = (component as any).componentConfig?.inputType || (component as any).inputType;
// DB input_type이 "text" 등 비-카테고리로 변경된 경우 이 분기를 건너뜀
const savedInputType = (component as any).componentConfig?.inputType || (component as any).inputType;
const webType = (component as any).componentConfig?.webType;
const tableName = (component as any).tableName;
const columnName = (component as any).columnName;
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
// ⚠️ 단, 다음 경우는 V2SelectRenderer로 직접 처리 (고급 모드 지원):
// 1. componentType이 "select-basic" 또는 "v2-select"인 경우
// 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등)
// DB input_type 확인: 데이터타입관리에서 변경한 최신 값이 레이아웃 저장값보다 우선
const dbMetaForField = columnName && screenTableName && !columnName.includes(".")
? columnMetaCache[screenTableName]?.[columnName]
: undefined;
const dbFieldInputType = dbMetaForField
? (() => { const raw = dbMetaForField.input_type || dbMetaForField.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })()
: undefined;
// DB에서 확인된 타입이 있으면 그걸 사용, 없으면 저장된 값 사용
const inputType = dbFieldInputType || savedInputType;
// webType도 DB 값으로 대체 (레이아웃에 webType: "category" 하드코딩되어 있을 수 있음)
const effectiveWebType = dbFieldInputType || webType;
const componentMode = (component as any).componentConfig?.mode || (component as any).config?.mode;
const isMultipleSelect = (component as any).componentConfig?.multiple;
const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"];
@ -392,7 +479,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const shouldUseV2Select =
componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode || isMultipleSelect;
if ((inputType === "category" || webType === "category") && tableName && columnName && shouldUseV2Select) {
// DB input_type이 비-카테고리(text 등)로 확인된 경우, 레이아웃에 category가 남아있어도 카테고리 분기 강제 스킵
// dbFieldInputType이 있으면(캐시 로드됨) 그 값으로 판단, 없으면 기존 로직 유지
const isDbConfirmedNonCategory = dbFieldInputType && !["category", "entity", "select"].includes(dbFieldInputType);
if (!isDbConfirmedNonCategory && (inputType === "category" || effectiveWebType === "category") && tableName && columnName && shouldUseV2Select) {
// V2SelectRenderer로 직접 렌더링 (카테고리 + 고급 모드)
try {
const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer");
@ -491,7 +582,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
} catch (error) {
console.error("❌ V2SelectRenderer 로드 실패:", error);
}
} else if ((inputType === "category" || webType === "category") && tableName && columnName) {
} else if (!isDbConfirmedNonCategory && (inputType === "category" || effectiveWebType === "category") && tableName && columnName) {
try {
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
const fieldName = columnName || component.id;

View File

@ -1,6 +1,6 @@
"use client";
import React, { useCallback, useMemo, useRef, useState } from "react";
import React, { useCallback, useMemo, useRef } from "react";
import {
ChevronLeft,
ChevronRight,
@ -11,17 +11,16 @@ import {
ZoomOut,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
TimelineSchedulerComponentProps,
ScheduleItem,
ZoomLevel,
DragEvent,
ResizeEvent,
} from "./types";
import { useTimelineData } from "./hooks/useTimelineData";
import { TimelineHeader, ResourceRow } from "./components";
import { TimelineHeader, ResourceRow, TimelineLegend } from "./components";
import { zoomLevelOptions, defaultTimelineSchedulerConfig } from "./config";
import { detectConflicts, addDaysToDateString } from "./utils/conflictDetection";
/**
* v2-timeline-scheduler
@ -45,19 +44,6 @@ export function TimelineSchedulerComponent({
}: TimelineSchedulerComponentProps) {
const containerRef = useRef<HTMLDivElement>(null);
// 드래그/리사이즈 상태
const [dragState, setDragState] = useState<{
schedule: ScheduleItem;
startX: number;
startY: number;
} | null>(null);
const [resizeState, setResizeState] = useState<{
schedule: ScheduleItem;
direction: "start" | "end";
startX: number;
} | null>(null);
// 타임라인 데이터 훅
const {
schedules,
@ -78,53 +64,43 @@ export function TimelineSchedulerComponent({
const error = externalError ?? hookError;
// 설정값
const rowHeight = config.rowHeight || defaultTimelineSchedulerConfig.rowHeight!;
const headerHeight = config.headerHeight || defaultTimelineSchedulerConfig.headerHeight!;
const rowHeight =
config.rowHeight || defaultTimelineSchedulerConfig.rowHeight!;
const headerHeight =
config.headerHeight || defaultTimelineSchedulerConfig.headerHeight!;
const resourceColumnWidth =
config.resourceColumnWidth || defaultTimelineSchedulerConfig.resourceColumnWidth!;
const cellWidthConfig = config.cellWidth || defaultTimelineSchedulerConfig.cellWidth!;
config.resourceColumnWidth ||
defaultTimelineSchedulerConfig.resourceColumnWidth!;
const cellWidthConfig =
config.cellWidth || defaultTimelineSchedulerConfig.cellWidth!;
const cellWidth = cellWidthConfig[zoomLevel] || 60;
// 리소스가 없으면 스케줄의 resourceId로 자동 생성
// 리소스 자동 생성 (리소스 테이블 미설정 시 스케줄 데이터에서 추출)
const effectiveResources = useMemo(() => {
if (resources.length > 0) {
return resources;
}
if (resources.length > 0) return resources;
// 스케줄에서 고유한 resourceId 추출하여 자동 리소스 생성
const uniqueResourceIds = new Set<string>();
schedules.forEach((schedule) => {
if (schedule.resourceId) {
uniqueResourceIds.add(schedule.resourceId);
}
schedules.forEach((s) => {
if (s.resourceId) uniqueResourceIds.add(s.resourceId);
});
return Array.from(uniqueResourceIds).map((id) => ({
id,
name: id, // resourceId를 이름으로 사용
}));
return Array.from(uniqueResourceIds).map((id) => ({ id, name: id }));
}, [resources, schedules]);
// 리소스별 스케줄 그룹화
const schedulesByResource = useMemo(() => {
const grouped = new Map<string, ScheduleItem[]>();
effectiveResources.forEach((resource) => {
grouped.set(resource.id, []);
});
effectiveResources.forEach((r) => grouped.set(r.id, []));
schedules.forEach((schedule) => {
const list = grouped.get(schedule.resourceId);
if (list) {
list.push(schedule);
} else {
// 리소스가 없는 스케줄은 첫 번째 리소스에 할당
const firstResource = effectiveResources[0];
if (firstResource) {
const firstList = grouped.get(firstResource.id);
if (firstList) {
firstList.push(schedule);
}
grouped.get(firstResource.id)?.push(schedule);
}
}
});
@ -132,27 +108,31 @@ export function TimelineSchedulerComponent({
return grouped;
}, [schedules, effectiveResources]);
// 줌 레벨 변경
// ────────── 충돌 감지 ──────────
const conflictIds = useMemo(() => {
if (config.showConflicts === false) return new Set<string>();
return detectConflicts(schedules);
}, [schedules, config.showConflicts]);
// ────────── 줌 레벨 변경 ──────────
const handleZoomIn = useCallback(() => {
const levels: ZoomLevel[] = ["month", "week", "day"];
const currentIdx = levels.indexOf(zoomLevel);
if (currentIdx < levels.length - 1) {
setZoomLevel(levels[currentIdx + 1]);
}
const idx = levels.indexOf(zoomLevel);
if (idx < levels.length - 1) setZoomLevel(levels[idx + 1]);
}, [zoomLevel, setZoomLevel]);
const handleZoomOut = useCallback(() => {
const levels: ZoomLevel[] = ["month", "week", "day"];
const currentIdx = levels.indexOf(zoomLevel);
if (currentIdx > 0) {
setZoomLevel(levels[currentIdx - 1]);
}
const idx = levels.indexOf(zoomLevel);
if (idx > 0) setZoomLevel(levels[idx - 1]);
}, [zoomLevel, setZoomLevel]);
// 스케줄 클릭 핸들러
// ────────── 스케줄 클릭 ──────────
const handleScheduleClick = useCallback(
(schedule: ScheduleItem) => {
const resource = effectiveResources.find((r) => r.id === schedule.resourceId);
const resource = effectiveResources.find(
(r) => r.id === schedule.resourceId
);
if (resource && onScheduleClick) {
onScheduleClick({ schedule, resource });
}
@ -160,7 +140,7 @@ export function TimelineSchedulerComponent({
[effectiveResources, onScheduleClick]
);
// 빈 셀 클릭 핸들러
// ────────── 빈 셀 클릭 ──────────
const handleCellClick = useCallback(
(resourceId: string, date: Date) => {
if (onCellClick) {
@ -173,47 +153,111 @@ export function TimelineSchedulerComponent({
[onCellClick]
);
// 드래그 시작
const handleDragStart = useCallback(
(schedule: ScheduleItem, e: React.MouseEvent) => {
setDragState({
schedule,
startX: e.clientX,
startY: e.clientY,
});
// ────────── 드래그 완료 (핵심 로직) ──────────
const handleDragComplete = useCallback(
async (schedule: ScheduleItem, deltaX: number) => {
// 줌 레벨에 따라 1셀당 일수가 달라짐
let daysPerCell = 1;
if (zoomLevel === "week") daysPerCell = 7;
if (zoomLevel === "month") daysPerCell = 30;
const deltaDays = Math.round((deltaX / cellWidth) * daysPerCell);
if (deltaDays === 0) return;
const newStartDate = addDaysToDateString(schedule.startDate, deltaDays);
const newEndDate = addDaysToDateString(schedule.endDate, deltaDays);
try {
await updateSchedule(schedule.id, {
startDate: newStartDate,
endDate: newEndDate,
});
// 외부 이벤트 핸들러 호출
onDragEnd?.({
scheduleId: schedule.id,
newStartDate,
newEndDate,
});
toast.success("스케줄 이동 완료", {
description: `${schedule.title}: ${newStartDate} ~ ${newEndDate}`,
});
} catch (err: any) {
toast.error("스케줄 이동 실패", {
description: err.message || "잠시 후 다시 시도해주세요",
});
}
},
[]
[cellWidth, zoomLevel, updateSchedule, onDragEnd]
);
// 드래그 종료
const handleDragEnd = useCallback(() => {
if (dragState) {
// TODO: 드래그 결과 계산 및 업데이트
setDragState(null);
}
}, [dragState]);
// ────────── 리사이즈 완료 (핵심 로직) ──────────
const handleResizeComplete = useCallback(
async (
schedule: ScheduleItem,
direction: "start" | "end",
deltaX: number
) => {
let daysPerCell = 1;
if (zoomLevel === "week") daysPerCell = 7;
if (zoomLevel === "month") daysPerCell = 30;
// 리사이즈 시작
const handleResizeStart = useCallback(
(schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => {
setResizeState({
schedule,
direction,
startX: e.clientX,
});
const deltaDays = Math.round((deltaX / cellWidth) * daysPerCell);
if (deltaDays === 0) return;
let newStartDate = schedule.startDate;
let newEndDate = schedule.endDate;
if (direction === "start") {
newStartDate = addDaysToDateString(schedule.startDate, deltaDays);
// 시작일이 종료일을 넘지 않도록
if (new Date(newStartDate) >= new Date(newEndDate)) {
toast.warning("시작일은 종료일보다 이전이어야 합니다");
return;
}
} else {
newEndDate = addDaysToDateString(schedule.endDate, deltaDays);
// 종료일이 시작일보다 앞서지 않도록
if (new Date(newEndDate) <= new Date(newStartDate)) {
toast.warning("종료일은 시작일보다 이후여야 합니다");
return;
}
}
try {
await updateSchedule(schedule.id, {
startDate: newStartDate,
endDate: newEndDate,
});
onResizeEnd?.({
scheduleId: schedule.id,
newStartDate,
newEndDate,
direction,
});
const days =
Math.round(
(new Date(newEndDate).getTime() -
new Date(newStartDate).getTime()) /
(1000 * 60 * 60 * 24)
) + 1;
toast.success("기간 변경 완료", {
description: `${schedule.title}: ${days}일 (${newStartDate} ~ ${newEndDate})`,
});
} catch (err: any) {
toast.error("기간 변경 실패", {
description: err.message || "잠시 후 다시 시도해주세요",
});
}
},
[]
[cellWidth, zoomLevel, updateSchedule, onResizeEnd]
);
// 리사이즈 종료
const handleResizeEnd = useCallback(() => {
if (resizeState) {
// TODO: 리사이즈 결과 계산 및 업데이트
setResizeState(null);
}
}, [resizeState]);
// 추가 버튼 클릭
// ────────── 추가 버튼 클릭 ──────────
const handleAddClick = useCallback(() => {
if (onAddSchedule && effectiveResources.length > 0) {
onAddSchedule(
@ -223,7 +267,13 @@ export function TimelineSchedulerComponent({
}
}, [onAddSchedule, effectiveResources]);
// 디자인 모드 플레이스홀더
// ────────── 하단 영역 높이 계산 (툴바 + 범례) ──────────
const showToolbar = config.showToolbar !== false;
const showLegend = config.showLegend !== false;
const toolbarHeight = showToolbar ? 36 : 0;
const legendHeight = showLegend ? 28 : 0;
// ────────── 디자인 모드 플레이스홀더 ──────────
if (isDesignMode) {
return (
<div className="flex h-full min-h-[200px] w-full items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/30 bg-muted/10">
@ -240,7 +290,7 @@ export function TimelineSchedulerComponent({
);
}
// 로딩 상태
// ────────── 로딩 상태 ──────────
if (isLoading) {
return (
<div
@ -255,7 +305,7 @@ export function TimelineSchedulerComponent({
);
}
// 에러 상태
// ────────── 에러 상태 ──────────
if (error) {
return (
<div
@ -270,7 +320,7 @@ export function TimelineSchedulerComponent({
);
}
// 스케줄 데이터 없음
// ────────── 데이터 없음 ──────────
if (schedules.length === 0) {
return (
<div
@ -279,9 +329,12 @@ export function TimelineSchedulerComponent({
>
<div className="text-center text-muted-foreground">
<Calendar className="mx-auto mb-2 h-8 w-8 opacity-50 sm:mb-3 sm:h-10 sm:w-10" />
<p className="text-xs font-medium sm:text-sm"> </p>
<p className="text-xs font-medium sm:text-sm">
</p>
<p className="mt-1.5 max-w-[200px] text-[10px] sm:mt-2 sm:text-xs">
,<br />
,
<br />
</p>
</div>
@ -289,18 +342,19 @@ export function TimelineSchedulerComponent({
);
}
// ────────── 메인 렌더링 ──────────
return (
<div
ref={containerRef}
className="w-full overflow-hidden rounded-lg border bg-background"
className="flex w-full flex-col overflow-hidden rounded-lg border bg-background"
style={{
height: config.height || 500,
maxHeight: config.maxHeight,
}}
>
{/* 툴바 */}
{config.showToolbar !== false && (
<div className="flex items-center justify-between border-b bg-muted/30 px-2 py-1.5 sm:px-3 sm:py-2">
{showToolbar && (
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-2 py-1.5 sm:px-3 sm:py-2">
{/* 네비게이션 */}
<div className="flex items-center gap-0.5 sm:gap-1">
{config.showNavigation !== false && (
@ -332,16 +386,23 @@ export function TimelineSchedulerComponent({
</>
)}
{/* 현재 날짜 범위 표시 */}
{/* 날짜 범위 표시 */}
<span className="ml-1 text-[10px] text-muted-foreground sm:ml-2 sm:text-sm">
{viewStartDate.getFullYear()} {viewStartDate.getMonth() + 1}{" "}
{viewStartDate.getDate()} ~{" "}
{viewEndDate.getMonth() + 1} {viewEndDate.getDate()}
{viewStartDate.getDate()} ~ {viewEndDate.getMonth() + 1}{" "}
{viewEndDate.getDate()}
</span>
</div>
{/* 오른쪽 컨트롤 */}
<div className="flex items-center gap-1 sm:gap-2">
{/* 충돌 카운트 표시 */}
{config.showConflicts !== false && conflictIds.size > 0 && (
<span className="rounded-full bg-destructive/10 px-1.5 py-0.5 text-[9px] font-medium text-destructive sm:px-2 sm:text-[10px]">
{conflictIds.size}
</span>
)}
{/* 줌 컨트롤 */}
{config.showZoomControls !== false && (
<div className="flex items-center gap-0.5 sm:gap-1">
@ -355,7 +416,10 @@ export function TimelineSchedulerComponent({
<ZoomOut className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</Button>
<span className="min-w-[20px] text-center text-[10px] text-muted-foreground sm:min-w-[24px] sm:text-xs">
{zoomLevelOptions.find((o) => o.value === zoomLevel)?.label}
{
zoomLevelOptions.find((o) => o.value === zoomLevel)
?.label
}
</span>
<Button
variant="ghost"
@ -385,15 +449,8 @@ export function TimelineSchedulerComponent({
</div>
)}
{/* 타임라인 본문 */}
<div
className="overflow-auto"
style={{
height: config.showToolbar !== false
? `calc(100% - 48px)`
: "100%",
}}
>
{/* 타임라인 본문 (스크롤 영역) */}
<div className="min-h-0 flex-1 overflow-auto">
<div className="min-w-max">
{/* 헤더 */}
<TimelineHeader
@ -420,17 +477,23 @@ export function TimelineSchedulerComponent({
cellWidth={cellWidth}
resourceColumnWidth={resourceColumnWidth}
config={config}
conflictIds={conflictIds}
onScheduleClick={handleScheduleClick}
onCellClick={handleCellClick}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onResizeStart={handleResizeStart}
onResizeEnd={handleResizeEnd}
onDragComplete={handleDragComplete}
onResizeComplete={handleResizeComplete}
/>
))}
</div>
</div>
</div>
{/* 범례 */}
{showLegend && (
<div className="shrink-0">
<TimelineLegend config={config} />
</div>
)}
</div>
);
}

View File

@ -2,54 +2,44 @@
import React, { useMemo } from "react";
import { cn } from "@/lib/utils";
import { Resource, ScheduleItem, ZoomLevel, TimelineSchedulerConfig } from "../types";
import {
Resource,
ScheduleItem,
ZoomLevel,
TimelineSchedulerConfig,
} from "../types";
import { ScheduleBar } from "./ScheduleBar";
interface ResourceRowProps {
/** 리소스 */
resource: Resource;
/** 해당 리소스의 스케줄 목록 */
schedules: ScheduleItem[];
/** 시작 날짜 */
startDate: Date;
/** 종료 날짜 */
endDate: Date;
/** 줌 레벨 */
zoomLevel: ZoomLevel;
/** 행 높이 */
rowHeight: number;
/** 셀 너비 */
cellWidth: number;
/** 리소스 컬럼 너비 */
resourceColumnWidth: number;
/** 설정 */
config: TimelineSchedulerConfig;
/** 스케줄 클릭 */
/** 충돌 스케줄 ID 목록 */
conflictIds?: Set<string>;
onScheduleClick?: (schedule: ScheduleItem) => void;
/** 빈 셀 클릭 */
onCellClick?: (resourceId: string, date: Date) => void;
/** 드래그 시작 */
onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void;
/** 드래그 종료 */
onDragEnd?: () => void;
/** 리사이즈 시작 */
onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void;
/** 리사이즈 종료 */
onResizeEnd?: () => void;
/** 드래그 완료: deltaX(픽셀) 전달 */
onDragComplete?: (schedule: ScheduleItem, deltaX: number) => void;
/** 리사이즈 완료: direction + deltaX(픽셀) 전달 */
onResizeComplete?: (
schedule: ScheduleItem,
direction: "start" | "end",
deltaX: number
) => void;
}
/**
* ()
*/
const getDaysDiff = (start: Date, end: Date): number => {
const startTime = new Date(start).setHours(0, 0, 0, 0);
const endTime = new Date(end).setHours(0, 0, 0, 0);
return Math.round((endTime - startTime) / (1000 * 60 * 60 * 24));
};
/**
*
*/
const getCellCount = (startDate: Date, endDate: Date): number => {
return getDaysDiff(startDate, endDate) + 1;
};
@ -64,20 +54,18 @@ export function ResourceRow({
cellWidth,
resourceColumnWidth,
config,
conflictIds,
onScheduleClick,
onCellClick,
onDragStart,
onDragEnd,
onResizeStart,
onResizeEnd,
onDragComplete,
onResizeComplete,
}: ResourceRowProps) {
// 총 셀 개수
const totalCells = useMemo(() => getCellCount(startDate, endDate), [startDate, endDate]);
// 총 그리드 너비
const totalCells = useMemo(
() => getCellCount(startDate, endDate),
[startDate, endDate]
);
const gridWidth = totalCells * cellWidth;
// 오늘 날짜
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
@ -92,21 +80,26 @@ export function ResourceRow({
scheduleStart.setHours(0, 0, 0, 0);
scheduleEnd.setHours(0, 0, 0, 0);
// 시작 위치 계산
const startOffset = getDaysDiff(startDate, scheduleStart);
const left = Math.max(0, startOffset * cellWidth);
// 너비 계산
const durationDays = getDaysDiff(scheduleStart, scheduleEnd) + 1;
const visibleStartOffset = Math.max(0, startOffset);
const visibleEndOffset = Math.min(
totalCells,
startOffset + durationDays
);
const width = Math.max(cellWidth, (visibleEndOffset - visibleStartOffset) * cellWidth);
const width = Math.max(
cellWidth,
(visibleEndOffset - visibleStartOffset) * cellWidth
);
// 시작일 = 종료일이면 마일스톤
const isMilestone = schedule.startDate === schedule.endDate;
return {
schedule,
isMilestone,
position: {
left: resourceColumnWidth + left,
top: 0,
@ -115,9 +108,15 @@ export function ResourceRow({
},
};
});
}, [schedules, startDate, cellWidth, resourceColumnWidth, rowHeight, totalCells]);
}, [
schedules,
startDate,
cellWidth,
resourceColumnWidth,
rowHeight,
totalCells,
]);
// 그리드 셀 클릭 핸들러
const handleGridClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!onCellClick) return;
@ -142,7 +141,9 @@ export function ResourceRow({
style={{ width: resourceColumnWidth }}
>
<div className="truncate">
<div className="truncate text-[10px] font-medium sm:text-sm">{resource.name}</div>
<div className="truncate text-[10px] font-medium sm:text-sm">
{resource.name}
</div>
{resource.group && (
<div className="truncate text-[9px] text-muted-foreground sm:text-xs">
{resource.group}
@ -162,7 +163,8 @@ export function ResourceRow({
{Array.from({ length: totalCells }).map((_, idx) => {
const cellDate = new Date(startDate);
cellDate.setDate(cellDate.getDate() + idx);
const isWeekend = cellDate.getDay() === 0 || cellDate.getDay() === 6;
const isWeekend =
cellDate.getDay() === 0 || cellDate.getDay() === 6;
const isToday = cellDate.getTime() === today.getTime();
const isMonthStart = cellDate.getDate() === 1;
@ -182,22 +184,22 @@ export function ResourceRow({
</div>
{/* 스케줄 바들 */}
{schedulePositions.map(({ schedule, position }) => (
{schedulePositions.map(({ schedule, position, isMilestone }) => (
<ScheduleBar
key={schedule.id}
schedule={schedule}
position={{
...position,
left: position.left - resourceColumnWidth, // 상대 위치
left: position.left - resourceColumnWidth,
}}
config={config}
draggable={config.draggable}
resizable={config.resizable}
hasConflict={conflictIds?.has(schedule.id) ?? false}
isMilestone={isMilestone}
onClick={() => onScheduleClick?.(schedule)}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onResizeStart={onResizeStart}
onResizeEnd={onResizeEnd}
onDragComplete={onDragComplete}
onResizeComplete={onResizeComplete}
/>
))}
</div>

View File

@ -2,79 +2,99 @@
import React, { useState, useCallback, useRef } from "react";
import { cn } from "@/lib/utils";
import { ScheduleItem, ScheduleBarPosition, TimelineSchedulerConfig } from "../types";
import { AlertTriangle } from "lucide-react";
import {
ScheduleItem,
ScheduleBarPosition,
TimelineSchedulerConfig,
} from "../types";
import { statusOptions } from "../config";
interface ScheduleBarProps {
/** 스케줄 항목 */
schedule: ScheduleItem;
/** 위치 정보 */
position: ScheduleBarPosition;
/** 설정 */
config: TimelineSchedulerConfig;
/** 드래그 가능 여부 */
draggable?: boolean;
/** 리사이즈 가능 여부 */
resizable?: boolean;
/** 클릭 이벤트 */
hasConflict?: boolean;
isMilestone?: boolean;
onClick?: (schedule: ScheduleItem) => void;
/** 드래그 시작 */
onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void;
/** 드래그 중 */
onDrag?: (deltaX: number, deltaY: number) => void;
/** 드래그 종료 */
onDragEnd?: () => void;
/** 리사이즈 시작 */
onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void;
/** 리사이즈 중 */
onResize?: (deltaX: number, direction: "start" | "end") => void;
/** 리사이즈 종료 */
onResizeEnd?: () => void;
/** 드래그 완료 시 deltaX(픽셀) 전달 */
onDragComplete?: (schedule: ScheduleItem, deltaX: number) => void;
/** 리사이즈 완료 시 direction과 deltaX(픽셀) 전달 */
onResizeComplete?: (
schedule: ScheduleItem,
direction: "start" | "end",
deltaX: number
) => void;
}
// 드래그/리사이즈 판정 최소 이동 거리 (px)
const MIN_MOVE_THRESHOLD = 5;
export function ScheduleBar({
schedule,
position,
config,
draggable = true,
resizable = true,
hasConflict = false,
isMilestone = false,
onClick,
onDragStart,
onDragEnd,
onResizeStart,
onResizeEnd,
onDragComplete,
onResizeComplete,
}: ScheduleBarProps) {
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragOffset, setDragOffset] = useState(0);
const [resizeOffset, setResizeOffset] = useState(0);
const [resizeDir, setResizeDir] = useState<"start" | "end">("end");
const barRef = useRef<HTMLDivElement>(null);
const startXRef = useRef(0);
const movedRef = useRef(false);
// 상태에 따른 색상
const statusColor = schedule.color ||
const statusColor =
schedule.color ||
config.statusColors?.[schedule.status] ||
statusOptions.find((s) => s.value === schedule.status)?.color ||
"#3b82f6";
// 진행률 바 너비
const progressWidth = config.showProgress && schedule.progress !== undefined
? `${schedule.progress}%`
: "0%";
const progressWidth =
config.showProgress && schedule.progress !== undefined
? `${schedule.progress}%`
: "0%";
// 드래그 시작 핸들러
const isEditable = config.editable !== false;
// ────────── 드래그 핸들러 ──────────
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (!draggable || isResizing) return;
if (!draggable || isResizing || !isEditable) return;
e.preventDefault();
e.stopPropagation();
startXRef.current = e.clientX;
movedRef.current = false;
setIsDragging(true);
onDragStart?.(schedule, e);
setDragOffset(0);
const handleMouseMove = (moveEvent: MouseEvent) => {
// 드래그 중 로직은 부모에서 처리
const delta = moveEvent.clientX - startXRef.current;
if (Math.abs(delta) > MIN_MOVE_THRESHOLD) {
movedRef.current = true;
}
setDragOffset(delta);
};
const handleMouseUp = () => {
const handleMouseUp = (upEvent: MouseEvent) => {
const finalDelta = upEvent.clientX - startXRef.current;
setIsDragging(false);
onDragEnd?.();
setDragOffset(0);
if (movedRef.current && Math.abs(finalDelta) > MIN_MOVE_THRESHOLD) {
onDragComplete?.(schedule, finalDelta);
}
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
@ -82,25 +102,39 @@ export function ScheduleBar({
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[draggable, isResizing, schedule, onDragStart, onDragEnd]
[draggable, isResizing, isEditable, schedule, onDragComplete]
);
// 리사이즈 시작 핸들러
const handleResizeStart = useCallback(
// ────────── 리사이즈 핸들러 ──────────
const handleResizeMouseDown = useCallback(
(direction: "start" | "end", e: React.MouseEvent) => {
if (!resizable) return;
if (!resizable || !isEditable) return;
e.preventDefault();
e.stopPropagation();
startXRef.current = e.clientX;
movedRef.current = false;
setIsResizing(true);
onResizeStart?.(schedule, direction, e);
setResizeOffset(0);
setResizeDir(direction);
const handleMouseMove = (moveEvent: MouseEvent) => {
// 리사이즈 중 로직은 부모에서 처리
const delta = moveEvent.clientX - startXRef.current;
if (Math.abs(delta) > MIN_MOVE_THRESHOLD) {
movedRef.current = true;
}
setResizeOffset(delta);
};
const handleMouseUp = () => {
const handleMouseUp = (upEvent: MouseEvent) => {
const finalDelta = upEvent.clientX - startXRef.current;
setIsResizing(false);
onResizeEnd?.();
setResizeOffset(0);
if (movedRef.current && Math.abs(finalDelta) > MIN_MOVE_THRESHOLD) {
onResizeComplete?.(schedule, direction, finalDelta);
}
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
@ -108,19 +142,62 @@ export function ScheduleBar({
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[resizable, schedule, onResizeStart, onResizeEnd]
[resizable, isEditable, schedule, onResizeComplete]
);
// 클릭 핸들러
// ────────── 클릭 핸들러 ──────────
const handleClick = useCallback(
(e: React.MouseEvent) => {
if (isDragging || isResizing) return;
if (movedRef.current) return;
e.stopPropagation();
onClick?.(schedule);
},
[isDragging, isResizing, onClick, schedule]
[onClick, schedule]
);
// ────────── 드래그/리사이즈 중 시각적 위치 계산 ──────────
let visualLeft = position.left;
let visualWidth = position.width;
if (isDragging) {
visualLeft += dragOffset;
}
if (isResizing) {
if (resizeDir === "start") {
visualLeft += resizeOffset;
visualWidth -= resizeOffset;
} else {
visualWidth += resizeOffset;
}
}
visualWidth = Math.max(10, visualWidth);
// ────────── 마일스톤 렌더링 (단일 날짜 마커) ──────────
if (isMilestone) {
return (
<div
ref={barRef}
className="absolute flex cursor-pointer items-center justify-center"
style={{
left: visualLeft + position.width / 2 - 8,
top: position.top + position.height / 2 - 8,
width: 16,
height: 16,
}}
onClick={handleClick}
title={schedule.title}
>
<div
className="h-2.5 w-2.5 rotate-45 shadow-sm transition-transform hover:scale-125 sm:h-3 sm:w-3"
style={{ backgroundColor: statusColor }}
/>
</div>
);
}
// ────────── 일반 스케줄 바 렌더링 ──────────
return (
<div
ref={barRef}
@ -128,19 +205,21 @@ export function ScheduleBar({
"absolute cursor-pointer rounded-md shadow-sm transition-shadow",
"hover:z-10 hover:shadow-md",
isDragging && "z-20 opacity-70 shadow-lg",
isResizing && "z-20",
draggable && "cursor-grab",
isDragging && "cursor-grabbing"
isResizing && "z-20 opacity-80",
draggable && isEditable && "cursor-grab",
isDragging && "cursor-grabbing",
hasConflict && "ring-2 ring-destructive ring-offset-1"
)}
style={{
left: position.left,
left: visualLeft,
top: position.top + 4,
width: position.width,
width: visualWidth,
height: position.height - 8,
backgroundColor: statusColor,
}}
onClick={handleClick}
onMouseDown={handleMouseDown}
title={schedule.title}
>
{/* 진행률 바 */}
{config.showProgress && schedule.progress !== undefined && (
@ -162,19 +241,26 @@ export function ScheduleBar({
</div>
)}
{/* 충돌 인디케이터 */}
{hasConflict && (
<div className="absolute -right-0.5 -top-0.5 sm:-right-1 sm:-top-1">
<AlertTriangle className="h-2.5 w-2.5 fill-destructive text-white sm:h-3 sm:w-3" />
</div>
)}
{/* 리사이즈 핸들 - 왼쪽 */}
{resizable && (
{resizable && isEditable && (
<div
className="absolute bottom-0 left-0 top-0 w-1.5 cursor-ew-resize rounded-l-md hover:bg-white/20 sm:w-2"
onMouseDown={(e) => handleResizeStart("start", e)}
onMouseDown={(e) => handleResizeMouseDown("start", e)}
/>
)}
{/* 리사이즈 핸들 - 오른쪽 */}
{resizable && (
{resizable && isEditable && (
<div
className="absolute bottom-0 right-0 top-0 w-1.5 cursor-ew-resize rounded-r-md hover:bg-white/20 sm:w-2"
onMouseDown={(e) => handleResizeStart("end", e)}
onMouseDown={(e) => handleResizeMouseDown("end", e)}
/>
)}
</div>

View File

@ -0,0 +1,55 @@
"use client";
import React from "react";
import { TimelineSchedulerConfig } from "../types";
import { statusOptions } from "../config";
interface TimelineLegendProps {
config: TimelineSchedulerConfig;
}
export function TimelineLegend({ config }: TimelineLegendProps) {
const colors = config.statusColors || {};
return (
<div className="flex flex-wrap items-center gap-2 border-t bg-muted/20 px-2 py-1 sm:gap-3 sm:px-3 sm:py-1.5">
<span className="text-[10px] font-medium text-muted-foreground sm:text-xs">
:
</span>
{statusOptions.map((status) => (
<div key={status.value} className="flex items-center gap-1">
<div
className="h-2 w-4 rounded-sm sm:h-2.5 sm:w-5"
style={{
backgroundColor:
colors[status.value as keyof typeof colors] || status.color,
}}
/>
<span className="text-[9px] text-muted-foreground sm:text-[10px]">
{status.label}
</span>
</div>
))}
{/* 마일스톤 범례 */}
<div className="flex items-center gap-1">
<div className="flex h-2.5 w-4 items-center justify-center sm:h-3 sm:w-5">
<div className="h-1.5 w-1.5 rotate-45 bg-foreground/60 sm:h-2 sm:w-2" />
</div>
<span className="text-[9px] text-muted-foreground sm:text-[10px]">
</span>
</div>
{/* 충돌 범례 */}
{config.showConflicts && (
<div className="flex items-center gap-1">
<div className="h-2 w-4 rounded-sm ring-1.5 ring-destructive sm:h-2.5 sm:w-5" />
<span className="text-[9px] text-muted-foreground sm:text-[10px]">
</span>
</div>
)}
</div>
);
}

View File

@ -1,3 +1,4 @@
export { TimelineHeader } from "./TimelineHeader";
export { ScheduleBar } from "./ScheduleBar";
export { ResourceRow } from "./ResourceRow";
export { TimelineLegend } from "./TimelineLegend";

View File

@ -0,0 +1,58 @@
"use client";
import { ScheduleItem } from "../types";
/**
*
* @returns ID Set
*/
export function detectConflicts(schedules: ScheduleItem[]): Set<string> {
const conflictIds = new Set<string>();
// 리소스별로 그룹화
const byResource = new Map<string, ScheduleItem[]>();
for (const schedule of schedules) {
if (!byResource.has(schedule.resourceId)) {
byResource.set(schedule.resourceId, []);
}
byResource.get(schedule.resourceId)!.push(schedule);
}
// 리소스별 충돌 검사
for (const [, resourceSchedules] of byResource) {
if (resourceSchedules.length < 2) continue;
// 시작일 기준 정렬
const sorted = [...resourceSchedules].sort(
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
);
for (let i = 0; i < sorted.length; i++) {
const aEnd = new Date(sorted[i].endDate).getTime();
for (let j = i + 1; j < sorted.length; j++) {
const bStart = new Date(sorted[j].startDate).getTime();
// 정렬되어 있으므로 aStart <= bStart
// 겹치는 조건: aEnd > bStart
if (aEnd > bStart) {
conflictIds.add(sorted[i].id);
conflictIds.add(sorted[j].id);
} else {
break;
}
}
}
}
return conflictIds;
}
/**
*
*/
export function addDaysToDateString(dateStr: string, days: number): string {
const date = new Date(dateStr);
date.setDate(date.getDate() + days);
return date.toISOString().split("T")[0];
}

View File

@ -0,0 +1,165 @@
# Screen 149 필드 타입 검증 가이드
## 배경
- **화면 149**: 품목정보 (item_info 테이블) 폼
- **division 컬럼**: DB에서 `input_type = 'text'`로 변경했으나 화면에는 여전히 SELECT 드롭다운으로 표시
- **unit 컬럼**: `input_type = 'category'` → SELECT 드롭다운으로 표시되어야 함
## DB 현황 (vexplor-dev 조회 결과)
| column_name | company_code | input_type |
|-------------|--------------|------------|
| division | * | category |
| division | COMPANY_7 | **text** |
| division | COMPANY_8, 9, 10, 18, 19, 20, 21 | category |
| unit | * | text |
| unit | COMPANY_18, 19, 20, 21, 7, 8, 9 | **category** |
**주의:** `wace` 사용자는 `company_code = '*'` (최고 관리자)입니다.
- division: company * → **category** (text 아님)
- unit: company * → **text** (category 아님)
**회사별로 다릅니다.** 예: COMPANY_7의 division은 text, unit은 category.
---
## 수동 검증 절차
### 1. 로그인
- URL: `http://localhost:9771/login`
- User ID: `wace`
- Password: `wace0909!!`
- 회사: "탑씰" (해당 회사 코드 확인 필요)
### 2. 화면 149 접속
- URL: `http://localhost:9771/screens/149`
- 페이지 로드 대기
### 3. 필드 확인
#### 구분 (division)
- **예상 (DB 기준):**
- company *: SELECT (category)
- COMPANY_7: TEXT INPUT (text)
- **실제:** TEXT INPUT 또는 SELECT 중 어느 쪽인지 확인
#### 단위 (unit)
- **예상 (DB 기준):**
- company *: TEXT INPUT (text)
- COMPANY_18~21, 7~9: SELECT (category)
- **실제:** TEXT INPUT 또는 SELECT 중 어느 쪽인지 확인
### 4. 스크린샷
- 구분, 단위 필드가 함께 보이도록 캡처
---
## 코드 흐름 (input_type → 렌더링)
### 1. 컬럼 메타 로드
```
DynamicComponentRenderer
→ loadColumnMeta(screenTableName)
→ GET /api/table-management/tables/item_info/columns?size=1000
→ columnMetaCache[tableName][columnName] = { inputType, ... }
```
### 2. 렌더 타입 결정 (357~369행)
```javascript
const dbInputType = columnMetaCache[screenTableName]?.[baseCol]?.inputType;
const ft = dbInputType || componentConfig?.fieldType;
if (["text", "number", ...].includes(ft)) return "v2-input"; // 텍스트 입력
if (["select", "category", "entity"].includes(ft)) return "v2-select"; // 드롭다운
```
### 3. mergeColumnMeta (81~130행)
- DB `input_type`이 화면 저장값보다 우선
- `needsSync`이면 DB 값으로 덮어씀
---
## 캐시 관련
### 1. 프론트엔드 (DynamicComponentRenderer)
- `columnMetaCache`: TTL 5초
- `table-columns-refresh` 이벤트 시 즉시 무효화 및 재로드
### 2. 백엔드 (tableManagementService)
- 컬럼 목록: 5분 TTL
- `updateColumnInputType` 호출 시 해당 테이블 캐시 삭제
### 3. 캐시 무효화가 필요한 경우
- 데이터 타입 관리에서 변경 후 화면이 갱신되지 않을 때
- **대응:** 페이지 새로고침 또는 `?_t=timestamp`로 API 재요청
---
## 가능한 원인
### 1. 회사 코드 불일치
- 로그인한 사용자 회사와 DB의 `company_code`가 다를 수 있음
- `wace``company_code = '*'` → division은 category, unit은 text
### 2. 화면 레이아웃에 저장된 값
- `componentConfig.fieldType`이 있으면 DB보다 우선될 수 있음
- 코드상으로는 `dbInputType`이 우선이므로, DB가 제대로 로드되면 덮어씀
### 3. 캐시
- 백엔드 5분, 프론트 5초
- 데이터 타입 변경 후 곧바로 화면을 열면 이전 캐시가 사용될 수 있음
### 4. API 응답 구조
- `columnMetaCache`에 넣을 때 `col.column_name || col.columnName` 사용
- `mergeColumnMeta``meta.input_type || meta.inputType` 사용
- 백엔드는 `inputType`(camelCase) 반환 → `columnMetaCache``inputType` 유지
---
## 디버깅용 Console 스크립트
화면 149 로드 후 브라우저 Console에서 실행:
```javascript
// 1. columnMetaCache 조회 (DynamicComponentRenderer 내부)
// React DevTools로 DynamicComponentRenderer 선택 후
// 또는 전역에 노출해 둔 경우:
const meta = window.__COLUMN_META_CACHE__?.item_info;
if (meta) {
console.log("division:", meta.division?.inputType || meta.division?.input_type);
console.log("unit:", meta.unit?.inputType || meta.unit?.input_type);
}
// 2. API 직접 호출
fetch("/api/table-management/tables/item_info/columns?size=1000", {
credentials: "include"
})
.then(r => r.json())
.then(d => {
const cols = d.data?.columns || d.columns || [];
const div = cols.find(c => (c.columnName || c.column_name) === "division");
const unit = cols.find(c => (c.columnName || c.column_name) === "unit");
console.log("API division:", div?.inputType || div?.input_type);
console.log("API unit:", unit?.inputType || unit?.input_type);
});
```
---
## 권장 사항
1. **회사 코드 확인**
- 로그인한 사용자의 `company_code` 확인
- `division`/`unit`을 text/category로 바꾼 회사가 맞는지 확인
2. **캐시 우회**
- 데이터 타입 변경 후 페이지 새로고침
- 또는 5초 이상 대기 후 다시 접속
3. **데이터 타입 관리에서 변경 시**
- 저장 후 `table-columns-refresh` 이벤트 발생 여부 확인
- 화면 디자이너의 V2FieldConfigPanel에서 변경 시에는 이벤트가 발생함
4. **테이블 관리 UI에서 변경 시**
- `table-columns-refresh` 이벤트가 발생하는지 확인
- 없으면 해당 화면에서 수동으로 `window.dispatchEvent(new CustomEvent("table-columns-refresh"))` 호출 후 재검증