From a06a5d551e8320393692791e98f0232f1c1d563b Mon Sep 17 00:00:00 2001 From: chpark Date: Sat, 23 May 2026 01:36:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(header,procurement):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=ED=86=A0=EA=B8=80=20=EA=B0=80=EB=93=9C=20=EA=B0=95?= =?UTF-8?q?=ED=99=94=20+=20=EB=A7=A4=EC=9E=85=EB=B0=9C=EC=A3=BC=20?= =?UTF-8?q?=EB=B0=9C=EC=A3=BC=EC=A7=80=EC=82=AC(HQ/KIMPO)=20=EC=85=80?= =?UTF-8?q?=EB=A0=89=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - header: 메뉴 변환 버튼은 authority_master "관리자" 권한그룹 멤버만 노출. user_type='A' 만으로는 부족 (실무자 다수에 부여돼 있음). /api/auth/me 가 isMasterAdmin 플래그 반환. - procurements: 발주서에 발주지사 셀렉트 추가 (기준 명세표 마스터 사용 — HQ/KIMPO 등). 통계/계산서 발행 시 지사별 집계 가능. --- src/app/(main)/m/admin/procurements/page.tsx | 41 +++++++++++++++++-- src/app/api/auth/me/route.ts | 25 ++++++++++- src/app/api/m/procurements/detail/route.ts | 19 ++++++++- .../api/m/procurements/update-header/route.ts | 19 ++++++++- src/components/layout/header.tsx | 3 +- src/types/index.ts | 3 ++ 6 files changed, 101 insertions(+), 9 deletions(-) diff --git a/src/app/(main)/m/admin/procurements/page.tsx b/src/app/(main)/m/admin/procurements/page.tsx index c633275..0e92249 100644 --- a/src/app/(main)/m/admin/procurements/page.tsx +++ b/src/app/(main)/m/admin/procurements/page.tsx @@ -19,7 +19,9 @@ interface ProcDetail { DELIVERY_PERIOD?: string; PAYMENT_TERMS?: string; FREIGHT_TERMS?: string; + BRANCH?: string; } +interface StatementBranch { CODE: string; NAME: string; IS_DEFAULT: string; SORT_ORDER: number } interface ProcLine { OBJID: string; ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT: string; QTY: number; COST_PRICE: number; TOTAL_AMOUNT: number; @@ -59,6 +61,7 @@ export default function ProcurementsPage() { const [activeId, setActiveId] = useState(""); const [detail, setDetail] = useState<{ proc: ProcDetail; items: ProcLine[] } | null>(null); const [vendors, setVendors] = useState([]); + const [branches, setBranches] = useState([]); const [busy, setBusy] = 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: "{}" }); 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 () => { if (!activeId) { setDetail(null); return; } @@ -94,7 +101,7 @@ export default function ProcurementsPage() { if (j.success) setDetail({ proc: j.proc, items: j.items }); }, [activeId]); - useEffect(() => { loadVendors(); }, []); + useEffect(() => { loadVendors(); loadBranches(); }, []); useEffect(() => { load(); }, [load]); 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) => { if (!detail) return; const res = await fetch("/api/m/procurements/update-header", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -302,7 +309,9 @@ export default function ProcurementsPage() { updateHeader({ vendorObjid: v || null })} + onSetBranch={(b) => updateHeader({ branch: b })} onSetMemo={(m) => updateHeader({ memo: m })} onSetTerm={(field, val) => updateHeader({ [field]: val })} 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[] }; vendors: Vendor[]; + branches: StatementBranch[]; onSetVendor: (id: string) => void; + onSetBranch: (code: string) => void; onSetMemo: (m: string) => void; onSetTerm: (field: "deliveryPlace" | "deliveryPeriod" | "paymentTerms" | "freightTerms", val: string) => void; onAddPicker: () => void; @@ -406,7 +417,7 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o 분류번호 - 매입발주 + 매입발주 발주서번호 @@ -416,6 +427,28 @@ function ProcurementForm({ detail, vendors, onSetVendor, onSetMemo, onSetTerm, o 발주일 {detail.proc.PROC_DATE} + + 발주지사 + + {editable ? ( + + ) : ( + + {branches.find((b) => b.CODE === (detail.proc.BRANCH ?? "HQ"))?.NAME ?? (detail.proc.BRANCH ?? "본사")} 발주 + + )} + + 공급업체 diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 0458356..567482b 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { getSession } from "@/lib/session"; import { queryOne } from "@/lib/db"; +import { SUPER_ADMIN } from "@/lib/constants"; export async function GET() { const user = await getSession(); @@ -10,6 +11,28 @@ export async function GET() { // 특수 권한 (발주한도 무시 / 숨김품목 보기) + 기본 용차비 설정 같이 내려줌. // default_charter_* 컬럼이 운영DB 에 아직 없을 수 있어 try-catch 로 안전 처리. 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 { const row = await queryOne<{ U: string; V: string; CU: string; CP: string }>( `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" }; } catch { /* ignore */ } } - return NextResponse.json({ success: true, user: { ...user, ...perms } }); + return NextResponse.json({ success: true, user: { ...user, ...perms, isMasterAdmin } }); } diff --git a/src/app/api/m/procurements/detail/route.ts b/src/app/api/m/procurements/detail/route.ts index 9add0c2..e940c77 100644 --- a/src/app/api/m/procurements/detail/route.ts +++ b/src/app/api/m/procurements/detail/route.ts @@ -1,10 +1,26 @@ 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"; +// 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) { const g = await requireMomoAdmin(); if (g instanceof NextResponse) return g; + await ensureBranchCol(); const { objid } = await req.json(); const proc = await queryOne( `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.payment_terms AS "PAYMENT_TERMS", P.freight_terms AS "FREIGHT_TERMS", + COALESCE(P.branch, 'HQ') AS "BRANCH", V.objid AS "VENDOR_OBJID", V.supply_name AS "VENDOR_NAME" FROM momo_procurements P LEFT JOIN supply_mng V ON P.vendor_objid = V.objid::text diff --git a/src/app/api/m/procurements/update-header/route.ts b/src/app/api/m/procurements/update-header/route.ts index cd72ba9..a4f26a4 100644 --- a/src/app/api/m/procurements/update-header/route.ts +++ b/src/app/api/m/procurements/update-header/route.ts @@ -1,16 +1,30 @@ -// 매입 발주서 헤더 (공급업체/메모) 수정 — OPEN/REQUESTED 상태만 +// 매입 발주서 헤더 (공급업체/메모/지사) 수정 — OPEN/REQUESTED 상태만 import { NextRequest, NextResponse } from "next/server"; import { pool } from "@/lib/db"; 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) { const g = await requireMomoAdmin(); if (g instanceof NextResponse) return g; + await ensureBranchCol(); 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; deliveryPlace?: string; deliveryPeriod?: string; paymentTerms?: string; freightTerms?: string; + branch?: string; }; 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 (paymentTerms !== undefined) { sets.push(`payment_terms = $${i++}`); params.push(paymentTerms); } 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 }); await pool.query(`UPDATE momo_procurements SET ${sets.join(", ")} WHERE objid = $1`, params); diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index 480c4e7..954cea6 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -10,7 +10,8 @@ export function Header() { const router = useRouter(); const { user, logout } = useAuthStore(); 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(() => { fetchTopMenus(); diff --git a/src/types/index.ts b/src/types/index.ts index b532dd8..d52cc1c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -17,6 +17,9 @@ export interface User { authName: string; partnerCd: string; isAdmin: boolean; + // 관리자 메뉴 토글 노출 가드 — 관리자 권한그룹(authority_master) 멤버일 때만 true + // /api/auth/me 에서 채워짐. user_type='A' 같은 광범위 플래그와 분리해서 사용. + isMasterAdmin?: boolean; // MOMO 추가 필드 (선택 — FITO 사용자에게는 비어있음) role?: "USER" | "ADMIN"; objid?: string;