Merge branch 'main' into ksh

This commit is contained in:
SeongHyun Kim 2026-01-15 16:56:18 +09:00
commit 6b9dc4e19d
11 changed files with 1730 additions and 288 deletions

63
PLAN.MD
View File

@ -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 등 다양한 외부 시스템과의 연동을 지원합니다.

View File

@ -2597,10 +2597,10 @@ export class ScreenManagementService {
// 없으면 원본과 같은 회사에 복사
const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code;
// 3. 화면 코드 중복 체크 (대상 회사 기준)
// 3. 화면 코드 중복 체크 (대상 회사 기준, 삭제되지 않은 화면만)
const existingScreens = await client.query<any>(
`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`,
[copyData.screenCode, targetCompanyCode]
);

View File

@ -53,6 +53,19 @@ export default function ScreenManagementPage() {
loadScreens();
}, [loadScreens]);
// 화면 목록 새로고침 이벤트 리스너
useEffect(() => {
const handleScreenListRefresh = () => {
console.log("🔄 화면 목록 새로고침 이벤트 수신");
loadScreens();
};
window.addEventListener("screen-list-refresh", handleScreenListRefresh);
return () => {
window.removeEventListener("screen-list-refresh", handleScreenListRefresh);
};
}, [loadScreens]);
// URL 쿼리 파라미터로 화면 디자이너 자동 열기
useEffect(() => {
const openDesignerId = searchParams.get("openDesigner");

View File

@ -33,7 +33,7 @@ function ScreenViewPage() {
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
// URL 쿼리에서 프리뷰용 company_code 가져오기
const previewCompanyCode = searchParams.get("company_code");
@ -265,8 +265,8 @@ function ScreenViewPage() {
newScale = Math.min(scaleX, scaleY, 1); // 최대 1배율
} else {
// 일반 모드: 가로 기준 스케일 (좌우 여백 16px씩 고정)
const MARGIN_X = 32;
const availableWidth = containerWidth - MARGIN_X;
const MARGIN_X = 32;
const availableWidth = containerWidth - MARGIN_X;
newScale = availableWidth / designWidth;
}

File diff suppressed because it is too large Load Diff

View File

@ -315,7 +315,11 @@ export function ScreenGroupModal({
<CommandItem
value="none"
onSelect={() => {
setFormData({ ...formData, parent_group_id: null });
setFormData({
...formData,
parent_group_id: null,
// 대분류 선택 시 현재 회사 코드 유지
});
setIsParentGroupSelectOpen(false);
}}
className="text-xs sm:text-sm"
@ -335,7 +339,13 @@ export function ScreenGroupModal({
key={parentGroup.id}
value={`${parentGroup.group_name} ${getGroupPath(parentGroup.id)}`}
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);
}}
className="text-xs sm:text-sm"

View File

@ -13,6 +13,9 @@ import {
Edit,
Trash2,
FolderInput,
Copy,
FolderTree,
Loader2,
} from "lucide-react";
import { ScreenDefinition } from "@/types/screen";
import {
@ -73,7 +76,9 @@ import {
} from "@/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import { ScreenGroupModal } from "./ScreenGroupModal";
import CopyScreenModal from "./CopyScreenModal";
import { toast } from "sonner";
import { screenApi } from "@/lib/api/screen";
interface ScreenGroupTreeViewProps {
screens: ScreenDefinition[];
@ -115,15 +120,41 @@ export function ScreenGroupTreeView({
// 삭제 확인 다이얼로그 상태
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
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 [isMoveMenuOpen, setIsMoveMenuOpen] = useState(false);
// 단일 화면 삭제 상태
const [isScreenDeleteDialogOpen, setIsScreenDeleteDialogOpen] = 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 [screenRole, setScreenRole] = useState<string>("");
const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false);
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(() => {
loadGroupsData();
@ -219,21 +250,110 @@ export function ScreenGroupTreeView({
};
// 그룹 삭제 버튼 클릭
const handleDeleteGroup = (group: ScreenGroup, e: React.MouseEvent) => {
e.stopPropagation();
const handleDeleteGroup = (group: ScreenGroup, e?: React.MouseEvent) => {
e?.stopPropagation();
setDeletingGroup(group);
setDeleteScreensWithGroup(false); // 기본값: 화면 삭제 안함
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 () => {
if (!deletingGroup) return;
// 삭제 전 통계 수집 (화면 수는 삭제 전에 계산)
const totalScreensToDelete = getAllScreensInGroupRecursively(deletingGroup.id).length;
const childGroupIds = getAllChildGroupIds(deletingGroup.id);
// 총 작업 수 계산 (화면 + 하위 그룹 + 현재 그룹)
const totalSteps = totalScreensToDelete + childGroupIds.length + 1;
let currentStep = 0;
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);
if (response.success) {
toast.success("그룹이 삭제되었습니다");
loadGroupsData();
toast.success(
deleteScreensWithGroup
? `그룹과 화면 ${totalScreensToDelete}개가 삭제되었습니다`
: "그룹이 삭제되었습니다"
);
await loadGroupsData();
window.dispatchEvent(new CustomEvent("screen-list-refresh"));
} else {
toast.error(response.message || "그룹 삭제에 실패했습니다");
}
@ -241,16 +361,45 @@ export function ScreenGroupTreeView({
console.error("그룹 삭제 실패:", error);
toast.error("그룹 삭제에 실패했습니다");
} finally {
setIsDeleting(false);
setDeleteProgress({ current: 0, total: 0, message: "" });
setIsDeleteDialogOpen(false);
setDeletingGroup(null);
setDeleteScreensWithGroup(false);
}
};
// 화면 이동 메뉴 열기
const handleMoveScreen = (screen: ScreenDefinition, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setMovingScreen(screen);
// 단일 화면 삭제 버튼 클릭
const handleDeleteScreen = (screen: ScreenDefinition) => {
setDeletingScreen(screen);
setIsScreenDeleteDialogOpen(true);
};
// 단일 화면 삭제 확인
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;
@ -273,17 +422,92 @@ export function ScreenGroupTreeView({
setSelectedGroupForMove(currentGroupId);
setScreenRole(currentScreenRole);
setDisplayOrder(currentDisplayOrder);
setIsMoveMenuOpen(true);
setIsEditScreenModalOpen(true);
};
// 화면을 특정 그룹으로 이동
const moveScreenToGroup = async (targetGroupId: number | null) => {
if (!movingScreen) return;
// 화면 복제 모달 열기 (CopyScreenModal 사용)
const handleOpenCopyModal = (screen: ScreenDefinition) => {
// 현재 화면이 속한 그룹 찾기 (기본값으로 설정)
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 {
// 현재 그룹에서 제거
// 1. 화면 이름이 변경되었으면 업데이트
if (editScreenName.trim() && editScreenName !== editingScreen.screenName) {
await screenApi.updateScreen(editingScreen.screenId, {
screenName: editScreenName.trim(),
});
}
// 2. 현재 그룹에서 제거
const currentGroupId = Array.from(groupScreensMap.entries()).find(([_, screenIds]) =>
screenIds.includes(movingScreen.screenId)
screenIds.includes(editingScreen.screenId)
)?.[0];
if (currentGroupId) {
@ -291,7 +515,7 @@ export function ScreenGroupTreeView({
const currentGroup = groups.find((g) => g.id === currentGroupId);
if (currentGroup && currentGroup.screens) {
const screenGroupScreen = currentGroup.screens.find(
(s: any) => s.screen_id === movingScreen.screenId
(s: any) => s.screen_id === editingScreen.screenId
);
if (screenGroupScreen) {
await removeScreenFromGroup(screenGroupScreen.id);
@ -299,25 +523,27 @@ export function ScreenGroupTreeView({
}
}
// 새 그룹에 추가 (미분류가 아닌 경우)
if (targetGroupId !== null) {
// 3. 새 그룹에 추가 (미분류가 아닌 경우)
if (selectedGroupForMove !== null) {
await addScreenToGroup({
group_id: targetGroupId,
screen_id: movingScreen.screenId,
group_id: selectedGroupForMove,
screen_id: editingScreen.screenId,
screen_role: screenRole,
display_order: displayOrder,
is_default: "N",
});
}
toast.success("화면이 이동되었습니다");
toast.success("화면이 수정되었습니다");
loadGroupsData();
window.dispatchEvent(new CustomEvent("screen-list-refresh"));
} catch (error) {
console.error("화면 이동 실패:", error);
toast.error("화면 이동에 실패했습니다");
console.error("화면 수정 실패:", error);
toast.error("화면 수정에 실패했습니다");
} finally {
setIsMoveMenuOpen(false);
setMovingScreen(null);
setIsEditScreenModalOpen(false);
setEditingScreen(null);
setEditScreenName("");
setSelectedGroupForMove(null);
setScreenRole("");
setDisplayOrder(1);
@ -444,6 +670,7 @@ export function ScreenGroupTreeView({
"text-sm font-medium group/item"
)}
onClick={() => toggleGroup(groupId)}
onContextMenu={(e) => handleGroupContextMenu(e, group)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
@ -507,6 +734,7 @@ export function ScreenGroupTreeView({
"text-xs font-medium group/item"
)}
onClick={() => toggleGroup(childGroupId)}
onContextMenu={(e) => handleGroupContextMenu(e, childGroup)}
>
{isChildExpanded ? (
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
@ -566,6 +794,7 @@ export function ScreenGroupTreeView({
"text-xs group/item"
)}
onClick={() => toggleGroup(grandChildId)}
onContextMenu={(e) => handleGroupContextMenu(e, grandChild)}
>
{isGrandExpanded ? (
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
@ -626,7 +855,7 @@ export function ScreenGroupTreeView({
)}
onClick={() => handleScreenClickInGroup(screen, grandChild)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
onContextMenu={(e) => handleMoveScreen(screen, e)}
onContextMenu={(e) => handleContextMenu(e, screen)}
>
<Monitor className="h-3 w-3 shrink-0 text-blue-500" />
<span className="truncate flex-1">{screen.screenName}</span>
@ -662,7 +891,7 @@ export function ScreenGroupTreeView({
)}
onClick={() => handleScreenClickInGroup(screen, childGroup)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
onContextMenu={(e) => handleMoveScreen(screen, e)}
onContextMenu={(e) => handleContextMenu(e, screen)}
>
<Monitor className="h-3 w-3 shrink-0 text-blue-500" />
<span className="truncate flex-1">{screen.screenName}</span>
@ -698,7 +927,7 @@ export function ScreenGroupTreeView({
)}
onClick={() => handleScreenClickInGroup(screen, group)}
onDoubleClick={() => handleScreenDoubleClick(screen)}
onContextMenu={(e) => handleMoveScreen(screen, e)}
onContextMenu={(e) => handleContextMenu(e, screen)}
>
<Monitor className="h-4 w-4 shrink-0 text-blue-500" />
<span className="truncate flex-1">{screen.screenName}</span>
@ -748,7 +977,7 @@ export function ScreenGroupTreeView({
)}
onClick={() => handleScreenClick(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" />
<span className="truncate flex-1">{screen.screenName}</span>
@ -781,6 +1010,23 @@ export function ScreenGroupTreeView({
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}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
@ -789,34 +1035,167 @@ export function ScreenGroupTreeView({
<AlertDialogDescription className="text-xs sm:text-sm">
"{deletingGroup?.group_name}" ?
<br />
.
{deleteScreensWithGroup
? <span className="text-destructive font-medium"> .</span>
: "그룹에 속한 화면들은 미분류로 이동됩니다."
}
</AlertDialogDescription>
</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">
<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>
<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"
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"삭제"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</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]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
"{movingScreen?.screenName}"
</DialogDescription>
</DialogHeader>
<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>
<Label htmlFor="target-group" className="text-xs sm:text-sm">
@ -949,8 +1328,9 @@ export function ScreenGroupTreeView({
<Button
variant="outline"
onClick={() => {
setIsMoveMenuOpen(false);
setMovingScreen(null);
setIsEditScreenModalOpen(false);
setEditingScreen(null);
setEditScreenName("");
setSelectedGroupForMove(null);
setScreenRole("");
setDisplayOrder(1);
@ -960,14 +1340,125 @@ export function ScreenGroupTreeView({
</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"
>
</Button>
</DialogFooter>
</DialogContent>
</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>
);
}

View File

@ -105,6 +105,18 @@ export const screenApi = {
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> => {
await apiClient.delete(`/screen-management/screens/${screenId}`, {

View File

@ -708,6 +708,47 @@ export class ButtonActionExecutor {
if (repeaterJsonKeys.length > 0) {
console.log("🔄 [handleSave] RepeaterFieldGroup JSON 문자열 감지:", repeaterJsonKeys);
// 🎯 채번 규칙 할당 처리 (RepeaterFieldGroup 저장 전에 실행)
console.log("🔍 [handleSave-RepeaterFieldGroup] 채번 규칙 할당 체크 시작");
const fieldsWithNumberingRepeater: Record<string, string> = {};
// formData에서 채번 규칙이 설정된 필드 찾기
for (const [key, value] of Object.entries(context.formData)) {
if (key.endsWith("_numberingRuleId") && value) {
const fieldName = key.replace("_numberingRuleId", "");
fieldsWithNumberingRepeater[fieldName] = value as string;
console.log(`🎯 [handleSave-RepeaterFieldGroup] 채번 필드 발견: ${fieldName} → 규칙 ${value}`);
}
}
console.log("📋 [handleSave-RepeaterFieldGroup] 채번 규칙이 설정된 필드:", fieldsWithNumberingRepeater);
// 채번 규칙이 있는 필드에 대해 allocateCode 호출
if (Object.keys(fieldsWithNumberingRepeater).length > 0) {
console.log("🎯 [handleSave-RepeaterFieldGroup] 채번 규칙 할당 시작 (allocateCode 호출)");
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumberingRepeater)) {
try {
console.log(`🔄 [handleSave-RepeaterFieldGroup] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
const allocateResult = await allocateNumberingCode(ruleId);
if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode;
console.log(`✅ [handleSave-RepeaterFieldGroup] ${fieldName} 새 코드 할당: ${context.formData[fieldName]}${newCode}`);
context.formData[fieldName] = newCode;
} else {
console.warn(`⚠️ [handleSave-RepeaterFieldGroup] ${fieldName} 코드 할당 실패:`, allocateResult.error);
}
} catch (allocateError) {
console.error(`❌ [handleSave-RepeaterFieldGroup] ${fieldName} 코드 할당 오류:`, allocateError);
}
}
}
console.log("✅ [handleSave-RepeaterFieldGroup] 채번 규칙 할당 완료");
// 🆕 상단 폼 데이터(마스터 정보) 추출
// RepeaterFieldGroup JSON과 컴포넌트 키를 제외한 나머지가 마스터 정보
const masterFields: Record<string, any> = {};

View File

@ -17,6 +17,7 @@
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@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-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.7",
@ -1715,6 +1716,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": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",

View File

@ -25,6 +25,7 @@
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@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-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.7",