26
12

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.

Nuxt3 (beta)がでましたね。
っということで、爆速で取引操作に特化たfreee取引をつくってみます。

この記事の目的

自社のサービスでfreee連携を検討しているのと、
RailsにVueをマウントして一部つかっているのですが、
その部分をNuxtにリプレースしたいので技術調査のため今回作ってみることにしました。
あとは、Tailwind CSSをついでに使ってみます。

今回つかった技術はこんな感じです。
普段はruby,pythonでバックエンドばっかりやっているのでフロントエンドの知見は以下の通りです。
Nuxt2はかいたことあるから3で書いても爆速でしょ!!とか思っていました。

使用した技術 使用経験等 類似
Nuxt3 知らない Nuxt2なら数回趣味でつかったことがある
Vue3 知らない Vue2なら気持ちわかる(option api)
Typescript 気持ち知っている
Tailwind CSS 知らない bootstrap,vuetifyなら気持ちわかる

結果、爆遅でした。

出落ちですが、めちゃくちゃハマって計23時間ほどかかりました。
当初は8時間ほどで仕上げるつもりですた。
理由は最後に後述しますが、使ったことない技術を使いすぎましたw

1. Nuxt3 立ち上げ ・・・ 1Hour

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

$ npx nuxi init nuxt-freee 
$ cd nuxt-freee
$ yarn install
$ mkdir assets
$ mkdir components
$ mkdir pages

# ブラウザで開ければ無事完了
$ yarn dev -o

2. pugのインストール

$ yarn add -D pug pug-plain-loader

pug形式で書くように、以下のように書く

app.vue
<template lang="pug">
div
  NuxtPage // Wellcome componentをnuxtpageに変更
</template>
pages/index.vue

<template lang="pug">
div
  h1 Freee Nuxt
</template>

3. daisy ui & tailwind cssをインストール

今回、UI( CSSフレームワーク )は、tailwind cssのcomponent集である、daisy uiを使います。
(いい感じで、無料なのでおすすめ)

公式通りにやると、tailwindcssのインストールで以下のエラーがでるので、
以下記事を参考にした
Pairing Nuxt 3 with TailwindCSS and Supabase

 ERROR  Cannot start nuxt:  Cannot read property 'resolveAlias' of undefined                                                                                                                       14:55:05

  at Object.tailwindCSSModule (node_modules/@nuxtjs/tailwindcss/dist/index.js:51:36)
  at installModule (node_modules/@nuxt/kit/dist/index.mjs:1294:17)
  at initNuxt (node_modules/nuxt3/dist/index.mjs:917:11)
  at async load (node_modules/nuxi/dist/chunks/dev.mjs:6713:9)
  at async Object.invoke (node_modules/nuxi/dist/chunks/dev.mjs:6752:5)
  at async _main (node_modules/nuxi/dist/chunks/index.mjs:386:7)

ライブラリをインストール

// ライブラリをインストール
$ npm install -D tailwindcss@latest postcss@latest autoprefixer@latest

// tailwindcssを構築
$ npx tailwindcss init
tailwind.config.js

module.exports = {
  mode: "jit",
  purge: [
    "./components/**/*.{vue,js}",
    "./layouts/**/*.vue",
    "./pages/**/*.vue",
    "./plugins/**/*.{js,ts}",
    "./nuxt.config.{js,ts}",
  ],
  darkMode: false, // or 'media' or 'class'
  theme: {
    extend: {},
  },
  variants: {
    extend: {},
  },
  plugins: [],
};
assets/css/tailwind.css
// ファイルごと作成

@tailwind base;
@tailwind components;
@tailwind utilities;
nuxt.config.ts
import { defineNuxtConfig } from "nuxt3";

export default defineNuxtConfig({
  css: ["~/assets/css/tailwind.css"],
  build: {
    postcss: {
      postcssOptions: {
        plugins: {
          tailwindcss: {},
          autoprefixer: {},
        },
      },
    },
  },
});

ここまでで、tailwindのインストールがおわったので、
daiyuiをインストールする

$ yarn add daisyui
// tailwind.config.js

module.exports = {
  
  
  },
  plugins: [
    require('daisyui'),   // 追記
  ],
}
app.vue
<template lang="pug">
div
  NuxtPage
  button.btn.btn-primary daisyUI Button   // 確認用に追記
</template>

<script lang="ts" setup>
import './assets/css/tailwind.css'       // 追記
</script>

確認用のボタンが無事出現すれば準備完了!
localhost:3000 2021-11-20 15-08-19.png

