feat(items+einvoices): 품목 상태값 제거 + 공급업체/거래처 조회조건 + 합계 면세/과세 분리
Deploy momo-erp / deploy (push) Failing after 2m35s

- 품목관리(items):
  · STATUS 컬럼 표시/필터/폼에서 제거 (전부 ACTIVE 가 default — 사용자 사용 안 함)
  · 조회조건에 공급업체 SearchableSelect 추가 (이미 백엔드 vendorObjid 지원)
- 계산서 발행(einvoices):
  · 조회조건에 거래처 SearchableSelect 추가 (customers list API 사용)
  · 페이지 하단 tfoot 에 면세 합계 / 과세 합계 / 총 합계 분리 표시

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-13 11:29:55 +09:00
parent 9cd9e5c0fd
commit 80d2240a23
2 changed files with 77 additions and 29 deletions
+66 -4
View File
@@ -1,9 +1,10 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useMemo } from "react";
import { FileText, Send, Download, RefreshCcw, AlertCircle } from "lucide-react";
import Swal from "sweetalert2";
import { downloadXlsx } from "@/lib/xlsx-export";
import { SearchableSelect } from "@/components/ui/searchable-select";
interface Einvoice {
OBJID: string;
@@ -53,9 +54,13 @@ function defaultRange() {
return [s.toISOString().slice(0, 10), e.toISOString().slice(0, 10)];
}
interface Customer { USER_ID: string; USER_NAME: string }
export default function EinvoicesPage() {
const [[from, to], setRange] = useState(defaultRange());
const [statusFilter, setStatusFilter] = useState("");
const [customerFilter, setCustomerFilter] = useState("");
const [customers, setCustomers] = useState<Customer[]>([]);
const [list, setList] = useState<Einvoice[]>([]);
const [pending, setPending] = useState<PendingOrder[]>([]);
const [busy, setBusy] = useState(false);
@@ -63,10 +68,37 @@ export default function EinvoicesPage() {
const load = useCallback(async () => {
const res = await fetch("/api/m/einvoices/list", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dateFrom: from, dateTo: to, status: statusFilter || undefined }),
body: JSON.stringify({
dateFrom: from,
dateTo: to,
status: statusFilter || undefined,
customerObjid: customerFilter || undefined,
}),
});
setList((await res.json()).RESULTLIST ?? []);
}, [from, to, statusFilter]);
}, [from, to, statusFilter, customerFilter]);
const loadCustomers = useCallback(async () => {
const res = await fetch("/api/m/customers/list", {
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
});
setCustomers((await res.json()).RESULTLIST ?? []);
}, []);
// 면세/과세/합계 합산
const summary = useMemo(() => {
let taxFreeAmount = 0, taxableSupply = 0, taxableVat = 0, total = 0;
for (const e of list) {
total += Number(e.TOTAL_AMOUNT) || 0;
if (e.INVOICE_KIND === "TAXFREE") {
taxFreeAmount += Number(e.TOTAL_SUPPLY) || 0;
} else {
taxableSupply += Number(e.TOTAL_SUPPLY) || 0;
taxableVat += Number(e.TOTAL_VAT) || 0;
}
}
return { taxFreeAmount, taxableSupply, taxableVat, taxableTotal: taxableSupply + taxableVat, total };
}, [list]);
const loadPending = useCallback(async () => {
// 발주 + 발행이력 동시 조회 후 이미 발행된 건은 제외
@@ -88,6 +120,7 @@ export default function EinvoicesPage() {
}, []);
useEffect(() => { load(); loadPending(); }, [load, loadPending]);
useEffect(() => { loadCustomers(); }, [loadCustomers]);
const issueFromOrder = async (orderObjid: string, kind: "TAX" | "TAXFREE" = "TAX") => {
const ok = await Swal.fire({
@@ -217,12 +250,20 @@ export default function EinvoicesPage() {
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
<div className="px-4 py-2.5 bg-slate-50 border-b border-slate-200 text-sm font-bold text-slate-700 flex flex-wrap items-center gap-2 justify-between">
<span> ({list.length})</span>
<div className="flex gap-2 items-center text-xs font-normal">
<div className="flex gap-2 items-center text-xs font-normal flex-wrap">
<input type="date" value={from} onChange={(e) => setRange([e.target.value, to])}
className="h-8 px-2 rounded border border-slate-200" />
<span className="text-slate-400">~</span>
<input type="date" value={to} onChange={(e) => setRange([from, e.target.value])}
className="h-8 px-2 rounded border border-slate-200" />
<div className="min-w-[180px]">
<SearchableSelect
options={[{ value: "", label: "전체 거래처" }, ...customers.map((c) => ({ value: c.USER_ID, label: c.USER_NAME }))]}
value={customerFilter}
onChange={setCustomerFilter}
placeholder="거래처"
/>
</div>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
className="h-8 px-2 rounded border border-slate-200 bg-white">
<option value=""> </option>
@@ -271,6 +312,27 @@ export default function EinvoicesPage() {
</tr>
))}
</tbody>
{list.length > 0 && (
<tfoot className="bg-slate-50 border-t-2 border-slate-300 font-bold text-[11px]">
<tr>
<td colSpan={4} className="px-3 py-2 text-right text-slate-600"> </td>
<td className="px-3 py-2 text-right tabular-nums text-violet-700" colSpan={3}>{fmt(summary.taxFreeAmount)}</td>
<td colSpan={2}></td>
</tr>
<tr>
<td colSpan={4} className="px-3 py-2 text-right text-slate-600"> ( + )</td>
<td className="px-3 py-2 text-right tabular-nums text-rose-700">{fmt(summary.taxableSupply)}</td>
<td className="px-3 py-2 text-right tabular-nums text-rose-700">{fmt(summary.taxableVat)}</td>
<td className="px-3 py-2 text-right tabular-nums text-rose-700">{fmt(summary.taxableTotal)}</td>
<td colSpan={2}></td>
</tr>
<tr className="border-t border-slate-300">
<td colSpan={4} className="px-3 py-2 text-right text-slate-700"> </td>
<td colSpan={3} className="px-3 py-2 text-right tabular-nums text-emerald-700 text-sm">{fmt(summary.total)}</td>
<td colSpan={2}></td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
+11 -25
View File
@@ -42,7 +42,7 @@ export default function AdminItemsPage() {
const [items, setItems] = useState<Item[]>([]);
const [vendors, setVendors] = useState<Vendor[]>([]);
const [keyword, setKeyword] = useState("");
const [filterStatus, setFilterStatus] = useState("");
const [filterVendor, setFilterVendor] = useState("");
const [editing, setEditing] = useState<Partial<Item> | null>(null);
const [attrs, setAttrs] = useState<ItemAttributes>({});
const [uploading, setUploading] = useState(false);
@@ -52,7 +52,7 @@ export default function AdminItemsPage() {
const res = await fetch("/api/m/items/list", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keyword, status: filterStatus || undefined }),
body: JSON.stringify({ keyword, vendorObjid: filterVendor || undefined }),
});
setItems((await res.json()).RESULTLIST ?? []);
};
@@ -180,15 +180,14 @@ export default function AdminItemsPage() {
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>
<div className="min-w-[200px]">
<SearchableSelect
options={[{ value: "", label: "전체 공급업체" }, ...vendors.map((v) => ({ value: v.OBJID, label: v.VENDOR_NAME }))]}
value={filterVendor}
onChange={setFilterVendor}
placeholder="공급업체"
/>
</div>
<button
onClick={loadItems}
className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold"
@@ -291,11 +290,8 @@ export default function AdminItemsPage() {
</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>
<span className="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>
@@ -408,16 +404,6 @@ export default function AdminItemsPage() {
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}