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形式で書くように、以下のように書く
<template lang="pug">
div
NuxtPage // Wellcome componentをnuxtpageに変更
</template>
<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
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: [],
};
// ファイルごと作成
@tailwind base;
@tailwind components;
@tailwind utilities;
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'), // 追記
],
}
<template lang="pug">
div
NuxtPage
button.btn.btn-primary daisyUI Button // 確認用に追記
</template>
<script lang="ts" setup>
import './assets/css/tailwind.css' // 追記
</script>
2. トップ画面 ・・・ 2Hour
今回は、取引一覧とその編集(いわゆるCRUD)ができるようにします。
トップ画面
<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になったの注意です!
(ここで少しはまりました。)
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
ここはそんなにハマりポイントはありませんでしたが、Tailwindのgridにちょっと時間がかかりましたが、あとは他のCSSフレームワークと似てますね。
pugにしているせいで見にくいですね。。。
<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を叩きます。
これはこれで新機能を使えたので結果良しとします。
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)
}
<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側がかなりスマートになった印象です。
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 []
}
}
}
4. 登録画面 (登録API) ・・・ 6Hour
ここからはサラッと行きます。
<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でバリデーションできるようにします。
今回は必須チェックだけ。
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クラスが使えることです。
同じ言語で、同じクラスをフロントでもサーバーサイドでも使えるのはめちゃくちゃ嬉しいですね。
かなり感動しました。あたりまえなのかもしれませんが、個人的には夢を感じます。
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
nuxt3は _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>
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/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もちゃんと勉強しようと思います。
使いこなせてない感じがヒシヒシ・・・
最後までお読みいただきありがとうございました。
ご指摘、コメントあれば是非お願いします。