feat(momo v0.4): 발주서 택배/용차 라인 + 택배전용 품목 자동 라인 + 모바일 반응형
Deploy momo-erp / deploy (push) Successful in 56s

[DB 010]
- momo_items.requires_delivery (택배 전용 플래그)
- momo_order_items.kind (ITEM/DELIVERY/CHARTER) + extra_label
- momo_orders.total_delivery / total_charter

[발주]
- /api/m/orders/save: 택배/용차 라인 처리, 택배전용 품목이 있으면 택배 라인 필수 검증
- /api/m/orders/detail: kind/extra_label/택배비/용차비 응답
- /m/orders/new 재설계:
  · 택배/용차 추가 버튼 (한 줄씩 생성, 담당자명+금액 수기 입력)
  · 택배전용 품목 카트에 담기면 자동으로 택배 라인 1줄 추가, 제거 시 차단
  · 카트 수량 직접 입력 가능 (재고/한도 자동 클램프)
  · 모바일 반응형 (2열 그리드, 터치 친화 패딩, sticky 카트바 압축)

[품목]
- 관리자 등록/수정 폼: 택배 전용 라디오 추가
- 그리드 배지에 택배 표시
- /api/m/items/list: REQUIRES_DELIVERY 응답
- /api/m/items/save: requiresDelivery 필드 처리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-02 00:26:20 +09:00
parent defd358e20
commit 1f9b017617
7 changed files with 400 additions and 111 deletions
+33
View File
@@ -0,0 +1,33 @@
-- 010_delivery_charter.sql
-- v0.4 (2026-04-27)
-- 발주서에 택배비/용차비 라인 + 택배 전용 품목 자동 라인 지원
BEGIN;
-- 1. momo_items: 택배 전용 플래그
ALTER TABLE momo_items
ADD COLUMN IF NOT EXISTS requires_delivery CHAR(1) NOT NULL DEFAULT 'N';
COMMENT ON COLUMN momo_items.requires_delivery
IS '택배 전용 품목 (Y) — 카트에 담기면 택배 라인이 자동으로 추가됨';
-- 2. momo_order_items: 라인 종류 + 라벨
-- kind: 'ITEM'(품목) / 'DELIVERY'(택배비) / 'CHARTER'(용차비)
ALTER TABLE momo_order_items
ADD COLUMN IF NOT EXISTS kind VARCHAR(16) NOT NULL DEFAULT 'ITEM',
ADD COLUMN IF NOT EXISTS extra_label VARCHAR(100);
COMMENT ON COLUMN momo_order_items.kind
IS 'ITEM=품목 / DELIVERY=택배비 / CHARTER=용차비';
COMMENT ON COLUMN momo_order_items.extra_label
IS '택배비/용차비 라인의 담당자명 또는 부가 메모';
-- 기존 가맹 데이터는 ITEM 으로 간주
UPDATE momo_order_items SET kind = 'ITEM' WHERE kind IS NULL;
-- 3. momo_orders: 택배비/용차비 합계 (집계 편의용)
ALTER TABLE momo_orders
ADD COLUMN IF NOT EXISTS total_delivery NUMERIC(15,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS total_charter NUMERIC(15,2) DEFAULT 0;
COMMENT ON COLUMN momo_orders.total_delivery IS '택배비 라인 합계';
COMMENT ON COLUMN momo_orders.total_charter IS '용차비 라인 합계';
COMMIT;
+26 -1
View File
@@ -21,6 +21,7 @@ interface Item {
ATTRIBUTES: Record<string, unknown> | null;
MAX_ORDER_QTY: number | null;
IS_HIDDEN: string;
REQUIRES_DELIVERY: string;
}
interface Maker { OBJID: string; MAKER_NAME: string }
@@ -80,7 +81,7 @@ export default function AdminItemsPage() {
};
const openNew = () => {
setEditing({ ITEM_NAME: "", UNIT: "EA", IS_TAX_FREE: "N", STATUS: "ACTIVE", IS_HIDDEN: "N", MAX_ORDER_QTY: null });
setEditing({ ITEM_NAME: "", UNIT: "EA", IS_TAX_FREE: "N", STATUS: "ACTIVE", IS_HIDDEN: "N", MAX_ORDER_QTY: null, REQUIRES_DELIVERY: "N" });
setAttrs({});
};
@@ -103,6 +104,7 @@ export default function AdminItemsPage() {
attributes: Object.keys(attrs).length > 0 ? attrs : null,
maxOrderQty: editing.MAX_ORDER_QTY ?? null,
isHidden: editing.IS_HIDDEN === "Y" ? "Y" : "N",
requiresDelivery: editing.REQUIRES_DELIVERY === "Y" ? "Y" : "N",
};
const res = await fetch("/api/m/items/save", {
method: "POST",
@@ -260,6 +262,9 @@ export default function AdminItemsPage() {
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-sky-100 text-sky-700 font-bold">{it.MAX_ORDER_QTY}</span>
)}
{it.REQUIRES_DELIVERY === "Y" && (
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 font-bold"></span>
)}
</td>
<td className="px-3 py-2 text-right">
<button onClick={() => openEdit(it)} className="text-slate-400 hover:text-emerald-700 p-1">
@@ -405,6 +410,26 @@ export default function AdminItemsPage() {
</label>
</div>
</Field>
<Field label="택배 전용 (담기면 자동 택배 라인 추가)">
<div className="flex gap-2 h-10">
<label className="flex-1 inline-flex items-center justify-center rounded-lg border cursor-pointer text-sm font-semibold hover:bg-slate-50">
<input
type="radio" name="requires_delivery" checked={editing.REQUIRES_DELIVERY !== "Y"}
onChange={() => setEditing({ ...editing, REQUIRES_DELIVERY: "N" })}
className="mr-1.5"
/>
</label>
<label className="flex-1 inline-flex items-center justify-center rounded-lg border cursor-pointer text-sm font-semibold hover:bg-slate-50">
<input
type="radio" name="requires_delivery" checked={editing.REQUIRES_DELIVERY === "Y"}
onChange={() => setEditing({ ...editing, REQUIRES_DELIVERY: "Y" })}
className="mr-1.5"
/>
</label>
</div>
</Field>
<div className="sm:col-span-2">
<Field label="상세 설명">
<textarea
+251 -91
View File
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import { useEffect, useState, useMemo, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Search, ShoppingCart, Plus, Minus, X } from "lucide-react";
import { Search, ShoppingCart, Plus, Minus, X, Truck, Package } from "lucide-react";
import Swal from "sweetalert2";
interface Item {
@@ -16,10 +16,15 @@ interface Item {
IS_TAX_FREE: string;
IMAGE_URL: string;
STOCK_QTY: number;
MAX_ORDER_QTY: number | null;
IS_HIDDEN: string;
REQUIRES_DELIVERY: string;
}
interface CartLine { item: Item; qty: number }
interface ExtraLine { id: string; kind: "DELIVERY" | "CHARTER"; amount: number; label: string }
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
const newKey = () => Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
export default function ItemsBrowse() {
const router = useRouter();
@@ -28,8 +33,10 @@ export default function ItemsBrowse() {
const [taxFilter, setTaxFilter] = useState<"" | "Y" | "N">("");
const [loading, setLoading] = useState(false);
const [cart, setCart] = useState<CartLine[]>([]);
const [extras, setExtras] = useState<ExtraLine[]>([]);
const [cartOpen, setCartOpen] = useState(false);
const fetchItems = async () => {
const fetchItems = useCallback(async () => {
setLoading(true);
const res = await fetch("/api/m/items/list", {
method: "POST",
@@ -39,30 +46,50 @@ export default function ItemsBrowse() {
const j = await res.json();
setItems(j.RESULTLIST ?? []);
setLoading(false);
};
}, [keyword, taxFilter]);
useEffect(() => { fetchItems(); }, []); // eslint-disable-line
// 카트에 택배전용 품목이 있는지
const cartNeedsDelivery = useMemo(
() => cart.some((c) => c.item.REQUIRES_DELIVERY === "Y"),
[cart]
);
const hasDeliveryLine = extras.some((e) => e.kind === "DELIVERY");
// 택배전용 품목이 카트에 들어왔는데 택배 라인이 없으면 자동 한 줄 추가
useEffect(() => {
fetchItems();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (cartNeedsDelivery && !hasDeliveryLine) {
setExtras((prev) => [
{ id: newKey(), kind: "DELIVERY", amount: 0, label: "택배비" },
...prev,
]);
}
}, [cartNeedsDelivery, hasDeliveryLine]);
const addToCart = (item: Item) => {
setCart((c) => {
const found = c.find((x) => x.item.OBJID === item.OBJID);
const stock = Number(item.STOCK_QTY);
const maxQ = Number(item.MAX_ORDER_QTY ?? 0);
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
if (found) {
if (found.qty + 1 > Number(item.STOCK_QTY)) {
Swal.fire({ icon: "warning", title: "재고 부족", text: `현재고는 ${fmt(item.STOCK_QTY)}개입니다.` });
if (found.qty + 1 > limit) {
Swal.fire({
icon: "warning",
title: maxQ > 0 && stock > maxQ ? "1회 발주 한도 초과" : "재고 부족",
text: `현재 ${maxQ > 0 && stock > maxQ ? `1회 한도는 ${fmt(maxQ)}` : `재고는 ${fmt(stock)}`}입니다.`,
});
return c;
}
return c.map((x) => x.item.OBJID === item.OBJID ? { ...x, qty: x.qty + 1 } : x);
}
return [...c, { item, qty: 1 }];
});
// 시각 피드백: 우측 상단 토스트
Swal.fire({
toast: true, position: "top-end", icon: "success",
title: `장바구니에 추가됨: ${item.ITEM_NAME}`,
showConfirmButton: false, timer: 1500, timerProgressBar: true,
showConfirmButton: false, timer: 1200, timerProgressBar: true,
});
};
@@ -72,16 +99,47 @@ export default function ItemsBrowse() {
if (x.item.OBJID !== objid) return x;
const newQty = x.qty + delta;
if (newQty <= 0) return x;
if (newQty > Number(x.item.STOCK_QTY)) return x;
const stock = Number(x.item.STOCK_QTY);
const maxQ = Number(x.item.MAX_ORDER_QTY ?? 0);
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
if (newQty > limit) return x;
return { ...x, qty: newQty };
})
);
};
const setQty = (objid: string, value: number) => {
setCart((c) =>
c.map((x) => {
if (x.item.OBJID !== objid) return x;
const stock = Number(x.item.STOCK_QTY);
const maxQ = Number(x.item.MAX_ORDER_QTY ?? 0);
const limit = maxQ > 0 ? Math.min(stock, maxQ) : stock;
const clamped = Math.max(1, Math.min(limit, Math.floor(value || 0)));
return { ...x, qty: clamped };
})
);
};
const removeLine = (objid: string) => setCart((c) => c.filter((x) => x.item.OBJID !== objid));
const addExtra = (kind: "DELIVERY" | "CHARTER") => {
setExtras((p) => [...p, { id: newKey(), kind, amount: 0, label: kind === "DELIVERY" ? "택배비" : "용차비" }]);
};
const updateExtra = (id: string, field: keyof ExtraLine, value: string | number) => {
setExtras((p) => p.map((e) => (e.id === id ? { ...e, [field]: value } as ExtraLine : e)));
};
const removeExtra = (id: string) => {
const target = extras.find((e) => e.id === id);
if (target?.kind === "DELIVERY" && cartNeedsDelivery) {
Swal.fire({ icon: "warning", title: "택배 전용 품목이 있어 택배 라인을 제거할 수 없습니다." });
return;
}
setExtras((p) => p.filter((e) => e.id !== id));
};
const totals = useMemo(() => {
let supply = 0, vat = 0, total = 0, taxFree = 0, taxable = 0;
let supply = 0, vat = 0, total = 0, taxFree = 0, taxable = 0, delivery = 0, charter = 0;
for (const ln of cart) {
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
total += lineTotal;
@@ -95,18 +153,36 @@ export default function ItemsBrowse() {
taxable += s;
}
}
return { supply, vat, total, taxFree, taxable };
}, [cart]);
for (const ex of extras) {
const amt = Number(ex.amount) || 0;
total += amt;
const s = Math.round(amt / 1.1);
supply += s;
vat += amt - s;
taxable += s;
if (ex.kind === "DELIVERY") delivery += amt;
if (ex.kind === "CHARTER") charter += amt;
}
return { supply, vat, total, taxFree, taxable, delivery, charter };
}, [cart, extras]);
const submitOrder = async () => {
if (cart.length === 0) {
Swal.fire({ icon: "warning", title: "발주 품목을 추가하세요." });
return;
}
if (cartNeedsDelivery && !hasDeliveryLine) {
Swal.fire({ icon: "warning", title: "택배 전용 품목이 포함되어 택배 라인이 필요합니다." });
return;
}
if (extras.some((e) => Number(e.amount) <= 0)) {
Swal.fire({ icon: "warning", title: "택배/용차 금액을 입력하세요." });
return;
}
const ok = await Swal.fire({
icon: "question",
title: "발주를 요청하시겠습니까?",
text: `합계 ₩${fmt(totals.total)} (${cart.length}개 품목)`,
text: `합계 ₩${fmt(totals.total)} (품목 ${cart.length}, 부가 ${extras.length})`,
showCancelButton: true, confirmButtonText: "발주", cancelButtonText: "취소",
confirmButtonColor: "#0f766e",
});
@@ -116,43 +192,42 @@ export default function ItemsBrowse() {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
lines: cart.map((c) => ({ itemObjid: c.item.OBJID, qty: c.qty })),
extras: extras.map((e) => ({ kind: e.kind, amount: Number(e.amount), label: e.label })),
}),
});
const j = await res.json();
if (j.success) {
await Swal.fire({ icon: "success", title: "발주 요청 완료", text: `발주번호: ${j.orderNo}` });
setCart([]);
setCart([]); setExtras([]);
router.push("/m/orders");
} else {
Swal.fire({ icon: "error", title: "오류", text: j.message });
}
};
const [cartOpen, setCartOpen] = useState(false);
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ===== 상단 sticky 카트 바 — 항상 노출, 클릭하면 내역 펼침 ===== */}
{/* ===== 상단 sticky 카트 바 ===== */}
<div className="sticky top-0 z-20 bg-white border-2 border-emerald-300 rounded-xl shadow-lg mb-3 overflow-hidden">
<button
onClick={() => setCartOpen((v) => !v)}
className="w-full flex items-center justify-between gap-3 px-4 py-2.5 hover:bg-emerald-50/40 transition"
className="w-full flex items-center justify-between gap-2 px-3 sm:px-4 py-2.5 hover:bg-emerald-50/40 transition"
>
<div className="flex items-center gap-2 font-bold text-slate-800">
<ShoppingCart size={18} className="text-emerald-700" />
<span className="px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-xs font-bold tabular-nums">
{cart.length}
<div className="flex items-center gap-2 font-bold text-slate-800 text-sm sm:text-base min-w-0">
<ShoppingCart size={16} className="text-emerald-700 shrink-0" />
<span className="truncate"> </span>
<span className="shrink-0 px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-[11px] font-bold tabular-nums">
{cart.length + extras.length}
</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 shrink-0">
<span className="hidden md:inline text-xs text-violet-700 tabular-nums"> {fmt(totals.taxFree)}</span>
<span className="hidden md:inline text-xs text-rose-700 tabular-nums"> {fmt(totals.taxable)}</span>
<span className="text-base font-bold text-emerald-700 tabular-nums">{fmt(totals.total)}</span>
<span className="text-sm sm:text-base font-bold text-emerald-700 tabular-nums">{fmt(totals.total)}</span>
<button
onClick={(e) => { e.stopPropagation(); submitOrder(); }}
disabled={cart.length === 0}
className="h-9 px-4 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed"
className="h-8 sm:h-9 px-3 sm:px-4 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold hover:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed"
>
</button>
@@ -161,61 +236,134 @@ export default function ItemsBrowse() {
</button>
{cartOpen && (
<div className="border-t border-emerald-100 px-4 py-3 max-h-[40vh] overflow-y-auto bg-slate-50/50">
{cart.length === 0 ? (
<div className="border-t border-emerald-100 px-3 sm:px-4 py-3 max-h-[55vh] overflow-y-auto bg-slate-50/50 space-y-3">
{/* 택배/용차 추가 버튼 */}
<div className="flex flex-wrap gap-2 items-center justify-between">
<div className="flex gap-2 flex-wrap">
<button
type="button"
onClick={() => addExtra("DELIVERY")}
className="inline-flex items-center gap-1 h-8 px-3 rounded-md bg-orange-100 text-orange-700 text-xs font-bold hover:bg-orange-200"
>
<Truck size={13} /> +
</button>
<button
type="button"
onClick={() => addExtra("CHARTER")}
className="inline-flex items-center gap-1 h-8 px-3 rounded-md bg-sky-100 text-sky-700 text-xs font-bold hover:bg-sky-200"
>
<Package size={13} /> +
</button>
</div>
{(cart.length > 0 || extras.length > 0) && (
<button
onClick={() => { setCart([]); setExtras([]); }}
className="text-xs text-slate-400 hover:text-rose-500"
>
</button>
)}
</div>
{/* 택배/용차 라인 */}
{extras.length > 0 && (
<div className="space-y-1.5">
{extras.map((ex) => (
<div
key={ex.id}
className={`flex items-center gap-2 p-2 rounded-lg border ${ex.kind === "DELIVERY" ? "bg-orange-50/60 border-orange-200" : "bg-sky-50/60 border-sky-200"}`}
>
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded shrink-0 ${ex.kind === "DELIVERY" ? "bg-orange-200 text-orange-800" : "bg-sky-200 text-sky-800"}`}>
{ex.kind === "DELIVERY" ? "택배" : "용차"}
</span>
<input
value={ex.label}
onChange={(e) => updateExtra(ex.id, "label", e.target.value)}
placeholder="담당자/메모"
className="flex-1 min-w-0 h-8 px-2 rounded border border-slate-200 text-xs bg-white"
/>
<input
type="number"
min={0}
value={ex.amount || ""}
onChange={(e) => updateExtra(ex.id, "amount", Number(e.target.value))}
placeholder="금액"
className="w-24 sm:w-32 h-8 px-2 rounded border border-slate-200 text-xs text-right tabular-nums bg-white"
/>
<button
onClick={() => removeExtra(ex.id)}
className="text-slate-300 hover:text-rose-500 shrink-0"
title="삭제"
>
<X size={14} />
</button>
</div>
))}
</div>
)}
{/* 품목 라인 */}
{cart.length === 0 && extras.length === 0 ? (
<div className="text-slate-400 text-sm text-center py-6">
<span className="font-bold text-emerald-700">+ </span> .
</div>
) : (
<>
<div className="flex justify-end mb-2">
<button onClick={() => setCart([])} className="text-xs text-slate-400 hover:text-rose-500">
</button>
</div>
<div className="grid sm:grid-cols-2 gap-2">
{cart.map((ln) => {
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
return (
<div key={ln.item.OBJID} className="bg-white border border-slate-100 rounded-lg p-2.5">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="text-sm font-semibold leading-tight">{ln.item.ITEM_NAME}</div>
<button onClick={() => removeLine(ln.item.OBJID)} className="text-slate-300 hover:text-rose-500">
<X size={14} />
) : cart.length > 0 && (
<div className="grid sm:grid-cols-2 gap-2">
{cart.map((ln) => {
const lineTotal = Math.round(Number(ln.item.UNIT_PRICE) * ln.qty);
return (
<div key={ln.item.OBJID} className="bg-white border border-slate-100 rounded-lg p-2.5">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="text-sm font-semibold leading-tight min-w-0">
<div className="truncate">{ln.item.ITEM_NAME}</div>
{ln.item.REQUIRES_DELIVERY === "Y" && (
<span className="inline-block mt-0.5 text-[9px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 font-bold"></span>
)}
</div>
<button onClick={() => removeLine(ln.item.OBJID)} className="text-slate-300 hover:text-rose-500 shrink-0">
<X size={14} />
</button>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
<button onClick={() => updateQty(ln.item.OBJID, -1)} className="w-7 h-7 rounded-md bg-slate-100 hover:bg-slate-200 flex items-center justify-center">
<Minus size={12} />
</button>
<input
type="number"
min={1}
value={ln.qty}
onChange={(e) => setQty(ln.item.OBJID, Number(e.target.value))}
className="w-12 h-7 text-center text-sm font-bold tabular-nums border border-slate-200 rounded"
/>
<button onClick={() => updateQty(ln.item.OBJID, 1)} className="w-7 h-7 rounded-md bg-slate-100 hover:bg-slate-200 flex items-center justify-center">
<Plus size={12} />
</button>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<button onClick={() => updateQty(ln.item.OBJID, -1)} className="w-7 h-7 rounded-md bg-slate-100 hover:bg-slate-200 flex items-center justify-center">
<Minus size={12} />
</button>
<span className="w-10 text-center text-sm font-bold tabular-nums">{ln.qty}</span>
<button onClick={() => updateQty(ln.item.OBJID, 1)} className="w-7 h-7 rounded-md bg-slate-100 hover:bg-slate-200 flex items-center justify-center">
<Plus size={12} />
</button>
</div>
<div className="text-sm font-bold tabular-nums">{fmt(lineTotal)}</div>
</div>
<div className="text-sm font-bold tabular-nums">{fmt(lineTotal)}</div>
</div>
);
})}
</div>
<div className="border-t border-slate-200 mt-3 pt-2 grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
<Row label="면세 합계" value={`${fmt(totals.taxFree)}`} color="violet" />
<Row label="과세 공급가" value={`${fmt(totals.taxable)}`} color="rose" />
<Row label="세액" value={`${fmt(totals.vat)}`} />
<Row label="총 합계" value={`${fmt(totals.total)}`} />
</div>
</>
</div>
);
})}
</div>
)}
<div className="border-t border-slate-200 pt-2 grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
<Row label="면세 합계" value={`${fmt(totals.taxFree)}`} color="violet" />
<Row label="과세 공급가" value={`${fmt(totals.taxable)}`} color="rose" />
<Row label="세액" value={`${fmt(totals.vat)}`} />
<Row label="총 합계" value={`${fmt(totals.total)}`} />
{totals.delivery > 0 && <Row label="택배비" value={`${fmt(totals.delivery)}`} color="orange" />}
{totals.charter > 0 && <Row label="용차비" value={`${fmt(totals.charter)}`} color="sky" />}
</div>
</div>
)}
</div>
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
<div>
<h1 className="text-2xl font-bold text-slate-900"> </h1>
<p className="text-slate-500 text-sm mt-1"> [ ] .</p>
<h1 className="text-xl sm:text-2xl font-bold text-slate-900"> </h1>
<p className="text-slate-500 text-xs sm:text-sm mt-1"> [ ] .</p>
</div>
<div className="flex gap-2 items-center">
@@ -229,12 +377,12 @@ export default function ItemsBrowse() {
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500/15 outline-none"
/>
</div>
<select value={taxFilter} onChange={(e) => setTaxFilter(e.target.value as "" | "Y" | "N")} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
<select value={taxFilter} onChange={(e) => setTaxFilter(e.target.value as "" | "Y" | "N")} className="h-10 px-2 sm:px-3 rounded-lg border border-slate-200 text-sm">
<option value=""></option>
<option value="Y"></option>
<option value="N"></option>
</select>
<button onClick={fetchItems} className="h-10 px-4 rounded-lg bg-emerald-700 text-white text-sm font-semibold hover:bg-emerald-800">
<button onClick={fetchItems} className="h-10 px-3 sm:px-4 rounded-lg bg-emerald-700 text-white text-sm font-semibold hover:bg-emerald-800">
</button>
</div>
@@ -244,10 +392,10 @@ export default function ItemsBrowse() {
) : items.length === 0 ? (
<div className="text-slate-400 text-center py-12 bg-white rounded-xl border border-slate-100"> .</div>
) : (
<div className="grid sm:grid-cols-2 xl:grid-cols-3 gap-3">
<div className="grid grid-cols-2 sm:grid-cols-2 xl:grid-cols-3 gap-2 sm:gap-3">
{items.map((it) => (
<div key={it.OBJID} className="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition">
<div className="aspect-square bg-slate-50 rounded-lg mb-3 overflow-hidden flex items-center justify-center">
<div key={it.OBJID} className="bg-white border border-slate-200 rounded-xl p-3 sm:p-4 hover:shadow-md transition">
<div className="aspect-square bg-slate-50 rounded-lg mb-2 sm:mb-3 overflow-hidden flex items-center justify-center">
{it.IMAGE_URL ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={it.IMAGE_URL} alt={it.ITEM_NAME} className="w-full h-full object-cover" />
@@ -255,25 +403,33 @@ export default function ItemsBrowse() {
<div className="text-slate-300 text-xs"> </div>
)}
</div>
<div className="flex items-start justify-between gap-2 mb-1">
<div className="font-bold text-sm text-slate-900 leading-tight">{it.ITEM_NAME}</div>
{it.IS_TAX_FREE === "Y" && (
<span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold"></span>
)}
</div>
<div className="text-xs text-slate-500 mb-2">{it.MAKER_NAME || "-"}</div>
<div className="flex items-baseline justify-between">
<div className="font-bold text-slate-900 tabular-nums">{fmt(it.UNIT_PRICE)}</div>
<div className={`text-xs font-semibold ${Number(it.STOCK_QTY) > 0 ? "text-emerald-700" : "text-rose-500"}`}>
{fmt(it.STOCK_QTY)} {it.UNIT}
<div className="flex items-start justify-between gap-1 mb-1">
<div className="font-bold text-xs sm:text-sm text-slate-900 leading-tight">{it.ITEM_NAME}</div>
<div className="flex flex-col gap-0.5 items-end shrink-0">
{it.IS_TAX_FREE === "Y" && (
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 text-[9px] font-bold"></span>
)}
{it.REQUIRES_DELIVERY === "Y" && (
<span className="px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px] font-bold"></span>
)}
</div>
</div>
<div className="text-[11px] text-slate-500 mb-1.5 truncate">{it.MAKER_NAME || "-"}</div>
<div className="flex items-baseline justify-between mb-1">
<div className="font-bold text-slate-900 tabular-nums text-sm">{fmt(it.UNIT_PRICE)}</div>
<div className={`text-[11px] font-semibold ${Number(it.STOCK_QTY) > 0 ? "text-emerald-700" : "text-rose-500"}`}>
{fmt(it.STOCK_QTY)}{it.UNIT}
</div>
</div>
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
<div className="text-[10px] text-sky-700 mb-1">1 {fmt(it.MAX_ORDER_QTY)}</div>
)}
<button
disabled={Number(it.STOCK_QTY) === 0}
onClick={() => addToCart(it)}
className="w-full mt-3 h-9 rounded-lg bg-emerald-700 text-white text-xs font-bold hover:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed flex items-center justify-center gap-1"
className="w-full mt-1.5 h-8 sm:h-9 rounded-lg bg-emerald-700 text-white text-[11px] sm:text-xs font-bold hover:bg-emerald-800 disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed flex items-center justify-center gap-1"
>
<Plus size={14} />
<Plus size={13} />
</button>
</div>
))}
@@ -284,8 +440,12 @@ export default function ItemsBrowse() {
);
}
function Row({ label, value, color }: { label: string; value: string; color?: "violet" | "rose" }) {
const cls = color === "violet" ? "text-violet-700" : color === "rose" ? "text-rose-700" : "text-slate-700";
function Row({ label, value, color }: { label: string; value: string; color?: "violet" | "rose" | "orange" | "sky" }) {
const cls = color === "violet" ? "text-violet-700"
: color === "rose" ? "text-rose-700"
: color === "orange" ? "text-orange-700"
: color === "sky" ? "text-sky-700"
: "text-slate-700";
return (
<div className="flex justify-between">
<span className={cls}>{label}</span>
+1
View File
@@ -78,6 +78,7 @@ export async function POST(req: NextRequest) {
I.attributes AS "ATTRIBUTES",
I.max_order_qty AS "MAX_ORDER_QTY",
COALESCE(I.is_hidden, 'N') AS "IS_HIDDEN",
COALESCE(I.requires_delivery, 'N') AS "REQUIRES_DELIVERY",
COALESCE((
SELECT SUM(S.qty) FROM momo_stocks S
JOIN momo_warehouses W ON S.wh_objid = W.objid
+8 -6
View File
@@ -24,9 +24,11 @@ export async function POST(req: NextRequest) {
status,
maxOrderQty,
isHidden,
requiresDelivery,
} = body;
const maxQty = maxOrderQty == null || maxOrderQty === "" ? null : Number(maxOrderQty);
const hidden = isHidden === "Y" ? "Y" : "N";
const reqDelivery = requiresDelivery === "Y" ? "Y" : "N";
if (!itemName) {
return NextResponse.json({ success: false, message: "품목명은 필수입니다." }, { status: 400 });
@@ -45,15 +47,15 @@ export async function POST(req: NextRequest) {
`INSERT INTO momo_items (
objid, item_code, item_name, item_detail, maker_objid,
unit, unit_price, cost_price, is_tax_free, image_url, attributes, status,
max_order_qty, is_hidden,
max_order_qty, is_hidden, requires_delivery,
is_del, regdate, regid
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,'N',NOW(),$15)`,
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,$15,'N',NOW(),$16)`,
[newId, itemCode, cleanName, itemDetail ?? null, makerObjid ?? null,
unit ?? "EA", Number(unitPrice ?? 0), Number(costPrice ?? 0),
taxFree, imageUrl ?? null,
attributes ? JSON.stringify(attributes) : null,
status ?? "ACTIVE",
maxQty, hidden,
maxQty, hidden, reqDelivery,
userId]
);
return NextResponse.json({ success: true, objId: newId, itemCode });
@@ -65,15 +67,15 @@ export async function POST(req: NextRequest) {
item_name=$2, item_detail=$3, maker_objid=$4, unit=$5,
unit_price=$6, cost_price=$7, is_tax_free=$8, image_url=$9,
attributes=$10::jsonb, status=$11,
max_order_qty=$12, is_hidden=$13,
update_date=NOW(), update_id=$14
max_order_qty=$12, is_hidden=$13, requires_delivery=$14,
update_date=NOW(), update_id=$15
WHERE objid=$1`,
[objid, cleanName, itemDetail ?? null, makerObjid ?? null, unit ?? "EA",
Number(unitPrice ?? 0), Number(costPrice ?? 0),
taxFree, imageUrl ?? null,
attributes ? JSON.stringify(attributes) : null,
status ?? "ACTIVE",
maxQty, hidden,
maxQty, hidden, reqDelivery,
userId]
);
return NextResponse.json({ success: true, objId: objid });
+4
View File
@@ -21,6 +21,8 @@ export async function POST(req: NextRequest) {
O.total_supply AS "TOTAL_SUPPLY", O.total_vat AS "TOTAL_VAT",
O.total_amount AS "TOTAL_AMOUNT",
O.total_taxfree AS "TOTAL_TAXFREE", O.total_taxable AS "TOTAL_TAXABLE",
COALESCE(O.total_delivery, 0) AS "TOTAL_DELIVERY",
COALESCE(O.total_charter, 0) AS "TOTAL_CHARTER",
O.invoice_no AS "INVOICE_NO",
TO_CHAR(O.invoice_date,'YYYY-MM-DD') AS "INVOICE_DATE",
O.paid_amount AS "PAID_AMOUNT",
@@ -47,6 +49,8 @@ export async function POST(req: NextRequest) {
OI.supply_amount AS "SUPPLY_AMOUNT",
OI.vat_amount AS "VAT_AMOUNT",
OI.total_amount AS "TOTAL_AMOUNT",
COALESCE(OI.kind, 'ITEM') AS "KIND",
OI.extra_label AS "EXTRA_LABEL",
I.unit AS "UNIT",
I.image_url AS "IMAGE_URL",
COALESCE(
+77 -13
View File
@@ -1,30 +1,36 @@
// 출고요청서 작성 (대리점) — status=REQUESTED 로 저장
// v0.4: 택배비/용차비 라인 + 택배 전용 품목 자동 검증 지원
import { NextRequest, NextResponse } from "next/server";
import { pool, queryOne } from "@/lib/db";
import { createObjectId } from "@/lib/utils";
import { requireMomoUser } from "@/lib/momo-guard";
import { calcLine, sumTotals } from "@/lib/momo-pricing";
interface InputLine {
interface InputItemLine {
itemObjid: string;
qty: number;
}
interface InputExtraLine {
kind: "DELIVERY" | "CHARTER";
amount: number;
label?: string;
}
export async function POST(req: NextRequest) {
const r = await requireMomoUser();
if (r instanceof NextResponse) return r;
// MOMO 가입자는 r.user.objid 가 user_id 와 동일하게 채워지지만,
// FITO 사용자(예: plm_admin)는 objid 가 undefined 이므로 userId 로 폴백.
const customerObjid = r.user.objid || r.user.userId;
if (!customerObjid) {
return NextResponse.json({ success: false, message: "사용자 식별자를 확인할 수 없습니다." }, { status: 400 });
}
let lines: InputLine[];
let lines: InputItemLine[];
let extras: InputExtraLine[];
let memo: string | undefined;
try {
const body = await req.json() as { lines: InputLine[]; memo?: string };
const body = await req.json() as { lines: InputItemLine[]; extras?: InputExtraLine[]; memo?: string };
lines = body.lines;
extras = Array.isArray(body.extras) ? body.extras : [];
memo = body.memo;
} catch {
return NextResponse.json({ success: false, message: "요청 본문을 해석할 수 없습니다." }, { status: 400 });
@@ -37,9 +43,16 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: false, message: "품목/수량 형식이 올바르지 않습니다." }, { status: 400 });
}
}
for (const ex of extras) {
if (ex.kind !== "DELIVERY" && ex.kind !== "CHARTER") {
return NextResponse.json({ success: false, message: "택배/용차 라인 종류가 올바르지 않습니다." }, { status: 400 });
}
if (!Number.isFinite(Number(ex.amount)) || Number(ex.amount) < 0) {
return NextResponse.json({ success: false, message: "택배/용차 금액 형식이 올바르지 않습니다." }, { status: 400 });
}
}
try {
// 발주자(회원) 권한 — unlimited_qty='Y' 면 max_order_qty 무시
const customerRow = await pool.query(
`SELECT COALESCE(unlimited_qty, 'N') AS unlimited_qty FROM user_info WHERE user_id = $1`,
[customerObjid]
@@ -51,7 +64,9 @@ export async function POST(req: NextRequest) {
const items = await pool.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,
I.max_order_qty,
COALESCE(I.is_hidden, 'N') AS is_hidden,
COALESCE(I.requires_delivery, 'N') AS requires_delivery,
COALESCE((
SELECT SUM(S.qty) FROM momo_stocks S
JOIN momo_warehouses W ON S.wh_objid = W.objid
@@ -68,7 +83,8 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: false, message: `존재하지 않는 품목입니다: ${missing.itemObjid}` }, { status: 400 });
}
// 수량 검증: 재고 한도 + (unlimited_qty 가 아니면) max_order_qty 한도
// 수량/숨김/택배 검증
let needsDelivery = false;
for (const ln of lines) {
const it = itemMap.get(ln.itemObjid)!;
const stock = Number(it.stock_qty ?? 0);
@@ -87,10 +103,22 @@ export async function POST(req: NextRequest) {
}, { status: 400 });
}
}
if (it.requires_delivery === "Y") needsDelivery = true;
}
// 택배 전용 품목이 있는데 택배 라인이 없으면 차단
const hasDeliveryLine = extras.some((e) => e.kind === "DELIVERY");
if (needsDelivery && !hasDeliveryLine) {
return NextResponse.json({
success: false,
message: "택배 전용 품목이 포함되어 택배 라인이 필요합니다. 택배 추가 후 다시 시도하세요.",
}, { status: 400 });
}
const orderObjid = createObjectId();
const orderNo = await genOrderNo();
// 품목 라인
const enriched = lines.map((ln, idx) => {
const it = itemMap.get(ln.itemObjid)!;
const isFree = it.is_tax_free === "Y";
@@ -105,7 +133,30 @@ export async function POST(req: NextRequest) {
...calc,
};
});
const totals = sumTotals(enriched);
// 택배/용차 라인 — 입력 금액이 VAT 포함 합계라고 가정, 일반 과세 처리
let totalDelivery = 0;
let totalCharter = 0;
const extraEnriched = extras.map((ex, idx) => {
const amount = Math.round(Number(ex.amount));
const calc = calcLine({ unitPrice: amount, qty: 1, isTaxFree: false });
if (ex.kind === "DELIVERY") totalDelivery += amount;
if (ex.kind === "CHARTER") totalCharter += amount;
return {
seq: enriched.length + idx + 1,
kind: ex.kind,
label: ex.label?.trim() || (ex.kind === "DELIVERY" ? "택배비" : "용차비"),
unitPrice: amount,
qty: 1,
isTaxFree: false,
...calc,
};
});
const totals = sumTotals([
...enriched,
...extraEnriched,
]);
const client = await pool.connect();
try {
@@ -114,22 +165,35 @@ export async function POST(req: NextRequest) {
`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
) VALUES ($1,$2,$3,CURRENT_DATE,'REQUESTED',$4,$5,$6,$7,$8,$9,NOW(),$10)`,
) VALUES ($1,$2,$3,CURRENT_DATE,'REQUESTED',$4,$5,$6,$7,$8,$9,$10,$11,NOW(),$12)`,
[orderObjid, orderNo, customerObjid,
totals.supply, totals.vat, totals.total, totals.taxFree, totals.taxable, memo ?? null,
totals.supply, totals.vat, totals.total, totals.taxFree, totals.taxable,
totalDelivery, totalCharter,
memo ?? null,
customerObjid]
);
for (const ln of enriched) {
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
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
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, ln.itemName, ln.unitPrice, ln.qty,
ln.isTaxFree ? "Y" : "N", ln.supplyAmount, ln.vatAmount, ln.totalAmount, ln.seq]
);
}
for (const ex of extraEnriched) {
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, extra_label
) VALUES ($1,$2,NULL,$3,$4,1,'N',$5,$6,$7,$8,$9,$10)`,
[createObjectId(), orderObjid, ex.label, ex.unitPrice,
ex.supplyAmount, ex.vatAmount, ex.totalAmount, ex.seq, ex.kind, ex.label]
);
}
await client.query("COMMIT");
return NextResponse.json({ success: true, objId: orderObjid, orderNo });
} catch (err) {