- 사이드바: '거래처' 키워드 필터 제거. 사용자 모드 = DB 권한 메뉴 전체, 관리자 모드 = 시스템 관리 가상 카테고리(사용자/권한/메뉴/공통코드/로그) - admin-panel: ?tab= 쿼리로 진입 탭 결정. 좌상단 '← 사용자' 복귀 링크 - header: admin 자동 admin 모드 진입 제거 (기본 사용자 모드) - 출고관리 거래명세표 미리보기: 엑셀 다운로드를 이미지 공유/인쇄 옆으로 이동, 출고요청 상태일 때 [출고] 버튼 추가하여 체크 없이 바로 처리 - 발주서 미리보기: [인쇄] 버튼 추가 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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") && (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 상태와 무관하게 항상 고정 노출 */}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user