탭기능 중간커밋
This commit is contained in:
parent
ddb1d4cf60
commit
00501f359c
|
|
@ -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)만 가능합니다",
|
||||
|
|
|
|||
|
|
@ -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] || "";
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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,6 +341,11 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<Settings className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||
</div>
|
||||
<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}
|
||||
|
|
@ -349,6 +354,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
||||
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
export function TabsWidget({ component, className, style }: TabsWidgetProps) {
|
||||
const {
|
||||
tabs = [],
|
||||
defaultTab,
|
||||
orientation = "horizontal",
|
||||
variant = "default",
|
||||
allowCloseable = false,
|
||||
persistSelection = false,
|
||||
} = component;
|
||||
|
||||
// console.log("🔍 TabsWidget 렌더링:", {
|
||||
// component,
|
||||
// componentConfig: (component as any).componentConfig,
|
||||
// tabs,
|
||||
// tabsLength: tabs.length
|
||||
// });
|
||||
console.log("🎨 TabsWidget 렌더링:", {
|
||||
componentId: component.id,
|
||||
tabs,
|
||||
tabsLength: tabs.length,
|
||||
component,
|
||||
});
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(defaultTab || tabs[0]?.id || "");
|
||||
const [loadedScreens, setLoadedScreens] = useState<Record<string, any>>({});
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// 화면 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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
// 빈 탭 목록
|
||||
if (tabs.length === 0) {
|
||||
// 탭 닫기 핸들러
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// 탭 스타일 클래스
|
||||
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 (
|
||||
<Card className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<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>
|
||||
<p className="text-xs text-gray-400">상세설정에서 탭을 추가하세요</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
value={selectedTab}
|
||||
onValueChange={handleTabChange}
|
||||
orientation={orientation}
|
||||
className="flex h-full w-full flex-col"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
{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>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="flex-1 mt-0 data-[state=inactive]:hidden"
|
||||
>
|
||||
{renderTabContent(tab)}
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"; // 탭 기반 화면 전환 컴포넌트
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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("✅ 탭 컴포넌트 등록 완료");
|
||||
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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; // 기본 설정 (패널 없음)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -85,7 +85,8 @@ export type ComponentType =
|
|||
| "area"
|
||||
| "layout"
|
||||
| "flow"
|
||||
| "component";
|
||||
| "component"
|
||||
| "tabs";
|
||||
|
||||
/**
|
||||
* 기본 위치 정보
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue