diff --git a/.claude/settings.json b/.claude/settings.json index 4aa68cd..19098eb 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -12,7 +12,13 @@ "Bash(docker cp *)", "Bash(ssh *)", "Bash(scp *)", - "Bash(sshpass *)" + "Bash(sshpass *)", + "Bash(*)", + "Read(*)", + "Edit(*)", + "Write(*)", + "Glob(*)", + "Grep(*)" ] } -} +} \ No newline at end of file diff --git a/src/app/(main)/m/admin/inventory/history/page.tsx b/src/app/(main)/m/admin/inventory/history/page.tsx new file mode 100644 index 0000000..81c4654 --- /dev/null +++ b/src/app/(main)/m/admin/inventory/history/page.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; + +interface Move { + OBJID: string; + WH_NAME: string; + WH_CODE: string; + ITEM_CODE: string; + ITEM_NAME: string; + UNIT: string; + MOVE_TYPE: string; + MOVE_TYPE_NAME: string; + QTY: number; + REF_TYPE: string; + REF_OBJID: string; + MEMO: string; + REGID: string; + REGDATE: string; +} + +interface Wh { OBJID: string; WH_NAME: string } + +const MOVE_TYPE_BADGE: Record = { + IN: "bg-emerald-100 text-emerald-700", + OUT: "bg-rose-100 text-rose-700", + ADJ: "bg-amber-100 text-amber-700", + TRANSFER: "bg-blue-100 text-blue-700", +}; + +const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR"); + +export default function InventoryHistoryPage() { + const [list, setList] = useState([]); + const [whs, setWhs] = useState([]); + const [loading, setLoading] = useState(false); + + const [whFilter, setWhFilter] = useState(""); + const [moveType, setMoveType] = useState(""); + const [keyword, setKeyword] = useState(""); + const [dateFrom, setDateFrom] = useState(() => { + const d = new Date(); + d.setDate(1); + return d.toISOString().slice(0, 10); + }); + const [dateTo, setDateTo] = useState(() => new Date().toISOString().slice(0, 10)); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await fetch("/api/m/inventory/history", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + whObjid: whFilter || undefined, + moveType: moveType || undefined, + keyword: keyword || undefined, + dateFrom: dateFrom || undefined, + dateTo: dateTo || undefined, + }), + }); + setList((await res.json()).RESULTLIST ?? []); + } finally { + setLoading(false); + } + }, [whFilter, moveType, keyword, dateFrom, dateTo]); + + const loadWhs = async () => { + const res = await fetch("/api/m/warehouses/list", { method: "POST" }); + setWhs((await res.json()).RESULTLIST ?? []); + }; + + useEffect(() => { + loadWhs(); + load(); + }, []); // eslint-disable-line + + const inTotal = list.filter((r) => r.MOVE_TYPE === "IN").reduce((s, r) => s + Number(r.QTY), 0); + const outTotal = list.filter((r) => r.MOVE_TYPE === "OUT").reduce((s, r) => s + Number(r.QTY), 0); + + return ( +
+
+

재고 이력 조회

+
+ + {/* 검색 영역 */} +
+
+
+ + +
+
+ + +
+
+ + setDateFrom(e.target.value)} + className="w-full h-9 px-2 rounded-lg border border-slate-200 text-sm outline-none" + /> +
+
+ + setDateTo(e.target.value)} + className="w-full h-9 px-2 rounded-lg border border-slate-200 text-sm outline-none" + /> +
+
+ + setKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && load()} + placeholder="품목명/코드" + className="w-full h-9 px-2 rounded-lg border border-slate-200 text-sm outline-none" + /> +
+
+
+ +
+
+ + {/* 요약 카드 */} +
+
+

총 이력

+

{list.length.toLocaleString()}

+
+
+

입고 수량 합계

+

{fmt(inTotal)}

+
+
+

출고 수량 합계

+

{fmt(outTotal)}

