feat(inventory+warehouse): 재고 이동 + 모바일 카드 + 창고코드 자동생성
Deploy momo-erp / deploy (push) Failing after 35s
Deploy momo-erp / deploy (push) Failing after 35s
[재고 관리] - 새 기능 '재고 이동' (A창고 → B창고): 출발창고 잠금 + 충분재고 검증 + 도착 창고 upsert + 이동 로그 2건(OUT/IN, ref_type=TRANSFER) 트랜잭션 처리 - /api/m/inventory/transfer 신규 - 모바일에서 테이블 가로 스크롤이 안 되던 문제 → sm:hidden 카드 + sm:block desktop 테이블로 분리. 페이지 자체 스크롤로 자연스러운 UX - 검색 영역 모바일 1열 / sm 3열 그리드 정리 - 재고 이동 모달은 출발창고 선택 시 그 창고에 재고 있는 품목만 셀렉트 - list API 응답에 WH_OBJID 추가 (이동 모달에서 출발창고 필터 용도) [창고 관리] - 창고 코드는 자동생성(WH001, WH002 ...) — 등록/수정 폼에서 readonly + 회색. save API: regist 시 nextWhCode() 로 MAX+1 패딩. update 시 wh_code 미변경 - 클라이언트가 whCode 보내도 무시되도록 서버에서 분기 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Plus, Search, Trash2, History } from "lucide-react";
|
import { Plus, Search, Trash2, History, ArrowRightLeft, Package } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Swal from "sweetalert2";
|
import Swal from "sweetalert2";
|
||||||
|
|
||||||
interface Stock { OBJID: string; WH_CODE: string; WH_NAME: string; ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT: string; IS_TAX_FREE: string; QTY: number; UPDATE_DATE: string }
|
interface Stock { OBJID: string; WH_OBJID: string; WH_CODE: string; WH_NAME: string; ITEM_OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT: string; IS_TAX_FREE: string; QTY: number; UPDATE_DATE: string }
|
||||||
interface Wh { OBJID: string; WH_CODE: string; WH_NAME: string }
|
interface Wh { OBJID: string; WH_CODE: string; WH_NAME: string }
|
||||||
interface Item { OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT_PRICE: number }
|
interface Item { OBJID: string; ITEM_CODE: string; ITEM_NAME: string; UNIT_PRICE: number }
|
||||||
interface InboundLine { itemObjid: string; itemName: string; qty: number; costPrice?: number }
|
interface InboundLine { itemObjid: string; itemName: string; qty: number; costPrice?: number }
|
||||||
@@ -20,6 +20,7 @@ export default function InventoryPage() {
|
|||||||
const [whFilter, setWhFilter] = useState("");
|
const [whFilter, setWhFilter] = useState("");
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const [inboundOpen, setInboundOpen] = useState(false);
|
const [inboundOpen, setInboundOpen] = useState(false);
|
||||||
|
const [transferOpen, setTransferOpen] = useState(false);
|
||||||
|
|
||||||
// 입고 폼
|
// 입고 폼
|
||||||
const [inboundWh, setInboundWh] = useState("");
|
const [inboundWh, setInboundWh] = useState("");
|
||||||
@@ -27,6 +28,12 @@ export default function InventoryPage() {
|
|||||||
const [pickItem, setPickItem] = useState("");
|
const [pickItem, setPickItem] = useState("");
|
||||||
const [pickQty, setPickQty] = useState(1);
|
const [pickQty, setPickQty] = useState(1);
|
||||||
|
|
||||||
|
// 이동 폼
|
||||||
|
const [trFrom, setTrFrom] = useState("");
|
||||||
|
const [trTo, setTrTo] = useState("");
|
||||||
|
const [trItem, setTrItem] = useState("");
|
||||||
|
const [trQty, setTrQty] = useState(1);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const res = await fetch("/api/m/inventory/list", {
|
const res = await fetch("/api/m/inventory/list", {
|
||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
@@ -73,84 +80,164 @@ export default function InventoryPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 출발 창고에 재고가 있는 품목만 추리기 (현재 화면 list 기준)
|
||||||
|
const trItemsInFrom = useMemo(() => {
|
||||||
|
if (!trFrom) return [] as Stock[];
|
||||||
|
return list.filter((s) => s.WH_OBJID === trFrom && Number(s.QTY) > 0);
|
||||||
|
}, [list, trFrom]);
|
||||||
|
|
||||||
|
const trCurrent = useMemo(() => {
|
||||||
|
if (!trFrom || !trItem) return 0;
|
||||||
|
return Number(list.find((s) => s.WH_OBJID === trFrom && s.ITEM_OBJID === trItem)?.QTY ?? 0);
|
||||||
|
}, [list, trFrom, trItem]);
|
||||||
|
|
||||||
|
const submitTransfer = async () => {
|
||||||
|
if (!trFrom || !trTo || !trItem || trQty <= 0) {
|
||||||
|
Swal.fire({ icon: "warning", title: "출발창고/도착창고/품목/수량을 입력하세요." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trFrom === trTo) {
|
||||||
|
Swal.fire({ icon: "warning", title: "출발창고와 도착창고가 같습니다." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trQty > trCurrent) {
|
||||||
|
Swal.fire({ icon: "warning", title: "재고 부족", text: `현재고 ${trCurrent}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch("/api/m/inventory/transfer", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ fromWhObjid: trFrom, toWhObjid: trTo, itemObjid: trItem, qty: trQty }),
|
||||||
|
});
|
||||||
|
const j = await res.json().catch(() => ({ success: false, message: "응답 파싱 실패" }));
|
||||||
|
if (j.success) {
|
||||||
|
Swal.fire({ icon: "success", title: "재고 이동 완료", text: j.message, timer: 1500, showConfirmButton: false });
|
||||||
|
setTransferOpen(false);
|
||||||
|
setTrFrom(""); setTrTo(""); setTrItem(""); setTrQty(1);
|
||||||
|
load();
|
||||||
|
} else {
|
||||||
|
Swal.fire({ icon: "error", title: "이동 실패", text: j.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<h1 className="text-2xl font-bold">재고 관리</h1>
|
<h1 className="text-xl sm:text-2xl font-bold">재고 관리</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/m/admin/inventory/history")}
|
onClick={() => router.push("/m/admin/inventory/history")}
|
||||||
className="px-4 h-10 inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white text-slate-700 text-sm font-semibold hover:bg-slate-50"
|
className="h-9 sm:h-10 px-3 inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white text-slate-700 text-xs sm:text-sm font-semibold hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
<History size={15} /> 재고 이력
|
<History size={14} /> 이력
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setInboundOpen(true)} className="px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-sm font-bold">
|
<button
|
||||||
<Plus size={16} /> 매입 입고
|
onClick={() => setTransferOpen(true)}
|
||||||
|
className="h-9 sm:h-10 px-3 inline-flex items-center gap-1.5 rounded-lg bg-sky-600 text-white text-xs sm:text-sm font-bold hover:bg-sky-700"
|
||||||
|
>
|
||||||
|
<ArrowRightLeft size={14} /> 재고 이동
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setInboundOpen(true)} className="h-9 sm:h-10 px-3 inline-flex items-center gap-1.5 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold hover:bg-emerald-800">
|
||||||
|
<Plus size={14} /> 매입 입고
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
{/* 검색 영역 — 모바일 1열 / sm 2열 / lg 옆으로 */}
|
||||||
<select value={whFilter} onChange={(e) => setWhFilter(e.target.value)} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
|
<div className="bg-white border border-slate-200 rounded-xl p-2.5">
|
||||||
<option value="">전체 창고</option>
|
<div className="grid grid-cols-1 sm:grid-cols-[1fr_2fr_auto] gap-2">
|
||||||
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
|
<select value={whFilter} onChange={(e) => setWhFilter(e.target.value)} className="h-9 px-3 rounded-lg border border-slate-200 text-sm bg-white">
|
||||||
</select>
|
<option value="">전체 창고</option>
|
||||||
<div className="relative flex-1 max-w-sm">
|
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
|
||||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
</select>
|
||||||
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && load()} placeholder="품목명/코드" className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm" />
|
<div className="relative">
|
||||||
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} onKeyDown={(e) => e.key === "Enter" && load()} placeholder="품목명 / 코드" className="w-full h-9 pl-8 pr-3 rounded-lg border border-slate-200 text-sm" />
|
||||||
|
</div>
|
||||||
|
<button onClick={load} className="h-9 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</button>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
{/* 데스크톱 테이블 */}
|
||||||
<table className="w-full text-sm">
|
<div className="hidden sm:block bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||||
<thead className="bg-slate-50 text-slate-600">
|
<div className="overflow-x-auto">
|
||||||
<tr>
|
<table className="w-full text-sm min-w-[640px]">
|
||||||
<th className="text-left px-4 py-3">창고</th>
|
<thead className="bg-slate-50 text-slate-600">
|
||||||
<th className="text-left px-4 py-3">품목코드</th>
|
<tr>
|
||||||
<th className="text-left px-4 py-3">품목명</th>
|
<th className="text-left px-4 py-3">창고</th>
|
||||||
<th className="text-center px-4 py-3">구분</th>
|
<th className="text-left px-4 py-3">품목코드</th>
|
||||||
<th className="text-right px-4 py-3">현재고</th>
|
<th className="text-left px-4 py-3">품목명</th>
|
||||||
<th className="text-left px-4 py-3">최종 변경</th>
|
<th className="text-center px-4 py-3">구분</th>
|
||||||
</tr>
|
<th className="text-right px-4 py-3">현재고</th>
|
||||||
</thead>
|
<th className="text-left px-4 py-3">최종 변경</th>
|
||||||
<tbody>
|
|
||||||
{list.length === 0 ? (
|
|
||||||
<tr><td colSpan={6} className="text-center py-12 text-slate-400">재고 데이터가 없습니다. 매입 입고로 등록하세요.</td></tr>
|
|
||||||
) : list.map((s) => (
|
|
||||||
<tr key={s.OBJID} className="border-t border-slate-100">
|
|
||||||
<td className="px-4 py-3">{s.WH_NAME}</td>
|
|
||||||
<td className="px-4 py-3 font-mono text-xs">{s.ITEM_CODE}</td>
|
|
||||||
<td className="px-4 py-3 font-semibold">{s.ITEM_NAME}</td>
|
|
||||||
<td className="px-4 py-3 text-center">
|
|
||||||
{s.IS_TAX_FREE === "Y"
|
|
||||||
? <span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold">면세</span>
|
|
||||||
: <span className="px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 text-[10px] font-bold">과세</span>}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right tabular-nums font-bold">{fmt(s.QTY)} {s.UNIT}</td>
|
|
||||||
<td className="px-4 py-3 text-slate-500 text-xs">{s.UPDATE_DATE}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{list.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} className="text-center py-12 text-slate-400">재고 데이터가 없습니다. 매입 입고로 등록하세요.</td></tr>
|
||||||
|
) : list.map((s) => (
|
||||||
|
<tr key={s.OBJID} className="border-t border-slate-100">
|
||||||
|
<td className="px-4 py-3">{s.WH_NAME}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">{s.ITEM_CODE}</td>
|
||||||
|
<td className="px-4 py-3 font-semibold">{s.ITEM_NAME}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{s.IS_TAX_FREE === "Y"
|
||||||
|
? <span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[10px] font-bold">면세</span>
|
||||||
|
: <span className="px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 text-[10px] font-bold">과세</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums font-bold">{fmt(s.QTY)} {s.UNIT}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-500 text-xs">{s.UPDATE_DATE}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모바일 카드 — 페이지 자체 스크롤 사용 (테이블 가로 스크롤 X) */}
|
||||||
|
<div className="sm:hidden space-y-2">
|
||||||
|
{list.length === 0 ? (
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl py-12 text-center text-slate-400 text-sm">
|
||||||
|
<Package size={32} className="mx-auto mb-2 opacity-50" />
|
||||||
|
재고 데이터가 없습니다.
|
||||||
|
</div>
|
||||||
|
) : list.map((s) => (
|
||||||
|
<div key={s.OBJID} className="bg-white border border-slate-200 rounded-xl p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[10px] text-slate-400 font-mono">{s.ITEM_CODE}</div>
|
||||||
|
<div className="font-bold text-sm text-slate-900 truncate">{s.ITEM_NAME}</div>
|
||||||
|
<div className="text-[11px] text-slate-500 mt-0.5">{s.WH_NAME}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<div className="text-lg font-bold tabular-nums text-emerald-700">{fmt(s.QTY)}<span className="text-xs ml-0.5 text-slate-500">{s.UNIT}</span></div>
|
||||||
|
{s.IS_TAX_FREE === "Y"
|
||||||
|
? <span className="px-1.5 py-0.5 rounded bg-violet-100 text-violet-700 text-[9px] font-bold">면세</span>
|
||||||
|
: <span className="px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 text-[9px] font-bold">과세</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 pt-2 border-t border-slate-100 text-[10px] text-slate-400">
|
||||||
|
최종 변경 · {s.UPDATE_DATE}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 입고 모달 */}
|
{/* 입고 모달 */}
|
||||||
{inboundOpen && (
|
{inboundOpen && (
|
||||||
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-4" onClick={() => setInboundOpen(false)}>
|
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4" onClick={() => setInboundOpen(false)}>
|
||||||
<div onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
<div onClick={(e) => e.stopPropagation()} className="bg-white rounded-t-xl sm:rounded-xl max-w-2xl w-full p-4 sm:p-6 max-h-[90vh] overflow-y-auto">
|
||||||
<h3 className="font-bold mb-4">매입 입고 등록</h3>
|
<h3 className="font-bold mb-4">매입 입고 등록</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<select value={inboundWh} onChange={(e) => setInboundWh(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200">
|
<select value={inboundWh} onChange={(e) => setInboundWh(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 bg-white">
|
||||||
<option value="">창고 선택</option>
|
<option value="">창고 선택</option>
|
||||||
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
|
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<div className="flex gap-2">
|
<div className="grid grid-cols-[1fr_80px_auto] gap-2">
|
||||||
<select value={pickItem} onChange={(e) => setPickItem(e.target.value)} className="flex-1 h-10 px-3 rounded-lg border border-slate-200">
|
<select value={pickItem} onChange={(e) => setPickItem(e.target.value)} className="h-10 px-3 rounded-lg border border-slate-200 bg-white min-w-0">
|
||||||
<option value="">품목 선택</option>
|
<option value="">품목 선택</option>
|
||||||
{items.map((i) => <option key={i.OBJID} value={i.OBJID}>{i.ITEM_NAME}</option>)}
|
{items.map((i) => <option key={i.OBJID} value={i.OBJID}>{i.ITEM_NAME}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<input type="number" min={1} value={pickQty} onChange={(e) => setPickQty(Number(e.target.value))} className="w-24 h-10 px-3 rounded-lg border border-slate-200" />
|
<input type="number" min={1} value={pickQty} onChange={(e) => setPickQty(Number(e.target.value))} className="h-10 px-3 rounded-lg border border-slate-200" />
|
||||||
<button onClick={addLine} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">추가</button>
|
<button onClick={addLine} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">추가</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="border border-slate-200 rounded-lg max-h-60 overflow-y-auto">
|
<div className="border border-slate-200 rounded-lg max-h-60 overflow-y-auto">
|
||||||
@@ -158,7 +245,7 @@ export default function InventoryPage() {
|
|||||||
<div className="text-center text-slate-400 text-sm py-8">품목을 추가하세요</div>
|
<div className="text-center text-slate-400 text-sm py-8">품목을 추가하세요</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-slate-50 text-slate-600 text-xs">
|
<thead className="bg-slate-50 text-slate-600 text-xs sticky top-0">
|
||||||
<tr><th className="px-3 py-2 text-left">품목</th><th className="px-3 py-2 text-right">수량</th><th></th></tr>
|
<tr><th className="px-3 py-2 text-left">품목</th><th className="px-3 py-2 text-right">수량</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -183,6 +270,51 @@ export default function InventoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 재고 이동 모달 — A창고 → B창고 */}
|
||||||
|
{transferOpen && (
|
||||||
|
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4" onClick={() => setTransferOpen(false)}>
|
||||||
|
<div onClick={(e) => e.stopPropagation()} className="bg-white rounded-t-xl sm:rounded-xl max-w-md w-full p-4 sm:p-6 max-h-[90vh] overflow-y-auto">
|
||||||
|
<h3 className="font-bold mb-1 inline-flex items-center gap-1.5"><ArrowRightLeft size={16} className="text-sky-600" /> 재고 이동</h3>
|
||||||
|
<p className="text-xs text-slate-500 mb-4">A창고에서 B창고로 수량을 옮깁니다.</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-slate-500 mb-1">출발 창고 (A)</label>
|
||||||
|
<select value={trFrom} onChange={(e) => { setTrFrom(e.target.value); setTrItem(""); }} className="w-full h-10 px-3 rounded-lg border border-slate-200 bg-white">
|
||||||
|
<option value="">선택</option>
|
||||||
|
{whs.map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-slate-500 mb-1">도착 창고 (B)</label>
|
||||||
|
<select value={trTo} onChange={(e) => setTrTo(e.target.value)} className="w-full h-10 px-3 rounded-lg border border-slate-200 bg-white">
|
||||||
|
<option value="">선택</option>
|
||||||
|
{whs.filter((w) => w.OBJID !== trFrom).map((w) => <option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-slate-500 mb-1">품목 {trFrom && <span className="text-slate-400">(출발창고 재고가 있는 품목만)</span>}</label>
|
||||||
|
<select value={trItem} onChange={(e) => setTrItem(e.target.value)} disabled={!trFrom} className="w-full h-10 px-3 rounded-lg border border-slate-200 bg-white disabled:bg-slate-50">
|
||||||
|
<option value="">{trFrom ? "선택" : "출발창고 먼저 선택"}</option>
|
||||||
|
{trItemsInFrom.map((s) => (
|
||||||
|
<option key={s.ITEM_OBJID} value={s.ITEM_OBJID}>{s.ITEM_NAME} (재고 {fmt(s.QTY)} {s.UNIT})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-slate-500 mb-1">
|
||||||
|
이동 수량 {trItem && <span className="text-emerald-700 font-bold">최대 {fmt(trCurrent)}</span>}
|
||||||
|
</label>
|
||||||
|
<input type="number" min={1} max={trCurrent || undefined} value={trQty} onChange={(e) => setTrQty(Number(e.target.value))} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-right tabular-nums" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end mt-5">
|
||||||
|
<button onClick={() => setTransferOpen(false)} className="px-4 h-10 rounded-lg border border-slate-200 text-sm font-semibold">취소</button>
|
||||||
|
<button onClick={submitTransfer} disabled={!trFrom || !trTo || !trItem || trQty <= 0} className="px-5 h-10 rounded-lg bg-sky-600 text-white text-sm font-bold disabled:opacity-50">이동</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function WarehousesPage() {
|
|||||||
method: "POST", headers: { "Content-Type": "application/json" },
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
objid: editing.OBJID, actionType: editing.OBJID ? "update" : "regist",
|
objid: editing.OBJID, actionType: editing.OBJID ? "update" : "regist",
|
||||||
whCode: editing.WH_CODE, whName: editing.WH_NAME,
|
whName: editing.WH_NAME,
|
||||||
location: editing.LOCATION, whType: editing.WH_TYPE || "STOCK",
|
location: editing.LOCATION, whType: editing.WH_TYPE || "STOCK",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -119,7 +119,14 @@ export default function WarehousesPage() {
|
|||||||
className="bg-white rounded-xl max-w-md w-full p-5 sm:p-6 max-h-[90vh] overflow-y-auto">
|
className="bg-white rounded-xl max-w-md w-full p-5 sm:p-6 max-h-[90vh] overflow-y-auto">
|
||||||
<h3 className="font-bold text-base sm:text-lg mb-4">{editing.OBJID ? "창고 수정" : "창고 추가"}</h3>
|
<h3 className="font-bold text-base sm:text-lg mb-4">{editing.OBJID ? "창고 수정" : "창고 추가"}</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<input required placeholder="코드 (예: WH005)" value={editing.WH_CODE ?? ""} onChange={(e) => setEditing({ ...editing, WH_CODE: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" />
|
<div>
|
||||||
|
<label className="block text-[11px] font-semibold text-slate-500 mb-1">코드 <span className="text-slate-400 font-normal">(자동 생성, 수정 불가)</span></label>
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={editing.OBJID ? (editing.WH_CODE ?? "") : "등록 시 자동 생성"}
|
||||||
|
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-slate-50 text-slate-500 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<input required placeholder="이름" value={editing.WH_NAME ?? ""} onChange={(e) => setEditing({ ...editing, WH_NAME: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" />
|
<input required placeholder="이름" value={editing.WH_NAME ?? ""} onChange={(e) => setEditing({ ...editing, WH_NAME: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm" />
|
||||||
<select value={editing.WH_TYPE ?? "STOCK"} onChange={(e) => setEditing({ ...editing, WH_TYPE: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
|
<select value={editing.WH_TYPE ?? "STOCK"} onChange={(e) => setEditing({ ...editing, WH_TYPE: e.target.value })} className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white">
|
||||||
{Object.entries(TYPE_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
{Object.entries(TYPE_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const rows = await queryRows(
|
const rows = await queryRows(
|
||||||
`SELECT
|
`SELECT
|
||||||
S.objid AS "OBJID",
|
S.objid AS "OBJID",
|
||||||
|
W.objid AS "WH_OBJID",
|
||||||
W.wh_code AS "WH_CODE",
|
W.wh_code AS "WH_CODE",
|
||||||
W.wh_name AS "WH_NAME",
|
W.wh_name AS "WH_NAME",
|
||||||
I.objid AS "ITEM_OBJID",
|
I.objid AS "ITEM_OBJID",
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// 재고 이동 (A창고 → B창고). 출발창고 차감 + 도착창고 가산 + 이동 로그 2건
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
import { createObjectId } from "@/lib/utils";
|
||||||
|
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const g = await requireMomoAdmin();
|
||||||
|
if (g instanceof NextResponse) return g;
|
||||||
|
const userId = g.user.objid || g.user.userId;
|
||||||
|
|
||||||
|
const { fromWhObjid, toWhObjid, itemObjid, qty, memo } = await req.json().catch(() => ({})) as {
|
||||||
|
fromWhObjid?: string; toWhObjid?: string; itemObjid?: string; qty?: number; memo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!fromWhObjid || !toWhObjid || !itemObjid || !qty || qty <= 0) {
|
||||||
|
return NextResponse.json({ success: false, message: "출발창고/도착창고/품목/수량을 입력하세요." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (fromWhObjid === toWhObjid) {
|
||||||
|
return NextResponse.json({ success: false, message: "출발창고와 도착창고가 같습니다." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 출발 재고 잠금 + 충분 여부 확인
|
||||||
|
const cur = await client.query<{ qty: number }>(
|
||||||
|
`SELECT qty::int FROM momo_stocks WHERE wh_objid=$1 AND item_objid=$2 FOR UPDATE`,
|
||||||
|
[fromWhObjid, itemObjid]
|
||||||
|
);
|
||||||
|
const have = cur.rows[0]?.qty ?? 0;
|
||||||
|
if (have < qty) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return NextResponse.json({ success: false, message: `출발창고 재고가 부족합니다. (현재고 ${have})` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 출발 차감
|
||||||
|
await client.query(
|
||||||
|
`UPDATE momo_stocks SET qty = qty - $3, update_date = NOW()
|
||||||
|
WHERE wh_objid = $1 AND item_objid = $2`,
|
||||||
|
[fromWhObjid, itemObjid, qty]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 도착 가산 (없으면 생성)
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO momo_stocks (objid, wh_objid, item_objid, qty, update_date)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
|
ON CONFLICT (wh_objid, item_objid) DO UPDATE
|
||||||
|
SET qty = momo_stocks.qty + EXCLUDED.qty, update_date = NOW()`,
|
||||||
|
[createObjectId(), toWhObjid, itemObjid, qty]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 이동 로그 — 출발(OUT) + 도착(IN)
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO momo_stock_moves (objid, wh_objid, item_objid, move_type, qty, ref_type, memo, regdate, regid)
|
||||||
|
VALUES ($1, $2, $3, 'OUT', $4, 'TRANSFER', $5, NOW(), $6),
|
||||||
|
($7, $8, $3, 'IN', $4, 'TRANSFER', $5, NOW(), $6)`,
|
||||||
|
[createObjectId(), fromWhObjid, itemObjid, qty, memo ?? null, userId, createObjectId(), toWhObjid]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return NextResponse.json({ success: true, message: `${qty} 단위 이동 완료` });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query("ROLLBACK").catch(() => {});
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error("[transfer]", err);
|
||||||
|
return NextResponse.json({ success: false, message: msg }, { status: 500 });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,40 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { execute } from "@/lib/db";
|
import { execute, queryOne } from "@/lib/db";
|
||||||
import { createObjectId } from "@/lib/utils";
|
import { createObjectId } from "@/lib/utils";
|
||||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||||
|
|
||||||
|
// 다음 자동 코드 생성: WH001, WH002 ... — 가장 큰 숫자 + 1
|
||||||
|
async function nextWhCode(): Promise<string> {
|
||||||
|
const row = await queryOne<{ MAX_CODE: string | null }>(
|
||||||
|
`SELECT MAX(wh_code) AS "MAX_CODE" FROM momo_warehouses WHERE wh_code ~ '^WH[0-9]+$'`
|
||||||
|
);
|
||||||
|
const max = row?.MAX_CODE;
|
||||||
|
const nextNum = max ? parseInt(max.replace(/^WH/, ""), 10) + 1 : 1;
|
||||||
|
return `WH${String(nextNum).padStart(3, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const g = await requireMomoAdmin();
|
const g = await requireMomoAdmin();
|
||||||
if (g instanceof NextResponse) return g;
|
if (g instanceof NextResponse) return g;
|
||||||
|
|
||||||
const { objid, actionType, whCode, whName, location, whType } = await req.json();
|
const { objid, actionType, whName, location, whType } = await req.json();
|
||||||
if (!whCode || !whName) {
|
if (!whName) {
|
||||||
return NextResponse.json({ success: false, message: "코드와 이름은 필수입니다." }, { status: 400 });
|
return NextResponse.json({ success: false, message: "창고명은 필수입니다." }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (actionType === "regist") {
|
if (actionType === "regist") {
|
||||||
const id = createObjectId();
|
const id = createObjectId();
|
||||||
|
const code = await nextWhCode();
|
||||||
await execute(
|
await execute(
|
||||||
`INSERT INTO momo_warehouses (objid, wh_code, wh_name, location, wh_type, regdate)
|
`INSERT INTO momo_warehouses (objid, wh_code, wh_name, location, wh_type, regdate)
|
||||||
VALUES ($1,$2,$3,$4,$5,NOW())`,
|
VALUES ($1,$2,$3,$4,$5,NOW())`,
|
||||||
[id, whCode, whName, location ?? null, whType ?? "STOCK"]
|
[id, code, whName, location ?? null, whType ?? "STOCK"]
|
||||||
);
|
);
|
||||||
return NextResponse.json({ success: true, objId: id });
|
return NextResponse.json({ success: true, objId: id, whCode: code });
|
||||||
}
|
}
|
||||||
|
// 수정 시 wh_code 는 손대지 않음
|
||||||
await execute(
|
await execute(
|
||||||
`UPDATE momo_warehouses SET wh_code=$2, wh_name=$3, location=$4, wh_type=$5 WHERE objid=$1`,
|
`UPDATE momo_warehouses SET wh_name=$2, location=$3, wh_type=$4 WHERE objid=$1`,
|
||||||
[objid, whCode, whName, location ?? null, whType ?? "STOCK"]
|
[objid, whName, location ?? null, whType ?? "STOCK"]
|
||||||
);
|
);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user