Edited at

Vue.jsとAWSでつくる、シングルページの家計簿アプリ


概要


作ったもの

最近お金が減るのが早くなった気がしたので、家計簿をつけようと思い立ちました。アプリをダウンロードしてきてもよいのですが、せっかくなので自分で作ることにしました。

タブレット、スマホ、パソコンのどれでも見れるようレスポンシブ対応を行ったのですが、タブレット(iPad)からみると以下の画像のようなものになっています。

チャート画面

IMG_0004.PNG

IMG_0005.PNG

記入画面

IMG_0006.PNG

取引一覧画面

IMG_0007.PNG


構成

静的ファイルはAWSのS3にホスティングし、お金のやり取りに関する情報はRDSに貯め、情報の追加・削除・修正にはLambdaとAPI Gatewayを用いて作ったAPIを通して行います。LambdaのコードはPythonで書いています。

簡単な図にすると、以下の画像のようになります。

Screen Shot 2019-02-19 at 22.35.06.png


共通部分


フロントエンド

フロントはVue.jsを使って実装します。以下の画像の赤枠で囲った部分に表示するコンポーネントを切り替えます(URLの遷移はしないSPAとなります。)。

IMG_0004.PNG

この部分のソースコードは以下のようになっています。

クリックしてコードを展開(JavaScript)


main.js

const baseUrl = "https://XXX?"

const config = {headers: {'Content-Type': 'application/json'}}

var app = new Vue({
el:"#app",
data:{
showSideMenu:false,
showSummary:true,
showInput:false,
showDetail:false,
dataset:[],
fromDate:null,
toDate:null
},
methods:{
/*
* 変数を初期化
*/

init(){
this.showSummary=false;
this.showInput = false;
this.showDetail=false;
},
/*
* APIに情報を問い合わせる
*/

clicked(){
// パラメータとして指定するためフォーマット整形(YYYY-MM-DD => YYYYMMDD)
const fromStr = this.fromDate.slice(0,4) + this.fromDate.slice(5,7) + this.fromDate.slice(8,10)
const toStr = this.toDate.slice(0,4) + this.toDate.slice(5,7) + this.toDate.slice(8,10)
const getUrl = baseUrl + "from=" + fromStr + "&to=" + toStr;
axios.get(getUrl, config).then((response) => {
let rawData = response.data.body;
for (let data of rawData){
data['datetime_date'] = new Date(data['date'])
}
rawData.sort(function(a,b) {
return (a.datetime_date < b.datetime_date ? -1 : 1);
});
this.dataset = rawData;
this.showSideMenu = false;
})
},
/*
* サイドメニューを開閉する
*/

clickBar(){
this.showSideMenu = !this.showSideMenu;
}
},
created(){
const today = new Date()
let MM = today.getMonth() + 1;
if(today.getMonth() < 9){
MM = "0" + (today.getMonth() + 1);
}
const fromDateTemp = new Date(today.getFullYear(), today.getMonth(), 1)
this.fromDate = fromDateTemp.getFullYear() + "-" + MM + "-01";
const nextMonth = today.getMonth() + 1
const toDateTemp = new Date(today.getFullYear(), nextMonth, 0)
this.toDate = toDateTemp.getFullYear() + "-" + MM + "-" + toDateTemp.getDate();
this.clicked();
},
})






クリックしてコードを展開(HTML)


index.html

<html>

<head>
<link rel="stylesheet" type="text/css" href="./css/all.css">
<link rel="stylesheet" type="text/css" href="./css/accounting.css">
<title>Accounting App</title>
<meta http-equiv="content-type" charset="utf-8">
<meta name="viewport" content="width=device-width, maximum-scale=1.0, minimum-scale=0.5,user-scalable=yes,initial-scale=1.0" />
</head>
<body>
<div id="app">

<!-- ヘッダー -->
<div class="header-bar">
<p class="header-text">Accounting App</p>
</div>
<!-- ヘッダーここまで -->

