diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index a7e8404f..da0ea772 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1097,7 +1097,11 @@ export async function saveMenu( let requestCompanyCode = menuData.companyCode || menuData.company_code; // "none"이나 빈 값은 undefined로 처리하여 사용자 회사 코드 사용 - if (requestCompanyCode === "none" || requestCompanyCode === "" || !requestCompanyCode) { + if ( + requestCompanyCode === "none" || + requestCompanyCode === "" || + !requestCompanyCode + ) { requestCompanyCode = undefined; } @@ -1252,7 +1256,8 @@ export async function updateMenu( } } - const requestCompanyCode = menuData.companyCode || menuData.company_code || currentMenu.company_code; + const requestCompanyCode = + menuData.companyCode || menuData.company_code || currentMenu.company_code; // company_code 변경 시도하는 경우 권한 체크 if (requestCompanyCode !== currentMenu.company_code) { @@ -1268,7 +1273,10 @@ export async function updateMenu( } } // 회사 관리자는 자기 회사로만 변경 가능 - else if (userCompanyCode !== "*" && requestCompanyCode !== userCompanyCode) { + else if ( + userCompanyCode !== "*" && + requestCompanyCode !== userCompanyCode + ) { res.status(403).json({ success: false, message: "해당 회사로 변경할 권한이 없습니다.", @@ -1493,8 +1501,13 @@ export async function deleteMenusBatch( ); // 권한 체크: 공통 메뉴 포함 여부 확인 - const hasCommonMenu = menusToDelete.some((menu: any) => menu.company_code === "*"); - if (hasCommonMenu && (userCompanyCode !== "*" || userType !== "SUPER_ADMIN")) { + const hasCommonMenu = menusToDelete.some( + (menu: any) => menu.company_code === "*" + ); + if ( + hasCommonMenu && + (userCompanyCode !== "*" || userType !== "SUPER_ADMIN") + ) { res.status(403).json({ success: false, message: "공통 메뉴는 최고 관리자만 삭제할 수 있습니다.", @@ -1506,7 +1519,8 @@ export async function deleteMenusBatch( // 회사 관리자는 자기 회사 메뉴만 삭제 가능 if (userCompanyCode !== "*") { const unauthorizedMenus = menusToDelete.filter( - (menu: any) => menu.company_code !== userCompanyCode && menu.company_code !== "*" + (menu: any) => + menu.company_code !== userCompanyCode && menu.company_code !== "*" ); if (unauthorizedMenus.length > 0) { res.status(403).json({ @@ -2674,7 +2688,10 @@ export const getCompanyByCode = async ( res.status(200).json(response); } catch (error) { - logger.error("회사 정보 조회 실패", { error, companyCode: req.params.companyCode }); + logger.error("회사 정보 조회 실패", { + error, + companyCode: req.params.companyCode, + }); res.status(500).json({ success: false, message: "회사 정보 조회 중 오류가 발생했습니다.", @@ -2740,7 +2757,9 @@ export const updateCompany = async ( // 사업자등록번호 중복 체크 및 유효성 검증 (자기 자신 제외) if (business_registration_number && business_registration_number.trim()) { // 유효성 검증 - const businessNumberValidation = validateBusinessNumber(business_registration_number.trim()); + const businessNumberValidation = validateBusinessNumber( + business_registration_number.trim() + ); if (!businessNumberValidation.isValid) { res.status(400).json({ success: false, @@ -3283,7 +3302,9 @@ export async function copyMenu( // 권한 체크: 최고 관리자만 가능 if (!isSuperAdmin && userType !== "SUPER_ADMIN") { - logger.warn(`권한 없음: ${userId} (userType=${userType}, company_code=${userCompanyCode})`); + logger.warn( + `권한 없음: ${userId} (userType=${userType}, company_code=${userCompanyCode})` + ); res.status(403).json({ success: false, message: "메뉴 복사는 최고 관리자(SUPER_ADMIN)만 가능합니다", diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 8c93380e..4b5d70e8 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -397,6 +397,37 @@ export const InteractiveScreenViewer: React.FC = ( ); } + // 탭 컴포넌트 처리 + if (comp.type === "tabs" || (comp.type === "component" && comp.componentId === "tabs-widget")) { + const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget; + + // componentConfig에서 탭 정보 추출 + const tabsConfig = comp.componentConfig || {}; + const tabsComponent = { + ...comp, + type: "tabs" as const, + tabs: tabsConfig.tabs || [], + defaultTab: tabsConfig.defaultTab, + orientation: tabsConfig.orientation || "horizontal", + variant: tabsConfig.variant || "default", + allowCloseable: tabsConfig.allowCloseable || false, + persistSelection: tabsConfig.persistSelection || false, + }; + + console.log("🔍 탭 컴포넌트 렌더링:", { + originalType: comp.type, + componentId: (comp as any).componentId, + tabs: tabsComponent.tabs, + tabsConfig, + }); + + return ( +
+ +
+ ); + } + const { widgetType, label, placeholder, required, readonly, columnName } = comp; const fieldName = columnName || comp.id; const currentValue = formData[fieldName] || ""; diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 81f8dc21..7a0dbc34 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -554,6 +554,44 @@ export const RealtimePreviewDynamic: React.FC = ({ ); })()} + {/* 탭 컴포넌트 타입 */} + {(type === "tabs" || (type === "component" && (component as any).componentId === "tabs-widget")) && + (() => { + const tabsComponent = component as any; + // componentConfig에서 탭 정보 가져오기 + const tabs = tabsComponent.componentConfig?.tabs || tabsComponent.tabs || []; + + return ( +
+
+
+ +
+

탭 컴포넌트

+

+ {tabs.length > 0 + ? `${tabs.length}개의 탭 (실제 화면에서 표시됩니다)` + : "탭이 없습니다. 설정 패널에서 탭을 추가하세요"} +

+ {tabs.length > 0 && ( +
+ {tabs.map((tab: any, index: number) => ( + + {tab.label || `탭 ${index + 1}`} + {tab.screenName && ( + + ({tab.screenName}) + + )} + + ))} +
+ )} +
+
+ ); + })()} + {/* 그룹 타입 */} {type === "group" && (
diff --git a/frontend/components/screen/config-panels/TabsConfigPanel.tsx b/frontend/components/screen/config-panels/TabsConfigPanel.tsx new file mode 100644 index 00000000..7d401e69 --- /dev/null +++ b/frontend/components/screen/config-panels/TabsConfigPanel.tsx @@ -0,0 +1,391 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Check, ChevronsUpDown, Plus, X, GripVertical, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { TabItem, TabsComponent } from "@/types/screen-management"; + +interface TabsConfigPanelProps { + config: any; + onChange: (config: any) => void; +} + +interface ScreenInfo { + screenId: number; + screenName: string; + screenCode: string; + tableName: string; +} + +export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) { + const [screens, setScreens] = useState([]); + const [loading, setLoading] = useState(false); + const [localTabs, setLocalTabs] = useState(config.tabs || []); + + // 화면 목록 로드 + useEffect(() => { + const loadScreens = async () => { + try { + setLoading(true); + + // API 클라이언트 동적 import (named export 사용) + const { apiClient } = await import("@/lib/api/client"); + + // 전체 화면 목록 조회 (페이징 사이즈 크게) + const response = await apiClient.get("/screen-management/screens", { + params: { size: 1000 } + }); + + console.log("화면 목록 조회 성공:", response.data); + + if (response.data.success && response.data.data) { + setScreens(response.data.data); + } + } catch (error: any) { + console.error("Failed to load screens:", error); + console.error("Error response:", error.response?.data); + } finally { + setLoading(false); + } + }; + + loadScreens(); + }, []); + + // 컴포넌트 변경 시 로컬 상태 동기화 + useEffect(() => { + setLocalTabs(config.tabs || []); + }, [config.tabs]); + + // 탭 추가 + const handleAddTab = () => { + const newTab: TabItem = { + id: `tab-${Date.now()}`, + label: `새 탭 ${localTabs.length + 1}`, + order: localTabs.length, + disabled: false, + }; + + const updatedTabs = [...localTabs, newTab]; + setLocalTabs(updatedTabs); + onChange({ ...config, tabs: updatedTabs }); + }; + + // 탭 제거 + const handleRemoveTab = (tabId: string) => { + const updatedTabs = localTabs.filter((tab) => tab.id !== tabId); + setLocalTabs(updatedTabs); + onChange({ ...config, tabs: updatedTabs }); + }; + + // 탭 라벨 변경 + const handleLabelChange = (tabId: string, label: string) => { + const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, label } : tab)); + setLocalTabs(updatedTabs); + onChange({ ...config, tabs: updatedTabs }); + }; + + // 탭 화면 선택 + const handleScreenSelect = (tabId: string, screenId: number, screenName: string) => { + const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, screenId, screenName } : tab)); + setLocalTabs(updatedTabs); + onChange({ ...config, tabs: updatedTabs }); + }; + + // 탭 비활성화 토글 + const handleDisabledToggle = (tabId: string, disabled: boolean) => { + const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, disabled } : tab)); + setLocalTabs(updatedTabs); + onChange({ ...config, tabs: updatedTabs }); + }; + + // 탭 순서 변경 + const handleMoveTab = (tabId: string, direction: "up" | "down") => { + const index = localTabs.findIndex((tab) => tab.id === tabId); + if ( + (direction === "up" && index === 0) || + (direction === "down" && index === localTabs.length - 1) + ) { + return; + } + + const newTabs = [...localTabs]; + const targetIndex = direction === "up" ? index - 1 : index + 1; + [newTabs[index], newTabs[targetIndex]] = [newTabs[targetIndex], newTabs[index]]; + + // order 값 재조정 + const updatedTabs = newTabs.map((tab, idx) => ({ ...tab, order: idx })); + setLocalTabs(updatedTabs); + onChange({ ...config, tabs: updatedTabs }); + }; + + return ( +
+
+

탭 설정

+ +
+ {/* 탭 방향 */} +
+ + +
+ + {/* 탭 스타일 */} +
+ + +
+ + {/* 선택 상태 유지 */} +
+
+ +

+ 페이지 새로고침 후에도 선택한 탭이 유지됩니다 +

+
+ onChange({ ...config, persistSelection: checked })} + /> +
+ + {/* 탭 닫기 버튼 */} +
+
+ +

+ 각 탭에 닫기 버튼을 표시합니다 +

+
+ onChange({ ...config, allowCloseable: checked })} + /> +
+
+
+ +
+
+

