feat(perm): 사용자 특수권한(발주한도 무시·숨김 품목 보기) UI 노출 + 출고요청 반영
Deploy momo-erp / deploy (push) Failing after 43s

[사용자 관리]
- /api/admin/users 목록에 UNLIMITED_QTY / VIEW_HIDDEN / USER_TYPE 컬럼 반환
- UserManagement 그리드에 '발주한도무시' / '숨김품목보기' 컬럼 추가 (/—)
- 사용자 수정 폼: '거래처 특수 권한' → '특수 권한 (발주 시 적용)' 으로 라벨 변경,
  거래처(C) 전용이던 조건을 풀어서 일반 사용자(U) 도 권한 부여 가능

[출고요청 (/m/orders/new)]
- /api/auth/me 가 unlimitedQty / viewHidden 반환
- 클라이언트가 unlimitedQty true 면 MAX_ORDER_QTY 무시하고 재고만큼 발주 가능
- '한도 ≤ N' 라벨도 권한자에겐 숨김

(백엔드 검증 — /api/m/items/list 의 view_hidden, /api/m/orders/save 의
 unlimited_qty 우회 — 는 이미 구현돼 있어 그대로 동작)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-12 00:46:37 +09:00
parent 77d89527b8
commit 9e9922e219
5 changed files with 39 additions and 14 deletions
+14 -6
View File
@@ -39,6 +39,14 @@ export default function ItemsBrowse() {
const [extras, setExtras] = useState<ExtraLine[]>([]); const [extras, setExtras] = useState<ExtraLine[]>([]);
const [cartOpen, setCartOpen] = useState(false); const [cartOpen, setCartOpen] = useState(false);
const [viewMode, setViewMode] = useState<"card" | "list">("card"); const [viewMode, setViewMode] = useState<"card" | "list">("card");
// 현재 사용자의 발주 한도 우회 권한 (관리자 또는 unlimited_qty='Y' 거래처)
const [unlimitedQty, setUnlimitedQty] = useState(false);
useEffect(() => {
fetch("/api/auth/me").then((r) => r.json()).then((d) => {
if (d?.user) setUnlimitedQty(!!d.user.unlimitedQty || d.user.role === "ADMIN" || d.user.isAdmin === true);
}).catch(() => {});
}, []);
const fetchItems = useCallback(async () => { const fetchItems = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -76,7 +84,7 @@ export default function ItemsBrowse() {
const addManyToCart = (item: Item, qty: number) => { const addManyToCart = (item: Item, qty: number) => {
const stock = Number(item.STOCK_QTY); const stock = Number(item.STOCK_QTY);
const maxQ = Number(item.MAX_ORDER_QTY ?? 0); const maxQ = Number(item.MAX_ORDER_QTY ?? 0);
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock; const limit = unlimitedQty || maxQ <= 0 ? stock : Math.min(stock, maxQ);
let toastTitle = ""; let toastTitle = "";
let warned = false; let warned = false;
setCart((c) => { setCart((c) => {
@@ -115,7 +123,7 @@ export default function ItemsBrowse() {
if (newQty <= 0) return x; if (newQty <= 0) return x;
const stock = Number(x.item.STOCK_QTY); const stock = Number(x.item.STOCK_QTY);
const maxQ = Number(x.item.MAX_ORDER_QTY ?? 0); const maxQ = Number(x.item.MAX_ORDER_QTY ?? 0);
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock; const limit = unlimitedQty || maxQ <= 0 ? stock : Math.min(stock, maxQ);
if (newQty > limit) return x; if (newQty > limit) return x;
return { ...x, qty: newQty }; return { ...x, qty: newQty };
}) })
@@ -128,7 +136,7 @@ export default function ItemsBrowse() {
if (x.item.OBJID !== objid) return x; if (x.item.OBJID !== objid) return x;
const stock = Number(x.item.STOCK_QTY); const stock = Number(x.item.STOCK_QTY);
const maxQ = Number(x.item.MAX_ORDER_QTY ?? 0); const maxQ = Number(x.item.MAX_ORDER_QTY ?? 0);
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock; const limit = unlimitedQty || maxQ <= 0 ? stock : Math.min(stock, maxQ);
const clamped = Math.max(1, Math.min(limit, Math.floor(value || 0))); const clamped = Math.max(1, Math.min(limit, Math.floor(value || 0)));
return { ...x, qty: clamped }; return { ...x, qty: clamped };
}) })
@@ -474,7 +482,7 @@ export default function ItemsBrowse() {
const inCart = cartLine?.qty ?? 0; const inCart = cartLine?.qty ?? 0;
const stock = Number(it.STOCK_QTY); const stock = Number(it.STOCK_QTY);
const maxQ = Number(it.MAX_ORDER_QTY ?? 0); const maxQ = Number(it.MAX_ORDER_QTY ?? 0);
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock; const limit = unlimitedQty || maxQ <= 0 ? stock : Math.min(stock, maxQ);
const soldOut = stock === 0; const soldOut = stock === 0;
return ( return (
<div key={it.OBJID} className={`bg-white border rounded-lg p-2 transition ${inCart > 0 ? "border-emerald-400 ring-2 ring-emerald-100" : "border-slate-200 hover:shadow-md"}`}> <div key={it.OBJID} className={`bg-white border rounded-lg p-2 transition ${inCart > 0 ? "border-emerald-400 ring-2 ring-emerald-100" : "border-slate-200 hover:shadow-md"}`}>
@@ -506,7 +514,7 @@ export default function ItemsBrowse() {
{fmt(stock)}{it.UNIT} {fmt(stock)}{it.UNIT}
</div> </div>
</div> </div>
{maxQ > 0 && ( {maxQ > 0 && !unlimitedQty && (
<div className="text-[9px] text-sky-700 mb-0.5"> {fmt(maxQ)}</div> <div className="text-[9px] text-sky-700 mb-0.5"> {fmt(maxQ)}</div>
)} )}
@@ -623,7 +631,7 @@ function ListView({ items, cart, onAdd, onPlus, onMinus, onSetQty, onRemove }: {
const inCart = cartLine?.qty ?? 0; const inCart = cartLine?.qty ?? 0;
const stock = Number(it.STOCK_QTY); const stock = Number(it.STOCK_QTY);
const maxQ = Number(it.MAX_ORDER_QTY ?? 0); const maxQ = Number(it.MAX_ORDER_QTY ?? 0);
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock; const limit = unlimitedQty || maxQ <= 0 ? stock : Math.min(stock, maxQ);
const soldOut = stock === 0; const soldOut = stock === 0;
return ( return (
<tr key={it.OBJID} className={`border-t border-slate-100 ${inCart > 0 ? "bg-emerald-50/40" : "hover:bg-slate-50"}`}> <tr key={it.OBJID} className={`border-t border-slate-100 ${inCart > 0 ? "bg-emerald-50/40" : "hover:bg-slate-50"}`}>
+8 -5
View File
@@ -266,14 +266,17 @@ function UserManagement() {
const [data, setData] = useState<Record<string, unknown>[]>([]); const [data, setData] = useState<Record<string, unknown>[]>([]);
const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]); const [selectedRows, setSelectedRows] = useState<Record<string, unknown>[]>([]);
const yn = (v: unknown) => v === "Y" ? "✅" : "—";
const columns: GridColumn[] = [ const columns: GridColumn[] = [
{ title: "부서명", field: "DEPT_NAME", width: 120 }, { title: "부서명", field: "DEPT_NAME", width: 110 },
{ title: "사용자 ID", field: "USER_ID", width: 120, cellClick: (row) => openUserForm(String(row.USER_ID)) }, { title: "사용자 ID", field: "USER_ID", width: 110, cellClick: (row) => openUserForm(String(row.USER_ID)) },
{ title: "사용자명", field: "USER_NAME", width: 100, hozAlign: "center" }, { title: "사용자명", field: "USER_NAME", width: 100, hozAlign: "center" },
{ title: "전화번호", field: "CELL_PHONE", width: 130 }, { title: "전화번호", field: "CELL_PHONE", width: 120 },
{ title: "이메일", field: "EMAIL", width: 180 }, { title: "이메일", field: "EMAIL", width: 160 },
{ title: "발주한도무시", field: "UNLIMITED_QTY", width: 110, hozAlign: "center", formatter: (cell) => yn(cell) },
{ title: "숨김품목보기", field: "VIEW_HIDDEN", width: 110, hozAlign: "center", formatter: (cell) => yn(cell) },
{ title: "등록일", field: "REGDATE", width: 100, hozAlign: "center" }, { title: "등록일", field: "REGDATE", width: 100, hozAlign: "center" },
{ title: "상태", field: "STATUS", width: 80, hozAlign: "center" }, { title: "상태", field: "STATUS", width: 70, hozAlign: "center" },
]; ];
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
+2 -2
View File
@@ -118,9 +118,9 @@ function UserForm() {
</div> </div>
)} )}
{isCustomer && !isNew && ( {!isNew && (
<div className="mt-5 pt-4 border-t"> <div className="mt-5 pt-4 border-t">
<h3 className="text-sm font-bold text-gray-700 mb-3"> </h3> <h3 className="text-sm font-bold text-gray-700 mb-3"> ( )</h3>
<div className="space-y-2"> <div className="space-y-2">
<label className="flex items-center gap-3 p-2.5 rounded border hover:bg-gray-50 cursor-pointer"> <label className="flex items-center gap-3 p-2.5 rounded border hover:bg-gray-50 cursor-pointer">
<input <input
+3
View File
@@ -36,8 +36,11 @@ export async function POST(request: NextRequest) {
SELECT U.user_id AS "USER_ID", U.user_name AS "USER_NAME", U.dept_name AS "DEPT_NAME", SELECT U.user_id AS "USER_ID", U.user_name AS "USER_NAME", U.dept_name AS "DEPT_NAME",
U.sabun AS "SABUN", U.position_name AS "POSITION_NAME", U.email AS "EMAIL", U.sabun AS "SABUN", U.position_name AS "POSITION_NAME", U.email AS "EMAIL",
U.cell_phone AS "CELL_PHONE", U.tel AS "TEL", U.user_type_name AS "USER_TYPE_NAME", U.cell_phone AS "CELL_PHONE", U.tel AS "TEL", U.user_type_name AS "USER_TYPE_NAME",
U.user_type AS "USER_TYPE",
TO_CHAR(U.regdate, 'YYYY-MM-DD') AS "REGDATE", TO_CHAR(U.regdate, 'YYYY-MM-DD') AS "REGDATE",
COALESCE(U.status, 'active') AS "STATUS", COALESCE(U.status, 'active') AS "STATUS",
COALESCE(U.unlimited_qty, 'N') AS "UNLIMITED_QTY",
COALESCE(U.view_hidden, 'N') AS "VIEW_HIDDEN",
COALESCE((SELECT STRING_AGG(AM.auth_name, ',') FROM authority_sub_user ASU JOIN authority_master AM ON AM.objid = ASU.master_objid WHERE ASU.user_id = U.user_id), '') AS "AUTH_NAME" COALESCE((SELECT STRING_AGG(AM.auth_name, ',') FROM authority_sub_user ASU JOIN authority_master AM ON AM.objid = ASU.master_objid WHERE ASU.user_id = U.user_id), '') AS "AUTH_NAME"
FROM user_info U FROM user_info U
WHERE ${where} WHERE ${where}
+12 -1
View File
@@ -1,10 +1,21 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getSession } from "@/lib/session"; import { getSession } from "@/lib/session";
import { queryOne } from "@/lib/db";
export async function GET() { export async function GET() {
const user = await getSession(); const user = await getSession();
if (!user) { if (!user) {
return NextResponse.json({ success: false }, { status: 401 }); return NextResponse.json({ success: false }, { status: 401 });
} }
return NextResponse.json({ success: true, user }); // 특수 권한 (발주 한도 무시 / 숨김 품목 보기) 도 같이 내려줌
let perms = { unlimitedQty: false, viewHidden: false };
try {
const row = await queryOne<{ U: string; V: string }>(
`SELECT COALESCE(unlimited_qty, 'N') AS "U", COALESCE(view_hidden, 'N') AS "V"
FROM user_info WHERE user_id = $1`,
[user.userId]
);
if (row) perms = { unlimitedQty: row.U === "Y", viewHidden: row.V === "Y" };
} catch { /* ignore */ }
return NextResponse.json({ success: true, user: { ...user, ...perms } });
} }