14
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

freee APIで業務を楽しく便利にハックしよう!2021【PR】freeeAdvent Calendar 2021

Day 14

Tinder風 freee経費承認アプリをNuxtで作ってみた

Last updated at Posted at 2021-12-13

「はやく経費の承認してください!月末過ぎてますよ!」
みたいなことを経理の方にみんな言われることでしょう。
でも、打ち合わせもあるし、忙しくて忙しくて後回しになりますよね。(おもしろくないし)
ということで、移動中などに、「片手間で」「簡単に」「お手軽に」、いつでもできるように承認に特化したアプリを作ります。
フリック(スワイプ)で承認と却下ができるといいと思ったので、

Tinder風のUIで実装してみようと思います。

ということで、Nuxt.js (ver2系)でつくってみます。

さっそく、最終的な完成形はこんな感じです。
ezgif-2-0700c609e9ea.gif

1.Nuxtプロジェクト立ち上げ

Nuxtを立ち上げていきます。
ライブラリも最初に入れちゃいます。

言語:JS
UI framework: Vuetify
主なJSライブラリ:pug, @nuxtjs/proxy, vue-tinder

vue-tinderとは:
https://shanlh.github.io/vue-tinder/guide/getting-started.html#installation

// -----  nuxt 立ち上げ
$ yarn create nuxt-app freeecker    
yarn create v1.22.10
✨  Generating Nuxt.js project in freeecker
? Project name: freeecker
? Programming language: JavaScript
? Package manager: Yarn
? UI framework: Vuetify.js
? Nuxt.js modules: Axios - Promise based HTTP client, Progressive Web App (PWA)
? Linting tools: ESLint, Prettier
? Testing framework: None
? Rendering mode: Universal (SSR / SSG)
? Deployment target: Server (Node.js hosting)
? Development tools: (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Continuous integration: None
? Version control system: Git
  :

🎉  Successfully created project freeecker

  :
✨  Done in 112.45s.

$ cd freeecker 

// -----  必要そうなライブラリをインストール
$ yarn add vue-tinder  // ★★今回の肝★★
$ yarn add @nuxtjs/proxy
$ yarn add pug pug-plain-loader eslint-plugin-pug 

// ------ 無事起動すればOK
$ yarn dev
yarn run v1.22.10
$ nuxt

   ╭───────────────────────────────────────╮
   │                                       │
   │   Nuxt @ v2.15.8                      │
   │                                       │
   │   ▸ Environment: development          │
   │   ▸ Rendering:   server-side          │
   │   ▸ Target:      server               │
   │                                       │
   │   Listening: http://localhost:3000/   │
   │                                       │
   ╰───────────────────────────────────────╯

UIを実装

公式のvue-tinderを参考に実装してきます。
ここは、かなり簡単にインストールできました。

肝はclient-onlyをつけるところです。
SSR時にVueTinderのコンポーネントがエラーになってしまうので、これをつけないと一生動かないです。
ちょっとはまりました。

expense_applicationsのサンプルデータはfreeeのAPIリファレンスから引っ張ってきました。
経費申請一覧の取得 GET: /api/1/expense_applications

// pages/expences/index.vue
<template lang="pug">
  div
    v-col
      client-only // 重要!
        vue-tinder.text-center.d-flex.justify-center(key-name='id' :queue.sync='expenses' :offset-y='10' @submit='onSubmit')
          .card.tinder-card.text-left.pa-3(slot-scope='scope')
            v-row
              v-col(cols="6")
                span 申請No.{{scope.data.application_number}}
              v-col.text-right(cols="6")
                span 
                  small 申請日: 
                  | {{scope.data.issue_date}}
            h3.mb-0.mt-3.text-center {{scope.data.title}}
            p.text-h2.text-bold.mb-2.font-weight-bold.text-center.mb-4(style="margin-top: -4px !important;") ¥ {{scope.data.total_amount.toLocaleString()}}
            hr
            v-row.mt-1
              v-col
                v-chip.ma-1(class="" color="primary" outlined small) 営業部 
                v-chip.ma-1(class="" color="deep-purple" outlined small) メモ 
            v-row
              v-col.py-1(cols="4")
                span 備考:
              v-col.py-1.text-right(cols="8")
                span {{scope.data.description}}

          // ここで承認時と却下時に表示される、画像を設定する
          img.text-left(slot='like' src='~/assets/good.png')
          img(slot='nope' src='~/assets/bad.png')
</template>

<script>
import VueTinder from 'vue-tinder'

export default {
  components: {
    VueTinder
  },
  data: () => ({
    expenses: [
      {
        "id": 1,
        "company_id": 1,
        "title": "大阪出張",
        "issue_date": "2019-12-17",
        "description": "◯◯連携先ID: cx12345",
        "total_amount": 40000,
        "status": "draft",
        "section_id": 101,
        "tag_ids": [
          202
        ],
        "expense_application_lines": [
          {
            "id": 1,
            "transaction_date": "2019-12-17",
            "description": "交通費:新幹線往復(東京〜大阪)",
            "amount": 30000,
            "expense_application_line_template_id": 505,
            "receipt_id": 606
          }
        ],
        "deal_id": 1,
        "deal_status": "settled",
        "applicant_id": 1,
        "application_number": "4",
      },
      { .. }, { .. }, { .. }
    ],
    queae: []
  }),
  created() {
    this.queue = this.expenses
  },
  methods: {
    onSubmit(type, key, item) {
      console.log(type)
    }
  }
}
</script>

<style scoped>
.vue-tinder {
  width: 100%;
  height: 500px;
}
.tinder-card {
  height: 100%;
}
</style>

動かしてみるとこんな感じです。
本来であればもっとピュッ!と飛ぶのですが、vuetifyとの相性が悪いらしいです。
いろいろためして、bootstrapも試してみましたが改善されないので諦めました。。。
ezgif.com-gif-maker.gif

Freee APIと連携

Free APIと連携していきます。
今回は一覧取得と、経費申請の承認操作の2種類を使っていきます。

経費申請一覧の取得

axiosで取得していきます。
また、取得したデータはオブジェクトにしたいので、modelクラスを定義していきます。

pages/expences/index.vue
<script>
import VueTinder from 'vue-tinder'
import { Expence } from "@/models/expense"
import { axios } from 'axios'

const API_HEAD = {
  headers: {
    'Authorization': "Bearer XXXXXXXXXXXXXXXXXXXXXXX",
    'X-Api-Version': "2020-06-15",
    'accept': "application/json"
  }
}

export default {
  components: {
    VueTinder
  },
  data: () => ({
    expenses: [],
  }),
  async created() {
    const res = await this.$axios.$get(`/api/1/expense_applications?company_id=XXXXXXX&status=in_progress`, API_HEAD)
    // 取得したデータをExpenceオブジェクトにしていく
    this.expenses = (res.expense_applications).map((expence) => new Expence(expence))
  },
  methods: {
    onSubmit(type, key, item) {
      console.log(type)
    }
  },
}
</script>

API仕様書にあわせてデータ型(オブジェクト)を定義します。
合わせてタグ名とメモ名等を取得できるようにメソッドを作っておきます。

models/expense.ts
import { Master } from './master'

export interface ExpenceInterface {
  id:           number // 経費申請ID
  company_id:   number // 事業所ID
  title:        string // 申請タイトル
  issue_date:   string // 申請日
  description:  string // 備考
  total_amount: number // 合計金額
  status:       string // 申請ステータス(draft:下書き, in_progress:申請中, approved:承認済, rejected:却下, feedback:差戻し)
  section_id:   number // 部門ID
  tag_ids:      Array<number> // メモタグID
  deal_id:              number // 取引ID
  deal_status:          string // 取引ステータス
  applicant_id:         number // 申請者のユーザーID
  application_number:   string // 申請No
  current_step_id:      number // 現在承認ステップID
  current_round:        number //在のround。差し戻し等により申請がstepの最初からやり直しになるとroundの値が増えます。
  expense_application_lines:  Array<ExpenceLine> // 項目行(詳細)
}

// 明細行
export interface ExpenceLineInterface {
  id:                number // 経費申請の項目行ID
  transaction_date:  string // 日付 (yyyy-mm-dd)
  description:       string // 内容 例:交通費:新幹線往復(東京〜大阪)
  amount:            number // 金額
  receipt_id:        number // 証憑ファイルID(ファイルボックスのファイルID)
  expense_application_line_template_id: number // 経費科目ID
}

// 経費クラス
export class Expence implements ExpenceInterface {
  id:           number
  company_id:   number
  title:        string
  issue_date:   string
  description:  string
  total_amount: number
  status:       string
  section_id:   number
  tag_ids:      Array<number>
  deal_id:              number
  deal_status:          string
  applicant_id:         number
  application_number:   string
  current_step_id:      number
  current_round:        number
  expense_application_lines: Array<ExpenceLine>
 
  constructor(data: ExpenceInterface) {
    this.id =           data.id
    this.company_id =   data.company_id
    this.title =        data.title
    this.issue_date =   data.issue_date
    this.description =  data.description
    this.total_amount = data.total_amount
    this.status =       data.status
    this.section_id =   data.section_id
    this.tag_ids =      data.tag_ids
    this.deal_id =      data.deal_id
    this.deal_status =          data.deal_status
    this.applicant_id =         data.applicant_id
    this.application_number =   data.application_number
    this.applicant_id =         data.applicant_id
    this.current_step_id =      data.current_step_id
    this.current_round =        data.current_round
    this.expense_application_lines =  data.expense_application_lines.map((expence_line: ExpenceLineInterface) => new ExpenceLine(expence_line))
  }

  // タグ名
  get tagNames(): any {
    if (this.tag_ids.length > 0) {
      return this.tag_ids.map(tag => Master.getByKeyword('tag', tag))
    } else {
      return []
    }
  }

  // 部門名
  get sectionName(): any {
    return Master.getByKeyword('section', this.section_id)
  }


}

// 経費の明細行クラス
export class ExpenceLine implements ExpenceLineInterface  {
  id:                number
  transaction_date:  string
  description:       string
  amount:            number
  receipt_id:        number
  expense_application_line_template_id: number

  constructor(data: ExpenceLineInterface) {
    this.id = data.id
    this.transaction_date = data.transaction_date
    this.description = data.description
    this.amount = data.amount
    this.receipt_id = data.receipt_id
    this.expense_application_line_template_id = data.expense_application_line_template_id
  }
}

今季、部門やメモタグはマスター化して使います。
本来であればAPIを定期的に叩いて最新化する必要がありますが割愛します。
さらに言うと、Masterクラスではなく、各データごとにクラスを作っておくべきですね。
そうすれば、あとでAPI化したときにそこを書き換えるだけでいいので。

models/master.js
// freeeのマスターを管理
// 本来であれば、都度API(定期的に)で取得

export class Master {
  static getByKeyword(masterKind: string, keyword: string | number): string | null{
    let value = null
    
   if (masterKind == 'section') {  // 部門名
      let _r = Master.sections.find(section => section.id == keyword)
      if (_r) {
        value = _r['name']
      }
    } else if (masterKind == 'tag') {  // タグ名
      let _r = Master.tags.find(tag => tag.id == keyword)
      if (_r) {
        value = _r['name']
      }
    } else if (masterKind == 'userName') { // 申請者の名前
      let _r = Master.users.find(user => user.id == keyword)
      if (_r) {
        value = _r['last_name'] + _r['first_name']
      }
    } else if (masterKind == 'userEmail') { // 申請者のEmail
      let _r = Master.users.find(user => user.id == keyword)
      if (_r) {
        value = _r['email']
      }
    }  else {
      value = '不明'
    }

    return value
  }


  // 部門
  static sections = [
    {
      "id": 1647XXX,
      "company_id": 3376XXX,
      "name": "営業部",
      "long_name": null,
      "available": true,
      "shortcut1": null,
      "shortcut2": null,
      "indent_count": 0,
      "parent_id": null
    }, {} , ...,{}
  ]

  // メモタグ
  static tags = [
    {
      "id": 1594XXX,
      "company_id": 337XXX,
      "name": "デザイン",
      "shortcut1": null,
      "shortcut2": null,
      "update_date": "2021-11-23"
    }, {} , ...,{}
  ]

  // ユーザー
  static users = [
    {
      "id": 380XXXX,
      "email": "sample@freee.jp",
      "display_name": "管理者",
      "first_name": "太郎",
      "last_name": "山口",
      "first_name_kana": "リキマル",
      "last_name_kana": "ヤマグチ"
    },
    {
      "id": 39XXXXX,
      "email": "yamada@freee.jp",
      "display_name": '山田',
      "first_name": '三四郎',
      "last_name": null,
      "first_name_kana": null,
      "last_name_kana": null
    }
  ]
}

普通に外部APIに接続するとCROSエラーになるので、
@nuxt/proxyをつかって回避します。(地味に重要)

nuxt.config.js
export default
  :
  :
  // Axios module configuration: https://go.nuxtjs.dev/config-axios
  axios: {
    proxy: true,
  },
  proxy: {
    '/api/1/': {
      target: `https://api.freee.co.jp`,
    },
  },
  :
  :
}

UI・レイアウトの調整

明細と、footerでボタンでも操作するように承認ボタン、差し戻しボタンなども配置していきます。

pages/expences/index.vue
<template lang="pug">
  div
    // tinder風カード
    v-col
      client-only
        v-card(v-if="expenses.length == 0")
          v-row
            v-col
              p.mt-5.mb-0.text-center お疲れさまです
              p.text-center 申請は全て片付きました
          v-img(src="coffee-break-pana.png")
        div(else)
          vue-tinder.text-center.d-flex.justify-center(ref="tinder" key-name='id' :queue.sync='expenses' :offset-y='10' @submit='onSubmit')
            .card.tinder-card.text-left.pa-3(slot-scope='scope')
              v-row
                v-col(cols="6")
                  span 申請No.{{scope.data.application_number}}
                v-col.text-right(cols="6")
                  span 
                    small 申請日: 
                    | {{scope.data.issue_date}}
              h3.mb-1.mt-3.text-center {{scope.data.title}}
              p.text-h2.text-bold.mb-2.font-weight-bold.text-center.mb-4(style="margin-top: -4px !important;") ¥ {{scope.data.total_amount.toLocaleString()}}
              v-divider
              v-row.mt-1
                v-col
                  v-chip.ma-1(color="primary" outlined small v-show="scope.data.sectionName" ) {{ scope.data.sectionName }} 
                  v-chip.ma-1(color="deep-purple" outlined small v-for="tagName in scope.data.tagNames" :key="tagName") {{ tagName }}
              v-row
                v-col.py-0.text-letf(cols="6")
                  p.mb-0.caption 申請者:
                v-col.py-0.text-right(cols="6")
                  p.mb-0 {{scope.data.applicantName}}
              v-row
                v-col.py-0.text-letf(cols="6")
                v-col.py-0.text-right(cols="6")
                  p.mb-0 {{scope.data.applicantEmail}}

              v-row
                v-col.py-1.text-left(cols="12" v-if="(scope.data.description)")
                  p.my-1
                    | {{scope.data.description}}

    // ----------------------------------- footerを追加 -----------------------------------
    v-footer.py-0.px-0(fixed color="secondary")
      v-card.primary.lighten-1.text-center(flat tile width='100%')
        v-card-text
          v-row
            v-col.pa-0(cols=4)
              v-btn.mx-0.py-5(block depressed x-large text)
                v-icon(size='28px' color="white")
                  | mdi-hand-front-left
              p.white--text.my-0.caption(color="white") 差し戻し
            v-col.pa-0(cols=4)
              v-btn.mx-0.py-5(block depressed x-large text)
                v-icon(size='28px' color="white")
                  | mdi-hand-okay
              p.white--text.my-0.caption(color="white") 承認
            v-col.pa-0(cols=4)
              v-btn.mx-0.py-5(block depressed x-large text)
                v-icon(size='28px' color="white")
                  | mdi-hand-wave
              p.white--text.my-0.caption(color="white") 却下

                      
