443 lines
16 KiB
TypeScript
443 lines
16 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { useState, useEffect } from "react";
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogDescription,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
} from "@/components/ui/dialog";
|
||
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { Label } from "@/components/ui/label";
|
||
|
|
import { Textarea } from "@/components/ui/textarea";
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
|
|
import {
|
||
|
|
Select,
|
||
|
|
SelectContent,
|
||
|
|
SelectItem,
|
||
|
|
SelectTrigger,
|
||
|
|
SelectValue,
|
||
|
|
} from "@/components/ui/select";
|
||
|
|
import {
|
||
|
|
FileText,
|
||
|
|
Layers,
|
||
|
|
GitBranch,
|
||
|
|
Plus,
|
||
|
|
Trash2,
|
||
|
|
GripVertical,
|
||
|
|
Loader2,
|
||
|
|
Save,
|
||
|
|
} from "lucide-react";
|
||
|
|
import { toast } from "sonner";
|
||
|
|
import { ScreenDefinition } from "@/types/screen";
|
||
|
|
import { screenApi } from "@/lib/api/screen";
|
||
|
|
import { PopScreenGroup, getPopScreenGroups } from "@/lib/api/popScreenGroup";
|
||
|
|
import { PopScreenFlowView } from "./PopScreenFlowView";
|
||
|
|
|
||
|
|
// ============================================================
|
||
|
|
// 타입 정의
|
||
|
|
// ============================================================
|
||
|
|
|
||
|
|
interface PopScreenSettingModalProps {
|
||
|
|
open: boolean;
|
||
|
|
onOpenChange: (open: boolean) => void;
|
||
|
|
screen: ScreenDefinition | null;
|
||
|
|
onSave?: (updatedScreen: Partial<ScreenDefinition>) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface SubScreenItem {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
type: "modal" | "drawer" | "fullscreen";
|
||
|
|
triggerFrom?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================
|
||
|
|
// 메인 컴포넌트
|
||
|
|
// ============================================================
|
||
|
|
|
||
|
|
export function PopScreenSettingModal({
|
||
|
|
open,
|
||
|
|
onOpenChange,
|
||
|
|
screen,
|
||
|
|
onSave,
|
||
|
|
}: PopScreenSettingModalProps) {
|
||
|
|
const [activeTab, setActiveTab] = useState("overview");
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
const [saving, setSaving] = useState(false);
|
||
|
|
|
||
|
|
// 개요 탭 상태
|
||
|
|
const [screenName, setScreenName] = useState("");
|
||
|
|
const [screenDescription, setScreenDescription] = useState("");
|
||
|
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string>("");
|
||
|
|
const [screenIcon, setScreenIcon] = useState("");
|
||
|
|
|
||
|
|
// 하위 화면 탭 상태
|
||
|
|
const [subScreens, setSubScreens] = useState<SubScreenItem[]>([]);
|
||
|
|
|
||
|
|
// 카테고리 목록
|
||
|
|
const [categories, setCategories] = useState<PopScreenGroup[]>([]);
|
||
|
|
|
||
|
|
// 초기 데이터 로드
|
||
|
|
useEffect(() => {
|
||
|
|
if (!open || !screen) return;
|
||
|
|
|
||
|
|
// 화면 정보 설정
|
||
|
|
setScreenName(screen.screenName || "");
|
||
|
|
setScreenDescription(screen.description || "");
|
||
|
|
setScreenIcon("");
|
||
|
|
setSelectedCategoryId("");
|
||
|
|
|
||
|
|
// 카테고리 목록 로드
|
||
|
|
loadCategories();
|
||
|
|
|
||
|
|
// 레이아웃에서 하위 화면 정보 로드
|
||
|
|
loadLayoutData();
|
||
|
|
}, [open, screen]);
|
||
|
|
|
||
|
|
const loadCategories = async () => {
|
||
|
|
try {
|
||
|
|
const data = await getPopScreenGroups();
|
||
|
|
setCategories(data.filter((g) => g.hierarchy_path?.startsWith("POP/")));
|
||
|
|
} catch (error) {
|
||
|
|
console.error("카테고리 로드 실패:", error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const loadLayoutData = async () => {
|
||
|
|
if (!screen) return;
|
||
|
|
|
||
|
|
try {
|
||
|
|
setLoading(true);
|
||
|
|
const layout = await screenApi.getLayoutPop(screen.screenId);
|
||
|
|
|
||
|
|
if (layout && layout.subScreens) {
|
||
|
|
setSubScreens(
|
||
|
|
layout.subScreens.map((sub: any) => ({
|
||
|
|
id: sub.id || `sub-${Date.now()}`,
|
||
|
|
name: sub.name || "",
|
||
|
|
type: sub.type || "modal",
|
||
|
|
triggerFrom: sub.triggerFrom || "main",
|
||
|
|
}))
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
setSubScreens([]);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("레이아웃 로드 실패:", error);
|
||
|
|
setSubScreens([]);
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 하위 화면 추가
|
||
|
|
const addSubScreen = () => {
|
||
|
|
const newSubScreen: SubScreenItem = {
|
||
|
|
id: `sub-${Date.now()}`,
|
||
|
|
name: `새 모달 ${subScreens.length + 1}`,
|
||
|
|
type: "modal",
|
||
|
|
triggerFrom: "main",
|
||
|
|
};
|
||
|
|
setSubScreens([...subScreens, newSubScreen]);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 하위 화면 삭제
|
||
|
|
const removeSubScreen = (id: string) => {
|
||
|
|
setSubScreens(subScreens.filter((s) => s.id !== id));
|
||
|
|
};
|
||
|
|
|
||
|
|
// 하위 화면 업데이트
|
||
|
|
const updateSubScreen = (id: string, field: keyof SubScreenItem, value: string) => {
|
||
|
|
setSubScreens(
|
||
|
|
subScreens.map((s) => (s.id === id ? { ...s, [field]: value } : s))
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 저장
|
||
|
|
const handleSave = async () => {
|
||
|
|
if (!screen) return;
|
||
|
|
|
||
|
|
try {
|
||
|
|
setSaving(true);
|
||
|
|
|
||
|
|
// 화면 기본 정보 업데이트
|
||
|
|
const screenUpdate: Partial<ScreenDefinition> = {
|
||
|
|
screenName,
|
||
|
|
description: screenDescription,
|
||
|
|
};
|
||
|
|
|
||
|
|
// 레이아웃에 하위 화면 정보 저장
|
||
|
|
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,
|
||
|
|
})),
|
||
|
|
};
|
||
|
|
|
||
|
|
await screenApi.saveLayoutPop(screen.screenId, updatedLayout);
|
||
|
|
|
||
|
|
toast.success("화면 설정이 저장되었습니다.");
|
||
|
|
onSave?.(screenUpdate);
|
||
|
|
onOpenChange(false);
|
||
|
|
} catch (error) {
|
||
|
|
console.error("저장 실패:", error);
|
||
|
|
toast.error("저장에 실패했습니다.");
|
||
|
|
} finally {
|
||
|
|
setSaving(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
if (!screen) return null;
|
||
|
|
|
||
|
|
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">
|
||
|
|
<DialogTitle className="text-base sm:text-lg">POP 화면 설정</DialogTitle>
|
||
|
|
<DialogDescription className="text-xs sm:text-sm">
|
||
|
|
{screen.screenName} ({screen.screenCode})
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
<Tabs
|
||
|
|
value={activeTab}
|
||
|
|
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">
|
||
|
|
<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"
|
||
|
|
>
|
||
|
|
<FileText className="h-4 w-4 mr-2" />
|
||
|
|
개요
|
||
|
|
</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"
|
||
|
|
>
|
||
|
|
<Layers className="h-4 w-4 mr-2" />
|
||
|
|
하위 화면
|
||
|
|
{subScreens.length > 0 && (
|
||
|
|
<Badge variant="secondary" className="ml-2 text-xs">
|
||
|
|
{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"
|
||
|
|
>
|
||
|
|
<GitBranch className="h-4 w-4 mr-2" />
|
||
|
|
화면 흐름
|
||
|
|
</TabsTrigger>
|
||
|
|
</TabsList>
|
||
|
|
|
||
|
|
{/* 개요 탭 */}
|
||
|
|
<TabsContent value="overview" className="flex-1 m-0 p-4 overflow-auto">
|
||
|
|
{loading ? (
|
||
|
|
<div className="flex items-center justify-center h-full">
|
||
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-4 max-w-[500px]">
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="screenName" className="text-xs sm:text-sm">
|
||
|
|
화면명 *
|
||
|
|
</Label>
|
||
|
|
<Input
|
||
|
|
id="screenName"
|
||
|
|
value={screenName}
|
||
|
|
onChange={(e) => setScreenName(e.target.value)}
|
||
|
|
placeholder="화면 이름"
|
||
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="category" className="text-xs sm:text-sm">
|
||
|
|
카테고리
|
||
|
|
</Label>
|
||
|
|
<Select value={selectedCategoryId} onValueChange={setSelectedCategoryId}>
|
||
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||
|
|
<SelectValue placeholder="카테고리 선택" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{categories.map((cat) => (
|
||
|
|
<SelectItem key={cat.id} value={String(cat.id)}>
|
||
|
|
{cat.group_name}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||
|
|
설명
|
||
|
|
</Label>
|
||
|
|
<Textarea
|
||
|
|
id="description"
|
||
|
|
value={screenDescription}
|
||
|
|
onChange={(e) => setScreenDescription(e.target.value)}
|
||
|
|
placeholder="화면에 대한 설명"
|
||
|
|
rows={3}
|
||
|
|
className="text-xs sm:text-sm resize-none"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="icon" className="text-xs sm:text-sm">
|
||
|
|
아이콘
|
||
|
|
</Label>
|
||
|
|
<Input
|
||
|
|
id="icon"
|
||
|
|
value={screenIcon}
|
||
|
|
onChange={(e) => setScreenIcon(e.target.value)}
|
||
|
|
placeholder="lucide 아이콘 이름 (예: Package)"
|
||
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||
|
|
/>
|
||
|
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||
|
|
lucide-react 아이콘 이름을 입력하세요.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</TabsContent>
|
||
|
|
|
||
|
|
{/* 하위 화면 탭 */}
|
||
|
|
<TabsContent value="subscreens" className="flex-1 m-0 p-4 overflow-auto">
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<p className="text-sm text-muted-foreground">
|
||
|
|
이 화면에서 열리는 모달, 드로어 등의 하위 화면을 관리합니다.
|
||
|
|
</p>
|
||
|
|
<Button size="sm" onClick={addSubScreen}>
|
||
|
|
<Plus className="h-4 w-4 mr-1" />
|
||
|
|
추가
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<ScrollArea className="h-[300px]">
|
||
|
|
{subScreens.length === 0 ? (
|
||
|
|
<div className="text-center text-muted-foreground py-8">
|
||
|
|
<Layers className="h-8 w-8 mx-auto mb-3 opacity-50" />
|
||
|
|
<p className="text-sm">하위 화면이 없습니다.</p>
|
||
|
|
<Button variant="link" className="text-xs" onClick={addSubScreen}>
|
||
|
|
하위 화면 추가하기
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-3">
|
||
|
|
{subScreens.map((subScreen, index) => (
|
||
|
|
<div
|
||
|
|
key={subScreen.id}
|
||
|
|
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30"
|
||
|
|
>
|
||
|
|
<GripVertical className="h-5 w-5 text-muted-foreground shrink-0 mt-1 cursor-grab" />
|
||
|
|
|
||
|
|
<div className="flex-1 space-y-2">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Input
|
||
|
|
value={subScreen.name}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateSubScreen(subScreen.id, "name", e.target.value)
|
||
|
|
}
|
||
|
|
placeholder="화면 이름"
|
||
|
|
className="h-8 text-xs flex-1"
|
||
|
|
/>
|
||
|
|
<Select
|
||
|
|
value={subScreen.type}
|
||
|
|
onValueChange={(v) =>
|
||
|
|
updateSubScreen(subScreen.id, "type", v)
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<SelectTrigger className="h-8 text-xs w-[100px]">
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="modal">모달</SelectItem>
|
||
|
|
<SelectItem value="drawer">드로어</SelectItem>
|
||
|
|
<SelectItem value="fullscreen">전체화면</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<span className="text-xs text-muted-foreground shrink-0">
|
||
|
|
트리거:
|
||
|
|
</span>
|
||
|
|
<Select
|
||
|
|
value={subScreen.triggerFrom || "main"}
|
||
|
|
onValueChange={(v) =>
|
||
|
|
updateSubScreen(subScreen.id, "triggerFrom", v)
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<SelectTrigger className="h-7 text-xs flex-1">
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="main">메인 화면</SelectItem>
|
||
|
|
{subScreens
|
||
|
|
.filter((s) => s.id !== subScreen.id)
|
||
|
|
.map((s) => (
|
||
|
|
<SelectItem key={s.id} value={s.id}>
|
||
|
|
{s.name}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||
|
|
onClick={() => removeSubScreen(subScreen.id)}
|
||
|
|
>
|
||
|
|
<Trash2 className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</ScrollArea>
|
||
|
|
</div>
|
||
|
|
</TabsContent>
|
||
|
|
|
||
|
|
{/* 화면 흐름 탭 */}
|
||
|
|
<TabsContent value="flow" className="flex-1 m-0 overflow-hidden">
|
||
|
|
<PopScreenFlowView screen={screen} className="h-full" />
|
||
|
|
</TabsContent>
|
||
|
|
</Tabs>
|
||
|
|
|
||
|
|
{/* 푸터 */}
|
||
|
|
<div className="shrink-0 p-4 border-t flex items-center justify-end gap-2">
|
||
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||
|
|
취소
|
||
|
|
</Button>
|
||
|
|
<Button onClick={handleSave} disabled={saving}>
|
||
|
|
{saving ? (
|
||
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Save className="h-4 w-4 mr-2" />
|
||
|
|
)}
|
||
|
|
저장
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|