feat(sale-period): 판매기간 분 단위 + 재고초과 차단 + 모바일 2열 카드
Deploy momo-erp / deploy (push) Successful in 2m1s
Deploy momo-erp / deploy (push) Successful in 2m1s
- momo_items.sale_start_date/sale_end_date DATE → TIMESTAMP 자동 승격 (items/list ensureColumns 에 information_schema 체크 후 ALTER) - items API (list/save/bulk-sale-range/daily-order-inventory): ::date → ::timestamp, CURRENT_DATE → NOW(), 응답 포맷 'YYYY-MM-DD HH24:MI' - 품목 관리 편집/일괄적용 UI: <input type="date"> → datetime-local + toLocal 헬퍼로 응답값 "YYYY-MM-DD HH:MM" → "YYYY-MM-DDTHH:MM" 변환 - 출고 요청 카드 그리드 모바일 2열로 변경 + 카드/리스트에 판매 종료일시 표시 - 재고/한도 초과 수량 입력 시 즉시 clamp + 우상단 토스트 경고 (카드/리스트 inline qty input onChange, setQty/updateQty 양쪽) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,9 @@ interface ItemAttributes {
|
||||
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
|
||||
// API 응답 "YYYY-MM-DD HH:MM" → datetime-local 입력값 "YYYY-MM-DDTHH:MM"
|
||||
const toLocal = (s?: string | null) => (s ? String(s).replace(" ", "T").slice(0, 16) : "");
|
||||
|
||||
export default function AdminItemsPage() {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [vendors, setVendors] = useState<Vendor[]>([]);
|
||||
@@ -502,18 +505,18 @@ export default function AdminItemsPage() {
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="판매 시작일">
|
||||
<Field label="판매 시작일시 (분 단위)">
|
||||
<input
|
||||
type="date"
|
||||
value={editing.SALE_START_DATE ?? ""}
|
||||
type="datetime-local"
|
||||
value={toLocal(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="판매 종료일">
|
||||
<Field label="판매 종료일시 (분 단위)">
|
||||
<input
|
||||
type="date"
|
||||
value={editing.SALE_END_DATE ?? ""}
|
||||
type="datetime-local"
|
||||
value={toLocal(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"
|
||||
/>
|
||||
@@ -708,18 +711,18 @@ function BulkSaleRangeBar({
|
||||
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>
|
||||
<label className="block text-[11px] font-semibold text-emerald-800 mb-1">판매 시작일시</label>
|
||||
<input
|
||||
type="date"
|
||||
type="datetime-local"
|
||||
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>
|
||||
<label className="block text-[11px] font-semibold text-emerald-800 mb-1">판매 종료일시</label>
|
||||
<input
|
||||
type="date"
|
||||
type="datetime-local"
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className="h-9 px-2 rounded-md border border-emerald-300 text-sm bg-white"
|
||||
|
||||
@@ -19,6 +19,8 @@ interface Item {
|
||||
MAX_ORDER_QTY: number | null;
|
||||
IS_HIDDEN: string;
|
||||
REQUIRES_DELIVERY: string;
|
||||
SALE_START_DATE?: string | null;
|
||||
SALE_END_DATE?: string | null;
|
||||
}
|
||||
interface CartLine { item: Item; qty: number }
|
||||
interface ExtraLine { id: string; kind: "DELIVERY" | "CHARTER"; unitPrice: number; qty: number; label: string }
|
||||
@@ -155,6 +157,8 @@ function ItemsBrowse() {
|
||||
};
|
||||
|
||||
const updateQty = (objid: string, delta: number) => {
|
||||
let warnLimit = -1;
|
||||
let warnIsStock = false;
|
||||
setCart((c) =>
|
||||
c.map((x) => {
|
||||
if (x.item.OBJID !== objid) return x;
|
||||
@@ -165,13 +169,20 @@ function ItemsBrowse() {
|
||||
const isDelivery = x.item.REQUIRES_DELIVERY === "Y";
|
||||
const effStock = isDelivery ? Number.MAX_SAFE_INTEGER : stock;
|
||||
const limit = unlimitedQty || maxQ <= 0 ? effStock : Math.min(effStock, maxQ);
|
||||
if (newQty > limit) return x;
|
||||
if (newQty > limit) {
|
||||
warnLimit = limit;
|
||||
warnIsStock = maxQ <= 0 || stock <= maxQ;
|
||||
return x;
|
||||
}
|
||||
return { ...x, qty: newQty };
|
||||
})
|
||||
);
|
||||
if (warnLimit >= 0) toastLimit(warnLimit, warnIsStock);
|
||||
};
|
||||
|
||||
const setQty = (objid: string, value: number) => {
|
||||
let warnLimit = -1;
|
||||
let warnIsStock = false;
|
||||
setCart((c) =>
|
||||
c.map((x) => {
|
||||
if (x.item.OBJID !== objid) return x;
|
||||
@@ -180,10 +191,24 @@ function ItemsBrowse() {
|
||||
const isDelivery = x.item.REQUIRES_DELIVERY === "Y";
|
||||
const effStock = isDelivery ? Number.MAX_SAFE_INTEGER : stock;
|
||||
const limit = unlimitedQty || maxQ <= 0 ? effStock : Math.min(effStock, maxQ);
|
||||
const clamped = Math.max(1, Math.min(limit, Math.floor(value || 0)));
|
||||
const requested = Math.floor(value || 0);
|
||||
if (requested > limit) {
|
||||
warnLimit = limit;
|
||||
warnIsStock = maxQ <= 0 || stock <= maxQ;
|
||||
}
|
||||
const clamped = Math.max(1, Math.min(limit, requested));
|
||||
return { ...x, qty: clamped };
|
||||
})
|
||||
);
|
||||
if (warnLimit >= 0) toastLimit(warnLimit, warnIsStock);
|
||||
};
|
||||
|
||||
const toastLimit = (limit: number, isStockLimit: boolean) => {
|
||||
Swal.fire({
|
||||
toast: true, position: "top-end", icon: "warning",
|
||||
title: `${isStockLimit ? "재고" : "1회 발주 한도"}: 최대 ${fmt(limit)}개`,
|
||||
showConfirmButton: false, timer: 1500, timerProgressBar: true,
|
||||
});
|
||||
};
|
||||
|
||||
const removeLine = (objid: string) => setCart((c) => c.filter((x) => x.item.OBJID !== objid));
|
||||
@@ -526,9 +551,10 @@ function ItemsBrowse() {
|
||||
onMinus={(o) => updateQty(o, -1)}
|
||||
onSetQty={setQty}
|
||||
onRemove={removeLine}
|
||||
onLimitToast={toastLimit}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
||||
{items.map((it) => {
|
||||
const cartLine = cart.find((x) => x.item.OBJID === it.OBJID);
|
||||
const inCart = cartLine?.qty ?? 0;
|
||||
@@ -569,6 +595,11 @@ function ItemsBrowse() {
|
||||
{fmt(stock)}{it.UNIT}
|
||||
</div>
|
||||
</div>
|
||||
{it.SALE_END_DATE && (
|
||||
<div className="text-[9px] text-rose-600 mb-0.5 tabular-nums font-semibold">
|
||||
⏰ {it.SALE_END_DATE} 마감
|
||||
</div>
|
||||
)}
|
||||
{maxQ > 0 && !unlimitedQty && (
|
||||
<div className="text-[9px] text-sky-700 mb-0.5">한도 ≤ {fmt(maxQ)}</div>
|
||||
)}
|
||||
@@ -586,6 +617,14 @@ function ItemsBrowse() {
|
||||
max={limit}
|
||||
defaultValue={1}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
onChange={(e) => {
|
||||
const el = e.target as HTMLInputElement;
|
||||
const val = Number(el.value) || 0;
|
||||
if (val > limit) {
|
||||
toastLimit(limit, maxQ <= 0 || stock <= maxQ);
|
||||
el.value = String(limit);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const val = Number((e.target as HTMLInputElement).value) || 1;
|
||||
@@ -659,13 +698,14 @@ function Row({ label, value, color }: { label: string; value: string; color?: "v
|
||||
);
|
||||
}
|
||||
|
||||
function ListView({ items, cart, unlimitedQty, onAdd, onPlus, onMinus, onSetQty, onRemove }: {
|
||||
function ListView({ items, cart, unlimitedQty, onAdd, onPlus, onMinus, onSetQty, onRemove, onLimitToast }: {
|
||||
items: Item[]; cart: CartLine[]; unlimitedQty: boolean;
|
||||
onAdd: (it: Item, qty: number) => void;
|
||||
onPlus: (o: string) => void;
|
||||
onMinus: (o: string) => void;
|
||||
onSetQty: (o: string, v: number) => void;
|
||||
onRemove: (o: string) => void;
|
||||
onLimitToast: (limit: number, isStockLimit: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
@@ -677,6 +717,7 @@ function ListView({ items, cart, unlimitedQty, onAdd, onPlus, onMinus, onSetQty,
|
||||
<th className="text-center px-1 py-2 w-10">구분</th>
|
||||
<th className="text-right px-1 py-2 w-[68px]">단가</th>
|
||||
<th className="text-right px-1 py-2 w-12">재고</th>
|
||||
<th className="text-center px-1 py-2 w-[120px]">마감</th>
|
||||
<th className="text-center px-1 py-2 w-[112px]">수량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -706,6 +747,9 @@ function ListView({ items, cart, unlimitedQty, onAdd, onPlus, onMinus, onSetQty,
|
||||
</td>
|
||||
<td className="px-1 py-2 text-right tabular-nums font-bold text-[11px]">₩{Number(it.UNIT_PRICE).toLocaleString("ko-KR")}</td>
|
||||
<td className={`px-1 py-2 text-right tabular-nums text-[11px] ${stock <= 0 ? "text-rose-500 font-bold" : "text-slate-700"}`}>{Number(stock).toLocaleString("ko-KR")}</td>
|
||||
<td className="px-1 py-2 text-center text-[10px] tabular-nums text-rose-600 font-semibold">
|
||||
{it.SALE_END_DATE ? it.SALE_END_DATE : <span className="text-slate-300">상시</span>}
|
||||
</td>
|
||||
<td className="px-1 py-2">
|
||||
{soldOut ? (
|
||||
<div className="text-center text-[10px] text-slate-400">품절</div>
|
||||
@@ -717,6 +761,14 @@ function ListView({ items, cart, unlimitedQty, onAdd, onPlus, onMinus, onSetQty,
|
||||
max={limit}
|
||||
defaultValue={1}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
onChange={(e) => {
|
||||
const el = e.target as HTMLInputElement;
|
||||
const val = Number(el.value) || 0;
|
||||
if (val > limit) {
|
||||
onLimitToast(limit, maxQ <= 0 || stock <= maxQ);
|
||||
el.value = String(limit);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const v = Number((e.target as HTMLInputElement).value) || 1;
|
||||
|
||||
@@ -35,8 +35,9 @@ export async function POST(req: NextRequest) {
|
||||
"COALESCE(I.is_del, 'N') != 'Y'",
|
||||
"COALESCE(I.is_hidden, 'N') != 'Y'",
|
||||
"UPPER(COALESCE(I.status, '')) = 'ACTIVE'",
|
||||
`(I.sale_start_date IS NULL OR $${dateIdx}::date >= I.sale_start_date)`,
|
||||
`(I.sale_end_date IS NULL OR $${dateIdx}::date <= I.sale_end_date)`,
|
||||
// 분 단위 시간까지 들어 있어도 날짜 단위로 겹침 비교 (선택일이 판매기간 내에 포함되면 노출)
|
||||
`(I.sale_start_date IS NULL OR $${dateIdx}::date >= I.sale_start_date::date)`,
|
||||
`(I.sale_end_date IS NULL OR $${dateIdx}::date <= I.sale_end_date::date)`,
|
||||
];
|
||||
if (keyword) {
|
||||
params.push(keyword);
|
||||
@@ -52,8 +53,8 @@ export async function POST(req: NextRequest) {
|
||||
I.unit AS "UNIT",
|
||||
I.unit_price AS "UNIT_PRICE",
|
||||
I.is_tax_free AS "IS_TAX_FREE",
|
||||
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",
|
||||
TO_CHAR(I.sale_start_date, 'YYYY-MM-DD HH24:MI') AS "SALE_START_DATE",
|
||||
TO_CHAR(I.sale_end_date, 'YYYY-MM-DD HH24:MI') AS "SALE_END_DATE",
|
||||
V.supply_name AS "VENDOR_NAME",
|
||||
COALESCE((
|
||||
SELECT SUM(OI.qty)
|
||||
|
||||
@@ -21,8 +21,8 @@ export async function POST(req: NextRequest) {
|
||||
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,
|
||||
SET sale_start_date = $${objids.length + 1}::timestamp,
|
||||
sale_end_date = $${objids.length + 2}::timestamp,
|
||||
update_date = NOW(),
|
||||
update_id = $${objids.length + 3}
|
||||
WHERE objid IN (${placeholders})`,
|
||||
|
||||
@@ -14,8 +14,20 @@ async function ensureColumns() {
|
||||
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 sale_start_date DATE,
|
||||
ADD COLUMN IF NOT EXISTS sale_end_date DATE;
|
||||
ADD COLUMN IF NOT EXISTS sale_start_date TIMESTAMP,
|
||||
ADD COLUMN IF NOT EXISTS sale_end_date TIMESTAMP;
|
||||
`);
|
||||
// sale_start_date / sale_end_date 가 이전에 DATE 로 만들어졌다면 TIMESTAMP 로 자동 승격
|
||||
await pool.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF (SELECT data_type FROM information_schema.columns
|
||||
WHERE table_name='momo_items' AND column_name='sale_start_date') = 'date' THEN
|
||||
ALTER TABLE momo_items
|
||||
ALTER COLUMN sale_start_date TYPE TIMESTAMP USING sale_start_date::timestamp,
|
||||
ALTER COLUMN sale_end_date TYPE TIMESTAMP USING sale_end_date::timestamp;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
columnsEnsured = true;
|
||||
} catch (err) {
|
||||
@@ -95,12 +107,13 @@ export async function POST(req: NextRequest) {
|
||||
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)`
|
||||
);
|
||||
}
|
||||
// 출고요청(orders/new) 메뉴: 판매 가능일(sale_start_date ~ sale_end_date) 안의 품목만.
|
||||
// 출고요청(orders/new) 메뉴: 판매 가능 기간(sale_start_date ~ sale_end_date) 안의 품목만.
|
||||
// 기간이 NULL 인 품목은 상시 노출. USER 항상 적용, ADMIN 도 forSale=true 이면 적용.
|
||||
// 분 단위 시간까지 비교 (NOW() vs TIMESTAMP)
|
||||
if (isUser || forSale) {
|
||||
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)`
|
||||
`(I.sale_start_date IS NULL OR NOW() >= I.sale_start_date)
|
||||
AND (I.sale_end_date IS NULL OR NOW() <= I.sale_end_date)`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,8 +135,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",
|
||||
TO_CHAR(I.sale_start_date, 'YYYY-MM-DD HH24:MI') AS "SALE_START_DATE",
|
||||
TO_CHAR(I.sale_end_date, 'YYYY-MM-DD HH24:MI') AS "SALE_END_DATE",
|
||||
I.vendor_objid AS "VENDOR_OBJID",
|
||||
V.supply_name AS "VENDOR_NAME",
|
||||
COALESCE((
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function POST(req: NextRequest) {
|
||||
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,$18::date,$19::date,'N',NOW(),$17)`,
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13,$14,$15,$16,$18::timestamp,$19::timestamp,'N',NOW(),$17)`,
|
||||
[newId, itemCode, cleanName, itemDetail ?? null, makerObjid ?? null, vendorObjid ?? null,
|
||||
unit ?? "EA", Number(unitPrice ?? 0), Number(costPrice ?? 0),
|
||||
taxFree, imageUrl ?? null,
|
||||
@@ -75,7 +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,
|
||||
sale_start_date=$17::timestamp, sale_end_date=$18::timestamp,
|
||||
update_date=NOW(), update_id=$16
|
||||
WHERE objid=$1`,
|
||||
[objid, cleanName, itemDetail ?? null, makerObjid ?? null, unit ?? "EA",
|
||||
|
||||
Reference in New Issue
Block a user