탭 목록

+ +
+ + {localTabs.length === 0 ? ( +
+

탭이 없습니다

+

+ 탭 추가 버튼을 클릭하여 새 탭을 생성하세요 +

+
+ ) : ( +
+ {localTabs.map((tab, index) => ( +
+
+
+ + 탭 {index + 1} +
+
+ + + +
+
+ +
+ {/* 탭 라벨 */} +
+ + handleLabelChange(tab.id, e.target.value)} + placeholder="탭 이름" + className="h-8 text-xs sm:h-9 sm:text-sm" + /> +
+ + {/* 화면 선택 */} +
+ + {loading ? ( +
+ + 로딩 중... +
+ ) : ( + + handleScreenSelect(tab.id, screenId, screenName) + } + /> + )} + {tab.screenName && ( +

+ 선택된 화면: {tab.screenName} +

+ )} +
+ + {/* 비활성화 */} +
+ + handleDisabledToggle(tab.id, checked)} + /> +
+
+
+ ))} +
+ )} +
+
+ ); +} + +// 화면 선택 Combobox 컴포넌트 +function ScreenSelectCombobox({ + screens, + selectedScreenId, + onSelect, +}: { + screens: ScreenInfo[]; + selectedScreenId?: number; + onSelect: (screenId: number, screenName: string) => void; +}) { + const [open, setOpen] = useState(false); + + const selectedScreen = screens.find((s) => s.screenId === selectedScreenId); + + return ( + + + + + + + + + + 화면을 찾을 수 없습니다. + + + {screens.map((screen) => ( + { + onSelect(screen.screenId, screen.screenName); + setOpen(false); + }} + className="text-xs sm:text-sm" + > + +
+ {screen.screenName} + + 코드: {screen.screenCode} | 테이블: {screen.tableName} + +
+
+ ))} +
+
+
+
+
+ ); +} + diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 61051439..4485cb7e 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, Suspense } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -341,14 +341,20 @@ export const UnifiedPropertiesPanel: React.FC = ({

{definition.name} 설정

- + +
설정 패널 로딩 중...
+ + }> + +
); }; diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 03dec3ba..90608a4b 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -1,210 +1,187 @@ "use client"; import React, { useState, useEffect } from "react"; -import { TabsComponent, TabItem, ScreenDefinition } from "@/types"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Card } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Loader2, FileQuestion } from "lucide-react"; -import { screenApi } from "@/lib/api/screen"; +import { Button } from "@/components/ui/button"; +import { X, Loader2 } from "lucide-react"; +import type { TabsComponent, TabItem } from "@/types/screen-management"; import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; interface TabsWidgetProps { component: TabsComponent; - isPreview?: boolean; + className?: string; + style?: React.CSSProperties; } -/** - * 탭 위젯 컴포넌트 - * 각 탭에 다른 화면을 표시할 수 있습니다 - */ -export const TabsWidget: React.FC = ({ component, isPreview = false }) => { - // componentConfig에서 설정 읽기 (새 컴포넌트 시스템) - const config = (component as any).componentConfig || component; - const { tabs = [], defaultTab, orientation = "horizontal", variant = "default" } = config; - - // console.log("🔍 TabsWidget 렌더링:", { - // component, - // componentConfig: (component as any).componentConfig, - // tabs, - // tabsLength: tabs.length - // }); +export function TabsWidget({ component, className, style }: TabsWidgetProps) { + const { + tabs = [], + defaultTab, + orientation = "horizontal", + variant = "default", + allowCloseable = false, + persistSelection = false, + } = component; - const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id || ""); - const [loadedScreens, setLoadedScreens] = useState>({}); + console.log("🎨 TabsWidget 렌더링:", { + componentId: component.id, + tabs, + tabsLength: tabs.length, + component, + }); + + const storageKey = `tabs-${component.id}-selected`; + + // 초기 선택 탭 결정 + const getInitialTab = () => { + if (persistSelection && typeof window !== "undefined") { + const saved = localStorage.getItem(storageKey); + if (saved && tabs.some((t) => t.id === saved)) { + return saved; + } + } + return defaultTab || tabs[0]?.id || ""; + }; + + const [selectedTab, setSelectedTab] = useState(getInitialTab()); + const [visibleTabs, setVisibleTabs] = useState(tabs); const [loadingScreens, setLoadingScreens] = useState>({}); - const [screenErrors, setScreenErrors] = useState>({}); + const [screenLayouts, setScreenLayouts] = useState>({}); - // 탭 변경 시 화면 로드 + // 컴포넌트 탭 목록 변경 시 동기화 useEffect(() => { - if (!activeTab) return; + setVisibleTabs(tabs.filter((tab) => !tab.disabled)); + }, [tabs]); - const currentTab = tabs.find((tab) => tab.id === activeTab); - if (!currentTab || !currentTab.screenId) return; + // 선택된 탭 변경 시 localStorage에 저장 + useEffect(() => { + if (persistSelection && typeof window !== "undefined") { + localStorage.setItem(storageKey, selectedTab); + } + }, [selectedTab, persistSelection, storageKey]); - // 이미 로드된 화면이면 스킵 - if (loadedScreens[activeTab]) return; + // 화면 레이아웃 로드 + const loadScreenLayout = async (screenId: number) => { + if (screenLayouts[screenId]) { + return; // 이미 로드됨 + } - // 이미 로딩 중이면 스킵 - if (loadingScreens[activeTab]) return; - - // 화면 로드 시작 - loadScreen(activeTab, currentTab.screenId); - }, [activeTab, tabs]); - - const loadScreen = async (tabId: string, screenId: number) => { - setLoadingScreens((prev) => ({ ...prev, [tabId]: true })); - setScreenErrors((prev) => ({ ...prev, [tabId]: "" })); + setLoadingScreens((prev) => ({ ...prev, [screenId]: true })); try { - const layoutData = await screenApi.getLayout(screenId); - - if (layoutData) { - setLoadedScreens((prev) => ({ - ...prev, - [tabId]: { - screenId, - layout: layoutData, - }, - })); - } else { - setScreenErrors((prev) => ({ - ...prev, - [tabId]: "화면을 불러올 수 없습니다", - })); + const response = await fetch(`/api/screen-management/screens/${screenId}/layout`); + if (response.ok) { + const data = await response.json(); + if (data.success && data.data) { + setScreenLayouts((prev) => ({ ...prev, [screenId]: data.data })); + } } - } catch (error: any) { - setScreenErrors((prev) => ({ - ...prev, - [tabId]: error.message || "화면 로드 중 오류가 발생했습니다", - })); + } catch (error) { + console.error(`Failed to load screen layout ${screenId}:`, error); } finally { - setLoadingScreens((prev) => ({ ...prev, [tabId]: false })); + setLoadingScreens((prev) => ({ ...prev, [screenId]: false })); } }; - // 탭 콘텐츠 렌더링 - const renderTabContent = (tab: TabItem) => { - const isLoading = loadingScreens[tab.id]; - const error = screenErrors[tab.id]; - const screenData = loadedScreens[tab.id]; + // 탭 변경 핸들러 + const handleTabChange = (tabId: string) => { + setSelectedTab(tabId); - // 로딩 중 - if (isLoading) { - return ( -
- -

화면을 불러오는 중...

-
- ); + // 해당 탭의 화면 로드 + const tab = visibleTabs.find((t) => t.id === tabId); + if (tab && tab.screenId && !screenLayouts[tab.screenId]) { + loadScreenLayout(tab.screenId); } + }; - // 에러 발생 - if (error) { - return ( -
- -
-

