Scan to open on your phone
Point a camera at the QR code - it opens this exact paste, no app needed.
https://codepastes.com/zzgsykgtf3gzae57無題Plain Text
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>支出管理</title>
<style>
:root {
--main-color: #4f6f52;
--main-color-light: #e9f0e9;
--card-color: rgba(255,255,255,var(--card-opacity,.94));
--text-color: #222;
--sub-text: #666;
--border-color: #d8ddd8;
--danger: #d93025;
--heading-size: 20px;
--year-size: 15px;
--month-size: 15px;
--add-size: 15px;
--setting-size: 15px;
--description-size: 16px;
--shadow:
0 4px 18px rgba(0,0,0,.08);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
min-height: 100%;
font-family:
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"Hiragino Kaku Gothic ProN",
"Yu Gothic",
Meiryo,
sans-serif;
color: var(--text-color);
background: transparent;
}
body {
min-height: 100vh;
background-size: cover;
background-position: center;
background-attachment: fixed;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background-image: var(--background-image, none);
background-size: cover;
background-position: center;
background-attachment: fixed;
opacity: 1;
}
body.dark {
--card-color: rgba(32,35,32,var(--card-opacity,.95));
--text-color: #eee;
--sub-text: #aaa;
--border-color: #454a45;
--main-color-light: #29372b;
}
button,
input,
textarea,
select {
font: inherit;
}
button {
cursor: pointer;
}
.hidden {
display: none !important;
}
/* =========================================================
起動画面
========================================================= */
#startupScreen {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 30px;
position: relative;
z-index: 1;
overflow: hidden;
background:
radial-gradient(
circle at 12% 18%,
#a9e6bd 0%,
transparent 40%
),
radial-gradient(
circle at 88% 78%,
#aec9f2 0%,
transparent 42%
),
radial-gradient(
circle at 55% 105%,
#f5d99a 0%,
transparent 44%
),
linear-gradient(
135deg,
#e1f4e6 0%,
#e3ebfa 48%,
#f8eedb 100%
);
}
#startupScreen::before {
content: "";
position: absolute;
width: 500px;
height: 500px;
top: -230px;
right: -150px;
border-radius: 50%;
background:
radial-gradient(
circle,
rgba(255,255,255,.55) 0%,
rgba(255,255,255,.18) 45%,
transparent 70%
);
pointer-events: none;
}
#startupScreen::after {
content: "";
position: absolute;
width: 420px;
height: 420px;
bottom: -220px;
left: -140px;
border-radius: 50%;
background:
radial-gradient(
circle,
rgba(255,255,255,.40) 0%,
rgba(255,255,255,.10) 45%,
transparent 72%
);
pointer-events: none;
}
.startup-card {
width: min(560px, 100%);
background: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 40px;
box-shadow: var(--shadow);
text-align: center;
position: relative;
z-index: 2;
}
.startup-title {
font-size: 32px;
font-weight: 700;
margin-bottom: 12px;
}
.startup-description {
color: var(--sub-text);
line-height: 1.8;
margin-bottom: 30px;
}
.folder-status {
background: var(--main-color-light);
border-radius: 12px;
padding: 15px;
margin-bottom: 20px;
font-size: 14px;
text-align: left;
}
/* =========================================================
ボタン
========================================================= */
.primary-btn,
.secondary-btn,
.danger-btn {
border: none;
border-radius: 9px;
padding: 10px 18px;
font-weight: 600;
}
.primary-btn {
background: var(--main-color);
color: white;
}
.secondary-btn {
background: var(--main-color-light);
color: var(--text-color);
}
.danger-btn {
background: var(--danger);
color: white;
}
.primary-btn:hover,
.secondary-btn:hover,
.danger-btn:hover {
opacity: .88;
}
/* =========================================================
上部バー
========================================================= */
.topbar {
position: sticky;
top: 0;
z-index: 100;
min-height: 64px;
display: flex;
align-items: center;
gap: 4px;
padding: 0 18px;
background: var(--card-color);
border-bottom: 1px solid var(--border-color);
backdrop-filter: blur(12px);
}
.app-title {
font-size: var(--heading-size);
font-weight: 700;
margin-right: 15px;
white-space: nowrap;
}
.nav-btn {
position: relative;
border: none;
background: transparent;
color: var(--text-color);
padding: 10px 13px;
border-radius: 8px;
transition:
background .15s,
color .15s;
}
/* ホバー */
.nav-btn:hover {
background: var(--main-color-light);
}
/* 選択中 */
.nav-btn.active {
background: var(--main-color);
color: white;
font-weight: 700;
}
/*
選択中であることをさらに分かりやすくする
*/
.nav-btn.active::after {
content: "";
position: absolute;
left: 10px;
right: 10px;
bottom: -5px;
height: 3px;
background: var(--main-color);
border-radius: 10px;
}
.nav-spacer {
flex: 1;
}
.folder-indicator {
font-size: 12px;
color: var(--sub-text);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 5px;
}
/* =========================================================
本体
========================================================= */
#app {
min-height: 100vh;
position: relative;
z-index: 1;
}
.main {
max-width: 1500px;
margin: 0 auto;
padding: 28px;
}
.page-title {
font-size: 27px;
font-weight: 700;
margin: 0 0 22px;
}
.page-description {
color: var(--sub-text);
margin-bottom: 20px;
line-height: 1.8;
}
/* =========================================================
年別
========================================================= */
.year-table-wrapper {
width: 100%;
height: calc(100vh - 200px);
overflow: auto;
background: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 14px;
box-shadow: var(--shadow);
}
.year-table {
width: 100%;
border-collapse: collapse;
min-width: 1000px;
font-size: var(--year-size);
}
.year-table th,
.list-table th {
position: sticky;
top: 0;
z-index: 2;
background: #e9f0e9;
color: #222;
font-weight: 700;
}
body.dark .list-table th {
background: #29372b;
color: #eee;
}
.year-table th {
background: #e9f0e9 !important;
background-color: #e9f0e9 !important;
background-image: none !important;
font-weight: 700;
opacity: 1 !important;
position: sticky;
top: 0;
z-index: 20;
isolation: isolate;
}
body.dark .year-table th {
background: #29372b !important;
background-color: #29372b !important;
color: #eee !important;
}
.year-table th:first-child,
.year-table td:first-child {
text-align: left;
}
.year-table td {
border-bottom: 1px solid var(--border-color);
padding: 8px 10px;
}
/* 年別画面:数値を右揃えにし、行間を広げる */
.year-table th,
.year-table td {
padding: 16px 18px;
text-align: right;
vertical-align: middle;
}
/* 年の項目名は左揃え */
.year-table th:first-child,
.year-table td:first-child {
text-align: left;
}
/* 年別画面:前年比を3段に表示 */
.year-change {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 9px;
margin-top: 9px;
font-size: 0.85em;
line-height: 1.7;
text-align: right;
}
.year-change-rate,
.year-change-amount {
display: block;
white-space: nowrap;
text-align: right;
}
.year-change-amount {
margin-bottom: 4px;
}
.year-table tr:last-child td {
border-bottom: none;
}
.year-link {
color: var(--main-color);
font-weight: 700;
cursor: pointer;
text-decoration: underline;
}
.memo-edit-btn {
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-color);
border-radius: 7px;
padding: 6px 10px;
}
.memo-status {
display: inline-flex;
align-items: center;
margin-right: 8px;
}
.memo-status input {
width: 18px;
height: 18px;
margin: 0;
accent-color: var(--main-color);
pointer-events: none;
}
/*
前年比
*/
.year-change {
margin-left: 4px;
font-size: .82em;
color: var(--sub-text);
}
.year-change.negative {
color: var(--danger);
font-weight: 700;
}
/* =========================================================
詳細
========================================================= */
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22px;
}
.detail-year {
font-size: 36px;
font-weight: 800;
color: var(--main-color);
}
.back-btn {
border: 1px solid var(--border-color);
background: var(--card-color);
border-radius: 9px;
padding: 9px 14px;
}
.month-table-wrapper {
overflow-x: auto;
background: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 14px;
box-shadow: var(--shadow);
}
.month-table {
width: 100%;
min-width: max-content;
border-collapse: collapse;
table-layout: auto;
font-size: var(--month-size);
}
.month-table th,
.month-table td {
padding: 0.35em 0.4em;
border-bottom: 1px solid var(--border-color);
text-align: right;
vertical-align: top;
}
.month-table th {
padding: 11px 10px;
background: var(--main-color-light);
color: var(--text-color);
white-space: nowrap;
}
.month-table th:first-child,
.month-table td:first-child {
text-align: left;
}
.asset-cell {
font-weight: 700;
background: rgba(79,111,82,.08);
}
.sub-section-title {
font-size: 20px;
font-weight: 700;
margin: 35px 0 15px;
}
/* =========================================================
明細
========================================================= */
.detail-list {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 150px;
text-align: left;
}
.detail-line {
display: flex;
justify-content: space-between;
gap: 15px;
}
.detail-note {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 170px;
}
.detail-empty {
color: var(--sub-text);
}
/* =========================================================
追加・編集画面
========================================================= */
.form-card {
background: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 22px;
box-shadow: var(--shadow);
margin-bottom: 20px;
font-size: var(--add-size);
}
/*
追加画面は横幅を有効利用する
*/
.add-form-grid {
display: grid;
grid-template-columns:
repeat(4, minmax(140px, 1fr));
gap: 12px 14px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.form-group label {
font-weight: 600;
font-size: .92em;
}
.form-group input,
.form-group textarea {
width: 100%;
}
/*
各段の幅を制御
*/
.form-row-full {
grid-column: 1 / -1;
}
/*
年月
*/
.form-year-month {
grid-column: span 1;
}
/*
臨時収入・出費
*/
.detail-editor-section {
margin-top: 20px;
}
.detail-editor-title {
font-size: 17px;
font-weight: 700;
margin-bottom: 8px;
}
.line-editor {
display: flex;
flex-direction: column;
gap: 7px;
}
/*
明細1行
*/
.line-row {
display: grid;
grid-template-columns:
minmax(0, 1fr)
150px
auto;
gap: 7px;
align-items: center;
}
.line-row button {
border: none;
background: #eee;
color: #333;
border-radius: 7px;
padding: 8px 11px;
}
body.dark .line-row button {
background: #444;
color: white;
}
/*
「追加」は削除の左隣
*/
.line-actions {
display: flex;
gap: 5px;
align-items: center;
}
.add-line-small,
.delete-line-small {
border-radius: 7px;
padding: 8px 12px;
white-space: nowrap;
}
.add-line-small {
border: 1px solid var(--main-color);
background: var(--main-color);
color: white;
}
.delete-line-small {
border: 1px solid var(--border-color);
background: #eee;
color: #333;
}
body.dark .delete-line-small {
background: #444;
color: white;
}
/*
資産・ローン・積立
*/
.three-column-row {
display: grid;
grid-template-columns:
repeat(4, minmax(0, 1fr));
gap: 14px;
}
/*
電気・ガス・水道
*/
.four-column-row {
display: grid;
grid-template-columns:
repeat(4, minmax(0, 1fr));
gap: 14px;
}
.utility-inner {
display: grid;
grid-template-columns:
1fr 1fr;
gap: 7px;
}
/*
メモ
*/
.memo-area {
margin-top: 20px;
}
.memo-area textarea {
min-height: 100px;
resize: vertical;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 25px;
}
/* =========================================================
設定
========================================================= */
.setting-section {
background: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 24px;
margin-bottom: 18px;
box-shadow: var(--shadow);
font-size: var(--setting-size);
}
.setting-section h3 {
margin-top: 0;
font-size: var(--setting-size) !important;
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 12px 0;
border-bottom: 1px solid var(--border-color);
}
.setting-row:last-child {
border-bottom: none;
}
.range-value {
min-width: 50px;
text-align: right;
}
.setting-control {
display: flex;
align-items: center;
gap: 10px;
}
/* フォント選択欄と文字サイズ表示の間隔 */
.setting-control select[id^="font-family-"] {
width: auto;
min-width: 170px;
margin-left: 12px;
flex-shrink: 0;
}
/* =========================================================
説明
========================================================= */
.description-card {
background: var(--card-color);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 30px;
line-height: 2;
font-size: var(--description-size);
box-shadow: var(--shadow);
}
.description-card h2 {
margin-top: 0;
font-family: var(--description-font) !important;
font-size: var(--description-size) !important;
}
/* =========================================================
モーダル
========================================================= */
.modal {
position: fixed;
inset: 0;
background: rgba(0,0,0,.48);
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
z-index: 500;
}
.modal-card {
width: min(720px, 100%);
max-height: 90vh;
overflow: auto;
background: var(--card-color);
color: var(--text-color);
border-radius: 15px;
padding: 25px;
box-shadow:
0 15px 50px rgba(0,0,0,.25);
}
.modal-title {
font-size: 21px;
font-weight: 700;
margin-bottom: 18px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
}
.warning {
color: var(--danger);
font-weight: 700;
}
/* =========================================================
入力
========================================================= */
input[type="text"],
input[type="number"],
input[type="month"],
textarea,
select {
width: 100%;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 9px 10px;
background: var(--card-color);
color: var(--text-color);
}
/* =========================================================
設定画面の選択ボタン
========================================================= */
#darkModeSetting {
width: 58px;
height: 32px;
appearance: none;
-webkit-appearance: none;
position: relative;
border: none;
border-radius: 999px;
background: #cfd5cf;
cursor: pointer;
transition:
background .2s,
box-shadow .2s;
}
#darkModeSetting::after {
content: "";
position: absolute;
top: 4px;
left: 4px;
width: 24px;
height: 24px;
border-radius: 50%;
background: white;
box-shadow:
0 2px 5px rgba(0,0,0,.22);
transition:
transform .2s;
}
#darkModeSetting:checked {
background: var(--main-color);
box-shadow:
0 0 0 3px var(--main-color-light);
}
#darkModeSetting:checked::after {
transform: translateX(26px);
}
#mainColorSetting,
#backgroundColorSetting {
width: 64px;
height: 42px;
padding: 4px;
border: 2px solid var(--border-color);
border-radius: 10px;
background: var(--card-color);
cursor: pointer;
box-shadow:
0 2px 8px rgba(0,0,0,.10);
transition:
transform .15s,
box-shadow .15s,
border-color .15s;
}
#mainColorSetting:hover,
#backgroundColorSetting:hover {
transform: translateY(-1px);
border-color: var(--main-color);
box-shadow:
0 4px 12px rgba(0,0,0,.16);
}
#mainColorSetting:focus,
#backgroundColorSetting:focus {
outline: none;
border-color: var(--main-color);
box-shadow:
0 0 0 3px var(--main-color-light),
0 4px 12px rgba(0,0,0,.12);
}
textarea {
min-height: 100px;
}
/* =========================================================
レスポンシブ
========================================================= */
@media (max-width: 1000px) {
.add-form-grid {
grid-template-columns:
repeat(2, minmax(0, 1fr));
}
.three-column-row {
grid-template-columns:
repeat(2, minmax(0, 1fr));
}
.four-column-row {
grid-template-columns:
repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 700px) {
.topbar {
overflow-x: auto;
}
.folder-indicator {
display: none;
}
.main {
padding: 18px 12px;
}
.add-form-grid,
.three-column-row,
.four-column-row {
grid-template-columns: 1fr;
}
.line-row {
grid-template-columns: 1fr;
}
.line-actions {
justify-content: flex-start;
}
.detail-year {
font-size: 28px;
}
}
/* =========================================================
一覧ページ
========================================================= */
.list-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.list-toolbar button {
border: 1px solid var(--border-color);
border-radius: 7px;
padding: 7px 12px;
background: var(--card-color);
color: var(--text-color);
}
.list-toolbar button.active {
background: var(--main-color);
color: white;
}
.list-year-panel {
display: flex;
flex-wrap: wrap;
gap: 7px 14px;
padding: 12px;
margin-bottom: 12px;
border: 1px solid var(--border-color);
border-radius: 9px;
background: var(--card-color);
}
.list-year-panel label {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.list-table-wrapper {
width: 100%;
overflow: auto;
max-height: calc(100vh - 230px);
border: 1px solid var(--border-color);
border-radius: 9px;
background: var(--card-color);
}
.list-table {
width: max-content;
min-width: 100%;
border-collapse: collapse;
table-layout: auto;
}
.list-table th,
.list-table td {
border: 1px solid var(--border-color);
white-space: nowrap;
text-align: right;
vertical-align: middle;
}
.list-table th {
position: sticky;
top: 0;
z-index: 2;
background: var(--main-color-light);
color: var(--text-color);
font-weight: 700;
}
.list-table th:first-child,
.list-table td:first-child {
position: sticky;
left: 0;
text-align: left;
background-color: var(--card-color);
color: var(--text-color);
z-index: 1;
}
.list-table th:first-child {
background-color: var(--main-color-light);
color: var(--text-color);
z-index: 4;
}
.list-table .list-memo-cell {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}
.list-table .list-detail-memo {
text-align: center;
min-width: 30px;
}
.list-memo-button {
border: 0;
padding: 1px 5px;
background: transparent;
color: var(--text-color);
cursor: pointer;
font-weight: 700;
}
.list-bonus {
color: red !important;
font-weight: 700;
}
.list-empty {
text-align: center !important;
padding: 25px;
color: var(--sub-text);
}
/* サイズ1:かなり小さい */
.list-size-1 .list-table {
font-size: 8px;
}
.list-size-1 .list-table th,
.list-size-1 .list-table td {
padding: 2px 3px;
}
/* サイズ2 */
.list-size-2 .list-table {
font-size: 9px;
}
.list-size-2 .list-table th,
.list-size-2 .list-table td {
padding: 3px 4px;
}
/* サイズ3 */
.list-size-3 .list-table {
font-size: 10px;
}
.list-size-3 .list-table th,
.list-size-3 .list-table td {
padding: 4px 5px;
}
/* サイズ4:標準 */
.list-size-4 .list-table {
font-size: 12px;
}
.list-size-4 .list-table th,
.list-size-4 .list-table td {
padding: 6px 7px;
}
/* サイズ5:大きめ */
.list-size-5 .list-table {
font-size: 14px;
}
.list-size-5 .list-table th,
.list-size-5 .list-table td {
padding: 8px 9px;
}
/* 文字設定:フォントの適用 */
:root {
--heading-font: "Yu Gothic", YuGothic, sans-serif;
--year-font: "Yu Gothic", YuGothic, sans-serif;
--month-font: "Yu Gothic", YuGothic, sans-serif;
--add-font: "Yu Gothic", YuGothic, sans-serif;
--setting-font: "Yu Gothic", YuGothic, sans-serif;
--description-font: "Yu Gothic", YuGothic, sans-serif;
}
.app-title,
.startup-title,
.topbar .nav-btn,
.topbar .folder-indicator,
.page-title,
.sub-section-title,
.detail-editor-title,
.modal-title {
font-family: var(--heading-font);
}
.year-table-wrapper {
font-family: var(--year-font);
}
.month-table-wrapper {
font-family: var(--month-font);
}
.form-card {
font-family: var(--add-font);
}
.setting-section {
font-family: var(--setting-font);
}
.description-card {
font-family: var(--description-font);
}
/* 文字設定:画面見出しと上部メニュー */
h1,
h2,
h3,
h4,
h5,
h6,
.page-title,
.sub-section-title,
.detail-editor-title,
.modal-title,
.nav-btn,
.folder-indicator {
font-size: var(--heading-size) !important;
}
/* アプリ説明内は「説明」のフォント・文字サイズを優先 */
.description-font {
font-family: var(--description-font) !important;
font-size: var(--description-size) !important;
}
/* ODSバックアップページは「説明」の文字設定を使用 */
.ods-page,
.ods-page * {
font-family: var(--description-font) !important;
font-size: var(--description-size) !important;
}
/* アプリ名だけは他の見出しより大きくする */
.app-title,
.startup-title {
font-size: calc(var(--heading-size) + 15px) !important;
}
/* 月別画面の年表示 */
.detail-year {
font-size: var(--month-size) !important;
}
.setting-section p,
.setting-section .setting-row > div > div:nth-child(2),
.setting-section > div[style*="margin-top:10px"] {
font-family: var(--setting-font, inherit) !important;
font-size: var(--setting-size) !important;
}
/* ライトモード:一覧の見出し背景を不透明にする */
body:not(.dark) .list-table th {
background-color: #e9f0e9;
color: #222;
opacity: 1;
z-index: 5;
}
/* ライトモード:左端の年月見出しを最前面に */
body:not(.dark) .list-table th:first-child {
background-color: #e9f0e9;
color: #222;
opacity: 1;
z-index: 6;
}
</style>
</head>
<body>
<!-- =====================================================
起動画面
===================================================== -->
<div id="startupScreen">
<div class="startup-card">
<div class="startup-title">
支出管理
</div>
<div class="startup-description">
指定したフォルダに記録を保存します。<br>
</div>
<div
class="folder-status"
id="startupFolderStatus"
>
保存フォルダが指定されていません。
</div>
<button
class="primary-btn"
id="restoreFolderBtn"
>
前回フォルダを許可
</button>
<button
class="primary-btn"
id="selectFolderBtn"
style="margin-top:10px;"
>
新しくフォルダを指定
</button>
<div
style="
margin-top:18px;
font-size:13px;
color:var(--sub-text);
"
>
</div>
</div>
</div>
<!-- =====================================================
アプリ本体
===================================================== -->
<div id="app" class="hidden">
<header class="topbar">
<div
class="app-title"
id="appTitle"
>
支出管理
</div>
<button
class="nav-btn"
data-page="years"
>
年別
</button>
<button
class="nav-btn"
data-page="add"
>
追加
</button>
<button
class="nav-btn"
data-page="list"
>
一覧
</button>
<button
class="nav-btn"
data-page="settings"
>
設定
</button>
<button
class="nav-btn"
data-page="background"
>
背景画像
</button>
<button
class="nav-btn"
data-page="description"
>
アプリ説明
</button>
<div class="nav-spacer"></div>
<div
class="folder-indicator"
id="folderIndicator"
></div>
<button
class="nav-btn"
id="resetBtn"
style="color:var(--danger);"
>
記録初期化
</button>
</header>
<main
class="main"
id="main"
></main>
</div>
<!-- =====================================================
モーダル
===================================================== -->
<div
id="modal"
class="modal hidden"
>
<div
class="modal-card"
id="modalCard"
></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<script>
"use strict";
/* =========================================================
定数
========================================================= */
const DATA_FILE =
"expenditure-data.json";
const SETTINGS_FILE =
"settings.json";
const DRAFT_FILE =
"expenditure-draft.json";
let directoryHandle = null;
let data = {
months: {},
bonuses: [],
yearMemos: {}
};
let settings = {
darkMode: false,
mainColor: "#4f6f52",
backgroundColor: "#f4f6f4",
backgroundOpacity: .85,
backgroundImageName: "",
appTitle: "支出管理",
fontSizes: {
heading: 20,
year: 15,
month: 15,
add: 15,
setting: 15,
description: 16
},
fontFamilies: {
heading: "yu-gothic",
year: "yu-gothic",
month: "yu-gothic",
add: "yu-gothic",
setting: "yu-gothic",
description: "yu-gothic"
}
};
let currentPage = "years";
let editingMonthKey = null;
let averageWindow = null;
/* =========================================================
初期化
========================================================= */
document.addEventListener(
"DOMContentLoaded",
() => {
document
.getElementById("restoreFolderBtn")
.addEventListener(
"click",
() => {
restorePreviousFolder(true);
}
);
document
.getElementById("selectFolderBtn")
.addEventListener(
"click",
selectFolder
);
document
.querySelectorAll(
".nav-btn[data-page]"
)
.forEach(btn => {
btn.addEventListener(
"click",
() => {
showPage(
btn.dataset.page
);
}
);
});
document
.getElementById("resetBtn")
.addEventListener(
"click",
resetRecords
);
document.addEventListener(
"click",
e => {
if (
averageWindow &&
!averageWindow.closed
) {
if (
!e.target.closest(
'button[onclick^="showYearAverage"]'
)
) {
averageWindow.close();
averageWindow = null;
}
}
}
);
document
.getElementById("modal")
.addEventListener(
"click",
e => {
if (
e.target.id === "modal"
) {
closeModal();
}
}
);
if (
!("showDirectoryPicker" in window)
) {
document
.getElementById(
"startupFolderStatus"
)
.innerHTML =
'<span class="warning">' +
'このブラウザはフォルダ保存機能に対応していません。' +
'ChromeまたはEdge等をご利用ください。' +
'</span>';
} else {
restorePreviousFolder();
}
}
);
/* =========================================================
フォルダ記憶
========================================================= */
const FOLDER_DB_NAME = "expenditure-folder-db";
const FOLDER_STORE_NAME = "handles";
const FOLDER_HANDLE_KEY = "directory";
function openFolderDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(
FOLDER_DB_NAME,
1
);
request.onupgradeneeded = () => {
const db = request.result;
if (
!db.objectStoreNames.contains(
FOLDER_STORE_NAME
)
) {
db.createObjectStore(
FOLDER_STORE_NAME
);
}
};
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(request.error);
};
});
}
async function deleteSavedDirectoryHandle() {
const db =
await openFolderDB();
return new Promise(
(resolve, reject) => {
const transaction =
db.transaction(
FOLDER_STORE_NAME,
"readwrite"
);
const store =
transaction.objectStore(
FOLDER_STORE_NAME
);
const request =
store.delete(
FOLDER_HANDLE_KEY
);
request.onsuccess = () => {
db.close();
resolve();
};
request.onerror = () => {
db.close();
reject(
request.error
);
};
}
);
}
async function saveDirectoryHandle(
handle
) {
const db =
await openFolderDB();
return new Promise(
(resolve, reject) => {
const transaction =
db.transaction(
FOLDER_STORE_NAME,
"readwrite"
);
transaction.objectStore(
FOLDER_STORE_NAME
).put(
handle,
FOLDER_HANDLE_KEY
);
transaction.oncomplete = () => {
db.close();
resolve();
};
transaction.onerror = () => {
db.close();
reject(
transaction.error
);
};
}
);
}
async function loadDirectoryHandle() {
const db =
await openFolderDB();
return new Promise(
(resolve, reject) => {
const transaction =
db.transaction(
FOLDER_STORE_NAME,
"readonly"
);
const request =
transaction.objectStore(
FOLDER_STORE_NAME
).get(
FOLDER_HANDLE_KEY
);
request.onsuccess = () => {
db.close();
resolve(
request.result || null
);
};
request.onerror = () => {
db.close();
reject(
request.error
);
};
}
);
}
async function restorePreviousFolder(
requestAccess = false
) {
try {
const handle =
await loadDirectoryHandle();
/*
* 前回フォルダ自体が記憶されていない
*/
if (!handle) {
directoryHandle = null;
document
.getElementById(
"startupFolderStatus"
)
.innerHTML =
"前回の保存フォルダはありません。<br>" +
"新しくフォルダを指定してください。";
return false;
}
directoryHandle =
handle;
/*
* 現在の権限を確認
*/
let permission =
await handle.queryPermission({
mode: "readwrite"
});
/*
* 起動時の自動確認では、
* ここで止める。
*
* 権限要求はユーザーが
* 「前回フォルダを許可」を
* 押したときだけ行う。
*/
if (
permission !== "granted" &&
!requestAccess
) {
document
.getElementById(
"startupFolderStatus"
)
.innerHTML =
"前回の保存フォルダが見つかりました。<br>" +
"アクセス許可が必要です。";
return false;
}
/*
* 「前回フォルダを許可」が
* 押された場合だけ権限を要求する。
*/
if (
permission !== "granted" &&
requestAccess
) {
permission =
await handle.requestPermission({
mode: "readwrite"
});
if (
permission !== "granted"
) {
directoryHandle = null;
document
.getElementById(
"startupFolderStatus"
)
.innerHTML =
'<span class="warning">' +
"前回の保存フォルダへのアクセスが許可されませんでした。" +
"</span>";
return false;
}
}
/*
* 保存フォルダとして使えるか確認。
*
* data / settings / draft の
* いずれかが存在すれば、
* 前回使用していたフォルダと判断する。
*/
let hasSavedFile = false;
for (
const fileName of [
DATA_FILE,
SETTINGS_FILE,
DRAFT_FILE
]
) {
try {
await directoryHandle.getFileHandle(
fileName,
{
create: false
}
);
hasSavedFile = true;
break;
} catch (error) {
if (
error.name !==
"NotFoundError"
) {
throw error;
}
}
}
/*
* 前回フォルダに
* 保存ファイルが存在しない。
*/
if (!hasSavedFile) {
directoryHandle = null;
await deleteSavedDirectoryHandle();
document
.getElementById(
"startupFolderStatus"
)
.innerHTML =
'<span class="warning">' +
"前回の保存フォルダを確認できませんでした。<br>" +
"保存データが存在しないため、" +
"新しい保存フォルダを指定してください。" +
"</span>";
return false;
}
/*
* 前回フォルダのデータを読み込む。
*/
await loadData();
/*
* アプリ起動
*/
document
.getElementById(
"startupScreen"
)
.classList
.add("hidden");
document
.getElementById("app")
.classList
.remove("hidden");
document
.getElementById(
"folderIndicator"
)
.textContent =
"保存先: " +
directoryHandle.name;
applySettings();
const lastPage = settings.lastPage || "years";
await showPage(lastPage);
return true;
} catch (error) {
console.error(
"前回のフォルダを復元できませんでした。",
error
);
/*
* JSONの読み込み・解析エラーの場合。
*
* フォルダ自体にはアクセスできているため、
* 新しいフォルダを要求しない。
*/
if (
error instanceof SyntaxError
) {
document
.getElementById(
"startupFolderStatus"
)
.innerHTML =
'<span class="warning">' +
"保存データまたは設定ファイルのJSONが壊れています。<br>" +
"ファイルの内容を確認してください。" +
"</span>";
return false;
}
/*
* それ以外は、
* フォルダへのアクセス自体に
* 問題がある可能性がある。
*/
directoryHandle = null;
document
.getElementById(
"startupFolderStatus"
)
.innerHTML =
'<span class="warning">' +
"前回の保存フォルダを利用できません。<br>" +
"新しい保存フォルダを指定してください。" +
"</span>";
return false;
}
}
/* =========================================================
フォルダ
========================================================= */
async function selectFolder() {
try {
/*
* ユーザーが明示的に
* 「新しくフォルダを指定」を押した場合。
*
* 前回フォルダの確認は行わない。
*/
directoryHandle =
await window.showDirectoryPicker({
mode: "readwrite"
});
/*
* ユーザーが選択したフォルダを
* IndexedDBへ記憶する。
*/
await saveDirectoryHandle(
directoryHandle
);
/*
* 新しいフォルダなので、
* 空フォルダでも正常に開始する。
*/
await loadData();
/*
* 起動画面を閉じる。
*/
document
.getElementById(
"startupScreen"
)
.classList
.add("hidden");
document
.getElementById("app")
.classList
.remove("hidden");
document
.getElementById(
"folderIndicator"
)
.textContent =
"保存先: " +
directoryHandle.name;
applySettings();
showPage("years");
} catch (error) {
console.error(
"新しいフォルダの指定に失敗しました。",
error
);
/*
* ユーザーが選択をキャンセルした場合。
*/
if (
error.name ===
"AbortError"
) {
return;
}
directoryHandle = null;
document
.getElementById(
"startupFolderStatus"
)
.innerHTML =
'<span class="warning">' +
"フォルダへのアクセスに失敗しました。" +
"</span>";
}
}
/* =========================================================
ファイル読み書き
========================================================= */
async function getFileHandle(
name,
create = true
) {
return await directoryHandle
.getFileHandle(
name,
{ create }
);
}
async function readJsonFile(
name,
defaultValue
) {
try {
const fileHandle =
await getFileHandle(
name,
false
);
const file =
await fileHandle.getFile();
const text =
await file.text();
if (!text.trim()) {
return defaultValue;
}
return JSON.parse(text);
} catch (error) {
if (
error.name ===
"NotFoundError"
) {
return defaultValue;
}
throw error;
}
}
async function writeJsonFile(
name,
value
) {
const fileHandle =
await getFileHandle(
name,
true
);
const writable =
await fileHandle.createWritable();
await writable.write(
JSON.stringify(
value,
null,
2
)
);
await writable.close();
}
async function deleteJsonFile(name) {
if (!directoryHandle) {
return;
}
try {
await directoryHandle.removeEntry(name);
console.log(
name + " を削除しました。"
);
} catch (error) {
/*
* すでに存在しない場合は正常。
*/
if (
error.name ===
"NotFoundError"
) {
return;
}
throw error;
}
}
async function loadData() {
data =
await readJsonFile(
DATA_FILE,
{
months: {},
yearMemos: {}
}
);
settings =
await readJsonFile(
SETTINGS_FILE,
settings
);
if (!data.months) {
data.months = {};
}
if (!data.bonuses) {
data.bonuses = [];
}
if (!data.yearMemos) {
data.yearMemos = {};
}
if (!settings.fontSizes) {
settings.fontSizes = {};
}
if (settings.fontSizes.heading === undefined) {
settings.fontSizes.heading = 20;
}
if (settings.fontSizes.year === undefined) {
settings.fontSizes.year = 15;
}
if (settings.fontSizes.month === undefined) {
settings.fontSizes.month = 15;
}
if (settings.fontSizes.add === undefined) {
settings.fontSizes.add = 15;
}
if (settings.fontSizes.setting === undefined) {
settings.fontSizes.setting = 15;
}
if (settings.fontSizes.description === undefined) {
settings.fontSizes.description = 16;
}
if (!settings.fontFamilies) {
settings.fontFamilies = {};
}
[
"heading",
"year",
"month",
"add",
"setting",
"description"
].forEach(type => {
if (!settings.fontFamilies[type]) {
settings.fontFamilies[type] = "yu-gothic";
}
});
}
async function saveData() {
await writeJsonFile(
DATA_FILE,
data
);
}
async function saveDraft() {
if (!directoryHandle) {
return;
}
const getNumber = id =>
Number(
document
.getElementById(id)
.value || 0
);
const draft = {
editingMonthKey:
editingMonthKey,
month: {
yearMonth:
document
.getElementById(
"f-yearMonth"
)
.value,
income:
getNumber("f-income"),
overtimeHours:
getNumber(
"f-overtimeHours"
),
overtimePay:
getNumber(
"f-overtimePay"
),
grossPay:
getNumber("f-grossPay"),
expense:
getNumber("f-expense"),
financialAsset1:
getNumber(
"f-financialAsset1"
),
financialAsset2:
getNumber(
"f-financialAsset2"
),
temporaryIncome:
collectLineEditor(
"temporaryIncome"
),
expenses:
collectLineEditor(
"expenses"
),
asset:
getNumber("f-asset"),
loan:
getNumber("f-loan"),
saving:
getNumber("f-saving"),
electricity: {
kwh:
getNumber(
"f-electricity-kwh"
),
amount:
getNumber(
"f-electricity-amount"
)
},
gas: {
volume:
getNumber(
"f-gas-volume"
),
amount:
getNumber(
"f-gas-amount"
)
},
water: {
volume:
getNumber(
"f-water-volume"
),
amount:
getNumber(
"f-water-amount"
)
},
memo:
document
.getElementById(
"f-memo"
)
.value
}
};
/*
* 何も入力されていないか確認。
*/
const month =
draft.month;
const hasTemporaryIncome =
month.temporaryIncome.some(
item =>
String(
item.memo || ""
).trim() !== "" ||
Number(
item.amount || 0
) !== 0
);
const hasExpenses =
month.expenses.some(
item =>
String(
item.memo || ""
).trim() !== "" ||
Number(
item.amount || 0
) !== 0
);
const hasInput =
String(
month.yearMonth || ""
).trim() !== "" ||
month.income !== 0 ||
month.overtimeHours !== 0 ||
month.overtimePay !== 0 ||
month.grossPay !== 0 ||
month.expense !== 0 ||
month.financialAsset1 !== 0 ||
month.financialAsset2 !== 0 ||
hasTemporaryIncome ||
hasExpenses ||
month.asset !== 0 ||
month.loan !== 0 ||
month.saving !== 0 ||
month.electricity.kwh !== 0 ||
month.electricity.amount !== 0 ||
month.gas.volume !== 0 ||
month.gas.amount !== 0 ||
month.water.volume !== 0 ||
month.water.amount !== 0 ||
String(
month.memo || ""
).trim() !== "";
/*
* 完全に空ならdraftを作らない。
*
* すでに古い空draftがあれば削除する。
*/
if (!hasInput) {
await deleteDraft();
return;
}
/*
* 何か入力されている場合だけ
* draftとして保存する。
*/
try {
await writeJsonFile(
DRAFT_FILE,
draft
);
console.log(
"編集中データを一時保存しました。"
);
} catch (error) {
console.error(
"draftの保存に失敗しました。",
error
);
alert(
"編集中データの一時保存に失敗しました。"
);
}
}
async function loadDraft() {
if (!directoryHandle) {
return null;
}
try {
const fileHandle =
await directoryHandle.getFileHandle(
DRAFT_FILE
);
const file =
await fileHandle.getFile();
const text =
await file.text();
/*
* 空ファイルはdraftなしとして扱う。
*/
if (!text.trim()) {
return null;
}
const draft =
JSON.parse(text);
/*
* draftの基本構造を確認。
*/
if (
!draft ||
typeof draft !== "object" ||
!draft.month ||
typeof draft.month !== "object"
) {
throw new Error(
"draftの形式が正しくありません。"
);
}
/*
* month.yearMonth が存在する場合は、
* 年月として正しい形式か確認。
*
* 空の場合は「新規入力のdraft」として許可する。
*/
const yearMonth =
draft.month.yearMonth;
if (
yearMonth !== undefined &&
yearMonth !== "" &&
!/^\d{4}-\d{2}$/.test(
String(yearMonth)
)
) {
throw new Error(
"draftの年月データが正しくありません。"
);
}
/*
* 明細が存在しない場合は
* 復元時に空の1行を作れるため許可する。
*/
if (
draft.month.temporaryIncome !== undefined &&
!Array.isArray(
draft.month.temporaryIncome
)
) {
throw new Error(
"draftの臨時収入データが正しくありません。"
);
}
if (
draft.month.expenses !== undefined &&
!Array.isArray(
draft.month.expenses
)
) {
throw new Error(
"draftの出費データが正しくありません。"
);
}
return draft;
} catch (error) {
/*
* draftが存在しない場合は正常。
*/
if (
error.name ===
"NotFoundError"
) {
return null;
}
console.error(
"draftの読み込みに失敗しました。",
error
);
/*
* 壊れたdraftを
* 勝手に削除しない。
*
* ユーザーの入力データを
* 勝手に消さないため。
*/
alert(
"一時保存データを読み込めませんでした。\n\n" +
"draftファイルが壊れているか、" +
"形式が正しくありません。\n\n" +
"一時保存データは削除していません。"
);
return null;
}
}
function restoreDraft(draft) {
if (!draft || !draft.month) {
return;
}
const month = draft.month;
document.getElementById(
"f-yearMonth"
).value =
month.yearMonth || "";
document.getElementById(
"f-income"
).value =
month.income ?? 0;
document.getElementById(
"f-overtimeHours"
).value =
month.overtimeHours ?? 0;
document.getElementById(
"f-overtimePay"
).value =
month.overtimePay ?? 0;
document.getElementById(
"f-grossPay"
).value =
month.grossPay ?? 0;
document.getElementById(
"f-expense"
).value =
month.expense ?? 0;
document.getElementById(
"f-financialAsset1"
).value =
month.financialAsset1 ?? 0;
document.getElementById(
"f-financialAsset2"
).value =
month.financialAsset2 ?? 0;
document.getElementById(
"f-asset"
).value =
month.asset ?? 0;
document.getElementById(
"f-loan"
).value =
month.loan ?? 0;
document.getElementById(
"f-saving"
).value =
month.saving ?? 0;
document.getElementById(
"f-electricity-kwh"
).value =
month.electricity?.kwh ?? 0;
document.getElementById(
"f-electricity-amount"
).value =
month.electricity?.amount ?? 0;
document.getElementById(
"f-gas-volume"
).value =
month.gas?.volume ?? 0;
document.getElementById(
"f-gas-amount"
).value =
month.gas?.amount ?? 0;
document.getElementById(
"f-water-volume"
).value =
month.water?.volume ?? 0;
document.getElementById(
"f-water-amount"
).value =
month.water?.amount ?? 0;
document.getElementById(
"f-memo"
).value =
month.memo || "";
/*
* 臨時収入を復元
*/
renderLineEditor(
"temporaryIncomeEditor",
month.temporaryIncome &&
month.temporaryIncome.length
? month.temporaryIncome
: [
{
memo: "",
amount: 0
}
],
"temporaryIncome"
);
/*
* 出費を復元
*/
renderLineEditor(
"expenseEditor",
month.expenses &&
month.expenses.length
? month.expenses
: [
{
memo: "",
amount: 0
}
],
"expenses"
);
editingMonthKey =
draft.editingMonthKey || null;
console.log(
"draftを復元しました。"
);
}
async function deleteDraft() {
if (!directoryHandle) {
return;
}
try {
await deleteJsonFile(
DRAFT_FILE
);
console.log(
"draftを削除しました。"
);
} catch (error) {
console.error(
"draftの削除に失敗しました。",
error
);
}
}
async function saveSettings() {
await writeJsonFile(
SETTINGS_FILE,
settings
);
}
/* =========================================================
ページ切り替え
========================================================= */
async function showPage(page) {
/*
* 追加・編集画面から別ページへ移動する場合、
* 正式保存とは別に編集中の内容を一時保存する。
*/
if (
currentPage === "add" &&
page !== "add" &&
document.getElementById("f-yearMonth")
) {
await saveDraft();
}
currentPage = page;
if (page !== null) {
settings.lastPage = page;
if (directoryHandle) {
try {
await saveSettings();
} catch (error) {
console.error(
"ページ切り替え時の設定保存に失敗しました。",
error
);
}
}
}
/*
* 上部バーの選択状態を変更
*/
document
.querySelectorAll(
".nav-btn[data-page]"
)
.forEach(btn => {
btn.classList.toggle(
"active",
btn.dataset.page === page
);
});
/*
* 先に各ページを表示する。
*/
if (page === "years") {
renderYears();
}
if (page === "list") {
renderListPage();
}
if (page === "settings") {
renderSettings();
}
if (page === "background") {
renderBackgroundPage();
}
if (page === "description") {
if (typeof renderDescription === "function") {
renderDescription();
} else {
console.error(
"renderDescription() が定義されていません。"
);
document.getElementById("main").innerHTML = `
<h1 class="page-title">アプリ説明</h1>
<p class="page-description">
説明画面の描画処理が見つかりません。
年別画面へ戻って、ほかの機能をご利用ください。
</p>
<button
class="primary-btn"
onclick="showPage('years')"
>
年別画面へ戻る
</button>
`;
}
}
if (page === "ods") {
renderOdsPage();
}
/*
* 追加画面は、
* まず入力欄を作ってからdraftを確認する。
*/
if (page === "add") {
renderAddPage();
const draft =
await loadDraft();
if (draft) {
const restore =
confirm(
"編集中のデータがあります。\n\n" +
"前回の入力内容を復元しますか?"
);
if (restore) {
restoreDraft(draft);
} else {
await deleteDraft();
}
}
}
}
/* =========================================================
年取得
========================================================= */
function getYears() {
const years = [
...Object.keys(data.months)
.map(
key => key.substring(0, 4)
),
...(data.bonuses || [])
.map(
bonus =>
bonus.yearMonth.substring(0, 4)
)
];
return years
.filter(
(year, index, arr) =>
arr.indexOf(year) === index
)
.sort(
(a, b) =>
Number(b) - Number(a)
);
}
function getYearMonths(year) {
return Object.entries(
data.months
)
.filter(
([key]) =>
key.startsWith(
year + "-"
)
)
.sort(
([a],[b]) =>
a.localeCompare(b)
)
.map(
([,value]) => value
);
}
/* =========================================================
年集計
========================================================= */
function sumNumbers(values) {
return values.reduce(
(sum,value) => {
return sum +
Number(value || 0);
},
0
);
}
function calculateYear(year) {
const months =
getYearMonths(year);
const bonusList =
(data.bonuses || [])
.filter(
bonus =>
bonus.yearMonth.startsWith(
year + "-"
)
);
const bonusIncomeTotal =
sumNumbers(
bonusList.map(
bonus => bonus.income
)
);
const bonusGrossTotal =
sumNumbers(
bonusList.map(
bonus => bonus.grossPay
)
);
const income =
sumNumbers(
months.map(
m => m.income
)
) +
bonusIncomeTotal;
const gross =
sumNumbers(
months.map(
m => m.grossPay
)
) +
bonusGrossTotal;
const expense =
sumNumbers(
months.map(
m => m.expense
)
);
const loan =
sumNumbers(
months.map(
m => m.loan
)
);
const saving =
sumNumbers(
months.map(
m => m.saving
)
);
const balance =
income -
expense -
loan -
saving;
const asset =
months.length
? Number(
months[
months.length - 1
].asset || 0
)
: 0;
return {
income,
gross,
expense,
balance,
asset
};
}
function calculateCumulativeFinancialAssets(year) {
const targetYear = Number(year);
const months = Object.values(
data.months || {}
).filter(month =>
Number(
String(month.yearMonth || "").substring(0, 4)
) <= targetYear
);
const bonuses = (
data.bonuses || []
).filter(bonus =>
Number(
String(bonus.yearMonth || "").substring(0, 4)
) <= targetYear
);
const financialAsset1 =
sumNumbers(
months.map(month => month.financialAsset1)
) +
sumNumbers(
bonuses.map(bonus => bonus.financialAsset1)
);
const financialAsset2 =
sumNumbers(
months.map(month => month.financialAsset2)
) +
sumNumbers(
bonuses.map(bonus => bonus.financialAsset2)
);
return {
financialAsset1,
financialAsset2,
total: financialAsset1 + financialAsset2
};
}
function showCumulativeFinancialAssets(year) {
const result =
calculateCumulativeFinancialAssets(year);
alert(
`${year}年までの累計金融資産\n\n` +
`金融資産1:${money(result.financialAsset1)}\n` +
`金融資産2:${money(result.financialAsset2)}\n` +
`合計:${money(result.total)}`
);
}
/* =========================================================
前年比
%+金額
========================================================= */
function getYearChange(
current,
previous
) {
if (
previous === null ||
previous === undefined
) {
return {
text: "-",
negative: false
};
}
const difference =
current - previous;
if (previous === 0) {
if (current === 0) {
return {
text: "0.0% / 0",
negative: false
};
}
return {
text: "- / " +
formatSignedMoney(
difference
),
negative:
difference < 0
};
}
const percent =
(
difference /
Math.abs(previous)
) * 100;
const percentText =
(percent > 0 ? "+" : "") +
percent.toFixed(1) +
"%";
const moneyText =
formatSignedMoney(
difference
);
return {
text:
percentText +
" / " +
moneyText,
negative:
difference < 0
};
}
function formatSignedMoney(
value
) {
const number =
Number(value || 0);
if (number > 0) {
return "+" +
money(number);
}
if (number < 0) {
return "-" +
money(
Math.abs(number)
);
}
return "0";
}
function money(value) {
return Math.floor(
Number(value || 0)
).toLocaleString(
"ja-JP"
);
}
/* =========================================================
年別画面
========================================================= */
function renderYears() {
let html = `
<p class="page-description">
年ごとの所得・総支給・支出・収支・資産を確認できます。
年をクリックすると、その年の詳細を表示します。
</p>
`;
const years =
getYears();
if (!years.length) {
html += `
<div class="form-card">
まだ記録がありません。<br>
「追加」から月の記録を登録してください。
</div>
`;
document
.getElementById("main")
.innerHTML = html;
return;
}
html += `
<div class="year-table-wrapper">
<table class="year-table">
<thead>
<tr>
<th>年</th>
<th>所得</th>
<th>総支給</th>
<th>支出</th>
<th>収支</th>
<th>資産</th>
<th>累計金融資産</th>
<th>メモ編集</th>
</tr>
</thead>
<tbody>
`;
years.forEach(year => {
const current =
calculateYear(year);
const previousYear =
years.find(
y =>
Number(y) ===
Number(year) - 1
);
const previous =
previousYear
? calculateYear(
previousYear
)
: null;
const incomeChange =
getYearChange(
current.income,
previous
? previous.income
: null
);
const grossChange =
getYearChange(
current.gross,
previous
? previous.gross
: null
);
const expenseChange =
getYearChange(
current.expense,
previous
? previous.expense
: null
);
const assetChange =
getYearChange(
current.asset,
previous
? previous.asset
: null
);
html += `
<tr>
<td>
<div style="
display:flex;
align-items:center;
gap:10px;
">
<span
class="year-link"
onclick="showYearDetail('${year}')"
>
${year}
</span>
<button
class="secondary-btn"
style="
padding:6px 12px;
font-size:13px;
"
onclick="showYearAverage('${year}')"
>
平均
</button>
</div>
</td>
<td>
<div>${money(current.income)}</div>
<div class="year-change ${incomeChange.negative ? "negative" : ""}">
<div>前年比 【${incomeChange.text.split(" / ")[0]}】</div>
<div>【${incomeChange.text.split(" / ")[1] || ""}】</div>
</div>
</td>
<td>
<div>${money(current.gross)}</div>
<div class="year-change ${grossChange.negative ? "negative" : ""}">
<div>前年比 【${grossChange.text.split(" / ")[0]}】</div>
<div>【${grossChange.text.split(" / ")[1] || ""}】</div>
</div>
</td>
<td>
<div>${money(current.expense)}</div>
<div class="year-change ${expenseChange.negative ? "negative" : ""}">
<div>前年比 【${expenseChange.text.split(" / ")[0]}】</div>
<div>【${expenseChange.text.split(" / ")[1] || ""}】</div>
</div>
</td>
<td>
${money(current.balance)}
</td>
<td>
<div>${money(current.asset)}</div>
<div class="year-change ${assetChange.negative ? "negative" : ""}">
<div>前年比 【${assetChange.text.split(" / ")[0]}】</div>
<div>【${assetChange.text.split(" / ")[1] || ""}】</div>
</div>
</td>
<td>
<span
class="year-link"
onclick="showCumulativeFinancialAssets('${year}')"
>
${money(
calculateCumulativeFinancialAssets(year).total
)}
</span>
</td>
<td>
<span class="memo-status">
<input
type="checkbox"
${
String(
(data.yearMemos || {})[year] || ""
).trim() !== ""
? "checked"
: ""
}
tabindex="-1"
>
</span>
<button
class="memo-edit-btn"
onclick="openYearMemo('${year}')"
>
編集
</button>
</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
`;
document
.getElementById("main")
.innerHTML = html;
}
/* =========================================================
年平均
========================================================= */
function showYearAverage(year) {
/*
* 通常月のみを平均計算に使用
* 賞与は「賞与込み」の項目だけに加算する。
*/
const months =
getYearMonths(year);
const monthCount =
months.length;
console.table(
months.map(month => ({
yearMonth: month.yearMonth,
income: month.income,
grossPay: month.grossPay
}))
);
console.log(
"通常月数:",
monthCount
);
const bonusList =
(data.bonuses || [])
.filter(
bonus =>
bonus.yearMonth.startsWith(
year + "-"
)
);
/*
* 通常所得
*/
const normalIncome =
monthCount
? sumNumbers(
months.map(
month =>
month.income
)
) / monthCount
: 0;
/*
* 賞与所得
*/
const bonusIncome =
sumNumbers(
bonusList.map(
bonus =>
bonus.income
)
);
/*
* 賞与込み所得
*/
const incomeWithBonus =
monthCount
? (
sumNumbers(
months.map(
month =>
month.income
)
) +
bonusIncome
) / monthCount
: 0;
/*
* 通常総支給
*/
const normalGross =
monthCount
? sumNumbers(
months.map(
month =>
month.grossPay
)
) / monthCount
: 0;
/*
* 賞与総支給
*/
const bonusGross =
sumNumbers(
bonusList.map(
bonus =>
bonus.grossPay
)
);
/*
* 賞与込み総支給
*/
const grossWithBonus =
monthCount
? (
sumNumbers(
months.map(
month =>
month.grossPay
)
) +
bonusGross
) / monthCount
: 0;
/*
* 支出
*/
const averageExpense =
monthCount
? sumNumbers(
months.map(
month =>
month.expense
)
) / monthCount
: 0;
/*
* 収支
*
* 通常月の
* 所得 - 支出 - ローン - 積立
*
* を各月で計算して平均する。
* 賞与は収支平均には含めない。
*/
const averageBalance =
monthCount
? sumNumbers(
months.map(
month =>
Number(
month.income || 0
) -
Number(
month.expense || 0
) -
Number(
month.loan || 0
) -
Number(
month.saving || 0
)
)
) / monthCount
: 0;
/*
* 電気
*/
const averageElectricity =
monthCount
? sumNumbers(
months.map(
month =>
month.electricity?.amount || 0
)
) / monthCount
: 0;
/*
* ガス
*/
const averageGas =
monthCount
? sumNumbers(
months.map(
month =>
month.gas?.amount || 0
)
) / monthCount
: 0;
/*
* 水道
*/
const averageWater =
monthCount
? sumNumbers(
months.map(
month =>
month.water?.amount || 0
)
) / monthCount
: 0;
/*
* 別ウィンドウ
*/
if (
averageWindow &&
!averageWindow.closed
) {
averageWindow.focus();
} else {
averageWindow =
window.open(
"",
"yearAverageWindow",
"width=500,height=650,resizable=yes,scrollbars=yes"
);
}
if (!averageWindow) {
alert(
"平均画面を開けませんでした。\n" +
"ブラウザのポップアップブロックを確認してください。"
);
return;
}
if (!averageWindow) {
alert(
"平均画面を開けませんでした。\n" +
"ブラウザのポップアップブロックを確認してください。"
);
return;
}
averageWindow.document.write(`
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>${year}年 平均</title>
<style>
* {
box-sizing:border-box;
}
body {
margin:0;
padding:30px;
font-family:
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"Hiragino Kaku Gothic ProN",
"Yu Gothic",
Meiryo,
sans-serif;
color:#222;
background:#f4f6f4;
}
h1 {
margin:0 0 25px;
font-size:26px;
}
.average-card {
background:white;
border:1px solid #d8ddd8;
border-radius:14px;
padding:24px;
box-shadow:
0 4px 18px rgba(0,0,0,.08);
}
.average-row {
display:flex;
justify-content:space-between;
align-items:center;
gap:20px;
padding:13px 0;
border-bottom:1px solid #e2e5e2;
}
.average-row:last-child {
border-bottom:none;
}
.average-label {
font-weight:600;
}
.average-value {
font-weight:700;
text-align:right;
white-space:nowrap;
}
.average-space {
height:10px;
}
</style>
</head>
<body>
<h1>${year}年 平均</h1>
<div class="average-card">
<div class="average-row">
<span class="average-label">
所得
</span>
<span class="average-value">
${money(normalIncome)}円
</span>
</div>
<div class="average-row">
<span class="average-label">
所得(賞与込み)
</span>
<span class="average-value">
${money(incomeWithBonus)}円
</span>
</div>
<div class="average-space"></div>
<div class="average-row">
<span class="average-label">
総支給
</span>
<span class="average-value">
${money(normalGross)}円
</span>
</div>
<div class="average-row">
<span class="average-label">
総支給(賞与込み)
</span>
<span class="average-value">
${money(grossWithBonus)}円
</span>
</div>
<div class="average-space"></div>
<div class="average-row">
<span class="average-label">
支出
</span>
<span class="average-value">
${money(averageExpense)}円
</span>
</div>
<div class="average-row">
<span class="average-label">
収支
</span>
<span class="average-value">
${money(averageBalance)}円
</span>
</div>
<div class="average-space"></div>
<div class="average-row">
<span class="average-label">
電気
</span>
<span class="average-value">
${money(averageElectricity)}円
</span>
</div>
<div class="average-row">
<span class="average-label">
ガス
</span>
<span class="average-value">
${money(averageGas)}円
</span>
</div>
<div class="average-row">
<span class="average-label">
水道
</span>
<span class="average-value">
${money(averageWater)}円
</span>
</div>
</div>
</body>
</html>
`);
averageWindow.document.close();
}
/* =========================================================
年詳細
========================================================= */
function showYearDetail(year) {
currentPage = "years";
document
.querySelectorAll(".nav-btn[data-page]")
.forEach(btn => {
btn.classList.toggle(
"active",
btn.dataset.page === "years"
);
});
/*
* 通常月
*/
const normalRows =
getYearMonths(year).map(month => ({
type: "normal",
yearMonth: month.yearMonth,
data: month
}));
/*
* 賞与
*
* 通常月が存在しなくても表示する。
*/
const bonusRows =
(data.bonuses || [])
.filter(
bonus =>
bonus.yearMonth.startsWith(
year + "-"
)
)
.map(bonus => ({
type: "bonus",
yearMonth: bonus.yearMonth,
data: bonus
}));
/*
* 通常+賞与を年月順に統合
*
* 同じ年月なら
* 通常 → 賞与
*/
const rows = [
...normalRows,
...bonusRows
].sort((a, b) => {
const dateCompare =
a.yearMonth.localeCompare(
b.yearMonth
);
if (dateCompare !== 0) {
return dateCompare;
}
if (a.type === b.type) {
return 0;
}
return a.type === "normal"
? -1
: 1;
});
let html = `
<div class="detail-header">
<div class="detail-year">
${year}年
</div>
<button
class="back-btn"
onclick="showPage('years')"
>
← 年別へ戻る
</button>
</div>
<div class="month-table-wrapper">
<table class="month-table">
<thead>
<tr>
<th>月</th>
<th>所得</th>
<th>総支給</th>
<th>支出</th>
<th>資産</th>
<th>ローン</th>
<th>電気</th>
<th>ガス</th>
<th>水道</th>
</tr>
</thead>
<tbody>
`;
if (!rows.length) {
html += `
<tr>
<td
colspan="9"
style="
text-align:center;
color:var(--sub-text);
padding:25px;
"
>
記録がありません。
</td>
</tr>
`;
} else {
rows.forEach(row => {
/*
* ==========================
* 賞与
* ==========================
*/
if (row.type === "bonus") {
const bonus = row.data;
html += `
<tr>
<td>
<span
class="year-link"
style="
color:red;
font-weight:700;
cursor:pointer;
"
onclick="editBonus(${bonus.id})"
>
${bonus.yearMonth}
</span>
</td>
<td>
<span
style="
color:red;
font-weight:700;
"
>
${money(
bonus.income
)}
</span>
</td>
<td>
<span
style="
color:red;
font-weight:700;
"
>
${money(
bonus.grossPay
)}
</span>
</td>
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
</tr>
`;
return;
}
/*
* ==========================
* 通常月
* ==========================
*/
const month = row.data;
html += `
<tr>
<td>
<span
class="year-link"
onclick="editMonth(
'${month.yearMonth}'
)"
>
${month.yearMonth}
</span>
</td>
<td>
${money(
month.income
)}
</td>
<td>
${money(
month.grossPay
)}
</td>
<td>
${money(
month.expense
)}
</td>
<td
class="asset-cell"
>
${money(
month.asset
)}
</td>
<td>
${money(
month.loan
)}
</td>
<td>
${money(
month.electricity?.amount || 0
)}
</td>
<td>
${money(
month.gas?.amount || 0
)}
</td>
<td>
${money(
month.water?.amount || 0
)}
</td>
</tr>
`;
});
}
html += `
</tbody>
</table>
</div>
`;
document
.getElementById("main")
.innerHTML = html;
}
/* =========================================================
空の月
========================================================= */
function emptyMonth() {
return {
yearMonth: "",
income: 0,
overtimeHours: 0,
overtimePay: 0,
grossPay: 0,
expense: 0,
temporaryIncome: [
{
memo: "",
amount: 0
}
],
expenses: [
{
memo: "",
amount: 0
}
],
asset: 0,
loan: 0,
electricity: {
kwh: 0,
amount: 0
},
gas: {
volume: 0,
amount: 0
},
water: {
volume: 0,
amount: 0
},
saving: 0,
memo: ""
};
}
/* =========================================================
追加画面
========================================================= */
function renderBonusPage() {
document.getElementById("main").innerHTML = `
<h1 class="page-title">
賞与入力
</h1>
<div class="form-card">
<div class="add-form-grid">
<div class="form-group">
<label>年月</label>
<input
id="bonus-yearMonth"
type="month"
>
</div>
<div class="form-group">
<label>賞与 所得</label>
<input
id="bonus-income"
type="number"
value="0"
>
</div>
<div class="form-group">
<label>賞与 総支給</label>
<input
id="bonus-grossPay"
type="number"
value="0"
>
</div>
<div class="form-group">
<label>メモ</label>
<input
id="bonus-memo"
type="text"
placeholder="メモ"
>
</div>
<div class="form-group">
<label>金融資産1</label>
<input
id="bonus-financialAsset1"
type="number"
value="0"
>
</div>
<div class="form-group">
<label>金融資産2</label>
<input
id="bonus-financialAsset2"
type="number"
value="0"
>
</div>
</div>
<div class="form-actions">
<button
class="secondary-btn"
onclick="showPage('add')"
>
戻る
</button>
<button
class="primary-btn"
onclick="saveBonus()"
>
賞与を保存
</button>
</div>
</div>
`;
}
function renderAddPage(
monthKey = null
) {
editingMonthKey =
monthKey;
const month =
monthKey &&
data.months[monthKey]
? JSON.parse(
JSON.stringify(
data.months[
monthKey
]
)
)
: emptyMonth();
if (
!month.temporaryIncome ||
!month.temporaryIncome.length
) {
month.temporaryIncome = [
{
memo: "",
amount: 0
}
];
}
if (
!month.expenses ||
!month.expenses.length
) {
month.expenses = [
{
memo: "",
amount: 0
}
];
}
const title =
monthKey
? `${month.yearMonth} 編集`
: "追加";
/*
ここから追加画面
1段目
年月 / 所得 / 残業時間 / 残業代
2段目
総支給 / 支出
3段目
臨時収入
4段目
出費
5段目
資産 / ローン / 積立
6段目
電気 / ガス / 水道
その下
メモ
*/
document
.getElementById("main")
.innerHTML = `
<div style="
display:flex;
align-items:center;
gap:12px;
margin-bottom:15px;
">
<input
id="bonusModeCheck"
type="checkbox"
style="
width:24px;
height:24px;
cursor:pointer;
"
>
<span>
👈賞与入力画面へ
</span>
</div>
<div class="form-card">
<!-- ================================
1段目
================================= -->
<div class="add-form-grid">
<div class="form-group">
<label>年月</label>
<input
id="f-yearMonth"
type="month"
value="${month.yearMonth}"
>
</div>
<div class="form-group">
<label>所得</label>
<input
id="f-income"
type="number"
value="${month.income}"
>
</div>
<div class="form-group">
<label>残業時間(h)</label>
<input
id="f-overtimeHours"
type="number"
step="0.1"
value="${month.overtimeHours}"
>
</div>
<div class="form-group">
<label>残業代</label>
<input
id="f-overtimePay"
type="number"
value="${month.overtimePay}"
>
<div class="overtime-per-hour">
1時間あたり
<span id="f-overtimePerHour">0</span> 円
</div>
</div>
<!-- ================================
2段目
================================= -->
<div class="form-group">
<label>総支給</label>
<input
id="f-grossPay"
type="number"
value="${month.grossPay}"
>
</div>
<div class="form-group">
<label>支出</label>
<input
id="f-expense"
type="number"
value="${month.expense}"
>
</div>
<div class="form-group">
<label>金融資産1</label>
<input
id="f-financialAsset1"
type="number"
value="${month.financialAsset1 ?? 0}"
>
</div>
<div class="form-group">
<label>金融資産2</label>
<input
id="f-financialAsset2"
type="number"
value="${month.financialAsset2 ?? 0}"
>
</div>
</div>
<!-- ================================
3段目 臨時収入
================================= -->
<div class="detail-editor-section">
<div class="detail-editor-title">
臨時収入
</div>
<div
id="temporaryIncomeEditor"
class="line-editor"
></div>
</div>
<!-- ================================
4段目 出費
================================= -->
<div class="detail-editor-section">
<div class="detail-editor-title">
出費
</div>
<div
id="expenseEditor"
class="line-editor"
></div>
</div>
<!-- ================================
5段目 資産・ローン・積立
================================= -->
<div class="detail-editor-section">
<div class="detail-editor-title">
資産・ローン・積立
</div>
<div class="three-column-row">
<div class="form-group">
<label>
資産
</label>
<input
id="f-asset"
type="number"
value="${month.asset}"
>
</div>
<div class="form-group">
<label>
ローン
</label>
<input
id="f-loan"
type="number"
value="${month.loan}"
>
</div>
<div class="form-group">
<label>
積立
</label>
<input
id="f-saving"
type="number"
value="${month.saving}"
>
</div>
</div>
</div>
<!-- ================================
6段目
================================= -->
<div class="detail-editor-section">
<div class="detail-editor-title">
光熱費
</div>
<div class="four-column-row">
<!-- 電気 -->
<div class="form-group">
<div class="utility-inner">
<div class="form-group">
<label>電気量 (kwh)</label>
<input
id="f-electricity-kwh"
type="number"
step="0.1"
placeholder="kWh"
value="${month.electricity.kwh}"
>
</div>
<div class="form-group">
<label>電気</label>
<input
id="f-electricity-amount"
type="number"
placeholder="金額"
value="${month.electricity.amount}"
>
</div>
</div>
</div>
<!-- ガス -->
<div class="form-group">
<div class="utility-inner">
<div class="form-group">
<label>ガス量 (㎥)</label>
<input
id="f-gas-volume"
type="number"
step="0.1"
placeholder="㎥"
value="${month.gas.volume}"
>
</div>
<div class="form-group">
<label>ガス</label>
<input
id="f-gas-amount"
type="number"
placeholder="金額"
value="${month.gas.amount}"
>
</div>
</div>
</div>
<!-- 水道 -->
<div class="form-group">
<div class="utility-inner">
<div class="form-group">
<label>水道量 (㎥)</label>
<input
id="f-water-volume"
type="number"
step="0.1"
placeholder="㎥"
value="${month.water.volume}"
>
</div>
<div class="form-group">
<label>水道</label>
<input
id="f-water-amount"
type="number"
placeholder="金額"
value="${month.water.amount}"
>
</div>
</div>
</div>
</div>
</div>
<!-- ================================
メモ
================================= -->
<div class="memo-area">
<div class="detail-editor-title">
メモ
</div>
<textarea
id="f-memo"
placeholder="メモを入力"
>${escapeHtml(
month.memo || ""
)}</textarea>
</div>
<!-- ================================
ボタン
================================= -->
<div class="form-actions">
<button
class="secondary-btn"
onclick="showPage('years')"
>
キャンセル
</button>
<button
class="primary-btn"
onclick="saveMonthForm()"
>
保存
</button>
<button
class="danger-btn"
id="deleteMonthBtn"
onclick="deleteEditingMonth()"
>
削除
</button>
</div>
</div>
`;
const deleteMonthBtn =
document.getElementById(
"deleteMonthBtn"
);
if (deleteMonthBtn) {
deleteMonthBtn.classList.toggle(
"hidden",
!editingMonthKey
);
}
renderLineEditor(
"temporaryIncomeEditor",
month.temporaryIncome,
"temporaryIncome"
);
renderLineEditor(
"expenseEditor",
month.expenses,
"expenses"
);
const overtimeHoursInput =
document.getElementById(
"f-overtimeHours"
);
const overtimePayInput =
document.getElementById(
"f-overtimePay"
);
const overtimePerHour =
document.getElementById(
"f-overtimePerHour"
);
function updateOvertimePerHour() {
const hours =
Number(
overtimeHoursInput.value
) || 0;
const pay =
Number(
overtimePayInput.value
) || 0;
if (hours > 0) {
overtimePerHour.textContent =
Math.round(
pay / hours
).toLocaleString();
} else {
overtimePerHour.textContent =
"0";
}
}
overtimeHoursInput.addEventListener(
"input",
updateOvertimePerHour
);
overtimePayInput.addEventListener(
"input",
updateOvertimePerHour
);
const bonusModeCheck =
document.getElementById(
"bonusModeCheck"
);
bonusModeCheck.addEventListener(
"change",
() => {
if (bonusModeCheck.checked) {
renderBonusPage();
}
}
);
updateOvertimePerHour();
}
/* =========================================================
臨時収入・出費の明細
========================================================= */
function renderLineEditor(
containerId,
items,
type
) {
const container =
document.getElementById(
containerId
);
container.innerHTML = "";
items.forEach(
(item,index) => {
const row =
document.createElement(
"div"
);
row.className =
"line-row";
row.innerHTML = `
<input
type="text"
placeholder="メモ"
value="${escapeHtml(
item.memo || ""
)}"
data-line-type="${type}"
data-field="memo"
>
<input
type="number"
placeholder="金額"
value="${Number(
item.amount || 0
)}"
data-line-type="${type}"
data-field="amount"
>
<div class="line-actions">
<button
type="button"
class="add-line-small"
onclick="addEditorLine('${type}')"
>
追加
</button>
<button
type="button"
class="delete-line-small"
onclick="this.closest('.line-row').remove()"
>
削除
</button>
</div>
`;
container.appendChild(row);
}
);
}
/*
「追加」を押すと、
その種類の明細を1行追加する。
*/
function addEditorLine(type) {
const containerId =
type === "temporaryIncome"
? "temporaryIncomeEditor"
: "expenseEditor";
const container =
document.getElementById(containerId);
if (!container) {
console.error(
"明細入力欄が見つかりません:",
containerId
);
return;
}
const currentCount =
container.querySelectorAll(".line-row").length;
if (currentCount >= 4) {
alert(
(type === "temporaryIncome"
? "臨時収入"
: "出費") +
"は最大4件までです。"
);
return;
}
const row =
document.createElement("div");
row.className = "line-row";
row.innerHTML = `
<input
type="text"
placeholder="メモ"
data-line-type="${type}"
data-field="memo"
>
<input
type="number"
placeholder="金額"
value="0"
data-line-type="${type}"
data-field="amount"
>
<div class="line-actions">
<button
type="button"
class="add-line-small"
onclick="addEditorLine('${type}')"
>
追加
</button>
<button
type="button"
class="delete-line-small"
onclick="this.closest('.line-row').remove()"
>
削除
</button>
</div>
`;
container.appendChild(row);
}
/* =========================================================
明細取得
========================================================= */
function collectLineEditor(type) {
const containerId =
type === "temporaryIncome"
? "temporaryIncomeEditor"
: "expenseEditor";
const container =
document.getElementById(
containerId
);
const rows =
[
...container.querySelectorAll(
".line-row"
)
];
return rows
.map(row => {
const memo =
row
.querySelector(
'[data-field="memo"]'
)
.value;
const amount =
Number(
row
.querySelector(
'[data-field="amount"]'
)
.value || 0
);
return {
memo,
amount
};
})
.filter(
item =>
item.memo.trim() !== "" ||
item.amount !== 0
);
}
/* =========================================================
月削除
========================================================= */
async function deleteEditingMonth() {
if (!editingMonthKey) {
return;
}
const confirmed =
confirm(
editingMonthKey +
" の記録を削除します。\n\n" +
"この操作は元に戻せません。\n" +
"削除してよろしいですか?"
);
if (!confirmed) {
return;
}
try {
delete data.months[
editingMonthKey
];
await saveData();
await deleteDraft();
const deletedYear =
editingMonthKey.substring(
0,
4
);
editingMonthKey = null;
showYearDetail(
deletedYear
);
} catch (error) {
console.error(
"月データの削除に失敗しました。",
error
);
alert(
"削除に失敗しました。\n\n" +
"データは変更されていません。"
);
}
}
/* =========================================================
月保存
========================================================= */
async function saveMonthForm() {
const yearMonth =
document
.getElementById(
"f-yearMonth"
)
.value;
if (!yearMonth) {
alert(
"年月を入力してください。"
);
return;
}
const month = {
yearMonth,
income:
Number(
document
.getElementById(
"f-income"
)
.value || 0
),
overtimeHours:
Number(
document
.getElementById(
"f-overtimeHours"
)
.value || 0
),
overtimePay:
Number(
document
.getElementById(
"f-overtimePay"
)
.value || 0
),
grossPay:
Number(
document
.getElementById(
"f-grossPay"
)
.value || 0
),
expense:
Number(
document
.getElementById(
"f-expense"
)
.value || 0
),
financialAsset1:
Number(
document
.getElementById(
"f-financialAsset1"
)
.value || 0
),
financialAsset2:
Number(
document
.getElementById(
"f-financialAsset2"
)
.value || 0
),
temporaryIncome:
collectLineEditor(
"temporaryIncome"
),
expenses:
collectLineEditor(
"expenses"
),
asset:
Number(
document
.getElementById(
"f-asset"
)
.value || 0
),
loan:
Number(
document
.getElementById(
"f-loan"
)
.value || 0
),
electricity: {
kwh:
Number(
document
.getElementById(
"f-electricity-kwh"
)
.value || 0
),
amount:
Number(
document
.getElementById(
"f-electricity-amount"
)
.value || 0
)
},
gas: {
volume:
Number(
document
.getElementById(
"f-gas-volume"
)
.value || 0
),
amount:
Number(
document
.getElementById(
"f-gas-amount"
)
.value || 0
)
},
water: {
volume:
Number(
document
.getElementById(
"f-water-volume"
)
.value || 0
),
amount:
Number(
document
.getElementById(
"f-water-amount"
)
.value || 0
)
},
saving:
Number(
document
.getElementById(
"f-saving"
)
.value || 0
),
memo:
document
.getElementById(
"f-memo"
)
.value
};
/*
* 正式データの更新前に退避する。
* 年月変更による旧データの削除も含めて復元できるようにする。
*/
const previousMonths = {
...data.months
};
/*
* 正式データを更新する。
*
* 編集中に年月を変更した場合は、
* 変更前のキーを削除してから
* 新しい年月のキーへ保存する。
*/
if (
editingMonthKey &&
editingMonthKey !== yearMonth
) {
delete data.months[editingMonthKey];
}
data.months[yearMonth] = month;
/*
* 正式保存。
* 失敗した場合は更新前のデータを復元し、
* draftは削除しない。
*/
try {
await saveData();
} catch (error) {
data.months = previousMonths;
console.error(
"月データの保存に失敗しました。",
error
);
alert(
"保存に失敗しました。\n\n" +
"更新前のデータをメモリ上で復元しました。\n" +
"入力内容を保持するため、" +
"一時保存データは削除していません。"
);
return;
}
/*
* 正式保存成功後だけ、
* draftを削除する。
*/
try {
await deleteDraft();
} catch (error) {
/*
* 正式データの保存自体は成功しているため、
* draft削除失敗では保存失敗扱いにしない。
*/
console.error(
"draftの削除に失敗しました。",
error
);
alert(
"データは保存されましたが、" +
"一時保存データの削除に失敗しました。"
);
}
editingMonthKey =
null;
/*
* 正式保存成功後のみ表示。
*/
showYearDetail(
yearMonth.substring(0, 4)
);
}
async function saveBonus() {
const yearMonth =
document.getElementById(
"bonus-yearMonth"
).value;
const income =
Number(
document.getElementById(
"bonus-income"
).value || 0
);
const grossPay =
Number(
document.getElementById(
"bonus-grossPay"
).value || 0
);
const financialAsset1 =
Number(
document.getElementById(
"bonus-financialAsset1"
).value || 0
);
const financialAsset2 =
Number(
document.getElementById(
"bonus-financialAsset2"
).value || 0
);
const memo =
document.getElementById(
"bonus-memo"
).value.trim();
if (!yearMonth) {
alert("年月を入力してください。");
return;
}
if (
income === 0 &&
grossPay === 0 &&
financialAsset1 === 0 &&
financialAsset2 === 0
) {
alert(
"賞与の所得・総支給・金融資産1・金融資産2のいずれかを入力してください。"
);
return;
}
if (!data.bonuses) {
data.bonuses = [];
}
data.bonuses.push({
id: Date.now(),
yearMonth,
income,
grossPay,
financialAsset1,
financialAsset2,
memo
});
try {
await saveData();
showYearDetail(
yearMonth.substring(0, 4)
);
} catch (error) {
console.error(
"賞与保存エラー:",
error
);
alert(
"賞与の保存に失敗しました。\n\n" +
error.message
);
}
}
/* =========================================================
賞与編集
========================================================= */
function editBonus(bonusId) {
const bonus =
(data.bonuses || [])
.find(
item =>
String(item.id) ===
String(bonusId)
);
if (!bonus) {
alert("賞与データが見つかりません。");
return;
}
document.getElementById("main").innerHTML = `
<h1 class="page-title">
賞与編集
</h1>
<div class="form-card">
<div class="add-form-grid">
<div class="form-group">
<label>年月</label>
<input
id="bonus-yearMonth"
type="month"
value="${bonus.yearMonth}"
>
</div>
<div class="form-group">
<label>賞与 所得</label>
<input
id="bonus-income"
type="number"
value="${Number(
bonus.income || 0
)}"
>
</div>
<div class="form-group">
<label>賞与 総支給</label>
<input
id="bonus-grossPay"
type="number"
value="${Number(
bonus.grossPay || 0
)}"
>
</div>
<div class="form-group">
<label>メモ</label>
<input
id="bonus-memo"
type="text"
value="${escapeHtml(
bonus.memo || ""
)}"
>
</div>
<div class="form-group">
<label>金融資産1</label>
<input
id="bonus-financialAsset1"
type="number"
value="${Number(
bonus.financialAsset1 || 0
)}"
>
</div>
<div class="form-group">
<label>金融資産2</label>
<input
id="bonus-financialAsset2"
type="number"
value="${Number(
bonus.financialAsset2 || 0
)}"
>
</div>
</div>
<div class="form-actions">
<button
class="secondary-btn"
onclick="
showYearDetail(
'${bonus.yearMonth.substring(0, 4)}'
)
"
>
戻る
</button>
<button
class="primary-btn"
onclick="
updateBonus(${bonus.id})
"
>
保存
</button>
<button
class="danger-btn"
onclick="
deleteBonus(${bonus.id})
"
>
削除
</button>
</div>
</div>
`;
}
async function updateBonus(bonusId) {
const bonus =
(data.bonuses || []).find(
item =>
String(item.id) ===
String(bonusId)
);
if (!bonus) {
alert("賞与データが見つかりません。");
return;
}
const yearMonth =
document.getElementById(
"bonus-yearMonth"
).value;
const income =
Number(
document.getElementById(
"bonus-income"
).value || 0
);
const grossPay =
Number(
document.getElementById(
"bonus-grossPay"
).value || 0
);
const financialAsset1 =
Number(
document.getElementById(
"bonus-financialAsset1"
).value || 0
);
const financialAsset2 =
Number(
document.getElementById(
"bonus-financialAsset2"
).value || 0
);
const memo =
document.getElementById(
"bonus-memo"
).value.trim();
if (!yearMonth) {
alert("年月を入力してください。");
return;
}
// 更新前の賞与データを退避
const originalBonus = { ...bonus };
// 入力内容を反映
bonus.yearMonth = yearMonth;
bonus.income = income;
bonus.grossPay = grossPay;
bonus.financialAsset1 = financialAsset1;
bonus.financialAsset2 = financialAsset2;
bonus.memo = memo;
try {
await saveData();
showYearDetail(
yearMonth.substring(0, 4)
);
} catch (error) {
// 更新前のデータ構造と値を復元
Object.keys(bonus).forEach(key => {
if (
!Object.prototype.hasOwnProperty.call(
originalBonus,
key
)
) {
delete bonus[key];
}
});
Object.assign(
bonus,
originalBonus
);
console.error(
"賞与更新エラー:",
error
);
alert(
"賞与の更新に失敗しました。\n\n" +
"更新前のデータをメモリ上で復元しました。\n" +
"保存先とファイルの状態を確認してください。\n\n" +
error.message
);
}
}
async function deleteBonus(bonusId) {
if (
!confirm(
"この賞与を削除しますか?"
)
) {
return;
}
const bonus =
(data.bonuses || [])
.find(
item =>
String(item.id) ===
String(bonusId)
);
if (!bonus) {
alert(
"賞与データが見つかりません。"
);
return;
}
const deletedYear =
bonus.yearMonth.substring(
0,
4
);
data.bonuses =
(data.bonuses || [])
.filter(
item =>
String(item.id) !==
String(bonusId)
);
try {
await saveData();
showYearDetail(
deletedYear
);
} catch (error) {
console.error(
"賞与削除エラー:",
error
);
alert(
"賞与の削除に失敗しました。\n\n" +
error.message
);
}
}
/* =========================================================
月編集
========================================================= */
function editMonth(
yearMonth
) {
renderAddPage(
yearMonth
);
}
/* =========================================================
年メモ
========================================================= */
function openYearMemo(
year
) {
const existing =
(data.yearMemos || {})[year] ||
"";
document
.getElementById(
"modalCard"
)
.innerHTML = `
<div class="modal-title">
${year}年 メモ
</div>
<textarea
id="yearMemoInput"
style="min-height:260px;"
>${escapeHtml(
existing
)}</textarea>
<div class="modal-actions">
<button
class="secondary-btn"
onclick="closeModal()"
>
キャンセル
</button>
<button
class="primary-btn"
onclick="saveYearMemo('${year}')"
>
保存
</button>
</div>
`;
document
.getElementById(
"modal"
)
.classList
.remove("hidden");
}
async function saveYearMemo(
year
) {
data.yearMemos[year] =
document
.getElementById(
"yearMemoInput"
)
.value;
await saveData();
closeModal();
// 年別画面を即時更新
renderYears();
}
function closeModal() {
document
.getElementById(
"modal"
)
.classList
.add("hidden");
}
/* =========================================================
設定
========================================================= */
function renderSettings() {
document
.getElementById("main")
.innerHTML = `
<div class="setting-section">
<h3>
基本設定
</h3>
<div class="setting-row">
<div>
ダークモード
</div>
<div class="setting-control">
<input
id="darkModeSetting"
type="checkbox"
${
settings.darkMode
? "checked"
: ""
}
>
</div>
</div>
<div class="setting-row">
<div>
イメージカラー
</div>
<div class="setting-control">
<input
id="mainColorSetting"
type="color"
value="${settings.mainColor}"
>
</div>
</div>
<div class="setting-row">
<div>
タイトル変更
</div>
<div class="setting-control">
<input
id="appTitleSetting"
type="text"
value="${escapeHtml(
settings.appTitle || "支出管理"
)}"
placeholder="支出管理"
>
</div>
</div>
</div>
<div class="setting-section">
<h3>
文字設定
</h3>
${fontSettingRow(
"heading",
"見出し",
settings.fontSizes.heading
)}
${fontSettingRow(
"year",
"年別",
settings.fontSizes.year
)}
${fontSettingRow(
"month",
"月別",
settings.fontSizes.month
)}
${fontSettingRow(
"add",
"追加",
settings.fontSizes.add
)}
${fontSettingRow(
"setting",
"設定",
settings.fontSizes.setting
)}
${fontSettingRow(
"description",
"説明",
settings.fontSizes.description
)}
</div>
<div class="form-actions">
<button
class="primary-btn"
onclick="saveSettingPage()"
>
設定を保存
</button>
</div>
<div class="form-actions">
<button
class="secondary-btn"
onclick="showPage('ods')"
>
バックアップ
</button>
</div>
`;
[
"heading",
"year",
"month",
"add",
"setting",
"description"
]
.forEach(type => {
const input =
document.getElementById(
"font-" + type
);
const output =
document.getElementById(
"font-" +
type +
"-value"
);
input.addEventListener(
"input",
() => {
output.textContent =
input.value +
"px";
}
);
});
[
"heading",
"year",
"month",
"add",
"setting",
"description"
].forEach(type => {
const select = document.getElementById(
"font-family-" + type
);
if (!select) {
console.warn(
"フォント選択欄が見つかりません:",
type
);
return;
}
const fontVariables = {
heading: "--heading-font",
year: "--year-font",
month: "--month-font",
add: "--add-font",
setting: "--setting-font",
description: "--description-font"
};
const fontFamilies = {
"yu-gothic": '"Yu Gothic", YuGothic, sans-serif',
"meiryo": 'Meiryo, sans-serif',
"biz-udp-gothic": '"BIZ UDPGothic", "BIZ UDPゴシック", sans-serif',
"biz-udp-mincho": '"BIZ UDPMincho", "BIZ UDP明朝", serif',
"ms-p-gothic": '"MS PGothic", "MS Pゴシック", sans-serif',
"ms-p-mincho": '"MS PMincho", "MS P明朝", serif'
};
select.value =
settings.fontFamilies?.[type] || "yu-gothic";
document.documentElement.style.setProperty(
fontVariables[type],
fontFamilies[select.value] ||
fontFamilies["yu-gothic"]
);
// フォント変更時の処理
select.addEventListener("change", async () => {
const selectedFont = select.value;
settings.fontFamilies =
settings.fontFamilies || {};
settings.fontFamilies[type] = selectedFont;
document.documentElement.style.setProperty(
fontVariables[type],
fontFamilies[selectedFont] ||
fontFamilies["yu-gothic"]
);
if (!directoryHandle) {
console.warn(
"保存先が未設定のため、フォント設定は未保存です。"
);
return;
}
try {
await saveSettings();
} catch (error) {
console.error(
"フォント設定の保存に失敗しました。",
error
);
alert(
"フォント設定を保存できませんでした。\n" +
"保存先を確認してください。"
);
}
});
});
}
function renderDescription() {
document.getElementById("main").innerHTML = `
<h1 class="page-title description-font">
アプリ説明
</h1>
<div class="description-card description-font">
<h2>1. アプリの概要</h2>
<p>
毎月の収入・支出・資産・光熱費などを記録し、
年別・月別・一覧で確認するためのアプリです。
通常月と賞与は別々のデータとして管理します。
入力データは指定フォルダに保存します。
</p>
<h2>2. 年別画面</h2>
<p>
年ごとの所得・総支給・支出・収支・資産・累計金融資産を確認できます。
年をクリックすると、その年の月別詳細へ移動します。
「平均」からは年間の月平均を確認できます。
</p>
<p>
所得・総支給の年間集計には賞与も含まれます。
支出は通常月の支出を集計します。
前年比は前年の数値との比較です。
</p>
<h2>3. 収支と資産の計算</h2>
<p>
<strong>収支 = 所得 − 支出 − ローン − 積立</strong>
</p>
<p>
臨時収入・出費は上記の収支計算には自動加算されません。
賞与の所得は年間の所得に含まれますが、
年間の収支は通常月の所得・支出・ローン・積立から計算します。
</p>
<p>
「資産」は通常月に入力した資産の値です。
「金融資産1」「金融資産2」は資産とは別に管理します。
「累計金融資産」は、その年までに登録された通常月と賞与の
金融資産1・2をそれぞれ合計した金額です。
年別画面の累計金融資産をクリックすると、
金融資産1・2の内訳と合計を確認できます。
</p>
<h2>4. 年メモとチェックボックス</h2>
<p>
年別画面の「メモ編集」欄にあるチェックボックスは、
その年の年メモに文字が入力されているかを示す表示専用です。
チェックの操作でメモを編集するものではありません。
右隣の「編集」ボタンから年メモを入力・変更します。
年メモは月ごとのメモとは別に保存されます。
</p>
<h2>5. 月別画面・一覧画面</h2>
<p>
年別画面で年をクリックすると、月ごとの記録や明細を確認できます。
通常月と賞与は別の記録として表示されます。
月別詳細から対象の記録を確認し、編集操作へ進みます。
</p>
<p>
一覧画面では、通常月・賞与の記録を表形式でまとめて確認できます。
年月の新しい順に表示され、年月の絞り込みやサイズ変更ができます。
年月をクリックすると、該当する通常月または賞与の編集画面へ移動します。
メモ欄の📃をクリックすると、メモの内容を確認できます。
</p>
<h2>6. 通常月の追加・編集</h2>
<p>
上部の「追加」から年月を選び、通常月のデータを入力します。
所得・総支給・支出・資産・金融資産1・金融資産2・残業時間・残業代・
ローン・積立・電気・ガス・水道の使用量と金額・メモを記録できます。
</p>
<p>
所得は収支計算に使う金額、総支給は総支給額の記録です。
金融資産1・2はそれぞれ独立した金額として管理します。
電気・ガス・水道は使用量と料金を別々に入力します。
メモには、その月の補足情報を記録できます。
</p>
<p>
臨時収入と出費は、それぞれメモと金額を組み合わせて最大4件まで登録できます。
追加ボタンで明細を増やし、不要な明細は削除できます。
画面移動前の入力内容は一時保存・復元の対象となります。
正式な保存と一時保存は別の処理です。
</p>
<h2>7. 賞与の入力・編集</h2>
<p>
賞与は通常月の入力欄に加算するのではなく、独立した記録として登録します。
「追加」画面の「👈賞与入力画面へ」から賞与入力画面を開きます。
年月・賞与の所得・賞与の総支給・メモ・金融資産1・金融資産2を入力し、
保存ボタンで登録します。
</p>
<p>
所得・総支給・金融資産1・金融資産2のすべてが0の場合は登録できません。
いずれかの項目に値を入力してください。
登録済みの賞与は、一覧画面で賞与の年月をクリックして編集できます。
通常月と賞与は同じ年月でも別の記録として扱います。
</p>
<h2>8. 設定・背景画像</h2>
<p>
「設定」では、ダークモード、イメージカラー、タイトル、文字サイズ、
フォントなどを変更できます。
「背景画像」では背景画像・背景カラー・透過設定を調整できます。
設定項目によっては保存先への保存が必要です。
</p>
<h2>9. ODSバックアップ</h2>
<p>
設定画面からODSバックアップを開き、
「ODS出力」でデータを表計算ファイルに出力、
「ODS読取り」でファイルのデータを取り込めます。
通常月と賞与は別行で扱い、臨時収入・出費は各4組の固定列で管理します。
年メモは月ごとのメモとは別の項目です。
</p>
<p>
読取り時には、年月・数値・重複などの検査や上書き確認が行われます。
読取り前には、念のためバックアップを作成してください。
外部で編集したODSは、見出しや列の対応関係を確認してから取り込んでください。
</p>
<h2>10. 保存と一時保存</h2>
<p>
正式データは指定フォルダ内の expenditure-data.json、
設定は settings.json、一時保存は expenditure-draft.json に保存します。
起動時は指定フォルダを選択または復元してください。
保存先が利用できない場合、保存や設定変更が正常に完了しないことがあります。
</p>
<h2>11. 編集場所の早見表</h2>
<p>
・通常月の新規登録:上部の「追加」<br>
・通常月の修正:月別詳細または一覧画面から対象年月を選択<br>
・賞与の新規登録:「追加」から「👈賞与入力画面へ」<br>
・賞与の修正:一覧画面で賞与の年月を選択<br>
・年メモの修正:年別画面の「メモ編集」欄にある「編集」<br>
・設定の変更:上部の「設定」<br>
・ODSの読取り・出力:設定画面から「ODSバックアップ」
</p>
</div>
<div class="form-actions description-font">
<button
class="secondary-btn description-font"
onclick="showPage('years')"
>
年別画面へ戻る
</button>
</div>
`;
}
/* =========================================================
ODSバックアップページ
========================================================= */
function renderOdsPage() {
document.getElementById("main").innerHTML = `
<div class="ods-page">
<h1 class="page-title">ODSバックアップ</h1>
<p>
アプリのデータをODS形式で出力・読取りできます。
出力ファイル名は expenditure-backup.ods です。
読取り前には、念のため現在のデータをバックアップしてください。
</p>
<div class="setting-section">
<h3>ODS操作</h3>
<div class="form-actions">
<button class="secondary-btn" onclick="importOds()">
ODS読取り
</button>
<button class="primary-btn" onclick="exportOds()">
ODS出力
</button>
</div>
</div>
<div class="description-card">
<h2>1. 出力形式と並び順</h2>
<p>
先頭行は見出し、2行目以降は1レコードにつき1行です。
通常月と賞与は別レコードとして出力し、
年月の新しい順に並びます。同じ年月の場合は通常月が先、
賞与が後です。ファイルは「expenditure-backup.ods」
という名前で出力されます。
</p>
<h2>2. 36項目の列順</h2>
<p>
①年月 ②所得 ③総支給 ④支出 ⑤収支
⑥金融資産1 ⑦金融資産2 ⑧資産
⑨残業時間 ⑩残業代 ⑪ローン
⑫電気量 ⑬電気 ⑭ガス量 ⑮ガス
⑯水道量 ⑰水道 ⑱積立
⑲臨時収入メモ1 ⑳臨時収入1
㉑臨時収入メモ2 ㉒臨時収入2
㉓臨時収入メモ3 ㉔臨時収入3
㉕臨時収入メモ4 ㉖臨時収入4
㉗出費メモ1 ㉘出費1
㉙出費メモ2 ㉚出費2
㉛出費メモ3 ㉜出費3
㉝出費メモ4 ㉞出費4
㉟メモ ㊱メモ編集(年メモ)。
</p>
<h2>3. 各項目とアプリ内データ</h2>
<p>
所得・総支給・支出・資産・残業時間・残業代・ローン・積立は、
通常月の対応する入力値を出力します。
収支は「所得 − 支出 − ローン − 積立」で計算します。
臨時収入・出費はメモと金額を各4組の固定列に分けて出力します。
金融資産1・2は既存の資産とは別の項目で、
通常月・賞与それぞれの保存値を出力します。
</p>
<h2>4. 電気・ガス・水道の量と金額</h2>
<p>
「電気量」「ガス量」「水道量」は使用量、「電気」「ガス」「水道」は料金です。
量と金額は別々の列に保存され、互いに合算・変換されません。
読取り時も、それぞれの見出しに対応するアプリ内項目へ反映します。
</p>
<h2>5. 年メモと通常月・賞与のメモ</h2>
<p>
「メモ」は各レコードのメモです。
「メモ編集」は年別画面で編集する年メモです。
年メモは、その年で最も古い年月のレコードの行にだけ出力します。
最も古いレコードが賞与でも、その賞与行に年メモを出力します。
読取りでは通常月・賞与のどちらの行からも年メモを取得し、
同じ年に異なる年メモがあれば不一致としてエラーになります。
</p>
<p>
賞与の行は年月の末尾に「賞与」を付けて区別します。
所得・総支給・メモ・金融資産1・金融資産2を出力し、
それ以外の項目は空欄です。賞与は通常月とは別に読み取られます。
</p>
<h2>6. 読取り時のチェックと既存データ</h2>
<p>
最初のシートを読み取り、見出し名に基づいて項目を対応付けます。
必須の「年月」がない場合、年月形式や数値が不正な場合、
同じ年月の通常月または賞与が重複する場合はエラーになります。
年月が空欄の行は読み飛ばします。
対応しない見出しは無視し、重複した見出しは誤読防止のため無視します。
</p>
<p>
ODSの値が空欄の項目は、原則として既存データを空欄で
上書きしない方式です。既存データとの重複時は確認画面が表示されます。
読取り途中で問題が発生した場合は、エラー内容を確認してください。
臨時収入・出費はそれぞれ最大4件で、5件目以降の明細列があるODSは
読取りを中止します。
</p>
<h2>7. 読取り・出力の注意点</h2>
<p>
・見出し名と列の対応を維持してください。列の順番を変えても
見出しが正しければ対応付けできますが、見出しの変更・重複には注意してください。<br>
・空欄は「0」と同じ意味ではありません。空欄項目は既存値の保持に関係します。<br>
・収支は出力時に計算されます。臨時収入・出費を収支へ自動加算しません。<br>
・年メモは各行に繰り返し出力されません。年ごとの最古のレコード行を確認してください。<br>
・読取り前に既存データをバックアップし、読取り後は年別画面・一覧画面で値を確認してください。
</p>
</div>
<div class="form-actions">
<button class="secondary-btn" onclick="showPage('settings')">
設定へ戻る
</button>
</div>
</div>
`;
}
/* =========================================================
ODS出力
========================================================= */
function exportOds() {
if (typeof XLSX === "undefined") {
alert(
"ODS出力ライブラリを読み込めません。\n" +
"インターネット接続とConsoleを確認してください。"
);
return;
}
try {
const headers = [
"年月",
"所得",
"総支給",
"支出",
"収支",
"金融資産1",
"金融資産2",
"資産",
"残業時間",
"残業代",
"ローン",
"電気量",
"電気",
"ガス量",
"ガス",
"水道量",
"水道",
"積立",
"臨時収入メモ1",
"臨時収入1",
"臨時収入メモ2",
"臨時収入2",
"臨時収入メモ3",
"臨時収入3",
"臨時収入メモ4",
"臨時収入4",
"出費メモ1",
"出費1",
"出費メモ2",
"出費2",
"出費メモ3",
"出費3",
"出費メモ4",
"出費4",
"メモ",
"メモ編集"
];
const rows = [headers];
// 通常月のデータを作成
const records = Object.keys(data.months || {})
.map(yearMonth => ({
yearMonth,
type: "month",
value: data.months[yearMonth]
}))
.filter(record => record.value);
// 賞与を通常月とは別の行として追加
(data.bonuses || []).forEach(bonus => {
if (!bonus || !bonus.yearMonth) return;
records.push({
yearMonth: bonus.yearMonth,
type: "bonus",
value: bonus
});
});
// 新しい年月を上にする。
// 同じ年月なら通常月を賞与より先にする。
records.sort((a, b) => {
const dateCompare =
String(b.yearMonth).localeCompare(
String(a.yearMonth)
);
if (dateCompare !== 0) return dateCompare;
if (a.type === b.type) return 0;
return a.type === "month" ? -1 : 1;
});
// 年ごとに最も古い年月の行を特定する。
// 同じ年月なら通常月を優先する。
const oldestRecordByYear = {};
records.forEach(record => {
const year = String(record.yearMonth).substring(0, 4);
const current = oldestRecordByYear[year];
if (
!current ||
record.yearMonth < current.yearMonth ||
(
record.yearMonth === current.yearMonth &&
record.type === "month" &&
current.type === "bonus"
)
) {
oldestRecordByYear[year] = record;
}
});
records.forEach(record => {
const yearMonth = String(record.yearMonth);
const year = yearMonth.substring(0, 4);
const value = record.value;
// 賞与は指定された5項目を出力する。
// 年内で最も古いレコードが賞与の場合は年メモも出力する。
if (record.type === "bonus") {
const row = Array(headers.length).fill("");
row[0] = yearMonth + "賞与";
row[1] = value.income ?? "";
row[2] = value.grossPay ?? "";
row[5] = value.financialAsset1 ?? "";
row[6] = value.financialAsset2 ?? "";
row[34] = value.memo ?? "";
row[35] =
oldestRecordByYear[year] === record
? (data.yearMemos?.[year] || "")
: "";
rows.push(row);
return;
}
const month = value;
const temporaryIncome =
Array.isArray(month.temporaryIncome)
? month.temporaryIncome
: [];
const expenses =
Array.isArray(month.expenses)
? month.expenses
: [];
const income = Number(month.income || 0);
const expense = Number(month.expense || 0);
const loan = Number(month.loan || 0);
const saving = Number(month.saving || 0);
// 基本収支の既存仕様を維持
const balance =
income - expense - loan - saving;
const row = [
yearMonth,
income,
Number(month.grossPay || 0),
expense,
balance,
// 金融資産欄は、保存値が存在する場合だけ出力
month.financialAsset1 ?? "",
month.financialAsset2 ?? "",
Number(month.asset || 0),
Number(month.overtimeHours || 0),
Number(month.overtimePay || 0),
loan,
Number(month.electricity?.kwh || 0),
Number(month.electricity?.amount || 0),
Number(month.gas?.volume || 0),
Number(month.gas?.amount || 0),
Number(month.water?.volume || 0),
Number(month.water?.amount || 0),
saving,
// 臨時収入4組を先に出力し、その後に出費4組を出力
temporaryIncome[0]?.memo ?? "",
temporaryIncome[0]?.amount ?? "",
temporaryIncome[1]?.memo ?? "",
temporaryIncome[1]?.amount ?? "",
temporaryIncome[2]?.memo ?? "",
temporaryIncome[2]?.amount ?? "",
temporaryIncome[3]?.memo ?? "",
temporaryIncome[3]?.amount ?? "",
expenses[0]?.memo ?? "",
expenses[0]?.amount ?? "",
expenses[1]?.memo ?? "",
expenses[1]?.amount ?? "",
expenses[2]?.memo ?? "",
expenses[2]?.amount ?? "",
expenses[3]?.memo ?? "",
expenses[3]?.amount ?? "",
String(month.memo || ""),
// 年メモは年内で最も古い年月の行だけに出力
oldestRecordByYear[year] === record
? (data.yearMemos?.[year] || "")
: ""
];
rows.push(row);
});
const worksheet = XLSX.utils.aoa_to_sheet(rows);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(
workbook,
worksheet,
"支出管理"
);
XLSX.writeFile(
workbook,
"expenditure-backup.ods",
{ bookType: "ods" }
);
} catch (error) {
console.error("ODS出力に失敗しました。", error);
alert(
"ODS出力に失敗しました。\n" +
"F12のConsoleに表示されたエラーを確認してください。"
);
}
}
/* =========================================================
ODS読み取り
========================================================= */
async function importOds() {
if (typeof XLSX === "undefined") {
alert("ODSライブラリが読み込まれていません。");
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = ".ods";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
try {
const workbook = XLSX.read(
await file.arrayBuffer(),
{ type: "array" }
);
const sheetName = workbook.SheetNames[0];
if (!sheetName) {
throw new Error("シートがありません。");
}
const rows = XLSX.utils.sheet_to_json(
workbook.Sheets[sheetName],
{ header: 1, defval: "" }
);
if (rows.length < 2) {
throw new Error("読み取れるデータがありません。");
}
const headers = rows[0].map(
value => String(value ?? "").trim()
);
// 5件目以降の明細があるODSは、切り捨てずに読み込みを中止する。
const excessDetailHeaders = headers.filter(header => {
const match = header.match(
/^(?:臨時収入|出費)(?:メモ)?(\d+)$/
);
return match && Number(match[1]) > 4;
});
if (excessDetailHeaders.length > 0) {
throw new Error(
"ODSに5件目以降の明細列があります。\n" +
"明細は臨時収入・出費とも最大4件です。\n" +
"超過列: " +
excessDetailHeaders.join("、") +
"\n\nデータは読み込んでいません。"
);
}
// アプリが対応する項目だけを認識する。
// 対応しない見出しは無視する。
const aliases = {
"年月": "yearMonth",
"所得": "income",
"総支給": "grossPay",
"支出": "expense",
"金融資産1": "financialAsset1",
"金融資産2": "financialAsset2",
"資産": "asset",
"残業時間": "overtimeHours",
"残業代": "overtimePay",
"ローン": "loan",
"電気": "electricity.amount",
"電気量": "electricity.kwh",
"kwh": "electricity.kwh",
"ガス": "gas.amount",
"ガス量": "gas.volume",
"水道": "water.amount",
"水道量": "water.volume",
"積立": "saving",
"臨時収入メモ1": "temporaryIncomeMemo1",
"臨時収入1": "temporaryIncomeAmount1",
"臨時収入メモ2": "temporaryIncomeMemo2",
"臨時収入2": "temporaryIncomeAmount2",
"臨時収入メモ3": "temporaryIncomeMemo3",
"臨時収入3": "temporaryIncomeAmount3",
"臨時収入メモ4": "temporaryIncomeMemo4",
"臨時収入4": "temporaryIncomeAmount4",
"出費メモ1": "expenseMemo1",
"出費1": "expenseAmount1",
"出費メモ2": "expenseMemo2",
"出費2": "expenseAmount2",
"出費メモ3": "expenseMemo3",
"出費3": "expenseAmount3",
"出費メモ4": "expenseMemo4",
"出費4": "expenseAmount4",
"メモ": "memo",
"メモ編集": "yearMemo"
};
const columns = {};
const counts = {};
headers.forEach((header, index) => {
if (aliases[header]) {
counts[header] = (counts[header] || 0) + 1;
if (counts[header] === 1) {
columns[header] = index;
}
}
});
// 重複した見出しは誤った列を選ばないよう無視する。
Object.keys(counts).forEach(header => {
if (counts[header] > 1) {
delete columns[header];
console.warn(
"重複見出しを無視しました:",
header
);
}
});
if (columns["年月"] === undefined) {
throw new Error(
"必須項目「年月」が見つかりません。"
);
}
const cell = (row, header) => {
const index = columns[header];
if (index === undefined) return "";
return row[index] ?? "";
};
const isBlank = value =>
value === "" ||
value === null ||
value === undefined ||
String(value).trim() === "";
function readNumber(value, label, line) {
if (isBlank(value)) return null;
const number = Number(value);
if (!Number.isFinite(number)) {
throw new Error(
line + "行目の「" + label +
"」が数値ではありません。"
);
}
return number;
}
function parseYearMonth(value, line) {
let text = String(value ?? "").trim();
let isBonus = false;
if (text.endsWith("賞与")) {
isBonus = true;
text = text.slice(0, -2).trim();
}
const match = text.match(
/^(\d{4})[.\/-](\d{1,2})$/
);
if (!match) {
throw new Error(
line + "行目の年月が不正です: " + value
);
}
const month = Number(match[2]);
if (month < 1 || month > 12) {
throw new Error(
line + "行目の月が不正です。"
);
}
return {
yearMonth:
match[1] + "-" +
String(month).padStart(2, "0"),
isBonus
};
}
function parseDetails(value, line, label) {
if (isBlank(value)) return null;
return String(value).split(" / ").map(entry => {
const separator = entry.lastIndexOf(":");
if (separator < 0) {
throw new Error(
line + "行目の「" + label +
"」の明細形式が不正です。"
);
}
const memo = entry.slice(0, separator);
const amount = Number(
entry.slice(separator + 1)
);
if (!Number.isFinite(amount)) {
throw new Error(
line + "行目の「" + label +
"」の金額が不正です。"
);
}
return { memo, amount };
});
}
const importedMonths = {};
const importedBonuses = {};
const importedYearMemos = {};
rows.slice(1).forEach((row, index) => {
if (
!row ||
row.every(value => isBlank(value))
) {
return;
}
const line = index + 2;
// 年月が空欄の行は読み飛ばす
const rawYearMonth = cell(row, "年月");
if (isBlank(rawYearMonth)) {
console.warn(
line + "行目は年月が空欄のため読み飛ばしました。"
);
return;
}
const date = parseYearMonth(
rawYearMonth,
line
);
const key = date.yearMonth;
const year = key.slice(0, 4);
// 通常月・賞与のどちらの行でも年メモを取得する。
const yearMemo = cell(row, "メモ編集");
if (!isBlank(yearMemo)) {
if (
importedYearMemos[year] !== undefined &&
importedYearMemos[year] !== String(yearMemo)
) {
throw new Error(
year + "年の年メモが一致しません。"
);
}
importedYearMemos[year] = String(yearMemo);
}
if (date.isBonus) {
if (importedBonuses[key]) {
throw new Error(
"ODS内に同じ年月の賞与が複数あります: " +
key
);
}
const bonus = {};
const income = readNumber(
cell(row, "所得"), "所得", line
);
const grossPay = readNumber(
cell(row, "総支給"), "総支給", line
);
const financialAsset1 = readNumber(
cell(row, "金融資産1"),
"金融資産1",
line
);
const financialAsset2 = readNumber(
cell(row, "金融資産2"),
"金融資産2",
line
);
if (income !== null) bonus.income = income;
if (grossPay !== null) bonus.grossPay = grossPay;
if (financialAsset1 !== null) bonus.financialAsset1 = financialAsset1;
if (financialAsset2 !== null) bonus.financialAsset2 = financialAsset2;
const bonusMemo = cell(row, "メモ");
if (!isBlank(bonusMemo)) {
bonus.memo = String(bonusMemo);
}
importedBonuses[key] = bonus;
return;
}
if (importedMonths[key]) {
throw new Error(
"ODS内に同じ年月の通常月が複数あります: " +
key
);
}
// 既存データがあれば複製し、
// ODSに値がある項目だけを更新する。
const existing = data.months?.[key];
const month = existing
? JSON.parse(JSON.stringify(existing))
: {
yearMonth: key,
income: 0,
grossPay: 0,
expense: 0,
temporaryIncome: [],
expenses: [],
asset: 0,
overtimePay: 0,
overtimeHours: 0,
loan: 0,
electricity: { amount: 0, kwh: 0 },
gas: { amount: 0, volume: 0 },
water: { amount: 0, volume: 0 },
saving: 0,
memo: ""
};
month.yearMonth = key;
const numericFields = [
["所得", "income"],
["総支給", "grossPay"],
["支出", "expense"],
["金融資産1", "financialAsset1"],
["金融資産2", "financialAsset2"],
["資産", "asset"],
["残業時間", "overtimeHours"],
["残業代", "overtimePay"],
["ローン", "loan"],
["積立", "saving"],
];
const appliedFields = new Set();
numericFields.forEach(([header, property]) => {
if (appliedFields.has(property)) return;
const value = readNumber(
cell(row, header), header, line
);
if (value !== null) {
month[property] = value;
appliedFields.add(property);
}
});
const nestedFields = [
["電気", "electricity", "amount"],
["電気量", "electricity", "kwh"],
["kwh", "electricity", "kwh"],
["ガス", "gas", "amount"],
["ガス量", "gas", "volume"],
["水道", "water", "amount"],
["水道量", "water", "volume"]
];
const appliedNested = new Set();
nestedFields.forEach(([header, group, property]) => {
const id = group + "." + property;
if (appliedNested.has(id)) return;
const value = readNumber(
cell(row, header), header, line
);
if (value !== null) {
month[group] = {
...(month[group] || {}),
[property]: value
};
appliedNested.add(id);
}
});
const memo = cell(row, "メモ");
if (!isBlank(memo)) {
month.memo = String(memo);
}
// 36項目の固定列から臨時収入・出費を復元する。
function readDetailRows(prefix, line) {
const details = [];
for (let i = 1; i <= 4; i++) {
const memoHeader = prefix + "メモ" + i;
const amountHeader = prefix + i;
const memoValue = cell(row, memoHeader);
const amountValue = cell(row, amountHeader);
const memoBlank = isBlank(memoValue);
const amountBlank = isBlank(amountValue);
// メモと金額の両方が空欄なら、この明細は読み飛ばす。
if (memoBlank && amountBlank) {
continue;
}
// メモだけ、または金額だけの入力も保持する。
const memo = memoBlank
? ""
: String(memoValue);
let amount = 0;
if (!amountBlank) {
amount = readNumber(
amountValue,
amountHeader,
line
);
if (amount === null) {
amount = 0;
}
}
details.push({
memo,
amount
});
}
return details;
}
// ODSに対応する見出しがある場合だけ明細を更新する。
// 既存データにない列は、既存の明細を維持する。
const hasIncomeDetailColumns = [
"臨時収入メモ1",
"臨時収入1",
"臨時収入メモ2",
"臨時収入2",
"臨時収入メモ3",
"臨時収入3",
"臨時収入メモ4",
"臨時収入4"
].some(header => columns[header] !== undefined);
const hasExpenseDetailColumns = [
"出費メモ1",
"出費1",
"出費メモ2",
"出費2",
"出費メモ3",
"出費3",
"出費メモ4",
"出費4"
].some(header => columns[header] !== undefined);
if (hasIncomeDetailColumns) {
const details = readDetailRows(
"臨時収入",
line
);
if (details.length > 0) {
month.temporaryIncome = details;
}
}
if (hasExpenseDetailColumns) {
const details = readDetailRows(
"出費",
line
);
if (details.length > 0) {
month.expenses = details;
}
}
importedMonths[key] = month;
});
const monthKeys = Object.keys(importedMonths);
const bonusKeys = Object.keys(importedBonuses);
if (
monthKeys.length === 0 &&
bonusKeys.length === 0
) {
throw new Error("有効な月データがありません。");
}
const overlaps = [];
monthKeys.forEach(key => {
if (data.months?.[key]) {
overlaps.push(key + "(通常月)");
}
});
bonusKeys.forEach(key => {
if (
(data.bonuses || []).some(
item => item.yearMonth === key
)
) {
overlaps.push(key + "(賞与)");
}
});
Object.keys(importedYearMemos).forEach(year => {
if (
Object.prototype.hasOwnProperty.call(
data.yearMemos || {}, year
)
) {
overlaps.push(year + "年の年メモ");
}
});
let message =
"通常月 " + monthKeys.length +
"件、賞与 " + bonusKeys.length +
"件を読み込みます。\n";
if (overlaps.length) {
message +=
"\n既存データと重複しています。\n" +
overlaps.join("\n") +
"\n\n続行すると該当項目を上書きします。";
}
if (!confirm(message + "\n\n続行しますか?")) {
return;
}
const previousMonths = data.months;
const previousBonuses = data.bonuses || [];
const previousYearMemos = data.yearMemos || {};
const nextMonths = {
...previousMonths,
...importedMonths
};
const nextBonuses = previousBonuses.map(
item => {
const imported = importedBonuses[item.yearMonth];
if (!imported) return item;
return { ...item, ...imported };
}
);
bonusKeys.forEach(key => {
const exists = nextBonuses.some(
item => item.yearMonth === key
);
if (!exists) {
nextBonuses.push({
id: Date.now() + nextBonuses.length,
yearMonth: key,
income: importedBonuses[key].income ?? 0,
grossPay: importedBonuses[key].grossPay ?? 0,
financialAsset1:
importedBonuses[key].financialAsset1 ?? 0,
financialAsset2:
importedBonuses[key].financialAsset2 ?? 0,
memo: importedBonuses[key].memo ?? ""
});
}
});
const nextYearMemos = {
...previousYearMemos,
...importedYearMemos
};
data.months = nextMonths;
data.bonuses = nextBonuses;
data.yearMemos = nextYearMemos;
try {
await saveData();
} catch (error) {
data.months = previousMonths;
data.bonuses = previousBonuses;
data.yearMemos = previousYearMemos;
throw error;
}
alert(
"ODS読取り・保存が完了しました。\n" +
"通常月: " + monthKeys.length + "件\n" +
"賞与: " + bonusKeys.length + "件"
);
showPage("years");
} catch (error) {
console.error("ODS読み取りに失敗しました。", error);
alert(
"ODS読み取りに失敗しました。\n\n" +
error.message
);
}
};
input.click();
}
function fontSettingRow(
type,
label,
value
) {
const descriptions = {
heading: "アプリ名,上部バー",
year: "年別画面",
month: "月別画面",
add: "追加・賞与編集",
setting: "設定画面,背景画像",
description: "アプリ説明,バックアップ画面"
};
const sizeVariables = {
heading: "--heading-size",
year: "--year-size",
month: "--month-size",
add: "--add-size",
setting: "--setting-size",
description: "--description-size"
};
const sizeVariable =
sizeVariables[type] || "--setting-size";
return `
<div class="setting-row">
<div
style="
font-size: var(--setting-size);
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 10px;
"
>
<span>${label}</span>
<span
style="
font-size: calc(var(--setting-size) * 0.75);
font-weight: 400;
color: var(--sub-text);
"
>
${descriptions[type] || ""}
</span>
</div>
<div class="setting-control">
<input
id="font-${type}"
type="range"
min="10"
max="40"
value="${value}"
>
<span
id="font-${type}-value"
class="range-value"
>
${value}px
</span>
<select
id="font-family-${type}"
aria-label="${label}のフォント"
>
<option
value="yu-gothic"
style="font-family: 'Yu Gothic', YuGothic, sans-serif;"
>游ゴシック</option>
<option
value="meiryo"
style="font-family: Meiryo, sans-serif;"
>メイリオ</option>
<option
value="biz-udp-gothic"
style="font-family: 'BIZ UDPGothic', 'BIZ UDPゴシック', sans-serif;"
>BIZ UDPゴシック</option>
<option
value="biz-udp-mincho"
style="font-family: 'BIZ UDPMincho', 'BIZ UDP明朝', serif;"
>BIZ UDP明朝</option>
<option
value="ms-p-gothic"
style="font-family: 'MS PGothic', 'MS Pゴシック', sans-serif;"
>MS Pゴシック</option>
<option
value="ms-p-mincho"
style="font-family: 'MS PMincho', 'MS P明朝', serif;"
>MS P明朝</option>
</select>
</div>
</div>
`;
}
async function saveSettingPage() {
settings.darkMode =
document
.getElementById(
"darkModeSetting"
)
.checked;
settings.mainColor =
document
.getElementById(
"mainColorSetting"
)
.value;
settings.appTitle =
document
.getElementById(
"appTitleSetting"
)
.value
.trim() || "支出管理";
settings.fontSizes.heading =
Number(
document
.getElementById(
"font-heading"
)
.value
);
settings.fontSizes.year =
Number(
document
.getElementById(
"font-year"
)
.value
);
settings.fontSizes.month =
Number(
document
.getElementById(
"font-month"
)
.value
);
settings.fontSizes.add =
Number(
document
.getElementById(
"font-add"
)
.value
);
settings.fontSizes.setting =
Number(
document
.getElementById(
"font-setting"
)
.value
);
settings.fontSizes.description =
Number(
document
.getElementById(
"font-description"
)
.value
);
await saveSettings();
applySettings();
}
function applySettings() {
const appTitle =
settings.appTitle || "支出管理";
document.title = appTitle;
const appTitleElement =
document.getElementById("appTitle");
if (appTitleElement) {
appTitleElement.textContent =
appTitle;
}
const startupTitle =
document.querySelector(".startup-title");
if (startupTitle) {
startupTitle.textContent =
appTitle;
}
document.documentElement.style
.setProperty(
"--main-color",
settings.mainColor
);
document.documentElement.style
.setProperty(
"--heading-size",
settings.fontSizes.heading +
"px"
);
document.documentElement.style
.setProperty(
"--year-size",
settings.fontSizes.year +
"px"
);
document.documentElement.style
.setProperty(
"--month-size",
settings.fontSizes.month +
"px"
);
document.documentElement.style
.setProperty(
"--add-size",
settings.fontSizes.add +
"px"
);
document.documentElement.style
.setProperty(
"--setting-size",
settings.fontSizes.setting +
"px"
);
document.documentElement.style
.setProperty(
"--description-size",
settings.fontSizes.description +
"px"
);
document.body.classList.toggle(
"dark",
settings.darkMode
);
document.documentElement.style
.setProperty(
"--main-color-light",
hexToRgba(
settings.mainColor,
.12
)
);
applyBackground();
}
/* =========================================================
背景画像
========================================================= */
function renderBackgroundPage() {
document
.getElementById("main")
.innerHTML = `
<div class="setting-section">
<h3>
背景画像
</h3>
<p class="setting-message">
画像を指定すると、
指定フォルダに保存します。
</p>
<input
id="backgroundImageInput"
type="file"
accept="image/*"
>
<div
style="margin-top:20px;"
>
<button
class="primary-btn"
onclick="saveBackgroundImage()"
>
背景画像を設定
</button>
<button
class="secondary-btn"
onclick="removeBackgroundImage()"
>
背景画像を消去
</button>
</div>
</div>
<div class="setting-section">
<h3>
背景カラー
</h3>
<div class="setting-row">
<div>
<div>
背景カラー
</div>
<div class="setting-message">
背景画像が設定されていない場合のみ有効です。
</div>
</div>
<div class="setting-control">
<input
id="backgroundColorSetting"
type="color"
value="${settings.backgroundColor}"
${settings.backgroundImageName ? "disabled" : ""}
>
</div>
</div>
${
settings.backgroundImageName
? `
<div class="setting-message">
背景画像が設定されているため、
背景カラーは現在無効です。
</div>
`
: ""
}
</div>
<div class="setting-section">
<h3>
背景透過
</h3>
<div class="setting-row">
<div>
<div>
背景透過
</div>
<div class="setting-message">
背景画像が設定されている場合のみ有効です。
</div>
</div>
<div class="setting-control">
<input
id="backgroundOpacity"
type="range"
min="0"
max="100"
step="10"
value="${Math.round((1 - Number(settings.backgroundOpacity ?? 1)) * 100)}"
${settings.backgroundImageName ? "" : "disabled"}
style="width:180px;"
>
<span
id="backgroundOpacityValue"
class="range-value"
>
${Math.min(100, Math.max(0, Math.round((1 - Number(settings.backgroundOpacity ?? 1)) * 10) * 10))}%
</span>
</div>
</div>
${
!settings.backgroundImageName
? `
<div class="setting-message">
背景画像が設定されていないため、
背景透過は現在無効です。
</div>
`
: ""
}
</div>
<div class="form-actions">
<button
class="primary-btn"
onclick="saveBackgroundSettings()"
>
背景設定を保存
</button>
</div>
`;
const opacityInput =
document.getElementById(
"backgroundOpacity"
);
if (opacityInput) {
opacityInput.addEventListener(
"input",
e => {
document
.getElementById(
"backgroundOpacityValue"
)
.textContent =
Number(e.target.value) + "%"
}
);
}
}
async function saveBackgroundSettings() {
const colorInput =
document.getElementById(
"backgroundColorSetting"
);
const opacityInput =
document.getElementById(
"backgroundOpacity"
);
if (
colorInput &&
!colorInput.disabled
) {
settings.backgroundColor =
colorInput.value;
}
if (
opacityInput &&
!opacityInput.disabled
) {
settings.backgroundOpacity =
1 - Number(opacityInput.value) / 100;
}
await saveSettings();
applySettings();
renderBackgroundPage();
}
async function saveBackgroundImage() {
const input =
document.getElementById(
"backgroundImageInput"
);
if (!input.files.length) {
alert(
"画像を選択してください。"
);
return;
}
const file =
input.files[0];
const handle =
await directoryHandle
.getFileHandle(
"background-image",
{
create: true
}
);
const writable =
await handle.createWritable();
await writable.write(
file
);
await writable.close();
settings.backgroundImageName =
"background-image";
settings.backgroundOpacity =
1 - Number(
document.getElementById(
"backgroundOpacity"
).value
) / 100;
await saveSettings();
await applyBackground();
renderBackgroundPage();
}
async function removeBackgroundImage() {
try {
await directoryHandle
.removeEntry(
"background-image"
);
} catch (error) {
/* 存在しない場合は無視 */
}
settings.backgroundImageName =
"";
await saveSettings();
document.body.style
.backgroundImage = "";
applySettings();
renderBackgroundPage();
}
async function applyBackground() {
const root = document.documentElement;
if (
!directoryHandle ||
!settings.backgroundImageName
) {
document.body.style.backgroundImage = "";
document.body.style.backgroundColor =
settings.backgroundColor;
root.style.setProperty(
"--background-image",
"none"
);
root.style.removeProperty("--card-opacity");
return;
}
try {
const handle = await directoryHandle.getFileHandle(
settings.backgroundImageName,
{ create: false }
);
const file = await handle.getFile();
const url = URL.createObjectURL(file);
document.body.style.backgroundImage = "";
document.body.style.backgroundColor =
settings.backgroundColor;
root.style.setProperty(
"--background-image",
`url("${url}")`
);
// 透過するのはカード・枠の背景色だけ
const cardOpacity = Math.min(
1,
Math.max(
0,
Number(settings.backgroundOpacity ?? 1)
)
);
root.style.setProperty(
"--card-opacity",
String(cardOpacity)
);
} catch (error) {
console.error(
"背景画像の読み込みに失敗しました。",
error
);
root.style.setProperty(
"--background-image",
"none"
);
root.style.removeProperty("--card-opacity");
document.body.style.backgroundImage = "";
document.body.style.backgroundColor =
settings.backgroundColor;
}
}
/* =========================================================
初期化
========================================================= */
async function resetRecords() {
const first =
confirm(
"警告\n\n" +
"記録を初期化します。\n" +
"年別データ、月別データ、年メモがすべて削除されます。\n\n" +
"この操作は元に戻せません。\n\n" +
"本当に初期化しますか?"
);
if (!first) {
return;
}
const second =
confirm(
"最終確認\n\n" +
"保存フォルダ内の記録データと一時保存データを削除します。\n" +
"実行してよろしいですか?"
);
if (!second) {
return;
}
try {
/*
* 正式データをファイルごと削除
*/
await deleteJsonFile(
DATA_FILE
);
/*
* 一時保存データもファイルごと削除
*/
await deleteJsonFile(
DRAFT_FILE
);
/*
* アプリ内のデータも初期化
*/
data = {
months: {},
bonuses: [],
yearMemos: {}
};
editingMonthKey = null;
/*
* 年別画面を表示
*/
renderYears();
} catch (error) {
console.error(
"記録初期化に失敗しました。",
error
);
alert(
"記録の初期化に失敗しました。\n\n" +
error.message
);
}
}
/* =========================================================
一覧ページ
========================================================= */
async function saveListSettings() {
try {
await saveSettings();
} catch (error) {
console.error(
"一覧設定の保存に失敗しました。",
error
);
alert(
"一覧設定の保存に失敗しました。\n\n" +
error.message
);
}
}
function showListMemo(button) {
const existing = document.getElementById("listMemoPopup");
// 開いているメモをクリックした場合は閉じる
if (
existing &&
existing.dataset.sourceButton === button.dataset.memoId
) {
existing.remove();
return;
}
// 別のメモが開いていれば閉じる
if (existing) {
existing.remove();
}
const popup = document.createElement("div");
popup.id = "listMemoPopup";
popup.dataset.sourceButton = button.dataset.memoId || "";
Object.assign(popup.style, {
position: "fixed",
zIndex: "99999",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
width: "min(420px, calc(100vw - 32px))",
maxHeight: "70vh",
overflowY: "auto",
padding: "18px",
boxSizing: "border-box",
border: "1px solid var(--border-color)",
borderRadius: "10px",
background: "var(--card-color)",
color: "var(--text-color)",
boxShadow: "0 4px 20px rgba(0,0,0,0.25)",
whiteSpace: "pre-wrap",
overflowWrap: "anywhere"
});
const title = document.createElement("div");
title.textContent = "明細メモ";
title.style.fontWeight = "bold";
title.style.marginBottom = "12px";
const content = document.createElement("div");
content.textContent = button.dataset.memo || "";
popup.append(title, content);
document.body.appendChild(popup);
// メモの外側をクリックすると閉じる
const closeOnOutsideClick = event => {
if (
!popup.contains(event.target) &&
!event.target.closest(".list-memo-button")
) {
popup.remove();
document.removeEventListener(
"click",
closeOnOutsideClick,
true
);
}
};
// 表示直後のクリックで閉じないよう、次の操作から判定
setTimeout(() => {
if (popup.isConnected) {
document.addEventListener(
"click",
closeOnOutsideClick,
true
);
}
}, 0);
}
async function changeListSize(size) {
settings.listSize = size;
await saveListSettings();
renderListPage();
}
async function toggleListYear(year, checked) {
const selected = new Set(
Array.isArray(settings.listYears)
? settings.listYears.map(String)
: []
);
if (checked) {
selected.add(String(year));
} else {
selected.delete(String(year));
}
settings.listYears = [...selected].sort(
(a, b) => Number(b) - Number(a)
);
await saveListSettings();
renderListPage(true);
}
async function clearListYears() {
settings.listYears = [];
await saveListSettings();
// 現在の年月パネルの展開状態を維持する
const panel = document.getElementById("listYearPanel");
const isExpanded = panel
? !panel.classList.contains("hidden")
: false;
renderListPage(isExpanded);
}
function renderListPage(keepYearPanel = false) {
const main = document.getElementById("main");
const size = Math.min(
5,
Math.max(1, Number(settings.listSize ?? 4))
);
const selectedYears = Array.isArray(settings.listYears)
? settings.listYears.map(String)
: [];
const years = getYears().map(String);
const normalRows = Object.entries(data.months || {})
.map(([key, month]) => ({
type: "normal",
yearMonth: month.yearMonth || key,
data: month
}));
const bonusRows = (data.bonuses || [])
.map(bonus => ({
type: "bonus",
yearMonth: bonus.yearMonth,
data: bonus
}));
const rows = [
...normalRows,
...bonusRows
]
.filter(row => {
if (!row.yearMonth) {
return false;
}
if (selectedYears.length === 0) {
return true;
}
return selectedYears.includes(
String(row.yearMonth).substring(0, 4)
);
})
.sort((a, b) => {
const dateCompare =
String(b.yearMonth).localeCompare(
String(a.yearMonth)
);
if (dateCompare !== 0) {
return dateCompare;
}
// 同じ年月は通常月を先、賞与を後にする
if (a.type !== b.type) {
return a.type === "normal" ? -1 : 1;
}
return 0;
});
const headers = [
"年月",
"所得",
"総支給",
"支出",
"収支",
"金融1",
"金融2",
"資産",
"残業H",
"残業代",
"ローン",
"電気量",
"電気",
"ガス量",
"ガス",
"水道量",
"水道",
"積立",
"メモ",
"臨時1",
"収入1",
"臨時2",
"収入2",
"臨時3",
"収入3",
"臨時4",
"収入4",
"出費m",
"出費1",
"出費m2",
"出費2",
"出費m3",
"出費3",
"出費m4",
"出費4"
];
const moneyCell = value =>
money(Number(value || 0));
const detailMemoCell = memo => {
if (!String(memo || "").trim()) {
return '<td class="list-detail-memo">-</td>';
}
return `
<td class="list-detail-memo">
<button
type="button"
class="list-memo-button"
data-memo="${escapeHtml(memo)}"
onclick="showListMemo(this)"
title="メモを別ウィンドウで表示"
>📝</button>
</td>
`;
};
const normalCell = value =>
`<td>${escapeHtml(value)}</td>`;
const numericCell = value =>
`<td>${moneyCell(value)}</td>`;
const rowsHtml = rows.map(row => {
const d = row.data;
if (row.type === "bonus") {
const redCell = value =>
`<td class="list-bonus">${escapeHtml(value)}</td>`;
const redMoney = value =>
`<td class="list-bonus">${moneyCell(value)}</td>`;
const dash = "<td>-</td>";
return `
<tr>
<td class="list-bonus">
<span
class="year-link"
onclick="editBonus('${escapeHtml(d.id)}')"
>${escapeHtml(row.yearMonth)}</span>
</td>
${redMoney(d.income)}
${redMoney(d.grossPay)}
${dash}
${dash}
${redMoney(d.financialAsset1)}
${dash}
${dash}
${dash}
${dash}
${dash}
${dash}
${dash}
${dash}
${dash}
${dash}
${dash}
${dash}
${
String(d.memo || "").trim()
? `<td class="list-memo-cell list-detail-memo list-bonus">
<button
type="button"
class="list-memo-button"
data-memo-id="list-bonus-memo-${escapeHtml(d.id)}"
data-memo="${escapeHtml(d.memo)}"
onclick="showListMemo(this)"
title="賞与のメモを表示"
>📃</button>
</td>`
: '<td class="list-memo-cell list-detail-memo list-bonus">-</td>'
}
${Array.from({ length: 16 }, () => dash).join("")}
</tr>
`;
}
const income = Number(d.income || 0);
const expense = Number(d.expense || 0);
const loan = Number(d.loan || 0);
const saving = Number(d.saving || 0);
// 既存仕様:収支 = 所得 - 支出 - ローン - 積立
const balance = income - expense - loan - saving;
const temporaryIncome = Array.isArray(d.temporaryIncome)
? d.temporaryIncome
: [];
const expenses = Array.isArray(d.expenses)
? d.expenses
: [];
const electricity = d.electricity || {};
const gas = d.gas || {};
const water = d.water || {};
const incomeDetails = [];
const expenseDetails = [];
for (let i = 0; i < 4; i++) {
const item = temporaryIncome[i] || {};
incomeDetails.push(
detailMemoCell(item.memo),
item.memo || Number(item.amount || 0) !== 0
? numericCell(item.amount)
: '<td>-</td>'
);
}
for (let i = 0; i < 4; i++) {
const item = expenses[i] || {};
expenseDetails.push(
detailMemoCell(item.memo),
item.memo || Number(item.amount || 0) !== 0
? numericCell(item.amount)
: '<td>-</td>'
);
}
return `
<tr>
<td>
<span
class="year-link"
onclick="editMonth('${escapeHtml(row.yearMonth)}')"
>${escapeHtml(row.yearMonth)}</span>
</td>
${numericCell(d.income)}
${numericCell(d.grossPay)}
${numericCell(d.expense)}
${numericCell(balance)}
${numericCell(d.financialAsset1)}
${numericCell(d.financialAsset2)}
${numericCell(d.asset)}
${normalCell(d.overtimeHours ?? 0)}
${numericCell(d.overtimePay)}
${numericCell(d.loan)}
${normalCell(electricity.kwh ?? 0)}
${numericCell(electricity.amount)}
${normalCell(gas.volume ?? 0)}
${numericCell(gas.amount)}
${normalCell(water.volume ?? 0)}
${numericCell(water.amount)}
${numericCell(d.saving)}
<td class="list-memo-cell list-detail-memo">
${
String(d.memo || "").trim()
? `<button
type="button"
class="list-memo-button"
data-memo-id="list-month-memo-${escapeHtml(row.yearMonth)}"
data-memo="${escapeHtml(d.memo)}"
onclick="showListMemo(this)"
title="メモを表示"
>📃</button>`
: "-"
}
</td>
${incomeDetails.join("")}
${expenseDetails.join("")}
</tr>
`;
}).join("");
main.innerHTML = `
<div class="list-toolbar">
<button
type="button"
id="listYearToggle"
class="${keepYearPanel ? "active" : ""}"
>年月 ▼</button>
<button
type="button"
id="listYearClear"
>解除</button>
<span>サイズ</span>
${[1, 2, 3, 4, 5].map(n => `
<button
type="button"
class="${size === n ? "active" : ""}"
data-list-size="${n}"
>${n}</button>
`).join("")}
<span>
${rows.length}件
</span>
</div>
<div
id="listYearPanel"
class="list-year-panel ${keepYearPanel ? "" : "hidden"}"
>
${years.map(year => `
<label>
<input
type="checkbox"
data-list-year="${year}"
${selectedYears.includes(year) ? "checked" : ""}
>
${year}
</label>
`).join("")}
</div>
<div class="list-table-wrapper list-size-${size}">
<table class="list-table">
<thead>
<tr>
${headers.map(h => `<th>${h}</th>`).join("")}
</tr>
</thead>
<tbody>
${rowsHtml || `
<tr>
<td colspan="${headers.length}"
class="list-empty">
該当する記録がありません。
</td>
</tr>
`}
</tbody>
</table>
</div>
`;
document.getElementById("listYearToggle")
.addEventListener("click", () => {
const panel = document.getElementById("listYearPanel");
const button = document.getElementById("listYearToggle");
panel.classList.toggle("hidden");
const isExpanded = !panel.classList.contains("hidden");
button.textContent = isExpanded
? "年月 △"
: "年月 ▽";
button.classList.toggle("active", isExpanded);
});
document.querySelectorAll("[data-list-size]")
.forEach(button => {
button.addEventListener("click", () => {
changeListSize(Number(button.dataset.listSize));
});
});
document.querySelectorAll("[data-list-year]")
.forEach(checkbox => {
checkbox.addEventListener("change", () => {
toggleListYear(
checkbox.dataset.listYear,
checkbox.checked
);
});
});
document.getElementById("listYearClear")
.addEventListener("click", clearListYears);
}
/* =========================================================
HTMLエスケープ
========================================================= */
function escapeHtml(value) {
return String(
value ?? ""
)
.replace(
/&/g,
"&"
)
.replace(
/</g,
"<"
)
.replace(
/>/g,
">"
)
.replace(
/"/g,
"""
)
.replace(
/'/g,
"'"
);
}
/* =========================================================
色
========================================================= */
function hexToRgba(
hex,
alpha
) {
hex =
hex.replace(
"#",
""
);
if (hex.length === 3) {
hex =
hex[0] + hex[0] +
hex[1] + hex[1] +
hex[2] + hex[2];
}
const r =
parseInt(
hex.substring(0,2),
16
);
const g =
parseInt(
hex.substring(2,4),
16
);
const b =
parseInt(
hex.substring(4,6),
16
);
return `
rgba(
${r},
${g},
${b},
${alpha}
)
`;
}
/* =========================================================
グローバル公開
========================================================= */
window.showPage =
showPage;
window.showYearDetail =
showYearDetail;
window.openYearMemo =
openYearMemo;
window.saveYearMemo =
saveYearMemo;
window.closeModal =
closeModal;
window.addEditorLine =
addEditorLine;
window.saveMonthForm =
saveMonthForm;
window.editMonth =
editMonth;
window.saveSettingPage =
saveSettingPage;
window.saveBackgroundImage =
saveBackgroundImage;
window.removeBackgroundImage =
removeBackgroundImage;
window.resetRecords =
resetRecords;
</script>
</body>
</html>
185,208 chars · codepastes.com