Vue.js入門としてWEBブラウザで使える会社の歓送迎会用費用計算機を作った。

# はじめに
会社の歓送迎会の幹事。会社員ならば必ずと言っていいほどやらされる面倒くさい仕事。
2019年12月、ちょうど参加者20人を超える歓送迎会の幹事をやることになった。そこには歓送迎者に加え部長、課長、平社員勢揃いで、全員から同じ参加費を取るわけにもいかないので傾斜計算をする必要があった。

そして、いざ計算する時にやっぱり思う、「めちゃくちゃ面倒くさい...」
ゲスト3人タダで、部長が6000円で課長3人が5000円で平社員20人が4000円で…え？●●さんやっぱ出れない？→再計算が始まる。悪夢。


# できたもの
さっそくできたサイトは以下。着想からリリースまで1週間、15時間ぐらい。
「[歓送迎会の会費計算 | Cocktail -f liquor](https://cocktail-f.com/entry_fee)」
![スクショ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/161682/0fb7dd3d-d72f-3436-dc9c-f2d3d38df690.png)

# 使ったもの
簡単に傾斜計算をするためのWEBサービスを入門書だけ読んだVue.jsで作る事にした。
読んだ本→動かして学ぶ！Vue.js開発入門 (NEXT ONE)

既に自分はカクテルレシピ検索サイト「[Cocktail -f liquor](https://cocktail-f.com/)」を持っていたので、そこに機能として追加した。
レシピ検索サイト自体はPaaSにheroku、フレームワークにRails、CSSフレームワークにUIKitを使っているため、PCスマホどちらでもWEBブラウザ上で利用可能にした。


# 傾斜計算をするにあたって考えたこと
歓送迎会の傾斜計算をする場合に必要なものを考えた。いわゆる画面設計。

- 参加する全体の人数と一人分のコース料金のための予約情報の入力欄
- ゲスト、部長、課長、平社員といった参加費用の異なるグループごとの人数と参加費用の入力欄
- 全グループ参加費の合計と人数の合計
- 全グループの情報と予約情報が一致しているかの判定結果
- 残りの参加費を自動的に計算できるようにする機能（これが結構考えるの面倒だった）
- 結果を残しておけるように結果コピー機能

#Vue.js部分
自身のスキルが入門書レベルということもあり、コンポーネントは使わない非常にシンプルな構成でVue.jsは記述した。
大枠としては以下の通りで、filter,data,computed,watch,methodsの基本構成で作成することができた（全体のソースコードは最後に記述）。今回はお金と人数を扱うこともあり、Vue.filterを使用して20,000のようにコンマを自動的につけれるようにした。

```javascript
<script>
    Vue.filter('number_format', function (value) {
            ...
        });	
    new Vue({
        el: "#app",
        data: {
            price: null,
            ...
        },
        computed :{
            ...
        },
        watch :{
            ...
        },
        methods :{
            ...
        }
    })
</script>
```

## 少し止まった部分
各グループ情報ごとのグループ参加費合計（グループ人数×グループごとの参加費）を出す必要がある。グループごとの情報は連想配列を配列で保持しており、watchで各グループ情報を監視する際に連想配列の中身をwatchする場合はdeepを指定する必要があった。

参考
[[Vue]watchフックで連想配列を監視する場合、ディープウォッチャーにしておく必要がある件 - Qiita](https://qiita.com/riotam/items/ee5f0c59643d7e67c614)

```javascript
data: {
    calcs: [{c_name:"",c_price:null,c_person:null,c_total:0,c_auto:false}],
},
watch :{
    calcs: {
        handler: function(val){
            for (var i = 0; i < this.calcs.length; i++ ){
                this.calcs[i].c_total = this.calcs[i].c_price * this.calcs[i].c_person;
            }
        },
        deep: true
    }
},
```

# 最後に
歓送迎会用の費用計算機の感想・意見を募集中です。
Twitter @anemoi42 までリプライまたはDMを送っていただけると助かります。

以上、ありがとうございました。

# ソースコード
```html

<% @page_title = '歓送迎会の会費計算' %>
<% content_for :header do %>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js"></script>
<% end %>

<body>
    <div class='uk-card uk-card-default uk-card-small uk-card-body uk-margin-small'>
        <h3 class="box8 uk-margin-remove">歓送迎会の会費計算</h3>
        <div class="uk-margin-small">
        参加費用に傾斜をつける歓送迎会の会費シミュレーションが可能です。コース料金、全体参加人数を入力後、支払う金額ごとのグループを作成して最終的に参加費用と人数が予約と一致しているか確認できます。ゲストは無料、一般社員は3000円、課長は5000円、部長は6000円といった際の計算が簡単にできます。
        </div>

    </div>
    <div id="app">
        <div class='uk-card uk-card-default uk-card-small uk-card-body uk-margin-small'>
            <div class='uk-h4 uk-margin-small'>予約情報</div>
            <div>
                全体の参加費合計を出すために、一人分のコース料金と全体の参加人数を入力して下さい。
            </div>
            <hr>
            <div class='uk-grid uk-margin-small uk-flex uk-flex-middle uk-text-center' >
                 <div>１人分のコース料金：</div>
                 <div>
                    <input v-model.number="price" type="number" placeholder="例)5000" class="uk-input uk-form-width-medium" v-on:blur="complementPriceReserve">円
                </div>
                 <div>
                    <input v-model="tax" type="checkbox" class="uk-checkbox"> 税別(10%)
                </div>
            </div>
            <div class='uk-grid uk-margin-small uk-flex uk-flex-middle uk-text-center' >
                <div>
                    参加人数：
                </div>
                <div>
                    <input v-model.number="person" type="number" placeholder="例)10" class="uk-input uk-form-width-small" v-on:blur="complementPersonReserve">人
                </div>
            </div>
            <hr>
            <div class='uk-h4 uk-margin-small uk-align-right'>予約上の参加費合計： {{ total_price|number_format }}円</div>
            
        </div>
        
        <div class="tmpcalc uk-card uk-card-default uk-card-small uk-card-body uk-margin-small">
            <div class='uk-h4 uk-margin-small'>グループ情報</div>
            支払う金額ごとのグループを作成して下さい。
            <hr>
            <div v-for="(calc,c) in calcs">
                <div class="tmpcalc uk-card uk-card-group uk-card-small uk-card-body uk-margin-small">

                    <div class='uk-grid uk-margin-small uk-flex uk-flex-middle uk-text-center' >
                        <div>
                        グループ名（任意）：
                        </div>
                        <div>
                            <input v-model.number="calc.c_name" type="text" placeholder="例)ゲスト、平社員、課長" class="uk-input uk-form-width-auto">
                        </div>
                    </div>
                    <div class='uk-grid uk-margin-small uk-flex uk-flex-middle uk-text-center' > 
                        <div>
                            グループ人数：
                        </div>
                        <div>
                            <input v-model.number="calc.c_person" type="number" placeholder="例)5" v-on:blur="complementPerson(c)" class="uk-input uk-form-width-small">人
                        </div>
                    </div>
                    <div class='uk-grid uk-margin-small uk-flex uk-flex-middle uk-text-center' >
                        <div>
                            グループ参加費（１人分）：
                        </div>
                        <div>
                            <input v-model.number="calc.c_price" type="number" placeholder="例)4000" v-on:blur="complementPrice(c)" class="uk-input uk-form-width-medium">円
                        </div>

                        <div>
                            <button v-on:click="autoCalculate(c)" class="uk-button uk-button-default" uk-tooltip="title: 予約の参加費合計と一致するようにこのグループの参加費を計算します。自動計算をするには事前に他のグループ情報と、このグループの人数を入力して下さい。; pos: right">自動計算</button>
                        </div>

                    </div>
                    
                    <hr>
                    
                    <div class='uk-h4 uk-margin-small uk-text-right'>グループ参加費合計： {{ calc.c_total|number_format }}円</div>
                    <div class='uk-flex uk-flex-center' >                        
                        <div class='uk-padding-small uk-padding-remove-vertical'>
                            <button v-on:click="addList(c)" class="uk-button uk-button-default" uk-tooltip="title: グループを追加します。; pos: bottom"><span uk-icon="icon: plus-circle; ratio: 1.0" type="button" class="cursor_to_point"></span> 追加</button>
                        </div>
                        <div class='uk-padding-small uk-padding-remove-vertical'>
                            <button v-show="minus_circle" v-on:click="delList(c)" class="uk-button uk-button-default " uk-tooltip="title: このグループを削除します。; pos: bottom"><span uk-icon="icon: minus-circle; ratio: 1.0" type="button" class="cursor_to_point"></span> 削除</button>  
                        </div>                  
                    </div>
                    
                </div>
            </div>
                
            <hr>
            <div class='uk-h4 uk-margin-small ' align="right">全グループ参加費合計： {{calc_total_price|number_format}} 円</div>
            <div class='uk-h4 uk-margin-small ' align="right">全グループ参加人数合計： {{calc_total_person|number_format}} 人</div>
            
            <div v-if="sub_total_price > 0">
                <div class="uk-alert-warning uk-text-center" uk-alert>
                    予約に対して<font color="#ff4500"> {{sub_total_price|number_format}}円</font> 多く回収しています。
                    端数の場合は幹事が貰ってしまいましょう。
                </div>
            </div>
            <div v-else-if="sub_total_price < 0">
                <div class="uk-alert-danger uk-text-center" uk-alert>
                    予約に対して<font color="#ff4500"> {{-sub_total_price|number_format}}円</font> 少なく回収しています。
                </div>
            </div>
            <div v-else>
                <div class="uk-alert-success uk-text-center" uk-alert>
                    予約上の参加費合計と全グループ参加費合計は一致しています。
                </div>
            </div>

            <div v-if="sub_total_person > 0">
                <div class="uk-alert-warning uk-text-center" uk-alert>
                    予約に対して<font color="#ff4500"> {{sub_total_person|number_format}}人</font> 多く参加しています。
                </div>
            </div>
            <div v-else-if="sub_total_person < 0">
                <div class="uk-alert-danger uk-text-center" uk-alert>
                    予約に対して<font color="#ff4500"> {{-sub_total_person|number_format}}人</font> 少なく参加しています。
                </div>
            </div>
            <div v-else>
                <div class="uk-alert-success uk-text-center" uk-alert>
                    予約上の参加人数と全グループ参加人数は一致しています。
                </div>
            </div>
            <textarea id="copyTarget" class="uk-textarea" v-model="outputText"  rows="5" placeholder="Textarea" readonly></textarea>
            <div class='uk-flex uk-flex-center uk-margin-small' >   
            <button v-on:click="copyResult" class="uk-button uk-button-default " uk-tooltip="title: 結果をクリップボードにコピーします; pos: bottom" ><span uk-icon="icon: copy; ratio: 1.0" type="button" class="cursor_to_point"></span> 結果をコピー</button> 
            </div>
        </div>

    </div>
    <div class="uk-flex uk-flex-center uk-margin">
        <a class="uk-button uk-button-secondary" href="#topanchor" uk-scroll>ページトップへ</a>
    </div>

    <script>
        Vue.filter('number_format', function (value) {
            return addComma(value);
        });	
        var addComma = function(value){
             if (! value) { return 0; }
            return value.toString().replace( /([0-9]+?)(?=(?:[0-9]{3})+$)/g , '$1,' );
        }
        new Vue({
            el: "#app",
            data: {
                price: null,
                person: null,
                tax: false,
                calcs: [{c_name:"",c_price:null,c_person:null,c_total:0,c_auto:false}],
            },
            computed :{
                total_price: function(){
                    var total = this.price * this.person;
                    if (this.tax){
                        total *= 1.1;
                    }
                    return Math.round(total);
                },
                calc_total_price: function(){
                    var tmp_total = 0;
                    for (var i = 0; i < this.calcs.length; i++ ){
                        tmp_total += this.calcs[i].c_total;
                    }
                    return tmp_total;	
                },
                calc_total_person: function(){
                    var tmp_person = 0;
                    for (var i = 0; i < this.calcs.length; i++ ){
                        tmp_person += this.calcs[i].c_person;
                    }
                    return tmp_person;	
                },
                sub_total_price: function(){
                    return this.calc_total_price - this.total_price;
                },
                sub_total_person: function(){
                    return this.calc_total_person - this.person;
                },
                minus_circle: function(){
                    if (this.calcs.length > 1){
                        return true;
                    }else{
                        return false;
                    }
                },
                outputText: function(){
                    var resultText = "";
                    resultText += "◆予約情報◆\n";
                    resultText += "１人分のコース料金：" + addComma(this.price) + "円\n";
                    resultText += "参加人数：" + addComma(this.person) + "人\n";
                    resultText += "予約上の参加費合計：" + addComma(this.total_price) + "円\n";
                    resultText += "--------------------\n";
                    resultText += "◆グループ情報◆\n";
                    for (var i = 0; i < this.calcs.length; i++ ){
                        if(this.calcs[i].c_name == ""){
                            resultText += "◇グループ" + (i+1) + "◇\n";
                        }else{
                            resultText += "◇" + this.calcs[i].c_name + "◇\n";
                        }
                        resultText += "参加費：" +addComma(this.calcs[i].c_price) + "円  ";
                        resultText += "人数：" +addComma(this.calcs[i].c_person) + "人  ";
                        resultText += "合計：" +addComma(this.calcs[i].c_total) + "円\n";
                    }
                    resultText += "--------------------\n";
                    resultText += "全グループ参加費合計：" + addComma(this.calc_total_price) + "円\n";
                    resultText += "全グループ参加人数合計：" + addComma(this.calc_total_person) + "人\n";
                    if(this.sub_total_price > 0){
                        resultText += "※予約に対して " + addComma(this.sub_total_price) + "円 多く回収\n";
                    }else if(this.sub_total_price < 0){
                        resultText += "※予約に対して " + addComma(-this.sub_total_price) + "円 少なく回収\n";
                    }
                    if(this.sub_total_person > 0){
                        resultText += "※予約に対して " + addComma(this.sub_total_person) + "人 多く参加\n";
                    }else if(this.sub_total_person < 0){
                        resultText += "※予約に対して " + addComma(-this.sub_total_person) + "人 少なく参加\n";
                    }
                    return resultText;
                }
            },
            watch :{
                calcs: {
                    handler: function(val){
                        for (var i = 0; i < this.calcs.length; i++ ){
                            this.calcs[i].c_total = this.calcs[i].c_price * this.calcs[i].c_person;
                        }
                    },
                    deep: true
                }
            },
            methods :{
                addList: function(c){   
                    //残りの人数を計算
                    var left_person = this.person - this.calc_total_person;
                    if (left_person < 0){
                        left_person = 0;
                    }
                    this.calcs.splice(c+1,0,{c_name:"",c_price:0,c_person:left_person,c_total:0});
                },
                delList: function(c){
                    if(this.calcs.length > 1){
                        this.calcs.splice(c,1);
                    }
                },
                autoCalculate: function(target){
                    var left_person = 0;
                    var left_price = 0;
                    var conf_price = 0;
                    for (var i = 0; i < this.calcs.length; i++ ){
                        //自動計算対象外の人数と確定金額を取得
                        if (!this.calcs[i].c_person){
                            this.calcs[i].c_person = 1;
                        }
                        if (!this.calcs[i].c_price){
                            this.calcs[i].c_price = 0;
                        }
                        if (i == target){								
                            left_person += this.calcs[i].c_person;
                        } else {
                            conf_price += this.calcs[i].c_price * this.calcs[i].c_person;
                        }
                    }
                    //残額計算
                    left_price = (this.total_price - conf_price) / left_person;
                    //空欄につめる
                    this.calcs[target].c_price = Math.round(left_price);
                },
                complementPriceReserve: function(){
                    if(!this.price){
                        this.price = 0;
                    }else if (this.price < 0){
                        this.price = 0;
                    }
                },
                complementPersonReserve: function(){
                    if(!this.person){
                        this.person = 0;
                    }else if (this.person < 0){
                        this.person = 0;
                    }
                },
                complementPerson: function(target){
                    if(!this.calcs[target].c_person){
                        this.calcs[target].c_person = 0;
                    }else if (this.calcs[target].c_person < 0){
                        this.calcs[target].c_person = 0;
                    }
                },
                complementPrice: function(target){
                    if(!this.calcs[target].c_price){
                        this.calcs[target].c_price = 0;
                    }else if (this.calcs[target].c_price < 0){
                        this.calcs[target].c_price = 0;
                    }
                },
                copyResult: function(){
                    var copyTarget = document.getElementById("copyTarget");
                    copyTarget.select();
                    document.execCommand("Copy");
                }
            }
        })

    </script>
    
</body>
```