Laravel Vue.js POS chart reports

Laravel Vue.js POS – Part 7/7 (END): Dashboard with ApexCharts

Introduction

This Laravel Vue.js POS chart reports tutorial wraps up the series with beautiful interactive graphs using ApexCharts to present your data visually.

In this tutorial, you’ll learn how to visualize key business data using ApexCharts—a modern JavaScript charting library that integrates seamlessly with jQuery. We’ll create interactive charts that show total sales, daily trends, top products, and more.

By the end, your POS dashboard will not only be functional but visually insightful—giving store owners and admins a quick overview of performance without needing to open Excel files or read tables.

Setup Controller for Laravel Vue.js POS Chart Reports

Build a Laravel controller to process and return sales data tailored for chart rendering. This step is crucial for generating clean, structured data that powers your Laravel Vue.js POS chart reports.

Replace the contents of app/Http/Controllers/DashboardController.php with the code below. If the file doesn’t exist yet, please create it.

<?php
// Laravel Vue.js POS Chart Reports @https://laravelcenter.com
namespace App\Http\Controllers;

use App\Http\Controllers\Controller;
use App\Models\Order;
use Exception;
use Illuminate\Support\Facades\DB;

class DashboardController extends Controller
{
    public function __invoke()
    {
        try {
            // Top Products
            $top_products = Order::join('order_details', 'order_details.order_id', '=', 'orders.id')
                ->selectRaw('order_details.description,sum(order_details.qty) AS qty')
                ->where(DB::raw('DATE_FORMAT(orders.created_at,"%Y-%m-%d")'), DB::raw('DATE_FORMAT(CURDATE(),"%Y-%m-%d")'))
                ->groupBy('order_details.description')
                ->orderBy('qty', 'desc')
                ->take(10)->get();

            // Sale by Categories
            $sale_categories = 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")
                )
                ->where(DB::raw('DATE_FORMAT(orders.created_at,"%Y-%m-%d")'), DB::raw('DATE_FORMAT(CURDATE(),"%Y-%m-%d")'))
                ->groupBy(DB::raw('product_categories.name'))
                ->orderBy('product_categories.name')
                ->get();

            // Last 15 days total sale amount
            $days = 15;
            $data = Order::select(DB::raw("DATE_FORMAT(created_at,'%Y-%m-%d') AS dd, sum(net_amount) AS total"))
                ->where(DB::raw('DATE_FORMAT(created_at,"%Y-%m-%d")'), '>=', DB::raw('DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL ' . $days . ' DAY),"%Y-%m-%d")'))
                ->groupBy('dd')
                ->orderBy('dd')
                ->get()->toArray();

            $result = [];
            for ($i = $days - 1; $i >= 0; $i--) {
                $date = date('Y-m-d', strtotime('-' . $i . ' days'));
                $total = 0;
                foreach ($data as $row) {
                    if ($row['dd'] == $date) {
                        $total = $row['total'];
                        break;
                    }
                }
                array_push($result, [
                    'date' => date('d-M', strtotime($date)),
                    'total' => $total
                ]);
            }
        } catch (Exception $ex) {
            abort($ex->getCode(), $ex->getMessage());
        }

        return response()->json([
            'success' => true,
            'bar_data' => $result,
            'sale_categories' => $sale_categories,
            'top_products' => $top_products
        ]);
    }
}

Setup Vue Component for Laravel Vue.js POS Chart Reports

Create a Vue component that integrates with ApexCharts to display real-time sales charts. This component transforms your Laravel Vue.js POS chart reports into interactive, visual insights.

Replace the contents of resources/js/component/dashboard/Dashboard.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>Dashboard</h1>
        </div>
        <div class="row">
            <div class="col-md-4">
                <div class="card shadow bg-primary text-white">
                    <div class="card-body p-4">
                        <div class="d-flex justify-content-between align-items-center mb-2">
                            <div class="me-2">
                                <div class="display-6 text-white">
                                    {{ currencyFormat(summaryData.grand_total) }}
                                </div>
                                <div class="card-text fs-6 mt-2">Daily Total Sale</div>
                            </div>
                            <div style="color: lightblue"><i class="bi bi-cash-stack display-4"></i></div>
                        </div>
                    </div>
                </div>
            </div>
            <div class="col-md-4">
                <div class="card shadow bg-danger text-white">
                    <div class="card-body p-4">
                        <div class="d-flex justify-content-between align-items-center mb-2">
                            <div class="me-2">
                                <div class="display-6 text-white">{{ currencyFormat(summaryData.total_discount) }}</div>
                                <div class="card-text fs-6 mt-2">Daily Total Disount</div>
                            </div>
                            <div style="color: lightgray;"><i class="bi bi-percent display-4"></i></div>
                        </div>
                    </div>
                </div>
            </div>
            <div class="col-md-4">
                <div class="card shadow bg-success text-white">
                    <div class="card-body p-4">
                        <div class="d-flex justify-content-between align-items-center mb-2">
                            <div class="me-2">
                                <div class="display-6 text-white">{{ currencyFormat(summaryData.net_amount) }}</div>
                                <div class="card-text fs-6 mt-2">Daily Net Amount</div>
                            </div>
                            <div style="color: lightblue"><i class="bi bi-coin display-4"></i></div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        <div class="row">
            <div class="col-md-6">
                <div class="card shadow">
                    <strong class="card-header bg-dark text-white py-2">
                        <i class="fas fa-chart-area me-1"></i>
                        Daily Sale Report By Categories
                    </strong>
                    <div class="card-body">
                        <VueApexCharts height="390px" :options="ProductCategoryOptions" :series="ProductCategorySeries">
                        </VueApexCharts>
                    </div>
                </div>
            </div>
            <div class="col-md-6">
                <table class="table table-hover shadow align-middle bg-white">
                    <thead class="table-dark">
                        <tr>
                            <th style="width: 50px">#</th>
                            <th>Product Name</th>
                            <th class="text-end">QTY</th>
                        </tr>
                    </thead>
                    <tbody>
                        <tr v-for="(data, index) in Top10Products">
                            <td>{{ index + 1 }}</td>
                            <td>{{ data.description }}</td>
                            <td class="text-end">
                                {{ data.qty }}
                            </td>
                        </tr>
                        <tr v-for="item in (10 - Top10Products.length)" :key="item">
                            <td>{{ (Top10Products.length) + item }}</td>
                            <td></td>
                            <td></td>
                        </tr>
                    </tbody>
                </table>
            </div>
        </div>
        <div class="col">
            <div class="card shadow mb-3">
                <strong class="card-header bg-dark text-white py-2">
                    <i class="fas fa-chart-bar me-1"></i>
                    15 Days Total Sale Amount
                </strong>
                <div class="card-body">
                    <VueApexCharts height="400px" type="bar" :options="chartOptions" :series="series">
                    </VueApexCharts>
                </div>
            </div>
        </div>
    </div>
</template>

<script setup>
import { defineAsyncComponent } from 'vue';
import Loading from 'vue-loading-overlay';
import 'vue-loading-overlay/dist/css/index.css';
import { currencyFormat } from "../../helper";
import { onMounted, ref } from "vue";
const VueApexCharts = defineAsyncComponent(() =>
    import('vue3-apexcharts')
);
const isLoading = ref(false);
const summaryData = ref({
    grand_total: 0,
    total_discount: 0,
    net_amount: 0
});
const Top10Products = ref([]);

const ProductCategorySeries = ref([]);
const ProductCategoryOptions = ref({
    colors: ['#3366cc', '#660066', '#006600', '#cc0066', '#996633', '#006666', '#993399', '#999966', '#ffcc99', '#33cc33', '#cccc00'],
    fill: {
        colors: ['#3366cc', '#660066', '#006600', '#cc0066', '#996633', '#006666', '#993399', '#999966', '#ffcc99', '#33cc33', '#cccc00']
    },
    chart: {
        type: 'pie',
    },
    labels: [],
    responsive: [{
        options: {
            legend: {
                position: 'bottom'
            }
        }
    }],
    legend: {
        position: 'bottom'
    },
    theme: {
        palette: 'palette2' // upto palette10
    },

    dataLabels: {
        enabled: true,
        formatter: function (val) {
            return val.toFixed(2) + "%"
        },
        dropShadow: {
        }
    },
    tooltip: {
        shared: true,
        intersect: false,
        y: {
            formatter: function (value) {
                try {
                    if (typeof value !== "number") {
                        if (value && !isNaN(value)) {
                            value = parseFloat(value);
                        } else {
                            return value;
                        }
                    }
                    var formatter = new Intl.NumberFormat("en-US", {
                        style: "decimal",
                        minimumFractionDigits: 2,
                        maximumFractionDigits: 2,
                    });
                    return "$" + formatter.format(value);
                } catch (ex) {
                    return "$" + value;
                }
            }
        }
    },
});

const series = ref([
    {
        name: 'Net Amount',
        data: []
    },
]);
const chartOptions = ref({
    chart: {
        //height: 350,
        type: 'bar',
        stacked: true,
        toolbar: {
            show: false,
        },
    },
    stroke: {
        curve: 'smooth',
    },

    colors: ['#198754', '#0d6efd', '#9C27B0'],
    fill: {
        opacity: 1,
        gradient: {
            inverseColors: false,
            shade: 'light',
            type: "vertical",
            opacityFrom: 0.85,
            opacityTo: 0.55,
        },
        colors: ['#198754', '#0d6efd', '#9C27B0'],
    },
    labels: [],
    markers: {
        size: 0
    },

    tooltip: {
        shared: true,
        intersect: false,
        y: {
            formatter: function (value) {
                try {
                    if (typeof value !== "number") {
                        if (value && !isNaN(value)) {
                            value = parseFloat(value);
                        } else {
                            return value;
                        }
                    }
                    var formatter = new Intl.NumberFormat("en-US", {
                        style: "decimal",
                        minimumFractionDigits: 2,
                        maximumFractionDigits: 2,
                    });
                    return "$" + formatter.format(value);
                } catch (ex) {
                    return "$" + value;
                }
            }
        }
    },
    dataLabels: {
        enabled: true,
        formatter: function (value) {
            try {
                if (typeof value !== "number") {
                    if (value && !isNaN(value)) {
                        value = parseFloat(value);
                    } else {
                        return value;
                    }
                }
                var formatter = new Intl.NumberFormat("en-US", {
                    style: "decimal",
                    minimumFractionDigits: 2,
                    maximumFractionDigits: 2,
                });
                return "$" + formatter.format(value);
            } catch (ex) {
                return "$" + value;
            }
        }
    }
});

