405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
"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();
|
|
}, []);
|
|
|
|
// 컴포넌트 변경 시 로컬 상태 동기화 (초기화만, 입력 중에는 동기화하지 않음)
|
|
const [isUserEditing, setIsUserEditing] = useState(false);
|
|
|
|
useEffect(() => {
|
|
// 사용자가 입력 중이 아닐 때만 동기화
|
|
if (!isUserEditing) {
|
|
setLocalTabs(config.tabs || []);
|
|
}
|
|
}, [config.tabs, isUserEditing]);
|
|
|
|
// 탭 추가
|
|
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) => {
|
|
setIsUserEditing(true);
|
|
const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, label } : tab));
|
|
setLocalTabs(updatedTabs);
|
|
// onChange는 onBlur에서 호출
|
|
};
|
|
|
|
// 탭 라벨 변경 완료 (포커스 아웃 시)
|
|
const handleLabelBlur = () => {
|
|
setIsUserEditing(false);
|
|
onChange({ ...config, tabs: localTabs });
|
|
};
|
|
|
|
// 탭 화면 선택
|
|
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)}
|
|
onBlur={handleLabelBlur}
|
|
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>
|
|
);
|
|
}
|
|
|