feat(momo): 제조사 관리 + 재고 이력 + 품목 속성 JSONB 확장
Deploy momo-erp / deploy (push) Successful in 51s

- 제조사 관리 페이지/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:
chpark
2026-04-27 00:12:05 +09:00
parent 72786bfc98
commit 6930ea2bc7
11 changed files with 997 additions and 103 deletions
+8 -2
View File
@@ -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>
);
}
+14 -4
View File
@@ -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">
+309 -93
View File
@@ -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>
+249
View File
@@ -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>
);
}
+21 -3
View File
@@ -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 (
+82
View File
@@ -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 });
}
+5 -1
View File
@@ -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
`;
+19
View File
@@ -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}개 삭제되었습니다.` });
}
+35
View File
@@ -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 });
}
+34
View File
@@ -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: "수정되었습니다." });
}