feat(items): 품목 판매 가능일 (sale_start/end) — 관리자 일괄 적용 + USER 필터
Deploy momo-erp / deploy (push) Successful in 1m55s
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:
@@ -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 });
|
||||
}
|
||||
@@ -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((
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user