</template>

こんな感じになりました!!

freeecker - freeecker 2021-12-05 23-14-23.png

認証とAPI連携(POST)

### 認証
freeeの認証を行います。
まずは、トップページをつくります。
TOPページ -> 認証ページ -> 承認ページと言う感じです。

pages/index.vue
<template lang="pug">
 div.mt-5
    v-row(justify="center" align="center")
      v-col.text-center(cols="12")
        h1.mb-0
          | Freeecker
        conveLogo
        p.my-1
          | 承認作業を瞬殺
      v-col.text-center(cols="6" md="4" xl="3")
        a.mt-1.font-weight-bold(color="primary" href="https://accounts.secure.freee.co.jp/public_api/authorize?client_id=9c7deXXXXXXXXXXXXXXXXXXX52ffdf0e6&redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fexpences&response_type=token") 承認作業をはじめる
    v-row(justify="center" align="center")
      v-col.mt-0.text-center(cols="12" md="8" xl="6")
        v-spacer
        v-img(src="track-field-cuate.png" min-height="225")

        p.mt-4.px-3
          | 経費申請たまっていませんか?
          br
          | 承認作業瞬殺で済ませましょう。
                      
</template>

<script></script>

freeeの開発者画面はコールバックURLを修正しておきます。
image.png

するとこんな遷移になります。
ezgif-2-c282256a5e1d.gif

承認部分の実装

承認処理を実装していきます。
今回は以下の3つをできるようにします。

  • 承認(approve)
  • 却下(reject)
  • 差し戻し(feedback)

APIにアクセスしつつ、
APIのアクセス成功時、失敗時はスナックバーでメッセージを表示するようにします。
ボタンでも処理できるよに、フッターに各ボタンを配置します。
(company_idが決め打ちなのはごめんなさい、本来はAPIで取得すべき)

pages/expences/index.vue
<template lang="pug">
  div
    v-col
      client-only
        v-card(v-if="expenses.length == 0")
          v-row
            v-col
              p.mt-5.mb-0.text-center お疲れさまです
              p.text-center 申請は全て片付きました
          v-img(src="coffee-break-pana.png")
        div(else)
          vue-tinder.text-center.d-flex.justify-center(ref="tinder" key-name='id' :queue.sync='expenses' :offset-y='10' @submit='onSubmit')
            .card.tinder-card.text-left.pa-3(slot-scope='scope')
              v-row
                v-col(cols="6")
                  span 申請No.{{scope.data.application_number}}
                v-col.text-right(cols="6")
                  span 
                    small 申請日: 
                    | {{scope.data.issue_date}}
              h3.mb-1.mt-3.text-center {{scope.data.title}}
              p.text-h2.text-bold.mb-2.font-weight-bold.text-center.mb-4(style="margin-top: -4px !important;") ¥ {{scope.data.total_amount.toLocaleString()}}
              v-divider
              v-row.mt-1
                v-col
                  v-chip.ma-1(color="primary" outlined small v-show="scope.data.sectionName" ) {{ scope.data.sectionName }} 
                  v-chip.ma-1(color="deep-purple" outlined small v-for="tagName in scope.data.tagNames" :key="tagName") {{ tagName }}
              v-row
                v-col.py-0.text-letf(cols="6")
                  p.mb-0.caption 申請者:
                v-col.py-0.text-right(cols="6")
                  p.mb-0 {{scope.data.applicantName}}
              v-row
                v-col.py-0.text-letf(cols="6")
                v-col.py-0.text-right(cols="6")
                  p.mb-0 {{scope.data.applicantEmail}}

              v-row
                v-col.py-1.text-left(cols="12" v-if="(scope.data.description)")
                  p.my-1
                    | {{scope.data.description}}

              // ----------------------------------- 明細行 -----------------------------------
              v-row
                v-col.py-1(cols="4")
                  h4 明細:
              v-row(v-for="line in scope.data.expense_application_lines" :key="line.id")
                v-col.py-1(cols="6")
                  p.mb-0.caption {{line.transaction_date}}
                  p.mb-0 {{line.description}}
                v-col.py-1.text-right.pt-6(cols="6")
                  h4 ¥ {{line.amount.toLocaleString()}}
        
            img.text-left(slot='like' src='~/assets/good.png')
            img.text-left(slot='nope' src='~/assets/bad.png')

    // 承認メッセージ用スナックバー
    v-snackbar.freee-api-snack(v-model='approveSnackbar' :timeout="800" color="primary" outlined)
      v-icon(size='20px' color="primary")
        | mdi-hand-okay
      span.ml-3.font-weight-bold 承認しました。
      template(v-slot:action='{ attrs }')
 
    // 却下メッセージ用スナックバー
    v-snackbar.freee-api-snack(v-model='rejectSnackbar' :timeout="800" color="accent" outlined)
      v-icon(size='20px' color="accent")
        | mdi-hand-wave
      span.ml-3.font-weight-bold 却下しました。
      template(v-slot:action='{ attrs }')

    // 差し戻しメッセージ用スナックバー
    v-snackbar.freee-api-snack(v-model='feedbackSnackbar' :timeout="800" color="info" outlined)
      v-icon(size='20px' color="info")
        | mdi-hand-front-left
      span.ml-3.font-weight-bold 差し戻しました。
      template(v-slot:action='{ attrs }')

    // エラー発生メッセージ用スナックバー
    v-snackbar.freee-api-snack(v-model='errorSnackbar' :timeout="800" color="accent" outlined)
      v-icon(size='20px' color="accent")
        | mdi-alert-circle-outline
      span.ml-3.font-weight-bold エラーが発生しました。
      template(v-slot:action='{ attrs }')

    v-footer.py-0.px-0(fixed color="secondary")
      v-card.primary.lighten-1.text-center(flat tile width='100%')
        v-card-text
          v-row
            v-col.pa-0(cols=4)
              v-btn.mx-0.py-5(block depressed x-large text)
                v-icon(size='28px' color="white" @click="decide('nope')" :disabled="!canActions")
                  | mdi-hand-front-left
              p.white--text.my-0.caption(color="white") 差し戻し
            v-col.pa-0(cols=4)
              v-btn.mx-0.py-5(block depressed x-large text)
                v-icon(size='34px' color="white" @click="decide('like')" :disabled="!canActions")
                  | mdi-hand-okay
              p.white--text.my-0.caption(color="white") 承認
            v-col.pa-0(cols=4)
              v-btn.mx-0.py-5(block depressed x-large text)
                v-icon(size='28px' color="white" @click="decide('super')" :disabled="!canActions")
                  | mdi-hand-wave
              p.white--text.my-0.caption(color="white") 却下


</template>

<script>
import VueTinder from 'vue-tinder'
import { Expence } from "@/models/expense"

const COMPANY_ID = XXXXXX

export default {
  components: {
    VueTinder
  },
  data: () => ({
    expenses: [],
    apiHead: {},
    approveSnackbar: false,
    rejectSnackbar: false,
    feedbackSnackbar: false,
    errorSnackbar: false
  }),
  async created() {
    await this.createAuthHeadear()
    const res = await this.$axios.$get(`/api/1/expense_applications?company_id=${COMPANY_ID}&status=in_progress`, this.apiHead)
    this.expenses = (res.expense_applications).map((expence) => new Expence(expence))
  },
  computed: {
    canActions() {
      return this.expenses.length > 0
    }
  },
  methods: {
    // freeeアクセス用のヘッダーを作成
    createAuthHeadear() {
      console.log(this.$route.hash)
      this.apiHead = { 
        headers: {
          'Authorization': `Bearer ${this.accessTokenFromParams()}`,
          'X-Api-Version': "2020-06-15",
          'accept': "application/json"
        }
      }
    },
    // URLからアクセストークンを取得
    accessTokenFromParams() {
      const params = this.$route.hash
      const found = params.match(/access_token=(.*?)(&|$)/);
      if (found != null && found.length > 1) {
        return found[1]
      } else {
        return 0
      }
    },
    onSubmit({type, key, item}) {
      console.log(type)
      if (type=='like') {
        // 承認
        this.approve(item)
      } else if (type=='super') {
        // 却下
        this.reject(item)
      } else if (type=='nope') {
        // 差し戻し
        this.feedback(item)
      }
    },
    decide(choice) {
      this.$refs.tinder.decide(choice)
    },
    // 申請を承認
    approve(expense) {
      this.$axios.$post(
        `/api/1/expense_applications/${expense.id}/actions`,
        {
          'company_id': COMPANY_ID,
          'approval_action': 'approve',
          'target_step_id': expense.current_step_id,
          'target_round': expense.current_round,
        },
        this.apiHead
      ).then((response)=> {
        this.approveSnackbar = true
      }).catch((response)=> {
        this.errorSnackbar = true
      })
    },
    // 申請を却下
    reject(expense) {
      this.$axios.$post(
        `/api/1/expense_applications/${expense.id}/actions`,
        {
          'company_id': COMPANY_ID,
          'approval_action': 'reject',
          'target_step_id': expense.current_step_id,
          'target_round': expense.current_round,
        },
        this.apiHead
      ).then((response)=> {
        this.rejectSnackbar = true
      }).catch((response)=> {
        this.errorSnackbar = true
      })
    },
    // 申請を差し戻し
    feedback(expense) {
      this.$axios.$post(
        `/api/1/expense_applications/${expense.id}/actions`,
        {
          'company_id': COMPANY_ID,
          'approval_action': 'feedback',
          'target_step_id': expense.current_step_id,
          'target_round': expense.current_round,
        },
        this.apiHead
      ).then((response)=> {
        this.feedbackSnackbar = true
      }).catch((response)=> {
        this.errorSnackbar = true
      })
    },
  },
}
</script>