2. トップ画面 ・・・ 2Hour

今回は、取引一覧とその編集(いわゆるCRUD)ができるようにします。

トップ画面

Freee取引 2021-12-06 21-43-18.png

pages/index.vue
<template lang="pug">
.bg-gradient-to-br.from-primary.to-secondary(style="margin-top: -65px;")
  .min-h-screen.pt-16.overflow-hidden.hero.text-primary-content
    .flex-col.justify-between.w-full.max-w-6xl.mt-10.mb-48.hero-content
      h1.py-4.mb-2.font-extrabold.text-center.font-title.lg
        .mb-3.text-5xl.lg
          | Freee 取引
        .mb-5
          | Powered By Nuxt3
        .text-4xl.lg
          a(href="https://accounts.secure.freee.co.jp/public_api/authorize?client_id=XXXXXXXXX&redirect_uri=http%3A%2F%2F127.0.0.1%3A3000/deals&response_type=token")
            button.btn.btn-secondary.btn-wide.btn-lg 認証してはじめる
</template>

<script lang="ts">
</script>

テーマ設定

Daisy UIはテーマを選べるので、meta情報を書き換えます。
正確にはhtmlタグに'data-theme'="cupcake"を追加すればOK
Nuxt3からheadはなくなり、metaになったの注意です!
(ここで少しはまりました。)

nuxt.config.ts
  meta: {                      // !!Nuxt3からheadになったので注意!!!
    htmlAttrs: {
      lang: 'ja',
      'data-theme': "cupcake"  // テーマを選択
    },
    title: 'Freee取引',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' }
    ],
    link: [
      { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
    ]
  },

3. 取引一覧画面 ・・・ 8 Hour

Freee取引 2021-12-06 21-47-03.png

もちろんレスポンシブにも対応にします!
Freee取引 2021-12-06 22-00-19.png

ここはそんなにハマりポイントはありませんでしたが、Tailwindのgridにちょっと時間がかかりましたが、あとは他のCSSフレームワークと似てますね。
pugにしているせいで見にくいですね。。。

pages/deals/index.vue
<template lang="pug">
.container.w-full.max-w-8xl.mx-auto.mt-4  
  div.mb-3.text-right
    NuxtLink(to="/deals/new")
      button.btn.btn-primary.btn-outline + 取引を登録

    // ======================== PC・タブレット ==============================
  div(class="hidden md:block")
    table.table.w-full
      thead
        tr
          th
          th 
            | 発生日
            div.text-md.opacity-60 / 支払期日
          th 
            | 勘定項目
            div.text-md.opacity-60 / 税区分
          th.text-right 金額
          th
            span.badge.badge-sm.badge-outline.mb-2.mr-1 取引先
            span.badge.badge-sm.badge-secondary.badge-outline.mb-2.mr-1  品目
            div
              span.badge.badge-sm.badge-info.badge-outline.mr-1  メモタグ
              span.badge.badge-sm.badge-accent.badge-outline.mr-1  部門
          th
      tbody
        template(v-for="deal in deals")
          tr(v-for="detail in deal.details" :key="detail.id")
            th
              span.badge.badge-sm(:class="[deal.isExpense ? 'badge-secondary' : 'badge-primary']") {{ deal.typeName }}
            td 
              | {{ deal.issue_date }}
              div.text-md.opacity-60 {{ deal.due_date }}

            td 
              | {{ detail.accountItemName }}
              div.text-md.opacity-60 {{ detail.taxName }}
            td.text-right 
              div ¥{{ deal.amount.toLocaleString() }}
            td
              span.badge.badge-sm.badge-outline.mr-1(v-show="deal.partnerNeme") {{ deal.partnerNeme }}
              span.badge.badge-sm.badge-secondary.badge-outline.mr-1(v-show="detail.itemName") {{ detail.itemName }}
              br
              span.badge.badge-sm.badge-info.badge-outline.mr-1(v-for="tagName in detail.tagNames") {{ tagName }}
              span.badge.badge-sm.badge-accent.badge-outline.mr-1(v-show="detail.sectionName") {{ detail.sectionName }}
            th
              NuxtLink(:to="`/deals/${deal.id}`")
                button
                  i.fas.fa-edit

    // ======================== スマホ ==============================
  div(class="visible md:hidden") 
    div(v-for="mobileDeal in mobileDeals" :key="mobileDeal.id")
      .card.shadow-lg.compact.side.bg-base-100.mb-2(v-for="mobileDetail in mobileDeal.details" :key="mobileDetail.id")
        .card-body
          .grid.grid-cols-12
            .col-span-5
              span.badge.badge-outline.badge-lg.mr-2(:class="[mobileDeal.isExpense ? 'badge-secondary' : 'badge-primary']") {{ mobileDeal.typeName }}
            .col-span-7![Freee取引 2021-12-06 22-00-19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/182994/4a13d41e-dbdd-94b6-832d-1b2e214489af.png)

              h4.text-xl.font-bold.card-title.float-right
                span.pr-2.text-sm.opacity-60 発生日
                span.pr-2 {{ mobileDeal.issue_date }}

          h2.mt-2.text-4xl.font-bold.card-title
              div ¥{{ mobileDeal.amount.toLocaleString() }}
              
          .mb-1.space-x-2.card-actions
            span.badge.badge-sm.badge-outline.mr-1(v-show="mobileDeal.partnerNeme") {{ mobileDeal.partnerNeme }}
            span.badge.badge-sm.badge-secondary.badge-outline.mr-1(v-show="mobileDetail.itemName") {{ mobileDetail.itemName }}
            span.badge.badge-sm.badge-info.badge-outline.mr-1(v-for="tagName in mobileDetail.tagNames") {{ tagName }}
            span.badge.badge-sm.badge-accent.badge-outline.mr-1(v-show="mobileDetail.sectionName") {{ mobileDetail.sectionName }}
 
          .grid.grid-cols-12
            .col-span-6
              p.text-xs 勘定項目:
              p.text-xl {{ mobileDetail.accountItemName }}
              p.text-xs 税区分:
              p.text-xl {{ mobileDetail.taxName }}
            .col-span-6
              .justify-end.space-x-2.card-actions
                NuxtLink(:to="`/deals/${mobileDeal.id}`")
                  button.btn.btn-primary 編集

    // ======================== ページャ ==============================
  .m-3
    .text-center
      button.btn.btn-outline {{ "<" }}
      span.mx-2(v-for="n of Math.ceil(totalCount / limit)" :key="n")
        button.btn.btn-accent.btn-disabled(v-if="n == currentPage") {{n}}
        button.btn.btn-outline(v-else) {{n}}
      button.btn.btn-outline {{ '>' }}

