feat(menu): 사용자/관리자 모드 사이드바 분리 + 거래명세표 즉시출고
Deploy momo-erp / deploy (push) Successful in 36s

- 사이드바: '거래처' 키워드 필터 제거. 사용자 모드 = DB 권한 메뉴 전체,
  관리자 모드 = 시스템 관리 가상 카테고리(사용자/권한/메뉴/공통코드/로그)
- admin-panel: ?tab= 쿼리로 진입 탭 결정. 좌상단 '← 사용자' 복귀 링크
- header: admin 자동 admin 모드 진입 제거 (기본 사용자 모드)
- 출고관리 거래명세표 미리보기: 엑셀 다운로드를 이미지 공유/인쇄 옆으로
  이동, 출고요청 상태일 때 [출고] 버튼 추가하여 체크 없이 바로 처리
- 발주서 미리보기: [인쇄] 버튼 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-08 12:18:59 +09:00
parent 6cfe0041a2
commit 63cdc6cab9
5 changed files with 156 additions and 33 deletions
+82 -18
View File
@@ -298,16 +298,8 @@ export default function AdminOrdersPage() {
{/* 우측: 거래명세표 미리보기 */}
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden flex flex-col">
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
<span> </span>
{detail && (
<a
href={`/api/m/orders/statement/${detail.order.OBJID}`}
className="text-xs text-emerald-700 font-semibold inline-flex items-center gap-1 hover:underline"
>
<Download size={12} />
</a>
)}
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600">
</div>
<div className="flex-1 lg:overflow-auto p-4">
{!detail ? (
@@ -316,7 +308,7 @@ export default function AdminOrdersPage() {
<div className="text-sm"> .</div>
</div>
) : (
<StatementPreview order={detail.order} items={detail.items} supplier={detail.supplier} onCancel={cancelOne} busy={busy} onReload={reloadDetail} />
<StatementPreview order={detail.order} items={detail.items} supplier={detail.supplier} onCancel={cancelOne} busy={busy} onReload={reloadDetail} onReloadList={load} />
)}
</div>
</div>
@@ -332,6 +324,7 @@ function StatementPreview({
onCancel,
busy,
onReload,
onReloadList,
}: {
order: DetailOrder;
items: DetailLine[];
@@ -339,7 +332,60 @@ function StatementPreview({
onCancel: (o: Order) => void;
busy: boolean;
onReload: () => void;
onReloadList: () => void;
}) {
const [shipping, setShipping] = useState(false);
const shipNow = async () => {
const lack = items.filter((it) => it.KIND === "ITEM" && Number(it.STOCK_QTY) < Number(it.QTY));
if (lack.length > 0) {
const ok = await Swal.fire({
icon: "warning",
title: "재고 부족 항목이 있습니다.",
html: `<div class="text-left text-sm">${lack.map((it) => `· ${it.ITEM_NAME} (요청 ${fmt(it.QTY)} / 현재고 ${fmt(it.STOCK_QTY)})`).join("<br>")}</div><br>그래도 출고를 시도하시겠습니까?`,
showCancelButton: true,
confirmButtonText: "출고 시도",
cancelButtonText: "취소",
confirmButtonColor: "#0f766e",
});
if (!ok.isConfirmed) return;
} else {
const ok = await Swal.fire({
icon: "question",
title: "출고 처리하시겠습니까?",
html: `발주 <b>${order.ORDER_NO}</b><br>거래처: ${order.COMPANY_NAME}<br>합계: ₩${fmt(order.TOTAL_AMOUNT)}<br><br>재고가 차감되고, 거래명세표가 메일로 발송됩니다.`,
showCancelButton: true,
confirmButtonText: "출고",
cancelButtonText: "취소",
confirmButtonColor: "#0f766e",
});
if (!ok.isConfirmed) return;
}
setShipping(true);
try {
const res = await fetch("/api/m/orders/approve", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ objid: order.OBJID }),
});
const j = await res.json();
if (j.success) {
await Swal.fire({
icon: "success",
title: "출고 완료",
html: j.mailSent ? "거래명세표 메일이 발송되었습니다." : `<span class="text-rose-600">메일 발송에 실패했습니다.</span>`,
timer: 1800,
showConfirmButton: false,
});
onReloadList();
onReload();
} else {
Swal.fire({ icon: "error", title: "출고 실패", text: j.message ?? "오류" });
}
} finally {
setShipping(false);
}
};
const statementRef = useRef<HTMLDivElement>(null);
const lowStock = items.filter((it) => it.KIND === "ITEM" && Number(it.STOCK_QTY) < Number(it.QTY));
const editable = order.STATUS === "REQUESTED";
@@ -434,8 +480,8 @@ function StatementPreview({
};
return (
<div className="text-[12px] text-slate-800 space-y-3">
{/* 공유/캡처 버튼 — 캡처 영역 밖에 배치 */}
<div className="flex justify-end gap-2 print:hidden">
{/* 공유/캡처/엑셀/출고 버튼 — 캡처 영역 밖에 배치 */}
<div className="flex justify-end gap-2 print:hidden flex-wrap">
<button
type="button"
onClick={captureAndShare}
@@ -452,6 +498,24 @@ function StatementPreview({
>
🖨
</button>
<a
href={`/api/m/orders/statement/${order.OBJID}`}
className="inline-flex items-center gap-1 h-8 px-3 rounded-lg bg-emerald-100 text-emerald-800 text-xs font-bold hover:bg-emerald-200"
title="거래명세표 엑셀 다운로드"
>
<Download size={12} />
</a>
{editable && (
<button
type="button"
onClick={shipNow}
disabled={shipping || busy}
className="inline-flex items-center gap-1 h-8 px-3 rounded-lg bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:opacity-50 disabled:cursor-not-allowed"
title="이 발주를 바로 출고 처리"
>
<Truck size={12} /> {shipping ? "출고 중..." : "출고"}
</button>
)}
</div>
<div ref={statementRef} className="bg-white p-3">
@@ -615,17 +679,17 @@ function StatementPreview({
</div>{/* /statementRef capture area */}
{order.STATUS === "REQUESTED" && (
<div className="flex justify-end gap-2 pt-3 border-t border-slate-200">
<div className="flex items-center justify-between gap-2 pt-3 border-t border-slate-200 flex-wrap">
<span className="text-[11px] text-slate-400">
[] .
</span>
<button
onClick={() => onCancel(order)}
disabled={busy}
disabled={busy || shipping}
className="px-3 h-9 rounded-lg border border-rose-200 text-rose-700 text-xs font-semibold hover:bg-rose-50 disabled:opacity-50 inline-flex items-center gap-1"
>
<X size={12} />
</button>
<span className="text-[11px] text-slate-400 self-center">
[] .
</span>
</div>
)}
{(order.STATUS === "APPROVED" || order.STATUS === "SHIPPED" || order.STATUS === "PAID") && (
+10 -2
View File
@@ -314,8 +314,8 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
return (
<div className="text-[12px]">
{/* 공유/엑셀 버튼 — 캡처 영역 밖 */}
<div className="flex justify-end gap-2 mb-2 print:hidden">
{/* 공유/인쇄/엑셀 버튼 — 캡처 영역 밖 */}
<div className="flex justify-end gap-2 mb-2 print:hidden flex-wrap">
<button
type="button"
onClick={captureAndShare}
@@ -324,6 +324,14 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
>
<ImageIcon size={14} />
</button>
<button
type="button"
onClick={() => window.print()}
className="inline-flex items-center gap-1 h-8 px-3 rounded-lg bg-slate-100 text-slate-700 text-xs font-bold hover:bg-slate-200"
title="인쇄"
>
🖨
</button>
<a
href={`/api/m/procurements/excel/${detail.proc.OBJID}`}
className="inline-flex items-center gap-1 h-8 px-3 rounded-lg bg-emerald-100 text-emerald-800 text-xs font-bold hover:bg-emerald-200"
+29 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useCallback, useEffect, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { cn } from "@/lib/utils";
import { DataGrid, type GridColumn } from "@/components/grid/data-grid";
import { SearchForm, SearchField } from "@/components/layout/search-form";
@@ -98,11 +99,30 @@ interface SidebarGroup {
items: { objid: string; label: string; url: string }[];
}
const VALID_TABS: AdminTab[] = [
"menu","auth","user","dept","code","supply","template","exchange",
"log-file","log-login","log-mail",
"ref-customer","ref-material","ref-car","ref-car-grade",
"ref-product-group","ref-product","spec-data-category","car-option",
];
export default function AdminPanelPage() {
const [activeTab, setActiveTab] = useState<AdminTab>("user");
const searchParams = useSearchParams();
const tabParam = searchParams.get("tab");
const initialTab: AdminTab = tabParam && (VALID_TABS as string[]).includes(tabParam)
? (tabParam as AdminTab)
: "user";
const [activeTab, setActiveTab] = useState<AdminTab>(initialTab);
const [groups, setGroups] = useState<SidebarGroup[]>([]);
const [openSections, setOpenSections] = useState<Set<string>>(new Set(["권한 및 사용자 관리"]));
// 사이드바에서 ?tab= 으로 다른 탭 클릭 시 동기화
useEffect(() => {
if (tabParam && (VALID_TABS as string[]).includes(tabParam)) {
setActiveTab(tabParam as AdminTab);
}
}, [tabParam]);
const toggleSection = (label: string) => {
setOpenSections((prev) => {
const next = new Set(prev);
@@ -124,11 +144,18 @@ export default function AdminPanelPage() {
<div className="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">
<div className="px-4 py-3 border-b border-white/10 flex items-center justify-between gap-2">
<h1 className="text-white font-bold text-sm flex items-center gap-2">
<Shield size={16} className="text-orange-400" />
</h1>
<a
href="/dashboard"
className="text-[10px] text-gray-400 hover:text-white hover:underline"
title="일반 사용자 메뉴로 돌아가기"
>
</a>
</div>
<nav className="flex-1 overflow-y-auto py-2">
{/* ★ 메뉴 관리 — DB 상태와 무관하게 항상 고정 노출 */}
+1 -5
View File
@@ -14,11 +14,7 @@ export function Header() {
fetchTopMenus();
}, [fetchTopMenus]);
// 관리자가 로그인 직후 자동으로 관리자 모드 진입 (한 번만)
useEffect(() => {
if (isAdminUser && viewMode === "user") setViewMode("admin");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAdminUser]);
// 관리자도 기본은 사용자 모드. 토글로 관리자 메뉴 진입.
// 초기 로드: "사용자" 메뉴의 사이드바를 불러오기 (관리자 제외)
useEffect(() => {
+34 -6
View File
@@ -6,12 +6,14 @@ import { cn } from "@/lib/utils";
import { useMenuStore } from "@/store/menu-store";
import { MENU_ICON_MAP } from "@/lib/constants";
import { mapMenuUrl } from "@/lib/menu-url-map";
import type { MenuItem } from "@/types";
import {
LayoutDashboard, TrendingUp, FolderKanban, Package, ListChecks,
ShoppingCart, FileText, Warehouse, Boxes, Factory, Wrench,
Headset, Clock, Calculator, Coins, Truck, Settings,
ClipboardCheck, Compass, GitBranch, Puzzle, Stamp, Folder,
ChevronDown, Menu as MenuIcon, X,
ChevronDown, Menu as MenuIcon, X, Shield, Users as UsersIcon,
Building, KeyRound, Database, FileSearch,
} from "lucide-react";
const ICON_COMPONENTS: Record<string, React.ElementType> = {
@@ -22,6 +24,7 @@ const ICON_COMPONENTS: Record<string, React.ElementType> = {
};
function getMenuIcon(menuName: string): React.ElementType {
if (menuName === "시스템 관리") return Shield;
for (const [key, iconName] of Object.entries(MENU_ICON_MAP)) {
if (menuName?.toUpperCase().includes(key.toUpperCase())) {
return ICON_COMPONENTS[iconName] || Folder;
@@ -30,17 +33,42 @@ function getMenuIcon(menuName: string): React.ElementType {
return Folder;
}
// 관리자 모드 전용 가상 메뉴 — admin-panel 의 탭들을 사이드바로 노출
const ADMIN_SYSTEM_MENU: MenuItem = {
objid: "__sys__",
menuNameKor: "시스템 관리",
menuNameEng: "System",
menuUrl: "",
parentObjId: "",
menuOrder: "0",
level: "1",
children: [
{ objid: "__sys_user", menuNameKor: "사용자 관리", menuNameEng: "Users", menuUrl: "/admin-panel?tab=user", parentObjId: "__sys__", menuOrder: "1", level: "2" },
{ objid: "__sys_auth", menuNameKor: "권한 관리", menuNameEng: "Auth", menuUrl: "/admin-panel?tab=auth", parentObjId: "__sys__", menuOrder: "2", level: "2" },
{ objid: "__sys_dept", menuNameKor: "부서 관리", menuNameEng: "Dept", menuUrl: "/admin-panel?tab=dept", parentObjId: "__sys__", menuOrder: "3", level: "2" },
{ objid: "__sys_menu", menuNameKor: "메뉴 관리", menuNameEng: "Menu", menuUrl: "/admin-panel?tab=menu", parentObjId: "__sys__", menuOrder: "4", level: "2" },
{ objid: "__sys_code", menuNameKor: "공통코드 관리", menuNameEng: "Code", menuUrl: "/admin-panel?tab=code", parentObjId: "__sys__", menuOrder: "5", level: "2" },
{ objid: "__sys_supply", menuNameKor: "공급업체 관리", menuNameEng: "Vendors", menuUrl: "/admin-panel?tab=supply", parentObjId: "__sys__", menuOrder: "6", level: "2" },
{ objid: "__sys_login", menuNameKor: "로그인 로그", menuNameEng: "Login Log", menuUrl: "/admin-panel?tab=log-login", parentObjId: "__sys__", menuOrder: "7", level: "2" },
{ objid: "__sys_file", menuNameKor: "파일 로그", menuNameEng: "File Log", menuUrl: "/admin-panel?tab=log-file", parentObjId: "__sys__", menuOrder: "8", level: "2" },
],
};
// 사용 가능한 lucide 아이콘들을 전역 등록 (시스템 관리 자식들을 위해)
Object.assign(ICON_COMPONENTS, {
Shield, UsersIcon, Building, KeyRound, Database, FileSearch,
});
export function Sidebar() {
const router = useRouter();
const {
sideMenus: allSideMenus, activeSubMenu, isCollapsed, viewMode,
setActiveSubMenu, toggleCollapsed, setMobileOpen,
} = useMenuStore();
// 사용자 모드: '거래처 주문' 그룹만. 관리자 모드: 그 외 그룹.
const sideMenus = allSideMenus.filter((m) => {
const isUserGroup = m.menuNameKor?.includes("거래처");
return viewMode === "user" ? isUserGroup : !isUserGroup;
});
// 사용자 모드 = DB 메뉴(권한대로 내려옴) 그대로
// 관리자 모드 = 시스템 관리 가상 메뉴(사용자/권한/메뉴/공통코드/로그 등)
const sideMenus: MenuItem[] = viewMode === "admin"
? [ADMIN_SYSTEM_MENU]
: allSideMenus;
const [openCategories, setOpenCategories] = useState<Set<string>>(new Set());
// 축소 모드 호버 팝업
const [hoveredCategory, setHoveredCategory] = useState<string | null>(null);