feat(perm): 사용자 특수권한(발주한도 무시·숨김 품목 보기) UI 노출 + 출고요청 반영
Deploy momo-erp / deploy (push) Failing after 43s
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:
@@ -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"}`}>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 } });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user