feat(invoices): 계산서 발행 페이지 종합 개선 + deploy.yml 충돌 우회
Deploy momo-erp / deploy (push) Failing after 4m37s
Deploy momo-erp / deploy (push) Failing after 4m37s
invoices(계산서 발행) page: - 조회조건 추가: 거래처(SearchableSelect) / 날짜 from~to / 상태 - 조회 버튼 제거 — 입력하면 즉시 클라이언트사이드 필터 적용 - "조회 결과 합계" 카드: 면세 / 과세(공급+세액) / 합계 분리 표시 - "선택 합산" 카드: 체크박스로 고른 건들의 면세/과세/합계 실시간 합산 - 표 행마다 면세/과세 컬럼 추가 - 전체 선택 체크박스 (헤더) deploy.yml: - docker compose up 흐름 강화: down --remove-orphans 후 docker rm -f momo-erp 로 잔존 컨테이너 강제 제거 + --force-recreate - 수동 SSH 배포 + 자동 배포 겹쳤을 때 "container name already in use" 충돌 자동 해소 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -62,10 +62,12 @@ jobs:
|
||||
DEPLOY_WEBHOOK_TOKEN=momo-deploy-2026-secure
|
||||
ENVEOF
|
||||
|
||||
# 빌드는 먼저, 그 다음 down + up 으로 swap (--force-recreate 가 가끔 이름 충돌 일으킴)
|
||||
# 빌드는 먼저, 그 다음 down + 잔존 컨테이너 강제 제거 + up.
|
||||
# 수동 SSH 배포와 자동 배포가 겹쳐 "container name already in use" 충돌 시 멈추지 않도록.
|
||||
docker compose -f docker-compose.prod.yml build momo-erp
|
||||
docker compose -f docker-compose.prod.yml down --remove-orphans
|
||||
docker compose -f docker-compose.prod.yml up -d momo-erp
|
||||
docker compose -f docker-compose.prod.yml down --remove-orphans 2>&1 || true
|
||||
docker rm -f momo-erp 2>/dev/null || true
|
||||
docker compose -f docker-compose.prod.yml up -d --force-recreate momo-erp
|
||||
|
||||
# 옛 momo-erp 이미지(latest 태그가 새 빌드로 갱신되며 dangling 이 된 옛 sha)는 prune.
|
||||
# -f 만 사용 (dangling 만). 다른 프로젝트의 사용 중 이미지는 건드리지 않음.
|
||||
|
||||
@@ -1,67 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import Swal from "sweetalert2";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
|
||||
interface Order {
|
||||
OBJID: string;
|
||||
ORDER_NO: string;
|
||||
ORDER_DATE: string;
|
||||
COMPANY_NAME: string;
|
||||
CUSTOMER_OBJID?: string;
|
||||
STATUS: string;
|
||||
TOTAL_AMOUNT: number;
|
||||
TOTAL_TAXFREE?: number;
|
||||
TOTAL_TAXABLE?: number;
|
||||
TOTAL_VAT?: number;
|
||||
INVOICE_NO: string | null;
|
||||
INVOICE_DATE: string | null;
|
||||
}
|
||||
interface Customer { USER_ID: string; USER_NAME: string }
|
||||
|
||||
interface Order { OBJID: string; ORDER_NO: string; ORDER_DATE: string; COMPANY_NAME: string; STATUS: string; TOTAL_AMOUNT: number; INVOICE_NO: string | null; INVOICE_DATE: string | null }
|
||||
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
||||
const STATUS_LABEL: Record<string, string> = { APPROVED: "출고완료", PAID: "입금완료", INVOICED: "계산서발행" };
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
APPROVED: "출고완료",
|
||||
PAID: "입금완료",
|
||||
INVOICED: "계산서발행",
|
||||
};
|
||||
|
||||
function defaultRange() {
|
||||
const e = new Date(), s = new Date();
|
||||
s.setDate(s.getDate() - 30);
|
||||
return [s.toISOString().slice(0, 10), e.toISOString().slice(0, 10)];
|
||||
}
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const [list, setList] = useState<Order[]>([]);
|
||||
const [all, setAll] = useState<Order[]>([]);
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/m/orders/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) });
|
||||
const all = ((await res.json()).RESULTLIST ?? []) as Order[];
|
||||
setList(all.filter((o) => ["APPROVED", "PAID", "INVOICED"].includes(o.STATUS)));
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
const [[from, to], setRange] = useState(defaultRange());
|
||||
const [customerFilter, setCustomerFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
|
||||
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 loadAll = useCallback(async () => {
|
||||
const res = await fetch("/api/m/orders/list", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}),
|
||||
});
|
||||
const rows = ((await res.json()).RESULTLIST ?? []) as Order[];
|
||||
setAll(rows.filter((o) => ["APPROVED", "PAID", "INVOICED"].includes(o.STATUS)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadAll(); loadCustomers(); }, [loadAll, loadCustomers]);
|
||||
|
||||
// 클라이언트 사이드 필터 — 입력하면 즉시 반영 (조회 버튼 불필요)
|
||||
const list = useMemo(() => {
|
||||
return all.filter((o) => {
|
||||
if (from && o.ORDER_DATE && o.ORDER_DATE < from) return false;
|
||||
if (to && o.ORDER_DATE && o.ORDER_DATE > to) return false;
|
||||
if (customerFilter && o.CUSTOMER_OBJID !== customerFilter) return false;
|
||||
if (statusFilter && o.STATUS !== statusFilter) return false;
|
||||
return true;
|
||||
});
|
||||
}, [all, from, to, customerFilter, statusFilter]);
|
||||
|
||||
// 선택 합산
|
||||
const selectedSum = useMemo(() => {
|
||||
let taxFree = 0, taxable = 0, vat = 0, total = 0;
|
||||
for (const o of list) {
|
||||
if (!selected.has(o.OBJID)) continue;
|
||||
taxFree += Number(o.TOTAL_TAXFREE) || 0;
|
||||
taxable += Number(o.TOTAL_TAXABLE) || 0;
|
||||
vat += Number(o.TOTAL_VAT) || 0;
|
||||
total += Number(o.TOTAL_AMOUNT) || 0;
|
||||
}
|
||||
return { taxFree, taxable, vat, total, count: [...selected].filter((id) => list.some((o) => o.OBJID === id)).length };
|
||||
}, [list, selected]);
|
||||
|
||||
// 전체 합산 (필터 적용된 list)
|
||||
const listSum = useMemo(() => {
|
||||
let taxFree = 0, taxable = 0, vat = 0, total = 0;
|
||||
for (const o of list) {
|
||||
taxFree += Number(o.TOTAL_TAXFREE) || 0;
|
||||
taxable += Number(o.TOTAL_TAXABLE) || 0;
|
||||
vat += Number(o.TOTAL_VAT) || 0;
|
||||
total += Number(o.TOTAL_AMOUNT) || 0;
|
||||
}
|
||||
return { taxFree, taxable, vat, total };
|
||||
}, [list]);
|
||||
|
||||
const issue = async () => {
|
||||
const targets = list.filter((o) => selected.has(o.OBJID) && !o.INVOICE_NO);
|
||||
if (targets.length === 0) return Swal.fire({ icon: "warning", title: "발행 대상 없음", text: "이미 발행된 건은 제외됩니다." });
|
||||
const sum = targets.reduce((a, o) => a + Number(o.TOTAL_AMOUNT), 0);
|
||||
const r = await Swal.fire({
|
||||
icon: "question", title: `계산서 ${targets.length}건 발행`,
|
||||
text: `합계 ₩${fmt(targets.reduce((a, o) => a + Number(o.TOTAL_AMOUNT), 0))}`,
|
||||
showCancelButton: true, confirmButtonText: "발행", confirmButtonColor: "#0f766e",
|
||||
icon: "question",
|
||||
title: `계산서 ${targets.length}건 발행`,
|
||||
text: `합계 ₩${fmt(sum)}`,
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "발행",
|
||||
confirmButtonColor: "#0f766e",
|
||||
});
|
||||
if (!r.isConfirmed) return;
|
||||
const res = await fetch("/api/m/orders/invoice", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ objids: targets.map((o) => o.OBJID) }),
|
||||
});
|
||||
if ((await res.json()).success) {
|
||||
Swal.fire({ icon: "success", title: "계산서 발행 완료", timer: 1500, showConfirmButton: false });
|
||||
setSelected(new Set()); load();
|
||||
setSelected(new Set());
|
||||
loadAll();
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (id: string) => {
|
||||
const s = new Set(selected);
|
||||
s.has(id) ? s.delete(id) : s.add(id);
|
||||
if (s.has(id)) s.delete(id);
|
||||
else s.add(id);
|
||||
setSelected(s);
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
const issuable = list.filter((o) => !o.INVOICE_NO).map((o) => o.OBJID);
|
||||
if (issuable.every((id) => selected.has(id))) {
|
||||
// 모두 선택돼있으면 해제
|
||||
const s = new Set(selected);
|
||||
issuable.forEach((id) => s.delete(id));
|
||||
setSelected(s);
|
||||
} else {
|
||||
setSelected(new Set([...selected, ...issuable]));
|
||||
}
|
||||
};
|
||||
|
||||
const unissued = list.filter((o) => !o.INVOICE_NO).length;
|
||||
const selectedTotal = list.filter((o) => selected.has(o.OBJID)).reduce((a, o) => a + Number(o.TOTAL_AMOUNT), 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold">계산서 발행</h1>
|
||||
<p className="text-xs sm:text-sm text-slate-500 mt-1">미발행 {unissued}건 · 선택 {selected.size}건 (₩{fmt(selectedTotal)})</p>
|
||||
<p className="text-xs sm:text-sm text-slate-500 mt-1">
|
||||
전체 {list.length}건 (미발행 {unissued}건) · 선택 {selectedSum.count}건
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={issue}
|
||||
disabled={selected.size === 0}
|
||||
disabled={selectedSum.count === 0}
|
||||
className="h-10 px-3 sm:px-4 rounded-lg bg-emerald-700 text-white text-xs sm:text-sm font-bold disabled:opacity-50 hover:bg-emerald-800"
|
||||
>
|
||||
{selected.size}건 일괄 발행
|
||||
{selectedSum.count}건 일괄 발행
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 조회 조건 — 입력 즉시 필터 적용 (조회 버튼 없음) */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-3 flex flex-wrap gap-2 items-center text-xs">
|
||||
<span className="text-slate-500 font-semibold mr-1">조회조건</span>
|
||||
<input type="date" value={from} onChange={(e) => setRange([e.target.value, to])}
|
||||
className="h-9 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-9 px-2 rounded border border-slate-200" />
|
||||
<div className="min-w-[200px]">
|
||||
<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-9 px-2 rounded border border-slate-200 bg-white">
|
||||
<option value="">전체 상태</option>
|
||||
{Object.entries(STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
{(customerFilter || statusFilter) && (
|
||||
<button
|
||||
onClick={() => { setCustomerFilter(""); setStatusFilter(""); setRange(defaultRange()); }}
|
||||
className="h-9 px-3 rounded border border-slate-200 text-slate-600 text-xs"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 합계 요약 — 필터 결과 + 선택 합산 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-3">
|
||||
<div className="text-[11px] text-slate-500 font-semibold mb-1.5">조회 결과 합계 ({list.length}건)</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div><div className="text-violet-600">면세</div><div className="font-bold tabular-nums">₩{fmt(listSum.taxFree)}</div></div>
|
||||
<div><div className="text-rose-600">과세</div><div className="font-bold tabular-nums">₩{fmt(listSum.taxable + listSum.vat)}</div></div>
|
||||
<div><div className="text-slate-700">합계</div><div className="font-bold tabular-nums text-emerald-700">₩{fmt(listSum.total)}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-emerald-50 border border-emerald-300 rounded-lg p-3">
|
||||
<div className="text-[11px] text-emerald-700 font-semibold mb-1.5">✓ 선택 합산 ({selectedSum.count}건)</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div><div className="text-violet-700">면세</div><div className="font-bold tabular-nums">₩{fmt(selectedSum.taxFree)}</div></div>
|
||||
<div><div className="text-rose-700">과세</div><div className="font-bold tabular-nums">₩{fmt(selectedSum.taxable + selectedSum.vat)}</div></div>
|
||||
<div><div className="text-slate-800">합계</div><div className="font-bold tabular-nums text-emerald-800">₩{fmt(selectedSum.total)}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일: 카드 리스트 */}
|
||||
<div className="space-y-2 sm:hidden">
|
||||
{list.length === 0 ? (
|
||||
@@ -94,18 +242,13 @@ export default function InvoicesPage() {
|
||||
{STATUS_LABEL[o.STATUS]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-[11px]">
|
||||
<div>
|
||||
<div className="text-slate-400">합계</div>
|
||||
<div className="font-bold tabular-nums">₩{fmt(o.TOTAL_AMOUNT)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-400">계산서</div>
|
||||
<div className="font-mono text-[11px] truncate">{o.INVOICE_NO || <span className="text-slate-300">미발행</span>}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-[11px]">
|
||||
<div><div className="text-slate-400">면세</div><div className="tabular-nums">₩{fmt(o.TOTAL_TAXFREE ?? 0)}</div></div>
|
||||
<div><div className="text-slate-400">과세</div><div className="tabular-nums">₩{fmt((Number(o.TOTAL_TAXABLE) || 0) + (Number(o.TOTAL_VAT) || 0))}</div></div>
|
||||
<div><div className="text-slate-400">합계</div><div className="font-bold tabular-nums">₩{fmt(o.TOTAL_AMOUNT)}</div></div>
|
||||
</div>
|
||||
{o.INVOICE_DATE && (
|
||||
<div className="text-[10px] text-slate-500 mt-1">발행일 {o.INVOICE_DATE}</div>
|
||||
{o.INVOICE_NO && (
|
||||
<div className="text-[10px] text-slate-500 mt-1">계산서 {o.INVOICE_NO} {o.INVOICE_DATE && `· ${o.INVOICE_DATE}`}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,13 +259,22 @@ export default function InvoicesPage() {
|
||||
|
||||
{/* 데스크탑: 표 */}
|
||||
<div className="hidden sm:block bg-white border border-slate-200 rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[800px]">
|
||||
<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-10"></th>
|
||||
<th className="px-3 py-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 accent-emerald-600"
|
||||
checked={unissued > 0 && list.filter((o) => !o.INVOICE_NO).every((o) => selected.has(o.OBJID))}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
</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-right px-4 py-3">면세</th>
|
||||
<th className="text-right px-4 py-3">과세</th>
|
||||
<th className="text-right px-4 py-3">합계</th>
|
||||
<th className="text-center px-4 py-3">상태</th>
|
||||
<th className="text-left px-4 py-3">계산서번호</th>
|
||||
@@ -131,16 +283,18 @@ export default function InvoicesPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.length === 0 ? (
|
||||
<tr><td colSpan={8} className="text-center py-12 text-slate-400">대상 발주가 없습니다.</td></tr>
|
||||
<tr><td colSpan={10} className="text-center py-12 text-slate-400">대상 발주가 없습니다.</td></tr>
|
||||
) : list.map((o) => (
|
||||
<tr key={o.OBJID} className="border-t border-slate-100">
|
||||
<tr key={o.OBJID} className={`border-t border-slate-100 ${selected.has(o.OBJID) ? "bg-emerald-50/40" : ""}`}>
|
||||
<td className="px-3 py-3 text-center">
|
||||
{!o.INVOICE_NO && <input type="checkbox" checked={selected.has(o.OBJID)} onChange={() => toggle(o.OBJID)} className="w-4 h-4 accent-emerald-600" />}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-semibold">{o.ORDER_NO}</td>
|
||||
<td className="px-4 py-3">{o.ORDER_DATE}</td>
|
||||
<td className="px-4 py-3">{o.COMPANY_NAME}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-bold">₩{fmt(o.TOTAL_AMOUNT)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-violet-700">₩{fmt(o.TOTAL_TAXFREE ?? 0)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-rose-700">₩{fmt((Number(o.TOTAL_TAXABLE) || 0) + (Number(o.TOTAL_VAT) || 0))}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums font-bold text-emerald-700">₩{fmt(o.TOTAL_AMOUNT)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-1 rounded text-xs font-semibold ${o.STATUS === "INVOICED" ? "bg-violet-100 text-violet-700" : "bg-amber-100 text-amber-700"}`}>
|
||||
{STATUS_LABEL[o.STATUS]}
|
||||
|
||||
Reference in New Issue
Block a user