화면 로드 실패

-

{error}

-
-
- ); + // 탭 닫기 핸들러 + const handleCloseTab = (tabId: string, e: React.MouseEvent) => { + e.stopPropagation(); + + const updatedTabs = visibleTabs.filter((tab) => tab.id !== tabId); + setVisibleTabs(updatedTabs); + + // 닫은 탭이 선택된 탭이었다면 다음 탭 선택 + if (selectedTab === tabId && updatedTabs.length > 0) { + setSelectedTab(updatedTabs[0].id); } + }; - // 화면 ID가 없는 경우 - if (!tab.screenId) { - return ( -
- -
-

화면이 할당되지 않았습니다

-

상세설정에서 화면을 선택하세요

-
-
- ); - } - - // 화면 렌더링 - 원본 화면의 모든 컴포넌트를 그대로 렌더링 - if (screenData && screenData.layout && screenData.layout.components) { - const components = screenData.layout.components; - const screenResolution = screenData.layout.screenResolution || { width: 1920, height: 1080 }; - - return ( -
-
- {components.map((comp) => ( - - ))} -
-
- ); - } + // 탭 스타일 클래스 + const getTabsListClass = () => { + const baseClass = orientation === "vertical" ? "flex-col" : ""; + const variantClass = + variant === "pills" + ? "bg-muted p-1 rounded-lg" + : variant === "underline" + ? "border-b" + : "bg-muted p-1"; + return `${baseClass} ${variantClass}`; + }; + if (visibleTabs.length === 0) { return ( -
- -
-

