feat(sale-period): 판매기간 분 단위 + 재고초과 차단 + 모바일 2열 카드
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:
chpark
2026-05-20 21:17:47 +09:00
parent 5ba9b9f04e
commit b34121b597
6 changed files with 98 additions and 29 deletions
+13 -10
View File
@@ -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"
+56 -4
View File
@@ -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)
+2 -2
View File
@@ -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})`,
+20 -7
View File
@@ -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((
+2 -2
View File
@@ -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",