diff --git a/src/app/(main)/m/admin/items/page.tsx b/src/app/(main)/m/admin/items/page.tsx index fce6d6c..8620483 100644 --- a/src/app/(main)/m/admin/items/page.tsx +++ b/src/app/(main)/m/admin/items/page.tsx @@ -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([]); const [vendors, setVendors] = useState([]); @@ -502,18 +505,18 @@ export default function AdminItemsPage() { - + 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" /> - + 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 (
- + setFrom(e.target.value)} className="h-9 px-2 rounded-md border border-emerald-300 text-sm bg-white" />
- + setTo(e.target.value)} className="h-9 px-2 rounded-md border border-emerald-300 text-sm bg-white" diff --git a/src/app/(main)/m/orders/new/page.tsx b/src/app/(main)/m/orders/new/page.tsx index 07774bc..90ab094 100644 --- a/src/app/(main)/m/orders/new/page.tsx +++ b/src/app/(main)/m/orders/new/page.tsx @@ -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} /> ) : ( -
+
{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}
+ {it.SALE_END_DATE && ( +
+ ⏰ {it.SALE_END_DATE} 마감 +
+ )} {maxQ > 0 && !unlimitedQty && (
한도 ≤ {fmt(maxQ)}
)} @@ -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 (
@@ -677,6 +717,7 @@ function ListView({ items, cart, unlimitedQty, onAdd, onPlus, onMinus, onSetQty, 구분 단가 재고 + 마감 수량 @@ -706,6 +747,9 @@ function ListView({ items, cart, unlimitedQty, onAdd, onPlus, onMinus, onSetQty, ₩{Number(it.UNIT_PRICE).toLocaleString("ko-KR")} {Number(stock).toLocaleString("ko-KR")} + + {it.SALE_END_DATE ? it.SALE_END_DATE : 상시} + {soldOut ? (
품절
@@ -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; diff --git a/src/app/api/m/admin/daily-order-inventory/route.ts b/src/app/api/m/admin/daily-order-inventory/route.ts index 5e4e6f7..335da2d 100644 --- a/src/app/api/m/admin/daily-order-inventory/route.ts +++ b/src/app/api/m/admin/daily-order-inventory/route.ts @@ -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) diff --git a/src/app/api/m/items/bulk-sale-range/route.ts b/src/app/api/m/items/bulk-sale-range/route.ts index 2638495..23e4d72 100644 --- a/src/app/api/m/items/bulk-sale-range/route.ts +++ b/src/app/api/m/items/bulk-sale-range/route.ts @@ -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})`, diff --git a/src/app/api/m/items/list/route.ts b/src/app/api/m/items/list/route.ts index 9f6f043..bd22344 100644 --- a/src/app/api/m/items/list/route.ts +++ b/src/app/api/m/items/list/route.ts @@ -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(( diff --git a/src/app/api/m/items/save/route.ts b/src/app/api/m/items/save/route.ts index bb26af6..2ffecc0 100644 --- a/src/app/api/m/items/save/route.ts +++ b/src/app/api/m/items/save/route.ts @@ -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",