</template>!

CORSエラー地獄の始まり・・・

通常フロントエンドからアクセスすると、ブラウザでCORSエラーが起きます。
Nuxt2であれば@nuxt/proxyを使えばサクッと解決できます。
が、Nuxt3は対応していないことが判明しました。
なんとか回避できないか半日以上試行錯誤しました。
解決策はNuxt3の新機能である、 serverを使います。

簡単にAPIエンドポイントつくりますよ。って機能です。(だと思います)
https://v3.nuxtjs.org/docs/directory-structure/server/

なので、それを利用してサーバーサイドからfreee apiを叩きます。
これはこれで新機能を使えたので結果良しとします。

/server/api/deal.ts
import type { IncomingMessage, ServerResponse } from "http";
import axios from 'axios'

const ENDPOINT = "https://api.freee.co.jp/api/1/deals"
const API_HEAD = {
  headers: {
    'Authorization': `Bearer ${process.env.access_token}`,
    // access_tokenは本来環境変数にするものではないです。力尽きました。ごめんなさい。
    'X-Api-Version': "2020-06-15",
    'accept': "application/json"
  }
}

export default async (req: IncomingMessage, res: ServerResponse) => {
  if (req.method != 'GET') {
    console.log(req.method)
    res.statusCode = 448
    res.end()
  }

  let data: Array<any>
  await axios.get(
    `${ENDPOINT}?company_id=${process.env.company_id}&limit=50`, 
    API_HEAD
  ).then(res => {
    data = res.data.deals;
  });

  const json = JSON.stringify(data)
  res.statusCode = 200
  res.setHeader('Content-Type', 'application/json')
  res.end(json)
}
pages/deals/index.vue
<script lang="ts">
import { Deal } from "@/models/deals"

export default defineComponent({
  async setup() {
    const deals = await Deal.getDeals()
    const mobileDeals = deals

    return {
      deals,mobileDeals,
      totalCount: 195,
      limit: 50,
      offset: 0,
      // totalPage: Math.ceil(totalCount / perPage),
      currentPage: 1,
    }
  }
})
</script>

/server へのアクセス元はmodelクラスを作ってそこからアクセスします。
(作り方がRails脳だなぁと思います。)
が、tsを活かすためにそこそこ真面目に書きました。
おかげで、index.vue側がかなりスマートになった印象です。

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

export interface DealIinterface {
  id:           number
  company_id:   number
  issue_date:   string
  due_date:     string
  amount:       number
  due_amount:   number
  type:         string
  partner_id:   number
  details:      Array<DealDetail>
}

// 明細行
export interface DealDetailInterface {
  id:                number
  account_item_id:   number
  tax_code:          string
  item_id:           number
  tag_ids:           Array<number>
  section_id:        number
  amount:            number
}

// 取引クラス
export class Deal implements DealIinterface {

  id:           number  // 取引ID
  company_id:   number  // 事業所ID
  issue_date:   string  // 発生日  (yyyy-mm-dd)
  due_date:     string  // 支払期日 (yyyy-mm-dd)
  amount:       number  // 金額   
  due_amount:   number  // 支払金額
  type:         string  // 収支区分 (収入: income, 支出: expense)
  partner_id:   number  // 取引先ID
  details:      Array<DealDetail>

  constructor(data: DealIinterface) {
    this.id = data.id
    this.company_id = data.company_id
    this.issue_date = data.issue_date
    this.due_date = data.due_date
    this.amount = data.amount
    this.due_amount = data.amount
    this.type = data.type
    this.partner_id = data.partner_id
    this.details = data.details.map(detail => new DealDetail(detail))
  }

  // 一覧 /deals
  static getDeals = async (page=50) => {
    const data = await $fetch('/api/deals')
    let model = (data as any).map(deal => new Deal(deal))
    return model
  }

  // 取引先名
  get partnerNeme():string {
    return Master.getByKeyword('partner', this.partner_id)
  }

  // 収支区分 (収入: income, 支出: expense)
  get typeName(): string {
    let name: string = ''
    if (this.type == 'expense') {
      name = '支出'
    } else {
      name = '収入'
    }
    return name
  }

  // 収入か
  get isExpense(): Boolean {
    return this.type == 'expense'
  }

  // 支出か
  get isIncome(): Boolean {
    return this.type == 'income'
  }
}


// 取引の明細行クラス
export class DealDetail implements DealDetailInterface  {
  id:                number         // 取引行ID
  account_item_id:   number         // 勘定科目ID
  tax_code:          string         // 税区分コード
  item_id:           number         // 品目ID
  tag_ids:           Array<number>  // メモタグID
  section_id:        number         // 部門ID
  amount:            number         // 金額

  constructor(data: DealDetailInterface) {
    this.id = data.id
    this.account_item_id = data.account_item_id
    this.tax_code = data.tax_code
    this.item_id = data.item_id
    this.tag_ids = data.tag_ids
    this.section_id = data.section_id
    this.amount = data.amount
  }

  // 税区分名
  get itemName(): string {
    return Master.getByKeyword('item', this.item_id)
  }

  // 勘定項目名
  get accountItemName(): string {
    return Master.getByKeyword('account', this.account_item_id)
  }

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

  // 税区分名
  get taxName(): string {
    return Master.getByKeyword('tax', this.tax_code)
  }

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

図にまとめるとこんな感じです。
無題のプレゼンテーション - Google スライド 2021-12-06 22-50-42.png

4. 登録画面 (登録API) ・・・ 6Hour

ezgif-1-8f33fc83867d.gif

ここからはサラッと行きます。

pages/deals/new.vue
<template lang="pug">
.container.w-full.max-w-8xl.mx-auto.mt-4
  .grid.grid-cols-12
    div(class="col-start-0 col-span-12 md:col-span-8 md:col-start-3")
      h2.text-2xl.text-center.mb-3
        i.fas.fa-edit.mr-2
        | 取引の登録

      .form-control.mb-3
        .p-6.card.shadow
          .form-control
            label.cursor-pointer.label
              span.label-text 支出
              input.radio.radio-secondary(type='radio' name='type' value='income' checked='checked' v-model="form.type")
          .form-control
            label.cursor-pointer.label
              span.label-text 収入
              input.radio.radio-primary(type='radio' name='type' value='expense' v-model="form.type")

      .p-6.card.shadow.mb-3
        .grid.grid-cols-12.gap-3
          .col-span-12.pb-2(class="md:col-span-6")
            .form-control
              label.label
                span.label-text 
                  | 発生日
                  .badge.badge-secondary.badge-outline.ml-2 必須
              input.input.input-primary.input-bordered(placeholder='2021-01-01' type='date' pattern="\d{4}-\d{2}-\d{2}" v-model="form.issue_date")
          .col-span-12.pb-2(class="md:col-span-6")
            .form-control
              label.label
                span.label-text 決済期日
              input.input.input-primary.input-bordered(placeholder='2021-01-01' type='date' pattern="\d{4}-\d{2}-\d{2}" v-model="form.due_date")

