feat(header,procurement): 관리자 토글 가드 강화 + 매입발주 발주지사(HQ/KIMPO) 셀렉트
Deploy momo-erp / deploy (push) Successful in 4m27s
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:
@@ -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">
|
||||||
|
|||||||
@@ -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 } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user