From d25db4a023e00eb7a63bfbd056ba6c4d17e262bc Mon Sep 17 00:00:00 2001 From: chpark Date: Thu, 14 May 2026 16:10:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(orders):=20admin=20=EC=88=98=EA=B8=B0=20?= =?UTF-8?q?=EB=B0=9C=EC=A3=BC=20=EC=9E=91=EC=84=B1=20=E2=80=94=20=EA=B1=B0?= =?UTF-8?q?=EB=9E=98=EC=B2=98=20=EB=8C=80=EC=8B=A0=20=EB=AA=85=EC=9D=98?= =?UTF-8?q?=EB=A1=9C=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전화 요청 등 시 admin 이 거래처를 대신해 발주를 작성할 수 있도록. - /m/admin/orders 헤더에 '수기 발주' 버튼 + SearchableSelect 거래처 picker → 선택 후 /m/orders/new?customerObjid=momoNNN 로 이동 - /m/orders/new 가 query param customerObjid 받음: · admin 일 때만 활성 (USER 가 query 박아도 무시) · 상단 배너에 거래처명 표시 + 취소 링크 · save 호출 시 body 에 customerObjid 포함 - /api/m/orders/save: admin 이 body.customerObjid 명시하면 그걸로 발주 INSERT (supplier_branch snapshot 도 해당 거래처 기준) --- src/app/(main)/m/admin/orders/page.tsx | 80 +++++++++++++++++++++++--- src/app/(main)/m/orders/new/page.tsx | 49 ++++++++++++++-- src/app/api/m/orders/save/route.ts | 17 ++++-- 3 files changed, 127 insertions(+), 19 deletions(-) diff --git a/src/app/(main)/m/admin/orders/page.tsx b/src/app/(main)/m/admin/orders/page.tsx index 1febf14..8e55241 100644 --- a/src/app/(main)/m/admin/orders/page.tsx +++ b/src/app/(main)/m/admin/orders/page.tsx @@ -1,9 +1,11 @@ "use client"; import { useEffect, useMemo, useState, useCallback, useRef } from "react"; -import { Check, Download, X, RefreshCcw, Truck, AlertCircle, Package } from "lucide-react"; +import { Check, Download, X, RefreshCcw, Truck, AlertCircle, Package, PhoneCall } from "lucide-react"; import Swal from "sweetalert2"; import { captureAndShare } from "@/lib/capture-share"; +import { useRouter } from "next/navigation"; +import { SearchableSelect } from "@/components/ui/searchable-select"; interface Order { OBJID: string; ORDER_NO: string; ORDER_DATE: string; @@ -246,13 +248,16 @@ export default function AdminOrdersPage() { 발주를 선택하면 거래명세표가 미리보기로 표시됩니다. 체크박스로 다중 선택 후 [출고]를 누르면 일괄 처리됩니다.

- +
+ + +
{/* 검색바 — 모바일에선 한 줄에 핵심만, 데스크탑에선 여유 있게 */} @@ -935,3 +940,62 @@ function QtyInput({ initial, onSave }: { initial: number; onSave: (q: number) => /> ); } + +// 수기 발주 작성 — admin 이 전화 요청 등을 받아 거래처 대신 발주 등록 +function ManualOrderButton() { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [customers, setCustomers] = useState<{ USER_ID: string; USER_NAME: string }[]>([]); + const [selected, setSelected] = useState(""); + + useEffect(() => { + if (!open || customers.length > 0) return; + fetch("/api/m/customers/list", { + method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", + }) + .then((r) => r.json()) + .then((j) => setCustomers(j.RESULTLIST ?? [])) + .catch(() => {}); + }, [open, customers.length]); + + const onProceed = () => { + if (!selected) { Swal.fire({ icon: "warning", title: "거래처를 선택하세요." }); return; } + setOpen(false); + router.push(`/m/orders/new?customerObjid=${encodeURIComponent(selected)}`); + }; + + return ( + <> + + {open && ( +
setOpen(false)}> +
e.stopPropagation()}> +

수기 발주 — 거래처 선택

+

전화 요청 등 거래처를 대신해 발주를 작성합니다. 선택한 거래처 명의로 발주가 등록되며, 그 거래처의 기준 명세표가 적용됩니다.

