전화 요청 등 시 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:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user