feat(invoices): 계산서 발행 페이지 종합 개선 + deploy.yml 충돌 우회
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:
chpark
2026-05-13 11:40:19 +09:00
parent 80d2240a23
commit a8049f57a6
2 changed files with 196 additions and 40 deletions
+5 -3
View File
@@ -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 만). 다른 프로젝트의 사용 중 이미지는 건드리지 않음.
+191 -37
View File
@@ -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]}