9705a04328
Deploy momo-erp / deploy (push) Failing after 1m31s
- 품목 폼/리스트/모바일 카드에서 제조사 컬럼·셀렉트 제거 (dead code 정리) - 공급업체 셀렉트 → SearchableSelect (결과내 검색 가능) - 단가/원가 인풋: type=number → text + 천단위 콤마 표시, 소수점 제거(반올림) - 운영 menu_info: '제조사 관리' (9000204) status='inactive' Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
607 lines
27 KiB
TypeScript
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>
|
|
);
|
|
}
|