From b27016d71dc1d4df4a8cc18bf9785191c84efe99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Thu, 27 Aug 2026 13:45:19 +0000 Subject: [PATCH] =?UTF-8?q?HEL-177:=20=E5=85=AC=E5=8F=B8=E7=AB=AF=E6=8C=89?= =?UTF-8?q?=E6=96=B9=E6=A1=88=20A=20=E5=AE=9E=E7=8E=B0=E8=BD=AC=E8=B4=A6?= =?UTF-8?q?=E5=BE=80=E6=9D=A5=E5=9B=9B=E5=B1=82=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工作台概览小卡 + 侧栏入口;总览/对方汇总/明细/原始流水抽屉接入 HEL-175/176 接口;无期初时展示期间净变动;模块内用 info 品蓝。 Co-authored-by: Cursor Co-authored-by: multica-agent --- scripts/shot_hel177.py | 286 +++++++++++ server.py | 11 +- tests/test_company_confirm_status_color.py | 13 +- tests/test_company_transfers_page.py | 138 +++++ web/app.js | 567 ++++++++++++++++++++- web/company.html | 260 +++++++++- web/design-system.css | 95 ++++ 7 files changed, 1352 insertions(+), 18 deletions(-) create mode 100644 scripts/shot_hel177.py create mode 100644 tests/test_company_transfers_page.py diff --git a/scripts/shot_hel177.py b/scripts/shot_hel177.py new file mode 100644 index 0000000..5b4de18 --- /dev/null +++ b/scripts/shot_hel177.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +"""HEL-177: 转账往来页 360/820/1440 冒烟截图(mock 接口,不依赖登录库)。""" + +from __future__ import annotations + +import json +import threading +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[1] +WEB = ROOT / "web" +OUT = ROOT / "hel177-shots" +OUT.mkdir(parents=True, exist_ok=True) + +VIEWPORTS = [(1440, 900, "1440"), (820, 900, "820"), (360, 800, "360")] + +SUMMARY = { + "status": "ok", + "own_company": {"id": 3, "name": "河南金牛煤业有限公司"}, + "window": { + "start": "2026-01-01", + "end": "2026-08-20", + "has_opening": False, + "opening": None, + "ending": None, + }, + "confirmed": { + "outflow_total": "123860000.00", + "outflow_count": 48, + "inflow_total": "184205000.00", + "inflow_count": 77, + "net_change": "-60345000.00", + "net_direction": "payable", + }, + "pending": {"count": 3, "amount_total": "3200000.00"}, + "counterparties": [ + { + "company_id": 5, + "company_name": "河南金牛置业有限公司", + "confirmed_outflow": "52000000.00", + "confirmed_inflow": "86000000.00", + "net": "-34000000.00", + "pending_count": 2, + "last_effective_at": "2026-08-18", + }, + { + "company_id": 6, + "company_name": "河南金牛贸易有限公司", + "confirmed_outflow": "10000000.00", + "confirmed_inflow": "12605000.00", + "net": "-2605000.00", + "pending_count": 1, + "last_effective_at": "2026-08-10", + }, + { + "company_id": 7, + "company_name": "河南金牛农业科技发展有限公司", + "confirmed_outflow": "61860000.00", + "confirmed_inflow": "85600000.00", + "net": "-23740000.00", + "pending_count": 0, + "last_effective_at": "2026-07-30", + }, + ], +} + +# 修正 mock:按 HEL-169 净变动=转出-转入,正数应收 +SUMMARY["confirmed"] = { + "outflow_total": "184205000.00", + "outflow_count": 80, + "inflow_total": "123860000.00", + "inflow_count": 48, + "net_change": "60345000.00", + "net_direction": "receivable", +} +SUMMARY["counterparties"] = [ + { + "company_id": 5, + "company_name": "河南金牛置业有限公司", + "confirmed_outflow": "86000000.00", + "confirmed_inflow": "52000000.00", + "net": "34000000.00", + "pending_count": 2, + "last_effective_at": "2026-08-18", + }, + { + "company_id": 6, + "company_name": "河南金牛贸易有限公司", + "confirmed_outflow": "12605000.00", + "confirmed_inflow": "10000000.00", + "net": "2605000.00", + "pending_count": 1, + "last_effective_at": "2026-08-10", + }, + { + "company_id": 7, + "company_name": "河南金牛农业科技发展有限公司", + "confirmed_outflow": "85600000.00", + "confirmed_inflow": "61860000.00", + "net": "23740000.00", + "pending_count": 0, + "last_effective_at": "2026-07-30", + }, +] + +EVENTS = { + "status": "ok", + "window": {"start": "2026-01-01", "end": "2026-08-20"}, + "events": [ + { + "event_id": 101, + "direction": "in", + "state": "confirmed", + "pairing": "paired", + "locked": False, + "amount": "12000000.00", + "currency": "CNY", + "effective_at": "2026-08-18", + "summary": "周转资金调拨", + "status": "matched", + }, + { + "event_id": 102, + "direction": "out", + "state": "pending", + "pairing": "unilateral", + "locked": False, + "amount": "1500000.00", + "currency": "CNY", + "effective_at": "2026-08-12", + "summary": "工程款结算(待确认)", + "status": "unresolved", + }, + ], + "next_cursor": None, + "has_more": False, +} + +DETAIL = { + "status": "ok", + "event": { + "event_id": 101, + "classification": "intercompany", + "pairing": "paired", + "status": "matched", + "amount": "12000000.00", + "currency": "CNY", + "effective_at": "2026-08-18", + "mode": "auto", + "reason": "双边匹配", + "counterparty": { + "company_id": 5, + "company_name": "河南金牛置业有限公司", + "account_number_masked": "****8821", + }, + "observations": [ + { + "source_row_id": 1, + "role": "payee", + "source_row": 148, + "sheet_name": "流水", + "transaction_at": "2026-08-18 10:22:00", + "income": "12000000.00", + "expense": None, + "import_batch_id": 19, + "original_filename": "工行3305_202608.xls", + "own_account_masked": "****3305", + "counterparty_account_masked": "****8821", + "counterparty_name": "河南金牛置业有限公司", + "summary": "周转资金调拨", + "reference": "JS20260818-00317", + } + ], + }, +} + + +def main() -> None: + handler = partial(SimpleHTTPRequestHandler, directory=str(WEB)) + httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler) + port = httpd.server_address[1] + threading.Thread(target=httpd.serve_forever, daemon=True).start() + base = f"http://127.0.0.1:{port}" + + mock_js = f""" + window.__HEL177_MOCK__ = true; + const SUMMARY = {json.dumps(SUMMARY, ensure_ascii=False)}; + const EVENTS = {json.dumps(EVENTS, ensure_ascii=False)}; + const DETAIL = {json.dumps(DETAIL, ensure_ascii=False)}; + const _fetch = window.fetch.bind(window); + window.fetch = async (input, init) => {{ + const url = String(input); + if (url.includes('/api/me') || url.includes('/api/session')) {{ + return new Response(JSON.stringify({{role:'company', company_id:3, username:'牛女士', company_name:'河南金牛煤业有限公司'}}), {{status:200, headers:{{'Content-Type':'application/json'}}}}); + }} + if (url.includes('/api/company/workspace')) {{ + return new Response(JSON.stringify({{status:'ok', pending_unilateral:0, pending_total:0, unilateral_events:[]}}), {{status:200, headers:{{'Content-Type':'application/json'}}}}); + }} + if (url.includes('/api/company/intercompany/summary')) {{ + return new Response(JSON.stringify(SUMMARY), {{status:200, headers:{{'Content-Type':'application/json'}}}}); + }} + if (url.includes('/api/company/intercompany/events')) {{ + return new Response(JSON.stringify(EVENTS), {{status:200, headers:{{'Content-Type':'application/json'}}}}); + }} + if (url.includes('/api/company/transfer-events/')) {{ + return new Response(JSON.stringify(DETAIL), {{status:200, headers:{{'Content-Type':'application/json'}}}}); + }} + if (url.includes('/api/company/')) {{ + return new Response(JSON.stringify({{status:'ok'}}), {{status:200, headers:{{'Content-Type':'application/json'}}}}); + }} + return _fetch(input, init); + }}; + """ + + console_errors: list[str] = [] + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.on("pageerror", lambda exc: console_errors.append(f"pageerror:{exc}")) + page.on("console", lambda msg: console_errors.append(f"console:{msg.type}:{msg.text}") if msg.type == "error" else None) + + for width, height, label in VIEWPORTS: + page.set_viewport_size({"width": width, "height": height}) + page.add_init_script(mock_js) + # bypass auth guard + page.add_init_script( + """ + const orig = window.fetch; + // initAuthGuard 读 /api/me;已在 mock 中处理 + """ + ) + page.goto(f"{base}/company.html", wait_until="domcontentloaded") + page.evaluate( + """() => { + // 若鉴权把页面踢走,强制停留 + if (!document.body || document.body.dataset.portal !== 'company') return; + }""" + ) + # 等 summary 渲染 + page.wait_for_timeout(600) + # 切到转账往来 + page.evaluate("() => { if (typeof showView === 'function') showView('transfers'); }") + page.wait_for_selector("#transfersData:not([hidden])", timeout=5000) + page.wait_for_timeout(300) + overflow = page.evaluate( + "() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1" + ) + assert not overflow, f"{label} overview overflow" + # 文案:期间净变动,无期末余额误用 + net_title = page.inner_text("#tfStatNetTitle") + assert "期间净变动" in net_title, net_title + page.screenshot(path=str(OUT / f"overview-{label}.png"), full_page=True) + + # 下钻明细 + page.click("#transfersCpBody tr.clickable") + page.wait_for_selector("#transfersDetailLayer:not([hidden])", timeout=5000) + page.wait_for_timeout(300) + overflow2 = page.evaluate( + "() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1" + ) + assert not overflow2, f"{label} detail overflow" + page.screenshot(path=str(OUT / f"detail-{label}.png"), full_page=True) + + # 打开抽屉 + page.click("[data-transfer-evidence]") + page.wait_for_selector("#transferEvidenceDrawer.is-open", timeout=5000) + page.wait_for_timeout(200) + page.screenshot(path=str(OUT / f"drawer-{label}.png"), full_page=True) + page.click("[data-close-transfer-evidence]") + + browser.close() + + hard = [e for e in console_errors if "Failed to load resource" not in e and "favicon" not in e] + print("shots:", list(OUT.glob("*.png"))) + print("console_errors:", hard) + if hard: + raise SystemExit(1) + print("OK") + + +if __name__ == "__main__": + main() diff --git a/server.py b/server.py index e1f9bdf..078bc57 100644 --- a/server.py +++ b/server.py @@ -2439,10 +2439,12 @@ class AppHandler(SimpleHTTPRequestHandler): SELECT r.id, r.source_row, r.transaction_at, r.income, r.expense, r.own_account, r.own_name, r.counterparty_account, r.counterparty_name, r.summary, r.reference, - s.sheet_name, b.company_id AS batch_company_id + s.sheet_name, b.id AS import_batch_id, + f.original_filename, b.company_id AS batch_company_id FROM source_rows r JOIN sheet_batches s ON s.id = r.sheet_batch_id JOIN import_batches b ON b.id = s.import_batch_id + JOIN source_files f ON f.id = b.source_file_id WHERE r.id = ? """, (observation["source_row_id"],), @@ -2459,9 +2461,16 @@ class AppHandler(SimpleHTTPRequestHandler): "income": row["income"], "expense": row["expense"], "batch_company_id": row["batch_company_id"], + "import_batch_id": row["import_batch_id"], + "original_filename": row["original_filename"], "own_account_masked": master_data.mask_account_number(row["own_account"]) if row["own_account"] else None, + "counterparty_account_masked": master_data.mask_account_number( + row["counterparty_account"] + ) + if row["counterparty_account"] + else None, "counterparty_name": row["counterparty_name"], "summary": row["summary"], "reference": row["reference"], diff --git a/tests/test_company_confirm_status_color.py b/tests/test_company_confirm_status_color.py index 1165a56..bfc4628 100644 --- a/tests/test_company_confirm_status_color.py +++ b/tests/test_company_confirm_status_color.py @@ -50,7 +50,7 @@ class ConfirmStatusSourceContractTests(unittest.TestCase): self.assertIn('id="workspacePendingStatus"', html) self.assertIn('id="workspaceFlowSub"', html) self.assertIn('data-view-link="reconcile"', html) - self.assertIn("app.js?v=11", html) + self.assertIn("app.js?v=12", html) # 静态初值仍为进行中(黄),由 JS 在 pending=0 时切 done self.assertRegex(html, r'class="flow-step doing"[^>]*data-view-link="reconcile"') @@ -69,7 +69,18 @@ def _extract_fn(source: str, name: str) -> str: raise AssertionError(f"未能截取 function {name}") +def _chromium_available() -> bool: + """本机缺 libatk 等系统库时 chromium 无法启动。""" + try: + import ctypes.util + + return bool(ctypes.util.find_library("atk-1.0")) + except Exception: + return False + + @unittest.skipUnless(sync_playwright, "playwright 未安装,跳过真实 DOM 色值校验") +@unittest.skipUnless(_chromium_available(), "系统缺少 chromium 依赖库(如 libatk),跳过浏览器色值校验") class ConfirmStatusDomTests(unittest.TestCase): """真实浏览器:pending>0 为黄,pending=0 为绿。""" diff --git a/tests/test_company_transfers_page.py b/tests/test_company_transfers_page.py new file mode 100644 index 0000000..cdf9538 --- /dev/null +++ b/tests/test_company_transfers_page.py @@ -0,0 +1,138 @@ +"""HEL-177: 公司端转账往来页面(方案 A)结构与接口契约。""" + +from __future__ import annotations + +import re +import threading +import unittest +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WEB = ROOT / "web" + +try: + from playwright.sync_api import sync_playwright +except ImportError: # pragma: no cover + sync_playwright = None + + +class TransfersPageSourceContractTests(unittest.TestCase): + def test_company_html_has_nav_overview_and_layers(self) -> None: + html = (WEB / "company.html").read_text(encoding="utf-8") + self.assertIn('data-view="transfers"', html) + self.assertIn("转账往来", html) + self.assertIn('id="workspaceTransfersCard"', html) + self.assertIn("转账往来概览", html) + self.assertIn('data-page="transfers"', html) + self.assertIn('id="transfersOverviewLayer"', html) + self.assertIn('id="transfersDetailLayer"', html) + self.assertIn('id="transferEvidenceDrawer"', html) + self.assertIn("期间净变动", html) + self.assertNotIn("本公司往来合计", html) + self.assertIn("design-system.css?v=6", html) + self.assertIn("app.js?v=12", html) + # 侧栏顺序:流水管理 → 转账往来 → 往来确认 + flows = html.index('data-view="flows"') + transfers = html.index('data-view="transfers"') + reconcile = html.index('data-view="reconcile"') + self.assertLess(flows, transfers) + self.assertLess(transfers, reconcile) + + def test_app_js_wires_real_apis_no_demo_totals(self) -> None: + js = (WEB / "app.js").read_text(encoding="utf-8") + self.assertIn('transfers: "转账往来"', js) + self.assertIn("/api/company/intercompany/summary", js) + self.assertIn("/api/company/intercompany/events", js) + self.assertIn("/api/company/intercompany/export.csv", js) + self.assertIn("/api/company/transfer-events/", js) + self.assertIn("function loadTransfersSummary", js) + self.assertIn("function openTransfersDetail", js) + self.assertIn("function openTransferEvidence", js) + self.assertIn("function initTransfers", js) + self.assertIn("initTransfers()", js) + self.assertIn("期间净变动", js) + self.assertIn("has_opening", js) + # 不得把「期末余额」写死为无期初时的标签 + self.assertNotRegex(js, r'netLabelForWindow[^{]+{[^}]*return "期末余额"') + + def test_design_system_scopes_info_to_transfers_module(self) -> None: + css = (WEB / "design-system.css").read_text(encoding="utf-8") + self.assertIn('a[data-view="transfers"].active', css) + self.assertIn("var(--info-soft)", css) + self.assertIn('[data-page="transfers"] .btn-primary', css) + self.assertIn(".xfer-split", css) + self.assertIn(".xfer-split-pane.confirmed", css) + self.assertIn(".xfer-split-pane.pending", css) + # 无新色硬编码 + self.assertNotRegex(css, r"\.xfer-split[^\{]*\{[^}]*#[0-9a-fA-F]{3,8}") + + +class TransfersDetailObservationContractTests(unittest.TestCase): + def test_server_exposes_batch_fields_on_observations(self) -> None: + server = (ROOT / "server.py").read_text(encoding="utf-8") + self.assertIn("import_batch_id", server) + self.assertIn("original_filename", server) + self.assertIn("counterparty_account_masked", server) + + +def _chromium_available() -> bool: + try: + import ctypes.util + + return bool(ctypes.util.find_library("atk-1.0")) + except Exception: + return False + + +@unittest.skipUnless(sync_playwright, "playwright 未安装,跳过布局冒烟") +@unittest.skipUnless(_chromium_available(), "系统缺少 chromium 依赖库(如 libatk),跳过布局冒烟") +class TransfersPageLayoutSmokeTests(unittest.TestCase): + """静态壳:360 / 820 / 1440 无横向溢出。""" + + @classmethod + def setUpClass(cls) -> None: + handler = partial(SimpleHTTPRequestHandler, directory=str(WEB)) + cls.httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler) + cls.port = cls.httpd.server_address[1] + cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True) + cls.thread.start() + cls.base = f"http://127.0.0.1:{cls.port}" + + @classmethod + def tearDownClass(cls) -> None: + cls.httpd.shutdown() + cls.httpd.server_close() + + def test_transfers_shell_no_horizontal_overflow(self) -> None: + html = (WEB / "company.html").read_text(encoding="utf-8") + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + for width in (360, 820, 1440): + page.set_viewport_size({"width": width, "height": 900}) + page.set_content( + html.replace('src="app.js?v=12"', 'src=""'), + base_url=self.base, + ) + page.evaluate( + """() => { + document.querySelectorAll('.app-view').forEach((el) => { + el.classList.toggle('is-active', el.dataset.page === 'transfers'); + }); + const data = document.getElementById('transfersData'); + const loading = document.getElementById('transfersLoading'); + if (loading) loading.hidden = true; + if (data) data.hidden = false; + }""" + ) + overflow = page.evaluate( + "() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1" + ) + self.assertFalse(overflow, f"{width}px 出现横向溢出") + browser.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/web/app.js b/web/app.js index 8e9561e..33551f8 100644 --- a/web/app.js +++ b/web/app.js @@ -4,7 +4,7 @@ const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)] const portal = document.body.dataset.portal || "entry"; const viewNames = portal === "admin" ? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", reminders: "提醒管理" } - : { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" }; + : { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", transfers: "转账往来", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" }; const storageKeys = { manual: "ledger-demo-manual-records", @@ -177,11 +177,27 @@ function showView(view) { else item.removeAttribute("aria-current"); }); const title = $("#currentViewName"); - if (title) title.textContent = viewNames[view]; + if (title) { + if (view === "transfers" && state.transfersDetail?.company_name) { + title.textContent = `转账往来 / ${state.transfersDetail.company_name}`; + } else { + title.textContent = viewNames[view]; + } + } closeNavigation({ restoreFocus: navigationWasOpen }); const activeView = $(`.app-view[data-page="${view}"]`); requestAnimationFrame(() => animateView(activeView)); window.scrollTo({ top: 0, behavior: motionQuery.matches ? "auto" : "smooth" }); + if (portal === "company" && view === "transfers") { + if (state.transfersKeepDetail && state.transfersDetail?.company_id) { + showTransfersDetailLayer(); + } else { + state.transfersDetail = null; + showTransfersOverviewLayer(); + loadTransfersSummary(); + } + } + state.transfersKeepDetail = false; } // 原型占位,非本司真待办/真断档数据:detailContent 仅用于演示总览/工作台事项 @@ -2934,6 +2950,550 @@ function openAccountDetail(account) { $("#modal-account-detail")?.classList.add("open"); } +function yuanToWan(value) { + const n = Number(value); + if (!Number.isFinite(n)) return null; + return n / 10000; +} + +function formatWan(value, { signed = false } = {}) { + const wan = yuanToWan(value); + if (wan === null) return "—"; + const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + if (!signed) return `${abs}万元`; + const sign = wan > 0 ? "+" : wan < 0 ? "−" : ""; + return `${sign}${abs}万元`; +} + +function formatWanText(value, { signed = false } = {}) { + const wan = yuanToWan(value); + if (wan === null) return "—"; + const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + if (!signed) return `${abs} 万元`; + const sign = wan > 0 ? "+" : wan < 0 ? "−" : ""; + return `${sign}${abs} 万元`; +} + +function netDirectionMeta(netValue, netDirection) { + const wan = yuanToWan(netValue); + if (wan === null || wan === 0 || !netDirection) { + return { label: "持平", className: "flat", signedClass: "" }; + } + if (netDirection === "receivable" || wan > 0) { + return { label: "应收", className: "recv", signedClass: "pos" }; + } + return { label: "应付", className: "pay", signedClass: "neg" }; +} + +function netLabelForWindow(windowInfo) { + return windowInfo?.has_opening ? "期末净往来" : "期间净变动"; +} + +function showTransfersOverviewLayer() { + const overview = $("#transfersOverviewLayer"); + const detail = $("#transfersDetailLayer"); + if (overview) overview.hidden = false; + if (detail) detail.hidden = true; +} + +function showTransfersDetailLayer() { + const overview = $("#transfersOverviewLayer"); + const detail = $("#transfersDetailLayer"); + if (overview) overview.hidden = true; + if (detail) detail.hidden = false; +} + +function setTransfersUiState(mode) { + ["transfersLoading", "transfersError", "transfersEmpty", "transfersData"].forEach((id) => { + const el = $(`#${id}`); + if (el) el.hidden = id !== mode; + }); +} + +function applyTransfersNavBadge(pendingCount) { + const badge = $("#transfersNavBadge") || $('.side-nav a[data-view="transfers"] .nav-badge'); + if (!badge) return; + const n = Number(pendingCount) || 0; + badge.textContent = String(n); + badge.hidden = n <= 0; + if (n <= 0) badge.setAttribute("hidden", ""); + else badge.removeAttribute("hidden"); +} + +function renderWorkspaceTransfersCard(summary) { + const sub = $("#workspaceTransfersSub"); + if (!sub || !summary) return; + const win = summary.window || {}; + const confirmed = summary.confirmed || {}; + const pending = summary.pending || {}; + const netMeta = netDirectionMeta(confirmed.net_change, confirmed.net_direction); + sub.textContent = `${win.start || "—"} 至 ${win.end || "—"} · 集团内公司间 · 单位:万元`; + const inflow = $("#wsTfIn"); + const outflow = $("#wsTfOut"); + const net = $("#wsTfNet"); + const pendingEl = $("#wsTfPending"); + const netLabel = $("#wsTfNetLabel"); + if (inflow) inflow.innerHTML = formatWan(confirmed.inflow_total, { signed: true }); + if (outflow) { + outflow.innerHTML = formatWan( + confirmed.outflow_total != null ? -Math.abs(Number(confirmed.outflow_total)) : null, + { signed: true }, + ); + // formatWan with negative value already adds −; ensure unit + if (confirmed.outflow_total != null) { + const wan = yuanToWan(confirmed.outflow_total); + const abs = Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + outflow.innerHTML = `−${abs}万元`; + } + } + if (netLabel) netLabel.textContent = `${netLabelForWindow(win)}${netMeta.label !== "持平" ? ` · ${netMeta.label}` : ""}`; + if (net) { + net.className = `ms-value ${netMeta.signedClass}`.trim(); + net.innerHTML = formatWan(confirmed.net_change, { signed: true }); + } + if (pendingEl) { + const pCount = Number(pending.count) || 0; + pendingEl.innerHTML = pCount + ? `${formatWan(pending.amount_total)} · ${pCount} 笔` + : `0.00万元 · 0 笔`; + } +} + +async function loadTransfersSummary({ asOf } = {}) { + if (!$("#transfersOverviewLayer")) return null; + setTransfersUiState("transfersLoading"); + const params = new URLSearchParams(); + if (asOf) params.set("as_of", asOf); + const qs = params.toString(); + const response = await fetch(`/api/company/intercompany/summary${qs ? `?${qs}` : ""}`).catch(() => null); + if (response?.status === 401 || response?.status === 403) { + window.location.href = "index.html"; + return null; + } + const result = await response?.json().catch(() => null); + if (!response?.ok || !result || result.status !== "ok") { + setTransfersUiState("transfersError"); + const body = $("#transfersErrorBody"); + if (body) body.textContent = result?.message || "服务器连接异常。为避免误读,本页不展示任何金额。"; + return null; + } + state.transfersSummary = result; + applyTransfersNavBadge(result.pending?.count); + renderWorkspaceTransfersCard(result); + renderTransfersOverview(result); + return result; +} + +function renderTransfersOverview(summary) { + const win = summary.window || {}; + const confirmed = summary.confirmed || {}; + const pending = summary.pending || {}; + const cps = Array.isArray(summary.counterparties) ? summary.counterparties : []; + const ownName = summary.own_company?.name || "本公司"; + const pageSub = $("#transfersPageSub"); + if (pageSub) { + pageSub.textContent = `${ownName} · 统计区间 ${win.start || "—"} 至 ${win.end || "—"},仅含集团内公司间转账(HEL-169 口径)。单位:万元。`; + } + + const goConfirm = $("#transfersGoConfirm"); + const pCount = Number(pending.count) || 0; + if (goConfirm) { + goConfirm.hidden = pCount <= 0; + goConfirm.textContent = pCount ? `去确认待确认 ${pCount} 笔` : "去确认待确认"; + } + + const hasAny = cps.length > 0 || Number(confirmed.outflow_count || 0) > 0 || Number(confirmed.inflow_count || 0) > 0 || pCount > 0; + if (!hasAny) { + setTransfersUiState("transfersEmpty"); + const emptyStats = $("#transfersEmptyStats"); + if (emptyStats) { + emptyStats.innerHTML = ` +
往来公司数
0
${win.start || "—"} ~ ${win.end || "—"}
+
本期转入 · 流入
0.00万元
+
本期转出 · 流出
0.00万元
+
${netLabelForWindow(win)}
0.00万元
`; + } + return; + } + + setTransfersUiState("transfersData"); + const companies = $("#tfStatCompanies"); + const inflow = $("#tfStatIn"); + const outflow = $("#tfStatOut"); + const net = $("#tfStatNet"); + const netTitle = $("#tfStatNetTitle"); + const netFoot = $("#tfStatNetFoot"); + const netMeta = netDirectionMeta(confirmed.net_change, confirmed.net_direction); + if (companies) companies.innerHTML = `${cps.length}`; + if (inflow) inflow.innerHTML = formatWan(confirmed.inflow_total, { signed: true }); + if (outflow) { + const wan = yuanToWan(confirmed.outflow_total); + const abs = wan === null ? "—" : Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + outflow.innerHTML = wan === null ? "—" : `−${abs}万元`; + } + if (netTitle) netTitle.textContent = netLabelForWindow(win); + if (net) { + const tag = netMeta.label !== "持平" + ? ` ${netMeta.label}` + : ""; + net.innerHTML = `${formatWan(confirmed.net_change, { signed: true })}${tag}`; + } + if (netFoot) { + netFoot.textContent = win.has_opening + ? "期末 = 期初 + 已确认转出 − 已确认转入" + : "起算日未就绪 · 展示期间净变动(转出 − 转入),不得当作期末余额"; + } + + const confirmedCount = (Number(confirmed.outflow_count) || 0) + (Number(confirmed.inflow_count) || 0); + const tfConfirmedCount = $("#tfConfirmedCount"); + const tfConfirmedNet = $("#tfConfirmedNet"); + const tfPendingCount = $("#tfPendingCount"); + const tfPendingAmount = $("#tfPendingAmount"); + if (tfConfirmedCount) tfConfirmedCount.textContent = `${confirmedCount} 笔`; + if (tfConfirmedNet) tfConfirmedNet.innerHTML = formatWan(confirmed.net_change, { signed: true }); + if (tfPendingCount) tfPendingCount.textContent = `${pCount} 笔`; + if (tfPendingAmount) tfPendingAmount.innerHTML = formatWan(pending.amount_total || 0); + + const tbody = $("#transfersCpBody"); + const tfoot = $("#transfersCpFoot"); + if (!tbody) return; + if (!cps.length) { + tbody.innerHTML = `
暂无对方公司汇总
`; + if (tfoot) tfoot.innerHTML = ""; + return; + } + tbody.innerHTML = cps.map((cp) => { + const meta = netDirectionMeta(cp.net, Number(cp.net) > 0 ? "receivable" : Number(cp.net) < 0 ? "payable" : null); + const pendingN = Number(cp.pending_count) || 0; + const pendingPill = pendingN + ? `${pendingN} 笔待确认` + : `全部已确认`; + const last = cp.last_effective_at ? String(cp.last_effective_at).slice(0, 10) : "—"; + const inWan = yuanToWan(cp.confirmed_inflow); + const outWan = yuanToWan(cp.confirmed_outflow); + const netWan = yuanToWan(cp.net); + const fmtAbs = (wan) => wan === null ? "—" : Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + const inText = inWan === null ? "—" : `+${fmtAbs(inWan)}`; + const outText = outWan === null ? "—" : `−${fmtAbs(outWan)}`; + const netText = netWan === null ? "—" : `${netWan > 0 ? "+" : netWan < 0 ? "−" : ""}${fmtAbs(netWan)}`; + return ` + ${cp.company_name || "—"} + ${inText} + ${outText} + ${netText} + ${meta.label} + ${pendingPill} + ${last} + `; + }).join(""); + + if (tfoot) { + const totalPending = cps.reduce((s, cp) => s + (Number(cp.pending_count) || 0), 0); + const inWan = yuanToWan(confirmed.inflow_total); + const outWan = yuanToWan(confirmed.outflow_total); + const netWan = yuanToWan(confirmed.net_change); + const fmtAbs = (wan) => wan === null ? "—" : Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + tfoot.innerHTML = ` + 合计 + ${inWan === null ? "—" : `+${fmtAbs(inWan)}`} + ${outWan === null ? "—" : `−${fmtAbs(outWan)}`} + ${netWan === null ? "—" : `${netWan > 0 ? "+" : netWan < 0 ? "−" : ""}${fmtAbs(netWan)}`} + ${netMeta.label} + ${totalPending ? `${totalPending} 笔待确认` : `全部已确认`} + + `; + } +} + +function transfersEventQueryParams({ includeCursor = false } = {}) { + const detail = state.transfersDetail || {}; + const filters = detail.filters || {}; + const params = new URLSearchParams(); + if (detail.company_id) params.set("counterparty_id", String(detail.company_id)); + if (filters.from) params.set("from", filters.from); + if (filters.to) params.set("to", filters.to); + if (filters.direction) params.set("direction", filters.direction); + if (filters.state) params.set("state", filters.state); + params.set("limit", "50"); + if (includeCursor && detail.nextCursor) params.set("cursor", detail.nextCursor); + return params; +} + +async function openTransfersDetail(companyId, companyName, { keepFilters = false } = {}) { + const summary = state.transfersSummary; + const win = summary?.window || {}; + const existing = state.transfersDetail; + const filters = keepFilters && existing?.filters + ? { ...existing.filters } + : { + from: win.start || "", + to: win.end || "", + direction: "", + state: "", + }; + state.transfersDetail = { + company_id: Number(companyId), + company_name: companyName || "对方公司", + filters, + nextCursor: null, + events: [], + }; + state.transfersKeepDetail = true; + showTransfersDetailLayer(); + const title = $("#currentViewName"); + if (title) title.textContent = `转账往来 / ${state.transfersDetail.company_name}`; + $("#transfersDetailTitle").textContent = state.transfersDetail.company_name; + $("#tfFilterFrom").value = filters.from || ""; + $("#tfFilterTo").value = filters.to || ""; + $("#tfFilterDirection").value = filters.direction || ""; + $("#tfFilterState").value = filters.state || ""; + + const cp = (summary?.counterparties || []).find((c) => Number(c.company_id) === Number(companyId)); + const pendingN = Number(cp?.pending_count) || 0; + $("#tfDetailCount").innerHTML = `—`; + $("#tfDetailCountFoot").textContent = pendingN ? `其中待确认 ${pendingN} 笔(汇总)` : "按筛选加载明细"; + if (cp) { + $("#tfDetailIn").innerHTML = formatWan(cp.confirmed_inflow, { signed: true }); + const wan = yuanToWan(cp.confirmed_outflow); + $("#tfDetailOut").innerHTML = wan === null + ? "—" + : `−${Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}万元`; + } + if (state.currentView !== "transfers") { + state.transfersKeepDetail = true; + showView("transfers"); + } + await loadTransfersEvents({ reset: true }); +} + +async function loadTransfersEvents({ reset = false } = {}) { + if (!state.transfersDetail?.company_id) return; + const tbody = $("#transfersEventBody"); + const empty = $("#transfersEventEmpty"); + const foot = $("#transfersEventFoot"); + const moreBtn = $("#transfersLoadMore"); + if (reset) { + state.transfersDetail.nextCursor = null; + state.transfersDetail.events = []; + if (tbody) showTableLoading(tbody, 6); + if (empty) empty.hidden = true; + if (moreBtn) moreBtn.hidden = true; + } + const params = transfersEventQueryParams({ includeCursor: !reset && !!state.transfersDetail.nextCursor }); + // Always need a distinguishing filter so HEL-176 path is used (counterparty_id is enough) + const response = await fetch(`/api/company/intercompany/events?${params}`).catch(() => null); + if (response?.status === 401 || response?.status === 403) { + window.location.href = "index.html"; + return; + } + const result = await response?.json().catch(() => null); + if (!response?.ok || !result || result.status !== "ok") { + if (tbody) showTableError(tbody, 6); + showToast("明细加载失败", result?.message || "请稍后重试", "danger"); + return; + } + const events = Array.isArray(result.events) ? result.events : []; + if (reset) state.transfersDetail.events = events; + else state.transfersDetail.events = [...(state.transfersDetail.events || []), ...events]; + state.transfersDetail.nextCursor = result.next_cursor || null; + state.transfersDetail.hasMore = !!result.has_more; + renderTransfersEvents(); + if (foot) foot.textContent = `已加载 ${state.transfersDetail.events.length} 笔${result.has_more ? " · 还有更多" : ""}`; + if (moreBtn) moreBtn.hidden = !result.has_more; + $("#tfDetailCount").innerHTML = `${state.transfersDetail.events.length}${result.has_more ? "+" : ""}`; +} + +function transfersStateLabel(event) { + if (event.state === "confirmed") { + if (event.pairing === "paired") return { pill: "pill-success", text: "已确认 · 双边" }; + if (event.locked) return { pill: "pill-success", text: "已确认 · 单边锁定" }; + return { pill: "pill-success", text: "已确认" }; + } + return { pill: "pill-warn", text: "待确认" }; +} + +function renderTransfersEvents() { + const tbody = $("#transfersEventBody"); + const empty = $("#transfersEventEmpty"); + if (!tbody) return; + const events = state.transfersDetail?.events || []; + if (!events.length) { + tbody.innerHTML = ""; + if (empty) empty.hidden = false; + return; + } + if (empty) empty.hidden = true; + tbody.innerHTML = events.map((ev) => { + const dir = ev.direction === "out" ? "转出" : ev.direction === "in" ? "转入" : "—"; + const dirClass = ev.direction === "out" ? "amt-out" : ev.direction === "in" ? "amt-in" : ""; + const wan = yuanToWan(ev.amount); + const amt = wan === null + ? "—" + : `${ev.direction === "out" ? "−" : "+"}${Math.abs(wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + const st = transfersStateLabel(ev); + const date = String(ev.effective_at || "").slice(0, 10) || "—"; + const pendingClass = ev.state === "pending" ? " is-pending" : ""; + return ` + ${date} + ${ev.summary || "—"} + ${dir}${ev.direction === "in" ? " ↓" : ev.direction === "out" ? " ↑" : ""} + ${amt} + ${st.text} + + `; + }).join(""); +} + +async function openTransferEvidence(eventId) { + const drawer = $("#transferEvidenceDrawer"); + if (!drawer) return; + $("#tfEvTitle").textContent = "加载中…"; + $("#tfEvDesc").textContent = ""; + $("#tfEvFields").replaceChildren(); + drawer.classList.add("is-open"); + drawer.setAttribute("aria-hidden", "false"); + const response = await fetch(`/api/company/transfer-events/${eventId}`).catch(() => null); + if (response?.status === 401 || response?.status === 403) { + window.location.href = "index.html"; + return; + } + const result = await response?.json().catch(() => null); + if (!response?.ok || !result || result.status !== "ok") { + $("#tfEvTitle").textContent = "无法打开原始流水"; + $("#tfEvDesc").textContent = result?.message || "事件不存在或无权查看"; + return; + } + const event = result.event || {}; + const obs = Array.isArray(event.observations) ? event.observations[0] : null; + const cp = event.counterparty || {}; + $("#tfEvTag").className = "pill pill-info"; + $("#tfEvTag").textContent = "银行原始流水 · 只读"; + $("#tfEvTitle").textContent = obs?.reference || `事件 #${event.event_id}`; + $("#tfEvDesc").textContent = obs?.import_batch_id + ? `导入批次 IMP-${String(obs.import_batch_id).padStart(6, "0")}${obs.original_filename ? ` · ${obs.original_filename}` : ""}` + : "本方银行流水原文"; + const income = obs?.income != null && Number(obs.income) !== 0; + const amountText = obs + ? `${Number(income ? obs.income : obs.expense).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元(${income ? "收 / 转入" : "付 / 转出"})` + : `${event.amount || "—"} ${event.currency || "CNY"}`; + const fields = [ + ["本方账户", obs?.own_account_masked || "—"], + ["交易时间", obs?.transaction_at || event.effective_at || "—"], + ["方向", income ? "收入(转入)" : "支出(转出)"], + ["金额", amountText], + ["对方户名", obs?.counterparty_name || cp.company_name || "—"], + ["对方账号", obs?.counterparty_account_masked || cp.account_number_masked || "—"], + ["工作表", obs?.sheet_name || "—"], + ["原始文件行号", obs?.source_row != null ? String(obs.source_row) : "—"], + ["摘要", obs?.summary || event.reason || "—"], + ["确认状态", event.pairing === "paired" ? "已确认 · 双边一致" : event.status || "—"], + ]; + const dl = $("#tfEvFields"); + dl.replaceChildren(...fields.flatMap(([label, value]) => { + const dt = document.createElement("dt"); + dt.textContent = label; + const dd = document.createElement("dd"); + dd.className = "num"; + dd.textContent = value; + return [dt, dd]; + })); + $("[data-close-transfer-evidence]", drawer)?.focus(); +} + +function closeTransferEvidence({ restoreFocus = true } = {}) { + const drawer = $("#transferEvidenceDrawer"); + if (!drawer) return; + drawer.classList.remove("is-open"); + drawer.setAttribute("aria-hidden", "true"); +} + +function buildTransfersExportUrl() { + const detail = state.transfersDetail; + const summary = state.transfersSummary; + const params = new URLSearchParams(); + if (detail?.company_id) { + params.set("counterparty_id", String(detail.company_id)); + const f = detail.filters || {}; + if (f.from) params.set("from", f.from); + if (f.to) params.set("to", f.to); + if (f.direction) params.set("direction", f.direction); + } else if (summary?.window) { + if (summary.window.start) params.set("from", summary.window.start); + if (summary.window.end) params.set("to", summary.window.end); + } + const qs = params.toString(); + return `/api/company/intercompany/export.csv${qs ? `?${qs}` : ""}`; +} + +function initTransfers() { + if (!$("[data-page='transfers']")) return; + + $("#transfersRetryBtn")?.addEventListener("click", () => loadTransfersSummary()); + $("#transfersExportBtn")?.addEventListener("click", () => { + window.location.href = buildTransfersExportUrl(); + }); + $("#transfersDetailExportBtn")?.addEventListener("click", () => { + window.location.href = buildTransfersExportUrl(); + }); + $("#transfersBackBtn")?.addEventListener("click", () => { + state.transfersDetail = null; + showTransfersOverviewLayer(); + const title = $("#currentViewName"); + if (title) title.textContent = "转账往来"; + if (!state.transfersSummary) loadTransfersSummary(); + else renderTransfersOverview(state.transfersSummary); + }); + $("#transfersCpBody")?.addEventListener("click", (event) => { + const row = event.target.closest("tr[data-cp-id]"); + if (!row) return; + openTransfersDetail(row.dataset.cpId, row.dataset.cpName); + }); + $("#transfersFilterForm")?.addEventListener("submit", (event) => { + event.preventDefault(); + if (!state.transfersDetail) return; + state.transfersDetail.filters = { + from: $("#tfFilterFrom")?.value || "", + to: $("#tfFilterTo")?.value || "", + direction: $("#tfFilterDirection")?.value || "", + state: $("#tfFilterState")?.value || "", + }; + loadTransfersEvents({ reset: true }); + }); + const clearFilters = () => { + if (!state.transfersDetail) return; + const win = state.transfersSummary?.window || {}; + state.transfersDetail.filters = { + from: win.start || "", + to: win.end || "", + direction: "", + state: "", + }; + $("#tfFilterFrom").value = state.transfersDetail.filters.from; + $("#tfFilterTo").value = state.transfersDetail.filters.to; + $("#tfFilterDirection").value = ""; + $("#tfFilterState").value = ""; + loadTransfersEvents({ reset: true }); + }; + $("#tfFilterReset")?.addEventListener("click", clearFilters); + $("#tfEmptyClear")?.addEventListener("click", clearFilters); + $("#transfersLoadMore")?.addEventListener("click", () => loadTransfersEvents({ reset: false })); + $("#transfersEventBody")?.addEventListener("click", (event) => { + const btn = event.target.closest("[data-transfer-evidence]"); + if (!btn) return; + openTransferEvidence(btn.dataset.transferEvidence); + }); + $$("[data-close-transfer-evidence]").forEach((btn) => btn.addEventListener("click", () => closeTransferEvidence())); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && $("#transferEvidenceDrawer")?.classList.contains("is-open")) { + event.stopPropagation(); + closeTransferEvidence(); + } + }); + + // 工作台概览与角标:与页面共用 summary + loadTransfersSummary(); +} + function initCompany() { renderCompanyManualRecords(); loadCompanyAccounts(); @@ -3071,6 +3631,9 @@ function initCompany() { showToast("手工记录已撤回", "该记录已从审核队列中移除,需重新登记提交", "success"); }); + // ── 转账往来(方案 A)── + initTransfers(); + // ── 往来确认 + 工作台权威待办 ── initReconcile(); (async () => { diff --git a/web/company.html b/web/company.html index a52069f..6b21427 100644 --- a/web/company.html +++ b/web/company.html @@ -5,7 +5,7 @@ 公司业务端 · 金牛集团 - + @@ -22,6 +22,7 @@ 流水导入 手工记录 流水管理 + 转账往来 往来确认5 银行账户 @@ -141,29 +142,29 @@
-
+
- 本公司往来合计2026-01-01 至 2026-08-20 · 与管理端口径一致 · 单位:万元 + 转账往来概览加载中…
-
+
-
借方合计
-
18,420.50万元
+
转入合计
+
-
贷方合计
-
12,386.00万元
+
转出合计
+
-
明细笔数
-
128
+
期间净变动
+
-
期末净往来 · 应收方向
-
+6,034.50万元
+
待确认 · 不计入合计
+
- +
@@ -421,6 +422,214 @@
+
+
+
+
+