+ ({ value: c.USER_ID, label: `${c.USER_NAME} (${c.USER_ID})` }))} + placeholder="거래처 검색/선택" + /> +
+ + +
+
+
+ )} + + ); +} diff --git a/src/app/(main)/m/orders/new/page.tsx b/src/app/(main)/m/orders/new/page.tsx index d2b652b..37cce5b 100644 --- a/src/app/(main)/m/orders/new/page.tsx +++ b/src/app/(main)/m/orders/new/page.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useState, useMemo, useCallback } from "react"; -import { useRouter } from "next/navigation"; -import { Search, ShoppingCart, Plus, Minus, X, Truck, Package, LayoutGrid, List as ListIcon } from "lucide-react"; +import { useEffect, useState, useMemo, useCallback, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Search, ShoppingCart, Plus, Minus, X, Truck, Package, LayoutGrid, List as ListIcon, PhoneCall } from "lucide-react"; import Swal from "sweetalert2"; interface Item { @@ -29,8 +29,20 @@ const DEFAULT_DELIVERY_PRICE = 4000; const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR"); const newKey = () => Math.random().toString(36).slice(2, 10) + Date.now().toString(36); -export default function ItemsBrowse() { +export default function ItemsBrowsePage() { + return ( + 로딩 중...}> + + + ); +} + +function ItemsBrowse() { const router = useRouter(); + const params = useSearchParams(); + // admin 이 수기 발주 작성 시 URL ?customerObjid=momoNNN 로 거래처 명시 + const onBehalfOfCustomer = params.get("customerObjid") || ""; + const [onBehalfName, setOnBehalfName] = useState(""); const [items, setItems] = useState([]); const [keyword, setKeyword] = useState(""); const [taxFilter, setTaxFilter] = useState<"" | "Y" | "N">(""); @@ -42,12 +54,26 @@ export default function ItemsBrowse() { const [viewMode, setViewMode] = useState<"card" | "list">("card"); // 현재 사용자의 발주 한도 우회 권한 (관리자 또는 unlimited_qty='Y' 거래처) const [unlimitedQty, setUnlimitedQty] = useState(false); + const [isAdmin, setIsAdmin] = useState(false); + + // 수기 발주 모드일 때 거래처 이름 표시용 조회 + useEffect(() => { + if (!onBehalfOfCustomer) return; + fetch("/api/m/customers/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }) + .then((r) => r.json()) + .then((j) => { + const c = (j.RESULTLIST ?? []).find((x: { USER_ID: string; USER_NAME: string }) => x.USER_ID === onBehalfOfCustomer); + if (c) setOnBehalfName(c.USER_NAME); + }) + .catch(() => {}); + }, [onBehalfOfCustomer]); useEffect(() => { fetch("/api/auth/me").then((r) => r.json()).then((d) => { if (d?.user) { - const isAdmin = d.user.role === "ADMIN" || d.user.isAdmin === true || d.user.userType === "A"; - setUnlimitedQty(isAdmin || !!d.user.unlimitedQty); + const adm = d.user.role === "ADMIN" || d.user.isAdmin === true || d.user.userType === "A"; + setIsAdmin(adm); + setUnlimitedQty(adm || !!d.user.unlimitedQty); } }).catch(() => {}); }, []); @@ -237,6 +263,8 @@ export default function ItemsBrowse() { const res = await fetch("/api/m/orders/save", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ + // admin 의 수기 발주 — 선택한 거래처 명의로 저장 + customerObjid: isAdmin && onBehalfOfCustomer ? onBehalfOfCustomer : undefined, lines: cart.map((c) => ({ itemObjid: c.item.OBJID, qty: c.qty })), extras: extras.map((e) => ({ kind: e.kind, @@ -435,6 +463,15 @@ export default function ItemsBrowse() {

현재 재고가 있는 품목을 선택해 상단 장바구니에 담고 [발주 요청] 버튼으로 전송하세요.

+ {isAdmin && onBehalfOfCustomer && ( +
+ + 수기 발주 — 거래처 {onBehalfName || onBehalfOfCustomer} 명의로 저장됩니다 + +
+ )} +
diff --git a/src/app/api/m/orders/save/route.ts b/src/app/api/m/orders/save/route.ts index df7e7f2..0538e08 100644 --- a/src/app/api/m/orders/save/route.ts +++ b/src/app/api/m/orders/save/route.ts @@ -23,19 +23,26 @@ interface InputExtraLine { export async function POST(req: NextRequest) { const r = await requireMomoUser(); if (r instanceof NextResponse) return r; - const customerObjid = r.user.objid || r.user.userId; - if (!customerObjid) { - return NextResponse.json({ success: false, message: "사용자 식별자를 확인할 수 없습니다." }, { status: 400 }); - } + const isAdmin = r.user.isAdmin === true || r.user.role === "ADMIN" || r.user.userType === "A"; let lines: InputItemLine[]; let extras: InputExtraLine[]; let memo: string | undefined; + let customerObjid: string; try { - const body = await req.json() as { lines: InputItemLine[]; extras?: InputExtraLine[]; memo?: string }; + const body = await req.json() as { lines: InputItemLine[]; extras?: InputExtraLine[]; memo?: string; customerObjid?: string }; lines = body.lines; extras = Array.isArray(body.extras) ? body.extras : []; memo = body.memo; + // admin 만 customerObjid 명시 가능 (수기 발주 작성). USER 는 본인 ID 자동. + if (isAdmin && body.customerObjid) { + customerObjid = body.customerObjid; + } else { + customerObjid = r.user.objid || r.user.userId; + } + if (!customerObjid) { + return NextResponse.json({ success: false, message: "사용자 식별자를 확인할 수 없습니다." }, { status: 400 }); + } } catch { return NextResponse.json({ success: false, message: "요청 본문을 해석할 수 없습니다." }, { status: 400 }); }