Files
distribution_erp/src/app/(main)/m/admin/items/page.tsx
T
chpark 9705a04328
Deploy momo-erp / deploy (push) Failing after 1m31s
feat(items): 제조사 필드/메뉴 제거 + 공급업체 검색 + 원가/단가 천단위 콤마
- 품목 폼/리스트/모바일 카드에서 제조사 컬럼·셀렉트 제거 (dead code 정리)
- 공급업체 셀렉트 → SearchableSelect (결과내 검색 가능)
- 단가/원가 인풋: type=number → text + 천단위 콤마 표시, 소수점 제거(반올림)
- 운영 menu_info: '제조사 관리' (9000204) status='inactive'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:54:32 +09:00

607 lines
27 KiB
TypeScript

"use client";
import { useEffect, useState, useRef, FormEvent } from "react";
import { Plus, Search, Pencil, Trash2, Upload } from "lucide-react";
import Swal from "sweetalert2";
import { SearchableSelect } from "@/components/ui/searchable-select";
interface Item {
OBJID: string;
ITEM_CODE: string;
ITEM_NAME: string;
ITEM_DETAIL: 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;
MAX_ORDER_QTY: number | null;
IS_HIDDEN: string;
REQUIRES_DELIVERY: string;
VENDOR_OBJID?: string;
VENDOR_NAME?: string;
}
interface Vendor { OBJID: string; VENDOR_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 [vendors, setVendors] = useState<Vendor[]>([]);
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, status: filterStatus || undefined }),
});
setItems((await res.json()).RESULTLIST ?? []);
};
const loadVendors = async () => {
const res = await fetch("/api/m/vendors/list", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}),
});
setVendors((await res.json()).RESULTLIST ?? []);
};
useEffect(() => {
loadItems();
loadVendors();
}, []); // 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", IS_HIDDEN: "N", MAX_ORDER_QTY: null, REQUIRES_DELIVERY: "N" });
setAttrs({});
};
const onSave = async (e: FormEvent) => {
e.preventDefault();
if (!editing) return;
const isNew = !editing.OBJID;
const body = {
objid: editing.OBJID,
actionType: isNew ? "regist" : "update",
itemName: editing.ITEM_NAME,
itemDetail: editing.ITEM_DETAIL,
unit: editing.UNIT || "EA",
unitPrice: editing.UNIT_PRICE,
costPrice: editing.COST_PRICE,
isTaxFree: editing.IS_TAX_FREE,
imageUrl: editing.IMAGE_URL,
status: editing.STATUS || "ACTIVE",
attributes: Object.keys(attrs).length > 0 ? attrs : null,
maxOrderQty: editing.MAX_ORDER_QTY ?? null,
isHidden: editing.IS_HIDDEN === "Y" ? "Y" : "N",
requiresDelivery: editing.REQUIRES_DELIVERY === "Y" ? "Y" : "N",
vendorObjid: editing.VENDOR_OBJID || null,
};
const res = await fetch("/api/m/items/save", {
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 });
}
};
const onUpload = async (file: File) => {
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/m/items/upload-image", { method: "POST", body: fd });
const j = await res.json();
if (j.success && editing) {
setEditing({ ...editing, IMAGE_URL: j.url });
} else if (!j.success) {
Swal.fire({ icon: "error", title: "업로드 실패", text: j.message });
}
} finally {
setUploading(false);
}
};
const onDelete = async (objid: string) => {
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" },
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 text-slate-800"> </h1>
<button
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} />
</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" && 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>
<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="grid grid-cols-1 sm:hidden gap-2">
{items.length === 0 ? (
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center text-slate-400"> .</div>
) : items.map((it) => (
<button key={it.OBJID} onClick={() => openEdit(it)}
className="bg-white border border-slate-200 rounded-xl p-3 shadow-sm text-left hover:bg-slate-50 active:bg-emerald-50">
<div className="flex items-start gap-3">
<div className="w-14 h-14 bg-slate-50 rounded overflow-hidden flex items-center justify-center shrink-0">
{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>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-semibold text-sm">{it.ITEM_NAME}</span>
{it.IS_TAX_FREE === "Y"
? <span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 text-[9px] font-bold"></span>
: <span className="px-1 py-0.5 rounded bg-rose-100 text-rose-700 text-[9px] font-bold"></span>}
{it.IS_HIDDEN === "Y" && <span className="px-1 py-0.5 rounded bg-amber-100 text-amber-700 text-[9px] font-bold"></span>}
{it.REQUIRES_DELIVERY === "Y" && <span className="px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px] font-bold"></span>}
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && <span className="px-1 py-0.5 rounded bg-sky-100 text-sky-700 text-[9px] font-bold">{it.MAX_ORDER_QTY}</span>}
</div>
<div className="text-[10px] text-slate-400 font-mono mt-0.5">{it.ITEM_CODE}</div>
<div className="flex items-center justify-between mt-1.5 text-[11px]">
<div>
<span className="text-slate-400"></span> <b className="tabular-nums">{fmt(it.UNIT_PRICE)}</b>
<span className="text-slate-300 mx-1">·</span>
<span className="text-slate-400"></span> <span className="text-slate-600 tabular-nums">{fmt(it.COST_PRICE)}</span>
</div>
<div className={Number(it.STOCK_QTY) <= 0 ? "text-rose-500 font-bold" : "text-slate-700"}>
{fmt(it.STOCK_QTY)}
</div>
</div>
</div>
</div>
</button>
))}
</div>
{/* 데스크탑: 표 */}
<div className="hidden sm:block bg-white border border-slate-200 rounded-xl overflow-x-auto">
<table className="w-full text-sm min-w-[900px]">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="px-3 py-3 w-14"></th>
<th className="text-left px-3 py-3"></th>
<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 w-[70px]"></th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td colSpan={9} 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-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>
{it.IS_HIDDEN === "Y" && (
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-bold"></span>
)}
{it.MAX_ORDER_QTY != null && Number(it.MAX_ORDER_QTY) > 0 && (
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-sky-100 text-sky-700 font-bold">{it.MAX_ORDER_QTY}</span>
)}
{it.REQUIRES_DELIVERY === "Y" && (
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 font-bold"></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-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 text-sm focus:border-emerald-500 outline-none"
/>
</Field>
<Field label="공급업체">
<SearchableSelect
options={vendors.map((v) => ({ value: v.OBJID, label: v.VENDOR_NAME }))}
value={editing.VENDOR_OBJID ?? ""}
onChange={(v) => setEditing({ ...editing, VENDOR_OBJID: v })}
placeholder="공급업체 검색"
/>
</Field>
<Field label="단위">
<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 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 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" })}
className="mr-1.5"
/>
</label>
<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" })}
className="mr-1.5"
/>
</label>
</div>
</Field>
<Field label="단가 (VAT포함)">
<input
type="text" inputMode="numeric"
value={editing.UNIT_PRICE == null ? "" : Math.round(Number(editing.UNIT_PRICE)).toLocaleString("ko-KR")}
onChange={(e) => {
const n = Number(e.target.value.replace(/[^0-9]/g, ""));
setEditing({ ...editing, UNIT_PRICE: Number.isFinite(n) ? n : 0 });
}}
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm tabular-nums text-right focus:border-emerald-500 outline-none"
/>
</Field>
<Field label="원가">
<input
type="text" inputMode="numeric"
value={editing.COST_PRICE == null ? "" : Math.round(Number(editing.COST_PRICE)).toLocaleString("ko-KR")}
onChange={(e) => {
const n = Number(e.target.value.replace(/[^0-9]/g, ""));
setEditing({ ...editing, COST_PRICE: Number.isFinite(n) ? n : 0 });
}}
className="w-full h-10 px-3 rounded-lg border border-slate-200 text-sm tabular-nums text-right 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>
<Field label="발주 제한수량 (1회 최대)">
<input
type="number" min={0}
value={editing.MAX_ORDER_QTY ?? ""}
onChange={(e) => {
const v = e.target.value;
setEditing({ ...editing, MAX_ORDER_QTY: v === "" ? null : Number(v) });
}}
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="숨김 처리">
<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="hidden" checked={editing.IS_HIDDEN !== "Y"}
onChange={() => setEditing({ ...editing, IS_HIDDEN: "N" })}
className="mr-1.5"
/>
</label>
<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="hidden" checked={editing.IS_HIDDEN === "Y"}
onChange={() => setEditing({ ...editing, IS_HIDDEN: "Y" })}
className="mr-1.5"
/>
</label>
</div>
</Field>
<Field label="택배 전용 (담기면 자동 택배 라인 추가)">
<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="requires_delivery" checked={editing.REQUIRES_DELIVERY !== "Y"}
onChange={() => setEditing({ ...editing, REQUIRES_DELIVERY: "N" })}
className="mr-1.5"
/>
</label>
<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="requires_delivery" checked={editing.REQUIRES_DELIVERY === "Y"}
onChange={() => setEditing({ ...editing, REQUIRES_DELIVERY: "Y" })}
className="mr-1.5"
/>
</label>
</div>
</Field>
<div className="sm:col-span-2">
<Field label="상세 설명">
<textarea
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 text-sm focus:border-emerald-500 outline-none resize-none"
/>
</Field>
</div>
</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 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>
)}
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5">{label}</label>
{children}
</div>
);
}