feat(items+einvoices): 품목 상태값 제거 + 공급업체/거래처 조회조건 + 합계 면세/과세 분리
Deploy momo-erp / deploy (push) Failing after 2m35s
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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user