From fac0f0d83e0949a7d7ec8a0bef744546319b54b9 Mon Sep 17 00:00:00 2001 From: chpark Date: Thu, 14 May 2026 22:07:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(orders):=20admin=20=EC=B6=9C=EA=B3=A0?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=9D=B8=EB=9D=BC=EC=9D=B8=20=EC=88=98?= =?UTF-8?q?=EA=B8=B0=20=EB=B0=9C=EC=A3=BC=20+=20=ED=92=88=EB=AA=A9=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20+=20=EA=B1=B0=EB=9E=98=EC=B2=98=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 매입 발주서 작성 패턴처럼 출고관리 안에서 직접 빈 발주 → 거래처 → 품목 채워가는 흐름. 신규 API: - /api/m/orders/create-empty (admin) — 빈 발주 INSERT · status='REQUESTED', customer 임시 admin, HQ 기본 supplier snapshot - /api/m/orders/update-customer (admin) — 발주의 거래처 변경 · 변경 시 새 거래처 statement_branch 기반 supplier snapshot 재계산 · REQUESTED/APPROVED 만 변경 허용 (입금 후 잠금) UI (/m/admin/orders): - '수기 발주' 버튼 → 즉시 create-empty 호출 → 리스트 새로고침 + 새 row 자동 활성화 (모달/redirect 제거) - detail 의 거래명세서 안 '귀하' 줄 → editable 시 CustomerEditor (select) - 액션바에 '+ 품목 추가' 버튼 → AdminItemPickerModal (재고 있는 품목 검색) · items/add API 호출, ITEM 라인 일괄 INSERT --- src/app/(main)/m/admin/orders/page.tsx | 253 ++++++++++++++---- src/app/api/m/orders/create-empty/route.ts | 60 +++++ src/app/api/m/orders/update-customer/route.ts | 45 ++++ 3 files changed, 304 insertions(+), 54 deletions(-) create mode 100644 src/app/api/m/orders/create-empty/route.ts create mode 100644 src/app/api/m/orders/update-customer/route.ts diff --git a/src/app/(main)/m/admin/orders/page.tsx b/src/app/(main)/m/admin/orders/page.tsx index 8e55241..64484a5 100644 --- a/src/app/(main)/m/admin/orders/page.tsx +++ b/src/app/(main)/m/admin/orders/page.tsx @@ -4,8 +4,7 @@ import { useEffect, useMemo, useState, useCallback, useRef } from "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"; +// SearchableSelect/useRouter 는 이전 수기 발주 모달용 — 단순 인라인 흐름으로 변경되어 제거 interface Order { OBJID: string; ORDER_NO: string; ORDER_DATE: string; @@ -16,6 +15,7 @@ interface Order { interface DetailOrder extends Order { CEO_NAME?: string; BIZ_NO?: string; PHONE?: string; ADDRESS?: string; MEMO?: string; APPROVE_DATE?: string; + CUSTOMER_OBJID?: string; } interface DetailLine { OBJID: string; @@ -249,7 +249,7 @@ export default function AdminOrdersPage() {

- + { await load(); setActiveId(newObjid); }} />
)} + + {pickerOpen && ( + setPickerOpen(false)} + onConfirm={addItemLines} + /> + )} + + ); +} + +function AdminItemPickerModal({ onClose, onConfirm }: { + onClose: () => void; + onConfirm: (selected: { itemObjid: string; qty: number }[]) => void; +}) { + const [items, setItems] = useState>([]); + const [keyword, setKeyword] = useState(""); + const [cart, setCart] = useState>({}); + + useEffect(() => { + fetch("/api/m/items/list", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stockFilter: "AVAILABLE" }), + }) + .then((r) => r.json()) + .then((j) => setItems(j.RESULTLIST ?? [])) + .catch(() => {}); + }, []); + + const filtered = useMemo(() => { + const kw = keyword.trim().toLowerCase(); + if (!kw) return items; + return items.filter((it) => it.ITEM_NAME?.toLowerCase().includes(kw) || it.ITEM_CODE?.toLowerCase().includes(kw)); + }, [items, keyword]); + + const confirm = () => { + const selected = Object.entries(cart) + .filter(([, q]) => q > 0) + .map(([itemObjid, qty]) => ({ itemObjid, qty })); + onConfirm(selected); + }; + + return ( +
+
e.stopPropagation()}> +
+

