[새 API: /api/m/orders/items/update] - 본인 또는 관리자가 자기 발주의 품목(ITEM) 라인 수량 변경 또는 삭제 - REQUESTED 상태에서만 허용. 단가는 변경 불가 (momo_items.unit_price 기준 자동 재계산) - 재고 / max_order_qty 한도 자동 검증 (unlimited_qty 권한이면 한도 우회) - 트랜잭션으로 라인 수정 + momo_orders 합계 7종 자동 재집계 [/m/orders 거래명세표 모달 UI] - 출고요청 상태 거래처 본인 화면에서 품목 라인 직접 편집: · 수량 인풋 (블러 시 자동 저장) · 행 끝의 [×] 버튼으로 그 품목만 삭제 - 택배/용차 라인은 인풋 안 보이고 "자동" 표시 — 모모 담당자가 조정 - 저장/삭제 후 onReload 로 모달 + 리스트 동시 갱신 - 안내 배너: "수량 수정 / 품목 삭제 / 주문 취소" 모두 가능 명시 [매뉴얼] - 가-2 내 발주 이력 섹션에 수량 수정 / 품목 삭제 / 주문 취소 사용법 추가 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+8
-1
@@ -382,7 +382,14 @@
|
||||
<ul>
|
||||
<li><b>주문번호 또는 [보기] 클릭</b> → 거래명세표가 큰 창으로 떠요</li>
|
||||
<li>거래명세표 위쪽에 <span class="btn btn-orange">📤 이미지 공유</span> <span class="btn btn-emerald">⬇ 엑셀 다운로드</span> 버튼이 있어 카톡 공유나 파일 저장 가능</li>
|
||||
<li><b>출고요청 상태</b>인 주문은 <span class="btn btn-rose">🗑 주문 취소</span> 버튼이 떠 있어요. 수량 수정이 필요하면 취소 후 새로 작성하세요.</li>
|
||||
<li><b>출고요청 상태</b>인 주문은 그 자리에서 <b>수정·삭제·취소</b>가 가능해요:
|
||||
<ul>
|
||||
<li>품목 행의 <b>수량 칸을 클릭</b>해서 새 수량 입력 → 다른 곳 누르면 자동 저장</li>
|
||||
<li>품목 오른쪽 <b>[×]</b> 버튼 → 그 품목만 주문에서 삭제</li>
|
||||
<li>위쪽 <span class="btn btn-rose">🗑 주문 취소</span> → 주문 전체 취소</li>
|
||||
<li>택배·용차 라인은 모모 담당자가 조정합니다 (직접 수정 불가)</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<h4>주문 상태별 뜻</h4>
|
||||
<table>
|
||||
|
||||
@@ -176,22 +176,53 @@ export default function MyOrdersPage() {
|
||||
supplier={detail.supplier}
|
||||
onClose={() => setDetail(null)}
|
||||
onCancel={() => cancelOrder(detail.order)}
|
||||
onReload={async () => {
|
||||
const res = await fetch("/api/m/orders/detail", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objid: detail.order.OBJID }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) setDetail({ order: j.order, items: j.items, supplier: j.supplier });
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailModal({ order, items, supplier, onClose, onCancel }: {
|
||||
function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: {
|
||||
order: Order & { CEO_NAME?: string; BIZ_NO?: string; PHONE?: string; ADDRESS?: string; EMAIL?: string };
|
||||
items: DetailLine[];
|
||||
supplier: Supplier;
|
||||
onClose: () => void;
|
||||
onCancel: () => void;
|
||||
onReload: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const editable = order.STATUS === "REQUESTED";
|
||||
|
||||
const updateItemLine = async (lineObjid: string, qty: number) => {
|
||||
const res = await fetch("/api/m/orders/items/update", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ orderObjid: order.OBJID, lines: [{ objid: lineObjid, qty }] }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) onReload();
|
||||
else Swal.fire({ icon: "error", title: "수정 실패", text: j.message });
|
||||
};
|
||||
const deleteItemLine = async (lineObjid: string) => {
|
||||
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/items/update", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ orderObjid: order.OBJID, lines: [{ objid: lineObjid, delete: true }] }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) onReload();
|
||||
else Swal.fire({ icon: "error", title: "삭제 실패", text: j.message });
|
||||
};
|
||||
|
||||
const captureAndShare = async () => {
|
||||
try {
|
||||
const mod = await import("html-to-image");
|
||||
@@ -278,18 +309,24 @@ function DetailModal({ order, items, supplier, onClose, onCancel }: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="w-full text-[11px] border border-slate-300 mt-3">
|
||||
{editable && (
|
||||
<p className="mt-3 text-[11px] text-amber-700 bg-amber-50 border border-amber-200 rounded p-2">
|
||||
✏️ 출고요청 상태 — 품목 수량을 직접 고치거나 [×]로 삭제할 수 있어요. 저장은 자동.
|
||||
</p>
|
||||
)}
|
||||
<table className="w-full text-[11px] border border-slate-300 mt-2">
|
||||
<thead className="bg-slate-100">
|
||||
<tr>
|
||||
<th className="border border-slate-300 px-1.5 py-1.5 w-8">#</th>
|
||||
<th className="border border-slate-300 px-1.5 py-1.5 text-left">품명</th>
|
||||
<th className="border border-slate-300 px-1.5 py-1.5 w-12">구분</th>
|
||||
<th className="border border-slate-300 px-1.5 py-1.5 w-14">수량</th>
|
||||
<th className="border border-slate-300 px-1.5 py-1.5 w-16">수량</th>
|
||||
<th className="border border-slate-300 px-1.5 py-1.5 w-20">단가</th>
|
||||
<th className="border border-slate-300 px-1.5 py-1.5">공급가</th>
|
||||
<th className="border border-slate-300 px-1.5 py-1.5">세액</th>
|
||||
<th className="border border-slate-300 px-1.5 py-1.5">합계</th>
|
||||
<th className="border border-slate-300 px-1.5 py-1.5 w-24">비고</th>
|
||||
{editable && <th className="border border-slate-300 px-1 py-1.5 w-8"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="tabular-nums">
|
||||
@@ -297,6 +334,7 @@ function DetailModal({ order, items, supplier, onClose, onCancel }: {
|
||||
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 라인만 본인이 수정 가능
|
||||
return (
|
||||
<tr key={it.OBJID || idx} className={kindBg}>
|
||||
<td className="border border-slate-300 px-1.5 py-1 text-center">{idx + 1}</td>
|
||||
@@ -307,12 +345,23 @@ function DetailModal({ order, items, supplier, onClose, onCancel }: {
|
||||
<td className={`border border-slate-300 px-1.5 py-1 text-center ${it.IS_TAX_FREE === "Y" ? "text-violet-700" : "text-rose-700"}`}>
|
||||
{it.IS_TAX_FREE === "Y" ? "면세" : "과세"}
|
||||
</td>
|
||||
<td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.QTY)}</td>
|
||||
<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)}
|
||||
</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>
|
||||
<td className="border border-slate-300 px-1.5 py-1 text-[10px] text-slate-600">{it.REMARK || ""}</td>
|
||||
{editable && (
|
||||
<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>}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -334,10 +383,26 @@ function DetailModal({ order, items, supplier, onClose, onCancel }: {
|
||||
|
||||
{editable && (
|
||||
<div className="mt-4 pt-3 border-t border-slate-200 text-[11px] text-amber-700 bg-amber-50 rounded p-2">
|
||||
※ 출고요청 상태이므로 [주문 취소] 가능합니다. 수량 수정·재요청은 주문 취소 후 [새 발주]로 다시 작성하세요.
|
||||
※ 출고요청 상태 — 품목 <b>수량 수정</b> · <b>품목 삭제</b> · <b>주문 취소</b> 모두 가능합니다. 택배/용차 라인은 모모 담당자가 조정합니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QtyInput({ initial, onSave }: { initial: number; onSave: (qty: number) => void }) {
|
||||
const [v, setV] = useState(initial);
|
||||
useEffect(() => { setV(initial); }, [initial]);
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={v}
|
||||
onChange={(e) => setV(Number(e.target.value))}
|
||||
onBlur={() => { if (v !== initial && v > 0) onSave(v); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
||||
className="w-full h-6 px-1 border border-slate-300 rounded text-[11px] text-right tabular-nums bg-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// 발주 품목(ITEM) 라인 수량 변경/삭제 — 본인 또는 관리자, REQUESTED 상태에서만.
|
||||
// 단가는 변경 불가 (momo_items.unit_price 기준 그대로).
|
||||
// 변경 후 momo_orders 합계 자동 재집계.
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { pool } from "@/lib/db";
|
||||
import { requireMomoUser } from "@/lib/momo-guard";
|
||||
import { calcLine } from "@/lib/momo-pricing";
|
||||
|
||||
interface LineUpdate {
|
||||
objid: string; // 라인 OBJID
|
||||
qty?: number; // 새 수량 (undefined 면 변경 안 함)
|
||||
delete?: boolean; // true 면 삭제
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const r = await requireMomoUser();
|
||||
if (r instanceof NextResponse) return r;
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { orderObjid, lines } = body as { orderObjid?: string; lines?: LineUpdate[] };
|
||||
if (!orderObjid || !Array.isArray(lines) || lines.length === 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];
|
||||
if (!isAdmin && order.customer_objid !== r.user.objid) {
|
||||
await client.query("ROLLBACK");
|
||||
return NextResponse.json({ success: false, message: "권한이 없습니다." }, { status: 403 });
|
||||
}
|
||||
if (order.status !== "REQUESTED") {
|
||||
await client.query("ROLLBACK");
|
||||
return NextResponse.json({ success: false, message: "출고요청 상태에서만 수정할 수 있습니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
for (const ln of lines) {
|
||||
// 라인 조회 + ITEM 종류 검증
|
||||
const lineRes = await client.query(
|
||||
`SELECT OI.objid, OI.item_objid, OI.unit_price, OI.qty, OI.is_tax_free,
|
||||
COALESCE(OI.kind,'ITEM') AS kind,
|
||||
I.item_name, I.max_order_qty,
|
||||
COALESCE((
|
||||
SELECT SUM(S.qty) FROM momo_stocks S
|
||||
JOIN momo_warehouses W ON S.wh_objid = W.objid
|
||||
WHERE S.item_objid = OI.item_objid AND COALESCE(W.is_del,'N') != 'Y'
|
||||
), 0) AS stock_qty
|
||||
FROM momo_order_items OI
|
||||
LEFT JOIN momo_items I ON I.objid = OI.item_objid
|
||||
WHERE OI.objid = $1 AND OI.order_objid = $2`,
|
||||
[ln.objid, orderObjid]
|
||||
);
|
||||
if (lineRes.rowCount === 0) continue;
|
||||
const cur = lineRes.rows[0];
|
||||
if (cur.kind !== "ITEM") {
|
||||
await client.query("ROLLBACK");
|
||||
return NextResponse.json({ success: false, message: "택배/용차 라인은 이 화면에서 수정할 수 없습니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (ln.delete) {
|
||||
await client.query(`DELETE FROM momo_order_items WHERE objid = $1`, [ln.objid]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newQty = Number(ln.qty);
|
||||
if (!Number.isFinite(newQty) || newQty <= 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return NextResponse.json({ success: false, message: "수량은 1 이상이어야 합니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
// 재고 / 한도 검증 (관리자가 아니고 unlimited_qty 권한 없으면)
|
||||
if (!isAdmin) {
|
||||
const stock = Number(cur.stock_qty);
|
||||
if (newQty > stock) {
|
||||
await client.query("ROLLBACK");
|
||||
return NextResponse.json({ success: false, message: `${cur.item_name} — 재고(${stock})를 초과할 수 없습니다.` }, { status: 400 });
|
||||
}
|
||||
const u = await client.query(
|
||||
`SELECT COALESCE(unlimited_qty,'N') AS u FROM user_info WHERE user_id = $1`,
|
||||
[r.user.objid]
|
||||
);
|
||||
const unlimited = u.rows[0]?.u === "Y";
|
||||
if (!unlimited) {
|
||||
const maxQ = cur.max_order_qty == null ? 0 : Number(cur.max_order_qty);
|
||||
if (maxQ > 0 && newQty > maxQ) {
|
||||
await client.query("ROLLBACK");
|
||||
return NextResponse.json({ success: false, message: `${cur.item_name} — 1회 발주 한도(${maxQ}) 초과` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isFree = cur.is_tax_free === "Y";
|
||||
const calc = calcLine({ unitPrice: Number(cur.unit_price), qty: newQty, isTaxFree: isFree });
|
||||
await client.query(
|
||||
`UPDATE momo_order_items SET
|
||||
qty = $2,
|
||||
supply_amount = $3,
|
||||
vat_amount = $4,
|
||||
total_amount = $5
|
||||
WHERE objid = $1`,
|
||||
[ln.objid, newQty, calc.supplyAmount, calc.vatAmount, calc.totalAmount]
|
||||
);
|
||||
}
|
||||
|
||||
// 합계 재집계
|
||||
const sum = 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 = sum.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/update]", err);
|
||||
const msg = err instanceof Error ? err.message : "수정 오류";
|
||||
return NextResponse.json({ success: false, message: msg }, { status: 500 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user