feat(items): 품목 판매 가능일 (sale_start/end) — 관리자 일괄 적용 + USER 필터
Deploy momo-erp / deploy (push) Successful in 1m55s

- momo_items: sale_start_date/sale_end_date DATE 컬럼 (ensureColumns)
- USER 측 출고요청: CURRENT_DATE ∈ [start, end] 인 품목만 노출 (NULL=상시)
- 택배 전용(requires_delivery='Y') 품목은 재고 무관 노출 (onlyAvailable)
- 관리자 품목 관리: 체크박스 + 시작일/종료일 일괄 적용 바, 모달에 판매기간 입력, 리스트에 판매기간 컬럼

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-15 09:46:33 +09:00
parent 86b90e2d5a
commit f73c486c4f
4 changed files with 217 additions and 6 deletions
+160 -1
View File
@@ -23,6 +23,8 @@ interface Item {
REQUIRES_DELIVERY: string;
VENDOR_OBJID?: string;
VENDOR_NAME?: string;
SALE_START_DATE?: string | null;
SALE_END_DATE?: string | null;
}
interface Vendor { OBJID: string; VENDOR_NAME: string }
@@ -46,8 +48,24 @@ export default function AdminItemsPage() {
const [editing, setEditing] = useState<Partial<Item> | null>(null);
const [attrs, setAttrs] = useState<ItemAttributes>({});
const [uploading, setUploading] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const fileRef = useRef<HTMLInputElement>(null);
const toggleSel = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleSelAll = () => {
setSelectedIds((prev) => {
if (prev.size === items.length) return new Set();
return new Set(items.map((it) => it.OBJID));
});
};
const loadItems = async () => {
const res = await fetch("/api/m/items/list", {
method: "POST",
@@ -104,6 +122,8 @@ export default function AdminItemsPage() {
isHidden: editing.IS_HIDDEN === "Y" ? "Y" : "N",
requiresDelivery: editing.REQUIRES_DELIVERY === "Y" ? "Y" : "N",
vendorObjid: editing.VENDOR_OBJID || null,
saleStartDate: editing.SALE_START_DATE || null,
saleEndDate: editing.SALE_END_DATE || null,
};
const res = await fetch("/api/m/items/save", {
method: "POST",
@@ -159,6 +179,10 @@ export default function AdminItemsPage() {
return (
<div className="space-y-4">
<BulkSaleRangeBar
selectedIds={selectedIds}
onApplied={() => { setSelectedIds(new Set()); loadItems(); }}
/>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-slate-800"> </h1>
<button
@@ -243,6 +267,14 @@ export default function AdminItemsPage() {
<table className="w-full text-sm min-w-[900px]">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="px-2 py-1.5 w-8">
<input
type="checkbox"
checked={items.length > 0 && selectedIds.size === items.length}
onChange={toggleSelAll}
className="cursor-pointer"
/>
</th>
<th className="px-3 py-3 w-14"></th>
<th className="text-left px-3 py-1.5"></th>
<th className="text-left px-3 py-1.5"></th>
@@ -250,6 +282,7 @@ export default function AdminItemsPage() {
<th className="text-right px-3 py-1.5"></th>
<th className="text-right px-3 py-1.5"></th>
<th className="text-right px-3 py-1.5"></th>
<th className="text-center px-3 py-1.5 whitespace-nowrap"></th>
<th className="text-center px-3 py-1.5"></th>
<th className="text-right px-3 py-1.5 w-[80px] whitespace-nowrap"></th>
</tr>
@@ -257,13 +290,21 @@ export default function AdminItemsPage() {
<tbody>
{items.length === 0 ? (
<tr>
<td colSpan={9} className="text-center py-12 text-slate-400">
<td colSpan={11} className="text-center py-12 text-slate-400">
. .
</td>
</tr>
) : (
items.map((it) => (
<tr key={it.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
<td className="px-2 py-2 text-center">
<input
type="checkbox"
checked={selectedIds.has(it.OBJID)}
onChange={() => toggleSel(it.OBJID)}
className="cursor-pointer"
/>
</td>
<td className="px-3 py-2">
<div className="w-10 h-10 bg-slate-50 rounded overflow-hidden flex items-center justify-center">
{it.IMAGE_URL ? (
@@ -289,6 +330,11 @@ export default function AdminItemsPage() {
{fmt(it.STOCK_QTY)}
</span>
</td>
<td className="px-3 py-2 text-center text-[11px] tabular-nums text-slate-600 whitespace-nowrap">
{it.SALE_START_DATE || it.SALE_END_DATE
? <>{it.SALE_START_DATE ?? "—"} <span className="text-slate-300">~</span> {it.SALE_END_DATE ?? "—"}</>
: <span className="text-slate-300"></span>}
</td>
<td className="px-3 py-2 text-center">
{it.IS_HIDDEN === "Y" && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-bold"></span>
@@ -456,6 +502,22 @@ export default function AdminItemsPage() {
</label>
</div>
</Field>
<Field label="판매 시작일">
<input
type="date"
value={editing.SALE_START_DATE ?? ""}
onChange={(e) => setEditing({ ...editing, SALE_START_DATE: e.target.value || null })}
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
/>
</Field>
<Field label="판매 종료일">
<input
type="date"
value={editing.SALE_END_DATE ?? ""}
onChange={(e) => setEditing({ ...editing, SALE_END_DATE: e.target.value || null })}
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
/>
</Field>
<div className="sm:col-span-2">
<Field label="상세 설명">
<textarea
@@ -590,3 +652,100 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
</div>
);
}
function BulkSaleRangeBar({
selectedIds,
onApplied,
}: {
selectedIds: Set<string>;
onApplied: () => void;
}) {
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [busy, setBusy] = useState(false);
const apply = async (clear: boolean) => {
if (selectedIds.size === 0) {
Swal.fire({ icon: "warning", title: "품목을 선택하세요" });
return;
}
if (!clear && !from && !to) {
Swal.fire({ icon: "warning", title: "시작일 또는 종료일을 입력하세요" });
return;
}
setBusy(true);
try {
const res = await fetch("/api/m/items/bulk-sale-range", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
objids: Array.from(selectedIds),
saleStartDate: from || null,
saleEndDate: to || null,
clear,
}),
});
const j = await res.json();
if (j.success) {
Swal.fire({
icon: "success",
title: clear ? "판매기간 해제됨" : "판매기간 일괄 적용 완료",
text: `${j.count}개 품목 적용`,
timer: 1500,
showConfirmButton: false,
});
setFrom("");
setTo("");
onApplied();
} else {
Swal.fire({ icon: "error", title: "실패", text: j.message });
}
} finally {
setBusy(false);
}
};
return (
<div className="bg-emerald-50 border border-emerald-200 rounded-lg px-4 py-3 flex flex-wrap items-end gap-3">
<div>
<label className="block text-[11px] font-semibold text-emerald-800 mb-1"> </label>
<input
type="date"
value={from}
onChange={(e) => setFrom(e.target.value)}
className="h-9 px-2 rounded-md border border-emerald-300 text-sm bg-white"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-emerald-800 mb-1"> </label>
<input
type="date"
value={to}
onChange={(e) => setTo(e.target.value)}
className="h-9 px-2 rounded-md border border-emerald-300 text-sm bg-white"
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={busy}
onClick={() => apply(false)}
className="h-9 px-3 rounded-md bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-50"
>
{selectedIds.size}
</button>
<button
type="button"
disabled={busy}
onClick={() => apply(true)}
className="h-9 px-3 rounded-md bg-white border border-emerald-300 text-emerald-700 text-sm font-semibold hover:bg-emerald-100 disabled:opacity-50"
>
</button>
</div>
<div className="text-[11px] text-emerald-700/80 ml-auto">
( = )
</div>
</div>
);
}
@@ -0,0 +1,32 @@
// 품목 판매 가능일 일괄 적용 — admin 만.
import { NextRequest, NextResponse } from "next/server";
import { pool } from "@/lib/db";
import { requireMomoAdmin } from "@/lib/momo-guard";
export async function POST(req: NextRequest) {
const g = await requireMomoAdmin();
if (g instanceof NextResponse) return g;
const { objids, saleStartDate, saleEndDate, clear } = await req.json() as {
objids: string[]; saleStartDate?: string; saleEndDate?: string; clear?: boolean;
};
if (!Array.isArray(objids) || objids.length === 0) {
return NextResponse.json({ success: false, message: "대상 품목을 선택하세요." }, { status: 400 });
}
const userId = g.user.objid || g.user.userId;
const start = clear ? null : (saleStartDate || null);
const end = clear ? null : (saleEndDate || null);
const placeholders = objids.map((_, i) => `$${i + 1}`).join(",");
const res = await pool.query(
`UPDATE momo_items
SET sale_start_date = $${objids.length + 1}::date,
sale_end_date = $${objids.length + 2}::date,
update_date = NOW(),
update_id = $${objids.length + 3}
WHERE objid IN (${placeholders})`,
[...objids, start, end, userId]
);
return NextResponse.json({ success: true, count: res.rowCount ?? 0 });
}
+16 -2
View File
@@ -13,7 +13,9 @@ async function ensureColumns() {
ADD COLUMN IF NOT EXISTS vendor_objid TEXT,
ADD COLUMN IF NOT EXISTS max_order_qty INTEGER,
ADD COLUMN IF NOT EXISTS is_hidden CHAR(1) DEFAULT 'N',
ADD COLUMN IF NOT EXISTS requires_delivery CHAR(1) DEFAULT 'N';
ADD COLUMN IF NOT EXISTS requires_delivery CHAR(1) DEFAULT 'N',
ADD COLUMN IF NOT EXISTS sale_start_date DATE,
ADD COLUMN IF NOT EXISTS sale_end_date DATE;
`);
columnsEnsured = true;
} catch (err) {
@@ -86,8 +88,18 @@ export async function POST(req: NextRequest) {
params.push(vendorObjid);
}
if (onlyAvailable) {
// 택배 필수 품목(requires_delivery='Y') 은 재고 무관 노출.
conditions.push(
`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) > 0`
`(COALESCE(I.requires_delivery,'N') = 'Y'
OR 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) > 0)`
);
}
// USER: 판매 가능일(sale_start_date ~ sale_end_date) 안의 품목만.
// 기간이 NULL 인 품목은 상시 노출(=기간 무관). admin 은 무필터.
if (isUser) {
conditions.push(
`(I.sale_start_date IS NULL OR CURRENT_DATE >= I.sale_start_date)
AND (I.sale_end_date IS NULL OR CURRENT_DATE <= I.sale_end_date)`
);
}
@@ -109,6 +121,8 @@ export async function POST(req: NextRequest) {
I.max_order_qty AS "MAX_ORDER_QTY",
COALESCE(I.is_hidden, 'N') AS "IS_HIDDEN",
COALESCE(I.requires_delivery, 'N') AS "REQUIRES_DELIVERY",
TO_CHAR(I.sale_start_date, 'YYYY-MM-DD') AS "SALE_START_DATE",
TO_CHAR(I.sale_end_date, 'YYYY-MM-DD') AS "SALE_END_DATE",
I.vendor_objid AS "VENDOR_OBJID",
V.supply_name AS "VENDOR_NAME",
COALESCE((
+9 -3
View File
@@ -26,10 +26,14 @@ export async function POST(req: NextRequest) {
isHidden,
requiresDelivery,
vendorObjid,
saleStartDate,
saleEndDate,
} = body;
const maxQty = maxOrderQty == null || maxOrderQty === "" ? null : Number(maxOrderQty);
const hidden = isHidden === "Y" ? "Y" : "N";
const reqDelivery = requiresDelivery === "Y" ? "Y" : "N";
const saleStart = saleStartDate && String(saleStartDate).trim() !== "" ? saleStartDate : null;
const saleEnd = saleEndDate && String(saleEndDate).trim() !== "" ? saleEndDate : null;
if (!itemName) {
return NextResponse.json({ success: false, message: "품목명은 필수입니다." }, { status: 400 });
@@ -49,15 +53,16 @@ export async function POST(req: NextRequest) {
objid, item_code, item_name, item_detail, maker_objid, vendor_objid,
unit, unit_price, cost_price, is_tax_free, image_url, attributes, status,
max_order_qty, is_hidden, requires_delivery,
sale_start_date, sale_end_date,
is_del, regdate, regid
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13,$14,$15,$16,'N',NOW(),$17)`,
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13,$14,$15,$16,$18::date,$19::date,'N',NOW(),$17)`,
[newId, itemCode, cleanName, itemDetail ?? null, makerObjid ?? null, vendorObjid ?? null,
unit ?? "EA", Number(unitPrice ?? 0), Number(costPrice ?? 0),
taxFree, imageUrl ?? null,
attributes ? JSON.stringify(attributes) : null,
status ?? "ACTIVE",
maxQty, hidden, reqDelivery,
userId]
userId, saleStart, saleEnd]
);
return NextResponse.json({ success: true, objId: newId, itemCode });
}
@@ -70,6 +75,7 @@ export async function POST(req: NextRequest) {
attributes=$10::jsonb, status=$11,
max_order_qty=$12, is_hidden=$13, requires_delivery=$14,
vendor_objid=$15,
sale_start_date=$17::date, sale_end_date=$18::date,
update_date=NOW(), update_id=$16
WHERE objid=$1`,
[objid, cleanName, itemDetail ?? null, makerObjid ?? null, unit ?? "EA",
@@ -79,7 +85,7 @@ export async function POST(req: NextRequest) {
status ?? "ACTIVE",
maxQty, hidden, reqDelivery,
vendorObjid ?? null,
userId]
userId, saleStart, saleEnd]
);
return NextResponse.json({ success: true, objId: objid });
}