전화 요청 등 시 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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
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 Swal from "sweetalert2";
|
||||||
import { captureAndShare } from "@/lib/capture-share";
|
import { captureAndShare } from "@/lib/capture-share";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
|
|
||||||
interface Order {
|
interface Order {
|
||||||
OBJID: string; ORDER_NO: string; ORDER_DATE: string;
|
OBJID: string; ORDER_NO: string; ORDER_DATE: string;
|
||||||
@@ -246,13 +248,16 @@ export default function AdminOrdersPage() {
|
|||||||
발주를 선택하면 거래명세표가 미리보기로 표시됩니다. 체크박스로 다중 선택 후 [출고]를 누르면 일괄 처리됩니다.
|
발주를 선택하면 거래명세표가 미리보기로 표시됩니다. 체크박스로 다중 선택 후 [출고]를 누르면 일괄 처리됩니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={bulkShip}
|
<ManualOrderButton />
|
||||||
disabled={busy || requestedSelectedIds.length === 0}
|
<button
|
||||||
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"
|
onClick={bulkShip}
|
||||||
>
|
disabled={busy || requestedSelectedIds.length === 0}
|
||||||
<Truck size={14} /> 출고{requestedSelectedIds.length > 0 ? ` (${requestedSelectedIds.length})` : ""}
|
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"
|
||||||
</button>
|
>
|
||||||
|
<Truck size={14} /> 출고{requestedSelectedIds.length > 0 ? ` (${requestedSelectedIds.length})` : ""}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
import { useEffect, useState, useMemo, useCallback, Suspense } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Search, ShoppingCart, Plus, Minus, X, Truck, Package, LayoutGrid, List as ListIcon } from "lucide-react";
|
import { Search, ShoppingCart, Plus, Minus, X, Truck, Package, LayoutGrid, List as ListIcon, PhoneCall } from "lucide-react";
|
||||||
import Swal from "sweetalert2";
|
import Swal from "sweetalert2";
|
||||||
|
|
||||||
interface Item {
|
interface Item {
|
||||||
@@ -29,8 +29,20 @@ const DEFAULT_DELIVERY_PRICE = 4000;
|
|||||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||||
const newKey = () => Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
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 router = useRouter();
|
||||||
|
const params = useSearchParams();
|
||||||
|
// admin 이 수기 발주 작성 시 URL ?customerObjid=momoNNN 로 거래처 명시
|
||||||
|
const onBehalfOfCustomer = params.get("customerObjid") || "";
|
||||||
|
const [onBehalfName, setOnBehalfName] = useState("");
|
||||||
const [items, setItems] = useState<Item[]>([]);
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const [taxFilter, setTaxFilter] = useState<"" | "Y" | "N">("");
|
const [taxFilter, setTaxFilter] = useState<"" | "Y" | "N">("");
|
||||||
@@ -42,12 +54,26 @@ export default function ItemsBrowse() {
|
|||||||
const [viewMode, setViewMode] = useState<"card" | "list">("card");
|
const [viewMode, setViewMode] = useState<"card" | "list">("card");
|
||||||
// 현재 사용자의 발주 한도 우회 권한 (관리자 또는 unlimited_qty='Y' 거래처)
|
// 현재 사용자의 발주 한도 우회 권한 (관리자 또는 unlimited_qty='Y' 거래처)
|
||||||
const [unlimitedQty, setUnlimitedQty] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
fetch("/api/auth/me").then((r) => r.json()).then((d) => {
|
fetch("/api/auth/me").then((r) => r.json()).then((d) => {
|
||||||
if (d?.user) {
|
if (d?.user) {
|
||||||
const isAdmin = d.user.role === "ADMIN" || d.user.isAdmin === true || d.user.userType === "A";
|
const adm = d.user.role === "ADMIN" || d.user.isAdmin === true || d.user.userType === "A";
|
||||||
setUnlimitedQty(isAdmin || !!d.user.unlimitedQty);
|
setIsAdmin(adm);
|
||||||
|
setUnlimitedQty(adm || !!d.user.unlimitedQty);
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -237,6 +263,8 @@ export default function ItemsBrowse() {
|
|||||||
const res = await fetch("/api/m/orders/save", {
|
const res = await fetch("/api/m/orders/save", {
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
// admin 의 수기 발주 — 선택한 거래처 명의로 저장
|
||||||
|
customerObjid: isAdmin && onBehalfOfCustomer ? onBehalfOfCustomer : undefined,
|
||||||
lines: cart.map((c) => ({ itemObjid: c.item.OBJID, qty: c.qty })),
|
lines: cart.map((c) => ({ itemObjid: c.item.OBJID, qty: c.qty })),
|
||||||
extras: extras.map((e) => ({
|
extras: extras.map((e) => ({
|
||||||
kind: e.kind,
|
kind: e.kind,
|
||||||
@@ -435,6 +463,15 @@ export default function ItemsBrowse() {
|
|||||||
<p className="text-slate-500 text-xs sm:text-sm mt-1">현재 재고가 있는 품목을 선택해 상단 장바구니에 담고 [발주 요청] 버튼으로 전송하세요.</p>
|
<p className="text-slate-500 text-xs sm:text-sm mt-1">현재 재고가 있는 품목을 선택해 상단 장바구니에 담고 [발주 요청] 버튼으로 전송하세요.</p>
|
||||||
</div>
|
</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="flex gap-2 items-center flex-wrap">
|
||||||
<div className="relative flex-1 min-w-[160px]">
|
<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" />
|
<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) {
|
export async function POST(req: NextRequest) {
|
||||||
const r = await requireMomoUser();
|
const r = await requireMomoUser();
|
||||||
if (r instanceof NextResponse) return r;
|
if (r instanceof NextResponse) return r;
|
||||||
const customerObjid = r.user.objid || r.user.userId;
|
const isAdmin = r.user.isAdmin === true || r.user.role === "ADMIN" || r.user.userType === "A";
|
||||||
if (!customerObjid) {
|
|
||||||
return NextResponse.json({ success: false, message: "사용자 식별자를 확인할 수 없습니다." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
let lines: InputItemLine[];
|
let lines: InputItemLine[];
|
||||||
let extras: InputExtraLine[];
|
let extras: InputExtraLine[];
|
||||||
let memo: string | undefined;
|
let memo: string | undefined;
|
||||||
|
let customerObjid: string;
|
||||||
try {
|
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;
|
lines = body.lines;
|
||||||
extras = Array.isArray(body.extras) ? body.extras : [];
|
extras = Array.isArray(body.extras) ? body.extras : [];
|
||||||
memo = body.memo;
|
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 {
|
} catch {
|
||||||
return NextResponse.json({ success: false, message: "요청 본문을 해석할 수 없습니다." }, { status: 400 });
|
return NextResponse.json({ success: false, message: "요청 본문을 해석할 수 없습니다." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user