<!-- サイドバー -->
<div class="navi-bar" id="app">
<div class="icon pointable" @click="clickBar">
<i class="fas fa-bars"></i>
</div>
<div class="side-bar" :class="{'hidden':!showSideMenu}">
<div class="side-bar-list" @click="init(); clicked(); showSummary=true">
<i class="fas fa-chart-line"></i> サマリー
</div>
<div class="side-bar-list" @click="init(); clicked(); showInput=true">
<i class="fas fa-pen-square"></i> 記入
</div>
<div class="side-bar-list" @click="init(); clicked(); showDetail=true">
<i class="fas fa-table"></i> 詳細
</div>
<div class="side-bar-list">
<i class="fas fa-cog"></i> 設定
</div>
</div>
</div>
<!-- サイドバーここまで -->

<!-- メインコンテンツ -->
<div class="main-contents">
<div class="date-select">
<p>
<input v-model="fromDate" type="date" class="date-select-input"> ~ <input v-model="toDate" type="date" class="date-select-input">
<button class="pointable date-select-button" @click="clicked()">更新</button>
</p>
</div>
<chart :dataset="dataset" v-if="showSummary"></chart>
<post v-else-if="showInput"></post>
<detail :dataset="dataset" v-else-if="showDetail" v-on:deleted="clicked()"></detail>
</div>
<!-- メインコンテンツここまで -->
</div>
<!-- チャート描画 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.3/Chart.js"></script>
<!-- Vue.js -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.22/dist/vue.js"></script>
<!-- ajax -->
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="./js/charts/pieChart.js"></script>
<script src="./js/charts/lineChart.js"></script>
<script src="./js/summary.js"></script>
<script src="./js/input.js"></script>
<script src="./js/detail.js"></script>
<script src="./js/main.js"></script>
</body>
</html>




サーバサイド

基本的に私しか使わないので、アクセス元となるIPアドレスは限られます。そのため、特定のIPアドレス以外ははじくようにS3を設定します。

こちらの記事を参考に設定しました。


チャートの画面


線グラフ

特定期間の支出額の遷移を線グラフとして表示します。ソースコードは以下です。

クリックしてコードを展開(JavaScript)


lineChart.js

Vue.component('line-chart', {

template: `
<div class="summary-content" style="position: relative; max-width:80vw; height:40vh; margin:auto;">
<canvas id="lineChart"></canvas>
</div>
`
,
data: function () {
return {
"dataList": [],
"labelList": [],
}
},
props: [
"dataset"
],
watch: {
dataset: function () {
this.init();
this.editLineChartData();
},
labelList: function () {
this.createLineChart();
}
},
methods: {
/*
* 変数の初期化
*/

init() {
this.dataList = [];
this.labelList = [];
},
/*
* パイチャート表示用のデータを作成
*/

editLineChartData() {
let prevDate = ""
let dataListTemp = []
let labelListTemp = []

for (let data of this.dataset){
if(data["ifearning"] == 1){
continue;
}
//前のデータと同一の日付であれば、加算する
if (prevDate == data["date"]){
console.log(data)
dataListTemp[dataListTemp.length - 1] += data["amount"]
}else{
dataListTemp.push(data["amount"])
labelListTemp.push((data["datetime_date"].getMonth() + 1 ) + "" + data["datetime_date"].getDate() + "")
}
prevDate = data["date"]
}
console.log(dataListTemp)
console.log(labelListTemp)
this.dataList = dataListTemp;
this.labelList = labelListTemp;
},
/*
* パイチャートの描画
*/

createLineChart() {
//グラフ描画
let config = {
type: 'line',
data: {
labels: this.labelList,
datasets:[{
label:"支出",
borderColor: "#3cba9f",
data:this.dataList,
fill:true,
lineTension:0.1,
borderWidth:4,
pointRadius:1,
borderJoinStyle:"round"
}]
},
options: {
legend: {
display: false
},
maintainAspectRatio: false,
scales: {
xAxes: [{
ticks: {
fontSize: 10
}
}]
}
}
};
chart = new Chart(document.getElementById('lineChart').getContext('2d'), config);
}
},
created() {
this.init();
this.editLineChartData();
document.addEventListener("resize", this.createPieChart);
},
})






ここでは、親コンポーネントから受け取った値をもとにチャートの描画を行うのですが、このデータはAPIから非同期で取得するものであるため、watchプロパティで変化を監視しないと、うまく画面が切り替わりません。watch以外でも、computedプロパティでもうまくいくそうです。


円グラフ

特定期間の支出の種類を円グラフとして表示します。ソースコードは以下です。

クリックしてコードを展開(JavaScript)


donutChart.js

Vue.component('pie-chart', {

template: `
<div class="summary-content" style="position: relative; max-width:100vw; height:60vh">
<canvas id="pieChart"></canvas>
</div>
`
,
data: function () {
return {
"dataList": [],
"labelList": [],
"colorList": []
}
},
props: [
"dataset"
],
watch: {
dataset: function () {
this.init();
this.editPieChartData();
},
labelList: function () {
this.createPieChart();
}
},
methods: {
/*
* 変数の初期化
*/

init() {
this.dataList = [];
this.labelList = [];
this.colorList = [];
},
/*
* パイチャート表示用のデータを作成
*/

editPieChartData() {
rankedByAmount = [];
for (let data of this.dataset) {
if (data.ifearning == 0) {
if (data.type in rankedByAmount) {
rankedByAmount[data.type]["data"] += data.amount;
} else {
rankedByAmount[data.type] = { "data": data.amount, "name": data.type };
}
}
}
rankedByAmount = Object.values(rankedByAmount)
rankedByAmount.sort(function (a, b) {
if (a.data < b.data) return 1;
if (a.data > b.data) return -1;
return 0;
});
count = 0;
for (let data of rankedByAmount) {
if (count < 10) {
this.dataList.push(data.data);
// ラベルの文字数が長ければ省略
if(data.name.length > 10){
this.labelList.push(data.name.slice(1,11) + "...");
}else{
this.labelList.push(data.name);
}
this.colorList.push(pieColors[count])
} else {
let addedAmount = this.dataList[9] + data.amount;
this.dataList.splice(9, 1, addedAmount)
this.labelList[9].splice(9, 1, "その他")
}
count += 1
}
},
/*
* パイチャートの描画
*/

createPieChart() {
//グラフ描画
let config = {
type: 'doughnut',
data: {
labels: this.labelList,
datasets: [{
data: this.dataList,
backgroundColor: this.colorList
}],
},
options: {
legend: {
position: "bottom",
labels: {
fontSize: 16,
},
padding: 0
},
maintainAspectRatio: false,
}
};
chart = new Chart(document.getElementById('pieChart').getContext('2d'), config);
}
},
created() {
this.init();
this.editPieChartData();
document.addEventListener("resize", this.createPieChart);
},
})






サーバサイド

AWS上のAPIはRESTfulな構成としており、チャート描画処理ではGETメソッドを用いて情報を取得しています。

以下がPythonによるソースコードです。

クリックしてコードを展開(Python)


GET.py

# coding: utf-8


import pymysql.cursors
import pymysql
import json
import os

def main(event,context):
"""
メインのハンドラ
Args:
event:リクエストのパラメータなど
context:リクエストの詳細
Returns:
json:取得した情報
"""

print(event)
result_body,status = get(event['from'],event['to'],event['ifearning'],event['type'],event['ifcash'])
result = {}
result["status"] = status
result["body"] = result_body
return result

def get(from_date,to_date,if_earning,type,if_cash):
"""
DBにクエリを送る
Args:
from_date:取得期間の開始日
to_date:取得期間の終了日
if_earning:収入であるかどうか
type:種類
if_cash:現金であるかどうか
Returns:
dict[]:DBより取得した情報の配列
str:ステータス
"""

connection = pymysql.connect(host=os.environ['host'],
user=os.environ['user'],
password=os.environ['password'],
db=os.environ['db'],
cursorclass=pymysql.cursors.DictCursor)

# 必須パラメータをチェック
if (from_date == '') or (to_date == ''):
return None, "lacking required parameter(s)"

# SQLインジェクション対策
invalid_chars = [';','*','-']
for char in invalid_chars:
type.replace(char,'')

# クエリの組み立て
sql = "select * from transactions where date between " + from_date + " and " + to_date
if if_earning != '':
sql += " and ifearing = " + if_earning
if if_cash != '':
sql += " and ifcash = " + if_cash
if type != '':
sql += " and type = " + type
sql += ";"

# クエリの送信
result_dict = []
try:
with connection.cursor() as cursor:
cursor.execute(sql)
connection.commit()
for row in cursor:
result_dict.append(row)
finally:
connection.close()

# 日付型はjson型の応答に含めるのが大変なので、文字列に変える
for result in result_dict:
result['date'] = str(result['date'])

return result_dict, "success"







記入画面


フロントエンド

ユーザの入力を受け付ける部分のソースコードは、以下のようになっています。

クリックしてコードを展開(JavaScript)


input.js

const Url = "https://XXX"

Vue.component('post', {
template: `
<div class="input-area">
<table class="input_table">
<tr>
<th>入力項目</th>
<th>入力欄</th>
<th>入力状況</th>
</tr>
<tr>
<td>金額</td>
<td><input type = "number" v-model="amount" class="user_input_box"> </td>
<td align="center">
<div v-if="amountValid"><i class="far fa-check-circle fa-lg my-green"></i></div>
<div v-if="!amountValid"><i class="far fa-times-circle fa-lg my-red" ></i></div>
</td>
</tr>
<tr>
<td>日付</td>
<td><input type = "date" v-model="date" class="user_input_box"> </td>
<td align="center">
<div v-if="dateValid"><i class="far fa-check-circle fa-lg my-green"></i></div>
<div v-if="!dateValid"><i class="far fa-times-circle fa-lg my-red" ></i></div>
</td>
</tr>
<tr>
<td>種別</td>
<td><input type = "text" v-model="type" class="user_input_box"> </td>
<td align="center">
<div v-if="typeValid"><i class="far fa-check-circle fa-lg my-green"></i></div>
<div v-if="!typeValid"><i class="far fa-times-circle fa-lg my-red" ></i></div>
</td>
</tr>
<tr>
<td>収入である</td>
<td align="center">
<select name="ifEarning" v-model="ifEarning" class="user_input_box">
<option value="true">YES</option>
<option value="false">NO</option>
</select>
</td>
<td align="center">
<div v-if="ifEarningValid"><i class="far fa-check-circle fa-lg my-green"></i></div>
<div v-if="!ifEarningValid"><i class="far fa-times-circle fa-lg my-red" ></i></div>
</td>
</tr>
<tr>
<td>現金である</td>
<td>
<select name="ifCash" v-model="ifCash" class="user_input_box">
<option value="true">YES</option>
<option value="false">NO</option>
</select>
</td>
<td align="center">
<div v-if="ifCashValid"><i class="far fa-check-circle fa-lg my-green"></i></div>
<div v-if="!ifCashValid"><i class="far fa-times-circle fa-lg my-red" ></i></div>
</td>
</tr>
<tr>
<td>コメント</td>
<td><input type="text" v-model="comment" class="user_input_box"> </td>
</tr>
</table>
<button class="input-area-button pointable" @click="postData()">送信</button><button class="input-area-button pointable" :class={hidden:!showDeleteButton} @click="deleteData()">削除</button>
</div>
`
,
props:[
"showndata"
],
data: function () {
return {
"amount":null,
"date":null,
"type":"",
"ifEarning":null,
"ifCash":null,
"comment":"",
"amountValid":false,
"dateValid":false,
"typeValid":false,
"ifEarningValid":false,
"ifCashValid":false,
"showDeleteButton":false
}
},
watch: {
amount: function (newVal) {
this.amountValid = this.validateNum(newVal);
},
date: function(newVal){
this.dateValid = this.validateDate(newVal);
},
type: function(newVal){
this.typeValid = this.validateText(newVal);
},
ifEarning: function(newVal){
this.ifEarningValid = this.validateBool(newVal);
},
ifCash: function(newVal){
this.ifCashValid = this.validateBool(newVal);
}
},
methods: {
/*
* 数値の入力事項のチェック
* @ param num {Integer} - チェック対象の数値
* @ returns {boolean} - 適正な値かどうか
*
*/

validateNum(num) {
if(num <= 0){
return false;
}
if(num % 1 != 0){
return false;
}
return true;
},
/*
* 文字列の入力事項のチェック
* @ param text {String} - チェック対象の文字
* @ returns {boolean} - 適正な値かどうか
*
*/

validateText(num) {
if (num.length < 2) {
return false;
}
if (num.length > 32) {
return false;
}
return true;
},
/*
* 日付の入力事項のチェック
* @ param date {Object} - チェック対象の日付
* @ returns {boolean} - 適正な値かどうか
*
*/

validateDate(date) {
try{
date = new Date(date)
if(date.getFullYear() <= 2018 || date.getFullYear() > 2050){
return false;
};
if(date.getMonth() < 0 || date.getMonth() >= 12){
return false;
};
if(date.getDate() < 1 || date.getDate() > 31){
return false;
}
return true;
}catch(err){
return false;
}
},
/*
* 真偽の入力事項のチェック
* @ param bool {boolean} - チェック対象の値
* @ returns {boolean} - 適正な値かどうか
*
*/

validateBool(bool) {
return true;
},
/*
* データをポスト
*/

postData(){
if(!this.amountValid || !this.dateValid || !this.ifEarningValid || !this.ifCashValid || !this.typeValid){
alert("入力が未完です")
return;
}
// 詳細画面から遷移してきた場合は、データをアップデートする
if(this.showndata){
axios.post(Url,{
amount:this.amount,
date:this.date,
type:this.type,
ifEarning:this.ifEarning,
ifCash:this.ifCash,
comment:this.comment,
id:this.showndata.id
}).then((response) => {
alert("データを更新しました")
this.$emit("success");
})
}else{
axios.post(Url,{
amount:this.amount,
date:this.date,
type:this.type,
ifEarning:this.ifEarning,
ifCash:this.ifCash,
comment:this.comment,
id:""
}).then((response) => {
alert("データを追加しました")
this.$emit("success");
})
}
},
/*
* データを削除
*/

deleteData(){
if(!this.showndata){
alert("削除するデータがありません")
return
}
axios.delete(Url,{data:{id:this.showndata.id}}).then((response) => {
alert("データを削除しました")
this.$emit("success");
})
}
},
created(){
if(this.showndata){
// 詳細画面から遷移してきた場合は、「削除」ボタンを表示
this.showDeleteButton = true;
this.amount = this.showndata.amount;
this.date = this.showndata.date;
this.type = this.showndata.type;
if(this.showndata.ifearning == 0){
this.ifEarning = "false"
}else{
this.ifEarning = "true"
}
if(this.showndata.ifcash == 0){
this.ifCash = "false"
}else{
this.ifCash = "true"
}
this.comment = this.showndata.comment
}else{
this.showDeleteButton = false;
}
}
})







サーバサイド

新しくデータを加えるには、POSTメソッドを用います。以下がソースコードです。

クリックしてコードを展開(Python)


POST.py

# coding: utf-8


import pymysql.cursors
import pymysql
import json
import os

def main(event,context):
"""
メインのハンドラ
Args:
event:リクエストのパラメータなど
context:リクエストの詳細
Returns:
json:取得した情報
"""

print(event)
print(json.dumps(event))
result_body,status = post(event["body"]["amount"],event["body"]["date"],event["body"]["type"],event["body"]["ifEarning"],event["body"]["ifCash"],event["body"]["comment"],event["body"]["id"])
result = {}
result["status"] = status
result["body"] = result_body
return result

def post(amount,date,type,if_earning,if_cash,comment,update_id):
"""
DBにクエリを送る
Args:
amount:金額
date:日付
type:種別
if_earning:収入かどうか
if_cash:現金かどうか
comment:コメント
update_id:(データをアップデートする場合)id
Returns:
dict[]:DBより取得した情報の配列
str:ステータス
"""

print(amount)
print(str(date))
print(type)
print(if_earning)
print(if_cash)
print(comment)
connection = pymysql.connect(host=os.environ['host'],
user=os.environ['user'],
password=os.environ['password'],
db=os.environ['db'],
cursorclass=pymysql.cursors.DictCursor)

# 必須パラメータをチェック
if (amount == '') or (date == '') or (type == '') or (if_earning == '') or (if_cash == ''):
return None, "lacking one or more required parameter(s)"

# 真偽値を数値に変換
if (if_earning == "true"):
if_earning = 1
else:
if_earning = 0
if (if_cash == "true"):
if_cash = 1
else:
if_cash = 0

# SQLインジェクション対策
invalid_chars = [';','*','-']
for char in invalid_chars:
type.replace(char,'')
comment.replace(char,'')

# idがある場合はデータを更新する
if update_id != "":
sql = "update transactions set date='" + str(date) + "', ifearning=" + str(if_earning) + ", type='" + type + "', comment='" + comment + "', ifcash=" + str(if_cash) + ", amount=" + str(amount) + " where id = " + str(update_id) + ";"
print(sql)
else:
sql = "insert into transactions (date,ifearning,type,comment,ifcash,amount) values ('" + str(date) + "', " + str(if_earning) + ", '" + type + "', '" + comment + "', " + str(if_cash) + ", " + str(amount) + ");"
print(sql)

# クエリの送信
result_dict = []
try:
with connection.cursor() as cursor:
cursor.execute(sql)
connection.commit()
for row in cursor:
result_dict.append(row)
finally:
connection.close()

return result_dict, "success"







取引一覧画面


フロントエンド

ただ表にするだけでなく、列をクリックしたら、登録情報を変更できるよう「記入画面」へ遷移をするようにします。

クリックしてコードを展開(JavaScript)


detail.js

Vue.component('detail', {

template: `
<div class="detail-area">
<div class="detail-modal-window" :class="{ hidden: !showModal }">
<div class="go-back-button"><i class="fas fa-times-circle pointable" @click="hideModal()"></i></div>
<post v-if="showModal" :showndata="shownData" v-on:success="hideModal(); updateData()"></post>
</div>
<table class="detail-table">
<tr>
<th>日付</th>
<th>金額</th>
<th>種別</th>
</tr>
<tr v-for="data in dataset" :class="[data.ifearning == 0 ? 'outgo' : 'income']" @click="onClickItem(data)">
<td><div class="detail-table-cell">{{data.date}}</div></td>
<td><div class="detail-table-cell">{{data.amount}}</div></td>
<td><div class="detail-table-cell">{{data.type}}</div></td>
</tr>
</table>
</div>
`
,
props: [
"dataset"
],
data: function () {
return {
"showModal":false,
"shownData":{}
}
},
methods:{
/*
* モーダルウィンドウを表示する
* @params data {object} - 選択された取引データ
*/

onClickItem(data){
this.shownData = data;
this.showModal = true;
},
/*
* モーダルウィンドウを隠す
*/

hideModal(){
this.showModal = false;
},
/*
* データの削除後、画面を更新する
*/

updateData(){
this.$emit("deleted");
}
}
})







サーバサイド

データを削除するには、DELETEメソッドを用います。

クリックしてコードを展開(Python)


DELETE.py

# coding: utf-8


import pymysql.cursors
import pymysql
import json
import os

def main(event,context):
"""
メインのハンドラ
Args:
event:リクエストのパラメータなど
context:リクエストの詳細
Returns:
json:取得した情報
"""

print(event)
result_body,status = delete(event['body']['id'])
result = {}
result["status"] = status
result["body"] = result_body
return result

def delete(delete_id):
"""
DBにクエリを送る
Args:
delete_id:削除する取引のid
Returns:
dict[]:DBより取得した情報の配列
str:ステータス
"""

print(delete_id)
connection = pymysql.connect(host=os.environ['host'],
user=os.environ['user'],
password=os.environ['password'],
db=os.environ['db'],
cursorclass=pymysql.cursors.DictCursor)

# 必須パラメータをチェック
if delete_id == '' or (type(delete_id) is not int):
return None, "specify the id"

# クエリの組み立て
sql = "delete from transactions where id = " + str(delete_id) + ";"

# クエリの送信
result_dict = []
try:
with connection.cursor() as cursor:
cursor.execute(sql)
connection.commit()
for row in cursor:
result_dict.append(row)
finally:
connection.close()

return result_dict, "success"