かなりいい感じになりました!
ezgif-2-1b0aba939284.gif

禁断...の無限スワイプ

最後にTinderおなじみの無限スワイプを実装します。
忙しくて、お金がうなるほどある会社さんにはきっと需要があるでしょう。
ノールックで全て承認してしまう、承認フローを形骸化させる無限承認機能を実装します。

pages/expences/index.vue
<template lang="pug">
  div
    :
    :
    v-footer.py-0.px-0(fixed color="secondary")
      v-card.primary.lighten-1.text-center(flat tile width='100%')
        v-card-text
          v-row
            v-col.pa-0(cols=3)
              v-btn.mx-0.py-5(block depressed x-large text)
                v-icon(size='28px' color="white" @click="decide('nope')" :disabled="!canActions")
                  | mdi-hand-front-left
              p.white--text.my-0.caption(color="white") 差し戻し
            v-col.pa-0(cols=3)
              v-btn.mx-0.py-5(block depressed x-large text)
                v-icon(size='28px' color="white" @click="decide('super')" :disabled="!canActions")
                  | mdi-hand-wave
              p.white--text.my-0.caption(color="white") 却下
            v-col.pa-0(cols=3)
              v-btn.mx-0.py-5(block depressed x-large text)
                v-icon(size='34px' color="white" @click="decide('like')" :disabled="!canActions")
                  | mdi-hand-okay
              p.white--text.my-0.caption(color="white") 承認
            // ================== 追加 ==================
            v-col.pa-0(cols=3
              v-btn.mx-0.py-5(block depressed x-large text)
                v-icon(size='28px' color="white" @click="nolookApprove()" :disabled="!canActions")
                  | mdi-all-inclusive
              p.white--text.my-0.caption(color="white") 全部OK!

</template>

<script>
export default {
    // 申請を全て承認
    nolookApprove() {
      let index = 0;
      let time;
      let that = this;

      time = setInterval(function(){
          that.decide('like')

          index++;
          if (index > that.length){
              // タイマーをクリア
              clearInterval(time);
          }
      }, 1100);
    },
}
</script>

ezgif-2-6581c253d303.gif

最後に

感想などを簡単にまとめます。

Freee APIに関して

  • ドキュメントが日本語で読みやすい
  • 各APIが詳しくまとまっている印象で、実装はめちゃくちゃスムーズだった(エンジニアの細やかさを感じる)
  • 強いて言うなら、認証系のドキュメントが少ない印象なので、他言語でもサンプルを準備してもらえるとうれしい
  • 経費申請は状態遷移図があるとうれしいなぁと思った

Nuxtについて

  • 特にないですが、明日Nuxt3で別記事をあげます。Nuxt2のほうが全然開発スピードがでた
  • nuxtというより、vue-tinderがよくできていた。今後も活用したい。
  • 弱点としては、bootstrap,vuetifyとの相性が悪いこと、おそらくtailwind等ほかのCSSフレームワークも同じそう。

Freeeckerについて

普通に便利だとおもうので、時間をみて最後まで仕上げ用と思った
残タスクは、認証、企業取得などまだまだあるが個人的な利用ならいますぐ使える

最後までお読みいただきありがとうございます。
ご指摘アドバイス等ありましたら、是非お願いします。

[最終的なソースコードはコチラ]
https://github.com/tikaranimaru/freeecker-app

14
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?