修正
This commit is contained in:
768
frontend/bank-statement.html
Normal file
768
frontend/bank-statement.html
Normal file
@@ -0,0 +1,768 @@
|
||||
<!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; }
|
||||
|
||||
/* ── 印刷スタイル ── */
|
||||
@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="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="window.print()">印刷</button>
|
||||
<a class="btn btn-gray" href="index.html">← メニューへ戻る</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="msgBar" class="msg-bar no-print"></div>
|
||||
|
||||
<!-- 検索フォーム -->
|
||||
<div class="search-form no-print">
|
||||
<label>年:</label>
|
||||
<input type="number" id="filterYear" min="2000" max="2099" style="width:80px" />
|
||||
<label>月:</label>
|
||||
<select id="filterMonth">
|
||||
<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>
|
||||
<label>銀行:</label>
|
||||
<select id="filterBank">
|
||||
<option value="">全銀行</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" onclick="loadData()">検索</button>
|
||||
</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">対応形式: CSV(UTF-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 (_) {}
|
||||
}
|
||||
|
||||
// ── 明細データ読み込み ──────────────────────────────────
|
||||
async function loadData() {
|
||||
const year = document.getElementById('filterYear').value;
|
||||
const month = document.getElementById('filterMonth').value;
|
||||
const bank = document.getElementById('filterBank').value;
|
||||
|
||||
hideMsg('msgBar');
|
||||
|
||||
const qp = [];
|
||||
if (year) qp.push(`year=${year}`);
|
||||
if (month) qp.push(`month=${month}`);
|
||||
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, year, month);
|
||||
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, selYear, selMonth) {
|
||||
const wrap = document.getElementById('monthlyChips');
|
||||
wrap.innerHTML = '';
|
||||
if (!monthly.length) return;
|
||||
|
||||
// 「全月」チップ
|
||||
const allChip = document.createElement('span');
|
||||
allChip.className = 'month-chip no-print' + (!selMonth ? ' active' : '');
|
||||
allChip.textContent = '全月';
|
||||
allChip.onclick = () => {
|
||||
document.getElementById('filterMonth').value = '';
|
||||
loadData();
|
||||
};
|
||||
wrap.appendChild(allChip);
|
||||
|
||||
monthly.forEach(m => {
|
||||
const [y, mo] = m.ym.split('-');
|
||||
const chip = document.createElement('span');
|
||||
const isActive = (String(selYear) === y && String(selMonth) === String(parseInt(mo)));
|
||||
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('filterYear').value = y;
|
||||
document.getElementById('filterMonth').value = parseInt(mo);
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user