        .pb-2
          label.label
              span.label-text 取引先
          select.select.select-bordered.select-primary.w-full(v-model="form.partner_id")
            option(v-for="partner in partners" :value="partner.id" :key="partner.id") {{ partner.name }}

      .p-6.card.shadow.mb-3
        .pb-2
          label.label
              span.label-text 
                | 勘定科目
                .badge.badge-secondary.badge-outline.ml-2 必須

          select.select.select-bordered.select-primary.w-full(v-model="form.account_item_id")
            option(v-for="account in accounts" :value="account.id" :key="account.id") {{ account.name }}

        .form-control.pb-2
          label.label
            span.label-text 
              | 金額
              .badge.badge-secondary.badge-outline.ml-2 必須

          input.input.input-primary.input-bordered(placeholder='4,000' type='tell' v-model="form.amount")

        .pb-2
          label.label
            span.label-text
              | 税区分
              .badge.badge-secondary.badge-outline.ml-2 必須
          select.select.select-bordered.select-primary.w-full(v-model="form.tax_code")
            option(v-for="tax in taxes" :value="tax.code" :key="tax.code") {{ tax.name_ja }}

        .pb-2
          .grid.grid-cols-12.gap-3
            .col-span-12(class="md:col-span-6")
              label.label
                span.label-text 品目
              select.select.select-bordered.select-primary.w-full(v-model="form.item_id")
                option(v-for="item in items" :value="item.id" :key="item.id") {{ item.name }}

            .col-span-12(class="md:col-span-6")
              label.label
                span.label-text 部門
              select.select.select-bordered.select-primary.w-full(v-model="form.section_id")
                option(value="") 選択してください
                option(v-for="section in sections" :value="section.id" :key="section.id") {{ section.name }}

      button.btn.btn-xl.btn-outline.float-letf(@click="$router.back()") 戻る
      button.btn.btn-primary.btn-xl.float-right(:disabled="!isValid()" @click="postDeals()") 登録


</template>

<script lang="ts">
import { Master } from '@/models/master'
import { Deal, FormDealIinterface, FormDeal } from '@/models/deals'

function getNowYMDStr(){
  const date = new Date()
  const Y = date.getFullYear()
  const M = ("00" + (date.getMonth()+1)).slice(-2)
  const D = ("00" + date.getDate()).slice(-2)

  return `${Y}-${M}-${D}`
}

export default defineComponent({
  async setup() {
    // フォーム用のインスタンス
    let form = reactive(new FormDeal(null))
    form.type = 'income'
    form.issue_date = getNowYMDStr()

    // セレクトボックス用のリスト
    let accounts = Master.accounts.filter(a => a.tax_code != 2) // 勘定項目
    let items = Master.items  // 品目
    let sections = Master.sections // 部門
    let taxes = Master.taxes // 税
    let partners = Master.partners  // 取引先

    const postDeals = () => {
      const deal = Deal.saveDeal(form)
      console.log(deal)
      pushDetail()
    }

    const pushDetail = () => {
      window.location.href = '/deals'
    }

    const isValid = () => {
      return form.isValid()
    }

    return {
      form,
      accounts, items, sections, taxes, partners,
      postDeals,
      isValid
    }
  },
})
</script>

この辺から、modelクラスを作った恩恵が出てきます。
フォームクラスを作って、バリデーションやpost用のパラメータを生成できるようにします。
送信するまえに、ある程度バリデーションしたほうが親切なので、formでバリデーションできるようにします。
今回は必須チェックだけ。

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

export interface DealIinterface {...}

// 明細行
export interface DealDetailInterface {...}

// 取引クラス
export class Deal implements DealIinterface {
   :
   :
  // 保存 /deals
  static saveDeal = async (form: FormDealIinterface) => {
    const data = await $fetch(`/api/deal-post`, {
      method: 'POST', 
      body: form
    }).catch(reason => {
      console.log(reason)
    })
    return data
  }
}

// フォーム
export interface FormDealIinterface {
  id:              number
  company_id:      number
  issue_date:      string
  due_date:        string
  amount:          number
  due_amount:      number
  type:            string
  partner_id:      number
  account_item_id: number
  tax_code:        string
  item_id:         number
  section_id:      number
}

// フォームクラス
export class FormDeal implements FormDealIinterface {
  id:              number
  company_id:      number
  issue_date:      string
  due_date:        string
  amount:          number
  due_amount:      number
  type:            string
  partner_id:      number
  account_item_id: number
  tax_code:        string
  item_id:         number
  section_id:      number

