feat(pop): POP 화면 복사 기능 구현 (단일 화면 + 카테고리 일괄 복사)
최고관리자의 POP 화면을 다른 회사로 복사하는 기능 추가. 화면 단위 복사와 카테고리(그룹) 단위 일괄 복사를 모두 지원하며, 화면 간 참조(cartScreenId, sourceScreenId 등)를 자동 치환하고 카테고리 구조까지 대상 회사에 재생성한다. [백엔드] - analyzePopScreenLinks: POP 레이아웃 내 다른 화면 참조 스캔 - deployPopScreens: screen_definitions + screen_layouts_pop 복사, screenId 참조 치환, numberingRuleId 초기화, 그룹 구조 복사 - POP 그룹 조회 쿼리 개선 (screen_layouts_pop JOIN으로 실제 POP 화면만 카운트) - ensurePopRootGroup 최고관리자 전용으로 변경 [프론트엔드] - PopDeployModal: 단일 화면/카테고리 일괄 복사 모달 (대상 회사 선택, 연결 화면 감지, 카테고리 트리 미리보기) - PopCategoryTree: 그룹 컨텍스트 메뉴에 '카테고리 복사' 추가, 하위 그룹 화면까지 재귀 수집 - PopScreenSettingModal: UI 간소화 및 화면명 저장 기능 보완 - screenApi: analyzePopScreenLinks, deployPopScreens 클라이언트 함수 추가
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
Settings,
|
||||
LayoutGrid,
|
||||
GitBranch,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { PopDesigner } from "@/components/pop/designer";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
PopScreenPreview,
|
||||
PopScreenFlowView,
|
||||
PopScreenSettingModal,
|
||||
PopDeployModal,
|
||||
} from "@/components/pop/management";
|
||||
import { PopScreenGroup } from "@/lib/api/popScreenGroup";
|
||||
|
||||
@@ -62,6 +64,10 @@ export default function PopScreenManagementPage() {
|
||||
// UI 상태
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
|
||||
const [isDeployModalOpen, setIsDeployModalOpen] = useState(false);
|
||||
const [deployGroupScreens, setDeployGroupScreens] = useState<ScreenDefinition[]>([]);
|
||||
const [deployGroupName, setDeployGroupName] = useState("");
|
||||
const [deployGroupInfo, setDeployGroupInfo] = useState<any>(undefined);
|
||||
const [devicePreview, setDevicePreview] = useState<DevicePreview>("tablet");
|
||||
const [rightPanelView, setRightPanelView] = useState<RightPanelView>("preview");
|
||||
|
||||
@@ -235,6 +241,21 @@ export default function PopScreenManagementPage() {
|
||||
<Button variant="outline" size="icon" onClick={loadScreens}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
{selectedScreen && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeployGroupScreens([]);
|
||||
setDeployGroupName("");
|
||||
setDeployGroupInfo(undefined);
|
||||
setIsDeployModalOpen(true);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
복사
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
새 POP 화면
|
||||
@@ -290,6 +311,24 @@ export default function PopScreenManagementPage() {
|
||||
selectedScreen={selectedScreen}
|
||||
onScreenSelect={handleScreenSelect}
|
||||
onScreenDesign={handleDesignScreen}
|
||||
onScreenSettings={(screen) => {
|
||||
setSelectedScreen(screen);
|
||||
setIsSettingModalOpen(true);
|
||||
}}
|
||||
onScreenCopy={(screen) => {
|
||||
setSelectedScreen(screen);
|
||||
setDeployGroupScreens([]);
|
||||
setDeployGroupName("");
|
||||
setDeployGroupInfo(undefined);
|
||||
setIsDeployModalOpen(true);
|
||||
}}
|
||||
onGroupCopy={(groupScreensList, groupName, gInfo) => {
|
||||
setSelectedScreen(null);
|
||||
setDeployGroupScreens(groupScreensList);
|
||||
setDeployGroupName(groupName);
|
||||
setDeployGroupInfo(gInfo);
|
||||
setIsDeployModalOpen(true);
|
||||
}}
|
||||
onGroupSelect={handleGroupSelect}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
@@ -383,6 +422,18 @@ export default function PopScreenManagementPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* POP 화면 배포 모달 */}
|
||||
<PopDeployModal
|
||||
open={isDeployModalOpen}
|
||||
onOpenChange={setIsDeployModalOpen}
|
||||
screen={selectedScreen}
|
||||
groupScreens={deployGroupScreens.length > 0 ? deployGroupScreens : undefined}
|
||||
groupName={deployGroupName || undefined}
|
||||
groupInfo={deployGroupInfo}
|
||||
allScreens={screens}
|
||||
onDeployed={loadScreens}
|
||||
/>
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Search,
|
||||
Settings,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -68,11 +70,27 @@ import {
|
||||
// 타입 정의
|
||||
// ============================================================
|
||||
|
||||
export interface GroupCopyInfo {
|
||||
sourceGroupId: number;
|
||||
groupName: string;
|
||||
groupCode: string;
|
||||
screenIds: number[];
|
||||
children: Array<{
|
||||
sourceGroupId: number;
|
||||
groupName: string;
|
||||
groupCode: string;
|
||||
screenIds: number[];
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PopCategoryTreeProps {
|
||||
screens: ScreenDefinition[]; // POP 레이아웃이 있는 화면 목록
|
||||
selectedScreen: ScreenDefinition | null;
|
||||
onScreenSelect: (screen: ScreenDefinition) => void;
|
||||
onScreenDesign: (screen: ScreenDefinition) => void;
|
||||
onScreenSettings?: (screen: ScreenDefinition) => void;
|
||||
onScreenCopy?: (screen: ScreenDefinition) => void;
|
||||
onGroupCopy?: (screens: ScreenDefinition[], groupName: string, groupInfo?: GroupCopyInfo) => void;
|
||||
onGroupSelect?: (group: PopScreenGroup | null) => void;
|
||||
searchTerm?: string;
|
||||
}
|
||||
@@ -87,6 +105,8 @@ interface TreeNodeProps {
|
||||
onGroupSelect: (group: PopScreenGroup) => void;
|
||||
onScreenSelect: (screen: ScreenDefinition) => void;
|
||||
onScreenDesign: (screen: ScreenDefinition) => void;
|
||||
onScreenSettings: (screen: ScreenDefinition) => void;
|
||||
onScreenCopy: (screen: ScreenDefinition) => void;
|
||||
onEditGroup: (group: PopScreenGroup) => void;
|
||||
onDeleteGroup: (group: PopScreenGroup) => void;
|
||||
onAddSubGroup: (parentGroup: PopScreenGroup) => void;
|
||||
@@ -101,6 +121,7 @@ interface TreeNodeProps {
|
||||
onMoveScreenUp: (screen: ScreenDefinition, groupId: number) => void;
|
||||
onMoveScreenDown: (screen: ScreenDefinition, groupId: number) => void;
|
||||
onDeleteScreen: (screen: ScreenDefinition) => void;
|
||||
onGroupCopy: (screens: ScreenDefinition[], groupName: string, groupInfo?: GroupCopyInfo) => void;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -118,6 +139,7 @@ function TreeNode({
|
||||
onMoveScreenUp,
|
||||
onMoveScreenDown,
|
||||
onDeleteScreen,
|
||||
onGroupCopy,
|
||||
expandedGroups,
|
||||
onToggle,
|
||||
selectedGroupId,
|
||||
@@ -125,6 +147,8 @@ function TreeNode({
|
||||
onGroupSelect,
|
||||
onScreenSelect,
|
||||
onScreenDesign,
|
||||
onScreenSettings,
|
||||
onScreenCopy,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
onAddSubGroup,
|
||||
@@ -134,7 +158,7 @@ function TreeNode({
|
||||
const hasChildren = (group.children && group.children.length > 0) || (group.screens && group.screens.length > 0);
|
||||
const isSelected = selectedGroupId === group.id;
|
||||
|
||||
// 그룹에 연결된 화면 목록
|
||||
// 그룹에 직접 연결된 화면 목록
|
||||
const groupScreens = useMemo(() => {
|
||||
if (!group.screens) return [];
|
||||
return group.screens
|
||||
@@ -142,6 +166,20 @@ function TreeNode({
|
||||
.filter((s): s is ScreenDefinition => s !== undefined);
|
||||
}, [group.screens, screensMap]);
|
||||
|
||||
// 하위 그룹 포함 전체 화면 (복사용)
|
||||
const allDescendantScreens = useMemo(() => {
|
||||
const collected = new Map<number, ScreenDefinition>();
|
||||
const collectRecursive = (g: PopScreenGroup) => {
|
||||
g.screens?.forEach((gs) => {
|
||||
const screen = screensMap.get(gs.screen_id);
|
||||
if (screen) collected.set(screen.screenId, screen);
|
||||
});
|
||||
g.children?.forEach(collectRecursive);
|
||||
};
|
||||
collectRecursive(group);
|
||||
return Array.from(collected.values());
|
||||
}, [group, screensMap]);
|
||||
|
||||
// 루트 레벨(POP 화면)인지 확인
|
||||
const isRootLevel = level === 0;
|
||||
|
||||
@@ -193,8 +231,15 @@ function TreeNode({
|
||||
<Folder className={cn("h-4 w-4 shrink-0", isRootLevel ? "text-orange-500" : "text-amber-500")} />
|
||||
)}
|
||||
|
||||
{/* 그룹명 - 루트는 볼드체 */}
|
||||
<span className={cn("flex-1 text-sm truncate", isRootLevel && "font-semibold")}>{group.group_name}</span>
|
||||
{/* 그룹명 - 루트는 볼드체 + 회사코드 표시 */}
|
||||
<span className={cn("flex-1 text-sm truncate", isRootLevel && "font-semibold")}>
|
||||
{group.group_name}
|
||||
{isRootLevel && group.company_code && (
|
||||
<span className="ml-1 text-[10px] text-muted-foreground font-normal">
|
||||
{group.company_code === "*" ? "(전체)" : `(${group.company_code})`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* 화면 수 배지 */}
|
||||
{group.screen_count && group.screen_count > 0 && (
|
||||
@@ -224,6 +269,34 @@ function TreeNode({
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</DropdownMenuItem>
|
||||
{allDescendantScreens.length > 0 && (
|
||||
<DropdownMenuItem onClick={() => {
|
||||
const buildGroupInfo = (g: PopScreenGroup): GroupCopyInfo => {
|
||||
const directScreenIds = (g.screens || [])
|
||||
.map((gs) => gs.screen_id)
|
||||
.filter((id) => screensMap.has(id));
|
||||
const children = (g.children || []).map((child) => ({
|
||||
sourceGroupId: child.id,
|
||||
groupName: child.group_name,
|
||||
groupCode: child.group_code,
|
||||
screenIds: (child.screens || [])
|
||||
.map((gs) => gs.screen_id)
|
||||
.filter((id) => screensMap.has(id)),
|
||||
}));
|
||||
return {
|
||||
sourceGroupId: g.id,
|
||||
groupName: g.group_name,
|
||||
groupCode: g.group_code,
|
||||
screenIds: directScreenIds,
|
||||
children,
|
||||
};
|
||||
};
|
||||
onGroupCopy(allDescendantScreens, group.group_name, buildGroupInfo(group));
|
||||
}}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
카테고리 복사 ({allDescendantScreens.length}개 화면)
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onMoveGroupUp(group)}
|
||||
@@ -267,6 +340,8 @@ function TreeNode({
|
||||
onGroupSelect={onGroupSelect}
|
||||
onScreenSelect={onScreenSelect}
|
||||
onScreenDesign={onScreenDesign}
|
||||
onScreenSettings={onScreenSettings}
|
||||
onScreenCopy={onScreenCopy}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
onAddSubGroup={onAddSubGroup}
|
||||
@@ -279,6 +354,7 @@ function TreeNode({
|
||||
onMoveScreenUp={onMoveScreenUp}
|
||||
onMoveScreenDown={onMoveScreenDown}
|
||||
onDeleteScreen={onDeleteScreen}
|
||||
onGroupCopy={onGroupCopy}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -324,6 +400,14 @@ function TreeNode({
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
설계
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onScreenSettings(screen)}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
설정 (이름 변경)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onScreenCopy(screen)}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onMoveScreenUp(screen, group.id)}
|
||||
@@ -378,6 +462,9 @@ export function PopCategoryTree({
|
||||
selectedScreen,
|
||||
onScreenSelect,
|
||||
onScreenDesign,
|
||||
onScreenSettings,
|
||||
onScreenCopy,
|
||||
onGroupCopy,
|
||||
onGroupSelect,
|
||||
searchTerm = "",
|
||||
}: PopCategoryTreeProps) {
|
||||
@@ -887,6 +974,8 @@ export function PopCategoryTree({
|
||||
onGroupSelect={handleGroupSelect}
|
||||
onScreenSelect={onScreenSelect}
|
||||
onScreenDesign={onScreenDesign}
|
||||
onScreenSettings={onScreenSettings || (() => {})}
|
||||
onScreenCopy={onScreenCopy || (() => {})}
|
||||
onEditGroup={(g) => openGroupModal(undefined, g)}
|
||||
onDeleteGroup={(g) => {
|
||||
setDeletingGroup(g);
|
||||
@@ -902,66 +991,95 @@ export function PopCategoryTree({
|
||||
onMoveScreenUp={handleMoveScreenUp}
|
||||
onMoveScreenDown={handleMoveScreenDown}
|
||||
onDeleteScreen={handleDeleteScreen}
|
||||
onGroupCopy={onGroupCopy || (() => {})}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 미분류 화면 */}
|
||||
{/* 미분류 화면 - 회사코드별 그룹핑 */}
|
||||
{ungroupedScreens.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="text-xs text-muted-foreground px-2 mb-2">
|
||||
미분류 ({ungroupedScreens.length})
|
||||
</div>
|
||||
{ungroupedScreens.map((screen) => (
|
||||
<div
|
||||
key={`ungrouped-${screen.screenId}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-1.5 px-2 rounded-md cursor-pointer transition-colors",
|
||||
selectedScreen?.screenId === screen.screenId
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted",
|
||||
"group"
|
||||
)}
|
||||
onClick={() => onScreenSelect(screen)}
|
||||
onDoubleClick={() => onScreenDesign(screen)}
|
||||
>
|
||||
<Monitor className="h-4 w-4 text-gray-400 shrink-0" />
|
||||
<span className="flex-1 text-sm truncate">{screen.screenName}</span>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">#{screen.screenId}</span>
|
||||
|
||||
{/* 더보기 메뉴 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{(() => {
|
||||
const grouped = ungroupedScreens.reduce<Record<string, typeof ungroupedScreens>>((acc, screen) => {
|
||||
const code = screen.companyCode || "unknown";
|
||||
if (!acc[code]) acc[code] = [];
|
||||
acc[code].push(screen);
|
||||
return acc;
|
||||
}, {});
|
||||
const companyKeys = Object.keys(grouped).sort();
|
||||
const hasMultipleCompanies = companyKeys.length > 1;
|
||||
|
||||
return companyKeys.map((companyCode) => (
|
||||
<div key={`ungrouped-company-${companyCode}`}>
|
||||
{hasMultipleCompanies && (
|
||||
<div className="text-[10px] font-medium text-muted-foreground px-2 py-1 mt-1 bg-muted/50 rounded">
|
||||
{companyCode === "*" ? "최고관리자" : companyCode}
|
||||
</div>
|
||||
)}
|
||||
{grouped[companyCode].map((screen) => (
|
||||
<div
|
||||
key={`ungrouped-${screen.screenId}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-1.5 px-2 rounded-md cursor-pointer transition-colors",
|
||||
selectedScreen?.screenId === screen.screenId
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted",
|
||||
"group",
|
||||
hasMultipleCompanies && "pl-4"
|
||||
)}
|
||||
onClick={() => onScreenSelect(screen)}
|
||||
onDoubleClick={() => onScreenDesign(screen)}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => onScreenDesign(screen)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
설계
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => openMoveModal(screen, null)}>
|
||||
<MoveRight className="h-4 w-4 mr-2" />
|
||||
카테고리로 이동
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteScreen(screen)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
화면 삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
<Monitor className="h-4 w-4 text-gray-400 shrink-0" />
|
||||
<span className="flex-1 text-sm truncate">{screen.screenName}</span>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">#{screen.screenId}</span>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => onScreenDesign(screen)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
설계
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onScreenSettings?.(screen)}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
설정 (이름 변경)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onScreenCopy?.(screen)}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => openMoveModal(screen, null)}>
|
||||
<MoveRight className="h-4 w-4 mr-2" />
|
||||
카테고리로 이동
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteScreen(screen)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
화면 삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Loader2, Link2, Monitor, Folder, ChevronRight } from "lucide-react";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { GroupCopyInfo } from "./PopCategoryTree";
|
||||
import { getCompanyList } from "@/lib/api/company";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { Company } from "@/types/company";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface LinkedScreenInfo {
|
||||
screenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
references: Array<{
|
||||
componentId: string;
|
||||
referenceType: string;
|
||||
}>;
|
||||
deploy: boolean;
|
||||
newScreenName: string;
|
||||
newScreenCode: string;
|
||||
}
|
||||
|
||||
interface ScreenEntry {
|
||||
screenId: number;
|
||||
screenName: string;
|
||||
newScreenName: string;
|
||||
newScreenCode: string;
|
||||
included: boolean;
|
||||
}
|
||||
|
||||
interface PopDeployModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
screen: ScreenDefinition | null;
|
||||
groupScreens?: ScreenDefinition[];
|
||||
groupName?: string;
|
||||
groupInfo?: GroupCopyInfo;
|
||||
allScreens: ScreenDefinition[];
|
||||
onDeployed?: () => void;
|
||||
}
|
||||
|
||||
export function PopDeployModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
screen,
|
||||
groupScreens,
|
||||
groupName,
|
||||
groupInfo,
|
||||
allScreens,
|
||||
onDeployed,
|
||||
}: PopDeployModalProps) {
|
||||
const isGroupMode = !!(groupScreens && groupScreens.length > 0);
|
||||
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [targetCompanyCode, setTargetCompanyCode] = useState("");
|
||||
|
||||
// 단일 화면 모드
|
||||
const [screenName, setScreenName] = useState("");
|
||||
const [screenCode, setScreenCode] = useState("");
|
||||
const [linkedScreens, setLinkedScreens] = useState<LinkedScreenInfo[]>([]);
|
||||
|
||||
// 그룹 모드
|
||||
const [groupEntries, setGroupEntries] = useState<ScreenEntry[]>([]);
|
||||
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
|
||||
// 회사 목록 로드
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
getCompanyList({ status: "active" })
|
||||
.then((list) => {
|
||||
setCompanies(list.filter((c) => c.company_code !== "*"));
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 모달 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
setTargetCompanyCode("");
|
||||
setLinkedScreens([]);
|
||||
|
||||
if (isGroupMode && groupScreens) {
|
||||
setGroupEntries(
|
||||
groupScreens.map((s) => ({
|
||||
screenId: s.screenId,
|
||||
screenName: s.screenName,
|
||||
newScreenName: s.screenName,
|
||||
newScreenCode: "",
|
||||
included: true,
|
||||
})),
|
||||
);
|
||||
setScreenName("");
|
||||
setScreenCode("");
|
||||
} else if (screen) {
|
||||
setScreenName(screen.screenName);
|
||||
setScreenCode("");
|
||||
setGroupEntries([]);
|
||||
analyzeLinks(screen.screenId);
|
||||
}
|
||||
}, [open, screen, groupScreens, isGroupMode]);
|
||||
|
||||
// 회사 선택 시 화면 코드 자동 생성
|
||||
useEffect(() => {
|
||||
if (!targetCompanyCode) return;
|
||||
|
||||
if (isGroupMode) {
|
||||
const count = groupEntries.filter((e) => e.included).length;
|
||||
if (count > 0) {
|
||||
screenApi
|
||||
.generateMultipleScreenCodes(targetCompanyCode, count)
|
||||
.then((codes) => {
|
||||
let codeIdx = 0;
|
||||
setGroupEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.included
|
||||
? { ...e, newScreenCode: codes[codeIdx++] || "" }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
} else {
|
||||
const count = 1 + linkedScreens.filter((ls) => ls.deploy).length;
|
||||
screenApi
|
||||
.generateMultipleScreenCodes(targetCompanyCode, count)
|
||||
.then((codes) => {
|
||||
setScreenCode(codes[0] || "");
|
||||
setLinkedScreens((prev) =>
|
||||
prev.map((ls, idx) => ({
|
||||
...ls,
|
||||
newScreenCode: codes[idx + 1] || "",
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [targetCompanyCode]);
|
||||
|
||||
const analyzeLinks = async (screenId: number) => {
|
||||
setAnalyzing(true);
|
||||
try {
|
||||
const result = await screenApi.analyzePopScreenLinks(screenId);
|
||||
const linked: LinkedScreenInfo[] = result.linkedScreenIds.map(
|
||||
(linkedId) => {
|
||||
const linkedScreen = allScreens.find(
|
||||
(s) => s.screenId === linkedId,
|
||||
);
|
||||
const refs = result.references.filter(
|
||||
(r) => r.targetScreenId === linkedId,
|
||||
);
|
||||
return {
|
||||
screenId: linkedId,
|
||||
screenName: linkedScreen?.screenName || `화면 ${linkedId}`,
|
||||
screenCode: linkedScreen?.screenCode || "",
|
||||
references: refs.map((r) => ({
|
||||
componentId: r.componentId,
|
||||
referenceType: r.referenceType,
|
||||
})),
|
||||
deploy: true,
|
||||
newScreenName: linkedScreen?.screenName || `화면 ${linkedId}`,
|
||||
newScreenCode: "",
|
||||
};
|
||||
},
|
||||
);
|
||||
setLinkedScreens(linked);
|
||||
} catch (error) {
|
||||
console.error("연결 분석 실패:", error);
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!targetCompanyCode) return;
|
||||
|
||||
setDeploying(true);
|
||||
try {
|
||||
let screensToSend: Array<{
|
||||
sourceScreenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
}>;
|
||||
|
||||
if (isGroupMode) {
|
||||
screensToSend = groupEntries
|
||||
.filter((e) => e.included && e.newScreenCode)
|
||||
.map((e) => ({
|
||||
sourceScreenId: e.screenId,
|
||||
screenName: e.newScreenName,
|
||||
screenCode: e.newScreenCode,
|
||||
}));
|
||||
} else {
|
||||
if (!screen || !screenName || !screenCode) return;
|
||||
screensToSend = [
|
||||
{
|
||||
sourceScreenId: screen.screenId,
|
||||
screenName,
|
||||
screenCode,
|
||||
},
|
||||
...linkedScreens
|
||||
.filter((ls) => ls.deploy)
|
||||
.map((ls) => ({
|
||||
sourceScreenId: ls.screenId,
|
||||
screenName: ls.newScreenName,
|
||||
screenCode: ls.newScreenCode,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
if (screensToSend.length === 0) {
|
||||
toast.error("복사할 화면이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const deployPayload: Parameters<typeof screenApi.deployPopScreens>[0] = {
|
||||
screens: screensToSend,
|
||||
targetCompanyCode,
|
||||
};
|
||||
|
||||
if (isGroupMode && groupInfo) {
|
||||
deployPayload.groupStructure = groupInfo;
|
||||
}
|
||||
|
||||
const result = await screenApi.deployPopScreens(deployPayload);
|
||||
|
||||
const groupMsg = result.createdGroups
|
||||
? ` (카테고리 ${result.createdGroups}개 생성)`
|
||||
: "";
|
||||
toast.success(
|
||||
`POP 화면 ${result.deployedScreens.length}개가 복사되었습니다.${groupMsg}`,
|
||||
);
|
||||
onOpenChange(false);
|
||||
onDeployed?.();
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.message || "복사에 실패했습니다.");
|
||||
} finally {
|
||||
setDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalCount = isGroupMode
|
||||
? groupEntries.filter((e) => e.included).length
|
||||
: 1 + linkedScreens.filter((ls) => ls.deploy).length;
|
||||
|
||||
const canDeploy = isGroupMode
|
||||
? !deploying && targetCompanyCode && groupEntries.some((e) => e.included)
|
||||
: !deploying && targetCompanyCode && screenName && screenCode;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
POP 화면 복사
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{isGroupMode
|
||||
? `"${groupName}" 카테고리의 화면 ${groupScreens!.length}개를 다른 회사로 복사합니다.`
|
||||
: screen
|
||||
? `"${screen.screenName}" (ID: ${screen.screenId}) 화면을 다른 회사로 복사합니다.`
|
||||
: "화면을 선택해주세요."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* 대상 회사 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
대상 회사 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={targetCompanyCode}
|
||||
onValueChange={setTargetCompanyCode}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="회사를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{companies.map((c) => (
|
||||
<SelectItem
|
||||
key={c.company_code}
|
||||
value={c.company_code}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{c.company_name} ({c.company_code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* ===== 그룹 모드: 카테고리 구조 + 화면 목록 ===== */}
|
||||
{isGroupMode ? (
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
복사 구조 ({groupEntries.filter((e) => e.included).length}개
|
||||
화면
|
||||
{groupInfo
|
||||
? ` + ${1 + (groupInfo.children?.length || 0)}개 카테고리`
|
||||
: ""}
|
||||
)
|
||||
</Label>
|
||||
<div className="mt-1 max-h-[280px] overflow-y-auto rounded-md border p-2">
|
||||
{groupInfo ? (
|
||||
<div className="space-y-0.5">
|
||||
{/* 메인 카테고리 */}
|
||||
<div className="flex items-center gap-1.5 rounded bg-muted/50 p-1.5 text-xs font-medium">
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<span>{groupInfo.groupName}</span>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
새 카테고리 생성
|
||||
</span>
|
||||
</div>
|
||||
{/* 메인 카테고리의 직접 화면 */}
|
||||
{groupEntries
|
||||
.filter((e) => groupInfo.screenIds.includes(e.screenId))
|
||||
.map((entry) => (
|
||||
<div
|
||||
key={entry.screenId}
|
||||
className="flex items-center gap-2 rounded p-1.5 pl-6 text-xs hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={entry.included}
|
||||
onCheckedChange={(checked) => {
|
||||
setGroupEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.screenId === entry.screenId
|
||||
? { ...e, included: !!checked }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Monitor className="h-3.5 w-3.5 shrink-0 text-blue-500" />
|
||||
<span className="flex-1 truncate">
|
||||
{entry.screenName}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
#{entry.screenId}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{/* 하위 카테고리들 */}
|
||||
{groupInfo.children?.map((child) => (
|
||||
<div key={child.sourceGroupId}>
|
||||
<div className="mt-1 flex items-center gap-1.5 rounded bg-muted/30 p-1.5 pl-4 text-xs font-medium">
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-amber-400" />
|
||||
<span>{child.groupName}</span>
|
||||
</div>
|
||||
{groupEntries
|
||||
.filter((e) =>
|
||||
child.screenIds.includes(e.screenId),
|
||||
)
|
||||
.map((entry) => (
|
||||
<div
|
||||
key={entry.screenId}
|
||||
className="flex items-center gap-2 rounded p-1.5 pl-10 text-xs hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={entry.included}
|
||||
onCheckedChange={(checked) => {
|
||||
setGroupEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.screenId === entry.screenId
|
||||
? { ...e, included: !!checked }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Monitor className="h-3.5 w-3.5 shrink-0 text-blue-500" />
|
||||
<span className="flex-1 truncate">
|
||||
{entry.screenName}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
#{entry.screenId}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{groupEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.screenId}
|
||||
className="flex items-center gap-2 rounded p-1.5 text-xs hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={entry.included}
|
||||
onCheckedChange={(checked) => {
|
||||
setGroupEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.screenId === entry.screenId
|
||||
? { ...e, included: !!checked }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Monitor className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">
|
||||
{entry.screenName}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
#{entry.screenId}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
카테고리 구조와 화면 간 연결(cartScreenId 등)이 자동으로
|
||||
복사됩니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ===== 단일 모드: 화면명 + 코드 ===== */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
새 화면명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
value={screenName}
|
||||
onChange={(e) => setScreenName(e.target.value)}
|
||||
placeholder="화면 이름"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
화면 코드 (자동생성)
|
||||
</Label>
|
||||
<Input
|
||||
className="mt-1 h-8 bg-muted text-xs sm:h-10 sm:text-sm"
|
||||
value={screenCode}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 연결 화면 감지 */}
|
||||
{analyzing ? (
|
||||
<div className="flex items-center gap-2 rounded-md border p-3 text-xs text-muted-foreground sm:text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
연결된 화면을 분석 중입니다...
|
||||
</div>
|
||||
) : linkedScreens.length > 0 ? (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-950/30">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-amber-800 dark:text-amber-300 sm:text-sm">
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
연결된 POP 화면 {linkedScreens.length}개 감지됨
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{linkedScreens.map((ls) => (
|
||||
<div
|
||||
key={ls.screenId}
|
||||
className="flex items-center justify-between rounded bg-background p-2 text-xs"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{ls.screenName}</div>
|
||||
<div className="text-muted-foreground">
|
||||
ID: {ls.screenId} |{" "}
|
||||
{ls.references
|
||||
.map((r) => r.referenceType)
|
||||
.join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={ls.deploy}
|
||||
onCheckedChange={(checked) => {
|
||||
setLinkedScreens((prev) =>
|
||||
prev.map((item) =>
|
||||
item.screenId === ls.screenId
|
||||
? { ...item, deploy: !!checked }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">함께 복사</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-amber-700 dark:text-amber-400 sm:text-xs">
|
||||
함께 복사하면 화면 간 연결(cartScreenId 등)이 새 ID로 자동
|
||||
치환됩니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
!analyzing && (
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground sm:text-sm">
|
||||
연결된 POP 화면이 없습니다. 이 화면만 복사됩니다.
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
disabled={deploying}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeploy}
|
||||
disabled={!canDeploy}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{deploying ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
복사 중...
|
||||
</>
|
||||
) : (
|
||||
`${totalCount}개 화면 복사`
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -165,19 +165,26 @@ export function PopScreenSettingModal({
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// 화면 기본 정보 업데이트
|
||||
const screenUpdate: Partial<ScreenDefinition> = {
|
||||
screenName,
|
||||
description: screenDescription,
|
||||
};
|
||||
|
||||
// screen_definitions 테이블에 화면명/설명 업데이트
|
||||
if (screenName !== screen.screenName || screenDescription !== (screen.description || "")) {
|
||||
await screenApi.updateScreenInfo(screen.screenId, {
|
||||
screenName,
|
||||
description: screenDescription,
|
||||
isActive: "Y",
|
||||
});
|
||||
}
|
||||
|
||||
// 레이아웃에 하위 화면 정보 저장
|
||||
const currentLayout = await screenApi.getLayoutPop(screen.screenId);
|
||||
const updatedLayout = {
|
||||
...currentLayout,
|
||||
version: "pop-1.0",
|
||||
subScreens: subScreens,
|
||||
// flow 배열 자동 생성 (메인 → 각 서브)
|
||||
flow: subScreens.map((sub) => ({
|
||||
from: sub.triggerFrom || "main",
|
||||
to: sub.id,
|
||||
@@ -201,11 +208,11 @@ export function PopScreenSettingModal({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] flex flex-col p-0">
|
||||
<DialogHeader className="p-4 pb-0 shrink-0">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[90vh] flex flex-col p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
||||
<DialogTitle className="text-base sm:text-lg">POP 화면 설정</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{screen.screenName} ({screen.screenCode})
|
||||
{screen.screenName} [{screen.screenCode}]
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -214,57 +221,57 @@ export function PopScreenSettingModal({
|
||||
onValueChange={setActiveTab}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<TabsList className="shrink-0 mx-4 justify-start border-b rounded-none bg-transparent h-auto p-0">
|
||||
<TabsList className="shrink-0 mx-6 justify-start border-b rounded-none bg-transparent h-auto p-0">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-3 py-2 text-xs sm:text-sm"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
개요
|
||||
<FileText className="h-3.5 w-3.5 mr-1.5" />
|
||||
기본 정보
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="subscreens"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-3 py-2 text-xs sm:text-sm"
|
||||
>
|
||||
<Layers className="h-4 w-4 mr-2" />
|
||||
<Layers className="h-3.5 w-3.5 mr-1.5" />
|
||||
하위 화면
|
||||
{subScreens.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2 text-xs">
|
||||
<Badge variant="secondary" className="ml-1.5 text-[10px] h-4 px-1.5">
|
||||
{subScreens.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="flow"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-3 py-2 text-xs sm:text-sm"
|
||||
>
|
||||
<GitBranch className="h-4 w-4 mr-2" />
|
||||
<GitBranch className="h-3.5 w-3.5 mr-1.5" />
|
||||
화면 흐름
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 개요 탭 */}
|
||||
<TabsContent value="overview" className="flex-1 m-0 p-4 overflow-auto">
|
||||
{/* 기본 정보 탭 */}
|
||||
<TabsContent value="overview" className="flex-1 m-0 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 max-w-[500px]">
|
||||
<div>
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="screenName" className="text-xs sm:text-sm">
|
||||
화면명 *
|
||||
화면명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="screenName"
|
||||
value={screenName}
|
||||
onChange={(e) => setScreenName(e.target.value)}
|
||||
placeholder="화면 이름"
|
||||
placeholder="화면 이름을 입력하세요"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="category" className="text-xs sm:text-sm">
|
||||
카테고리
|
||||
</Label>
|
||||
@@ -282,7 +289,7 @@ export function PopScreenSettingModal({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||||
설명
|
||||
</Label>
|
||||
@@ -290,13 +297,13 @@ export function PopScreenSettingModal({
|
||||
id="description"
|
||||
value={screenDescription}
|
||||
onChange={(e) => setScreenDescription(e.target.value)}
|
||||
placeholder="화면에 대한 설명"
|
||||
placeholder="화면에 대한 설명을 입력하세요"
|
||||
rows={3}
|
||||
className="text-xs sm:text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="icon" className="text-xs sm:text-sm">
|
||||
아이콘
|
||||
</Label>
|
||||
@@ -307,7 +314,7 @@ export function PopScreenSettingModal({
|
||||
placeholder="lucide 아이콘 이름 (예: Package)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
lucide-react 아이콘 이름을 입력하세요.
|
||||
</p>
|
||||
</div>
|
||||
@@ -316,19 +323,19 @@ export function PopScreenSettingModal({
|
||||
</TabsContent>
|
||||
|
||||
{/* 하위 화면 탭 */}
|
||||
<TabsContent value="subscreens" className="flex-1 m-0 p-4 overflow-auto">
|
||||
<div className="space-y-4">
|
||||
<TabsContent value="subscreens" className="flex-1 m-0 overflow-auto">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
이 화면에서 열리는 모달, 드로어 등의 하위 화면을 관리합니다.
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
모달, 드로어 등 하위 화면을 관리합니다.
|
||||
</p>
|
||||
<Button size="sm" onClick={addSubScreen}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<Button size="sm" className="h-8 text-xs" onClick={addSubScreen}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[300px]">
|
||||
<ScrollArea className="h-[280px]">
|
||||
{subScreens.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Layers className="h-8 w-8 mx-auto mb-3 opacity-50" />
|
||||
@@ -339,12 +346,12 @@ export function PopScreenSettingModal({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{subScreens.map((subScreen, index) => (
|
||||
{subScreens.map((subScreen) => (
|
||||
<div
|
||||
key={subScreen.id}
|
||||
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30"
|
||||
className="flex items-start gap-2 p-3 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<GripVertical className="h-5 w-5 text-muted-foreground shrink-0 mt-1 cursor-grab" />
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0 mt-1.5 cursor-grab" />
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -362,7 +369,7 @@ export function PopScreenSettingModal({
|
||||
updateSubScreen(subScreen.id, "type", v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs w-[100px]">
|
||||
<SelectTrigger className="h-8 text-xs w-[90px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -374,7 +381,7 @@ export function PopScreenSettingModal({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
트리거:
|
||||
</span>
|
||||
<Select
|
||||
@@ -403,10 +410,10 @@ export function PopScreenSettingModal({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeSubScreen(subScreen.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
@@ -423,11 +430,19 @@ export function PopScreenSettingModal({
|
||||
</Tabs>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="shrink-0 p-4 border-t flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<div className="shrink-0 px-6 py-4 border-t flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
|
||||
export { PopCategoryTree } from "./PopCategoryTree";
|
||||
export type { GroupCopyInfo } from "./PopCategoryTree";
|
||||
export { PopScreenPreview } from "./PopScreenPreview";
|
||||
export { PopScreenFlowView } from "./PopScreenFlowView";
|
||||
export { PopScreenSettingModal } from "./PopScreenSettingModal";
|
||||
export { PopDeployModal } from "./PopDeployModal";
|
||||
|
||||
@@ -269,6 +269,59 @@ export const screenApi = {
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// POP 화면 연결 분석 (다른 화면과의 참조 관계)
|
||||
analyzePopScreenLinks: async (
|
||||
screenId: number,
|
||||
): Promise<{
|
||||
linkedScreenIds: number[];
|
||||
references: Array<{
|
||||
componentId: string;
|
||||
referenceType: string;
|
||||
targetScreenId: number;
|
||||
}>;
|
||||
}> => {
|
||||
const response = await apiClient.get(
|
||||
`/screen-management/screens/${screenId}/pop-links`,
|
||||
);
|
||||
return response.data.data || { linkedScreenIds: [], references: [] };
|
||||
},
|
||||
|
||||
// POP 화면 배포 (다른 회사로 복사)
|
||||
deployPopScreens: async (data: {
|
||||
screens: Array<{
|
||||
sourceScreenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
}>;
|
||||
targetCompanyCode: string;
|
||||
groupStructure?: {
|
||||
sourceGroupId: number;
|
||||
groupName: string;
|
||||
groupCode: string;
|
||||
screenIds: number[];
|
||||
children?: Array<{
|
||||
sourceGroupId: number;
|
||||
groupName: string;
|
||||
groupCode: string;
|
||||
screenIds: number[];
|
||||
}>;
|
||||
};
|
||||
}): Promise<{
|
||||
deployedScreens: Array<{
|
||||
sourceScreenId: number;
|
||||
newScreenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
}>;
|
||||
createdGroups?: number;
|
||||
}> => {
|
||||
const response = await apiClient.post(
|
||||
`/screen-management/deploy-pop-screens`,
|
||||
data,
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// 메인 화면 + 모달 화면들 일괄 복사
|
||||
copyScreenWithModals: async (
|
||||
sourceScreenId: number,
|
||||
|
||||
Reference in New Issue
Block a user