- 제조사 관리 페이지/API CRUD (momo_makers) - 재고 이력 조회 페이지/API (momo_stock_moves) - 품목 관리: 제조관리 JSONB 섹션(소비기한·입고가·원산지·보관온도·바코드) 추가 - 품목 목록 API: onlyAvailable 서브쿼리 WHERE 절로 수정 - 재고 관리 페이지: 재고 이력 버튼 추가 - admin-panel: 메뉴 관리 DB 상태 무관 최상단 고정 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,13 @@
|
||||
"Bash(docker cp *)",
|
||||
"Bash(ssh *)",
|
||||
"Bash(scp *)",
|
||||
"Bash(sshpass *)"
|
||||
"Bash(sshpass *)",
|
||||
"Bash(*)",
|
||||
"Read(*)",
|
||||
"Edit(*)",
|
||||
"Write(*)",
|
||||
"Glob(*)",
|
||||
"Grep(*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<Move[]>([]);
|
||||
const [whs, setWhs] = useState<Wh[]>([]);
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-800">재고 이력 조회</h1>
|
||||
</div>
|
||||
|
||||
{/* 검색 영역 */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 mb-1">창고</label>
|
||||
<select
|
||||
value={whFilter}
|
||||
onChange={(e) => setWhFilter(e.target.value)}
|
||||
className="w-full h-9 px-2 rounded-lg border border-slate-200 text-sm bg-white outline-none"
|
||||
>
|
||||
<option value="">전체 창고</option>
|
||||
{whs.map((w) => (
|
||||
<option key={w.OBJID} value={w.OBJID}>{w.WH_NAME}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 mb-1">이동 유형</label>
|
||||
<select
|
||||
value={moveType}
|
||||
onChange={(e) => setMoveType(e.target.value)}
|
||||
className="w-full h-9 px-2 rounded-lg border border-slate-200 text-sm bg-white outline-none"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="IN">입고</option>
|
||||
<option value="OUT">출고</option>
|
||||
<option value="ADJ">재고조정</option>
|
||||
<option value="TRANSFER">이동</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 mb-1">시작일</label>
|
||||
<input
|
||||
type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="w-full h-9 px-2 rounded-lg border border-slate-200 text-sm outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 mb-1">종료일</label>
|
||||
<input
|
||||
type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
|
||||
className="w-full h-9 px-2 rounded-lg border border-slate-200 text-sm outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 mb-1">품목 검색</label>
|
||||
<input
|
||||
value={keyword} onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end mt-3">
|
||||
<button
|
||||
onClick={load}
|
||||
className="h-9 px-5 rounded-lg bg-slate-800 text-white text-sm font-semibold"
|
||||
>
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요약 카드 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
|
||||
<p className="text-xs text-slate-500 mb-1">총 이력</p>
|
||||
<p className="text-2xl font-bold text-slate-800">{list.length.toLocaleString()}<span className="text-sm font-normal ml-1">건</span></p>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
|
||||
<p className="text-xs text-slate-500 mb-1">입고 수량 합계</p>
|
||||
<p className="text-2xl font-bold text-emerald-700">{fmt(inTotal)}</p>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
|
||||
<p className="text-xs text-slate-500 mb-1">출고 수량 합계</p>
|
||||
<p className="text-2xl font-bold text-rose-600">{fmt(outTotal)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-center px-3 py-3 w-[100px]">유형</th>
|
||||
<th className="text-left px-3 py-3">창고</th>
|
||||
<th className="text-left px-3 py-3">품목코드</th>
|
||||
<th className="text-left px-3 py-3">품목명</th>
|
||||
<th className="text-right px-3 py-3 w-[90px]">수량</th>
|
||||
<th className="text-left px-3 py-3 w-[80px]">참조유형</th>
|
||||
<th className="text-left px-3 py-3">메모</th>
|
||||
<th className="text-left px-3 py-3 w-[80px]">처리자</th>
|
||||
<th className="text-center px-3 py-3 w-[130px]">일시</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={9} className="text-center py-12 text-slate-400">조회 중...</td></tr>
|
||||
) : list.length === 0 ? (
|
||||
<tr><td colSpan={9} className="text-center py-12 text-slate-400">이력이 없습니다.</td></tr>
|
||||
) : (
|
||||
list.map((m) => (
|
||||
<tr key={m.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded font-bold ${MOVE_TYPE_BADGE[m.MOVE_TYPE] || "bg-slate-100 text-slate-600"}`}>
|
||||
{m.MOVE_TYPE_NAME}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-700 text-xs">{m.WH_NAME || "-"}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-slate-500">{m.ITEM_CODE}</td>
|
||||
<td className="px-3 py-2 font-semibold text-slate-800">{m.ITEM_NAME}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums font-bold">
|
||||
<span className={m.MOVE_TYPE === "OUT" ? "text-rose-600" : "text-emerald-700"}>
|
||||
{m.MOVE_TYPE === "OUT" ? "-" : "+"}{fmt(m.QTY)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-500">{m.REF_TYPE || "-"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-500 max-w-[200px] truncate">{m.MEMO || "-"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-500">{m.REGID || "-"}</td>
|
||||
<td className="px-3 py-2 text-center text-xs text-slate-500">{m.REGDATE}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.length > 0 && (
|
||||
<div className="px-4 py-2 border-t border-slate-100 text-xs text-slate-400 text-right">
|
||||
최대 500건 표시
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Stock[]>([]);
|
||||
const [whs, setWhs] = useState<Wh[]>([]);
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
@@ -75,9 +77,17 @@ export default function InventoryPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">재고 관리</h1>
|
||||
<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">
|
||||
<Plus size={16} /> 매입 입고
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<History size={15} /> 재고 이력
|
||||
</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">
|
||||
<Plus size={16} /> 매입 입고
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
|
||||
@@ -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<string, unknown> | 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<Item[]>([]);
|
||||
const [makers, setMakers] = useState<Maker[]>([]);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState("");
|
||||
const [editing, setEditing] = useState<Partial<Item> | null>(null);
|
||||
const [attrs, setAttrs] = useState<ItemAttributes>({});
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(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<Item>) => {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">품목 관리</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-800">품목 관리</h1>
|
||||
<button
|
||||
onClick={() => setEditing({ ITEM_NAME: "", UNIT: "EA", IS_TAX_FREE: "N", STATUS: "ACTIVE" })}
|
||||
onClick={openNew}
|
||||
className="px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800"
|
||||
>
|
||||
<Plus size={16} /> 신규 등록
|
||||
@@ -108,15 +168,32 @@ export default function AdminItemsPage() {
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={keyword} onChange={(e) => setKeyword(e.target.value)}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && loadItems()}
|
||||
placeholder="품목명/코드 검색"
|
||||
className="w-full h-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={loadItems} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</button>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none bg-white"
|
||||
>
|
||||
<option value="">전체 상태</option>
|
||||
<option value="ACTIVE">사용</option>
|
||||
<option value="INACTIVE">중지</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={loadItems}
|
||||
className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"
|
||||
>
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-400">총 {items.length}개 품목</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
@@ -127,94 +204,122 @@ export default function AdminItemsPage() {
|
||||
<th className="text-left px-3 py-3">제조사</th>
|
||||
<th className="text-center px-3 py-3">구분</th>
|
||||
<th className="text-right px-3 py-3">단가</th>
|
||||
<th className="text-right px-3 py-3">원가</th>
|
||||
<th className="text-right px-3 py-3">재고</th>
|
||||
<th className="text-center px-3 py-3">상태</th>
|
||||
<th className="text-right px-3 py-3">작업</th>
|
||||
<th className="text-right px-3 py-3 w-[70px]">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 ? (
|
||||
<tr><td colSpan={8} className="text-center py-12 text-slate-400">품목이 없습니다. 신규 등록 버튼을 눌러주세요.</td></tr>
|
||||
) : items.map((it) => (
|
||||
<tr key={it.OBJID} className="border-t border-slate-100">
|
||||
<td className="px-3 py-2">
|
||||
<div className="w-10 h-10 bg-slate-50 rounded overflow-hidden">
|
||||
{it.IMAGE_URL ? <img src={it.IMAGE_URL} alt="" className="w-full h-full object-cover" /> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{it.ITEM_CODE}</td>
|
||||
<td className="px-3 py-2 font-semibold">{it.ITEM_NAME}</td>
|
||||
<td className="px-3 py-2 text-slate-600">{it.MAKER_NAME || "-"}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{it.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-3 py-2 text-right tabular-nums">₩{fmt(it.UNIT_PRICE)}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${it.STATUS === "ACTIVE" ? "bg-emerald-100 text-emerald-700" : "bg-slate-100 text-slate-500"}`}>
|
||||
{it.STATUS === "ACTIVE" ? "사용" : "중지"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button onClick={() => setEditing(it)} className="text-slate-500 hover:text-emerald-700 p-1"><Pencil size={14} /></button>
|
||||
<button onClick={() => onDelete(it.OBJID)} className="text-slate-500 hover:text-rose-600 p-1 ml-1"><Trash2 size={14} /></button>
|
||||
<tr>
|
||||
<td colSpan={10} className="text-center py-12 text-slate-400">
|
||||
품목이 없습니다. 신규 등록 버튼을 눌러주세요.
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
) : (
|
||||
items.map((it) => (
|
||||
<tr key={it.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-3 py-2">
|
||||
<div className="w-10 h-10 bg-slate-50 rounded overflow-hidden flex items-center justify-center">
|
||||
{it.IMAGE_URL ? (
|
||||
<img src={it.IMAGE_URL} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-300">없음</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-slate-500">{it.ITEM_CODE}</td>
|
||||
<td className="px-3 py-2 font-semibold text-slate-800">{it.ITEM_NAME}</td>
|
||||
<td className="px-3 py-2 text-slate-600 text-xs">{it.MAKER_NAME || "-"}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{it.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-3 py-2 text-right tabular-nums text-slate-700">₩{fmt(it.UNIT_PRICE)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-slate-500 text-xs">₩{fmt(it.COST_PRICE)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
<span className={Number(it.STOCK_QTY) <= 0 ? "text-rose-500 font-bold" : "text-slate-700"}>
|
||||
{fmt(it.STOCK_QTY)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${it.STATUS === "ACTIVE" ? "bg-emerald-100 text-emerald-700" : "bg-slate-100 text-slate-500"}`}>
|
||||
{it.STATUS === "ACTIVE" ? "사용" : "중지"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button onClick={() => openEdit(it)} className="text-slate-400 hover:text-emerald-700 p-1">
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button onClick={() => onDelete(it.OBJID)} className="text-slate-400 hover:text-rose-600 p-1 ml-1">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
{editing && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-4" onClick={() => setEditing(null)}>
|
||||
<form onSubmit={onSave} onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl shadow-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-bold mb-4">{editing.OBJID ? "품목 수정" : "품목 등록"}</h3>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-4"
|
||||
onClick={() => setEditing(null)}
|
||||
>
|
||||
<form
|
||||
onSubmit={onSave}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-white rounded-xl shadow-xl max-w-3xl w-full p-6 max-h-[92vh] overflow-y-auto"
|
||||
>
|
||||
<h3 className="text-lg font-bold mb-5 text-slate-800 border-b pb-3">
|
||||
{editing.OBJID ? "품목 수정" : "품목 등록"}
|
||||
</h3>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3">기본 정보</p>
|
||||
<div className="grid sm:grid-cols-2 gap-4 mb-5">
|
||||
<Field label="품목명 *">
|
||||
<input
|
||||
required
|
||||
value={editing.ITEM_NAME ?? ""}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="제조사">
|
||||
<select
|
||||
value={editing.MAKER_OBJID ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, MAKER_OBJID: 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"
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{makers.map((m) => <option key={m.OBJID} value={m.OBJID}>{m.MAKER_NAME}</option>)}
|
||||
{makers.map((m) => (
|
||||
<option key={m.OBJID} value={m.OBJID}>{m.MAKER_NAME}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="단위">
|
||||
<input
|
||||
<select
|
||||
value={editing.UNIT ?? "EA"}
|
||||
onChange={(e) => setEditing({ ...editing, UNIT: e.target.value })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="단가 (VAT포함)">
|
||||
<input
|
||||
type="number" min={0}
|
||||
value={editing.UNIT_PRICE ?? 0}
|
||||
onChange={(e) => setEditing({ ...editing, UNIT_PRICE: Number(e.target.value) })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="원가">
|
||||
<input
|
||||
type="number" min={0}
|
||||
value={editing.COST_PRICE ?? 0}
|
||||
onChange={(e) => 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"
|
||||
>
|
||||
<option value="EA">EA (개)</option>
|
||||
<option value="BOX">BOX (박스)</option>
|
||||
<option value="KG">KG (킬로그램)</option>
|
||||
<option value="L">L (리터)</option>
|
||||
<option value="PACK">PACK (팩)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="구분">
|
||||
<div className="flex gap-2">
|
||||
<label className="flex-1 inline-flex items-center justify-center h-10 rounded-lg border cursor-pointer text-sm font-semibold">
|
||||
<div className="flex gap-2 h-10">
|
||||
<label className="flex-1 inline-flex items-center justify-center rounded-lg border cursor-pointer text-sm font-semibold hover:bg-slate-50">
|
||||
<input
|
||||
type="radio" name="tax" checked={editing.IS_TAX_FREE !== "Y"}
|
||||
onChange={() => setEditing({ ...editing, IS_TAX_FREE: "N" })}
|
||||
@@ -222,7 +327,7 @@ export default function AdminItemsPage() {
|
||||
/>
|
||||
과세
|
||||
</label>
|
||||
<label className="flex-1 inline-flex items-center justify-center h-10 rounded-lg border cursor-pointer text-sm font-semibold">
|
||||
<label className="flex-1 inline-flex items-center justify-center rounded-lg border cursor-pointer text-sm font-semibold hover:bg-slate-50">
|
||||
<input
|
||||
type="radio" name="tax" checked={editing.IS_TAX_FREE === "Y"}
|
||||
onChange={() => setEditing({ ...editing, IS_TAX_FREE: "Y" })}
|
||||
@@ -232,39 +337,150 @@ export default function AdminItemsPage() {
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="단가 (VAT포함)">
|
||||
<input
|
||||
type="number" min={0}
|
||||
value={editing.UNIT_PRICE ?? 0}
|
||||
onChange={(e) => setEditing({ ...editing, UNIT_PRICE: Number(e.target.value) })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="원가">
|
||||
<input
|
||||
type="number" min={0}
|
||||
value={editing.COST_PRICE ?? 0}
|
||||
onChange={(e) => setEditing({ ...editing, COST_PRICE: Number(e.target.value) })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="상태">
|
||||
<select
|
||||
value={editing.STATUS ?? "ACTIVE"}
|
||||
onChange={(e) => setEditing({ ...editing, STATUS: e.target.value })}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm bg-white focus:border-emerald-500 outline-none"
|
||||
>
|
||||
<option value="ACTIVE">사용</option>
|
||||
<option value="INACTIVE">중지</option>
|
||||
</select>
|
||||
</Field>
|
||||
<div className="sm:col-span-2">
|
||||
<Field label="상세 설명">
|
||||
<textarea
|
||||
rows={3}
|
||||
rows={2}
|
||||
value={editing.ITEM_DETAIL ?? ""}
|
||||
onChange={(e) => setEditing({ ...editing, ITEM_DETAIL: e.target.value })}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200"
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none resize-none"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Field label="이미지">
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="w-24 h-24 bg-slate-50 border border-slate-200 rounded-lg overflow-hidden flex items-center justify-center">
|
||||
{editing.IMAGE_URL ? <img src={editing.IMAGE_URL} alt="" className="w-full h-full object-cover" /> : <span className="text-xs text-slate-300">미리보기</span>}
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
ref={fileRef} type="file" accept="image/*" className="hidden"
|
||||
onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])}
|
||||
/>
|
||||
<button type="button" onClick={() => fileRef.current?.click()} className="inline-flex items-center gap-2 px-3 h-9 rounded-lg border border-slate-200 hover:bg-slate-50 text-sm">
|
||||
<Upload size={14} /> {uploading ? "업로드 중..." : "이미지 선택"}
|
||||
</button>
|
||||
<p className="text-xs text-slate-400 mt-1">JPG/PNG/WEBP, 최대 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* 제조 관리 (attributes JSONB — 시트8: 소비기한/입고가) */}
|
||||
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3 mt-2 border-t pt-4">
|
||||
제조 관리 정보
|
||||
</p>
|
||||
<div className="grid sm:grid-cols-2 gap-4 mb-5">
|
||||
<Field label="소비기한 (유통기한)">
|
||||
<input
|
||||
type="date"
|
||||
value={(attrs.expiry_date as string) ?? ""}
|
||||
onChange={(e) => setAttr("expiry_date", e.target.value)}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="입고가">
|
||||
<input
|
||||
type="number" min={0}
|
||||
value={(attrs.inbound_price as number) ?? ""}
|
||||
onChange={(e) => setAttr("inbound_price", Number(e.target.value))}
|
||||
placeholder="입고 단가"
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="원산지">
|
||||
<input
|
||||
value={(attrs.origin as string) ?? ""}
|
||||
onChange={(e) => setAttr("origin", e.target.value)}
|
||||
placeholder="예: 국산, 수입산"
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="보관온도">
|
||||
<input
|
||||
value={(attrs.storage_temp as string) ?? ""}
|
||||
onChange={(e) => setAttr("storage_temp", e.target.value)}
|
||||
placeholder="예: 냉장(0~10°C)"
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="바코드">
|
||||
<input
|
||||
value={(attrs.barcode as string) ?? ""}
|
||||
onChange={(e) => setAttr("barcode", e.target.value)}
|
||||
placeholder="EAN/UPC 바코드"
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="제조 메모">
|
||||
<input
|
||||
value={(attrs.memo as string) ?? ""}
|
||||
onChange={(e) => setAttr("memo", e.target.value)}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* 이미지 */}
|
||||
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3 border-t pt-4">
|
||||
이미지
|
||||
</p>
|
||||
<div className="flex gap-3 items-start mb-5">
|
||||
<div className="w-24 h-24 bg-slate-50 border border-slate-200 rounded-lg overflow-hidden flex items-center justify-center shrink-0">
|
||||
{editing.IMAGE_URL ? (
|
||||
<img src={editing.IMAGE_URL} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xs text-slate-300">미리보기</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
ref={fileRef} type="file" accept="image/*" className="hidden"
|
||||
onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="inline-flex items-center gap-2 px-3 h-9 rounded-lg border border-slate-200 hover:bg-slate-50 text-sm"
|
||||
>
|
||||
<Upload size={14} /> {uploading ? "업로드 중..." : "이미지 선택"}
|
||||
</button>
|
||||
<p className="text-xs text-slate-400 mt-1">JPG/PNG/WEBP, 최대 5MB</p>
|
||||
{editing.IMAGE_URL && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing({ ...editing, IMAGE_URL: "" })}
|
||||
className="mt-1 text-xs text-rose-500 hover:underline"
|
||||
>
|
||||
이미지 제거
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end mt-6 pt-4 border-t border-slate-100">
|
||||
<button type="button" onClick={() => setEditing(null)} className="px-4 h-10 rounded-lg border border-slate-200 text-sm font-semibold hover:bg-slate-50">취소</button>
|
||||
<button type="submit" className="px-5 h-10 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800">저장</button>
|
||||
|
||||
<div className="flex gap-2 justify-end pt-4 border-t border-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(null)}
|
||||
className="px-4 h-10 rounded-lg border border-slate-200 text-sm font-semibold hover:bg-slate-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 h-10 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, FormEvent } from "react";
|
||||
import { Plus, Search, Pencil, Trash2 } from "lucide-react";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
interface Maker {
|
||||
OBJID: string;
|
||||
MAKER_NAME: string;
|
||||
CONTACT: string;
|
||||
PHONE: string;
|
||||
MEMO: string;
|
||||
REGDATE: string;
|
||||
}
|
||||
|
||||
export default function AdminMakersPage() {
|
||||
const [makers, setMakers] = useState<Maker[]>([]);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [editing, setEditing] = useState<Partial<Maker> | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/m/makers/list", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ keyword }),
|
||||
});
|
||||
setMakers((await res.json()).RESULTLIST ?? []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
const onSave = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const isNew = !editing.OBJID;
|
||||
const res = await fetch("/api/m/makers/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
objid: editing.OBJID,
|
||||
actionType: isNew ? "regist" : "update",
|
||||
makerName: editing.MAKER_NAME,
|
||||
contact: editing.CONTACT,
|
||||
phone: editing.PHONE,
|
||||
memo: editing.MEMO,
|
||||
}),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
Swal.fire({ icon: "success", title: j.message, timer: 1200, showConfirmButton: false });
|
||||
setEditing(null);
|
||||
load();
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "저장 실패", text: j.message });
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (objid: string, name: string) => {
|
||||
const ok = await Swal.fire({
|
||||
icon: "warning",
|
||||
title: `"${name}" 삭제`,
|
||||
text: "삭제하시겠습니까?",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "삭제",
|
||||
cancelButtonText: "취소",
|
||||
confirmButtonColor: "#dc2626",
|
||||
});
|
||||
if (!ok.isConfirmed) return;
|
||||
const res = await fetch("/api/m/makers/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objids: [objid] }),
|
||||
});
|
||||
const j = await res.json();
|
||||
if (j.success) {
|
||||
Swal.fire({ icon: "success", title: j.message, timer: 1200, showConfirmButton: false });
|
||||
load();
|
||||
} else {
|
||||
Swal.fire({ icon: "error", title: "오류", text: j.message });
|
||||
}
|
||||
};
|
||||
|
||||
const set = (k: keyof Maker) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
setEditing((prev) => prev ? { ...prev, [k]: e.target.value } : prev);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-800">제조사 관리</h1>
|
||||
<button
|
||||
onClick={() => setEditing({ MAKER_NAME: "", CONTACT: "", PHONE: "", MEMO: "" })}
|
||||
className="px-4 h-10 inline-flex items-center gap-2 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800"
|
||||
>
|
||||
<Plus size={16} /> 신규 등록
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search size={16} 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-10 pl-9 pr-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={load}
|
||||
className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"
|
||||
>
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<tr>
|
||||
<th className="text-left 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-left px-4 py-3">메모</th>
|
||||
<th className="text-center px-4 py-3 w-[100px]">등록일</th>
|
||||
<th className="text-right px-4 py-3 w-[80px]">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{makers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-12 text-slate-400">
|
||||
제조사가 없습니다. 신규 등록 버튼을 눌러주세요.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
makers.map((m) => (
|
||||
<tr key={m.OBJID} className="border-t border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-4 py-3 font-semibold text-slate-800">{m.MAKER_NAME}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{m.CONTACT || "-"}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{m.PHONE || "-"}</td>
|
||||
<td className="px-4 py-3 text-slate-500 text-xs">{m.MEMO || "-"}</td>
|
||||
<td className="px-4 py-3 text-center text-slate-500 text-xs">{m.REGDATE}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => setEditing(m)}
|
||||
className="text-slate-400 hover:text-emerald-700 p-1"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(m.OBJID, m.MAKER_NAME)}
|
||||
className="text-slate-400 hover:text-rose-600 p-1 ml-1"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
{editing && (
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-4"
|
||||
onClick={() => setEditing(null)}
|
||||
>
|
||||
<form
|
||||
onSubmit={onSave}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6"
|
||||
>
|
||||
<h3 className="text-lg font-bold mb-5 text-slate-800">
|
||||
{editing.OBJID ? "제조사 수정" : "제조사 등록"}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">
|
||||
제조사명 <span className="text-rose-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
required
|
||||
value={editing.MAKER_NAME ?? ""}
|
||||
onChange={set("MAKER_NAME")}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">담당자</label>
|
||||
<input
|
||||
value={editing.CONTACT ?? ""}
|
||||
onChange={set("CONTACT")}
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">연락처</label>
|
||||
<input
|
||||
value={editing.PHONE ?? ""}
|
||||
onChange={set("PHONE")}
|
||||
placeholder="010-0000-0000"
|
||||
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">메모</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={editing.MEMO ?? ""}
|
||||
onChange={set("MEMO")}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-200 text-sm focus:border-emerald-500 outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end mt-6 pt-4 border-t border-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(null)}
|
||||
className="px-4 h-10 rounded-lg border border-slate-200 text-sm font-semibold hover:bg-slate-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="px-5 h-10 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-60"
|
||||
>
|
||||
{saving ? "저장 중..." : "저장"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -131,9 +131,27 @@ export default function AdminPanelPage() {
|
||||
</h1>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
{groups.length === 0 ? (
|
||||
<div className="px-3 py-4 text-[11px] text-gray-500">메뉴 로딩 중...</div>
|
||||
) : groups.map((section) => {
|
||||
{/* ★ 메뉴 관리 — DB 상태와 무관하게 항상 고정 노출 */}
|
||||
<div className="border-b border-white/10 pb-1 mb-1">
|
||||
<div className="px-3 py-1.5 text-[10px] font-bold text-gray-500 uppercase tracking-wider">메뉴관리</div>
|
||||
<button
|
||||
onClick={() => setActiveTab("menu")}
|
||||
className={cn(
|
||||
"w-full text-left pl-6 pr-3 py-1.5 text-[11px] transition-colors flex items-center gap-2",
|
||||
activeTab === "menu" ? "text-white bg-[#1C90FB]" : "text-gray-400 hover:text-white hover:bg-white/5"
|
||||
)}
|
||||
>
|
||||
<Menu size={12} />
|
||||
메뉴 관리
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* DB 기반 동적 메뉴 (없으면 정적 ADMIN_MENUS 폴백) */}
|
||||
{(groups.length > 0 ? groups : ADMIN_MENUS.slice(1).map((g, i) => ({
|
||||
objid: String(i),
|
||||
label: g.label,
|
||||
items: g.items.map((it, j) => ({ objid: String(j), label: it.label, url: "" })),
|
||||
}))).map((section) => {
|
||||
const isOpen = openSections.has(section.label);
|
||||
const Icon = SECTION_ICONS[section.label] || FileText;
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryRows } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
// 재고 입출고 이력 (momo_stock_moves)
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { whObjid, itemObjid, moveType, dateFrom, dateTo, keyword } = body as {
|
||||
whObjid?: string;
|
||||
itemObjid?: string;
|
||||
moveType?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
keyword?: string;
|
||||
};
|
||||
|
||||
const conditions = ["1=1"];
|
||||
const params: unknown[] = [];
|
||||
let i = 1;
|
||||
|
||||
if (whObjid) {
|
||||
conditions.push(`SM.wh_objid = $${i++}`);
|
||||
params.push(whObjid);
|
||||
}
|
||||
if (itemObjid) {
|
||||
conditions.push(`SM.item_objid = $${i++}`);
|
||||
params.push(itemObjid);
|
||||
}
|
||||
if (moveType) {
|
||||
conditions.push(`SM.move_type = $${i++}`);
|
||||
params.push(moveType);
|
||||
}
|
||||
if (dateFrom) {
|
||||
conditions.push(`SM.regdate >= $${i++}::date`);
|
||||
params.push(dateFrom);
|
||||
}
|
||||
if (dateTo) {
|
||||
conditions.push(`SM.regdate < ($${i++}::date + INTERVAL '1 day')`);
|
||||
params.push(dateTo);
|
||||
}
|
||||
if (keyword) {
|
||||
conditions.push(`(I.item_name ILIKE '%' || $${i} || '%' OR I.item_code ILIKE '%' || $${i} || '%')`);
|
||||
params.push(keyword);
|
||||
i++;
|
||||
}
|
||||
|
||||
const rows = await queryRows(
|
||||
`SELECT
|
||||
SM.objid AS "OBJID",
|
||||
W.wh_name AS "WH_NAME",
|
||||
W.wh_code AS "WH_CODE",
|
||||
I.item_code AS "ITEM_CODE",
|
||||
I.item_name AS "ITEM_NAME",
|
||||
I.unit AS "UNIT",
|
||||
SM.move_type AS "MOVE_TYPE",
|
||||
CASE SM.move_type
|
||||
WHEN 'IN' THEN '입고'
|
||||
WHEN 'OUT' THEN '출고'
|
||||
WHEN 'ADJ' THEN '재고조정'
|
||||
WHEN 'TRANSFER' THEN '이동'
|
||||
ELSE SM.move_type
|
||||
END AS "MOVE_TYPE_NAME",
|
||||
SM.qty AS "QTY",
|
||||
SM.ref_type AS "REF_TYPE",
|
||||
SM.ref_objid AS "REF_OBJID",
|
||||
SM.memo AS "MEMO",
|
||||
SM.regid AS "REGID",
|
||||
TO_CHAR(SM.regdate, 'YYYY-MM-DD HH24:MI') AS "REGDATE"
|
||||
FROM momo_stock_moves SM
|
||||
LEFT JOIN momo_warehouses W ON SM.wh_objid = W.objid
|
||||
LEFT JOIN momo_items I ON SM.item_objid = I.objid
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY SM.regdate DESC
|
||||
LIMIT 500`,
|
||||
params
|
||||
);
|
||||
|
||||
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||
}
|
||||
@@ -40,6 +40,11 @@ export async function POST(req: NextRequest) {
|
||||
conditions.push(`I.maker_objid = $${i++}`);
|
||||
params.push(makerObjid);
|
||||
}
|
||||
if (onlyAvailable) {
|
||||
conditions.push(
|
||||
`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`
|
||||
);
|
||||
}
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
@@ -65,7 +70,6 @@ export async function POST(req: NextRequest) {
|
||||
FROM momo_items I
|
||||
LEFT JOIN momo_makers M ON I.maker_objid = M.objid
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
${onlyAvailable ? `HAVING COALESCE((SELECT SUM(S.qty) FROM momo_stocks S WHERE S.item_objid = I.objid),0) > 0` : ""}
|
||||
ORDER BY I.item_name ASC
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execute } from "@/lib/db";
|
||||
import { requireMomoAdmin } from "@/lib/momo-guard";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const g = await requireMomoAdmin();
|
||||
if (g instanceof NextResponse) return g;
|
||||
|
||||
const { objids } = await req.json();
|
||||
if (!Array.isArray(objids) || objids.length === 0) {
|
||||
return NextResponse.json({ success: false, message: "삭제할 항목을 선택하세요." }, { status: 400 });
|
||||
}
|
||||
|
||||
for (const id of objids) {
|
||||
await execute(`UPDATE momo_makers SET is_del='Y' WHERE objid=$1`, [id]);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, message: `${objids.length}개 삭제되었습니다.` });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryRows } from "@/lib/db";
|
||||
import { requireMomoUser } from "@/lib/momo-guard";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const r = await requireMomoUser();
|
||||
if (r instanceof NextResponse) return r;
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { keyword } = body as { keyword?: string };
|
||||
|
||||
const conditions = ["COALESCE(is_del, 'N') != 'Y'"];
|
||||
const params: unknown[] = [];
|
||||
let i = 1;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`maker_name ILIKE '%' || $${i++} || '%'`);
|
||||
params.push(keyword);
|
||||
}
|
||||
|
||||
const rows = await queryRows(
|
||||
`SELECT objid AS "OBJID",
|
||||
maker_name AS "MAKER_NAME",
|
||||
contact AS "CONTACT",
|
||||
phone AS "PHONE",
|
||||
memo AS "MEMO",
|
||||
TO_CHAR(regdate, 'YYYY-MM-DD') AS "REGDATE"
|
||||
FROM momo_makers
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY maker_name ASC`,
|
||||
params
|
||||
);
|
||||
|
||||
return NextResponse.json({ RESULTLIST: rows, TOTAL_CNT: rows.length });
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { execute } 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 body = await req.json();
|
||||
const { objid, actionType, makerName, contact, phone, memo } = body;
|
||||
|
||||
if (!makerName) {
|
||||
return NextResponse.json({ success: false, message: "제조사명은 필수입니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (actionType === "regist") {
|
||||
const newId = createObjectId();
|
||||
await execute(
|
||||
`INSERT INTO momo_makers (objid, maker_name, contact, phone, memo, is_del, regdate, regid)
|
||||
VALUES ($1, $2, $3, $4, $5, 'N', NOW(), $6)`,
|
||||
[newId, makerName, contact ?? null, phone ?? null, memo ?? null, g.user.userId]
|
||||
);
|
||||
return NextResponse.json({ success: true, objId: newId, message: "등록되었습니다." });
|
||||
}
|
||||
|
||||
if (!objid) return NextResponse.json({ success: false, message: "objid 누락" }, { status: 400 });
|
||||
|
||||
await execute(
|
||||
`UPDATE momo_makers SET maker_name=$2, contact=$3, phone=$4, memo=$5 WHERE objid=$1`,
|
||||
[objid, makerName, contact ?? null, phone ?? null, memo ?? null]
|
||||
);
|
||||
return NextResponse.json({ success: true, objId: objid, message: "수정되었습니다." });
|
||||
}
|
||||
Reference in New Issue
Block a user