システム開発

VuetifyでCRUDを作る手順【Laravel6とNuxt.jsで作る管理画面】


PHPの人気のフレームワークLaravelではWebサイトの管理画面を開発することができます。

このシリーズではそんな管理画面の構築に関して、技術者向けにその手順を紹介しています。
この記事ではVuetifyを利用してCRUDを作る方法をご紹介!

Nuxt.jsからLaravelのAPIをAjaxで通信できるようにする手順はこちらの記事で解説。CookieによるAPI経由のユーザー認証機能を作る方法はこちらの記事で解説しています。

・Laravelを使って構築をしたい方
・Webサイト構築の具体的な手法が知りたい方

これらに当てはまる方におすすめの記事となっています。このシリーズを読めばLaravel6とNuxt.jsで管理画面を作成することができますよ

Vuetifyを使う理由

VuetifyはマテリアルデザインベースのVueのUIコンポーネントライブラリです。

Vueを使って画面を作りたい!と思った時に、用意されているコンポーネントを使うだけでいい感じの画面が作れてしまいます。

プロトタイプや管理画面など独自のデザインを作る程ではないが、
いい感じのUIにはしたい場合、VuetifyのようなUIコンポーネントライブラリは便利です。

またVuetifyには管理画面のCRUD機能を簡単に実装できるコンポーネントが用意されているので、楽に今回の要件を満たすことができます。

公式サイトでも紹介されている通り、Nuxt.jsは他にも様々なUIライブラリをサポートしているのでぜひ一度目を通してみてください。

最低限やりたいことを決める

今回も実装の前に、最低限やりたいことを決めておきます。

VuetifyのUIコンポーネントには検索、ページング、ソート機能も用意されているので、CRUD機能以外にも要件に追加しました。

・ 管理者の一覧表示、追加、編集、削除ができるようにする
・ 管理者の一覧画面で検索、ページング、ソートができるようにする

最終的には以下のような画面になる予定です。

Laravel(API)側の実装

CRUD機能の見た目だけであればNuxt側だけで実装できますが、
本運用では当然データの永続化が必要ですので、LaravelのAPIも実装していきます。

認証済の管理者のみAPIを実行できるようにする

まずCRUD機能用のルーティングを追加する前に、routes/api.php を以下のように編集します。

Route::group(["middleware" => "api"], function () {
    Route::post('/login', 'Auth\LoginController@login');
    Route::get('/current_admin_user', function () {
        return Auth::user();
    });
    //追加
    Route::group(['middleware' => ['auth:api']], function () {
        //ここに認証が必要なパスを書いていく
    });
});

前回の記事でauth:apiはCookieで管理者の認証を行うようになっているので、
これで認証済の管理者のみAPIを実行できるようになります。

管理者のCRUD機能用のコントローラーを作成

次に以下のコマンドを実行して管理者のCRUD機能用のコントローラーを作成します。

php artisan make:controller Api/AdminUserController --resource --api

--apiをつけることでAPIとしては不要な新規作成画面(create)と編集画面(edit)のメソッドは省略したコントローラーを作成することができます。

管理者のCRUD機能用のルーティングを作成

次にroutes/api.php を以下のように編集します。

Route::group(["middleware" => "api"], function () {
    Route::post('/login', 'Auth\LoginController@login');
    Route::get('/current_admin_user', function () {
        return Auth::user();
    });
    Route::group(['middleware' => ['auth:api']], function () {
        //追加
        Route::apiResource('admin_users', 'Api\AdminUserController')->except(['show']);
    });
});

Route::apiResourceを使うことでCRUD機能用のルーティングを作成することができます。
今回は詳細表示は不要なのでshowは除外しています。

php artisan route:list

上記のコマンドを実行して以下のような結果になっていればOKです。

管理者のCRUD機能を実装

次にapp/Http/Controllers/Api/AdminUserController.phpを以下のように編集します。

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
//追加
use Illuminate\Support\Facades\Hash;
use App\AdminUser;

