feat: 화면 관리 기능 개선 (복제/삭제/그룹 관리)
- 단일 화면 복제 및 그룹 전체 복제 기능 추가 - 정렬 순서 유지 및 일괄 이름 변경 기능 구현 - 삭제 기능 개선: 단일 화면 삭제 및 그룹 삭제 시 옵션 추가 - 회사 코드 지원 기능 추가: 복제된 그룹/화면에 선택한 회사 코드 적용 - 관련 파일 및 진행 상태 업데이트
This commit is contained in:
parent
059ea6b30a
commit
5d89b69451
63
PLAN.MD
63
PLAN.MD
|
|
@ -1,4 +1,65 @@
|
||||||
# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
|
# 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리)
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
화면 관리 시스템의 복제 및 삭제 기능을 전면 개선하여, 단일 화면 복제, 그룹(폴더) 전체 복제, 정렬 순서 유지, 일괄 이름 변경 등 다양한 고급 기능을 지원합니다.
|
||||||
|
|
||||||
|
## 핵심 기능
|
||||||
|
|
||||||
|
### 1. 단일 화면 복제
|
||||||
|
- [x] 우클릭 컨텍스트 메뉴에서 "복제" 선택
|
||||||
|
- [x] 화면명, 화면 코드 자동 생성 (중복 시 `_COPY` 접미사 추가)
|
||||||
|
- [x] 연결된 모달 화면 함께 복제
|
||||||
|
- [x] 대상 그룹 선택 가능
|
||||||
|
- [x] 복제 후 목록 자동 새로고침
|
||||||
|
|
||||||
|
### 2. 그룹(폴더) 전체 복제
|
||||||
|
- [x] 대분류 폴더 복제 시 모든 하위 폴더 + 화면 재귀적 복제
|
||||||
|
- [x] 정렬 순서(display_order) 유지
|
||||||
|
- 그룹 생성 시 원본 display_order 전달
|
||||||
|
- 화면 추가 시 원본 display_order 유지
|
||||||
|
- 하위 그룹들 display_order 순으로 정렬 후 복제
|
||||||
|
- [x] 대분류(최상위 그룹) 복제 시 경고 문구 표시
|
||||||
|
- [x] 정렬 순서 입력 필드 추가 (사용자가 직접 수정 가능)
|
||||||
|
- [x] 원본 그룹 정보 표시 개선
|
||||||
|
- 직접 포함 화면 수
|
||||||
|
- 하위 그룹 수
|
||||||
|
- 복제될 총 화면 수 (하위 그룹 포함)
|
||||||
|
|
||||||
|
### 3. 고급 옵션: 이름 일괄 변경
|
||||||
|
- [x] 삭제할 텍스트 지정 (모든 폴더/화면 이름에서 제거)
|
||||||
|
- [x] 추가할 접미사 지정 (기본값: " (복제)")
|
||||||
|
- [x] 미리보기 기능
|
||||||
|
|
||||||
|
### 4. 삭제 기능
|
||||||
|
- [x] 단일 화면 삭제 (휴지통으로 이동)
|
||||||
|
- [x] 그룹 삭제 시 옵션 선택
|
||||||
|
- "화면도 함께 삭제" 체크박스
|
||||||
|
- 체크 시: 그룹 + 포함된 화면 모두 삭제
|
||||||
|
- 미체크 시: 화면은 "미분류"로 이동
|
||||||
|
|
||||||
|
### 5. 회사 코드 지원 (최고 관리자)
|
||||||
|
- [x] 대상 회사 선택 가능
|
||||||
|
- [x] 복제된 그룹/화면에 선택한 회사 코드 적용
|
||||||
|
|
||||||
|
## 관련 파일
|
||||||
|
- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달 (화면/그룹 통합)
|
||||||
|
- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴
|
||||||
|
- `frontend/lib/api/screen.ts` - 화면 API (복제, 삭제)
|
||||||
|
- `frontend/lib/api/screenGroup.ts` - 그룹 API
|
||||||
|
|
||||||
|
## 진행 상태
|
||||||
|
- [완료] 단일 화면 복제 + 새로고침
|
||||||
|
- [완료] 그룹 전체 복제 (재귀적)
|
||||||
|
- [완료] 정렬 순서(display_order) 유지
|
||||||
|
- [완료] 대분류 경고 문구
|
||||||
|
- [완료] 정렬 순서 입력 필드
|
||||||
|
- [완료] 고급 옵션: 이름 일괄 변경
|
||||||
|
- [완료] 단일 화면 삭제
|
||||||
|
- [완료] 그룹 삭제 (화면 함께 삭제 옵션)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 이전 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
|
||||||
|
|
||||||
## 개요
|
## 개요
|
||||||
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
|
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
|
||||||
|
|
|
||||||
|
|
@ -2597,10 +2597,10 @@ export class ScreenManagementService {
|
||||||
// 없으면 원본과 같은 회사에 복사
|
// 없으면 원본과 같은 회사에 복사
|
||||||
const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code;
|
const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code;
|
||||||
|
|
||||||
// 3. 화면 코드 중복 체크 (대상 회사 기준)
|
// 3. 화면 코드 중복 체크 (대상 회사 기준, 삭제되지 않은 화면만)
|
||||||
const existingScreens = await client.query<any>(
|
const existingScreens = await client.query<any>(
|
||||||
`SELECT screen_id FROM screen_definitions
|
`SELECT screen_id FROM screen_definitions
|
||||||
WHERE screen_code = $1 AND company_code = $2
|
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[copyData.screenCode, targetCompanyCode]
|
[copyData.screenCode, targetCompanyCode]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,19 @@ export default function ScreenManagementPage() {
|
||||||
loadScreens();
|
loadScreens();
|
||||||
}, [loadScreens]);
|
}, [loadScreens]);
|
||||||
|
|
||||||
|
// 화면 목록 새로고침 이벤트 리스너
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScreenListRefresh = () => {
|
||||||
|
console.log("🔄 화면 목록 새로고침 이벤트 수신");
|
||||||
|
loadScreens();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("screen-list-refresh", handleScreenListRefresh);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("screen-list-refresh", handleScreenListRefresh);
|
||||||
|
};
|
||||||
|
}, [loadScreens]);
|
||||||
|
|
||||||
// URL 쿼리 파라미터로 화면 디자이너 자동 열기
|
// URL 쿼리 파라미터로 화면 디자이너 자동 열기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const openDesignerId = searchParams.get("openDesigner");
|
const openDesignerId = searchParams.get("openDesigner");
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ function ScreenViewPage() {
|
||||||
|
|
||||||
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
|
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
|
||||||
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
|
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
|
||||||
|
|
||||||
// URL 쿼리에서 프리뷰용 company_code 가져오기
|
// URL 쿼리에서 프리뷰용 company_code 가져오기
|
||||||
const previewCompanyCode = searchParams.get("company_code");
|
const previewCompanyCode = searchParams.get("company_code");
|
||||||
|
|
||||||
|
|
@ -264,8 +264,8 @@ function ScreenViewPage() {
|
||||||
newScale = Math.min(scaleX, scaleY, 1); // 최대 1배율
|
newScale = Math.min(scaleX, scaleY, 1); // 최대 1배율
|
||||||
} else {
|
} else {
|
||||||
// 일반 모드: 가로 기준 스케일 (좌우 여백 16px씩 고정)
|
// 일반 모드: 가로 기준 스케일 (좌우 여백 16px씩 고정)
|
||||||
const MARGIN_X = 32;
|
const MARGIN_X = 32;
|
||||||
const availableWidth = containerWidth - MARGIN_X;
|
const availableWidth = containerWidth - MARGIN_X;
|
||||||
newScale = availableWidth / designWidth;
|
newScale = availableWidth / designWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -315,7 +315,11 @@ export function ScreenGroupModal({
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value="none"
|
value="none"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setFormData({ ...formData, parent_group_id: null });
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
parent_group_id: null,
|
||||||
|
// 대분류 선택 시 현재 회사 코드 유지
|
||||||
|
});
|
||||||
setIsParentGroupSelectOpen(false);
|
setIsParentGroupSelectOpen(false);
|
||||||
}}
|
}}
|
||||||
className="text-xs sm:text-sm"
|
className="text-xs sm:text-sm"
|
||||||
|
|
@ -335,7 +339,13 @@ export function ScreenGroupModal({
|
||||||
key={parentGroup.id}
|
key={parentGroup.id}
|
||||||
value={`${parentGroup.group_name} ${getGroupPath(parentGroup.id)}`}
|
value={`${parentGroup.group_name} ${getGroupPath(parentGroup.id)}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setFormData({ ...formData, parent_group_id: parentGroup.id });
|
// 상위 그룹의 company_code로 자동 설정
|
||||||
|
const parentCompanyCode = parentGroup.company_code || formData.target_company_code;
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
parent_group_id: parentGroup.id,
|
||||||
|
target_company_code: parentCompanyCode,
|
||||||
|
});
|
||||||
setIsParentGroupSelectOpen(false);
|
setIsParentGroupSelectOpen(false);
|
||||||
}}
|
}}
|
||||||
className="text-xs sm:text-sm"
|
className="text-xs sm:text-sm"
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ import {
|
||||||
Edit,
|
Edit,
|
||||||
Trash2,
|
Trash2,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
|
Copy,
|
||||||
|
FolderTree,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
import {
|
import {
|
||||||
|
|
@ -73,7 +76,9 @@ import {
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { Check, ChevronsUpDown } from "lucide-react";
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { ScreenGroupModal } from "./ScreenGroupModal";
|
import { ScreenGroupModal } from "./ScreenGroupModal";
|
||||||
|
import CopyScreenModal from "./CopyScreenModal";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { screenApi } from "@/lib/api/screen";
|
||||||
|
|
||||||
interface ScreenGroupTreeViewProps {
|
interface ScreenGroupTreeViewProps {
|
||||||
screens: ScreenDefinition[];
|
screens: ScreenDefinition[];
|
||||||
|
|
@ -115,15 +120,41 @@ export function ScreenGroupTreeView({
|
||||||
// 삭제 확인 다이얼로그 상태
|
// 삭제 확인 다이얼로그 상태
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [deletingGroup, setDeletingGroup] = useState<ScreenGroup | null>(null);
|
const [deletingGroup, setDeletingGroup] = useState<ScreenGroup | null>(null);
|
||||||
|
const [deleteScreensWithGroup, setDeleteScreensWithGroup] = useState(false); // 화면도 함께 삭제 체크박스
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false); // 삭제 진행 중 상태
|
||||||
|
const [deleteProgress, setDeleteProgress] = useState({ current: 0, total: 0, message: "" }); // 삭제 진행 상태
|
||||||
|
|
||||||
// 화면 이동 메뉴 상태
|
// 단일 화면 삭제 상태
|
||||||
const [movingScreen, setMovingScreen] = useState<ScreenDefinition | null>(null);
|
const [isScreenDeleteDialogOpen, setIsScreenDeleteDialogOpen] = useState(false);
|
||||||
const [isMoveMenuOpen, setIsMoveMenuOpen] = useState(false);
|
const [deletingScreen, setDeletingScreen] = useState<ScreenDefinition | null>(null);
|
||||||
|
const [isScreenDeleting, setIsScreenDeleting] = useState(false); // 화면 삭제 진행 중
|
||||||
|
|
||||||
|
// 화면 수정 모달 상태 (이름 변경 + 그룹 이동 통합)
|
||||||
|
const [editingScreen, setEditingScreen] = useState<ScreenDefinition | null>(null);
|
||||||
|
const [isEditScreenModalOpen, setIsEditScreenModalOpen] = useState(false);
|
||||||
|
const [editScreenName, setEditScreenName] = useState<string>("");
|
||||||
const [selectedGroupForMove, setSelectedGroupForMove] = useState<number | null>(null);
|
const [selectedGroupForMove, setSelectedGroupForMove] = useState<number | null>(null);
|
||||||
const [screenRole, setScreenRole] = useState<string>("");
|
const [screenRole, setScreenRole] = useState<string>("");
|
||||||
const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false);
|
const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false);
|
||||||
const [displayOrder, setDisplayOrder] = useState<number>(1);
|
const [displayOrder, setDisplayOrder] = useState<number>(1);
|
||||||
|
|
||||||
|
// 화면 복제 모달 상태 (CopyScreenModal 사용)
|
||||||
|
const [isCopyModalOpen, setIsCopyModalOpen] = useState(false);
|
||||||
|
const [copyingScreen, setCopyingScreen] = useState<ScreenDefinition | null>(null);
|
||||||
|
const [copyTargetGroupId, setCopyTargetGroupId] = useState<number | null>(null);
|
||||||
|
const [copyMode, setCopyMode] = useState<"screen" | "group">("screen");
|
||||||
|
|
||||||
|
// 그룹 복제 모달 상태 (CopyScreenModal 그룹 모드 사용)
|
||||||
|
const [copyingGroup, setCopyingGroup] = useState<ScreenGroup | null>(null);
|
||||||
|
|
||||||
|
// 컨텍스트 메뉴 상태 (화면용)
|
||||||
|
const [contextMenuScreen, setContextMenuScreen] = useState<ScreenDefinition | null>(null);
|
||||||
|
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
// 그룹 컨텍스트 메뉴 상태
|
||||||
|
const [contextMenuGroup, setContextMenuGroup] = useState<ScreenGroup | null>(null);
|
||||||
|
const [contextMenuGroupPosition, setContextMenuGroupPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
// 그룹 목록 및 그룹별 화면 로드
|
// 그룹 목록 및 그룹별 화면 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadGroupsData();
|
loadGroupsData();
|
||||||
|
|
@ -219,21 +250,110 @@ export function ScreenGroupTreeView({
|
||||||
};
|
};
|
||||||
|
|
||||||
// 그룹 삭제 버튼 클릭
|
// 그룹 삭제 버튼 클릭
|
||||||
const handleDeleteGroup = (group: ScreenGroup, e: React.MouseEvent) => {
|
const handleDeleteGroup = (group: ScreenGroup, e?: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e?.stopPropagation();
|
||||||
setDeletingGroup(group);
|
setDeletingGroup(group);
|
||||||
|
setDeleteScreensWithGroup(false); // 기본값: 화면 삭제 안함
|
||||||
setIsDeleteDialogOpen(true);
|
setIsDeleteDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 그룹과 모든 하위 그룹의 화면을 재귀적으로 수집
|
||||||
|
const getAllScreensInGroupRecursively = (groupId: number): ScreenDefinition[] => {
|
||||||
|
const result: ScreenDefinition[] = [];
|
||||||
|
|
||||||
|
// 현재 그룹의 화면들
|
||||||
|
const currentGroupScreens = getScreensInGroup(groupId);
|
||||||
|
result.push(...currentGroupScreens);
|
||||||
|
|
||||||
|
// 하위 그룹들 찾기
|
||||||
|
const childGroups = groups.filter((g) => (g as any).parent_group_id === groupId);
|
||||||
|
for (const childGroup of childGroups) {
|
||||||
|
const childScreens = getAllScreensInGroupRecursively(childGroup.id);
|
||||||
|
result.push(...childScreens);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모든 하위 그룹 ID를 재귀적으로 수집 (삭제 순서: 자식 → 부모)
|
||||||
|
const getAllChildGroupIds = (groupId: number): number[] => {
|
||||||
|
const result: number[] = [];
|
||||||
|
const childGroups = groups.filter((g) => (g as any).parent_group_id === groupId);
|
||||||
|
|
||||||
|
for (const childGroup of childGroups) {
|
||||||
|
// 자식의 자식들을 먼저 수집 (깊은 곳부터)
|
||||||
|
const grandChildIds = getAllChildGroupIds(childGroup.id);
|
||||||
|
result.push(...grandChildIds);
|
||||||
|
result.push(childGroup.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
// 그룹 삭제 확인
|
// 그룹 삭제 확인
|
||||||
const confirmDeleteGroup = async () => {
|
const confirmDeleteGroup = async () => {
|
||||||
if (!deletingGroup) return;
|
if (!deletingGroup) return;
|
||||||
|
|
||||||
|
// 삭제 전 통계 수집 (화면 수는 삭제 전에 계산)
|
||||||
|
const totalScreensToDelete = getAllScreensInGroupRecursively(deletingGroup.id).length;
|
||||||
|
const childGroupIds = getAllChildGroupIds(deletingGroup.id);
|
||||||
|
|
||||||
|
// 총 작업 수 계산 (화면 + 하위 그룹 + 현재 그룹)
|
||||||
|
const totalSteps = totalScreensToDelete + childGroupIds.length + 1;
|
||||||
|
let currentStep = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
setIsDeleting(true);
|
||||||
|
setDeleteProgress({ current: 0, total: totalSteps, message: "삭제 준비 중..." });
|
||||||
|
|
||||||
|
// 화면도 함께 삭제하는 경우
|
||||||
|
if (deleteScreensWithGroup) {
|
||||||
|
// 현재 그룹 + 모든 하위 그룹의 화면을 재귀적으로 수집
|
||||||
|
const allScreens = getAllScreensInGroupRecursively(deletingGroup.id);
|
||||||
|
if (allScreens.length > 0) {
|
||||||
|
const { screenApi } = await import("@/lib/api/screen");
|
||||||
|
|
||||||
|
// 화면을 하나씩 삭제하면서 진행률 업데이트
|
||||||
|
for (let i = 0; i < allScreens.length; i++) {
|
||||||
|
const screen = allScreens[i];
|
||||||
|
currentStep++;
|
||||||
|
setDeleteProgress({
|
||||||
|
current: currentStep,
|
||||||
|
total: totalSteps,
|
||||||
|
message: `화면 삭제 중: ${screen.screenName}`
|
||||||
|
});
|
||||||
|
await screenApi.deleteScreen(screen.screenId, "그룹 삭제와 함께 삭제");
|
||||||
|
}
|
||||||
|
console.log(`✅ 그룹 및 하위 그룹 내 화면 ${allScreens.length}개 삭제 완료`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 하위 그룹들을 먼저 삭제 (자식 → 부모 순서)
|
||||||
|
for (let i = 0; i < childGroupIds.length; i++) {
|
||||||
|
const childId = childGroupIds[i];
|
||||||
|
const childGroup = groups.find(g => g.id === childId);
|
||||||
|
currentStep++;
|
||||||
|
setDeleteProgress({
|
||||||
|
current: currentStep,
|
||||||
|
total: totalSteps,
|
||||||
|
message: `하위 그룹 삭제 중: ${childGroup?.group_name || childId}`
|
||||||
|
});
|
||||||
|
await deleteScreenGroup(childId);
|
||||||
|
console.log(`✅ 하위 그룹 ${childId} 삭제 완료`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최종적으로 대상 그룹 삭제
|
||||||
|
currentStep++;
|
||||||
|
setDeleteProgress({ current: currentStep, total: totalSteps, message: "그룹 삭제 완료 중..." });
|
||||||
const response = await deleteScreenGroup(deletingGroup.id);
|
const response = await deleteScreenGroup(deletingGroup.id);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
toast.success("그룹이 삭제되었습니다");
|
toast.success(
|
||||||
loadGroupsData();
|
deleteScreensWithGroup
|
||||||
|
? `그룹과 화면 ${totalScreensToDelete}개가 삭제되었습니다`
|
||||||
|
: "그룹이 삭제되었습니다"
|
||||||
|
);
|
||||||
|
await loadGroupsData();
|
||||||
|
window.dispatchEvent(new CustomEvent("screen-list-refresh"));
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.message || "그룹 삭제에 실패했습니다");
|
toast.error(response.message || "그룹 삭제에 실패했습니다");
|
||||||
}
|
}
|
||||||
|
|
@ -241,16 +361,45 @@ export function ScreenGroupTreeView({
|
||||||
console.error("그룹 삭제 실패:", error);
|
console.error("그룹 삭제 실패:", error);
|
||||||
toast.error("그룹 삭제에 실패했습니다");
|
toast.error("그룹 삭제에 실패했습니다");
|
||||||
} finally {
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
setDeleteProgress({ current: 0, total: 0, message: "" });
|
||||||
setIsDeleteDialogOpen(false);
|
setIsDeleteDialogOpen(false);
|
||||||
setDeletingGroup(null);
|
setDeletingGroup(null);
|
||||||
|
setDeleteScreensWithGroup(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 화면 이동 메뉴 열기
|
// 단일 화면 삭제 버튼 클릭
|
||||||
const handleMoveScreen = (screen: ScreenDefinition, e: React.MouseEvent) => {
|
const handleDeleteScreen = (screen: ScreenDefinition) => {
|
||||||
e.preventDefault();
|
setDeletingScreen(screen);
|
||||||
e.stopPropagation();
|
setIsScreenDeleteDialogOpen(true);
|
||||||
setMovingScreen(screen);
|
};
|
||||||
|
|
||||||
|
// 단일 화면 삭제 확인
|
||||||
|
const confirmDeleteScreen = async () => {
|
||||||
|
if (!deletingScreen) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsScreenDeleting(true);
|
||||||
|
const { screenApi } = await import("@/lib/api/screen");
|
||||||
|
await screenApi.deleteScreen(deletingScreen.screenId, "사용자 요청으로 삭제");
|
||||||
|
toast.success(`"${deletingScreen.screenName}" 화면이 삭제되었습니다`);
|
||||||
|
await loadGroupsData();
|
||||||
|
window.dispatchEvent(new CustomEvent("screen-list-refresh"));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("화면 삭제 실패:", error);
|
||||||
|
toast.error("화면 삭제에 실패했습니다");
|
||||||
|
} finally {
|
||||||
|
setIsScreenDeleting(false);
|
||||||
|
setIsScreenDeleteDialogOpen(false);
|
||||||
|
setDeletingScreen(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면 수정 모달 열기 (이름 변경 + 그룹 이동)
|
||||||
|
const handleOpenEditScreenModal = (screen: ScreenDefinition) => {
|
||||||
|
setEditingScreen(screen);
|
||||||
|
setEditScreenName(screen.screenName);
|
||||||
|
|
||||||
// 현재 화면이 속한 그룹 정보 찾기
|
// 현재 화면이 속한 그룹 정보 찾기
|
||||||
let currentGroupId: number | null = null;
|
let currentGroupId: number | null = null;
|
||||||
|
|
@ -273,17 +422,92 @@ export function ScreenGroupTreeView({
|
||||||
setSelectedGroupForMove(currentGroupId);
|
setSelectedGroupForMove(currentGroupId);
|
||||||
setScreenRole(currentScreenRole);
|
setScreenRole(currentScreenRole);
|
||||||
setDisplayOrder(currentDisplayOrder);
|
setDisplayOrder(currentDisplayOrder);
|
||||||
setIsMoveMenuOpen(true);
|
setIsEditScreenModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 화면을 특정 그룹으로 이동
|
// 화면 복제 모달 열기 (CopyScreenModal 사용)
|
||||||
const moveScreenToGroup = async (targetGroupId: number | null) => {
|
const handleOpenCopyModal = (screen: ScreenDefinition) => {
|
||||||
if (!movingScreen) return;
|
// 현재 화면이 속한 그룹 찾기 (기본값으로 설정)
|
||||||
|
let currentGroupId: number | null = null;
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.screens && Array.isArray(group.screens)) {
|
||||||
|
const found = group.screens.find((s: any) => Number(s.screen_id) === Number(screen.screenId));
|
||||||
|
if (found) {
|
||||||
|
currentGroupId = group.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCopyingScreen(screen);
|
||||||
|
setCopyTargetGroupId(currentGroupId);
|
||||||
|
setCopyMode("screen");
|
||||||
|
setIsCopyModalOpen(true);
|
||||||
|
setContextMenuPosition(null); // 컨텍스트 메뉴 닫기
|
||||||
|
};
|
||||||
|
|
||||||
|
// 그룹 복제 모달 열기 (CopyScreenModal 그룹 모드 사용)
|
||||||
|
const handleOpenGroupCopyModal = (group: ScreenGroup) => {
|
||||||
|
setCopyingGroup(group);
|
||||||
|
setCopyMode("group");
|
||||||
|
setIsCopyModalOpen(true);
|
||||||
|
closeGroupContextMenu(); // 그룹 컨텍스트 메뉴 닫기
|
||||||
|
};
|
||||||
|
|
||||||
|
// 복제 성공 콜백
|
||||||
|
const handleCopySuccess = async () => {
|
||||||
|
console.log("🔄 복제 성공 - 새로고침 시작");
|
||||||
|
// 그룹 목록 새로고침
|
||||||
|
await loadGroupsData();
|
||||||
|
console.log("✅ 그룹 목록 새로고침 완료");
|
||||||
|
// 화면 목록 새로고침
|
||||||
|
window.dispatchEvent(new CustomEvent("screen-list-refresh"));
|
||||||
|
console.log("✅ 화면 목록 새로고침 이벤트 발송 완료");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컨텍스트 메뉴 열기
|
||||||
|
const handleContextMenu = (e: React.MouseEvent, screen: ScreenDefinition) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setContextMenuScreen(screen);
|
||||||
|
setContextMenuPosition({ x: e.clientX, y: e.clientY });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컨텍스트 메뉴 닫기
|
||||||
|
const closeContextMenu = () => {
|
||||||
|
setContextMenuPosition(null);
|
||||||
|
setContextMenuScreen(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 그룹 컨텍스트 메뉴 열기
|
||||||
|
const handleGroupContextMenu = (e: React.MouseEvent, group: ScreenGroup) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setContextMenuGroup(group);
|
||||||
|
setContextMenuGroupPosition({ x: e.clientX, y: e.clientY });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 그룹 컨텍스트 메뉴 닫기
|
||||||
|
const closeGroupContextMenu = () => {
|
||||||
|
setContextMenuGroupPosition(null);
|
||||||
|
setContextMenuGroup(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면 수정 저장 (이름 변경 + 그룹 이동)
|
||||||
|
const saveScreenEdit = async () => {
|
||||||
|
if (!editingScreen) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 현재 그룹에서 제거
|
// 1. 화면 이름이 변경되었으면 업데이트
|
||||||
|
if (editScreenName.trim() && editScreenName !== editingScreen.screenName) {
|
||||||
|
await screenApi.updateScreen(editingScreen.screenId, {
|
||||||
|
screenName: editScreenName.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 현재 그룹에서 제거
|
||||||
const currentGroupId = Array.from(groupScreensMap.entries()).find(([_, screenIds]) =>
|
const currentGroupId = Array.from(groupScreensMap.entries()).find(([_, screenIds]) =>
|
||||||
screenIds.includes(movingScreen.screenId)
|
screenIds.includes(editingScreen.screenId)
|
||||||
)?.[0];
|
)?.[0];
|
||||||
|
|
||||||
if (currentGroupId) {
|
if (currentGroupId) {
|
||||||
|
|
@ -291,7 +515,7 @@ export function ScreenGroupTreeView({
|
||||||
const currentGroup = groups.find((g) => g.id === currentGroupId);
|
const currentGroup = groups.find((g) => g.id === currentGroupId);
|
||||||
if (currentGroup && currentGroup.screens) {
|
if (currentGroup && currentGroup.screens) {
|
||||||
const screenGroupScreen = currentGroup.screens.find(
|
const screenGroupScreen = currentGroup.screens.find(
|
||||||
(s: any) => s.screen_id === movingScreen.screenId
|
(s: any) => s.screen_id === editingScreen.screenId
|
||||||
);
|
);
|
||||||
if (screenGroupScreen) {
|
if (screenGroupScreen) {
|
||||||
await removeScreenFromGroup(screenGroupScreen.id);
|
await removeScreenFromGroup(screenGroupScreen.id);
|
||||||
|
|
@ -299,25 +523,27 @@ export function ScreenGroupTreeView({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새 그룹에 추가 (미분류가 아닌 경우)
|
// 3. 새 그룹에 추가 (미분류가 아닌 경우)
|
||||||
if (targetGroupId !== null) {
|
if (selectedGroupForMove !== null) {
|
||||||
await addScreenToGroup({
|
await addScreenToGroup({
|
||||||
group_id: targetGroupId,
|
group_id: selectedGroupForMove,
|
||||||
screen_id: movingScreen.screenId,
|
screen_id: editingScreen.screenId,
|
||||||
screen_role: screenRole,
|
screen_role: screenRole,
|
||||||
display_order: displayOrder,
|
display_order: displayOrder,
|
||||||
is_default: "N",
|
is_default: "N",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success("화면이 이동되었습니다");
|
toast.success("화면이 수정되었습니다");
|
||||||
loadGroupsData();
|
loadGroupsData();
|
||||||
|
window.dispatchEvent(new CustomEvent("screen-list-refresh"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("화면 이동 실패:", error);
|
console.error("화면 수정 실패:", error);
|
||||||
toast.error("화면 이동에 실패했습니다");
|
toast.error("화면 수정에 실패했습니다");
|
||||||
} finally {
|
} finally {
|
||||||
setIsMoveMenuOpen(false);
|
setIsEditScreenModalOpen(false);
|
||||||
setMovingScreen(null);
|
setEditingScreen(null);
|
||||||
|
setEditScreenName("");
|
||||||
setSelectedGroupForMove(null);
|
setSelectedGroupForMove(null);
|
||||||
setScreenRole("");
|
setScreenRole("");
|
||||||
setDisplayOrder(1);
|
setDisplayOrder(1);
|
||||||
|
|
@ -444,6 +670,7 @@ export function ScreenGroupTreeView({
|
||||||
"text-sm font-medium group/item"
|
"text-sm font-medium group/item"
|
||||||
)}
|
)}
|
||||||
onClick={() => toggleGroup(groupId)}
|
onClick={() => toggleGroup(groupId)}
|
||||||
|
onContextMenu={(e) => handleGroupContextMenu(e, group)}
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
|
@ -507,6 +734,7 @@ export function ScreenGroupTreeView({
|
||||||
"text-xs font-medium group/item"
|
"text-xs font-medium group/item"
|
||||||
)}
|
)}
|
||||||
onClick={() => toggleGroup(childGroupId)}
|
onClick={() => toggleGroup(childGroupId)}
|
||||||
|
onContextMenu={(e) => handleGroupContextMenu(e, childGroup)}
|
||||||
>
|
>
|
||||||
{isChildExpanded ? (
|
{isChildExpanded ? (
|
||||||
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
|
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
|
|
@ -566,6 +794,7 @@ export function ScreenGroupTreeView({
|
||||||
"text-xs group/item"
|
"text-xs group/item"
|
||||||
)}
|
)}
|
||||||
onClick={() => toggleGroup(grandChildId)}
|
onClick={() => toggleGroup(grandChildId)}
|
||||||
|
onContextMenu={(e) => handleGroupContextMenu(e, grandChild)}
|
||||||
>
|
>
|
||||||
{isGrandExpanded ? (
|
{isGrandExpanded ? (
|
||||||
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
|
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
|
|
@ -626,7 +855,7 @@ export function ScreenGroupTreeView({
|
||||||
)}
|
)}
|
||||||
onClick={() => handleScreenClickInGroup(screen, grandChild)}
|
onClick={() => handleScreenClickInGroup(screen, grandChild)}
|
||||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||||
onContextMenu={(e) => handleMoveScreen(screen, e)}
|
onContextMenu={(e) => handleContextMenu(e, screen)}
|
||||||
>
|
>
|
||||||
<Monitor className="h-3 w-3 shrink-0 text-blue-500" />
|
<Monitor className="h-3 w-3 shrink-0 text-blue-500" />
|
||||||
<span className="truncate flex-1">{screen.screenName}</span>
|
<span className="truncate flex-1">{screen.screenName}</span>
|
||||||
|
|
@ -662,7 +891,7 @@ export function ScreenGroupTreeView({
|
||||||
)}
|
)}
|
||||||
onClick={() => handleScreenClickInGroup(screen, childGroup)}
|
onClick={() => handleScreenClickInGroup(screen, childGroup)}
|
||||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||||
onContextMenu={(e) => handleMoveScreen(screen, e)}
|
onContextMenu={(e) => handleContextMenu(e, screen)}
|
||||||
>
|
>
|
||||||
<Monitor className="h-3 w-3 shrink-0 text-blue-500" />
|
<Monitor className="h-3 w-3 shrink-0 text-blue-500" />
|
||||||
<span className="truncate flex-1">{screen.screenName}</span>
|
<span className="truncate flex-1">{screen.screenName}</span>
|
||||||
|
|
@ -698,7 +927,7 @@ export function ScreenGroupTreeView({
|
||||||
)}
|
)}
|
||||||
onClick={() => handleScreenClickInGroup(screen, group)}
|
onClick={() => handleScreenClickInGroup(screen, group)}
|
||||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||||
onContextMenu={(e) => handleMoveScreen(screen, e)}
|
onContextMenu={(e) => handleContextMenu(e, screen)}
|
||||||
>
|
>
|
||||||
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
|
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
|
||||||
<span className="truncate flex-1">{screen.screenName}</span>
|
<span className="truncate flex-1">{screen.screenName}</span>
|
||||||
|
|
@ -748,7 +977,7 @@ export function ScreenGroupTreeView({
|
||||||
)}
|
)}
|
||||||
onClick={() => handleScreenClick(screen)}
|
onClick={() => handleScreenClick(screen)}
|
||||||
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
onDoubleClick={() => handleScreenDoubleClick(screen)}
|
||||||
onContextMenu={(e) => handleMoveScreen(screen, e)}
|
onContextMenu={(e) => handleContextMenu(e, screen)}
|
||||||
>
|
>
|
||||||
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
|
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
|
||||||
<span className="truncate flex-1">{screen.screenName}</span>
|
<span className="truncate flex-1">{screen.screenName}</span>
|
||||||
|
|
@ -781,6 +1010,23 @@ export function ScreenGroupTreeView({
|
||||||
group={editingGroup}
|
group={editingGroup}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 화면/그룹 복제 모달 (CopyScreenModal 사용) */}
|
||||||
|
<CopyScreenModal
|
||||||
|
isOpen={isCopyModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsCopyModalOpen(false);
|
||||||
|
setCopyingScreen(null);
|
||||||
|
setCopyingGroup(null);
|
||||||
|
}}
|
||||||
|
sourceScreen={copyMode === "screen" ? copyingScreen : null}
|
||||||
|
onCopySuccess={handleCopySuccess}
|
||||||
|
mode={copyMode}
|
||||||
|
sourceGroup={copyMode === "group" ? copyingGroup : null}
|
||||||
|
groups={groups}
|
||||||
|
targetGroupId={copyTargetGroupId}
|
||||||
|
allScreens={screens}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 그룹 삭제 확인 다이얼로그 */}
|
{/* 그룹 삭제 확인 다이얼로그 */}
|
||||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
|
@ -789,34 +1035,167 @@ export function ScreenGroupTreeView({
|
||||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||||
"{deletingGroup?.group_name}" 그룹을 삭제하시겠습니까?
|
"{deletingGroup?.group_name}" 그룹을 삭제하시겠습니까?
|
||||||
<br />
|
<br />
|
||||||
그룹에 속한 화면들은 미분류로 이동됩니다.
|
{deleteScreensWithGroup
|
||||||
|
? <span className="text-destructive font-medium">그룹에 속한 화면들도 함께 삭제됩니다.</span>
|
||||||
|
: "그룹에 속한 화면들은 미분류로 이동됩니다."
|
||||||
|
}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
{/* 그룹 정보 표시 */}
|
||||||
|
{deletingGroup && (
|
||||||
|
<div className="rounded-md border bg-muted/50 p-3 text-xs space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">하위 그룹 수:</span>
|
||||||
|
<span className="font-medium">{getAllChildGroupIds(deletingGroup.id).length}개</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">총 화면 수 (하위 포함):</span>
|
||||||
|
<span className="font-medium">{getAllScreensInGroupRecursively(deletingGroup.id).length}개</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 화면도 함께 삭제 체크박스 */}
|
||||||
|
{deletingGroup && getAllScreensInGroupRecursively(deletingGroup.id).length > 0 && (
|
||||||
|
<div className="flex items-center space-x-2 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="deleteScreensWithGroup"
|
||||||
|
checked={deleteScreensWithGroup}
|
||||||
|
onChange={(e) => setDeleteScreensWithGroup(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-destructive focus:ring-destructive"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="deleteScreensWithGroup"
|
||||||
|
className="text-sm text-muted-foreground cursor-pointer"
|
||||||
|
>
|
||||||
|
화면도 함께 삭제 ({getAllScreensInGroupRecursively(deletingGroup.id).length}개)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 로딩 오버레이 */}
|
||||||
|
{isDeleting && (
|
||||||
|
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center rounded-lg bg-background/90 backdrop-blur-sm">
|
||||||
|
<Loader2 className="h-10 w-10 animate-spin text-destructive" />
|
||||||
|
<p className="mt-4 text-sm font-medium">{deleteProgress.message}</p>
|
||||||
|
{deleteProgress.total > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-secondary">
|
||||||
|
<div
|
||||||
|
className="h-full bg-destructive transition-all duration-300"
|
||||||
|
style={{ width: `${Math.round((deleteProgress.current / deleteProgress.total) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{deleteProgress.current} / {deleteProgress.total}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||||
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
<AlertDialogCancel
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
취소
|
취소
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={confirmDeleteGroup}
|
onClick={(e) => {
|
||||||
|
e.preventDefault(); // 자동 닫힘 방지
|
||||||
|
confirmDeleteGroup();
|
||||||
|
}}
|
||||||
|
disabled={isDeleting}
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm bg-destructive hover:bg-destructive/90"
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm bg-destructive hover:bg-destructive/90"
|
||||||
>
|
>
|
||||||
삭제
|
{isDeleting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
삭제 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"삭제"
|
||||||
|
)}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
{/* 화면 이동 메뉴 (다이얼로그) */}
|
{/* 단일 화면 삭제 확인 다이얼로그 */}
|
||||||
<Dialog open={isMoveMenuOpen} onOpenChange={setIsMoveMenuOpen}>
|
<AlertDialog open={isScreenDeleteDialogOpen} onOpenChange={setIsScreenDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
{/* 로딩 오버레이 */}
|
||||||
|
{isScreenDeleting && (
|
||||||
|
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center rounded-lg bg-background/90 backdrop-blur-sm">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-destructive" />
|
||||||
|
<p className="mt-3 text-sm font-medium">화면 삭제 중...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-base sm:text-lg">화면 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||||
|
"{deletingScreen?.screenName}" 화면을 삭제하시겠습니까?
|
||||||
|
<br />
|
||||||
|
삭제된 화면은 휴지통으로 이동됩니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<AlertDialogCancel
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
disabled={isScreenDeleting}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault(); // 자동 닫힘 방지
|
||||||
|
confirmDeleteScreen();
|
||||||
|
}}
|
||||||
|
disabled={isScreenDeleting}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm bg-destructive hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{isScreenDeleting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
삭제 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"삭제"
|
||||||
|
)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* 화면 수정 모달 (이름 변경 + 그룹 이동) */}
|
||||||
|
<Dialog open={isEditScreenModalOpen} onOpenChange={setIsEditScreenModalOpen}>
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-base sm:text-lg">화면 그룹 설정</DialogTitle>
|
<DialogTitle className="text-base sm:text-lg">화면 수정</DialogTitle>
|
||||||
<DialogDescription className="text-xs sm:text-sm">
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
"{movingScreen?.screenName}"의 그룹과 역할을 설정하세요
|
화면 정보를 수정하세요
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
{/* 화면 이름 */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="edit-screen-name" className="text-xs sm:text-sm">
|
||||||
|
화면 이름 *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-screen-name"
|
||||||
|
value={editScreenName}
|
||||||
|
onChange={(e) => setEditScreenName(e.target.value)}
|
||||||
|
placeholder="화면 이름을 입력하세요"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 그룹 선택 (트리 구조 + 검색) */}
|
{/* 그룹 선택 (트리 구조 + 검색) */}
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="target-group" className="text-xs sm:text-sm">
|
<Label htmlFor="target-group" className="text-xs sm:text-sm">
|
||||||
|
|
@ -949,8 +1328,9 @@ export function ScreenGroupTreeView({
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsMoveMenuOpen(false);
|
setIsEditScreenModalOpen(false);
|
||||||
setMovingScreen(null);
|
setEditingScreen(null);
|
||||||
|
setEditScreenName("");
|
||||||
setSelectedGroupForMove(null);
|
setSelectedGroupForMove(null);
|
||||||
setScreenRole("");
|
setScreenRole("");
|
||||||
setDisplayOrder(1);
|
setDisplayOrder(1);
|
||||||
|
|
@ -960,14 +1340,125 @@ export function ScreenGroupTreeView({
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => moveScreenToGroup(selectedGroupForMove)}
|
onClick={saveScreenEdit}
|
||||||
|
disabled={!editScreenName.trim()}
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
이동
|
저장
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 커스텀 컨텍스트 메뉴 */}
|
||||||
|
{contextMenuPosition && contextMenuScreen && (
|
||||||
|
<>
|
||||||
|
{/* 백드롭 - 클릭 시 메뉴 닫기 */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={closeContextMenu}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
closeContextMenu();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* 컨텍스트 메뉴 */}
|
||||||
|
<div
|
||||||
|
className="fixed z-50 min-w-[150px] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
|
||||||
|
style={{
|
||||||
|
left: contextMenuPosition.x,
|
||||||
|
top: contextMenuPosition.y,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
|
||||||
|
onClick={() => {
|
||||||
|
handleOpenCopyModal(contextMenuScreen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
복제
|
||||||
|
</div>
|
||||||
|
<div className="my-1 h-px bg-border" />
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
|
||||||
|
onClick={() => {
|
||||||
|
handleOpenEditScreenModal(contextMenuScreen);
|
||||||
|
closeContextMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
수정
|
||||||
|
</div>
|
||||||
|
<div className="my-1 h-px bg-border" />
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm text-destructive hover:bg-accent"
|
||||||
|
onClick={() => {
|
||||||
|
handleDeleteScreen(contextMenuScreen);
|
||||||
|
closeContextMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
삭제
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 그룹 컨텍스트 메뉴 */}
|
||||||
|
{contextMenuGroupPosition && contextMenuGroup && (
|
||||||
|
<>
|
||||||
|
{/* 백드롭 - 클릭 시 메뉴 닫기 */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={closeGroupContextMenu}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
closeGroupContextMenu();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* 컨텍스트 메뉴 */}
|
||||||
|
<div
|
||||||
|
className="fixed z-50 min-w-[150px] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
|
||||||
|
style={{
|
||||||
|
left: contextMenuGroupPosition.x,
|
||||||
|
top: contextMenuGroupPosition.y,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
|
||||||
|
onClick={() => handleOpenGroupCopyModal(contextMenuGroup)}
|
||||||
|
>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
그룹 복제
|
||||||
|
</div>
|
||||||
|
<div className="my-1 h-px bg-border" />
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingGroup(contextMenuGroup);
|
||||||
|
setIsGroupModalOpen(true);
|
||||||
|
closeGroupContextMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
수정
|
||||||
|
</div>
|
||||||
|
<div className="my-1 h-px bg-border" />
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm text-destructive hover:bg-accent"
|
||||||
|
onClick={() => {
|
||||||
|
handleDeleteGroup(contextMenuGroup);
|
||||||
|
closeGroupContextMenu();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
그룹 삭제
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -105,6 +105,18 @@ export const screenApi = {
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 화면 수정 (이름, 설명 등)
|
||||||
|
updateScreen: async (
|
||||||
|
screenId: number,
|
||||||
|
data: {
|
||||||
|
screenName?: string;
|
||||||
|
description?: string;
|
||||||
|
tableName?: string;
|
||||||
|
}
|
||||||
|
): Promise<void> => {
|
||||||
|
await apiClient.put(`/screen-management/screens/${screenId}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
// 화면 삭제 (휴지통으로 이동)
|
// 화면 삭제 (휴지통으로 이동)
|
||||||
deleteScreen: async (screenId: number, deleteReason?: string, force?: boolean): Promise<void> => {
|
deleteScreen: async (screenId: number, deleteReason?: string, force?: boolean): Promise<void> => {
|
||||||
await apiClient.delete(`/screen-management/screens/${screenId}`, {
|
await apiClient.delete(`/screen-management/screens/${screenId}`, {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
|
@ -1672,6 +1673,34 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-context-menu": {
|
||||||
|
"version": "2.2.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz",
|
||||||
|
"integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-menu": "2.1.16",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-dialog": {
|
"node_modules/@radix-ui/react-dialog": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue