From 789909991a8990de6eb099319f8e47f829ee6b45 Mon Sep 17 00:00:00 2001 From: chpark Date: Thu, 14 May 2026 16:17:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(orders):=20USER=20=EC=B8=A1=20=ED=92=88?= =?UTF-8?q?=EB=AA=A9=20=EC=B6=94=EA=B0=80=20+=20=ED=83=9D=EB=B0=B0/?= =?UTF-8?q?=EC=9A=A9=EC=B0=A8=20=EC=A7=81=EC=A0=91=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — 키워드 검색 + 행별 수량 입력 → 일괄 추가 --- src/app/(main)/m/orders/page.tsx | 188 +++++++++++++++++++++-- src/app/api/m/orders/items/add/route.ts | 160 +++++++++++++++++++ src/app/api/m/orders/lines/save/route.ts | 11 +- 3 files changed, 341 insertions(+), 18 deletions(-) create mode 100644 src/app/api/m/orders/items/add/route.ts diff --git a/src/app/(main)/m/orders/page.tsx b/src/app/(main)/m/orders/page.tsx index 9e31650..c3fe866 100644 --- a/src/app/(main)/m/orders/page.tsx +++ b/src/app/(main)/m/orders/page.tsx @@ -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 }: { {editable && ( -

- ✏️ {order.STATUS === "REQUESTED" ? "출고요청" : "출고완료"} 상태 — 입금완료 전까지 품목 수량 수정 · [×]로 삭제 가능. 저장은 자동. -

+ <> +

+ ✏️ {order.STATUS === "REQUESTED" ? "출고요청" : "출고완료"} 상태 — 입금완료 전까지 품목 수량/추가/삭제, 택배·용차 라인 직접 수정 가능. 저장은 자동. +

+
+ + + +
+ )} @@ -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 ( @@ -366,9 +428,15 @@ function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: { + - @@ -377,7 +445,9 @@ function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: { )} @@ -401,10 +471,108 @@ function DetailModal({ order, items, supplier, onClose, onCancel, onReload }: { {editable && (
- ※ {order.STATUS === "REQUESTED" ? "출고요청" : "출고완료"} 상태 — 입금완료 전까지 품목 수량 수정 · 품목 삭제 · 주문 취소 가능합니다. 택배/용차 라인은 모모 담당자가 조정합니다. + ※ {order.STATUS === "REQUESTED" ? "출고요청" : "출고완료"} 상태 — 입금완료 전까지 품목 추가/수정/삭제, 택배·용차 라인 직접 수정, 주문 취소 가능합니다.
)} + + {pickerOpen && ( + setPickerOpen(false)} + onConfirm={addItems} + /> + )} + + ); +} + +function ItemPickerModal({ 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" /> +
+
+
{idx + 1} {canEditItem ? updateItemLine(it.OBJID, q)} /> - : fmt(it.QTY)} + : canEditExtra + ? 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)} + + {canEditExtra + ? 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)} {fmt(it.UNIT_PRICE)} {fmt(it.SUPPLY_AMOUNT)} {it.IS_TAX_FREE === "Y" ? "-" : fmt(it.VAT_AMOUNT)} {fmt(it.TOTAL_AMOUNT)} {canEditItem ? - : 자동} + : canEditExtra + ? + : -}
+ + + + + + + + + + {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" /> +
+ +
+ + +
+ ); } diff --git a/src/app/api/m/orders/items/add/route.ts b/src/app/api/m/orders/items/add/route.ts new file mode 100644 index 0000000..eb5ef9b --- /dev/null +++ b/src/app/api/m/orders/items/add/route.ts @@ -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(); + } +} diff --git a/src/app/api/m/orders/lines/save/route.ts b/src/app/api/m/orders/lines/save/route.ts index 5ced23b..411275e 100644 --- a/src/app/api/m/orders/lines/save/route.ts +++ b/src/app/api/m/orders/lines/save/route.ts @@ -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)