class AdminUserController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */    public function index()
    {
        //追加
        return AdminUser::all();
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */    public function store(Request $request)
    {
        //追加
        $admin_user = new AdminUser;
        $admin_user->fill(array_merge(
            $request->all(), ['password' => Hash::make($request->password)]
        ))->save();
        return $admin_user;
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */    public function update(Request $request, $id)
    {
        //追加
        $admin_user = AdminUser::find($id);
        $admin_user->fill(array_merge(
            $request->all(), ['password' => Hash::make($request->password)]
        ))->save();
        return $admin_user;
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */    public function destroy($id)
    {
        //追加
        $admin_user = AdminUser::find($id);
        $admin_user->delete();
        return $admin_user;
    }
}

新規作成と更新時にはfillメソッドを使うことで、
app/AdminUser.php$fillableで許可されているカラムのみ保存されるようにしています。

Nuxt.js側の実装

次に実装したAPIを使ってNuxt.js側でCRUD機能を作っていきます。

Vuetifyのセットアップ

まず下記コマンドでVuetifyをインストールします。

yarn add @nuxtjs/vuetify -D

次にnuxt.config.jsbuildModulesの項目を以下のように編集します。

/*
** Nuxt.js dev-modules
*/buildModules: [
  //追加
  '@nuxtjs/vuetify',
],

これでVuetifyを利用する準備が整いました。

ログイン画面のレイアウトファイルを追加

次にVuetifyを使ってログイン画面のスタイルを整えていきます。
まず専用のレイアウトファイルとして、以下の内容のlayouts/admin_login.vueを追加します。

<template>
  <v-app>
    <!-- ヘッダー部分 -->
    <v-app-bar
      :clipped-left="clipped"
      fixed
      app
    >
      <v-toolbar-title v-text="title" />
    </v-app-bar>
    <!-- コンテンツ部分 -->
    <v-content>
      <v-container>
        <nuxt />
      </v-container>
    </v-content>
    <!-- フッター部分 -->
    <v-footer
      :fixed="fixed"
      app
    >
      <span>&copy; {{ new Date().getFullYear() }}</span>
    </v-footer>
  </v-app>
</template>

<script>
export default {
  data () {
    return {
      clipped: false,
      fixed: false,
      title: 'Laravel Nuxt Admin'
    }
  }
}
</script>

レイアウトファイルが追加できたら、pages/admin/login.vueに以下のようにlayoutの設定を追加します。

<script>
export default {
  middleware: 'logined_admin_user',
  //追加
  layout: 'admin_login',
  data () {
    return {
      email: '',
      password: ''
    }
  },
  methods: {
    async submit () {
      await this.$store.dispatch('auth/login', {
        email: this.email,
        password: this.password
      })
      this.$router.push('/admin')
    }
  }
}
</script>

http://localhost:3000/admin/login にアクセスしたら、以下のような画面になっていればOKです。

ログインフォームにスタイルを反映

次にpages/admin/login.vueのフォーム部分を以下のように編集します。

<template>
  <section>
    <h1 class="display-1 mt-5">管理者ログイン</h1>
    <v-form @submit.prevent="submit">
      <v-text-field label="メールアドレス" v-model="email" />
      <v-text-field type="password" label="パスワード" v-model="password" />
      <v-btn type="submit" class="info">ログイン</v-btn>
    </v-form>
  </section>
</template>

http://localhost:3000/admin/login にアクセスしたら、以下のような画面になっていればOKです。

テキストフォームを始め、Vuetifyで使えるコンポーネントは公式サイトで紹介されているので、興味のある方はぜひご覧ください。

ログイン後の画面のレイアウトファイルを追加

次にログイン後の画面のスタイルを整えていきます。
専用のレイアウトファイルとして以下の内容のlayouts/admin.vueを追加します。

<template>
  <v-app>
    <!-- サイドバー部分  -->
    <v-navigation-drawer
      v-model="drawer"
      :mini-variant="miniVariant"
      :clipped="clipped"
      fixed
      app
    >
      <v-list>
        <v-list-item
          v-for="(item, i) in items"
          :key="i"
          :to="item.to"
          router
          exact
        >
          <v-list-item-action>
            <v-icon>{{ item.icon }}</v-icon>
          </v-list-item-action>
          <v-list-item-content>
            <v-list-item-title v-text="item.title" />
          </v-list-item-content>
        </v-list-item>
      </v-list>
    </v-navigation-drawer>
    <!-- ヘッダー部分 -->
    <v-app-bar
      :clipped-left="clipped"
      fixed
      app
    >
      <v-app-bar-nav-icon @click.stop="drawer = !drawer" />
      <v-toolbar-title v-text="title" />
      <v-spacer />
    </v-app-bar>
    <!-- コンテンツ部分 -->
    <v-content>
      <v-container fluid pa-10>
        <nuxt />
      </v-container>
    </v-content>
    <!-- フッター部分 -->
    <v-footer
      :fixed="fixed"
      app
    >
      <span>© {{ new Date().getFullYear() }}</span>
    </v-footer>
  </v-app>
</template>

<script>
export default {
  data () {
    return {
      clipped: false,
      drawer: true,
      fixed: false,
      items: [
        {
          icon: 'mdi-account',
          title: '管理者一覧',
          to: '/admin'
        },
      ],
      miniVariant: false,
      title: 'Laravel Nuxt Admin'
    }
  }
}
</script>

ログイン画面と同様にpages/admin/index.vueにlayoutの設定を追加します。

<template>
  <section>
    <h1>管理画面へようこそ</h1>
  </section>
</template>

<script>
export default {
  middleware: 'not_logined_admin_user',
  //追加
  layout: 'admin',
}
</script>

ログインした状態で http://localhost:3000/admin にアクセスしたら、以下のような画面になっていればOKです。
ちゃんとレスポンシブに対応されているかも確認しておきましょう。

これで以前に比べれば、大分いい感じのUIになってきました。
ちなみにアイコンに関してはMaterial Design Iconsに対応しています。ぜひ一度目を通しておきましょう。

Vuetifyのサンプルではコンテンツ部分はcontainerクラスで囲まれているのですが、
これだと余白が広すぎたのでfluidオプションで横幅一杯に広げpa-10クラスで丁度いい余白に調整しています。

管理者一覧の実装

次にいよいよCRUD機能の実装に入っていきます。
まずは管理者の一覧表示を作っていきましょう。

管理者一覧用のStoreを追加

JSで動的に変更できるようにするため、APIで取得したデータをStoreで管理します。
以下の内容のstore/adminUsers.jsを追加します。

export const state = () => ({
  adminUsers: []
})

export const getters = {
  list (state) {
    return state.adminUsers
  }
}

export const mutations = {
  setList (state, data) {
    state.adminUsers = data
  }
}

export const actions = {
  async fetchList () {
    return await this.$axios.$get('/admin_users')
      .catch(err => {
        console.log(err)
      })
  }
}

基本方針として、非同期処理は全てStoreのactionsに記述するようにしています。
ViewDispatchActionAPI実行CommitViewのようなデータフローになります。

一覧テーブルの追加

次に先に作成したStoreを利用して管理者の一覧をテーブルに表示するため、
pages/admin/index.vueを以下の内容で編集します。

<template>
  <v-layout
    column
    justify-center
  >
    <v-card v-if="adminUsers">
      <v-card-title>
        管理者一覧
        <v-spacer />
        <v-text-field
          v-model="searchText"
          append-icon="mdi-magnify"
          label="検索"
          sigle-line
        />
      </v-card-title>
      <v-data-table
        :headers="headers"
        :items="adminUsers"
        :items-per-page="5"
        :search="searchText"
        sort-by="id"
        :sort-desc="true"
        class="elevation-1"
      >
      </v-data-table>
    </v-card>
  </v-layout>
</template>

<script>
export default {
  middleware: 'not_logined_admin_user',
  layout: 'admin',
  async fetch ({ store }) {
    const adminUsers = await store.dispatch('adminUsers/fetchList')
    store.commit('adminUsers/setList', adminUsers)
  },
  data () {
    return {
      searchText: '',
    }
  },
  computed: {
    adminUsers () {
      return this.$store.getters['adminUsers/list']
    },
    headers () {
     return [
        { text: 'ID', value: 'id' },
        { text: 'メールアドレス', value: 'email' },
        { text: '名前', value: 'name' },
        { text: '', value: 'edit-action' },
        { text: '', value: 'delete-action' },
      ]
    }
  }
}
</script>

fetchメソッドはNuxtの機能で、コンポーネントを表示する前に非同期処理で取得したデータで初期化することが出来ます。

似た機能でasyncDataがありますが、非同期処理で取得したデータをStoreにセットしてレンダリングしたい場合は、fetchメソッドを使います。

fetchで初期化した配列データをStoreから取得し、v-data-tableitems propsに渡すだけで動的にテーブルが作られます。

これで一覧表示、検索、ページング、ソート機能まで一気に実装が出来てしまいました。
一度触ってみて機能が動いているかを確認してみましょう。

編集機能の実装

次に一覧のデータの編集機能を実装していきます。

Storeに編集用のactionとmutationを追加

まずstore/adminUsers.jsに以下の内容のactionmutationを追加します。

export const mutations = {
  setList (state, data) {
    state.adminUsers = data
  },
  //追加
  update (state, data) {
    state.adminUsers.forEach((adminUser, index) => {
      if (adminUser.id === data.id) {
        state.adminUsers.splice(index, 1, data)
      }
    })
  }
}

export const actions = {
  async fetchList () {
    return await this.$axios.$get('/admin_users')
      .catch(err => {
        console.log(err)
      })
  },
  //追加
  async update ({ commit }, adminUser) {
    const response = await this.$axios.$patch(`/admin_users/${adminUser.id}`, adminUser)
      .catch(err => {
        console.log(err)
      })
    commit('update', response)
  }
}

ビューでupdateアクションがDispatchされたら、
LaravelのAPIを実行し更新した値をStoreにも反映して再レンダリングする処理になります。

一覧テーブルに編集用アイコンを表示

次に編集のトリガーとなるアイコンを一覧テーブルに表示します。pages/admin/index.vueを以下の内容で編集します。

<template>
  <v-layout
    column
    justify-center
  >
    <v-card v-if="adminUsers">
      <v-card-title>
        管理者一覧
        <v-spacer />
        <v-text-field
          v-model="searchText"
          append-icon="mdi-magnify"
          label="検索"
          sigle-line
        />
      </v-card-title>
      <v-data-table
        :headers="headers"
        :items="adminUsers"
        :items-per-page="5"
        :search="searchText"
        sort-by="id"
        :sort-desc="true"
        class="elevation-1"
      >
        <!-- 追加 -->
        <template v-slot:item.edit-action="{ item }">
          <v-icon
            small
            @click="onClickEditIcon(item)"
          >
            mdi-pencil
          </v-icon>
        </template>
      </v-data-table>
    </v-card>
  </v-layout>
</template>

<script>
export default {
  middleware: 'not_logined_admin_user',
  layout: 'admin',
  async fetch ({ store }) {
    const adminUsers = await store.dispatch('adminUsers/fetchList')
    store.commit('adminUsers/setList', adminUsers)
  },
  data () {
    return {
      //追加
      dialogAdminUser: {},
      //追加
      isShowDialog: false,
      searchText: '',
    }
  },
  computed: {
    adminUsers () {
      return this.$store.getters['adminUsers/list']
    },
    headers () {
     return [
        { text: 'ID', value: 'id' },
        { text: 'メールアドレス', value: 'email' },
        { text: '名前', value: 'name' },
        { text: '', value: 'edit-action' },
        { text: '', value: 'delete-action' },
      ]
    }
  },
  //追加
  methods: {
    onClickEditIcon (adminUser) {
      this.dialogAdminUser = Object.assign({}, adminUser)
      this.isShowDialog = true
    },
  }
}
</script>

これで以下のように編集用のアイコンが表示されるようになります。

ここで重要なのが、v-slotという記述です。
まずVue.jsにはスロットというコンポーネントを使う側がコンテンツデータを渡せる仕組みがあります。

スロットによるコンテンツ配信 | Vue.jsガイド

そしてコンポーネントに複数のスロットを渡したい場合は、名前付きスロットを使います。

名前付きスロット | Vue.jsガイド

以下のようにすることで、
コンポーネントを使う側がv-slot:xxxで名前をつけたスロットに複数の値を渡せるようになります。

# base-layoutコンポーネントの中身
<div>
 <header>
   <slot name="header"></slot>
 </header>
 <footer>
   <slot name="footer"></slot>
 </footer>
</div>

# base-layoutコンポーネントを使う側
<base-layout>
  <template v-slot:header>
    <h1>Here might be a page title</h1>
  </template>

  <template v-slot:footer>
    <p>Here's some contact info</p>
  </template>
</base-layout>

v-data-tableコンポーネントはこの名前付きスロットの仕組みを利用しており、
headersで指定した名前のスロットがtdタグの子要素として出力されるようになっています (コードはこちら)

<template v-slot:item.edit-action="{ item }">
  <v-icon
    small
    @click="onClickEditIcon(item)"
  >
    mdi-pencil
  </v-icon>
</template>

そのため上記のように書くことで編集アイコンが表示されるようになります。

編集モーダルの追加

次に編集用のモーダルを追加します。
pages/admin/index.vueを以下の内容で編集します。

<template>
  <v-layout
    column
    justify-center
  >
    <v-card v-if="adminUsers">
      <v-card-title>
        管理者一覧
        <v-spacer />
        <v-text-field
          v-model="searchText"
          append-icon="mdi-magnify"
          label="検索"
          sigle-line
        />
      </v-card-title>
      <v-data-table
        :headers="headers"
        :items="adminUsers"
        :items-per-page="5"
        :search="searchText"
        sort-by="id"
        :sort-desc="true"
        class="elevation-1"
      >
        <!-- 追加 -->
        <template v-slot:top>
          <v-dialog v-model="isShowDialog" max-width="500px">
            <v-card>
              <v-card-title>
                <span class="headline">管理者編集</span>
              </v-card-title>
              <v-card-text>
                <v-container>
                  <v-row>
                    <v-col cols="6">
                      <v-text-field v-model="dialogAdminUser.email" label="メールアドレス" />
                    </v-col>
                    <v-col cols="6">
                      <v-text-field v-model="dialogAdminUser.name" label="名前" />
                    </v-col>
                    <v-col cols="12">
                      <v-text-field type="password" v-model="dialogAdminUser.password" label="パスワード" />
                    </v-col>
                  </v-row>
                </v-container>
              </v-card-text>
              <v-card-actions>
                <v-spacer />
                <v-btn @click="closeDialog">閉じる</v-btn>
                <v-btn class="primary" @click="onClickUpdateBtn">更新する</v-btn>
                <v-spacer />
              </v-card-actions>
            </v-card>
          </v-dialog>
        </template>
        <template v-slot:item.edit-action="{ item }">
          <v-icon
            small
            @click="onClickEditIcon(item)"
          >
            mdi-pencil
          </v-icon>
        </template>
      </v-data-table>
    </v-card>
  </v-layout>
</template>

<script>
export default {
  middleware: 'not_logined_admin_user',
  layout: 'admin',
  async fetch ({ store }) {
    const adminUsers = await store.dispatch('adminUsers/fetchList')
    store.commit('adminUsers/setList', adminUsers)
  },
  data () {
    return {
      dialogAdminUser: {},
      isShowDialog: false,
      searchText: '',
    }
  },
  computed: {
    adminUsers () {
      return this.$store.getters['adminUsers/list']
    },
    headers () {
     return [
        { text: 'ID', value: 'id' },
        { text: 'メールアドレス', value: 'email' },
        { text: '名前', value: 'name' },
        { text: '', value: 'edit-action' },
        { text: '', value: 'delete-action' },
      ]
    }
  },
  methods: {
    //追加
    closeDialog () {
      this.dialogAdminUser = {}
      this.isShowDialog = false
    },
    onClickEditIcon (adminUser) {
      this.dialogAdminUser = Object.assign({}, adminUser)
      this.isShowDialog = true
    },
    //追加
    async onClickUpdateBtn () {
      await this.$store.dispatch('adminUsers/update', this.dialogAdminUser)
      this.closeDialog()
    }
  }
}
</script>

v-slot:topでテーブルの上にモーダル用のテンプレートがスロットとして出力され、isShowDialogがtrueになった時に表示されるようになります。

v-data-tableコンポーネントで利用できるスロットの種類はこちらを参照してください。

これで下記のようなモーダルで内容を編集できるようになりました。


削除機能の実装

次に削除機能を実装していきます。

Storeに削除用のactionとmutationを追加

まずstore/adminUsers.jsに以下の内容のactionmutationを追加します。

export const state = () => ({
  adminUsers: []
})

export const getters = {
  list (state) {
    return state.adminUsers
  }
}

export const mutations = {
  setList (state, data) {
    state.adminUsers = data
  },
  //追加
  delete (state, data) {
    state.adminUsers.forEach((adminUser, index) => {
      if (adminUser.id === data.id) {
        state.adminUsers.splice(index, 1)
      }
    })
  },
  update (state, data) {
    state.adminUsers.forEach((adminUser, index) => {
      if (adminUser.id === data.id) {
        state.adminUsers.splice(index, 1, data)
      }
    })
  }
}

export const actions = {
  async fetchList () {
    return await this.$axios.$get('/admin_users')
      .catch(err => {
        console.log(err)
      })
  },
  //追加
  async delete ({ commit }, adminUser) {
    const response = await this.$axios.$delete(`/admin_users/${adminUser.id}`)
      .catch(err => {
        console.log(err)
      })
    commit('delete', response)
  },
  async update ({ commit }, adminUser) {
    const response = await this.$axios.$patch(`/admin_users/${adminUser.id}`, adminUser)
      .catch(err => {
        console.log(err)
      })
    commit('update', response)
  }
}


一覧テーブルに削除用アイコンを表示

次に同様に削除のトリガーとなるアイコンを一覧テーブルに表示します。
pages/admin/index.vueを以下の内容で編集します。

<template>
  <v-layout
    column
    justify-center
  >
    <v-card v-if="adminUsers">
      <v-card-title>
        管理者一覧
        <v-spacer />
        <v-text-field
          v-model="searchText"
          append-icon="mdi-magnify"
          label="検索"
          sigle-line
        />
      </v-card-title>
      <v-data-table
        :headers="headers"
        :items="adminUsers"
        :items-per-page="5"
        :search="searchText"
        sort-by="id"
        :sort-desc="true"
        class="elevation-1"
      >
        <template v-slot:top>
          <v-dialog v-model="isShowDialog" max-width="500px">
            <v-card>
              <v-card-title>
                <span class="headline">管理者編集</span>
              </v-card-title>
              <v-card-text>
                <v-container>
                  <v-row>
                    <v-col cols="6">
                      <v-text-field v-model="dialogAdminUser.email" label="メールアドレス" />
                    </v-col>
                    <v-col cols="6">
                      <v-text-field v-model="dialogAdminUser.name" label="名前" />
                    </v-col>
                    <v-col cols="12">
                      <v-text-field type="password" v-model="dialogAdminUser.password" label="パスワード" />
                    </v-col>
                  </v-row>
                </v-container>
              </v-card-text>
              <v-card-actions>
                <v-spacer />
                <v-btn @click="closeDialog">閉じる</v-btn>
                <v-btn class="primary" @click="onClickUpdateBtn">更新する</v-btn>
                <v-spacer />
              </v-card-actions>
            </v-card>
          </v-dialog>
        </template>
        <template v-slot:item.edit-action="{ item }">
          <v-icon
            small
            @click="onClickEditIcon(item)"
          >
            mdi-pencil
          </v-icon>
        </template>
        <!-- 追加 -->
        <template v-slot:item.delete-action="{ item }">
          <v-icon
            small
            @click="onClickDeleteIcon(item)"
          >
            mdi-delete
          </v-icon>
        </template>
      </v-data-table>
    </v-card>
  </v-layout>
</template>

<script>
export default {
  middleware: 'not_logined_admin_user',
  layout: 'admin',
  async fetch ({ store }) {
    const adminUsers = await store.dispatch('adminUsers/fetchList')
    store.commit('adminUsers/setList', adminUsers)
  },
  data () {
    return {
      dialogAdminUser: {},
      isShowDialog: false,
      searchText: '',
    }
  },
  computed: {
    adminUsers () {
      return this.$store.getters['adminUsers/list']
    },
    headers () {
     return [
        { text: 'ID', value: 'id' },
        { text: 'メールアドレス', value: 'email' },
        { text: '名前', value: 'name' },
        { text: '', value: 'edit-action' },
        { text: '', value: 'delete-action' },
      ]
    }
  },
  methods: {
    closeDialog () {
      this.dialogAdminUser = {}
      this.isShowDialog = false
    },
    onClickEditIcon (adminUser) {
      this.dialogAdminUser = Object.assign({}, adminUser)
      this.isShowDialog = true
    },
    //追加
    async onClickDeleteIcon (adminUser) {
      await this.$store.dispatch('adminUsers/delete', adminUser)
    },
    async onClickUpdateBtn () {
      await this.$store.dispatch('adminUsers/update', this.dialogAdminUser)
      this.closeDialog()
    }
  }
}
</script>

これで以下のように削除アイコンが表示され、データを削除出来るようになりました。

新規追加の実装

最後に新規追加の実装を行っていきます。

Storeに新規追加用のactionとmutationを追加

export const state = () => ({
  adminUsers: []
})

export const getters = {
  list (state) {
    return state.adminUsers
  }
}

export const mutations = {
  setList (state, data) {
    state.adminUsers = data
  },
  //追加
  create (state, data) {
    state.adminUsers.push(data)
  },
  delete (state, data) {
    state.adminUsers.forEach((adminUser, index) => {
      if (adminUser.id === data.id) {
        state.adminUsers.splice(index, 1)
      }
    })
  },
  update (state, data) {
    state.adminUsers.forEach((adminUser, index) => {
      if (adminUser.id === data.id) {
        state.adminUsers.splice(index, 1, data)
      }
    })
  }
}

export const actions = {
  async fetchList () {
    return await this.$axios.$get('/admin_users')
      .catch(err => {
        console.log(err)
      })
  },
  //追加
  async create ({ commit }, adminUser) {
    const response = await this.$axios.$post('/admin_users', adminUser)
      .catch(err => {
        console.log(err)
      })
    commit('create', response)
  },
  async delete ({ commit }, adminUser) {
    const response = await this.$axios.$delete(`/admin_users/${adminUser.id}`)
      .catch(err => {
        console.log(err)
      })
    commit('delete', response)
  },
  async update ({ commit }, adminUser) {
    const response = await this.$axios.$patch(`/admin_users/${adminUser.id}`, adminUser)
      .catch(err => {
        console.log(err)
      })
    commit('update', response)
  }
}


モーダルを新規追加・編集両方で利用できるように修正

次に新規追加でもモーダルを利用できるように、pages/admin/index.vueを以下の内容で編集します。

<template>
  <v-layout
    column
    justify-center
  >
    <v-card v-if="adminUsers">
      <v-card-title>
        管理者一覧
        <v-spacer />
        <v-text-field
          v-model="searchText"
          append-icon="mdi-magnify"
          label="検索"
          sigle-line
        />
      </v-card-title>
      <v-data-table
        :headers="headers"
        :items="adminUsers"
        :items-per-page="5"
        :search="searchText"
        sort-by="id"
        :sort-desc="true"
        class="elevation-1"
      >
        <template v-slot:top>
          <v-dialog v-model="isShowDialog" max-width="500px">
            <v-card>
              <v-card-title>
                <!-- 編集 -->
                <span class="headline">{{ formTitle }}</span>
              </v-card-title>
              <v-card-text>
                <v-container>
                  <v-row>
                    <v-col cols="6">
                      <v-text-field v-model="dialogAdminUser.email" label="メールアドレス" />
                    </v-col>
                    <v-col cols="6">
                      <v-text-field v-model="dialogAdminUser.name" label="名前" />
                    </v-col>
                    <v-col cols="12">
                      <v-text-field type="password" v-model="dialogAdminUser.password" label="パスワード" />
                    </v-col>
                  </v-row>
                </v-container>
              </v-card-text>
              <v-card-actions>
                <v-spacer />
                <v-btn @click="closeDialog">閉じる</v-btn>
                <!-- 編集 -->
                <v-btn v-if="isPersistedAdminUser" class="primary" @click="onClickUpdateBtn">更新する</v-btn>
                <!-- 追加 -->
                <v-btn v-else class="primary" @click="onClickCreateBtn">追加する</v-btn>
                <v-spacer />
              </v-card-actions>
            </v-card>
          </v-dialog>
        </template>
        <template v-slot:item.edit-action="{ item }">
          <v-icon
            small
            @click="onClickEditIcon(item)"
          >
            mdi-pencil
          </v-icon>
        </template>
        <template v-slot:item.delete-action="{ item }">
          <v-icon
            small
            @click="onClickDeleteIcon(item)"
          >
            mdi-delete
          </v-icon>
        </template>
      </v-data-table>
    </v-card>
  </v-layout>
</template>

<script>
export default {
  middleware: 'not_logined_admin_user',
  layout: 'admin',
  async fetch ({ store }) {
    const adminUsers = await store.dispatch('adminUsers/fetchList')
    store.commit('adminUsers/setList', adminUsers)
  },
  data () {
    return {
      dialogAdminUser: {},
      isShowDialog: false,
      searchText: '',
    }
  },
  computed: {
    adminUsers () {
      return this.$store.getters['adminUsers/list']
    },
    //追加
    formTitle () {
      return this.isPersistedAdminUser ? '管理者編集' : '管理者追加'
    },
    headers () {
     return [
        { text: 'ID', value: 'id' },
        { text: 'メールアドレス', value: 'email' },
        { text: '名前', value: 'name' },
        { text: '', value: 'edit-action' },
        { text: '', value: 'delete-action' },
      ]
    },
    //追加
    isPersistedAdminUser () {
      return !!this.dialogAdminUser.id
    },
  },
  methods: {
    closeDialog () {
      //編集
      this.isShowDialog = false
      setTimeout(() => {
        this.dialogAdminUser = {}
      }, 1000)
    },
    onClickEditIcon (adminUser) {
      this.dialogAdminUser = Object.assign({}, adminUser)
      this.isShowDialog = true
    },
    //追加
    async onClickCreateBtn () {
      await this.$store.dispatch('adminUsers/create', this.dialogAdminUser)
      this.closeDialog()
    },
    async onClickDeleteIcon (adminUser) {
      await this.$store.dispatch('adminUsers/delete', adminUser)
    },
    async onClickUpdateBtn () {
      await this.$store.dispatch('adminUsers/update', this.dialogAdminUser)
      this.closeDialog()
    }
  }
}
</script>


新規追加用のボタンを追加

次に新規追加でモーダルを表示するためのボタンを追加します。pages/admin/index.vueを以下の内容で編集します。

<template>
  <v-layout
    column
    justify-center
  >
    <v-card v-if="adminUsers">
      <v-card-title>
        管理者一覧
        <v-spacer />
        <v-text-field
          v-model="searchText"
          append-icon="mdi-magnify"
          label="検索"
          sigle-line
        />
      </v-card-title>
      <v-data-table
        :headers="headers"
        :items="adminUsers"
        :items-per-page="5"
        :search="searchText"
        sort-by="id"
        :sort-desc="true"
        class="elevation-1"
      >
        <template v-slot:top>
          <v-dialog v-model="isShowDialog" max-width="500px">
            <!-- 追加 -->
            <template v-slot:activator>
              <v-btn fab dark color="pink" class="mb-2"
                @click="onClickAddBtn"
              >
                <v-icon dark>
                  mdi-plus
                </v-icon>
              </v-btn>
            </template>
            <v-card>
              <v-card-title>
                <span class="headline">{{ formTitle }}</span>
              </v-card-title>
              <v-card-text>
                <v-container>
                  <v-row>
                    <v-col cols="6">
                      <v-text-field v-model="dialogAdminUser.email" label="メールアドレス" />
                    </v-col>
                    <v-col cols="6">
                      <v-text-field v-model="dialogAdminUser.name" label="名前" />
                    </v-col>
                    <v-col cols="12">
                      <v-text-field type="password" v-model="dialogAdminUser.password" label="パスワード" />
                    </v-col>
                  </v-row>
                </v-container>
              </v-card-text>
              <v-card-actions>
                <v-spacer />
                <v-btn @click="closeDialog">閉じる</v-btn>
                <v-btn v-if="isPersistedAdminUser" class="primary" @click="onClickUpdateBtn">更新する</v-btn>
                <v-btn v-else class="primary" @click="onClickCreateBtn">追加する</v-btn>
                <v-spacer />
              </v-card-actions>
            </v-card>
          </v-dialog>
        </template>
        <template v-slot:item.edit-action="{ item }">
          <v-icon
            small
            @click="onClickEditIcon(item)"
          >
            mdi-pencil
          </v-icon>
        </template>
        <template v-slot:item.delete-action="{ item }">
          <v-icon
            small
            @click="onClickDeleteIcon(item)"
          >
            mdi-delete
          </v-icon>
        </template>
      </v-data-table>
    </v-card>
  </v-layout>
</template>

<script>
export default {
  middleware: 'not_logined_admin_user',
  layout: 'admin',
  async fetch ({ store }) {
    const adminUsers = await store.dispatch('adminUsers/fetchList')
    store.commit('adminUsers/setList', adminUsers)
  },
  data () {
    return {
      dialogAdminUser: {},
      isShowDialog: false,
      searchText: '',
    }
  },
  computed: {
    adminUsers () {
      return this.$store.getters['adminUsers/list']
    },
    formTitle () {
      return this.isPersistedAdminUser ? '管理者編集' : '管理者追加'
    },
    headers () {
     return [
        { text: 'ID', value: 'id' },
        { text: 'メールアドレス', value: 'email' },
        { text: '名前', value: 'name' },
        { text: '', value: 'edit-action' },
        { text: '', value: 'delete-action' },
      ]
    },
    isPersistedAdminUser () {
      return !!this.dialogAdminUser.id
    },
  },
  methods: {
    closeDialog () {
      this.isShowDialog = false
      setTimeout(() => {
        this.dialogAdminUser = {}
      }, 1000)
    },
    //追加
    onClickAddBtn () {
      this.dialogAdminUser = {}
      this.isShowDialog = true
    },
    onClickEditIcon (adminUser) {
      this.dialogAdminUser = Object.assign({}, adminUser)
      this.isShowDialog = true
    },
    async onClickCreateBtn () {
      await this.$store.dispatch('adminUsers/create', this.dialogAdminUser)
      this.closeDialog()
    },
    async onClickDeleteIcon (adminUser) {
      await this.$store.dispatch('adminUsers/delete', adminUser)
    },
    async onClickUpdateBtn () {
      await this.$store.dispatch('adminUsers/update', this.dialogAdminUser)
      this.closeDialog()
    }
  }
}
</script>

v-dialogコンポーネントのv-slot:activatorスロットとして、
フローティングボタンを追加して、クリックしたらモーダルを表示するようにしています。

以下のような画面が表示されればOKです。新規追加が正常に動くかも確認しておきましょう。

おわりに

今回はLaravel(API)とVuetifyを組み合わせてCRUD機能の実装を行いました。
UIコンポーネントを組み合わせれば、柔軟にカスタマイズが可能です。

これでLaravel(API)とNuxt.jsで管理画面を作ることができました。
1つ1つの手順を見直して実装をしていきましょう。


本日紹介したようなデザインを外注してみるのはいかがでしょうか。 dehaソリューションズではオフショア開発によって低コストで迅速な開発をサポートしています。

Laravelに関して詳しくお話を聞きたい方、開発相談や無料お見積りをしたい方はこちらからご気軽にお問い合わせください。

▼ dehaソリューションへの簡単見積もりの依頼はこちら

Mai Tran

Recent Posts

【オフショア開発の価格高騰】各国の最新コスト動向と今後の展望

近年、IT開発の現場では「オフショア開発のコストが上昇している」という声が多く聞かれるようになりました。 かつてオフショア開発は「低コストで開発できる手段」として広く活用されてきましたが、現在ではその前提が変化しつつあります。 為替環境の変化、各国の人件費上昇、グローバル市場の競争激化などにより、オフショア開発の価格構造は大きく変わり始めています。 一方で、日本国内ではエンジニア不足が深刻化しており、企業は開発リソースを確保するために海外人材の活用を続けざるを得ない状況にあります。 つまり、オフショア開発は「安いから使う」ものから、「必要だから使う」ものへと役割が変化しているのです。 この記事では、オフショア開発の最新動向をもとに、各国のコスト動向、企業の発注傾向、案件内容の変化、契約形態の変化、そして今後の展望について詳しく解説します。 オフショア開発を検討している方 開発効率を上げたい方 社内のIT人材が不足している方 これらに当てはまる方におすすめの記事となっています。これを読めばオフショア開発のコスト面について最新の情報がわかるのはもちろん、今後の展望もわかりますよ。 (more…)

4 days ago

【不動産DX】不動産業界に最適なオークション形式とシステム選定のポイント

不動産業界は、これまで「対面営業」「紙契約」「属人的な価格交渉」といったアナログな手法が中心でした。 しかし近年、デジタル技術の進化と顧客行動の変化により、業界全体でDX(デジタルトランスフォーメーション)が加速しています。 この記事ではそんな不動産業界のDX化において、注目されている「オークション形式」についてどんな特徴があるのかや、システムを選定する際のポイントについて見ていきたいと思います。 DX化をすすめたい企業の方 不動産業界の方 社内のIT人材が不足している方 これらに当てはまる方におすすめの記事となっています。これを読めば不動産業界におけるオークション形式のポイントや注意点が丸わかりですよ。 不動産DXが求められる背景とオークションモデルの可能性 国土交通省の電子契約解禁やオンライン重要事項説明の普及により、売買・賃貸のプロセスは大きく変わりました。さらに、ポータルサイト依存型の集客モデルから脱却し、より収益性の高い販売手法を模索する動きが強まっています。 そこで注目されているのが「オークション形式」です。 従来の不動産取引は「売主が価格を提示し、買主が交渉する」という相対交渉モデルが一般的でした。 しかし、オークションモデルでは市場原理をより明確に反映させることが可能です。需要が集中するエリアや希少物件では価格が自然に上昇し、売主にとっては最大利益を得られる可能性があります。 また、オークション形式は透明性の向上にも寄与します。 価格決定のプロセスが明確になり、「なぜこの価格になったのか」という説明責任を果たしやすくなります。 これはコンプライアンス強化が求められる現代において大きな利点です。…

2 weeks ago

2026年のAIエージェント トレンド【Googleの調査】

2026年、AI活用は新たなフェーズへと突入します。これまでの「生成AIを使う」段階から、「AIエージェントが業務を遂行する」段階へと進化しています。 Google Cloudが発表したレポート『AI agent trends 2026』では、企業活動におけるAIの中心がAgentic AI(エージェント型AI)へ移行すると指摘しています。 AIエージェントとは、単に質問に答える存在ではありません。目標を理解し、計画を立て、複数のシステムを横断しながら実行まで行う「行動するAI」です。 この記事では、Googleの調査をもとに、2026年を形づくる5つのAIエージェントトレンドを詳しく解説します。 AIエージェントは何か知りたい方 業務効率を上げたい方 これらに当てはまる方におすすめの数となっています。これを読めばAIエージェントのトレンドがわかるのはもちろん、利用のポイントもわかりますよ。 すべての従業員にAIエージェントがつく時代(Agents for Every…

2 weeks ago

3層品質保証で実現する安心のITアウトソーシング体制

グローバル市場におけるITアウトソーシングでは、品質保証は単なる最終テスト工程ではありません。 品質は「工程の最後で確認するもの」ではなく、「開発の初期段階から設計され、統制されるべき経営基盤」です。  従来型のQAがリリース直前のテストに依存するのに対し、DEHA SOLUTIONSではTQA・PQA・SQAの3層構造により、技術・プロセス・サービス全体を横断的に管理しています。 これは単なる品質向上施策ではなく、リスクコントロールと持続的成長を実現するためのガバナンス設計です。  (more…)

2 weeks ago

システム開発におけるPMの役割を徹底解説|失敗や納期遅延を防ぐポイント

システム開発プロジェクトにおいて、成功と失敗を分ける最大の要因は「PM(プロジェクトマネージャー)」の力量だと言っても過言ではありません。 技術力の高いエンジニアが揃っていても、要件が曖昧だったり、スケジュールが破綻したり、関係者間の認識がずれたりすれば、プロジェクトは簡単に炎上します。 特に近年は、アジャイル開発やハイブリッド型開発など手法の多様化、オフショア開発の増加、DX推進によるスピード要求の高まりなど、PMに求められる能力はますます高度化しています。 この記事では、そんなシステム開発におけるPMの役割を体系的に整理し、失敗や納期遅延を防ぐための実践的なポイントを徹底解説します。 システム開発をしたい方 システム開発を効率よく行いたい方 社内にIT人材が不足している方 これらに当てはまる方におすすめの記事となっています。これを読めばシステム開発におけるPMの役割がわかるのはもちろん、失敗しないためのポイントも丸わかりですよ。 PMとは何か?システム開発における本質的な役割 システム開発におけるPM(プロジェクトマネージャー)は、単なる進捗管理者ではありません。 PMの本質的な役割は、「プロジェクトを成功に導くための総責任者」であることです。 プロジェクトには必ず「QCD(品質・コスト・納期)」という制約があります。さらに、近年では「スコープ(範囲)」や「リスク」、「ステークホルダー満足度」も重要な要素です。 PMはこれらすべてを統合的に管理し、バランスを取りながら意思決定を行います。PMの主な責任領域は以下の通りです。 目的・ゴールの明確化 要件定義の統括…

3 weeks ago

アジャイル・ウォーターフォールハイブリッド開発の手法とは?オフショア開発に効果?

アジャイル・ウォーターフォールハイブリッド開発は、ウォーターフォール開発の計画性・文書化・統制力と、アジャイル開発の柔軟性・反復改善・顧客密着型の進め方を組み合わせる手法です。 この記事では、そんなアジャイル・ウォーターフォールハイブリッド開発の基本概念から具体的な実践方法、さらにオフショア開発における効果や導入時の注意点まで、体系的に解説していきます。 アジャイル・ウォーターフォールハイブリッド開発が気になる方 オフショア開発に興味がある方 開発効率を上げたい方 これらに当てはまる方におすすめの記事となっています。これを読めばアジャイル・ウォーターフォールハイブリッド開発について特徴わかるだけでなく、導入のポイントも丸わかりですよ。 なぜ今「ハイブリッド開発」が注目されているのか 近年、ITシステム開発の現場では「スピード」と「品質」の両立が強く求められています。市場環境は急速に変化し、顧客ニーズも多様化しています。 その一方で、セキュリティ要件や法規制への対応、社内ガバナンスの強化など、開発プロジェクトに求められる統制レベルは年々高まっています。 このような背景の中で、従来型のウォーターフォール開発だけでは変化への対応が難しく、またアジャイル開発だけでは大規模案件や厳格な要件管理が必要なプロジェクトに対応しきれないケースも増えています。 そこで注目されているのが、「アジャイル・ウォーターフォールハイブリッド開発」です。 これは、ウォーターフォール開発の計画性・文書化・統制力と、アジャイル開発の柔軟性・反復改善・顧客密着型の進め方を組み合わせる手法です。 単なる折衷案ではなく、プロジェクトの特性やフェーズに応じて最適な開発アプローチを選択・融合する実践的な方法論といえます。 特にオフショア開発においては、言語・文化・時差・契約形態といった要素が絡み合うため、開発手法の選択はプロジェクトの成否を左右します。 日本国内で要件定義を固めた上で海外チームに実装を委託するケース、あるいは海外側に一部設計まで任せるケースなど、形態はさまざまです。…

3 weeks ago