  constructor(data: FormDealIinterface | null = null) {
    if (data != null) {
      this.id = data.id
      this.issue_date = data.issue_date
      this.due_date = data.due_date
      this.amount = data.amount
      this.due_amount = data.amount
      this.type = data.type
      this.partner_id = data.partner_id
      this.account_item_id = data.account_item_id
      this.item_id = data.item_id
      this.tax_code = data.tax_code
      this.item_id = data.item_id
      this.section_id = data.section_id
    }
  }

  isValid() {
    // 必須チェック
    if (this.issue_date && this.type && this.company_id && this.account_item_id && this.tax_code && this.amount) {
      return true
    } 
    return false
  }
  
  // POST用のパラメータに整形
  postBodyFromForm(): any {
    let body = {
      'issue_date': this.issue_date,
      'type': this.type,
      'company_id': this.company_id,
      'details': [
        {
          'tax_code': this.tax_code,
          "account_item_id": this.account_item_id,
          'amount': this.amount,
        }
      ], 
    }
    // nullは許容されないので、値がある場合のみ代入
    if (this.due_date) { body['due_date'] = this.due_date }
    if (this.partner_id) { body['partner_id'] = this.partner_id }
    if (this.item_id) { body.details[0]['item_id'] = this.item_id }
    if (this.section_id) { body.details[0]['section_id'] = this.section_id }

    return body
  }
}

一番うれしかったポイントはサーバー側でも同じmodelクラスが使えることです。
同じ言語で、同じクラスをフロントでもサーバーサイドでも使えるのはめちゃくちゃ嬉しいですね。
かなり感動しました。あたりまえなのかもしれませんが、個人的には夢を感じます。

server/api/deal-post.ts
export default (req: IncomingMessage, res: ServerResponse) => {
  console.log("www")
  if (req.method != 'POST') {
    console.log(req.method)
    res.statusCode = 448
    res.end()
  }
  let data: any
  let body: any
  let resMpdel: any
  let statusCode = 201
  let msg = null
  let response = null

  req.on('data', chunk => {
    data = `${chunk}`
  })

  req.on('end', chunk => {
    body = JSON.parse(data);
    // ===== フォームクラスが使える!!! ======
    const form = new FormDeal(body)
    axios.post(
      `${ENDPOINT}`, 
      form.postBodyFromForm(),
      { headers: API_HEAD.headers }
    ).then(res => {
      resMpdel = res.data.deal;
    }).catch(error => {
      if(error.response){
        console.log(error.response);
        console.log(error.response.data.status_code);
        console.log(error.response.data.errors[0]);
        statusCode = error.response.status_code
        msg = error.response.data.errors[0]
      }
    });
  })

  return data

  res.statusMessage = msg
  res.setHeader('Content-Type', 'application/json')
  res.end(response)
}

5. 編集画面(参照API, 更新API, 削除API) ・・・ 6Hour

ezgif-1-6e04a76df0b5.gif

nuxt3は _id.vue ではないので注意!

pages/deals/[id].vue
<template lang="pug">
.container.w-full.max-w-8xl.mx-auto.mt-4
  .grid.grid-cols-12
    div(class="col-start-0 col-span-12 md:col-span-8 md:col-start-3")
      h2.text-2xl.text-center.mb-3
        i.fas.fa-edit.mr-2
        | {{ deal.typeName }}取引の編集

      .p-6.card.shadow.mb-3
        .grid.grid-cols-12.gap-3
          .col-span-12.pb-2(class="md:col-span-6")
            .form-control
              label.label
                span.label-text 
                  | 発生日
                  .badge.badge-secondary.badge-outline.ml-2 必須
              input.input.input-primary.input-bordered(placeholder='2021-01-01' type='date' pattern="\d{4}-\d{2}-\d{2}" v-model="form.issue_date")
          .col-span-12.pb-2(class="md:col-span-6")
            .form-control
              label.label
                span.label-text 決済期日
              input.input.input-primary.input-bordered(placeholder='2021-01-01' type='date' pattern="\d{4}-\d{2}-\d{2}" v-model="form.due_date")

        .pb-2
          label.label
              span.label-text 取引先
          select.select.select-bordered.select-primary.w-full(v-model="form.partner_id")
            option(v-for="partner in partners" :value="partner.id" :key="partner.id") {{ partner.name }}


      .p-6.card.shadow.mb-3
        .pb-2
          label.label
              span.label-text 
                | 勘定科目
                .badge.badge-secondary.badge-outline.ml-2 必須

