feat(orders): admin 수기 발주 작성 — 거래처 대신 명의로 등록
Deploy momo-erp / deploy (push) Successful in 2m26s

전화 요청 등 시 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 도 해당 거래처 기준)
This commit is contained in:
chpark
2026-05-14 16:10:05 +09:00
parent 8d8bb17345
commit d25db4a023
3 changed files with 127 additions and 19 deletions
+72 -8
View File
@@ -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() {
. [] .
</p>
</div>
<button
onClick={bulkShip}
disabled={busy || requestedSelectedIds.length === 0}
className="h-9 px-4 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-40 disabled:cursor-not-allowed inline-flex items-center gap-1.5 shadow-sm"
>
<Truck size={14} /> {requestedSelectedIds.length > 0 ? ` (${requestedSelectedIds.length})` : ""}
</button>
<div className="flex items-center gap-2">
<ManualOrderButton />
<button
onClick={bulkShip}
disabled={busy || requestedSelectedIds.length === 0}
className="h-9 px-4 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-40 disabled:cursor-not-allowed inline-flex items-center gap-1.5 shadow-sm"
>
<Truck size={14} /> {requestedSelectedIds.length > 0 ? ` (${requestedSelectedIds.length})` : ""}
</button>
</div>
</div>
{/* 검색바 — 모바일에선 한 줄에 핵심만, 데스크탑에선 여유 있게 */}
@@ -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 (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="h-9 px-3 rounded-lg bg-white border border-amber-300 text-amber-700 text-sm font-bold hover:bg-amber-50 inline-flex items-center gap-1.5"
title="전화 요청 등 수기로 거래처 대신 발주 작성"
>
<PhoneCall size={14} />
</button>
{open && (
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-3" onClick={() => setOpen(false)}>
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-5" onClick={(e) => e.stopPropagation()}>
<h3 className="font-bold text-lg mb-3"> </h3>
<p className="text-xs text-slate-500 mb-4"> . , .</p>
<SearchableSelect
value={selected}
onChange={setSelected}
options={customers.map((c) => ({ value: c.USER_ID, label: `${c.USER_NAME} (${c.USER_ID})` }))}
placeholder="거래처 검색/선택"
/>
<div className="flex gap-2 justify-end mt-5">
<button type="button" onClick={() => setOpen(false)}
className="h-10 px-4 rounded-lg border border-slate-200 text-sm font-semibold"></button>
<button type="button" onClick={onProceed}
className="h-10 px-5 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800">
</button>
</div>
</div>
</div>
)}
</>
);
}
+43 -6
View File
@@ -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 (
<Suspense fallback={<div className="p-8 text-center text-slate-400"> ...</div>}>
<ItemsBrowse />
</Suspense>
);
}
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<Item[]>([]);
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() {
<p className="text-slate-500 text-xs sm:text-sm mt-1"> [ ] .</p>
</div>
{isAdmin && onBehalfOfCustomer && (
<div className="border border-amber-300 bg-amber-50 rounded-lg p-3 inline-flex items-center gap-2 text-sm">
<PhoneCall size={16} className="text-amber-700" />
<span> <b className="text-amber-800">{onBehalfName || onBehalfOfCustomer}</b> </span>
<button type="button" onClick={() => router.push("/m/admin/orders")}
className="ml-2 text-xs text-amber-700 underline"></button>
</div>
)}
<div className="flex gap-2 items-center flex-wrap">
<div className="relative flex-1 min-w-[160px]">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
+12 -5
View File
@@ -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 });
}