Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Welcome to Part 5 of our Laravel Vue.js POS Tutorial Series! π
In this tutorial, weβll build the core feature of any Point-of-Sale systemβthe POS Cart System. Youβll learn how to dynamically add items to the cart, update quantities, remove products, and automatically calculate subtotals and grand totals using Vue.js reactivity.
This post focuses on building a smooth and responsive Laravel Vue.js POS Cart System that enhances the user experience with instant feedback and real-time updates. By the end, youβll have a working cart thatβs ready to integrate with payment and order modules in upcoming parts.
Letβs dive in and bring your POS app to life with interactive cart functionality! π
Create the necessary Eloquent models to support your Laravel Vue.js POS Cart System, including relationships for products and cart items.
Replace the contents of app/Models/OrderDetailTemp.php with the code below. If the file doesnβt exist yet, please create it.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class OrderDetailTemp extends Model
{
public function table()
{
return $this->belongsTo(Table::class);
}
}
Replace the contents of app/Models/OrderDetail.php with the code below. If the file doesnβt exist yet, please create it.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class OrderDetail extends Model
{
use SoftDeletes;
}
Replace the contents of app/Models/Order.php with the code below. If the file doesnβt exist yet, please create it.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Order extends Model
{
use SoftDeletes;
public function order_details()
{
return $this->hasMany(OrderDetail::class);
}
public function table()
{
return $this->belongsTo(Table::class);
}
public function operator()
{
return $this->belongsTo(User::class, 'created_by_id', 'id');
}
}
Replace the contents of app/Models/Table.php with the code below. If the file doesnβt exist yet, please create it.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Table extends Model
{
use SoftDeletes;
public function order_detail_temps()
{
return $this->hasMany(OrderDetailTemp::class);
}
public function operator()
{
return $this->belongsTo(User::class, 'updated_by_id', 'id');
}
}
Build a dedicated Laravel controller to handle the core cart logicβadding items, updating quantities, removing products, and calculating totals.
Replace the contents of app/Http/Controllers/CashierController.php with the code below. If the file doesnβt exist yet, please create it.
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use App\Models\OrderDetail;
use App\Models\OrderDetailTemp;
use App\Models\Product;
use App\Models\ProductCategory;
use App\Models\Table;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class CashierController extends Controller
{
public function index()
{
try {
$product_categories = ProductCategory::select('id', 'name')->orderBy('order')->orderBy('name')->get();
$products = Product::orderBy('name')->get();
} catch (Exception $ex) {
abort($ex->getCode(), $ex->getMessage());
}
return response()->json(['success' => true, 'products' => $products, 'product_categories' => $product_categories]);
}
public function showTable($id = 0)
{
try {
$data = Table::select('id', 'name', 'status')->where('id', '!=', $id)->orderBy('name')->get();
} catch (Exception $ex) {
abort($ex->getCode(), $ex->getMessage());
}
return response()->json(['success' => true, 'data' => $data]);
}
private function getTableOrder($table_id): JsonResponse
{
$data = Table::with('order_detail_temps')->find($table_id);
if ($data && $data->order_detail_temps()->count() > 0) {
$grand_total = $data->order_detail_temps->sum(function ($detail) {
return $detail->qty * $detail->unit_price;
});
$total = $data->order_detail_temps->sum(function ($detail) {
return $detail->qty * $detail->unit_price * (1 - $detail->discount / 100);
});
$total_discount = $grand_total - $total + ($total * $data->discount / 100);
$data->grand_total = $grand_total;
$data->total = $total;
$data->total_discount = $total_discount;
$data->net_amount = $grand_total - $total_discount;
$data->save();
}
return response()->json(['success' => true, 'data' => $data]);
}
public function selectTable(Request $request)
{
$selectedItem = json_decode($request->ids);
try {
DB::beginTransaction();
if ($request->old_table_id > 0 && count($selectedItem) > 0) {
$old_table = Table::with('order_detail_temps')->find($request->old_table_id);
Table::where('id', $request->new_table_id)->update(['status' => 1, 'discount' => $old_table->discount]);
if ($old_table && $old_table->order_detail_temps()->count() == 0) {
$old_table->status = 2;
$old_table->discount = 0;
$old_table->total_discount = 0;
$old_table->grand_total = 0;
$old_table->total = 0;
$old_table->net_amount = 0;
} else {
$old_table->order_detail_temps()->whereIn('id', $selectedItem)->update(['table_id' => $request->new_table_id]);
$grand_total = $old_table->order_detail_temps->whereNotIn('id', $selectedItem)->sum(function ($detail) {
return $detail->qty * $detail->unit_price;
});
$total = $old_table->order_detail_temps->whereNotIn('id', $selectedItem)->sum(function ($detail) {
return $detail->qty * $detail->unit_price * (1 - $detail->discount / 100);
});
$total_discount = $grand_total - $total + ($total * $old_table->discount / 100);
$old_table->grand_total = $grand_total;
$old_table->total = $total;
$old_table->total_discount = $total_discount;
$old_table->net_amount = $grand_total - $total_discount;
}
$old_table->save();
}
DB::commit();
} catch (Exception $ex) {
DB::rollBack();
abort($ex->getCode());
}
return $this->getTableOrder($request->new_table_id);
}
public function addToOrder(Request $request)
{
$product = Product::find($request->product_id);
try {
DB::beginTransaction();
$order_detail = OrderDetailTemp::where('table_id', $request->table_id)->where('product_id', $product->id)->first();
if (!$order_detail) {
$order_detail = new OrderDetailTemp();
$order_detail->table_id = $request->table_id;
$order_detail->product_id = $product->id;
$order_detail->product_category_id = $product->product_category_id;
$order_detail->qty = 1;
$order_detail->description = $product->name;
$order_detail->unit_price = $product->unit_price;
$order_detail->created_by_id = $request->user()->id;
} else {
$order_detail->qty += 1;
}
$order_detail->updated_by_id = $request->user()->id;
$order_detail->table->status = 1;
$order_detail->push();
DB::commit();
} catch (Exception $ex) {
dd($ex->getMessage());
DB::rollBack();
abort($ex->getCode(), $ex->getMessage());
}
return $this->getTableOrder($request->table_id);
}
public function deleteOrder($product_id, $table_id)
{
try {
OrderDetailTemp::destroy($product_id);
} catch (Exception $ex) {
abort($ex->getCode(), $ex->getMessage());
}
return $this->getTableOrder($table_id);
}
public function updateOrderQty(Request $request)
{
try {
$data = OrderDetailTemp::find($request->id);
$data->qty = $request->qty;
$data->save();
} catch (Exception $ex) {
abort($ex->getCode(), $ex->getMessage());
}
return $this->getTableOrder($request->table_id);
}
public function updateDetailDiscount(Request $request)
{
try {
$data = OrderDetailTemp::find($request->id);
$data->discount = $request->discount;
$data->save();
} catch (Exception $ex) {
abort($ex->getCode(), $ex->getMessage());
}
return $this->getTableOrder($request->table_id);
}
public function updateOrderDiscount(Request $request)
{
try {
$data = Table::find($request->table_id);
$data->discount = $request->discount;
$data->save();
} catch (Exception $ex) {
abort($ex->getCode(), $ex->getMessage());
}
return $this->getTableOrder($request->table_id);
}
public function printInvoice(Request $request)
{
try {
$data = Table::with(['order_detail_temps', 'operator'])->find($request->table_id);
if (!$data->invoice_no)
$data->invoice_no = date('YmdHis');
$data->status = 3;
$data->save();
} catch (Exception $ex) {
abort($ex->getCode(), $ex->getMessage());
}
return response()->json(['success' => true, 'data' => $data]);
}
public function confirmPayment(Request $request)
{
$table = Table::find($request->table_id);
// validation
$rules = [
'receive_amount' => 'required|numeric|min:' . $table->net_amount
];
$validator = Validator::make($request->all(), $rules, [
'receive_amount.required' => 'is required',
'receive_amount.numeric' => 'must be number',
'receive_amount.min' => 'must be at least ' . $table->net_amount,
]);
if ($validator->fails())
return response()->json([
'success' => false,
'errors' => $validator->errors()
]);
try {
DB::beginTransaction();
$order = new Order();
$order->table_id = $table->id;
$order->invoice_no = $table->invoice_no ? $table->invoice_no : (date('YmdHi') . $request->table_id);
$order->discount = $table->discount;
$order->total_discount = $table->total_discount;
$order->grand_total = $table->grand_total;
$order->total = $table->total;
$order->net_amount = $table->net_amount;
$order->receive_amount = $request->receive_amount;
$order->created_by_id = $request->user()->id;
$order->updated_by_id = $request->user()->id;
if ($order->save()) {
foreach ($table->order_detail_temps as $item) {
$order_detail = new OrderDetail();
$order_detail->order_id = $order->id;
$order_detail->product_id = $item->product_id;
$order_detail->description = $item->description;
$order_detail->qty = $item->qty;
$order_detail->unit_price = $item->unit_price;
$order_detail->product_category_id = $item->product_category_id;
$order_detail->discount = $item->discount;
$order_detail->created_by_id = $request->user()->id;
$order_detail->updated_by_id = $request->user()->id;
$order_detail->save();
}
OrderDetailTemp::where('table_id', $request->table_id)->delete();
$table->invoice_no = '';
$table->discount = 0;
$table->total_discount = 0;
$table->grand_total = 0;
$table->total = 0;
$table->net_amount = 0;
$table->status = 2;
$table->save();
}
DB::commit();
} catch (Exception $ex) {
DB::rollBack();
abort($ex->getCode(), $ex->getMessage());
}
return response()->json([
'success' => true,
'data' => Order::with(['order_details', 'table', 'operator'])->find($order->id)
]);
}
}
Develop a responsive Vue component that powers the interactive cart interface for your Laravel Vue.js POS Cart System, using reactivity and event-driven updates.
Replace the contents of resources/js/component/layout/Cashier.vue with the code below. If the file doesnβt exist yet, please create it.
<template>
<div class="vl-parent">
<loading v-model:active="isLoading" :is-full-page="true" />
<ShareModal ref="messageBox"></ShareModal>
<div class="modal fade" ref="formModal" tabindex="-1" aria-hidden="true" data-bs-keyboard="false"
data-bs-backdrop="static" data-bs-focus="false">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header py-2 bg-secondary text-light">
<h5 class="modal-title" style="font-weight: bold">
Change Password
</h5>
</div>
<div class="modal-body">
<form @submit.prevent="changePassword" id="formPassword">
<div class="row">
<div class="col-12 mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" :value="auth.user.username" disabled />
</div>
<div class="col-12 mb-3">
<label class="form-label required">Old Password</label>
<input type="password" :disabled="isLoading"
:class="['form-control', { 'is-invalid': errors.old_password }]" v-model="form.old_password"
ref="autofocus" />
<span v-if="errors.old_password" class="invalid-feedback"> {{
errors.old_password[0] }}
</span>
</div>
<div class="col-12 mb-3">
<label class="form-label required">New Password</label>
<input type="password" :disabled="isLoading"
:class="['form-control', { 'is-invalid': errors.new_password }]" v-model="form.new_password" />
<span v-if="errors.new_password" class="invalid-feedback"> {{
errors.new_password[0] }}
</span>
</div>
<div class="col-12 mb-3">
<label class="form-label required">New Passwo rd Confirmation</label>
<input type="password" :disabled="isLoading"
:class="['form-control', { 'is-invalid': errors.new_password_confirmation }]"
v-model="form.new_password_confirmation" />
<span v-if="errors.new_password_confirmation" class="invalid-feedback"> {{
errors.new_password_confirmation }}
</span>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-lg"></i> Cancel
</button>
<button type="submit" class="btn btn-primary px-3" form="formPassword" :disabled="isLoading">
<i v-if="isLoading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></i>
<i v-else class="bi bi-floppy" style="padding-right: 3px;"></i> Save
</button>
</div>
</div>
</div>
</div>
<div id="print_invoice" class="d-none">
<div style="text-align: center;">
<img src="images/sourkea.png" height="100px" width="250px" />
<i style="font-size: 11px; display: block">
Address: #33, Steet 99, Boeung Trabek, Chamkar Mon, Phnom Penh. Tel: 069 868
768, 078 551 115
</i>
<h1 style="padding: 0px; margin: 0px; font-size: 30px">Invoice</h1>
</div>
<hr style="margin-top: 0px; padding-top: 0px" />
<table style="width: 100%; font-size: 12px">
<thead>
<tr>
<td width="80px" style="text-align: right">Table No:</td>
<td style="text-align: left">{{ order?.name }}</td>
<td width="80px" style="text-align: right">Invoice #:</td>
<td style="text-align: left">{{ order?.invoice_no }}</td>
</tr>
<tr>
<td style="width: 60px; text-align: right">Cashier:</td>
<td style="text-align: left; width: 100px">{{ order?.operator?.username }}</td>
<td style="width: 60px; text-align: right">Date:</td>
<td style="text-align: left; width: 100px">{{ dateFormat(order?.updated_at) }}</td>
</tr>
</thead>
</table>
<table style="width: 100%; margin-top: 10px" border="0" cellspacing="0" cellpadding="2px">
<thead>
<tr style="background: darkgray">
<th width="20px">No</th>
<th style="text-align: left">Description</th>
<th style="width: 8%; text-align: center">Qty</th>
<th style="width: 16%; text-align: right">U.P ($)</th>
<th style="width: 12%; text-align: right">Disc (%)</th>
<th style="width: 18%; text-align: right">Total ($)
</th>
</tr>
</thead>
<tbody>
<tr style="font-size: 11px" v-for="(data, index) in order?.order_detail_temps">
<td align="center">{{ index + 1 }}</td>
<td align="left">{{ data.description }}</td>
<td align="center">{{ data.qty }}</td>
<td align="right">{{ numberFormat(data.unit_price) }}</td>
<td align="right">{{ data.discount }}</td>
<td align="right">
{{ numberFormat(data.unit_price * data.qty * (1 - data.discount / 100)) }}
</td>
</tr>
</tbody>
</table>
<hr />
<table style="font-size: 14px; width: 100%;">
<tbody>
<tr v-if="order && order.discount > 0">
<td style="text-align: right">Grand Total ($) :</td>
<td style="text-align: right; width: 100px;">{{ numberFormat(order?.total) }}</td>
</tr>
<tr v-if="order && order.discount > 0">
<td style="text-align: right">
Discount ({{ order.discount }}%) :
</td>
<td style="text-align: right;">
{{ numberFormat((order.total * order.discount) / 100, 2) }}
</td>
</tr>
<tr>
<th style="text-align: right">Total ($) :</th>
<th style="text-align: right; width: 100px;">{{ numberFormat(order?.net_amount) }}</th>
</tr>
</tbody>
</table>
</div>
<div id="print_receipt" class="d-none">
<div style="text-align: center">
<img src="images/sourkea.png" height="100px" width="250px" />
<i style="display: block">
Address: #33, Steet 99, Boeung Trabek, Chamkar Mon, Phnom Penh. Tel: 069 868
768, 078 551 115
</i>
<h1 style="padding: 0px; margin: 0px; font-size: 30px">Receipt</h1>
</div>
<hr style="margin-top: 0px; padding-top: 0px" />
<table style="width: 100%">
<thead>
<tr>
<td width="80px" style="text-align: right">Table No:</td>
<td style="text-align: left">{{ order?.table?.name }}</td>
<td width="80px" style="text-align: right">Invoice #:</td>
<td style="text-align: left">{{ order?.invoice_no }}</td>
</tr>
<tr>
<td style="width: 60px; text-align: right">Cashier:</td>
<td style="text-align: left; width: 100px">{{ order?.operator?.username }}</td>
<td style="width: 60px; text-align: right">Date:</td>
<td style="text-align: left; width: 100px">{{ dateFormat(order?.created_at) }}</td>
</tr>
</thead>
</table>
<table style="width: 100%; margin-top: 10px;" border="0" cellspacing="0" cellpadding="2px">
<thead>
<tr style="background: darkgray">
<th width="20px">No</th>
<th style="text-align: left">Description</th>
<th style="width: 8%; text-align: center">Qty</th>
<th style="width: 16%; text-align: right">U.P ($)</th>
<th style="width: 12%; text-align: right">Disc (%)</th>
<th style="width: 18%; text-align: right">Total ($)
</th>
</tr>
</thead>
<tbody>
<tr v-for="(data, index) in order?.order_details">
<td align="center">{{ index + 1 }}</td>
<td align="left">{{ data.description }}</td>
<td align="center">{{ data.qty }}</td>
<td align="right">{{ numberFormat(data.unit_price) }}</td>
<td align="right">{{ data.discount }}</td>
<td align="right">
{{ numberFormat(data.unit_price * data.qty * (1 - data.discount / 100)) }}
</td>
</tr>
</tbody>
</table>
<hr />
<table style="width: 100%;">
<tbody>
<tr v-if="order && order.discount > 0">
<td style="text-align: right">Grand Total ($) :</td>
<td style="text-align: right; width: 100px;">{{ numberFormat(order?.total) }}</td>
</tr>
<tr v-if="order && order.discount > 0">
<td style="text-align: right">
Discount ({{ order.discount }}%) :
</td>
<td style="text-align: right;">
{{ numberFormat((order.total * order.discount) / 100) }}
</td>
</tr>
<tr>
<th style="text-align: right">Total ($) :</th>
<th style="text-align: right; width: 100px;">{{ numberFormat(order?.net_amount) }}</th>
</tr>
<tr v-if="order?.receive_amount > 0">
<td style="text-align: right">Receive Amount($) :</td>
<td style="text-align: right">{{ numberFormat(order?.receive_amount, 2) }}</td>
</tr>
<tr v-if="order?.receive_amount - order?.net_amount">
<td style="text-align: right">Change ($) :</td>
<td style="text-align: right">{{
numberFormat(order?.receive_amount - order?.net_amount, 2) }}</td>
</tr>
</tbody>
</table>
<hr />
<div style="text-align: center">
<i style="font-size: 12px">Thank you, see you again!</i><br />
</div>
</div>
<div class="fade modal" ref="tableModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header py-1 text-bg-secondary">
<h4 class="modal-title" style="font-weight: bold">Select Table</h4>
</div>
<div class="modal-body">
<div class="row row-cols-3 row-cols-sm-4 row-cols-xl-5 g-2">
<div v-for="data in arrayTable">
<div class="p-3 fs-2 text-center fw-bold w-100" :class="getStatus(data.status)" style="cursor: pointer"
@click="selectTable(data.id)">
{{ data.name }}
</div>
</div>
</div>
</div>
<div class="modal-footer">
<div class="px-2 py-1 text-bg-secondary" style="text-align: right">Free</div>
<div class="px-2 py-1 text-bg-danger" style="text-align: right">Busy</div>
<div class="px-2 py-1 text-bg-success" style="text-align: right">Printed</div>
</div>
</div>
</div>
</div>
<div class="fade modal" ref="paymentModal" tabindex="-1" aria-hidden="true" data-bs-keyboard="false">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header py-1 text-bg-secondary">
<h4 class="modal-title" style="font-weight: bold">Make Payment</h4>
</div>
<div class="modal-body p-2" style="background: white">
<table style="width: 100%" cellspacing="0" cellpadding="2px" class="table mb-0">
<thead>
<tr class="table-dark">
<th width="20px">No</th>
<th style="text-align: left">Description</th>
<th style="width: 8%; text-align: center">Qty</th>
<th style="width: 16%; text-align: right">U.P ($)</th>
<th style="width: 15%; text-align: right">Disc (%)</th>
<th style="width: 18%; text-align: right">Total ($)</th>
</tr>
</thead>
<tbody>
<tr v-for="(data, index) in order?.order_detail_temps">
<td align="center">{{ index + 1 }}</td>
<td align="left">{{ data.description }}</td>
<td align="center">{{ data.qty }}</td>
<td align="right">{{ numberFormat(data.unit_price) }}</td>
<td align="center">{{ data.discount }}</td>
<td align="right">{{ numberFormat(data.unit_price * data.qty * (1 - data.discount / 100)) }}</td>
</tr>
</tbody>
</table>
<table class="mt-2" style="font-size: 14px; width: 100%;" cellpadding="5px">
<tbody>
<tr v-if="order && order.discount > 0">
<td style="text-align: right"> Discount ({{ order.discount }}%) : </td>
<td style="text-align: right;"> {{ numberFormat((order.total * order.discount) / 100) }} </td>
</tr>
<tr>
<th style="text-align: right">Total ($) : </th>
<th style="text-align: right; width: 100px;">{{ numberFormat(order?.total) }}</th>
</tr>
<tr>
<td style="text-align: right">Total ($) : </td>
<td style="text-align: right">{{ numberFormat(order?.net_amount) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer" style="display: block">
<div class="row">
<div class="col-md-8">
<label class="form-label required">Receive Amount</label>
<span v-if="receive_amount_error" class="text-danger"> {{ receive_amount_error }} </span>
<div class="row">
<div class="col-md-6">
<div class="input-group mb-3">
<span class="input-group-text">$</span>
<input type="number" class="form-control" v-model="receive_amount"
@keydown.enter="confirmPayment()" ref="autofocus1" />
<div class="input-group-append">
<button class="btn btn-success" style="border-radius: 0px"
@click="receive_amount = numberFormat(order?.net_amount)">
<i class="bi bi-check-lg"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="text-end align-text-bottom pt-lg-4">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel</button>Β
<button type="button" class="btn btn-primary" @click="confirmPayment()" :disabled="isLoading">
{{ isLoading ? 'Processing' : 'Confirm' }}
</button>
</div>
</div>
</div>
<!-- <div class="row">
<div class="col-md-12 text-danger" v-if="errorMsg">
{{ errorMsg }}
</div>
</div> -->
</div>
</div>
</div>
</div>
<header id="header" class="header fixed-top d-flex align-items-center">
<div class="d-flex align-items-center justify-content-between">
<a href="/" class="logo d-flex align-items-center">
<img src="images/logo.png" alt="">
</a>
</div>
<nav class="header-nav ms-auto">
<ul class="d-flex align-items-center">
<li class="d-none d-md-inline-block form-inline ms-auto nav-item dropdown me-5">
<i class="bi bi-alarm-fill text-secondary pe-2"></i>
<span class="text-secondary">{{ auth.user.server_time }}</span>
</li>
<li class="nav-item dropdown pe-3">
<a class="nav-link nav-profile d-flex align-items-center pe-0" href="#" data-bs-toggle="dropdown">
<i class="bi bi-person-fill" style="font-size: 35px;"></i>
<span class="d-none d-md-block dropdown-toggle ps-2 text-capitalize">{{ auth.user.username }}</span>
</a>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-arrow profile">
<li>
<button class="dropdown-item d-flex align-items-center" @click="openModal">
<i class="bi bi-shield-lock"></i>
<span>Change Password</span>
</button>
</li>
<li>
<hr class="dropdown-divider">
</li>
<li>
<button type="submit" class="dropdown-item d-flex align-items-center" @click="auth.logout()">
<i class="bi bi-box-arrow-right"></i>
<span>Sign Out</span>
</button>
</li>
</ul>
</li>
</ul>
</nav>
</header>
<main id="main" class="main ms-0 pt-3">
<div class="row">
<div class="col-xl-7 col-lg-6">
<div class="card mb-0" style="height: 88vh">
<ul class="nav nav-tabs nav-fill">
<li class="nav-item">
<span class="nav-link menu-item active" style="cursor: pointer" @click="menuByCateId(0)"
id="cate_0">ALL</span>
</li>
<li class="nav-item" v-for="data in categoryList">
<span class="nav-link menu-item" style="cursor: pointer" @click="menuByCateId(data.id)"
:id="'cate_' + data.id">{{ data.name }}</span>
</li>
<li class="nav-item">
<span class="nav-link menu-item" style="padding: 5px; background: whitesmoke !important">
<input type="text" class="form-control" placeholder="Search..." v-model="search"
style="background: lightyellow" />
</span>
</li>
</ul>
<div class="card-body p-1" style="overflow-y: scroll">
<div class="row row-cols-2 row-cols-sm-3 row-cols-lg-4 row-cols-xl-5 g-1">
<div class="col" v-for="data in productList" style="cursor: pointer" @click="addToOrder(data.id)">
<div class="card h-100 mb-0"
:style="data.qty && data.qty > 0 ? 'background: lightcyan' : 'background: white'">
<div class="card-img-top" :style="getProductImage(data.image)"></div>
<div class="card-body" style="font-size: 14px; padding: 3px">
<p class="card-text text-center mb-1">{{ data.name }}</p>
<span style="float: right" class="badge text-bg-success" v-if="data.qty && data.qty > 0">{{
data.qty
}}</span>
<p class="card-text text-center mb-1" style="color: red">
{{ currencyFormat(data.unit_price) }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-5 col-lg-6">
<div class="card mb-0" style="height: 88vh">
<div class="card-header p-2 d-none d-lg-block">
<div class="row row-cols-4 g-1">
<div>
<button class="btn btn-secondary w-100" @click="showTable()" title="Select Table">
{{ order?.name ?? "Table" }}
</button>
</div>
<div>
<button :disabled="!(checkList && checkList.length > 0)" title="Change Table"
class="btn btn-primary w-100" @click="showTable(table_id)">Change</button>
</div>
<div>
<button class="btn btn-warning w-100" title="Print"
:disabled="!order || order?.order_detail_temps?.length == 0" @click="printInvoice()">Print</button>
</div>
<div>
<button class="btn btn-success w-100" title="Payment"
:disabled="!order || order?.order_detail_temps?.length == 0" @click="makePayment">Payment</button>
</div>
</div>
</div>
<div class="card-body p-0 cashier-menu" style="overflow-y: scroll">
<table class="table">
<thead>
<tr class="table-dark">
<th style="width: 10px;padding: 5px !important;" class="pb-1">
<input type="checkbox"
:checked="checkList && checkList.length == order?.order_detail_temps.length"
v-if="order?.order_detail_temps && order?.order_detail_temps.length > 0"
:indeterminate="checkList && checkList.length > 0 && checkList.length < order?.order_detail_temps.length"
style="width: 18px; height: 18px; margin-top: 3px" @change="checkAll($event)" />
</th>
<th style="padding: 5px !important;">Desc</th>
<th style="width: 50px;padding: 5px !important;">QTY</th>
<th class="text-end" style="width: 90px;padding: 5px !important;"> U.P ($) </th>
<th class="text-end" style="width: 70px;padding: 5px !important;">DC(%)</th>
<th class="text-end" style="width: 100px;padding: 5px !important;"> Total ($) </th>
<th style="width: 10px;padding: 5px !important;"></th>
</tr>
</thead>
<tbody>
<tr v-for="data in order?.order_detail_temps">
<td class="pb-0" style="padding: 5px !important;">
<input type="checkbox" :id="data.id" :value="data.id" v-model="checkList"
style="width: 18px; height: 18px; margin-top: 3px" />
</td>
<td style="padding: 5px !important;"> {{ data.description }} </td>
<td style="padding: 5px !important;">
<input type="number" min="1" style="border: none; appearance: none; background: #e9ecef;"
class="form-control p-0 text-center" @change="updateQty($event, data.id)" v-model="data.qty" />
</td>
<td class="text-end" style="padding: 5px !important;">{{ numberFormat(data.unit_price, 2) }}
</td>
<td style="padding: 0 10px !important;" class="text-end">
<input type="number" min="0" style="border: none; appearance: none;background: #e9ecef;"
class="form-control p-0 text-center" v-model="data.discount"
@change="updateDetailDiscount($event, data.id)" />
</td>
<td class="text-end" style="padding: 5px !important;">
{{ numberFormat(data.unit_price * data.qty * (1 - data.discount / 100), 2) }}
</td>
<td style="padding: 5px !important;">
<i class="bi bi-trash" style="color: red; cursor: pointer" @click="deleteData(data.id)"></i>
</td>
</tr>
</tbody>
</table>
</div>
<div class="card-footer p-1 text-dark" style="background: whitesmoke" v-if="order">
<table class="table mb-0" style="background: whitesmoke">
<tbody>
<tr>
<td class="text-end" style="width: 50px">Discount (%) :</td>
<td style="width: 100px">
<input type="number" min="0"
style="border: none; appearance: none;background: #e9ecef;max-width: 50px"
class="form-control p-0 text-center w-100" @change="updateOrderDiscount($event)"
v-model="order.discount" />
</td>
<th class="text-end" style="width: 100px">Total ($) :</th>
<th class="text-end text-danger" style="width: 50px;">{{ numberFormat(order.total, 2) }}</th>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
</div>
</template>
<script setup>
import Loading from 'vue-loading-overlay';
import 'vue-loading-overlay/dist/css/index.css';
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { clearForm, currencyFormat, dateFormat, numberFormat, setFocus } from '../../helper.js';
import { Modal } from 'bootstrap';
import ShareModal from '../Share/Modal.vue';
import printJS from 'print-js'
import { useAuthStore } from '@/store/auth';
const formModalInstance = ref(null);
const formModal = ref(null);
const autofocus = ref(null);
const autofocus1 = ref(null);
const messageBox = ref(null);
const auth = useAuthStore();
const categoryList = ref([]);
const productList = ref([]);
const productListBase = ref([]);
const arrayTable = ref([]);
const search = ref("");
const categoryId = ref(0);
const tableModal = ref(null);
const tableModalInstance = ref(null);
const paymentModal = ref(null);
const paymentModalInstance = ref(null);
const deleteModal = ref(null);
const deleteModalInstance = ref(null);
const table_id = ref(0);
const old_cate_id = ref(0);
const checkList = ref([]);
const isLoading = ref(false);
const errors = ref({});
const receive_amount_error = ref(null);
const order = ref({});
const form = ref(
{
old_password: null,
new_password: null,
new_password_confirmation: null
}
);
const receive_amount = ref(0);
onMounted(() => {
document.body.style.display = "block";
if (formModal.value) {
formModalInstance.value = new Modal(formModal.value);
formModal.value.addEventListener("shown.bs.modal", () => {
setFocus(autofocus);
});
formModal.value.addEventListener("hide.bs.modal", () => {
document.activeElement?.blur();
});
formModal.value.addEventListener("hidden.bs.modal", () => {
clearForm(form.value);
errors.value = {};
});
}
if (tableModal.value) {
tableModalInstance.value = new Modal(tableModal.value);
tableModal.value.addEventListener("hide.bs.modal", () => {
document.activeElement?.blur();
});
}
if (deleteModal.value) {
deleteModalInstance.value = new Modal(deleteModal.value);
deleteModal.value.addEventListener("hide.bs.modal", () => {
document.activeElement?.blur();
});
}
if (paymentModal.value) {
paymentModalInstance.value = new Modal(paymentModal.value);
paymentModal.value.addEventListener("shown.bs.modal", () => {
setFocus(autofocus1);
});
paymentModal.value.addEventListener("hide.bs.modal", () => {
document.activeElement?.blur();
});
paymentModal.value.addEventListener("hidden.bs.modal", () => {
clearForm(form.value);
receive_amount_error.value = null;
});
}
getData();
table_id.value = sessionStorage.getItem("table_id");
selectTable(table_id.value);
});
onUnmounted(() => {
if (formModalInstance.value) {
formModalInstance.value.dispose();
}
if (tableModalInstance.value) {
tableModalInstance.value.dispose();
}
if (deleteModalInstance.value) {
deleteModalInstance.value.dispose();
}
if (paymentModalInstance.value) {
paymentModalInstance.value.dispose();
}
});
// load data
const getData = () => {
isLoading.value = true;
axios.get("api/cashier").then((response) => {
if (response.data.success) {
categoryList.value = response.data.product_categories;
productList.value = response.data.products;
productListBase.value = response.data.products;
}
})
.catch((ex) => {
console.log(ex);
})
.finally(() => {
isLoading.value = false;
});
};
// add or create
const openModal = () => {
formModalInstance.value.show();
};
// submit form
const changePassword = () => {
isLoading.value = true;
axios.post("api/auth/change-password", form.value)
.then((response) => {
if (response.data.success) {
formModalInstance.value.hide();
messageBox.value.showModal(1, null, null, 'Your password has been changed successfully');
} else {
errors.value = response.data.errors;
setFocus(autofocus);
}
})
.catch((ex) => {
console.log(ex);
setFocus(autofocus);
})
.finally(() => {
isLoading.value = false;
});
};
const menuByCateId = (id) => {
categoryId.value = id;
filterMenu();
if (id != old_cate_id.value) {
document.getElementById("cate_" + old_cate_id.value).classList.remove("active");
document.getElementById("cate_" + id).classList.add("active");
old_cate_id.value = id;
}
};
// Filter Menu by its category
const filterMenu = () => {
if (categoryId.value == 0) productList.value = productListBase.value;
else
productList.value = productListBase.value.filter(
(v) => v.product_category_id == categoryId.value
);
if (search.value) {
productList.value = productList.value.filter((v) => v.name.toLowerCase().includes(search.value.toLowerCase()));
}
};
// get table status
const getStatus = (status) => {
if (status == 2) return "text-bg-secondary";
else if (status == 1) return "text-bg-danger";
else return "text-bg-success";
};
// show table list
const showTable = (id = 0) => {
arrayTable.value = [];
isLoading.value = true;
axios.get("api/cashier/show-table/" + id)
.then((response) => {
if (response.data.success) {
Object.assign(arrayTable.value, response.data.data);
tableModalInstance.value.show();
}
})
.catch((ex) => {
console.log(ex);
})
.finally(() => {
isLoading.value = false;
});
};
// select table
const selectTable = (id) => {
axios.post("api/cashier/select-table", {
old_table_id: table_id.value,
new_table_id: id,
ids: JSON.stringify(checkList.value)
})
.then((response) => {
if (response.data.success) {
table_id.value = id;
order.value = response.data.data;
sessionStorage.setItem("table_id", id);
checkList.value = [];
tableModalInstance.value.hide();
}
})
.catch((ex) => {
console.log(ex);
});
};
// add product to order list
const addToOrder = (id) => {
if (isNaN(table_id.value) || parseInt(table_id.value) == 0) {
messageBox.value.showModal(2, null, null, 'Please select any table first');
return;
}
axios.post("api/cashier/add-to-order", {
table_id: table_id.value,
product_id: id
})
.then((response) => {
if (response.data.success) {
order.value = response.data.data;
}
})
.catch((ex) => {
console.log(ex);
});
};
const getProductImage = (image) => {
if (image)
return "background: url('storage/" + image + "') no-repeat center; height:80px";
else
return "background: url('./images/default.png') no-repeat center; height:80px";
};
// delete
const deleteData = (id) => {
messageBox.value.showModal(4, () => {
axios.delete("api/cashier/delete-order/" + id + "/" + table_id.value).then((response) => {
if (response.data.success) {
order.value = response.data.data;
}
})
.catch((ex) => {
console.log(ex);
});
});
};
// update order quantity
const updateQty = (e, id) => {
axios.post("api/cashier/update-order-qty", {
id: id,
qty: e.target.value,
table_id: table_id.value,
})
.then((response) => {
if (response.data.success) {
order.value = response.data.data;
}
})
.catch((ex) => {
console.log(ex);
});
};
// update order detail discount
const updateDetailDiscount = (e, id) => {
axios.post("api/cashier/update-detail-discount", {
id: id,
discount: e.target.value,
table_id: table_id.value
})
.then((response) => {
if (response.data.success) {
order.value = response.data.data;
}
})
.catch((ex) => {
console.log(ex);
});
};
// update main order discount
const updateOrderDiscount = (e) => {
axios.post("api/cashier/update-order-discount", {
discount: e.target.value,
table_id: table_id.value
})
.then((response) => {
if (response.data.success) {
order.value = response.data.data;
}
})
.catch((ex) => {
console.log(ex);
});
};
// check all order items
const checkAll = (e) => {
if (e.target.checked) {
order.value.order_detail_temps.forEach((v) => {
if (v.id) checkList.value.push(v.id);
});
} else {
checkList.value = [];
}
};
// print order invoice
const printInvoice = () => {
printJS({
printable: "print_invoice",
type: "html",
scanStyles: false,
style: "#print_invoice{ display: block !important; }"
});
};
// make payment
const makePayment = () => {
paymentModalInstance.value.show();
};
// confirm payment
const confirmPayment = () => {
isLoading.value = true;
axios.post("api/cashier/confirm-payment", {
table_id: table_id.value,
receive_amount: receive_amount.value
})
.then((response) => {
if (response.data.success) {
order.value = response.data.data;
paymentModalInstance.value.hide();
setTimeout(() => {
printJS({
printable: "print_receipt",
type: "html",
scanStyles: false,
style: "#print_receipt{ display: block !important; }"
});
order.value = null;
}, 10);
} else {
if (response.data.errors && response.data.errors.receive_amount)
receive_amount_error.value = response.data.errors.receive_amount[0];
setFocus(autofocus1);
}
})
.catch((ex) => {
console.log(ex);
setFocus(autofocus1);
})
.finally(() => {
isLoading.value = false;
});
};
// Watch
watch(() => search.value, (newValue, oldValue) => {
filterMenu();
});
</script>
Configure Vue Router to handle navigation to the cart page or section, making your POS Cart System part of a seamless single-page application experience.
Replace the contents of resources/js/router/index.js with the code below. If the file doesnβt exist yet, please create it.
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '@/store/auth';
const routes = [
{
name: "login",
path: "/login",
component: () => import("@/component/share/Login.vue"),
},
// Cashier
{
path: "/cashier",
component: () => import("@/component/layout/Cashier.vue"),
meta: {
requiresAuth: true,
role: ['cashier']
},
},
{
path: "/",
component: () => import("@/component/layout/Admin.vue"),
meta: {
requiresAuth: true,
role: ['admin', 'superadmin']
},
children: [
// ========= Dashboard =========
{
path: "/",
component: () => import("@/component/dashboard/Dashboard.vue"),
},
// ========= Data =========
{
path: "/table",
component: () => import("@/component/data/Table.vue")
},
{
path: "/product",
component: () => import("@/component/data/Product.vue")
},
{
path: "/product-category",
component: () => import("@/component/data/ProductCategory.vue")
},
// ========= Operation =========
{
path: "/balance-adjustment",
component: () => import("@/component/operation/BalanceAdjustment.vue")
},
// Setting
{
path: "/user",
component: () => import("@/component/setting/User.vue")
},
{
path: "/:pathMatch(.*)*",
component: () => import("@/component/share/404.vue"),
}
],
}
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach(async (to, from, next) => {
const auth = useAuthStore()
if (auth.user === null) {
await auth.getUser();
}
if (to.meta.requiresAuth && !auth.user) {
next('/login')
}
else if (to.meta.role && !to.meta.role.includes(auth.user?.role)) {
if (auth.user?.role == 'cashier')
return next('/cashier')
else
return next('/dashboard')
}
else {
next()
}
})
export default router
You can always find the latest documentation and features on the official Vue Router website.
Define Laravel API routes to connect your frontend cart actions with backend logic, enabling real-time data updates in your Laravel Vue.js POS Cart System.
Replace the contents of routes/api.php with the code below.
<?php
use App\Http\Controllers\AuthController;
use App\Http\Controllers\BalanceAdjustmentController;
use App\Http\Controllers\CashierController;
use App\Http\Controllers\ProductCategoryController;
use App\Http\Controllers\ProductController;
use App\Http\Controllers\TableController;
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthController::class, 'login']);
Route::post('/logout', [AuthController::class, 'logout']);
Route::middleware('auth:sanctum')->group(function () {
// Auth
Route::prefix('auth')->controller(AuthController::class)->group(function () {
Route::get('/user', 'user');
Route::post('/change-password', [AuthController::class, 'changePassword']);
});
// Table
Route::prefix('table')->controller(TableController::class)->group(function () {
Route::post('list', 'list');
Route::get('edit/{id}', 'edit');
Route::match(['post', 'put'], 'save', 'save');
Route::delete('delete/{id}', 'delete');
});
// Product Category
Route::prefix('product-category')->controller(ProductCategoryController::class)->group(function () {
Route::post('list', 'list');
Route::get('edit/{id}', 'edit');
Route::match(['post', 'put'], 'save', 'save');
Route::delete('delete/{id}', 'delete');
});
// Product
Route::prefix('product')->controller(ProductController::class)->group(function () {
Route::post('list', 'list');
Route::get('edit/{id}', 'edit');
Route::match(['post', 'put'], 'save', 'save');
Route::delete('delete/{id}', 'delete');
Route::get('category-list', 'categoryList');
});
// Balance Adjustment
Route::prefix('balance-adjustment')->controller(BalanceAdjustmentController::class)->group(function () {
Route::post('list', 'list');
Route::get('edit/{id}', 'edit');
Route::match(['post', 'put'], 'save', 'save');
Route::delete('delete/{id}', 'delete');
});
// System User
Route::prefix('user')->controller(UserController::class)->group(function () {
Route::post('list', 'list');
Route::get('edit/{id}', 'edit');
Route::match(['post', 'put'], 'save', 'save');
Route::delete('delete/{id}', 'delete');
});
// Cashier
Route::prefix('cashier')->controller(CashierController::class)->group(function () {
Route::get('/', 'index');
Route::get('show-table/{id?}', 'showTable');
Route::post('select-table', 'selectTable');
Route::post('update-order-qty', 'updateOrderQty');
Route::post('update-detail-discount', 'updateDetailDiscount');
Route::post('update-order-discount', 'updateOrderDiscount');
Route::delete('delete-order/{product_id}/{table_id}', 'deleteOrder');
Route::post('add-to-order', 'addToOrder');
Route::post('print-invoice', 'printInvoice');
Route::post('confirm-payment', 'confirmPayment');
});
});
Use Vite to compile and bundle all frontend assets, ensuring your Laravel Vue.js POS Cart System runs smoothly with up-to-date JavaScript and styles.
Run the following command to compile your assets:
npm run dev
Use <strong>npm run build</strong>
for production.
Once compiled, visit:
http://laravel-vuejs-pos
Awesome work! π Youβve just built the core POS cart system in your Laravel Vue.js POS project. With real-time add-to-cart functionality, quantity adjustments, and item removal, your checkout is now fast, interactive, and optimized for smooth sales operations.
This dynamic cart experience brings your POS one step closer to being ready for real-world useβperfect for retail stores, cafΓ©s, and busy sales counters.
In the next tutorial, weβll explore how to generate reports and apply sales filters to gain valuable insights from your transaction data.
π Continue to Part 6: Reports & Sales Filters
This step-by-step series will guide you through building a complete Laravel Vue.js POS system from scratch: