1209 lines
37 KiB
HTML
1209 lines
37 KiB
HTML
<!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">
|
||
対応形式: 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 (_) {}
|
||
}
|
||
|
||
// ── 明細データ読み込み ──────────────────────────────────
|
||
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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|