feat(header,procurement): 관리자 토글 가드 강화 + 매입발주 발주지사(HQ/KIMPO) 셀렉트
Deploy momo-erp / deploy (push) Successful in 4m27s

- header: 메뉴 변환 버튼은 authority_master "관리자" 권한그룹 멤버만 노출. user_type='A' 만으로는 부족 (실무자 다수에 부여돼 있음). /api/auth/me 가 isMasterAdmin 플래그 반환.
- procurements: 발주서에 발주지사 셀렉트 추가 (기준 명세표 마스터 사용 — HQ/KIMPO 등). 통계/계산서 발행 시 지사별 집계 가능.
This commit is contained in:
chpark
2026-05-23 01:36:44 +09:00
parent a40bb609e3
commit a06a5d551e
6 changed files with 101 additions and 9 deletions
+37 -4
View File
@@ -19,7 +19,9 @@ interface ProcDetail {
DELIVERY_PERIOD?: string; DELIVERY_PERIOD?: string;
PAYMENT_TERMS?: string; PAYMENT_TERMS?: string;
FREIGHT_TERMS?: string; FREIGHT_TERMS?: string;
BRANCH?: string;
} }
interface StatementBranch { CODE: string; NAME: string; IS_DEFAULT: string; SORT_ORDER: number }
interface ProcLine { interface ProcLine {
OBJID: string; ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string; OBJID: string; ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string;
UNIT: string; QTY: number; COST_PRICE: number; TOTAL_AMOUNT: number; UNIT: string; QTY: number; COST_PRICE: number; TOTAL_AMOUNT: number;
@@ -59,6 +61,7 @@ export default function ProcurementsPage() {
const [activeId, setActiveId] = useState(""); const [activeId, setActiveId] = useState("");
const [detail, setDetail] = useState<{ proc: ProcDetail; items: ProcLine[] } | null>(null); const [detail, setDetail] = useState<{ proc: ProcDetail; items: ProcLine[] } | null>(null);
const [vendors, setVendors] = useState<Vendor[]>([]); const [vendors, setVendors] = useState<Vendor[]>([]);
const [branches, setBranches] = useState<StatementBranch[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
@@ -83,6 +86,10 @@ export default function ProcurementsPage() {
const r = await fetch("/api/m/vendors/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); const r = await fetch("/api/m/vendors/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
setVendors((await r.json()).RESULTLIST ?? []); setVendors((await r.json()).RESULTLIST ?? []);
}; };
const loadBranches = async () => {
const r = await fetch("/api/m/admin/statement-branches/list", { method: "POST" });
setBranches((await r.json()).RESULTLIST ?? []);
};
const loadDetail = useCallback(async () => { const loadDetail = useCallback(async () => {
if (!activeId) { setDetail(null); return; } if (!activeId) { setDetail(null); return; }
@@ -94,7 +101,7 @@ export default function ProcurementsPage() {
if (j.success) setDetail({ proc: j.proc, items: j.items }); if (j.success) setDetail({ proc: j.proc, items: j.items });
}, [activeId]); }, [activeId]);
useEffect(() => { loadVendors(); }, []); useEffect(() => { loadVendors(); loadBranches(); }, []);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
useEffect(() => { loadDetail(); }, [loadDetail]); useEffect(() => { loadDetail(); }, [loadDetail]);
@@ -109,7 +116,7 @@ export default function ProcurementsPage() {
} }
}; };
const updateHeader = async (patch: { vendorObjid?: string | null; memo?: string }) => { const updateHeader = async (patch: Record<string, unknown>) => {
if (!detail) return; if (!detail) return;
const res = await fetch("/api/m/procurements/update-header", { const res = await fetch("/api/m/procurements/update-header", {
method: "POST", headers: { "Content-Type": "application/json" }, method: "POST", headers: { "Content-Type": "application/json" },
@@ -302,7 +309,9 @@ export default function ProcurementsPage() {
<ProcurementForm <ProcurementForm
detail={detail} detail={detail}
vendors={vendors} vendors={vendors}
branches={branches}
onSetVendor={(v) => updateHeader({ vendorObjid: v || null })} onSetVendor={(v) => updateHeader({ vendorObjid: v || null })}
onSetBranch={(b) => updateHeader({ branch: b })}
onSetMemo={(m) => updateHeader({ memo: m })} onSetMemo={(m) => updateHeader({ memo: m })}
onSetTerm={(field, val) => updateHeader({ [field]: val })} onSetTerm={(field, val) => updateHeader({ [field]: val })}
onAddPicker={() => setPickerOpen(true)} onAddPicker={() => setPickerOpen(true)}
@@ -342,10 +351,12 @@ export default function ProcurementsPage() {
); );
} }
function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, onAddPicker, onUpdateLine, onDeleteLine }: { function ProcurementForm({ detail, vendors, branches, onSetVendor, onSetBranch, onSetMemo, onSetTerm, onAddPicker, onUpdateLine, onDeleteLine }: {
detail: { proc: ProcDetail; items: ProcLine[] }; detail: { proc: ProcDetail; items: ProcLine[] };
vendors: Vendor[]; vendors: Vendor[];
branches: StatementBranch[];
onSetVendor: (id: string) => void; onSetVendor: (id: string) => void;
onSetBranch: (code: string) => void;
onSetMemo: (m: string) => void; onSetMemo: (m: string) => void;
onSetTerm: (field: "deliveryPlace" | "deliveryPeriod" | "paymentTerms" | "freightTerms", val: string) => void; onSetTerm: (field: "deliveryPlace" | "deliveryPeriod" | "paymentTerms" | "freightTerms", val: string) => void;
onAddPicker: () => void; onAddPicker: () => void;
@@ -406,7 +417,7 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
<tbody> <tbody>
<tr> <tr>
<th className="border border-slate-400 bg-slate-100 px-2 py-1 w-[100px] text-center"></th> <th className="border border-slate-400 bg-slate-100 px-2 py-1 w-[100px] text-center"></th>
<td className="border border-slate-400 px-3 py-1 font-semibold w-[200px]"></td> <td className="border border-slate-400 px-3 py-1 font-semibold w-[260px]"></td>
</tr> </tr>
<tr> <tr>
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center"></th> <th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center"></th>
@@ -416,6 +427,28 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center"></th> <th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center"></th>
<td className="border border-slate-400 px-3 py-1">{detail.proc.PROC_DATE}</td> <td className="border border-slate-400 px-3 py-1">{detail.proc.PROC_DATE}</td>
</tr> </tr>
<tr>
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center"></th>
<td className="border border-slate-400 px-3 py-1">
{editable ? (
<select
value={detail.proc.BRANCH ?? branches.find((b) => b.IS_DEFAULT === "Y")?.CODE ?? "HQ"}
onChange={(e) => onSetBranch(e.target.value)}
className="h-7 px-2 rounded border border-slate-300 text-[13px] bg-white"
>
{branches.length === 0 ? (
<option value="HQ"> (HQ)</option>
) : branches.map((b) => (
<option key={b.CODE} value={b.CODE}>{b.NAME} ({b.CODE}){b.IS_DEFAULT === "Y" ? " ★" : ""}</option>
))}
</select>
) : (
<span className="font-semibold">
{branches.find((b) => b.CODE === (detail.proc.BRANCH ?? "HQ"))?.NAME ?? (detail.proc.BRANCH ?? "본사")}
</span>
)}
</td>
</tr>
<tr> <tr>
<th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center"></th> <th className="border border-slate-400 bg-slate-100 px-2 py-1 text-center"></th>
<td className="border border-slate-400 px-3 py-1"> <td className="border border-slate-400 px-3 py-1">
+24 -1
View File
@@ -1,6 +1,7 @@
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"; import { queryOne } from "@/lib/db";
import { SUPER_ADMIN } from "@/lib/constants";
export async function GET() { export async function GET() {
const user = await getSession(); const user = await getSession();
@@ -10,6 +11,28 @@ export async function GET() {
// 특수 권한 (발주한도 무시 / 숨김품목 보기) + 기본 용차비 설정 같이 내려줌. // 특수 권한 (발주한도 무시 / 숨김품목 보기) + 기본 용차비 설정 같이 내려줌.
// default_charter_* 컬럼이 운영DB 에 아직 없을 수 있어 try-catch 로 안전 처리. // default_charter_* 컬럼이 운영DB 에 아직 없을 수 있어 try-catch 로 안전 처리.
let perms = { unlimitedQty: false, viewHidden: false, defaultCharterUse: false, defaultCharterPrice: 0 }; let perms = { unlimitedQty: false, viewHidden: false, defaultCharterUse: false, defaultCharterPrice: 0 };
// 관리자 메뉴 토글 노출용 — "관리자 권한그룹" 멤버십 검사.
// user_type='A' 만으로는 부족 (실무자에게 광범위하게 부여돼 있어 메뉴 토글 노출이 과함).
// authority_master.auth_code='ADMIN' 또는 auth_name 에 '관리자' 포함 + active 상태 그룹에 소속된 경우만 true.
// SUPER_ADMIN(plm_admin) 은 항상 통과.
let isMasterAdmin = user.userId?.toLowerCase() === SUPER_ADMIN;
try {
if (!isMasterAdmin) {
const row = await queryOne<{ N: string }>(
`SELECT 1 AS "N"
FROM authority_sub_user ASU
JOIN authority_master AM ON AM.objid = ASU.master_objid
WHERE ASU.user_id = $1
AND COALESCE(AM.status, 'active') = 'active'
AND (UPPER(COALESCE(AM.auth_code,'')) = 'ADMIN' OR AM.auth_name LIKE '%관리자%')
LIMIT 1`,
[user.userId]
);
isMasterAdmin = !!row;
}
} catch {
// authority 테이블 미존재 등 — 폴백: SUPER_ADMIN 만 true
}
try { try {
const row = await queryOne<{ U: string; V: string; CU: string; CP: string }>( const row = await queryOne<{ U: string; V: string; CU: string; CP: string }>(
`SELECT COALESCE(unlimited_qty, 'N') AS "U", `SELECT COALESCE(unlimited_qty, 'N') AS "U",
@@ -36,5 +59,5 @@ export async function GET() {
if (row) perms = { ...perms, unlimitedQty: row.U === "Y", viewHidden: row.V === "Y" }; if (row) perms = { ...perms, unlimitedQty: row.U === "Y", viewHidden: row.V === "Y" };
} catch { /* ignore */ } } catch { /* ignore */ }
} }
return NextResponse.json({ success: true, user: { ...user, ...perms } }); return NextResponse.json({ success: true, user: { ...user, ...perms, isMasterAdmin } });
} }
+18 -1
View File
@@ -1,10 +1,26 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { queryOne, queryRows } from "@/lib/db"; import { pool, queryOne, queryRows } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard"; import { requireMomoAdmin } from "@/lib/momo-guard";
// momo_procurements 에 branch 컬럼이 없으면 1회 자동 증설
let branchColEnsured = false;
async function ensureBranchCol() {
if (branchColEnsured) return;
try {
await pool.query(`
ALTER TABLE momo_procurements
ADD COLUMN IF NOT EXISTS branch VARCHAR(20) DEFAULT 'HQ';
`);
branchColEnsured = true;
} catch (err) {
console.error("[procurements/ensureBranchCol]", err);
}
}
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const g = await requireMomoAdmin(); const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g; if (g instanceof NextResponse) return g;
await ensureBranchCol();
const { objid } = await req.json(); const { objid } = await req.json();
const proc = await queryOne( const proc = await queryOne(
`SELECT P.objid AS "OBJID", P.proc_no AS "PROC_NO", `SELECT P.objid AS "OBJID", P.proc_no AS "PROC_NO",
@@ -14,6 +30,7 @@ export async function POST(req: NextRequest) {
P.delivery_period AS "DELIVERY_PERIOD", P.delivery_period AS "DELIVERY_PERIOD",
P.payment_terms AS "PAYMENT_TERMS", P.payment_terms AS "PAYMENT_TERMS",
P.freight_terms AS "FREIGHT_TERMS", P.freight_terms AS "FREIGHT_TERMS",
COALESCE(P.branch, 'HQ') AS "BRANCH",
V.objid AS "VENDOR_OBJID", V.supply_name AS "VENDOR_NAME" V.objid AS "VENDOR_OBJID", V.supply_name AS "VENDOR_NAME"
FROM momo_procurements P FROM momo_procurements P
LEFT JOIN supply_mng V ON P.vendor_objid = V.objid::text LEFT JOIN supply_mng V ON P.vendor_objid = V.objid::text
@@ -1,16 +1,30 @@
// 매입 발주서 헤더 (공급업체/메모) 수정 — OPEN/REQUESTED 상태만 // 매입 발주서 헤더 (공급업체/메모/지사) 수정 — OPEN/REQUESTED 상태만
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { pool } from "@/lib/db"; import { pool } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard"; import { requireMomoAdmin } from "@/lib/momo-guard";
let branchColEnsured = false;
async function ensureBranchCol() {
if (branchColEnsured) return;
try {
await pool.query(`
ALTER TABLE momo_procurements
ADD COLUMN IF NOT EXISTS branch VARCHAR(20) DEFAULT 'HQ';
`);
branchColEnsured = true;
} catch { /* ignore */ }
}
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const g = await requireMomoAdmin(); const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g; if (g instanceof NextResponse) return g;
await ensureBranchCol();
const body = await req.json().catch(() => ({})); const body = await req.json().catch(() => ({}));
const { objid, vendorObjid, memo, deliveryPlace, deliveryPeriod, paymentTerms, freightTerms } = body as { const { objid, vendorObjid, memo, deliveryPlace, deliveryPeriod, paymentTerms, freightTerms, branch } = body as {
objid?: string; vendorObjid?: string | null; memo?: string; objid?: string; vendorObjid?: string | null; memo?: string;
deliveryPlace?: string; deliveryPeriod?: string; paymentTerms?: string; freightTerms?: string; deliveryPlace?: string; deliveryPeriod?: string; paymentTerms?: string; freightTerms?: string;
branch?: string;
}; };
if (!objid) return NextResponse.json({ success: false, message: "objid 누락" }, { status: 400 }); if (!objid) return NextResponse.json({ success: false, message: "objid 누락" }, { status: 400 });
@@ -30,6 +44,7 @@ export async function POST(req: NextRequest) {
if (deliveryPeriod !== undefined) { sets.push(`delivery_period = $${i++}`); params.push(deliveryPeriod); } if (deliveryPeriod !== undefined) { sets.push(`delivery_period = $${i++}`); params.push(deliveryPeriod); }
if (paymentTerms !== undefined) { sets.push(`payment_terms = $${i++}`); params.push(paymentTerms); } if (paymentTerms !== undefined) { sets.push(`payment_terms = $${i++}`); params.push(paymentTerms); }
if (freightTerms !== undefined) { sets.push(`freight_terms = $${i++}`); params.push(freightTerms); } if (freightTerms !== undefined) { sets.push(`freight_terms = $${i++}`); params.push(freightTerms); }
if (branch !== undefined) { sets.push(`branch = $${i++}`); params.push(branch); }
if (sets.length === 0) return NextResponse.json({ success: true }); if (sets.length === 0) return NextResponse.json({ success: true });
await pool.query(`UPDATE momo_procurements SET ${sets.join(", ")} WHERE objid = $1`, params); await pool.query(`UPDATE momo_procurements SET ${sets.join(", ")} WHERE objid = $1`, params);
+2 -1
View File
@@ -10,7 +10,8 @@ export function Header() {
const router = useRouter(); const router = useRouter();
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
const { topMenus, activeTopMenu, fetchTopMenus, fetchSideMenus, setMobileOpen, viewMode, setViewMode } = useMenuStore(); const { topMenus, activeTopMenu, fetchTopMenus, fetchSideMenus, setMobileOpen, viewMode, setViewMode } = useMenuStore();
const isAdminUser = !!user && (user.isAdmin || user.role === "ADMIN" || user.userType === "A"); // 관리자 메뉴 토글 — 관리자 권한그룹 멤버만 (user_type='A' 만으로는 부족, 실무자도 다수 부여돼 있음)
const isAdminUser = !!user && (user.isMasterAdmin === true || user.isAdmin === true);
useEffect(() => { useEffect(() => {
fetchTopMenus(); fetchTopMenus();
+3
View File
@@ -17,6 +17,9 @@ export interface User {
authName: string; authName: string;
partnerCd: string; partnerCd: string;
isAdmin: boolean; isAdmin: boolean;
// 관리자 메뉴 토글 노출 가드 — 관리자 권한그룹(authority_master) 멤버일 때만 true
// /api/auth/me 에서 채워짐. user_type='A' 같은 광범위 플래그와 분리해서 사용.
isMasterAdmin?: boolean;
// MOMO 추가 필드 (선택 — FITO 사용자에게는 비어있음) // MOMO 추가 필드 (선택 — FITO 사용자에게는 비어있음)
role?: "USER" | "ADMIN"; role?: "USER" | "ADMIN";
objid?: string; objid?: string;