          select.select.select-bordered.select-primary.w-full(v-model="form.account_item_id")
            option(v-for="account in accounts" :value="account.id" :key="account.id") {{ account.name }}

        .form-control.pb-2
          label.label
            span.label-text 
              | 金額
              .badge.badge-secondary.badge-outline.ml-2 必須

          input.input.input-primary.input-bordered(placeholder='4,000' type='tell' v-model="form.amount")

        .pb-2
          label.label
            span.label-text
              | 税区分
              .badge.badge-secondary.badge-outline.ml-2 必須
          select.select.select-bordered.select-primary.w-full(v-model="form.tax_code")
            option(v-for="tax in taxes" :value="tax.code" :key="tax.code") {{ tax.name_ja }}

        .pb-2
          .grid.grid-cols-12.gap-3
            .col-span-12(class="md:col-span-6")
              label.label
                span.label-text 品目
              select.select.select-bordered.select-primary.w-full(v-model="form.item_id")
                option(v-for="item in items" :value="item.id" :key="item.id") {{ item.name }}

            .col-span-12(class="md:col-span-6")
              label.label
                span.label-text 部門
              select.select.select-bordered.select-primary.w-full(v-model="form.section_id")
                option(value="") 選択してください
                option(v-for="section in sections" :value="section.id" :key="section.id") {{ section.name }}

      NuxtLink(to="/deals")
        a.btn.btn-xl.float-letf.btn-outline 戻る
      button.btn.btn-primary.btn-xl.float-right(:disabled="!isValid()" @click="updateDeal()") 保存
      a.btn.btn-secondary.btn-xl.float-right.text-white.mr-2(href='#delete-confirm-modal') 削除

  div
    #delete-confirm-modal.modal
      .modal-box
        p 本当に削除しますか?
        .modal-action
          a.btn.btn-primary(@click="deleteDeal()") 削除する
          a.btn(href="/deals/edit") 閉じる


</template>

<script lang="ts">
import { Deal, FormDealIinterface, FormDeal } from '@/models/deals'
import { Master } from "@/models/master"

function getNowYMDStr(){
  const date = new Date()
  const Y = date.getFullYear()
  const M = ("00" + (date.getMonth()+1)).slice(-2)
  const D = ("00" + date.getDate()).slice(-2)

  return `${Y}-${M}-${D}`
}

export default defineComponent({
  async setup() {
    const { params } = useRoute()

    // 参照 /deals/{id}
    const deal = await Deal.getDeal(params.id as any)
    let form = reactive(new FormDeal(deal.formParam))

    // セレクトボックス用のリスト
    let accounts = Master.accounts.filter(a => a.tax_code != 2)
    let items = Master.items
    let sections = Master.sections
    let taxes = Master.taxes
    let partners = Master.partners

    // 更新 put /deals/{id}
    const updateDeal = () => {
      const deal = Deal.updateDeal(params.id as any, form)
      pushDetail()
    }

    const isValid = () => {
      return form.isValid()
    }
    
    // 削除 delete /deals/{id}
    const deleteDeal = () => {
      const deal = Deal.deleteDeal(params.id as any)
      pushDetail()
    } 

    const pushDetail = () => {
      window.location.href = '/deals'
    }

    return {
      deal,
      form,
      accounts, items, sections, taxes, partners,
      updateDeal,
      isValid,
      deleteDeal
    }
  }
})
</script>
models/deals.ts
import { Master } from './master'

export interface DealIinterface {...}

// 明細行
export interface DealDetailInterface {...}

// 取引クラス
export class Deal implements DealIinterface {
  :
  :
  :
  // 参照 /deals/{id}
  static getDeal = async (id: number): Promise<Deal> => {
    const data = await $fetch(`/api/deal/${id}`)
    let model = new Deal(data as any);
    return model
  }

  // 更新 /deals/{id}
  static updateDeal = async (id: number, form: FormDealIinterface) => {
    const data = await $fetch(`/api/deal-put/${id}`, {
      method: 'PATCH', 
      body: form
    }).catch(reason => {
      console.log(reason)
    })
    return data
  }

  // 削除 /deals/{id}
  static deleteDeal = async (id: number) => {
    const data = await $fetch(`/api/deal-delete/${id}`, {
      method: 'DELETE'
    }).catch(reason => {
      console.log(reason)
    })
    return data
  }

