Pendahuluan
Dalam setiap kegiatan berwirausaha pasti terdapat biaya biaya yang harus dikeluarkan untuk menjalankan operasional dari usaha tersebut, maka dalam aplikasi ini, kita tidak hanya akan mencatat transaksi yang masuk tapi juga memiliki fitur untuk mencatat pengeluaran yang telah dikeluarkan.
Adapun schema yang diinginkan adalah setiap user dapat melakukan request untuk setiap keperluan / biaya yang dibutuhkannya dan finance memiliki peran untuk menyetujui/menolak permintaan tersebut disertai dengan alasan sebagai feedback kepada user yang melakukan permintaan.
Pada fitur ini, selain berinteraksi dengan proses manipulasi database, juga kita akan belajar hal baru yakni push notifications secara real time. Jadi ketika user melakukan permintaan, maka secara otomatis notifikasi tersebut akan tampil pada user terkait tanpa harus melakukan reload browser.
Baca Juga: Aplikasi Laundry (Laravel 5.8 - Vue.js - SPA) #6: Role & User Permissions
Manage Request Lists
Module ini akan membahas bagaimana membuat fitur untuk menampilkan data request yang telah dilakukan, dimana setiap kurir/admin hanya akan melihat data request-nya sendiri terkecuali finance dan superadmin. Sebelum melangkah lebih jauh, maka kita perlu menambahkan dua buah field kedalam table expanses
, pada command line jalankan command:
php artisan make:migration add_field_status_to_expenses_table
Buka file migration tersebut dan modifikasi menjadi:
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddFieldStatusToExpensesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('expenses', function (Blueprint $table) {
$table->char('status', 1)->comment('0:open, 1: approved, 2: canceled')->after('user_id')->nullable();
$table->string('reason')->nullable()->after('status');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('expenses', function (Blueprint $table) {
$table->dropColumn('status', 1);
$table->dropColumn('reason');
});
}
}
Penjelasan: Code diatas akan menambahkan dua buah field, pertama status dengan tipe char dan yang kedua adalah reason untuk menampung alasan penolakan dari permintaan user.
Kemudian jalankan command:
php artisan migrate
Masih pada command line, buat controller dengan command:
php artisan make:controller API/ExpensesController
Kemudian buat juga resources collection:
php artisan make:resource ExpenseCollection
Buka file ExpenseCollection.php
yang berada di dalam folder App/Http/Resources
dan modifikasi menjadi:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ExpenseCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
public function toArray($request)
{
return [
'data' => $this->collection
];
}
}
Saatnya kita mengerjakan API untuk menampilkan data expenses, buka file ExpensesController.php
dan tambahkan method:
public function index()
{
$user = request()->user(); //AMBIL DATA USER YANG SEDANG LOGIN
$expenses = Expense::with(['user'])->orderBy('created_at', 'DESC'); //GET DATA EXPENSES YANG DIURUTKAN BERDASARKAN DATA TERBARU BESERTA SEBUAH EAGER LOADING UNTUK MENGAMBIL SIAPA PEMILIK DATA TERSEBUT
//APABILA ADA PENCARIAN
if (request()->q != '') {
//MAKA AMBIL DATA BERDASARKAN PENCARIAN YANG DILAKUKAN
$expenses = $expenses->where('description', 'LIKE', '%' . request()->q . '%');
}
//JIKA ROLE USER YANG LOGIN ADALAH 1 (ADMIN) & 3 (KURIR), MAKA AMBIL DATA KHUSUS DATA MEREKA SAJA
if (in_array($user->role, [1, 3])) {
$expenses = $expenses->where('user_id', $user->id);
}
return (new ExpenseCollection($expenses->paginate(10)));
}
Masih dengan file yang sama, tambahkan use statement:
use App\Http\Resources\ExpenseCollection;
use App\Expense;
Selanjutnya adalah membuat routing untuk API-nya, buka file routes/api.php
dan tambahkan route dibawah ini:
Route::resource('expenses', 'API\ExpensesController')->except(['create', 'show']);
Sebelum berpindah pada sisi client, buka file app/Expense.php
dan modifikasi menjadi:
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Expense extends Model
{
protected $guarded = [];
public function user()
{
return $this->belongsTo(User::class);
}
}
Satu hal lagi, agar nama user dimulai dengan huruf kapital maka kita perlu membuat Accessor. Buat file app/User.php
dan tambahkan method:
public function getNameAttribute($value)
{
return ucfirst($value);
}
API sudah selesai, maka saatnya untuk membuat halaman yang akan menggunakan API tersebut, buka file resources/js/router.js
dan tambahkan route berikut:
{
path: '/expenses',
component: IndexExpenses,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'expenses.data',
component: DataExpenses,
meta: { title: 'Manage Expenses' }
}
]
}
Pada file yang sama tambahkan line berikut pada bagian import statement:
import IndexExpenses from './pages/expenses/Index.vue'
import DataExpenses from './pages/expenses/Expenses.vue'
Kemudian buat file Index.vue
di dalam folder /pages/expenses
dan tambahkan code:
<template>
<div class="container">
<section class="content-header">
<h1>
Manage Expenses
</h1>
<breadcrumb></breadcrumb>
</section>
<section class="content">
<div class="row">
<router-view></router-view>
</div>
</section>
</div>
</template>
<script>
import Breadcrumb from '../../components/Breadcrumb.vue'
export default {
name: 'IndexExpenses',
components: {
'breadcrumb': Breadcrumb
}
}
</script>
Note: Tidak ada yang perlu dijelaskan karena sama dengna artikel sebelumnya.
Lalu file selanjutnya adalah Expenses.vue
didalam folder yang sama.
<template>
<div class="col-md-12">
<div class="panel">
<div class="panel-heading">
<router-link :to="{ name: 'expenses.create' }" class="btn btn-primary btn-sm btn-flat">Tambah</router-link>
<div class="pull-right">
<input type="text" class="form-control" placeholder="Cari..." v-model="search">
</div>
</div>
<div class="panel-body">
<!-- TABLE INI AKAN MENAMPILKAN DATA EXPENSES -->
<b-table striped hover bordered :items="expenses.data" :fields="fields" show-empty>
<template slot="status" slot-scope="row">
<span class="label label-success" v-if="row.item.status == 1">Diterima</span>
<span class="label label-warning" v-else-if="row.item.status == 0">Diproses</span>
<span class="label label-default" v-else>Ditolak</span>
</template>
<template slot="user" slot-scope="row">
{{ row.item.user.name }}
</template>
<template slot="reason" slot-scope="row">
{{ row.item.reason == '' ? '-':row.item.reason }}
</template>
<template slot="actions" slot-scope="row">
<router-link :to="{ name: 'expenses.edit', params: {id: row.item.id} }" class="btn btn-warning btn-sm" v-if="row.item.status == 0"><i class="fa fa-pencil"></i></router-link>
<router-link :to="{ name: 'expenses.view', params: {id: row.item.id} }" class="btn btn-info btn-sm"><i class="fa fa-eye"></i></router-link>
<button class="btn btn-danger btn-sm" @click="deleteExpenses(row.item.id)" v-if="row.item.status == 0"><i class="fa fa-trash"></i></button>
</template>
</b-table>
<!-- TABLE INI AKAN MENAMPILKAN DATA EXPENSES -->
<div class="row">
<!-- BAGIAN INI AKAN MENAMPILKAN INFORMASI DATA DAN PAGINATION -->
<div class="col-md-6">
<p v-if="expenses.data"><i class="fa fa-bars"></i> {{ expenses.data.length }} item dari {{ expenses.meta.total }} total data</p>
</div>
<div class="col-md-6">
<div class="pull-right">
<b-pagination
v-model="page"
:total-rows="expenses.meta.total"
:per-page="expenses.meta.per_page"
aria-controls="expenses"
v-if="expenses.data && expenses.data.length > 0"
></b-pagination>
</div>
</div>
<!-- BAGIAN INI AKAN MENAMPILKAN INFORMASI DATA DAN PAGINATION -->
</div>
</div>
</div>
</div>
</template>
<script>
import { mapActions, mapState } from 'vuex'
export default {
name: 'DataExpenses',
created() {
this.getExpenses() //KETIKA HALAMAN DI-LOAD MAKA FUNGSI INI AKAN DIJALANKAN
},
data() {
return {
//UNTUK FIELD TABLE DIATAS YANG AKAN DITAMPILKAN
fields: [
{ key: 'description', label: 'Permintaan' },
{ key: 'price', label: 'Biaya' },
{ key: 'note', label: 'Catatan' },
{ key: 'user', label: 'Kurir/Admin' },
{ key: 'status', label: 'Status' },
{ key: 'reason', label: 'Alasan' },
{ key: 'actions', label: 'Aksi' }
],
search: '' //VARIABLE UNTUK SEARCH
}
},
computed: {
...mapState('expenses', {
expenses: state => state.expenses //AMBIL STATE DARI MODULE EXPENSES YANG AKAN DIBUAT KEMUDIAN
}),
//AMBIL DAN MODIFIKASI STATE PAGE JIKA TERJADI PERUBAHAN
page: {
get() {
return this.$store.state.expenses.page
},
set(val) {
this.$store.commit('expenses/SET_PAGE', val)
}
}
},
watch: {
//KETIKA TERJADI PERUBAHAN VALUE STATE PAGE
page() {
this.getExpenses() //MAKA JALANKAN FUNGSI INI
},
//KETIKA TERJADI PERUBAHAN VALUE VARIABLE SEARCH
search() {
this.getExpenses(this.search) //MAKA JALANKAN FUNGSI INI DGN MENGIRIMKAN VALUE DARI SEARCH
}
},
methods: {
...mapActions('expenses', ['getExpenses', 'removeExpenses']), //DEFINISIKAN ACTIONS DARI MODULE EXPENSES
//FUNGSI INI SAMA DENGAN MODULE SEBELUMNYA UNTUK MENAMPILKAN ALERT KETIKA MENGHAPUS DATA
deleteExpenses(id) {
this.$swal({
title: 'Kamu Yakin?',
text: "Tindakan ini akan menghapus secara permanent!",
type: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Iya, Lanjutkan!'
}).then((result) => {
if (result.value) {
//JIKA DISETUJUI MAKA FUNGSI INI DIJALANKAN
this.removeExpenses(id)
}
})
}
}
}
</script>
Untuk mengelola request ke backend, maka kita memerlukan bantuan vuex, buat file expenses.js
di dalam folder resources/js/stores
dan tambahkan code:
import $axios from '../api.js'
const state = () => ({
expenses: [], //UNTUK MENAMPUNG DATA EXPENSES
page: 1 //STATE UNTUK HALAMAN YANG SEDANG AKTIF
})
const mutations = {
//ASSIGN DATA EXPENSES YANG DIDAPATKAN KE DALAM STATE
ASSIGN_DATA(state, payload) {
state.expenses = payload
},
//SET PAGE YANG AKTIF KE DALAM STATE PAGE
SET_PAGE(state, payload) {
state.page = payload
}
}
const actions = {
//FUNGSI UNTUK MENG-HANDLE REQUEST KE BACKEND
getExpenses({ commit, state }, payload) {
let search = typeof payload != 'undefined' ? payload:''
return new Promise((resolve, reject) => {
//KIRIM PERMINTAAN KE BACKEND
$axios.get(`/expenses?page=${state.page}&q=${search}`)
.then((response) => {
//KETIKA RESPONSE NYA DIDAPATKAN, MAKA ASSIGN DATA TERSEBUT KE STATE
commit('ASSIGN_DATA', response.data)
resolve(response.data)
})
})
}
}
export default {
namespaced: true,
state,
actions,
mutations
}
Agar module vuex diatas dapat digunakan, daftar terlebih dahulu dengan membuka file store.js
dan tambahkan bagian berikut:
modules: {
auth,
outlet,
courier,
product,
user,
expenses //TAMBAHKAN BAGIAN INI
},
Jangan lupa di import pada bagian import statement:
import expenses from './stores/expenses.js'
Terakhir dari bagian ini, buka file Header.vue
untuk menambahkan link pada menu navigasi agar lebih mudah untuk mengaksesnya melakukan menu navigasi. Tambahkan code pada bagian tag html menu-nya:
<li><router-link :to="{ name: 'expenses.data' }">Expenses</router-link></li>
Add New Expenses
Hal yang menarik akan kita mulai dari module ini, dimana ketika admin/kurir menambahkan data permintaan biaya operasional maka secara otomatis akan mengirimkan notifikasi ke bagian finance dan superadmin. Pertama kita buat dulu form input-nya, buat file Add.vue
di dalam folder /pages/expenses
dan tambahkan code:
<template>
<div class="col-md-12">
<div class="panel">
<div class="panel-heading">
<h3 class="panel-title">Add New Expenses</h3>
</div>
<div class="panel-body">
<!-- TAMBAHKAN REF PADA CUSTOM TAG INI -->
<expenses-form ref="formExpense"></expenses-form>
<div class="form-group">
<button class="btn btn-primary btn-sm btn-flat" @click.prevent="submit">
<i class="fa fa-save"></i> Add New
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import FormExpenses from './Form.vue'
export default {
name: 'AddExpenses',
methods: {
//KETIKA TOMBOL ADD NEW DITEKAN MAKA FUNGSI INI AKAN DIJALANKAN
submit() {
//FUNGSI INI AKAN MEMICU METHOD YANG BERADA DIDALAM FILE FORM.VUE
//DENGAN MEMANFAATKAN $refs MAKAN KITA DAPAT MENGAKSES PROPERTY YANG ADA DI FILE LAINNYA
this.$refs.formExpense.submit()
}
},
components: {
'expenses-form': FormExpenses
}
}
</script>
Kemudian buat file Form.vue
di dalam folder yang sama dan tambahkan code:
<template>
<div>
<div class="form-group" :class="{ 'has-error': errors.description }">
<label for="">Permintaan</label>
<input type="text" class="form-control" v-model="expenses.description">
<p class="text-danger" v-if="errors.description">{{ errors.description[0] }}</p>
</div>
<div class="form-group" :class="{ 'has-error': errors.price }">
<label for="">Biaya</label>
<input type="number" class="form-control" v-model="expenses.price">
<p class="text-danger" v-if="errors.price">{{ errors.price[0] }}</p>
</div>
<div class="form-group" :class="{ 'has-error': errors.note }">
<label for="">Catatan</label>
<textarea cols="5" rows="5" class="form-control" v-model="expenses.note"></textarea>
<p class="text-danger" v-if="errors.note">{{ errors.note[0] }}</p>
</div>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex'
export default {
name: 'FormExpenses',
created() {
//KARENA FILE INI KITA BUAT REUSABLE, BISA DIGUNAKAN UNTUK ADD DAN EDIT
//MAKA KETIKA COMPONENT INI DI-LOAD DARI HALAMAN EDIT EXPANSES
if (this.$route.name == 'expenses.edit') {
//FUNGSI UNTUK MENGAMBIL SINGLE DATA YANG AKAN DIEDIT DIJALANKAN
//ADAPUN FUNGSINYA AKAN KITA BUAT SAAT MEMBAHAS FITUR EDIT
this.editExpenses(this.$route.params.id).then((res) => {
//RESPONSENYA KITA MASUKKAN KE DALAM VARIABLE YANG TELAH DIDEFINISIKAN
this.expenses = {
description: res.data.description,
price: res.data.price,
note: res.data.note
}
})
}
},
data() {
return {
expenses: {
description: '',
price: '',
note: ''
}
}
},
computed: {
...mapState(['errors']) //MENGAMBIL STATE ERROR
},
methods: {
//MENDEFINISIKAN ACTION DARI MODULE EXPENSES VUEX
...mapActions('expenses', ['submitExpense', 'editExpenses', 'updateExpenses']),
submit() {
//KETIKA FUNGSI INI BERJALAN (NOTE: DI PICU DARI FILE ADD TADI)
//DI CEK DARI HALAMAN MANA
if (this.$route.name == 'expenses.edit') {
//JIKA DARI EDIT, MAKA KITA ASSIGN ID YANG AKAN DI EDIT KE DALAM
//OBJECT VARIABLE EXPENSES
let data = Object.assign({id: this.$route.params.id}, this.expenses)
//KEMUDIAN KIRIM PERMINTAAN UPDATE DATA KE BACKEND
this.updateExpenses(data).then(() => this.$router.push({name: 'expenses.data'}))
} else {
//JIKA DARI HALAMAN ADD NEW EXPENSES
//MAKA LANGSUNG MENGIRIMKAN PERMINTAAN UNTUK MENAMBAHKAN DATA
this.submitExpense(this.expenses).then(() => this.$router.push({ name: 'expenses.data' }))
}
}
},
destroyed() {
//KETIKA COMPONENT DITINGGALKAN, MAKA VARIABLENYA DI KOSONGKAN
this.expenses = {
description: '',
price: '',
note: ''
}
}
}
</script>
Karena fungsi untuk mengambil single data yang akan di-edit dan di-update telah disinggung diatas, maka kita definisikan juga fungsinya sekalian agar tidak terjadi error ketika halaman add new dibuka. Buka file stores/expenses.js
dan tambahkan code berikut pada bagian actions:
submitExpense({ dispatch, commit }, payload) {
return new Promise((resolve, reject) => {
//KIRIM PERMINTAAN UNTUK MENAMBAHKAN DATA DENGAN METHOD POST
$axios.post(`/expenses`, payload)
.then((response) => {
//AMBIL DATA YANG BARU
dispatch('getExpenses').then(() => {
resolve(response.data)
})
})
.catch((error) => {
//JIKA VALIDASI ERROR
if (error.response.status == 422) {
//MAKA ERRORNYA DI ASSIGN KE STATE ERRORS
commit('SET_ERRORS', error.response.data.errors, { root: true })
}
})
})
},
//FUNGSI INI UNTUK MENGAMBIL SINGLE DATA
editExpenses({ commit }, payload) {
return new Promise((resolve, reject) => {
//MENGIRIMKAN PERMINTAAN KE BACKEND UNTUK MENGAMBIL DATA BERDASARKAN ID
$axios.get(`/expenses/${payload}/edit`)
.then((response) => {
resolve(response.data)
})
})
},
//FUNGSI INI UNTUK MENGUPDATE DATA
updateExpenses({ commit }, payload) {
return new Promise((resolve, reject) => {
//MENGIRIMKAN PERMINTAAN KE SERVER UNTUK MENGUBAH DATA BERDASARKAN ID
$axios.put(`/expenses/${payload.id}`, payload)
.then((response) => {
resolve(response.data)
})
})
},
Ets jangan buru-buru, kita belum mendefinisikan route untuk halaman add data. Buka file router.js
dan tambahkan code ini di dalam children dari path /expenses
{
path: 'add',
name: 'expenses.create',
component: CreateExpenses,
meta: { title: 'Add New Expenses' }
},
Pastikan kita juga menambahkan bagian ini pada import statement di dalam file yang sama:
import CreateExpenses from './pages/expenses/Add.vue'
Dari sisi client sudah selesai, saatnya untuk berpindah pada sisi backend. Buka file ExpensesController.php
dan tambahkan method:
public function store(Request $request)
{
//VALIDASI DATA YANG DIKIRIM
$this->validate($request, [
'description' => 'required|string|max:150',
'price' => 'required|integer',
'note' => 'nullable|string'
]);
$user = $request->user(); //GET USER YANG SEDANG LOGIN
//JIKA USER YANG SEDANG LOGIN ROLENYA SUPERADMIN/FINANCE
//MAKA SECARA OTOMATIS DI APPROVE, SELAIN ITU STATUSNYA 0 BERARTI HARUS
//MENUNGGU PERSETUJUAN
$status = $user->role == 0 || $user->role == 2 ? 1:0;
//TAMBAHKAN USER_ID DAN STATUS KE DALAM REQUEST
$request->request->add([
'user_id' => $user->id,
'status' => $status
]);
//KEMUDIAN BUAT RECORD BARU KE DALAM DATABASE
$expenses = Expense::create($request->all());
//GET USER YANG ROLE-NYA SUPERADMIN DAN FINANCE
//KENAPA? KARENA HANYA ROLE ITULAH YANG AKAN MENDAPATKAN NOTIFIKASI
$users = User::whereIn('role', [0, 2])->get();
//KIRIM NOTIFIKASINYA MENGGUNAKAN FACADE NOTIFICATION
Notification::send($users, new ExpensesNotification($expenses, $user));
return response()->json(['status' => 'success']);
}
Masih dengan file yang sama, tambahkan use statement:
use Illuminate\Support\Facades\Notification;
use App\Notifications\ExpensesNotification;
use App\User;
Tugas kita selanjutnya adalah men-generate file ExpensesNotification.php
, pada command line jalankan command:
php artisan make:notification ExpensesNotification
Buka file tersebut yang berada di dalam folder app/Notifications
dan modifikasi menjadi:
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\BroadcastMessage;
class ExpensesNotification extends Notification implements ShouldQueue
{
use Queueable;
//DEFINSIKAN GLOBAL VARIABLE
protected $expenses;
protected $user;
public function __construct($expenses, $user)
{
//ASSIGN DATA YANG DITERIMA KE DALAM GLOBAL VARIABLE
$this->expenses = $expenses;
$this->user = $user;
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
//JADI KITA MENGGUNAKAN DUA METHOD, SIMPAN KE DATABASE DAN BROADCAST KE PUSHER
//PUSHER ADALAH PIHAK KETIGA YANG AKAN DIGUNAKAN UNTUK REALTIME NOTIFICATION
return ['database', 'broadcast'];
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
* @return array
*/
//FORM DATA YANG DISIMPAN KE DALAM DATABASE
public function toDatabase($notifiable)
{
return [
'sender_id' => $this->user->id,
'sender_name' => $this->user->name,
'expenses' => $this->expenses
];
}
//FORM DATA YANG AKAN DI BROADCAST
public function toBroadcast($notifiable)
{
return new BroadcastMessage([
'sender_id' => $this->user->id,
'sender_name' => $this->user->name,
'expenses' => $this->expenses
]);
}
//SEBENARNYA JIKA TIDAK ADA PERBEDAAN FORM DATA, KITA BISA LANGSUNG MENGGUNAKAN SATU METHOD SAJA YAKNI toArray()
}
Agar data broadcast hanya dapat diakses oleh user yang sudah login saja, maka buka file app/Providers/BroadcastServiceProvider
dan modifikasi code ini:
Broadcast::routes();
//MEJADI
Broadcast::routes(['middleware' => 'auth:api']);
Kemudian buka file config/app.php
dan uncomment code ini:
App\Providers\BroadcastServiceProvider::class,
Adapun konfigurasi sebelumnya bahwa kita juga akan menyimpan notifikasi yang di-broadcast ke dalam database, maka generate migration-nya terlebih dahulu dengan command:
php artisan notifications:table
php artisan migrate
Register an Account In Pusher
Sebelum melanjutkan codingan kita, hal yang harus dilakukan adalah membuat akun di Pusher karena kita membutuhkan pihak ketiga ini untuk membuat realtime notifications. Ada beberapa provider selain Pusher, tapi pada seri ini kita akan menggunakan provider tersebut.
- Buka halaman Registrasi.
- Kemudian signup akun baru / bisa menggunakan signup via sosmed/github.
- Kemudian login ke halaman dashboardnya.
Biasanya secara otomatis akan ada popup untuk membuat app, tapi jika popup-nya tidak auto, klik menu create new app pada sidebar menu. Kemudian masukkan nama app yang kamu inginkan, disini saya menggunakan name "Daengweb" dan pilih cluster-nya dimana saya memilih ap1 (Singpore) karena paling dekat. Terakhir klik tombol Create my app.
Secara otomatis kamu akan diarahkan ke halaman channel yang telah kamu buat dan jika tidak bisa dengan mengunjungi menu Channels apps pada sidebar menu. Setelah itu, kunjungi tab Getting started dan pada sisi kanan kita akan melihat channel credentials seperti berikut:
PUSHER_APP_ID=831xxx
PUSHER_APP_KEY=f6526f8fxxxxxxx
PUSHER_APP_SECRET=477d72e8exxxxxxx
Buka file .env
dan masukkan key tersebut pada code dibawah:
BROADCAST_DRIVER=pusher
PUSHER_APP_ID=masukkan id kamu disini
PUSHER_APP_KEY=masukkan key kamu disini
PUSHER_APP_SECRET=masukkan secret kamu disini
PUSHER_APP_CLUSTER=ap1
Note: Semua variable diatas sudah tersedia, kita hanya perlu mengubah value-nya saja.
Create Listners Broadcasting
Backend telah menyelesaikan tugasnya untuk mengirimkan broadcast, maka selanjutnya kita akan membuat fungsi dari sisi client untuk meng-handle setiap ada broadcast yang diterima dari Pusher. Buka file app.js
dan tambahkan methods:
computed: {
...mapGetters(['isAuth']),
...mapState(['token']), //GET TOKEN
...mapState('user', {
user_authenticated: state => state.authenticated //MENGAMBIL STATE USER YANG SEDANG LOGIN
})
},
methods: {
...mapActions('user', ['getUserLogin']),
...mapActions('notification', ['getNotifications']), //DEFINISIKAN FUNGSI UNTUK MENGAMBIL NOTIFIKASI DARI TABLE NOTIFICATIONS
...mapActions('expenses', ['getExpenses']), //FUNGSI UNTUK MENGAMBIL EXPENSES YANG BARU
//METHOD INI UNTUK MENGISIASI LISTER MENGGUNAKAN LARAVEL ECHO
initialLister() {
//JIKA USER SUDAH LOGIN
if (this.isAuth) {
//MAKA INISIASI FUNGSI BROADCASTER DENGAN KONFIGURASI BERIKUT
window.Echo = new Echo({
broadcaster: 'pusher',
key: process.env.MIX_PUSHER_APP_KEY, //VALUENYA DI AMBIL DARI FILE .ENV
cluster: process.env.MIX_PUSHER_APP_CLUSTER,
encrypted: false,
auth: {
headers: {
Authorization: 'Bearer ' + this.token
},
},
});
if (typeof this.user_authenticated.id != 'undefined') {
//KEMUDIAN KITA MENGAKSES CHANNEL BROADCAST SECARA PRIVATE
window.Echo.private(`App.User.${this.user_authenticated.id}`)
.notification(() => {
//APABILA DITEMUKAN, MAKA KITA MENJALANKAN KEDUA FUNGSI INI
//UNTUK MENGAMBIL DATA TERBARU
this.getNotifications()
this.getExpenses()
})
}
}
}
},
watch: {
//KITA WATCH KETIKA VALUE TOKEN BERUBAH, MAKA
token() {
this.initialLister() //KITA JALANKAN FUNGSI UNTUK MENGINISIASI LAGI
},
//KETIKA VALUE USER YANG SEDANG LOGIN BERUBAH
user_authenticated() {
this.initialLister() //KITA JALANKAN LAGI
}
},
created() {
if (this.isAuth) {
this.getUserLogin()
//TAMBAHKAN BAGIAN INI KETIKA COMPONENT DILOAD
this.initialLister()
this.getNotifications()
}
}
Note: App.User.${this.user_authenticated.id}
adalah channel yang dapat ditemukan pada file routes/channel.php
, fungsinya untuk memastikan bahwa user yang login benar karena kita menggunakan private channel dimana hanya user yang memiliki akses yang dapat mengambil data broadcast.
Masih dengan file yang sama, pada bagian import statement tambahkan code berikut:
import { mapActions, mapGetters, mapState } from 'vuex'
import Echo from 'laravel-echo'
import Pusher from 'pusher-js'
Pada command line, install package ini:
composer require pusher/pusher-php-server
npm install --save laravel-echo pusher-js
Sebenarnya sampai pada tahap ini kita telah berhasil menyelesaikan persiapan untuk mengawasi broadcast yang dikirimkan oleh Laravel ke Pusher dan client sudah dapat mendeteksi ketika terdapat data yang baru pada channel Pusher. Akan tetapi terdapat beberapa bagian yang error karena module Vuex-nya belum kita buat, maka tugas selanjutnya akan dibahas pada sub-chapter berikutnya.
Show Notifications
Metode yang digunakan adalah via broadcast dan database, maka listening channel hanya kita gunakan untuk mengetahui apakah ada data yang diterima dari channel yang sedang di awasi atau tidak, dan dari informasi tersebut digunakan untuk melakukan request ke server guna mengambil data notifikasi yang telah disimpan ke database. Jika kita lihat codingan sebelumnya, kita melakukan dua buah request yakni menjalankan method getNotifications()
dan getExpenses()
, namun method kedua sudah dibuat sebelumnya dan diletakkan ke dalam module expenses
dari Vuex.
Tugas kita kali ini adalah membuat module notifications
guna menampung actions getNotifications()
. Buat file notification.js
dan tempatkan di dalam folder resources/js/stores
.
import $axios from '../api.js'
const state = () => ({
notifications: [] //MENAMPUNG DATA NOTIFIKASI
})
const mutations = {
//ASSIGN DATA NOTIFIKASI KE DALAM STATE NOTIFICATIONS
ASSIGN_DATA(state, payload) {
state.notifications = payload
}
}
const actions = {
getNotifications({ commit }) {
return new Promise((resolve, reject) => {
//REQUEST KE SERVER UNTUK MENGAMBIL NOTIFIKASI
$axios.get(`/notification`)
.then((response) => {
//DATA YANG DITERIMA DI COMMIT KE MUTATIONS ASSING_DATA
commit('ASSIGN_DATA', response.data.data)
resolve(response.data)
})
})
},
readNotification({ dispatch }, payload) {
return new Promise((resolve, reject) => {
//UNTUK MENGUPDATE DATA NOTIFIKASI BAHWA NOTIF TERSEBUT SUDAH DIBACA
$axios.post(`/notification`, payload)
.then((response) => {
//AMBIL DATA NOTIFIKASI TERBARU
dispatch('getNotifications').then(() => resolve(response.data))
})
})
}
}
export default {
namespaced: true,
state,
actions,
mutations
}
Agar file diatas dapat digunakan, daftarkan ke dalam file store.js
:
modules: {
auth,
outlet,
courier,
product,
user,
expenses,
notification //TAMBAHKAN BAGIAN INI
},
Jangan lupa import pada bagian import statement:
import notification from './stores/notification.js'
Pindah ke sisi Backend, buat sebuah controller baru dengan command:
php artisan make:controller API/NotificationController
Kemudian buka file NotificationController.php
dan modifikasi menjadi:
<?php
namespace App\Http\Controllers\API;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class NotificationController extends Controller
{
public function index()
{
$user = request()->user(); //AMBIL USER YANG SEDANG LOGIN
//KEMUDIAN YANG DI READ ADALAH HANYA NOTIFIKASI YANG STATUSNYA BELUM DIREAD
//SECARA LANGSUNG KITA DAPAT MENGAMBIL DATA NYA MELALUI USER DENGAN MENGAKSES PROPERTY unreadNotifications.
return response()->json(['status' => 'success', 'data' => $user->unreadNotifications]);
}
public function store(Request $request)
{
$user = $request->user(); //AMBIL USER YANG SEDANG LOGIN
//EDIT RECORD NOTIFIKASI BERDASARKAN ID YANG DITERIMA
//NOTIFIKASI YANG SUDAH/BELUM DIREAD DITANDAI DENGAN read_at YANG MASIH NULL
$user->unreadNotifications()->where('id', $request->id)->update(['read_at' => now()]);
return response()->json(['status' => 'success']);
}
}
Jangan lupa definisikan routing-nya, buka file routes/api.php
dan tambahkan:
Route::resource('notification', 'API\NotificationController')->except(['create', 'destroy']);
Kembali lagi ke sisi Client, kita akan meng-handle data notifikasi tersebut dengan menampilkannya pada Header navbar. Buka file components/Header.vue
dan modifikasi beberapa bagian berikut:
<li class="dropdown messages-menu">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-bell-o"></i>
<!-- FUNGSI INI UNTUK MENGHITUNG JUMLAH DATA NOTIFIKASI YANG ADA -->
<span class="label label-success">{{ notifications.length }}</span>
</a>
<ul class="dropdown-menu">
<li class="header">You have {{ notifications.length }} messages</li>
<li>
<ul class="menu" v-if="notifications.length > 0">
<!-- KITA MELAKUKAN LOOPING TERHADAP DATA NOTIFIKASI YANG DISIMPAN KE DALAM STATE NOTIFICATIONS -->
<li v-for="(row, index) in notifications" :key="index">
<a href="javascript:void(0)" @click="readNotif(row)">
<div class="pull-left">
<img src="https://via.placeholder.com/160" class="img-circle" alt="User Image">
</div>
<h4>
<!-- TAMPILKAN NAMA PENGIRIM NOTIFIKASI -->
{{ row.data.sender_name }}
<!-- TAMPILKAN WAKTU NOTIFIKASI -->
<small><i class="fa fa-clock-o"></i> {{ row.created_at | formatDate }}</small>
</h4>
<!-- TAMPILKAN JENIS PERMINTAAN NOTIFIKASI -->
<p>{{ row.data.expenses.description.substr(0, 30) }}</p>
</a>
</li>
</ul>
</li>
<!-- <li class="footer"><a href="#">See All Messages</a></li> -->
</ul>
</li>
Note: Adapun icon informasi lainnya kita disable saja, boleh dihapus / di-comment.
Masih dengan file yang sama, bergeser pada bagian javascript-nya, tambahkan code berikut:
computed: {
...mapState('user', {
authenticated: state => state.authenticated
}),
//CUKUP TAMBAHKAN BAGIAN INI
...mapState('notification', {
notifications: state => state.notifications //MENGAMBIL STATE NOTIFICATIONS
})
},
//TAMBAHKAN JUGA BAGIAN INI
filters: {
//UNTUK MENGUBAH FORMAT TANGGAL MENJADI TIME AGO
formatDate(val) {
return moment(new Date(val)).fromNow()
}
},
methods: {
...mapActions('notification', ['readNotification']), //DEFINISIKAN FUNGSI UNTUK READ NOTIF
//KETIKA NOTIFIKASI DI KLIK MAKA AKAN MENJALANKAN FUNGSI INI
readNotif(row) {
//MENGIRIMKAN REQUEST KE SERVER UNTUK MENANDAI BAHWA NOTIFIKASI TELAH DI BACA
//KEMUDIAN SELANJUTNYA KITA REDIRECT KE HALAMAN VIEW EXPENSES
this.readNotification({ id: row.id}).then(() => this.$router.push({ name: 'expenses.view', params: {id: row.data.expenses.id} }))
},
//[.. CODE SETELAHNYA ..]
}
Lagi lagi dengan file yang sama, tambahkan bagi ini pada import statement:
import { mapState, mapActions } from 'vuex'
import moment from 'moment'
Jangan lupa untuk meng-install momentJs
npm install moment --save
View Detail Expenses
Menampilkan detail expenses dimana terdapat fitur untuk menyetujui atau menolak permintaan tersebut akan menjadi tugas kita pada bagian ini. Buat file View.vue
di dalam folder /pages/expenses
dan tambahkan code:
<template>
<div class="col-md-12">
<div class="panel">
<div class="panel-heading">
<h3 class="panel-title">Detail Expenses</h3>
</div>
<div class="panel-body">
<template>
<!-- INFORMASI DETAIL EXPENSES -->
<dt>Permintaan Karyawan</dt>
<dd>- {{ description }}</dd>
<hr>
<dt>Biaya Yang Diperlukan</dt>
<dd>- Rp {{ price }}</dd>
<hr>
<dt>Catatan</dt>
<dd>- {{ note }}</dd>
<hr>
<dt>User/Kurir</dt>
<dd>- {{ user.name }}</dd>
<hr>
<dt>Status</dt>
<dd>
<span class="label label-success" v-if="status == 1">Diterima</span>
<span class="label label-warning" v-else-if="status == 0">Diproses</span>
<span class="label label-default" v-else>Ditolak</span>
</dd>
<hr>
<!-- INFORMASI DETAIL EXPENSES -->
<!-- JIKA STATUSNYA 2 = CANCEL, MAKA ALASANNYA DITAMPILKAN -->
<div v-if="status == 2">
<dt>Alasan Penolakan</dt>
<dd>- {{ reason }}</dd>
<hr>
</div>
<!-- JIKA STATUS 0 = BARU ATAU BARU DAN formReason = false MAKA TOMBOL TOLAK DAN TERIMA DITAMPILKAN -->
<div class="pull-right" v-if="status == 0 || (status == 0 && !formReason)">
<!-- KETIKA TOMBOL INI DITEKAN MAKA AKAN MENGUBAH VALUE formReason JADI TRUE -->
<button class="btn btn-danger btn-sm" @click="formReason = true">Tolak</button>
<button class="btn btn-primary btn-sm" @click="accept">Terima</button>
</div>
</template>
<!-- JIKA formReason NILAINYA TRUE, MAKA FORM INI DITAMPILAKN UNTUK MENGISI ALASAN PENOLAKAN -->
<div v-if="formReason">
<div class="form-group">
<label for="">Alasan Penolakan</label>
<input type="text" v-model="inputReason" class="form-control">
</div>
<div class="form-group">
<button class="btn btn-primary btn-sm pull-right" @click="cancelRequest">Respon Penolakan</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapActions } from 'vuex';
export default {
name: 'ViewEpenses',
created() {
//KETIKA COMPONENT INI DI-LOAD MAKA KITA MENGAMBIL SINGLE DATA YANG AKAN DI TAMPILKAN BERDASARKAN ID PADA URL
this.editExpenses(this.$route.params.id).then((res) => {
let row = res.data
//ASSIGN SEMUA DATANYA KE DALAM VARIABLE YANG TELAH DIDEFINISIKAN
this.description = row.description
this.price = row.price
this.note = row.note
this.status = row.status
this.reason = row.reason
this.user = row.user
})
},
data() {
return {
description: '',
price: '',
note: '',
status: '',
reason: '',
user: '',
formReason: false,
inputReason: ''
}
},
methods: {
...mapActions('expenses', ['editExpenses', 'acceptExpenses', 'cancelExpenses']),
//KETIKA TOMBOL TERIMA DITEKAN, MAKA AKAN MENJALANKAN FUNGSI accpet
accept() {
this.$swal({
title: 'Kamu Yakin?',
text: "Permintaan yang disetujui tidak dapat dikembalikan!",
type: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Iya, Lanjutkan!'
}).then((result) => {
if (result.value) {
//JIKA YES, MAKA KITA AKAN MENGIRIMKAN PERMINTAAN KE SERVER UNTUK MENYETUJUI PERMINTAAN TERSEBUT DAN REDIRECT KE HALAMAN LIST EXPENSES
this.acceptExpenses(this.$route.params.id).then(() => this.$router.push({ name: 'expenses.data' }))
}
})
},
//KETIKA TOMBOL RESPON PENOLAK DITEKAN, MAKA FUNGSI INI AKAN DIJALANKAN
cancelRequest() {
this.$swal({
title: 'Kamu Yakin?',
text: "Permintaan yang ditolak tidak dapat dikembalikan!",
type: 'warning',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Iya, Lanjutkan!'
}).then((result) => {
if (result.value) {
//JIKA IYA, MAKA KITA AKAN MENGIRIMKAN PERMINTAAN KE BACKEND UNTUK MENGUBAH STATUS EXPENSES MENJADI DITOLAK
this.cancelExpenses({id: this.$route.params.id, reason: this.inputReason}).then(() => {
this.formReason = false //FORMREASON DI SET KEMBALI JADI FALSE
this.$router.push({ name: 'expenses.data' }) //DAN REDIRECT KEMBALI KE HALAMAN LIST EXPENSES
})
}
})
}
}
}
</script>
Tugas selanjutnya adalah membuat actions pada module expenses.js
, buka file tersebut dan tambahkan kedua method berikut pada bagian actions.
acceptExpenses({ commit }, payload) {
return new Promise((resolve, reject) => {
//KIRIM PERMINTAAN KE SERVER UNTUK MENGUBAH VALUE JADI ACCEPT
$axios.post(`/expenses/accept`, { id: payload })
.then((response) => {
resolve(response.data)
})
})
},
cancelExpenses({ commit }, payload) {
return new Promise((resolve, reject) => {
//KIRIM PERMINTAAN KE SERVER UNTUK MENGUBAH VALUE JADI CANCEL
$axios.post(`/expenses/cancel`, payload)
.then((response) => {
resolve(response.data)
})
})
}
Kemudian kita buat routing-nya dari sisi client, buka file router.js
dan tambahkan code dibawah ini pada children dari expenses.
{
path: 'view/:id',
name: 'expenses.view',
component: ViewExpenses,
meta: { title: 'View Expenses' }
},
Jangan lupa tambahkan fungí untuk meng-import pada bagian import statement di dalam file router.js
:
import ViewExpenses from './pages/expenses/View.vue'
Saatnya mengolah kembali dari sisi Backend, buka file ExpensesController.php
dan tambahkan kedua method berikut:
public function accept(Request $request)
{
$this->validate($request, ['id' => 'required|exists:expenses,id']); //VALIDASI DATA
$expenses = Expense::with(['user'])->find($request->id); //AMBIL SINGLE DATA EXPENSES
$expenses->update(['status' => 1]); //UBAH STATUSNYA JADI APPROVE
Notification::send($expenses->user, new ExpensesNotification($expenses, $expenses->user)); //KIRIM NOTIFIKASI KE PEMBUAT REQUEST EXPENSES
return response()->json(['status' => 'success']);
}
public function cancelRequest(Request $request)
{
$this->validate($request, ['id' => 'required|exists:expenses,id', 'reason' => 'required|string']); //VALIDASI
$expenses = Expense::with(['user'])->find($request->id); //AMBIL SINGLE DATA EXPENSES
$expenses->update(['status' => 2, 'reason' => $request->reason]); //UBAH STATUS DAN TAMBAHKAN REASON
Notification::send($expenses->user, new ExpensesNotification($expenses, $expenses->user)); //KIRIM NOTIFIKASI
return response()->json(['status' => 'success']);
}
Terakhir buka file routes/api.php
dan tambahkan kedua routing berikut:
Route::post('expenses/accept', 'API\ExpensesController@accept')->name('expenses.accept');
Route::post('expenses/cancel', 'API\ExpensesController@cancelRequest')->name('expenses.cancel');
Edit Data Expenses
Data Expenses hanya dapat di-edit apabila statusnya masih 0 (open) dan bagian ini sudah kita kerjakan pada sub-chapter Manage Request Lists (scroll ke atas), dimana kita menambahkan v-if="row.item.status == 0"
. Tugas kita disini adalah membuat halaman untuk menampilkan form edit data, buat file Edit.vue
di dalam folder /pages/expenses
dan tambahkan code:
<template>
<div class="col-md-12">
<div class="panel">
<div class="panel-heading">
<h3 class="panel-title">Edit Expenses</h3>
</div>
<div class="panel-body">
<expenses-form ref="formExpense"></expenses-form>
<div class="form-group">
<button class="btn btn-primary btn-sm btn-flat" @click.prevent="submit">
<i class="fa fa-save"></i> Update
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import FormExpenses from './Form.vue'
export default {
name: 'EditExpenses',
methods: {
submit() {
this.$refs.formExpense.submit()
}
},
components: {
'expenses-form': FormExpenses
}
}
</script>
Note: Disini kita tidak lagi membuat fungsi, hanya sebuah parent yang me-load file Form.vue
karena fungsi-nya sudah dibuat di dalam file tersebut.
Kemudian definisikan routing-nya, buka file router.js
dan tambahkan code berikut pada children expenses:
{
path: 'edit/:id',
name: 'expenses.edit',
component: EditExpenses,
meta: { title: 'Edit Expenses' }
},
Jangan lupa pada bagian import statement tambahkan code:
import EditExpenses from './pages/expenses/Edit.vue'
Adapun actions-nya juga sudah kita buat pada sub-chapter Add New Expenses (scroll ke atas), sehingga yang perlu kita lakukan hanyalah membuat API-nya saja. Buka file ExpensesController
dan tambahkan method:
public function edit($id)
{
$expenses = Expense::with(['user'])->find($id); //AMBIL DATA BERDASARKAN ID
return response()->json(['status' => 'success', 'data' => $expenses]);
}
public function update(Request $request, $id)
{
//VALIDASI
$this->validate($request, [
'description' => 'required|string|max:150',
'price' => 'required|integer',
'note' => 'nullable|string'
]);
$expenses = Expense::find($id); //AMBIL DATA BERDASARKAN ID
$expenses->update($request->except('id')); //UPDATE DATA TERSEBUT
return response()->json(['status' => 'success']);
}
Delete Data Expenses
Pada sub-chapter ini menjadi lebih ringan lagi karena fungsi dari sisi client-nya sudah dikerjakan pada bagian Manage Request Lists, sehingga tugas kita hanya melengkapi actions pada module expenses
untuk melakukan request ke backend. Buka file expenses.js
dan tambahkan code berikut pada bagian actions.
removeExpenses({ dispatch }, payload) {
return new Promise((resolve, reject) => {
//KIRIM PERMINTAAN UNTUK MENGHAPUS BERDASARKAN ID
$axios.delete(`/expenses/${payload}`)
.then((response) => {
//AMBIL DATA TERBARU
dispatch('getExpenses').then(() => resolve())
})
})
},
Berpindah pada sisi Backend, buka ExpensesController.php
dan tambahkan method:
public function destroy($id)
{
$expenses = Expense::find($id);
$expenses->delete();
return response()->json(['status' => 'success']);
}
Sampai disini semua bagian telah selesai kita kerjakan, tugas terakhir adalah menjalankan command:
npm run dev
atau command
npm run watch
Kemudian testing dengan menggunakan dua buah browser yang berbeda dan salah satu browser login sebagai kurir/admin dan browser lainnya login sebagai superadmin/finance (red: fitur add admin dan finance belum ada, sehingga kamu bisa menambahkannya secara langsung ke table users terlebih dahulu atau jika tidak gunakan kurir dan superadmin saja untuk proses testing).
Baca Juga: Aplikasi Laundry (Laravel 5.8 - Vue.js - SPA) #5: Manage Laundry Products
Kesimpulan
Artikel ini cukup panjang tapi juga sebanding dengan hal hal baru yang kita pelajari. Tidak hanya berinteraksi dengan proses manipulasi database tapi juga kita belajar bagaimana mengirimkan dan menerima data secara realtime tanpa harus re-load browser.
Adapun documentasi codenya dapat kamu lihat di Github.
Comments