Files
distribution_erp/src/app/(mobile)/m/more/page.tsx
T
hjjeong 39465b38d9 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>
2026-05-03 22:06:53 +09:00

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>
);
}