Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Great progress so far! 🎉 In this tutorial, you’ll learn how to build a flexible and dynamic sale report system for your Laravel Vue.js POS application. We’ll cover how to generate real-time reports, filter sales by date range, user, or product, and display the results in a clean, interactive layout.
To make your reports even more useful, we’ll integrate the popular <a href="https://laravel-excel.com/" target="_blank" data-type="link" data-id="https://laravel-excel.com/" rel="noreferrer noopener">maatwebsite/excel</a>
package to enable Excel export functionality. This allows users to download and analyze sales data offline—perfect for accounting, audits, or deeper insights.
With this powerful feature set, your Laravel Vue.js POS sale report module will offer both on-screen analytics and exportable data, giving businesses the tools they need to make smart, data-driven decisions.
Create a dedicated controller to handle the logic behind generating and filtering sale reports. This step ensures your Laravel Vue.js POS sale report is accurate, secure, and performance-optimized.
Replace the contents of app/Exports/ExportDataToExcel.php with the code below. If the file doesn’t exist yet, please create it manually or run a command to create as below
php artisan make:export ExportDataToExcel
And here is the content of the file
<?php
// Laravel Vue.js POS Sale Report @ https://laravelcenter.com
namespace App\Exports;
use Maatwebsite\Excel\Concerns\FromArray;
class ExportDataToExcel implements FromArray
{
protected $data;
public function __construct(array $data)
{
$this->data = $data;
}
public function array(): array
{
return $this->data;
}
}
Replace the contents of app/Http/Controllers/ReportController.php with the code below. If the file doesn’t exist yet, please create it.
<?php
// Laravel Vue.js POS Sale Report @ https://laravelcenter.com
namespace App\Http\Controllers;
use App\Models\Order;
use App\Models\ProductCategory;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ReportController extends Controller
{
public function saleSummary(Request $request)
{
session()->put('sale_summary_fd', $request->get('sale_summary_fd', session('sale_summary_fd', date('Y-m-d'))));
session()->put('sale_summary_td', $request->get('sale_summary_td', session('sale_summary_td', date('Y-m-d'))));
$list = Order::join('order_details', 'orders.id', '=', 'order_details.order_id')
->join('product_categories', 'product_categories.id', '=', 'order_details.product_category_id')
->select(DB::raw("product_categories.name,sum((order_details.qty * order_details.unit_price*order_details.discount/100) + (order_details.qty * order_details.unit_price * (1-order_details.discount/100) * orders.discount/100)) as discount, sum(order_details.qty * order_details.unit_price) as total"))
->when(session('sale_summary_fd'), function ($query) {
$query->where('orders.created_at', '>=', date('Y-m-d 00:00:00', strtotime(session('sale_summary_fd'))));
})
->when(session('sale_summary_td'), function ($query) {
$query->where('orders.created_at', '<=', date('Y-m-d 23:59:59', strtotime(session('sale_summary_td'))));
})
->groupBy(DB::raw('product_categories.name'))
->orderBy('product_categories.name', 'DESC')
->get();
// return back to compoment
return view('report.sale_summary', compact('list'));
}
public function productSummary(Request $request)
{
// get param value
session()->put('product_summary_category_id', $request->get('product_summary_category_id', session('product_summary_category_id', 0)));
session()->put('product_summary_fd', $request->get('product_summary_fd', session('product_summary_fd', date('Y-m-d'))));
session()->put('product_summary_td', $request->get('product_summary_td', session('product_summary_td', date('Y-m-d'))));
// select from table with filter, sort, and paginate
$list = Order::join('order_details', 'order_details.order_id', '=', 'orders.id')->join('product_categories', 'product_categories.id', '=', 'order_details.product_category_id')
->selectRaw('order_details.description,order_details.product_category_id,product_categories.name AS category_name,sum(order_details.qty) AS qty')
->when(session('product_summary_fd'), function ($query) {
$query->where('orders.created_at', '>=', date('Y-m-d 00:00:00', strtotime(session('product_summary_fd'))));
})
->when(session('product_summary_td'), function ($query) {
$query->where('orders.created_at', '<=', date('Y-m-d 23:59:59', strtotime(session('product_summary_td'))));
})
->when(session('product_summary_category_id'), function ($query) {
$query->where('order_details.product_category_id', session('product_summary_category_id'));
})
->groupBy('order_details.description', 'order_details.product_category_id', 'product_categories.name')
->orderBy(DB::raw('sum(order_details.qty)'), 'DESC')
->paginate(50);
// product category list
$product_categories = ProductCategory::all(['id', 'name']);
// return back to compoment
return view('report.product_summary', compact('list', 'product_categories'));
}
public function exportProductSummary()
{
// select from table with filter, sort, and paginate
$list = Order::join('order_details', 'order_details.order_id', '=', 'orders.id')->join('product_categories', 'product_categories.id', '=', 'order_details.product_category_id')
->selectRaw('order_details.description,order_details.product_category_id,product_categories.name AS category_name,sum(order_details.qty) AS qty')
->when(session('product_summary_fd'), function ($query) {
$query->where('orders.created_at', '>=', date('Y-m-d 00:00:00', strtotime(session('product_summary_fd'))));
})
->when(session('product_summary_td'), function ($query) {
$query->where('orders.created_at', '<=', date('Y-m-d 23:59:59', strtotime(session('product_summary_td'))));
})
->when(session('product_summary_category_id'), function ($query) {
$query->where('order_details.product_category_id', session('product_summary_category_id'));
})
->groupBy('order_details.description', 'order_details.product_category_id', 'product_categories.name')
->orderBy(DB::raw('sum(order_details.qty)'), 'DESC')
->get();
// return back to compoment
return response()->json($list);
}
public function saleHistory(Request $request)
{
// get param value
session()->put('sale_history_invoice_no', $request->get('sale_history_invoice_no', session('sale_history_invoice_no')));
session()->put('sale_history_fd', $request->get('sale_history_fd', session('sale_history_fd', date('Y-m-d'))));
session()->put('sale_history_td', $request->get('sale_history_td', session('sale_history_td', date('Y-m-d'))));
session()->put('sale_history_field', $request->get('sale_history_field', session('sale_history_field', 'orders.created_at')));
session()->put('sale_history_order', $request->get('sale_history_order', session('sale_history_order', 'desc')));
$list = Order::join('tables', 'tables.id', '=', 'orders.table_id')
->join('users', 'users.id', '=', 'orders.created_by_id')
->select(
'orders.invoice_no',
'tables.name AS table_name',
'orders.grand_total',
'orders.total_discount',
'orders.net_amount',
'orders.id',
'orders.created_at',
DB::raw('users.username AS cashier')
)
->when(session('sale_history_fd'), function ($query) {
$query->where('orders.created_at', '>=', date('Y-m-d 00:00:00', strtotime(session('sale_history_fd'))));
})
->when(session('sale_history_td'), function ($query) {
$query->where('orders.created_at', '<=', date('Y-m-d 23:59:59', strtotime(session('sale_history_td'))));
})
->when(session('sale_history_invoice_no'), function ($query) {
$query->where('orders.invoice_no', 'like', '%' . session('sale_history_invoice_no') . '%');
})
->orderBy(session('sale_history_field'), session('sale_history_order'))
->paginate(50);
$sale_summary = $this->saleHistorySummary($request);
// return back to compoment
return view('report.sale_history', compact('list', 'sale_summary'));
}
private function saleHistorySummary()
{
$data = Order::select(DB::raw("sum(grand_total) as grand_total, sum(total_discount) as total_discount, sum(net_amount) as net_amount"))
->when(session('sale_history_fd'), function ($query) {
$query->where('created_at', '>=', date('Y-m-d 00:00:00', strtotime(session('sale_history_fd'))));
})
->when(session('sale_history_td'), function ($query) {
$query->where('created_at', '<=', date('Y-m-d 23:59:59', strtotime(session('sale_history_td'))));
})
->when(session('sale_history_invoice_no'), function ($query) {
$query->where('invoice_no', 'like', '%' . session('sale_history_invoice_no') . '%');
})->first();
return $data;
}
public function exportSaleHistory()
{
$data = Order::join('tables', 'tables.id', '=', 'orders.table_id')
->join('users', 'users.id', '=', 'orders.created_by_id')
->select(
'orders.invoice_no',
'tables.name AS table_name',
DB::raw('DATE_FORMAT(orders.created_at, "%d-%b-%Y %H:%i:%s") AS order_date'),
'orders.grand_total',
'orders.total_discount',
'orders.net_amount',
'orders.created_by_id',
DB::raw('users.username AS cashier')
)
->when(session('sale_history_fd'), function ($query) {
$query->where('orders.created_at', '>=', date('Y-m-d 00:00:00', strtotime(session('sale_history_fd'))));
})
->when(session('sale_history_td'), function ($query) {
$query->where('orders.created_at', '<=', date('Y-m-d 23:59:59', strtotime(session('sale_history_td'))));
})
->when(session('sale_history_invoice_no'), function ($query) {
$query->where('orders.invoice_no', 'like', '%' . session('sale_history_invoice_no') . '%');
})
->orderBy(session('sale_history_field'), session('sale_history_order'))
->get();
return response()->json($data);
}
public function showOrderDetail($id)
{
$data = Order::with('order_details')->join('tables', 'tables.id', '=', 'orders.table_id')
->join('users', 'users.id', '=', 'orders.created_by_id')
->select(
'orders.total',
'orders.net_amount',
'orders.discount',
'orders.invoice_no',
'tables.name AS table_name',
'orders.created_at',
'orders.id',
'orders.created_by_id',
'orders.receive_amount',
DB::raw('users.username AS cashier')
)
->find($id);
return view('report.order_detail', compact('data'));
}
}
Build a responsive Vue component to display and interact with filtered sales data. This makes your Laravel Vue.js POS sale report visually clear and easy to navigate for end users.
Replace the contents of resources/js/component/Report/SaleSummary.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" />
<div class="pagetitle">
<h1>Sale Summary</h1>
</div>
<section class="section">
<div class="col">
<div class="card">
<div class="card-body">
<form @submit.prevent="getData">
<div class="row pt-4">
<div class="col-md-10">
<div class="row justify-content-start">
<div class="col-lg-3 col-sm-6">
<label class="form-label">From Date</label>
<flat-pickr v-model="filter.from_date" class="form-control" :config="dateFilterConfig"
@change="onStartChange" />
</div>
<div class="col-lg-3 col-sm-6">
<label class="form-label">To Date</label>
<flat-pickr v-model="filter.to_date" class="form-control" :config="dateFilterConfig"
@change="onEndChange" />
</div>
</div>
</div>
<div class="col-md-2 align-self-end">
<button type="submit" class="btn btn-secondary pt-1" style="float: right">
<i class="bi bi-search"></i> Search
</button>
</div>
</div>
</form>
<hr class="text-secondary" />
<table class="table shadow mb-4">
<thead>
<tr class="table-dark">
<th class="text-center">
Total Amount
</th>
<th class="text-center">
Total Discount
</th>
<th class="text-center">
Net Amount
</th>
</tr>
</thead>
<tbody>
<tr class="fs-4">
<th class="text-center text-primary">
{{ currencyFormat(summaryData.grand_total) }}
</th>
<th class="text-center text-danger">
{{ currencyFormat(summaryData.total_discount) }}
</th>
<th class="text-center text-success">
{{ currencyFormat(summaryData.net_amount) }}
</th>
</tr>
</tbody>
</table>
<table class="table shadow">
<thead>
<tr class="table-dark">
<th>Product Category</th>
<th class="text-end" width="300px">Total Amount</th>
<th class="text-end" width="300px">Total Discount</th>
<th class="text-end" width="300px">Net Amount</th>
</tr>
</thead>
<tbody>
<tr v-if="dataList && dataList.length > 0" v-for="d in dataList" :key="d.id">
<td>{{ d.name }}</td>
<td class="text-end">{{ currencyFormat(d.total) }}</td>
<td class="text-end">{{ currencyFormat(d.discount) }}</td>
<td class="text-end">{{ currencyFormat(d.total - d.discount) }}</td>
</tr>
<tr v-else>
<td colspan="10" class="shadow-none">
No record found
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup>
import Loading from 'vue-loading-overlay';
import 'vue-loading-overlay/dist/css/index.css';
import { onMounted, ref } from 'vue';
import { currencyFormat, dateFilterConfig } from '../../helper.js';
import flatPickr from 'vue-flatpickr-component';
import 'flatpickr/dist/flatpickr.css';
const isLoading = ref(false);
const filter = ref(
{
from_date: new Date(),
to_date: new Date()
}
);
const dataList = ref([]);
const summaryData = ref({});
onMounted(() => {
summaryData.value = {
grand_total: 0,
total_discount: 0,
net_amount: 0
};
getData();
});
const onStartChange = (selectedDates, dateStr, instance) => {
dateFilterConfig.value.minDate = dateStr;
};
const onEndChange = (selectedDates, dateStr, instance) => {
dateFilterConfig.value.maxDate = dateStr;
};
// load data
const getData = () => {
isLoading.value = true;
axios.post("api/report/sale-summary", filter.value).then((response) => {
if (response.data.success) {
dataList.value = response.data.data;
summaryData.value = response.data.data.reduce(
(sum, item) => {
sum.grand_total += Number(item.total) ?? 0;
sum.total_discount += Number(item.discount) ?? 0;
return sum;
},
{ grand_total: 0, total_discount: 0 }
);
summaryData.value.net_amount = (summaryData.value.grand_total - summaryData.value.total_discount) ?? 0;
}
})
.catch((ex) => {
console.log(ex);
})
.finally(() => {
isLoading.value = false;
});
};
</script>
Replace the contents of resources/js/component/Report/ProductSummary.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" />
<button type="button" class="btn btn-success" style="float: right" @click="exportToExcel">
<i class="bi bi-file-earmark-excel"></i> Export to Excel
</button>
<div class="pagetitle">
<h1>Product Summary</h1>
</div>
<section class="section">
<div class="col">
<div class="card">
<div class="card-body">
<form @submit.prevent="getData(true)">
<div class="row pt-4">
<div class="col-md-10">
<div class="row justify-content-start">
<div class="col-lg-3 col-sm-6">
<label class="form-label">Product Name</label>
<input type="text" class="form-control" v-model="filter.product_name" placeholder="Search..." />
</div>
<div class="col-lg-3 col-sm-6">
<label class="form-label">Product Category</label>
<select class="form-select" v-model="filter.product_category_id">
<option value="0">ALL</option>
<option v-for="data in productCategoryList" :key="data.id" :value="data.id">
{{ data.name }}
</option>
</select>
</div>
<div class="col-lg-3 col-sm-6">
<label class="form-label">From Date</label>
<flat-pickr v-model="filter.from_date" class="form-control" :config="dateFilterConfig"
@change="onStartChange" />
</div>
<div class="col-lg-3 col-sm-6">
<label class="form-label">To Date</label>
<flat-pickr v-model="filter.to_date" class="form-control" :config="dateFilterConfig"
@change="onEndChange" />
</div>
</div>
</div>
<div class="col-md-2 align-self-end">
<button type="submit" class="btn btn-secondary pt-1" style="float: right">
<i class="bi bi-search"></i> Search
</button>
</div>
</div>
</form>
<hr class="text-secondary" />
<table class="table table-striped">
<thead>
<tr class="table-dark">
<th style="width: 50px">#</th>
<th scope="col" @click="sortData('order_details.description')" style="cursor: pointer">
Product Name <i class="text-secondary"
:class="filter.sortBy == 'order_details.description' ? (filter.orderBy == 'desc' ? 'bi bi-sort-alpha-down-alt' : 'bi bi-sort-alpha-down') : 'bi bi-arrow-down-up'"></i>
</th>
<th scope="col" @click="sortData('category_name')" style="cursor: pointer">
Product Category <i class="text-secondary"
:class="filter.sortBy == 'category_name' ? (filter.orderBy == 'desc' ? 'bi bi-sort-alpha-down-alt' : 'bi bi-sort-alpha-down') : 'bi bi-arrow-down-up'"></i>
</th>
<th scope="col" @click="sortData('qty')" style="cursor: pointer" class="text-end">
Quantity <i class="text-secondary"
:class="filter.sortBy == 'qty' ? (filter.orderBy == 'desc' ? 'bi bi-sort-alpha-down-alt' : 'bi bi-sort-alpha-down') : 'bi bi-arrow-down-up'"></i>
</th>
</tr>
</thead>
<tbody>
<tr v-if="dataList && dataList.data && dataList.data.length > 0" v-for="(d, index) in dataList.data"
:key="d.id">
<th scope="row">{{ dataList.from + index }}</th>
<td>{{ d.description }}</td>
<td>{{ d.category_name }}</td>
<td class="text-end">{{ d.qty }}</td>
</tr>
<tr v-else>
<td colspan="10" class="shadow-none">
No record found
</td>
</tr>
</tbody>
</table>
<!-- Pagination -->
<div class="d-flex justify-content-end">
<nav v-if="dataList.links && dataList.links.length > 3">
<ul class="pagination">
<li :class="['page-item', data.url ? '' : 'disabled', data.active ? 'active' : '']"
v-for="data in dataList.links">
<span class="page-link" style="cursor: pointer" v-html="data.label" v-if="data.url && !data.active"
@click="paginate(data.url.substring(data.url.lastIndexOf('?page=') + 6))"></span>
<span class="page-link" v-html="data.label" v-else></span>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup>
import Loading from 'vue-loading-overlay';
import 'vue-loading-overlay/dist/css/index.css';
import { onMounted, ref } from 'vue';
import flatPickr from 'vue-flatpickr-component';
import 'flatpickr/dist/flatpickr.css';
import { dateFilterConfig } from '../../helper';
const isLoading = ref(false);
const productCategoryList = ref([]);
const dataList = ref([]);
onMounted(() => {
getProductCategoryList();
getData(true);
});
// get product category list
const getProductCategoryList = () => {
isLoading.value = true;
axios.get("api/product/category-list").then((response) => {
productCategoryList.value = response.data;
})
.catch((ex) => {
console.log(ex);
})
.finally(() => {
isLoading.value = false;
});
};
const filter = ref(
{
product_name: null,
product_category_id: 0,
from_date: new Date(),
to_date: new Date(),
sortBy: 'qty',
orderBy: 'desc',
page: 1
}
);
// sort
const sortData = (field) => {
if (filter.value.sortBy === field) {
filter.value.orderBy = filter.value.orderBy == 'asc' ? 'desc' : 'asc';
} else {
filter.value.sortBy = field;
filter.value.orderBy = 'asc';
}
getData();
};
// Pagination
const paginate = (page_number) => {
filter.value.page = page_number;
if (page_number > dataList.last_page) {
filter.value.page = dataList.last_page;
}
if (page_number <= 0) {
filter.value.page = 1;
}
getData();
};
const exportToExcel = () => {
isLoading.value = true;
axios.post("api/report/export-product-summary", filter.value, {
responseType: 'blob' // REQUIRED!
}).then((response) => {
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', 'Product Summary Report.xlsx');
document.body.appendChild(link);
link.click();
})
.catch((ex) => {
console.log(ex);
})
.finally(() => {
isLoading.value = false;
});
};
const onStartChange = (selectedDates, dateStr, instance) => {
dateFilterConfig.value.minDate = dateStr;
};
const onEndChange = (selectedDates, dateStr, instance) => {
dateFilterConfig.value.maxDate = dateStr;
};
// load data
const getData = (resetPge = false) => {
isLoading.value = true;
if (resetPge)
filter.value.page = 1;
axios.post("api/report/product-summary", filter.value).then((response) => {
if (response.data.success) {
dataList.value = response.data.data;
}
})
.catch((ex) => {
console.log(ex);
})
.finally(() => {
isLoading.value = false;
});
};
</script>
Replace the contents of resources/js/component/Report/SaleHistory.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" />
<div class="modal fade" ref="formModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header py-2 bg-secondary text-light">
<h4 class="modal-title" style="font-weight: bold">Order Detail</h4>
</div>
<div class="modal-body">
<table class="table">
<tbody>
<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" class="text-capitalize">{{
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>
</tbody>
</table>
<table class="table">
<thead>
<tr class="table-dark">
<th>No</th>
<th>Descripiton</th>
<th class="text-center">QTY</th>
<th class="text-end">Unit Price ($)</th>
<th class="text-end">Discount (%)</th>
<th class="text-end">Total ($)</th>
</tr>
</thead>
<tbody>
<tr v-for="(data, index) in order.order_details">
<td>{{ index + 1 }}</td>
<td>{{ data.description }}</td>
<td class="text-center">{{ data.qty }}</td>
<td class="text-end">{{ (data.unit_price) }} </td>
<td class="text-end">{{ data.discount }}</td>
<td class="text-end">
{{ (data.unit_price * data.qty * (1 - data.discount / 100)) }} </td>
</tr>
</tbody>
</table>
<hr />
<table class="table">
<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>
</div>
</div>
</div>
</div>
<button type="button" class="btn btn-success" style="float: right" @click="exportToExcel">
<i class="bi bi-file-earmark-excel"></i> Export to Excel
</button>
<div class="pagetitle">
<h1>Sale History</h1>
</div>
<section class="section">
<div class="col">
<div class="card">
<div class="card-body">
<form @submit.prevent="searchData">
<div class="row pt-4">
<div class="col-md-10">
<div class="row justify-content-start">
<div class="col-lg-3 col-sm-6">
<label class="form-label">Invoice No</label>
<input type="text" class="form-control" v-model="filter.invoice_no" placeholder="Search..." />
</div>
<div class="col-lg-3 col-sm-6">
<label class="form-label">From Date</label>
<flat-pickr v-model="filter.from_date" class="form-control" :config="dateFilterConfig"
@change="onStartChange" />
</div>
<div class="col-lg-3 col-sm-6">
<label class="form-label">To Date</label>
<flat-pickr v-model="filter.to_date" class="form-control" :config="dateFilterConfig"
@change="onEndChange" />
</div>
</div>
</div>
<div class="col-md-2 align-self-end">
<button type="submit" class="btn btn-secondary pt-1" style="float: right">
<i class="bi bi-search"></i> Search
</button>
</div>
</div>
</form>
<hr class="text-secondary" />
<table class="table">
<thead>
<tr class="table-dark">
<th class="text-center">
Total Amount
</th>
<th class="text-center">
Total Discount
</th>
<th class="text-center">
Net Amount
</th>
</tr>
</thead>
<tbody>
<tr>
<th class="text-center text-primary">
{{ currencyFormat(sale_summary.grand_total ?? 0) }}
</th>
<th class="text-center text-danger">
{{ currencyFormat(sale_summary.total_discount ?? 0) }}
</th>
<th class="text-center text-success">
{{ currencyFormat(sale_summary.net_amount ?? 0) }}
</th>
</tr>
</tbody>
</table>
<table class="table table-striped">
<thead>
<tr class="table-dark">
<th style="width: 50px">#</th>
<th @click="sortData('orders.invoice_no')" style="cursor: pointer">
Invoice No <i class="text-secondary"
:class="filter.sortBy == 'orders.invoice_no' ? (filter.orderBy == 'desc' ? 'bi bi-sort-alpha-down-alt' : 'bi bi-sort-alpha-down') : 'bi bi-arrow-down-up'"></i>
</th>
<th @click="sortData('tables.name')" style="cursor: pointer">
Table No <i class="text-secondary"
:class="filter.sortBy == 'orders.name' ? (filter.orderBy == 'desc' ? 'bi bi-sort-alpha-down-alt' : 'bi bi-sort-alpha-down') : 'bi bi-arrow-down-up'"></i>
</th>
<th @click="sortData('orders.grand_total')" style="cursor: pointer" class="text-end">
Total Amount<i class="text-secondary"
:class="filter.sortBy == 'orders.grand_total' ? (filter.orderBy == 'desc' ? 'bi bi-sort-alpha-down-alt' : 'bi bi-sort-alpha-down') : 'bi bi-arrow-down-up'"></i>
</th>
<th @click="sortData('orders.total_discount')" style="cursor: pointer" class="text-end">
Discount<i class="text-secondary"
:class="filter.sortBy == 'orders.total_discount' ? (filter.orderBy == 'desc' ? 'bi bi-sort-alpha-down-alt' : 'bi bi-sort-alpha-down') : 'bi bi-arrow-down-up'"></i>
</th>
<th @click="sortData('orders.net_amount')" style="cursor: pointer" class="text-end">
Net Amount<i class="text-secondary"
:class="filter.sortBy == 'orders.net_amount' ? (filter.orderBy == 'desc' ? 'bi bi-sort-alpha-down-alt' : 'bi bi-sort-alpha-down') : 'bi bi-arrow-down-up'"></i>
</th>
<th @click="sortData('orders.created_at')" style="cursor: pointer;">
Date <i class="text-secondary"
:class="filter.sortBy == 'orders.created_at' ? (filter.orderBy == 'desc' ? 'bi bi-sort-alpha-down-alt' : 'bi bi-sort-alpha-down') : 'bi bi-arrow-down-up'"></i>
</th>
<th>
Cashier
</th>
</tr>
</thead>
<tbody>
<tr v-if="dataList && dataList.data && dataList.data.length > 0" v-for="(d, index) in dataList.data"
:key="d.id">
<th scope="row">{{ dataList.from + index }}</th>
<td><button class="btn btn-link p-0" @click="viewDetail(d.id)">{{ d.invoice_no }}</button></td>
<td>{{ d.table_name }}</td>
<td class="text-end">{{ currencyFormat(d.grand_total) }}</td>
<td class="text-end">{{ currencyFormat(d.total_discount) }}</td>
<td class="text-end">{{ currencyFormat(d.net_amount) }}</td>
<td>{{ dateFormat(d.created_at) }}</td>
<td class="text-capitalize">{{ d.cashier }}</td>
</tr>
<tr v-else>
<td colspan="10" class="shadow-none">
No record found
</td>
</tr>
</tbody>
</table>
<!-- Pagination -->
<div class="d-flex justify-content-end">
<nav v-if="dataList.links && dataList.links.length > 3">
<ul class="pagination">
<li :class="['page-item', data.url ? '' : 'disabled', data.active ? 'active' : '']"
v-for="data in dataList.links">
<span class="page-link" style="cursor: pointer" v-html="data.label" v-if="data.url && !data.active"
@click="paginate(data.url.substring(data.url.lastIndexOf('?page=') + 6))"></span>
<span class="page-link" v-html="data.label" v-else></span>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup>
import Loading from 'vue-loading-overlay';
import 'vue-loading-overlay/dist/css/index.css';
import { onMounted, onUnmounted, ref } from 'vue';
import { currencyFormat, dateFilterConfig, dateFormat, numberFormat } from '../../helper.js';
import flatPickr from 'vue-flatpickr-component';
import 'flatpickr/dist/flatpickr.css';
import { Modal } from 'bootstrap';
const isLoading = ref(false);
const formModalInstance = ref(null);
const formModal = ref(null);
const order = ref({});
const dataList = ref([]);
const sale_summary = ref({});
const filter = ref(
{
invoice_no: null,
from_date: new Date(),
to_date: new Date(),
sortBy: 'created_at',
orderBy: 'desc',
page: 1
}
);
onMounted(() => {
if (formModal.value) {
formModalInstance.value = new Modal(formModal.value);
formModal.value.addEventListener("hide.bs.modal", () => {
document.activeElement?.blur();
});
}
getSummaryData();
getData(true);
});
onUnmounted(() => {
if (formModalInstance.value) {
formModalInstance.value.dispose();
}
});
const viewDetail = (id) => {
isLoading.value = true;
axios.get("api/report/show-order-detail/" + id).then((response) => {
if (response.data.success) {
order.value = response.data.data;
formModalInstance.value.show();
}
})
.catch((ex) => {
console.log(ex);
})
.finally(() => {
isLoading.value = false;
});
};
const onStartChange = (selectedDates, dateStr, instance) => {
dateFilterConfig.value.minDate = dateStr;
};
const onEndChange = (selectedDates, dateStr, instance) => {
dateFilterConfig.value.maxDate = dateStr;
};
// sort
const sortData = (field) => {
if (filter.value.sortBy === field) {
filter.value.orderBy = filter.value.orderBy == 'asc' ? 'desc' : 'asc';
} else {
filter.value.sortBy = field;
filter.value.orderBy = 'asc';
}
getData();
};
// Pagination
const paginate = (page_number) => {
filter.value.page = page_number;
if (page_number > dataList.last_page) {
filter.value.page = dataList.last_page;
}
if (page_number <= 0) {
filter.value.page = 1;
}
getData();
};
// search data
const searchData = () => {
getSummaryData();
getData(true);
};
// load data
const getData = (resetPge = false) => {
isLoading.value = true;
if (resetPge)
filter.value.page = 1;
axios.post("api/report/sale-history", filter.value).then((response) => {
if (response.data.success) {
dataList.value = response.data.data;
}
})
.catch((ex) => {
console.log(ex);
})
.finally(() => {
isLoading.value = false;
});
};
// get summary data
const getSummaryData = () => {
axios.post("api/report/sale-history-summary", filter.value).then((response) => {
if (response.data.success) {
console.log(response.data.data);
sale_summary.value = response.data.data;
}
})
.catch((ex) => {
console.log(ex);
})
.finally(() => {
isLoading.value = false;
});
};
// export to excel
const exportToExcel = () => {
isLoading.value = true;
axios.post("api/report/export-sale-history", filter.value, {
responseType: 'blob' // REQUIRED!
}).then((response) => {
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', 'Sale History Report.xlsx');
document.body.appendChild(link);
link.click();
})
.catch((ex) => {
console.log(ex);
})
.finally(() => {
isLoading.value = false;
});
};
</script>
Configure a new route to access the sale report view directly in your POS app. Seamless routing improves user experience and keeps your Laravel Vue.js POS sale report easily accessible.
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")
},
// ========= Report =========
{
name: "product-summary",
path: "/product-summary",
component: () => import("@/component/report/ProductSummary.vue")
},
{
name: "sale-history",
path: "/sale-history",
component: () => import("@/component/report/SaleHistory.vue")
},
{
name: "sale-summary",
path: "/sale-summary",
component: () => import("@/component/report/SaleSummary.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
Define API routes in Laravel to fetch filtered report data based on user input like date range, cashier, or product. This enables dynamic loading of your Laravel Vue.js POS sale report without page reloads.
Replace the contents of routes/api.php with the code below.
<?php
// Laravel Vue.js POS Sale Report @ https://laravelcenter.com
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\ReportController;
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');
});
// Report
Route::prefix('report')->controller(ReportController::class)->group(function () {
Route::post('sale-summary', 'saleSummary');
Route::post('product-summary', 'productSummary');
Route::post('sale-history', 'saleHistory');
Route::post('sale-history-summary', 'saleHistorySummary');
Route::post('export-product-summary', 'exportProductSummary');
Route::post('export-sale-history', 'exportSaleHistory');
Route::get('show-order-detail/{id}', 'showOrderDetail');
});
});
Compile your Vue components and assets using Vite or Laravel Mix to make your updated sale report module live. Ensure all UI and functionality in your Laravel Vue.js POS sale report work smoothly in production.
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 job! 🎉 You’ve just implemented powerful sales reporting and filtering features into your Laravel Vue.js POS system—allowing users to filter transactions by date, user, or product, and even export the results to Excel using maatwebsite/excel
. With these tools in place, your POS app is now smarter, more insightful, and ready to support better business decisions.
In the final part of this series, we’ll take your reporting to the next level by visualizing key metrics with ApexCharts. You’ll learn how to transform raw sales data into beautiful, interactive charts directly within your POS dashboard.
👉 Continue to Part 7: Dashboard with ApexCharts
This step-by-step series will guide you through building a complete Laravel Vue.js POS system from scratch: