feat(mobile): admin 메뉴 토글 + 발주 카드/리스트 뷰 + admin-panel PC 전용 안내

모바일 더보기에 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>
This commit is contained in:
hjjeong
2026-05-03 22:06:53 +09:00
parent 47a1dc5843
commit 39465b38d9
3 changed files with 341 additions and 19 deletions
+135 -15
View File
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import {
LogOut, ChevronRight,
LogOut, ChevronRight, ShieldCheck, ShieldOff,
LayoutDashboard, TrendingUp, FolderKanban, Package, ListChecks,
ShoppingCart, FileText, Warehouse, Boxes, Factory, Wrench,
Headset, Clock, Calculator, Coins, Truck, Settings,
@@ -38,10 +38,24 @@ interface MenuGroup {
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 [groups, setGroups] = useState<MenuGroup[]>([]);
// 모드별로 메뉴 그룹을 따로 들고 토글로 노출 전환.
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")
@@ -53,29 +67,32 @@ export default function MorePage() {
}, []);
// PC 사이드바와 동일한 소스(DB MENU_INFO) 에서 메뉴 트리를 받아온다.
// 구조: 최상위(top, parent=0) → 중간(parents, level 1) → 리프(children, level 2)
// 모바일 더보기는 중간을 그룹 헤더, 리프를 클릭 가능한 메뉴 항목으로 노출.
// "관리자" 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) return;
if (!topRes.ok) {
setLoaded(true);
return;
}
const topJson = await topRes.json();
const tops: { OBJID: string; MENU_NAME_KOR: string }[] = topJson.menus || [];
const all: MenuGroup[] = [];
for (const t of tops) {
const buildGroupsFor = async (topObjId: string): Promise<MenuGroup[]> => {
const sideRes = await fetch("/api/menu", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ MENUOBJID: t.OBJID }),
body: JSON.stringify({ MENUOBJID: topObjId }),
});
if (!sideRes.ok) continue;
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 와 동일 규칙)
@@ -86,7 +103,7 @@ export default function MorePage() {
return true;
});
if (uniqueKids.length === 0) continue;
all.push({
result.push({
objid: p.objid,
name: p.menuNameKor,
children: uniqueKids.map((c) => ({
@@ -96,8 +113,33 @@ export default function MorePage() {
})),
});
}
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);
}
setGroups(all);
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();
}, []);
@@ -115,18 +157,96 @@ export default function MorePage() {
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">
<div className="bg-gradient-to-br from-emerald-700 to-emerald-600 rounded-2xl px-5 py-6 text-white shadow-md">
{/* 사용자 카드 — 관리자 모드일 땐 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="text-emerald-100/90 text-sm mt-1">{user?.userId}</div>
<div className={isAdminMode ? "text-amber-50/90 text-sm mt-1" : "text-emerald-100/90 text-sm mt-1"}>
{user?.userId}
</div>
</div>
{groups.length === 0 ? (
{/*
관리자 토글 버튼 — 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) => (
+173 -3
View File
@@ -2,7 +2,7 @@
import { useEffect, useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { Search, ShoppingCart, Plus, Minus, X } from "lucide-react";
import { Search, ShoppingCart, Plus, Minus, X, LayoutGrid, List } from "lucide-react";
import Swal from "sweetalert2";
interface Item {
@@ -21,6 +21,9 @@ interface CartLine { item: Item; qty: number }
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
// 모바일 발주 페이지의 뷰 모드 (card/list) 사용자 선택 기억
const VIEW_MODE_KEY = "momo_orders_new_view_mode";
// PC 와 모바일에 서로 다른 디자인을 보여주지만, state(cart, items 등)는 부모에서 한 번만 관리.
// 두 트리는 hidden md:flex / md:hidden 로 분기되며, 보이는 한 쪽만 사용자가 만진다.
export default function ItemsBrowse() {
@@ -31,6 +34,19 @@ export default function ItemsBrowse() {
const [loading, setLoading] = useState(false);
const [cart, setCart] = useState<CartLine[]>([]);
const [cartOpen, setCartOpen] = useState(false);
// 모바일 뷰 모드 — card(기본, 2열 그리드) / list(가로 한 줄)
const [viewMode, setViewMode] = useState<"card" | "list">("card");
useEffect(() => {
if (typeof window === "undefined") return;
const saved = window.localStorage.getItem(VIEW_MODE_KEY);
if (saved === "list" || saved === "card") setViewMode(saved);
}, []);
const changeViewMode = (m: "card" | "list") => {
setViewMode(m);
if (typeof window !== "undefined") window.localStorage.setItem(VIEW_MODE_KEY, m);
};
const fetchItems = async () => {
setLoading(true);
@@ -256,11 +272,44 @@ export default function ItemsBrowse() {
</button>
</div>
{/* 카드 / 리스트 뷰 토글 — PC. viewMode state 는 모바일 트리와 공유 */}
{!loading && items.length > 0 && (
<div className="flex items-center justify-between">
<div className="text-sm text-slate-500"> {items.length}</div>
<div className="flex bg-slate-100 rounded-lg p-1">
<button
onClick={() => changeViewMode("card")}
className={
"h-8 px-3 rounded-md flex items-center gap-1.5 text-xs font-bold transition " +
(viewMode === "card"
? "bg-white text-emerald-700 shadow-sm"
: "text-slate-500 hover:text-slate-700")
}
aria-pressed={viewMode === "card"}
>
<LayoutGrid size={14} strokeWidth={2.4} />
</button>
<button
onClick={() => changeViewMode("list")}
className={
"h-8 px-3 rounded-md flex items-center gap-1.5 text-xs font-bold transition " +
(viewMode === "list"
? "bg-white text-emerald-700 shadow-sm"
: "text-slate-500 hover:text-slate-700")
}
aria-pressed={viewMode === "list"}
>
<List size={14} strokeWidth={2.4} />
</button>
</div>
</div>
)}
{loading ? (
<div className="text-slate-400 text-center py-12"> ...</div>
) : items.length === 0 ? (
<div className="text-slate-400 text-center py-12 bg-white rounded-xl border border-slate-100"> .</div>
) : (
) : viewMode === "card" ? (
<div className="grid sm:grid-cols-2 xl:grid-cols-3 gap-3">
{items.map((it) => (
<div key={it.OBJID} className="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition">
@@ -295,6 +344,47 @@ export default function ItemsBrowse() {
</div>
))}
</div>
) : (
/* PC 리스트 뷰 — 가로 한 줄. 여유 공간이 더 많으니 정보를 펼쳐 표시. */
<div className="space-y-2">
{items.map((it) => (
<div
key={it.OBJID}
className="flex items-center gap-4 bg-white border border-slate-200 rounded-xl p-3 hover:shadow-md transition"
>
<div className="w-16 h-16 rounded-lg bg-slate-50 overflow-hidden flex items-center justify-center shrink-0">
{it.IMAGE_URL ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={it.IMAGE_URL} alt={it.ITEM_NAME} className="w-full h-full object-cover" />
) : (
<div className="text-slate-300 text-[10px]"> </div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<div className="font-bold text-sm text-slate-900 truncate">{it.ITEM_NAME}</div>
{it.IS_TAX_FREE === "Y" && (
<span className="shrink-0 px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold"></span>
)}
</div>
<div className="text-xs text-slate-500 truncate">{it.MAKER_NAME || "-"}</div>
</div>
<div className="text-right shrink-0 w-32">
<div className="font-bold text-base text-slate-900 tabular-nums">{fmt(it.UNIT_PRICE)}</div>
<div className={`text-xs font-semibold ${Number(it.STOCK_QTY) > 0 ? "text-emerald-700" : "text-rose-500"}`}>
{fmt(it.STOCK_QTY)} {it.UNIT}
</div>
</div>
<button
disabled={Number(it.STOCK_QTY) === 0}
onClick={() => addToCart(it)}
className="h-10 px-4 rounded-lg bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed flex items-center justify-center gap-1 shrink-0"
>
<Plus size={14} />
</button>
</div>
))}
</div>
)}
</div>
</div>
@@ -324,13 +414,46 @@ export default function ItemsBrowse() {
</button>
</div>
{/* 카드 / 리스트 뷰 모드 토글 — 결과 개수와 함께 같은 줄에 배치 */}
{!loading && items.length > 0 && (
<div className="flex items-center justify-between">
<div className="text-sm text-slate-500"> {items.length}</div>
<div className="flex bg-slate-100 rounded-xl p-1">
<button
onClick={() => changeViewMode("card")}
className={
"h-10 px-3 rounded-lg flex items-center gap-1.5 text-sm font-bold transition " +
(viewMode === "card"
? "bg-white text-emerald-700 shadow-sm"
: "text-slate-500 active:text-slate-700")
}
aria-pressed={viewMode === "card"}
>
<LayoutGrid size={16} strokeWidth={2.4} />
</button>
<button
onClick={() => changeViewMode("list")}
className={
"h-10 px-3 rounded-lg flex items-center gap-1.5 text-sm font-bold transition " +
(viewMode === "list"
? "bg-white text-emerald-700 shadow-sm"
: "text-slate-500 active:text-slate-700")
}
aria-pressed={viewMode === "list"}
>
<List size={16} strokeWidth={2.4} />
</button>
</div>
</div>
)}
{loading ? (
<div className="text-slate-400 text-center py-16"> ...</div>
) : items.length === 0 ? (
<div className="text-slate-400 text-center py-16 bg-white rounded-xl border border-slate-100">
.
</div>
) : (
) : viewMode === "card" ? (
<div className="grid grid-cols-2 gap-3 pb-32">
{items.map((it) => (
<div key={it.OBJID} className="bg-white border border-slate-200 rounded-2xl p-3 flex flex-col">
@@ -362,6 +485,53 @@ export default function ItemsBrowse() {
</div>
))}
</div>
) : (
/* 리스트 뷰 — 가로 한 줄, 썸네일+정보+담기 버튼 */
<div className="space-y-2 pb-32">
{items.map((it) => (
<div
key={it.OBJID}
className="flex items-center gap-3 bg-white border border-slate-200 rounded-2xl p-3"
>
<div className="w-20 h-20 rounded-xl bg-slate-50 overflow-hidden flex items-center justify-center shrink-0">
{it.IMAGE_URL ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={it.IMAGE_URL} alt={it.ITEM_NAME} className="w-full h-full object-cover" />
) : (
<div className="text-slate-300 text-[10px] text-center px-1"> </div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="font-bold text-base text-slate-900 leading-snug line-clamp-1">
{it.ITEM_NAME}
</div>
<div className="text-xs text-slate-500 truncate mt-0.5">{it.MAKER_NAME || "-"}</div>
<div className="flex items-baseline gap-2 mt-1.5">
<div className="font-extrabold text-slate-900 tabular-nums text-lg">
{fmt(it.UNIT_PRICE)}
</div>
<div
className={
"text-xs font-semibold tabular-nums " +
(Number(it.STOCK_QTY) > 0 ? "text-emerald-700" : "text-rose-500")
}
>
{Number(it.STOCK_QTY) > 0
? `재고 ${fmt(it.STOCK_QTY)}${it.UNIT || ""}`
: "재고 없음"}
</div>
</div>
</div>
<button
disabled={Number(it.STOCK_QTY) === 0}
onClick={() => addToCart(it)}
className="h-12 px-4 rounded-xl bg-emerald-700 text-white text-sm font-bold active:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed flex items-center justify-center gap-1 shrink-0"
>
<Plus size={16} strokeWidth={2.5} />
</button>
</div>
))}
</div>
)}
{/* 모바일 floating 카트 바 (BottomNav 위에 위치) */}
+33 -1
View File
@@ -121,7 +121,14 @@ export default function AdminPanelPage() {
}, []);
return (
<div className="flex h-screen bg-gray-100">
<>
{/*
모바일(md 미만)에서 진입했을 때 안내 — admin-panel 자체는 1630×950 데스크탑 팝업 전용.
PC 동작에는 영향 없음 (md 이상에서는 hidden).
*/}
<MobilePcOnlyNotice />
<div className="hidden md:flex h-screen bg-gray-100">
{/* 좌측 메뉴 (adminMenu.jsp 대응) */}
<aside className="w-[220px] bg-[#2a2a2a] text-gray-300 flex flex-col shrink-0">
<div className="px-4 py-3 border-b border-white/10">
@@ -221,6 +228,31 @@ export default function AdminPanelPage() {
)}
</main>
</div>
</>
);
}
// 모바일에서 admin-panel 진입 시 안내. md+ 에선 hidden.
function MobilePcOnlyNotice() {
return (
<div className="md:hidden min-h-screen bg-slate-50 flex flex-col items-center justify-center px-6 py-10 text-center">
<div className="w-20 h-20 rounded-full bg-amber-100 flex items-center justify-center mb-5">
<Shield size={40} className="text-amber-600" />
</div>
<h1 className="text-2xl font-extrabold text-slate-900 mb-2"> </h1>
<p className="text-slate-600 text-base leading-relaxed max-w-xs mb-1">
<span className="font-bold">PC에서만</span> .
</p>
<p className="text-slate-500 text-sm leading-relaxed max-w-xs mb-8">
PC로 ··· .
</p>
<a
href="/m/more"
className="inline-flex items-center justify-center gap-2 h-12 px-6 rounded-xl bg-emerald-700 text-white text-base font-bold active:bg-emerald-800 hover:bg-emerald-800 transition shadow-sm"
>
</a>
</div>
);
}