转账往来

+

加载本公司与集团内其他公司的转账往来…

+
+
+ + +
+
+ + + + + + + + +
+ + +
+
@@ -813,7 +1022,30 @@
+ +
- + diff --git a/web/design-system.css b/web/design-system.css index 9ffbd64..c91d177 100644 --- a/web/design-system.css +++ b/web/design-system.css @@ -988,3 +988,98 @@ a.flow-step.doing:hover { background: color-mix(in oklch, var(--warn) 16%, trans /* ─── 提示条补充:成功态 ───────────────────────────────────────── */ .notice.success { background: var(--success-soft); border-color: color-mix(in oklch, var(--success) 30%, transparent); } .notice.success .n-title { color: color-mix(in oklch, var(--success) 82%, black); } + +/* ─── 公司端 · 转账往来(方案 A,HEL-177)─────────────────────────── + 新模块范围内用 --info 品蓝;不改全站 accent。取值一律既有 token。 */ +body[data-portal="company"] .side-nav a[data-view="transfers"].active { + background: var(--info-soft); + color: var(--fg); +} +body[data-portal="company"] .side-nav a[data-view="transfers"].active svg { + color: var(--info); +} +[data-page="transfers"] .btn-primary { + background: var(--info); + border-color: var(--info); + color: var(--surface); +} +[data-page="transfers"] .btn-primary:hover { + background: color-mix(in oklch, var(--info) 88%, black); + border-color: color-mix(in oklch, var(--info) 88%, black); +} +[data-page="transfers"] .loading-inline::before { + border-top-color: var(--info); +} +[data-page="transfers"] .stat-card .stat-value.amt-in { color: var(--success); } +[data-page="transfers"] .stat-card .stat-value.amt-out { color: var(--danger); } +[data-page="transfers"] .ds-table tbody tr.is-pending { + background: color-mix(in oklch, var(--warn) 8%, transparent); +} +[data-page="transfers"] .ds-table tbody tr.is-pending:hover { + background: color-mix(in oklch, var(--warn) 14%, transparent); +} + +.xfer-split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--surface); +} +.xfer-split-pane { + padding: 16px 18px; +} +.xfer-split-pane.confirmed { + background: color-mix(in oklch, var(--success) 6%, var(--surface)); + border-right: 1px solid var(--border); +} +.xfer-split-pane.pending { + background: color-mix(in oklch, var(--warn) 8%, var(--surface)); +} +.xfer-split-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; +} +.xfer-split-value { + font-size: 22px; + font-weight: 650; + letter-spacing: -0.02em; +} +.xfer-split-pane.confirmed .xfer-split-value { color: var(--success); } +.xfer-split-pane.pending .xfer-split-value { color: color-mix(in oklch, var(--warn) 80%, black); } +.xfer-split-note { + margin-top: 6px; + font-size: 12px; + color: var(--muted); +} +.xfer-dir-tag { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; +} +.xfer-dir-tag.recv { + background: var(--success-soft); + color: var(--success); +} +.xfer-dir-tag.pay { + background: var(--danger-soft); + color: var(--danger); +} +.xfer-dir-tag.flat { + background: var(--fg-soft); + color: var(--muted); +} + +@media (max-width: 820px) { + .xfer-split { grid-template-columns: 1fr; } + .xfer-split-pane.confirmed { border-right: 0; border-bottom: 1px solid var(--border); } +}