탭기능 중간커밋

This commit is contained in:
kjs 2025-11-24 17:24:47 +09:00
parent ddb1d4cf60
commit 00501f359c
13 changed files with 1403 additions and 191 deletions

View File

@ -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)만 가능합니다",

View File

@ -397,6 +397,37 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
);
}
// 탭 컴포넌트 처리
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 (
<div className="h-full w-full">
<TabsWidget component={tabsComponent as any} />
</div>
);
}
const { widgetType, label, placeholder, required, readonly, columnName } = comp;
const fieldName = columnName || comp.id;
const currentValue = formData[fieldName] || "";

View File

@ -554,6 +554,44 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
);
})()}
{/* 탭 컴포넌트 타입 */}
{(type === "tabs" || (type === "component" && (component as any).componentId === "tabs-widget")) &&
(() => {
const tabsComponent = component as any;
// componentConfig에서 탭 정보 가져오기
const tabs = tabsComponent.componentConfig?.tabs || tabsComponent.tabs || [];
return (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
<div className="text-center">
<div className="flex items-center justify-center">
<Folder className="h-8 w-8 text-gray-400" />
</div>
<p className="text-muted-foreground mt-2 text-sm font-medium"> </p>
<p className="text-xs text-gray-400">
{tabs.length > 0
? `${tabs.length}개의 탭 (실제 화면에서 표시됩니다)`
: "탭이 없습니다. 설정 패널에서 탭을 추가하세요"}
</p>
{tabs.length > 0 && (
<div className="mt-2 flex flex-wrap justify-center gap-1">
{tabs.map((tab: any, index: number) => (
<Badge key={tab.id} variant="outline" className="text-xs">
{tab.label || `${index + 1}`}
{tab.screenName && (
<span className="ml-1 text-[10px] text-gray-400">
({tab.screenName})
</span>
)}
</Badge>
))}
</div>
)}
</div>
</div>
);
})()}
{/* 그룹 타입 */}
{type === "group" && (
<div className="relative h-full w-full">

View File

@ -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<ScreenInfo[]>([]);
const [loading, setLoading] = useState(false);
const [localTabs, setLocalTabs] = useState<TabItem[]>(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 (
<div className="space-y-6 p-4">
<div>
<h3 className="mb-4 text-sm font-semibold"> </h3>
<div className="space-y-4">
{/* 탭 방향 */}
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={config.orientation || "horizontal"}
onValueChange={(value: "horizontal" | "vertical") =>
onChange({ ...config, orientation: value })
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="horizontal"></SelectItem>
<SelectItem value="vertical"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 탭 스타일 */}
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={config.variant || "default"}
onValueChange={(value: "default" | "pills" | "underline") =>
onChange({ ...config, variant: value })
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"></SelectItem>
<SelectItem value="pills"></SelectItem>
<SelectItem value="underline"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 선택 상태 유지 */}
<div className="flex items-center justify-between">
<div>
<Label className="text-xs sm:text-sm"> </Label>
<p className="text-muted-foreground text-[10px] sm:text-xs">
</p>
</div>
<Switch
checked={config.persistSelection || false}
onCheckedChange={(checked) => onChange({ ...config, persistSelection: checked })}
/>
</div>
{/* 탭 닫기 버튼 */}
<div className="flex items-center justify-between">
<div>
<Label className="text-xs sm:text-sm"> </Label>
<p className="text-muted-foreground text-[10px] sm:text-xs">
</p>
</div>
<Switch
checked={config.allowCloseable || false}
onCheckedChange={(checked) => onChange({ ...config, allowCloseable: checked })}
/>
</div>
</div>
</div>
<div>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold"> </h3>
<Button
onClick={handleAddTab}
size="sm"
variant="outline"
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Plus className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</div>
{localTabs.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center">
<p className="text-muted-foreground text-sm"> </p>
<p className="text-muted-foreground mt-1 text-xs">
</p>
</div>
) : (
<div className="space-y-3">
{localTabs.map((tab, index) => (
<div
key={tab.id}
className="rounded-lg border bg-card p-3 shadow-sm"
>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground" />
<span className="text-xs font-medium"> {index + 1}</span>
</div>
<div className="flex items-center gap-1">
<Button
onClick={() => handleMoveTab(tab.id, "up")}
disabled={index === 0}
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
>
</Button>
<Button
onClick={() => handleMoveTab(tab.id, "down")}
disabled={index === localTabs.length - 1}
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
>
</Button>
<Button
onClick={() => handleRemoveTab(tab.id)}
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
<div className="space-y-3">
{/* 탭 라벨 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={tab.label}
onChange={(e) => handleLabelChange(tab.id, e.target.value)}
placeholder="탭 이름"
className="h-8 text-xs sm:h-9 sm:text-sm"
/>
</div>
{/* 화면 선택 */}
<div>
<Label className="text-xs"> </Label>
{loading ? (
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="text-muted-foreground text-xs"> ...</span>
</div>
) : (
<ScreenSelectCombobox
screens={screens}
selectedScreenId={tab.screenId}
onSelect={(screenId, screenName) =>
handleScreenSelect(tab.id, screenId, screenName)
}
/>
)}
{tab.screenName && (
<p className="text-muted-foreground mt-1 text-xs">
: {tab.screenName}
</p>
)}
</div>
{/* 비활성화 */}
<div className="flex items-center justify-between">
<Label className="text-xs"></Label>
<Switch
checked={tab.disabled || false}
onCheckedChange={(checked) => handleDisabledToggle(tab.id, checked)}
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
// 화면 선택 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-8 w-full justify-between text-xs sm:h-9 sm:text-sm"
>
{selectedScreen ? selectedScreen.screenName : "화면 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50 sm:h-4 sm:w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="화면 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm">
.
</CommandEmpty>
<CommandGroup>
{screens.map((screen) => (
<CommandItem
key={screen.screenId}
value={screen.screenName}
onSelect={() => {
onSelect(screen.screenId, screen.screenName);
setOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-3 w-3 sm:h-4 sm:w-4",
selectedScreenId === screen.screenId ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.screenName}</span>
<span className="text-muted-foreground text-[10px]">
: {screen.screenCode} | : {screen.tableName}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -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<UnifiedPropertiesPanelProps> = ({
<Settings className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{definition.name} </h3>
</div>
<ConfigPanelComponent
config={config}
onChange={handleConfigChange}
tables={tables} // 테이블 정보 전달
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
/>
<Suspense fallback={
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground"> ...</div>
</div>
}>
<ConfigPanelComponent
config={config}
onChange={handleConfigChange}
tables={tables} // 테이블 정보 전달
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
/>
</Suspense>
</div>
);
};

View File

@ -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<TabsWidgetProps> = ({ 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<string>(defaultTab || tabs[0]?.id || "");
const [loadedScreens, setLoadedScreens] = useState<Record<string, any>>({});
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<string>(getInitialTab());
const [visibleTabs, setVisibleTabs] = useState<TabItem[]>(tabs);
const [loadingScreens, setLoadingScreens] = useState<Record<string, boolean>>({});
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
const [screenLayouts, setScreenLayouts] = useState<Record<number, any>>({});
// 탭 변경 시 화면 로드
// 컴포넌트 목록 변경 시 동기
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 (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground text-sm"> ...</p>
</div>
);
// 해당 탭의 화면 로드
const tab = visibleTabs.find((t) => t.id === tabId);
if (tab && tab.screenId && !screenLayouts[tab.screenId]) {
loadScreenLayout(tab.screenId);
}
};
// 에러 발생
if (error) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<FileQuestion className="h-12 w-12 text-destructive" />
<div className="text-center">
<p className="mb-2 font-medium text-destructive"> </p>
<p className="text-muted-foreground text-sm">{error}</p>
</div>
</div>
);
// 탭 닫기 핸들러
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 (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<FileQuestion className="text-muted-foreground h-12 w-12" />
<div className="text-center">
<p className="text-muted-foreground mb-2 text-sm"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
</div>
);
}
// 화면 렌더링 - 원본 화면의 모든 컴포넌트를 그대로 렌더링
if (screenData && screenData.layout && screenData.layout.components) {
const components = screenData.layout.components;
const screenResolution = screenData.layout.screenResolution || { width: 1920, height: 1080 };
return (
<div className="bg-white" style={{ width: `${screenResolution.width}px`, height: '100%' }}>
<div className="relative h-full">
{components.map((comp) => (
<InteractiveScreenViewerDynamic
key={comp.id}
component={comp}
allComponents={components}
screenInfo={{ id: tab.screenId }}
/>
))}
</div>
</div>
);
}
// 탭 스타일 클래스
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 (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<FileQuestion className="text-muted-foreground h-12 w-12" />
<div className="text-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
<p className="text-muted-foreground text-sm"> </p>
</div>
);
};
// 빈 탭 목록
if (tabs.length === 0) {
return (
<Card className="flex h-full w-full items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground text-sm"> </p>
<p className="text-xs text-gray-400"> </p>
</div>
</Card>
);
}
return (
<div className="h-full w-full overflow-auto">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
orientation={orientation}
className="flex h-full w-full flex-col"
>
<TabsList className={orientation === "horizontal" ? "justify-start shrink-0" : "flex-col shrink-0"}>
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
disabled={tab.disabled}
className={orientation === "horizontal" ? "" : "w-full justify-start"}
>
<span>{tab.label}</span>
{tab.screenName && (
<Badge variant="secondary" className="ml-2 text-[10px]">
{tab.screenName}
</Badge>
)}
<Tabs
value={selectedTab}
onValueChange={handleTabChange}
orientation={orientation}
className={className}
style={style}
>
<TabsList className={getTabsListClass()}>
{visibleTabs.map((tab) => (
<div key={tab.id} className="relative">
<TabsTrigger value={tab.id} disabled={tab.disabled} className="relative pr-8">
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent
key={tab.id}
value={tab.id}
className="flex-1 mt-0 data-[state=inactive]:hidden"
>
{renderTabContent(tab)}
</TabsContent>
{allowCloseable && (
<Button
onClick={(e) => handleCloseTab(tab.id, e)}
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 p-0 hover:bg-destructive/10"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
))}
</Tabs>
</div>
);
};
</TabsList>
{visibleTabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="h-full w-full">
{tab.screenId ? (
loadingScreens[tab.screenId] ? (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-muted-foreground ml-2"> ...</span>
</div>
) : screenLayouts[tab.screenId] ? (
<div className="h-full w-full overflow-auto">
<InteractiveScreenViewerDynamic
component={screenLayouts[tab.screenId].components[0]}
allComponents={screenLayouts[tab.screenId].components}
/>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
)
) : (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
<p className="text-muted-foreground text-sm"> </p>
</div>
)}
</TabsContent>
))}
</Tabs>
);
}

View File

@ -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"; // 탭 기반 화면 전환 컴포넌트
/**
*
*/

View File

@ -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 (
<div
className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50"
onClick={onClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<div className="text-center">
<div className="flex items-center justify-center">
<Folder className="h-8 w-8 text-gray-400" />
</div>
<p className="text-muted-foreground mt-2 text-sm font-medium"> </p>
<p className="text-xs text-gray-400">
{tabs.length > 0
? `${tabs.length}개의 탭 (실제 화면에서 표시됩니다)`
: "탭이 없습니다. 설정 패널에서 탭을 추가하세요"}
</p>
{tabs.length > 0 && (
<div className="mt-2 flex flex-wrap justify-center gap-1">
{tabs.map((tab: TabItem, index: number) => (
<span
key={tab.id}
className="rounded-md border bg-white px-2 py-1 text-xs"
>
{tab.label || `${index + 1}`}
</span>
))}
</div>
)}
</div>
</div>
);
},
// 인터랙티브 모드에서의 렌더링 (실제 동작)
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("✅ 탭 컴포넌트 등록 완료");

View File

@ -42,6 +42,8 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
// 🆕 섹션 그룹화 레이아웃
"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<Reac
module.ButtonConfigPanel || // button-primary의 export명
module.SectionCardConfigPanel || // section-card의 export명
module.SectionPaperConfigPanel || // section-paper의 export명
module.TabsConfigPanel || // tabs-widget의 export명
module.default;
if (!ConfigPanelComponent) {

View File

@ -14,6 +14,7 @@ import { RatingTypeConfigPanel } from "@/components/screen/panels/webtype-config
import { ButtonConfigPanel as OriginalButtonConfigPanel } from "@/components/screen/config-panels/ButtonConfigPanel";
import { CardConfigPanel } from "@/components/screen/config-panels/CardConfigPanel";
import { DashboardConfigPanel } from "@/components/screen/config-panels/DashboardConfigPanel";
import { TabsConfigPanel } from "@/components/screen/config-panels/TabsConfigPanel";
// 설정 패널 컴포넌트 타입
export type ConfigPanelComponent = React.ComponentType<{
@ -83,6 +84,26 @@ const DashboardConfigPanelWrapper: ConfigPanelComponent = ({ config, onConfigCha
return <DashboardConfigPanel component={mockComponent as any} onUpdateProperty={handleUpdateProperty} />;
};
// 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 <TabsConfigPanel component={mockComponent as any} onUpdate={handleUpdate} />;
};
// 설정 패널 이름으로 직접 매핑하는 함수 (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; // 기본 설정 (패널 없음)

View File

@ -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;
};

View File

@ -85,7 +85,8 @@ export type ComponentType =
| "area"
| "layout"
| "flow"
| "component";
| "component"
| "tabs";
/**
*

542
시연_시나리오.md Normal file
View File

@ -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