feat(orders): admin 출고관리 인라인 수기 발주 + 품목 추가 + 거래처 변경
Deploy momo-erp / deploy (push) Successful in 2m19s

매입 발주서 작성 패턴처럼 출고관리 안에서 직접 빈 발주 → 거래처 → 품목 채워가는 흐름.

신규 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
This commit is contained in:
chpark
2026-05-14 22:07:45 +09:00
parent 527cfddc1b
commit fac0f0d83e
3 changed files with 304 additions and 54 deletions
+199 -54
View File
@@ -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() {
</p>
</div>
<div className="flex items-center gap-2">
<ManualOrderButton />
<ManualOrderButton onCreated={async (newObjid) => { await load(); setActiveId(newObjid); }} />
<button
onClick={bulkShip}
disabled={busy || requestedSelectedIds.length === 0}
@@ -521,6 +521,20 @@ function StatementPreview({
: { unitPrice: 5000, qty: 1, label: "용차" };
upsertExtra({ kind, ...defaults });
};
// ITEM 라인 추가 (피커 모달)
const [pickerOpen, setPickerOpen] = useState(false);
const addItemLines = async (selected: { itemObjid: string; qty: number }[]) => {
if (selected.length === 0) return;
const res = await fetch("/api/m/orders/items/add", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderObjid: order.OBJID, items: selected }),
});
const j = await res.json();
if (j.success) { setPickerOpen(false); onReload(); onReloadList(); }
else Swal.fire({ icon: "error", title: "품목 추가 실패", text: j.message });
};
return (
<div className="text-[12px] text-slate-800 space-y-3">
{/* 공유/캡처/엑셀/출고 버튼 — 캡처 영역 밖에 배치 */}
@@ -593,13 +607,19 @@ function StatementPreview({
</div>
<div className="border border-slate-200 rounded p-2 bg-slate-50/60 mt-3">
<div className="font-semibold text-slate-900">{order.COMPANY_NAME} <span className="text-slate-500 font-normal"></span></div>
<div className="text-[11px] text-slate-600 mt-0.5 leading-relaxed">
{order.CEO_NAME && <>: {order.CEO_NAME} · </>}
{order.BIZ_NO && <>: {order.BIZ_NO} · </>}
{order.PHONE && <>: {order.PHONE}</>}
{order.ADDRESS && <div>: {order.ADDRESS}</div>}
</div>
{editable ? (
<CustomerEditor order={order} onReload={onReload} onReloadList={onReloadList} />
) : (
<>
<div className="font-semibold text-slate-900">{order.COMPANY_NAME} <span className="text-slate-500 font-normal"></span></div>
<div className="text-[11px] text-slate-600 mt-0.5 leading-relaxed">
{order.CEO_NAME && <>: {order.CEO_NAME} · </>}
{order.BIZ_NO && <>: {order.BIZ_NO} · </>}
{order.PHONE && <>: {order.PHONE}</>}
{order.ADDRESS && <div>: {order.ADDRESS}</div>}
</div>
</>
)}
</div>
{lowStock.length > 0 && (
@@ -618,7 +638,14 @@ function StatementPreview({
{editable && (
<div className="flex flex-wrap items-center gap-2 js-no-export">
<span className="text-[11px] text-slate-500">/ :</span>
<span className="text-[11px] text-slate-500"> :</span>
<button
type="button"
onClick={() => setPickerOpen(true)}
className="inline-flex items-center gap-1 h-7 px-2.5 rounded bg-emerald-100 text-emerald-800 text-[11px] font-bold hover:bg-emerald-200"
>
+
</button>
<button
type="button"
onClick={() => addNewExtra("DELIVERY")}
@@ -746,6 +773,104 @@ function StatementPreview({
<IssueEinvoiceButton order={order} />
</div>
)}
{pickerOpen && (
<AdminItemPickerModal
onClose={() => setPickerOpen(false)}
onConfirm={addItemLines}
/>
)}
</div>
);
}
function AdminItemPickerModal({ onClose, onConfirm }: {
onClose: () => void;
onConfirm: (selected: { itemObjid: string; qty: number }[]) => void;
}) {
const [items, setItems] = useState<Array<{ OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT_PRICE: number; STOCK_QTY: number; IS_TAX_FREE: string; UNIT: string }>>([]);
const [keyword, setKeyword] = useState("");
const [cart, setCart] = useState<Record<string, number>>({});
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 (
<div className="fixed inset-0 bg-slate-900/60 z-[60] flex items-center justify-center p-3" onClick={onClose}>
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full max-h-[85vh] overflow-hidden flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200">
<h3 className="font-bold"> </h3>
<button onClick={onClose} className="text-slate-400 hover:text-slate-700"><X size={18} /></button>
</div>
<div className="px-4 py-2 border-b border-slate-200">
<input value={keyword} onChange={(e) => setKeyword(e.target.value)}
placeholder="품목명/코드 검색"
className="w-full h-9 px-3 rounded border border-slate-300 text-sm" />
</div>
<div className="flex-1 overflow-y-auto">
<table className="w-full text-xs">
<thead className="bg-slate-50 text-slate-600 sticky top-0">
<tr>
<th className="text-left p-2"></th>
<th className="text-right p-2 w-16"></th>
<th className="text-right p-2 w-20"></th>
<th className="text-center p-2 w-20"></th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr><td colSpan={4} className="text-center py-8 text-slate-400"> </td></tr>
) : filtered.slice(0, 100).map((it) => (
<tr key={it.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
<td className="p-2">
<div className="font-semibold">{it.ITEM_NAME}</div>
<div className="text-[10px] text-slate-400">{it.ITEM_CODE} · {it.IS_TAX_FREE === "Y" ? "면세" : "과세"}</div>
</td>
<td className="p-2 text-right tabular-nums">{Number(it.STOCK_QTY).toLocaleString()}</td>
<td className="p-2 text-right tabular-nums">{Number(it.UNIT_PRICE).toLocaleString()}</td>
<td className="p-2 text-center">
<input type="number" min={0} max={Number(it.STOCK_QTY)}
value={cart[it.OBJID] ?? 0}
onChange={(e) => {
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" />
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-end gap-2 px-4 py-3 border-t border-slate-200">
<button onClick={onClose} className="h-9 px-4 rounded border border-slate-200 text-sm font-semibold"></button>
<button onClick={confirm}
disabled={Object.values(cart).every((q) => !q)}
className="h-9 px-5 rounded bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-40">
</button>
</div>
</div>
</div>
);
}
@@ -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 (
<>
<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="전화 요청 등 수기로 거래처 대신 발주 작성"
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] text-slate-500 font-semibold"></span>
<select
value={order.CUSTOMER_OBJID ?? ""}
onChange={(e) => change(e.target.value)}
disabled={busy}
className="h-7 px-2 rounded border border-slate-300 text-[11px] bg-white max-w-[280px]"
>
<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>
<option value="">-- --</option>
{customers.map((c) => (
<option key={c.USER_ID} value={c.USER_ID}>{c.USER_NAME} ({c.USER_ID})</option>
))}
</select>
<span className="text-slate-500 font-normal"></span>
{order.COMPANY_NAME && (
<span className="text-[10px] text-slate-400 ml-1">
{order.CEO_NAME && <> {order.CEO_NAME} · </>}
{order.PHONE && <>{order.PHONE}</>}
</span>
)}
</>
</div>
);
}
// 수기 발주 — 클릭하면 빈 발주 즉시 생성 → 자동 활성화. 거래처/품목은 detail 에서 채움.
function ManualOrderButton({ onCreated }: { onCreated: (newObjid: string) => void }) {
const [busy, setBusy] = useState(false);
const onClick = async () => {
setBusy(true);
try {
const res = await fetch("/api/m/orders/create-empty", { method: "POST" });
const j = await res.json();
if (j.success) onCreated(j.objId);
else Swal.fire({ icon: "error", title: "생성 실패", text: j.message });
} finally { setBusy(false); }
};
return (
<button
type="button"
onClick={onClick}
disabled={busy}
className="h-9 px-3 rounded-lg bg-white border border-amber-300 text-amber-700 text-sm font-bold hover:bg-amber-50 disabled:opacity-50 inline-flex items-center gap-1.5"
title="빈 발주를 생성하여 오른쪽에서 거래처/품목 채워가는 흐름"
>
<PhoneCall size={14} />
</button>
);
}
@@ -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<string> {
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");
}
@@ -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 });
}