2025-09-01 11:48:12 +09:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
2025-11-19 13:22:49 +09:00
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
2025-09-01 11:48:12 +09:00
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
2025-09-08 13:10:09 +09:00
|
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
|
|
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
2025-11-19 13:22:49 +09:00
|
|
|
|
import { useAuth } from "@/hooks/useAuth";
|
2025-09-01 11:48:12 +09:00
|
|
|
|
import {
|
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
|
} from "@/components/ui/dropdown-menu";
|
2025-09-08 13:10:09 +09:00
|
|
|
|
import {
|
|
|
|
|
|
AlertDialog,
|
|
|
|
|
|
AlertDialogAction,
|
|
|
|
|
|
AlertDialogCancel,
|
2025-11-06 12:11:49 +09:00
|
|
|
|
AlertDialogContent,
|
|
|
|
|
|
AlertDialogDescription,
|
2025-09-08 13:10:09 +09:00
|
|
|
|
AlertDialogFooter,
|
2025-11-06 12:11:49 +09:00
|
|
|
|
AlertDialogHeader,
|
2025-09-08 13:10:09 +09:00
|
|
|
|
AlertDialogTitle,
|
|
|
|
|
|
} from "@/components/ui/alert-dialog";
|
|
|
|
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
2025-11-06 12:11:49 +09:00
|
|
|
|
import {
|
|
|
|
|
|
Dialog,
|
|
|
|
|
|
DialogContent,
|
|
|
|
|
|
DialogHeader,
|
|
|
|
|
|
DialogTitle,
|
|
|
|
|
|
DialogFooter,
|
|
|
|
|
|
DialogDescription,
|
|
|
|
|
|
} from "@/components/ui/dialog";
|
2025-10-15 18:31:40 +09:00
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
2025-11-25 09:34:44 +09:00
|
|
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
|
|
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash, Check, ChevronsUpDown } from "lucide-react";
|
2025-09-01 11:48:12 +09:00
|
|
|
|
import { ScreenDefinition } from "@/types/screen";
|
2025-09-01 17:57:52 +09:00
|
|
|
|
import { screenApi } from "@/lib/api/screen";
|
2025-12-02 13:20:49 +09:00
|
|
|
|
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
|
2026-01-05 10:05:31 +09:00
|
|
|
|
import { getScreenGroups, ScreenGroup } from "@/lib/api/screenGroup";
|
|
|
|
|
|
import { Layers } from "lucide-react";
|
2025-09-01 17:57:52 +09:00
|
|
|
|
import CreateScreenModal from "./CreateScreenModal";
|
2025-09-03 18:23:47 +09:00
|
|
|
|
import CopyScreenModal from "./CopyScreenModal";
|
2025-10-15 18:31:40 +09:00
|
|
|
|
import dynamic from "next/dynamic";
|
|
|
|
|
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
|
|
|
|
|
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
|
|
|
|
|
import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils";
|
2025-11-26 14:44:49 +09:00
|
|
|
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
2025-11-26 14:58:18 +09:00
|
|
|
|
import { RealtimePreview } from "./RealtimePreviewDynamic";
|
|
|
|
|
|
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
2025-10-15 18:31:40 +09:00
|
|
|
|
|
|
|
|
|
|
// InteractiveScreenViewer를 동적으로 import (SSR 비활성화)
|
|
|
|
|
|
const InteractiveScreenViewer = dynamic(
|
|
|
|
|
|
() => import("./InteractiveScreenViewer").then((mod) => mod.InteractiveScreenViewer),
|
|
|
|
|
|
{
|
|
|
|
|
|
ssr: false,
|
|
|
|
|
|
loading: () => <div className="flex items-center justify-center p-8">로딩 중...</div>,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2025-09-01 11:48:12 +09:00
|
|
|
|
|
|
|
|
|
|
interface ScreenListProps {
|
|
|
|
|
|
onScreenSelect: (screen: ScreenDefinition) => void;
|
|
|
|
|
|
selectedScreen: ScreenDefinition | null;
|
2025-09-01 14:00:31 +09:00
|
|
|
|
onDesignScreen: (screen: ScreenDefinition) => void;
|
2025-09-01 11:48:12 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-08 13:10:09 +09:00
|
|
|
|
type DeletedScreenDefinition = ScreenDefinition & {
|
|
|
|
|
|
deletedDate?: Date;
|
|
|
|
|
|
deletedBy?: string;
|
|
|
|
|
|
deleteReason?: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-01 14:00:31 +09:00
|
|
|
|
export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: ScreenListProps) {
|
2025-11-19 13:22:49 +09:00
|
|
|
|
const { user } = useAuth();
|
|
|
|
|
|
const isSuperAdmin = user?.userType === "SUPER_ADMIN" || user?.companyCode === "*";
|
|
|
|
|
|
|
2025-09-08 13:10:09 +09:00
|
|
|
|
const [activeTab, setActiveTab] = useState("active");
|
2025-09-01 11:48:12 +09:00
|
|
|
|
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
2025-09-08 13:10:09 +09:00
|
|
|
|
const [deletedScreens, setDeletedScreens] = useState<DeletedScreenDefinition[]>([]);
|
2025-11-19 13:22:49 +09:00
|
|
|
|
const [loading, setLoading] = useState(true); // 초기 로딩
|
|
|
|
|
|
const [isSearching, setIsSearching] = useState(false); // 검색 중 로딩 (포커스 유지)
|
2025-09-01 11:48:12 +09:00
|
|
|
|
const [searchTerm, setSearchTerm] = useState("");
|
2025-11-19 13:22:49 +09:00
|
|
|
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
|
|
|
|
|
const [selectedCompanyCode, setSelectedCompanyCode] = useState<string>("all");
|
|
|
|
|
|
const [companies, setCompanies] = useState<any[]>([]);
|
|
|
|
|
|
const [loadingCompanies, setLoadingCompanies] = useState(false);
|
2025-09-01 11:48:12 +09:00
|
|
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
|
|
|
|
const [totalPages, setTotalPages] = useState(1);
|
2025-09-01 17:57:52 +09:00
|
|
|
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
2025-09-03 18:23:47 +09:00
|
|
|
|
const [isCopyOpen, setIsCopyOpen] = useState(false);
|
|
|
|
|
|
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
|
2025-09-01 11:48:12 +09:00
|
|
|
|
|
2026-01-05 10:05:31 +09:00
|
|
|
|
// 그룹 필터 관련 상태
|
|
|
|
|
|
const [selectedGroupId, setSelectedGroupId] = useState<string>("all");
|
|
|
|
|
|
const [groups, setGroups] = useState<ScreenGroup[]>([]);
|
|
|
|
|
|
const [loadingGroups, setLoadingGroups] = useState(false);
|
|
|
|
|
|
|
2025-11-19 13:22:49 +09:00
|
|
|
|
// 검색어 디바운스를 위한 타이머 ref
|
|
|
|
|
|
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
// 첫 로딩 여부를 추적 (한 번만 true)
|
|
|
|
|
|
const isFirstLoad = useRef(true);
|
|
|
|
|
|
|
2025-09-08 13:10:09 +09:00
|
|
|
|
// 삭제 관련 상태
|
|
|
|
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
|
|
|
|
const [screenToDelete, setScreenToDelete] = useState<ScreenDefinition | null>(null);
|
|
|
|
|
|
const [deleteReason, setDeleteReason] = useState("");
|
|
|
|
|
|
const [dependencies, setDependencies] = useState<
|
|
|
|
|
|
Array<{
|
|
|
|
|
|
screenId: number;
|
|
|
|
|
|
screenName: string;
|
|
|
|
|
|
screenCode: string;
|
|
|
|
|
|
componentId: string;
|
|
|
|
|
|
componentType: string;
|
|
|
|
|
|
referenceType: string;
|
|
|
|
|
|
}>
|
|
|
|
|
|
>([]);
|
|
|
|
|
|
const [showDependencyWarning, setShowDependencyWarning] = useState(false);
|
|
|
|
|
|
const [checkingDependencies, setCheckingDependencies] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
// 영구 삭제 관련 상태
|
|
|
|
|
|
const [permanentDeleteDialogOpen, setPermanentDeleteDialogOpen] = useState(false);
|
|
|
|
|
|
const [screenToPermanentDelete, setScreenToPermanentDelete] = useState<DeletedScreenDefinition | null>(null);
|
|
|
|
|
|
|
2025-12-03 16:02:09 +09:00
|
|
|
|
// 휴지통 일괄삭제 관련 상태
|
2025-09-08 13:10:09 +09:00
|
|
|
|
const [selectedScreenIds, setSelectedScreenIds] = useState<number[]>([]);
|
|
|
|
|
|
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
|
|
|
|
|
const [bulkDeleting, setBulkDeleting] = useState(false);
|
|
|
|
|
|
|
2025-12-03 16:02:09 +09:00
|
|
|
|
// 활성 화면 일괄삭제 관련 상태
|
|
|
|
|
|
const [selectedActiveScreenIds, setSelectedActiveScreenIds] = useState<number[]>([]);
|
|
|
|
|
|
const [activeBulkDeleteDialogOpen, setActiveBulkDeleteDialogOpen] = useState(false);
|
|
|
|
|
|
const [activeBulkDeleteReason, setActiveBulkDeleteReason] = useState("");
|
|
|
|
|
|
const [activeBulkDeleting, setActiveBulkDeleting] = useState(false);
|
|
|
|
|
|
|
2025-10-15 18:31:40 +09:00
|
|
|
|
// 편집 관련 상태
|
|
|
|
|
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
|
|
|
|
|
const [screenToEdit, setScreenToEdit] = useState<ScreenDefinition | null>(null);
|
|
|
|
|
|
const [editFormData, setEditFormData] = useState({
|
|
|
|
|
|
screenName: "",
|
|
|
|
|
|
description: "",
|
|
|
|
|
|
isActive: "Y",
|
2025-11-20 15:30:00 +09:00
|
|
|
|
tableName: "",
|
2025-12-02 13:20:49 +09:00
|
|
|
|
dataSourceType: "database" as "database" | "restapi",
|
|
|
|
|
|
restApiConnectionId: null as number | null,
|
|
|
|
|
|
restApiEndpoint: "",
|
|
|
|
|
|
restApiJsonPath: "data",
|
2025-10-15 18:31:40 +09:00
|
|
|
|
});
|
2025-11-25 09:34:44 +09:00
|
|
|
|
const [tables, setTables] = useState<Array<{ tableName: string; tableLabel: string }>>([]);
|
2025-11-20 15:30:00 +09:00
|
|
|
|
const [loadingTables, setLoadingTables] = useState(false);
|
2025-11-25 09:34:44 +09:00
|
|
|
|
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
2025-12-02 13:20:49 +09:00
|
|
|
|
|
|
|
|
|
|
// REST API 연결 관련 상태 (편집용)
|
|
|
|
|
|
const [editRestApiConnections, setEditRestApiConnections] = useState<ExternalRestApiConnection[]>([]);
|
|
|
|
|
|
const [editRestApiComboboxOpen, setEditRestApiComboboxOpen] = useState(false);
|
2025-10-15 18:31:40 +09:00
|
|
|
|
|
|
|
|
|
|
// 미리보기 관련 상태
|
|
|
|
|
|
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
|
|
|
|
|
|
const [screenToPreview, setScreenToPreview] = useState<ScreenDefinition | null>(null);
|
|
|
|
|
|
const [previewLayout, setPreviewLayout] = useState<any>(null);
|
|
|
|
|
|
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
|
|
|
|
|
|
const [previewFormData, setPreviewFormData] = useState<Record<string, any>>({});
|
|
|
|
|
|
|
2025-11-19 13:22:49 +09:00
|
|
|
|
// 최고 관리자인 경우 회사 목록 로드
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (isSuperAdmin) {
|
|
|
|
|
|
loadCompanies();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [isSuperAdmin]);
|
|
|
|
|
|
|
|
|
|
|
|
const loadCompanies = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoadingCompanies(true);
|
|
|
|
|
|
const { apiClient } = await import("@/lib/api/client"); // named export
|
|
|
|
|
|
const response = await apiClient.get("/admin/companies");
|
|
|
|
|
|
const data = response.data.data || response.data || [];
|
|
|
|
|
|
setCompanies(data.map((c: any) => ({
|
|
|
|
|
|
companyCode: c.company_code || c.companyCode,
|
|
|
|
|
|
companyName: c.company_name || c.companyName,
|
|
|
|
|
|
})));
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("회사 목록 조회 실패:", error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingCompanies(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-05 10:05:31 +09:00
|
|
|
|
// 화면 그룹 목록 로드
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadGroups();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const loadGroups = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoadingGroups(true);
|
|
|
|
|
|
const response = await getScreenGroups();
|
|
|
|
|
|
if (response.success && response.data) {
|
|
|
|
|
|
setGroups(response.data);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("그룹 목록 조회 실패:", error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingGroups(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-19 13:22:49 +09:00
|
|
|
|
// 검색어 디바운스 처리 (150ms 지연 - 빠른 응답)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
// 이전 타이머 취소
|
|
|
|
|
|
if (debounceTimer.current) {
|
|
|
|
|
|
clearTimeout(debounceTimer.current);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 새 타이머 설정
|
|
|
|
|
|
debounceTimer.current = setTimeout(() => {
|
|
|
|
|
|
setDebouncedSearchTerm(searchTerm);
|
|
|
|
|
|
}, 150);
|
|
|
|
|
|
|
|
|
|
|
|
// 클린업
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (debounceTimer.current) {
|
|
|
|
|
|
clearTimeout(debounceTimer.current);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [searchTerm]);
|
|
|
|
|
|
|
|
|
|
|
|
// 화면 목록 로드 (실제 API) - debouncedSearchTerm 사용
|
2025-09-01 11:48:12 +09:00
|
|
|
|
useEffect(() => {
|
2025-09-01 17:57:52 +09:00
|
|
|
|
let abort = false;
|
|
|
|
|
|
const load = async () => {
|
|
|
|
|
|
try {
|
2025-11-19 13:22:49 +09:00
|
|
|
|
// 첫 로딩인 경우에만 loading=true, 그 외에는 isSearching=true
|
|
|
|
|
|
if (isFirstLoad.current) {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
isFirstLoad.current = false; // 첫 로딩 완료 표시
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setIsSearching(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-08 13:10:09 +09:00
|
|
|
|
if (activeTab === "active") {
|
2025-11-19 13:22:49 +09:00
|
|
|
|
const params: any = { page: currentPage, size: 20, searchTerm: debouncedSearchTerm };
|
|
|
|
|
|
|
|
|
|
|
|
// 최고 관리자이고 특정 회사를 선택한 경우
|
|
|
|
|
|
if (isSuperAdmin && selectedCompanyCode !== "all") {
|
|
|
|
|
|
params.companyCode = selectedCompanyCode;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-05 10:05:31 +09:00
|
|
|
|
// 그룹 필터
|
|
|
|
|
|
if (selectedGroupId !== "all") {
|
|
|
|
|
|
params.groupId = selectedGroupId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-19 13:22:49 +09:00
|
|
|
|
console.log("🔍 화면 목록 API 호출:", params); // 디버깅용
|
|
|
|
|
|
const resp = await screenApi.getScreens(params);
|
|
|
|
|
|
console.log("✅ 화면 목록 응답:", resp); // 디버깅용
|
|
|
|
|
|
|
2025-09-08 13:10:09 +09:00
|
|
|
|
if (abort) return;
|
|
|
|
|
|
setScreens(resp.data || []);
|
|
|
|
|
|
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
|
|
|
|
|
} else if (activeTab === "trash") {
|
|
|
|
|
|
const resp = await screenApi.getDeletedScreens({ page: currentPage, size: 20 });
|
|
|
|
|
|
if (abort) return;
|
|
|
|
|
|
setDeletedScreens(resp.data || []);
|
|
|
|
|
|
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
|
|
|
|
|
}
|
2025-09-01 17:57:52 +09:00
|
|
|
|
} catch (e) {
|
2025-11-19 13:22:49 +09:00
|
|
|
|
console.error("화면 목록 조회 실패", e);
|
2025-09-08 13:10:09 +09:00
|
|
|
|
if (activeTab === "active") {
|
|
|
|
|
|
setScreens([]);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setDeletedScreens([]);
|
|
|
|
|
|
}
|
2025-09-01 17:57:52 +09:00
|
|
|
|
setTotalPages(1);
|
|
|
|
|
|
} finally {
|
2025-11-19 13:22:49 +09:00
|
|
|
|
if (!abort) {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
setIsSearching(false);
|
|
|
|
|
|
}
|
2025-09-01 17:57:52 +09:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
load();
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
abort = true;
|
|
|
|
|
|
};
|
2026-01-05 10:05:31 +09:00
|
|
|
|
}, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, selectedGroupId, isSuperAdmin]);
|
2025-09-01 17:57:52 +09:00
|
|
|
|
|
|
|
|
|
|
const filteredScreens = screens; // 서버 필터 기준 사용
|
2025-09-01 11:48:12 +09:00
|
|
|
|
|
2025-09-03 18:23:47 +09:00
|
|
|
|
// 화면 목록 다시 로드
|
|
|
|
|
|
const reloadScreens = async () => {
|
|
|
|
|
|
try {
|
2025-11-19 13:22:49 +09:00
|
|
|
|
setIsSearching(true);
|
|
|
|
|
|
const params: any = { page: currentPage, size: 20, searchTerm: debouncedSearchTerm };
|
|
|
|
|
|
|
|
|
|
|
|
// 최고 관리자이고 특정 회사를 선택한 경우
|
|
|
|
|
|
if (isSuperAdmin && selectedCompanyCode !== "all") {
|
|
|
|
|
|
params.companyCode = selectedCompanyCode;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const resp = await screenApi.getScreens(params);
|
2025-09-03 18:23:47 +09:00
|
|
|
|
setScreens(resp.data || []);
|
|
|
|
|
|
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
|
|
|
|
|
} catch (e) {
|
2025-11-19 13:22:49 +09:00
|
|
|
|
console.error("화면 목록 조회 실패", e);
|
2025-09-03 18:23:47 +09:00
|
|
|
|
} finally {
|
2025-11-19 13:22:49 +09:00
|
|
|
|
setIsSearching(false);
|
2025-09-03 18:23:47 +09:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-01 11:48:12 +09:00
|
|
|
|
const handleScreenSelect = (screen: ScreenDefinition) => {
|
|
|
|
|
|
onScreenSelect(screen);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-20 15:30:00 +09:00
|
|
|
|
const handleEdit = async (screen: ScreenDefinition) => {
|
2025-10-15 18:31:40 +09:00
|
|
|
|
setScreenToEdit(screen);
|
2025-12-02 13:20:49 +09:00
|
|
|
|
|
|
|
|
|
|
// 데이터 소스 타입 결정
|
|
|
|
|
|
const isRestApi = screen.dataSourceType === "restapi" || screen.tableName?.startsWith("_restapi_");
|
|
|
|
|
|
|
2025-10-15 18:31:40 +09:00
|
|
|
|
setEditFormData({
|
|
|
|
|
|
screenName: screen.screenName,
|
|
|
|
|
|
description: screen.description || "",
|
|
|
|
|
|
isActive: screen.isActive,
|
2025-11-20 15:30:00 +09:00
|
|
|
|
tableName: screen.tableName || "",
|
2025-12-02 13:20:49 +09:00
|
|
|
|
dataSourceType: isRestApi ? "restapi" : "database",
|
|
|
|
|
|
restApiConnectionId: (screen as any).restApiConnectionId || null,
|
|
|
|
|
|
restApiEndpoint: (screen as any).restApiEndpoint || "",
|
|
|
|
|
|
restApiJsonPath: (screen as any).restApiJsonPath || "data",
|
2025-10-15 18:31:40 +09:00
|
|
|
|
});
|
|
|
|
|
|
setEditDialogOpen(true);
|
2025-11-20 15:30:00 +09:00
|
|
|
|
|
|
|
|
|
|
// 테이블 목록 로드
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoadingTables(true);
|
|
|
|
|
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
|
|
|
|
|
const response = await tableManagementApi.getTableList();
|
|
|
|
|
|
if (response.success && response.data) {
|
2025-11-25 09:34:44 +09:00
|
|
|
|
// tableName과 displayName 매핑 (백엔드에서 displayName으로 라벨을 반환함)
|
|
|
|
|
|
const tableList = response.data.map((table: any) => ({
|
|
|
|
|
|
tableName: table.tableName,
|
|
|
|
|
|
tableLabel: table.displayName || table.tableName,
|
|
|
|
|
|
}));
|
|
|
|
|
|
setTables(tableList);
|
2025-11-20 15:30:00 +09:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("테이블 목록 조회 실패:", error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingTables(false);
|
|
|
|
|
|
}
|
2025-12-02 13:20:49 +09:00
|
|
|
|
|
|
|
|
|
|
// REST API 연결 목록 로드
|
|
|
|
|
|
try {
|
|
|
|
|
|
const connections = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" });
|
|
|
|
|
|
setEditRestApiConnections(connections);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("REST API 연결 목록 조회 실패:", error);
|
|
|
|
|
|
setEditRestApiConnections([]);
|
|
|
|
|
|
}
|
2025-10-15 18:31:40 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleEditSave = async () => {
|
|
|
|
|
|
if (!screenToEdit) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2025-12-02 13:20:49 +09:00
|
|
|
|
// 데이터 소스 타입에 따라 업데이트 데이터 구성
|
|
|
|
|
|
const updateData: any = {
|
|
|
|
|
|
screenName: editFormData.screenName,
|
|
|
|
|
|
description: editFormData.description,
|
|
|
|
|
|
isActive: editFormData.isActive,
|
|
|
|
|
|
dataSourceType: editFormData.dataSourceType,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (editFormData.dataSourceType === "database") {
|
|
|
|
|
|
updateData.tableName = editFormData.tableName;
|
|
|
|
|
|
updateData.restApiConnectionId = null;
|
|
|
|
|
|
updateData.restApiEndpoint = null;
|
|
|
|
|
|
updateData.restApiJsonPath = null;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// REST API
|
|
|
|
|
|
updateData.tableName = `_restapi_${editFormData.restApiConnectionId}`;
|
|
|
|
|
|
updateData.restApiConnectionId = editFormData.restApiConnectionId;
|
|
|
|
|
|
updateData.restApiEndpoint = editFormData.restApiEndpoint;
|
|
|
|
|
|
updateData.restApiJsonPath = editFormData.restApiJsonPath || "data";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log("📤 화면 편집 저장 요청:", {
|
|
|
|
|
|
screenId: screenToEdit.screenId,
|
|
|
|
|
|
editFormData,
|
|
|
|
|
|
updateData,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-10-15 18:31:40 +09:00
|
|
|
|
// 화면 정보 업데이트 API 호출
|
2025-12-02 13:20:49 +09:00
|
|
|
|
await screenApi.updateScreenInfo(screenToEdit.screenId, updateData);
|
2025-10-15 18:31:40 +09:00
|
|
|
|
|
2025-11-25 09:34:44 +09:00
|
|
|
|
// 선택된 테이블의 라벨 찾기
|
|
|
|
|
|
const selectedTable = tables.find((t) => t.tableName === editFormData.tableName);
|
|
|
|
|
|
const tableLabel = selectedTable?.tableLabel || editFormData.tableName;
|
|
|
|
|
|
|
2025-10-15 18:31:40 +09:00
|
|
|
|
// 목록에서 해당 화면 정보 업데이트
|
|
|
|
|
|
setScreens((prev) =>
|
|
|
|
|
|
prev.map((s) =>
|
|
|
|
|
|
s.screenId === screenToEdit.screenId
|
|
|
|
|
|
? {
|
|
|
|
|
|
...s,
|
|
|
|
|
|
screenName: editFormData.screenName,
|
2025-12-02 13:20:49 +09:00
|
|
|
|
tableName: updateData.tableName,
|
2025-11-25 09:34:44 +09:00
|
|
|
|
tableLabel: tableLabel,
|
2025-10-15 18:31:40 +09:00
|
|
|
|
description: editFormData.description,
|
|
|
|
|
|
isActive: editFormData.isActive,
|
2025-12-02 13:20:49 +09:00
|
|
|
|
dataSourceType: editFormData.dataSourceType,
|
2025-10-15 18:31:40 +09:00
|
|
|
|
}
|
|
|
|
|
|
: s,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
setEditDialogOpen(false);
|
|
|
|
|
|
setScreenToEdit(null);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("화면 정보 업데이트 실패:", error);
|
|
|
|
|
|
alert("화면 정보 업데이트에 실패했습니다.");
|
|
|
|
|
|
}
|
2025-09-01 11:48:12 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-08 13:10:09 +09:00
|
|
|
|
const handleDelete = async (screen: ScreenDefinition) => {
|
|
|
|
|
|
setScreenToDelete(screen);
|
|
|
|
|
|
setCheckingDependencies(true);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 의존성 체크
|
|
|
|
|
|
const dependencyResult = await screenApi.checkScreenDependencies(screen.screenId);
|
|
|
|
|
|
|
|
|
|
|
|
if (dependencyResult.hasDependencies) {
|
|
|
|
|
|
setDependencies(dependencyResult.dependencies);
|
|
|
|
|
|
setShowDependencyWarning(true);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setDeleteDialogOpen(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2025-10-01 18:17:30 +09:00
|
|
|
|
// console.error("의존성 체크 실패:", error);
|
2025-09-08 13:10:09 +09:00
|
|
|
|
// 의존성 체크 실패 시에도 삭제 다이얼로그는 열어줌
|
|
|
|
|
|
setDeleteDialogOpen(true);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setCheckingDependencies(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const confirmDelete = async (force: boolean = false) => {
|
|
|
|
|
|
if (!screenToDelete) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await screenApi.deleteScreen(screenToDelete.screenId, deleteReason, force);
|
|
|
|
|
|
setScreens((prev) => prev.filter((s) => s.screenId !== screenToDelete.screenId));
|
|
|
|
|
|
setDeleteDialogOpen(false);
|
|
|
|
|
|
setShowDependencyWarning(false);
|
|
|
|
|
|
setScreenToDelete(null);
|
|
|
|
|
|
setDeleteReason("");
|
|
|
|
|
|
setDependencies([]);
|
|
|
|
|
|
} catch (error: any) {
|
2025-10-01 18:17:30 +09:00
|
|
|
|
// console.error("화면 삭제 실패:", error);
|
2025-09-08 13:10:09 +09:00
|
|
|
|
|
|
|
|
|
|
// 의존성 오류인 경우 경고창 표시
|
|
|
|
|
|
if (error.response?.status === 409 && error.response?.data?.code === "SCREEN_HAS_DEPENDENCIES") {
|
|
|
|
|
|
setDependencies(error.response.data.dependencies || []);
|
|
|
|
|
|
setShowDependencyWarning(true);
|
|
|
|
|
|
setDeleteDialogOpen(false);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert("화면 삭제에 실패했습니다.");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCancelDelete = () => {
|
|
|
|
|
|
setDeleteDialogOpen(false);
|
|
|
|
|
|
setShowDependencyWarning(false);
|
|
|
|
|
|
setScreenToDelete(null);
|
|
|
|
|
|
setDeleteReason("");
|
|
|
|
|
|
setDependencies([]);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleRestore = async (screen: DeletedScreenDefinition) => {
|
|
|
|
|
|
if (!confirm(`"${screen.screenName}" 화면을 복원하시겠습니까?`)) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await screenApi.restoreScreen(screen.screenId);
|
|
|
|
|
|
setDeletedScreens((prev) => prev.filter((s) => s.screenId !== screen.screenId));
|
|
|
|
|
|
// 활성 탭으로 이동하여 복원된 화면 확인
|
|
|
|
|
|
setActiveTab("active");
|
|
|
|
|
|
reloadScreens();
|
|
|
|
|
|
} catch (error) {
|
2025-10-01 18:17:30 +09:00
|
|
|
|
// console.error("화면 복원 실패:", error);
|
2025-09-08 13:10:09 +09:00
|
|
|
|
alert("화면 복원에 실패했습니다.");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handlePermanentDelete = (screen: DeletedScreenDefinition) => {
|
|
|
|
|
|
setScreenToPermanentDelete(screen);
|
|
|
|
|
|
setPermanentDeleteDialogOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const confirmPermanentDelete = async () => {
|
|
|
|
|
|
if (!screenToPermanentDelete) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await screenApi.permanentDeleteScreen(screenToPermanentDelete.screenId);
|
|
|
|
|
|
setDeletedScreens((prev) => prev.filter((s) => s.screenId !== screenToPermanentDelete.screenId));
|
|
|
|
|
|
setPermanentDeleteDialogOpen(false);
|
|
|
|
|
|
setScreenToPermanentDelete(null);
|
|
|
|
|
|
} catch (error) {
|
2025-10-01 18:17:30 +09:00
|
|
|
|
// console.error("화면 영구 삭제 실패:", error);
|
2025-09-08 13:10:09 +09:00
|
|
|
|
alert("화면 영구 삭제에 실패했습니다.");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-03 16:02:09 +09:00
|
|
|
|
// 휴지통 체크박스 선택 처리
|
2025-09-08 13:10:09 +09:00
|
|
|
|
const handleScreenCheck = (screenId: number, checked: boolean) => {
|
|
|
|
|
|
if (checked) {
|
|
|
|
|
|
setSelectedScreenIds((prev) => [...prev, screenId]);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setSelectedScreenIds((prev) => prev.filter((id) => id !== screenId));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-03 16:02:09 +09:00
|
|
|
|
// 휴지통 전체 선택/해제
|
2025-09-08 13:10:09 +09:00
|
|
|
|
const handleSelectAll = (checked: boolean) => {
|
|
|
|
|
|
if (checked) {
|
|
|
|
|
|
setSelectedScreenIds(deletedScreens.map((screen) => screen.screenId));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setSelectedScreenIds([]);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-03 16:02:09 +09:00
|
|
|
|
// 휴지통 일괄삭제 실행
|
2025-09-08 13:10:09 +09:00
|
|
|
|
const handleBulkDelete = () => {
|
|
|
|
|
|
if (selectedScreenIds.length === 0) {
|
|
|
|
|
|
alert("삭제할 화면을 선택해주세요.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setBulkDeleteDialogOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-03 16:02:09 +09:00
|
|
|
|
// 활성 화면 체크박스 선택 처리
|
|
|
|
|
|
const handleActiveScreenCheck = (screenId: number, checked: boolean) => {
|
|
|
|
|
|
if (checked) {
|
|
|
|
|
|
setSelectedActiveScreenIds((prev) => [...prev, screenId]);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setSelectedActiveScreenIds((prev) => prev.filter((id) => id !== screenId));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 활성 화면 전체 선택/해제
|
|
|
|
|
|
const handleActiveSelectAll = (checked: boolean) => {
|
|
|
|
|
|
if (checked) {
|
|
|
|
|
|
setSelectedActiveScreenIds(screens.map((screen) => screen.screenId));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setSelectedActiveScreenIds([]);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 활성 화면 일괄삭제 실행
|
|
|
|
|
|
const handleActiveBulkDelete = () => {
|
|
|
|
|
|
if (selectedActiveScreenIds.length === 0) {
|
|
|
|
|
|
alert("삭제할 화면을 선택해주세요.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setActiveBulkDeleteDialogOpen(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 활성 화면 일괄삭제 확인
|
|
|
|
|
|
const confirmActiveBulkDelete = async () => {
|
|
|
|
|
|
if (selectedActiveScreenIds.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
setActiveBulkDeleting(true);
|
|
|
|
|
|
const result = await screenApi.bulkDeleteScreens(
|
|
|
|
|
|
selectedActiveScreenIds,
|
|
|
|
|
|
activeBulkDeleteReason || undefined,
|
|
|
|
|
|
true // 강제 삭제 (의존성 무시)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 삭제된 화면들을 목록에서 제거
|
|
|
|
|
|
setScreens((prev) => prev.filter((screen) => !selectedActiveScreenIds.includes(screen.screenId)));
|
|
|
|
|
|
|
|
|
|
|
|
setSelectedActiveScreenIds([]);
|
|
|
|
|
|
setActiveBulkDeleteDialogOpen(false);
|
|
|
|
|
|
setActiveBulkDeleteReason("");
|
|
|
|
|
|
|
|
|
|
|
|
// 결과 메시지 표시
|
|
|
|
|
|
let message = `${result.deletedCount}개 화면이 휴지통으로 이동되었습니다.`;
|
|
|
|
|
|
if (result.skippedCount > 0) {
|
|
|
|
|
|
message += `\n${result.skippedCount}개 화면은 삭제되지 않았습니다.`;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (result.errors.length > 0) {
|
|
|
|
|
|
message += `\n오류 발생: ${result.errors.map((e) => `화면 ${e.screenId}: ${e.error}`).join(", ")}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
alert(message);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("일괄 삭제 실패:", error);
|
|
|
|
|
|
alert("일괄 삭제에 실패했습니다.");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setActiveBulkDeleting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-08 13:10:09 +09:00
|
|
|
|
const confirmBulkDelete = async () => {
|
|
|
|
|
|
if (selectedScreenIds.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
setBulkDeleting(true);
|
|
|
|
|
|
const result = await screenApi.bulkPermanentDeleteScreens(selectedScreenIds);
|
|
|
|
|
|
|
|
|
|
|
|
// 삭제된 화면들을 목록에서 제거
|
|
|
|
|
|
setDeletedScreens((prev) => prev.filter((screen) => !selectedScreenIds.includes(screen.screenId)));
|
|
|
|
|
|
|
|
|
|
|
|
setSelectedScreenIds([]);
|
|
|
|
|
|
setBulkDeleteDialogOpen(false);
|
|
|
|
|
|
|
|
|
|
|
|
// 결과 메시지 표시
|
|
|
|
|
|
let message = `${result.deletedCount}개 화면이 영구 삭제되었습니다.`;
|
|
|
|
|
|
if (result.skippedCount > 0) {
|
|
|
|
|
|
message += `\n${result.skippedCount}개 화면은 삭제되지 않았습니다.`;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (result.errors.length > 0) {
|
|
|
|
|
|
message += `\n오류 발생: ${result.errors.map((e) => `화면 ${e.screenId}: ${e.error}`).join(", ")}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
alert(message);
|
|
|
|
|
|
} catch (error) {
|
2025-10-01 18:17:30 +09:00
|
|
|
|
// console.error("일괄 삭제 실패:", error);
|
2025-09-08 13:10:09 +09:00
|
|
|
|
alert("일괄 삭제에 실패했습니다.");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBulkDeleting(false);
|
2025-09-01 11:48:12 +09:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCopy = (screen: ScreenDefinition) => {
|
2025-09-03 18:23:47 +09:00
|
|
|
|
setScreenToCopy(screen);
|
|
|
|
|
|
setIsCopyOpen(true);
|
2025-09-01 11:48:12 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-15 18:31:40 +09:00
|
|
|
|
const handleView = async (screen: ScreenDefinition) => {
|
|
|
|
|
|
setScreenToPreview(screen);
|
2025-10-16 15:05:24 +09:00
|
|
|
|
setPreviewLayout(null); // 이전 레이아웃 초기화
|
2025-10-15 18:31:40 +09:00
|
|
|
|
setIsLoadingPreview(true);
|
2025-10-16 15:05:24 +09:00
|
|
|
|
setPreviewDialogOpen(true); // 모달 먼저 열기
|
2025-10-15 18:31:40 +09:00
|
|
|
|
|
2025-10-16 15:05:24 +09:00
|
|
|
|
// 모달이 열린 후에 레이아웃 로드
|
|
|
|
|
|
setTimeout(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 화면 레이아웃 로드
|
|
|
|
|
|
const layoutData = await screenApi.getLayout(screen.screenId);
|
|
|
|
|
|
console.log("📊 미리보기 레이아웃 로드:", layoutData);
|
|
|
|
|
|
setPreviewLayout(layoutData);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("❌ 레이아웃 로드 실패:", error);
|
|
|
|
|
|
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsLoadingPreview(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 100); // 100ms 딜레이로 모달 애니메이션이 먼저 시작되도록
|
2025-09-01 11:48:12 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-03 18:23:47 +09:00
|
|
|
|
const handleCopySuccess = () => {
|
|
|
|
|
|
// 복사 성공 후 화면 목록 다시 로드
|
|
|
|
|
|
reloadScreens();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-01 11:48:12 +09:00
|
|
|
|
if (loading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex items-center justify-center py-8">
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<div className="text-muted-foreground text-sm">로딩 중...</div>
|
2025-09-01 11:48:12 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
{/* 검색 및 필터 */}
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
2025-11-19 13:22:49 +09:00
|
|
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
|
|
|
|
|
{/* 최고 관리자 전용: 회사 필터 */}
|
|
|
|
|
|
{isSuperAdmin && (
|
|
|
|
|
|
<div className="w-full sm:w-[200px]">
|
|
|
|
|
|
<Select value={selectedCompanyCode} onValueChange={setSelectedCompanyCode} disabled={activeTab === "trash"}>
|
|
|
|
|
|
<SelectTrigger className="h-10 text-sm">
|
|
|
|
|
|
<SelectValue placeholder="전체 회사" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="all">전체 회사</SelectItem>
|
|
|
|
|
|
{companies.map((company) => (
|
|
|
|
|
|
<SelectItem key={company.companyCode} value={company.companyCode}>
|
|
|
|
|
|
{company.companyName}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-01-05 10:05:31 +09:00
|
|
|
|
{/* 그룹 필터 */}
|
|
|
|
|
|
<div className="w-full sm:w-[180px]">
|
|
|
|
|
|
<Select value={selectedGroupId} onValueChange={setSelectedGroupId} disabled={activeTab === "trash"}>
|
|
|
|
|
|
<SelectTrigger className="h-10 text-sm">
|
|
|
|
|
|
<Layers className="mr-2 h-4 w-4 text-muted-foreground" />
|
|
|
|
|
|
<SelectValue placeholder="전체 그룹" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="all">전체 그룹</SelectItem>
|
|
|
|
|
|
<SelectItem value="ungrouped">미분류</SelectItem>
|
|
|
|
|
|
{groups.map((group) => (
|
|
|
|
|
|
<SelectItem key={group.id} value={String(group.id)}>
|
|
|
|
|
|
{group.groupName}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-11-19 13:22:49 +09:00
|
|
|
|
{/* 검색 입력 */}
|
|
|
|
|
|
<div className="w-full sm:w-[400px]">
|
|
|
|
|
|
<div className="relative">
|
|
|
|
|
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
|
|
|
|
|
<Input
|
|
|
|
|
|
key="screen-search-input" // 리렌더링 시에도 동일한 Input 유지
|
|
|
|
|
|
placeholder="화면명, 코드, 테이블명으로 검색..."
|
|
|
|
|
|
value={searchTerm}
|
|
|
|
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
|
|
|
|
className="h-10 pl-10 text-sm"
|
|
|
|
|
|
disabled={activeTab === "trash"}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{/* 검색 중 인디케이터 */}
|
|
|
|
|
|
{isSearching && (
|
|
|
|
|
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
|
|
|
|
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-09-01 11:48:12 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-11-19 13:22:49 +09:00
|
|
|
|
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<Button
|
|
|
|
|
|
onClick={() => setIsCreateOpen(true)}
|
|
|
|
|
|
disabled={activeTab === "trash"}
|
|
|
|
|
|
className="h-10 gap-2 text-sm font-medium"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus className="h-4 w-4" />새 화면 생성
|
2025-09-01 11:48:12 +09:00
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-09-08 13:10:09 +09:00
|
|
|
|
{/* 탭 구조 */}
|
2025-12-03 16:02:09 +09:00
|
|
|
|
<Tabs value={activeTab} onValueChange={(value) => {
|
|
|
|
|
|
setActiveTab(value);
|
|
|
|
|
|
// 탭 전환 시 선택 상태 초기화
|
|
|
|
|
|
setSelectedActiveScreenIds([]);
|
|
|
|
|
|
setSelectedScreenIds([]);
|
|
|
|
|
|
}}>
|
2025-09-08 13:10:09 +09:00
|
|
|
|
<TabsList className="grid w-full grid-cols-2">
|
|
|
|
|
|
<TabsTrigger value="active">활성 화면</TabsTrigger>
|
|
|
|
|
|
<TabsTrigger value="trash">휴지통</TabsTrigger>
|
|
|
|
|
|
</TabsList>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 활성 화면 탭 */}
|
|
|
|
|
|
<TabsContent value="active">
|
2025-12-03 16:02:09 +09:00
|
|
|
|
{/* 선택 삭제 헤더 (선택된 항목이 있을 때만 표시) */}
|
|
|
|
|
|
{selectedActiveScreenIds.length > 0 && (
|
|
|
|
|
|
<div className="bg-muted/50 mb-4 flex items-center justify-between rounded-lg border p-3">
|
|
|
|
|
|
<span className="text-sm font-medium">
|
|
|
|
|
|
{selectedActiveScreenIds.length}개 화면 선택됨
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => setSelectedActiveScreenIds([])}
|
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
선택 해제
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="destructive"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={handleActiveBulkDelete}
|
|
|
|
|
|
disabled={activeBulkDeleting}
|
|
|
|
|
|
className="h-8 gap-1 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
|
|
|
|
{activeBulkDeleting ? "삭제 중..." : "선택 삭제"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-10-22 14:52:13 +09:00
|
|
|
|
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
2025-10-30 17:02:30 +09:00
|
|
|
|
<div className="bg-card hidden shadow-sm lg:block">
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<Table>
|
|
|
|
|
|
<TableHeader>
|
2025-10-30 17:02:30 +09:00
|
|
|
|
<TableRow>
|
2025-12-03 16:02:09 +09:00
|
|
|
|
<TableHead className="h-12 w-12 px-4 py-3">
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
checked={screens.length > 0 && selectedActiveScreenIds.length === screens.length}
|
|
|
|
|
|
onCheckedChange={handleActiveSelectAll}
|
|
|
|
|
|
aria-label="전체 선택"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TableHead>
|
2025-10-30 17:02:30 +09:00
|
|
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">화면명</TableHead>
|
|
|
|
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">테이블명</TableHead>
|
|
|
|
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">상태</TableHead>
|
|
|
|
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">생성일</TableHead>
|
|
|
|
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">작업</TableHead>
|
2025-10-22 14:52:13 +09:00
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableHeader>
|
|
|
|
|
|
<TableBody>
|
|
|
|
|
|
{screens.map((screen) => (
|
|
|
|
|
|
<TableRow
|
|
|
|
|
|
key={screen.screenId}
|
2025-10-30 17:02:30 +09:00
|
|
|
|
className={`bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors ${
|
2025-10-22 14:52:13 +09:00
|
|
|
|
selectedScreen?.screenId === screen.screenId ? "border-primary/20 bg-accent" : ""
|
2025-12-03 16:02:09 +09:00
|
|
|
|
} ${selectedActiveScreenIds.includes(screen.screenId) ? "bg-muted/30" : ""}`}
|
2025-10-28 15:39:22 +09:00
|
|
|
|
onClick={() => onDesignScreen(screen)}
|
2025-10-22 14:52:13 +09:00
|
|
|
|
>
|
2025-12-03 16:02:09 +09:00
|
|
|
|
<TableCell className="h-16 px-4 py-3">
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
checked={selectedActiveScreenIds.includes(screen.screenId)}
|
|
|
|
|
|
onCheckedChange={(checked) => handleActiveScreenCheck(screen.screenId, checked as boolean)}
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
aria-label={`${screen.screenName} 선택`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TableCell>
|
2025-10-30 17:02:30 +09:00
|
|
|
|
<TableCell className="h-16 px-6 py-3 cursor-pointer">
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<div>
|
|
|
|
|
|
<div className="font-medium">{screen.screenName}</div>
|
|
|
|
|
|
{screen.description && (
|
|
|
|
|
|
<div className="text-muted-foreground mt-1 text-sm">{screen.description}</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
2025-10-30 17:02:30 +09:00
|
|
|
|
<TableCell className="h-16 px-6 py-3">
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<span className="text-muted-foreground font-mono text-sm">
|
|
|
|
|
|
{screen.tableLabel || screen.tableName}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</TableCell>
|
2025-10-30 17:02:30 +09:00
|
|
|
|
<TableCell className="h-16 px-6 py-3">
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<Badge variant={screen.isActive === "Y" ? "default" : "secondary"}>
|
|
|
|
|
|
{screen.isActive === "Y" ? "활성" : "비활성"}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</TableCell>
|
2025-10-30 17:02:30 +09:00
|
|
|
|
<TableCell className="h-16 px-6 py-3">
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<div className="text-muted-foreground text-sm">{screen.createdDate.toLocaleDateString()}</div>
|
|
|
|
|
|
<div className="text-muted-foreground text-xs">{screen.createdBy}</div>
|
|
|
|
|
|
</TableCell>
|
2025-10-30 17:02:30 +09:00
|
|
|
|
<TableCell className="h-16 px-6 py-3">
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<DropdownMenu>
|
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
|
|
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
|
<DropdownMenuContent align="end">
|
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onDesignScreen(screen);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Palette className="mr-2 h-4 w-4" />
|
|
|
|
|
|
화면 설계
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
handleView(screen);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Eye className="mr-2 h-4 w-4" />
|
|
|
|
|
|
미리보기
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
handleEdit(screen);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Edit className="mr-2 h-4 w-4" />
|
|
|
|
|
|
편집
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
handleCopy(screen);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Copy className="mr-2 h-4 w-4" />
|
|
|
|
|
|
복사
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
handleDelete(screen);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-destructive"
|
|
|
|
|
|
disabled={checkingDependencies && screenToDelete?.screenId === screen.screenId}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
|
|
|
|
{checkingDependencies && screenToDelete?.screenId === screen.screenId
|
|
|
|
|
|
? "확인 중..."
|
|
|
|
|
|
: "삭제"}
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
|
</TableCell>
|
2025-09-08 13:10:09 +09:00
|
|
|
|
</TableRow>
|
2025-10-22 14:52:13 +09:00
|
|
|
|
))}
|
|
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
|
|
|
|
|
|
{filteredScreens.length === 0 && (
|
|
|
|
|
|
<div className="flex h-64 flex-col items-center justify-center">
|
|
|
|
|
|
<p className="text-muted-foreground text-sm">검색 결과가 없습니다.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
2025-12-03 16:02:09 +09:00
|
|
|
|
<div className="space-y-4 lg:hidden">
|
|
|
|
|
|
{/* 선택 헤더 */}
|
|
|
|
|
|
<div className="bg-card flex items-center justify-between rounded-lg border p-4 shadow-sm">
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
checked={screens.length > 0 && selectedActiveScreenIds.length === screens.length}
|
|
|
|
|
|
onCheckedChange={handleActiveSelectAll}
|
|
|
|
|
|
aria-label="전체 선택"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-sm text-muted-foreground">전체 선택</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{selectedActiveScreenIds.length > 0 && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="destructive"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={handleActiveBulkDelete}
|
|
|
|
|
|
disabled={activeBulkDeleting}
|
|
|
|
|
|
className="h-9 gap-2 text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="h-4 w-4" />
|
|
|
|
|
|
{activeBulkDeleting ? "삭제 중..." : `${selectedActiveScreenIds.length}개 삭제`}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 카드 목록 */}
|
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
|
|
|
|
{screens.map((screen) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={screen.screenId}
|
|
|
|
|
|
className={`bg-card hover:bg-muted/50 cursor-pointer rounded-lg border p-4 shadow-sm transition-colors ${
|
|
|
|
|
|
selectedScreen?.screenId === screen.screenId ? "border-primary bg-accent" : ""
|
|
|
|
|
|
} ${selectedActiveScreenIds.includes(screen.screenId) ? "bg-muted/30 border-primary/50" : ""}`}
|
|
|
|
|
|
onClick={() => handleScreenSelect(screen)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
|
<div className="mb-4 flex items-start gap-3">
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
checked={selectedActiveScreenIds.includes(screen.screenId)}
|
|
|
|
|
|
onCheckedChange={(checked) => handleActiveScreenCheck(screen.screenId, checked as boolean)}
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
className="mt-1"
|
|
|
|
|
|
aria-label={`${screen.screenName} 선택`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
|
<h3 className="text-base font-semibold">{screen.screenName}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Badge variant={screen.isActive === "Y" ? "default" : "secondary"}>
|
|
|
|
|
|
{screen.isActive === "Y" ? "활성" : "비활성"}
|
|
|
|
|
|
</Badge>
|
2025-10-22 14:52:13 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 설명 */}
|
|
|
|
|
|
{screen.description && <p className="text-muted-foreground mb-4 text-sm">{screen.description}</p>}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 정보 */}
|
|
|
|
|
|
<div className="space-y-2 border-t pt-4">
|
|
|
|
|
|
<div className="flex justify-between text-sm">
|
|
|
|
|
|
<span className="text-muted-foreground">테이블</span>
|
|
|
|
|
|
<span className="font-mono font-medium">{screen.tableLabel || screen.tableName}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex justify-between text-sm">
|
|
|
|
|
|
<span className="text-muted-foreground">생성일</span>
|
|
|
|
|
|
<span className="font-medium">{screen.createdDate.toLocaleDateString()}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex justify-between text-sm">
|
|
|
|
|
|
<span className="text-muted-foreground">작성자</span>
|
|
|
|
|
|
<span className="font-medium">{screen.createdBy}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 액션 */}
|
|
|
|
|
|
<div className="mt-4 flex gap-2 border-t pt-4">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onDesignScreen(screen);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="h-9 flex-1 gap-2 text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Palette className="h-4 w-4" />
|
|
|
|
|
|
설계
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
handleView(screen);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="h-9 flex-1 gap-2 text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Eye className="h-4 w-4" />
|
|
|
|
|
|
미리보기
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
|
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
|
<Button variant="outline" size="sm" className="h-9 px-3">
|
|
|
|
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
|
<DropdownMenuContent align="end">
|
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
handleEdit(screen);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Edit className="mr-2 h-4 w-4" />
|
|
|
|
|
|
편집
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
handleCopy(screen);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Copy className="mr-2 h-4 w-4" />
|
|
|
|
|
|
복사
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
handleDelete(screen);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-destructive"
|
|
|
|
|
|
disabled={checkingDependencies && screenToDelete?.screenId === screen.screenId}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
|
|
|
|
{checkingDependencies && screenToDelete?.screenId === screen.screenId ? "확인 중..." : "삭제"}
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
2025-12-03 16:02:09 +09:00
|
|
|
|
{filteredScreens.length === 0 && (
|
|
|
|
|
|
<div className="bg-card col-span-2 flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
|
|
|
|
|
<p className="text-muted-foreground text-sm">검색 결과가 없습니다.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-10-22 14:52:13 +09:00
|
|
|
|
</div>
|
2025-09-08 13:10:09 +09:00
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 휴지통 탭 */}
|
|
|
|
|
|
<TabsContent value="trash">
|
2025-10-22 14:52:13 +09:00
|
|
|
|
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
2025-10-30 17:02:30 +09:00
|
|
|
|
<div className="bg-card hidden shadow-sm lg:block">
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<Table>
|
|
|
|
|
|
<TableHeader>
|
2025-10-30 17:02:30 +09:00
|
|
|
|
<TableRow>
|
|
|
|
|
|
<TableHead className="h-12 w-12 px-6 py-3">
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<Checkbox
|
|
|
|
|
|
checked={deletedScreens.length > 0 && selectedScreenIds.length === deletedScreens.length}
|
|
|
|
|
|
onCheckedChange={handleSelectAll}
|
|
|
|
|
|
aria-label="전체 선택"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TableHead>
|
2025-10-30 17:02:30 +09:00
|
|
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">화면명</TableHead>
|
|
|
|
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">테이블명</TableHead>
|
|
|
|
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">삭제일</TableHead>
|
|
|
|
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">삭제자</TableHead>
|
|
|
|
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">삭제 사유</TableHead>
|
|
|
|
|
|
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">작업</TableHead>
|
2025-10-22 14:52:13 +09:00
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableHeader>
|
|
|
|
|
|
<TableBody>
|
|
|
|
|
|
{deletedScreens.map((screen) => (
|
2025-10-30 17:02:30 +09:00
|
|
|
|
<TableRow key={screen.screenId} className="bg-background hover:bg-muted/50 border-b transition-colors">
|
|
|
|
|
|
<TableCell className="h-16 px-6 py-3">
|
2025-09-08 13:10:09 +09:00
|
|
|
|
<Checkbox
|
2025-10-22 14:52:13 +09:00
|
|
|
|
checked={selectedScreenIds.includes(screen.screenId)}
|
|
|
|
|
|
onCheckedChange={(checked) => handleScreenCheck(screen.screenId, checked as boolean)}
|
|
|
|
|
|
aria-label={`${screen.screenName} 선택`}
|
2025-09-08 13:10:09 +09:00
|
|
|
|
/>
|
2025-10-22 14:52:13 +09:00
|
|
|
|
</TableCell>
|
2025-10-30 17:02:30 +09:00
|
|
|
|
<TableCell className="h-16 px-6 py-3">
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<div>
|
|
|
|
|
|
<div className="font-medium">{screen.screenName}</div>
|
|
|
|
|
|
{screen.description && (
|
|
|
|
|
|
<div className="text-muted-foreground mt-1 text-sm">{screen.description}</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
2025-10-30 17:02:30 +09:00
|
|
|
|
<TableCell className="h-16 px-6 py-3">
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<span className="text-muted-foreground font-mono text-sm">
|
|
|
|
|
|
{screen.tableLabel || screen.tableName}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</TableCell>
|
2025-10-30 17:02:30 +09:00
|
|
|
|
<TableCell className="h-16 px-6 py-3">
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<div className="text-muted-foreground text-sm">{screen.deletedDate?.toLocaleDateString()}</div>
|
|
|
|
|
|
</TableCell>
|
2025-10-30 17:02:30 +09:00
|
|
|
|
<TableCell className="h-16 px-6 py-3">
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<div className="text-muted-foreground text-sm">{screen.deletedBy}</div>
|
|
|
|
|
|
</TableCell>
|
2025-10-30 17:02:30 +09:00
|
|
|
|
<TableCell className="h-16 px-6 py-3">
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<div className="text-muted-foreground max-w-32 truncate text-sm" title={screen.deleteReason}>
|
|
|
|
|
|
{screen.deleteReason || "-"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
2025-10-30 17:02:30 +09:00
|
|
|
|
<TableCell className="h-16 px-6 py-3">
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => handleRestore(screen)}
|
|
|
|
|
|
className="text-primary hover:text-primary/80 h-9 gap-2 text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<RotateCcw className="h-4 w-4" />
|
|
|
|
|
|
복원
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="destructive"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => handlePermanentDelete(screen)}
|
|
|
|
|
|
className="h-9 gap-2 text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash className="h-4 w-4" />
|
|
|
|
|
|
영구삭제
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</TableCell>
|
2025-09-08 13:10:09 +09:00
|
|
|
|
</TableRow>
|
2025-10-22 14:52:13 +09:00
|
|
|
|
))}
|
|
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
|
|
|
|
|
|
|
|
|
|
|
{deletedScreens.length === 0 && (
|
|
|
|
|
|
<div className="flex h-64 flex-col items-center justify-center">
|
|
|
|
|
|
<p className="text-muted-foreground text-sm">휴지통이 비어있습니다.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 모바일/태블릿 카드 뷰 (lg 미만) */}
|
|
|
|
|
|
<div className="space-y-4 lg:hidden">
|
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
|
<div className="bg-card flex items-center justify-between rounded-lg border p-4 shadow-sm">
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
checked={deletedScreens.length > 0 && selectedScreenIds.length === deletedScreens.length}
|
|
|
|
|
|
onCheckedChange={handleSelectAll}
|
|
|
|
|
|
aria-label="전체 선택"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{selectedScreenIds.length > 0 && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="destructive"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={handleBulkDelete}
|
|
|
|
|
|
disabled={bulkDeleting}
|
|
|
|
|
|
className="h-9 gap-2 text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash className="h-4 w-4" />
|
|
|
|
|
|
{bulkDeleting ? "삭제 중..." : `${selectedScreenIds.length}개`}
|
|
|
|
|
|
</Button>
|
2025-09-08 13:10:09 +09:00
|
|
|
|
)}
|
2025-10-22 14:52:13 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 카드 목록 */}
|
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
|
|
|
|
{deletedScreens.map((screen) => (
|
|
|
|
|
|
<div key={screen.screenId} className="bg-card rounded-lg border p-4 shadow-sm">
|
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
|
<div className="mb-4 flex items-start gap-3">
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
checked={selectedScreenIds.includes(screen.screenId)}
|
|
|
|
|
|
onCheckedChange={(checked) => handleScreenCheck(screen.screenId, checked as boolean)}
|
|
|
|
|
|
className="mt-1"
|
|
|
|
|
|
aria-label={`${screen.screenName} 선택`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
|
<h3 className="text-base font-semibold">{screen.screenName}</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 설명 */}
|
|
|
|
|
|
{screen.description && <p className="text-muted-foreground mb-4 text-sm">{screen.description}</p>}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 정보 */}
|
|
|
|
|
|
<div className="space-y-2 border-t pt-4">
|
|
|
|
|
|
<div className="flex justify-between text-sm">
|
|
|
|
|
|
<span className="text-muted-foreground">테이블</span>
|
|
|
|
|
|
<span className="font-mono font-medium">{screen.tableLabel || screen.tableName}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex justify-between text-sm">
|
|
|
|
|
|
<span className="text-muted-foreground">삭제일</span>
|
|
|
|
|
|
<span className="font-medium">{screen.deletedDate?.toLocaleDateString()}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex justify-between text-sm">
|
|
|
|
|
|
<span className="text-muted-foreground">삭제자</span>
|
|
|
|
|
|
<span className="font-medium">{screen.deletedBy}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{screen.deleteReason && (
|
|
|
|
|
|
<div className="flex flex-col gap-1 text-sm">
|
|
|
|
|
|
<span className="text-muted-foreground">삭제 사유</span>
|
|
|
|
|
|
<span className="font-medium">{screen.deleteReason}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 액션 */}
|
|
|
|
|
|
<div className="mt-4 flex gap-2 border-t pt-4">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => handleRestore(screen)}
|
|
|
|
|
|
className="text-primary hover:text-primary/80 h-9 flex-1 gap-2 text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<RotateCcw className="h-4 w-4" />
|
|
|
|
|
|
복원
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="destructive"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => handlePermanentDelete(screen)}
|
|
|
|
|
|
className="h-9 flex-1 gap-2 text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash className="h-4 w-4" />
|
|
|
|
|
|
영구삭제
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{deletedScreens.length === 0 && (
|
|
|
|
|
|
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
|
|
|
|
|
<p className="text-muted-foreground text-sm">휴지통이 비어있습니다.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-09-08 13:10:09 +09:00
|
|
|
|
</TabsContent>
|
|
|
|
|
|
</Tabs>
|
2025-09-01 11:48:12 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 페이지네이션 */}
|
|
|
|
|
|
{totalPages > 1 && (
|
|
|
|
|
|
<div className="flex items-center justify-center space-x-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
|
|
|
|
|
disabled={currentPage === 1}
|
|
|
|
|
|
>
|
|
|
|
|
|
이전
|
|
|
|
|
|
</Button>
|
2025-10-15 18:31:40 +09:00
|
|
|
|
<span className="text-muted-foreground text-sm">
|
2025-09-01 11:48:12 +09:00
|
|
|
|
{currentPage} / {totalPages}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
|
|
|
|
|
disabled={currentPage === totalPages}
|
|
|
|
|
|
>
|
|
|
|
|
|
다음
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-09-01 17:57:52 +09:00
|
|
|
|
{/* 새 화면 생성 모달 */}
|
|
|
|
|
|
<CreateScreenModal
|
|
|
|
|
|
open={isCreateOpen}
|
|
|
|
|
|
onOpenChange={setIsCreateOpen}
|
|
|
|
|
|
onCreated={(created) => {
|
|
|
|
|
|
// 목록에 즉시 반영 (첫 페이지 기준 상단 추가)
|
|
|
|
|
|
setScreens((prev) => [created, ...prev]);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2025-09-03 18:23:47 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 화면 복사 모달 */}
|
|
|
|
|
|
<CopyScreenModal
|
|
|
|
|
|
isOpen={isCopyOpen}
|
|
|
|
|
|
onClose={() => setIsCopyOpen(false)}
|
|
|
|
|
|
sourceScreen={screenToCopy}
|
|
|
|
|
|
onCopySuccess={handleCopySuccess}
|
|
|
|
|
|
/>
|
2025-09-08 13:10:09 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 삭제 확인 다이얼로그 */}
|
|
|
|
|
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogTitle>화면 삭제 확인</AlertDialogTitle>
|
|
|
|
|
|
<AlertDialogDescription>
|
|
|
|
|
|
"{screenToDelete?.screenName}" 화면을 휴지통으로 이동하시겠습니까?
|
|
|
|
|
|
<br />
|
|
|
|
|
|
휴지통에서 언제든지 복원할 수 있습니다.
|
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="deleteReason">삭제 사유 (선택사항)</Label>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
id="deleteReason"
|
|
|
|
|
|
placeholder="삭제 사유를 입력하세요..."
|
|
|
|
|
|
value={deleteReason}
|
|
|
|
|
|
onChange={(e) => setDeleteReason(e.target.value)}
|
|
|
|
|
|
rows={3}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
|
<AlertDialogCancel onClick={handleCancelDelete}>취소</AlertDialogCancel>
|
2025-10-02 14:34:15 +09:00
|
|
|
|
<AlertDialogAction onClick={() => confirmDelete(false)} variant="destructive">
|
2025-09-08 13:10:09 +09:00
|
|
|
|
휴지통으로 이동
|
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 의존성 경고 다이얼로그 */}
|
|
|
|
|
|
<AlertDialog open={showDependencyWarning} onOpenChange={setShowDependencyWarning}>
|
|
|
|
|
|
<AlertDialogContent className="max-w-2xl">
|
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogTitle className="text-orange-600">⚠️ 화면 삭제 경고</AlertDialogTitle>
|
|
|
|
|
|
<AlertDialogDescription>
|
|
|
|
|
|
"{screenToDelete?.screenName}" 화면이 다른 화면에서 사용 중입니다.
|
|
|
|
|
|
<br />이 화면을 삭제하면 아래 화면들의 버튼 기능이 작동하지 않을 수 있습니다.
|
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="max-h-60 overflow-y-auto">
|
|
|
|
|
|
<div className="space-y-3">
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<h4 className="font-medium">사용 중인 화면 목록:</h4>
|
2025-09-08 13:10:09 +09:00
|
|
|
|
{dependencies.map((dep, index) => (
|
|
|
|
|
|
<div key={index} className="rounded-lg border border-orange-200 bg-orange-50 p-3">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<div className="font-medium">{dep.screenName}</div>
|
2025-10-15 18:31:40 +09:00
|
|
|
|
<div className="text-muted-foreground text-sm">화면 코드: {dep.screenCode}</div>
|
2025-09-08 13:10:09 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-right">
|
|
|
|
|
|
<div className="text-sm font-medium text-orange-600">
|
|
|
|
|
|
{dep.referenceType === "popup" && "팝업 버튼"}
|
|
|
|
|
|
{dep.referenceType === "navigate" && "이동 버튼"}
|
|
|
|
|
|
{dep.referenceType === "url" && "URL 링크"}
|
|
|
|
|
|
{dep.referenceType === "menu_assignment" && "메뉴 할당"}
|
|
|
|
|
|
</div>
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<div className="text-muted-foreground text-xs">
|
2025-09-08 13:10:09 +09:00
|
|
|
|
{dep.referenceType === "menu_assignment" ? "메뉴" : "컴포넌트"}: {dep.componentId}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="forceDeleteReason">삭제 사유 (필수)</Label>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
id="forceDeleteReason"
|
|
|
|
|
|
placeholder="강제 삭제 사유를 입력하세요..."
|
|
|
|
|
|
value={deleteReason}
|
|
|
|
|
|
onChange={(e) => setDeleteReason(e.target.value)}
|
|
|
|
|
|
rows={3}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
|
<AlertDialogCancel onClick={handleCancelDelete}>취소</AlertDialogCancel>
|
|
|
|
|
|
<AlertDialogAction
|
|
|
|
|
|
onClick={() => confirmDelete(true)}
|
2025-10-02 14:34:15 +09:00
|
|
|
|
variant="destructive"
|
2025-09-08 13:10:09 +09:00
|
|
|
|
disabled={!deleteReason.trim()}
|
|
|
|
|
|
>
|
|
|
|
|
|
강제 삭제
|
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 영구 삭제 확인 다이얼로그 */}
|
|
|
|
|
|
<AlertDialog open={permanentDeleteDialogOpen} onOpenChange={setPermanentDeleteDialogOpen}>
|
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogTitle>영구 삭제 확인</AlertDialogTitle>
|
2025-10-02 14:34:15 +09:00
|
|
|
|
<AlertDialogDescription className="text-destructive">
|
2025-09-08 13:10:09 +09:00
|
|
|
|
⚠️ "{screenToPermanentDelete?.screenName}" 화면을 영구적으로 삭제하시겠습니까?
|
|
|
|
|
|
<br />
|
|
|
|
|
|
<strong>이 작업은 되돌릴 수 없습니다!</strong>
|
|
|
|
|
|
<br />
|
|
|
|
|
|
모든 레이아웃 정보와 관련 데이터가 완전히 삭제됩니다.
|
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
|
<AlertDialogCancel
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setPermanentDeleteDialogOpen(false);
|
|
|
|
|
|
setScreenToPermanentDelete(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
취소
|
|
|
|
|
|
</AlertDialogCancel>
|
2025-10-02 14:34:15 +09:00
|
|
|
|
<AlertDialogAction onClick={confirmPermanentDelete} variant="destructive">
|
2025-09-08 13:10:09 +09:00
|
|
|
|
영구 삭제
|
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
|
2025-12-03 16:02:09 +09:00
|
|
|
|
{/* 휴지통 일괄삭제 확인 다이얼로그 */}
|
2025-09-08 13:10:09 +09:00
|
|
|
|
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogTitle>일괄 영구 삭제 확인</AlertDialogTitle>
|
2025-10-02 14:34:15 +09:00
|
|
|
|
<AlertDialogDescription className="text-destructive">
|
2025-12-03 16:02:09 +09:00
|
|
|
|
선택된 {selectedScreenIds.length}개 화면을 영구적으로 삭제하시겠습니까?
|
2025-09-08 13:10:09 +09:00
|
|
|
|
<br />
|
|
|
|
|
|
<strong>이 작업은 되돌릴 수 없습니다!</strong>
|
|
|
|
|
|
<br />
|
|
|
|
|
|
모든 레이아웃 정보와 관련 데이터가 완전히 삭제됩니다.
|
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
|
<AlertDialogCancel
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setBulkDeleteDialogOpen(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
disabled={bulkDeleting}
|
|
|
|
|
|
>
|
|
|
|
|
|
취소
|
|
|
|
|
|
</AlertDialogCancel>
|
2025-10-15 18:31:40 +09:00
|
|
|
|
<AlertDialogAction onClick={confirmBulkDelete} variant="destructive" disabled={bulkDeleting}>
|
2025-09-08 13:10:09 +09:00
|
|
|
|
{bulkDeleting ? "삭제 중..." : `${selectedScreenIds.length}개 영구 삭제`}
|
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
|
</AlertDialog>
|
2025-10-15 18:31:40 +09:00
|
|
|
|
|
2025-12-03 16:02:09 +09:00
|
|
|
|
{/* 활성 화면 일괄삭제 확인 다이얼로그 */}
|
|
|
|
|
|
<AlertDialog open={activeBulkDeleteDialogOpen} onOpenChange={setActiveBulkDeleteDialogOpen}>
|
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogTitle>선택 화면 삭제 확인</AlertDialogTitle>
|
|
|
|
|
|
<AlertDialogDescription>
|
|
|
|
|
|
선택된 {selectedActiveScreenIds.length}개 화면을 휴지통으로 이동하시겠습니까?
|
|
|
|
|
|
<br />
|
|
|
|
|
|
휴지통에서 언제든지 복원할 수 있습니다.
|
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="activeBulkDeleteReason">삭제 사유 (선택사항)</Label>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
id="activeBulkDeleteReason"
|
|
|
|
|
|
placeholder="삭제 사유를 입력하세요..."
|
|
|
|
|
|
value={activeBulkDeleteReason}
|
|
|
|
|
|
onChange={(e) => setActiveBulkDeleteReason(e.target.value)}
|
|
|
|
|
|
rows={3}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
|
<AlertDialogCancel
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setActiveBulkDeleteDialogOpen(false);
|
|
|
|
|
|
setActiveBulkDeleteReason("");
|
|
|
|
|
|
}}
|
|
|
|
|
|
disabled={activeBulkDeleting}
|
|
|
|
|
|
>
|
|
|
|
|
|
취소
|
|
|
|
|
|
</AlertDialogCancel>
|
|
|
|
|
|
<AlertDialogAction onClick={confirmActiveBulkDelete} variant="destructive" disabled={activeBulkDeleting}>
|
|
|
|
|
|
{activeBulkDeleting ? "삭제 중..." : `${selectedActiveScreenIds.length}개 휴지통으로 이동`}
|
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
|
2025-10-15 18:31:40 +09:00
|
|
|
|
{/* 화면 편집 다이얼로그 */}
|
|
|
|
|
|
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
|
|
|
|
|
<DialogContent className="sm:max-w-[500px]">
|
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
|
<DialogTitle>화면 정보 편집</DialogTitle>
|
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
<div className="space-y-4 py-4">
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="edit-screenName">화면명 *</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="edit-screenName"
|
|
|
|
|
|
value={editFormData.screenName}
|
|
|
|
|
|
onChange={(e) => setEditFormData({ ...editFormData, screenName: e.target.value })}
|
|
|
|
|
|
placeholder="화면명을 입력하세요"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-12-02 13:20:49 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 데이터 소스 타입 선택 */}
|
2025-11-20 15:30:00 +09:00
|
|
|
|
<div className="space-y-2">
|
2025-12-02 13:20:49 +09:00
|
|
|
|
<Label>데이터 소스 타입</Label>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={editFormData.dataSourceType}
|
|
|
|
|
|
onValueChange={(value: "database" | "restapi") => {
|
|
|
|
|
|
setEditFormData({
|
|
|
|
|
|
...editFormData,
|
|
|
|
|
|
dataSourceType: value,
|
|
|
|
|
|
tableName: "",
|
|
|
|
|
|
restApiConnectionId: null,
|
|
|
|
|
|
restApiEndpoint: "",
|
|
|
|
|
|
restApiJsonPath: "data",
|
|
|
|
|
|
});
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="database">데이터베이스</SelectItem>
|
|
|
|
|
|
<SelectItem value="restapi">REST API</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
2025-11-20 15:30:00 +09:00
|
|
|
|
</div>
|
2025-12-02 13:20:49 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 데이터베이스 선택 (database 타입인 경우) */}
|
|
|
|
|
|
{editFormData.dataSourceType === "database" && (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="edit-tableName">테이블 *</Label>
|
|
|
|
|
|
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
role="combobox"
|
|
|
|
|
|
aria-expanded={tableComboboxOpen}
|
|
|
|
|
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
|
|
|
|
|
disabled={loadingTables}
|
|
|
|
|
|
>
|
|
|
|
|
|
{loadingTables
|
|
|
|
|
|
? "로딩 중..."
|
|
|
|
|
|
: editFormData.tableName
|
|
|
|
|
|
? tables.find((table) => table.tableName === editFormData.tableName)?.tableLabel || editFormData.tableName
|
|
|
|
|
|
: "테이블을 선택하세요"}
|
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
|
|
|
|
</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>
|
|
|
|
|
|
{tables.map((table) => (
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
key={table.tableName}
|
|
|
|
|
|
value={`${table.tableName} ${table.tableLabel}`}
|
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
|
setEditFormData({ ...editFormData, tableName: table.tableName });
|
|
|
|
|
|
setTableComboboxOpen(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-xs sm:text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"mr-2 h-4 w-4",
|
|
|
|
|
|
editFormData.tableName === table.tableName ? "opacity-100" : "opacity-0"
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
|
<span className="font-medium">{table.tableLabel}</span>
|
|
|
|
|
|
<span className="text-[10px] text-gray-500">{table.tableName}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
|
</CommandList>
|
|
|
|
|
|
</Command>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* REST API 선택 (restapi 타입인 경우) */}
|
|
|
|
|
|
{editFormData.dataSourceType === "restapi" && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label>REST API 연결 *</Label>
|
|
|
|
|
|
<Popover open={editRestApiComboboxOpen} onOpenChange={setEditRestApiComboboxOpen}>
|
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
role="combobox"
|
|
|
|
|
|
aria-expanded={editRestApiComboboxOpen}
|
|
|
|
|
|
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
{editFormData.restApiConnectionId
|
|
|
|
|
|
? editRestApiConnections.find((c) => c.id === editFormData.restApiConnectionId)?.connection_name || "선택된 연결"
|
|
|
|
|
|
: "REST API 연결 선택"}
|
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
|
|
|
|
</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>
|
|
|
|
|
|
{editRestApiConnections.map((conn) => (
|
|
|
|
|
|
<CommandItem
|
|
|
|
|
|
key={conn.id}
|
|
|
|
|
|
value={conn.connection_name}
|
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
|
setEditFormData({ ...editFormData, restApiConnectionId: conn.id || null });
|
|
|
|
|
|
setEditRestApiComboboxOpen(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="text-xs sm:text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Check
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"mr-2 h-4 w-4",
|
|
|
|
|
|
editFormData.restApiConnectionId === conn.id ? "opacity-100" : "opacity-0"
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
|
<span className="font-medium">{conn.connection_name}</span>
|
|
|
|
|
|
<span className="text-[10px] text-gray-500">{conn.base_url}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CommandItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
|
</CommandList>
|
|
|
|
|
|
</Command>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="edit-restApiEndpoint">API 엔드포인트</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="edit-restApiEndpoint"
|
|
|
|
|
|
value={editFormData.restApiEndpoint}
|
|
|
|
|
|
onChange={(e) => setEditFormData({ ...editFormData, restApiEndpoint: e.target.value })}
|
|
|
|
|
|
placeholder="예: /api/data/list"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p className="text-muted-foreground text-[10px]">
|
|
|
|
|
|
데이터를 조회할 API 엔드포인트 경로입니다
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="edit-restApiJsonPath">JSON 경로</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="edit-restApiJsonPath"
|
|
|
|
|
|
value={editFormData.restApiJsonPath}
|
|
|
|
|
|
onChange={(e) => setEditFormData({ ...editFormData, restApiJsonPath: e.target.value })}
|
|
|
|
|
|
placeholder="예: data 또는 result.items"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p className="text-muted-foreground text-[10px]">
|
|
|
|
|
|
응답 JSON에서 데이터 배열의 경로입니다 (기본: data)
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-10-15 18:31:40 +09:00
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="edit-description">설명</Label>
|
|
|
|
|
|
<Textarea
|
|
|
|
|
|
id="edit-description"
|
|
|
|
|
|
value={editFormData.description}
|
|
|
|
|
|
onChange={(e) => setEditFormData({ ...editFormData, description: e.target.value })}
|
|
|
|
|
|
placeholder="화면 설명을 입력하세요"
|
|
|
|
|
|
rows={3}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="edit-isActive">상태</Label>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={editFormData.isActive}
|
|
|
|
|
|
onValueChange={(value) => setEditFormData({ ...editFormData, isActive: value })}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger id="edit-isActive">
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="Y">활성</SelectItem>
|
|
|
|
|
|
<SelectItem value="N">비활성</SelectItem>
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
|
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
|
|
|
|
|
취소
|
|
|
|
|
|
</Button>
|
2025-12-02 13:20:49 +09:00
|
|
|
|
<Button
|
|
|
|
|
|
onClick={handleEditSave}
|
|
|
|
|
|
disabled={
|
|
|
|
|
|
!editFormData.screenName.trim() ||
|
|
|
|
|
|
(editFormData.dataSourceType === "database" && !editFormData.tableName.trim()) ||
|
|
|
|
|
|
(editFormData.dataSourceType === "restapi" && !editFormData.restApiConnectionId)
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
2025-10-15 18:31:40 +09:00
|
|
|
|
저장
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 화면 미리보기 다이얼로그 */}
|
|
|
|
|
|
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
|
2025-10-16 15:05:24 +09:00
|
|
|
|
<DialogContent className="h-[95vh] max-w-[95vw]">
|
2025-10-15 18:31:40 +09:00
|
|
|
|
<DialogHeader>
|
|
|
|
|
|
<DialogTitle>화면 미리보기 - {screenToPreview?.screenName}</DialogTitle>
|
|
|
|
|
|
</DialogHeader>
|
2025-11-26 14:58:18 +09:00
|
|
|
|
<ScreenPreviewProvider isPreviewMode={true}>
|
|
|
|
|
|
<TableOptionsProvider>
|
|
|
|
|
|
<div className="flex flex-1 items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-slate-100 p-6">
|
2025-11-26 14:44:49 +09:00
|
|
|
|
{isLoadingPreview ? (
|
|
|
|
|
|
<div className="flex h-full items-center justify-center">
|
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
|
<div className="mb-2 text-lg font-medium">레이아웃 로딩 중...</div>
|
|
|
|
|
|
<div className="text-muted-foreground text-sm">화면 정보를 불러오고 있습니다.</div>
|
|
|
|
|
|
</div>
|
2025-10-15 18:31:40 +09:00
|
|
|
|
</div>
|
2025-11-26 14:44:49 +09:00
|
|
|
|
) : previewLayout && previewLayout.components ? (
|
2025-10-15 18:31:40 +09:00
|
|
|
|
(() => {
|
|
|
|
|
|
const screenWidth = previewLayout.screenResolution?.width || 1200;
|
|
|
|
|
|
const screenHeight = previewLayout.screenResolution?.height || 800;
|
|
|
|
|
|
|
|
|
|
|
|
// 모달 내부 가용 공간 계산 (헤더, 푸터, 패딩 제외)
|
2025-11-26 14:58:18 +09:00
|
|
|
|
const modalPadding = 100; // 헤더 + 푸터 + 패딩
|
|
|
|
|
|
const availableWidth = typeof window !== "undefined" ? window.innerWidth * 0.95 - modalPadding : 1700;
|
|
|
|
|
|
const availableHeight = typeof window !== "undefined" ? window.innerHeight * 0.95 - modalPadding : 900;
|
|
|
|
|
|
|
|
|
|
|
|
// 가로/세로 비율을 모두 고려하여 작은 쪽에 맞춤 (화면이 잘리지 않도록)
|
|
|
|
|
|
const scaleX = availableWidth / screenWidth;
|
|
|
|
|
|
const scaleY = availableHeight / screenHeight;
|
|
|
|
|
|
const scale = Math.min(scaleX, scaleY, 1); // 최대 1배율 (확대 방지)
|
|
|
|
|
|
|
|
|
|
|
|
console.log("📐 미리보기 스케일 계산:", {
|
|
|
|
|
|
screenWidth,
|
|
|
|
|
|
screenHeight,
|
|
|
|
|
|
availableWidth,
|
|
|
|
|
|
availableHeight,
|
|
|
|
|
|
scaleX,
|
|
|
|
|
|
scaleY,
|
|
|
|
|
|
finalScale: scale,
|
|
|
|
|
|
});
|
2025-10-15 18:31:40 +09:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
2025-10-22 14:52:13 +09:00
|
|
|
|
className="bg-card relative mx-auto rounded-xl border shadow-lg"
|
2025-10-15 18:31:40 +09:00
|
|
|
|
style={{
|
|
|
|
|
|
width: `${screenWidth}px`,
|
|
|
|
|
|
height: `${screenHeight}px`,
|
|
|
|
|
|
transform: `scale(${scale})`,
|
|
|
|
|
|
transformOrigin: "center center",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* 실제 화면과 동일한 렌더링 */}
|
|
|
|
|
|
{previewLayout.components
|
|
|
|
|
|
.filter((comp: any) => !comp.parentId) // 최상위 컴포넌트만 렌더링
|
|
|
|
|
|
.map((component: any) => {
|
|
|
|
|
|
if (!component || !component.id) return null;
|
|
|
|
|
|
|
|
|
|
|
|
// 그룹 컴포넌트인 경우 특별 처리
|
|
|
|
|
|
if (component.type === "group") {
|
|
|
|
|
|
const groupChildren = previewLayout.components.filter(
|
|
|
|
|
|
(child: any) => child.parentId === component.id,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={component.id}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
position: "absolute",
|
|
|
|
|
|
left: `${component.position?.x || 0}px`,
|
|
|
|
|
|
top: `${component.position?.y || 0}px`,
|
|
|
|
|
|
width: component.style?.width || `${component.size?.width || 200}px`,
|
|
|
|
|
|
height: component.style?.height || `${component.size?.height || 40}px`,
|
|
|
|
|
|
zIndex: component.position?.z || 1,
|
|
|
|
|
|
backgroundColor: component.backgroundColor || "rgba(59, 130, 246, 0.05)",
|
|
|
|
|
|
border: component.border || "1px solid rgba(59, 130, 246, 0.2)",
|
|
|
|
|
|
borderRadius: component.borderRadius || "12px",
|
|
|
|
|
|
padding: "20px",
|
|
|
|
|
|
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* 그룹 제목 */}
|
|
|
|
|
|
{component.title && (
|
|
|
|
|
|
<div className="mb-3 inline-block rounded-lg bg-blue-50 px-3 py-1 text-sm font-semibold text-blue-700">
|
|
|
|
|
|
{component.title}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 그룹 내 자식 컴포넌트들 렌더링 */}
|
|
|
|
|
|
{groupChildren.map((child: any) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={child.id}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
position: "absolute",
|
|
|
|
|
|
left: `${child.position.x}px`,
|
|
|
|
|
|
top: `${child.position.y}px`,
|
|
|
|
|
|
width: child.style?.width || `${child.size.width}px`,
|
|
|
|
|
|
height: child.style?.height || `${child.size.height}px`,
|
|
|
|
|
|
zIndex: child.position.z || 1,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<InteractiveScreenViewer
|
|
|
|
|
|
component={child}
|
|
|
|
|
|
allComponents={previewLayout.components}
|
|
|
|
|
|
formData={previewFormData}
|
|
|
|
|
|
onFormDataChange={(fieldName, value) => {
|
|
|
|
|
|
setPreviewFormData((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[fieldName]: value,
|
|
|
|
|
|
}));
|
|
|
|
|
|
}}
|
|
|
|
|
|
screenInfo={{
|
|
|
|
|
|
id: screenToPreview!.screenId,
|
|
|
|
|
|
tableName: screenToPreview?.tableName,
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-26 14:58:18 +09:00
|
|
|
|
// 일반 컴포넌트 렌더링 - RealtimePreview 사용 (실제 화면과 동일)
|
2025-10-15 18:31:40 +09:00
|
|
|
|
return (
|
2025-11-26 14:58:18 +09:00
|
|
|
|
<RealtimePreview
|
|
|
|
|
|
key={component.id}
|
|
|
|
|
|
component={component}
|
|
|
|
|
|
isSelected={false}
|
|
|
|
|
|
isDesignMode={false}
|
|
|
|
|
|
onClick={() => {}}
|
|
|
|
|
|
screenId={screenToPreview!.screenId}
|
|
|
|
|
|
tableName={screenToPreview?.tableName}
|
|
|
|
|
|
formData={previewFormData}
|
|
|
|
|
|
onFormDataChange={(fieldName, value) => {
|
|
|
|
|
|
setPreviewFormData((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[fieldName]: value,
|
|
|
|
|
|
}));
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* 자식 컴포넌트들 */}
|
|
|
|
|
|
{(component.type === "group" ||
|
|
|
|
|
|
component.type === "container" ||
|
|
|
|
|
|
component.type === "area") &&
|
|
|
|
|
|
previewLayout.components
|
|
|
|
|
|
.filter((child: any) => child.parentId === component.id)
|
|
|
|
|
|
.map((child: any) => {
|
|
|
|
|
|
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
|
|
|
|
|
|
const relativeChildComponent = {
|
|
|
|
|
|
...child,
|
|
|
|
|
|
position: {
|
|
|
|
|
|
x: child.position.x - component.position.x,
|
|
|
|
|
|
y: child.position.y - component.position.y,
|
|
|
|
|
|
z: child.position.z || 1,
|
2025-10-15 18:31:40 +09:00
|
|
|
|
},
|
2025-11-26 14:58:18 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<RealtimePreview
|
|
|
|
|
|
key={child.id}
|
|
|
|
|
|
component={relativeChildComponent}
|
|
|
|
|
|
isSelected={false}
|
|
|
|
|
|
isDesignMode={false}
|
|
|
|
|
|
onClick={() => {}}
|
|
|
|
|
|
screenId={screenToPreview!.screenId}
|
|
|
|
|
|
tableName={screenToPreview?.tableName}
|
|
|
|
|
|
formData={previewFormData}
|
|
|
|
|
|
onFormDataChange={(fieldName, value) => {
|
|
|
|
|
|
setPreviewFormData((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[fieldName]: value,
|
|
|
|
|
|
}));
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</RealtimePreview>
|
2025-10-15 18:31:40 +09:00
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})()
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="flex h-full items-center justify-center">
|
|
|
|
|
|
<div className="text-center">
|
2025-10-22 14:52:13 +09:00
|
|
|
|
<div className="text-muted-foreground mb-2 text-lg font-medium">레이아웃이 비어있습니다</div>
|
|
|
|
|
|
<div className="text-muted-foreground text-sm">이 화면에는 아직 컴포넌트가 배치되지 않았습니다.</div>
|
2025-10-15 18:31:40 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-11-26 14:58:18 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</TableOptionsProvider>
|
|
|
|
|
|
</ScreenPreviewProvider>
|
2025-10-15 18:31:40 +09:00
|
|
|
|
<DialogFooter>
|
|
|
|
|
|
<Button variant="outline" onClick={() => setPreviewDialogOpen(false)}>
|
|
|
|
|
|
닫기
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button onClick={() => onDesignScreen(screenToPreview!)}>
|
|
|
|
|
|
<Palette className="mr-2 h-4 w-4" />
|
|
|
|
|
|
편집 모드로 전환
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
2025-09-01 11:48:12 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|