화면 데이터를 불러올 수 없습니다

-
+
+

탭이 없습니다

); - }; - - // 빈 탭 목록 - if (tabs.length === 0) { - return ( - -
-

탭이 없습니다

-

상세설정에서 탭을 추가하세요

-
-
- ); } return ( -
- - - {tabs.map((tab) => ( - - {tab.label} - {tab.screenName && ( - - {tab.screenName} - - )} + + + {visibleTabs.map((tab) => ( +
+ + {tab.label} - ))} - - - {tabs.map((tab) => ( - - {renderTabContent(tab)} - + {allowCloseable && ( + + )} +
))} -
-
- ); -}; + + {visibleTabs.map((tab) => ( + + {tab.screenId ? ( + loadingScreens[tab.screenId] ? ( +
+ + 화면 로딩 중... +
+ ) : screenLayouts[tab.screenId] ? ( +
+ +
+ ) : ( +
+

화면을 불러올 수 없습니다

+
+ ) + ) : ( +
+

연결된 화면이 없습니다

+
+ )} +
+ ))} + + ); +} diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 98e53425..12e6e944 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -59,6 +59,9 @@ import "./selected-items-detail-input/SelectedItemsDetailInputRenderer"; import "./section-paper/SectionPaperRenderer"; // Section Paper (색종이 - 배경색 기반 그룹화) - Renderer 방식 import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리 기반 그룹화) - Renderer 방식 +// 🆕 탭 컴포넌트 +import "./tabs/tabs-component"; // 탭 기반 화면 전환 컴포넌트 + /** * 컴포넌트 초기화 함수 */ diff --git a/frontend/lib/registry/components/tabs/tabs-component.tsx b/frontend/lib/registry/components/tabs/tabs-component.tsx new file mode 100644 index 00000000..18fbf297 --- /dev/null +++ b/frontend/lib/registry/components/tabs/tabs-component.tsx @@ -0,0 +1,131 @@ +"use client"; + +import React from "react"; +import { ComponentRegistry } from "../../ComponentRegistry"; +import { ComponentCategory } from "@/types/component"; +import { Folder } from "lucide-react"; +import type { TabsComponent, TabItem } from "@/types/screen-management"; + +/** + * 탭 컴포넌트 정의 + * + * 여러 화면을 탭으로 구분하여 전환할 수 있는 컴포넌트 + */ +ComponentRegistry.registerComponent({ + id: "tabs-widget", + name: "탭 컴포넌트", + description: "화면을 탭으로 전환할 수 있는 컴포넌트입니다. 각 탭마다 다른 화면을 연결할 수 있습니다.", + category: ComponentCategory.LAYOUT, + webType: "text" as any, // 레이아웃 컴포넌트이므로 임시값 + component: () => null as any, // 레이아웃 컴포넌트이므로 임시값 + defaultConfig: {}, + tags: ["tabs", "navigation", "layout", "screen"], + icon: Folder, + version: "1.0.0", + + defaultSize: { + width: 800, + height: 600, + }, + + defaultProps: { + type: "tabs" as const, + tabs: [ + { + id: "tab-1", + label: "탭 1", + order: 0, + disabled: false, + }, + { + id: "tab-2", + label: "탭 2", + order: 1, + disabled: false, + }, + ] as TabItem[], + defaultTab: "tab-1", + orientation: "horizontal" as const, + variant: "default" as const, + allowCloseable: false, + persistSelection: false, + }, + + // 에디터 모드에서의 렌더링 + renderEditor: ({ component, isSelected, onClick, onDragStart, onDragEnd, children }) => { + const tabsComponent = component as TabsComponent; + const tabs = tabsComponent.tabs || []; + + return ( +
+
+
+ +
+

탭 컴포넌트

+

+ {tabs.length > 0 + ? `${tabs.length}개의 탭 (실제 화면에서 표시됩니다)` + : "탭이 없습니다. 설정 패널에서 탭을 추가하세요"} +

