From a8049f57a670c84be6de6866754e6c385dee08b0 Mon Sep 17 00:00:00 2001 From: chpark Date: Wed, 13 May 2026 11:40:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(invoices):=20=EA=B3=84=EC=82=B0=EC=84=9C?= =?UTF-8?q?=20=EB=B0=9C=ED=96=89=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A2=85?= =?UTF-8?q?=ED=95=A9=20=EA=B0=9C=EC=84=A0=20+=20deploy.yml=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=EC=9A=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitea/workflows/deploy.yml | 8 +- src/app/(main)/m/admin/invoices/page.tsx | 228 +++++++++++++++++++---- 2 files changed, 196 insertions(+), 40 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 980a056..1d57158 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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 만). 다른 프로젝트의 사용 중 이미지는 건드리지 않음. diff --git a/src/app/(main)/m/admin/invoices/page.tsx b/src/app/(main)/m/admin/invoices/page.tsx index 54df9d0..3d284fd 100644 --- a/src/app/(main)/m/admin/invoices/page.tsx +++ b/src/app/(main)/m/admin/invoices/page.tsx @@ -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 = { APPROVED: "출고완료", PAID: "입금완료", INVOICED: "계산서발행" }; +const STATUS_LABEL: Record = { + 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([]); + const [all, setAll] = useState([]); + const [customers, setCustomers] = useState([]); const [selected, setSelected] = useState>(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 ( -
+

계산서 발행

-

미발행 {unissued}건 · 선택 {selected.size}건 (₩{fmt(selectedTotal)})

+

+ 전체 {list.length}건 (미발행 {unissued}건) · 선택 {selectedSum.count}건 +

+ {/* 조회 조건 — 입력 즉시 필터 적용 (조회 버튼 없음) */} +
+ 조회조건 + setRange([e.target.value, to])} + className="h-9 px-2 rounded border border-slate-200" /> + ~ + setRange([from, e.target.value])} + className="h-9 px-2 rounded border border-slate-200" /> +
+ ({ value: c.USER_ID, label: c.USER_NAME }))]} + value={customerFilter} + onChange={setCustomerFilter} + placeholder="거래처" + /> +
+ + {(customerFilter || statusFilter) && ( + + )} +
+ + {/* 합계 요약 — 필터 결과 + 선택 합산 */} +
+
+
조회 결과 합계 ({list.length}건)
+
+
면세
₩{fmt(listSum.taxFree)}
+
과세
₩{fmt(listSum.taxable + listSum.vat)}
+
합계
₩{fmt(listSum.total)}
+
+
+
+
✓ 선택 합산 ({selectedSum.count}건)
+
+
면세
₩{fmt(selectedSum.taxFree)}
+
과세
₩{fmt(selectedSum.taxable + selectedSum.vat)}
+
합계
₩{fmt(selectedSum.total)}
+
+
+
+ {/* 모바일: 카드 리스트 */}
{list.length === 0 ? ( @@ -94,18 +242,13 @@ export default function InvoicesPage() { {STATUS_LABEL[o.STATUS]}
-
-
-
합계
-
₩{fmt(o.TOTAL_AMOUNT)}
-
-
-
계산서
-
{o.INVOICE_NO || 미발행}
-
+
+
면세
₩{fmt(o.TOTAL_TAXFREE ?? 0)}
+
과세
₩{fmt((Number(o.TOTAL_TAXABLE) || 0) + (Number(o.TOTAL_VAT) || 0))}
+
합계
₩{fmt(o.TOTAL_AMOUNT)}
- {o.INVOICE_DATE && ( -
발행일 {o.INVOICE_DATE}
+ {o.INVOICE_NO && ( +
계산서 {o.INVOICE_NO} {o.INVOICE_DATE && `· ${o.INVOICE_DATE}`}
)}
@@ -116,13 +259,22 @@ export default function InvoicesPage() { {/* 데스크탑: 표 */}
- +
- + + + @@ -131,16 +283,18 @@ export default function InvoicesPage() { {list.length === 0 ? ( - + ) : list.map((o) => ( - + - + + +
+ 0 && list.filter((o) => !o.INVOICE_NO).every((o) => selected.has(o.OBJID))} + onChange={toggleAll} + /> + 발주번호 발주일 업체명면세과세 합계 상태 계산서번호
대상 발주가 없습니다.
대상 발주가 없습니다.
{!o.INVOICE_NO && toggle(o.OBJID)} className="w-4 h-4 accent-emerald-600" />} {o.ORDER_NO} {o.ORDER_DATE} {o.COMPANY_NAME}₩{fmt(o.TOTAL_AMOUNT)}₩{fmt(o.TOTAL_TAXFREE ?? 0)}₩{fmt((Number(o.TOTAL_TAXABLE) || 0) + (Number(o.TOTAL_VAT) || 0))}₩{fmt(o.TOTAL_AMOUNT)} {STATUS_LABEL[o.STATUS]}