+
+
+ + {/* 테이블 */} +
+ + + + + + + + + + + + + + + + {loading ? ( + + ) : list.length === 0 ? ( + + ) : ( + list.map((m) => ( + + + + + + + + + + + + )) + )} + +
유형창고품목코드품목명수량참조유형메모처리자일시
조회 중...
이력이 없습니다.
+ + {m.MOVE_TYPE_NAME} + + {m.WH_NAME || "-"}{m.ITEM_CODE}{m.ITEM_NAME} + + {m.MOVE_TYPE === "OUT" ? "-" : "+"}{fmt(m.QTY)} + + {m.REF_TYPE || "-"}{m.MEMO || "-"}{m.REGID || "-"}{m.REGDATE}
+ {list.length > 0 && ( +
+ 최대 500건 표시 +
+ )} +
+
+ ); +} diff --git a/src/app/(main)/m/admin/inventory/page.tsx b/src/app/(main)/m/admin/inventory/page.tsx index 0d88418..b27d76a 100644 --- a/src/app/(main)/m/admin/inventory/page.tsx +++ b/src/app/(main)/m/admin/inventory/page.tsx @@ -1,7 +1,8 @@ "use client"; import { useEffect, useState } from "react"; -import { Plus, Search, Trash2 } from "lucide-react"; +import { Plus, Search, Trash2, History } from "lucide-react"; +import { useRouter } from "next/navigation"; 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 } @@ -12,6 +13,7 @@ interface InboundLine { itemObjid: string; itemName: string; qty: number; costPr const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR"); export default function InventoryPage() { + const router = useRouter(); const [list, setList] = useState([]); const [whs, setWhs] = useState([]); const [items, setItems] = useState([]); @@ -75,9 +77,17 @@ export default function InventoryPage() {

재고 관리

- +
+ + +
diff --git a/src/app/(main)/m/admin/items/page.tsx b/src/app/(main)/m/admin/items/page.tsx index ca62f78..2fd3faa 100644 --- a/src/app/(main)/m/admin/items/page.tsx +++ b/src/app/(main)/m/admin/items/page.tsx @@ -5,35 +5,82 @@ import { Plus, Search, Pencil, Trash2, Upload } from "lucide-react"; import Swal from "sweetalert2"; interface Item { - OBJID: string; ITEM_CODE: string; ITEM_NAME: string; ITEM_DETAIL: string; - MAKER_OBJID: string; MAKER_NAME: string; UNIT: string; UNIT_PRICE: number; - COST_PRICE: number; IS_TAX_FREE: string; IMAGE_URL: string; STATUS: string; + OBJID: string; + ITEM_CODE: string; + ITEM_NAME: string; + ITEM_DETAIL: string; + MAKER_OBJID: string; + MAKER_NAME: string; + UNIT: string; + UNIT_PRICE: number; + COST_PRICE: number; + IS_TAX_FREE: string; + IMAGE_URL: string; + STATUS: string; + STOCK_QTY: number; + ATTRIBUTES: Record | null; } + interface Maker { OBJID: string; MAKER_NAME: string } +interface ItemAttributes { + expiry_date?: string; // 소비기한 (유통기한) + origin?: string; // 원산지 + inbound_price?: number; // 입고가 + storage_temp?: string; // 보관온도 + barcode?: string; // 바코드 + memo?: string; // 제조관리 메모 +} + const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR"); export default function AdminItemsPage() { const [items, setItems] = useState([]); const [makers, setMakers] = useState([]); const [keyword, setKeyword] = useState(""); + const [filterStatus, setFilterStatus] = useState(""); const [editing, setEditing] = useState | null>(null); + const [attrs, setAttrs] = useState({}); const [uploading, setUploading] = useState(false); const fileRef = useRef(null); const loadItems = async () => { const res = await fetch("/api/m/items/list", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ keyword }), + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keyword, status: filterStatus || undefined }), }); setItems((await res.json()).RESULTLIST ?? []); }; + const loadMakers = async () => { - const res = await fetch("/api/m/makers/list", { method: "POST" }); + const res = await fetch("/api/m/makers/list", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); setMakers((await res.json()).RESULTLIST ?? []); }; - useEffect(() => { loadItems(); loadMakers(); }, []); // eslint-disable-line + useEffect(() => { + loadItems(); + loadMakers(); + }, []); // eslint-disable-line + + const openEdit = (item: Partial) => { + setEditing(item); + try { + const a = item.ATTRIBUTES; + setAttrs(typeof a === "object" && a !== null ? (a as ItemAttributes) : {}); + } catch { + setAttrs({}); + } + }; + + const openNew = () => { + setEditing({ ITEM_NAME: "", UNIT: "EA", IS_TAX_FREE: "N", STATUS: "ACTIVE" }); + setAttrs({}); + }; const onSave = async (e: FormEvent) => { e.preventDefault(); @@ -51,14 +98,18 @@ export default function AdminItemsPage() { isTaxFree: editing.IS_TAX_FREE, imageUrl: editing.IMAGE_URL, status: editing.STATUS || "ACTIVE", + attributes: Object.keys(attrs).length > 0 ? attrs : null, }; const res = await fetch("/api/m/items/save", { - method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), }); const j = await res.json(); if (j.success) { Swal.fire({ icon: "success", title: "저장되었습니다", timer: 1500, showConfirmButton: false }); setEditing(null); + setAttrs({}); loadItems(); } else { Swal.fire({ icon: "error", title: "저장 실패", text: j.message }); @@ -83,21 +134,30 @@ export default function AdminItemsPage() { }; const onDelete = async (objid: string) => { - const ok = await Swal.fire({ icon: "warning", title: "삭제하시겠습니까?", showCancelButton: true, confirmButtonColor: "#dc2626" }); + const ok = await Swal.fire({ + icon: "warning", + title: "삭제하시겠습니까?", + showCancelButton: true, + confirmButtonColor: "#dc2626", + }); if (!ok.isConfirmed) return; const res = await fetch("/api/m/items/delete", { - method: "POST", headers: { "Content-Type": "application/json" }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ objids: [objid] }), }); if ((await res.json()).success) loadItems(); }; + const setAttr = (k: keyof ItemAttributes, v: string | number) => + setAttrs((prev) => ({ ...prev, [k]: v })); + return (
-

품목 관리

+

품목 관리

+ +
+
총 {items.length}개 품목
+
@@ -127,94 +204,122 @@ export default function AdminItemsPage() { + + - + {items.length === 0 ? ( - - ) : items.map((it) => ( - - - - - - - - - + - ))} + ) : ( + items.map((it) => ( + + + + + + + + + + + + + )) + )}
제조사 구분 단가원가재고 상태작업작업
품목이 없습니다. 신규 등록 버튼을 눌러주세요.
-
- {it.IMAGE_URL ? : null} -
-
{it.ITEM_CODE}{it.ITEM_NAME}{it.MAKER_NAME || "-"} - {it.IS_TAX_FREE === "Y" - ? 면세 - : 과세} - ₩{fmt(it.UNIT_PRICE)} - - {it.STATUS === "ACTIVE" ? "사용" : "중지"} - - - - +
+ 품목이 없습니다. 신규 등록 버튼을 눌러주세요.
+
+ {it.IMAGE_URL ? ( + + ) : ( + 없음 + )} +
+
{it.ITEM_CODE}{it.ITEM_NAME}{it.MAKER_NAME || "-"} + {it.IS_TAX_FREE === "Y" ? ( + 면세 + ) : ( + 과세 + )} + ₩{fmt(it.UNIT_PRICE)}₩{fmt(it.COST_PRICE)} + + {fmt(it.STOCK_QTY)} + + + + {it.STATUS === "ACTIVE" ? "사용" : "중지"} + + + + +
{/* 등록/수정 모달 */} {editing && ( -
setEditing(null)}> -
e.stopPropagation()} className="bg-white rounded-xl shadow-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto"> -

{editing.OBJID ? "품목 수정" : "품목 등록"}

-
+
setEditing(null)} + > + e.stopPropagation()} + className="bg-white rounded-xl shadow-xl max-w-3xl w-full p-6 max-h-[92vh] overflow-y-auto" + > +

+ {editing.OBJID ? "품목 수정" : "품목 등록"} +

+ + {/* 기본 정보 */} +

기본 정보

+
setEditing({ ...editing, ITEM_NAME: e.target.value })} - className="w-full h-10 px-3 rounded-lg border border-slate-200" + className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none" /> - setEditing({ ...editing, UNIT: e.target.value })} - className="w-full h-10 px-3 rounded-lg border border-slate-200" - /> - - - setEditing({ ...editing, UNIT_PRICE: Number(e.target.value) })} - className="w-full h-10 px-3 rounded-lg border border-slate-200" - /> - - - setEditing({ ...editing, COST_PRICE: Number(e.target.value) })} - className="w-full h-10 px-3 rounded-lg border border-slate-200" - /> + className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white focus:border-emerald-500 outline-none" + > + + + + + + -
-