  // 更新用のパラメータを整形
  get formParam(): FormDealIinterface {
    return {
      'id': this.id,
      'issue_date': this.issue_date,
      'due_date': this.due_date,
      'amount': this.amount,
      'company_id': this.company_id,
      'due_amount': this.amount,
      'type': this.type,
      'partner_id': this.partner_id,
      'account_item_id': this.details[0].account_item_id,
      'item_id': this.details[0].item_id,
      'tax_code': this.details[0].tax_code,
      'section_id': this.details[0].section_id
    }
  }
}

postと同じように、エンドポイントごとにtsを作っていきます。

/server/api/*.ts
// server/api/deal.ts  参照
export default async (req: IncomingMessage, res: ServerResponse) => {
  if (req.method != 'GET') {
    console.log(req.method)
    res.statusCode = 448
    res.end()
  }

  let data: Array<any>
  await axios.get(
    `${ENDPOINT}${req.url}?company_id=${process.env.company_id}`, 
    API_HEAD
  ).then(res => {
    data = res.data.deal;
  });

  const json = JSON.stringify(data)
  res.statusCode = 200
  res.setHeader('Content-Type', 'application/json')
  res.end(json)
}

// server/api/deal-put.ts  更新
export default (req: IncomingMessage, res: ServerResponse) => {
  if (req.method != 'PUT') {
    res.statusCode = 448
    res.end()
  }
  let data: any
  let body: any
  let resMpdel: any
  let statusCode = 201
  let msg = null
  let response = null

  req.on('data', chunk => {
    data = `${chunk}`
  })

  req.on('end', chunk => {
    body = JSON.parse(data);
    const form = new FormDeal(body)
    axios.put(
      `https://api.freee.co.jp/api/1/deals${req.url}`, 
      form.postBodyFromForm(),
      { headers: API_HEAD.headers }
    ).then(res => {
      console.log('put: succes')
      resMpdel = res.data.deal;
    }).catch(error => {
      if(error.response){
        statusCode = error.response.status_code
        msg = error.response.data.errors[0]
      }
    });
  })

  return data

  res.statusMessage = msg
  res.setHeader('Content-Type', 'application/json')
  res.end(response)
}


// server/api/deal-delete.ts  削除
export default async (req: IncomingMessage, res: ServerResponse) => {
  if (req.method != 'DELETE') {
    console.log(req.method)
    res.statusCode = 448
    res.end()
  }

  let data: Array<any>
  await axios.delete(
    `${ENDPOINT}${req.url}?company_id=${process.env.company_id}`, 
    API_HEAD
  ).then(res => {
    data = res.data.deal;
  });

  const json = JSON.stringify(data)
  res.statusCode = 200
  res.setHeader('Content-Type', 'application/json')
  res.end(json)
}

6. 総括

Freee APIについて

◯良かったところ

  • ドキュメントがRailsライクっぽいので、Rails書いてる人はかなり読み込みやすい
  • APIのお試しもできるので、ためしやすかった。パラメータ多い系は特に助かる。
  • 開発用の事業所を登録したときに、大量のサンプルデータが登録されたのがかなりよかった!サンプルデータ登録って結構大変なのですごい良い(自分のサービスでも真似します)

◯つらかったところ

  • APIのコードサンプルをもっと準備してくれると、開発がもっと爆速になりそう
  • 会計知識がなさすぎて、APIの使い方が終始わからなかった。これは会計の勉強しろって話・・

連携は相乗以上に簡単にできそうなことがわかってよかったです。

Nuxt3

◯良かったところ

  • /serverがかなりよかった、*フロント、バックエンドでおなじクラスを使い回せるのは魅力的
  • 初期構築がNuxt2より簡単だった。
  • Nuxt2よりシンプルな印象でとっかかりやすい

◯つらかったところ(=これかれに期待)

  • ドキュメントはほぼない。公式もまだまだ少ない印象
  • プラグインもまだ対応していないので、これから期待という感じでしょうか。対応上京はコチラ Is Nuxt 3 ready?
  • エラーがわかりにくい気がした

Tailwindについて

  • 良い!!
  • インストールも楽で、思ったより使いやすかったです。
  • daisy uiをつかったのであまり使いこなしてはないですが他のCSSフレームワークをつかったことあれば普通に使えそう。

最後に

全く爆速ではなかったですが、かなりいい勉強になりました。(なぞに徹夜をしてしまいました)
正式リリースしたら、また作りたいと思います。
あと、Typescriptもちゃんと勉強しようと思います。
使いこなせてない感じがヒシヒシ・・・

最後までお読みいただきありがとうございました。
ご指摘、コメントあれば是非お願いします。

26
12
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
26
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?