+ {tabs.length > 0 && ( +
+ {tabs.map((tab: TabItem, index: number) => ( + + {tab.label || `탭 ${index + 1}`} + + ))} +
+ )} +
+
+ ); + }, + + // 인터랙티브 모드에서의 렌더링 (실제 동작) + renderInteractive: ({ component }) => { + // InteractiveScreenViewer에서 TabsWidget을 사용하므로 여기서는 null 반환 + return null; + }, + + // 설정 패널 (동적 로딩) + configPanel: React.lazy(() => + import("@/components/screen/config-panels/TabsConfigPanel").then(module => ({ + default: module.TabsConfigPanel + })) + ), + + // 검증 함수 + validate: (component) => { + const tabsComponent = component as TabsComponent; + const errors: string[] = []; + + if (!tabsComponent.tabs || tabsComponent.tabs.length === 0) { + errors.push("최소 1개 이상의 탭이 필요합니다."); + } + + if (tabsComponent.tabs) { + const tabIds = tabsComponent.tabs.map((t) => t.id); + const uniqueIds = new Set(tabIds); + if (tabIds.length !== uniqueIds.size) { + errors.push("탭 ID가 중복되었습니다."); + } + } + + return { + isValid: errors.length === 0, + errors, + }; + }, +}); + +console.log("✅ 탭 컴포넌트 등록 완료"); + diff --git a/frontend/lib/utils/getComponentConfigPanel.tsx b/frontend/lib/utils/getComponentConfigPanel.tsx index 6bae6944..f921016c 100644 --- a/frontend/lib/utils/getComponentConfigPanel.tsx +++ b/frontend/lib/utils/getComponentConfigPanel.tsx @@ -42,6 +42,8 @@ const CONFIG_PANEL_MAP: Record Promise> = { // 🆕 섹션 그룹화 레이아웃 "section-card": () => import("@/lib/registry/components/section-card/SectionCardConfigPanel"), "section-paper": () => import("@/lib/registry/components/section-paper/SectionPaperConfigPanel"), + // 🆕 탭 컴포넌트 + "tabs-widget": () => import("@/components/screen/config-panels/TabsConfigPanel"), }; // ConfigPanel 컴포넌트 캐시 @@ -76,6 +78,7 @@ export async function getComponentConfigPanel(componentId: string): Promise; }; +// TabsConfigPanel 래퍼 +const TabsConfigPanelWrapper: ConfigPanelComponent = ({ config, onConfigChange }) => { + const mockComponent = { + id: "temp", + type: "tabs" as const, + tabs: config.tabs || [], + defaultTab: config.defaultTab, + orientation: config.orientation || "horizontal", + variant: config.variant || "default", + allowCloseable: config.allowCloseable || false, + persistSelection: config.persistSelection || false, + }; + + const handleUpdate = (updates: any) => { + onConfigChange({ ...config, ...updates }); + }; + + return ; +}; + // 설정 패널 이름으로 직접 매핑하는 함수 (DB의 config_panel 필드용) export const getConfigPanelComponent = (panelName: string): ConfigPanelComponent | null => { console.log(`🔧 getConfigPanelComponent 호출: panelName="${panelName}"`); @@ -128,6 +149,9 @@ export const getConfigPanelComponent = (panelName: string): ConfigPanelComponent case "DashboardConfigPanel": console.log(`🔧 DashboardConfigPanel 래퍼 컴포넌트 반환`); return DashboardConfigPanelWrapper; + case "TabsConfigPanel": + console.log(`🔧 TabsConfigPanel 래퍼 컴포넌트 반환`); + return TabsConfigPanelWrapper; default: console.warn(`🔧 알 수 없는 설정 패널: ${panelName}, 기본 설정 사용`); return null; // 기본 설정 (패널 없음) diff --git a/frontend/types/screen-management.ts b/frontend/types/screen-management.ts index 195b9b61..0320f303 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -190,6 +190,32 @@ export interface ComponentComponent extends BaseComponent { componentConfig: any; // 컴포넌트별 설정 } +/** + * 탭 아이템 인터페이스 + */ +export interface TabItem { + id: string; + label: string; + screenId?: number; // 연결된 화면 ID + screenName?: string; // 화면 이름 (표시용) + icon?: string; // 아이콘 (선택사항) + disabled?: boolean; // 비활성화 여부 + order: number; // 탭 순서 +} + +/** + * 탭 컴포넌트 + */ +export interface TabsComponent extends BaseComponent { + type: "tabs"; + tabs: TabItem[]; // 탭 목록 + defaultTab?: string; // 기본 선택 탭 ID + orientation?: "horizontal" | "vertical"; // 탭 방향 + variant?: "default" | "pills" | "underline"; // 탭 스타일 + allowCloseable?: boolean; // 탭 닫기 버튼 표시 여부 + persistSelection?: boolean; // 선택 상태 유지 (localStorage) +} + /** * 통합 컴포넌트 데이터 타입 */ @@ -200,7 +226,8 @@ export type ComponentData = | DataTableComponent | FileComponent | FlowComponent - | ComponentComponent; + | ComponentComponent + | TabsComponent; // ===== 웹타입별 설정 인터페이스 ===== @@ -791,6 +818,13 @@ export const isFlowComponent = (component: ComponentData): component is FlowComp return component.type === "flow"; }; +/** + * TabsComponent 타입 가드 + */ +export const isTabsComponent = (component: ComponentData): component is TabsComponent => { + return component.type === "tabs"; +}; + // ===== 안전한 타입 캐스팅 유틸리티 ===== /** @@ -852,3 +886,13 @@ export const asFlowComponent = (component: ComponentData): FlowComponent => { } return component; }; + +/** + * ComponentData를 TabsComponent로 안전하게 캐스팅 + */ +export const asTabsComponent = (component: ComponentData): TabsComponent => { + if (!isTabsComponent(component)) { + throw new Error(`Expected TabsComponent, got ${component.type}`); + } + return component; +}; diff --git a/frontend/types/unified-core.ts b/frontend/types/unified-core.ts index 4da2280a..f80c5c39 100644 --- a/frontend/types/unified-core.ts +++ b/frontend/types/unified-core.ts @@ -85,7 +85,8 @@ export type ComponentType = | "area" | "layout" | "flow" - | "component"; + | "component" + | "tabs"; /** * 기본 위치 정보 diff --git a/시연_시나리오.md b/시연_시나리오.md new file mode 100644 index 00000000..e67a7b09 --- /dev/null +++ b/시연_시나리오.md @@ -0,0 +1,542 @@ +# ERP-node 시스템 시연 시나리오 + +## 전체 개요 + +**주제**: 발주 → 입고 프로세스 자동화 +**목표**: 버튼 클릭 한 번으로 발주 데이터가 입고 테이블로 자동 이동하는 것을 보여주기 +**총 시간**: 10분 + +--- + +## Part 1: 테이블 2개 생성 (2분) + +### 1-1. 발주 테이블 생성 + +**화면 조작**: + +1. 테이블 관리 메뉴 접속 +2. "새 테이블" 버튼 클릭 +3. 테이블 정보 입력: + + - **테이블명(영문)**: `purchase_order` + - **테이블명(한글)**: `발주` + - **설명**: `발주 관리` + +4. 컬럼 추가 (4개): + +| 컬럼명(영문) | 컬럼명(한글) | 타입 | 필수 여부 | +| ------------ | ------------ | ------ | --------- | +| order_no | 발주번호 | text | ✓ | +| item_name | 품목명 | text | ✓ | +| quantity | 수량 | number | ✓ | +| unit_price | 단가 | number | ✓ | + +5. "테이블 생성" 버튼 클릭 +6. 성공 메시지 확인 + +--- + +### 1-2. 입고 테이블 생성 + +**화면 조작**: + +1. "새 테이블" 버튼 클릭 +2. 테이블 정보 입력: + + - **테이블명(영문)**: `receiving` + - **테이블명(한글)**: `입고` + - **설명**: `입고 관리` + +3. 컬럼 추가 (5개): + +| 컬럼명(영문) | 컬럼명(한글) | 타입 | 필수 여부 | 비고 | +| -------------- | ------------ | ------ | --------- | ------------------- | +| receiving_no | 입고번호 | text | ✓ | 자동 생성 | +| order_no | 발주번호 | text | ✓ | 발주 테이블 참조 | +| item_name | 품목명 | text | ✓ | | +| quantity | 수량 | number | ✓ | | +| receiving_date | 입고일자 | date | ✓ | 오늘 날짜 자동 입력 | + +4. "테이블 생성" 버튼 클릭 +5. 성공 메시지 확인 + +**포인트 강조**: + +- 클릭만으로 데이터베이스 테이블 자동 생성 +- Input Type에 따라 적절한 UI 자동 설정 + +--- + +## Part 2: 메뉴 2개 생성 (1분) + +### 2-1. 발주 관리 메뉴 생성 + +**화면 조작**: + +1. 관리자 메뉴 > 메뉴 관리 접속 +2. "새 메뉴 추가" 버튼 클릭 +3. 메뉴 정보 입력: + - **메뉴명**: `발주 관리` + - **순서**: 1 +4. "저장" 클릭 + +--- + +### 2-2. 입고 관리 메뉴 생성 + +**화면 조작**: + +1. "새 메뉴 추가" 버튼 클릭 +2. 메뉴 정보 입력: + - **메뉴명**: `입고 관리` + - **순서**: 2 +3. "저장" 클릭 +4. 좌측 메뉴바에서 새로 생성된 메뉴 2개 확인 + +**포인트 강조**: + +- URL 기반 자동 라우팅 +- 아이콘으로 직관적인 메뉴 구성 + +--- + +## Part 3: 플로우 생성 (2분) + +### 3-1. 플로우 생성 + +**화면 조작**: + +1. 제어 관리 메뉴 접속 +2. "새 플로우 생성" 버튼 클릭 +3. 플로우 생성 모달에서 입력: + - **플로우명**: `발주-입고 프로세스` + - **설명**: `발주에서 입고로 데이터 자동 이동` +4. "생성" 버튼 클릭 +5. 플로우 편집 화면(캔버스)으로 자동 이동 + +--- + +### 3-2. 노드 구성 + +**내레이션**: +"플로우는 소스 테이블과 액션 노드로 구성합니다. 발주 테이블에서 입고 테이블로 데이터를 INSERT하는 구조입니다." + +**노드 1: 발주 테이블 소스** + +**화면 조작**: + +1. 캔버스 좌측 팔레트에서 "테이블 소스" 에서 테이블 노드 드래그 +2. 캔버스에 드롭 +3. 생성된 노드 클릭 → 우측 속성 패널 표시 +4. 속성 패널에서 설정: + - **노드명**: `발주 테이블` + - **소스 테이블**: `purchase_order` 선택 + - **색상**: 파란색 (#3b82f6) +5. 데이터 소스 타입 컨텍스트 데이터 선택 + +--- + +**노드 2: 입고 INSERT 액션** + +**화면 조작**: + +1. 좌측 팔레트에서 "INSERT 액션" 노드 드래그 +2. 캔버스의 발주 테이블 오른쪽에 드롭 +3. 노드 클릭 → 우측 속성 패널 표시 +4. 속성 패널에서 설정: + - **노드명**: `입고 처리` + - **타겟 테이블**: `receiving`(입고) 선택 + - **액션 타입**: INSERT + - **색상**: 초록색 (#22c55e) + +--- + +### 3-3. 노드 연결 및 필드 매핑 + +**내레이션**: +"소스 테이블과 액션 노드를 연결하고 필드 매핑을 설정합니다." + +**화면 조작**: + +1. "발주 테이블" 노드의 오른쪽 연결점(핸들)에 마우스 올리기 +2. 연결점에서 드래그 시작 +3. "입고 처리" 노드의 왼쪽 연결점으로 드래그 +4. 연결선 자동 생성됨 + +5. "입고 처리" (INSERT 액션) 노드 클릭 +6. 우측 속성 패널에서 "필드 매핑" 탭 선택 +7. 필드 매핑 설정: + +| 소스 필드 (발주) | 타겟 필드 (입고) | 비고 | +| ---------------- | ---------------- | ------------- | +| order_no | order_no | 발주번호 복사 | +| item_name | item_name | 품목명 복사 | +| quantity | quantity | 수량 복사 | +| (자동 생성) | receiving_no | 입고번호 | +| (현재 날짜) | receiving_date | 입고일자 | + +8. 우측 상단 "저장" 버튼 클릭 +9. 성공 메시지: "플로우가 저장되었습니다" + +**포인트 강조**: + +- 테이블 소스 → 액션 노드 구조 +- 필드 매핑으로 데이터 자동 복사 설정 +- INSERT 액션으로 새 테이블에 데이터 생성 + +**참고**: + +- `receiving_no`와 `receiving_date`는 자동 생성 필드로 설정 +- 같은 이름의 필드는 자동 매핑됨 + +--- + +## Part 4: 화면 설계 (2분) + +### 4-1. 발주 관리 화면 설계 + +**화면 조작**: + +1. 화면 관리 > 화면 설계 메뉴 접속 +2. "발주 관리" 메뉴의 "화면 할당" 클릭 +3. "새 화면 생성" 선택 +4. 테이블 선택: `purchase_order` (발주) + +**화면 구성**: + +**전체: 테이블 리스트 컴포넌트 (CRUD 기능 포함)** + +1. 컴포넌트 팔레트에서 "테이블 리스트" 드래그 +2. 테이블 설정: + - **연결 테이블**: `purchase_order` + - **컬럼 표시**: + +| 컬럼 | 표시 | 정렬 가능 | 너비 | +| ---------- | ---- | --------- | ----- | +| order_no | ✓ | ✓ | 150px | +| item_name | ✓ | ✓ | 200px | +| quantity | ✓ | | 100px | +| unit_price | ✓ | | 120px | + +3. 기능 설정: + + - **조회**: 활성화 + - **등록**: 활성화 (신규 버튼) + - **수정**: 활성화 + - **삭제**: 활성화 + - **페이징**: 10개씩 + - **입고 처리 버튼**: 커스텀 액션 추가 + +4. 입고 처리 버튼 설정: + + - **버튼 라벨**: `입고 처리` + - **버튼 위치**: 행 액션 + - **연결 플로우**: `발주-입고 프로세스` 선택 + - **플로우 액션**: `입고 처리` (Connection에서 정의한 액션) + +5. "화면 저장" 버튼 클릭 + +--- + +### 4-2. 입고 관리 화면 설계 + +**화면 조작**: + +1. "입고 관리" 메뉴의 "화면 할당" 클릭 +2. "새 화면 생성" 선택 +3. 테이블 선택: `receiving` (입고) + +**화면 구성**: + +**전체: 테이블 리스트 컴포넌트 (조회 전용)** + +1. 컴포넌트 팔레트에서 "테이블 리스트" 드래그 +2. 테이블 설정: + - **연결 테이블**: `receiving` + - **컬럼 표시**: + +| 컬럼 | 표시 | 정렬 가능 | 너비 | +| -------------- | ---- | --------- | ----- | +| receiving_no | ✓ | ✓ | 150px | +| order_no | ✓ | ✓ | 150px | +| item_name | ✓ | ✓ | 200px | +| quantity | ✓ | | 100px | +| receiving_date | ✓ | ✓ | 120px | + +3. 기능 설정: + + - **조회**: 활성화 + - **등록**: 비활성화 (플로우로만 데이터 생성) + - **수정**: 비활성화 + - **삭제**: 비활성화 + - **페이징**: 20개씩 + - **정렬**: 입고일자 내림차순 + +4. "화면 저장" 버튼 클릭 + +**포인트 강조**: + +- 테이블 리스트 컴포넌트로 CRUD 자동 구성 +- 발주 화면에는 "입고 처리" 버튼으로 플로우 실행 +- 입고 화면은 조회 전용 (플로우로만 데이터 생성) + +--- + +## Part 5: 실행 및 동작 확인 (3분) + +### 5-1. 발주 등록 + +**화면 조작**: + +1. 좌측 메뉴에서 "발주 관리" 클릭 +2. 화면 구성 확인: + + - 테이블 리스트 컴포넌트 (빈 테이블) + - 상단에 "신규" 버튼 + +3. "신규" 버튼 클릭 +4. 입력 모달 창 표시 +5. 데이터 입력: + + - **발주번호**: PO-001 + - **품목명**: 노트북 (LG Gram 17) + - **수량**: 10 + - **단가**: 2,000,000 + +6. "저장" 버튼 클릭 +7. 성공 메시지 확인: "저장되었습니다" + +8. 결과 확인: + - 테이블에 새 행 추가됨 + - 행 우측에 "입고 처리" 버튼 표시됨 + +**추가 발주 등록 (옵션)**: + +9. "신규" 버튼 클릭 +10. 2번째 데이터 입력: + +- **발주번호**: PO-002 +- **품목명**: 모니터 (삼성 27인치) +- **수량**: 5 +- **단가**: 300,000 + +11. "저장" 클릭 +12. 테이블에 2개 행 확인 + +--- + +### 5-2. 입고 처리 실행 ⭐ (핵심 데모) + +**화면 조작**: + +1. 발주 테이블에서 첫 번째 행(PO-001 노트북) 확인 +2. 행 우측의 **"입고 처리"** 버튼 클릭 +3. 확인 대화상자: + + - "이 발주를 입고 처리하시겠습니까?" + - **"예"** 클릭 + +4. 성공 메시지: "입고 처리되었습니다" + +--- + +### 5-3. 자동 데이터 이동 확인 ⭐⭐⭐ + +**실시간 변화 확인**: + +**1) 발주 테이블 자동 업데이트** + +- PO-001 항목이 테이블에서 **즉시 사라짐** +- PO-002만 남아있음 (추가로 등록했다면) + +**2) 입고 관리 화면으로 이동** + +1. 좌측 메뉴에서 **"입고 관리"** 클릭 +2. 입고 테이블에 **자동으로 데이터 생성됨**: + +| 입고번호 | 발주번호 | 품목명 | 수량 | 입고일자 | +| ---------------- | -------- | ------------------- | ---- | ---------- | +| RCV-20250124-001 | PO-001 | 노트북 (LG Gram 17) | 10 | 2025-01-24 | + +3. **데이터 자동 생성 확인**: + - 입고번호: 자동 생성됨 (RCV-20250124-001) + - 발주번호: PO-001 복사됨 + - 품목명: 노트북 (LG Gram 17) 복사됨 + - 수량: 10 복사됨 + - 입고일자: 오늘 날짜 자동 입력 + +**3) 다시 발주 관리로 돌아가기** + +1. 좌측 메뉴 "발주 관리" 클릭 +2. PO-001은 여전히 사라진 상태 확인 +3. PO-002만 남아있음 + +**4) 제어 관리에서 확인** + +1. 제어 관리 > 플로우 목록 접속 +2. "발주-입고 프로세스" 클릭 +3. 플로우 현황 확인: + - **발주 완료**: 1건 (PO-002) + - **입고 완료**: 1건 (PO-001) + +--- + +### 5-4. 추가 입고 처리 (옵션) + +**화면 조작**: + +1. "발주 관리" 화면에서 PO-002 (모니터) 선택 +2. "입고 처리" 버튼 클릭 +3. 확인 후 입고 완료 + +4. 최종 확인: + - 발주 관리: 0건 (모두 입고 처리됨) + - 입고 관리: 2건 (PO-001, PO-002) + - 제어 관리 플로우: + - **발주 완료: 0건** + - **입고 완료: 2건** + +--- + +## 시연 마무리 (30초) + +**화면 정리 및 요약**: + +**보여준 핵심 기능**: + +- ✅ **코딩 없이 테이블 생성**: 클릭만으로 DB 테이블 자동 생성 +- ✅ **시각적 플로우 구성**: 드래그앤드롭으로 업무 흐름 설계 +- ✅ **자동 데이터 이동**: 버튼 클릭 한 번으로 테이블 간 데이터 자동 복사 및 이동 +- ✅ **실시간 상태 추적**: 제어 관리에서 플로우 현황 확인 +- ✅ **빠른 화면 구성**: 테이블 리스트 컴포넌트로 CRUD 자동 완성 + +**마지막 화면**: + +- 대시보드 또는 시스템 전체 구성도 +- 로고 및 연락처 정보 + +**자막**: +"개발자 없이도 비즈니스 담당자가 직접 업무 시스템을 구축할 수 있습니다." + +--- + +## 시간 배분 요약 + +| 파트 | 시간 | 주요 내용 | +| -------- | ---------- | ---------------------------- | +| Part 1 | 2분 | 테이블 2개 생성 (발주, 입고) | +| Part 2 | 1분 | 메뉴 2개 생성 | +| Part 3 | 2분 | 플로우 구성 및 연결 설정 | +| Part 4 | 2분 | 화면 2개 디자인 | +| Part 5 | 3분 | 발주 등록 → 입고 처리 실행 | +| 마무리 | 0.5분 | 요약 및 정리 | +| **합계** | **10.5분** | | + +--- + +## 시연 준비사항 + +### 사전 설정 + +1. 개발 서버 실행: `http://localhost:9771` +2. 로그인 정보: `wace / qlalfqjsgh11` +3. 데이터베이스 초기화 (테스트 데이터 제거) + +### 녹화 설정 + +- **해상도**: 1920x1080 (Full HD) +- **프레임**: 30fps +- **마우스 효과**: 클릭 하이라이트 활성화 +- **배경음악**: 부드러운 BGM (옵션) +- **자막**: 주요 포인트마다 표시 + +### 시연 팁 + +- 각 단계마다 2-3초 대기 (시청자 이해 시간) +- 중요한 버튼 클릭 시 화면 확대 효과 +- 플로우 위젯 카운트 변화는 빨간색 박스로 강조 +- 성공 메시지는 충분히 길게 보여주기 (최소 3초) +- 입고 테이블에 데이터 들어오는 순간 화면 확대 + +--- + +## 시연 스크립트 (참고용) + +### 오프닝 (10초) + +"안녕하세요. 오늘은 ERP-node 시스템의 핵심 기능을 시연하겠습니다. 발주에서 입고까지 데이터가 자동으로 이동하는 과정을 보여드립니다." + +### Part 1 (2분) + +"먼저 발주와 입고를 관리할 테이블을 생성합니다. 코딩 없이 클릭만으로 데이터베이스 테이블이 자동으로 만들어집니다." + +### Part 2 (1분) + +"이제 사용자가 접근할 메뉴를 추가합니다. URL만 지정하면 자동으로 라우팅이 연결됩니다." + +### Part 3 (2분) + +"발주에서 입고로 데이터가 이동하는 흐름을 제어 플로우로 정의합니다. 두 테이블을 연결하고 버튼을 누르면 자동으로 데이터가 복사 및 이동하도록 설정합니다." + +### Part 4 (2분) + +"실제 사용자가 볼 화면을 디자인합니다. 테이블 리스트 컴포넌트를 사용하면 CRUD 기능이 자동으로 구성되고, 각 행에 입고 처리 버튼을 추가하여 플로우를 실행할 수 있습니다." + +### Part 5 (3분) + +"이제 실제로 작동하는 모습을 보겠습니다. 발주를 등록하고... (데이터 입력) 저장하면 테이블에 추가됩니다. 입고 처리 버튼을 누르면... (클릭) 발주 테이블에서 데이터가 사라지고 입고 테이블에 자동으로 생성됩니다!" + +### 클로징 (10초) + +"이처럼 ERP-node는 코딩 없이 비즈니스 로직을 구현할 수 있는 노코드 플랫폼입니다. 감사합니다." + +--- + +## 체크리스트 + +### 시연 전 + +- [ ] 개발 서버 실행 확인 +- [ ] 로그인 테스트 +- [ ] 기존 테스트 데이터 삭제 +- [ ] 브라우저 창 크기 조정 (1920x1080) +- [ ] 녹화 프로그램 설정 +- [ ] 마이크 테스트 +- [ ] 시나리오 1회 이상 리허설 + +### 시연 중 + +- [ ] 천천히 명확하게 진행 +- [ ] 각 단계마다 결과 확인 +- [ ] 플로우 위젯 카운트 강조 +- [ ] 입고 테이블 데이터 자동 생성 강조 + +### 시연 후 + +- [ ] 녹화 파일 확인 +- [ ] 자막 추가 (필요 시) +- [ ] 배경음악 삽입 (옵션) +- [ ] 인트로/아웃트로 편집 +- [ ] 최종 영상 검수 + +--- + +## 추가 개선 아이디어 + +### 시연 버전 2 (고급) + +- 발주 승인 단계 추가 (발주 요청 → 승인 → 입고) +- 입고 수량 불일치 처리 (일부 입고) +- 대시보드에서 통계 차트 표시 + +### 시연 버전 3 (실전) + +- 실제 업무: 구매 요청 → 견적 → 발주 → 입고 → 검수 +- 권한 관리: 요청자, 승인자, 구매담당자 역할 분리 +- 알림: 각 단계 변경 시 담당자에게 알림 + +--- + +**작성일**: 2025-01-24 +**버전**: 1.0 +**작성자**: AI Assistant