Files
njts-accounting-core/frontend/bank-statement.html
2026-06-07 21:58:20 +09:00

1209 lines
37 KiB
HTML
Raw Permalink 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.
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="js/auth.js"></script>
<title>銀行明細照会</title>
<link rel="stylesheet" href="css/style.css" />
<link rel="stylesheet" href="/css/mobile.css" />
<style>
body {
font-family: "MS Gothic", "Meiryo", sans-serif;
margin: 24px auto;
font-size: 14px;
max-width: 1200px;
padding: 0 16px;
}
h2 {
margin: 0 0 16px;
font-size: 20px;
}
/* ── ヘッダー ── */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 10px;
}
.page-header h2 {
margin: 0;
}
.header-btns {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* ── 検索フォーム ── */
.search-form {
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 14px 18px;
margin-bottom: 16px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.search-form label {
font-weight: bold;
white-space: nowrap;
}
.search-form select,
.search-form input[type="number"] {
padding: 5px 10px;
border: 1px solid #999;
border-radius: 3px;
font-size: 14px;
font-family: inherit;
}
/* ── ボタン共通 ── */
.btn {
padding: 6px 16px;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
font-family: inherit;
white-space: nowrap;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #4caf50;
color: #fff;
}
.btn-primary:hover {
background: #43a047;
}
.btn-blue {
background: #2196f3;
color: #fff;
}
.btn-blue:hover {
background: #1976d2;
}
.btn-orange {
background: #ff9800;
color: #fff;
}
.btn-orange:hover {
background: #f57c00;
}
.btn-danger {
background: #e53935;
color: #fff;
}
.btn-danger:hover {
background: #c62828;
}
.btn-gray {
background: #9e9e9e;
color: #fff;
}
.btn-gray:hover {
background: #757575;
}
/* ── 月別サマリー ── */
.monthly-summary {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.month-chip {
padding: 5px 12px;
border: 1px solid #999;
border-radius: 16px;
background: #fff;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
}
.month-chip:hover {
background: #e3f2fd;
border-color: #2196f3;
}
.month-chip.active {
background: #2196f3;
color: #fff;
border-color: #1976d2;
}
/* ── 統計バー ── */
.stats-bar {
display: flex;
gap: 20px;
margin-bottom: 12px;
font-size: 13px;
flex-wrap: wrap;
}
.stat-item {
display: flex;
align-items: center;
gap: 6px;
}
.stat-label {
color: #666;
}
.stat-value {
font-weight: bold;
}
.stat-value.debit {
color: #e53935;
}
.stat-value.credit {
color: #1565c0;
}
/* ── テーブル ── */
table {
border-collapse: collapse;
width: 100%;
font-size: 13px;
}
th,
td {
border: 1px solid #bbb;
padding: 6px 10px;
}
th {
background: #e8e8e8;
font-weight: bold;
text-align: center;
white-space: nowrap;
}
td {
background: #fff;
}
tr:nth-child(even) td {
background: #fafafa;
}
tr:hover td {
background: #e3f2fd;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
td.debit-cell {
color: #c62828;
}
td.credit-cell {
color: #1565c0;
}
.no-data {
text-align: center;
color: #999;
padding: 30px;
font-size: 15px;
}
/* ── モーダル ── */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: flex-start;
padding: 30px 16px;
overflow-y: auto;
}
.modal-overlay.active {
display: flex;
}
.modal-box {
background: #fff;
border-radius: 8px;
padding: 28px;
width: 100%;
max-width: 920px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.modal-box h3 {
margin: 0 0 18px;
font-size: 17px;
}
.modal-form-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.modal-form-row label {
font-weight: bold;
white-space: nowrap;
}
.modal-form-row input[type="text"] {
flex: 1;
min-width: 160px;
padding: 7px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
}
/* ドラッグ&ドロップエリア */
.drop-zone {
border: 2px dashed #999;
border-radius: 6px;
padding: 30px;
text-align: center;
color: #666;
cursor: pointer;
margin-bottom: 16px;
transition:
border-color 0.2s,
background 0.2s;
}
.drop-zone:hover,
.drop-zone.dragover {
border-color: #2196f3;
background: #e3f2fd;
color: #1565c0;
}
.drop-zone input[type="file"] {
display: none;
}
.drop-zone .drop-icon {
font-size: 36px;
display: block;
margin-bottom: 8px;
}
/* プレビュー結果 */
.preview-summary {
display: flex;
gap: 20px;
margin-bottom: 12px;
padding: 10px 16px;
background: #f5f5f5;
border-radius: 4px;
flex-wrap: wrap;
}
.preview-summary .ps-item {
font-size: 14px;
}
.ps-new {
color: #2e7d32;
font-weight: bold;
}
.ps-dup {
color: #e65100;
}
.ps-total {
font-weight: bold;
}
.preview-table-wrap {
max-height: 360px;
overflow-y: auto;
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 16px;
}
.preview-table-wrap table {
font-size: 12px;
}
.preview-table-wrap th {
position: sticky;
top: 0;
z-index: 1;
}
tr.row-new td {
background: #e8f5e9 !important;
}
tr.row-dup td {
background: #fff3e0 !important;
color: #999;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: bold;
}
.badge-new {
background: #c8e6c9;
color: #1b5e20;
}
.badge-dup {
background: #ffe0b2;
color: #bf360c;
}
.modal-btn-row {
display: flex;
gap: 10px;
justify-content: flex-end;
flex-wrap: wrap;
}
/* ── メッセージ ── */
.msg-bar {
padding: 10px 16px;
border-radius: 4px;
margin-bottom: 14px;
display: none;
font-size: 14px;
}
.msg-bar.info {
background: #e3f2fd;
color: #0d47a1;
border: 1px solid #90caf9;
}
.msg-bar.success {
background: #e8f5e9;
color: #1b5e20;
border: 1px solid #a5d6a7;
}
.msg-bar.error {
background: #ffebee;
color: #b71c1c;
border: 1px solid #ef9a9a;
}
/* ── ページネーション ── */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
padding: 8px 0;
}
.page-info {
font-size: 13px;
color: #333;
}
.btn-page {
background: #f0f0f0;
color: #333;
border: 1px solid #bbb;
}
.btn-page:hover:not(:disabled) {
background: #e0e0e0;
}
.btn-page:disabled {
opacity: 0.4;
cursor: default;
}
/* ── 印刷ヘッダー ── */
.print-header {
display: none;
}
@media print {
.print-header {
display: block;
margin-bottom: 12px;
border-bottom: 2px solid #000;
padding-bottom: 8px;
}
.print-header .ph-company {
font-size: 16px;
font-weight: bold;
margin-bottom: 4px;
}
.print-header .ph-sub {
font-size: 12px;
display: flex;
gap: 24px;
}
}
/* ── 印刷スタイル ── */
@media print {
.no-print {
display: none !important;
}
body {
margin: 0;
font-size: 12px;
}
table {
font-size: 11px;
}
th,
td {
padding: 4px 6px;
}
.monthly-summary,
.stats-bar {
margin-bottom: 6px;
}
.month-chip {
border: 1px solid #999 !important;
color: #000 !important;
background: #fff !important;
}
}
</style>
</head>
<body>
<!-- 印刷時のみ表示されるヘッダー -->
<div class="print-header">
<div class="ph-company" id="printCompany"></div>
<div class="ph-sub">
<span id="printBank"></span>
<span id="printDate"></span>
</div>
</div>
<div class="page-header no-print">
<h2>🏦 銀行明細照会</h2>
<div class="header-btns">
<button class="btn btn-orange" onclick="openImportModal()">
CSV取込
</button>
<button class="btn btn-blue" onclick="doPrint()">印刷</button>
<a class="btn btn-gray" href="index.html">← メニューへ戻る</a>
</div>
</div>
<!-- 印刷設定(画面のみ) -->
<div
class="search-form no-print"
style="padding: 10px 18px; background: #fff8e1; border-color: #ffe082"
>
<label style="font-weight: bold; white-space: nowrap">印刷設定:</label>
<label style="white-space: nowrap">会社名:</label>
<input
type="text"
id="printCompanyInput"
style="
width: 220px;
padding: 4px 8px;
border: 1px solid #999;
border-radius: 3px;
font-size: 13px;
"
placeholder="例:株式会社〇〇"
/>
</div>
<div id="msgBar" class="msg-bar no-print"></div>
<!-- 検索フォーム -->
<div
class="search-form no-print"
style="flex-direction: column; align-items: flex-start; gap: 10px"
>
<div
style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px"
>
<label style="min-width: 3em">開始:</label>
<input
type="number"
id="startYear"
min="2000"
max="2099"
style="width: 72px"
placeholder="年"
/><span></span>
<select id="startMonth" style="width: 60px">
<option value=""></option>
<option value="1">1月</option>
<option value="2">2月</option>
<option value="3">3月</option>
<option value="4">4月</option>
<option value="5">5月</option>
<option value="6">6月</option>
<option value="7">7月</option>
<option value="8">8月</option>
<option value="9">9月</option>
<option value="10">10月</option>
<option value="11">11月</option>
<option value="12">12月</option>
</select>
<input
type="number"
id="startDay"
min="1"
max="31"
style="width: 48px"
placeholder="日"
/><span></span>
<span style="margin: 0 6px; color: #999"></span>
<label style="min-width: 3em">終了:</label>
<input
type="number"
id="endYear"
min="2000"
max="2099"
style="width: 72px"
placeholder="年"
/><span></span>
<select id="endMonth" style="width: 60px">
<option value=""></option>
<option value="1">1月</option>
<option value="2">2月</option>
<option value="3">3月</option>
<option value="4">4月</option>
<option value="5">5月</option>
<option value="6">6月</option>
<option value="7">7月</option>
<option value="8">8月</option>
<option value="9">9月</option>
<option value="10">10月</option>
<option value="11">11月</option>
<option value="12">12月</option>
</select>
<input
type="number"
id="endDay"
min="1"
max="31"
style="width: 48px"
placeholder="日"
/><span></span>
</div>
<div
style="display: flex; align-items: center; flex-wrap: wrap; gap: 8px"
>
<label>銀行:</label>
<select id="filterBank">
<option value="">全銀行</option>
</select>
<button class="btn btn-primary" onclick="loadData()">検索</button>
<button class="btn btn-gray" onclick="clearDateFilter()">クリア</button>
</div>
</div>
<!-- 月別チップ -->
<div class="monthly-summary" id="monthlyChips"></div>
<!-- 統計バー -->
<div class="stats-bar" id="statsBar"></div>
<!-- 明細テーブル -->
<div id="tableWrap">
<div class="no-data">データを読み込み中…</div>
</div>
<div id="paginationBar" class="pagination no-print"></div>
<!-- ========== CSV取込モーダル ========== -->
<div class="modal-overlay no-print" id="importModal">
<div class="modal-box">
<h3>📂 銀行明細 CSV 取込</h3>
<!-- ステップ1: ファイル選択 -->
<div id="step1">
<div class="modal-form-row">
<label>銀行名:</label>
<input
type="text"
id="bankNameInput"
placeholder="例三菱UFJ銀行 本店普通口座"
/>
</div>
<div
class="drop-zone"
id="dropZone"
onclick="document.getElementById('csvFile').click()"
>
<span class="drop-icon">📄</span>
<div>CSVファイルをドラッグドロップ、またはクリックして選択</div>
<div style="font-size: 12px; color: #999; margin-top: 6px">
対応形式: CSVUTF-8 / Shift-JIS
</div>
<input
type="file"
id="csvFile"
accept=".csv,.txt"
onchange="onFileSelected(this)"
/>
</div>
<div id="step1msg" class="msg-bar"></div>
<div class="modal-btn-row">
<button class="btn btn-gray" onclick="closeImportModal()">
キャンセル
</button>
</div>
</div>
<!-- ステップ2: プレビュー -->
<div id="step2" style="display: none">
<div class="preview-summary" id="previewSummary"></div>
<div class="preview-table-wrap">
<table id="previewTable">
<thead>
<tr>
<th style="width: 36px">状態</th>
<th>日付</th>
<th>摘要</th>
<th>出金</th>
<th>入金</th>
<th>残高</th>
<th>メモ</th>
</tr>
</thead>
<tbody id="previewBody"></tbody>
</table>
</div>
<div id="step2msg" class="msg-bar"></div>
<div class="modal-btn-row">
<button class="btn btn-gray" onclick="backToStep1()">← 戻る</button>
<button
class="btn btn-primary"
id="importConfirmBtn"
onclick="confirmImport()"
>
新規データを取込む
</button>
</div>
</div>
<!-- ステップ3: 完了 -->
<div id="step3" style="display: none">
<div
id="step3msg"
class="msg-bar success"
style="display: block"
></div>
<div class="modal-btn-row" style="margin-top: 16px">
<button
class="btn btn-primary"
onclick="
closeImportModal();
loadData();
"
>
明細を表示する
</button>
<button class="btn btn-orange" onclick="backToStep1()">
続けて取込む
</button>
</div>
</div>
</div>
</div>
<script>
// ── 初期化 ──────────────────────────────────────────────
let _previewRows = [];
let _bankName = "";
let _allRows = [];
let _currentPage = 1;
const PAGE_SIZE = 100;
document.addEventListener("DOMContentLoaded", () => {
loadBanks();
loadData();
initDropZone();
});
// ── メッセージ表示 ──────────────────────────────────────
function showMsg(id, text, type) {
const el = document.getElementById(id);
el.textContent = text;
el.className = "msg-bar " + type;
el.style.display = "block";
}
function hideMsg(id) {
const el = document.getElementById(id);
el.style.display = "none";
}
// ── 銀行名リスト読み込み ────────────────────────────────
async function loadBanks() {
try {
const res = await fetch("/bank-statements?limit=1");
if (!res.ok) return;
const data = await res.json();
const sel = document.getElementById("filterBank");
const cur = sel.value;
// 既存オプションを保持しつつ追加
const existing = Array.from(sel.options).map((o) => o.value);
data.banks.forEach((b) => {
if (!existing.includes(b)) {
const opt = document.createElement("option");
opt.value = b;
opt.textContent = b;
sel.appendChild(opt);
}
});
if (cur) sel.value = cur;
} catch (_) {}
}
// ── 明細データ読み込み ──────────────────────────────────
function _lastDayOfMonth(year, month) {
return new Date(parseInt(year), parseInt(month), 0).getDate();
}
// 会社名をlocalStorageに保存・復元
const COMPANY_KEY = "bankPrintCompanyName";
document.addEventListener("DOMContentLoaded", function () {
const saved = localStorage.getItem(COMPANY_KEY) || "";
const input = document.getElementById("printCompanyInput");
if (input && saved) input.value = saved;
input &&
input.addEventListener("input", function () {
localStorage.setItem(COMPANY_KEY, this.value);
});
});
function doPrint() {
const company = (
document.getElementById("printCompanyInput").value || ""
).trim();
const bankVal = document.getElementById("filterBank").value;
// 期間ラベル生成
const sy = document.getElementById("startYear").value;
const sm = document.getElementById("startMonth").value;
const sd = document.getElementById("startDay").value;
const ey = document.getElementById("endYear").value;
const em = document.getElementById("endMonth").value;
const ed = document.getElementById("endDay").value;
let periodLabel = "";
if (sy || ey) {
const from = sy
? sy +
(sm ? "/" + String(sm).padStart(2, "0") : "") +
(sd ? "/" + String(sd).padStart(2, "0") : "")
: "";
const to = ey
? ey +
(em ? "/" + String(em).padStart(2, "0") : "") +
(ed ? "/" + String(ed).padStart(2, "0") : "")
: "";
if (from && to) periodLabel = " (" + from + " 〜 " + to + ")";
else if (from) periodLabel = " (" + from + " 〜)";
else if (to) periodLabel = " (〜 " + to + ")";
}
// 印刷ヘッダーを設定
document.getElementById("printCompany").textContent = company;
document.getElementById("printBank").textContent = bankVal
? "銀行:" + bankVal + periodLabel
: "期間:全期間" + periodLabel || "全期間";
const today = new Date();
const dateStr =
today.getFullYear() +
"年" +
(today.getMonth() + 1) +
"月" +
today.getDate() +
"日";
document.getElementById("printDate").textContent = "印刷日:" + dateStr;
window.print();
}
function clearDateFilter() {
[
"startYear",
"startMonth",
"startDay",
"endYear",
"endMonth",
"endDay",
].forEach((id) => {
const el = document.getElementById(id);
if (el) el.value = "";
});
loadData();
}
async function loadData() {
const sy = document.getElementById("startYear").value.trim();
const sm = document.getElementById("startMonth").value;
const sd = document.getElementById("startDay").value.trim();
const ey = document.getElementById("endYear").value.trim();
const em = document.getElementById("endMonth").value;
const ed = document.getElementById("endDay").value.trim();
const bank = document.getElementById("filterBank").value;
hideMsg("msgBar");
const qp = [];
if (sy) {
const m = sm ? String(sm).padStart(2, "0") : "01";
const d = sd ? String(sd).padStart(2, "0") : "01";
qp.push(`date_from=${sy}-${m}-${d}`);
}
if (ey) {
const m = em ? String(em).padStart(2, "0") : "12";
let d;
if (ed) {
d = String(ed).padStart(2, "0");
} else if (em) {
d = String(_lastDayOfMonth(ey, em)).padStart(2, "0");
} else {
d = "31";
}
qp.push(`date_to=${ey}-${m}-${d}`);
}
if (bank) qp.push(`bank_name=${encodeURIComponent(bank)}`);
const url = "/bank-statements" + (qp.length ? "?" + qp.join("&") : "");
try {
const res = await fetch(url);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
_allRows = data.rows;
_currentPage = 1;
renderMonthlyChips(data.monthly, { sy, sm, sd, ey, em, ed });
renderStats();
renderTable();
updateBankFilter(data.banks);
} catch (e) {
showMsg("msgBar", "データ取得エラー: " + e.message, "error");
}
}
function updateBankFilter(banks) {
const sel = document.getElementById("filterBank");
const cur = sel.value;
// 全銀行以外のオプションをクリアして再構築
while (sel.options.length > 1) sel.remove(1);
banks.forEach((b) => {
const opt = document.createElement("option");
opt.value = b;
opt.textContent = b;
sel.appendChild(opt);
});
if (cur) sel.value = cur;
}
// ── 月別チップ ──────────────────────────────────────────
function renderMonthlyChips(monthly, filter) {
const { sy, sm, sd, ey, em, ed } = filter || {};
const wrap = document.getElementById("monthlyChips");
wrap.innerHTML = "";
if (!monthly.length) return;
// 「全月」チップ:開始・終了が同じ年で月未指定の場合にアクティブ
const noMonthSelected = !sm && !em && !sd && !ed;
const allChip = document.createElement("span");
allChip.className =
"month-chip no-print" + (noMonthSelected ? " active" : "");
allChip.textContent = "全月";
allChip.onclick = () => {
document.getElementById("startMonth").value = "";
document.getElementById("startDay").value = "";
document.getElementById("endMonth").value = "";
document.getElementById("endDay").value = "";
loadData();
};
wrap.appendChild(allChip);
monthly.forEach((m) => {
const [y, mo] = m.ym.split("-");
const chip = document.createElement("span");
const isActive =
sy === y &&
ey === y &&
String(parseInt(sm || "0")) === String(parseInt(mo)) &&
String(parseInt(em || "0")) === String(parseInt(mo)) &&
!sd &&
!ed;
chip.className = "month-chip no-print" + (isActive ? " active" : "");
chip.innerHTML = `${parseInt(mo)}月 <small style="color:inherit;opacity:.8">${m.count}件</small>`;
chip.title = `出金 ${fmt(m.total_debit)} / 入金 ${fmt(m.total_credit)}`;
chip.onclick = () => {
document.getElementById("startYear").value = y;
document.getElementById("startMonth").value = parseInt(mo);
document.getElementById("startDay").value = "";
document.getElementById("endYear").value = y;
document.getElementById("endMonth").value = parseInt(mo);
document.getElementById("endDay").value = "";
loadData();
};
wrap.appendChild(chip);
});
}
// ── 統計バー ────────────────────────────────────────────
function renderStats() {
const bar = document.getElementById("statsBar");
if (!_allRows.length) {
bar.innerHTML = "";
return;
}
const totalDebit = _allRows.reduce((s, r) => s + (r.debit || 0), 0);
const totalCredit = _allRows.reduce((s, r) => s + (r.credit || 0), 0);
bar.innerHTML = `
<div class="stat-item">
<span class="stat-label">総件数:</span>
<span class="stat-value">${_allRows.length.toLocaleString()} 件</span>
</div>
<div class="stat-item">
<span class="stat-label">出金合計:</span>
<span class="stat-value debit">${fmt(totalDebit)}</span>
</div>
<div class="stat-item">
<span class="stat-label">入金合計:</span>
<span class="stat-value credit">${fmt(totalCredit)}</span>
</div>
<div class="stat-item">
<span class="stat-label">差引:</span>
<span class="stat-value">${fmt(totalCredit - totalDebit)}</span>
</div>
`;
}
// ── 明細テーブル ─────────────────────────────────────────
function renderTable() {
const wrap = document.getElementById("tableWrap");
if (!_allRows.length) {
wrap.innerHTML =
'<div class="no-data">該当する明細データがありません</div>';
renderPagination(0);
return;
}
const start = (_currentPage - 1) * PAGE_SIZE;
const pageRows = _allRows.slice(start, start + PAGE_SIZE);
let html = `
<table>
<thead>
<tr>
<th class="text-center">日付</th>
<th class="text-left">摘要</th>
<th class="text-right">出金</th>
<th class="text-right">入金</th>
<th class="text-right">残高</th>
<th class="text-left">メモ</th>
<th class="text-center no-print">銀行</th>
</tr>
</thead>
<tbody>
`;
pageRows.forEach((r) => {
html += `<tr>
<td class="text-center">${r.tx_date}</td>
<td class="text-left">${esc(r.description)}</td>
<td class="text-right debit-cell">${r.debit ? fmt(r.debit) : ""}</td>
<td class="text-right credit-cell">${r.credit ? fmt(r.credit) : ""}</td>
<td class="text-right">${r.balance != null ? fmt(r.balance) : ""}</td>
<td class="text-left">${esc(r.memo || "")}</td>
<td class="text-center no-print">${esc(r.bank_name || "")}</td>
</tr>`;
});
html += "</tbody></table>";
wrap.innerHTML = html;
renderPagination(_allRows.length);
}
function renderPagination(total) {
const bar = document.getElementById("paginationBar");
if (!bar) return;
const totalPages = Math.ceil(total / PAGE_SIZE);
if (totalPages <= 1) {
bar.innerHTML = "";
return;
}
const start = (_currentPage - 1) * PAGE_SIZE + 1;
const end = Math.min(_currentPage * PAGE_SIZE, total);
bar.innerHTML = `
<button class="btn btn-page" onclick="changePage(${_currentPage - 1})" ${_currentPage <= 1 ? "disabled" : ""}>◀ 前へ</button>
<span class="page-info">${_currentPage} / ${totalPages} ページ(${start}${end}件 / 全${total}件)</span>
<button class="btn btn-page" onclick="changePage(${_currentPage + 1})" ${_currentPage >= totalPages ? "disabled" : ""}>次へ ▶</button>
`;
}
function changePage(page) {
const totalPages = Math.ceil(_allRows.length / PAGE_SIZE);
if (page < 1 || page > totalPages) return;
_currentPage = page;
renderTable();
window.scrollTo(0, 0);
}
// ── CSV取込モーダル ──────────────────────────────────────
function openImportModal() {
backToStep1();
document.getElementById("importModal").classList.add("active");
}
function closeImportModal() {
document.getElementById("importModal").classList.remove("active");
}
function backToStep1() {
document.getElementById("step1").style.display = "";
document.getElementById("step2").style.display = "none";
document.getElementById("step3").style.display = "none";
document.getElementById("csvFile").value = "";
hideMsg("step1msg");
hideMsg("step2msg");
_previewRows = [];
}
// ドラッグ&ドロップ初期化
function initDropZone() {
const zone = document.getElementById("dropZone");
zone.addEventListener("dragover", (e) => {
e.preventDefault();
zone.classList.add("dragover");
});
zone.addEventListener("dragleave", () =>
zone.classList.remove("dragover"),
);
zone.addEventListener("drop", (e) => {
e.preventDefault();
zone.classList.remove("dragover");
const f = e.dataTransfer.files[0];
if (f) uploadAndPreview(f);
});
}
function onFileSelected(input) {
if (input.files[0]) uploadAndPreview(input.files[0]);
}
async function uploadAndPreview(file) {
if (!file.name.match(/\.(csv|txt)$/i)) {
showMsg(
"step1msg",
"CSVファイルを選択してください.csv または .txt",
"error",
);
return;
}
showMsg("step1msg", "解析中...", "info");
const fd = new FormData();
fd.append("file", file);
fd.append("bank_name", document.getElementById("bankNameInput").value);
try {
const res = await fetch("/bank-statements/preview", {
method: "POST",
body: fd,
});
const data = await res.json();
if (!res.ok) {
showMsg("step1msg", data.detail || "エラーが発生しました", "error");
return;
}
hideMsg("step1msg");
_previewRows = data.rows;
// 銀行名: 入力欄が空でAPIがヒントを返した場合は自動セット
if (
data.bank_name &&
!document.getElementById("bankNameInput").value.trim()
) {
document.getElementById("bankNameInput").value = data.bank_name;
}
_bankName = document.getElementById("bankNameInput").value;
renderPreview(data);
document.getElementById("step1").style.display = "none";
document.getElementById("step2").style.display = "";
} catch (e) {
showMsg("step1msg", "アップロードエラー: " + e.message, "error");
}
}
function renderPreview(data) {
// サマリー
document.getElementById("previewSummary").innerHTML = `
<div class="ps-item ps-total">合計: ${data.total} 件</div>
<div class="ps-item ps-new">🆕 新規: ${data.new} 件(取込対象)</div>
<div class="ps-item ps-dup">⚠️ 重複: ${data.duplicates} 件(スキップ)</div>
`;
// 取込ボタンの状態
const btn = document.getElementById("importConfirmBtn");
if (data.new === 0) {
btn.disabled = true;
btn.textContent = "新規データなし(取込不要)";
btn.style.opacity = "0.5";
} else {
btn.disabled = false;
btn.textContent = `新規 ${data.new} 件を取込む`;
btn.style.opacity = "1";
}
// プレビューテーブル
const tbody = document.getElementById("previewBody");
tbody.innerHTML = "";
data.rows.forEach((r) => {
const tr = document.createElement("tr");
tr.className = r.status === "new" ? "row-new" : "row-dup";
tr.innerHTML = `
<td class="text-center">
<span class="badge badge-${r.status === "new" ? "new" : "dup"}">
${r.status === "new" ? "新規" : "重複"}
</span>
</td>
<td>${r.tx_date}</td>
<td>${esc(r.description)}</td>
<td class="text-right">${r.debit ? fmt(r.debit) : ""}</td>
<td class="text-right">${r.credit ? fmt(r.credit) : ""}</td>
<td class="text-right">${r.balance != null ? fmt(r.balance) : ""}</td>
<td>${esc(r.memo || "")}</td>
`;
tbody.appendChild(tr);
});
}
async function confirmImport() {
const newRows = _previewRows.filter((r) => r.status === "new");
if (!newRows.length) return;
const btn = document.getElementById("importConfirmBtn");
btn.disabled = true;
btn.textContent = "取込中...";
hideMsg("step2msg");
try {
const res = await fetch("/bank-statements/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bank_name: _bankName, rows: newRows }),
});
const data = await res.json();
if (!res.ok) {
showMsg("step2msg", data.detail || "取込エラー", "error");
btn.disabled = false;
btn.textContent = `新規 ${newRows.length} 件を取込む`;
return;
}
// 完了画面
document.getElementById("step2").style.display = "none";
document.getElementById("step3").style.display = "";
document.getElementById("step3msg").textContent =
`✅ 取込完了:${data.inserted} 件を登録しました` +
(data.skipped ? `${data.skipped} 件スキップ)` : "");
loadBanks();
} catch (e) {
showMsg("step2msg", "通信エラー: " + e.message, "error");
btn.disabled = false;
btn.textContent = `新規 ${newRows.length} 件を取込む`;
}
}
// ── ユーティリティ ──────────────────────────────────────
function fmt(v) {
if (v == null || v === "") return "";
return Number(v).toLocaleString("ja-JP");
}
function esc(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
</script>
</body>
</html>