품목 추가

+ +
+
+ setKeyword(e.target.value)} + placeholder="품목명/코드 검색" + className="w-full h-9 px-3 rounded border border-slate-300 text-sm" /> +
+
+ + + + + + + + + + + {filtered.length === 0 ? ( + + ) : filtered.slice(0, 100).map((it) => ( + + + + + + + ))} + +
품목재고단가수량
검색 결과 없음
+
{it.ITEM_NAME}
+
{it.ITEM_CODE} · {it.IS_TAX_FREE === "Y" ? "면세" : "과세"}
+
{Number(it.STOCK_QTY).toLocaleString()}{Number(it.UNIT_PRICE).toLocaleString()} + { + const v = Math.min(Number(it.STOCK_QTY), Math.max(0, Number(e.target.value) || 0)); + setCart((p) => ({ ...p, [it.OBJID]: v })); + }} + className="w-16 h-7 px-1 border border-slate-200 rounded text-right tabular-nums" /> +
+
+
+ + +
+
); } @@ -942,60 +1067,80 @@ function QtyInput({ initial, onSave }: { initial: number; onSave: (q: number) => } // 수기 발주 작성 — admin 이 전화 요청 등을 받아 거래처 대신 발주 등록 -function ManualOrderButton() { - const router = useRouter(); - const [open, setOpen] = useState(false); +// 거래처 변경 — admin 이 출고관리 detail 안에서 거래처 직접 select +function CustomerEditor({ order, onReload, onReloadList }: { + order: DetailOrder; onReload: () => void; onReloadList: () => void; +}) { const [customers, setCustomers] = useState<{ USER_ID: string; USER_NAME: string }[]>([]); - const [selected, setSelected] = useState(""); + const [busy, setBusy] = useState(false); useEffect(() => { - if (!open || customers.length > 0) return; - fetch("/api/m/customers/list", { - method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", - }) + 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)}`); + const change = async (newId: string) => { + if (!newId || newId === order.CUSTOMER_OBJID) return; + setBusy(true); + try { + const res = await fetch("/api/m/orders/update-customer", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ objid: order.OBJID, customerObjid: newId }), + }); + const j = await res.json(); + if (j.success) { onReload(); onReloadList(); } + else Swal.fire({ icon: "error", title: "거래처 변경 실패", text: j.message }); + } finally { setBusy(false); } }; return ( - <> - ); } diff --git a/src/app/api/m/orders/create-empty/route.ts b/src/app/api/m/orders/create-empty/route.ts new file mode 100644 index 0000000..4065373 --- /dev/null +++ b/src/app/api/m/orders/create-empty/route.ts @@ -0,0 +1,60 @@ +// 빈 발주 생성 — admin 수기 작성용. 즉시 출고관리 화면 안에서 거래처/품목 채워가는 흐름. +// status='REQUESTED', customer 는 admin 본인으로 임시 박음. detail 에서 변경 가능. +import { NextResponse } from "next/server"; +import { pool, queryOne } from "@/lib/db"; +import { createObjectId } from "@/lib/utils"; +import { requireMomoAdmin } from "@/lib/momo-guard"; +import { getSupplierByBranch } from "@/lib/momo-branches"; + +export async function POST() { + const g = await requireMomoAdmin(); + if (g instanceof NextResponse) return g; + const adminId = g.user.objid || g.user.userId; + + const orderObjid = createObjectId(); + const orderNo = await genOrderNo(); + + // 기본 supplier — admin 본인 사용자(또는 HQ) 의 기준명세표 snapshot + // customer 가 바뀌면 별도 API (update-customer) 가 supplier 재계산 + const supplier = await getSupplierByBranch("HQ"); + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query( + `INSERT INTO momo_orders ( + objid, order_no, customer_objid, order_date, status, + total_supply, total_vat, total_amount, total_taxfree, total_taxable, + total_delivery, total_charter, memo, regdate, regid, + supplier_branch, supplier_name, supplier_ceo, supplier_bank_account, + supplier_phone, supplier_email, supplier_biz_no, supplier_address + ) VALUES ($1, $2, $3, CURRENT_DATE, 'REQUESTED', + 0, 0, 0, 0, 0, 0, 0, NULL, NOW(), $3, + $4, $5, $6, $7, $8, $9, $10, $11)`, + [orderObjid, orderNo, adminId, + supplier.CODE, supplier.NAME, supplier.CEO, supplier.BANK_ACCOUNT, + supplier.PHONE, supplier.EMAIL, supplier.BIZ_NO, supplier.ADDRESS] + ); + await client.query("COMMIT"); + return NextResponse.json({ success: true, objId: orderObjid, orderNo }); + } catch (err) { + await client.query("ROLLBACK"); + console.error("[orders/create-empty]", err); + return NextResponse.json({ success: false, message: err instanceof Error ? err.message : "오류" }, { status: 500 }); + } finally { + client.release(); + } +} + +async function genOrderNo(): Promise { + const today = new Date(); + const ymd = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, "0")}${String(today.getDate()).padStart(2, "0")}`; + const prefix = `ORD-${ymd}-`; + const row = await queryOne<{ MAX_NO: string }>( + `SELECT COALESCE(MAX(order_no), '') AS "MAX_NO" FROM momo_orders WHERE order_no LIKE $1 || '%'`, + [prefix] + ); + const last = row?.MAX_NO ?? ""; + const lastNum = last ? Number(last.replace(prefix, "")) || 0 : 0; + return prefix + String(lastNum + 1).padStart(4, "0"); +} diff --git a/src/app/api/m/orders/update-customer/route.ts b/src/app/api/m/orders/update-customer/route.ts new file mode 100644 index 0000000..da511c5 --- /dev/null +++ b/src/app/api/m/orders/update-customer/route.ts @@ -0,0 +1,45 @@ +// 발주의 거래처 변경 — admin 전용, REQUESTED 또는 APPROVED 상태만. +// 변경 시 supplier_branch snapshot 도 새 거래처의 기준 명세표로 재계산. +import { NextRequest, NextResponse } from "next/server"; +import { pool, queryOne } from "@/lib/db"; +import { requireMomoAdmin } from "@/lib/momo-guard"; +import { getSupplierByBranch } from "@/lib/momo-branches"; + +export async function POST(req: NextRequest) { + const g = await requireMomoAdmin(); + if (g instanceof NextResponse) return g; + + const { objid, customerObjid } = await req.json() as { objid?: string; customerObjid?: string }; + if (!objid || !customerObjid) { + return NextResponse.json({ success: false, message: "필수 항목 누락" }, { status: 400 }); + } + + // 거래처 존재 + statement_branch 조회 + const cust = await queryOne<{ statement_branch: string | null }>( + `SELECT COALESCE(statement_branch, 'HQ') AS statement_branch FROM user_info WHERE user_id = $1`, + [customerObjid] + ); + if (!cust) { + return NextResponse.json({ success: false, message: "거래처를 찾을 수 없습니다." }, { status: 404 }); + } + const supplier = await getSupplierByBranch(cust.statement_branch ?? "HQ"); + + const cur = await pool.query(`SELECT status FROM momo_orders WHERE objid = $1`, [objid]); + if (cur.rowCount === 0) return NextResponse.json({ success: false, message: "발주 없음" }, { status: 404 }); + if (!["REQUESTED", "APPROVED"].includes(cur.rows[0].status)) { + return NextResponse.json({ success: false, message: "입금 전 발주만 거래처 변경 가능합니다." }, { status: 400 }); + } + + await pool.query( + `UPDATE momo_orders SET + customer_objid = $2, + supplier_branch = $3, supplier_name = $4, supplier_ceo = $5, supplier_bank_account = $6, + supplier_phone = $7, supplier_email = $8, supplier_biz_no = $9, supplier_address = $10, + update_date = NOW() + WHERE objid = $1`, + [objid, customerObjid, + supplier.CODE, supplier.NAME, supplier.CEO, supplier.BANK_ACCOUNT, + supplier.PHONE, supplier.EMAIL, supplier.BIZ_NO, supplier.ADDRESS] + ); + return NextResponse.json({ success: true }); +}