39465b38d9
모바일 더보기에 admin 메뉴 전환 기능, 발주 페이지에 카드/리스트 뷰 선택,
admin-panel 페이지의 모바일 진입 가드를 함께 추가. PC 동작 영향 없음.
- /m/more: user.isAdmin 인 경우에만 [관리자 메뉴 보기] 토글 노출.
ON 시 menu_info.menu_type=0 그룹(권한/부서/사용자/공통코드 등)으로 메뉴바 전환.
사용자 카드 색도 amber 로 바뀌어 현재 모드를 시각적으로 표시. 다시 누르면
사용자 메뉴(거래처 주문/마스터/매입/출고/통계)로 복귀. 모드는 localStorage
("momo_admin_mode") 에 저장되어 새로고침 후에도 유지.
- /m/orders/new: 카드 뷰(2열 그리드)와 리스트 뷰(가로 한 줄 + 썸네일)를 토글로
전환. PC 트리·모바일 트리 양쪽에 동일 적용, 같은 viewMode state 공유. 사용자
선택은 localStorage ("momo_orders_new_view_mode") 에 저장.
- /admin-panel: 모바일(md 미만) 진입 시 안내 화면(PC에서 사용해주세요 + 모바일
메뉴 복귀 링크) 노출. md+ 에서는 기존 데스크탑 패널 그대로. /m/more 의
관리자 메뉴 항목들이 admin-panel 로 이동할 때 모바일에서 화면 깨짐 방지.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import {
|
|
LogOut, ChevronRight, ShieldCheck, ShieldOff,
|
|
LayoutDashboard, TrendingUp, FolderKanban, Package, ListChecks,
|
|
ShoppingCart, FileText, Warehouse, Boxes, Factory, Wrench,
|
|
Headset, Clock, Calculator, Coins, Truck, Settings,
|
|
ClipboardCheck, Compass, GitBranch, Puzzle, Stamp, Folder,
|
|
} from "lucide-react";
|
|
import Swal from "sweetalert2";
|
|
import { useAuthStore } from "@/store/auth-store";
|
|
import { mapMenuUrl } from "@/lib/menu-url-map";
|
|
import { MENU_ICON_MAP } from "@/lib/constants";
|
|
import type { MenuItem } from "@/types";
|
|
|
|
// PC 사이드바와 동일한 아이콘 매핑 (sidebar.tsx 와 같은 로직)
|
|
const ICON_COMPONENTS: Record<string, React.ElementType> = {
|
|
LayoutDashboard, TrendingUp, FolderKanban, Package, ListChecks,
|
|
ShoppingCart, FileText, Warehouse, Boxes, Factory, Wrench,
|
|
Headset, UserClock: Clock, Calculator, Coins, Truck, Settings,
|
|
ClipboardCheck, Compass, GitBranch, Puzzle, Stamp,
|
|
};
|
|
|
|
function getMenuIcon(menuName: string): React.ElementType {
|
|
for (const [key, iconName] of Object.entries(MENU_ICON_MAP)) {
|
|
if (menuName?.toUpperCase().includes(key.toUpperCase())) {
|
|
return ICON_COMPONENTS[iconName] || Folder;
|
|
}
|
|
}
|
|
return Folder;
|
|
}
|
|
|
|
interface MenuGroup {
|
|
objid: string;
|
|
name: string;
|
|
children: { objid: string; name: string; href: string }[];
|
|
}
|
|
|
|
const ADMIN_MODE_KEY = "momo_admin_mode";
|
|
|
|
export default function MorePage() {
|
|
const { user, logout } = useAuthStore();
|
|
const [companyName, setCompanyName] = useState<string | null>(null);
|
|
// 모드별로 메뉴 그룹을 따로 들고 토글로 노출 전환.
|
|
const [userGroups, setUserGroups] = useState<MenuGroup[]>([]);
|
|
const [adminGroups, setAdminGroups] = useState<MenuGroup[]>([]);
|
|
// 'user' | 'admin' — admin 만 토글 가능. 새로고침 후 유지를 위해 localStorage 동기화.
|
|
const [mode, setMode] = useState<"user" | "admin">("user");
|
|
const [loaded, setLoaded] = useState(false);
|
|
|
|
// 모드 초기 로드 (localStorage)
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
const saved = window.localStorage.getItem(ADMIN_MODE_KEY);
|
|
if (saved === "admin") setMode("admin");
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetch("/api/auth/profile")
|
|
.then((r) => r.json())
|
|
.then((j) => {
|
|
if (j.success) setCompanyName(j.data?.USER_NAME ?? null);
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
// PC 사이드바와 동일한 소스(DB MENU_INFO) 에서 메뉴 트리를 받아온다.
|
|
// "관리자" top menu 의 자식 = 시스템 관리(menu_type=0) 메뉴들 → adminGroups
|
|
// 그 외 top menu 들의 자식 = 운영 메뉴(menu_type=1) → userGroups
|
|
useEffect(() => {
|
|
const load = async () => {
|
|
const topRes = await fetch("/api/menu/top");
|
|
if (!topRes.ok) {
|
|
setLoaded(true);
|
|
return;
|
|
}
|
|
const topJson = await topRes.json();
|
|
const tops: { OBJID: string; MENU_NAME_KOR: string }[] = topJson.menus || [];
|
|
|
|
const buildGroupsFor = async (topObjId: string): Promise<MenuGroup[]> => {
|
|
const sideRes = await fetch("/api/menu", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ MENUOBJID: topObjId }),
|
|
});
|
|
if (!sideRes.ok) return [];
|
|
const sideJson = await sideRes.json();
|
|
const items = (sideJson.RESULT || []) as MenuItem[];
|
|
|
|
const parents = items.filter((i) => i.level === "1" || Number(i.level) < 2);
|
|
const children = items.filter((i) => i.level !== "1" && Number(i.level) >= 2);
|
|
|
|
const result: MenuGroup[] = [];
|
|
for (const p of parents) {
|
|
const kids = children.filter((c) => c.parentObjId === p.objid);
|
|
// 동일 이름 중복 제거 (sidebar 와 동일 규칙)
|
|
const seen = new Set<string>();
|
|
const uniqueKids = kids.filter((c) => {
|
|
if (seen.has(c.menuNameKor)) return false;
|
|
seen.add(c.menuNameKor);
|
|
return true;
|
|
});
|
|
if (uniqueKids.length === 0) continue;
|
|
result.push({
|
|
objid: p.objid,
|
|
name: p.menuNameKor,
|
|
children: uniqueKids.map((c) => ({
|
|
objid: c.objid,
|
|
name: c.menuNameKor,
|
|
href: mapMenuUrl(c.menuUrl) || "#",
|
|
})),
|
|
});
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const adminTop = tops.find((m) => m.MENU_NAME_KOR === "관리자");
|
|
const userTops = tops.filter((m) => m.MENU_NAME_KOR !== "관리자");
|
|
|
|
const userResults: MenuGroup[] = [];
|
|
for (const t of userTops) {
|
|
const g = await buildGroupsFor(t.OBJID);
|
|
userResults.push(...g);
|
|
}
|
|
setUserGroups(userResults);
|
|
|
|
if (adminTop) {
|
|
// 관리자 메뉴 항목은 /admin-panel?menuId={리프 menu objid} 로 통일.
|
|
// (admin-panel 페이지가 menuId 쿼리에 따라 내부 탭 전환)
|
|
const raw = await buildGroupsFor(adminTop.OBJID);
|
|
const remapped: MenuGroup[] = raw.map((g) => ({
|
|
...g,
|
|
children: g.children.map((c) => ({
|
|
...c,
|
|
href: `/admin-panel?menuId=${c.objid}`,
|
|
})),
|
|
}));
|
|
setAdminGroups(remapped);
|
|
}
|
|
setLoaded(true);
|
|
};
|
|
load();
|
|
}, []);
|
|
|
|
const onLogout = async () => {
|
|
const ok = await Swal.fire({
|
|
icon: "question",
|
|
title: "로그아웃 하시겠어요?",
|
|
showCancelButton: true,
|
|
confirmButtonText: "로그아웃",
|
|
cancelButtonText: "취소",
|
|
confirmButtonColor: "#0f766e",
|
|
});
|
|
if (!ok.isConfirmed) return;
|
|
await logout();
|
|
};
|
|
|
|
const toggleMode = () => {
|
|
setMode((prev) => {
|
|
const next = prev === "admin" ? "user" : "admin";
|
|
if (typeof window !== "undefined") {
|
|
window.localStorage.setItem(ADMIN_MODE_KEY, next);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const canShowAdminToggle = user?.isAdmin && adminGroups.length > 0;
|
|
const groups = mode === "admin" ? adminGroups : userGroups;
|
|
const isAdminMode = mode === "admin";
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
{/* 사용자 카드 — 관리자 모드일 땐 amber 톤으로 시각 구분 */}
|
|
<div
|
|
className={
|
|
isAdminMode
|
|
? "bg-gradient-to-br from-amber-600 to-amber-500 rounded-2xl px-5 py-6 text-white shadow-md"
|
|
: "bg-gradient-to-br from-emerald-700 to-emerald-600 rounded-2xl px-5 py-6 text-white shadow-md"
|
|
}
|
|
>
|
|
<div className="flex items-center gap-2 text-sm mb-1 opacity-90">
|
|
{isAdminMode ? (
|
|
<>
|
|
<ShieldCheck size={16} />
|
|
<span className="font-semibold tracking-wide">관리자 모드</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
<div className="text-2xl font-bold tracking-tight">
|
|
{companyName ?? user?.userName ?? "사용자"}님
|
|
</div>
|
|
<div className={isAdminMode ? "text-amber-50/90 text-sm mt-1" : "text-emerald-100/90 text-sm mt-1"}>
|
|
{user?.userId}
|
|
</div>
|
|
</div>
|
|
|
|
{/*
|
|
관리자 토글 버튼 — user.isAdmin 이고 관리자 메뉴 그룹이 있을 때만 노출.
|
|
ON 일 땐 메뉴바가 관리자 메뉴(권한·사용자·기준정보 관리 등)로 교체.
|
|
*/}
|
|
{canShowAdminToggle && (
|
|
<button
|
|
onClick={toggleMode}
|
|
className={
|
|
"w-full flex items-center justify-between px-5 h-16 rounded-2xl border-2 transition shadow-sm " +
|
|
(isAdminMode
|
|
? "bg-emerald-50 border-emerald-300 active:bg-emerald-100"
|
|
: "bg-amber-50 border-amber-300 active:bg-amber-100")
|
|
}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
{isAdminMode ? (
|
|
<ShieldOff size={24} className="text-emerald-700" />
|
|
) : (
|
|
<ShieldCheck size={24} className="text-amber-600" />
|
|
)}
|
|
<div className="text-left">
|
|
<div
|
|
className={
|
|
"text-base font-extrabold " +
|
|
(isAdminMode ? "text-emerald-800" : "text-amber-800")
|
|
}
|
|
>
|
|
{isAdminMode ? "사용자 메뉴로 돌아가기" : "관리자 메뉴 보기"}
|
|
</div>
|
|
<div
|
|
className={
|
|
"text-[11px] " +
|
|
(isAdminMode ? "text-emerald-700/80" : "text-amber-700/80")
|
|
}
|
|
>
|
|
{isAdminMode
|
|
? "거래처 주문 · 마스터 · 매입 · 출고 · 통계"
|
|
: "권한 · 부서 · 사용자 · 기준정보 관리"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<ChevronRight size={20} className={isAdminMode ? "text-emerald-500" : "text-amber-500"} />
|
|
</button>
|
|
)}
|
|
|
|
{!loaded ? (
|
|
<div className="text-slate-400 text-sm text-center py-8">메뉴를 불러오는 중...</div>
|
|
) : groups.length === 0 ? (
|
|
<div className="text-slate-400 text-sm text-center py-8">
|
|
{isAdminMode ? "관리자 메뉴가 없습니다." : "표시할 메뉴가 없습니다."}
|
|
</div>
|
|
) : (
|
|
groups.map((group) => (
|
|
<Section key={group.objid} title={group.name}>
|
|
{group.children.map((it) => (
|
|
<MenuItemRow
|
|
key={it.objid}
|
|
href={it.href}
|
|
label={it.name}
|
|
Icon={getMenuIcon(it.name)}
|
|
/>
|
|
))}
|
|
</Section>
|
|
))
|
|
)}
|
|
|
|
<Section title="계정">
|
|
<button
|
|
onClick={onLogout}
|
|
className="w-full flex items-center justify-between px-5 h-16 bg-white rounded-xl border border-slate-200 active:bg-rose-50 transition"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<LogOut size={22} className="text-rose-600" />
|
|
<span className="text-base font-semibold text-rose-600">로그아웃</span>
|
|
</div>
|
|
</button>
|
|
</Section>
|
|
|
|
<div className="text-center text-xs text-slate-400 pt-4 pb-2">
|
|
모모유통 ERP
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
return (
|
|
<div>
|
|
<div className="text-xs font-bold text-slate-500 mb-2 px-2 tracking-wider uppercase">
|
|
{title}
|
|
</div>
|
|
<div className="space-y-2">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MenuItemRow({ href, label, Icon }: { href: string; label: string; Icon: React.ElementType }) {
|
|
return (
|
|
<Link
|
|
href={href}
|
|
className="flex items-center justify-between px-5 h-16 bg-white rounded-xl border border-slate-200 active:bg-emerald-50 transition"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Icon size={22} className="text-emerald-700" />
|
|
<span className="text-base font-semibold text-slate-800">{label}</span>
|
|
</div>
|
|
<ChevronRight size={20} className="text-slate-400" />
|
|
</Link>
|
|
);
|
|
}
|