feat(pop): PC <-> POP 모드 전환 네비게이션 + POP 기본 화면(Landing) 기능
PC 모드에서 프로필 드롭다운을 통해 POP 화면으로 진입하고, POP에서 PC로 돌아오는 양방향 네비게이션을 구현한다. 기존 메뉴 시스템(menu_info)을 활용하여 POP 화면의 권한 제어와 회사별 관리가 가능하도록 한다. [백엔드: POP 메뉴 조회 API] - AdminService.getPopMenuList: L1 POP 메뉴(menu_desc [POP] 또는 menu_name_kor POP 포함) 하위의 active L2 메뉴 조회 - company_code 필터링 적용 (L1 + L2 모두) - landingMenu 반환: menu_desc에 [POP_LANDING] 태그가 있는 메뉴 - GET /admin/pop-menus 라우트 추가 [프론트: PC -> POP 진입] - AppLayout: handlePopModeClick 함수 추가 - landingMenu 있으면 해당 URL로 바로 이동 - 없으면 childMenus 수에 따라 단일 화면/대시보드/안내 분기 - UserDropdown: onPopModeClick prop + "POP 모드" 메뉴 항목 추가 - 사이드바 하단 + 모바일 헤더 프로필 드롭다운 2곳 모두 적용 [프론트: POP -> PC 복귀] - DashboardHeader: "PC 모드" 버튼 추가 (router.push "/") - POP 개별 화면 page.tsx: 상단 네비게이션 바 추가 (POP 대시보드 / PC 모드 버튼) [프론트: POP 대시보드 동적 메뉴] - PopDashboard: 하드코딩 MENU_ITEMS -> menuApi.getPopMenus() API 조회 - API 실패 시 하드코딩 fallback 유지 [프론트: POP 기본 화면 설정 (MenuFormModal)] - L2 POP 화면 수정 시 "POP 기본 화면으로 설정" 체크박스 추가 - 체크 시 menu_desc에 [POP_LANDING] 태그 자동 추가/제거 - 회사당 1개만 설정 가능 (다른 메뉴에 이미 설정 시 비활성화) [API 타입] - PopMenuItem, PopMenuResponse(landingMenu 포함) 인터페이스 추가 - menuApi.getPopMenus() 함수 추가
This commit is contained in:
parent
62e11127a7
commit
3933f1e966
|
|
@ -107,6 +107,46 @@ export async function getUserMenus(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POP 메뉴 목록 조회
|
||||
* [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환
|
||||
*/
|
||||
export async function getPopMenus(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode || "ILSHIN";
|
||||
const userType = req.user?.userType;
|
||||
|
||||
const result = await AdminService.getPopMenuList({
|
||||
userCompanyCode,
|
||||
userType,
|
||||
});
|
||||
|
||||
const response: ApiResponse<any> = {
|
||||
success: true,
|
||||
message: "POP 메뉴 목록 조회 성공",
|
||||
data: result,
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error("POP 메뉴 목록 조회 중 오류 발생:", error);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: false,
|
||||
message: "POP 메뉴 목록 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "POP_MENU_LIST_ERROR",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
|
||||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 정보 조회
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Router } from "express";
|
|||
import {
|
||||
getAdminMenus,
|
||||
getUserMenus,
|
||||
getPopMenus,
|
||||
getMenuInfo,
|
||||
saveMenu, // 메뉴 추가
|
||||
updateMenu, // 메뉴 수정
|
||||
|
|
@ -40,6 +41,7 @@ router.use(authenticateToken);
|
|||
// 메뉴 관련 API
|
||||
router.get("/menus", getAdminMenus);
|
||||
router.get("/user-menus", getUserMenus);
|
||||
router.get("/pop-menus", getPopMenus);
|
||||
router.get("/menus/:menuId", getMenuInfo);
|
||||
router.post("/menus", saveMenu); // 메뉴 추가
|
||||
router.post("/menus/:menuObjid/copy", copyMenu); // 메뉴 복사 (NEW!)
|
||||
|
|
|
|||
|
|
@ -615,6 +615,74 @@ export class AdminService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POP 메뉴 목록 조회
|
||||
* menu_name_kor에 'POP'이 포함되거나 menu_desc에 [POP] 태그가 있는 L1 메뉴의 하위 active 메뉴를 반환
|
||||
* [POP_LANDING] 태그가 있는 하위 메뉴를 landingMenu로 별도 반환
|
||||
*/
|
||||
static async getPopMenuList(paramMap: any): Promise<{ parentMenu: any | null; childMenus: any[]; landingMenu: any | null }> {
|
||||
try {
|
||||
const { userCompanyCode, userType } = paramMap;
|
||||
logger.info("AdminService.getPopMenuList 시작", { userCompanyCode, userType });
|
||||
|
||||
let queryParams: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
let companyFilter = "";
|
||||
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
|
||||
companyFilter = `AND COMPANY_CODE = '*'`;
|
||||
} else {
|
||||
companyFilter = `AND COMPANY_CODE = $${paramIndex}`;
|
||||
queryParams.push(userCompanyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// POP L1 메뉴 조회
|
||||
const parentMenus = await query<any>(
|
||||
`SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS
|
||||
FROM MENU_INFO
|
||||
WHERE PARENT_OBJ_ID = 0
|
||||
AND MENU_TYPE = 1
|
||||
AND (
|
||||
MENU_DESC LIKE '%[POP]%'
|
||||
OR UPPER(MENU_NAME_KOR) LIKE '%POP%'
|
||||
)
|
||||
${companyFilter}
|
||||
ORDER BY SEQ
|
||||
LIMIT 1`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
if (parentMenus.length === 0) {
|
||||
logger.info("POP 메뉴 없음 (L1 POP 메뉴 미발견)");
|
||||
return { parentMenu: null, childMenus: [], landingMenu: null };
|
||||
}
|
||||
|
||||
const parentMenu = parentMenus[0];
|
||||
|
||||
// 하위 active 메뉴 조회 (부모와 같은 company_code로 필터링)
|
||||
const childMenus = await query<any>(
|
||||
`SELECT OBJID, MENU_NAME_KOR, MENU_URL, MENU_DESC, SEQ, COMPANY_CODE, STATUS
|
||||
FROM MENU_INFO
|
||||
WHERE PARENT_OBJ_ID = $1
|
||||
AND STATUS = 'active'
|
||||
AND COMPANY_CODE = $2
|
||||
ORDER BY SEQ`,
|
||||
[parentMenu.objid, parentMenu.company_code]
|
||||
);
|
||||
|
||||
// [POP_LANDING] 태그가 있는 메뉴를 랜딩 화면으로 지정
|
||||
const landingMenu = childMenus.find((m: any) => m.menu_desc?.includes("[POP_LANDING]")) || null;
|
||||
|
||||
logger.info(`POP 메뉴 조회 완료: 부모=${parentMenu.menu_name_kor}, 하위=${childMenus.length}개, 랜딩=${landingMenu?.menu_name_kor || '없음'}`);
|
||||
|
||||
return { parentMenu, childMenus, landingMenu };
|
||||
} catch (error) {
|
||||
logger.error("AdminService.getPopMenuList 오류:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 정보 조회
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react";
|
||||
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw, LayoutGrid, Monitor } from "lucide-react";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
|
@ -284,14 +284,23 @@ function PopScreenViewPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 일반 모드 네비게이션 바 */}
|
||||
{!isPreviewMode && (
|
||||
<div className="sticky top-0 z-50 flex h-10 items-center justify-between border-b bg-white/80 px-3 backdrop-blur">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push("/pop")} className="gap-1 text-xs">
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
POP 대시보드
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">{screen.screenName}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push("/")} className="gap-1 text-xs">
|
||||
<Monitor className="h-3.5 w-3.5" />
|
||||
PC 모드
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* POP 화면 컨텐츠 */}
|
||||
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}>
|
||||
{/* 현재 모드 표시 (일반 모드) */}
|
||||
{!isPreviewMode && (
|
||||
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
||||
{currentModeKey.replace("_", " ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full min-h-full"}`}
|
||||
|
|
|
|||
|
|
@ -80,12 +80,19 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
});
|
||||
|
||||
// 화면 할당 관련 상태
|
||||
const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard">("screen"); // URL 직접 입력 or 화면 할당 or 대시보드 할당 (기본값: 화면 할당)
|
||||
const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard" | "pop">("screen");
|
||||
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
||||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||
const [screenSearchText, setScreenSearchText] = useState("");
|
||||
const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false);
|
||||
|
||||
// POP 화면 할당 관련 상태
|
||||
const [selectedPopScreen, setSelectedPopScreen] = useState<ScreenDefinition | null>(null);
|
||||
const [popScreenSearchText, setPopScreenSearchText] = useState("");
|
||||
const [isPopScreenDropdownOpen, setIsPopScreenDropdownOpen] = useState(false);
|
||||
const [isPopLanding, setIsPopLanding] = useState(false);
|
||||
const [hasOtherPopLanding, setHasOtherPopLanding] = useState(false);
|
||||
|
||||
// 대시보드 할당 관련 상태
|
||||
const [selectedDashboard, setSelectedDashboard] = useState<any | null>(null);
|
||||
const [dashboards, setDashboards] = useState<any[]>([]);
|
||||
|
|
@ -194,8 +201,27 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
toast.success(`대시보드가 선택되었습니다: ${dashboard.title}`);
|
||||
};
|
||||
|
||||
// POP 화면 선택 시 URL 자동 설정
|
||||
const handlePopScreenSelect = (screen: ScreenDefinition) => {
|
||||
const actualScreenId = screen.screenId || screen.id;
|
||||
if (!actualScreenId) {
|
||||
toast.error("화면 ID를 찾을 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedPopScreen(screen);
|
||||
setIsPopScreenDropdownOpen(false);
|
||||
|
||||
const popUrl = `/pop/screens/${actualScreenId}`;
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuUrl: popUrl,
|
||||
}));
|
||||
};
|
||||
|
||||
// URL 타입 변경 시 처리
|
||||
const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard") => {
|
||||
const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard" | "pop") => {
|
||||
// console.log("🔄 URL 타입 변경:", {
|
||||
// from: urlType,
|
||||
// to: type,
|
||||
|
|
@ -206,36 +232,53 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
setUrlType(type);
|
||||
|
||||
if (type === "direct") {
|
||||
// 직접 입력 모드로 변경 시 선택된 화면 초기화
|
||||
setSelectedScreen(null);
|
||||
// URL 필드와 screenCode 초기화 (사용자가 직접 입력할 수 있도록)
|
||||
setSelectedPopScreen(null);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuUrl: "",
|
||||
screenCode: undefined, // 화면 코드도 함께 초기화
|
||||
screenCode: undefined,
|
||||
}));
|
||||
} else {
|
||||
// 화면 할당 모드로 변경 시
|
||||
// 기존에 선택된 화면이 있고, 해당 화면의 URL이 있다면 유지
|
||||
} else if (type === "pop") {
|
||||
setSelectedScreen(null);
|
||||
if (selectedPopScreen) {
|
||||
const actualScreenId = selectedPopScreen.screenId || selectedPopScreen.id;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuUrl: `/pop/screens/${actualScreenId}`,
|
||||
}));
|
||||
} else {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuUrl: "",
|
||||
}));
|
||||
}
|
||||
} else if (type === "screen") {
|
||||
setSelectedPopScreen(null);
|
||||
if (selectedScreen) {
|
||||
console.log("📋 기존 선택된 화면 유지:", selectedScreen.screenName);
|
||||
// 현재 선택된 화면으로 URL 재생성
|
||||
const actualScreenId = selectedScreen.screenId || selectedScreen.id;
|
||||
let screenUrl = `/screens/${actualScreenId}`;
|
||||
|
||||
// 관리자 메뉴인 경우 mode=admin 파라미터 추가
|
||||
const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0";
|
||||
if (isAdminMenu) {
|
||||
screenUrl += "?mode=admin";
|
||||
}
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuUrl: screenUrl,
|
||||
screenCode: selectedScreen.screenCode, // 화면 코드도 함께 유지
|
||||
screenCode: selectedScreen.screenCode,
|
||||
}));
|
||||
} else {
|
||||
// 선택된 화면이 없으면 URL과 screenCode 초기화
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuUrl: "",
|
||||
screenCode: undefined,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// dashboard
|
||||
setSelectedScreen(null);
|
||||
setSelectedPopScreen(null);
|
||||
if (!selectedDashboard) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
menuUrl: "",
|
||||
|
|
@ -294,8 +337,8 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
|
||||
const menuUrl = menu.menu_url || menu.MENU_URL || "";
|
||||
|
||||
// URL이 "/screens/"로 시작하면 화면 할당으로 판단 (실제 라우팅 패턴에 맞게 수정)
|
||||
const isScreenUrl = menuUrl.startsWith("/screens/");
|
||||
const isPopScreenUrl = menuUrl.startsWith("/pop/screens/");
|
||||
const isScreenUrl = !isPopScreenUrl && menuUrl.startsWith("/screens/");
|
||||
|
||||
setFormData({
|
||||
objid: menu.objid || menu.OBJID,
|
||||
|
|
@ -356,10 +399,31 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
}, 500);
|
||||
}
|
||||
}
|
||||
} else if (isPopScreenUrl) {
|
||||
setUrlType("pop");
|
||||
setSelectedScreen(null);
|
||||
|
||||
// [POP_LANDING] 태그 감지
|
||||
const menuDesc = menu.menu_desc || menu.MENU_DESC || "";
|
||||
setIsPopLanding(menuDesc.includes("[POP_LANDING]"));
|
||||
|
||||
const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1];
|
||||
if (popScreenId) {
|
||||
const setPopScreenFromId = () => {
|
||||
const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId);
|
||||
if (screen) {
|
||||
setSelectedPopScreen(screen);
|
||||
}
|
||||
};
|
||||
if (screens.length > 0) {
|
||||
setPopScreenFromId();
|
||||
} else {
|
||||
setTimeout(setPopScreenFromId, 500);
|
||||
}
|
||||
}
|
||||
} else if (menuUrl.startsWith("/dashboard/")) {
|
||||
setUrlType("dashboard");
|
||||
setSelectedScreen(null);
|
||||
// 대시보드 ID 추출 및 선택은 useEffect에서 처리됨
|
||||
} else {
|
||||
setUrlType("direct");
|
||||
setSelectedScreen(null);
|
||||
|
|
@ -404,6 +468,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
} else {
|
||||
console.log("메뉴 등록 모드 - parentId:", parentId, "menuType:", menuType);
|
||||
setIsEdit(false);
|
||||
setIsPopLanding(false);
|
||||
|
||||
// 메뉴 타입 변환 (0 -> 0, 1 -> 1, admin -> 0, user -> 1)
|
||||
let defaultMenuType = "1"; // 기본값은 사용자
|
||||
|
|
@ -420,9 +485,9 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
menuDesc: "",
|
||||
seq: 1,
|
||||
menuType: defaultMenuType,
|
||||
status: "ACTIVE", // 기본값은 활성화
|
||||
companyCode: parentCompanyCode || "none", // 상위 메뉴의 회사 코드를 기본값으로 설정
|
||||
langKey: "", // 다국어 키 초기화
|
||||
status: "ACTIVE",
|
||||
companyCode: parentCompanyCode || "none",
|
||||
langKey: "",
|
||||
});
|
||||
|
||||
// console.log("메뉴 등록 기본값 설정:", {
|
||||
|
|
@ -465,6 +530,31 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
}
|
||||
}, [isOpen, formData.companyCode]);
|
||||
|
||||
// POP 기본 화면 중복 체크: 같은 부모 하위에 이미 [POP_LANDING]이 있는 다른 메뉴가 있는지 확인
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const checkOtherPopLanding = async () => {
|
||||
try {
|
||||
const res = await menuApi.getPopMenus();
|
||||
if (res.success && res.data?.landingMenu) {
|
||||
const landingObjId = res.data.landingMenu.objid?.toString();
|
||||
const currentObjId = formData.objid?.toString();
|
||||
// 현재 수정 중인 메뉴가 아닌 다른 메뉴에 [POP_LANDING]이 있으면 중복
|
||||
setHasOtherPopLanding(!!landingObjId && landingObjId !== currentObjId);
|
||||
} else {
|
||||
setHasOtherPopLanding(false);
|
||||
}
|
||||
} catch {
|
||||
setHasOtherPopLanding(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (urlType === "pop") {
|
||||
checkOtherPopLanding();
|
||||
}
|
||||
}, [isOpen, urlType, formData.objid]);
|
||||
|
||||
// 화면 목록 및 대시보드 목록 로드
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
|
|
@ -512,6 +602,22 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
}
|
||||
}, [dashboards, isEdit, formData.menuUrl, urlType, selectedDashboard]);
|
||||
|
||||
// POP 화면 목록 로드 완료 후 기존 할당 설정
|
||||
useEffect(() => {
|
||||
if (screens.length > 0 && isEdit && formData.menuUrl && urlType === "pop") {
|
||||
const menuUrl = formData.menuUrl;
|
||||
if (menuUrl.startsWith("/pop/screens/")) {
|
||||
const popScreenId = menuUrl.match(/\/pop\/screens\/(\d+)/)?.[1];
|
||||
if (popScreenId && !selectedPopScreen) {
|
||||
const screen = screens.find((s) => s.screenId.toString() === popScreenId || s.id?.toString() === popScreenId);
|
||||
if (screen) {
|
||||
setSelectedPopScreen(screen);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [screens, isEdit, formData.menuUrl, urlType, selectedPopScreen]);
|
||||
|
||||
// 드롭다운 외부 클릭 시 닫기
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
|
|
@ -528,16 +634,20 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
setIsDashboardDropdownOpen(false);
|
||||
setDashboardSearchText("");
|
||||
}
|
||||
if (!target.closest(".pop-screen-dropdown")) {
|
||||
setIsPopScreenDropdownOpen(false);
|
||||
setPopScreenSearchText("");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen) {
|
||||
if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen || isPopScreenDropdownOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isLangKeyDropdownOpen, isScreenDropdownOpen]);
|
||||
}, [isLangKeyDropdownOpen, isScreenDropdownOpen, isDashboardDropdownOpen, isPopScreenDropdownOpen]);
|
||||
|
||||
const loadCompanies = async () => {
|
||||
try {
|
||||
|
|
@ -585,10 +695,17 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
try {
|
||||
setLoading(true);
|
||||
|
||||
// POP 기본 화면 태그 처리
|
||||
let finalMenuDesc = formData.menuDesc;
|
||||
if (urlType === "pop") {
|
||||
const descWithoutTag = finalMenuDesc.replace(/\[POP_LANDING\]/g, "").trim();
|
||||
finalMenuDesc = isPopLanding ? `${descWithoutTag} [POP_LANDING]`.trim() : descWithoutTag;
|
||||
}
|
||||
|
||||
// 백엔드에 전송할 데이터 변환
|
||||
const submitData = {
|
||||
...formData,
|
||||
// 상태를 소문자로 변환 (백엔드에서 소문자 기대)
|
||||
menuDesc: finalMenuDesc,
|
||||
status: formData.status.toLowerCase(),
|
||||
};
|
||||
|
||||
|
|
@ -843,7 +960,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
|
||||
|
||||
{/* URL 타입 선택 */}
|
||||
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex space-x-6">
|
||||
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex flex-wrap gap-x-6 gap-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="screen" id="screen" />
|
||||
<Label htmlFor="screen" className="cursor-pointer">
|
||||
|
|
@ -856,6 +973,12 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
대시보드 할당
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="pop" id="pop" />
|
||||
<Label htmlFor="pop" className="cursor-pointer">
|
||||
POP 화면 할당
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="direct" id="direct" />
|
||||
<Label htmlFor="direct" className="cursor-pointer">
|
||||
|
|
@ -1021,6 +1144,106 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* POP 화면 할당 */}
|
||||
{urlType === "pop" && (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsPopScreenDropdownOpen(!isPopScreenDropdownOpen)}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="text-left">
|
||||
{selectedPopScreen ? selectedPopScreen.screenName : "POP 화면을 선택하세요"}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{isPopScreenDropdownOpen && (
|
||||
<div className="pop-screen-dropdown absolute top-full right-0 left-0 z-50 mt-1 max-h-60 overflow-y-auto rounded-md border bg-white shadow-lg">
|
||||
<div className="sticky top-0 border-b bg-white p-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="POP 화면 검색..."
|
||||
value={popScreenSearchText}
|
||||
onChange={(e) => setPopScreenSearchText(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{screens
|
||||
.filter(
|
||||
(screen) =>
|
||||
screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) ||
|
||||
screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()),
|
||||
)
|
||||
.map((screen, index) => (
|
||||
<div
|
||||
key={`pop-screen-${screen.screenId || screen.id || index}-${screen.screenCode || index}`}
|
||||
onClick={() => handlePopScreenSelect(screen)}
|
||||
className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{screen.screenName}</div>
|
||||
<div className="text-xs text-gray-500">{screen.screenCode}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">ID: {screen.screenId || screen.id || "N/A"}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{screens.filter(
|
||||
(screen) =>
|
||||
screen.screenName.toLowerCase().includes(popScreenSearchText.toLowerCase()) ||
|
||||
screen.screenCode.toLowerCase().includes(popScreenSearchText.toLowerCase()),
|
||||
).length === 0 && <div className="px-3 py-2 text-sm text-gray-500">검색 결과가 없습니다.</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedPopScreen && (
|
||||
<div className="bg-accent rounded-md border p-3">
|
||||
<div className="text-sm font-medium text-blue-900">{selectedPopScreen.screenName}</div>
|
||||
<div className="text-primary text-xs">코드: {selectedPopScreen.screenCode}</div>
|
||||
<div className="text-primary text-xs">생성된 URL: {formData.menuUrl}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* POP 기본 화면 설정 */}
|
||||
<div className="flex items-center space-x-2 rounded-md border p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="popLanding"
|
||||
checked={isPopLanding}
|
||||
disabled={!isPopLanding && hasOtherPopLanding}
|
||||
onChange={(e) => setIsPopLanding(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 accent-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<label
|
||||
htmlFor="popLanding"
|
||||
className={`text-sm font-medium ${!isPopLanding && hasOtherPopLanding ? "text-muted-foreground" : ""}`}
|
||||
>
|
||||
POP 기본 화면으로 설정
|
||||
</label>
|
||||
{!isPopLanding && hasOtherPopLanding && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(이미 다른 메뉴가 기본 화면으로 설정되어 있습니다)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isPopLanding && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
프로필에서 POP 모드 전환 시 이 화면으로 바로 이동합니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URL 직접 입력 */}
|
||||
{urlType === "direct" && (
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -18,11 +18,12 @@ import {
|
|||
LogOut,
|
||||
User,
|
||||
Building2,
|
||||
Monitor,
|
||||
} from "lucide-react";
|
||||
import { useMenu } from "@/contexts/MenuContext";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { MenuItem } from "@/lib/api/menu";
|
||||
import { MenuItem, menuApi } from "@/lib/api/menu";
|
||||
import { menuScreenApi } from "@/lib/api/screen";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -393,6 +394,30 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
}
|
||||
};
|
||||
|
||||
// POP 모드 진입 핸들러
|
||||
const handlePopModeClick = async () => {
|
||||
try {
|
||||
const response = await menuApi.getPopMenus();
|
||||
if (response.success && response.data) {
|
||||
const { childMenus, landingMenu } = response.data;
|
||||
|
||||
if (landingMenu?.menu_url) {
|
||||
router.push(landingMenu.menu_url);
|
||||
} else if (childMenus.length === 0) {
|
||||
toast.info("설정된 POP 화면이 없습니다");
|
||||
} else if (childMenus.length === 1) {
|
||||
router.push(childMenus[0].menu_url);
|
||||
} else {
|
||||
router.push("/pop");
|
||||
}
|
||||
} else {
|
||||
toast.info("설정된 POP 화면이 없습니다");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("POP 메뉴 조회 중 오류가 발생했습니다");
|
||||
}
|
||||
};
|
||||
|
||||
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
|
||||
const renderMenu = (menu: any, level: number = 0) => {
|
||||
const isExpanded = expandedMenus.has(menu.id);
|
||||
|
|
@ -518,6 +543,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
<User className="mr-2 h-4 w-4" />
|
||||
<span>프로필</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>POP 모드</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>로그아웃</span>
|
||||
|
|
@ -686,6 +715,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
<User className="mr-2 h-4 w-4" />
|
||||
<span>프로필</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>POP 모드</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>로그아웃</span>
|
||||
|
|
|
|||
|
|
@ -6,13 +6,14 @@ interface MainHeaderProps {
|
|||
user: any;
|
||||
onSidebarToggle: () => void;
|
||||
onProfileClick: () => void;
|
||||
onPopModeClick?: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메인 헤더 컴포넌트
|
||||
*/
|
||||
export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }: MainHeaderProps) {
|
||||
export function MainHeader({ user, onSidebarToggle, onProfileClick, onPopModeClick, onLogout }: MainHeaderProps) {
|
||||
return (
|
||||
<header className="bg-background/95 fixed top-0 z-50 h-14 min-h-14 w-full flex-shrink-0 border-b backdrop-blur">
|
||||
<div className="flex h-full w-full items-center justify-between px-6">
|
||||
|
|
@ -27,7 +28,7 @@ export function MainHeader({ user, onSidebarToggle, onProfileClick, onLogout }:
|
|||
|
||||
{/* Right side - Admin Button + User Menu */}
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
<UserDropdown user={user} onProfileClick={onProfileClick} onLogout={onLogout} />
|
||||
<UserDropdown user={user} onProfileClick={onProfileClick} onPopModeClick={onPopModeClick} onLogout={onLogout} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -8,18 +8,19 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { LogOut, User } from "lucide-react";
|
||||
import { LogOut, Monitor, User } from "lucide-react";
|
||||
|
||||
interface UserDropdownProps {
|
||||
user: any;
|
||||
onProfileClick: () => void;
|
||||
onPopModeClick?: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 드롭다운 메뉴 컴포넌트
|
||||
*/
|
||||
export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownProps) {
|
||||
export function UserDropdown({ user, onProfileClick, onPopModeClick, onLogout }: UserDropdownProps) {
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -79,6 +80,12 @@ export function UserDropdown({ user, onProfileClick, onLogout }: UserDropdownPro
|
|||
<User className="mr-2 h-4 w-4" />
|
||||
<span>프로필</span>
|
||||
</DropdownMenuItem>
|
||||
{onPopModeClick && (
|
||||
<DropdownMenuItem onClick={onPopModeClick}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>POP 모드</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={onLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<span>로그아웃</span>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { WeatherInfo, UserInfo, CompanyInfo } from "./types";
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
|
|
@ -11,6 +11,7 @@ interface DashboardHeaderProps {
|
|||
company: CompanyInfo;
|
||||
onThemeToggle: () => void;
|
||||
onUserClick: () => void;
|
||||
onPcModeClick?: () => void;
|
||||
}
|
||||
|
||||
export function DashboardHeader({
|
||||
|
|
@ -20,6 +21,7 @@ export function DashboardHeader({
|
|||
company,
|
||||
onThemeToggle,
|
||||
onUserClick,
|
||||
onPcModeClick,
|
||||
}: DashboardHeaderProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
|
|
@ -81,6 +83,17 @@ export function DashboardHeader({
|
|||
<div className="pop-dashboard-company-sub">{company.subTitle}</div>
|
||||
</div>
|
||||
|
||||
{/* PC 모드 복귀 */}
|
||||
{onPcModeClick && (
|
||||
<button
|
||||
className="pop-dashboard-theme-toggle"
|
||||
onClick={onPcModeClick}
|
||||
title="PC 모드로 돌아가기"
|
||||
>
|
||||
<Monitor size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 사용자 배지 */}
|
||||
<button className="pop-dashboard-user-badge" onClick={onUserClick}>
|
||||
<div className="pop-dashboard-user-avatar">{user.avatar}</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DashboardHeader } from "./DashboardHeader";
|
||||
import { NoticeBanner } from "./NoticeBanner";
|
||||
import { KpiBar } from "./KpiBar";
|
||||
|
|
@ -8,6 +9,8 @@ import { MenuGrid } from "./MenuGrid";
|
|||
import { ActivityList } from "./ActivityList";
|
||||
import { NoticeList } from "./NoticeList";
|
||||
import { DashboardFooter } from "./DashboardFooter";
|
||||
import { MenuItem as DashboardMenuItem } from "./types";
|
||||
import { menuApi, PopMenuItem } from "@/lib/api/menu";
|
||||
import {
|
||||
KPI_ITEMS,
|
||||
MENU_ITEMS,
|
||||
|
|
@ -17,10 +20,31 @@ import {
|
|||
} from "./data";
|
||||
import "./dashboard.css";
|
||||
|
||||
export function PopDashboard() {
|
||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||
const CATEGORY_COLORS: DashboardMenuItem["category"][] = [
|
||||
"production",
|
||||
"material",
|
||||
"quality",
|
||||
"equipment",
|
||||
"safety",
|
||||
];
|
||||
|
||||
function convertPopMenuToMenuItem(item: PopMenuItem, index: number): DashboardMenuItem {
|
||||
return {
|
||||
id: item.objid,
|
||||
title: item.menu_name_kor,
|
||||
count: 0,
|
||||
description: item.menu_desc?.replace("[POP]", "").trim() || "",
|
||||
status: "",
|
||||
category: CATEGORY_COLORS[index % CATEGORY_COLORS.length],
|
||||
href: item.menu_url || "#",
|
||||
};
|
||||
}
|
||||
|
||||
export function PopDashboard() {
|
||||
const router = useRouter();
|
||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||
const [menuItems, setMenuItems] = useState<DashboardMenuItem[]>(MENU_ITEMS);
|
||||
|
||||
// 로컬 스토리지에서 테마 로드
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null;
|
||||
if (savedTheme) {
|
||||
|
|
@ -28,6 +52,22 @@ export function PopDashboard() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// API에서 POP 메뉴 로드
|
||||
useEffect(() => {
|
||||
const loadPopMenus = async () => {
|
||||
try {
|
||||
const response = await menuApi.getPopMenus();
|
||||
if (response.success && response.data && response.data.childMenus.length > 0) {
|
||||
const converted = response.data.childMenus.map(convertPopMenuToMenuItem);
|
||||
setMenuItems(converted);
|
||||
}
|
||||
} catch {
|
||||
// API 실패 시 기존 하드코딩 데이터 유지
|
||||
}
|
||||
};
|
||||
loadPopMenus();
|
||||
}, []);
|
||||
|
||||
const handleThemeToggle = () => {
|
||||
const newTheme = theme === "dark" ? "light" : "dark";
|
||||
setTheme(newTheme);
|
||||
|
|
@ -40,6 +80,10 @@ export function PopDashboard() {
|
|||
}
|
||||
};
|
||||
|
||||
const handlePcModeClick = () => {
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const handleActivityMore = () => {
|
||||
alert("전체 활동 내역 화면으로 이동합니다.");
|
||||
};
|
||||
|
|
@ -58,13 +102,14 @@ export function PopDashboard() {
|
|||
company={{ name: "탑씰", subTitle: "현장 관리 시스템" }}
|
||||
onThemeToggle={handleThemeToggle}
|
||||
onUserClick={handleUserClick}
|
||||
onPcModeClick={handlePcModeClick}
|
||||
/>
|
||||
|
||||
<NoticeBanner text={NOTICE_MARQUEE_TEXT} />
|
||||
|
||||
<KpiBar items={KPI_ITEMS} />
|
||||
|
||||
<MenuGrid items={MENU_ITEMS} />
|
||||
<MenuGrid items={menuItems} />
|
||||
|
||||
<div className="pop-dashboard-bottom-section">
|
||||
<ActivityList items={ACTIVITY_ITEMS} onMoreClick={handleActivityMore} />
|
||||
|
|
|
|||
|
|
@ -76,6 +76,23 @@ export interface ApiResponse<T> {
|
|||
errorCode?: string;
|
||||
}
|
||||
|
||||
export interface PopMenuItem {
|
||||
objid: string;
|
||||
menu_name_kor: string;
|
||||
menu_url: string;
|
||||
menu_desc: string;
|
||||
seq: number;
|
||||
company_code: string;
|
||||
status: string;
|
||||
screenId?: number;
|
||||
}
|
||||
|
||||
export interface PopMenuResponse {
|
||||
parentMenu: PopMenuItem | null;
|
||||
childMenus: PopMenuItem[];
|
||||
landingMenu: PopMenuItem | null;
|
||||
}
|
||||
|
||||
export const menuApi = {
|
||||
// 관리자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시)
|
||||
getAdminMenus: async (): Promise<ApiResponse<MenuItem[]>> => {
|
||||
|
|
@ -91,6 +108,12 @@ export const menuApi = {
|
|||
return response.data;
|
||||
},
|
||||
|
||||
// POP 메뉴 목록 조회 ([POP] 태그 L1 하위 active 메뉴)
|
||||
getPopMenus: async (): Promise<ApiResponse<PopMenuResponse>> => {
|
||||
const response = await apiClient.get("/admin/pop-menus");
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 관리자 메뉴 목록 조회 (메뉴 관리 화면용 - 모든 상태 표시)
|
||||
getAdminMenusForManagement: async (): Promise<ApiResponse<MenuItem[]>> => {
|
||||
const response = await apiClient.get("/admin/menus", { params: { menuType: "0", includeInactive: "true" } });
|
||||
|
|
|
|||
Loading…
Reference in New Issue