Files
caiwuzongzhang/web/app.js
T
e5e326514d HEL-269: 完成月结锁账、撤演示数据与代码收尾
锁账后写保护与闭期补录留痕;重开须审批并按版本链再结;列表/导出改走真实 API。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-30 21:42:22 +08:00

4851 lines
208 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const $ = (selector, scope = document) => scope.querySelector(selector);
const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)];
const portal = document.body.dataset.portal || "entry";
const viewNames = portal === "admin"
? { dashboard: "管理总览", pair: "往来查询", audit: "审核中心", flows: "流水管理", companies: "公司与账号", settings: "结账与期初", "period-audit": "审计记录", reminders: "提醒管理" }
: { workspace: "工作台", upload: "流水导入", manual: "手工记录", flows: "流水管理", transfers: "转账往来", reconcile: "往来确认", accounts: "银行账户", notifications: "通知" };
const SUBJECT_CODE_LABEL = {
receivable: "应收",
other_receivable: "其他应收",
payable: "应付",
other_payable: "其他应付",
};
const SUBJECT_LABEL_CODE = {
应收: "receivable",
其他应收: "other_receivable",
应付: "payable",
其他应付: "other_payable",
};
const accountStatusLabels = {
pending: "待复核",
active: "已启用",
returned: "已退回",
disabled: "已停用",
};
const state = {
coverageGaps: [],
companyCoverageGaps: [],
companies: [],
currentView: portal === "admin" ? "dashboard" : "workspace",
selectedFile: null,
parseResult: null,
};
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
function animateView(view, { initial = false } = {}) {
if (!view || motionQuery.matches || typeof view.animate !== "function") return;
if (!initial) {
view.getAnimations().forEach((animation) => animation.cancel());
view.animate(
[{ opacity: 0.84, transform: "translateY(5px)" }, { opacity: 1, transform: "translateY(0)" }],
{ duration: 180, easing: "cubic-bezier(.22,1,.36,1)" },
);
return;
}
const selectors = [
".page-head",
".stat-card",
".card",
".notice",
".list-row",
".filters",
];
const elements = [...new Set(selectors.flatMap((selector) => [...view.querySelectorAll(selector)]))];
elements.forEach((element, index) => {
element.getAnimations().forEach((animation) => animation.cancel());
const keyframes = [
{ opacity: 0, transform: `translateY(${initial ? 16 : 10}px)` },
{ opacity: 1, transform: "translateY(0)" },
];
element.animate(keyframes, {
duration: 440,
delay: Math.min(index * 38, 260),
easing: "cubic-bezier(.22,1,.36,1)",
fill: "both",
});
});
}
function initMotion() {
if (motionQuery.matches) return;
animateView($(".app-view.is-active"), { initial: true });
$$(".side-nav a", $("#sidebar") || document).forEach((item, index) => {
item.animate(
[{ opacity: 0, transform: "translateX(-8px)" }, { opacity: 1, transform: "translateX(0)" }],
{ duration: 360, delay: 90 + index * 28, easing: "cubic-bezier(.22,1,.36,1)", fill: "both" },
);
});
}
function recordStatus(status) {
if (["已启用", "已确认"].includes(status)) return { className: "success", label: status };
if (status === "已退回") return { className: "danger", label: status };
if (["异常待处理", "已停用"].includes(status)) return { className: "neutral", label: status };
return { className: "warning", label: status || "待复核" };
}
function accountStatusLabel(status) {
return accountStatusLabels[status] || "待复核";
}
function pillClass(statusClass) {
return { success: "pill-success", danger: "pill-danger", warning: "pill-warn", neutral: "pill-muted", info: "pill-info" }[statusClass] || "pill-muted";
}
function accountTail(masked) {
return String(masked || "").replace(/^\*+/, "");
}
function formatCurrency(value) {
return Number(value).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function showTableLoading(tbody, cols = 6) {
if (!tbody) return;
tbody.innerHTML = `<tr class="loading-row"><td colspan="${cols}">加载中…</td></tr>`;
}
function showTableError(tbody, cols = 6) {
if (!tbody) return;
tbody.innerHTML = `<tr class="loading-row"><td colspan="${cols}" style="color: var(--danger);">加载失败,请稍后重试</td></tr>`;
}
function showToast(title, detail = "", kind = "info") {
const region = $("#toastRegion");
if (!region) return;
const toast = document.createElement("div");
toast.className = `toast ${["success", "warn", "danger", "info"].includes(kind) ? kind : "info"}`;
toast.setAttribute("role", "status");
const dot = document.createElement("span");
dot.className = "t-dot";
const body = document.createElement("div");
body.className = "t-body";
const heading = document.createElement("div");
heading.className = "t-title";
heading.textContent = title;
body.append(heading);
if (detail) {
const description = document.createElement("div");
description.className = "t-detail";
description.textContent = detail;
body.append(description);
}
toast.append(dot, body);
region.append(toast);
window.setTimeout(() => {
toast.style.opacity = "0";
toast.style.transition = "opacity 0.2s ease";
window.setTimeout(() => toast.remove(), 200);
}, 4200);
}
function toastIfLocked(result) {
const msg = String(result?.message || "");
if (!(result?.year_month || /已结账锁定/.test(msg))) return false;
const parts = msg.split(" / ");
showToast(parts[0] || "账期已锁定", parts.slice(1).join(" / "), "warn");
return true;
}
function closeNavigation({ restoreFocus = false } = {}) {
const sidebar = $("#sidebar");
if (!sidebar) return;
const wasOpen = sidebar.classList.contains("is-open");
sidebar.classList.remove("is-open");
$$(".menu-button").forEach((button) => {
button.setAttribute("aria-expanded", "false");
button.setAttribute("aria-label", "打开导航");
});
if (restoreFocus && wasOpen) $(".menu-button")?.focus();
}
function showView(view) {
if (!viewNames[view]) return;
const navigationWasOpen = $("#sidebar")?.classList.contains("is-open");
state.currentView = view;
$$(".app-view").forEach((page) => page.classList.toggle("is-active", page.dataset.page === view));
$$(".side-nav a[data-view]").forEach((item) => {
const active = item.dataset.view === view;
item.classList.toggle("active", active);
if (active) item.setAttribute("aria-current", "page");
else item.removeAttribute("aria-current");
});
const title = $("#currentViewName");
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;
if (portal === "admin" && view === "settings") loadPeriodClose();
if (portal === "admin" && view === "period-audit") loadPeriodAudit();
if (portal === "admin" && view === "audit") loadReopenQueue();
if (view === "flows") loadFlows();
if (portal === "company" && view === "manual") loadCompanyManualRecords();
}
// 原型占位,非本司真待办/真断档数据:detailContent 仅用于演示总览/工作台事项
// 点开抽屉的静态文案,真实待办与断档来自审核中心 / 往来确认等接口数据。
const detailContent = {
"admin-gap": { tag: ["danger", "高风险"], title: "金牛置业 · 中行账户断档", desc: "中国银行尾号 8821 缺少 07-06 至 07-16 流水,已影响 7 月结账。", fields: [["公司", "金牛置业"], ["账户", "中国银行 · 尾号 8821"], ["缺口期间", "2026-07-06 — 07-16 · 11 天"], ["影响", "7 月结账 · 与金牛煤业 320 万往来无法归集"], ["当前状态", "阻断结账"]], tip: "先向金牛置业出纳发送补传提醒,补齐流水后到审核中心复核覆盖区间。", action: ["去审核中心处理", "audit"] },
"admin-unsubmitted": { tag: ["danger", "高风险"], title: "金牛农业 7 月未提交流水", desc: "全部账户 7 月流水均未提交,已发 2 次系统提醒。", fields: [["公司", "金牛农业"], ["账户", "全部账户"], ["已提醒", "2 次 · 最近一次 08-18"], ["当前状态", "未响应"]], tip: "可再次发送提醒,或在提醒管理里查看已发出的提醒记录。", action: ["去提醒管理", "reminders"] },
"admin-unilateral": { tag: ["warning", "中风险"], title: "金牛新能源 ↔ 金牛贸易 单边流水", desc: "3 笔单边流水合计 486 万元,待对方选择银行流水佐证。", fields: [["本方", "金牛新能源"], ["对方", "金牛贸易"], ["笔数 / 金额", "3 笔 · 486 万元"], ["当前状态", "待对方证据"]], tip: "提醒新能源侧在往来确认中选择对方银行流水佐证后提交。", action: ["去审核中心匹配", "audit"] },
"admin-subject": { tag: ["warning", "中风险"], title: "手工记录待确认往来科目", desc: "5 笔手工记录应收 / 其他应收待判定,涉及煤业、物流、贸易。", fields: [["待确认", "应收 / 其他应收"], ["涉及公司", "煤业、物流、贸易"], ["笔数", "5 笔"]], tip: "科目只按确定性规则建议,拿不准时选「其他应收」并注明依据。", action: ["去审核中心复核", "audit"] },
"task-match": { tag: ["danger", "阻断"], title: "确认 3 笔单边流水", desc: "选择对方银行流水作为证据后提交确认。", fields: [["笔数", "3 笔"], ["处理方式", "选择对方银行流水佐证"], ["关联", "7 月结账阻断项"]], tip: "系统推荐账号一致的候选,请核对回单后再确认。", action: ["去往来确认", "reconcile"] },
"task-subject": { tag: ["danger", "阻断"], title: "确认 2 笔其他应收科目", desc: "核对后改判或维持原科目。", fields: [["笔数", "2 笔"], ["处理方式", "改判或维持原科目"], ["关联", "7 月结账阻断项"]], tip: "科目只按确定性规则建议,拿不准时选「其他应收」并注明依据。", action: ["去手工记录", "manual"] },
"task-upload": { tag: ["danger", "阻断"], title: "补传交行 7710 账户流水", desc: "账户已登记,待总行审核通过后即可导入。", fields: [["账户", "交通银行 · 尾号 7710"], ["状态", "待审核"], ["处理", "审核通过后上传 7 月流水"]], tip: "账户审核通过后,从交行网银导出 7 月流水直接上传。", action: ["去查看账户", "accounts"] },
"task-reconcile": { tag: ["muted", "一般"], title: "核对与金牛置业 320 万往来", desc: "置业 8821 账户 7 月流水断档,需人工核对。", fields: [["对方", "金牛置业"], ["金额", "320 万元"], ["原因", "置业 8821 账户流水断档"]], tip: "先核对双方流水日期与金额是否一致,再决定是否提交确认。", action: ["去流水管理", "flows"] },
};
function initDetailDrawer() {
const triggers = $$("[data-detail]");
if (!triggers.length) return;
const drawer = document.createElement("aside");
drawer.className = "drawer";
drawer.id = "detailDrawer";
drawer.setAttribute("aria-label", "事项详情");
drawer.innerHTML = `<div class="drawer-head"><div><span class="pill" id="detailTag"></span><h2 class="d-title" id="detailTitle"></h2><p class="d-desc" id="detailDesc"></p></div><button type="button" class="icon-button" data-close-detail aria-label="关闭详情" title="关闭详情"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M6 6l12 12M18 6L6 18"/></svg></button></div><div class="drawer-body"><dl class="kv" id="detailFields"></dl></div><div class="drawer-tip" id="detailTip"></div><div class="drawer-foot"><button type="button" class="btn" data-close-detail>关闭</button><button type="button" class="btn btn-primary" id="detailAction"></button></div>`;
document.body.append(drawer);
let lastTrigger = null;
function closeDrawer({ restoreFocus = true } = {}) {
drawer.classList.remove("is-open");
if (restoreFocus && lastTrigger) lastTrigger.focus();
}
function openDetail(id, trigger) {
const item = detailContent[id];
if (!item) return;
lastTrigger = trigger;
const tag = $("#detailTag", drawer);
tag.className = `pill ${pillClass(item.tag[0])}`;
tag.textContent = item.tag[1];
$("#detailTitle", drawer).textContent = item.title;
$("#detailDesc", drawer).textContent = item.desc;
const fields = $("#detailFields", drawer);
fields.replaceChildren(...item.fields.flatMap(([label, value]) => {
const dt = document.createElement("dt"); dt.textContent = label;
const dd = document.createElement("dd"); dd.textContent = value;
return [dt, dd];
}));
const tip = $("#detailTip", drawer);
tip.replaceChildren();
const tipHeading = document.createElement("strong"); tipHeading.textContent = "处理建议";
tip.append(tipHeading, document.createTextNode(item.tip));
const action = $("#detailAction", drawer);
action.textContent = item.action[0];
action.onclick = () => {
closeDrawer({ restoreFocus: false });
if (item.action[1] === "upload") $("[data-open-upload]")?.click();
else showView(item.action[1]);
};
drawer.classList.add("is-open");
$("[data-close-detail]", drawer).focus();
}
$$("[data-close-detail]", drawer).forEach((button) => button.addEventListener("click", () => closeDrawer()));
drawer.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.stopPropagation();
closeDrawer();
}
});
triggers.forEach((element) => {
element.addEventListener("click", (event) => {
const innerButton = event.target.closest("button");
if (innerButton && innerButton !== element) return;
openDetail(element.dataset.detail, element);
});
if (element.tagName !== "BUTTON") {
element.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openDetail(element.dataset.detail, element);
}
});
}
});
}
function initEntry() {
const form = $("#loginForm");
if (!form) return;
if (!motionQuery.matches) {
[
[$(".entry-form-wrap"), 0],
[$(".entry-statement h1"), 60],
[$(".entry-statement p"), 120],
[$(".entry-figure"), 120],
].forEach(([element, delay]) => {
if (!element) return;
element.animate(
[{ opacity: 0, transform: "translateY(12px)" }, { opacity: 1, transform: "translateY(0)" }],
{ duration: 300, delay, easing: "cubic-bezier(.16,1,.3,1)", fill: "both" },
);
});
}
const roleInputs = $$('input[name="role"]', form);
const username = $('input[name="username"]', form);
const password = $('input[name="password"]', form);
const action = $("#loginAction");
const errorBox = $("#loginError");
const changeSection = $("#changePassword");
let pendingRole = null;
const submitButton = $('button[type="submit"]', form);
function setLoading(loading) {
submitButton.disabled = loading;
submitButton.classList.toggle("is-loading", loading);
$$("input, button", form).forEach((el) => {
if (el !== submitButton) el.disabled = loading;
});
if (loading) {
action.textContent = "登录中…";
} else if (pendingRole) {
action.textContent = "设置新密码并进入";
} else {
updateRole();
}
}
function showError(message) {
errorBox.textContent = message;
errorBox.hidden = false;
}
function updateRole() {
const role = $('input[name="role"]:checked', form).value;
action.textContent = role === "admin" ? "进入管理端" : "进入公司业务端";
}
roleInputs.forEach((input) => input.addEventListener("change", updateRole));
$("#togglePassword").addEventListener("click", (event) => {
const visible = password.type === "text";
password.type = visible ? "password" : "text";
event.currentTarget.setAttribute("aria-label", visible ? "显示密码" : "隐藏密码");
event.currentTarget.title = visible ? "显示密码" : "隐藏密码";
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
errorBox.hidden = true;
setLoading(true);
const role = pendingRole || $('input[name="role"]:checked', form).value;
if (pendingRole) {
const newPassword = $('input[name="new_password"]', form).value;
const confirmPassword = $('input[name="confirm_password"]', form).value;
if (newPassword !== confirmPassword) {
showError("两次输入的新密码不一致。");
setLoading(false);
return;
}
const changeResponse = await fetch("/api/password/change", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ old_password: password.value, new_password: newPassword }),
}).catch(() => null);
const changeResult = await changeResponse?.json().catch(() => ({}));
if (!changeResponse || !changeResponse.ok) {
showError(changeResult?.message || "修改密码失败,请稍后重试。");
setLoading(false);
return;
}
window.location.href = role === "admin" ? "admin.html" : "company.html";
return;
}
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username.value.trim(), password: password.value, portal: role }),
}).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
showError(result?.message || "登录服务暂时不可用,请稍后重试。");
setLoading(false);
return;
}
if (result.must_change_password) {
pendingRole = role;
changeSection.hidden = false;
setLoading(false);
$('input[name="new_password"]', form).focus();
return;
}
window.location.href = role === "admin" ? "admin.html" : "company.html";
});
}
async function initAuthGuard() {
if (portal === "entry") return true;
try {
const response = await fetch("/api/me");
if (response.status === 401) {
window.location.href = "index.html";
return false;
}
const me = await response.json();
if (!response.ok || me.role !== portal) {
window.location.href = "index.html";
return false;
}
state.me = me;
applyCompanyIdentity(me);
return true;
} catch {
return true;
}
}
function applyCompanyIdentity(me) {
if (!me) return;
const row = $(".side-foot .user-row");
if (row) {
const name = me.username || (portal === "company" ? (me.company_name || "公司用户") : "系统管理员");
const avatar = $(".avatar", row);
if (avatar) avatar.textContent = name.slice(0, 1);
const nameEl = $(".user-name", row);
if (nameEl) nameEl.textContent = name;
const metaEl = $(".user-meta", row);
if (metaEl) metaEl.textContent = portal === "company" ? (me.company_name || "公司业务端") : "管理员";
}
// The company portal always shows the session-bound company in page copy.
if (portal === "company" && me.company_name) {
$$(".company-identity").forEach((el) => { el.textContent = me.company_name; });
}
}
function initShell() {
$$(".topbar").forEach((bar) => {
if (bar.querySelector(".menu-button")) return;
const button = document.createElement("button");
button.type = "button";
button.className = "icon-button menu-button";
button.setAttribute("aria-label", "打开导航");
button.setAttribute("aria-expanded", "false");
button.setAttribute("aria-controls", "sidebar");
button.title = "打开导航";
button.innerHTML = '<svg><use href="icons.svg#menu"></use></svg>';
bar.prepend(button);
});
$$(".menu-button").forEach((menuButton) => {
menuButton.addEventListener("click", () => {
const open = $("#sidebar").classList.toggle("is-open");
menuButton.setAttribute("aria-expanded", String(open));
menuButton.setAttribute("aria-label", open ? "关闭导航" : "打开导航");
});
});
$$(".side-nav a").forEach((item) => {
const label = $("span", item)?.textContent.trim();
if (label) {
item.setAttribute("aria-label", label);
item.title = label;
}
});
$$("[data-view]").forEach((button) => button.addEventListener("click", (event) => { event.preventDefault(); showView(button.dataset.view); }));
document.addEventListener("click", (event) => {
const button = event.target.closest("[data-view-link]");
if (!button) return;
event.preventDefault();
showView(button.dataset.viewLink);
});
$$(".logout").forEach((control) => control.addEventListener("click", async (event) => {
event.preventDefault();
try {
await fetch("/api/logout", { method: "POST" });
} catch { /* 网络异常时仍然回到登录页 */ }
window.location.href = "index.html";
}));
$$("[data-metric-link]").forEach((card) => {
const activate = () => showView(card.dataset.metricLink);
card.addEventListener("click", activate);
card.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
activate();
}
});
});
$$("[data-metric-action=\"upload\"]").forEach((card) => {
const activate = () => $("[data-open-upload]")?.click();
card.addEventListener("click", activate);
card.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
activate();
}
});
});
// Delegated: company table rows are rendered from the API after init.
document.addEventListener("click", (event) => {
const toastButton = event.target.closest("[data-toast]");
if (toastButton) showToast(toastButton.dataset.toast);
});
$(".side-nav a[data-view].active")?.setAttribute("aria-current", "page");
document.addEventListener("click", (event) => {
if ($("#sidebar")?.classList.contains("is-open") && !event.target.closest("#sidebar") && !event.target.closest(".menu-button")) closeNavigation({ restoreFocus: true });
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeNavigation({ restoreFocus: true });
});
initDetailDrawer();
initMotion();
}
function companyByName(name) {
return (state.companies || []).find((item) => item.name === name);
}
const pairSubjectOrder = ["应收", "其他应收", "应付", "其他应付"];
async function setPair(from, to, endDate = "2026-07-31") {
$$('[data-pair-from]').forEach((item) => { item.textContent = from; });
$$('[data-pair-to]').forEach((item) => { item.textContent = to; });
$$("[data-pair-form]").forEach((form) => {
const fromSelect = $('[name="from"]', form);
const toSelect = $('[name="to"]', form);
if (fromSelect && [...fromSelect.options].some((option) => option.value === from)) fromSelect.value = from;
if (toSelect && [...toSelect.options].some((option) => option.value === to)) toSelect.value = to;
const endInput = $('[name="end"]', form);
if (endInput) endInput.value = endDate;
});
if (!$("#pairReport")) return;
const fromCo = companyByName(from);
const toCo = companyByName(to);
const notice = $("#pairNotice");
if (!fromCo || !toCo) {
state.pairRows = [];
renderPairRows();
if (notice) notice.style.display = "";
return;
}
const start = state.calculationStart || "2026-01-01";
const pairResp = await fetch(`/api/admin/intercompany/pairs/${fromCo.id}/${toCo.id}?from=${encodeURIComponent(start)}&cutoff=${encodeURIComponent(endDate)}`).catch(() => null);
const eventsResp = await fetch(`/api/admin/intercompany/events?company_a=${fromCo.id}&company_b=${toCo.id}&from=${encodeURIComponent(start)}&cutoff=${encodeURIComponent(endDate)}&limit=200`).catch(() => null);
const pairResult = await pairResp?.json().catch(() => null);
const eventsResult = await eventsResp?.json().catch(() => null);
if (!pairResp?.ok || pairResult?.status !== "ok") {
showToast("往来查询失败", pairResult?.message || "请稍后重试", "danger");
return;
}
const item = (pairResult.items || [])[0] || {};
const a = item.a || { period: {}, result: {} };
const debit = Number(a.period?.debit || 0);
const credit = Number(a.period?.credit || 0);
const opening = Number(item.opening?.amount || 0);
const signed = Number(a.result?.signed_amount || (debit - credit));
$("#pairPeriod").textContent = `统计口径 ${start}${endDate}`;
$("#pairOpening").innerHTML = formatWanHtml(opening);
$("#pairDebit").innerHTML = formatWanHtml(debit);
$("#pairCredit").innerHTML = formatWanHtml(credit);
const dirLabel = signed > 0 ? "应收" : signed < 0 ? "应付" : "持平";
$("#pairFinal").innerHTML = `${dirLabel} ${formatWanHtml(Math.abs(signed))}`;
const unresolved = Number(item.unresolved?.count || 0);
$("#pairReviewStatus").textContent = unresolved ? `含 ${unresolved} 笔待审核` : "无待审核";
const subjects = item.subjects || {};
const totals = { 应收: 0, 其他应收: 0, 应付: 0, 其他应付: 0 };
Object.values(subjects).forEach((bucket) => {
const label = bucket.label || SUBJECT_CODE_LABEL[bucket.subject_code];
if (label && totals[label] != null) {
totals[label] = Number(bucket.a_debit || 0) + Number(bucket.a_credit || 0);
}
});
const events = eventsResult?.items || [];
$('[data-subject-total="all"]').textContent = `${events.length} 笔`;
Object.entries(totals).forEach(([subject, value]) => {
$(`[data-subject-total="${subject}"]`)?.replaceChildren(document.createTextNode(formatCurrency(yuanToWan(value) || 0)));
});
if (notice) notice.style.display = events.length ? "none" : "";
state.pairContext = { from, to, endDate, fromId: fromCo.id, toId: toCo.id };
state.pairRows = events.map((event) => {
const outgoing = Number(event.payer_company_id) === Number(fromCo.id);
return {
date: String(event.effective_at || "").slice(0, 10),
direction: outgoing ? "转出" : "转入",
subject: event.subject_label || SUBJECT_CODE_LABEL[event.subject_code] || "—",
ownAccount: event.own_account_label || event.own_account || "—",
counterparty: event.counterparty_company_name || (outgoing ? event.payee_company_name : event.payer_company_name) || to,
summary: event.summary || event.purpose || "—",
match: event.state === "confirmed" ? "双边匹配" : "待确认",
amount: Number(event.amount || 0),
serial: event.reference || event.source_id || "—",
batch: event.import_batch_id ? `IMP-${String(event.import_batch_id).padStart(6, "0")}` : "—",
peerAccount: event.counterparty_account || "—",
ledgerEventId: event.ledger_event_id,
};
});
renderPairRows();
}
function renderPairRows() {
const tbody = $("#pairTransactions");
if (!tbody) return;
tbody.replaceChildren();
(state.pairRows || []).forEach((row, index) => {
const tr = document.createElement("tr");
tr.dataset.subject = row.subject;
tr.dataset.pairIdx = String(index);
[["td", "num", row.date], ["td", "", row.direction], ["td", "", row.subject], ["td", "", row.ownAccount], ["td", "", row.counterparty], ["td", "wrap", row.summary]].forEach(([tag, cls, value]) => {
const td = document.createElement(tag);
if (cls) td.className = cls;
td.textContent = value;
tr.append(td);
});
const matchTd = document.createElement("td");
const pill = document.createElement("span");
pill.className = `pill ${row.match === "双边匹配" ? "pill-success" : "pill-warn"}`;
pill.textContent = row.match;
matchTd.append(pill);
const amt = document.createElement("td");
amt.className = `num-col ${row.direction === "转出" ? "amt-out" : "amt-in"}`;
amt.textContent = formatCurrency(row.amount);
const act = document.createElement("td");
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-sm";
btn.dataset.trace = String(index);
btn.textContent = "穿透";
act.append(btn);
tr.append(matchTd, amt, act);
tbody.append(tr);
});
}
function openTrace(index) {
const modal = $("#traceModal");
if (!modal) return;
const row = state.pairRows?.[index];
if (!row) return;
const ctx = state.pairContext || { from: "—", to: "—" };
$("#traceSub").textContent = `${ctx.from}${ctx.to} · ${row.subject} · ${row.date} · ${row.direction} ${formatCurrency(row.amount)} 元`;
$("#kvOwnTx").textContent = row.serial || "—";
$("#kvOwnAcct").textContent = row.ownAccount;
$("#kvPeerCo").textContent = ctx.to;
$("#kvPeerAcct").textContent = row.peerAccount || "—";
$("#kvTime").textContent = row.date;
$("#kvAmt").textContent = `${formatCurrency(row.amount)} 元`;
$("#kvBatch").textContent = row.batch || "—";
const evidence = $("#kvEvidence");
if (row.match === "双边匹配") { evidence.textContent = "已匹配"; evidence.style.color = ""; }
else { evidence.textContent = "待对方提供"; evidence.style.color = "var(--warn)"; }
modal.classList.add("open");
}
function initPairQueries() {
$$("[data-pair-form]").forEach((form) => {
$("[data-swap]", form)?.addEventListener("click", () => {
const from = $('[name="from"]', form);
const to = $('[name="to"]', form);
const previous = from.value;
from.value = to.value;
to.value = previous;
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const from = $('[name="from"]', form).value;
const to = $('[name="to"]', form).value;
const endDate = $('[name="end"]', form)?.value || "2026-07-31";
const error = $("#pairError");
if (from === to) {
if (error) { error.style.display = ""; }
showToast("请选择两个不同的公司", "同公司账户调拨不进入公司间往来查询", "warn");
return;
}
if (error) error.style.display = "none";
const notice = $("#pairNotice");
if (notice) notice.style.display = "none";
setPair(from, to, endDate);
showView("pair");
});
});
$$("[data-pair-link]").forEach((button) => button.addEventListener("click", () => {
const [from, to] = button.dataset.pairLink.split("|");
setPair(from, to);
showView("pair");
}));
$$("[data-subject-filter]").forEach((button) => button.addEventListener("click", () => {
const subject = button.dataset.subjectFilter;
$$("[data-subject-filter]").forEach((item) => {
const active = item === button;
item.classList.toggle("active", active);
item.setAttribute("aria-pressed", String(active));
});
$$("#pairTransactions tr").forEach((row) => { row.hidden = subject !== "all" && row.dataset.subject !== subject; });
}));
$("#pairTransactions")?.addEventListener("click", (event) => {
const button = event.target.closest("[data-trace]");
if (button) openTrace(Number(button.dataset.trace));
});
$("#traceClose")?.addEventListener("click", () => $("#traceModal")?.classList.remove("open"));
$("#traceOk")?.addEventListener("click", () => $("#traceModal")?.classList.remove("open"));
$("#traceModal")?.addEventListener("click", (event) => { if (event.target === event.currentTarget) event.currentTarget.classList.remove("open"); });
}
function fillAccountSelects(accounts) {
const usable = accounts.filter((account) => account.usable);
[$("#accountSelect"), $('#manualEntryForm [name="sourceAccount"]')].forEach((select) => {
if (!select) return;
const kept = [...select.options].filter((option) => option.value === "" || option.textContent === "个人过账");
select.replaceChildren(...kept);
usable.forEach((account) => {
const value = `${account.bank_name} · ${accountTail(account.account_number_masked)}`;
const option = new Option(value, value);
option.dataset.accountId = account.id;
select.add(option);
});
});
}
function companyAccountMeta(status) {
if (status === "active") return { status: { cls: "pill-success", label: "启用" }, audit: { cls: "pill-success", label: "已审核" } };
if (status === "returned") return { status: { cls: "pill-danger", label: "已退回" }, audit: { cls: "pill-danger", label: "已退回" } };
if (status === "disabled") return { status: { cls: "pill-muted", label: "停用" }, audit: { cls: "pill-success", label: "已审核" } };
return { status: { cls: "pill-info", label: "待启用" }, audit: { cls: "pill-info", label: "待审核" } };
}
function renderCompanyAccounts(accounts) {
state.accounts = accounts;
const tbody = $("#account-tbody");
if (tbody) {
tbody.replaceChildren(...accounts.map((account) => {
const row = document.createElement("tr");
row.dataset.accountId = account.id;
const identity = document.createElement("td");
const name = document.createElement("span"); name.className = "cell-main"; name.textContent = account.bank_name;
const tail = document.createElement("span"); tail.className = "cell-sub"; tail.textContent = `尾号 ${accountTail(account.account_number_masked)}`;
identity.append(name, tail);
const type = document.createElement("td"); type.innerHTML = `<span class="tag">${account.account_type || "—"}</span>`;
const meta = companyAccountMeta(account.status);
const statusCell = document.createElement("td"); statusCell.innerHTML = `<span class="pill ${meta.status.cls}">${meta.status.label}</span>`;
const coverage = document.createElement("td");
coverage.className = "num muted";
coverage.textContent = account.usable ? "尚未上传" : "—";
const auditCell = document.createElement("td"); auditCell.innerHTML = `<span class="pill ${meta.audit.cls}">${meta.audit.label}</span>`;
const action = document.createElement("td"); action.innerHTML = '<button type="button" class="btn btn-sm" data-account-view>查看明细</button>';
row.append(identity, type, statusCell, coverage, auditCell, action);
return row;
}));
}
const count = $("#account-count");
if (count) count.textContent = accounts.length;
const foot = $("#account-foot");
if (foot) foot.textContent = `共 ${accounts.length} 个账户`;
const empty = $("#account-empty");
if (empty) empty.hidden = accounts.length > 0;
fillAccountSelects(accounts);
}
async function loadCompanyAccounts() {
const tbody = $("#account-tbody");
showTableLoading(tbody, 6);
const response = await fetch("/api/company/accounts").catch(() => null);
if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html";
return;
}
if (!response?.ok) {
showTableError(tbody, 6);
return;
}
const result = await response.json().catch(() => null);
if (result?.accounts) renderCompanyAccounts(result.accounts);
else showTableError(tbody, 6);
}
const manualSubjectLabels = {
receivable: "应收",
payable: "应付",
other_receivable: "其他应收",
other_payable: "其他应付",
};
const manualDirectionLabels = {
incoming: "收入",
outgoing: "支出",
};
function appendAdminReviewRow(record, kind) {
const tbody = $("#auditRows");
if (!tbody) return;
const isAccount = kind === "account";
const isManual = kind === "manual";
const statusLabel = isAccount
? accountStatusLabel(record.status)
: (record.state === "pending" ? "待管理复核" : (record.state || "待复核"));
const row = document.createElement("tr");
row.dataset.storedReview = String(record.id);
row.dataset.recordId = String(record.id);
row.dataset.recordKind = kind;
if (isAccount) row.dataset.accountId = String(record.id);
if (isManual) {
row.dataset.decisionId = String(record.decision_id || "");
row.dataset.requestedSubject = record.requested_subject || "";
}
row.dataset.auditType = isAccount ? "账户" : "手工";
row.dataset.company = isAccount
? (record.company_name || "")
: (record.company_name || record.company || "");
row.dataset.evidence = isAccount
? "公司提交资料、开户行、账号、账户类型与启用日期"
: "公司手工记录、关联银行流水号、证明附件与提交说明";
if (isAccount) {
if (record.status !== "pending") row.dataset.resolved = "true";
row.dataset.accountStatus = record.status;
} else if (record.state && record.state !== "pending") {
row.dataset.resolved = "true";
}
const riskCell = document.createElement("td");
riskCell.innerHTML = '<span class="pill pill-warn">中</span>';
const identityCell = document.createElement("td");
const identity = document.createElement("span"); identity.className = "cell-main";
const detail = document.createElement("span"); detail.className = "cell-sub";
if (isAccount) {
identity.textContent = `${record.company_name} · ${record.bank_name} ${String(record.account_number).slice(-4)}`;
detail.textContent = `${record.account_type} · 完整账号 ${record.account_number}`;
} else {
const subject = manualSubjectLabels[record.requested_subject] || record.requested_subject || "—";
const direction = manualDirectionLabels[record.direction] || record.direction || "";
const counterparty = record.counterparty_company_name || record.counterparty || "—";
identity.textContent = `${row.dataset.company} · 手工单 #${record.id}`;
detail.textContent = `${direction} ${formatCurrency(record.amount)} 元 · ${counterparty} · ${subject}`;
}
identityCell.append(identity, detail);
const typeCell = document.createElement("td");
typeCell.textContent = isAccount ? "账户登记" : "手工记录";
const periodCell = document.createElement("td");
if (isAccount) {
periodCell.textContent = record.effective_from || "待审核确定";
} else {
periodCell.className = "num";
periodCell.textContent = String(record.occurred_at || record.transactionDate || "").slice(0, 10) || "—";
}
const impactCell = document.createElement("td");
impactCell.className = "wrap";
impactCell.textContent = isAccount
? "账户识别与流水上传"
: `待确认往来 ${(Number(record.amount) / 10000).toFixed(2)} 万元`;
const statusCell = document.createElement("td");
const status = recordStatus(statusLabel);
statusCell.innerHTML = `<span class="pill ${pillClass(status.className)}">${status.label}</span>`;
const actionCell = document.createElement("td");
if (row.dataset.resolved) {
const isActiveAccount = isAccount && record.status === "active";
if (isActiveAccount) {
actionCell.innerHTML = '<button type="button" class="btn btn-sm btn-danger" data-audit-action="disable">停用</button>';
} else {
const resolvedLabel = isAccount
? ({ returned: "已退回", disabled: "已停用" })[record.status] || "已通过"
: (statusLabel === "已确认" || record.state === "approved" ? "已通过" : "已驳回");
actionCell.innerHTML = `<span class="meta">${resolvedLabel} · 系统管理员</span>`;
}
} else {
actionCell.innerHTML = '<div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">通过</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">驳回</button></div>';
}
row.append(riskCell, identityCell, typeCell, periodCell, impactCell, statusCell, actionCell);
tbody.append(row);
}
function appendMatchExceptionRow(item) {
const tbody = $("#auditRows");
if (!tbody) return;
const payer = item.payer_company_name || "—";
const payee = item.payee_company_name || "—";
const companyLabel = payer !== "—" ? payer : payee;
const row = document.createElement("tr");
row.dataset.storedReview = `match-${item.event_id}`;
row.dataset.recordId = String(item.event_id);
row.dataset.recordKind = "match";
row.dataset.eventId = String(item.event_id);
row.dataset.revision = String(item.revision ?? "");
row.dataset.auditType = "单边";
row.dataset.company = companyLabel;
row.dataset.evidence = "匹配异常事件、观察流水、参与方与历史决定";
row.dataset.classification = item.classification || "";
const riskCell = document.createElement("td");
riskCell.innerHTML = '<span class="pill pill-danger">高</span>';
const identityCell = document.createElement("td");
const identity = document.createElement("span"); identity.className = "cell-main";
const detail = document.createElement("span"); detail.className = "cell-sub";
identity.textContent = `${payer}${payee} · 事件 #${item.event_id}`;
detail.textContent = `${item.classification || item.status || "unresolved"} · ${formatCurrency(item.amount)} ${item.currency || "CNY"} · 证据 ${item.evidence_count ?? 0} 条`;
identityCell.append(identity, detail);
const typeCell = document.createElement("td");
typeCell.textContent = "单边匹配";
const periodCell = document.createElement("td");
periodCell.className = "num";
periodCell.textContent = String(item.effective_at || "").slice(0, 10) || "—";
const impactCell = document.createElement("td");
impactCell.className = "wrap";
impactCell.textContent = `匹配异常 ${(Number(item.amount) / 10000).toFixed(2)} 万元 · 阻断往来归集`;
const statusCell = document.createElement("td");
statusCell.innerHTML = '<span class="pill pill-danger">待处理</span>';
const actionCell = document.createElement("td");
actionCell.innerHTML = '<div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">关闭异常</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">退回重匹配</button></div>';
row.append(riskCell, identityCell, typeCell, periodCell, impactCell, statusCell, actionCell);
tbody.append(row);
}
async function loadAdminAuditQueue() {
const tbody = $("#auditRows");
if (!tbody) return;
$$('[data-stored-review]', tbody).forEach((row) => row.remove());
showTableLoading(tbody, 7);
const [accountsRes, manualsRes, exceptionsRes] = await Promise.all([
fetch("/api/admin/accounts?status=pending").catch(() => null),
fetch("/api/admin/manual-records?state=pending").catch(() => null),
fetch("/api/admin/match-exceptions").catch(() => null),
]);
if ([accountsRes, manualsRes, exceptionsRes].some((res) => res?.status === 401)) {
window.location.href = "index.html";
return;
}
if (![accountsRes, manualsRes, exceptionsRes].every((res) => res?.ok)) {
showTableError(tbody, 7);
showToast("审核队列加载失败", "请稍后重试", "danger");
return;
}
const [accountsPayload, manualsPayload, exceptionsPayload] = await Promise.all([
accountsRes.json().catch(() => null),
manualsRes.json().catch(() => null),
exceptionsRes.json().catch(() => null),
]);
tbody.replaceChildren();
(accountsPayload?.accounts || []).forEach((account) => appendAdminReviewRow(account, "account"));
(manualsPayload?.records || []).forEach((record) => appendAdminReviewRow(record, "manual"));
(exceptionsPayload?.exceptions || []).forEach((item) => appendMatchExceptionRow(item));
updateAuditCounts();
updatePendingAccountNotice();
await refreshAuditCountsFromApi();
// 列表条数必须与后端口径一致:不一致时以真实列表为准覆盖标题/角标,避免再出现「数字 3 / 列表 1」。
const unresolved = $$("#auditRows tr").filter((row) => row.dataset.resolved !== "true");
if (!state.dashAudit || Number(state.dashAudit.total) !== unresolved.length) {
const high = unresolved.filter((row) => row.querySelector(".pill-danger")).length;
const medium = unresolved.filter((row) => row.querySelector(".pill-warn")).length;
const low = Math.max(0, unresolved.length - high - medium);
applyAuditCounts({ total: unresolved.length, high, medium, low });
}
}
function renderStoredAdminReviews() {
if (!$("#auditRows")) return;
// 完整队列:待复核账户 + 待审手工单 + 匹配异常,三处口径同源。
loadAdminAuditQueue();
}
function updateAuditCounts() {
const rows = $$("#auditRows tr").filter((row) => !row.classList.contains("loading-row"));
const unresolved = rows.filter((row) => row.dataset.resolved !== "true");
$$('[data-audit-filter]').forEach((button) => {
const type = button.dataset.auditFilter;
const count = unresolved.filter((row) => type === "all" || row.dataset.auditType === type).length;
const badge = $(".tab-count", button);
if (badge) badge.textContent = count;
});
const foot = $("#auditFoot");
if (foot) foot.textContent = `共 ${unresolved.length} 项 · 待审核 ${unresolved.length} 项`;
// 角标 / 首页待审核卡 / 审核中心标题数:优先后端口径;列表加载完成后由 loadAdminAuditQueue 再对齐。
if (!state.dashAuditFromApi) {
const high = unresolved.filter((row) => row.querySelector(".pill-danger")).length;
const medium = unresolved.filter((row) => row.querySelector(".pill-warn")).length;
const low = Math.max(0, unresolved.length - high - medium);
applyAuditCounts({ total: unresolved.length, high, medium, low });
} else {
const pending = $("#pending-count");
if (pending) pending.textContent = String(unresolved.length);
}
}
function applyAuditCounts(audit, { fromApi = false } = {}) {
if (!audit) return;
const total = Number(audit.total) || 0;
const high = Number(audit.high) || 0;
const medium = Number(audit.medium) || 0;
const low = Number(audit.low) || 0;
state.dashAudit = { total, high, medium, low };
if (fromApi) state.dashAuditFromApi = true;
const totalEl = $("#dashAuditTotal");
const footEl = $("#dashAuditFoot");
if (totalEl) totalEl.innerHTML = `${total}<span class="unit">项</span>`;
if (footEl) footEl.textContent = `高 ${high} 项 · 中 ${medium} 项 · 其余 ${low} 项低风险`;
const badge = $("#auditNavBadge") || $('.side-nav a[data-view="audit"] .nav-badge');
if (badge) {
badge.textContent = String(total);
badge.style.display = total > 0 ? "" : "none";
}
const pending = $("#pending-count");
if (pending) pending.textContent = String(total);
}
async function refreshAuditCountsFromApi() {
const from = await resolveDashStartDate();
const response = await fetch(`/api/admin/dashboard?from=${encodeURIComponent(from)}`).catch(() => null);
if (!response?.ok) return null;
const data = await response.json().catch(() => null);
if (!data || data.status !== "ok" || !data.audit) return null;
applyAuditCounts(data.audit, { fromApi: true });
return data.audit;
}
function companyStatusBadge(status) {
if (status === "preparing") return { className: "neutral", label: "筹备中" };
if (status === "disabled") return { className: "danger", label: "已停用" };
return { className: "success", label: "正常" };
}
function renderAdminCompanyTable(companies) {
const tbody = $("#companyTable tbody");
if (!tbody) return;
state.companies = companies;
tbody.replaceChildren(...companies.map((company) => {
const row = document.createElement("tr");
row.dataset.companyId = company.id;
const nameCell = document.createElement("td");
const name = document.createElement("span"); name.className = "cell-main"; name.textContent = company.name;
const code = document.createElement("span"); code.className = "cell-sub"; code.textContent = company.credit_code || "统一社会信用代码待补充";
nameCell.append(name, code);
const accounts = document.createElement("td"); accounts.className = "num-col"; accounts.textContent = `${company.account_count ?? 0}`;
const usernames = document.createElement("td"); usernames.className = "num"; usernames.textContent = company.usernames || "未创建";
const cashier = document.createElement("td"); cashier.textContent = company.cashier_name || "未指定";
const statusCell = document.createElement("td");
const badge = companyStatusBadge(company.status);
statusCell.innerHTML = `<span class="pill ${pillClass(badge.className)}">${badge.label}</span>`;
const actionCell = document.createElement("td");
actionCell.innerHTML = `<button type="button" class="btn btn-sm" data-company-view="${company.id}">查看</button>`;
row.append(nameCell, accounts, usernames, cashier, statusCell, actionCell);
return row;
}));
const foot = $("#companyFoot");
if (foot) foot.textContent = `共 ${companies.length} 家公司`;
}
function updatePendingAccountNotice() {
const rows = $$('#auditRows tr[data-record-kind="account"]');
const pending = rows.filter((r) => r.dataset.resolved !== "true");
const notice = $("#notice-pending-account");
if (!notice) return;
if (pending.length) {
notice.style.display = "";
$("#pending-account-count").textContent = pending.length;
const names = [...new Set(pending.map((r) => r.dataset.company))].join("、");
$("#pending-account-body").textContent = (names || "成员公司") + " 提交了新银行账户登记,等待审核通过后纳入账期流水归集范围。";
} else {
notice.style.display = "none";
}
}
function setSelectOptions(select, names, { keepFirst = false } = {}) {
if (!select || !names.length) return;
const kept = keepFirst && select.options.length ? [select.options[0].cloneNode(true)] : [];
select.replaceChildren(...kept, ...names.map((name) => new Option(name, name)));
}
function fillCompanySelects(names, companies) {
// Every company picker is driven by master data: a newly created company
// appears in pair queries, audit filters, flow filters and reminders
// without any code change.
$$("[data-pair-form]").forEach((form) => {
setSelectOptions($('[name="from"]', form), names);
setSelectOptions($('[name="to"]', form), names);
const toSelect = $('[name="to"]', form);
if (toSelect && names.length > 1) toSelect.value = names[1];
});
setSelectOptions($("#auditCompany"), names, { keepFirst: true });
setSelectOptions($("#flowCompany"), names, { keepFirst: true });
const reminderList = $("#reminderCompanyList");
if (reminderList && companies?.length) {
reminderList.replaceChildren(...companies.map((company) => {
const label = document.createElement("label");
label.className = "pick";
const input = document.createElement("input");
input.type = "checkbox";
input.name = "company";
input.value = String(company.id);
label.append(input, document.createTextNode(company.name));
return label;
}));
syncPendingPicks();
}
setSelectOptions($('#openingDialog [name="from"]'), names);
setSelectOptions($('#openingDialog [name="to"]'), names);
setSelectOptions($("#ql-self"), names);
setSelectOptions($("#ql-peer"), names);
if ($("#ql-peer") && names.length > 1) $("#ql-peer").value = names[0];
if ($("#ql-self") && names.length > 1) $("#ql-self").value = names[names.length - 1];
}
function openingStatusPill(status) {
if (status === "confirmed") return { cls: "pill-success", label: "已确认" };
if (status === "void") return { cls: "pill-danger", label: "已作废" };
if (status === "superseded") return { cls: "pill-muted", label: "已替代" };
return { cls: "pill-warn", label: "待确认" };
}
function formatMoneyYuan(value) {
const num = Number(value);
if (Number.isNaN(num)) return "—";
return num.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function humanizeChangeValue(value) {
if (value == null) return "—";
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (typeof value === "object") {
const entries = Object.entries(value);
if (!entries.length) return "—";
return entries.map(([k, v]) => `${k}=${v == null ? "空" : v}`).join("");
}
return String(value);
}
function openModal(id) { $("#" + id)?.classList.add("open"); }
function closeModal(id) { $("#" + id)?.classList.remove("open"); }
function askReason({ title, subtitle, confirmLabel } = {}) {
return new Promise((resolve) => {
const dialog = $("#reasonDialog");
const form = $("#reasonForm");
const input = $("#reasonInput");
if (!dialog || !form || !input) {
resolve(null);
return;
}
const titleEl = $("#reasonDialogTitle");
const subEl = $("#reasonDialogSub");
const submitBtn = $("#reasonSubmit");
if (titleEl && title) titleEl.textContent = title;
if (subEl && subtitle) subEl.textContent = subtitle;
if (submitBtn && confirmLabel) submitBtn.textContent = confirmLabel;
input.value = "";
const cleanup = () => {
form.removeEventListener("submit", onSubmit);
$$("[data-close-reason]").forEach((btn) => btn.removeEventListener("click", onCancel));
closeModal("reasonDialog");
};
const onCancel = () => { cleanup(); resolve(null); };
const onSubmit = (event) => {
event.preventDefault();
const reason = String(input.value || "").trim();
if (reason.length < 2) {
showToast("原因过短", "请至少填写 2 个字", "warn");
return;
}
cleanup();
resolve(reason);
};
form.addEventListener("submit", onSubmit);
$$("[data-close-reason]").forEach((btn) => btn.addEventListener("click", onCancel));
openModal("reasonDialog");
input.focus();
});
}
async function loadCalculationSettings() {
const response = await fetch("/api/admin/settings/calculation-start").catch(() => null);
if (!response?.ok) return null;
const data = await response.json().catch(() => null);
if (!data) return null;
const startInput = $("#cs-start");
const hint = $("#cs-start-hint");
const locked = $("#cs-start-locked");
if (startInput) {
startInput.value = data.calculation_start_date || "";
startInput.disabled = Boolean(data.locked);
startInput.dataset.locked = data.locked ? "1" : "0";
startInput.dataset.current = data.calculation_start_date || "";
}
if (data.calculation_start_date) state.calculationStart = data.calculation_start_date;
if (hint) {
if (!data.calculation_start_date) {
hint.textContent = "未设置起算日,系统暂按期间净变动口径显示";
hint.style.color = "var(--warn)";
} else {
hint.textContent = "期初余额以此日前一日的期末数为准";
hint.style.color = "";
}
}
if (locked) locked.style.display = data.locked ? "" : "none";
const subtitle = $("#openingSub");
if (subtitle && data.calculation_start_date) {
subtitle.textContent = `${data.calculation_start_date} 起算的公司间往来期初数`;
}
const summary = $("#openingSummary");
if (summary && data.summary) {
summary.style.display = "";
summary.className = "notice info";
summary.replaceChildren();
const wrap = document.createElement("div");
const title = document.createElement("div");
title.className = "n-title";
title.textContent = `共 ${data.summary.company_count} 家公司 · 已确认 ${data.summary.confirmed_pair_count} 对公司对期初`;
wrap.append(title);
summary.append(wrap);
}
return data;
}
function appendEmptyRow(tbody, cols, text) {
tbody.replaceChildren();
const tr = document.createElement("tr");
const td = document.createElement("td");
td.colSpan = cols;
td.className = "empty";
td.textContent = text;
tr.append(td);
tbody.append(tr);
}
async function loadOpeningBalances() {
const tbody = $("#openingRows");
if (!tbody) return;
const response = await fetch("/api/admin/opening-balances").catch(() => null);
if (!response?.ok) {
appendEmptyRow(tbody, 8, "加载失败");
return;
}
const data = await response.json().catch(() => null);
const items = data?.items || [];
if (!items.length) {
appendEmptyRow(tbody, 8, "暂无期初记录");
return;
}
tbody.replaceChildren();
items.forEach((item) => {
const pill = openingStatusPill(item.status);
const amount = Number(item.amount);
const direction = amount >= 0 ? "应收" : "应付";
const tr = document.createElement("tr");
if (item.status === "void") tr.style.textDecoration = "line-through";
tr.dataset.openingId = String(item.id);
const cells = [
["td", "cell-main", item.company_low_name || "—"],
["td", "", item.company_high_name || "—"],
["td", "", direction],
["td", "num-col", formatMoneyYuan(Math.abs(amount))],
["td", "", item.actor_username || "—"],
["td", "num", (item.created_at || "").slice(0, 10)],
];
cells.forEach(([tag, cls, text]) => {
const td = document.createElement(tag);
if (cls) td.className = cls;
if (cls === "" && text === direction) {
td.style.color = amount >= 0 ? "var(--success)" : "var(--danger)";
}
td.textContent = text;
tr.append(td);
});
const statusTd = document.createElement("td");
const span = document.createElement("span");
span.className = `pill ${pill.cls}`;
span.textContent = pill.label;
statusTd.append(span);
tr.append(statusTd);
const actionTd = document.createElement("td");
if (item.status === "draft") {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-sm";
btn.dataset.confirmOpening = String(item.id);
btn.textContent = "确认";
actionTd.append(btn);
} else if (item.status === "confirmed") {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-sm btn-ghost btn-danger";
btn.dataset.voidOpening = String(item.id);
btn.textContent = "作废";
actionTd.append(btn);
}
tr.append(actionTd);
tbody.append(tr);
});
}
async function loadCalculationChanges() {
const tbody = $("#calculationChangeRows");
if (!tbody) return;
const response = await fetch("/api/admin/calculation-changes").catch(() => null);
if (!response?.ok) return;
const data = await response.json().catch(() => null);
const items = data?.items || [];
if (!items.length) {
appendEmptyRow(tbody, 5, "暂无变更记录");
return;
}
tbody.replaceChildren();
items.forEach((item) => {
const tr = document.createElement("tr");
const values = [
(item.created_at || "").replace("T", " ").slice(0, 16),
item.actor_username || "—",
item.target || "—",
`${humanizeChangeValue(item.before)}${humanizeChangeValue(item.after)}`,
item.reason || "—",
];
values.forEach((text, index) => {
const td = document.createElement("td");
if (index === 0) td.className = "num";
if (index >= 3) td.className = "wrap";
td.textContent = text;
tr.append(td);
});
tbody.append(tr);
});
}
function appendAuditGapRows(gaps) {
const tbody = $("#auditRows");
if (!tbody) return;
$$('#auditRows tr[data-audit-type="断档"]').forEach((row) => row.remove());
(gaps || []).forEach((gap) => {
const tr = document.createElement("tr");
tr.dataset.auditType = "断档";
tr.dataset.gapId = String(gap.id || "");
tr.dataset.attestationId = gap.pending_attestation_id ? String(gap.pending_attestation_id) : "";
tr.dataset.accountId = String(gap.bank_account_id || "");
tr.dataset.gapStart = gap.gap_start || "";
tr.dataset.gapEnd = gap.gap_end || "";
const risk = document.createElement("td");
risk.innerHTML = '<span class="pill pill-danger">高</span>';
const company = document.createElement("td");
company.className = "cell-main";
company.textContent = `${gap.company_name || "—"} · ${gap.account_number_masked || gap.bank_account_id}`;
const type = document.createElement("td");
type.textContent = "流水断档";
const period = document.createElement("td");
period.className = "num";
period.textContent = `${gap.gap_start || "—"}${gap.gap_end || "—"}`;
const impact = document.createElement("td");
impact.className = "wrap";
impact.textContent = `${gap.gap_kind || "gap"} · ${gap.day_count || "?"} 天`;
const status = document.createElement("td");
const statusPill = document.createElement("span");
statusPill.className = "pill pill-warn";
statusPill.textContent = gap.pending_attestation_id ? "待审说明" : "待补传";
status.append(statusPill);
const action = document.createElement("td");
if (gap.pending_attestation_id) {
action.innerHTML = '<div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve-attestation">通过说明</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject-attestation">驳回说明</button></div>';
} else {
action.innerHTML = '<button type="button" class="btn btn-sm" data-view-link="reminders">去提醒</button>';
}
tr.append(risk, company, type, period, impact, status, action);
tbody.prepend(tr);
});
}
async function loadAdminCoverageGaps() {
const response = await fetch("/api/admin/coverage-gaps?status=open").catch(() => null);
if (!response?.ok) return;
const data = await response.json().catch(() => null);
state.coverageGaps = data?.items || [];
appendAuditGapRows(state.coverageGaps);
updateAuditCounts();
const badge = $('.side-nav a[data-view="audit"] .nav-badge');
if (badge) {
const n = state.coverageGaps.length || 0;
badge.textContent = String(n || "");
badge.hidden = n <= 0;
}
}
async function loadCompanyCoverageGaps() {
const response = await fetch("/api/company/coverage-gaps").catch(() => null);
if (!response?.ok) return [];
const data = await response.json().catch(() => null);
const items = (data?.items || []).filter((item) => item.status === "open");
state.companyCoverageGaps = items;
const renderNotice = (rootId, bodyId) => {
const root = $(rootId);
const body = $(bodyId);
if (!root || !body) return;
if (!items.length) {
root.style.display = "none";
return;
}
root.style.display = "";
body.textContent = items.slice(0, 3).map((g) => {
const acct = g.account_number_masked || g.bank_account_id;
return `${acct}${g.gap_start}${g.gap_end}${g.day_count || "?"}天)`;
}).join("") + (items.length > 3 ? ` 等 ${items.length} 处` : "");
};
renderNotice("#companyCoverageNotice", "#companyCoverageBody");
renderNotice("#flowsCoverageNotice", "#flowsCoverageBody");
return items;
}
function openAttestationDialog(gap) {
if (!gap) {
const gaps = state.companyCoverageGaps || [];
gap = gaps[0];
}
if (!gap) {
showToast("暂无断档", "当前没有可说明的断档区间", "warn");
return;
}
$("#att-account-id").value = gap.bank_account_id || "";
$("#att-gap-start").value = gap.gap_start || "";
$("#att-gap-end").value = gap.gap_end || "";
const label = $("#att-gap-label");
if (label) {
label.textContent = `${gap.account_number_masked || gap.bank_account_id} · ${gap.gap_start}${gap.gap_end}`;
}
$("#att-reason").value = "";
$("#att-evidence").value = "";
openModal("attestationDialog");
}
function formatWan(value) {
const num = Number(value);
if (!Number.isFinite(num)) return "0.00";
const abs = Math.abs(num).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
if (num > 0) return `+${abs}`;
if (num < 0) return `-${abs}`;
return abs;
}
function netClass(value) {
const num = Number(value);
if (num > 0) return "amt-in";
if (num < 0) return "amt-out";
return "";
}
function updateDashAuditCard(audit) {
applyAuditCounts(audit, { fromApi: true });
}
function renderDashCompanyRows(companies, selectedId) {
const list = $("#dashCompanyRows");
if (!list) return;
if (!companies.length) {
list.innerHTML = '<div class="dash-company-empty muted">暂无公司或尚无归集往来</div>';
return;
}
const monthLabel = state.dashPeriodMonth ? `${state.dashPeriodMonth}月` : "";
list.replaceChildren(...companies.map((company) => {
const item = document.createElement("div");
item.className = "dash-company-item";
item.setAttribute("role", "option");
item.dataset.companyId = company.id;
item.tabIndex = 0;
if (String(company.id) === String(selectedId)) {
item.classList.add("is-selected");
item.setAttribute("aria-selected", "true");
} else {
item.setAttribute("aria-selected", "false");
}
const main = document.createElement("div");
main.className = "dash-company-main";
const name = document.createElement("span");
name.className = "dash-company-name";
name.title = company.name;
name.textContent = company.name;
const meta = document.createElement("span");
meta.className = "dash-company-meta";
const statusLabel = company.period_status_label || "—";
meta.textContent = monthLabel
? `${company.detail_count ?? 0}笔 · ${monthLabel} ${statusLabel}`
: `${company.detail_count ?? 0}笔 · ${statusLabel}`;
main.append(name, meta);
const net = document.createElement("span");
net.className = `dash-company-net ${netClass(company.net_wan)}`;
net.textContent = formatWan(company.net_wan);
item.append(main, net);
return item;
}));
}
function chevronSvg(expanded) {
// Same inline chevron style used elsewhere in the deployed admin shell.
if (expanded) {
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width:12px;height:12px;display:inline-block;vertical-align:-1px;margin-right:6px;"><path d="m6 9 6 6 6-6"/></svg>';
}
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" style="width:12px;height:12px;display:inline-block;vertical-align:-1px;margin-right:6px;"><path d="m9 18 6-6-6-6"/></svg>';
}
function formatPlainWan(value) {
const num = Number(value);
if (!Number.isFinite(num)) return "0.00";
return Math.abs(num).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function updateDashDetailSummary(company, payload) {
const bar = $("#dashDetailSummary");
if (!bar) return;
if (!company) {
bar.hidden = true;
return;
}
bar.hidden = false;
const count = payload?.groups
? payload.groups.reduce((sum, g) => sum + Number(g.count || 0), 0)
: Number(company.detail_count || 0);
const debit = payload?.groups
? payload.groups.reduce((sum, g) => sum + Number(g.debit_wan || 0), 0)
: Number(company.debit_wan || 0);
const credit = payload?.groups
? payload.groups.reduce((sum, g) => sum + Number(g.credit_wan || 0), 0)
: Number(company.credit_wan || 0);
const ending = payload?.groups
? payload.groups.reduce((sum, g) => sum + Number(g.ending_wan || 0), 0)
: Number(company.net_wan || 0);
const setText = (id, text, className = "") => {
const el = $(id);
if (!el) return;
el.textContent = text;
el.className = `dash-summary-value num ${className}`.trim();
};
setText("#dashSumCount", String(count));
setText("#dashSumOpening", "—", "meta");
setText("#dashSumDebit", formatPlainWan(debit));
setText("#dashSumCredit", formatPlainWan(credit));
setText("#dashSumEnding", formatWan(ending), netClass(ending));
}
function showDashDetailEmpty(company) {
const wrap = $("#dashDetailWrap");
const empty = $("#dashDetailEmpty");
const desc = $("#dashDetailEmptyDesc");
if (wrap) wrap.hidden = true;
if (empty) empty.hidden = false;
if (desc) {
const cutoff = state.dashCutoff || "—";
// 方案 A 空态:两行克制说明;期初口径仍为不可用(—),不伪造 0.00
desc.textContent = `期初 — · 截至 ${cutoff} 无明细记录 · 导入流水并归集后在此展示`;
}
updateDashDetailSummary(null);
}
function showDashDetailTable() {
const wrap = $("#dashDetailWrap");
const empty = $("#dashDetailEmpty");
if (wrap) wrap.hidden = false;
if (empty) empty.hidden = true;
}
function renderDashDetail(payload, { expandFirst = false, company = null } = {}) {
const tbody = $("#dashDetailRows");
const title = $("#dashDetailTitle");
if (!tbody) return;
const selected = company || (state.dashCompanies || []).find(
(c) => String(c.id) === String(payload?.company_id || state.dashSelectedId)
) || null;
if (!payload) {
if (title) title.textContent = "请选择左侧公司";
showDashDetailTable();
tbody.innerHTML = '<tr><td colspan="6" class="muted">请选择左侧公司</td></tr>';
updateDashDetailSummary(null);
return;
}
if (title) title.textContent = payload.company_name || selected?.name || "公司往来明细";
const sub = $("#dashDetailSub");
if (sub) sub.textContent = "按对方公司分组 · 二级默认收起 · 已确认 / 待确认分列";
const groups = payload.groups || [];
if (!groups.length) {
showDashDetailEmpty(selected || { detail_count: 0, debit_wan: 0, credit_wan: 0, net_wan: 0 });
tbody.replaceChildren();
return;
}
showDashDetailTable();
updateDashDetailSummary(selected, payload);
const rows = [];
groups.forEach((group, index) => {
const expanded = expandFirst && index === 0;
const groupTr = document.createElement("tr");
groupTr.className = "clickable";
groupTr.dataset.peerGroup = String(group.peer_id);
groupTr.dataset.peerName = group.peer_name;
groupTr.dataset.expanded = expanded ? "true" : "false";
groupTr.innerHTML = `
<td style="font-weight:550;">${chevronSvg(expanded)}<span data-peer-label>${group.peer_name}</span></td>
<td class="num-col">${group.count}</td>
<td class="num-col meta">—</td>
<td class="num-col">${Number(group.debit_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td class="num-col">${Number(group.credit_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
<td class="num-col ${netClass(group.ending_wan)}">${formatWan(group.ending_wan)}</td>`;
rows.push(groupTr);
(group.lines || []).forEach((line) => {
const lineTr = document.createElement("tr");
lineTr.dataset.peerChild = String(group.peer_id);
lineTr.style.display = expanded ? "" : "none";
const dirTag = line.direction === "debit"
? '<span class="tag">借</span>'
: '<span class="tag">贷</span>';
lineTr.innerHTML = `
<td style="padding-left: 28px;">${dirTag} <span class="meta">${line.date}</span> ${line.summary}</td>
<td class="num-col">1</td>
<td class="num-col meta">—</td>
<td class="num-col">${line.direction === "debit" ? Number(line.amount_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"}</td>
<td class="num-col">${line.direction === "credit" ? Number(line.amount_wan).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : "—"}</td>
<td class="num-col meta">—</td>`;
rows.push(lineTr);
});
});
tbody.replaceChildren(...rows);
}
async function resolveDashStartDate() {
if (state.dashFrom) return state.dashFrom;
const response = await fetch("/api/admin/settings").catch(() => null);
if (response?.ok) {
const result = await response.json().catch(() => null);
const startDate = result?.settings?.start_date;
if (startDate) {
state.dashFrom = startDate;
return startDate;
}
}
return "2026-01-01";
}
async function loadDashCompanyDetail(companyId) {
if (!companyId) return;
state.dashSelectedId = companyId;
const company = (state.dashCompanies || []).find((c) => String(c.id) === String(companyId)) || null;
renderDashCompanyRows(state.dashCompanies || [], companyId);
const title = $("#dashDetailTitle");
if (title && company) title.textContent = company.name;
updateDashDetailSummary(company, null);
showDashDetailTable();
const tbody = $("#dashDetailRows");
if (tbody) tbody.innerHTML = '<tr class="loading-row"><td colspan="6">加载中…</td></tr>';
const from = await resolveDashStartDate();
const response = await fetch(`/api/admin/dashboard/companies/${companyId}?from=${encodeURIComponent(from)}`).catch(() => null);
if (!response?.ok) {
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="muted">明细加载失败</td></tr>';
return;
}
const result = await response.json().catch(() => null);
renderDashDetail(result, { company });
}
async function loadAdminDashboard() {
if (!$("#dashCompanyRows")) return;
const from = await resolveDashStartDate();
const response = await fetch(`/api/admin/dashboard?from=${encodeURIComponent(from)}`).catch(() => null);
if (!response?.ok) {
$("#dashCompanyRows").innerHTML = '<div class="dash-company-empty muted">总览加载失败</div>';
return;
}
const data = await response.json().catch(() => null);
if (!data || data.status !== "ok") {
$("#dashCompanyRows").innerHTML = '<div class="dash-company-empty muted">总览加载失败</div>';
return;
}
state.dashCompanies = data.companies || [];
state.dashFrom = data.from_date;
state.dashCutoff = data.cutoff;
state.dashPeriodMonth = data.period_month;
updateDashAuditCard(data.audit);
const listSub = $("#dashListSub");
if (listSub) {
listSub.textContent = `${data.from_date}${data.cutoff} · 单位:万元 · 左侧选择公司,右侧查看其往来明细`;
}
const meta = $("#dashMasterMeta");
if (meta) {
const totals = data.totals || {};
const companyCount = totals.company_count ?? state.dashCompanies.length;
const detailCount = totals.detail_count ?? 0;
meta.textContent = `${companyCount} 家公司 · 明细 ${detailCount} 笔`;
}
const selected = state.dashSelectedId || state.dashCompanies[0]?.id;
renderDashCompanyRows(state.dashCompanies, selected);
if (selected) await loadDashCompanyDetail(selected);
}
function initDashboard() {
if (!$("#companyMasterDetail")) return;
const selectCompany = (companyId) => {
if (!companyId) return;
loadDashCompanyDetail(companyId);
};
$("#dashCompanyRows")?.addEventListener("click", (event) => {
const item = event.target.closest(".dash-company-item[data-company-id]");
if (!item) return;
selectCompany(item.dataset.companyId);
});
$("#dashCompanyRows")?.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
const item = event.target.closest(".dash-company-item[data-company-id]");
if (!item) return;
event.preventDefault();
selectCompany(item.dataset.companyId);
});
$("#dashDetailRows")?.addEventListener("click", (event) => {
const tr = event.target.closest("tr[data-peer-group]");
if (!tr) return;
const peerId = tr.dataset.peerGroup;
const open = tr.dataset.expanded !== "true";
tr.dataset.expanded = open ? "true" : "false";
const nameCell = tr.children[0];
if (nameCell) {
nameCell.innerHTML = `${chevronSvg(open)}<span data-peer-label>${tr.dataset.peerName || ""}</span>`;
}
$$('#dashDetailRows tr[data-peer-child]').forEach((child) => {
if (child.dataset.peerChild === peerId) child.style.display = open ? "" : "none";
});
});
$("#company-search")?.addEventListener("input", function () {
const q = this.value.trim();
$$("#dashCompanyRows .dash-company-item[data-company-id]").forEach((item) => {
item.style.display = (!q || item.textContent.includes(q)) ? "" : "none";
});
});
loadAdminDashboard();
}
function formatReminderTime(value) {
if (!value) return "—";
const text = String(value).replace("T", " ").slice(0, 16);
return text;
}
function reminderTypePill(ruleKey) {
if (ruleKey === "pending_review") return "pill-warn";
if (ruleKey === "manual") return "pill-warn";
return "pill-danger";
}
function reminderStatusPill(statusUi) {
return { unread: "pill-danger", doing: "pill-warn", done: "pill-success" }[statusUi] || "pill-muted";
}
function reminderStatusLabel(statusUi) {
return { unread: "未读", doing: "处理中", done: "已完成" }[statusUi] || statusUi;
}
function updatePickCount() {
const countEl = $("#pick-count");
if (!countEl) return;
const n = $$('#reminderCompanyList input[name="company"]:checked').length;
countEl.textContent = "已选 " + n + " 家";
}
function syncPendingPicks() {
$$(".pending-check:checked").forEach((input) => {
const companyId = input.closest(".list-row")?.dataset.companyId;
if (!companyId) return;
const chip = document.querySelector('#reminderCompanyList input[name="company"][value="' + companyId + '"]');
if (chip) chip.checked = true;
});
updatePickCount();
}
async function loadAdminRemindersPending() {
const list = $("#pending-list");
const empty = $("#pending-empty");
const summary = $("#pending-summary");
const sendAll = $("#pending-send-all");
if (!list) return;
const response = await fetch("/api/admin/reminders/pending").catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) return;
const findings = result.findings || [];
list.querySelectorAll(".list-row").forEach((row) => row.remove());
if (!findings.length) {
if (empty) empty.style.display = "";
if (summary) summary.textContent = "0 家公司 · 0 项";
if (sendAll) { sendAll.disabled = true; sendAll.textContent = "全部一键发送"; }
return;
}
if (empty) empty.style.display = "none";
const companies = new Set(findings.map((item) => item.company_id)).size;
if (summary) summary.textContent = `${companies} 家公司 · ${findings.length} 项`;
if (sendAll) {
sendAll.disabled = false;
sendAll.textContent = "全部一键发送";
sendAll.dataset.keys = JSON.stringify(findings.map((item) => item.dedupe_key));
}
findings.forEach((item) => {
const row = document.createElement("div");
row.className = "list-row";
row.dataset.dedupeKey = item.dedupe_key;
row.dataset.companyId = String(item.company_id);
const sentHint = item.send_count > 0 ? ` · 已提醒 ${item.send_count} 次` : "";
row.innerHTML =
'<label class="row" style="gap:6px;flex:none;"><input type="checkbox" class="pending-check" style="width:auto;" /></label>' +
`<span class="pill ${reminderTypePill(item.rule_key)}">${item.rule_label}</span>` +
`<div class="lr-main"><div class="lr-title">${item.title}</div>` +
`<div class="lr-sub"><span class="meta">${item.reason}${sentHint}</span></div></div>` +
`<span class="pill ${item.duration_pill}" style="flex:none;font-family:var(--font-mono);">${item.days_open} 天</span>` +
`<div class="lr-side"><button type="button" class="btn btn-sm btn-primary pending-send-one">发送提醒</button></div>`;
list.append(row);
});
}
async function loadAdminRemindersHistory(sourceFilter) {
const tbody = $("#reminder-tbody");
if (!tbody) return;
const query = sourceFilter && sourceFilter !== "all" ? `?source=${sourceFilter === "system" ? "auto" : "manual"}` : "";
const response = await fetch(`/api/admin/reminders${query}`).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) return;
const items = result.reminders || [];
tbody.replaceChildren(...items.map((item) => {
const tr = document.createElement("tr");
tr.dataset.source = item.source === "auto" ? "system" : "manual";
tr.dataset.reminderId = String(item.id);
const summary = item.content.length > 46 ? item.content.slice(0, 46) + "…" : item.content;
const sourceCell = item.source === "auto"
? '<span class="pill pill-info">系统</span>'
: '<span class="tag">人工</span>';
tr.innerHTML =
`<td class="cell-main">${item.company_name}</td>` +
`<td>${sourceCell}</td>` +
`<td><span class="pill ${reminderTypePill(item.rule_key)}">${item.display_type}</span></td>` +
`<td class="wrap">${summary.replace(/</g, "&lt;")}</td>` +
`<td class="num-col">${formatReminderTime(item.last_sent_at)}</td>` +
`<td class="num-col">${item.deadline || "—"}</td>` +
`<td><span class="pill ${reminderStatusPill(item.status_ui)}">${reminderStatusLabel(item.status_ui)}</span></td>` +
'<td><div class="row" style="gap:6px;"><button type="button" class="btn btn-sm act-remind">再提醒</button><button type="button" class="btn btn-sm btn-ghost act-detail">详情</button></div></td>';
return tr;
}));
refreshReminderCounts(result.stats || {});
}
function refreshReminderCounts(stats) {
const rows = $$("#reminder-tbody tr");
let all = rows.length, sys = 0, man = 0;
rows.forEach((r) => { if (r.dataset.source === "system") sys++; else man++; });
$("#count-all").textContent = all;
$("#count-system").textContent = sys;
$("#count-manual").textContent = man;
$("#table-foot-count").textContent = `共 ${all} 条提醒记录`;
if (stats) {
$("#table-foot-state").textContent = `未读 ${stats.unread || 0} · 处理中 ${stats.doing || 0} · 已完成 ${stats.done || 0}`;
}
}
async function sendPendingReminders(keys) {
const response = await fetch("/api/admin/reminders/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dedupe_keys: keys }),
}).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return null; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("发送失败", result?.message || "请稍后重试", "danger");
return null;
}
return result;
}
function openReminderDetailDrawer(reminderId) {
fetch(`/api/admin/reminders/${reminderId}`)
.then((response) => response.json())
.then((result) => {
const item = result.reminder;
if (!item) return;
$("#rd-title").textContent = item.title;
$("#rd-sub").textContent = item.content;
const sourcePill = $("#rd-source-pill");
if (sourcePill) {
sourcePill.textContent = item.source === "auto" ? "系统" : "人工";
sourcePill.className = item.source === "auto" ? "pill pill-info" : "tag";
}
$("#rd-fields").innerHTML =
`<dt>公司</dt><dd>${item.company_name}</dd>` +
`<dt>类型</dt><dd>${item.display_type}</dd>` +
`<dt>触发原因</dt><dd>${item.rule_params?.pending_count ? `待处理 ${item.rule_params.pending_count} 项` : (item.rule_params?.period || "—")}</dd>` +
`<dt>发送时间</dt><dd>${formatReminderTime(item.last_sent_at)}</dd>` +
`<dt>催办次数</dt><dd>${item.send_count}</dd>` +
`<dt>处理状态</dt><dd>${reminderStatusLabel(item.status_ui)}</dd>`;
$("#rd-events").innerHTML = (item.events || []).map((ev) =>
`<div class="list-row"><div class="lr-main"><div class="lr-title">${ev.event_type}</div><div class="lr-sub meta">${formatReminderTime(ev.created_at)} · ${ev.actor}${ev.detail ? " · " + ev.detail : ""}</div></div></div>`,
).join("") || '<div class="empty"><div class="e-title">暂无事件</div></div>';
$("#reminder-detail-drawer")?.classList.add("is-open");
})
.catch(() => showToast("加载详情失败", "", "danger"));
}
function initAdminReminders() {
loadAdminRemindersPending();
loadAdminRemindersHistory("all");
$("#reminder-scan-btn")?.addEventListener("click", async () => {
const response = await fetch("/api/admin/reminders/scan", { method: "POST" }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("扫描失败", result?.message || "请稍后重试", "danger");
return;
}
await loadAdminRemindersPending();
showToast("扫描完成", `发现 ${result.summary?.items || 0} 项待提醒`, "success");
});
$("#pending-send-all")?.addEventListener("click", async () => {
const checked = $$(".pending-check:checked");
let keys;
if (checked.length) {
keys = checked.map((input) => input.closest(".list-row")?.dataset.dedupeKey).filter(Boolean);
} else {
keys = JSON.parse($("#pending-send-all").dataset.keys || "[]");
}
if (!keys.length) return;
const result = await sendPendingReminders(keys);
if (!result) return;
showToast(checked.length ? "已发送选中提醒" : "已全部发送", `共 ${result.count} 项`, "success");
await loadAdminRemindersPending();
await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all");
});
$("#pending-list")?.addEventListener("click", async (event) => {
const btn = event.target.closest(".pending-send-one");
if (!btn) return;
const row = btn.closest(".list-row");
const key = row?.dataset.dedupeKey;
if (!key) return;
const result = await sendPendingReminders([key]);
if (!result) return;
if (!motionQuery.matches) {
row.style.transition = "opacity 0.2s ease";
row.style.opacity = "0";
setTimeout(() => row.remove(), 200);
} else {
row.remove();
}
showToast("已发送提醒", "", "success");
await loadAdminRemindersPending();
await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all");
});
$("#pending-list")?.addEventListener("change", (event) => {
if (!event.target.classList.contains("pending-check")) return;
const checked = $$(".pending-check:checked").length;
const sendAll = $("#pending-send-all");
if (sendAll) sendAll.textContent = checked ? `发送选中(${checked}` : "全部一键发送";
if (event.target.checked) {
const companyId = event.target.closest(".list-row")?.dataset.companyId;
const chip = companyId && document.querySelector('#reminderCompanyList input[name="company"][value="' + companyId + '"]');
if (chip) chip.checked = true;
}
updatePickCount();
});
$("#reminderCompanyList")?.addEventListener("change", (event) => {
if (event.target.name === "company") updatePickCount();
});
$("#reminderForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const checked = Array.prototype.slice.call(form.querySelectorAll('input[name="company"]:checked'));
const type = $("#reminder-type").value;
const content = $("#reminder-content").value.trim();
const deadline = $("#reminder-deadline").value;
$("#company-error").style.display = checked.length ? "none" : "";
$("#content-error").style.display = content ? "none" : "";
if (!checked.length || !content) return;
for (const input of checked) {
const response = await fetch("/api/admin/reminders/manual", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
company_id: Number(input.value),
display_type: type,
content,
deadline: deadline || null,
}),
}).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("发送失败", result?.message || "请稍后重试", "danger");
return;
}
}
const companies = checked.map((c) => {
const company = (state.companies || []).find((item) => String(item.id) === c.value);
return company?.name || c.value;
}).join("、");
form.querySelectorAll('input[name="company"]').forEach((c) => { c.checked = false; });
updatePickCount();
const sendHint = $("#send-hint");
sendHint.style.display = "";
sendHint.textContent = `已发送给 ${companies},共 ${checked.length} 家公司。`;
clearTimeout(sendHint._t);
sendHint._t = setTimeout(() => { sendHint.style.display = "none"; }, 4000);
showToast("人工提醒已发送", companies, "success");
await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all");
});
$("#reminder-tabs")?.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-filter]");
if (!btn) return;
$("#reminder-tabs").querySelectorAll("button").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
loadAdminRemindersHistory(btn.dataset.filter);
});
$("#reminder-tbody")?.addEventListener("click", async (event) => {
const detailBtn = event.target.closest(".act-detail");
if (detailBtn) {
const tr = detailBtn.closest("tr");
openReminderDetailDrawer(tr.dataset.reminderId);
return;
}
const remindBtn = event.target.closest(".act-remind");
if (!remindBtn) return;
const tr = remindBtn.closest("tr");
const response = await fetch(`/api/admin/reminders/${tr.dataset.reminderId}/resend`, { method: "POST" }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("再提醒失败", result?.message || "请稍后重试", "danger");
return;
}
remindBtn.textContent = "已再提醒";
remindBtn.disabled = true;
await loadAdminRemindersHistory($("#reminder-tabs .active")?.dataset.filter || "all");
showToast("已再次发送提醒", "", "success");
});
$$("[data-close-rd]").forEach((btn) => btn.addEventListener("click", () => {
$("#reminder-detail-drawer")?.classList.remove("is-open");
}));
}
async function loadAdminCompanies() {
const tbody = $("#companyTable tbody");
showTableLoading(tbody, 6);
const response = await fetch("/api/admin/companies").catch(() => null);
if (!response?.ok) {
showTableError(tbody, 6);
return;
}
const result = await response.json().catch(() => null);
const companies = result?.companies || [];
state.companies = companies;
renderAdminCompanyTable(companies);
fillCompanySelects(companies.map((company) => company.name), companies);
if (companies.length >= 2) setPair(companies[0].name, companies[1].name);
}
function nextYearMonth(ym) {
const [y, m] = String(ym).split("-").map(Number);
const month = m === 12 ? 1 : m + 1;
const year = m === 12 ? y + 1 : y;
return `${year}-${String(month).padStart(2, "0")}`;
}
function collapseTimeline(items) {
if (window.innerWidth > 460 || !items?.length) return items;
const locked = items.filter((item) => item.cell === "locked");
const rest = items.filter((item) => item.cell !== "locked");
if (locked.length < 2) return items;
return [
{
year_month: `${locked[0].year_month}${locked[locked.length - 1].year_month.slice(5)}`,
cell: "locked",
label: "均已锁定",
merged: true,
},
...rest,
];
}
function renderTimeline(items) {
const root = $("#periodTimeline");
if (!root) return;
root.replaceChildren();
collapseTimeline(items || []).forEach((item) => {
const cell = document.createElement("div");
const klass = item.cell === "locked" ? "locked" : item.cell === "current" ? "current" : item.cell === "reopened" ? "reopened" : item.cell === "failed" ? "failed" : "open";
cell.className = `tl-cell ${klass}`;
const month = document.createElement("div");
month.className = "tl-month";
month.textContent = item.year_month;
const stateEl = document.createElement("div");
stateEl.className = "tl-state";
stateEl.textContent = item.label;
cell.append(month, stateEl);
root.append(cell);
});
}
function setClosingChecks(checks) {
(checks || []).forEach((check) => {
const row = $(`#closingPanel [data-closing-key="${check.key}"]`);
if (!row) return;
const ok = !!check.ok;
const mark = $("[data-closing-check]", row);
const sub = $("[data-closing-sub]", row);
const stateEl = $("[data-closing-state]", row);
if (mark) {
mark.className = `pill ${ok ? "pill-success" : "pill-danger"}`;
mark.textContent = check.title;
}
if (sub) sub.textContent = check.detail || "—";
if (stateEl) {
stateEl.className = `pill ${ok ? "pill-success" : check.blocking ? "pill-danger" : "pill-warn"}`;
stateEl.textContent = ok ? "已通过" : check.blocking ? "阻断" : "待处理";
}
});
}
function applyClosePanel(overview) {
state.periodClose = overview;
const close = overview.close || {};
const ym = close.year_month || overview.target_month || "—";
renderTimeline(overview.timeline);
const start = state.calculationStart || "—";
if ($("#timelineSub")) $("#timelineSub").textContent = `全局起算日 ${start} 起,每月 ${overview.closing_day || "—"} 日结账`;
setClosingChecks(close.checks);
$("#ckMonth").textContent = ym;
const totals = close.snapshot?.totals || {};
const net = totals.net_wan ?? totals.debit_wan;
if ($("#ckNet")) $("#ckNet").textContent = net != null ? `${formatCurrency(net)} 万元` : "—";
$("#ckCarry").textContent = ym !== "—" ? `结转至 ${nextYearMonth(ym)} 期初` : "—";
$("#ckReport").textContent = close.report_no || "尚未生成";
["block-notice", "failed-notice", "closed-notice", "reopened-notice"].forEach((id) => {
const el = $(`#${id}`);
if (el) el.style.display = "none";
});
const status = $("#closingStatus");
const btn = $("#executeClosing");
const panel = $("#closingPanel");
const busy = $("#closingBusy");
const dl = $("#downloadMonthReport");
panel?.classList.remove("is-processing");
if (busy) busy.hidden = true;
if (dl) dl.hidden = !close.report_no;
const history = overview.history;
if ($("#closingHistory")) {
$("#closingHistory").textContent = history
? `最近:${history.year_month} · ${history.closed_by_username || "—"} · ${String(history.closed_at || "").slice(0, 16).replace("T", " ")}`
: "尚无结账记录";
}
const desc = $("#closingDescription");
if (close.status === "closing") {
panel?.classList.add("is-processing");
if (busy) busy.hidden = false;
status.className = "pill pill-info";
status.textContent = "处理中";
desc.textContent = `${ym} · 正在锁定账期并生成月报`;
btn.disabled = true;
return;
}
if (close.status === "failed") {
status.className = "pill pill-danger";
status.textContent = "结账失败";
desc.textContent = `${ym} · 未改动任何数据`;
const failed = $("#failed-notice");
if (failed) {
failed.style.display = "";
$("#failed-notice-body").textContent = `${close.fail_reason || "结账失败"} · 未改动任何数据,可重新执行。`;
}
btn.disabled = !close.ready;
btn.className = "btn btn-primary";
btn.textContent = "重新执行结账";
return;
}
if (close.status === "closed" || close.locked) {
status.className = "pill pill-lock";
status.textContent = "已锁定";
desc.textContent = `${ym} · 已结账锁定`;
const closed = $("#closed-notice");
if (closed) {
closed.style.display = "";
$("#closed-notice-title").textContent = `${ym} 已结账`;
$("#closed-notice-body").textContent = `月报 ${close.report_no || "—"} · 结账人 ${close.closed_by_username || "—"} · ${String(close.closed_at || "").slice(0, 16).replace("T", " ")}`;
}
btn.disabled = false;
btn.className = "btn";
btn.textContent = `申请重开 ${ym} 账期`;
btn.dataset.mode = "reopen";
return;
}
if (close.status === "reopened") {
status.className = "pill pill-warn";
status.textContent = "已重开";
const days = close.reopen_remaining_days;
desc.textContent = `${ym} · 重开窗口内可更正`;
const reopened = $("#reopened-notice");
if (reopened) {
reopened.style.display = "";
$("#reopened-notice-body").textContent = `审批通过后窗口截止 ${close.reopen_window_end || "—"} · 剩 ${days == null ? "—" : days} 天,到期自动恢复锁定。`;
}
btn.disabled = !close.ready;
btn.className = "btn btn-warn";
btn.textContent = "提前结束重开并重新结账";
btn.dataset.mode = "close";
return;
}
const blocked = (close.blockers || []).length > 0 || !close.ready;
status.className = `pill ${blocked ? "pill-danger" : "pill-success"}`;
status.textContent = blocked ? "已阻断" : "可结账";
desc.textContent = blocked ? `${ym} · 当前未达到结账条件` : `${ym} · 全部前置检查已通过`;
if (blocked) {
const block = $("#block-notice");
if (block) {
block.style.display = "";
$("#block-notice-title").textContent = `存在阻断项,暂不能执行 ${ym} 月度结账`;
$("#block-notice-body").textContent = (close.blockers || []).map((item) => item.detail).join("") || "请先处理待审核事项与账户断档。";
}
}
btn.disabled = blocked;
btn.className = "btn btn-primary";
btn.textContent = `执行 ${ym} 月度结账`;
btn.dataset.mode = "close";
if (blocked) btn.title = "请先处理全部阻断事项";
else btn.removeAttribute("title");
}
async function loadPeriodClose() {
if (!$("#closingPanel")) return;
const response = await fetch("/api/admin/period-closes").catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => null);
if (!response?.ok || result?.status !== "ok") {
showToast("结账状态加载失败", result?.message || "请稍后重试", "danger");
return;
}
applyClosePanel(result);
}
function initPeriodClose() {
$("#runClosingCheck")?.addEventListener("click", () => loadPeriodClose());
$("#downloadMonthReport")?.addEventListener("click", () => {
const ym = state.periodClose?.close?.year_month;
if (ym) window.location.href = `/api/admin/period-closes/${ym}/report.json`;
});
$("#executeClosing")?.addEventListener("click", () => {
const mode = $("#executeClosing")?.dataset.mode || "close";
const ym = state.periodClose?.close?.year_month || "";
if (mode === "reopen") {
$("#reopenRequestTitle").textContent = `申请重开 ${ym} 账期`;
openModal("reopenRequestDialog");
return;
}
$("#closingDialogTitle").textContent = `确认执行 ${ym} 月度结账`;
$("#closingDialogSub").textContent = `结账后 ${ym} 流水与往来确认将锁定,不可再直接修改。`;
$("#cdMonth").textContent = ym;
$("#cdChecks").textContent = (state.periodClose?.close?.checks || []).every((c) => c.ok) ? "全部前置检查已通过" : "仍有未通过项";
$("#cdCarry").textContent = `往来净额将结转至 ${nextYearMonth(ym)} 期初`;
const box = $("#closingConfirmBox");
if (box) box.checked = false;
openModal("closingDialog");
});
$$("[data-close-closing]").forEach((button) => button.addEventListener("click", () => closeModal("closingDialog")));
$("#closingForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const ym = state.periodClose?.close?.year_month;
if (!ym || !$("#closingConfirmBox")?.checked) {
showToast("请先勾选确认", "须复核结账结果并知晓锁定后果", "warn");
return;
}
closeModal("closingDialog");
$("#closingPanel")?.classList.add("is-processing");
if ($("#closingBusy")) $("#closingBusy").hidden = false;
const response = await fetch(`/api/admin/period-closes/${ym}/execute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: true }),
}).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("结账失败", result?.message || "未改动任何数据", "danger");
await loadPeriodClose();
return;
}
showToast(`${ym} 已完成结账`, `月报 ${result.report_no || ""} 已生成并锁定`, "success");
await loadPeriodClose();
});
$$("[data-close-reopen-req]").forEach((button) => button.addEventListener("click", () => closeModal("reopenRequestDialog")));
$("#reopenRequestForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const ym = state.periodClose?.close?.year_month;
const reason = $("#reopenReason")?.value.trim() || "";
if (reason.length < 10) {
showToast("原因过短", "重开原因不少于 10 个字,将写入审计记录", "warn");
return;
}
const response = await fetch(`/api/admin/period-closes/${ym}/reopen`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
reason,
companies_note: $("#reopenCompanies")?.value || "",
window_days: Number($("#reopenDays")?.value || 3),
}),
}).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("申请失败", result?.message || "请稍后重试", "danger");
return;
}
closeModal("reopenRequestDialog");
showToast("重开申请已提交", result.item?.number || "", "success");
await loadPeriodClose();
await loadReopenQueue();
});
$$("[data-close-reopen-dec]").forEach((button) => button.addEventListener("click", () => closeModal("reopenDecideDialog")));
$("#reopenApproveBtn")?.addEventListener("click", () => decideReopen(true));
$("#reopenRejectBtn")?.addEventListener("click", () => decideReopen(false));
$("#paApply")?.addEventListener("click", () => loadPeriodAudit());
$("#paReload")?.addEventListener("click", () => loadPeriodAudit());
$("#paReset")?.addEventListener("click", () => {
["paSince", "paUntil", "paCompany"].forEach((id) => { if ($(`#${id}`)) $(`#${id}`).value = ""; });
if ($("#paAction")) $("#paAction").value = "";
loadPeriodAudit();
});
}
function renderDiffGrid(diff) {
const root = $("#reopenDiff");
if (!root) return;
root.replaceChildren();
(diff || []).forEach((row) => {
const wrap = document.createElement("div");
wrap.className = `diff-row${row.changed ? " changed" : ""}`;
const label = document.createElement("div");
label.className = "diff-label";
label.textContent = row.path || "状态";
const before = document.createElement("div");
before.className = "diff-before";
before.textContent = row.before == null ? "—" : typeof row.before === "object" ? JSON.stringify(row.before) : String(row.before);
const arrow = document.createElement("div");
arrow.className = "diff-arrow";
arrow.textContent = "→";
const after = document.createElement("div");
after.className = "diff-after";
after.textContent = row.after == null ? "—" : typeof row.after === "object" ? JSON.stringify(row.after) : String(row.after);
wrap.append(label, before, arrow, after);
root.append(wrap);
});
}
async function openReopenDecide(id) {
const response = await fetch(`/api/admin/period-reopens/${id}`).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("加载失败", result?.message || "请稍后重试", "danger");
return;
}
const item = result.item || {};
state.reopenDecideId = item.id;
$("#reopenDecideTitle").textContent = `重开审批 · ${item.year_month}`;
$("#reopenDecideSub").textContent = `${item.number || ""} · ${item.requester_username || ""} · ${String(item.requested_at || "").slice(0, 16).replace("T", " ")}`;
const kv = $("#reopenDecideKv");
if (kv) {
kv.replaceChildren();
[["账期", item.year_month], ["原因", item.reason], ["涉及", item.companies_note || "—"], ["窗口", `${item.window_days} 天`], ["原月报", item.report_no || "—"]].forEach(([dt, dd]) => {
const t = document.createElement("dt"); t.textContent = dt;
const d = document.createElement("dd"); d.textContent = dd;
kv.append(t, d);
});
}
renderDiffGrid(item.diff);
$("#reopenComment").value = "";
$("#reopenApproveBtn").textContent = `同意重开 ${item.year_month}`;
openModal("reopenDecideDialog");
}
async function decideReopen(approve) {
const id = state.reopenDecideId;
const comment = $("#reopenComment")?.value.trim() || "";
if (!approve && comment.length < 2) {
showToast("请填写驳回意见", "驳回必须填写审批意见", "warn");
return;
}
const response = await fetch(`/api/admin/period-reopens/${id}/decide`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ approve, comment }),
}).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("审批失败", result?.message || "请稍后重试", "danger");
return;
}
closeModal("reopenDecideDialog");
showToast(approve ? "已同意重开" : "已驳回申请", result.item?.number || "", approve ? "warn" : "danger");
await loadPeriodClose();
await loadReopenQueue();
}
function reopenStatusPill(status) {
if (status === "approved") return { cls: "pill-warn", label: "已通过" };
if (status === "rejected") return { cls: "pill-danger", label: "已驳回" };
return { cls: "pill-info", label: "待审批" };
}
async function loadReopenQueue() {
const list = $("#reopenQueueList");
if (!list) return;
const response = await fetch("/api/admin/period-reopens").catch(() => null);
const result = await response?.json().catch(() => ({}));
const items = result.items || [];
const pending = items.filter((item) => item.status === "pending").length;
if ($("#reopenTabCount")) $("#reopenTabCount").textContent = String(pending);
list.replaceChildren();
items.forEach((item) => {
const row = document.createElement("div");
row.className = "list-row";
const pill = document.createElement("span");
const meta = reopenStatusPill(item.status);
pill.className = `pill ${meta.cls}`;
pill.textContent = meta.label;
const main = document.createElement("div");
main.className = "lr-main";
const title = document.createElement("div");
title.className = "lr-title";
title.textContent = `申请重开 ${item.year_month}`;
const sub = document.createElement("div");
sub.className = "lr-sub";
sub.textContent = item.reason || "";
const metaLine = document.createElement("div");
metaLine.className = "meta";
metaLine.textContent = `${item.number} · ${item.requester_username || ""} · ${String(item.requested_at || "").slice(0, 16).replace("T", " ")}`;
main.append(title, sub, metaLine);
const side = document.createElement("div");
side.className = "lr-side";
if (item.status === "pending") {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-sm btn-primary";
btn.textContent = "审批";
btn.addEventListener("click", () => openReopenDecide(item.id));
side.append(btn);
} else {
const num = document.createElement("span");
num.className = "meta";
num.textContent = item.number;
side.append(num);
}
row.append(pill, main, side);
list.append(row);
});
const empty = $("#reopenQueueEmpty");
if (empty) empty.hidden = items.length > 0;
}
function auditActionPill(action) {
if (action === "close_execute") return { cls: "pill-success", label: "执行月结" };
if (action === "reopen_request") return { cls: "pill-info", label: "重开申请" };
if (action === "reopen_approve") return { cls: "pill-warn", label: "重开审批通过" };
if (action === "close_fail") return { cls: "pill-danger", label: "月结失败" };
if (action === "reopen_reject") return { cls: "pill-danger", label: "重开驳回" };
return { cls: "pill-muted", label: action || "—" };
}
async function loadPeriodAudit() {
const tbody = $("#periodAuditRows");
if (!tbody) return;
showTableLoading(tbody, 6);
$("#paError").style.display = "none";
const params = new URLSearchParams();
if ($("#paSince")?.value) params.set("since", $("#paSince").value);
if ($("#paUntil")?.value) params.set("until", $("#paUntil").value);
if ($("#paAction")?.value) params.set("action", $("#paAction").value);
if ($("#paCompany")?.value) params.set("company", $("#paCompany").value);
const response = await fetch(`/api/admin/period-audit?${params.toString()}`).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showTableError(tbody, 6);
$("#paError").style.display = "";
return;
}
const items = result.items || [];
tbody.replaceChildren();
const cards = $("#periodAuditCards");
if (cards) cards.replaceChildren();
items.forEach((item) => {
const tr = document.createElement("tr");
const time = document.createElement("td"); time.className = "num"; time.textContent = String(item.created_at || "").slice(0, 19).replace("T", " ");
const actor = document.createElement("td");
const name = document.createElement("div"); name.textContent = item.actor_username || "—";
const role = document.createElement("div"); role.className = "cell-sub"; role.textContent = item.actor_role || "";
actor.append(name, role);
const action = document.createElement("td");
const pill = document.createElement("span");
const meta = auditActionPill(item.action);
pill.className = `pill ${meta.cls}`;
pill.textContent = meta.label;
action.append(pill);
const obj = document.createElement("td"); obj.className = "wrap"; obj.textContent = `${item.year_month || ""} ${item.reason || item.object_label || ""}`.trim();
const change = document.createElement("td"); change.className = "num";
const before = item.before?.snapshot_hash || item.before?.status || "";
const after = item.after?.snapshot_hash || item.after?.status || "";
change.textContent = before || after ? `${String(before).slice(0, 8)}${String(after).slice(0, 8)}` : "—";
const proof = document.createElement("td"); proof.textContent = item.report_no || "—";
tr.append(time, actor, action, obj, change, proof);
tbody.append(tr);
if (cards) {
const card = document.createElement("div");
card.className = "card";
card.style.padding = "12px 14px";
[["时间", time.textContent], ["操作人", item.actor_username], ["动作", meta.label], ["对象", obj.textContent], ["变化", change.textContent], ["凭证", item.report_no || "—"]].forEach(([k, v]) => {
const line = document.createElement("div");
line.className = "row-between";
const l = document.createElement("span"); l.className = "meta"; l.textContent = k;
const r = document.createElement("span"); r.textContent = v || "—";
line.append(l, r);
card.append(line);
});
cards.append(card);
}
});
$("#periodAuditFoot").textContent = `共 ${items.length} 条`;
const empty = $("#periodAuditEmpty");
if (empty) empty.hidden = items.length > 0;
}
function initAdmin() {
renderStoredAdminReviews();
updateAuditCounts();
loadAdminCompanies();
loadCalculationSettings();
loadOpeningBalances();
loadCalculationChanges();
loadAdminCoverageGaps();
initDashboard();
loadPeriodClose();
loadReopenQueue();
let activeAuditType = "all";
function filterAuditRows() {
const company = $("#auditCompany")?.value || "全部公司";
$$(".audit-table tbody tr").forEach((row) => {
const typeMatches = activeAuditType === "all" || row.dataset.auditType === activeAuditType;
const companyMatches = company === "全部公司" || row.dataset.company === company;
row.hidden = !(typeMatches && companyMatches);
});
}
$$("[data-audit-filter]").forEach((button) => button.addEventListener("click", () => {
activeAuditType = button.dataset.auditFilter;
$$("[data-audit-filter]").forEach((item) => {
const active = item === button;
item.classList.toggle("active", active);
item.setAttribute("aria-pressed", String(active));
});
const reopen = activeAuditType === "reopen";
if ($("#auditTableWrap")) $("#auditTableWrap").hidden = reopen;
if ($("#reopenQueue")) $("#reopenQueue").hidden = !reopen;
if (reopen) loadReopenQueue();
else filterAuditRows();
}));
$("#auditCompany")?.addEventListener("change", filterAuditRows);
document.querySelectorAll("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close)));
document.querySelectorAll(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); }));
document.addEventListener("keydown", (e) => { if (e.key === "Escape") document.querySelectorAll(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); });
async function submitAuditResult(row) {
const decision = state.auditDecision;
const reason = state.auditReason || "";
const approved = decision.includes("通过") || decision.includes("确认并纳入") || decision.includes("启用") || decision.includes("关闭异常");
const returned = decision.includes("退回");
const kind = row.dataset.recordKind;
let storedStatus;
let reviewedAccount = null;
if (kind === "account" && row.dataset.accountId) {
const apiDecision = approved ? "approve" : returned ? "return" : "disable";
const response = await fetch(`/api/admin/accounts/${row.dataset.accountId}/review`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision: apiDecision, reason }),
}).catch(() => null);
if (response?.status === 401) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
if (!toastIfLocked(result)) showToast("审核结果提交失败", result?.message || "请稍后重试", "danger");
return;
}
reviewedAccount = result.account;
storedStatus = accountStatusLabel(result.account?.status);
} else if (kind === "manual" && row.dataset.recordId) {
const action = approved ? "approve_new" : "return";
const response = await fetch(`/api/admin/manual-records/${row.dataset.recordId}/decisions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action,
reason: reason || decision,
expected_decision_id: row.dataset.decisionId ? Number(row.dataset.decisionId) : null,
request_key: `audit-manual-${row.dataset.recordId}-${Date.now()}`,
subject_code: row.dataset.requestedSubject || undefined,
}),
}).catch(() => null);
if (response?.status === 401) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
if (!toastIfLocked(result)) showToast("手工单审核失败", result?.message || "请稍后重试", "danger");
return;
}
storedStatus = approved ? "已确认" : "已退回";
} else if (kind === "match" && row.dataset.eventId) {
// 既有 decision:撤销当前匹配决定,使异常退出待办队列(与 reverse 语义一致)。
const response = await fetch(`/api/admin/transfer-events/${row.dataset.eventId}/decisions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "reverse",
reason: reason || decision || "审核中心关闭匹配异常",
expected_revision: row.dataset.revision ? Number(row.dataset.revision) : null,
request_key: `audit-match-${row.dataset.eventId}-${Date.now()}`,
}),
}).catch(() => null);
if (response?.status === 401) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
showToast("匹配异常处置失败", result?.message || "请稍后重试", "danger");
return;
}
storedStatus = approved ? "已确认" : "已退回";
} else {
showToast("审核结果提交失败", "未知待办类型", "danger");
return;
}
// 处置后从列表移除并重拉三队列,保证页脚/过滤/标题与角标同步减一。
row.remove();
updateAuditCounts();
await refreshAuditCountsFromApi();
const unresolved = $$("#auditRows tr").filter((r) => r.dataset.resolved !== "true");
const high = unresolved.filter((r) => r.querySelector(".pill-danger")).length;
const medium = unresolved.filter((r) => r.querySelector(".pill-warn")).length;
const low = Math.max(0, unresolved.length - high - medium);
applyAuditCounts({ total: unresolved.length, high, medium, low }, { fromApi: true });
updatePendingAccountNotice();
filterAuditRows();
showToast(
"审核结果已记录",
approved ? "已通过的数据取得相应使用或核算资格" : "当前记录不参与自动核算",
"success",
);
}
$("#auditRows")?.addEventListener("click", async (event) => {
const button = event.target.closest("[data-audit-action]");
if (!button) return;
if (button.dataset.auditAction === "approve-attestation" || button.dataset.auditAction === "reject-attestation") {
const row = button.closest("tr");
const attestationId = row?.dataset.attestationId;
if (!attestationId) return;
const approve = button.dataset.auditAction === "approve-attestation";
const reason = await askReason({
title: approve ? "通过无业务说明" : "驳回无业务说明",
subtitle: "审核结论将写入留痕;通过后仅关闭断档,不生成银行行。",
confirmLabel: approve ? "通过" : "驳回",
});
if (!reason) return;
const response = await fetch(`/api/admin/no-business-attestations/${attestationId}/review`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision: approve ? "approve" : "reject", review_reason: reason }),
}).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("审核失败", result?.message || "请稍后重试", "danger");
return;
}
await loadAdminCoverageGaps();
showToast(approve ? "已通过说明" : "已驳回说明", "断档状态已更新", "success");
return;
}
const row = button.closest("tr");
state.auditRow = row;
const cells = $$("td", row);
const company = cells[1]?.querySelector(".cell-main")?.textContent.trim() || "";
const period = cells[3]?.textContent.trim() || "";
const type = cells[2]?.textContent.trim() || "";
const basis = row.dataset.evidence || "银行原始行、导入批次、账户覆盖区间与公司说明";
const action = button.dataset.auditAction;
if (action === "approve") {
$("#approve-company").textContent = company;
$("#approve-period").textContent = period;
$("#approve-type").textContent = type;
$("#approve-basis").textContent = basis;
openModal("modal-approve");
} else if (action === "reject") {
$("#reject-reason").value = "";
$("#reject-hint").style.display = "none";
openModal("modal-reject");
} else if (action === "disable") {
state.auditDecision = "停用并驳回";
state.auditReason = "停用并驳回该账户";
submitAuditResult(row);
}
});
$("#approve-confirm")?.addEventListener("click", () => {
if (!state.auditRow) return;
closeModal("modal-approve");
const kind = state.auditRow.dataset.recordKind;
state.auditDecision = kind === "account"
? "复核通过并启用账户"
: kind === "match"
? "关闭异常"
: "确认并纳入计算";
state.auditReason = state.auditDecision;
submitAuditResult(state.auditRow);
});
$("#reject-confirm")?.addEventListener("click", () => {
if (!state.auditRow) return;
const reason = $("#reject-reason").value.trim();
if (reason.length < 5) { $("#reject-hint").style.display = ""; return; }
closeModal("modal-reject");
const kind = state.auditRow.dataset.recordKind;
state.auditDecision = kind === "account"
? "退回公司修改"
: kind === "match"
? "退回重匹配"
: "退回公司补充材料";
state.auditReason = reason;
submitAuditResult(state.auditRow);
});
$("#openCompanyDialog")?.addEventListener("click", () => openModal("companyDialog"));
$("#companyForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const companyName = String(data.get("companyName") || "").trim();
const loginName = String(data.get("loginName") || "").trim();
const createUser = data.get("createUser") !== null;
const payload = {
name: companyName,
credit_code: String(data.get("creditCode") || "").trim(),
cashier_name: String(data.get("cashier") || "").trim(),
};
if (createUser && loginName) payload.username = loginName;
const response = await fetch("/api/admin/companies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => null);
if (response?.status === 401) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
showToast("公司创建失败", result?.message || "请稍后重试", "danger");
return;
}
const accountCreated = Boolean(result.username);
closeModal("companyDialog");
form.reset();
await loadAdminCompanies();
showToast(
accountCreated ? "公司与账号已创建" : "公司已创建",
accountCreated ? `账号 ${result.username} 的初始密码已生成(仅此一次显示):${result.initial_password},首次登录必须修改` : "可稍后在账号管理中创建公司账号",
"success",
);
});
function openCompanyDetail(companyId) {
const company = (state.companies || []).find((c) => String(c.id) === String(companyId));
if (!company) return;
$("#d-title").textContent = company.name;
$("#d-sub").textContent = `统一社会信用代码 ${company.credit_code || "待补充"}`;
$("#d-kv").innerHTML =
'<dt>公司名称</dt><dd>' + company.name + '</dd>' +
'<dt>统一社会信用代码</dt><dd>' + (company.credit_code || "待补充") + '</dd>' +
'<dt>出纳</dt><dd>' + (company.cashier_name || "未指定") + '</dd>' +
'<dt>银行账户</dt><dd>' + (company.account_count ?? 0) + ' 个</dd>' +
'<dt>状态</dt><dd>' + companyStatusBadge(company.status).label + '</dd>';
$("#d-login-account").textContent = company.usernames || "未创建公司账号";
$("#d-login-meta").textContent = company.usernames ? "公司端登录账号" : "该公司尚未创建登录账号";
const resetBtn = $("#btn-reset-pwd");
resetBtn.disabled = !company.usernames;
resetBtn.textContent = "重置密码";
resetBtn.dataset.companyId = company.id;
openModal("modal-detail");
}
$("#companyTable tbody")?.addEventListener("click", (event) => {
const btn = event.target.closest("[data-company-view]");
if (btn) openCompanyDetail(btn.dataset.companyView);
});
$("#btn-reset-pwd")?.addEventListener("click", async () => {
const companyId = $("#btn-reset-pwd").dataset.companyId;
const usersResponse = await fetch("/api/admin/users").catch(() => null);
const usersResult = await usersResponse?.json().catch(() => ({}));
const user = (usersResult?.users || []).find((u) => String(u.company_id) === String(companyId) && u.role === "company");
if (!user) { showToast("重置失败", "该公司尚无公司登录账号", "danger"); return; }
const response = await fetch(`/api/admin/users/${user.id}/reset-password`, { method: "POST" }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) { showToast("重置失败", result?.message || "请稍后重试", "danger"); return; }
$("#btn-reset-pwd").disabled = true;
$("#btn-reset-pwd").textContent = "已重置";
$("#d-login-meta").textContent = `临时密码已生成(仅此一次):${result.initial_password},首次登录必须修改`;
showToast("密码已重置", "临时密码仅本次显示,首次登录必须修改", "success");
});
const remind = $("#cs-remind");
const track = $("#cs-switch-track");
const thumb = $("#cs-switch-thumb");
const daysSel = $("#cs-remind-days");
function renderSwitch() {
if (!remind) return;
track.style.background = remind.checked ? "var(--accent)" : "var(--fg-soft)";
thumb.style.left = remind.checked ? "18px" : "2px";
if (daysSel) daysSel.disabled = !remind.checked;
}
remind?.addEventListener("change", renderSwitch);
renderSwitch();
function applySystemSettings(values) {
if (!values) return;
const start = $("#cs-start");
if (start && values.start_date) start.value = values.start_date;
const dayInput = $("#cs-day");
if (dayInput && values.closing_day) dayInput.value = values.closing_day;
const remindInput = $("#cs-remind");
if (remindInput && "auto_remind" in values) {
remindInput.checked = values.auto_remind === "1";
renderSwitch();
}
const daysSelect = $("#cs-remind-days");
if (daysSelect && values.remind_days) daysSelect.value = values.remind_days;
const closingDay = values.closing_day || "5";
const startDate = values.start_date || "2026-01-01";
const dash = $("#dashboardPeriodSub");
if (dash) dash.textContent = `每月 ${closingDay} 日结账 · 全局起算日 ${startDate}`;
const timeline = $("#timelineSub");
if (timeline) timeline.textContent = `全局起算日 ${startDate} 起,每月 ${closingDay} 日结账`;
const flows = $("#flowsRangeMeta");
if (flows) flows.textContent = `数据范围:${startDate} 起算 · 每月 ${closingDay} 日结账`;
const opening = $("#openingSub");
if (opening) opening.textContent = `${startDate} 起算的公司间往来期初数`;
}
async function loadSystemSettings() {
const response = await fetch("/api/admin/settings").catch(() => null);
if (response?.status === 401) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (response?.ok && result.settings) applySystemSettings(result.settings);
}
$("#systemSettings")?.addEventListener("submit", async (event) => {
event.preventDefault();
const tip = $("#cs-save-tip");
const day = parseInt($("#cs-day")?.value, 10);
if (!day || day < 1 || day > 28) {
if (tip) { tip.style.display = ""; tip.style.color = "var(--danger)"; tip.textContent = "结账日须为 1-28 之间的整数"; }
return;
}
const startInput = $("#cs-start");
const nextStart = startInput?.value || "";
const prevStart = startInput?.dataset.current || "";
const locked = startInput?.dataset.locked === "1";
if (nextStart !== prevStart) {
if (locked) {
showToast("起算日已锁定", "已有结账月份,起算日不可修改", "warn");
return;
}
const reason = await askReason({
title: "修改起算日",
subtitle: "修改起算日必须填写原因,并写入变更留痕。",
confirmLabel: "确认修改",
});
if (!reason) return;
const calcResp = await fetch("/api/admin/settings/calculation-start", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ calculation_start_date: nextStart, reason }),
}).catch(() => null);
if (calcResp?.status === 401) { window.location.href = "index.html"; return; }
const calcResult = await calcResp?.json().catch(() => ({}));
if (!calcResp?.ok) {
showToast("起算日保存失败", calcResult?.message || "请稍后重试", "danger");
return;
}
await loadCalculationSettings();
await loadCalculationChanges();
}
const payload = {
closing_day: String(day),
auto_remind: $("#cs-remind")?.checked ? "1" : "0",
remind_days: $("#cs-remind-days")?.value || "3",
};
// Keep display start_date in settings store in sync when calculation start exists.
if (nextStart) payload.start_date = nextStart;
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => null);
if (response?.status === 401) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
const message = result?.message || "保存失败,请稍后重试";
if (tip) { tip.style.display = ""; tip.style.color = "var(--danger)"; tip.textContent = message; }
showToast("设置保存失败", message, "danger");
return;
}
if (tip) { tip.style.display = ""; tip.style.color = "var(--success)"; tip.textContent = "已保存 · 立即生效"; }
applySystemSettings(result.settings || {});
await loadCalculationSettings();
const gapDays = parseInt($("#cs-remind-days")?.value, 10) || 5;
const reminderResp = await fetch("/api/admin/reminder-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ settings: { monthly_start_day: String(day || 5), gap_days: String(gapDays) } }),
}).catch(() => null);
if (reminderResp?.status === 401) { window.location.href = "index.html"; return; }
if (!reminderResp?.ok) {
showToast("提醒扫描参数保存失败", "", "danger");
return;
}
showToast("系统计算口径已保存", "结账日与起算日变更已留痕 · 提醒扫描参数已同步更新", "success");
});
initPeriodClose();
loadPeriodClose();
$("#openOpeningDialog")?.addEventListener("click", () => openModal("openingDialog"));
$$("[data-close-opening]").forEach((button) => button.addEventListener("click", () => closeModal("openingDialog")));
$("#openingForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const fromName = data.get("from");
const toName = data.get("to");
if (fromName === toName) {
showToast("本方与对方不能相同", "同公司账户余额不属于公司间期初", "warn");
return;
}
const fromCompany = (state.companies || []).find((c) => c.name === fromName);
const toCompany = (state.companies || []).find((c) => c.name === toName);
if (!fromCompany || !toCompany) {
showToast("公司无效", "请刷新页面后重试", "warn");
return;
}
const amount = Number(data.get("amount"));
if (Number.isNaN(amount)) {
showToast("金额无效", "请输入有效数字", "warn");
return;
}
const response = await fetch("/api/admin/opening-balances", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
from_company_id: fromCompany.id,
to_company_id: toCompany.id,
amount: String(amount),
reason: String(data.get("reason") || ""),
}),
}).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("提交失败", result?.message || "请稍后重试", "danger");
return;
}
closeModal("openingDialog");
form.reset();
await loadOpeningBalances();
await loadCalculationChanges();
await loadCalculationSettings();
showToast("期初余额已提交", "待确认后才会计入公司端余额", "success");
});
$("#openingRows")?.addEventListener("click", async (event) => {
const confirmBtn = event.target.closest("[data-confirm-opening]");
const voidBtn = event.target.closest("[data-void-opening]");
const id = confirmBtn?.dataset.confirmOpening || voidBtn?.dataset.voidOpening;
if (!id) return;
const reason = await askReason({
title: confirmBtn ? "确认期初余额" : "作废期初余额",
subtitle: "该操作必须填写原因,并写入变更留痕。",
confirmLabel: confirmBtn ? "确认" : "作废",
});
if (!reason) return;
const path = confirmBtn
? `/api/admin/opening-balances/${id}/confirm`
: `/api/admin/opening-balances/${id}/void`;
const response = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
}).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("操作失败", result?.message || "请稍后重试", "danger");
return;
}
await loadOpeningBalances();
await loadCalculationChanges();
showToast(confirmBtn ? "期初已确认" : "期初已作废", "变更已留痕", "success");
});
initAdminReminders();
loadSystemSettings();
}
async function loadFlows() {
const tbody = $("#flowTable tbody");
if (!tbody) return;
const params = new URLSearchParams();
const companyName = $("#flowCompany")?.value;
if (companyName && companyName !== "全部公司") {
const company = companyByName(companyName);
if (company) params.set("company_id", String(company.id));
}
const bank = $("#flowBank")?.value;
if (bank && bank !== "全部银行") params.set("bank", bank);
const account = $("#flowAccount")?.value;
if (account && account !== "全部账户") params.set("account", account);
if ($("#flowStart")?.value) params.set("start", $("#flowStart").value);
if ($("#flowEnd")?.value) params.set("end", $("#flowEnd").value);
if ($("#flowKeyword")?.value.trim()) params.set("keyword", $("#flowKeyword").value.trim());
params.set("limit", "200");
const response = await fetch(`/api/flows?${params.toString()}`).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => null);
if (!response?.ok || result?.status !== "ok") {
showTableError(tbody, portal === "admin" ? 9 : 8);
showToast("流水加载失败", result?.message || "请稍后重试", "danger");
return;
}
state.flowRows = result.items || [];
state.flowTotal = result.total;
renderFlowRows();
}
function renderFlowRows() {
const tbody = $("#flowTable tbody");
if (!tbody) return;
const data = state.flowRows || [];
tbody.replaceChildren();
data.forEach((row, index) => {
const tr = document.createElement("tr");
tr.className = "clickable";
tr.dataset.flowIdx = String(index);
tr.dataset.company = row.company || "";
tr.dataset.bank = row.bank || "";
tr.dataset.account = row.account || "";
tr.dataset.date = row.date || "";
tr.dataset.dir = row.direction || "";
const date = document.createElement("td"); date.className = "num"; date.textContent = row.date || "—";
tr.append(date);
if (portal === "admin") {
const company = document.createElement("td");
company.className = "cell-main";
company.textContent = row.company || "—";
tr.append(company);
}
const acct = document.createElement("td"); acct.textContent = row.account_label || row.account || "—";
const dir = document.createElement("td");
const dirPill = document.createElement("span");
dirPill.className = `pill ${row.direction === "收" ? "pill-success" : "pill-danger"}`;
dirPill.textContent = row.direction || "—";
dir.append(dirPill);
const peer = document.createElement("td"); peer.className = "wrap"; peer.textContent = row.peer || "—";
const summary = document.createElement("td"); summary.className = "wrap"; summary.textContent = row.summary || "—";
const serial = document.createElement("td"); serial.className = "num"; serial.textContent = row.serial || "—";
const status = document.createElement("td");
const statusPill = document.createElement("span");
const kind = row.status_kind === "success" ? "pill-success" : row.status_kind === "warn" ? "pill-warn" : row.status_kind === "danger" ? "pill-danger" : "pill-muted";
statusPill.className = `pill ${kind}`;
statusPill.textContent = row.status || "未归集";
status.append(statusPill);
const amt = document.createElement("td");
amt.className = `num-col ${row.direction === "收" ? "amt-in" : "amt-out"}`;
amt.textContent = ${formatCurrency(row.amount)}`;
tr.append(acct, dir, peer, summary, serial, status, amt);
tbody.append(tr);
});
const count = $("#flowCount");
if (count) count.textContent = `共 ${resultTotalLabel(data.length, state.flowTotal)}`;
const empty = $("#flowEmpty");
if (empty) empty.hidden = data.length > 0;
const sum = $("#flowSum");
if (sum) {
sum.replaceChildren();
if (!data.length) return;
const inflow = document.createElement("span");
inflow.className = "amt-in";
inflow.textContent = `+¥ ${formatCurrency(data.filter((r) => r.direction === "收").reduce((n, r) => n + Number(r.amount || 0), 0))}`;
const outflow = document.createElement("span");
outflow.className = "amt-out";
outflow.textContent = `-¥ ${formatCurrency(data.filter((r) => r.direction !== "收").reduce((n, r) => n + Number(r.amount || 0), 0))}`;
sum.append(document.createTextNode("收 "), inflow, document.createTextNode(" · 付 "), outflow);
}
}
function resultTotalLabel(shown, total) {
if (total != null && total !== shown) return `${total} 笔 · 本页 ${shown}`;
return `${shown} 笔`;
}
function openFlowDetail(index) {
const modal = $("#tx-modal");
if (!modal) return;
const row = (state.flowRows || [])[index];
if (!row) return;
$("#tx-modal-sub").textContent = `${row.company || ""} · ${row.account_label || ""} · ${row.date || ""}`;
$("#d-serial").textContent = row.serial || "—";
$("#d-bank").textContent = row.bank || "—";
$("#d-account").textContent = row.own_name ? `${row.own_name} · ${row.account || ""}` : (row.account || "—");
$("#d-time").textContent = row.time || row.date || "—";
$("#d-peer").textContent = row.peer || "—";
$("#d-peer-acct").textContent = row.peer_account || "—";
$("#d-amount").textContent = ${formatCurrency(row.amount)}${row.direction || ""}`;
$("#d-status").textContent = row.status || "—";
$("#d-pair").textContent = "—";
$("#d-subject").textContent = "—";
$("#d-batch").textContent = row.batch || "—";
$("#d-note").textContent = row.locator ? `源行 ${row.locator}` : "—";
modal.classList.add("open");
}
function exportFlows() {
const params = new URLSearchParams();
const companyName = $("#flowCompany")?.value;
if (companyName && companyName !== "全部公司") {
const company = companyByName(companyName);
if (company) params.set("company_id", String(company.id));
}
window.location.href = `/api/export.csv${params.toString() ? `?${params}` : ""}`;
showToast("开始导出", "仅含出纳已确认工作表的银行原始流水", "success");
}
function initFlowTools() {
$("#applyFlowFilters")?.addEventListener("click", loadFlows);
$("#exportFlows")?.addEventListener("click", exportFlows);
$("#resetFlowFilters")?.addEventListener("click", () => {
const company = $("#flowCompany");
if (company) company.value = "全部公司";
if ($("#flowBank")) $("#flowBank").value = "全部银行";
if ($("#flowAccount")) $("#flowAccount").value = "全部账户";
if ($("#flowStart")) $("#flowStart").value = "";
if ($("#flowEnd")) $("#flowEnd").value = "";
if ($("#flowKeyword")) $("#flowKeyword").value = "";
loadFlows();
});
$("#flowTable tbody")?.addEventListener("click", (event) => {
const row = event.target.closest("tr[data-flow-idx]");
if (row) openFlowDetail(Number(row.dataset.flowIdx));
});
$("#tx-modal-close")?.addEventListener("click", () => $("#tx-modal")?.classList.remove("open"));
$("#tx-modal-ok")?.addEventListener("click", () => $("#tx-modal")?.classList.remove("open"));
$("#tx-modal")?.addEventListener("click", (event) => { if (event.target === event.currentTarget) event.currentTarget.classList.remove("open"); });
}
function manualApiStatus(stateName) {
if (stateName === "approved") return { cls: "pill-success", label: "已通过" };
if (stateName === "returned" || stateName === "reversed") return { cls: "pill-danger", label: stateName === "returned" ? "已退回" : "已冲销" };
if (stateName === "exception") return { cls: "pill-danger", label: "异常" };
return { cls: "pill-info", label: "待审核" };
}
function renderCompanyManualRecords(records) {
const tbody = $("#manualRecordRows");
if (!tbody) return;
tbody.replaceChildren();
const rows = records || state.manualRecords || [];
rows.forEach((record) => {
const tr = document.createElement("tr");
tr.dataset.recordId = String(record.id);
const date = document.createElement("td"); date.className = "num"; date.textContent = String(record.occurred_at || "").slice(0, 10);
const direction = document.createElement("td"); direction.textContent = record.direction === "incoming" ? "收款" : "付款";
const counterparty = document.createElement("td");
const name = document.createElement("span"); name.className = "cell-main"; name.textContent = record.counterparty_company_name || "—";
counterparty.append(name);
const subject = document.createElement("td");
const tag = document.createElement("span"); tag.className = "tag"; tag.textContent = SUBJECT_CODE_LABEL[record.requested_subject] || record.requested_subject || "—";
subject.append(tag);
const isIn = record.direction === "incoming";
const amount = document.createElement("td");
amount.className = `num-col ${isIn ? "amt-in" : "amt-out"}`;
amount.textContent = `${isIn ? "+" : "-"}¥ ${formatCurrency(record.amount)}`;
const summary = document.createElement("td"); summary.className = "wrap"; summary.textContent = record.summary || "—";
const statusMeta = manualApiStatus(record.state);
const statusCell = document.createElement("td");
const pill = document.createElement("span"); pill.className = `pill ${statusMeta.cls}`; pill.textContent = statusMeta.label;
statusCell.append(pill);
const action = document.createElement("td");
action.innerHTML = '<span class="muted">—</span>';
tr.append(date, direction, counterparty, subject, amount, summary, statusCell, action);
tbody.append(tr);
});
const pending = rows.filter((record) => record.state === "pending").length;
const pendingEl = $("#manualPendingStatus");
if (pendingEl) pendingEl.textContent = pending;
const foot = $("#manualFoot");
if (foot) foot.textContent = `共 ${rows.length} 条 · 待复核 ${pending} 条`;
const empty = $("#manualEmpty");
if (empty) empty.hidden = rows.length > 0;
}
async function loadCompanyManualRecords() {
if (!$("#manualRecordRows")) return;
const response = await fetch("/api/company/manual-records").catch(() => null);
if (response?.status === 401 || response?.status === 403) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("手工记录加载失败", result?.message || "请稍后重试", "danger");
return;
}
state.manualRecords = result.records || [];
renderCompanyManualRecords(state.manualRecords);
}
async function loadCompanyPeerSelect() {
const select = $("#manualCounterparty");
if (!select) return;
const response = await fetch("/api/company/companies").catch(() => null);
const result = await response?.json().catch(() => ({}));
const companies = (result.companies || []).filter((item) => String(item.id) !== String(state.me?.company_id));
const first = select.options[0];
select.replaceChildren(first);
companies.forEach((company) => {
const option = document.createElement("option");
option.value = String(company.id);
option.textContent = company.name;
select.append(option);
});
}
function resetUpload() {
state.selectedFile = null;
state.parseResult = null;
$("#uploadForm")?.reset();
if ($("#filePreview")) $("#filePreview").hidden = true;
if ($("#parseResult")) $("#parseResult").hidden = true;
if ($("#sheetReview")) $("#sheetReview").hidden = true;
if ($("#sheetList")) $("#sheetList").replaceChildren();
if ($("#dropzone")) $("#dropzone").hidden = false;
if ($("#parseButton")) {
$("#parseButton").disabled = true;
$("#parseButton span").textContent = "开始解析";
delete $("#parseButton").dataset.stage;
}
}
function updateParseButton() {
const button = $("#parseButton");
if (button) button.disabled = !(state.selectedFile && $("#accountSelect").value);
}
function acceptFile(file) {
if (!file) return;
const extension = file.name.split(".").pop().toLowerCase();
if (!["xls", "xlsx"].includes(extension)) {
showToast("文件格式不支持", "请选择银行导出的 .xls 或 .xlsx 文件", "danger");
return;
}
state.selectedFile = file;
state.parseResult = null;
$("#fileName").textContent = file.name;
$("#fileMeta").textContent = `${(file.size / 1024).toFixed(1)} KB · 等待表头识别`;
$("#filePreview").hidden = false;
$("#dropzone").hidden = true;
$("#parseResult").hidden = true;
delete $("#parseButton").dataset.stage;
updateParseButton();
}
async function parseFile() {
const formData = new FormData();
formData.append("file", state.selectedFile);
const selectedAccount = $("#accountSelect")?.selectedOptions?.[0];
if (selectedAccount?.dataset.accountId) {
formData.append("bank_account_id", selectedAccount.dataset.accountId);
}
let result;
let parsed = false;
try {
const response = await fetch("/api/parse", { method: "POST", body: formData });
if (response.status === 401 || response.status === 403) {
window.location.href = "index.html";
return;
}
result = await response.json();
parsed = response.ok && ["parsed", "duplicate"].includes(result.status);
} catch {
result = { status: "error", message: "解析服务暂时不可用,请稍后重试。" };
}
state.parseResult = result;
renderParseResult(result, parsed);
}
function renderParseResult(result, parsed) {
const panel = $("#parseResult");
if (!panel) return;
const sheets = Array.isArray(result.sheets) ? result.sheets : [];
const duplicated = result.status === "duplicate";
const opaque = duplicated && !sheets.length;
panel.classList.toggle("info", parsed);
panel.classList.toggle("warn", !parsed);
const parseIcon = $("use", panel);
if (parseIcon) parseIcon.setAttribute("href", parsed ? "icons.svg#circle-check" : "icons.svg#circle-alert");
$("#parseTitle").textContent = duplicated ? "文件已导入过" : parsed ? "文件解析完成" : "未识别到银行模板";
const pendingCount = sheets.filter((s) => s.outcome === "parsed" && s.review_status === "pending").length;
const exceptionCount = sheets.filter((s) => s.outcome === "exception").length;
const ignoredCount = sheets.filter((s) => s.outcome === "ignored" || s.review_status === "ignored").length;
let summary;
if (opaque) {
summary = "相同内容的文件已由其他公司导入,仅记录重复状态,不重复入账。";
} else if (sheets.length) {
summary = `${sheets.length} 个工作表:${pendingCount} 个待确认${exceptionCount ? `、${exceptionCount} 个异常` : ""}${ignoredCount ? `、${ignoredCount} 个忽略` : ""}。解析成功不等于业务确认。`;
} else {
summary = `${result.message || ""} 系统不会猜测模板或自动入账。`;
}
$("#parseSummary").textContent = summary;
panel.hidden = false;
renderSheetList(sheets, result.batch_id);
const button = $("#parseButton");
button.dataset.stage = "confirm";
button.disabled = false;
if (!parsed) {
button.querySelector("span").textContent = "关闭";
} else if (opaque) {
button.querySelector("span").textContent = "完成";
} else if (sheets.length) {
button.querySelector("span").textContent = pendingCount ? `确认全部(${pendingCount} 个待确认)` : "完成";
} else {
button.querySelector("span").textContent = "完成";
}
}
function sheetStatusMeta(sheet) {
if (sheet.review_status === "confirmed") return { className: "success", label: "已确认" };
if (sheet.review_status === "ignored") return { className: "neutral", label: "已忽略" };
if (sheet.outcome === "exception") return { className: "danger", label: "异常待处理" };
if (sheet.outcome === "ignored") return { className: "neutral", label: "空表忽略" };
return { className: "warning", label: "待确认" };
}
function renderSheetList(sheets, batchId) {
const wrap = $("#sheetReview");
const list = $("#sheetList");
if (!wrap || !list || !sheets.length) {
if (wrap) wrap.hidden = true;
return;
}
wrap.hidden = false;
list.replaceChildren(...sheets.map((sheet) => buildSheetItem(sheet, batchId)));
}
function buildSheetItem(sheet, batchId) {
const item = document.createElement("div");
item.className = "list-row";
const meta = sheetStatusMeta(sheet);
const main = document.createElement("div");
main.className = "lr-main";
const title = document.createElement("div");
title.className = "lr-title";
title.textContent = sheet.sheet_name;
main.append(title);
const details = document.createElement("div");
details.className = "lr-sub";
if (sheet.outcome === "parsed" && sheet.bank) {
const period = sheet.period_start ? ` · ${sheet.period_start}${sheet.period_end}` : "";
details.textContent = `${sheet.bank} · ${sheet.transactions} 条明细${period}`;
} else if (sheet.message) {
details.textContent = sheet.message;
} else {
details.textContent = "空工作表。";
}
if (sheet.review_reason) {
details.textContent += ` · 原因:${sheet.review_reason}`;
}
main.append(details);
const badge = document.createElement("span");
badge.className = `pill ${pillClass(meta.className)}`;
badge.textContent = meta.label;
item.append(main, badge);
if (sheet.review_status === "pending") {
const actions = document.createElement("div");
actions.className = "lr-side";
actions.style.cssText = "display:flex;gap:6px;flex:none;";
if (sheet.outcome === "parsed") {
const confirmButton = document.createElement("button");
confirmButton.type = "button";
confirmButton.className = "btn btn-sm btn-primary";
confirmButton.textContent = "确认";
confirmButton.addEventListener("click", () => sheetReviewAction(batchId, sheet.sheet_name, "confirm"));
actions.append(confirmButton);
}
const ignoreButton = document.createElement("button");
ignoreButton.type = "button";
ignoreButton.className = "btn btn-sm";
ignoreButton.textContent = "忽略";
ignoreButton.addEventListener("click", () => sheetReviewAction(batchId, sheet.sheet_name, "ignore"));
actions.append(ignoreButton);
item.append(actions);
}
return item;
}
async function sheetReviewAction(batchId, sheetName, decision) {
const payload = { sheets: [sheetName] };
if (decision === "ignore") {
const reason = (window.prompt("请填写忽略原因(必填):", "") || "").trim();
if (!reason) {
showToast("忽略未提交", "必须填写忽略原因", "warn");
return;
}
payload.reason = reason;
}
const response = await fetch(`/api/batches/${batchId}/${decision}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => null);
if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
showToast("操作失败", result?.message || "请稍后重试", "danger");
return;
}
showToast(decision === "confirm" ? "工作表已确认" : "工作表已忽略", `${sheetName}`, "success");
await refreshAfterSheetAction(result, batchId);
}
async function refreshAfterSheetAction(result, batchId) {
if (Array.isArray(result.sheets)) renderSheetList(result.sheets, batchId);
await loadImportBatches();
const button = $("#parseButton");
if (!button) return;
const pending = (result.sheets || []).filter((s) => s.outcome === "parsed" && s.review_status === "pending").length;
if (pending === 0 && (result.sheets || []).length) {
button.querySelector("span").textContent = "完成";
button.dataset.stage = "done";
} else {
button.querySelector("span").textContent = `确认全部(${pending} 个待确认)`;
}
}
async function confirmImport() {
const result = state.parseResult;
const batchId = result?.batch_id;
const sheets = Array.isArray(result?.sheets) ? result.sheets : [];
const pending = sheets
.filter((s) => s.outcome === "parsed" && s.review_status === "pending")
.map((s) => s.sheet_name);
if (!pending.length) {
resetUpload();
await loadImportBatches();
showView("upload");
return;
}
const button = $("#parseButton");
button.disabled = true;
button.querySelector("span").textContent = "正在确认...";
const response = await fetch(`/api/batches/${batchId}/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sheets: pending }),
}).catch(() => null);
if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html";
return;
}
const outcome = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
button.disabled = false;
button.querySelector("span").textContent = "确认失败,点击重试";
showToast("确认失败", outcome?.message || "请稍后重试", "danger");
return;
}
button.disabled = false;
if (Array.isArray(outcome.sheets)) renderSheetList(outcome.sheets, batchId);
await loadImportBatches();
resetUpload();
showView("upload");
showToast("流水已确认", `${outcome.updated.length} 个工作表已确认;未确认的工作表不参与计算`, "success");
}
function submitImportException() {
resetUpload();
showView("upload");
showToast("解析异常未入账", `${state.selectedFile?.name || "该文件"} 不会进入匹配与计算,请核对模板后重新导出`, "warn");
}
function renderBatchRow(batch) {
const row = document.createElement("tr");
row.dataset.batchId = batch.id;
const idCell = document.createElement("td");
const id = document.createElement("span"); id.className = "cell-main num"; id.textContent = `IMP-${String(batch.id).padStart(6, "0")}`;
const file = document.createElement("span"); file.className = "cell-sub"; file.textContent = batch.original_filename || "";
idCell.append(id, file);
const bank = document.createElement("td");
bank.textContent = batch.bank_name || "—";
const period = document.createElement("td");
period.className = "num";
period.textContent = batch.period_start && batch.period_end
? `${batch.period_start} ~ ${batch.period_end}`
: "—";
const count = document.createElement("td");
count.className = "num-col";
count.textContent = `${batch.confirmed_transactions ?? 0}`;
const coverage = document.createElement("td");
const coverageStatus = batch.status === "exception"
? { className: "danger", label: "未导入" }
: batch.pending_sheets > 0
? { className: "warning", label: `${batch.pending_sheets} 个待确认` }
: batch.confirmed_sheets > 0
? { className: "success", label: "已确认" }
: { className: "neutral", label: "待处理" };
coverage.innerHTML = `<span class="pill ${pillClass(coverageStatus.className)}">${coverageStatus.label}</span>`;
const parseState = document.createElement("td");
const statusParts = [];
if (batch.exception_sheets > 0) statusParts.push(`${batch.exception_sheets} 个异常`);
if (batch.ignored_sheets > 0) statusParts.push(`${batch.ignored_sheets} 个忽略`);
if (statusParts.length) {
parseState.innerHTML = `<span class="pill pill-warn">${statusParts.join("、")}</span>`;
} else {
parseState.innerHTML = '<span class="pill pill-success">解析成功</span>';
}
const time = document.createElement("td");
time.className = "meta";
time.textContent = String(batch.created_at || "").slice(0, 16).replace("T", " ");
const action = document.createElement("td");
action.innerHTML = '<button type="button" class="btn btn-sm" data-batch-view>查看</button>';
row.append(idCell, bank, period, count, coverage, parseState, time, action);
return row;
}
async function loadImportBatches() {
const tbody = $("#importRows");
if (!tbody) return;
showTableLoading(tbody, 8);
const response = await fetch("/api/batches").catch(() => null);
if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html";
return;
}
if (!response?.ok) {
showTableError(tbody, 8);
return;
}
const result = await response?.json().catch(() => ({}));
const batches = Array.isArray(result?.batches) ? result.batches : [];
state.batches = batches;
tbody.replaceChildren(...batches.map(renderBatchRow));
const foot = $("#importFoot");
if (foot) foot.textContent = batches.length ? `共 ${batches.length} 个批次` : "暂无批次";
}
function formatWorkspaceAmount(amount, currency) {
const n = Number(amount);
const text = Number.isFinite(n)
? n.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: String(amount ?? "—");
return `${currency || "CNY"} ${text}`;
}
function applyCompanyWorkspace(payload) {
const events = Array.isArray(payload?.unilateral_events) ? payload.unilateral_events : [];
const pending = Number(payload?.pending_unilateral ?? events.length) || 0;
const total = Number(payload?.pending_total ?? pending) || 0;
state.workspace = { pending_unilateral: pending, pending_total: total, unilateral_events: events };
const cta = $("#workspaceUnilateralCta");
if (cta) {
cta.textContent = pending ? `去确认单边流水 (${pending})` : "单边流水已全部确认";
cta.classList.toggle("btn-primary", pending > 0);
}
const status = $("#workspacePendingStatus");
if (status) {
status.textContent = total ? `${total} 项待处理` : "已完成";
status.className = `pill ${total ? "pill-warn" : "pill-success"}`;
}
const sub = $("#workspaceTodoSub");
if (sub) sub.textContent = total ? `权威待确认单边流水 ${pending} 笔` : "本月单边流水待办已清空";
const list = $("#workspaceTodoList");
if (list) {
list.replaceChildren();
if (!events.length) {
const empty = document.createElement("div");
empty.className = "empty";
empty.style.padding = "18px 16px";
empty.innerHTML = '<div class="e-title">暂无待确认单边流水</div><div>刷新或重登后仍以服务端权威状态为准</div>';
list.append(empty);
} else {
events.forEach((event) => {
const row = document.createElement("div");
row.className = "list-row";
row.dataset.taskType = "match";
row.dataset.eventId = String(event.event_id);
row.innerHTML = `
<span class="pill pill-danger">阻断</span>
<div class="lr-main">
<div class="lr-title">确认单边流水 · ${formatWorkspaceAmount(event.amount, event.currency)}</div>
<div class="lr-sub">${event.counterparty_company_name || "对方待指定"} · ${event.effective_at || "—"}</div>
</div>
<button class="btn btn-sm" data-view-link="reconcile">去确认</button>`;
list.append(row);
});
}
}
const foot = $("#workspaceTodoFoot");
if (foot) {
foot.innerHTML = total
? `<span>处理完 ${total} 笔单边流水后,工作台数字与列表将同步归零</span>`
: "<span>单边流水待办已完成</span>";
}
const flowState = $("#workspaceConfirmState");
if (flowState) flowState.textContent = pending ? `待处理 ${pending} 笔` : "已完成";
const flowMeta = $("#workspaceConfirmMeta");
if (flowMeta) {
flowMeta.textContent = pending
? `单边流水 ${pending} 笔待确认`
: "已全部确认,等待集团结账";
}
// 完成态必须切到 success 绿(.flow-step.done),待确认保留 warn 黄(.doing
const flowStep = flowState?.closest(".flow-step");
if (flowStep) {
flowStep.classList.toggle("doing", pending > 0);
flowStep.classList.toggle("done", pending === 0);
}
const flowSub = $("#workspaceFlowSub");
if (flowSub) {
flowSub.textContent = pending
? "当前停在第 3 步「往来确认」,完成后即可等待集团结账"
: "第 3 步「往来确认」已完成,等待集团复核与结账";
}
const countMatch = $("#count-match");
if (countMatch) countMatch.textContent = String(pending);
const noticeTitle = $("#notice-title");
const noticeBody = $("#notice-body");
const notice = $("#blocking-notice");
if (noticeTitle) {
noticeTitle.textContent = pending
? `${pending} 项单边流水待确认,是结账阻断项`
: "单边流水已全部确认完成";
}
if (noticeBody) {
noticeBody.textContent = pending
? `工作台「去确认单边流水」与「本月待办」均读取同一权威集合(${pending} 笔)。确认成功后立即同步减一。`
: "本公司单边流水待办已清空;刷新或重新登录后仍为 0。";
}
if (notice) {
notice.classList.toggle("warn", pending > 0);
notice.classList.toggle("success", pending === 0);
}
const badge = $('.side-nav a[data-view="reconcile"] .nav-badge');
if (badge) {
badge.textContent = pending;
badge.style.display = pending ? "" : "none";
}
}
async function loadCompanyWorkspace() {
const response = await fetch("/api/company/workspace").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") {
applyCompanyWorkspace({ pending_unilateral: 0, pending_total: 0, unilateral_events: [] });
showToast("工作台待办读取失败", result?.message || "请稍后重试", "danger");
return null;
}
applyCompanyWorkspace(result);
return result;
}
function renderUnilateralMatchCard(event, peers) {
const card = document.createElement("div");
card.className = "card";
card.dataset.matchCard = "true";
card.dataset.eventId = String(event.event_id);
card.dataset.revision = String(event.revision ?? "");
const amountText = formatWorkspaceAmount(event.amount, event.currency);
const peerName = event.counterparty_company_name || "对方待指定";
const options = peers
.filter((p) => Number(p.id) !== Number(event.own_company_id))
.map((p) => {
const selected = Number(p.id) === Number(event.counterparty_company_id) ? " selected" : "";
return `<option value="${p.id}"${selected}>${p.name}</option>`;
})
.join("");
card.innerHTML = `
<div class="card-head">
<span class="card-title">${event.effective_at || "—"} · <span class="num">${amountText}</span>
<span class="sub">对方:${peerName}</span></span>
<span class="pill pill-danger">单边</span>
</div>
<div class="detail-box" style="margin-bottom: 12px;">
<dl class="kv">
<dt>事件编号</dt><dd class="num">${event.event_id}</dd>
<dt>状态</dt><dd>${event.status || event.classification || "待确认"}</dd>
<dt>金额</dt><dd class="num">${amountText}</dd>
</dl>
</div>
<div class="field" style="margin-bottom: 14px;">
<label>确认对方公司</label>
<select class="select" data-counterparty-select>
<option value="">请选择对方公司</option>
${options}
</select>
<span class="hint">确认后按现有单边确认规则锁定对方参与方;工作台数字以服务端权威状态重拉。</span>
</div>
<div class="row">
<button class="btn btn-primary" data-match-confirm disabled>确认匹配</button>
<a class="btn btn-ghost" data-view-link="flows">查看本方流水</a>
</div>`;
const select = $("[data-counterparty-select]", card);
const button = $("[data-match-confirm]", card);
const syncEnabled = () => {
if (button) button.disabled = !select?.value;
};
select?.addEventListener("change", syncEnabled);
syncEnabled();
button?.addEventListener("click", async () => {
if (!select?.value || button.dataset.busy === "1") return;
button.dataset.busy = "1";
button.disabled = true;
const requestKey = `company-confirm-${event.event_id}-${event.revision}-${select.value}`;
const response = await fetch(`/api/company/transfer-events/${event.event_id}/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
expected_revision: event.revision,
request_key: requestKey,
counterparty_company_id: Number(select.value),
reason: "公司端确认单边流水",
}),
}).catch(() => null);
if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
button.dataset.busy = "0";
syncEnabled();
showToast("确认失败", result?.message || "请刷新后重试", "danger");
// 失败不得本地误减:重拉权威状态
await loadCompanyWorkspace();
await renderReconcileMatchStack();
return;
}
const matchedList = $("#matched-list");
const matchedSummary = $("#matched-summary");
if (matchedList) {
const row = document.createElement("div");
row.className = "list-row";
row.innerHTML = `<div class="lr-main"><div class="lr-title">${amountText} · 已确认对方 ${select.selectedOptions[0]?.textContent || ""}</div></div><span class="pill pill-success lr-side">已匹配</span>`;
matchedList.append(row);
if (matchedSummary) matchedSummary.style.display = "";
}
if (result.workspace) applyCompanyWorkspace(result.workspace);
else await loadCompanyWorkspace();
await renderReconcileMatchStack();
showToast("匹配已确认", "工作台待办已按权威状态同步", "success");
});
return card;
}
async function loadCompanyPeerOptions() {
if (Array.isArray(state.companyPeers) && state.companyPeers.length) return state.companyPeers;
const response = await fetch("/api/company/companies").catch(() => null);
const result = await response?.json().catch(() => null);
const peers = Array.isArray(result?.companies) ? result.companies : [];
state.companyPeers = peers;
return peers;
}
async function renderReconcileMatchStack() {
const stack = $("#match-stack");
if (!stack) return;
const events = state.workspace?.unilateral_events || [];
const peers = await loadCompanyPeerOptions();
stack.replaceChildren();
if (!events.length) {
const empty = document.createElement("div");
empty.className = "empty";
empty.innerHTML = '<div class="e-title">单边流水已全部匹配</div><div>确认结果已同步至工作台权威待办</div>';
stack.append(empty);
return;
}
events.forEach((event) => stack.append(renderUnilateralMatchCard(event, peers)));
}
function initReconcile() {
const subjectRows = $$("[data-subject-row]");
let pendingSubject = subjectRows.length;
const countSubject = $("#count-subject");
if (countSubject) countSubject.textContent = pendingSubject;
$$(".subject-confirm-btn").forEach((button) => {
button.addEventListener("click", () => {
const row = button.closest("[data-subject-row]");
const select = $("select", row);
const subject = select.value;
select.disabled = true;
button.disabled = true;
button.textContent = "已确认";
const statusCell = $(".subject-status", row);
statusCell.innerHTML = `<span class="pill pill-success">已确认 · ${subject}</span>`;
pendingSubject -= 1;
if (countSubject) countSubject.textContent = pendingSubject;
showToast("科目已确认", "科目确认仍为页面演示,不计入工作台权威待办", "success");
});
});
const tabMatch = $("#tab-match");
const tabSubject = $("#tab-subject");
const panelMatch = $("#panel-match");
const panelSubject = $("#panel-subject");
function switchTab(which) {
tabMatch?.classList.toggle("active", which === "match");
tabSubject?.classList.toggle("active", which === "subject");
tabMatch?.setAttribute("aria-pressed", String(which === "match"));
tabSubject?.setAttribute("aria-pressed", String(which === "subject"));
if (panelMatch) panelMatch.style.display = which === "match" ? "" : "none";
if (panelSubject) panelSubject.style.display = which === "subject" ? "" : "none";
}
tabMatch?.addEventListener("click", () => switchTab("match"));
tabSubject?.addEventListener("click", () => switchTab("subject"));
}
function renderCompanyNoticeRow(item) {
const row = document.createElement("div");
row.className = "list-row";
row.dataset.status = item.status_ui;
row.dataset.reminderId = String(item.id);
const titleWeight = item.status_ui === "unread" ? "650" : "400";
const titleColor = item.status_ui === "unread" ? "var(--fg)" : "var(--muted)";
const sourceTag = item.source === "auto" ? '<span class="pill pill-info">系统</span>' : '<span class="tag">管理员</span>';
const actionLink = item.action_link;
let side = "";
if (item.status_ui === "unread") {
side = '<button class="btn btn-sm btn-mark-read">标记已读</button>';
} else if (item.status_ui === "doing" && actionLink) {
side = `<button class="btn btn-sm btn-primary" data-view-link="${actionLink}">去处理</button>`;
} else if (item.status_ui === "doing") {
side = '<button class="btn btn-sm btn-ghost btn-mark-done">标记完成</button>';
}
row.innerHTML =
`<span class="pill ${reminderStatusPill(item.status_ui)}" style="flex:none;">${reminderStatusLabel(item.status_ui)}</span>` +
sourceTag +
`<div class="lr-main"><div class="lr-title" style="font-weight:${titleWeight};color:${titleColor};">${item.title}</div>` +
`<div class="lr-sub"><span class="meta">${formatReminderTime(item.last_sent_at)}</span> · ${item.content}</div></div>` +
`<div class="lr-side">${side}</div>`;
return row;
}
function initNotifications() {
const tabs = $("#notice-tabs");
const list = $("#notice-list");
if (!tabs || !list) return;
const emptyBox = $("#notice-empty");
let currentFilter = "all";
let rows = [];
function refreshCounts(stats, unreadCount) {
const c = stats || { all: rows.length, unread: 0, doing: 0, done: 0 };
if (!stats) {
c.all = rows.length;
rows.forEach((row) => { c[row.getAttribute("data-status")] += 1; });
} else {
c.all = rows.length;
}
$("#count-all").textContent = c.all;
$("#count-unread").textContent = c.unread;
$("#count-doing").textContent = c.doing;
$("#count-done").textContent = c.done;
const navBadge = $('.side-nav a[data-view="notifications"] .nav-badge');
const unread = unreadCount ?? c.unread;
if (navBadge) {
navBadge.textContent = unread;
navBadge.style.display = unread ? "" : "none";
}
const wsLink = $("#workspace-notice-link");
if (wsLink) wsLink.textContent = unread ? `全部通知 (${c.all}) →` : "全部通知 →";
}
function applyFilter() {
let visible = 0;
rows.forEach((row) => {
const show = currentFilter === "all" || row.getAttribute("data-status") === currentFilter;
row.style.display = show ? "" : "none";
if (show) visible += 1;
});
if (emptyBox) emptyBox.style.display = visible === 0 ? "" : "none";
}
async function loadReminders() {
const response = await fetch("/api/company/reminders").catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
const result = await response?.json().catch(() => ({}));
if (!response?.ok) return;
const items = result.reminders || [];
list.replaceChildren(...items.map((item) => renderCompanyNoticeRow(item)));
rows = $$(".list-row", list);
refreshCounts(result.stats, result.unread_count);
applyFilter();
const wsList = $("#workspace-notice-list");
if (wsList) {
const preview = items.slice(0, 3);
if (!preview.length) {
wsList.innerHTML = '<div class="empty"><div class="e-title">暂无通知</div></div>';
} else {
wsList.replaceChildren(...preview.map((item) => {
const row = document.createElement("div");
row.className = "list-row";
row.innerHTML =
`<span class="pill ${reminderTypePill(item.rule_key)}">${item.display_type}</span>` +
`<div class="lr-main"><div class="lr-title">${item.title}</div><div class="lr-sub">${item.content.slice(0, 48)}</div></div>` +
`<span class="lr-side meta">${formatReminderTime(item.last_sent_at).slice(5, 10)}</span>`;
return row;
}));
}
}
}
tabs.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-filter]");
if (!btn) return;
$$("button", tabs).forEach((b) => { b.classList.remove("active"); b.setAttribute("aria-pressed", "false"); });
btn.classList.add("active");
btn.setAttribute("aria-pressed", "true");
currentFilter = btn.dataset.filter;
applyFilter();
});
list.addEventListener("click", async (event) => {
const viewLink = event.target.closest("[data-view-link]");
if (viewLink && list.contains(viewLink)) {
event.preventDefault();
showView(viewLink.dataset.viewLink);
return;
}
const readBtn = event.target.closest(".btn-mark-read");
const doneBtn = event.target.closest(".btn-mark-done");
const btn = readBtn || doneBtn;
if (!btn) return;
const row = btn.closest(".list-row");
const id = row?.dataset.reminderId;
if (!id) return;
const action = readBtn ? "acknowledge" : "resolve";
const response = await fetch(`/api/company/reminders/${id}/${action}`, { method: "POST" }).catch(() => null);
if (response?.status === 401) { window.location.href = "index.html"; return; }
if (!response?.ok) return;
await loadReminders();
});
$("#mark-all-read")?.addEventListener("click", async () => {
const unreadRows = rows.filter((row) => row.getAttribute("data-status") === "unread");
for (const row of unreadRows) {
const id = row.dataset.reminderId;
await fetch(`/api/company/reminders/${id}/acknowledge`, { method: "POST" }).catch(() => null);
}
await loadReminders();
showToast("通知已全部标为已读", "", "success");
});
loadReminders();
}
function openAccountDetail(account) {
const meta = companyAccountMeta(account.status);
$("#ad-title").textContent = `${account.bank_name || ""} · 尾号 ${accountTail(account.account_number_masked)}`;
$("#ad-sub").textContent = "本公司 · 登记账户明细";
$("#ad-bank").textContent = account.bank_name || "—";
$("#ad-tail").textContent = `尾号 ${accountTail(account.account_number_masked)}`;
$("#ad-type").textContent = account.account_type || "—";
$("#ad-branch").textContent = account.account_name || "—";
$("#ad-reg").textContent = String(account.created_at || "").slice(0, 10) || "—";
$("#ad-status").textContent = meta.status.label;
$("#ad-audit").textContent = meta.audit.label;
$("#ad-purpose").textContent = "—";
$("#ad-effective").textContent = account.effective_from || "待审核确定";
$("#ad-range").textContent = account.usable ? "尚未上传" : "—";
$("#ad-reason").textContent = account.status === "returned" && account.review_reason ? account.review_reason : "—";
$("#modal-account-detail")?.classList.add("open");
}
function yuanToWan(value) {
const n = Number(value);
if (!Number.isFinite(n)) return null;
return n / 10000;
}
function formatWanHtml(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}<span class="unit">万元</span>`;
const sign = wan > 0 ? "+" : wan < 0 ? "" : "";
return `${sign}${abs}<span class="unit">万元</span>`;
}
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 = formatWanHtml(confirmed.inflow_total, { signed: true });
if (outflow) {
outflow.innerHTML = formatWanHtml(
confirmed.outflow_total != null ? -Math.abs(Number(confirmed.outflow_total)) : null,
{ signed: true },
);
}
if (netLabel) netLabel.textContent = `${netLabelForWindow(win)}${netMeta.label !== "持平" ? ` · ${netMeta.label}` : ""}`;
if (net) {
net.className = `ms-value ${netMeta.signedClass}`.trim();
net.innerHTML = formatWanHtml(confirmed.net_change, { signed: true });
}
if (pendingEl) {
const pCount = Number(pending.count) || 0;
pendingEl.innerHTML = pCount
? `${formatWanHtml(pending.amount_total)}<span class="unit"> · ${pCount} 笔</span>`
: `0.00<span class="unit">万元 · 0 笔</span>`;
}
}
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 = `
<div class="card stat-card"><div class="stat-label">往来公司数</div><div class="stat-value">0<span class="unit">家</span></div><div class="stat-foot">${win.start || "—"} ~ ${win.end || "—"}</div></div>
<div class="card stat-card"><div class="stat-label">本期转入 · 流入</div><div class="stat-value">0.00<span class="unit">万元</span></div></div>
<div class="card stat-card"><div class="stat-label">本期转出 · 流出</div><div class="stat-value">0.00<span class="unit">万元</span></div></div>
<div class="card stat-card"><div class="stat-label">${netLabelForWindow(win)}</div><div class="stat-value">0.00<span class="unit">万元</span></div></div>`;
}
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}<span class="unit">家</span>`;
if (inflow) inflow.innerHTML = formatWanHtml(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}<span class="unit">万元</span>`;
}
if (netTitle) netTitle.textContent = netLabelForWindow(win);
if (net) {
const tag = netMeta.label !== "持平"
? ` <span class="xfer-dir-tag ${netMeta.className}">${netMeta.label}</span>`
: "";
net.innerHTML = `${formatWanHtml(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 = formatWanHtml(confirmed.net_change, { signed: true });
const openingCard = $("#tfStatOpeningCard");
const endingCard = $("#tfStatEndingCard");
const openingEl = $("#tfStatOpening");
const endingEl = $("#tfStatEnding");
if (win.has_opening) {
if (openingCard) openingCard.hidden = false;
if (endingCard) endingCard.hidden = false;
if (openingEl) openingEl.innerHTML = formatWanHtml(win.opening, { signed: true });
if (endingEl) endingEl.innerHTML = formatWanHtml(win.ending, { signed: true });
} else {
if (openingCard) openingCard.hidden = true;
if (endingCard) endingCard.hidden = true;
}
if (tfPendingCount) tfPendingCount.textContent = `${pCount} 笔`;
if (tfPendingAmount) tfPendingAmount.innerHTML = formatWanHtml(pending.amount_total || 0);
const tbody = $("#transfersCpBody");
const tfoot = $("#transfersCpFoot");
if (!tbody) return;
if (!cps.length) {
tbody.innerHTML = `<tr><td colspan="7"><div class="empty" style="border:0;padding:24px 0;"><div class="e-title">暂无对方公司汇总</div></div></td></tr>`;
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
? `<span class="pill pill-warn">${pendingN} 笔待确认</span>`
: `<span class="pill pill-success">全部已确认</span>`;
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 `<tr class="clickable" data-cp-id="${cp.company_id}" data-cp-name="${String(cp.company_name || "").replace(/"/g, "&quot;")}">
<td class="cell-main">${cp.company_name || "—"}</td>
<td class="num-col amt-in">${inText}</td>
<td class="num-col amt-out">${outText}</td>
<td class="num-col">${netText}</td>
<td><span class="xfer-dir-tag ${meta.className}">${meta.label}</span></td>
<td>${pendingPill}</td>
<td class="num">${last}</td>
</tr>`;
}).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 = `<tr>
<td>合计</td>
<td class="num-col amt-in">${inWan === null ? "—" : `+${fmtAbs(inWan)}`}</td>
<td class="num-col amt-out">${outWan === null ? "—" : `${fmtAbs(outWan)}`}</td>
<td class="num-col">${netWan === null ? "—" : `${netWan > 0 ? "+" : netWan < 0 ? "" : ""}${fmtAbs(netWan)}`}</td>
<td><span class="xfer-dir-tag ${netMeta.className}">${netMeta.label}</span></td>
<td>${totalPending ? `<span class="pill pill-warn">${totalPending} 笔待确认</span>` : `<span class="pill pill-success">全部已确认</span>`}</td>
<td></td>
</tr>`;
}
}
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 = formatWanHtml(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 })}<span class="unit">万元</span>`;
}
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 ? "+" : ""}<span class="unit">笔</span>`;
}
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 `<tr class="${pendingClass.trim()}" data-event-id="${ev.event_id}">
<td class="num">${date}</td>
<td class="wrap">${ev.summary || "—"}</td>
<td>${dir}${ev.direction === "in" ? " ↓" : ev.direction === "out" ? " ↑" : ""}</td>
<td class="num-col ${dirClass}">${amt}</td>
<td><span class="pill ${st.pill}">${st.text}</span></td>
<td><button type="button" class="btn btn-sm" data-transfer-evidence="${ev.event_id}">原始流水</button></td>
</tr>`;
}).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() {
$("#openAttestationFromWorkspace")?.addEventListener("click", () => openAttestationDialog());
$("#openAttestationFromFlows")?.addEventListener("click", () => openAttestationDialog());
$$("[data-close-attestation]").forEach((btn) => btn.addEventListener("click", () => closeModal("attestationDialog")));
$("#attestationForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const payload = {
bank_account_id: Number($("#att-account-id")?.value || 0),
gap_start: $("#att-gap-start")?.value || "",
gap_end: $("#att-gap-end")?.value || "",
reason: $("#att-reason")?.value || "",
evidence: $("#att-evidence")?.value || "",
};
const response = await fetch("/api/company/no-business-attestations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
showToast("提交失败", result?.message || "请稍后重试", "danger");
return;
}
closeModal("attestationDialog");
await loadCompanyCoverageGaps();
showToast("已提交无业务说明", "等待管理员审核,通过后仅关闭断档提醒", "success");
});
loadCompanyPeerSelect();
loadCompanyManualRecords();
loadCompanyAccounts();
loadImportBatches();
$$("[data-close]").forEach((el) => el.addEventListener("click", () => closeModal(el.dataset.close)));
$$(".modal-backdrop").forEach((bd) => bd.addEventListener("click", (e) => { if (e.target === bd) bd.classList.remove("open"); }));
document.addEventListener("keydown", (e) => { if (e.key === "Escape") $$(".modal-backdrop.open").forEach((m) => m.classList.remove("open")); });
$$('[data-open-upload]').forEach((button) => button.addEventListener("click", () => showView("upload")));
// ── 流水导入:多步流程(选文件 → 解析 → 分 sheet 审核 → 确认) ──
$("#accountSelect")?.addEventListener("change", updateParseButton);
$("#fileInput")?.addEventListener("change", (event) => acceptFile(event.target.files[0]));
$("#removeFile")?.addEventListener("click", () => {
state.selectedFile = null;
$("#fileInput").value = "";
$("#filePreview").hidden = true;
$("#dropzone").hidden = false;
$("#parseResult").hidden = true;
updateParseButton();
});
const dropzone = $("#dropzone");
if (dropzone) {
["dragenter", "dragover"].forEach((type) => dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.add("dragover"); }));
["dragleave", "drop"].forEach((type) => dropzone.addEventListener(type, (event) => { event.preventDefault(); dropzone.classList.remove("dragover"); }));
dropzone.addEventListener("click", () => $("#fileInput").click());
dropzone.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); $("#fileInput").click(); } });
dropzone.addEventListener("drop", (event) => acceptFile(event.dataTransfer.files[0]));
}
$("#uploadForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const stage = $("#parseButton").dataset.stage;
if (stage === "confirm" || stage === "done") {
const result = state.parseResult;
const parsed = result && ["parsed", "duplicate"].includes(result.status);
if (parsed && stage === "confirm") {
await confirmImport();
} else if (parsed) {
resetUpload();
showView("upload");
await loadImportBatches();
} else {
submitImportException();
}
return;
}
$("#parseButton").disabled = true;
$("#parseButton span").textContent = "正在识别表头...";
await parseFile();
});
// ── 导入批次详情弹窗 ──
$("#importRows")?.addEventListener("click", (event) => {
const btn = event.target.closest("[data-batch-view]");
if (!btn) return;
const tr = btn.closest("tr");
const batch = (state.batches || []).find((b) => String(b.id) === String(tr?.dataset.batchId));
if (!batch) return;
$("#mb-sub").textContent = `IMP-${String(batch.id).padStart(6, "0")} · ${batch.original_filename || ""}`;
$("#mb-id").textContent = `IMP-${String(batch.id).padStart(6, "0")}`;
$("#mb-bank").textContent = batch.bank_name || "—";
$("#mb-period").textContent = batch.period_start && batch.period_end ? `${batch.period_start} ~ ${batch.period_end}` : "—";
$("#mb-count").textContent = `${batch.confirmed_transactions ?? 0} 条`;
$("#mb-cover").textContent = batch.status === "exception" ? "未导入" : batch.pending_sheets > 0 ? `${batch.pending_sheets} 个待确认` : batch.confirmed_sheets > 0 ? "已确认" : "待处理";
$("#mb-parse").textContent = batch.exception_sheets > 0 ? `${batch.exception_sheets} 个异常` : batch.ignored_sheets > 0 ? `${batch.ignored_sheets} 个忽略` : "解析成功";
$("#mb-time").textContent = String(batch.created_at || "").slice(0, 16).replace("T", " ");
openModal("modal-batch");
});
// ── 手工记录:提交 + 撤回 ──
$("#manualEntryForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const counterpartyId = Number(data.get("counterparty"));
if (!counterpartyId) {
showToast("请选择对方公司", "手工记录仅登记集团内公司往来", "warn");
return;
}
const source = String(data.get("sourceAccount") || "");
const funding = source.includes("个人过账") ? "personal_transit" : (source ? "approved_bank_account" : "other");
const response = await fetch("/api/company/manual-records", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
counterparty_company_id: counterpartyId,
occurred_at: String(data.get("transactionDate") || ""),
direction: String(data.get("direction")) === "收款" ? "incoming" : "outgoing",
amount: String(data.get("amount") || ""),
currency: "CNY",
funding_source: funding === "approved_bank_account" ? "other" : funding,
requested_subject: SUBJECT_LABEL_CODE[String(data.get("subject") || "")] || "other_receivable",
request_key: `manual-${Date.now()}`,
summary: String(data.get("summary") || "").trim(),
reason: String(data.get("remark") || "").trim(),
}),
}).catch(() => null);
const result = await response?.json().catch(() => ({}));
if (!response?.ok) {
if (!toastIfLocked(result)) showToast("提交失败", result?.message || "请稍后重试", "danger");
return;
}
form.reset();
await loadCompanyManualRecords();
showToast("手工记录已提交", "管理员复核前不会纳入公司间往来计算", "success");
});
let manualWithdrawRow = null;
$("#manualRecordRows")?.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-action='withdraw']");
if (!btn) return;
const tr = btn.closest("tr");
manualWithdrawRow = tr;
const cells = tr.cells;
$("#wd-date").textContent = cells[0]?.textContent.trim() || "—";
$("#wd-peer").textContent = cells[2]?.textContent.trim() || "—";
$("#wd-amount").textContent = cells[4]?.textContent.trim() || "—";
$("#wd-summary").textContent = cells[5]?.textContent.trim() || "—";
openModal("withdraw-modal");
});
$("#wd-confirm")?.addEventListener("click", () => {
closeModal("withdraw-modal");
showToast("已提交记录不可在公司端撤回", "请联系管理员退回后重新登记", "warn");
});
// ── 转账往来(方案 A)──
initTransfers();
// ── 往来确认 + 工作台权威待办 ──
initReconcile();
(async () => {
await loadCompanyWorkspace();
await loadCompanyCoverageGaps();
await renderReconcileMatchStack();
})();
// ── 通知 ──
initNotifications();
// ── 银行账户 ──
$("#openAccountDialog")?.addEventListener("click", () => openModal("accountDialog"));
$("#account-tbody")?.addEventListener("click", (event) => {
const btn = event.target.closest("[data-account-view]");
if (!btn) return;
const tr = btn.closest("tr");
const account = (state.accounts || []).find((a) => String(a.id) === String(tr?.dataset.accountId));
if (!account) return;
openAccountDetail(account);
});
$("#accountForm")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
const response = await fetch("/api/company/accounts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bank_name: String(data.get("bank") || "").trim(),
account_type: String(data.get("type") || ""),
account_number: String(data.get("accountNumber") || ""),
start_date: String(data.get("startDate") || ""),
}),
}).catch(() => null);
if (response?.status === 401 || response?.status === 403) {
window.location.href = "index.html";
return;
}
const result = await response?.json().catch(() => ({}));
if (!response || !response.ok) {
showToast("账户登记失败", result?.message || "请稍后重试", "danger");
return;
}
closeModal("accountDialog");
form.reset();
await loadCompanyAccounts();
showToast("银行账户已提交登记", "复核通过前不能上传流水,也不参与账户识别和覆盖计算", "success");
});
}
if (portal === "entry") {
initEntry();
} else {
initAuthGuard().then((allowed) => {
if (!allowed) return;
initShell();
initFlowTools();
if (portal === "admin") {
initPairQueries();
initAdmin();
} else {
initCompany();
}
});
}