- Replaced existing toast error messages with the new `showErrorToast` utility across multiple components, improving consistency in error reporting. - Updated error messages to provide more specific guidance for users, enhancing the overall user experience during error scenarios. - Ensured that all relevant error handling in batch management, external call configurations, cascading management, and screen management components now utilizes the new utility for better maintainability.
1969 lines
83 KiB
TypeScript
1969 lines
83 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback, useRef } from "react";
|
||
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";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { useAuth } from "@/hooks/useAuth";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from "@/components/ui/alert-dialog";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogFooter,
|
||
DialogDescription,
|
||
} from "@/components/ui/dialog";
|
||
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 { cn } from "@/lib/utils";
|
||
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash, Check, ChevronsUpDown } from "lucide-react";
|
||
import { ScreenDefinition } from "@/types/screen";
|
||
import { screenApi } from "@/lib/api/screen";
|
||
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
|
||
import { getScreenGroups, ScreenGroup } from "@/lib/api/screenGroup";
|
||
import { Layers } from "lucide-react";
|
||
import CreateScreenModal from "./CreateScreenModal";
|
||
import CopyScreenModal from "./CopyScreenModal";
|
||
import dynamic from "next/dynamic";
|
||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
||
import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils";
|
||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||
import { RealtimePreview } from "./RealtimePreviewDynamic";
|
||
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||
|
||
// 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>,
|
||
},
|
||
);
|
||
|
||
interface ScreenListProps {
|
||
onScreenSelect: (screen: ScreenDefinition) => void;
|
||
selectedScreen: ScreenDefinition | null;
|
||
onDesignScreen: (screen: ScreenDefinition) => void;
|
||
}
|
||
|
||
type DeletedScreenDefinition = ScreenDefinition & {
|
||
deletedDate?: Date;
|
||
deletedBy?: string;
|
||
deleteReason?: string;
|
||
};
|
||
|
||
export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: ScreenListProps) {
|
||
const { user, switchCompany } = useAuth();
|
||
const isSuperAdmin = user?.userType === "SUPER_ADMIN" || user?.companyCode === "*";
|
||
|
||
const [activeTab, setActiveTab] = useState("active");
|
||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||
const [deletedScreens, setDeletedScreens] = useState<DeletedScreenDefinition[]>([]);
|
||
const [loading, setLoading] = useState(true); // 초기 로딩
|
||
const [isSearching, setIsSearching] = useState(false); // 검색 중 로딩 (포커스 유지)
|
||
const [searchTerm, setSearchTerm] = useState("");
|
||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||
const [selectedCompanyCode, setSelectedCompanyCode] = useState<string>("all");
|
||
const [companies, setCompanies] = useState<any[]>([]);
|
||
const [loadingCompanies, setLoadingCompanies] = useState(false);
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [totalPages, setTotalPages] = useState(1);
|
||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||
const [isCopyOpen, setIsCopyOpen] = useState(false);
|
||
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
|
||
|
||
// 그룹 필터 관련 상태
|
||
const [selectedGroupId, setSelectedGroupId] = useState<string>("all");
|
||
const [groups, setGroups] = useState<ScreenGroup[]>([]);
|
||
const [loadingGroups, setLoadingGroups] = useState(false);
|
||
|
||
// 검색어 디바운스를 위한 타이머 ref
|
||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||
|
||
// 첫 로딩 여부를 추적 (한 번만 true)
|
||
const isFirstLoad = useRef(true);
|
||
|
||
// 삭제 관련 상태
|
||
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);
|
||
|
||
// 휴지통 일괄삭제 관련 상태
|
||
const [selectedScreenIds, setSelectedScreenIds] = useState<number[]>([]);
|
||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||
|
||
// 활성 화면 일괄삭제 관련 상태
|
||
const [selectedActiveScreenIds, setSelectedActiveScreenIds] = useState<number[]>([]);
|
||
const [activeBulkDeleteDialogOpen, setActiveBulkDeleteDialogOpen] = useState(false);
|
||
const [activeBulkDeleteReason, setActiveBulkDeleteReason] = useState("");
|
||
const [activeBulkDeleting, setActiveBulkDeleting] = useState(false);
|
||
|
||
// 편집 관련 상태
|
||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||
const [screenToEdit, setScreenToEdit] = useState<ScreenDefinition | null>(null);
|
||
const [editFormData, setEditFormData] = useState({
|
||
screenName: "",
|
||
description: "",
|
||
isActive: "Y",
|
||
tableName: "",
|
||
dataSourceType: "database" as "database" | "restapi",
|
||
restApiConnectionId: null as number | null,
|
||
restApiEndpoint: "",
|
||
restApiJsonPath: "data",
|
||
});
|
||
const [tables, setTables] = useState<Array<{ tableName: string; tableLabel: string }>>([]);
|
||
const [loadingTables, setLoadingTables] = useState(false);
|
||
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
||
|
||
// REST API 연결 관련 상태 (편집용)
|
||
const [editRestApiConnections, setEditRestApiConnections] = useState<ExternalRestApiConnection[]>([]);
|
||
const [editRestApiComboboxOpen, setEditRestApiComboboxOpen] = useState(false);
|
||
|
||
// 미리보기 관련 상태
|
||
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>>({});
|
||
|
||
// 최고 관리자인 경우 회사 목록 로드
|
||
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);
|
||
}
|
||
};
|
||
|
||
// 🔧 회사 선택 시 회사 전환 (JWT 토큰 변경)
|
||
const handleCompanySelect = async (companyCode: string) => {
|
||
setSelectedCompanyCode(companyCode);
|
||
|
||
// "all"은 전체 조회이므로 회사 전환하지 않음 (최고 관리자 상태 유지)
|
||
if (companyCode && companyCode !== "all") {
|
||
const result = await switchCompany(companyCode);
|
||
if (!result.success) {
|
||
console.error("회사 전환 실패:", result.message);
|
||
return;
|
||
}
|
||
// 🔧 페이지 새로고침으로 새 JWT 확실하게 적용
|
||
window.location.reload();
|
||
} else if (companyCode === "all") {
|
||
// "전체 회사" 선택 시 최고 관리자 모드로 복귀
|
||
const result = await switchCompany("*");
|
||
if (!result.success) {
|
||
console.error("최고 관리자 모드 복귀 실패:", result.message);
|
||
return;
|
||
}
|
||
// 🔧 페이지 새로고침으로 새 JWT 확실하게 적용
|
||
window.location.reload();
|
||
}
|
||
};
|
||
|
||
// 화면 그룹 목록 로드
|
||
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);
|
||
}
|
||
};
|
||
|
||
// 검색어 디바운스 처리 (150ms 지연 - 빠른 응답)
|
||
useEffect(() => {
|
||
// 이전 타이머 취소
|
||
if (debounceTimer.current) {
|
||
clearTimeout(debounceTimer.current);
|
||
}
|
||
|
||
// 새 타이머 설정
|
||
debounceTimer.current = setTimeout(() => {
|
||
setDebouncedSearchTerm(searchTerm);
|
||
}, 150);
|
||
|
||
// 클린업
|
||
return () => {
|
||
if (debounceTimer.current) {
|
||
clearTimeout(debounceTimer.current);
|
||
}
|
||
};
|
||
}, [searchTerm]);
|
||
|
||
// 화면 목록 로드 (실제 API) - debouncedSearchTerm 사용
|
||
useEffect(() => {
|
||
let abort = false;
|
||
const load = async () => {
|
||
try {
|
||
// 첫 로딩인 경우에만 loading=true, 그 외에는 isSearching=true
|
||
if (isFirstLoad.current) {
|
||
setLoading(true);
|
||
isFirstLoad.current = false; // 첫 로딩 완료 표시
|
||
} else {
|
||
setIsSearching(true);
|
||
}
|
||
|
||
if (activeTab === "active") {
|
||
const params: any = { page: currentPage, size: 20, searchTerm: debouncedSearchTerm };
|
||
|
||
// 최고 관리자이고 특정 회사를 선택한 경우
|
||
if (isSuperAdmin && selectedCompanyCode !== "all") {
|
||
params.companyCode = selectedCompanyCode;
|
||
}
|
||
|
||
// 그룹 필터
|
||
if (selectedGroupId !== "all") {
|
||
params.groupId = selectedGroupId;
|
||
}
|
||
|
||
console.log("🔍 화면 목록 API 호출:", params); // 디버깅용
|
||
const resp = await screenApi.getScreens(params);
|
||
console.log("✅ 화면 목록 응답:", resp); // 디버깅용
|
||
|
||
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)));
|
||
}
|
||
} catch (e) {
|
||
console.error("화면 목록 조회 실패", e);
|
||
if (activeTab === "active") {
|
||
setScreens([]);
|
||
} else {
|
||
setDeletedScreens([]);
|
||
}
|
||
setTotalPages(1);
|
||
} finally {
|
||
if (!abort) {
|
||
setLoading(false);
|
||
setIsSearching(false);
|
||
}
|
||
}
|
||
};
|
||
load();
|
||
return () => {
|
||
abort = true;
|
||
};
|
||
}, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, selectedGroupId, isSuperAdmin]);
|
||
|
||
const filteredScreens = screens; // 서버 필터 기준 사용
|
||
|
||
// 화면 목록 다시 로드
|
||
const reloadScreens = async () => {
|
||
try {
|
||
setIsSearching(true);
|
||
const params: any = { page: currentPage, size: 20, searchTerm: debouncedSearchTerm };
|
||
|
||
// 최고 관리자이고 특정 회사를 선택한 경우
|
||
if (isSuperAdmin && selectedCompanyCode !== "all") {
|
||
params.companyCode = selectedCompanyCode;
|
||
}
|
||
|
||
const resp = await screenApi.getScreens(params);
|
||
setScreens(resp.data || []);
|
||
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
||
} catch (e) {
|
||
console.error("화면 목록 조회 실패", e);
|
||
} finally {
|
||
setIsSearching(false);
|
||
}
|
||
};
|
||
|
||
const handleScreenSelect = (screen: ScreenDefinition) => {
|
||
onScreenSelect(screen);
|
||
};
|
||
|
||
const handleEdit = async (screen: ScreenDefinition) => {
|
||
setScreenToEdit(screen);
|
||
|
||
// 데이터 소스 타입 결정
|
||
const isRestApi = screen.dataSourceType === "restapi" || screen.tableName?.startsWith("_restapi_");
|
||
|
||
setEditFormData({
|
||
screenName: screen.screenName,
|
||
description: screen.description || "",
|
||
isActive: screen.isActive,
|
||
tableName: screen.tableName || "",
|
||
dataSourceType: isRestApi ? "restapi" : "database",
|
||
restApiConnectionId: (screen as any).restApiConnectionId || null,
|
||
restApiEndpoint: (screen as any).restApiEndpoint || "",
|
||
restApiJsonPath: (screen as any).restApiJsonPath || "data",
|
||
});
|
||
setEditDialogOpen(true);
|
||
|
||
// 테이블 목록 로드
|
||
try {
|
||
setLoadingTables(true);
|
||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||
const response = await tableManagementApi.getTableList();
|
||
if (response.success && response.data) {
|
||
// tableName과 displayName 매핑 (백엔드에서 displayName으로 라벨을 반환함)
|
||
const tableList = response.data.map((table: any) => ({
|
||
tableName: table.tableName,
|
||
tableLabel: table.displayName || table.tableName,
|
||
}));
|
||
setTables(tableList);
|
||
}
|
||
} catch (error) {
|
||
console.error("테이블 목록 조회 실패:", error);
|
||
} finally {
|
||
setLoadingTables(false);
|
||
}
|
||
|
||
// REST API 연결 목록 로드
|
||
try {
|
||
const connections = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" });
|
||
setEditRestApiConnections(connections);
|
||
} catch (error) {
|
||
console.error("REST API 연결 목록 조회 실패:", error);
|
||
setEditRestApiConnections([]);
|
||
}
|
||
};
|
||
|
||
const handleEditSave = async () => {
|
||
if (!screenToEdit) return;
|
||
|
||
try {
|
||
// 데이터 소스 타입에 따라 업데이트 데이터 구성
|
||
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,
|
||
});
|
||
|
||
// 화면 정보 업데이트 API 호출
|
||
await screenApi.updateScreenInfo(screenToEdit.screenId, updateData);
|
||
|
||
// 선택된 테이블의 라벨 찾기
|
||
const selectedTable = tables.find((t) => t.tableName === editFormData.tableName);
|
||
const tableLabel = selectedTable?.tableLabel || editFormData.tableName;
|
||
|
||
// 목록에서 해당 화면 정보 업데이트
|
||
setScreens((prev) =>
|
||
prev.map((s) =>
|
||
s.screenId === screenToEdit.screenId
|
||
? {
|
||
...s,
|
||
screenName: editFormData.screenName,
|
||
tableName: updateData.tableName,
|
||
tableLabel: tableLabel,
|
||
description: editFormData.description,
|
||
isActive: editFormData.isActive,
|
||
dataSourceType: editFormData.dataSourceType,
|
||
}
|
||
: s,
|
||
),
|
||
);
|
||
|
||
setEditDialogOpen(false);
|
||
setScreenToEdit(null);
|
||
} catch (error) {
|
||
console.error("화면 정보 업데이트 실패:", error);
|
||
alert("화면 정보 업데이트에 실패했습니다.");
|
||
}
|
||
};
|
||
|
||
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) {
|
||
// console.error("의존성 체크 실패:", error);
|
||
// 의존성 체크 실패 시에도 삭제 다이얼로그는 열어줌
|
||
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) {
|
||
// console.error("화면 삭제 실패:", error);
|
||
|
||
// 의존성 오류인 경우 경고창 표시
|
||
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) {
|
||
// console.error("화면 복원 실패:", error);
|
||
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) {
|
||
// console.error("화면 영구 삭제 실패:", error);
|
||
alert("화면 영구 삭제에 실패했습니다.");
|
||
}
|
||
};
|
||
|
||
// 휴지통 체크박스 선택 처리
|
||
const handleScreenCheck = (screenId: number, checked: boolean) => {
|
||
if (checked) {
|
||
setSelectedScreenIds((prev) => [...prev, screenId]);
|
||
} else {
|
||
setSelectedScreenIds((prev) => prev.filter((id) => id !== screenId));
|
||
}
|
||
};
|
||
|
||
// 휴지통 전체 선택/해제
|
||
const handleSelectAll = (checked: boolean) => {
|
||
if (checked) {
|
||
setSelectedScreenIds(deletedScreens.map((screen) => screen.screenId));
|
||
} else {
|
||
setSelectedScreenIds([]);
|
||
}
|
||
};
|
||
|
||
// 휴지통 일괄삭제 실행
|
||
const handleBulkDelete = () => {
|
||
if (selectedScreenIds.length === 0) {
|
||
alert("삭제할 화면을 선택해주세요.");
|
||
return;
|
||
}
|
||
setBulkDeleteDialogOpen(true);
|
||
};
|
||
|
||
// 활성 화면 체크박스 선택 처리
|
||
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);
|
||
}
|
||
};
|
||
|
||
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) {
|
||
// console.error("일괄 삭제 실패:", error);
|
||
alert("일괄 삭제에 실패했습니다.");
|
||
} finally {
|
||
setBulkDeleting(false);
|
||
}
|
||
};
|
||
|
||
const handleCopy = (screen: ScreenDefinition) => {
|
||
setScreenToCopy(screen);
|
||
setIsCopyOpen(true);
|
||
};
|
||
|
||
const handleView = async (screen: ScreenDefinition) => {
|
||
setScreenToPreview(screen);
|
||
setPreviewLayout(null); // 이전 레이아웃 초기화
|
||
setIsLoadingPreview(true);
|
||
setPreviewDialogOpen(true); // 모달 먼저 열기
|
||
|
||
// 모달이 열린 후에 레이아웃 로드
|
||
setTimeout(async () => {
|
||
try {
|
||
// 화면 레이아웃 로드
|
||
const layoutData = await screenApi.getLayout(screen.screenId);
|
||
console.log("📊 미리보기 레이아웃 로드:", layoutData);
|
||
setPreviewLayout(layoutData);
|
||
} catch (error) {
|
||
console.error("❌ 레이아웃 로드 실패:", error);
|
||
showErrorToast("화면 레이아웃을 불러오는 데 실패했습니다", error, { guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요." });
|
||
} finally {
|
||
setIsLoadingPreview(false);
|
||
}
|
||
}, 100); // 100ms 딜레이로 모달 애니메이션이 먼저 시작되도록
|
||
};
|
||
|
||
const handleCopySuccess = () => {
|
||
// 복사 성공 후 화면 목록 다시 로드
|
||
reloadScreens();
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center py-8">
|
||
<div className="text-muted-foreground text-sm">로딩 중...</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* 검색 및 필터 */}
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||
<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={handleCompanySelect} 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>
|
||
)}
|
||
|
||
{/* 그룹 필터 */}
|
||
<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>
|
||
|
||
{/* 검색 입력 */}
|
||
<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>
|
||
</div>
|
||
</div>
|
||
|
||
<Button
|
||
onClick={() => setIsCreateOpen(true)}
|
||
disabled={activeTab === "trash"}
|
||
className="h-10 gap-2 text-sm font-medium"
|
||
>
|
||
<Plus className="h-4 w-4" />새 화면 생성
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 탭 구조 */}
|
||
<Tabs value={activeTab} onValueChange={(value) => {
|
||
setActiveTab(value);
|
||
// 탭 전환 시 선택 상태 초기화
|
||
setSelectedActiveScreenIds([]);
|
||
setSelectedScreenIds([]);
|
||
}}>
|
||
<TabsList className="grid w-full grid-cols-2">
|
||
<TabsTrigger value="active">활성 화면</TabsTrigger>
|
||
<TabsTrigger value="trash">휴지통</TabsTrigger>
|
||
</TabsList>
|
||
|
||
{/* 활성 화면 탭 */}
|
||
<TabsContent value="active">
|
||
{/* 선택 삭제 헤더 (선택된 항목이 있을 때만 표시) */}
|
||
{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>
|
||
)}
|
||
|
||
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
||
<div className="bg-card hidden shadow-sm lg:block">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="h-12 w-12 px-4 py-3">
|
||
<Checkbox
|
||
checked={screens.length > 0 && selectedActiveScreenIds.length === screens.length}
|
||
onCheckedChange={handleActiveSelectAll}
|
||
aria-label="전체 선택"
|
||
/>
|
||
</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>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{screens.map((screen) => (
|
||
<TableRow
|
||
key={screen.screenId}
|
||
className={`bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors ${
|
||
selectedScreen?.screenId === screen.screenId ? "border-primary/20 bg-accent" : ""
|
||
} ${selectedActiveScreenIds.includes(screen.screenId) ? "bg-muted/30" : ""}`}
|
||
onClick={() => onDesignScreen(screen)}
|
||
>
|
||
<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>
|
||
<TableCell className="h-16 px-6 py-3 cursor-pointer">
|
||
<div>
|
||
<div className="font-medium">{screen.screenName}</div>
|
||
{screen.description && (
|
||
<div className="text-muted-foreground mt-1 text-sm">{screen.description}</div>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="h-16 px-6 py-3">
|
||
<span className="text-muted-foreground font-mono text-sm">
|
||
{screen.tableLabel || screen.tableName}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="h-16 px-6 py-3">
|
||
<Badge variant={screen.isActive === "Y" ? "default" : "secondary"}>
|
||
{screen.isActive === "Y" ? "활성" : "비활성"}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="h-16 px-6 py-3">
|
||
<div className="text-muted-foreground text-sm">{screen.createdDate.toLocaleDateString()}</div>
|
||
<div className="text-muted-foreground text-xs">{screen.createdBy}</div>
|
||
</TableCell>
|
||
<TableCell className="h-16 px-6 py-3">
|
||
<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>
|
||
</TableRow>
|
||
))}
|
||
</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 미만) */}
|
||
<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>
|
||
</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>
|
||
))}
|
||
|
||
{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>
|
||
</div>
|
||
</TabsContent>
|
||
|
||
{/* 휴지통 탭 */}
|
||
<TabsContent value="trash">
|
||
{/* 데스크톱 테이블 뷰 (lg 이상) */}
|
||
<div className="bg-card hidden shadow-sm lg:block">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="h-12 w-12 px-6 py-3">
|
||
<Checkbox
|
||
checked={deletedScreens.length > 0 && selectedScreenIds.length === deletedScreens.length}
|
||
onCheckedChange={handleSelectAll}
|
||
aria-label="전체 선택"
|
||
/>
|
||
</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>
|
||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">작업</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{deletedScreens.map((screen) => (
|
||
<TableRow key={screen.screenId} className="bg-background hover:bg-muted/50 border-b transition-colors">
|
||
<TableCell className="h-16 px-6 py-3">
|
||
<Checkbox
|
||
checked={selectedScreenIds.includes(screen.screenId)}
|
||
onCheckedChange={(checked) => handleScreenCheck(screen.screenId, checked as boolean)}
|
||
aria-label={`${screen.screenName} 선택`}
|
||
/>
|
||
</TableCell>
|
||
<TableCell className="h-16 px-6 py-3">
|
||
<div>
|
||
<div className="font-medium">{screen.screenName}</div>
|
||
{screen.description && (
|
||
<div className="text-muted-foreground mt-1 text-sm">{screen.description}</div>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="h-16 px-6 py-3">
|
||
<span className="text-muted-foreground font-mono text-sm">
|
||
{screen.tableLabel || screen.tableName}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="h-16 px-6 py-3">
|
||
<div className="text-muted-foreground text-sm">{screen.deletedDate?.toLocaleDateString()}</div>
|
||
</TableCell>
|
||
<TableCell className="h-16 px-6 py-3">
|
||
<div className="text-muted-foreground text-sm">{screen.deletedBy}</div>
|
||
</TableCell>
|
||
<TableCell className="h-16 px-6 py-3">
|
||
<div className="text-muted-foreground max-w-32 truncate text-sm" title={screen.deleteReason}>
|
||
{screen.deleteReason || "-"}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="h-16 px-6 py-3">
|
||
<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>
|
||
</TableRow>
|
||
))}
|
||
</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>
|
||
)}
|
||
</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>
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
{/* 페이지네이션 */}
|
||
{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>
|
||
<span className="text-muted-foreground text-sm">
|
||
{currentPage} / {totalPages}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||
disabled={currentPage === totalPages}
|
||
>
|
||
다음
|
||
</Button>
|
||
</div>
|
||
)}
|
||
{/* 새 화면 생성 모달 */}
|
||
<CreateScreenModal
|
||
open={isCreateOpen}
|
||
onOpenChange={setIsCreateOpen}
|
||
onCreated={(created) => {
|
||
// 목록에 즉시 반영 (첫 페이지 기준 상단 추가)
|
||
setScreens((prev) => [created, ...prev]);
|
||
}}
|
||
/>
|
||
|
||
{/* 화면 복사 모달 */}
|
||
<CopyScreenModal
|
||
isOpen={isCopyOpen}
|
||
onClose={() => setIsCopyOpen(false)}
|
||
sourceScreen={screenToCopy}
|
||
onCopySuccess={handleCopySuccess}
|
||
/>
|
||
|
||
{/* 삭제 확인 다이얼로그 */}
|
||
<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>
|
||
<AlertDialogAction onClick={() => confirmDelete(false)} variant="destructive">
|
||
휴지통으로 이동
|
||
</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">
|
||
<h4 className="font-medium">사용 중인 화면 목록:</h4>
|
||
{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>
|
||
<div className="font-medium">{dep.screenName}</div>
|
||
<div className="text-muted-foreground text-sm">화면 코드: {dep.screenCode}</div>
|
||
</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>
|
||
<div className="text-muted-foreground text-xs">
|
||
{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)}
|
||
variant="destructive"
|
||
disabled={!deleteReason.trim()}
|
||
>
|
||
강제 삭제
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
|
||
{/* 영구 삭제 확인 다이얼로그 */}
|
||
<AlertDialog open={permanentDeleteDialogOpen} onOpenChange={setPermanentDeleteDialogOpen}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>영구 삭제 확인</AlertDialogTitle>
|
||
<AlertDialogDescription className="text-destructive">
|
||
⚠️ "{screenToPermanentDelete?.screenName}" 화면을 영구적으로 삭제하시겠습니까?
|
||
<br />
|
||
<strong>이 작업은 되돌릴 수 없습니다!</strong>
|
||
<br />
|
||
모든 레이아웃 정보와 관련 데이터가 완전히 삭제됩니다.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel
|
||
onClick={() => {
|
||
setPermanentDeleteDialogOpen(false);
|
||
setScreenToPermanentDelete(null);
|
||
}}
|
||
>
|
||
취소
|
||
</AlertDialogCancel>
|
||
<AlertDialogAction onClick={confirmPermanentDelete} variant="destructive">
|
||
영구 삭제
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
|
||
{/* 휴지통 일괄삭제 확인 다이얼로그 */}
|
||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>일괄 영구 삭제 확인</AlertDialogTitle>
|
||
<AlertDialogDescription className="text-destructive">
|
||
선택된 {selectedScreenIds.length}개 화면을 영구적으로 삭제하시겠습니까?
|
||
<br />
|
||
<strong>이 작업은 되돌릴 수 없습니다!</strong>
|
||
<br />
|
||
모든 레이아웃 정보와 관련 데이터가 완전히 삭제됩니다.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel
|
||
onClick={() => {
|
||
setBulkDeleteDialogOpen(false);
|
||
}}
|
||
disabled={bulkDeleting}
|
||
>
|
||
취소
|
||
</AlertDialogCancel>
|
||
<AlertDialogAction onClick={confirmBulkDelete} variant="destructive" disabled={bulkDeleting}>
|
||
{bulkDeleting ? "삭제 중..." : `${selectedScreenIds.length}개 영구 삭제`}
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
|
||
{/* 활성 화면 일괄삭제 확인 다이얼로그 */}
|
||
<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>
|
||
|
||
{/* 화면 편집 다이얼로그 */}
|
||
<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>
|
||
|
||
{/* 데이터 소스 타입 선택 */}
|
||
<div className="space-y-2">
|
||
<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>
|
||
</div>
|
||
|
||
{/* 데이터베이스 선택 (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>
|
||
</>
|
||
)}
|
||
|
||
<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>
|
||
<Button
|
||
onClick={handleEditSave}
|
||
disabled={
|
||
!editFormData.screenName.trim() ||
|
||
(editFormData.dataSourceType === "database" && !editFormData.tableName.trim()) ||
|
||
(editFormData.dataSourceType === "restapi" && !editFormData.restApiConnectionId)
|
||
}
|
||
>
|
||
저장
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 화면 미리보기 다이얼로그 */}
|
||
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
|
||
<DialogContent className="h-[95vh] max-w-[95vw]">
|
||
<DialogHeader>
|
||
<DialogTitle>화면 미리보기 - {screenToPreview?.screenName}</DialogTitle>
|
||
</DialogHeader>
|
||
<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">
|
||
{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>
|
||
</div>
|
||
) : previewLayout && previewLayout.components ? (
|
||
(() => {
|
||
const screenWidth = previewLayout.screenResolution?.width || 1200;
|
||
const screenHeight = previewLayout.screenResolution?.height || 800;
|
||
|
||
// 모달 내부 가용 공간 계산 (헤더, 푸터, 패딩 제외)
|
||
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,
|
||
});
|
||
|
||
return (
|
||
<div
|
||
className="bg-card relative mx-auto rounded-xl border shadow-lg"
|
||
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,
|
||
}}
|
||
layers={previewLayout.layers || []}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 일반 컴포넌트 렌더링 - RealtimePreview 사용 (실제 화면과 동일)
|
||
return (
|
||
<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,
|
||
},
|
||
};
|
||
|
||
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>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
})()
|
||
) : (
|
||
<div className="flex h-full items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="text-muted-foreground mb-2 text-lg font-medium">레이아웃이 비어있습니다</div>
|
||
<div className="text-muted-foreground text-sm">이 화면에는 아직 컴포넌트가 배치되지 않았습니다.</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</TableOptionsProvider>
|
||
</ScreenPreviewProvider>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setPreviewDialogOpen(false)}>
|
||
닫기
|
||
</Button>
|
||
<Button onClick={() => onDesignScreen(screenToPreview!)}>
|
||
<Palette className="mr-2 h-4 w-4" />
|
||
편집 모드로 전환
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|