feat(orders): USER 측 품목 추가 + 택배/용차 직접 수정
Deploy momo-erp / deploy (push) Successful in 2m5s

USER 가 본인의 출고요청/출고완료 발주에 직접:
- 신규 품목 추가 (피커 모달 — /api/m/items/list 재고 있는 품목 검색)
- 택배비 단가/수량 인라인 수정 + 라인 삭제
- 용차 단가/수량 인라인 수정 + 라인 삭제

신규 API:
- /api/m/orders/items/add — ITEM 추가 (재고/숨김/한도 검증, admin 우회)
- /api/m/orders/lines/save — 가드 풀어서 REQUESTED + APPROVED 둘 다 허용

UI:
- detail modal 상단 액션 바: '+ 품목 추가' / '택배 추가/+1' / '용차 추가/+1'
- 표 안의 택배/용차 행에 수량/단가 QtyInput → onBlur 자동저장
- ItemPickerModal — 키워드 검색 + 행별 수량 입력 → 일괄 추가
This commit is contained in:
chpark
2026-05-14 16:17:19 +09:00
parent d25db4a023
commit 789909991a
3 changed files with 341 additions and 18 deletions
+178 -10
View File
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useEffect, useState, useRef, useMemo } from "react";
import Link from "next/link";
import { Download, Image as ImageIcon, X, Eye, Trash2 } from "lucide-react";
import { Download, Image as ImageIcon, X, Eye, Trash2, Plus, Truck, Package } from "lucide-react";
import Swal from "sweetalert2";
import { downloadXlsx } from "@/lib/xlsx-export";
import { captureAndShare as captureAndShareLib } from "@/lib/capture-share";
@@ -248,6 +248,51 @@ function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: {
else Swal.fire({ icon: "error", title: "삭제 실패", text: j.message });
};
// 택배/용차 라인 추가/수정 — /api/m/orders/lines/save
const upsertExtra = async (line: { objid?: string; kind: "DELIVERY" | "CHARTER"; label?: string; unitPrice: number; qty: number }) => {
const res = await fetch("/api/m/orders/lines/save", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderObjid: order.OBJID, lines: [line] }),
});
const j = await res.json();
if (j.success) onReload();
else Swal.fire({ icon: "error", title: "택배/용차 저장 실패", text: j.message });
};
const deleteExtra = async (lineObjid: string, kind: "DELIVERY" | "CHARTER") => {
const ok = await Swal.fire({ icon: "question", title: "이 라인을 삭제하시겠습니까?", showCancelButton: true, confirmButtonText: "삭제", cancelButtonText: "취소", confirmButtonColor: "#dc2626" });
if (!ok.isConfirmed) return;
const res = await fetch("/api/m/orders/lines/save", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderObjid: order.OBJID, lines: [{ objid: lineObjid, kind, delete: true, unitPrice: 0, qty: 1 }] }),
});
const j = await res.json();
if (j.success) onReload();
else Swal.fire({ icon: "error", title: "삭제 실패", text: j.message });
};
const addNewExtra = (kind: "DELIVERY" | "CHARTER") => {
const existing = items.find((it) => it.KIND === kind);
if (existing) {
upsertExtra({ objid: existing.OBJID, kind, label: existing.EXTRA_LABEL || (kind === "DELIVERY" ? "택배비" : "용차비"),
unitPrice: Number(existing.UNIT_PRICE), qty: Number(existing.QTY) + 1 });
return;
}
upsertExtra({ kind, label: kind === "DELIVERY" ? "택배비" : "용차비",
unitPrice: kind === "DELIVERY" ? 4000 : 5000, qty: 1 });
};
// 품목 추가 (피커 모달)
const [pickerOpen, setPickerOpen] = useState(false);
const addItems = 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(); }
else Swal.fire({ icon: "error", title: "품목 추가 실패", text: j.message });
};
const user = useAuthStore((s) => s.user);
// 이미지 공유 버튼은 관리자(admin) 한해서만 노출. 일반 사용자(거래처) 화면에서는 숨김.
const showImageShare = user?.isAdmin === true || user?.role === "ADMIN";
@@ -328,9 +373,25 @@ function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: {
</div>
{editable && (
<p className="mt-3 text-[11px] text-amber-700 bg-amber-50 border border-amber-200 rounded p-2">
{order.STATUS === "REQUESTED" ? "출고요청" : "출고완료"} · [×] . .
</p>
<>
<p className="mt-3 text-[11px] text-amber-700 bg-amber-50 border border-amber-200 rounded p-2">
{order.STATUS === "REQUESTED" ? "출고요청" : "출고완료"} //, · . .
</p>
<div className="mt-2 flex flex-wrap gap-1.5 items-center js-no-export">
<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">
<Plus size={12} />
</button>
<button type="button" onClick={() => addNewExtra("DELIVERY")}
className="inline-flex items-center gap-1 h-7 px-2.5 rounded bg-orange-100 text-orange-800 text-[11px] font-bold hover:bg-orange-200">
<Truck size={12} /> /+1
</button>
<button type="button" onClick={() => addNewExtra("CHARTER")}
className="inline-flex items-center gap-1 h-7 px-2.5 rounded bg-sky-100 text-sky-800 text-[11px] font-bold hover:bg-sky-200">
<Package size={12} /> /+1
</button>
</div>
</>
)}
<table className="w-full text-[11px] border border-slate-300 mt-2">
<thead className="bg-slate-100">
@@ -352,7 +413,8 @@ function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: {
const isExtra = it.KIND === "DELIVERY" || it.KIND === "CHARTER";
const kindBadge = it.KIND === "DELIVERY" ? "택배" : it.KIND === "CHARTER" ? "용차" : null;
const kindBg = it.KIND === "DELIVERY" ? "bg-orange-50" : it.KIND === "CHARTER" ? "bg-sky-50" : "";
const canEditItem = editable && !isExtra; // ITEM 라인만 본인이 수정 가능
const canEditItem = editable && !isExtra;
const canEditExtra = editable && isExtra;
return (
<tr key={it.OBJID || idx} className={kindBg}>
<td className="border border-slate-300 px-1.5 py-1 text-center">{idx + 1}</td>
@@ -366,9 +428,15 @@ function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: {
<td className="border border-slate-300 px-1 py-0.5 text-right">
{canEditItem
? <QtyInput initial={Number(it.QTY)} onSave={(q) => updateItemLine(it.OBJID, q)} />
: fmt(it.QTY)}
: canEditExtra
? <QtyInput initial={Number(it.QTY)} onSave={(q) => upsertExtra({ objid: it.OBJID, kind: it.KIND as "DELIVERY"|"CHARTER", label: it.EXTRA_LABEL || it.ITEM_NAME, unitPrice: Number(it.UNIT_PRICE), qty: q })} />
: fmt(it.QTY)}
</td>
<td className="border border-slate-300 px-1 py-0.5 text-right">
{canEditExtra
? <QtyInput initial={Number(it.UNIT_PRICE)} onSave={(p) => upsertExtra({ objid: it.OBJID, kind: it.KIND as "DELIVERY"|"CHARTER", label: it.EXTRA_LABEL || it.ITEM_NAME, unitPrice: p, qty: Number(it.QTY) })} />
: fmt(it.UNIT_PRICE)}
</td>
<td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.UNIT_PRICE)}</td>
<td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.SUPPLY_AMOUNT)}</td>
<td className="border border-slate-300 px-1.5 py-1 text-right">{it.IS_TAX_FREE === "Y" ? "-" : fmt(it.VAT_AMOUNT)}</td>
<td className="border border-slate-300 px-1.5 py-1 text-right font-semibold">{fmt(it.TOTAL_AMOUNT)}</td>
@@ -377,7 +445,9 @@ function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: {
<td className="border border-slate-300 px-1 py-1 text-center">
{canEditItem
? <button onClick={() => deleteItemLine(it.OBJID)} className="text-rose-500 hover:text-rose-700" title="삭제"><X size={12} /></button>
: <span className="text-slate-300 text-[10px]"></span>}
: canEditExtra
? <button onClick={() => deleteExtra(it.OBJID, it.KIND as "DELIVERY"|"CHARTER")} className="text-rose-500 hover:text-rose-700" title="삭제"><X size={12} /></button>
: <span className="text-slate-300 text-[10px]">-</span>}
</td>
)}
</tr>
@@ -401,10 +471,108 @@ function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: {
{editable && (
<div className="mt-4 pt-3 border-t border-slate-200 text-[11px] text-amber-700 bg-amber-50 rounded p-2">
{order.STATUS === "REQUESTED" ? "출고요청" : "출고완료"} <b> </b> · <b> </b> · <b> </b> . / .
{order.STATUS === "REQUESTED" ? "출고요청" : "출고완료"} <b>//</b>, · <b> </b>, <b> </b> .
</div>
)}
</div>
{pickerOpen && (
<ItemPickerModal
onClose={() => setPickerOpen(false)}
onConfirm={addItems}
/>
)}
</div>
);
}
function ItemPickerModal({ 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>
);
}
+160
View File
@@ -0,0 +1,160 @@
// 발주에 ITEM 라인 추가 — 본인 또는 admin, REQUESTED/APPROVED 상태.
// 재고/숨김/한도 검증 후 momo_order_items INSERT + 합계 재집계.
import { NextRequest, NextResponse } from "next/server";
import { pool } from "@/lib/db";
import { createObjectId } from "@/lib/utils";
import { requireMomoUser } from "@/lib/momo-guard";
import { calcLine } from "@/lib/momo-pricing";
interface AddItem { itemObjid: string; qty: number }
export async function POST(req: NextRequest) {
const r = await requireMomoUser();
if (r instanceof NextResponse) return r;
const body = await req.json().catch(() => ({}));
const { orderObjid, items } = body as { orderObjid?: string; items?: AddItem[] };
if (!orderObjid || !Array.isArray(items) || items.length === 0) {
return NextResponse.json({ success: false, message: "잘못된 요청입니다." }, { status: 400 });
}
for (const it of items) {
if (!it.itemObjid || !Number.isFinite(Number(it.qty)) || Number(it.qty) <= 0) {
return NextResponse.json({ success: false, message: "품목/수량 형식이 올바르지 않습니다." }, { status: 400 });
}
}
const isAdmin = r.user.isAdmin || r.user.role === "ADMIN" || r.user.userType === "A";
const client = await pool.connect();
try {
await client.query("BEGIN");
const orderRes = await client.query(
`SELECT customer_objid, status FROM momo_orders WHERE objid = $1 FOR UPDATE`,
[orderObjid]
);
if (orderRes.rowCount === 0) {
await client.query("ROLLBACK");
return NextResponse.json({ success: false, message: "발주를 찾을 수 없습니다." }, { status: 404 });
}
const order = orderRes.rows[0];
const userOwn = r.user.objid ?? r.user.userId;
if (!isAdmin && order.customer_objid !== userOwn && order.customer_objid !== r.user.userId) {
await client.query("ROLLBACK");
return NextResponse.json({ success: false, message: "권한이 없습니다." }, { status: 403 });
}
if (order.status !== "REQUESTED" && order.status !== "APPROVED") {
await client.query("ROLLBACK");
return NextResponse.json({ success: false, message: "입금완료 이후 발주는 품목 추가 불가." }, { status: 400 });
}
// 품목 정보 조회 (재고/단가 등)
const itemIds = items.map((i) => i.itemObjid);
const placeholders = itemIds.map((_, i) => `$${i + 1}`).join(",");
const itemsRes = await client.query(
`SELECT
I.objid, I.item_name, I.unit_price, I.is_tax_free, I.max_order_qty,
COALESCE(I.is_hidden, 'N') AS is_hidden,
COALESCE((
SELECT SUM(S.qty) FROM momo_stocks S
JOIN momo_warehouses W ON S.wh_objid = W.objid
WHERE S.item_objid = I.objid AND COALESCE(W.is_del,'N') != 'Y'
), 0) AS stock_qty
FROM momo_items I
WHERE I.objid IN (${placeholders}) AND COALESCE(I.is_del, 'N') != 'Y'`,
itemIds
);
const itemMap = new Map(itemsRes.rows.map((row) => [row.objid as string, row]));
// 검증 (admin 은 재고/한도 우회)
if (!isAdmin) {
// 사용자별 권한
const u = await client.query(
`SELECT COALESCE(unlimited_qty,'N') AS u, COALESCE(view_hidden,'N') AS v FROM user_info WHERE user_id = $1`,
[order.customer_objid]
);
const unlimited = u.rows[0]?.u === "Y";
const viewHidden = u.rows[0]?.v === "Y";
for (const ln of items) {
const it = itemMap.get(ln.itemObjid);
if (!it) {
await client.query("ROLLBACK");
return NextResponse.json({ success: false, message: `존재하지 않는 품목: ${ln.itemObjid}` }, { status: 400 });
}
if (it.is_hidden === "Y" && !viewHidden) {
await client.query("ROLLBACK");
return NextResponse.json({ success: false, message: `${it.item_name} 은 발주 불가 품목입니다.` }, { status: 400 });
}
const stock = Number(it.stock_qty ?? 0);
if (Number(ln.qty) > stock) {
await client.query("ROLLBACK");
return NextResponse.json({ success: false, message: `${it.item_name} — 재고(${stock}) 초과` }, { status: 400 });
}
if (!unlimited) {
const maxQ = it.max_order_qty == null ? 0 : Number(it.max_order_qty);
if (maxQ > 0 && Number(ln.qty) > maxQ) {
await client.query("ROLLBACK");
return NextResponse.json({ success: false, message: `${it.item_name} — 1회 발주 한도(${maxQ}) 초과` }, { status: 400 });
}
}
}
}
// 다음 seq
const seqRes = await client.query(
`SELECT COALESCE(MAX(seq), 0) AS m FROM momo_order_items WHERE order_objid = $1`,
[orderObjid]
);
let nextSeq = Number(seqRes.rows[0]?.m ?? 0) + 1;
for (const ln of items) {
const it = itemMap.get(ln.itemObjid);
if (!it) continue;
const unitPrice = Number(it.unit_price);
const qty = Number(ln.qty);
const isFree = it.is_tax_free === "Y";
const calc = calcLine({ unitPrice, qty, isTaxFree: isFree });
await client.query(
`INSERT INTO momo_order_items (
objid, order_objid, item_objid, item_name_snap, unit_price, qty,
is_tax_free, supply_amount, vat_amount, total_amount, seq, kind
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'ITEM')`,
[createObjectId(), orderObjid, ln.itemObjid, it.item_name, unitPrice, qty,
isFree ? "Y" : "N", calc.supplyAmount, calc.vatAmount, calc.totalAmount, nextSeq]
);
nextSeq++;
}
// 합계 재집계
const sumRes = await client.query(
`SELECT
COALESCE(SUM(supply_amount), 0) AS supply,
COALESCE(SUM(vat_amount), 0) AS vat,
COALESCE(SUM(total_amount), 0) AS total,
COALESCE(SUM(CASE WHEN is_tax_free='Y' THEN supply_amount ELSE 0 END), 0) AS taxfree,
COALESCE(SUM(CASE WHEN is_tax_free='N' THEN supply_amount ELSE 0 END), 0) AS taxable,
COALESCE(SUM(CASE WHEN kind='DELIVERY' THEN total_amount ELSE 0 END), 0) AS delivery,
COALESCE(SUM(CASE WHEN kind='CHARTER' THEN total_amount ELSE 0 END), 0) AS charter
FROM momo_order_items WHERE order_objid = $1`,
[orderObjid]
);
const t = sumRes.rows[0];
await client.query(
`UPDATE momo_orders SET
total_supply=$2, total_vat=$3, total_amount=$4,
total_taxfree=$5, total_taxable=$6,
total_delivery=$7, total_charter=$8,
update_date=NOW(), update_id=$9
WHERE objid = $1`,
[orderObjid, t.supply, t.vat, t.total, t.taxfree, t.taxable, t.delivery, t.charter,
r.user.objid || r.user.userId]
);
await client.query("COMMIT");
return NextResponse.json({ success: true });
} catch (err) {
await client.query("ROLLBACK");
console.error("[orders/items/add]", err);
return NextResponse.json({ success: false, message: err instanceof Error ? err.message : "오류" }, { status: 500 });
} finally {
client.release();
}
}
+3 -8
View File
@@ -46,15 +46,10 @@ export async function POST(req: NextRequest) {
await client.query("ROLLBACK");
return NextResponse.json({ success: false, message: "권한이 없습니다." }, { status: 403 });
}
// USER: REQUESTED 만, ADMIN: 입금완료(PAID)/취소 전까지 모두
if (isAdmin) {
if (order.status === "PAID" || order.status === "CANCELED") {
await client.query("ROLLBACK");
return NextResponse.json({ success: false, message: "입금완료 또는 취소된 발주는 수정할 수 없습니다." }, { status: 400 });
}
} else if (order.status !== "REQUESTED") {
// USER: REQUESTED + APPROVED (입금완료 전), ADMIN: 동일
if (order.status !== "REQUESTED" && order.status !== "APPROVED") {
await client.query("ROLLBACK");
return NextResponse.json({ success: false, message: "출고 요청 상태에서만 수정할 수 습니다." }, { status: 400 });
return NextResponse.json({ success: false, message: "입금완료 이후 또는 취소된 발주는 수정할 수 습니다." }, { status: 400 });
}
// 다음 seq (현재 최대 + 1)