onMounted(() => {
    isLoading.value = true;
    axios.get("api/dashboard")
        .then((response) => {
            if (response.data.success) {
                // Daily Summary Data
                if (response.data.sale_categories && response.data.sale_categories.length > 0) {
                    var d = response.data.sale_categories;
                    for (var i = 0; i < d.length; i++) {
                        ProductCategoryOptions.value.labels.push(d[i].name);
                        ProductCategorySeries.value.push(Number(d[i].total) - Number(d[i].discount));

                        summaryData.value.grand_total += Number(d[i].total);
                        summaryData.value.total_discount += Number(d[i].discount);
                    };
                    summaryData.value.net_amount = (summaryData.value.grand_total - summaryData.value.total_discount) ?? 0;
                }

                // Top 10 Products
                Top10Products.value = response.data.top_products;

                // Last 15 days
                if (response.data.bar_data && response.data.bar_data.length > 0) {

                    response.data.bar_data.forEach(element => {
                        chartOptions.value.labels.push(element.date);
                        series.value[0].data.push(Number(element.total));
                    });
                }
            }
        })
        .catch((ex) => {
            console.log(ex);
        })
        .finally(() => {
            isLoading.value = false;
        });
});
</script>

Setup API Route for Laravel Vue.js POS Chart Reports

Define Laravel API routes to deliver chart data to the frontend securely and efficiently. These endpoints serve as the data backbone of your Laravel Vue.js POS chart reports.

Replace the contents of routes/api.php with the code below. If the file doesn’t exist yet, please create it.

<?php
// Laravel Vue.js POS Chart Reports @https://laravelcenter.com
use App\Http\Controllers\AuthController;
use App\Http\Controllers\BalanceAdjustmentController;
use App\Http\Controllers\CashierController;
use App\Http\Controllers\DashboardController;
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');
    });

    // Dashboard
    Route::get('dashboard', DashboardController::class);
});

Compile Assets for Laravel Vue.js POS Chart Reports

Bundle and compile your updated Vue components and ApexCharts integration using Vite. This ensures your Laravel Vue.js POS chart reports are fully functional in production.

Run the following command to compile your assets:

npm run dev

Use <strong>npm run build</strong> for production.

To better understand and optimize your Vite build, you can integrate the powerful rollup-plugin-visualizer. This plugin generates a visual report of your bundle’s contents, helping you identify large dependencies, duplicated modules, and potential areas for optimization.

By adding it to your Vite config, you’ll be able to analyze your final production build as an interactive treemap or sunburst chart—making it easier to improve loading performance and keep your Laravel Vue.js POS project lightweight and fast.

This tool is especially useful during production builds (vite build) when performance matters most.

Run the following command to install rollup-plugin-visualizer.

npm install rollup-plugin-visualizer

Here is the final version of vite.config.js for this project.

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/js/app.js'],
            refresh: true,
        }),
        vue({
            template: {
                transformAssetUrls: {
                    base: null,
                    includeAbsolute: false,
                },
            },
        })
    ],
    resolve: {
        alias: {
            vue: 'vue/dist/vue.esm-bundler.js',
        },
    },
    build: {
        rollupOptions: {
            output: {
                manualChunks(id) {
                    if (id.includes('node_modules')) {
                        return id.split('node_modules/')[1].split('/')[0];
                    }
                },
            },
            plugins: [
                visualizer({
                    open: true, // Automatically opens the report in the browser
                    filename: 'bundle-report.html', // Output file
                    gzipSize: true, // Show Gzip sizes
                    brotliSize: true, // Show Brotli sizes
                }),
            ],
        },
        chunkSizeWarningLimit: 1000,
    },
});

Once compiled, visit:

http://laravel-vuejs-pos

Setup Complete – Laravel Vue.js POS Chart Reports

Fantastic job! 🎉 You’ve successfully enhanced your Laravel Vue.js POS system by adding powerful graph reports using ApexCharts. Now your users can visualize sales trends, top products, and key metrics through beautiful, interactive charts—all right inside the dashboard.

With this final feature, your POS app is not only functional but also insightful and professional, ready to help business owners make data-driven decisions with ease.

Thank you for following along this complete Laravel Vue.js POS tutorial series! 🚀

Laravel Vue.js POS Tutorial for Beginners Series

This step-by-step series will guide you through building a complete Laravel Vue.js POS system from scratch:

Leave a Reply

Your email address will not be published. Required fields are marked *